Skip to content

feat: render bulk annotations with deck.gl overlay - #271

Open
igoroctaviano wants to merge 16 commits into
masterfrom
feat/bulk-ann-deckgl
Open

igoroctaviano wants to merge 16 commits into
masterfrom
feat/bulk-ann-deckgl

Conversation

@igoroctaviano

Copy link
Copy Markdown
Collaborator

Summary

Replace the OpenLayers Feature/cluster pipeline for DICOM Microscopy Bulk Simple Annotations with a deck.gl OrthographicView overlay behind the existing VolumeImageViewer public API (slim remains a thin consumer).

Why

Large ANN groups (hundreds of thousands of polygons) OOM / freeze on master because the viewer builds one OL Feature+Geometry per annotation (Canvas for polygons), does ~25–30 allocations + mathjs work per vertex, retains dual cluster/high-res sources, and re-processes all N on pan. The feat/viv-loader POC proved deck.gl is viable; this PR moves ownership into dmv so every consumer benefits.

What changed

  • New src/bulkAnnotations/ subsystem: streaming fetch, Float32 OL-space decode ([col, -(row+1)]), spatial tiles, tiered LOD (centroids / line-strip / PathLayer), Flatbush CPU picking
  • Binary PathLayer attributes with PathTesselator scratch neutralization (positions: null + _typedArrayManagerProps: { overAlloc: 1, poolSize: 0 })
  • Custom ol/layer/Layer wrapping Deck (official OL + deck.gl pattern); image tiles stay on OL
  • API parity: addAnnotationGroups / show / hide / setAnnotationGroupStyle / zoomToROI (first annotation ×7) / getAnnotationGroupMeasurementRange + limitValues
  • Events: POINTER_MOVE keeps OL MapBrowserEvent; ROI_SELECTED is a real roi.ROI; VIEWPORT_CLICKED.rois includes bulk hits
  • Deprecated public shims kept for bulkSimpleAnnotations Feature builders and annotation.fetch*
  • Deck/layers lazy-loaded so jsdom tests stay clean

Benchmark

Script: scripts/benchmark-bulk-ann.mjs (+ benchmark-bulk-ann-results.md). Puppeteer CDP numbers should be filled after a local run against the IDC example series; investigation already showed master OOM vs progressive deck.gl paint on the same study.

Companion slim PR will remove clustering UI and bump/link this branch.

