Skip to content

Commit 2490e71

Browse files
committed
refactor(color): extend ColorSpec across outline + restore degenerate norm (#700)
Thread the outline color through ColorSpec the same way fill already is, so the outline path no longer reassigns two loose vectors in lockstep: - add ColorSpec.align_to_length (colortype-aware) + reuse .filter, dropping the free helpers _align_outline_vector_to_length / _apply_mask_to_outline_vectors - shapes + labels keep one outline_color_spec and unpack once before drawing - categorical pads now carry the na_color hex, not NaN, fixing a crash when a cross-table categorical outline under-annotates the element (to_rgba_array(nan) -> Invalid RGBA argument) - @DataClass(frozen=True, eq=False): array fields make the autogenerated __eq__/__hash__ ambiguous - drop dead with_color_vector Fix the remaining none-colortype crashes the typed state exposes: the groups preamble for shapes + points guarded on `source_vector is not None` (true for the na-array none-state) and called .isin on a plain ndarray; guard on is_categorical to match labels. Restore the degenerate-range norm reset: a constant continuous column (vmin == vmax) now falls back to [0, 1] instead of the colormap floor; LogNorm is exempt (0 is out of its domain). Regression tests: align_to_length pad/truncate, LogNorm subclass preservation, degenerate reset, and render-level no-crash for all-NaN color across labels/shapes/points.
1 parent 11e78c5 commit 2490e71

6 files changed

Lines changed: 144 additions & 124 deletions

File tree

‎src/spatialdata_plot/pl/_color.py‎

Lines changed: 30 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Colormap,
2222
LinearSegmentedColormap,
2323
ListedColormap,
24+
LogNorm,
2425
Normalize,
2526
to_rgba,
2627
)
@@ -130,60 +131,16 @@ def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
130131
vmin = data_min
131132
if vmax is None:
132133
vmax = data_max
134+
if vmin == vmax and not isinstance(base, LogNorm):
135+
# A constant/degenerate range collapses the colormap onto its floor; fall back to the
136+
# unit interval so the single value maps to a stable color. LogNorm is exempt: 0 is
137+
# out of its domain.
138+
vmin, vmax = 0.0, 1.0
133139
resolved = copy(base)
134140
resolved.vmin, resolved.vmax = vmin, vmax
135141
return resolved
136142

137143

138-
def _apply_mask_to_outline_vectors(
139-
outline_color_vector: Any,
140-
outline_color_source_vector: pd.Series | None,
141-
mask: Any,
142-
) -> tuple[Any, pd.Series | None]:
143-
"""Apply a boolean ``keep`` mask to outline color vector(s).
144-
145-
Used to keep outline data aligned with the fill data after a ``groups``
146-
or rasterize-based filter is applied to the rendered element.
147-
"""
148-
arr = np.asarray(mask)
149-
if outline_color_source_vector is not None:
150-
outline_color_source_vector = outline_color_source_vector[arr]
151-
return outline_color_vector[arr], outline_color_source_vector
152-
153-
154-
def _align_outline_vector_to_length(
155-
outline_color_vector: Any,
156-
outline_color_source_vector: pd.Series | None,
157-
n: int,
158-
) -> tuple[Any, pd.Series | None]:
159-
"""Pad or truncate the outline color vector(s) to length ``n``.
160-
161-
Used when the outline column annotates a different row count than the rendered
162-
element (cross-table case, or rasterize-induced label drop). Missing entries
163-
are padded with NaN so downstream code maps them to ``na_color``.
164-
"""
165-
if outline_color_vector is None or len(outline_color_vector) == n:
166-
return outline_color_vector, outline_color_source_vector
167-
if len(outline_color_vector) > n:
168-
if outline_color_source_vector is not None:
169-
outline_color_source_vector = outline_color_source_vector[:n]
170-
return outline_color_vector[:n], outline_color_source_vector
171-
pad = n - len(outline_color_vector)
172-
if outline_color_source_vector is not None:
173-
# Categorical: downstream picks one hex per category from rows that *have* a
174-
# category. NaN-padded rows contribute no category, so the per-row hex pad is
175-
# immaterial; pad with NaN to skip the allocation.
176-
padded_vec = np.concatenate([np.asarray(outline_color_vector), np.full(pad, np.nan, dtype=object)])
177-
outline_color_source_vector = pd.Categorical(
178-
list(outline_color_source_vector) + [None] * pad,
179-
categories=outline_color_source_vector.categories,
180-
)
181-
else:
182-
# Continuous: numeric vector, pad with NaN so cmap maps padded rows to na_color.
183-
padded_vec = np.concatenate([np.asarray(outline_color_vector, dtype=float), np.full(pad, np.nan)])
184-
return padded_vec, outline_color_source_vector
185-
186-
187144
def _color_vector_to_rgba(
188145
color_vector: Any | None,
189146
color_source_vector: pd.Series | None,
@@ -742,7 +699,7 @@ def _set_color_source_vec(
742699
ColorType = Literal["categorical", "continuous", "none"]
743700

744701

745-
@dataclass(frozen=True)
702+
@dataclass(frozen=True, eq=False)
746703
class ColorSpec:
747704
"""Resolved color for one element layer: the explicit color-state the renderers consume.
748705
@@ -793,13 +750,32 @@ def apply_transfunc(self, transfunc: Any) -> ColorSpec:
793750
return replace(self, color_vector=transfunc(self.color_vector))
794751
return self
795752

796-
def with_color_vector(self, color_vector: ArrayLike) -> ColorSpec:
797-
"""Return a copy with a replaced ``color_vector`` (renderer-specific rewrites: broadcast, compute)."""
798-
return replace(self, color_vector=color_vector)
753+
def align_to_length(self, n: int, na_color: Color) -> ColorSpec:
754+
"""Pad or truncate both vectors to ``n`` rows (cross-table / rasterize outline alignment).
755+
756+
Padded rows render as ``na_color``: a categorical (hex) ``color_vector`` pads with the na_color
757+
hex — NaN would crash ``to_rgba_array`` — and ``source_vector`` with an absent category; a
758+
continuous vector pads with NaN so the cmap maps it to na_color.
759+
"""
760+
vec = self.color_vector
761+
if vec is None or len(vec) == n:
762+
return self
763+
if len(vec) > n:
764+
source = None if self.source_vector is None else self.source_vector[:n]
765+
return replace(self, source_vector=source, color_vector=np.asarray(vec)[:n])
766+
pad = n - len(vec)
767+
if self.source_vector is not None:
768+
padded = np.concatenate(
769+
[np.asarray(vec, dtype=object), np.full(pad, na_color.get_hex_with_alpha(), dtype=object)]
770+
)
771+
source = pd.Categorical(list(self.source_vector) + [None] * pad, categories=self.source_vector.categories)
772+
return replace(self, source_vector=source, color_vector=padded)
773+
padded = np.concatenate([np.asarray(vec, dtype=float), np.full(pad, np.nan)])
774+
return replace(self, color_vector=padded)
799775

800776

801777
def resolve_color(*args: Any, **kwargs: Any) -> ColorSpec:
802-
"""Resolve an element's color into a typed :class:`ColorSpec` (the #700 IR's color layer).
778+
"""Resolve an element's color into a typed :class:`ColorSpec`.
803779
804780
Pass-through wrapper over :func:`_set_color_source_vec` that classifies its result into an
805781
explicit ``colortype`` so callers stop re-deriving the state from ``source is None``/``categorical``.

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 25 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,8 @@
3333

3434
from spatialdata_plot._logging import _log_context, logger
3535
from spatialdata_plot.pl._color import (
36+
ColorSpec,
3637
ColorType,
37-
_align_outline_vector_to_length,
38-
_apply_mask_to_outline_vectors,
3938
_color_vector_to_rgba,
4039
_get_colors_for_categorical_obs,
4140
_get_linear_colormap,
@@ -646,8 +645,7 @@ def _render_shapes(
646645

647646
col_for_outline_color = render_params.col_for_outline_color
648647
outline_table_name = render_params.outline_table_name
649-
outline_color_source_vector: pd.Series | None = None
650-
outline_color_vector: Any = None
648+
outline_color_spec: ColorSpec | None = None
651649
if col_for_outline_color is not None:
652650
# When the outline column lives in a table that hasn't been joined yet
653651
# (no fill table, or a different table than fill's), left-join it onto
@@ -675,51 +673,41 @@ def _render_shapes(
675673
table_layer=table_layer,
676674
coordinate_system=coordinate_system,
677675
)
678-
outline_color_source_vector, outline_color_vector = (
679-
outline_color_spec.source_vector,
680-
outline_color_spec.color_vector,
681-
)
682676
# Cross-table case: if fill and outline tables differ and the outline table does
683677
# not annotate every row of the (fill-joined) element, the vector length will
684678
# differ from the rendered element row count. Warn + align so per-shape lookup stays
685679
# well-defined.
686680
_n_shapes = len(sdata_filt[element])
687-
if outline_color_vector is not None and len(outline_color_vector) != _n_shapes:
681+
if len(outline_color_spec.color_vector) != _n_shapes:
688682
logger.warning(
689683
f"Outline column '{col_for_outline_color}' does not fully annotate "
690684
f"element '{element}' under its fill-joined alignment "
691-
f"({len(outline_color_vector)} of {_n_shapes} rows). Missing rows will use na_color."
692-
)
693-
outline_color_vector, outline_color_source_vector = _align_outline_vector_to_length(
694-
outline_color_vector,
695-
outline_color_source_vector,
696-
_n_shapes,
685+
f"({len(outline_color_spec.color_vector)} of {_n_shapes} rows). Missing rows will use na_color."
697686
)
687+
outline_color_spec = outline_color_spec.align_to_length(_n_shapes, render_params.cmap_params.na_color)
698688

699689
_warn_groups(groups, colortype, color_spec.source_vector, col_for_color)
700690

701691
# When groups are specified, filter out non-matching elements by default.
702692
# Only show non-matching elements if the user explicitly sets na_color.
703693
_na = render_params.cmap_params.na_color
704-
if (
705-
groups is not None
706-
and color_spec.source_vector is not None
707-
and (_na.default_color_set or _na.is_fully_transparent())
708-
):
694+
if groups is not None and color_spec.is_categorical and (_na.default_color_set or _na.is_fully_transparent()):
709695
keep = color_spec.source_vector.isin(groups)
710696
color_spec = color_spec.filter(keep)
711697
shapes = shapes[keep].reset_index(drop=True)
712698
if len(shapes) == 0:
713699
return
714700
sdata_filt[element] = shapes
715-
if outline_color_vector is not None:
716-
outline_color_vector, outline_color_source_vector = _apply_mask_to_outline_vectors(
717-
outline_color_vector, outline_color_source_vector, keep
718-
)
701+
if outline_color_spec is not None:
702+
outline_color_spec = outline_color_spec.filter(keep)
719703

720-
# carrier pipeline done; unpack the final vectors for the read/draw phase below
721704
color_spec = color_spec.apply_transfunc(render_params.transfunc)
722705
color_source_vector, color_vector = color_spec.source_vector, color_spec.color_vector
706+
outline_color_source_vector, outline_color_vector = (
707+
(outline_color_spec.source_vector, outline_color_spec.color_vector)
708+
if outline_color_spec is not None
709+
else (None, None)
710+
)
723711

724712
norm = render_params.cmap_params.fresh_norm()
725713

@@ -1454,11 +1442,7 @@ def _render_points(
14541442
# When groups are specified, filter out non-matching elements by default.
14551443
# Only show non-matching elements if the user explicitly sets na_color.
14561444
_na = render_params.cmap_params.na_color
1457-
if (
1458-
groups is not None
1459-
and color_spec.source_vector is not None
1460-
and (_na.default_color_set or _na.is_fully_transparent())
1461-
):
1445+
if groups is not None and color_spec.is_categorical and (_na.default_color_set or _na.is_fully_transparent()):
14621446
keep = color_spec.source_vector.isin(groups)
14631447
color_spec = color_spec.filter(keep)
14641448
n_points = int(keep.sum())
@@ -1469,7 +1453,6 @@ def _render_points(
14691453
adata = adata[keep]
14701454
_reparse_points(sdata_filt, element, points, transformation_in_cs, coordinate_system, col_for_color)
14711455

1472-
# carrier pipeline done; unpack the final vectors for the read/draw phase below
14731456
color_spec = color_spec.apply_transfunc(render_params.transfunc)
14741457
color_source_vector, color_vector = color_spec.source_vector, color_spec.color_vector
14751458

@@ -2284,8 +2267,7 @@ def _render_labels(
22842267
# to the outline vectors to keep lengths consistent.
22852268
col_for_outline_color = render_params.col_for_outline_color
22862269
outline_table_name = render_params.outline_table_name
2287-
outline_color_source_vector: pd.Series | None = None
2288-
outline_color_vector: Any = None
2270+
outline_color_spec: ColorSpec | None = None
22892271
if col_for_outline_color is not None:
22902272
outline_color_spec = resolve_color(
22912273
sdata=sdata_filt,
@@ -2301,18 +2283,10 @@ def _render_labels(
23012283
render_type="labels",
23022284
coordinate_system=coordinate_system,
23032285
)
2304-
outline_color_source_vector, outline_color_vector = (
2305-
outline_color_spec.source_vector,
2306-
outline_color_spec.color_vector,
2307-
)
23082286
# Align to instance_id so the rasterize/groups masks (computed against
23092287
# instance_id) can be applied without IndexError when the outline table
23102288
# annotates a subset of the labels.
2311-
outline_color_vector, outline_color_source_vector = _align_outline_vector_to_length(
2312-
outline_color_vector,
2313-
outline_color_source_vector,
2314-
len(instance_id),
2315-
)
2289+
outline_color_spec = outline_color_spec.align_to_length(len(instance_id), render_params.cmap_params.na_color)
23162290

23172291
# rasterize/downsampling can drop labels from the raster; remove their (now-absent) instance ids
23182292
# so per-instance colors stay aligned and as_points does not emit dots for dropped cells.
@@ -2321,10 +2295,8 @@ def _render_labels(
23212295
instance_id = instance_id[mask]
23222296
if col_for_color is not None:
23232297
color_spec = color_spec.filter(mask)
2324-
if outline_color_vector is not None:
2325-
outline_color_vector, outline_color_source_vector = _apply_mask_to_outline_vectors(
2326-
outline_color_vector, outline_color_source_vector, mask
2327-
)
2298+
if outline_color_spec is not None:
2299+
outline_color_spec = outline_color_spec.filter(mask)
23282300

23292301
_warn_groups(groups, colortype, color_spec.source_vector, col_for_color)
23302302

@@ -2344,14 +2316,16 @@ def _render_labels(
23442316
label.values[~keep_mask] = 0
23452317
instance_id = instance_id[keep_vec]
23462318
color_spec = color_spec.filter(keep_vec)
2347-
if outline_color_vector is not None:
2348-
outline_color_vector, outline_color_source_vector = _apply_mask_to_outline_vectors(
2349-
outline_color_vector, outline_color_source_vector, keep_vec
2350-
)
2319+
if outline_color_spec is not None:
2320+
outline_color_spec = outline_color_spec.filter(keep_vec)
23512321

2352-
# carrier pipeline done; unpack the final vectors for the read/draw phase below
23532322
color_spec = color_spec.apply_transfunc(render_params.transfunc)
23542323
color_source_vector, color_vector = color_spec.source_vector, color_spec.color_vector
2324+
outline_color_source_vector, outline_color_vector = (
2325+
(outline_color_spec.source_vector, outline_color_spec.color_vector)
2326+
if outline_color_spec is not None
2327+
else (None, None)
2328+
)
23552329

23562330
if render_params.as_points:
23572331
# Fast mode: one dot per label at its centroid. Compute on the *rendered* raster (already

‎tests/pl/test_render_labels.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,25 @@ def track(x):
521521
assert called, "transfunc was not called for continuous labels data"
522522

523523

524+
def test_render_labels_all_nan_color_renders_under_rasterize(sdata_blobs: SpatialData):
525+
# Regression: an all-NaN color column is the "none" colortype (na-array source, not None).
526+
# Under default rasterize the per-instance mask path used to trip an
527+
# `assert color_source_vector is None`; it must now just render.
528+
labels_name = "blobs_labels"
529+
instances = get_element_instances(sdata_blobs[labels_name])
530+
n_obs = len(instances)
531+
adata = AnnData(np.zeros((n_obs, 1)))
532+
adata.obs["instance_id"] = instances.values
533+
adata.obs["nanvals"] = np.full(n_obs, np.nan)
534+
adata.obs["region"] = labels_name
535+
sdata_blobs["label_table"] = TableModel.parse(
536+
adata=adata, region_key="region", instance_key="instance_id", region=labels_name
537+
)
538+
fig, ax = plt.subplots()
539+
sdata_blobs.pl.render_labels(labels_name, color="nanvals", table_name="label_table").pl.show(ax=ax)
540+
plt.close(fig)
541+
542+
524543
@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64])
525544
def test_render_labels_rejects_float_dtype(dtype):
526545
# Regression test for #606: float-dtype labels must raise a clear

‎tests/pl/test_render_points.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,18 @@ def test_render_points_color_by_z_data_column():
10291029
plt.close(fig)
10301030

10311031

1032+
def test_render_points_all_nan_color_with_groups_does_not_crash():
1033+
# Regression: all-NaN color is the "none" colortype; the groups preamble must not call
1034+
# .isin on the na-array source (same none-state class as the labels/shapes crashes).
1035+
pts = PointsModel.parse(pd.DataFrame({"x": [1.0, 2.0, 3.0], "y": [1.0, 2.0, 3.0], "nanvals": [np.nan] * 3}))
1036+
sdata = SpatialData(points={"p": pts})
1037+
fig, ax = plt.subplots()
1038+
try:
1039+
sdata.pl.render_points("p", color="nanvals", groups=["a"]).pl.show(ax=ax)
1040+
finally:
1041+
plt.close(fig)
1042+
1043+
10321044
def test_render_points_color_by_z_with_extra_columns():
10331045
# regression test for #615
10341046
pts = PointsModel.parse(

‎tests/pl/test_render_shapes.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1121,6 +1121,15 @@ def test_plot_shapes_as_points_datashader(self, sdata_blobs: SpatialData):
11211121
self._as_points(sdata_blobs, "datashader").pl.show()
11221122

11231123

1124+
def test_render_shapes_all_nan_color_with_groups_does_not_crash(sdata_blobs_shapes_annotated: SpatialData):
1125+
# Regression: all-NaN color is the "none" colortype (na-array source, not categorical); the
1126+
# groups preamble must not feed it to _warn_missing_groups, which reads `.categories`.
1127+
sdata_blobs_shapes_annotated["blobs_polygons"]["nanvals"] = [np.nan] * 5
1128+
fig, ax = plt.subplots()
1129+
sdata_blobs_shapes_annotated.pl.render_shapes("blobs_polygons", color="nanvals", groups=["a"]).pl.show(ax=ax)
1130+
plt.close(fig)
1131+
1132+
11241133
def test_gene_symbols_auto_detect_table(sdata_blobs: SpatialData):
11251134
"""gene_symbols resolves correctly without explicit table_name (#247)."""
11261135
sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs)

0 commit comments

Comments
 (0)