feat: render bulk annotations with deck.gl overlay - #271
igoroctaviano wants to merge 16 commits into
Conversation
Code review pass — fixes pushed in 51a7c31A 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
Rendering lifecycle
Performance / plan-fidelity
|
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:
Also re-verified this round (no changes needed):
✅ biome clean · 190/190 jest tests pass · webpack build OK |
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 The babel rule now uses Both this PR's check and all of slim PR #405's checks are green. |
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>
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>
16084b7 to
c5a3627
Compare
Summary
Replace the OpenLayers Feature/cluster pipeline for DICOM Microscopy Bulk Simple Annotations with a deck.gl OrthographicView overlay behind the existing
VolumeImageViewerpublic 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+Geometryper 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. Thefeat/viv-loaderPOC proved deck.gl is viable; this PR moves ownership into dmv so every consumer benefits.What changed
src/bulkAnnotations/subsystem: streaming fetch, Float32 OL-space decode ([col, -(row+1)]), spatial tiles, tiered LOD (centroids / line-strip / PathLayer), Flatbush CPU pickingpositions: null+_typedArrayManagerProps: { overAlloc: 1, poolSize: 0 })ol/layer/Layerwrapping Deck (official OL + deck.gl pattern); image tiles stay on OLaddAnnotationGroups/show/hide/setAnnotationGroupStyle/zoomToROI(first annotation ×7) /getAnnotationGroupMeasurementRange+limitValuesPOINTER_MOVEkeeps OLMapBrowserEvent;ROI_SELECTEDis a realroi.ROI;VIEWPORT_CLICKED.roisincludes bulk hitsbulkSimpleAnnotationsFeature builders andannotation.fetch*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
src/bulkAnnotations/**) — decode, stream helpers, PathTesselatorpositions:null, OL→deck viewStateREACT_APP_CONFIG=exampleslim against linked dmv; toggle large POLYGON groups; verify no mirror (OL Y), progressive paint when Range works, hide frees GPU layers, pick/ROI_SELECTED/zoomToROInode scripts/benchmark-bulk-ann.mjs --url <study/series>and paste heap table into the PR