inou/docs/viewport-plan.md

5.4 KiB
Raw Permalink Blame History

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 NoCrop field from ImageOpts struct (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/height keys

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 viewport from the series metadata (already in seriesList)
  • Store as panels[idx].viewport
  • Compute physical FOV of the viewport region in mm:
    vpWidthPx = viewport_width_fraction * cols
    vpFovMM = vpWidthPx * pixel_spacing_col
    
    (Similarly for height; take the larger dimension)

applyViewportAndScale() — replaces normalizeScale():

For each panel, compute a base transform (zoom + pan) that:

  1. Zooms to show the viewport region (or full image if no viewport)
  2. 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 to display: inline-block (the CSS transform handles sizing now, wrapper should shrink-wrap)
  • .panel-content img: revert to max-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

  1. lib/dbcore.go — remove crop from ImageGet, remove NoCrop from ImageOpts
  2. api/api_image.go — remove ?full=1 handling
  3. lib/dicom.go — viewport ratios instead of crop pixels
  4. api/api_series.go — expose viewport in response
  5. portal/static/viewer.js — viewport zoom + synced scale
  6. portal/static/viewer.css — revert img-wrapper and img styles to original

Implementation Order

  1. Remove crop from ImageGet + api_image.go (immediate improvement — full images served)
  2. Revert CSS to original (inline-block wrapper, max-width/height img)
  3. Implement viewport zoom + synced scale in viewer.js
  4. Update import to store viewport ratios
  5. Expose viewport in series API
  6. 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)