inou/lib/render.go

375 lines
10 KiB
Go

package lib
import (
"bytes"
_ "embed"
"encoding/json"
"fmt"
"image"
"image/color"
"image/draw"
"image/png"
"math"
"strconv"
"github.com/chai2010/webp"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
xdraw "golang.org/x/image/draw"
)
//go:embed Sora-Regular.ttf
var soraRegularData []byte
//go:embed Sora-SemiBold.ttf
var soraSemiBoldData []byte
var (
soraFace14 font.Face
soraFace12 font.Face
soraBoldFace14 font.Face
soraBoldFace20 font.Face
)
func init() {
regular, err := opentype.Parse(soraRegularData)
if err != nil {
return
}
semibold, err := opentype.Parse(soraSemiBoldData)
if err != nil {
return
}
soraFace14, _ = opentype.NewFace(regular, &opentype.FaceOptions{Size: 14, DPI: 72})
soraFace12, _ = opentype.NewFace(regular, &opentype.FaceOptions{Size: 12, DPI: 72})
soraBoldFace14, _ = opentype.NewFace(semibold, &opentype.FaceOptions{Size: 14, DPI: 72})
soraBoldFace20, _ = opentype.NewFace(semibold, &opentype.FaceOptions{Size: 20, DPI: 72})
}
// RenderImage returns a DICOM slice as webp bytes, resized to maxDim.
func RenderImage(accessorID, id string, opts *ImageOpts, maxDim int) ([]byte, error) {
img, err := ImageGet(accessorID, id, opts)
if err != nil {
return nil, err
}
if maxDim <= 0 {
maxDim = 2000
}
bounds := img.Bounds()
w0, h0 := bounds.Dx(), bounds.Dy()
if w0 > maxDim || h0 > maxDim {
scale := float64(maxDim) / float64(max(w0, h0))
newW := int(float64(w0) * scale)
newH := int(float64(h0) * scale)
resized := image.NewRGBA(image.Rect(0, 0, newW, newH))
xdraw.BiLinear.Scale(resized, resized.Bounds(), img, bounds, xdraw.Over, nil)
img = resized
}
var buf bytes.Buffer
if err := webp.Encode(&buf, img, &webp.Options{Lossless: true}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// RenderContactSheet returns a contact sheet webp for a series.
func RenderContactSheet(accessorID, seriesID string, wc, ww float64) ([]byte, error) {
series, err := entryGetByID(accessorID, seriesID)
if err != nil || series == nil {
return nil, fmt.Errorf("series not found")
}
dossierID := series.DossierID
seriesDesc := series.Tags
var seriesData struct {
Modality string `json:"modality"`
}
json.Unmarshal([]byte(series.Data), &seriesData)
study, err := entryGetByID("", series.ParentID)
if err != nil || study == nil {
return nil, fmt.Errorf("study not found")
}
var studyData struct {
StudyDate string `json:"study_date"`
StudyDesc string `json:"study_description"`
}
json.Unmarshal([]byte(study.Data), &studyData)
dossier, _ := DossierGet("", dossierID)
patientName := ""
if dossier != nil {
patientName = dossier.Name
}
entries, err := EntryChildrenByType(dossierID, seriesID, "slice")
if err != nil {
return nil, err
}
if len(entries) == 0 {
return nil, fmt.Errorf("no slices found")
}
var firstSliceData struct {
SliceThickness float64 `json:"slice_thickness"`
}
json.Unmarshal([]byte(entries[0].Data), &firstSliceData)
step := csStepSize(5.0, firstSliceData.SliceThickness)
hasOverride := wc != 0 || ww != 0
type thumbInfo struct {
img image.Image
sliceNum int
pos float64
entryID string
}
var thumbs []thumbInfo
var usedWC, usedWW float64
for i, e := range entries {
if (i+1)%step != 1 && step != 1 {
continue
}
thumb, pos, twc, tww := csLoadThumb(accessorID, e, wc, ww, hasOverride)
if thumb != nil {
thumbs = append(thumbs, thumbInfo{img: thumb, sliceNum: i + 1, pos: pos, entryID: e.EntryID})
if usedWC == 0 && usedWW == 0 {
usedWC, usedWW = twc, tww
}
}
}
if len(thumbs) == 0 {
return nil, fmt.Errorf("could not load any images")
}
const thumbSz = 128
const hdrHeight = 58
const gridCols = 12
const pad = 2
activeCols := gridCols
if len(thumbs) < activeCols {
activeCols = len(thumbs)
}
gridRows := (len(thumbs) + gridCols - 1) / gridCols
outWidth := gridCols*thumbSz + (gridCols-1)*pad
outHeight := hdrHeight + gridRows*thumbSz + (gridRows-1)*pad
out := image.NewRGBA(image.Rect(0, 0, outWidth, outHeight))
draw.Draw(out, out.Bounds(), &image.Uniform{color.RGBA{80, 80, 80, 255}}, image.Point{}, draw.Src)
csDrawHeader(out, hdrHeight, patientName, studyData.StudyDesc, seriesDesc, seriesData.Modality, studyData.StudyDate, len(entries), firstSliceData.SliceThickness, step, usedWC, usedWW)
for i, t := range thumbs {
col := i % gridCols
row := i / gridCols
x := col * (thumbSz + pad)
y := hdrHeight + row*(thumbSz+pad)
draw.Draw(out, image.Rect(x, y, x+thumbSz, y+thumbSz), t.img, image.Point{}, draw.Src)
csDrawNumber(out, x+2, y+2, t.sliceNum)
csDrawPosition(out, x+thumbSz-2, y+2, t.pos)
csDrawHexID(out, x+2, y+thumbSz-4, t.entryID)
}
for i := len(thumbs); i < gridRows*gridCols; i++ {
col := i % gridCols
row := i / gridCols
x := col * (thumbSz + pad)
y := hdrHeight + row*(thumbSz+pad)
draw.Draw(out, image.Rect(x, y, x+thumbSz, y+thumbSz), &image.Uniform{color.Black}, image.Point{}, draw.Src)
}
var buf bytes.Buffer
if err := webp.Encode(&buf, out, &webp.Options{Quality: 10}); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// --- contact sheet helpers ---
func csStepSize(spacingMM, thicknessMM float64) int {
if thicknessMM <= 0 {
return 1
}
step := int(math.Round(spacingMM / thicknessMM))
if step < 1 {
step = 1
}
return step
}
func csLoadThumb(accessorID string, e *Entry, wcOverride, wwOverride float64, hasOverride bool) (image.Image, float64, float64, float64) {
var data struct {
WindowCenter float64 `json:"window_center"`
WindowWidth float64 `json:"window_width"`
PixelMin int `json:"pixel_min"`
PixelMax int `json:"pixel_max"`
SliceLocation float64 `json:"slice_location"`
}
json.Unmarshal([]byte(e.Data), &data)
var center, width float64
if hasOverride {
center, width = wcOverride, wwOverride
if width == 0 {
width = 1
}
} else {
center, width = data.WindowCenter, data.WindowWidth
if center == 0 && width == 0 {
center = float64(data.PixelMin+data.PixelMax) / 2
width = float64(data.PixelMax - data.PixelMin)
if width == 0 {
width = 1
}
}
}
dec, err := ObjectRead(&AccessContext{AccessorID: accessorID}, e.DossierID, e.EntryID)
if err != nil {
return nil, 0, 0, 0
}
img, err := png.Decode(bytes.NewReader(dec))
if err != nil {
return nil, 0, 0, 0
}
const thumbSz = 128
bounds := img.Bounds()
var processed image.Image
switch src := img.(type) {
case *image.Gray16:
low, high := center-width/2, center+width/2
gray := image.NewGray(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
v := float64(src.Gray16At(x, y).Y)
var out uint8
if v <= low {
out = 0
} else if v >= high {
out = 255
} else {
out = uint8((v - low) * 255 / width)
}
gray.SetGray(x, y, color.Gray{Y: out})
}
}
processed = gray
case *image.RGBA, *image.NRGBA:
processed = src
default:
return nil, 0, 0, 0
}
thumb := image.NewRGBA(image.Rect(0, 0, thumbSz, thumbSz))
xdraw.BiLinear.Scale(thumb, thumb.Bounds(), processed, processed.Bounds(), xdraw.Over, nil)
return thumb, data.SliceLocation, center, width
}
func csDrawHeader(img *image.RGBA, hdrHeight int, patient, study, series, modality, date string, totalSlices int, sliceThickness float64, step int, wc, ww float64) {
if len(date) == 8 {
date = date[0:4] + "-" + date[4:6] + "-" + date[6:8]
}
draw.Draw(img, image.Rect(0, 0, img.Bounds().Dx(), hdrHeight),
&image.Uniform{color.RGBA{32, 32, 32, 255}}, image.Point{}, draw.Src)
csDrawStringBold20(img, 10, 22, "!! NAVIGATION ONLY - USE fetch_image FOR DIAGNOSIS !!", color.RGBA{255, 50, 50, 255})
stInfo := strconv.FormatFloat(sliceThickness, 'f', 1, 64) + "mm"
if step > 1 {
stInfo += " (every " + strconv.Itoa(step) + ")"
}
wlInfo := "WC:" + strconv.FormatFloat(wc, 'f', 0, 64) + " WW:" + strconv.FormatFloat(ww, 'f', 0, 64)
csDrawStringBold14(img, 10, 38, patient, color.RGBA{255, 255, 255, 255})
patientWidth := csMeasureStringBold(patient)
rest := " | " + modality + " " + series + " | " + date + " | " + strconv.Itoa(totalSlices) + " slices | ST " + stInfo + " | " + wlInfo
csDrawString14(img, 10+patientWidth, 38, rest, color.RGBA{200, 200, 200, 255})
instructions := "Top-left: slice# Top-right: position(mm) Bottom: entry ID for fetch_image"
csDrawString14(img, 10, 54, instructions, color.RGBA{255, 255, 255, 255})
}
func csDrawNumber(img *image.RGBA, x, y, num int) {
s := strconv.Itoa(num)
textWidth := csMeasureString(s, 14) + 6
for dy := 0; dy < 18; dy++ {
for dx := 0; dx < textWidth; dx++ {
img.SetRGBA(x+dx, y+dy, color.RGBA{0, 0, 0, 200})
}
}
csDrawStringSize(img, x+3, y+14, s, color.RGBA{255, 255, 0, 255}, 14)
}
func csDrawHexID(img *image.RGBA, x, y int, id string) {
csDrawStringSize(img, x+4, y, id, color.RGBA{255, 255, 0, 255}, 12)
}
func csDrawPosition(img *image.RGBA, x, y int, pos float64) {
s := strconv.FormatFloat(pos, 'f', 1, 64) + "mm"
textWidth := csMeasureString(s, 12) + 6
for dy := 0; dy < 16; dy++ {
for dx := 0; dx < textWidth; dx++ {
img.SetRGBA(x-textWidth+dx, y+dy, color.RGBA{0, 0, 0, 200})
}
}
csDrawStringSize(img, x-textWidth+3, y+12, s, color.RGBA{255, 255, 0, 255}, 12)
}
func csDrawString14(img *image.RGBA, x, y int, s string, col color.RGBA) {
csDrawStringSize(img, x, y, s, col, 14)
}
func csDrawStringSize(img *image.RGBA, x, y int, s string, col color.RGBA, size int) {
face := soraFace14
if size <= 12 {
face = soraFace12
}
if face == nil {
return
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{col}, Face: face, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
d.DrawString(s)
}
func csDrawStringBold14(img *image.RGBA, x, y int, s string, col color.RGBA) {
if soraBoldFace14 == nil {
return
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{col}, Face: soraBoldFace14, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
d.DrawString(s)
}
func csDrawStringBold20(img *image.RGBA, x, y int, s string, col color.RGBA) {
if soraBoldFace20 == nil {
return
}
d := &font.Drawer{Dst: img, Src: &image.Uniform{col}, Face: soraBoldFace20, Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)}}
d.DrawString(s)
}
func csMeasureString(s string, size int) int {
face := soraFace14
if size <= 12 {
face = soraFace12
}
if face == nil {
return len(s) * 8
}
return (&font.Drawer{Face: face}).MeasureString(s).Ceil()
}
func csMeasureStringBold(s string) int {
if soraBoldFace14 == nil {
return len(s) * 8
}
return (&font.Drawer{Face: soraBoldFace14}).MeasureString(s).Ceil()
}