Skip to content

Commit 18bcb96

Browse files
committed
refactor(color): ColorSpec.to_rgba — one fill+outline continuous->RGBA mapping (#700)
The per-row continuous/categorical/object -> RGBA mapping lived twice: inline in _get_collection_shape (fill) and in _color_vector_to_rgba (outline), kept in sync by a "mirrors ..." comment. Hoist it onto ColorSpec.to_rgba(cmap_params) and call it from both: the shapes fill now passes color_spec.to_rgba(...) (so _get_collection_shape only handles the RGBA passthrough + single-color broadcast), and the outline calls outline_color_spec.to_rgba(...). Deletes _color_vector_to_rgba and the now-dead numeric/object cases + their imports. to_rgba is a verbatim port, so outline output is identical; the fill cases used byte-identical expressions. New unit tests lock to_rgba's RGBA (continuous+NaN, categorical, object-mix, RGBA passthrough) independent of visual baselines.
1 parent baf57e2 commit 18bcb96

4 files changed

Lines changed: 85 additions & 118 deletions

File tree

‎src/spatialdata_plot/pl/_color.py‎

Lines changed: 32 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -139,54 +139,6 @@ def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
139139
return resolved
140140

141141

142-
def _color_vector_to_rgba(
143-
color_vector: Any | None,
144-
color_source_vector: pd.Series | None,
145-
cmap_params: CmapParams,
146-
n_rows: int,
147-
) -> np.ndarray:
148-
"""Convert a fill/outline `color_vector` (categorical hex strings or continuous numerics) to (N, 4) RGBA.
149-
150-
Mirrors the per-row mapping done inside :func:`_get_collection_shape` so that
151-
callers can pre-materialize an outline-color array. NaN/non-finite entries are
152-
painted with ``cmap_params.na_color``.
153-
"""
154-
na_rgba = colors.to_rgba(cmap_params.na_color.get_hex_with_alpha())
155-
if color_vector is None:
156-
rgba = np.empty((n_rows, 4), dtype=float)
157-
rgba[:] = na_rgba
158-
return rgba
159-
160-
if color_source_vector is not None:
161-
# Categorical: color_vector contains hex strings aligned to color_source_vector
162-
return np.asarray(ColorConverter().to_rgba_array(list(color_vector)))
163-
164-
arr = np.asarray(color_vector)
165-
if arr.ndim == 2 and arr.shape[1] in (3, 4) and np.issubdtype(arr.dtype, np.number):
166-
return np.asarray(ColorConverter().to_rgba_array(arr))
167-
168-
rgba = np.empty((len(arr), 4), dtype=float)
169-
rgba[:] = na_rgba
170-
if np.issubdtype(arr.dtype, np.number):
171-
finite_mask = np.isfinite(arr)
172-
if finite_mask.any():
173-
used_norm = _resolve_continuous_norm(arr, cmap_params)
174-
rgba[finite_mask] = cmap_params.cmap(used_norm(arr[finite_mask]))
175-
return rgba
176-
177-
# Object dtype: mix of numerics and color-like specs (apply cmap to the numeric subset only)
178-
series = pd.Series(arr, copy=False)
179-
num = pd.to_numeric(series, errors="coerce").to_numpy()
180-
is_num = np.isfinite(num)
181-
if is_num.any():
182-
used_norm = _resolve_continuous_norm(num, cmap_params)
183-
rgba[is_num] = cmap_params.cmap(used_norm(num[is_num]))
184-
color_mask = (~is_num) & series.notna().to_numpy()
185-
if color_mask.any():
186-
rgba[color_mask] = ColorConverter().to_rgba_array(series[color_mask].tolist())
187-
return rgba
188-
189-
190142
def _prepare_cmap_norm(
191143
cmap: Colormap | str | None = None,
192144
norm: Normalize | None = None,
@@ -746,6 +698,38 @@ def apply_transfunc(self, transfunc: Any) -> ColorSpec:
746698
return replace(self, color_vector=transfunc(self.color_vector))
747699
return self
748700

701+
def to_rgba(self, cmap_params: CmapParams) -> np.ndarray:
702+
"""Map this layer's color to an ``(N, 4)`` RGBA array; the one fill+outline mapping.
703+
704+
Categorical/none ``color_vector`` is per-row hex -> straight to RGBA; continuous numerics map
705+
via norm+cmap with NaN/non-finite rows painted ``na_color``; an object vector mixes the two.
706+
"""
707+
na_rgba = colors.to_rgba(cmap_params.na_color.get_hex_with_alpha())
708+
if self.source_vector is not None: # categorical or none: color_vector holds per-row hex
709+
return np.asarray(ColorConverter().to_rgba_array(list(self.color_vector)))
710+
arr = np.asarray(self.color_vector)
711+
if arr.ndim == 2 and arr.shape[1] in (3, 4) and np.issubdtype(arr.dtype, np.number):
712+
return np.asarray(ColorConverter().to_rgba_array(arr))
713+
rgba = np.empty((len(arr), 4), dtype=float)
714+
rgba[:] = na_rgba
715+
if np.issubdtype(arr.dtype, np.number):
716+
finite = np.isfinite(arr)
717+
if finite.any():
718+
norm = _resolve_continuous_norm(arr, cmap_params)
719+
rgba[finite] = cmap_params.cmap(norm(arr[finite]))
720+
return rgba
721+
# object dtype: numeric subset via cmap(norm), explicit color-likes via to_rgba_array
722+
series = pd.Series(arr, copy=False)
723+
num = pd.to_numeric(series, errors="coerce").to_numpy()
724+
is_num = np.isfinite(num)
725+
if is_num.any():
726+
norm = _resolve_continuous_norm(num, cmap_params)
727+
rgba[is_num] = cmap_params.cmap(norm(num[is_num]))
728+
color_mask = (~is_num) & series.notna().to_numpy()
729+
if color_mask.any():
730+
rgba[color_mask] = ColorConverter().to_rgba_array(series[color_mask].tolist())
731+
return rgba
732+
749733
def align_to_length(self, n: int, na_color: Color) -> ColorSpec:
750734
"""Pad or truncate both vectors to ``n`` rows (cross-table / rasterize outline alignment).
751735

‎src/spatialdata_plot/pl/_geometry.py‎

Lines changed: 9 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,12 @@
1111
import pandas as pd
1212
import shapely
1313
from geopandas import GeoDataFrame
14-
from matplotlib import colors
1514
from matplotlib.collections import PatchCollection
1615
from matplotlib.colors import ColorConverter
1716
from scipy.spatial import ConvexHull
1817
from shapely.errors import GEOSException
1918

2019
from spatialdata_plot._logging import logger
21-
from spatialdata_plot.pl._color import _resolve_continuous_norm
2220
from spatialdata_plot.pl.render_params import ShapesRenderParams
2321
from spatialdata_plot.pl.utils import _extract_scalar_value
2422

@@ -176,75 +174,24 @@ def _get_collection_shape(
176174
prebuilt_patches: tuple[list[mpatches.Patch], list[int], int] | None = None,
177175
**kwargs: Any,
178176
) -> PatchCollection:
179-
"""
180-
Build a PatchCollection for shapes with correct handling of.
181-
182-
- continuous numeric vectors with NaNs,
183-
- per-row RGBA arrays,
184-
- a single color or a list of color specs.
177+
"""Build a PatchCollection for shapes.
185178
186-
Only NaNs are painted with na_color; finite values are mapped via norm+cmap.
187-
188-
.. note::
189-
When ``outline_color`` is passed as an ``(N, 4)`` RGBA array of dtype ``float``,
190-
its alpha channel is mutated in place to apply ``outline_alpha``. Pass a copy
191-
if you need to retain the original buffer.
179+
``c`` is the per-row fill: an ``(N, 4)`` RGBA array (from :meth:`ColorSpec.to_rgba`) or a single
180+
color / list of color specs (broadcast). ``outline_color`` may be an ``(N, 4)`` float RGBA array,
181+
whose alpha channel is mutated in place to apply ``outline_alpha`` (pass a copy to retain it).
192182
"""
193-
cmap = kwargs["cmap"]
194-
195-
# Resolve na color once
196-
na_rgba = colors.to_rgba(render_params.cmap_params.na_color.get_hex_with_alpha())
197-
198-
# Try to interpret c as numpy array
183+
# Per-row fill is pre-mapped to RGBA by ``ColorSpec.to_rgba``; here ``c`` is either that
184+
# (N, 4) array or a single color / list of color specs (e.g. the white outline placeholder).
199185
c_arr = np.asarray(c)
200-
fill_c: np.ndarray
201-
202-
def _as_rgba_array(x: Any) -> np.ndarray:
203-
return np.asarray(ColorConverter().to_rgba_array(x))
204-
205-
# Case A: per-row numeric colors given as Nx3 or Nx4 float array
206186
if (
207187
c_arr.ndim == 2
208188
and c_arr.shape[0] == len(shapes)
209189
and c_arr.shape[1] in (3, 4)
210190
and np.issubdtype(c_arr.dtype, np.number)
211191
):
212-
fill_c = _as_rgba_array(c_arr)
213-
214-
# Case B: continuous numeric vector len == n_shapes (possibly with NaNs)
215-
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number):
216-
finite_mask = np.isfinite(c_arr)
217-
218-
# Map finite values through cmap(norm(.)); NaNs get na_color.
219-
fill_c = np.empty((len(c_arr), 4), dtype=float)
220-
fill_c[:] = na_rgba
221-
if finite_mask.any():
222-
used_norm = _resolve_continuous_norm(c_arr, render_params.cmap_params)
223-
fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask]))
224-
225-
elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object:
226-
# Split into numeric vs color-like
227-
c_series = pd.Series(c_arr, copy=False)
228-
num = pd.to_numeric(c_series, errors="coerce").to_numpy()
229-
is_num = np.isfinite(num)
230-
231-
# init with na color
232-
fill_c = np.empty((len(c_series), 4), dtype=float)
233-
fill_c[:] = na_rgba
234-
235-
# numeric entries via cmap(norm)
236-
if is_num.any():
237-
used_norm = _resolve_continuous_norm(num, render_params.cmap_params)
238-
fill_c[is_num] = cmap(used_norm(num[is_num]))
239-
240-
# non-numeric, non-NaN entries as explicit colors
241-
non_numeric_color_mask = (~is_num) & c_series.notna().to_numpy()
242-
if non_numeric_color_mask.any():
243-
fill_c[non_numeric_color_mask] = ColorConverter().to_rgba_array(c_series[non_numeric_color_mask].tolist())
244-
245-
# Case C: single color or list of color-like specs (strings or tuples)
192+
fill_c = np.asarray(ColorConverter().to_rgba_array(c_arr))
246193
else:
247-
fill_c = _as_rgba_array(c)
194+
fill_c = np.asarray(ColorConverter().to_rgba_array(c))
248195

