Skip to content

feat(color): PercentileNormalize for percentile-based contrast limits#725

Open
timtreis wants to merge 8 commits into
mainfrom
feat/percentile-normalize
Open

feat(color): PercentileNormalize for percentile-based contrast limits#725
timtreis wants to merge 8 commits into
mainfrom
feat/percentile-normalize

Conversation

@timtreis

@timtreis timtreis commented Jun 19, 2026

Copy link
Copy Markdown
Member

Summary

Closes the dimness reported in #370. Heavy-tailed images (fluorescence, Xenium morphology) render dim because the default per-channel min/max normalization maps the bulk of the signal to near-black — a single bright outlier sets vmax.

Adds PercentileNormalize, a matplotlib.colors.Normalize subclass that autoscales vmin/vmax to data percentiles (pmin/pmax) instead of min/max. It plugs into the existing norm= argument — no new parameter:

  • a single instance is broadcast and autoscaled independently per channel;
  • a list applies channelwise limits (length must match the channel count, already validated).
import spatialdata_plot as sdp
sdata.pl.render_images("morphology_focus", palette=[...], channel=[...names...],
                       norm=sdp.PercentileNormalize(0, 99.5))           # broadcast
# channelwise:
norm=[sdp.PercentileNormalize(1, 99), sdp.PercentileNormalize(0, 99.9), ...]

Also unifies the two normalization code paths: _resolve_continuous_norm (single-channel colorbar + shapes/points/labels) now delegates to the norm's own autoscale_None instead of reimplementing min/max, so PercentileNormalize is honored everywhere. This is behavior-preserving for plain Normalize (min/max) and LogNorm (positive-only domain) — locked by an equivalence test.

Default behavior is unchanged (still min/max); percentile contrast is opt-in. No deprecation needed.

timtreis added 2 commits June 16, 2026 16:26
…t the resolved norm

_resolve_continuous_norm preserved the norm subclass (#722) but still derived
vmin/vmax from the full data range, so a LogNorm over data containing 0 or
negatives produced LogNorm(vmin<=0) and raised 'Invalid vmin or vmax' on render.
Derive the range from strictly-positive finite values for LogNorm (mirrors
matplotlib's LogNorm.autoscale_None).

Once the subclass is preserved, the continuous colorbar sites must attach the
resolved norm, not a default linear Normalize:
- shapes fill: set_norm(used_norm) instead of set_clim.
- labels: imshow of a pre-baked RGB image cannot carry a non-linear norm, so
  display the RGB without a norm and build the colorbar from a
  ScalarMappable(norm=used_norm), mirroring the outline path.

as_points now carries the upstream colortype instead of re-deriving it from
'source is not None', so an all-NaN ('none') layer is not mislabelled categorical.

Adds regression tests feeding LogNorm zeros/negatives/all-NaN (and invoking the
norm) plus a shapes colorbar-subclass assertion.
Matplotlib points passed an un-scaled fresh_norm() to ax.scatter and let it
autoscale, diverging from shapes/labels: a constant-valued continuous points
layer mapped to the cmap floor instead of the [0, 1] reset, and a LogNorm was
autoscaled over the raw data (crashing on a 0/negative). Route continuous points
through _resolve_continuous_norm so fill and colorbar match the other element
types and LogNorm/PowerNorm survive.

Kept separate from the core fix as it is the only change that can shift point
visual baselines.

Adds a regression test for render_points(norm=LogNorm()) over data with a 0.
@timtreis timtreis changed the title feat(color): PercentileNormalize for percentile-based contrast limits (#370) feat(color): PercentileNormalize for percentile-based contrast limits Jun 19, 2026
Heavy-tailed images (fluorescence, Xenium morphology) render dim because the
default per-channel min/max normalization maps the bulk of the signal to
near-black — a single bright outlier sets vmax. This addresses the dimness
reported in #370.

Add PercentileNormalize, a matplotlib Normalize subclass that autoscales
vmin/vmax to data percentiles (pmin/pmax) instead of min/max. It plugs into the
existing `norm=` argument with no new parameter: a single instance is broadcast
and autoscaled independently per channel, and a list applies channelwise limits
(length must match the channel count, as already validated).

Unify the two normalization code paths: _resolve_continuous_norm (single-channel
colorbar + shapes/points/labels) now delegates to the norm's own autoscale_None
instead of reimplementing min/max, so PercentileNormalize is honored everywhere.
This is behavior-preserving for plain Normalize (min/max) and LogNorm
(positive-only domain); an equivalence test locks that.

Default behavior is unchanged (still min/max); percentile contrast is opt-in.

Usage:
    import spatialdata_plot as sdp
    sdata.pl.render_images("morphology_focus", palette=[...], channel=[...],
                           norm=sdp.PercentileNormalize(0, 99.5))
@timtreis timtreis force-pushed the feat/percentile-normalize branch from 1df024f to bacc738 Compare June 19, 2026 19:10
@timtreis timtreis changed the base branch from fix/lognorm-norm-and-colorbar-consistency to main June 19, 2026 19:14
@timtreis timtreis closed this Jun 19, 2026
@timtreis timtreis reopened this Jun 19, 2026
…m fill

render_points now routes continuous color through _resolve_continuous_norm for
the datashader backend too (norm passed at render.py:1459), so the points use
the properly scaled norm (degenerate [0,1] reset, LogNorm/PowerNorm preserved,
data-range vmin/vmax) instead of an unscaled autoscaled norm. This shifts the
datashader continuous-color/reduction/NaN baselines; regenerated from CI.
@codecov-commenter

codecov-commenter commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.20%. Comparing base (b4ad6f7) to head (77e64e7).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
src/spatialdata_plot/pl/render.py 84.61% 1 Missing and 1 partial ⚠️
src/spatialdata_plot/pl/_color.py 96.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #725      +/-   ##
==========================================
+ Coverage   79.09%   79.20%   +0.11%     
==========================================
  Files          17       17              
  Lines        4467     4563      +96     
  Branches      999     1026      +27     
==========================================
+ Hits         3533     3614      +81     
- Misses        593      599       +6     
- Partials      341      350       +9     
Files with missing lines Coverage Δ
src/spatialdata_plot/__init__.py 100.00% <100.00%> (ø)
src/spatialdata_plot/pl/basic.py 82.81% <ø> (+0.12%) ⬆️
src/spatialdata_plot/pl/_color.py 69.14% <96.66%> (+1.01%) ⬆️
src/spatialdata_plot/pl/render.py 89.29% <84.61%> (-0.03%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

timtreis added 4 commits June 19, 2026 21:43
…r autoscaling

d5a811a routed continuous points through _resolve_continuous_norm, but the
resolved norm was passed to BOTH backends. For datashader the displayed value is
the per-pixel aggregate (count/sum/reduction), whose range differs from the
per-point color vector — applying a color-vector-derived vmin/vmax mis-scaled the
colorbar and painted empty pixels (purple background) instead of leaving them
transparent.

Scope the resolved norm to the matplotlib backend (where each point is colored by
its own value); datashader keeps the un-scaled fresh_norm so _apply_ds_norm
autoscales to the aggregate (honoring any explicit vmin/vmax) — the pre-d5a811a
behavior. Reverts the datashader point baselines regenerated in error.
Lock the rendered output of percentile-based contrast: a single PercentileNormalize
broadcast across channels, and a channelwise list. Baselines generated from CI.
…omments

- PercentileNormalize.autoscale_None now honors masked arrays (matplotlib parity):
  masked/NaN/inf are excluded from the percentile computation. Regression test added.
- Document that the datashader backend autoscales to the aggregate, not the percentiles.
- Add PercentileNormalize to the API docs (it was cross-referenced but unrendered).
- Trim comments that restated the code in _render_points / _resolve_continuous_norm and
  hoist the duplicated PercentileNormalize import in the image tests.
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