Skip to content

Commit 705d350

Browse files
committed
Reject NaN pixels in render_images (#628)
Single-channel silently substituted na_color via cmap.set_bad; multi-channel silently composited NaN as black via additive per-channel cmaps without set_bad. Both paths now raise ValueError listing the offending channels and pointing to fillna().
1 parent 59da170 commit 705d350

2 files changed

Lines changed: 74 additions & 0 deletions

File tree

src/spatialdata_plot/pl/render.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,21 @@ def _render_images(
13141314

13151315
n_channels = len(channels)
13161316

1317+
# Reject NaN early: silent substitution (na_color in 1ch, black in multi-channel)
1318+
# hides upstream data problems.
1319+
nan_channels: list[Any] = []
1320+
for ch in channels:
1321+
layer = img.sel(c=ch) if isinstance(ch, str) else img.isel(c=ch)
1322+
if np.issubdtype(layer.dtype, np.floating) and np.isnan(layer.values).any():
1323+
nan_channels.append(ch)
1324+
if nan_channels:
1325+
raise ValueError(
1326+
f"Image '{render_params.element}' contains NaN pixels in channel(s) {nan_channels}. "
1327+
"NaN is not supported by render_images. Replace NaN before plotting, e.g. "
1328+
f"`sdata.images['{render_params.element}'] = sdata.images['{render_params.element}'].fillna(0)`, "
1329+
"or mask the affected region."
1330+
)
1331+
13171332
# When grayscale was applied and user didn't provide an explicit cmap,
13181333
# default to "gray" for intuitive single-channel rendering.
13191334
got_multiple_cmaps = isinstance(render_params.cmap_params, list)

tests/pl/test_render_images.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,65 @@ def test_cmap_matches_selected_channels_not_full_image(sdata_blobs: SpatialData)
531531
plt.close(fig)
532532

533533

534+
# Regression for #628: NaN pixels must raise, not silently render
535+
# (na_color in 1ch, black in multi-channel).
536+
def _nan_image(n_channels: int, nan_indices: list[int]) -> SpatialData:
537+
rng = np.random.default_rng(0)
538+
data = rng.uniform(0, 1, (n_channels, 8, 8)).astype(np.float32)
539+
for ch in nan_indices:
540+
data[ch, 0:3, 0:3] = np.nan
541+
img = Image2DModel.parse(data, dims=("c", "y", "x"), c_coords=list(range(n_channels)))
542+
return SpatialData(images={"img": img})
543+
544+
545+
def test_nan_in_single_channel_raises():
546+
sdata = _nan_image(n_channels=1, nan_indices=[0])
547+
with pytest.raises(ValueError, match=r"NaN.*channel\(s\) \[0\]"):
548+
sdata.pl.render_images("img").pl.show()
549+
550+
551+
def test_nan_in_multi_channel_raises():
552+
sdata = _nan_image(n_channels=2, nan_indices=[0])
553+
with pytest.raises(ValueError, match=r"NaN.*channel\(s\) \[0\]"):
554+
sdata.pl.render_images("img").pl.show()
555+
556+
557+
def test_finite_multi_channel_unaffected():
558+
sdata = _nan_image(n_channels=2, nan_indices=[])
559+
fig, ax = plt.subplots()
560+
sdata.pl.render_images("img").pl.show(ax=ax)
561+
plt.close(fig)
562+
563+
564+
def test_integer_dtype_skips_nan_check():
565+
rng = np.random.default_rng(0)
566+
data = rng.integers(0, 255, (2, 8, 8), dtype=np.uint16)
567+
img = Image2DModel.parse(data, dims=("c", "y", "x"), c_coords=[0, 1])
568+
sdata = SpatialData(images={"img": img})
569+
fig, ax = plt.subplots()
570+
sdata.pl.render_images("img").pl.show(ax=ax)
571+
plt.close(fig)
572+
573+
574+
def test_nan_only_in_unselected_channel_renders():
575+
sdata = _nan_image(n_channels=2, nan_indices=[1])
576+
fig, ax = plt.subplots()
577+
sdata.pl.render_images("img", channel=[0]).pl.show(ax=ax)
578+
plt.close(fig)
579+
580+
581+
def test_nan_error_lists_all_offending_channels():
582+
sdata = _nan_image(n_channels=3, nan_indices=[0, 2])
583+
with pytest.raises(ValueError, match=r"\[0, 2\]"):
584+
sdata.pl.render_images("img").pl.show()
585+
586+
587+
def test_nan_error_message_includes_fillna_hint():
588+
sdata = _nan_image(n_channels=1, nan_indices=[0])
589+
with pytest.raises(ValueError, match="fillna"):
590+
sdata.pl.render_images("img").pl.show()
591+
592+
534593
# Regression for #612: vmin/vmax kwargs are no longer accepted on any render
535594
# function. The check covers all four to prevent the asymmetry from re-emerging.
536595
@pytest.mark.parametrize("kwarg", ["vmin", "vmax"])

0 commit comments

Comments
 (0)