Skip to content

Make zooming the Image Map cheap on large galleries - #301

Merged
lstein merged 2 commits into
mainfrom
perf/image-map-zoom
Sep 23, 2026
Merged

lstein merged 2 commits into
mainfrom
perf/image-map-zoom

Conversation

@lstein

@lstein lstein commented Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Summary

Zooming the Image Map was slow on a large gallery. The cause was not the point count — it was how the points were styled.

The base points were a single scattergl trace carrying per-point color, opacity and symbol arrays. Plotly reprocesses all of them on every relayout, and a zoom is a relayout per frame. On top of that, every wheel event applied its own relayout, and a wheel emits far faster than a frame, so relayouts queued behind the cursor.

  • Split the base points into one trace per distinct appearance, each with scalar marker properties. The trace count is bounded by the palette (15 colours + noise, × image/video = at most 32), not by the gallery.
  • Coalesce wheel and pinch zooms into the next animation frame, composing the factors.

Measured across four point distributions (even clusters, power-law with a long tail, all-noise, all 32 palette slots occupied):

before after
Zoom relayout, 170k points ~14x slower baseline
Zoom relayout, 300k points ~20x slower baseline
Scene build ~2.3x slower baseline
Retained heap for the built traces, 300k 13.7 MB 6.9 MB
Time to settle a 12-event wheel burst, 170k 3659 ms 800 ms

Absolute numbers are omitted deliberately: two independent harnesses measuring different windows of Plotly.relayout (synchronous body vs awaited redraw) differed by ~12x in scale while agreeing on the ratio, and everything was measured under SwiftShader. The ratio is the claim.

Related Issues / Discussions

Reported alongside the clustering work in #300, which is independent of this and can land in either order.

QA Instructions

pnpm -C invokeai/frontend/webv2 run lint                              # format + oxlint + tsc + architecture (70)
pnpm -C invokeai/frontend/webv2 exec vitest run \
  --config vitest.config.mts src/workbench/image-map                  # 114 passed
pnpm -C invokeai/frontend/webv2 exec vitest run \
  --config vitest.browser.config.mts src/workbench/image-map \
  src/workbench/widgets/image-map                                     # 66 passed
pnpm -C invokeai/frontend/webv2 run test:performance:build            # byte budgets pass, no rebaseline

Manual: open the Image Map on a large gallery and wheel-zoom. Also worth a pass with a cluster selected (click a cluster, then zoom) — see the limitation below.

Verified in the running app against the mock backend: the live plot reports 6 base traces plus the two overlays, 1008 points, every base trace scalar-styled. Screenshotting the same view before and after and diffing the map region gave 0 pixels differing above 4/255 — but see the appearance note, because that fixture happens not to contain the case that does change.

Review

Three independent read-only reviews (correctness; architecture/performance/operational safety; test value/product quality), each reproducing the measurements in its own harness. Material findings, all resolved:

  • Hover regresses above 100k points. Plotly builds a hit-testing kd-tree only for traces of ≥100k points, so the old single 170k trace had one and none of the split traces do. Hover goes from ~1.5ms to ~7ms at 170k and ~11ms at 300k, throttled to 20/s. Accepted — zoom at those sizes was 180ms+, so this trades a cost nobody could work through for one that is merely warm — but it is a real regression and it is now documented in the code. Below 100k there is no change, because the old trace had no tree either.
  • "Pixel-identical" was wrong. Points paint grouped rather than in gallery order, and markers are translucent, so where two points of different appearance overlap the compositing differs (up to 74% of inked pixels on a dense fixture). Noise painting first is the deliberate part. The claim is corrected here and in the code.
  • The bucketing could have miscoloured every point with no test noticing — the original fixture correlated cluster and kind, so keying on either alone passed. Likewise x/y/customdata alignment inside a trace was unchecked, so a shuffle would plot points at each other's coordinates and resolve clicks to the wrong image. Both now covered; seven previously-surviving mutants are killed.
  • A non-integer cluster id would silently delete points from the map (the bucket slot is an array index). Unreachable through the API today, which declares int, but the client does not validate and the old code still drew such points. Guarded.
  • An unreachable empty-map fallback justified by a false premise (plotly handles the trace count changing in both directions, and the view never mounts the plot for an empty set). Removed, along with the test defending it.
  • Two wheel tests passed vacuously once coalescing landed, asserting things an unapplied zoom also satisfies. Fixed with frame waits and an applied-count assertion.

Compatibility / Rollout

No API, schema, persisted-state or generated-artifact changes. Byte budgets pass without a rebaseline: everything touched sits behind the lazily-imported plot chunk, which no baseline entry graph covers.

Known limitation, not addressed here: buildHighlightedPointsTrace still carries per-point arrays and sits on the same zoom path, so selecting a cluster and then zooming gives back roughly a third of the win. MAX_CLUSTER_SELECTION = 5000 bounds it. The same treatment would apply, but the highlight is restyled in place by index, so splitting it is a larger change than it looks.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

🤖 Generated with Claude Code

The base points were one scattergl trace carrying per-point colour, opacity
and symbol arrays, and plotly reprocesses all of them on every relayout —
which a zoom is, once per frame. Splitting them into one trace per distinct
appearance (bounded by the palette at 32) makes every marker property
scalar: a zoom step drops roughly 14x at 170k points and 20x at 300k, the
scene builds about twice as fast, and the traces retain half the heap.

Wheel and pinch zooms now coalesce into the next animation frame. A wheel
emits far faster than a frame and each event applied its own relayout, so
they queued behind the cursor. Composing the factors lands on a bit-identical
range, so the gesture ends where it always did, in one paint instead of
twelve.

Two costs, both measured and accepted. Plotly builds a hit-testing kd-tree
only for traces of 100k points or more, so one 170k trace had one and none of
these do: hover goes from ~1.5ms to ~7ms at 170k and ~11ms at 300k. And below
roughly 5k points the per-trace overhead makes this a small net loss (~2.5ms
a frame at 200 points), taken rather than switched on a threshold, because
making draw order depend on gallery size is the worse bargain.

Appearance is not quite unchanged: points paint grouped rather than in
gallery order, so where two overlap the one on top can differ, and markers
are translucent. Noise now paints first, which is the deliberate part.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	invokeai/frontend/webv2/src/workbench/image-map/imageMap.test.ts
#	invokeai/frontend/webv2/src/workbench/image-map/imageMapTraces.ts
@lstein
lstein merged commit 0c154c8 into main Sep 23, 2026
16 checks passed
@lstein
lstein deleted the perf/image-map-zoom branch September 23, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant