inou/api/api_contact_sheet.go

424 lines
11 KiB
Go

package main
import (
"bytes"
_ "embed"
"encoding/json"
"image"
"image/color"
"image/draw"
"image/png"
"log"
"math"
"net/http"
"strconv"
"strings"
"github.com/chai2010/webp"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"inou/lib"
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 {
log.Printf("Failed to parse Sora Regular: %v", err)
return
}
semibold, err := opentype.Parse(soraSemiBoldData)
if err != nil {
log.Printf("Failed to parse Sora SemiBold: %v", err)
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})
}
const thumbSize = 128
const headerHeight = 58
const cols = 12
const padding = 2 // separation between contacts
func handleContactSheet(w http.ResponseWriter, r *http.Request) {
seriesHex := strings.TrimPrefix(r.URL.Path, "/contact-sheet.webp/")
if seriesHex == "" || len(seriesHex) != 16 {
http.Error(w, "Invalid series ID", http.StatusBadRequest)
return
}
// Get access context
ctx := getAccessContextOrFail(w, r)
if ctx == nil {
return
}
// Parse optional window/level overrides
q := r.URL.Query()
var wcOverride, wwOverride float64
var hasWLOverride bool
if wc := q.Get("wc"); wc != "" {
wcOverride, _ = strconv.ParseFloat(wc, 64)
hasWLOverride = true
}
if ww := q.Get("ww"); ww != "" {
wwOverride, _ = strconv.ParseFloat(ww, 64)
hasWLOverride = true
}
seriesID := seriesHex
// Look up series entry (RBAC already checked by portal)
series, err := lib.EntryGet(ctx, seriesID)
if err != nil {
http.Error(w, "Series not found", http.StatusNotFound)
return
}
dossierID := series.DossierID
seriesDesc := series.Tags
// Look up study entry (parent of series)
study, err := lib.EntryGet(nil, series.ParentID)
if err != nil {
http.Error(w, "Study not found", http.StatusNotFound)
return
}
var studyData struct {
StudyDate string `json:"study_date"`
StudyDesc string `json:"study_description"`
}
json.Unmarshal([]byte(study.Data), &studyData)
// Look up dossier for patient name
dossier, _ := lib.DossierGet(nil, dossierID)
patientName := ""
if dossier != nil {
patientName = dossier.Name
}
// Get all slices for this series
entries, err := lib.EntryChildrenByType(dossierID, seriesID, "slice")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if len(entries) == 0 {
http.Error(w, "No slices found", http.StatusNotFound)
return
}
// Get slice thickness for step calculation
var firstSliceData struct {
SliceThickness float64 `json:"slice_thickness"`
}
json.Unmarshal([]byte(entries[0].Data), &firstSliceData)
step := calculateStepSize(5.0, firstSliceData.SliceThickness)
// Load and resize selected slices (skip based on 5mm spacing)
type thumbInfo struct {
img image.Image
sliceNum int
pos float64
}
var thumbs []thumbInfo
var usedWC, usedWW float64 // track what window settings were actually used
for i, e := range entries {
// Same logic as dicom-import: every Nth slice starting at 1
if (i+1)%step != 1 && step != 1 {
continue
}
thumb, pos, wc, ww := loadSliceThumbWithPos(ctx, e, wcOverride, wwOverride, hasWLOverride)
if thumb != nil {
thumbs = append(thumbs, thumbInfo{img: thumb, sliceNum: i + 1, pos: pos})
if usedWC == 0 && usedWW == 0 {
usedWC, usedWW = wc, ww // capture from first slice
}
}
}
if len(thumbs) == 0 {
http.Error(w, "Could not load any images", http.StatusInternalServerError)
return
}
// Calculate grid
gridCols := cols
if len(thumbs) < gridCols {
gridCols = len(thumbs)
}
gridRows := (len(thumbs) + gridCols - 1) / gridCols
outWidth := cols*thumbSize + (cols-1)*padding
outHeight := headerHeight + gridRows*thumbSize + (gridRows-1)*padding
out := image.NewRGBA(image.Rect(0, 0, outWidth, outHeight))
// Fill with dark grey for grid lines (contrast between contacts)
draw.Draw(out, out.Bounds(), &image.Uniform{color.RGBA{80, 80, 80, 255}}, image.Point{}, draw.Src)
// Draw header
drawHeader(out, patientName, studyData.StudyDesc, seriesDesc, studyData.StudyDate, len(entries), firstSliceData.SliceThickness, step, usedWC, usedWW)
// Draw thumbnails
for i, t := range thumbs {
col := i % cols
row := i / cols
x := col * (thumbSize + padding)
y := headerHeight + row*(thumbSize+padding)
draw.Draw(out, image.Rect(x, y, x+thumbSize, y+thumbSize), t.img, image.Point{}, draw.Src)
drawNumber(out, x+2, y+2, t.sliceNum)
drawPosition(out, x+thumbSize-2, y+2, t.pos)
}
// Fill unused grid cells with black
for i := len(thumbs); i < gridRows*cols; i++ {
col := i % cols
row := i / cols
x := col * (thumbSize + padding)
y := headerHeight + row*(thumbSize+padding)
draw.Draw(out, image.Rect(x, y, x+thumbSize, y+thumbSize), &image.Uniform{color.Black}, image.Point{}, draw.Src)
}
w.Header().Set("Content-Type", "image/webp")
w.Header().Set("Cache-Control", "public, max-age=3600")
webp.Encode(w, out, &webp.Options{Quality: 10})
}
func calculateStepSize(requestedSpacingMM, sliceThicknessMM float64) int {
if sliceThicknessMM <= 0 {
return 1
}
step := int(math.Round(requestedSpacingMM / sliceThicknessMM))
if step < 1 {
step = 1
}
return step
}
func drawHeader(img *image.RGBA, patient, study, series, date string, totalSlices int, sliceThickness float64, step int, wc, ww float64) {
// Format date if in YYYYMMDD format
if len(date) == 8 {
date = date[0:4] + "-" + date[4:6] + "-" + date[6:8]
}
// Draw header background
draw.Draw(img, image.Rect(0, 0, img.Bounds().Dx(), headerHeight),
&image.Uniform{color.RGBA{32, 32, 32, 255}}, image.Point{}, draw.Src)
// Line 1: Big red warning
warning := "!! NAVIGATION ONLY - USE fetch_image FOR DIAGNOSIS !!"
drawStringBold20(img, 10, 22, warning, color.RGBA{255, 50, 50, 255})
// Line 2: Patient (white) | Series | Date | Slices | ST | WC/WW
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)
// Line 2: Patient (white) | Series | Date | Slices | ST | WC/WW
drawStringBold(img, 10, 38, patient, color.RGBA{255, 255, 255, 255})
patientWidth := measureStringBold(patient)
rest := " | " + series + " | " + date + " | " + strconv.Itoa(totalSlices) + " slices | ST " + stInfo + " | " + wlInfo
drawString(img, 10+patientWidth, 38, rest, color.RGBA{200, 200, 200, 255})
// Line 3: Instructions and legend
instructions := "MCP: fetch_image(slice_id) | API: GET /image/{slice_id} | Top-left: slice# Top-right: position(mm)"
drawString(img, 10, 54, instructions, color.RGBA{255, 255, 255, 255})
}
func drawPosition(img *image.RGBA, x, y int, pos float64) {
s := strconv.FormatFloat(pos, 'f', 1, 64) + "mm"
textWidth := measureString(s, 12) + 6
// Draw background (right-aligned)
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})
}
}
drawStringSize(img, x-textWidth+3, y+12, s, color.RGBA{255, 255, 0, 255}, 12)
}
func drawString(img *image.RGBA, x, y int, s string, col color.RGBA) {
drawStringSize(img, x, y, s, col, 14)
}
func drawStringSize(img *image.RGBA, x, y int, s string, col color.RGBA, size int) {
face := soraFace14
if size <= 12 {
face = soraFace12
}
if face == nil {
return // font not loaded
}
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 measureString(s string, size int) int {
face := soraFace14
if size <= 12 {
face = soraFace12
}
if face == nil {
return len(s) * 8 // fallback
}
d := &font.Drawer{Face: face}
return d.MeasureString(s).Ceil()
}
func drawStringBold(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 drawStringBold20(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 measureStringBold(s string) int {
if soraBoldFace14 == nil {
return len(s) * 8
}
d := &font.Drawer{Face: soraBoldFace14}
return d.MeasureString(s).Ceil()
}
func loadSliceThumbWithPos(ctx *lib.AccessContext, e *lib.Entry, wcOverride, wwOverride float64, hasOverride bool) (image.Image, float64, float64, float64) {
// Parse window/level and position from entry data
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 = wcOverride
width = wwOverride
if width == 0 {
width = 1
}
} else {
center = data.WindowCenter
width = 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
}
}
}
// Load and decompress 16-bit PNG using RBAC-enforced object access
decData, err := lib.ObjectRead(ctx, e.DossierID, e.EntryID)
if err != nil {
return nil, 0, 0, 0
}
img, err := png.Decode(bytes.NewReader(decData))
if err != nil {
return nil, 0, 0, 0
}
bounds := img.Bounds()
var processed image.Image
switch src := img.(type) {
case *image.Gray16:
// Apply window/level to 8-bit
low := center - width/2
high := 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:
// RGB images: pass through (already rendered)
processed = src
default:
return nil, 0, 0, 0
}
// Resize to thumbnail
thumb := image.NewRGBA(image.Rect(0, 0, thumbSize, thumbSize))
xdraw.BiLinear.Scale(thumb, thumb.Bounds(), processed, processed.Bounds(), xdraw.Over, nil)
return thumb, data.SliceLocation, center, width
}
func drawNumber(img *image.RGBA, x, y, num int) {
s := strconv.Itoa(num)
textWidth := measureString(s, 14) + 6
// Draw background
for dy := 0; dy < 18; dy++ {
for dx := 0; dx < textWidth; dx++ {
img.SetRGBA(x+dx, y+dy, color.RGBA{0, 0, 0, 200})
}
}
drawStringSize(img, x+3, y+14, s, color.RGBA{255, 255, 0, 255}, 14)
}