234 lines
5.8 KiB
Go
234 lines
5.8 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
"image/png"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/chai2010/webp"
|
|
"inou/lib"
|
|
)
|
|
|
|
// cropCoords holds the crop region from series metadata
|
|
type cropCoords struct {
|
|
X1, Y1, X2, Y2 int
|
|
Valid bool
|
|
}
|
|
|
|
// getCropFromSeries retrieves crop coordinates from series metadata
|
|
func getCropFromSeries(seriesID string) cropCoords {
|
|
if seriesID == "" {
|
|
return cropCoords{}
|
|
}
|
|
series, err := lib.EntryGet(nil, seriesID) // nil ctx - helper called after RBAC check
|
|
if err != nil {
|
|
return cropCoords{}
|
|
}
|
|
var data struct {
|
|
CropX1 int `json:"crop_x1"`
|
|
CropY1 int `json:"crop_y1"`
|
|
CropX2 int `json:"crop_x2"`
|
|
CropY2 int `json:"crop_y2"`
|
|
}
|
|
if err := json.Unmarshal([]byte(series.Data), &data); err != nil {
|
|
return cropCoords{}
|
|
}
|
|
// Validate: x2 > x1 and y2 > y1
|
|
if data.CropX2 > data.CropX1 && data.CropY2 > data.CropY1 {
|
|
return cropCoords{
|
|
X1: data.CropX1,
|
|
Y1: data.CropY1,
|
|
X2: data.CropX2,
|
|
Y2: data.CropY2,
|
|
Valid: true,
|
|
}
|
|
}
|
|
return cropCoords{}
|
|
}
|
|
|
|
func handleImage(w http.ResponseWriter, r *http.Request) {
|
|
ctx := getAccessContextOrFail(w, r)
|
|
if ctx == nil {
|
|
return
|
|
}
|
|
|
|
// Extract hex ID from path: /image/{id}
|
|
hexID := strings.TrimPrefix(r.URL.Path, "/image/")
|
|
if hexID == "" || len(hexID) != 16 {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
entryID := hexID
|
|
if entryID == "" {
|
|
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get slice info from DB (Category=Imaging, Type=slice)
|
|
entry, err := lib.EntryGet(ctx, entryID)
|
|
if err != nil {
|
|
if errors.Is(err, lib.ErrAccessDenied) {
|
|
http.Error(w, "Forbidden: access denied", http.StatusForbidden)
|
|
return
|
|
}
|
|
http.Error(w, "Slice not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if entry.Category != lib.CategoryImaging || entry.Type != "slice" {
|
|
http.Error(w, "Slice not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
dossierID := entry.DossierID
|
|
seriesID := entry.ParentID
|
|
|
|
// RBAC: Check read access to this entry
|
|
if !requireEntryAccess(w, ctx, dossierID, entryID, 'r') {
|
|
return
|
|
}
|
|
|
|
// Get crop coordinates from series (unless ?full=1)
|
|
wantFull := r.URL.Query().Get("full") == "1"
|
|
var crop cropCoords
|
|
if !wantFull {
|
|
crop = getCropFromSeries(seriesID)
|
|
}
|
|
|
|
// Parse data JSON
|
|
dataJSON := 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"`
|
|
RescaleSlope float64 `json:"rescale_slope"`
|
|
RescaleIntercept float64 `json:"rescale_intercept"`
|
|
}
|
|
json.Unmarshal([]byte(dataJSON), &data)
|
|
|
|
rescaleSlope := data.RescaleSlope
|
|
if rescaleSlope == 0 {
|
|
rescaleSlope = 1
|
|
}
|
|
|
|
center := data.WindowCenter
|
|
width := data.WindowWidth
|
|
|
|
// For CT (rescaleIntercept != 0), convert W/L from Hounsfield Units to raw pixel space
|
|
if data.RescaleIntercept != 0 {
|
|
center = (center - data.RescaleIntercept) / rescaleSlope
|
|
width = width / rescaleSlope
|
|
}
|
|
|
|
// Fallback to pixel range if no W/L provided or if W/L is outside pixel range
|
|
if center == 0 && width == 0 {
|
|
center = float64(data.PixelMin+data.PixelMax) / 2
|
|
width = float64(data.PixelMax - data.PixelMin)
|
|
if width == 0 {
|
|
width = 1
|
|
}
|
|
}
|
|
|
|
// Allow query param overrides
|
|
if wc := r.URL.Query().Get("wc"); wc != "" {
|
|
if v, err := strconv.ParseFloat(wc, 64); err == nil {
|
|
center = v
|
|
}
|
|
}
|
|
if ww := r.URL.Query().Get("ww"); ww != "" {
|
|
if v, err := strconv.ParseFloat(ww, 64); err == nil {
|
|
width = v
|
|
}
|
|
}
|
|
|
|
// Load and decrypt 16-bit PNG
|
|
encryptedData, err := os.ReadFile(lib.ObjectPath(dossierID, entryID))
|
|
if err != nil {
|
|
http.Error(w, "Image file not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
decryptedData, err := lib.CryptoDecryptBytes(encryptedData)
|
|
if err != nil {
|
|
http.Error(w, "Failed to decrypt image", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
img, err := png.Decode(bytes.NewReader(decryptedData))
|
|
if err != nil {
|
|
http.Error(w, "Failed to decode image", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
g16, ok := img.(*image.Gray16)
|
|
if !ok {
|
|
http.Error(w, "Image is not 16-bit grayscale", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Build LUT with threshold: values <18 become 0 (improves compression)
|
|
low := center - width/2
|
|
high := center + width/2
|
|
lut := make([]uint8, 65536)
|
|
for i := 0; i < 65536; i++ {
|
|
var v uint8
|
|
if float64(i) <= low {
|
|
v = 0
|
|
} else if float64(i) >= high {
|
|
v = 255
|
|
} else {
|
|
v = uint8((float64(i) - low) * 255 / width)
|
|
}
|
|
// Threshold near-black to pure black
|
|
if v < 18 {
|
|
v = 0
|
|
}
|
|
lut[i] = v
|
|
}
|
|
|
|
// Apply to 8-bit image
|
|
bounds := g16.Bounds()
|
|
out := image.NewGray(bounds)
|
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
|
pix := g16.Gray16At(x, y).Y
|
|
out.SetGray(x, y, color.Gray{Y: lut[pix]})
|
|
}
|
|
}
|
|
|
|
// Apply crop if available
|
|
var finalImg image.Image = out
|
|
if crop.Valid {
|
|
// Clamp crop to image bounds
|
|
x1 := max(crop.X1, bounds.Min.X)
|
|
y1 := max(crop.Y1, bounds.Min.Y)
|
|
x2 := min(crop.X2+1, bounds.Max.X) // +1 because crop coords are inclusive
|
|
y2 := min(crop.Y2+1, bounds.Max.Y)
|
|
|
|
if x2 > x1 && y2 > y1 {
|
|
cropRect := image.Rect(0, 0, x2-x1, y2-y1)
|
|
cropped := image.NewGray(cropRect)
|
|
draw.Draw(cropped, cropRect, out, image.Pt(x1, y1), draw.Src)
|
|
finalImg = cropped
|
|
}
|
|
}
|
|
|
|
// Return WebP lossless
|
|
w.Header().Set("Content-Type", "image/webp")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Header().Set("X-Inou-Window", fmt.Sprintf("%.0f", width))
|
|
w.Header().Set("X-Inou-Level", fmt.Sprintf("%.0f", center))
|
|
if crop.Valid {
|
|
w.Header().Set("X-Inou-Crop", fmt.Sprintf("%d,%d,%d,%d", crop.X1, crop.Y1, crop.X2, crop.Y2))
|
|
}
|
|
webp.Encode(w, finalImg, &webp.Options{Lossless: true})
|
|
}
|