Skip to content

Commit 1fd8b48

Browse files
committed
refactor(render): unify color/cmap/norm pipeline — CmapParams methods + single continuous-norm (#699)
PRs 1+2 of #699, rebased onto the post-#715 (utils split) / post-#720 main. PR3 (image-composite helper) deferred. CmapParams gains behavior: - fresh_norm(): a safe per-element copy (applying a Normalize autoscales vmin/vmax in place, leaking one element's range into the next). Converts the 3 copy(norm) sites in render.py. - cmap_with_alpha() / colormap_with_alpha(): public-API replacement for the private cmap._lut[:, -1] = alpha poke in the 1-channel image path; byte-identical body colors, no shared-cmap mutation. Single continuous-norm feeds pixels and colorbar: - _resolve_continuous_norm(values, cmap_params) in _color.py: one resolver called by each pixel-baking site and its matching colorbar site with the same vector, so they cannot diverge. Folds the duplicated inline norm blocks in _color_vector_to_rgba and _get_collection_shape (now in _geometry.py; its dead `norm` param dropped) and routes _map_color_seg + the labels imshow + _append_outline_colorbar + the shapes set_clim through it. - Datashader / image / points / graph paths unchanged (already share their norm). Behavior-preserving for normal renders; the only unified edge is an all-identical-value outline column. Adds unit tests for the new CmapParams methods and _resolve_continuous_norm plus a non-visual colorbar-clim integration test.
1 parent bfda2ed commit 1fd8b48

7 files changed

Lines changed: 187 additions & 89 deletions

File tree

src/spatialdata_plot/pl/_color.py

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,29 @@ def _make_continuous_mappable(vmin: float, vmax: float, cmap: Any) -> ScalarMapp
109109
return ScalarMappable(norm=Normalize(vmin=vmin, vmax=vmax), cmap=cmap)
110110

111111

112+
def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
113+
"""Resolve a concrete ``Normalize`` for continuous coloring.
114+
115+
Honor explicit ``norm`` vmin/vmax, else the finite-value data range of ``values``, else
116+
``[0, 1]``. Shared by the pixel and colorbar sites so both derive the same range. A degenerate
117+
``vmin == vmax`` is left as-is (matplotlib expands it downstream), not reset to ``[0, 1]``.
118+
"""
119+
base = cmap_params.norm
120+
vmin, vmax = base.vmin, base.vmax
121+
if vmin is None or vmax is None:
122+
arr = np.asarray(values)
123+
if not np.issubdtype(arr.dtype, np.number):
124+
arr = pd.to_numeric(arr.ravel(), errors="coerce")
125+
finite = np.isfinite(arr)
126+
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
127+
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
128+
if vmin is None:
129+
vmin = data_min
130+
if vmax is None:
131+
vmax = data_max
132+
return Normalize(vmin=vmin, vmax=vmax, clip=base.clip)
133+
134+
112135
def _apply_mask_to_outline_vectors(
113136
outline_color_vector: Any,
114137
outline_color_source_vector: pd.Series | None,
@@ -189,15 +212,7 @@ def _color_vector_to_rgba(
189212
if np.issubdtype(arr.dtype, np.number):
190213
finite_mask = np.isfinite(arr)
191214
if finite_mask.any():
192-
norm = cmap_params.norm
193-
if norm.vmin is None or norm.vmax is None:
194-
vmin = float(np.nanmin(arr[finite_mask]))
195-
vmax = float(np.nanmax(arr[finite_mask]))
196-
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
197-
vmin, vmax = 0.0, 1.0
198-
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
199-
else:
200-
used_norm = norm
215+
used_norm = _resolve_continuous_norm(arr, cmap_params)
201216
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
202217
return rgba
203218

@@ -206,15 +221,7 @@ def _color_vector_to_rgba(
206221
num = pd.to_numeric(series, errors="coerce").to_numpy()
207222
is_num = np.isfinite(num)
208223
if is_num.any():
209-
norm = cmap_params.norm
210-
if norm.vmin is None or norm.vmax is None:
211-
vmin = float(np.nanmin(num[is_num]))
212-
vmax = float(np.nanmax(num[is_num]))
213-
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
214-
vmin, vmax = 0.0, 1.0
215-
used_norm = Normalize(vmin=vmin, vmax=vmax, clip=False)
216-
else:
217-
used_norm = norm
224+
used_norm = _resolve_continuous_norm(num, cmap_params)
218225
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
219226
color_mask = (~is_num) & series.notna().to_numpy()
220227
if color_mask.any():
@@ -754,7 +761,8 @@ def _map_color_seg(
754761
color_vector = color_vector.to_numpy()
755762
# normalize only the not nan values, else the whole array would contain only nan values
756763
normed_color_vector = color_vector.copy().astype(float)
757-
normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm(
764+
used_norm = _resolve_continuous_norm(normed_color_vector, cmap_params)
765+
normed_color_vector[~np.isnan(normed_color_vector)] = used_norm(
758766
normed_color_vector[~np.isnan(normed_color_vector)]
759767
)
760768
cols = cmap_params.cmap(normed_color_vector)
@@ -779,7 +787,8 @@ def _map_color_seg(
779787
assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like."
780788
cols = colors.to_rgba_array(color_vector)
781789
else:
782-
cols = cmap_params.cmap(cmap_params.norm(color_vector))
790+
used_norm = _resolve_continuous_norm(color_vector, cmap_params)
791+
cols = cmap_params.cmap(used_norm(color_vector))
783792

784793
if seg_erosionpx is not None:
785794
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
@@ -813,7 +822,7 @@ def _map_color_seg(
813822
normed = ov.copy().astype(float)
814823
finite = ~np.isnan(normed)
815824
if finite.any():
816-
normed[finite] = cmap_params.norm(normed[finite])
825+
normed[finite] = _resolve_continuous_norm(ov, cmap_params)(normed[finite])
817826
outline_cols = cmap_params.cmap(normed)
818827
outline_val_im = map_array(seg, cell_id, cell_id)
819828
if seg_erosionpx is not None:

src/spatialdata_plot/pl/_datashader.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
from __future__ import annotations
77

8-
from copy import copy
98
from typing import Any, Literal
109

1110
import dask
@@ -588,7 +587,7 @@ def _render_ds_outline_by_column(
588587
)
589588
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
590589
# an explicit Normalize takes effect for the outline cmap.
591-
norm = copy(cmap_params.norm)
590+
norm = cmap_params.fresh_norm()
592591
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
593592
shaded = ds.tf.shade(
594593
agg_outline,

src/spatialdata_plot/pl/_geometry.py

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
from geopandas import GeoDataFrame
1414
from matplotlib import colors
1515
from matplotlib.collections import PatchCollection
16-
from matplotlib.colors import ColorConverter, Normalize
16+
from matplotlib.colors import ColorConverter
1717
from scipy.spatial import ConvexHull
1818
from shapely.errors import GEOSException
1919

2020
from spatialdata_plot._logging import logger
21+
from spatialdata_plot.pl._color import _resolve_continuous_norm
2122
from spatialdata_plot.pl.render_params import ShapesRenderParams
2223
from spatialdata_plot.pl.utils import _extract_scalar_value
2324

@@ -167,7 +168,6 @@ def _get_collection_shape(
167168
shapes: list[GeoDataFrame],
168169
c: Any,
169170
s: float,
170-
norm: Any,
171171
render_params: ShapesRenderParams,
172172
fill_alpha: None | float = None,
173173
outline_alpha: None | float = None,
@@ -215,23 +215,11 @@ def _as_rgba_array(x: Any) -> np.ndarray:
215215
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
216216
finite_mask = np.isfinite(c_arr)
217217

218-
# Select or build a normalization that ignores NaNs for scaling
219-
if isinstance(norm, Normalize):
220-
used_norm: Normalize = norm
221-
else:
222-
if finite_mask.any():
223-
vmin = float(np.nanmin(c_arr[finite_mask]))
224-
vmax = float(np.nanmax(c_arr[finite_mask]))
225-
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
226-
vmin, vmax = 0.0, 1.0
227-
else:
228-
vmin, vmax = 0.0, 1.0
229-
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
230-
231-
# Map finite values through cmap(norm(.)); NaNs get na_color
218+
# Map finite values through cmap(norm(.)); NaNs get na_color.
232219
fill_c = np.empty((len(c_arr), 4), dtype=float)
233220
fill_c[:] = na_rgba
234221
if finite_mask.any():
222+
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
235223
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))
236224

237225
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
@@ -246,14 +234,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:
246234

247235
# numeric entries via cmap(norm)
248236
if is_num.any():
249-
if isinstance(norm, Normalize):
250-
used_norm = norm
251-
else:
252-
vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0
253-
vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0
254-
if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax:
255-
vmin, vmax = 0.0, 1.0
256-
used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False)
237+
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
257238
fill_c[is_num] = cmap(used_norm(num[is_num]))
258239

259240
# non-numeric, non-NaN entries as explicit colors

src/spatialdata_plot/pl/render.py

Lines changed: 17 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,10 @@
3838
_color_vector_to_rgba,
3939
_get_colors_for_categorical_obs,
4040
_get_linear_colormap,
41-
_make_continuous_mappable,
4241
_map_color_seg,
4342
_maybe_set_colors,
4443
_prepare_cmap_norm,
44+
_resolve_continuous_norm,
4545
_set_color_source_vec,
4646
)
4747
from spatialdata_plot.pl._datashader import (
@@ -77,6 +77,7 @@
7777
PointsRenderParams,
7878
ShapesRenderParams,
7979
_DsReduction,
80+
colormap_with_alpha,
8081
)
8182
from spatialdata_plot.pl.utils import (
8283
_decorate_axs,
@@ -515,21 +516,17 @@ def _append_outline_colorbar(
515516
) -> None:
516517
"""Append a `ColorbarSpec` for a continuous outline column.
517518
518-
No-op when ``outline_color_vector`` has no finite values. Honors user-supplied
519-
`vmin`/`vmax` on ``cmap_params.norm``; falls back to data range. Mirrors the
520-
`vmin == vmax` ±0.5 expansion used by the fill colorbar.
519+
No-op when ``outline_color_vector`` has no finite values; derives the bar from the same resolved
520+
norm the outline pixels use.
521521
"""
522522
arr = pd.to_numeric(pd.Series(np.asarray(outline_color_vector)), errors="coerce").to_numpy()
523-
finite = np.isfinite(arr)
524-
if not finite.any():
523+
if not np.isfinite(arr).any():
525524
return
526-
norm = cmap_params.norm
527-
vmin = norm.vmin if norm.vmin is not None else float(np.nanmin(arr[finite]))
528-
vmax = norm.vmax if norm.vmax is not None else float(np.nanmax(arr[finite]))
525+
used_norm = _resolve_continuous_norm(outline_color_vector, cmap_params)
529526
colorbar_requests.append(
530527
ColorbarSpec(
531528
ax=ax,
532-
mappable=_make_continuous_mappable(vmin, vmax, cmap_params.cmap),
529+
mappable=ScalarMappable(norm=used_norm, cmap=cmap_params.cmap),
533530
params=colorbar_params,
534531
label=outline_col,
535532
alpha=alpha,
@@ -747,7 +744,7 @@ def _render_shapes(
747744

748745
color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)
749746

750-
norm = copy(render_params.cmap_params.norm)
747+
norm = render_params.cmap_params.fresh_norm()
751748

752749
if len(color_vector) == 0:
753750
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
@@ -968,7 +965,6 @@ def _render_shapes(
968965
render_params=render_params,
969966
rasterized=sc_settings._vector_friendly,
970967
cmap=None,
971-
norm=None,
972968
fill_alpha=0.0,
973969
outline_alpha=render_params.outline_alpha[0],
974970
outline_color=outline_rgba,
@@ -987,7 +983,6 @@ def _render_shapes(
987983
render_params=render_params,
988984
rasterized=sc_settings._vector_friendly,
989985
cmap=None,
990-
norm=None,
991986
fill_alpha=0.0,
992987
outline_alpha=render_params.outline_alpha[0],
993988
outline_color=render_params.outline_params.outer_outline_color.get_hex(),
@@ -1008,7 +1003,6 @@ def _render_shapes(
10081003
render_params=render_params,
10091004
rasterized=sc_settings._vector_friendly,
10101005
cmap=None,
1011-
norm=None,
10121006
fill_alpha=0.0,
10131007
outline_alpha=render_params.outline_alpha[1],
10141008
outline_color=render_params.outline_params.inner_outline_color.get_hex(),
@@ -1030,7 +1024,6 @@ def _render_shapes(
10301024
render_params=render_params,
10311025
rasterized=sc_settings._vector_friendly,
10321026
cmap=render_params.cmap_params.cmap,
1033-
norm=norm,
10341027
fill_alpha=render_params.fill_alpha,
10351028
outline_alpha=0.0,
10361029
zorder=render_params.zorder,
@@ -1043,25 +1036,9 @@ def _render_shapes(
10431036
path.vertices = trans.transform(path.vertices)
10441037

10451038
if not values_are_categorical:
1046-
# Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable
1047-
vmin = render_params.cmap_params.norm.vmin
1048-
vmax = render_params.cmap_params.norm.vmax
1049-
if vmin is None or vmax is None:
1050-
numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce")
1051-
finite_mask = np.isfinite(numeric_values)
1052-
if finite_mask.any():
1053-
data_min = float(np.nanmin(numeric_values[finite_mask]))
1054-
data_max = float(np.nanmax(numeric_values[finite_mask]))
1055-
if vmin is None:
1056-
vmin = data_min
1057-
if vmax is None:
1058-
vmax = data_max
1059-
else:
1060-
if vmin is None:
1061-
vmin = 0.0
1062-
if vmax is None:
1063-
vmax = 1.0
1064-
_cax.set_clim(vmin=vmin, vmax=vmax)
1039+
# Colorbar range from the same resolved norm the fill pixels use.
1040+
used_norm = _resolve_continuous_norm(color_vector, render_params.cmap_params)
1041+
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)
10651042

10661043
_add_legend_and_colorbar(
10671044
ax=ax,
@@ -1511,7 +1488,7 @@ def _render_points(
15111488

15121489
trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)
15131490

1514-
norm = copy(render_params.cmap_params.norm)
1491+
norm = render_params.cmap_params.fresh_norm()
15151492

15161493
method = render_params.method
15171494

@@ -1972,9 +1949,8 @@ def _render_images(
19721949
else render_params.cmap_params.cmap
19731950
)
19741951

1975-
# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
1976-
cmap._init()
1977-
cmap._lut[:, -1] = render_params.alpha
1952+
# Bake a uniform alpha into a fresh cmap (no shared-cmap mutation).
1953+
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())
19781954

19791955
# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
19801956
_ax_show_and_transform(
@@ -2432,7 +2408,7 @@ def _render_labels(
24322408
y=xy[:, 1],
24332409
color_vector=point_color_vector,
24342410
color_source_vector=point_color_source_vector,
2435-
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
2411+
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
24362412
na_color=na_color,
24372413
adata=table if table_name is not None else None,
24382414
col_for_color=col_for_color,
@@ -2465,11 +2441,12 @@ def _draw_labels(
24652441
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
24662442
)
24672443

2444+
# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
24682445
cax = ax.imshow(
24692446
labels,
24702447
rasterized=True,
24712448
cmap=None if categorical else render_params.cmap_params.cmap,
2472-
norm=None if categorical else render_params.cmap_params.norm,
2449+
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
24732450
alpha=alpha,
24742451
origin="lower",
24752452
zorder=render_params.zorder,

src/spatialdata_plot/pl/render_params.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable, Mapping, Sequence
4+
from copy import copy
45
from dataclasses import dataclass, field
56
from typing import Any, Literal
67

78
import numpy as np
89
from matplotlib.axes import Axes
910
from matplotlib.cm import ScalarMappable
10-
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex
11+
from matplotlib.colors import Colormap, ListedColormap, Normalize, rgb2hex, to_hex, to_rgba
1112
from matplotlib.figure import Figure
1213

1314
_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"]
@@ -148,6 +149,23 @@ def is_fully_transparent(self) -> bool:
148149
return self.alpha == "00"
149150

150151

152+
def colormap_with_alpha(cmap: Colormap, alpha: float, na_color: str) -> Colormap:
153+
"""Return ``cmap`` rebuilt with a uniform ``alpha`` and ``na_color`` as the bad/NaN color.
154+
155+
Resampling at ``linspace(0, 1, N)`` is lossless (matplotlib quantizes ``__call__`` into ``N`` bins).
156+
"""
157+
lut = cmap(np.linspace(0, 1, cmap.N))
158+
lut[:, -1] = alpha
159+
new = ListedColormap(lut, name=cmap.name)
160+
# Apply alpha to under/over too, matching the old ``_lut[:, -1] = alpha`` (which hit every row).
161+
new.set_extremes(
162+
bad=[*to_rgba(na_color)[:3], alpha],
163+
under=[*cmap.get_under()[:3], alpha],
164+
over=[*cmap.get_over()[:3], alpha],
165+
)
166+
return new
167+
168+
151169
@dataclass
152170
class CmapParams:
153171
"""Cmap params."""
@@ -157,6 +175,14 @@ class CmapParams:
157175
na_color: Color
158176
cmap_is_default: bool = True
159177

178+
def fresh_norm(self) -> Normalize:
179+
"""Return a copy of ``norm`` safe to apply/autoscale without mutating the shared one.
180+
181+
``Normalize.__call__`` autoscales ``vmin``/``vmax`` in place when unset, which would leak one
182+
element's data range into later elements that reuse the same ``CmapParams``.
183+
"""
184+
return copy(self.norm)
185+
160186

161187
@dataclass
162188
class FigParams:

0 commit comments

Comments
 (0)