perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes - #729

Merged
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering
Jun 21, 2026
Merged

perf(shapes): faster datashader circle rendering + matplotlib-fidelity fixes#729
timtreis merged 18 commits into
mainfrom
perf/shapes-circle-rendering

Conversation

@timtreis

@timtreistimtreis commented Jun 20, 2026

Copy link
Copy Markdown
Member

No description provided.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch 2 times, most recently from 2fbec2c to ce587c6CompareJune 20, 2026 15:05
Circles (Point+radius) were buffered to polygons at shapely's default resolution=16
(65 vertices/circle) before datashader rasterization. For large circle sets this
coordinate explosion dominates the render (buffer + per-vertex transform + polygon
aggregation), e.g. ~5.9M coords for 91k circles.
Choose the buffer resolution from the largest disc's on-screen pixel radius
(_circle_buffer_quad_segs / _circle_quad_segs): 4 segments/quadrant for small discs
(<=8px, where extra vertices are sub-pixel), 8 (<=32px), and shapely's full 16 once
discs are large enough to show facets. Faithful (IoU >=0.98 vs the 65-vertex circle)
and handles per-circle varying radii.
End-to-end on Visium HD (single coordinate system): 91k circles 2.0s->1.5s,
352k circles 8.3s->4.9s.
Note: shifts the datashader-circle visual baselines (17- vs 65-vertex circles);
regenerate those from CI artifacts.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from ce587c6 to b6927acCompareJune 20, 2026 15:10
@timtreistimtreis reopened this Jun 20, 2026
@codecov-commenter

codecov-commenter commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.38%. Comparing base (480d9f0) to head (ae5b7fa).

