Skip to content

Commit bacc738

Browse files
committed
feat(color): PercentileNormalize for percentile-based contrast limits
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))
1 parent d5a811a commit bacc738

4 files changed

Lines changed: 155 additions & 25 deletions

File tree

src/spatialdata_plot/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from . import pl
44
from ._logging import set_verbosity
55
from ._settings import Verbosity
6+
from .pl._color import PercentileNormalize
67

7-
__all__ = ["pl", "set_verbosity", "Verbosity"]
8+
__all__ = ["PercentileNormalize", "Verbosity", "pl", "set_verbosity"]
89

910
__version__ = version("spatialdata-plot")

src/spatialdata_plot/pl/_color.py

Lines changed: 67 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -110,39 +110,82 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
110110
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)
111111

112112

113+
class PercentileNormalize(Normalize):
114+
""":class:`~matplotlib.colors.Normalize` that autoscales to data percentiles instead of min/max.
115+
116+
Heavy-tailed images (fluorescence, Xenium morphology) have a few very bright pixels, so the
117+
default min/max mapping crushes the bulk of the signal into near-black. ``PercentileNormalize``
118+
derives ``vmin``/``vmax`` from the ``pmin``/``pmax`` percentiles of the data instead, which
119+
matches the per-channel contrast limits used by viewers like Xenium Explorer.
120+
121+
It plugs into the existing ``norm`` argument like any other ``Normalize``: a single instance is
122+
autoscaled independently per channel, and a list applies channelwise limits.
123+
124+
Parameters
125+
----------
126+
pmin
127+
Lower percentile in ``[0, 100]`` (``pmin < pmax``) used to derive ``vmin``.
128+
pmax
129+
Upper percentile in ``[0, 100]`` used to derive ``vmax``.
130+
clip
131+
Forwarded to :class:`~matplotlib.colors.Normalize`.
132+
133+
Notes
134+
-----
135+
Explicitly setting ``vmin``/``vmax`` overrides the corresponding percentile.
136+
"""
137+
138+
def __init__(self, pmin: float = 0.0, pmax: float = 100.0, clip: bool = False) -> None:
139+
if not 0.0 <= pmin < pmax <= 100.0:
140+
raise ValueError(f"Require 0 <= pmin < pmax <= 100, got pmin={pmin}, pmax={pmax}.")
141+
super().__init__(vmin=None, vmax=None, clip=clip)
142+
self.pmin = pmin
143+
self.pmax = pmax
144+
145+
def autoscale_None(self, A: Any) -> None:
146+
"""Fill unset ``vmin``/``vmax`` from the ``pmin``/``pmax`` percentiles of finite values."""
147+
finite = np.asarray(A)
148+
finite = finite[np.isfinite(finite)]
149+
if finite.size:
150+
if self.vmin is None:
151+
self.vmin = float(np.percentile(finite, self.pmin))
152+
if self.vmax is None:
153+
self.vmax = float(np.percentile(finite, self.pmax))
154+
155+
113156
def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
114157
"""Resolve ``cmap_params.norm`` with concrete vmin/vmax for continuous coloring.
115158
116-
Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
117-
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. Preserves the
118-
norm subclass (``LogNorm``/``PowerNorm``/...) so non-linear scaling is not silently linearized.
159+
Honor explicit ``norm`` vmin/vmax, else delegate to the norm's own ``autoscale_None`` over the
160+
finite values of ``values`` (so plain ``Normalize`` uses min/max, ``LogNorm`` uses its
161+
positive-only range, and ``PercentileNormalize`` uses percentiles), else fall back to ``[0, 1]``.
162+
Shared by the pixel and colorbar sites so both derive the same range; preserves the norm
163+
subclass so non-linear scaling is not silently linearized.
119164
"""
120-
base = cmap_params.norm
121-
vmin, vmax = base.vmin, base.vmax
122-
if vmin is None or vmax is None:
165+
resolved = copy(cmap_params.norm)
166+
if resolved.vmin is None or resolved.vmax is None:
123167
arr = np.asarray(values)
124168
if not np.issubdtype(arr.dtype, np.number):
125169
arr = pd.to_numeric(arr.ravel(), errors="coerce")
126-
finite = np.isfinite(arr)
127-
if isinstance(base, LogNorm):
128-
# LogNorm's domain excludes 0/negatives; derive the range from strictly-positive
129-
# finite values only (mirrors matplotlib's LogNorm.autoscale_None). Otherwise a
130-
# data_min <= 0 produces a LogNorm that raises "Invalid vmin or vmax" when called.
131-
positive = arr[finite & (arr > 0)]
132-
data_min = float(np.nanmin(positive)) if positive.size else 1.0
133-
data_max = float(np.nanmax(positive)) if positive.size else 1.0
170+
finite = arr[np.isfinite(arr)]
171+
if finite.size:
172+
resolved.autoscale_None(finite)
173+
if isinstance(resolved, LogNorm):
174+
# LogNorm needs strictly-positive bounds; all-nonpositive/empty data can't provide them
175+
# (matplotlib leaves them at 0), so fall back to a valid domain instead of raising later.
176+
if resolved.vmin is None or resolved.vmin <= 0:
177+
resolved.vmin = 1.0
178+
if resolved.vmax is None or resolved.vmax <= 0:
179+
resolved.vmax = 1.0
134180
else:
135-
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
136-
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
137-
if vmin is None:
138-
vmin = data_min
139-
if vmax is None:
140-
vmax = data_max
141-
if vmin == vmax and not isinstance(base, LogNorm):
181+
# Empty/all-NaN data can't fill the bounds; fall back to the unit interval.
182+
if resolved.vmin is None:
183+
resolved.vmin = 0.0
184+
if resolved.vmax is None:
185+
resolved.vmax = 1.0
186+
if resolved.vmin == resolved.vmax and not isinstance(resolved, LogNorm):
142187
# degenerate range collapses the cmap onto its floor; fall back to [0, 1]. LogNorm exempt (0 not in domain).
143-
vmin, vmax = 0.0, 1.0
144-
resolved = copy(base)
145-
resolved.vmin, resolved.vmax = vmin, vmax
188+
resolved.vmin, resolved.vmax = 0.0, 1.0
146189
return resolved
147190