Test plan

  • Unit tests (src/bulkAnnotations/**) — decode, stream helpers, PathTesselator positions:null, OL→deck viewState
  • Full jest suite (190) + webpack bundle + dynamic-import builds
  • Manual: REACT_APP_CONFIG=example slim against linked dmv; toggle large POLYGON groups; verify no mirror (OL Y), progressive paint when Range works, hide frees GPU layers, pick/ROI_SELECTED/zoomToROI
  • Run node scripts/benchmark-bulk-ann.mjs --url <study/series> and paste heap table into the PR

@igoroctaviano

Copy link
Copy Markdown
Collaborator Author

Code review pass — fixes pushed in 51a7c31

A careful self-review of the branch found several real defects, all fixed and covered by the existing test suite (190 tests passing, both webpack builds green):

Correctness

  • affineForReferencedPyramidLevel returned bare coeffs while manager.js destructured { coeffs } — every decode would have received undefined coefficients and produced NaN positions. The referenced-image lookup also read ReferencedImageSequence from the group sequence item, where it never exists; it now falls back to the top-level instance metadata (matching the legacy OL path), and the not-found fallback for 2D coordinates now composes the base level's pixel-to-slide affine (≈ identity) instead of the raw slide-to-pixel inverse, which would have scaled every coordinate by the downsample factor.
  • zoomToAnnotationGroup called view.it(...) instead of view.fit(...). Root cause: biome's noFocusedTests unsafe autofix rewrites fit( → it( because it pattern-matches focused tests. Fixed and suppressed at the call site so lint:fix --unsafe can't reintroduce it.

Rendering lifecycle

  • Nothing requested an OL render frame when a group's deck layer list changed, so hydration/hide/style changes only painted after the next map interaction. The manager now calls layer.changed() + map.render() whenever layers change.
  • The LOD tier and visible spatial-tile set were computed once at build time and never refreshed — panning/zooming kept stale tiles/tiers. A moveend listener now rebuilds a group's layers when its view signature (tier | rotation | visible tile keys) changes.
  • OL view rotation was ignored by the overlay (misalignment on rotated slides). Rotation is now reproduced via a per-layer modelMatrix = R(-rotation) plus a rotated OrthographicView target, mirroring OL's coordinateToPixelTransform.

Performance / plan-fidelity

  • Layer factories rebuilt fresh data objects on every style change, defeating deck's reference-based diffing (full re-tessellation + attribute re-upload per opacity tweak). Factories now accept prebuilt data objects; the manager caches them per group and per spatial tile, with stable tile-key-based layer ids.
  • Measurement-based styling was accepted but had no effect, and getAnnotationGroupMeasurementRange returned a hardcoded stub. Measurements are now lazily fetched (warmed after hydrate), real min/max ranges are computed, and DataFilterExtension filtering is wired end-to-end (per-vertex values for path layers, per-annotation for centroid/point layers, limitValues → filterRange).
  • CPU pick tolerance was a fixed 2 world units (sub-pixel when zoomed out); it now scales with the view resolution (~6 screen px). Bulk pick ROIs also include fetched measurement values, and getExtendedROI no longer emits NaN NumContentItems when values are unavailable.
  • Hydrate now goes through data/fetch.js's fetchGraphicDataForGroup instead of duplicating streaming-eligibility logic in the manager.

@igoroctaviano

Copy link
Copy Markdown
Collaborator Author

Review round 2 (b0fcc04)

Second review pass over the branch, focused on event lifecycles, external API assumptions, and the code added in round 1. Three issues found and fixed:

  1. Stuck loading indicator — _hydrateGroup published LOADING_STARTED unconditionally but only published LOADING_ENDED on the happy path. An aborted hydrate (group hidden mid-load), a fetch/decode error, or a stale-generation early return left consumers that pair the two events (slim's global spinner) stuck forever. LOADING_ENDED now fires from a finally block.
  2. Wrong dicomweb-client property — the streaming fallback passed client?.url as the base URL for relative BulkDataURI resolution, but dicomweb-client exposes baseURL (and wadoURL), not url, so the value was always undefined. Now client?.wadoURL || client?.baseURL.
  3. Unhandled rejection — showAnnotationGroup chained .then on ensureOverlay() without a .catch; overlay init failures are now logged instead of surfacing as unhandled promise rejections.

Also re-verified this round (no changes needed):

  • Rotation math: rotationModelMatrix and the rotated deck target both apply R(−θ), matching OL's coordinateToPixelTransform composition (checked with a concrete 90° example).
  • annotation.js named exports (fetchGraphicData, fetchGraphicIndex, getCommonZCoordinate, getCoordinateDimensionality) all exist for the manager's imports.
  • Filter cache invalidation order in _rebuildLayersForGroupAsync (filter computed before the stable-data check, so a measurement switch correctly rebuilds GPU data).
  • Per-vertex filter expansion bounds for both full paths and per-tile slices.
  • scripts/prepare.mjs dist sentinel matches the actual webpack output filename.

✅ biome clean · 190/190 jest tests pass · webpack build OK

@igoroctaviano

Copy link
Copy Markdown
Collaborator Author

CI note (6041fe0)

One more fix, found while getting slim's CI green: when this package is installed as a git dependency, pnpm builds it inside its content-addressable store, whose path on GitHub Actions contains a node_modules segment (.../setup-pnpm/node_modules/.bin/store/...). The webpack babel rule excluded on /(node_modules)/, which then matched every source file, silently skipping babel-plugin-transform-import-meta — so webpack treated new URL('./dataLoader.worker.min.js', import.meta.url) in webWorkerManager.js as a build-time file dependency and failed with "Module not found".

The babel rule now uses include: src instead of the path-segment exclude, which is robust regardless of where the checkout lives. Verified by reproducing the failure from a git archive extracted under a node_modules path (old config fails, new config builds), plus the usual local build and 190/190 tests.

Both this PR's check and all of slim PR #405's checks are green.

igoroctaviano added a commit that referenced this pull request Aug 20, 2026
Address review feedback from fedorov on PR #271:

1. Fix fill visibility at different zoom levels
   - Fill now renders at all zoom levels when enabled (not just high-res)
   - Pass isFilled flag to _layersForVisibleTiles for consistent styling
   - Use full PathLayer (2.5px stroke) instead of LineStripLayer when fill is on

2. Improve LOD heuristic to use vertex count as primary metric
   - Add BULK_LOD_MIN_VERTICES (100k) threshold based on GPU rendering cost
   - Raise BULK_LOD_MIN_ANNOTATIONS from 1000 to 5000 (fallback for sparse shapes)
   - Use OR logic: LOD triggers when either threshold is exceeded
   - This ensures small groups of large polygons always render full detail,
     while large groups of complex shapes appropriately use LOD

The new heuristic better correlates with actual rendering performance:
- 50 large polygons (1000 verts each = 50k) → NO LOD (always full detail)
- 500 complex polygons (200 verts each = 100k) → LOD at low zoom
- 5000 simple lines (10 verts each = 50k) → LOD at low zoom

Co-Authored-By: Claude <noreply@anthropic.com>
igoroctaviano and others added 16 commits August 24, 2026 16:38
Replace the OpenLayers Feature/cluster pipeline for DICOM Microscopy Bulk
Simple Annotations with a deck.gl OrthographicView overlay behind the
existing VolumeImageViewer API.

- Binary PathLayer/ScatterplotLayer attributes (PathTesselator scratch
  neutralized via positions:null + TypedArrayManager poolSize:0)
- Tiered LOD (centroids / line-strip / viewport spatial tiles)
- Streaming fetch with Range soft-fallback; OL map coords [col,-(row+1)]
- CPU picking with contract-faithful POINTER_MOVE / ROI_SELECTED events
- Keep bulkSimpleAnnotations Feature builders as deprecated public shims
Keep VolumeImageViewer import graph free of @deck.gl so jest can run
without TextDecoder / ESM edge cases. Polyfill TextEncoder/Decoder and
whitelist flatbush/flatqueue for transforms.
…esh, rotation, and measurement filters for deck.gl bulk annotations

Code-review fixes for the deck.gl bulk annotation renderer:
- resolve referenced pyramid level from top-level ReferencedImageSequence and
  fall back to the base level for 2D coordinates (previous fallback scaled
  coordinates by the downsample factor); fix { coeffs } destructure mismatch
  that produced NaN positions for every decode
- request an OL render frame whenever a group's deck layer list changes so
  hydration/hide/style changes paint without requiring a map interaction
- rebuild layers on moveend when the LOD tier, visible spatial tiles, or view
  rotation change (previously frozen after the first build)
- reproduce OL view rotation via per-layer modelMatrix + rotated ortho target
- stable data object refs (group + per-tile caches) so style-only rebuilds
  skip re-tessellation/re-upload; stable per-tile layer ids
- lazy measurement fetch with real min/max ranges and DataFilterExtension
  wiring (per-vertex path filters, per-annotation point filters, limitValues)
- scale CPU pick tolerance by view resolution; fix view.fit typo introduced by
  biome noFocusedTests unsafe autofix (now suppressed at the call site)
- skip NaN measurement items when building pick ROIs; include fetched
  measurement values in bulk pick ROIs
…; handle overlay init failure

Second review round:
- publish LOADING_ENDED from a finally block so aborted, failed, or
  superseded hydrates don't leave consumers' global loading indicator
  (slim gates on the STARTED/ENDED pair) stuck forever
- dicomweb-client exposes baseURL, not url — the streaming fallback
  base URL was always undefined
- surface overlay initialization failures instead of an unhandled
  promise rejection in showAnnotationGroup
Add a SolidPolygonLayer-backed fill layer for closed graphic types
(POLYGON, RECTANGLE, ELLIPSE), gated behind new filled/fillOpacity style
options. Reuses the same position/startIndices buffers as the stroke
PathLayer via a separate data object per layer type — PathLayer and
SolidPolygonLayer both use an internal "positions" scratch attribute name,
and PathLayer's dead-buffer neutralization would otherwise clobber the
polygon tesselator's real one. Fill is only rendered at the styled
(full-detail) LOD tier, skipped at the coarse line-strip tier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
fetchGraphicDataForGroup already accepted an onProgress(loadedBytes,
totalBytes) callback for the Range-based streaming path, but the manager
never passed one through, so consumers had no way to show retrieval
progress beyond a start/end loading flag. Wire it up as a new
ANNOTATION_GROUP_LOADING_PROGRESS event, throttled to ~10/s per group
(the stream invokes onProgress once per network chunk).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extend ANNOTATION_GROUP_LOADING_PROGRESS with a phase field so consumers
can show what a group's hydrate is actually doing, not just an opaque
spinner: fetching the graphic index, retrieving coordinate data (still
carrying loadedBytes/totalBytes), then decoding & building render layers.
Also attach annotationGroupUID to LOADING_ERROR so a failed hydrate can
be attributed to the right group.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SolidPolygonLayer triangulates every polygon on the CPU main thread when
built, with no LOD fallback of its own the way the stroke PathLayer has.
Fill was gated to the same 50k-per-tile cutoff as stroke, but stroke
doesn't triangulate — for dense tiles (nuclei segmentation clears this
easily) that meant synchronously ear-cutting tens of thousands of
polygons on toggle, visibly hanging the tab. Add a much lower,
fill-specific cap (4000/tile): above it, a tile renders stroke-only even
with `filled` on, rather than block trying to fill it. Applies to both
the tiled and non-tiled (small dataset) code paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-tile 4000-annotation cutoff (5e8d883) silently fell back to
stroke-only fill above the threshold, which meant fill never rendered
at all for dense datasets like nuclei segmentation groups.

SolidPolygonLayer triangulates every polygon synchronously on the CPU
with no LOD fallback of its own, unlike PathLayer, so building it in
one pass for a large group visibly hangs the tab. Instead of skipping
fill outright, small groups (<= BULK_FILL_INSTANT_MAX) still build in
one synchronous pass, and larger ones are built in
BULK_FILL_BATCH_SIZE-sized batches spread across
requestAnimationFrame-yielded frames, so fill always eventually
renders regardless of dataset size. A generation counter on each group
record cancels an in-flight batch loop when a newer style/filter
change or group removal supersedes it.
Every view change that touched a group's visible tile set (i.e. most
pans/zooms) rebuilt that group's deck.gl layers from scratch: stroke
and point layers were rebuilt and rendered immediately, then fill was
scheduled separately and only caught up a frame (or several, for
batched groups) later. That gap rendered as a visible unfilled ->
filled flicker on nearly every move.

Now the previous build's completed fill layers are kept rendered
underneath the new base layers while a new fill build is in flight,
instead of blanking to unfilled first. Fill polygon positions are
absolute world coordinates, so a stale set still draws in the right
place — it's just swapped out once the new build's fill (instant or
first progressive batch) is ready. Stale fill is dropped immediately
when fill is turned off, a group is hidden, or the view drops to a
tier that doesn't render fill at all (LOD/point tiers).
The previous fix (7f34213) kept the last fill build on screen while a
new one was in flight, but every rebuild still re-triangulated the
*entire* visible selection from scratch, including tiles that were
already filled a moment ago. On a large dataset that rebuild routinely
takes longer than a frame, so every pan into new territory showed a
real (if brief) unfilled-then-filled transition for the whole visible
area, not just the newly revealed part.

Fill geometry is now cached per spatial tile (GroupRecord#fillTileDataCache),
mirroring how stroke tile data is already cached. A tile already
triangulated on a previous build is reused as-is — deck.gl skips
re-tessellation for a stable data reference — so only genuinely new
tiles pay the triangulation cost, batched across animation frames the
same way as before. Revisiting a tile (panning back, an unrelated
style/opacity change) is now effectively instant instead of
re-triangulating. The cache is invalidated wherever the tile stroke
cache already is (hide, measurement filter change).
Fill (SolidPolygonLayer) has been silently broken since it was added —
toggling "Filled" throws "[bulkAnnotations] layer rebuild failed:
Cannot find module '.../solid-polygon-layer.js'" and does nothing.

Webpack's default vendor cache group splits a node_modules module into
a shared chunk whenever it's reachable from 2+ chunk groups. In this
build every dynamic import() becomes its own standalone chunk (there's
no parent page that also loads a shared vendor chunk alongside it), so
that heuristic produced a broken split: @deck.gl/layers/solid-polygon-
layer's own submodules (polygon.js, polygon-tesselator.js — apparently
also reachable from @deck.gl/extensions's DataFilterExtension) got
pulled into a vendors-*deck_gl_extensions* chunk, while the
solid-polygon-layer.js barrel itself was left behind in
bulkAnnotations_layers_index_js.worker.min.js as a bare require() with
no matching module definition — since that vendor chunk is never
loaded alongside this one, the require always throws.

Disabling the default cache groups keeps every async chunk
self-contained (bulkAnnotations_layers_index_js.worker.min.js grows
from 45 KiB to 2.22 MiB as a result — it now inlines the deck.gl code
that was wrongly split out — but this build already ships each
dynamic-import chunk as its own on-demand fetch, so that's the
correct tradeoff here).
Address review feedback from fedorov on PR #271:

1. Fix fill visibility at different zoom levels
   - Fill now renders at all zoom levels when enabled (not just high-res)
   - Pass isFilled flag to _layersForVisibleTiles for consistent styling
   - Use full PathLayer (2.5px stroke) instead of LineStripLayer when fill is on

2. Improve LOD heuristic to use vertex count as primary metric
   - Add BULK_LOD_MIN_VERTICES (100k) threshold based on GPU rendering cost
   - Raise BULK_LOD_MIN_ANNOTATIONS from 1000 to 5000 (fallback for sparse shapes)
   - Use OR logic: LOD triggers when either threshold is exceeded
   - This ensures small groups of large polygons always render full detail,
     while large groups of complex shapes appropriately use LOD

The new heuristic better correlates with actual rendering performance:
- 50 large polygons (1000 verts each = 50k) → NO LOD (always full detail)
- 500 complex polygons (200 verts each = 100k) → LOD at low zoom
- 5000 simple lines (10 verts each = 50k) → LOD at low zoom

Co-Authored-By: Claude <noreply@anthropic.com>
…hing

High-priority optimizations:
- Layer recycling for color/opacity changes (avoids full rebuild)
- Group-level bbox culling in view change handler
- Filter cache invalidation only on measurement changes

Medium-priority optimizations:
- LRU tile data cache with configurable max size (64 tiles)
- Debounced style updates to batch rapid slider changes

Also adds performance profiler utility (enable via ?profile=1 URL param)
for measuring rebuild times and tracking optimization effectiveness.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When fill was enabled, the highRes flag was forced to true regardless
of the current zoom level, bypassing LOD and showing full polygons
instead of centroids. Now fill respects LOD - centroids are shown at
low zoom, and full polygons with fill only appear at high resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SolidPolygonLayer uses getFillColor instead of getColor. The layer
recycling optimization was incorrectly setting getColor for fill
layers, which had no effect. Now correctly uses getFillColor for
SolidPolygonLayer (fill) and getColor for PathLayer/ScatterplotLayer.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant