Download docs/CT_VIEW.md from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 8.42 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/docs/CT_VIEW.md
- Command line
-
hf download hf://spaces/SharpMeow/unroll/docs/CT_VIEW.md
-
curl -L -o CT_VIEW.md https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/docs/CT_VIEW.md
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:
- Winding UV to grid cell through the winding's
colIdx/rowIdx, bilinear between the 4 grid vertices, plusaxis.centregives 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. - A cube of
sizevoxels (64 to 256; 256 on desktops, 128 on phones) at detail levelL(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 aTHREE.Data3DTexture(R8, linear filtering). 256³ is 16.8 MB on the GPU, 128³ is 2.1 MB. - 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. - 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.