375 lines
10 KiB
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()
|
|
}
|