unroll / docs /CT_VIEW.md
SharpMeow's picture
One page scroll bar and American English
757768f verified
|
Raw History Blame Contribute Delete
8.42 kB

CT tools: drag resolution, X-ray window/level, CT volume, labels, OME-Zarr streaming

All code in this document is original to Unroll and licensed Apache 2.0. The feature set was inspired by ScrollSlabViewer by Paul-G2 (MIT); the ideas (raymarched MIP, an oriented slab clip, a label overlay, a lower resolution while the camera moves, percentile contrast) are common volume rendering techniques and no code was copied from it or other viewers.

1. Lower resolution while dragging

lib/dragres.js. On OrbitControls start the renderer and the effect composer drop to a fraction of their pixel ratio; after end, once the camera has stopped changing for 150 ms (damping included), the full ratio returns. The render targets of the bloom pass shrink with it, which is where most of the saving is.

Where What
Settings Lower resolution while dragging: off, light (0.75x), strong (0.5x). Default light, strong on touch screens. The pixel ratio never goes below 0.5.
URL ?dragRes=off|light|strong (this visit only, like every setting)
API unrollView.dragRes.state(), unrollView.dragRes.set('strong'), test hooks begin() and end(); unrollView.settings.set('dragRes', 'off')
Event ui.js dispatches unroll:settings on every settings change

2. X-ray window/level with percentile auto contrast

lib/ctwl.js, plus one uniform in app.js (uCTWin, applied in the surface fragment shader right after the empty-surface test, so holes stay holes). Values are on the 0..1 scale of the CT atlas: low = level - window / 2, high = level + window / 2, and each pixel shows clamp((ct - low) / (high - low)).

Auto contrast builds a 256-bin histogram of the CT atlas (drawn once into a 512 px wide canvas, empty pixels with value 0..3 ignored) and sets low and high to the chosen percentiles (default 1 and 99).

Where What
Panel X-ray contrast group: Window (contrast range) and Level (center brightness) sliders, Auto contrast, Full range
Settings X-ray auto contrast on load, Auto contrast low percentile (0 to 20), Auto contrast high percentile (80 to 100)
URL ?ctwin=0.5&ctlevel=0.4, ?ctauto=1 (Settings percentiles) or ?ctauto=2,98
API unrollView.ct.setWindow(window, level), ct.setLowHigh(low, high), ct.auto(pLow, pHigh), ct.reset(), ct.state(), ct.histogram(), ct.percentiles(pLow, pHigh)
Actions ct.auto, ct.reset (in unrollView.actions.list())

3. CT volume: MIP inside an oriented slab

lib/ctvolume.js (view, panel, API) and lib/ctsource.js (where the data comes from). Two read-only hooks in app.js: unrollView.scanGeom() (the int16 mesh grids, which hold scan voxels minus the roll axis center) and unrollView.scanPick(x, y) (winding and UV under a screen point, from the existing float pick).

How a pick becomes a cube:

  1. Winding UV to grid cell through the winding's colIdx/rowIdx, bilinear between the 4 grid vertices, plus axis.centre gives the scan voxel (x, y, z) at level 0. The surface normal comes from the row and column tangents at that cell. The scan's zarr axes are (z, y, x), so [z][y][x] at level 0 is the picked voxel.
  2. A cube of size voxels (64 to 256; 256 on desktops, 128 on phones) at detail level L (voxels of 9.362 x 2^L µm) is centered on it, clamped to the level's shape, read chunk by chunk and uploaded as a THREE.Data3DTexture (R8, linear filtering). 256³ is 16.8 MB on the GPU, 128³ is 2.1 MB.
  3. The view is a unit cube drawn with back faces in its own canvas and WebGL context (so the main scroll renderer, its passes and its picking are untouched). The fragment shader marches each ray at 1.25 samples per voxel (up to 1024 steps, jittered against banding), clipped to the box and to the slab |dot(p, n) - offset| <= thickness / 2, and keeps the brightest sample. Window/level is applied to that maximum. It renders on demand (camera change or a control), not every frame.
  4. The slab normal starts as the surface normal, so the MIP looks across the sheet; Tilt across and Tilt along turn it about two perpendicular axes, Slab offset moves it, Face the slab points the camera down the normal.
