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() }