From 29282219c31908ddd5bda1d48c685a6e91b82980 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 20 May 2026 15:06:24 +0200 Subject: [PATCH 1/3] Suppress colorbar with clear warning for LogNorm + non-positive data render_images(norm=LogNorm()) crashed with an opaque matplotlib "Invalid vmin or vmax" when the image data was all zeros (or otherwise left LogNorm's vmin/vmax non-positive after autoscale). The default colorbar="auto" hit the crash without unusual args. Add a narrow guard at the start of _draw_colorbar: when the mappable norm is LogNorm and its (already autoscaled) bounds are invalid, emit a UserWarning pointing to colorbar=False / data clipping and skip the colorbar. The image artist itself still renders. Guard is class-specific so SymLogNorm (which legitimately supports zero/negative values) is unaffected. Closes #604 --- src/spatialdata_plot/pl/basic.py | 14 +++++++++- tests/pl/test_render_images.py | 47 +++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index e1d28d3b..819c0e17 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -20,7 +20,7 @@ from geopandas import GeoDataFrame from matplotlib.axes import Axes from matplotlib.backend_bases import RendererBase -from matplotlib.colors import Colormap, Normalize +from matplotlib.colors import Colormap, LogNorm, Normalize from matplotlib.figure import Figure from mpl_toolkits.axes_grid1.inset_locator import inset_axes from spatialdata import get_extent @@ -1299,6 +1299,18 @@ def _draw_colorbar( base_offsets_axes: dict[str, float], trackers_axes: dict[str, float], ) -> None: + norm = spec.mappable.norm + if isinstance(norm, LogNorm): + vmin, vmax = norm.vmin, norm.vmax + if vmin is None or vmax is None or vmin <= 0 or vmax <= 0 or vmin >= vmax: + warnings.warn( + "Data contains zeros or non-positive values; colorbar suppressed for `LogNorm`. " + "Pass `colorbar=False` to silence this warning, or clip the data to positive values.", + UserWarning, + stacklevel=2, + ) + return + base_layout = { "location": CBAR_DEFAULT_LOCATION, "fraction": CBAR_DEFAULT_FRACTION, diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index fb723c2f..6ff4b300 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -1,10 +1,12 @@ +import warnings + import dask.array as da import matplotlib import matplotlib.pyplot as plt import numpy as np import pytest import scanpy as sc -from matplotlib.colors import Normalize +from matplotlib.colors import LogNorm, Normalize, SymLogNorm from spatial_image import to_spatial_image from spatialdata import SpatialData from spatialdata.models import Image2DModel @@ -682,3 +684,46 @@ def test_channels_as_legend_coexists_with_other_elements(self, sdata_blobs: Spat assert "0" in labels assert "1" in labels plt.close("all") + + +def test_lognorm_with_zeros_suppresses_colorbar_with_warning(): + # regression test for #604: LogNorm + non-positive data must not raise an opaque + # matplotlib ValueError; instead suppress the colorbar with an actionable UserWarning. + img = np.zeros((1, 5, 5), dtype=np.float32) + sdata = SpatialData(images={"img": Image2DModel.parse(img, c_coords=["DAPI"])}) + fig, ax = plt.subplots() + try: + with pytest.warns(UserWarning, match="LogNorm"): + sdata.pl.render_images("img", norm=LogNorm()).pl.show(ax=ax) + finally: + plt.close(fig) + + +def test_lognorm_with_mixed_positives_renders_cleanly(): + # locks the passing path so the #604 guard does not widen accidentally + rng = np.random.default_rng(0) + img = rng.uniform(0.1, 5.0, size=(1, 8, 8)).astype(np.float32) + img[0, 0:2, 0:2] = 0.0 + sdata = SpatialData(images={"img": Image2DModel.parse(img, c_coords=["DAPI"])}) + fig, ax = plt.subplots() + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + sdata.pl.render_images("img", norm=LogNorm()).pl.show(ax=ax) + finally: + plt.close(fig) + + +def test_symlognorm_with_zeros_does_not_trigger_lognorm_guard(): + # regression test for #604: the guard must be class-specific so SymLogNorm + # (which legitimately supports zero/negative values) is unaffected. + img = np.zeros((1, 5, 5), dtype=np.float32) + img[0, 2, 2] = 1.0 + sdata = SpatialData(images={"img": Image2DModel.parse(img, c_coords=["DAPI"])}) + fig, ax = plt.subplots() + try: + with warnings.catch_warnings(): + warnings.filterwarnings("error", message=".*LogNorm.*", category=UserWarning) + sdata.pl.render_images("img", norm=SymLogNorm(linthresh=1e-3)).pl.show(ax=ax) + finally: + plt.close(fig) From 26a964b1a2b64fe1c1726b997803c62dbba79c3f Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 20 May 2026 15:12:16 +0200 Subject: [PATCH 2/3] Simplify #604 guard: drop redundant vmax <= 0 check vmax <= 0 is implied by vmin <= 0 combined with vmin >= vmax (if vmin > 0 and vmin < vmax, then vmax > 0). --- src/spatialdata_plot/pl/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 819c0e17..3d99cc77 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -1302,7 +1302,7 @@ def _draw_colorbar( norm = spec.mappable.norm if isinstance(norm, LogNorm): vmin, vmax = norm.vmin, norm.vmax - if vmin is None or vmax is None or vmin <= 0 or vmax <= 0 or vmin >= vmax: + if vmin is None or vmax is None or vmin <= 0 or vmin >= vmax: warnings.warn( "Data contains zeros or non-positive values; colorbar suppressed for `LogNorm`. " "Pass `colorbar=False` to silence this warning, or clip the data to positive values.", From 8b177dfe56287a0d5adce81ae3ca90059eae25d6 Mon Sep 17 00:00:00 2001 From: anon Date: Wed, 20 May 2026 15:16:52 +0200 Subject: [PATCH 3/3] Trim #604 tests to the regression case only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the mixed-positives and SymLogNorm tests — they guarded against hypothetical future regressions rather than the actual bug. Keep just the zeros-only LogNorm regression test. --- tests/pl/test_render_images.py | 34 +--------------------------------- 1 file changed, 1 insertion(+), 33 deletions(-) diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index 6ff4b300..13fe675c 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -1,12 +1,10 @@ -import warnings - import dask.array as da import matplotlib import matplotlib.pyplot as plt import numpy as np import pytest import scanpy as sc -from matplotlib.colors import LogNorm, Normalize, SymLogNorm +from matplotlib.colors import LogNorm, Normalize from spatial_image import to_spatial_image from spatialdata import SpatialData from spatialdata.models import Image2DModel @@ -697,33 +695,3 @@ def test_lognorm_with_zeros_suppresses_colorbar_with_warning(): sdata.pl.render_images("img", norm=LogNorm()).pl.show(ax=ax) finally: plt.close(fig) - - -def test_lognorm_with_mixed_positives_renders_cleanly(): - # locks the passing path so the #604 guard does not widen accidentally - rng = np.random.default_rng(0) - img = rng.uniform(0.1, 5.0, size=(1, 8, 8)).astype(np.float32) - img[0, 0:2, 0:2] = 0.0 - sdata = SpatialData(images={"img": Image2DModel.parse(img, c_coords=["DAPI"])}) - fig, ax = plt.subplots() - try: - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - sdata.pl.render_images("img", norm=LogNorm()).pl.show(ax=ax) - finally: - plt.close(fig) - - -def test_symlognorm_with_zeros_does_not_trigger_lognorm_guard(): - # regression test for #604: the guard must be class-specific so SymLogNorm - # (which legitimately supports zero/negative values) is unaffected. - img = np.zeros((1, 5, 5), dtype=np.float32) - img[0, 2, 2] = 1.0 - sdata = SpatialData(images={"img": Image2DModel.parse(img, c_coords=["DAPI"])}) - fig, ax = plt.subplots() - try: - with warnings.catch_warnings(): - warnings.filterwarnings("error", message=".*LogNorm.*", category=UserWarning) - sdata.pl.render_images("img", norm=SymLogNorm(linthresh=1e-3)).pl.show(ax=ax) - finally: - plt.close(fig)