249196
# Apply optional fill alpha without destroying existing transparency
250197
if fill_alpha is not None:
@@ -259,7 +206,7 @@ def _as_rgba_array(x: Any) -> np.ndarray:
259206
# on the hot path; otherwise upcast to a fresh float buffer.
260207
outline_c_array = outline_arr if outline_arr.dtype == float else outline_arr.astype(float)
261208
else:
262-
outline_c_array = _as_rgba_array(outline_color)
209+
outline_c_array = np.asarray(ColorConverter().to_rgba_array(outline_color))
263210
outline_c_array[..., -1] = outline_alpha
264211
outline_c = outline_c_array.tolist()
265212
else:

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535
from spatialdata_plot.pl._color import (
3636
ColorSpec,
3737
ColorType,
38-
_color_vector_to_rgba,
3938
_get_colors_for_categorical_obs,
4039
_get_linear_colormap,
4140
_map_color_seg,
@@ -917,12 +916,7 @@ def _render_shapes(
917916

918917
# render outlines separately to ensure they are always underneath the shape
919918
if col_for_outline_color is not None and render_params.outline_alpha[0] > 0:
920-
outline_rgba = _color_vector_to_rgba(
921-
outline_color_vector,
922-
outline_color_source_vector,
923-
render_params.cmap_params,
924-
n_rows=len(shapes),
925-
)
919+
outline_rgba = outline_color_spec.to_rgba(render_params.cmap_params)
926920
_cax = _get_collection_shape(
927921
shapes=shapes,
928922
s=render_params.scale,
@@ -984,7 +978,7 @@ def _render_shapes(
984978
_cax = _get_collection_shape(
985979
shapes=shapes,
986980
s=render_params.scale,
987-
c=color_vector.copy(), # copy bc c is modified in _get_collection_shape
981+
c=color_spec.to_rgba(render_params.cmap_params),
988982
prebuilt_patches=prebuilt_patches,
989983
render_params=render_params,
990984
rasterized=sc_settings._vector_friendly,

‎tests/pl/test_utils.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,3 +1047,45 @@ def test_align_to_length_pads_continuous_with_nan(self):
10471047
def test_align_to_length_noop_when_already_matching(self):
10481048
spec = self._cont_spec()
10491049
assert spec.align_to_length(4, Color()) is spec
1050+
1051+
1052+
class TestColorSpecToRgba:
1053+
"""ColorSpec.to_rgba is the single fill+outline continuous/categorical -> RGBA mapping."""
1054+
1055+
@staticmethod
1056+
def _params() -> CmapParams:
1057+
from matplotlib import colormaps
1058+
from matplotlib.colors import Normalize
1059+
1060+
# transparent-black na so the na rows are an unambiguous (0, 0, 0, 0)
1061+
return CmapParams(cmap=colormaps["viridis"], norm=Normalize(0.0, 10.0, clip=False), na_color=Color("#00000000"))
1062+
1063+
def test_continuous_maps_finite_via_norm_and_nan_to_na(self):
1064+
from spatialdata_plot.pl._color import ColorSpec
1065+
1066+
p = self._params()
1067+
rgba = ColorSpec("continuous", None, np.array([0.0, 10.0, np.nan])).to_rgba(p)
1068+
np.testing.assert_allclose(rgba[0], p.cmap(0.0))
1069+
np.testing.assert_allclose(rgba[1], p.cmap(1.0))
1070+
np.testing.assert_allclose(rgba[2], (0.0, 0.0, 0.0, 0.0))
1071+
1072+
def test_categorical_hex_to_rgba(self):
1073+
from spatialdata_plot.pl._color import ColorSpec
1074+
1075+
spec = ColorSpec("categorical", pd.Categorical(["a", "b"]), np.array(["#ff0000ff", "#00ff00ff"], dtype=object))
1076+
np.testing.assert_allclose(spec.to_rgba(self._params()), [[1, 0, 0, 1], [0, 1, 0, 1]])
1077+
1078+
def test_object_vector_mixes_numeric_and_colorlike(self):
1079+
from spatialdata_plot.pl._color import ColorSpec
1080+
1081+
p = self._params()
1082+
rgba = ColorSpec("continuous", None, np.array([0.0, "#ff0000ff", np.nan], dtype=object)).to_rgba(p)
1083+
np.testing.assert_allclose(rgba[0], p.cmap(0.0))
1084+
np.testing.assert_allclose(rgba[1], (1.0, 0.0, 0.0, 1.0))
1085+
np.testing.assert_allclose(rgba[2], (0.0, 0.0, 0.0, 0.0))
1086+
1087+
def test_precomputed_rgba_passthrough(self):
1088+
from spatialdata_plot.pl._color import ColorSpec
1089+
1090+
arr = np.array([[1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]])
1091+
np.testing.assert_allclose(ColorSpec("continuous", None, arr).to_rgba(self._params()), arr)

0 commit comments

Comments
 (0)