Where What
Panel CT volume group: Pick a point (crosshair; click the papyrus; Esc cancels), Show volume. The floating CT VOLUME · MIP panel has Slab clip, Slab thickness, Slab offset, Tilt across, Tilt along, Window, Level, Cube size, Detail level, Auto contrast, Face the slab, Pick another point
Settings CT volume cube size (voxels) (auto, 64, 128, 192, 256), CT volume detail level (auto, 0 to 4; auto is 1 on desktops, 2 on phones)
URL ?ctvol=w035,0.5,0.5 (winding, u, v) or ?ctvol=4126,3926,5478 (scan voxels x, y, z), with &ctlod=1, &ctsize=128, &ctslab=16 (voxels), &cttilt=10,0 (degrees)
API unrollView.ct.volume.open({ winding, u, v } | { voxel: [x, y, z] } | { x, y } screen point, level?, size? }), close(), state(), setSlab({ on, thickness, offset, tiltX, tiltY, face }), setWindow(w, l), auto(), setLevel(l), setSize(n), pick(on), scanPoint(winding, u, v), image() (PNG data URL), bench(n)
Actions ctvol.pick, ctvol.show, ctvol.close
Event unroll:ctvolume on window (open, loaded)

Only scrolls whose meshes were traced on a known public volume can open it (PHerc0139 today, see VOLUMES in lib/ctsource.js); a manifest may name its own with ctZarr and ctVoxelUM.

4. Labeled segments

lib/ctlabels.js. When the cube has loaded, every winding grid (the traced segment meshes) is walked cell by cell; cells within reach of the cube are split so samples sit under 0.7 voxels apart at the cube's detail level, and each sample writes the segment's number (1 to 255) into an R8 label volume of the cube's size, with its 6 neighbors, so a sheet is about 3 voxels thick. The label volume is a second Data3DTexture (nearest filtering) in the same raymarch: the first label met along a ray tints the MIP pixel in the segment's color, scaled by the MIP brightness; a highlighted segment is drawn stronger and the rest are dimmed.

Where What
Panel Labeled segments checkbox in the CT VOLUME panel, then one chip per segment in the cube (color swatch and winding id); click to highlight, click again to clear
URL ?ctlabels=1 (with ?ctvol=...)
API unrollView.ct.volume.labels.show(on), .highlight(id), .state() (segments, colors, voxel counts, build ms), .colors(); window.unroll.ct.labels(on), ct.highlight(id)

5. OME-Zarr streaming from the open data bucket

lib/ctsource.js reads the scan straight from https://vesuvius-challenge-open-data.s3.amazonaws.com (the public bucket s3://vesuvius-challenge-open-data). Only that bucket is accepted, for the built-in scrolls, for a manifest's ctZarr and for ?ctzarr=. Level metadata comes from <zarr>/<level>/.zarray (zarr v2, OME-Zarr multiscale levels 0 to 5) and the cube is assembled from the chunks it overlaps, fetched 6 at a time (the PHerc0139 volume stores raw uint8 chunks, so no decompression is needed; a compressed level is refused with a clear error).

CORS: checked on 2026-10-16, a GET of .zarray with an Origin header answers 200 with Access-Control-Allow-Origin: * and Access-Control-Allow-Methods: HEAD, GET, so no proxy is needed. If the bucket ever stops sending CORS headers, the fallback is a small read-only proxy (for example a Hugging Face Space or a Cloudflare Worker) that forwards GETs for that bucket only and adds the header.

Where What
URL ?ctzarr=https://vesuvius-challenge-open-data.s3.amazonaws.com/<path>.zarr&ctum=9.362
API window.unroll.ct.source() (bucket, zarr URL, voxel size), unrollView.ct.volume.state().source

Agents: window.unroll.ct

dragRes(mode), setWindow(w, l), autoContrast(pLow, pHigh), fullRange(), contrast(), open(where), close(), state(), setSlab(o), volumeWindow(w, l), volumeAuto(), setDetail(level), setSize(n), pick(on), image(), labels(on), highlight(id), source(). Specs in lib/ctapi.js (CT_SPECS), listed in api.json with extension: true.