From 8cdff26196805a8d0699a99663191a6b6156d831 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 21 May 2026 15:27:36 +0200 Subject: [PATCH 1/2] Warn when r/g/b channels have divergent ranges (#610) Global normalization in the RGB path silently crushed low-range channels when channels named r/g/b actually held fluorescence-like data with very different native ranges. Emit a warning when per-channel ranges differ by more than 100x so users know to rename channels or supply per-channel cmaps. --- src/spatialdata_plot/pl/render.py | 33 ++++++++++++++++++++++++++++++- tests/pl/test_render_images.py | 25 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index eb39824d..bbb9de1d 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1239,6 +1239,35 @@ def _is_rgb_image(channel_coords: list[Any]) -> tuple[bool, bool]: return False, False +def _warn_if_rgb_channels_have_divergent_ranges( + rgb_cyx: np.ndarray, + ratio_threshold: float = 100.0, +) -> None: + """Warn when r/g/b channel ranges differ enough that global normalization will crush some. + + The RGB path normalizes all channels with one scale (dtype max or global min/max) to + preserve hue balance. If per-channel native ranges differ by orders of magnitude — a + common sign of fluorescence channels aliased to r/g/b names — the low-range channels + end up near zero. We can't tell intent from naming alone, so we warn and let the user + decide whether to rename channels or supply explicit cmaps. + """ + flat = rgb_cyx.reshape(rgb_cyx.shape[0], -1).astype(np.float64) + ranges = flat.max(axis=1) - flat.min(axis=1) + positive = ranges[np.isfinite(ranges) & (ranges > 0)] + if positive.size < 2: + return + if positive.max() / positive.min() > ratio_threshold: + logger.warning( + "RGB channels have per-channel ranges differing by more than %.0fx (%s). " + "Global RGB normalization will make low-range channels nearly invisible. " + "If these are fluorescence channels aliased to 'r','g','b', rename them or " + "pass an explicit per-channel 'cmap'/'palette' so each channel is normalized " + "independently.", + ratio_threshold, + ", ".join(f"{r:.3g}" for r in ranges.tolist()), + ) + + def _collect_channel_legend_entries( channels: Sequence[str | int], seed_colors: Sequence[str | tuple[float, ...]], @@ -1473,7 +1502,9 @@ def _render_images( rgb_layers.append(np.clip(ch_norm(img.sel(c=ch).values).astype(np.float64), 0, 1)) stacked = np.stack(rgb_layers, axis=-1) else: - stacked = _normalize_dtype_to_float(np.moveaxis(img.sel(c=ordered).values, 0, -1)) + rgb_cyx = img.sel(c=ordered).values + _warn_if_rgb_channels_have_divergent_ranges(rgb_cyx) + stacked = _normalize_dtype_to_float(np.moveaxis(rgb_cyx, 0, -1)) show_kwargs: dict[str, Any] = {"zorder": render_params.zorder} diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index 7c2390ab..63b24f5e 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -386,6 +386,31 @@ def test_cmap_overrides_rgba_detection(self): plt.close("all") +class TestRGBDivergentRangesWarning: + """Regression tests for issue #610: warn when r/g/b channels have wildly different ranges.""" + + @staticmethod + def _make_rgb_sdata(maxima: list[float]) -> SpatialData: + data = np.stack([np.full((10, 10), m, dtype=np.float32) for m in maxima], axis=0) + data[:, 0, 0] = 0.0 # force min=0 so range == max + img = Image2DModel.parse(data, dims=("c", "y", "x"), c_coords=["r", "g", "b"]) + return SpatialData(images={"img": img}) + + def test_warns_when_ranges_differ_by_more_than_100x(self, caplog): + sdata = self._make_rgb_sdata([1.0, 100.0, 65535.0]) + with logger_warns(caplog, logger, match="differing by more than"): + sdata.pl.render_images("img").pl.show() + plt.close("all") + + def test_does_not_warn_for_typical_rgb_ranges(self, caplog): + from spatialdata_plot._logging import logger_no_warns + + sdata = self._make_rgb_sdata([1.0, 0.8, 0.5]) + with logger_no_warns(caplog, logger, match="differing by more than"): + sdata.pl.render_images("img").pl.show() + plt.close("all") + + class TestMultiChannelClipping: """Regression tests: multi-channel compositing should not produce clipping warnings.""" From 301209b4737d3cf02546240056a31c3acecb9771 Mon Sep 17 00:00:00 2001 From: anon Date: Thu, 21 May 2026 15:31:26 +0200 Subject: [PATCH 2/2] Drop float64 copy in range check; cover RGBA + multichannel paths - Compute per-channel min/max on original dtype with axis=(1,2) instead of copying to float64 and reshaping. - Add tests confirming the warning also fires for RGBA, and stays quiet on the multichannel (non-r/g/b-named) and user-norm paths. --- src/spatialdata_plot/pl/render.py | 3 +-- tests/pl/test_render_images.py | 36 +++++++++++++++++++++++-------- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index bbb9de1d..764510b7 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1251,8 +1251,7 @@ def _warn_if_rgb_channels_have_divergent_ranges( end up near zero. We can't tell intent from naming alone, so we warn and let the user decide whether to rename channels or supply explicit cmaps. """ - flat = rgb_cyx.reshape(rgb_cyx.shape[0], -1).astype(np.float64) - ranges = flat.max(axis=1) - flat.min(axis=1) + ranges = (rgb_cyx.max(axis=(1, 2)) - rgb_cyx.min(axis=(1, 2))).astype(np.float64) positive = ranges[np.isfinite(ranges) & (ranges > 0)] if positive.size < 2: return diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index 63b24f5e..c30817d9 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -10,7 +10,7 @@ from spatialdata.models import Image2DModel, Image3DModel import spatialdata_plot # noqa: F401 -from spatialdata_plot._logging import logger, logger_warns +from spatialdata_plot._logging import logger, logger_no_warns, logger_warns from spatialdata_plot.pl.render import _is_rgb_image from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over @@ -390,26 +390,44 @@ class TestRGBDivergentRangesWarning: """Regression tests for issue #610: warn when r/g/b channels have wildly different ranges.""" @staticmethod - def _make_rgb_sdata(maxima: list[float]) -> SpatialData: + def _make_sdata(maxima: list[float], c_coords: list[str]) -> SpatialData: data = np.stack([np.full((10, 10), m, dtype=np.float32) for m in maxima], axis=0) - data[:, 0, 0] = 0.0 # force min=0 so range == max - img = Image2DModel.parse(data, dims=("c", "y", "x"), c_coords=["r", "g", "b"]) + data[:, 0, 0] = 0.0 + img = Image2DModel.parse(data, dims=("c", "y", "x"), c_coords=c_coords) return SpatialData(images={"img": img}) - def test_warns_when_ranges_differ_by_more_than_100x(self, caplog): - sdata = self._make_rgb_sdata([1.0, 100.0, 65535.0]) + def test_warns_for_rgb_divergent_ranges(self, caplog): + sdata = self._make_sdata([1.0, 100.0, 65535.0], ["r", "g", "b"]) + with logger_warns(caplog, logger, match="differing by more than"): + sdata.pl.render_images("img").pl.show() + plt.close("all") + + def test_warns_for_rgba_divergent_ranges(self, caplog): + sdata = self._make_sdata([1.0, 100.0, 65535.0, 1.0], ["r", "g", "b", "a"]) with logger_warns(caplog, logger, match="differing by more than"): sdata.pl.render_images("img").pl.show() plt.close("all") - def test_does_not_warn_for_typical_rgb_ranges(self, caplog): - from spatialdata_plot._logging import logger_no_warns + def test_no_warning_for_typical_rgb_ranges(self, caplog): + sdata = self._make_sdata([1.0, 0.8, 0.5], ["r", "g", "b"]) + with logger_no_warns(caplog, logger, match="differing by more than"): + sdata.pl.render_images("img").pl.show() + plt.close("all") - sdata = self._make_rgb_sdata([1.0, 0.8, 0.5]) + def test_no_warning_for_non_rgb_named_channels(self, caplog): + # Multichannel path normalizes per-channel; the divergent-range warning is RGB-specific. + sdata = self._make_sdata([1.0, 100.0, 65535.0], ["DAPI", "GFP", "RFP"]) with logger_no_warns(caplog, logger, match="differing by more than"): sdata.pl.render_images("img").pl.show() plt.close("all") + def test_no_warning_when_user_norm_supplied(self, caplog): + # Explicit norm bypasses global normalization, so the warning should not fire. + sdata = self._make_sdata([1.0, 100.0, 65535.0], ["r", "g", "b"]) + with logger_no_warns(caplog, logger, match="differing by more than"): + sdata.pl.render_images("img", norm=Normalize(vmin=0.0, vmax=1.0)).pl.show() + plt.close("all") + class TestMultiChannelClipping: """Regression tests: multi-channel compositing should not produce clipping warnings."""