Skip to content

Commit 6f9953b

Browse files
timtreisclaude
andcommitted
Fix norm inconsistencies across all image rendering paths
1. Copy norm in _prepare_cmap_norm so each CmapParams gets an independent instance — fixes shared-norm bug when cmap is a list and cross-element state sharing in chained render_images calls. 2. Move per-channel auto-ranging norm copy outside if/else so it covers both list and single-cmap branches. 3. Global auto-range for float RGB data outside [0,1]: instead of clipping (which destroys contrast), scale using global min/max across all RGB channels to preserve color balance. 4. Explicit np.clip in Path 2A-default after stacking normalized channels, instead of relying on matplotlib's silent clipping. 5. Remove dead percentiles_for_norm field from ImageRenderParams and the quantiles_for_norm deprecation alias. 6. Remove unreachable ch_norm-is-not-None guard since _prepare_cmap_norm always creates a Normalize. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f8d0266 commit 6f9953b

4 files changed

Lines changed: 21 additions & 15 deletions

File tree

src/spatialdata_plot/pl/basic.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -505,7 +505,7 @@ def render_points(
505505

506506
return sdata
507507

508-
@_deprecation_alias(elements="element", quantiles_for_norm="percentiles_for_norm", version="version 0.3.0")
508+
@_deprecation_alias(elements="element", version="version 0.3.0")
509509
def render_images(
510510
self,
511511
element: str | None = None,

src/spatialdata_plot/pl/render.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,19 +1019,28 @@ def _render_points(
10191019

10201020

10211021
def _normalize_dtype_to_float(arr: np.ndarray) -> np.ndarray:
1022-
"""Normalize an integer or float array to float64 in [0, 1] for matplotlib.
1022+
"""Normalize an array to float64 in [0, 1] for matplotlib.
10231023
10241024
- uint8 → divide by 255
1025-
- other int/uint dtypes → divide by dtype max
1026-
- float → clip to [0, 1]
1025+
- other unsigned int → divide by dtype max
1026+
- signed int → divide by dtype max, clip negatives to 0
1027+
- float already in [0, 1] → pass through
1028+
- float outside [0, 1] → global auto-range (preserves relative balance across channels)
10271029
"""
10281030
if arr.dtype == np.uint8:
10291031
return arr.astype(np.float64) / 255.0
10301032
if arr.dtype.kind == "u":
10311033
return arr.astype(np.float64) / np.iinfo(arr.dtype).max
10321034
if arr.dtype.kind == "i":
10331035
return np.clip(arr.astype(np.float64) / np.iinfo(arr.dtype).max, 0, 1)
1034-
result: np.ndarray = np.clip(arr, 0, 1).astype(np.float64)
1036+
# Float: if already in [0, 1], keep as-is; otherwise auto-range globally
1037+
arr_f: np.ndarray = arr.astype(np.float64)
1038+
vmin, vmax = arr_f.min(), arr_f.max()
1039+
if vmin >= 0.0 and vmax <= 1.0:
1040+
return arr_f
1041+
if vmin == vmax:
1042+
return np.zeros_like(arr_f)
1043+
result: np.ndarray = (arr_f - vmin) / (vmax - vmin)
10351044
return result
10361045

10371046

@@ -1221,18 +1230,17 @@ def _render_images(
12211230
ch_norm = render_params.cmap_params[ch_idx].norm
12221231
else:
12231232
ch_norm = render_params.cmap_params.norm
1224-
# When a single auto-ranging norm is shared across channels, copy it so
1225-
# each channel normalizes independently based on its own value range.
1226-
if isinstance(ch_norm, Normalize) and (ch_norm.vmin is None or ch_norm.vmax is None):
1227-
ch_norm = copy(ch_norm)
12281233

1229-
if ch_norm is not None:
1230-
layers[ch] = ch_norm(layers[ch])
1234+
# Auto-ranging norms are stateful — copy so each channel normalizes independently
1235+
if isinstance(ch_norm, Normalize) and (ch_norm.vmin is None or ch_norm.vmax is None):
1236+
ch_norm = copy(ch_norm)
1237+
1238+
layers[ch] = ch_norm(layers[ch])
12311239

12321240
# 2A) Image has 3 channels, no palette info, and no/only one cmap was given
12331241
if palette is None and n_channels == 3 and not isinstance(render_params.cmap_params, list):
12341242
if render_params.cmap_params.cmap_is_default: # -> use RGB
1235-
stacked = np.stack([layers[ch] for ch in layers], axis=-1)
1243+
stacked = np.clip(np.stack([layers[ch] for ch in layers], axis=-1), 0, 1)
12361244
else: # -> use given cmap for each channel
12371245
channel_cmaps = [render_params.cmap_params.cmap] * n_channels
12381246
stacked = (

src/spatialdata_plot/pl/render_params.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,6 @@ class ImageRenderParams:
265265
channel: list[str] | list[int] | int | str | None = None
266266
palette: ListedColormap | list[str] | None = None
267267
alpha: float = 1.0
268-
percentiles_for_norm: tuple[float | None, float | None] = (None, None)
269268
scale: str | None = None
270269
zorder: int = 0
271270
colorbar: bool | str | None = "auto"

src/spatialdata_plot/pl/utils.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -628,8 +628,7 @@ def _prepare_cmap_norm(
628628

629629
assert isinstance(cmap, Colormap), f"Invalid type of `cmap`: {type(cmap)}, expected `Colormap`."
630630

631-
if norm is None:
632-
norm = Normalize(vmin=None, vmax=None, clip=False)
631+
norm = Normalize(vmin=None, vmax=None, clip=False) if norm is None else copy(norm)
633632

634633
cmap.set_bad(na_color.get_hex_with_alpha())
635634

0 commit comments

Comments
 (0)