A zero-backend browser viewer for Vesuvius Challenge CT scroll volumes. One
static page that reads axial slices directly from the public S3 bucket
(vesuvius-challenge-open-data.s3.amazonaws.com) with anonymous HTTP Range
requests. No server, no accounts, no build step, no dependencies. Vanilla
HTML/JS/CSS, everything in this folder.
The scroll volume zarrs are stored uncompressed as uint8 in 128×128×128
chunks (2,097,152 bytes each). Because they are uncompressed, byte offsets
inside a chunk are addressable: a single axial 128×128 plane of a chunk is
one contiguous 16,384-byte run at offset z_local × 16384. S3 honors Range
requests (206 responses, CORS *), so an axial slice needs one 16 KB read
per intersecting chunk instead of the full 2 MB chunk. That is a fixed 128x
reduction per stored chunk touched.
Measured against the live bucket (2026-08-11, PHerc1447 masked volume, level 4, slice z=759, 5×5 chunk grid):
| requests | bytes | |
|---|---|---|
| Range reads (this tool) | 11 × 206 + 14 × 404 (masked) | 180,224 B |
| Full-chunk fetching (same slice) | 11 × 2 MB chunks | 23,068,672 B |
128.0x less data, pixel-identical output (verified, see below). A worst-case fully populated L4 slice is 25 reads and 400 KB, against 50 MB of full chunks. The viewer shows this accounting live in its transfer ledger: actual session bytes next to what a chunk-granularity fetcher with a perfect cache would have downloaded to serve the same reads.
Masked-out regions of the scroll are simply not stored. S3 returns 404 for them, which the viewer treats as "all zeros" (the zarr fill value), renders as true black, counts separately as masked tiles, caches, and never retries.
python3 -m http.server 8000
from this directory, then open http://localhost:8000/. Any static file
server works. A server is needed only because browsers won't run ES modules
from file://.
Deep links work, and the address bar is kept in sync with the current view
(replaceState), so the URL is always a shareable link to exactly what you
see. The copy view link button in the sidebar copies it.
http://localhost:8000/?vol=PHerc1447&level=4&z=759&win=0,140
- Axial slice view with progressive tile rendering. Each 16 KB tile is drawn the moment its Range read lands. Scrubbing never flashes to black: the previous slice stays as a backdrop and tiles overwrite it in place, with a small dot in the HUD marking a slice still streaming.
- Progressive boot: first light lands on the cheapest pyramid level (about
150 KB), auto-contrast is derived from that slice's histogram, then the
view sharpens in place to the preferred level. No parameters needed for a
good first screen. Deep-linked
level=andwin=values always win. - z-scrubbing via the vertical rail on the right edge (the scroll's long axis). The amber marker is the current slice, grey ticks are slices already in cache, and hovering previews the slice number a click would land on. Arrow keys step (Shift steps 10, PageUp/Down step 64), or drag the rail.
- Cine fly-through: Space plays through the scroll along z, self-paced. Each step waits for the previous slice to settle, so playback adapts to the network instead of piling up a request queue, and the ledger keeps counting. Any manual navigation stops it.
- Pyramid level selector (levels read from each
.zarrayat runtime, levels 0 to 5 probed) with the per-slice cost shown before you click. Switching levels keeps your framing and sharpens in place. - Pan (drag), zoom (wheel, about the cursor),
for double-click to refit. - Brightness/contrast windowing (black and white point), re-rendered from
the in-memory cache with zero network.
auto(ora) re-derives the window from the current slice;resetreturns to raw 0-255. - Always-shareable URL plus a
copy view linkbutton. - In-memory LRU tile cache (200 MB cap) and automatic prefetch of z±1.
- Live transfer ledger: range reads, masked tiles, HTTP requests, session bytes, full-chunk-equivalent bytes, and the ratio.
- Three volumes wired in (PHerc1447 and PHerc0800 at 8.64 µm / 116 keV,
PHerc0172 at 7.91 µm / 53 keV, all masked scans). Adding more is a
one-entry change in
js/zarr.js, since all shape and chunk metadata is read from the bucket at runtime.
Published surface traces (the bucket's tifxyz meshes, three float32 TIFFs
of voxel coordinates on a regular quad grid) can be drawn over the slice as
contours: where the traced surface crosses the current axial plane, found
by marching squares over the mesh's z channel with linear interpolation.
Discovery is two anonymous S3 LIST requests, so it needs no backend either;
loading a segment costs three ~1.7 MB fetches, counted in their own ledger
row. Up to four segments load at once with distinct colors, toggle with the
color dot or o, and travel in the shareable URL (?seg=). Only meshes
registered on the exact scan on screen are accepted; a mesh traced on a
different scan would draw in the wrong place, so it is refused with a
message instead. The contour comes from the mesh's stored grid (about a
20-voxel vertex step for current segments) and is interpolated between
vertices; the per-segment tooltip says so. The TIFF decoder handles exactly
what the bucket serves (uncompressed strip or tile, float32 and integer
samples, either byte order) and refuses anything else by name. Details,
tests, and a format-doc discrepancy found on the way are in
OVERLAY-NOTES.md.
- Data plumbing, byte-exact: a Python re-implementation of the same
Range-read math (
bytes=z_local*16384 ..., same URLs) assembled the full L4 z=759 slice and compared it against a reference slice produced earlier by a full-chunk zarr pipeline. After undoing the reference PNG's contrast normalization (floor(x*255/202)), 0 of 272,484 pixels differed. The numbers in the table above are from that run against the live bucket. - In-browser rendering: driven with headless Chrome (
--headless=new) againstpython3 -m http.server, with screenshots confirming the slice renders in-canvas with the UI. First light (L5 plus z±1 prefetch, 6 metadata probes) showed 12 range reads and 15 masked 404s, 193 KB transferred against an 8 MB full-chunk equivalent (42.5x) in the live ledger. The full progressive boot (first light, auto-contrast, sharpen to L4 with its z±1 prefetch) settled at 721 KB against 30 MB (42.6x), re-measured live on 2026-08-12 after the boot sequence was added. The ledger ratio sits below the pure 128x because metadata JSON bytes are counted against the viewer, and masked 404s cost both approaches nothing.
Evidence is in verification/: the equivalence script, its rendered slice,
and two headless-Chrome screenshots (zero-param progressive boot, and the
deep-linked L4 z=759 windowed 0-140), captured 2026-08-11/12 against the
live bucket.
- Any current Chrome/Edge/Firefox/Safari (ES modules,
fetchwith Range, Canvas 2D, nothing exotic). Network access to the open-data bucket. - Axial only. Coronal and sagittal views need strided multi-range access (a plane is 128 reads of 128 bytes per chunk, and S3 does not support multipart ranges in one request), so they are left out of v0 rather than shipped badly. The fallback of fetching full chunk columns would defeat the point of the tool. If added later, it will be labeled in the ledger at full-chunk cost.
- Byte counts in the ledger are HTTP payload bytes. Per-request header overhead (roughly 0.3 to 0.5 KB each way) is not counted for either side of the comparison. At 16 KB payloads that overhead is 2 to 3 percent, at 2 MB it is noise.
- The full-chunk comparator assumes a chunk fetcher with a perfect cache (each chunk billed once per session). That is a conservative comparison that favors the naive approach. One consequence: densely scrubbing every slice inside one 128-slice chunk band converges toward the full-chunk cost, because 128 planes are the whole chunk. The live ratio is the true session ratio, not a flat 128x. Range reads never cost more than full chunks.
- The volume list is hardcoded (two verified volumes) rather than parsed
from the bucket's
metadata.min.json, whose schema is undocumented. Runtime metadata (shape,chunks,dimension_separator) is always fetched live. - The tile cache is per-session memory only. A reload starts the ledger and cache from zero.
- Desktop-oriented. Touch pinch-zoom is not implemented yet.
MIT, see LICENSE.