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