430 lines
11 KiB
Go
430 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"os"
|
|
"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
|
|
}
|
|
|
|
// Validate token
|
|
q := r.URL.Query()
|
|
token := q.Get("token")
|
|
if token == "" || len(token) != 16 {
|
|
http.Error(w, "token required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Parse optional window/level overrides
|
|
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 (using nil ctx - token check below provides auth)
|
|
series, err := lib.EntryGet(nil, seriesID)
|
|
if err != nil {
|
|
http.Error(w, "Series not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
dossierID := series.DossierID
|
|
|
|
// Verify token matches dossier
|
|
if token != dossierID {
|
|
http.Error(w, "Access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
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(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(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 encrypted 16-bit PNG
|
|
encData, err := os.ReadFile(lib.ObjectPath(e.DossierID, e.EntryID))
|
|
if err != nil {
|
|
return nil, 0, 0, 0
|
|
}
|
|
|
|
decData, err := lib.CryptoDecryptBytes(encData)
|
|
if err != nil {
|
|
return nil, 0, 0, 0
|
|
}
|
|
|
|
img, err := png.Decode(bytes.NewReader(decData))
|
|
if err != nil {
|
|
return nil, 0, 0, 0
|
|
}
|
|
|
|
g16, ok := img.(*image.Gray16)
|
|
if !ok {
|
|
return nil, 0, 0, 0
|
|
}
|
|
|
|
// Apply window/level to 8-bit
|
|
low := center - width/2
|
|
high := center + width/2
|
|
bounds := g16.Bounds()
|
|
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(g16.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})
|
|
}
|
|
}
|
|
|
|
// Resize to thumbnail
|
|
thumb := image.NewGray(image.Rect(0, 0, thumbSize, thumbSize))
|
|
xdraw.BiLinear.Scale(thumb, thumb.Bounds(), gray, gray.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)
|
|
}
|