148191

src/spatialdata_plot/pl/basic.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,10 @@ def render_images(
768768
A single :class:`~matplotlib.colors.Normalize` applies to all channels.
769769
A list of :class:`~matplotlib.colors.Normalize` objects applies per-channel
770770
(length must match the number of channels).
771+
For heavy-tailed images (e.g. fluorescence/Xenium morphology) where min/max
772+
scaling looks dim, pass :class:`~spatialdata_plot.PercentileNormalize` to clip each
773+
channel to a percentile range (single instance for all channels, or a list for
774+
channelwise limits).
771775
palette : list[str] | str | None
772776
Palette to color images. Can be a single palette name (broadcast to all channels) or a list
773777
matching the number of channels.

tests/pl/test_utils.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,3 +1126,85 @@ def test_precomputed_rgba_passthrough(self):
11261126

11271127
arr = np.array([[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]])
11281128
np.testing.assert_allclose(ColorSpec("continuous", None, arr).to_rgba(self._params()), arr)
1129+
1130+
1131+
class TestPercentileNormalize:
1132+
"""PercentileNormalize + _resolve_continuous_norm (issue #370: dim multichannel renders)."""
1133+
1134+
@staticmethod
1135+
def _three_channel_sdata() -> SpatialData:
1136+
from spatialdata.models import Image2DModel
1137+
from spatialdata.transformations import Identity
1138+
1139+
arr = np.zeros((3, 8, 8), dtype=np.uint16)
1140+
img = Image2DModel.parse(arr, dims=("c", "y", "x"), transformations={"global": Identity()})
1141+
return SpatialData(images={"img": img})
1142+
1143+
@pytest.mark.parametrize("norm_kind", ["normalize", "percentile"])
1144+
def test_norm_single_or_list_must_match_channels(self, norm_kind):
1145+
from matplotlib.colors import Normalize
1146+
1147+
from spatialdata_plot import PercentileNormalize
1148+
from spatialdata_plot.pl._validate import _validate_image_render_params
1149+
1150+
make = (lambda: Normalize(0, 200)) if norm_kind == "normalize" else (lambda: PercentileNormalize(1, 99))
1151+
sdata = self._three_channel_sdata()
1152+
kw = {
1153+
"element": "img",
1154+
"channel": None,
1155+
"alpha": 1.0,
1156+
"palette": None,
1157+
"cmap": None,
1158+
"scale": None,
1159+
"colorbar": True,
1160+
"colorbar_params": {},
1161+
}
1162+
# a single norm (broadcast) and a length-matching list are accepted; a mismatched list is rejected
1163+
_validate_image_render_params(sdata, norm=make(), **kw)
1164+
_validate_image_render_params(sdata, norm=[make() for _ in range(3)], **kw)
1165+
with pytest.raises(ValueError, match="must match the number of channels"):
1166+
_validate_image_render_params(sdata, norm=[make(), make()], **kw)
1167+
1168+
def test_autoscale_uses_percentiles(self):
1169+
from spatialdata_plot import PercentileNormalize
1170+
1171+
# heavy-tailed: one huge outlier should not set vmax under p99
1172+
data = np.concatenate([np.linspace(0, 100, 1000), [10000.0]])
1173+
norm = PercentileNormalize(1, 99)
1174+
norm.autoscale_None(data)
1175+
assert norm.vmin == pytest.approx(np.percentile(data, 1))
1176+
assert norm.vmax == pytest.approx(np.percentile(data, 99))
1177+
assert norm.vmax < 10000.0
1178+
1179+
def test_explicit_vmin_vmax_override_percentiles(self):
1180+
from spatialdata_plot import PercentileNormalize
1181+
1182+
norm = PercentileNormalize(1, 99)
1183+
norm.vmin, norm.vmax = 5.0, 50.0
1184+
norm.autoscale_None(np.linspace(0, 100, 100))
1185+
assert (norm.vmin, norm.vmax) == (5.0, 50.0)
1186+
1187+
@pytest.mark.parametrize("pmin,pmax", [(99, 1), (-1, 50), (50, 101), (5, 5)])
1188+
def test_invalid_percentiles_raise(self, pmin, pmax):
1189+
from spatialdata_plot import PercentileNormalize
1190+
1191+
with pytest.raises(ValueError):
1192+
PercentileNormalize(pmin, pmax)
1193+
1194+
def test_nan_values_ignored(self):
1195+
from spatialdata_plot import PercentileNormalize
1196+
1197+
norm = PercentileNormalize(0, 100)
1198+
norm.autoscale_None(np.array([np.nan, 1.0, 2.0, 3.0, np.inf, -np.inf]))
1199+
assert (norm.vmin, norm.vmax) == (1.0, 3.0)
1200+
1201+
def test_resolve_honors_percentile_norm(self):
1202+
# _resolve_continuous_norm (colorbar/shapes path) must defer to the norm's percentile autoscale;
1203+
# min/max and degenerate/empty fallbacks for builtin norms are covered by TestResolveContinuousNorm.
1204+
from spatialdata_plot import PercentileNormalize
1205+
from spatialdata_plot.pl._color import _prepare_cmap_norm, _resolve_continuous_norm
1206+
1207+
values = np.concatenate([np.linspace(0, 100, 1000), [10000.0]])
1208+
resolved = _resolve_continuous_norm(values, _prepare_cmap_norm(norm=PercentileNormalize(0, 99)))
1209+
assert resolved.vmax == pytest.approx(np.percentile(values, 99))
1210+
assert resolved.vmax < 10000.0

0 commit comments

Comments
 (0)