Skip to content

Commit 15fc496

Browse files
committed
refactor(render): give CmapParams norm/cmap behavior, drop private _lut poke (#699)
PR1 of the #699 color/cmap/norm unification. Adds behavior to CmapParams so callers stop re-implementing stateful-object handling: - fresh_norm(): a safe copy of the norm. Applying a Normalize autoscales vmin/vmax in place, so sharing it leaks one element's range into the next. Converts the 4 hand-written copy(cmap_params.norm) sites (render_shapes, render_points, render_labels-as-points, datashader outline) to use it. - cmap_with_alpha() / colormap_with_alpha(): a public-API replacement for the private cmap._lut[:, -1] = alpha poke in the 1-channel image path. Rebuilds the colormap via a lossless N-bin resample (matplotlib quantizes __call__ to N bins, so body colors are byte-identical), preserves the cmap name, and reproduces the old bad/NaN-pixel alpha behavior. Also stops mutating the shared cmap in place. Behavior-preserving except the bad-pixel alpha at alpha<1 with NaN pixels, which is reproduced exactly. Adds unit tests for the new CmapParams methods. Out of scope (deferred to PR2): unifying _map_color_seg's shared-norm mutation — the continuous labels colorbar currently depends on that in-place autoscale, so it must be fixed together with the colorbar's norm derivation.
1 parent 7a1b3a3 commit 15fc496

4 files changed

Lines changed: 88 additions & 17 deletions

File tree

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.dataframe as dd
@@ -572,7 +571,7 @@ def _render_ds_outline_by_column(
572571
)
573572
# Apply the user-provided norm (vmin/vmax) the same way the fill path does so
574573
# an explicit Normalize takes effect for the outline cmap.
575-
norm = copy(cmap_params.norm)
574+
norm = cmap_params.fresh_norm()
576575
agg_outline, color_span = _apply_ds_norm(agg_outline, norm)
577576
shaded = ds.tf.shade(
578577
agg_outline,

src/spatialdata_plot/pl/render.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
PointsRenderParams,
5252
ShapesRenderParams,
5353
_DsReduction,
54+
colormap_with_alpha,
5455
)
5556
from spatialdata_plot.pl.utils import (
5657
_align_outline_vector_to_length,
@@ -741,7 +742,7 @@ def _render_shapes(
741742

742743
color_vector = _maybe_apply_transfunc(color_source_vector, color_vector, render_params.transfunc)
743744

744-
norm = copy(render_params.cmap_params.norm)
745+
norm = render_params.cmap_params.fresh_norm()
745746

746747
if len(color_vector) == 0:
747748
color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()]
@@ -1505,7 +1506,7 @@ def _render_points(
15051506

15061507
trans, trans_data = _prepare_transformation(sdata.points[element], coordinate_system, ax)
15071508

1508-
norm = copy(render_params.cmap_params.norm)
1509+
norm = render_params.cmap_params.fresh_norm()
15091510

15101511
method = render_params.method
15111512

@@ -1966,9 +1967,9 @@ def _render_images(
19661967
else render_params.cmap_params.cmap
19671968
)
19681969

1969-
# Overwrite alpha in cmap: https://stackoverflow.com/a/10127675
1970-
cmap._init()
1971-
cmap._lut[:, -1] = render_params.alpha
1970+
# Bake a uniform alpha into the cmap via a public-API rebuild (no private `_lut` poke,
1971+
# no mutation of the shared cmap). The selected cmap may be palette-derived, so apply to it.
1972+
cmap = colormap_with_alpha(cmap, render_params.alpha, render_params.cmap_params.na_color.get_hex_with_alpha())
19721973

19731974
# norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip.
19741975
_ax_show_and_transform(
@@ -2426,7 +2427,7 @@ def _render_labels(
24262427
y=xy[:, 1],
24272428
color_vector=point_color_vector,
24282429
color_source_vector=point_color_source_vector,
2429-
norm=copy(render_params.cmap_params.norm), # ax.scatter autoscales in place; don't mutate the shared norm
2430+
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
24302431
na_color=na_color,
24312432
adata=table if table_name is not None else None,
24322433
col_for_color=col_for_color,

src/spatialdata_plot/pl/render_params.py

Lines changed: 35 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,22 @@ 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+
Body colors are reproduced exactly: matplotlib quantizes ``Colormap.__call__`` into ``N`` bins,
156+
so resampling at ``linspace(0, 1, N)`` is lossless. The bad-row alpha is also set to ``alpha`` to
157+
match the historical ``cmap._lut[:, -1] = alpha`` poke this replaces.
158+
"""
159+
lut = cmap(np.linspace(0, 1, cmap.N))
160+
lut[:, -1] = alpha
161+
new = ListedColormap(lut, name=cmap.name)
162+
bad = list(to_rgba(na_color))
163+
bad[-1] = alpha
164+
new.set_bad(bad)
165+
return new
166+
167+
151168
@dataclass
152169
class CmapParams:
153170
"""Cmap params."""
@@ -157,6 +174,23 @@ class CmapParams:
157174
na_color: Color
158175
cmap_is_default: bool = True
159176

177+
def fresh_norm(self) -> Normalize:
178+
"""Return a copy of ``norm`` safe to apply or autoscale without touching the shared one.
179+
180+
``Normalize.__call__`` runs ``autoscale_None``, which sets ``vmin``/``vmax`` in place when
181+
they are unset. Applying the shared ``norm`` directly therefore leaks the first element's
182+
data range into later elements/channels that reuse the same ``CmapParams``.
183+
"""
184+
return copy(self.norm)
185+
186+
def cmap_with_alpha(self, alpha: float) -> Colormap:
187+
"""Return ``cmap`` with a uniform ``alpha``, preserving the na/bad color.
188+
189+
Public-API replacement for poking ``cmap._lut``. Returning a fresh colormap also avoids
190+
mutating the shared ``cmap`` in place.
191+
"""
192+
return colormap_with_alpha(self.cmap, alpha, self.na_color.get_hex_with_alpha())
193+
160194

161195
@dataclass
162196
class FigParams:

tests/pl/test_utils.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
import spatialdata_plot
1515
from spatialdata_plot.pl import measure_obs
16-
from spatialdata_plot.pl.render_params import Color, ColorLike
16+
from spatialdata_plot.pl.render_params import CmapParams, Color, ColorLike
1717
from spatialdata_plot.pl.utils import (
1818
_apply_cmap_alpha_to_datashader_result,
1919
_datashader_map_aggregate_to_color,
@@ -505,9 +505,7 @@ def _add_shapes_table(sdata: SpatialData, element: str = "blobs_polygons", name:
505505
adata = AnnData(np.zeros((len(gdf), 1), dtype=np.float32))
506506
adata.obs["instance_id"] = list(gdf.index)
507507
adata.obs["region"] = element
508-
sdata[name] = TableModel.parse(
509-
adata, region_key="region", instance_key="instance_id", region=element
510-
)
508+
sdata[name] = TableModel.parse(adata, region_key="region", instance_key="instance_id", region=element)
511509
return sdata
512510

513511

@@ -547,9 +545,7 @@ def test_writes_centroid_area_diameter_for_labels(self, sdata_blobs: SpatialData
547545
# area is the pixel count (positive integers); diameter = 2*sqrt(area/pi)
548546
area = table.obs["area"].to_numpy()
549547
assert (area > 0).all()
550-
np.testing.assert_allclose(
551-
table.obs["equivalent_diameter"].to_numpy(), 2.0 * np.sqrt(area / np.pi), rtol=1e-12
552-
)
548+
np.testing.assert_allclose(table.obs["equivalent_diameter"].to_numpy(), 2.0 * np.sqrt(area / np.pi), rtol=1e-12)
553549

554550
def test_writes_for_shapes(self, sdata_blobs: SpatialData) -> None:
555551
_add_shapes_table(sdata_blobs, "blobs_polygons")
@@ -635,7 +631,7 @@ def test_existing_nonnumeric_column_raises_before_any_write(self, sdata_blobs: S
635631
def test_sparse_high_label_ids(self, sdata_blobs: SpatialData) -> None:
636632
# #5: sparse/high label ids (max id >> n_labels) are measured correctly (dense relabelling).
637633
arr = np.asarray(sdata_blobs["blobs_labels"].data)
638-
hi = (arr.astype(np.int64) * 1000) # ids become 1000, 2000, ... ; max id is huge, few labels
634+
hi = arr.astype(np.int64) * 1000 # ids become 1000, 2000, ... ; max id is huge, few labels
639635
measure_obs(sd_hi := _labels_sdata(hi), "lab", table_name="t")
640636
measure_obs(sd_lo := _labels_sdata(arr.astype(np.int64)), "lab", table_name="t")
641637
# relabelling values does not move pixels -> identical centroid set
@@ -814,3 +810,44 @@ def test_show_renders_all_coordinate_systems_for_distributed_elements():
814810
axes = axes if isinstance(axes, list) else [axes]
815811
assert {ax.get_title() for ax in axes} == {"cs_a", "cs_b"}
816812
plt.close("all")
813+
814+
815+
class TestCmapParamsMethods:
816+
"""Unit tests for the behavior added to ``CmapParams`` (#699 PR1)."""
817+
818+
@staticmethod
819+
def _params(cmap_name: str = "viridis", na: Color | None = None) -> CmapParams:
820+
from matplotlib import colormaps
821+
from matplotlib.colors import Normalize
822+
823+
return CmapParams(cmap=colormaps[cmap_name], norm=Normalize(vmin=0.0, vmax=1.0), na_color=na or Color())
824+
825+
def test_fresh_norm_is_independent_copy(self):
826+
params = self._params()
827+
fresh = params.fresh_norm()
828+
assert fresh is not params.norm
829+
assert (fresh.vmin, fresh.vmax) == (params.norm.vmin, params.norm.vmax)
830+
# autoscaling the copy must not mutate the shared norm (the bug fresh_norm prevents)
831+
fresh.autoscale(np.array([5.0, 10.0]))
832+
assert (params.norm.vmin, params.norm.vmax) == (0.0, 1.0)
833+
834+
def test_cmap_with_alpha_preserves_body_and_sets_alpha(self):
835+
from matplotlib import colormaps
836+
837+
params = self._params()
838+
out = params.cmap_with_alpha(0.5)
839+
# sample at bin centers so quantization is exact for both colormaps
840+
xs = (np.arange(params.cmap.N) + 0.5) / params.cmap.N
841+
got = out(xs)
842+
np.testing.assert_array_equal(got[:, :3], colormaps["viridis"](xs)[:, :3])
843+
np.testing.assert_allclose(got[:, 3], 0.5)
844+
845+
def test_cmap_with_alpha_bad_color_uses_na_with_requested_alpha(self):
846+
from matplotlib.colors import to_rgba
847+
848+
na = Color() # default lightgray, fully opaque
849+
out = self._params(na=na).cmap_with_alpha(0.25)
850+
bad = out(np.nan)
851+
np.testing.assert_allclose(bad[:3], to_rgba(na.get_hex_with_alpha())[:3], atol=1e-6)
852+
# historical `_lut[:, -1] = alpha` overwrote the bad-row alpha too
853+
assert bad[3] == 0.25

0 commit comments

Comments
 (0)