Additional details and impacted files
@@ Coverage Diff @@## main #729 +/- ##
==========================================
+ Coverage 79.21% 79.38% +0.17% 
==========================================
Files 17 17 Lines 4566 4604 +38 Branches 1026 1031 +5 ==========================================
+ Hits 3617 3655 +38 
Misses 599 599 Partials 350 350 
Files with missing linesCoverage Δ
src/spatialdata_plot/pl/_datashader.py88.04% <100.00%> (+0.50%)⬆️
src/spatialdata_plot/pl/basic.py82.81% <ø> (ø)
src/spatialdata_plot/pl/render.py89.54% <100.00%> (+0.25%)⬆️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from b6927ac to bb22094CompareJune 20, 2026 15:26
On the datashader backend, buffering every circle (Point+radius) to a polygon dominates
the render for large sets. A large (>50k), uniform-radius, outline-free circle element is
a dot-field where a filled disc and a spread point are visually equivalent, so rasterize
centroids as radius-faithful points (_circles_render_as_points gate + radius-aware spread
in _datashader_points) instead of buffering. This is datashader-backend behavior; use
method="matplotlib" for a pixel-exact rendering. Per-circle varying radii, outlines, and
custom shapes keep the polygon path.
`as_points` stays a simple bool (style: dots vs geometry) and is itself a speedup on both
backends; it is orthogonal to this datashader optimization.
Adds a 2x2 visual test (geometry/as_points x matplotlib/datashader) verifying the four
render paths look alike, incl. the datashader fast-path matching exact matplotlib discs.
End-to-end on Visium HD (single coordinate system): 91k circles 6.85s->0.56s vs v0.4.0,
352k 22.95s->1.14s, 5.5M 002um impractical->~12.7s; method="matplotlib" stays exact.
@timtreis
timtreisforce-pushed the perf/shapes-circle-rendering branch from bb22094 to 68cc388CompareJune 20, 2026 15:33
timtreis added 15 commits June 20, 2026 17:43
Adds 2x2 visual tests mirroring the circle one, for the other elements whose render
paths split by backend / as_points, so divergence or breakage across paths is visible:
- points: (no color / continuous) x (matplotlib / datashader)
- labels: (fill / as_points) x (matplotlib / datashader)
- polygons: (geometry / as_points) x (matplotlib / datashader)
Colorbars disabled and marker sizes bumped so panels stay non-degenerate; each panel is
titled with its (mode x backend) combination. New baselines to be generated from CI.
Generated from the py3.11-stable CI artifact. Only the 4 new 2x2 permutation tests
needed baselines; the existing datashader-circle baselines stayed within tolerance under
the adaptive-buffer change.
PlotTester.compare() force-shrinks every figure to a 400x300 / 5x3.75in thumbnail.
matplotlib scatter markers are point-sized (absolute) and don't shrink with the squished
axes, while datashader as_points (a data-coordinate raster) and the geometry do — so the
as_points/matplotlib panel rendered ~1.6x oversized vs the other three. Rendering the 2x2
grids at the harness canvas size/dpi makes compare()'s resize a no-op, so the point-sized
scatter and the data-coordinate paths stay consistent. (Not a library bug: at a native
render size mpl and datashader as_points agree.)
…ender
Re-rendered at the harness canvas size: the as_points grids (circle/polygon/labels) now show
matplotlib and datashader at matching sizes. The points grid retains the known render_points
matplotlib-vs-datashader marker-size difference (looser sqrt(s)*dpi/100 spread calibration).
…plotlib
render_points sized its datashader canvas to the full figure (fig.get_size_inches()*dpi),
so when the axes was a subplot the raster was built at figure resolution then imshow'd into
the smaller axes and the markers shrank — matplotlib-vs-datashader point sizes diverged by
the axes/figure ratio (e.g. ~1.8x in a 2x2 grid; they agreed only when the axes filled the
figure). It also used a looser sqrt(size)*dpi/100 spread vs matplotlib's exact sqrt(size)*
dpi/144 marker radius.
Size the points canvas to the axes box (ax.get_window_extent(), as the as_markers/as_points
path already did) and use the /144 marker-radius formula for both paths. The datashader dot
now matches the matplotlib scatter marker by construction, in any layout. Degenerate-extent
handling (single/coincident points) is preserved.
Permutation-grid sizes set to non-overlapping values (circle 25, points 30) now that the
mpl/datashader match is structural rather than tuned.
…c marker size
The render_points datashader marker-size fix (axes-box canvas + /144 spread) shifts all
render_points datashader baselines (point sizes now match matplotlib in any layout). Also
refreshes the points and circle permutation grids. Generated from py3.11-stable CI.
…ound
Two regressions from the earlier marker-size work, fixed:
1. render_points: sizing the datashader canvas to the axes box lowered its resolution, which
changed point AGGREGATION (counts/reductions/density) — std/var grew spurious nonzero
pixels, dots went blocky, colors shifted. Restore the figure-resolution canvas (aggregation
identical to before) and instead scale only the marker spread by canvas/axes so dot size
still matches matplotlib in any layout. Colors/aggregation unchanged; size deterministic.
2. circles: the adaptive quad_segs coarsened *visible* discs (they looked octagonal vs the
matplotlib circles). Only coarsen sub-pixel discs (≤2px, where it's invisible and where the
Visium HD speedup lives — HD spots are ~0.3-0.6px); any visible disc keeps the round default.
HD spots stay sub-pixel → quad_segs=4 → speedup preserved (91k still ~0.58s/CS).
The earlier attempts to make render_points datashader markers match matplotlib in multi-panel
layouts all regressed real rendering: the axes-box canvas changed point aggregation (std/var
gained spurious values, dots went blocky, colors shifted); the canvas/axes spread scaling
overshot when a legend shrank the axes; and the data->display transform isn't valid at render
time (axis limits not yet set). render_points single-panel rendering already matches matplotlib
(~0.95); the multi-panel difference is the figure-vs-axes raster scale, compounded by the test
harness squishing figures to a 400x300 thumbnail.
Per 'be accurate in real plotting; note and ignore harness artifacts': revert render_points to
its original (correct) sizing, restore its baselines, and document the grid caveat. Keep the
circle work (Phase 1/2 + conservative quad_segs so visible circles stay round).
…original sizing
render_points reverted to its original sizing, so the grid baseline (previously the broken
axes-box version) is regenerated. The documented multi-panel/harness size difference between
the matplotlib and datashader columns is expected; single-panel rendering matches.
Datashader markers shrank in multi-panel subplots: the spread radius used
sqrt(size)*dpi/100 on a figure-resolution canvas, so the on-screen size scaled
with axes_window/figure and halved in a 2x2 grid. Rescale the spread by the
axes-box/canvas factor ratio so the displayed radius stays at the matplotlib
marker radius (sqrt(size)*dpi/144) in any layout. Unifies the render_points and
as_markers paths (ratio is 1 for the axes-box canvas) and drops the 144-vs-100
split. Aggregation canvas is unchanged, so std/var/count are unaffected.
… size
Datashader markers now match matplotlib in any panel layout. Three baselines
shifted (multi-panel grid, multi-panel groups/na_color, and the dpi size-agree
test); all single-panel datashader baselines stayed within tolerance and
shapes/labels centroid baselines are unchanged (axes-box ratio is 1).
…radius
Two pre-existing datashader fidelity issues exposed by the render-permutation
grids:
1. render_points continuous color defaulted to reduction "sum", which inflates
the normalization range where dots overlap and pushes single points to the
dark end of the colormap (datashader looked nothing like matplotlib). Switch
the default to "max" (each pixel shows its own value, matching matplotlib and
the as_points path). The spread step also has to follow the *resolved*
reduction: it defaulted to "add" for ds_reduction=None, summing overlapping
dilated dots and undoing the "max" aggregate. Now the spread how uses
`ds_reduction or default_reduction`.
2. as_points=True on uniform-radius circles now sizes the datashader dots to the
true disc radius, so they match the geometry render (and the circle
fast-path). The matplotlib backend keeps the marker `size` (scatter markers
are display-sized, not data-sized) — documented as an expected backend
difference.
…radius
Continuous datashader points now use the "max" reduction (full colormap range
instead of sum-darkened), and uniform-circle as_points dots are sized to the
true radius on the datashader backend. Regenerate the six affected continuous
point baselines and the shapes as_points datashader baseline; clarify the
as_points test docstrings (matplotlib stays size-based, an expected backend
difference).
Drop the datashader-only circle-radius override for as_points: it made the same
render_shapes(as_points=True, size=...) call diverge between backends (datashader
discs vs matplotlib markers). as_points is a size-controlled speedup; both
backends now use the marker size, matching each other (as the polygon
permutation grid already demonstrates). Restores the pre-override as_points
datashader baseline. Keeps the layout-invariant marker-size fix and the faithful
continuous-color reduction, which are what make the backends agree.
- _datashader_points default_reduction default "sum"->"max" to match both call
sites (removes a latent footgun: a future caller omitting it would silently
re-inflate continuous color).
- Drop the duplicate ax.get_window_extent() in the marker-spread branch; make
the factor==factor_axesbox (ratio 1) identity explicit for as_markers.
- Trim the one-liner helper docstrings/comments to the load-bearing why.
- Strengthen tests: gate test covers NaN radius; fast-path test spies the
centroid renderer to prove the fast path actually fired (not just "an image");
soften the layout-invariance docstring to the real <1px guarantee.
@timtreistimtreis changed the title perf(shapes): faster circle rendering on the datashader backendperf(shapes): faster datashader circle rendering + matplotlib-fidelity fixesJun 20, 2026
- Extract _affine_major_scale() for the SVD major-axis stretch duplicated by
the fast-path and _circle_buffer_quad_segs.
- Fast-path: coerce only the first radius value (gate guarantees uniform+finite)
instead of re-coercing the whole column — drops an O(n) pass at HD scale.
- Drop a comment that restated the adjacent log line; drop a redundant bool().
@timtreis
timtreis merged commit 078afb1 into mainJun 21, 2026
7 of 8 checks passed
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 00:08
@timtreis
timtreis restored the perf/shapes-circle-rendering branch June 21, 2026 11:19
@timtreis
timtreis deleted the perf/shapes-circle-rendering branch June 21, 2026 11:21
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@timtreis@codecov-commenter