Skip to content

Add density mode to render_points#679

Merged
timtreis merged 8 commits into
mainfrom
feat/render-points-density
May 21, 2026
Merged

Add density mode to render_points#679
timtreis merged 8 commits into
mainfrom
feat/render-points-density

Conversation

@timtreis
Copy link
Copy Markdown
Member

@timtreis timtreis commented May 20, 2026

Summary

  • Adds density: bool and density_how: Literal["linear","log","cbrt","eq_hist"] to render_points for 2-D count density via datashader.
  • Reuses the existing color / groups / palette logic — categorical color produces per-category density via ds.by(col, ds.count()); no color produces plain ds.count().
  • Threads how= through the shade helpers; density=True forces method="datashader". Continuous or literal colors are rejected; size, transfunc, norm.vmin/vmax, and datashader_reduction are warn-and-ignored under density.

Why this design

The aggregation primitives already existed in _datashader.py — they were just unreachable without an explicit user-facing toggle. This change exposes them as a single boolean (density) plus one knob for the count-to-color mapping (density_how). Default density_how="linear" keeps the colorbar axis as a count, which is the intuitive first-time-user expectation; log/cbrt/eq_hist are available for dynamic-range compression.

API

sdata.pl.render_points("transcripts", density=True).pl.show()

sdata.pl.render_points(
    "transcripts", color="gene", groups=["Gad1", "Slc17a7"], palette="tab20", density=True
).pl.show()

timtreis added 3 commits May 20, 2026 23:56
Surfaces a discoverable density visualization on top of the existing
datashader plumbing: `density=True` switches `render_points` to a
2-D count aggregation, with `density_how` controlling the
count-to-color mapping (linear, log, cbrt, eq_hist).

The aggregation primitives already existed -- `cvs.points(..., agg=ds.count())`
for plain density and `ds.by(col, ds.count())` for per-category density
-- they were just unreachable without an explicit user-facing toggle.
This change wires them up and exposes `how=` through the shade helpers.

Under `density=True`, `method` is forced to `"datashader"`; passing
`method="matplotlib"` raises. Continuous or literal colors are rejected
(the validator catches the literal-color case early; the render path
catches continuous-color before `.compute()` materializes the data).
`size`, `transfunc`, `norm.vmin/vmax`, and `datashader_reduction` are
warn-and-ignored because they do not interact meaningfully with a
count-based density.
The shared blobs fixture only has ~200 points scattered across a
500x500 canvas, so density renders as nearly invisible single-pixel
dots. Add a dedicated sdata_dense_points fixture (~60k points across
three Gaussian clusters, with a categorical "gene" column) so the
test_plot_density_* cases produce a meaningful visual baseline.

Also fixes test_plot_density_categorical_with_groups, which previously
passed palette="tab20" alongside a single-element groups list -- our
palette parser treats single-string + single-group as a literal color
and rejected "tab20" with "Unknown color".
Generated from CI artifact for hatch-test.py3.11-stable on the
sdata_dense_points fixture.
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 20, 2026

Codecov Report

❌ Patch coverage is 86.66667% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.79%. Comparing base (75cadce) to head (af7c9aa).

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/render.py 80.00% 2 Missing and 2 partials ⚠️
src/spatialdata_plot/pl/utils.py 90.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #679      +/-   ##
==========================================
+ Coverage   77.71%   77.79%   +0.08%     
==========================================
  Files          11       11              
  Lines        3652     3693      +41     
  Branches      863      877      +14     
==========================================
+ Hits         2838     2873      +35     
- Misses        487      490       +3     
- Partials      327      330       +3     
Files with missing lines Coverage Δ
src/spatialdata_plot/pl/_datashader.py 90.32% <100.00%> (+0.06%) ⬆️
src/spatialdata_plot/pl/basic.py 86.20% <100.00%> (+0.07%) ⬆️
src/spatialdata_plot/pl/render_params.py 88.20% <100.00%> (+0.10%) ⬆️
src/spatialdata_plot/pl/utils.py 68.18% <90.00%> (+0.22%) ⬆️
src/spatialdata_plot/pl/render.py 87.10% <80.00%> (-0.22%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timtreis added 5 commits May 21, 2026 00:28
For ``color=None``, the dispatch in ``_render_points`` was sending the
aggregate to ``_ds_shade_categorical``, which uses ``color_vector[0]``
as a single hex color and only modulates alpha across counts. That
collapses any sequential colormap (``viridis`` and friends) to a flat
hue and makes a "density" plot look like a uniform smudge with low
opacity.

Plain density now routes through ``_ds_shade_continuous`` so the
configured cmap is used as a real intensity gradient over the count
aggregate. ``density_how`` ("linear" / "log" / "cbrt" / "eq_hist")
finally produces visibly different results.

The categorical density branch is unchanged: per-category color keys
still pass through ``_ds_shade_categorical`` and modulate alpha per
gene, which is the correct behaviour for "where does each category
concentrate".
Regenerated from CI after routing plain density through the
continuous shade path so the viridis cmap actually produces a
gradient over counts (previously alpha-only on a single hue).
For categorical density the only signal available to encode count is
per-pixel alpha (hue is fixed per category by the color key). The
existing min_alpha of ~254 (set so scatter plots render every point at
full opacity) clamps that signal to a single value, so all three
gene clusters were rendering as flat, uniform blobs.

Under density=True the categorical shade path now uses min_alpha=40
(~15%): low enough to give the count-driven alpha real range, high
enough to keep sparse-edge pixels visible under density_how="linear".
Plain density (continuous shade path) is unaffected -- its viridis
gradient already encodes count via hue.
Regenerated from CI after dropping the min_alpha floor for density:
each gene cluster now shows a real opacity gradient (darker centers,
lighter edges) instead of a flat hue cloud.
Consolidate 8 single-assertion unit tests into 2 parametrized ones
(rejections and warn-and-ignores) and drop 3 low-value cases
(forces_datashader, chaining_composes, default_path_unchanged) that
either duplicate other tests or assert tautologies. Folds the
"no warnings at default" check into the combined defaults test.

Drop two redundant visual tests: density_how_log (linear and eq_hist
already bracket the intensity-mapping range) and
density_categorical_with_groups (groups filtering shares no code with
density; covered elsewhere). Down from 12 + 5 to 4 + 3 tests with the
same coverage of decision points.
@timtreis timtreis merged commit 8a6a33f into main May 21, 2026
7 of 8 checks passed
@timtreis timtreis deleted the feat/render-points-density branch May 21, 2026 01:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants