5.4 KiB
Plan: Client-Side Viewport Zoom + Synced Physical Scale
Context
Server-side crop in ImageGet physically removes pixels from served images. This causes problems: the viewer gets different pixel dimensions per series, can't compute correct FOVs, and can't normalize display size across panels. Johan's direction: stop cropping server-side, store a suggested viewport as metadata, let the viewer handle zoom-to-content and synced sizing.
Changes
1. Remove server-side crop from ImageGet
lib/dbcore.go
- Delete
NoCropfield fromImageOptsstruct (line 450) - Delete the crop block (lines 546-571) — parent lookup, crop struct, draw.Draw
- Function becomes: read PNG → window/level → return full image
api/api_image.go
- Remove
NoCrop: r.URL.Query().Get("full") == "1"(line 27)
2. Import: aspect-ratio-preserving viewport ratios
lib/dicom.go (lines 1326-1352)
Keep boundingBox() and unionBBox() unchanged. Change the series metadata storage:
- Take the union bounding box (content region)
- Expand it to match the original image's aspect ratio (e.g., 420×395 content in 512×512 image → 420×420 square, centered on content center)
- Clamp to image bounds
- Store as ratios 0.0-1.0:
viewport_left,viewport_top,viewport_right,viewport_bottom - Keep the 5% threshold — don't store viewport if content fills >95% of the image
- Stop writing old
crop_x1/y1/x2/y2/width/heightkeys
3. Expose viewport in series API
api/api_series.go
Parse viewport_left/top/right/bottom from series entry Data JSON. Include in response only when present:
{
"id": "...",
"series_desc": "AX T2",
"slice_count": 24,
"viewport": [0.084, 0.113, 0.902, 0.885]
}
Array format [left, top, right, bottom] — compact, no key overhead.
Old series (with crop_x1 but no viewport_*) simply won't have the field — they show full image with no zoom-to-content. No migration needed.
4. Viewer: zoom-to-content + synced physical scale
portal/static/viewer.js
Replace the disabled normalizeScale() with viewport + scale logic.
On loadSeries():
- Read
viewportfrom the series metadata (already inseriesList) - Store as
panels[idx].viewport - Compute physical FOV of the viewport region in mm:
(Similarly for height; take the larger dimension)vpWidthPx = viewport_width_fraction * cols vpFovMM = vpWidthPx * pixel_spacing_col
applyViewportAndScale() — replaces normalizeScale():
For each panel, compute a base transform (zoom + pan) that:
- Zooms to show the viewport region (or full image if no viewport)
- In multi-panel mode, normalizes mm/pixel across all panels
viewportZoom = 1.0 / max(vpWidth, vpHeight) // fraction of image
scaleFactor = thisFovMM / maxFovMM // synced sizing (<=1.0)
baseZoom = viewportZoom * scaleFactor
panX = (0.5 - vpCenterX) * wrapperWidth
panY = (0.5 - vpCenterY) * wrapperHeight
The panel with the largest physical FOV fills its panel completely. Smaller FOV panels appear proportionally smaller — same mm per screen pixel.
Compose with user zoom in applyZoom():
totalZoom = baseZoom * zoomLevels[state.level]
totalPanX = basePanX + state.panX
totalPanY = basePanY + state.panY
wrapper.style.transform = 'scale(' + totalZoom + ') translate(...)'
At user zoom level 0, they see the viewport+synced view. Zoom in layers on top.
Single panel mode: No scale normalization needed (scaleFactor = 1.0). Just zoom to viewport.
5. Revert temporary CSS changes
portal/static/viewer.css
.img-wrapper: revert todisplay: inline-block(the CSS transform handles sizing now, wrapper should shrink-wrap).panel-content img: revert tomax-width: 100%; max-height: 100%(viewport zoom handles the rest)
Actually — need to think about this. With viewport zoom via CSS transform on the wrapper, the img needs to be at its natural size within the wrapper, and the wrapper gets scaled. So:
.panel-content img { max-width: 100%; max-height: 100%; }— image fits panel at 1x.img-wrapper { display: inline-block; }— shrink-wraps the image- CSS transform on wrapper handles zoom + pan
This is the original CSS, which works correctly with the transform-based approach.
Files Modified
lib/dbcore.go— remove crop from ImageGet, remove NoCrop from ImageOptsapi/api_image.go— remove?full=1handlinglib/dicom.go— viewport ratios instead of crop pixelsapi/api_series.go— expose viewport in responseportal/static/viewer.js— viewport zoom + synced scaleportal/static/viewer.css— revert img-wrapper and img styles to original
Implementation Order
- Remove crop from ImageGet + api_image.go (immediate improvement — full images served)
- Revert CSS to original (inline-block wrapper, max-width/height img)
- Implement viewport zoom + synced scale in viewer.js
- Update import to store viewport ratios
- Expose viewport in series API
- Deploy and test with Sophia's brain MRIs (2022 SAG T1 vs 2025 SAG T2 THINS)
Verification
- Compare SAG T1 (2022) and SAG T2 THINS (2025) side by side — anatomy should appear at same physical scale
- Compare AX T2 from both studies — same
- Single panel mode — should zoom to content (viewport)
- Zoom in/out should layer on top of the base viewport view
- Old series without viewport data should display full image (no zoom-to-content)