Skip to content

Commit 6adef54

Browse files
committed
refactor(render): drive groups + warnings off ColorSpec predicates (#700)
Replace the loose `colortype` string + `color_source_vector` passing with the spec itself: _warn_groups / _warn_groups_ignored_continuous / _reject_continuous_color_under_density now take a ColorSpec and read is_continuous / is_categorical, retiring the `colortype == "..."` compares. Centralize the 3x groups-filter guard into ColorSpec.groups_keep_mask(groups, na_color) — one place defines when the transparent-na groups filter applies; the renderers keep only their element-specific masking. Fix the datashader-shapes none-state misclassification: `color_by_categorical` keyed on `color_source_vector is not None` (true for the na-array none state) now uses is_categorical. Drops the redundant `categorical` local in labels.
1 parent 18bcb96 commit 6adef54

3 files changed

Lines changed: 47 additions & 44 deletions

File tree

‎src/spatialdata_plot/pl/_color.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,18 @@ def apply_transfunc(self, transfunc: Any) -> ColorSpec:
698698
return replace(self, color_vector=transfunc(self.color_vector))
699699
return self
700700

701+
def groups_keep_mask(self, groups: Any, na_color: Color) -> Any | None:
702+
"""Rows whose category is in ``groups``, or None when the groups filter does not apply.
703+
704+
Does not apply when there is no ``groups``, the layer is not categorical, or ``na_color`` is a
705+
visible override (which shows non-matching rows rather than dropping them).
706+
"""
707+
if groups is None or not self.is_categorical:
708+
return None
709+
if not (na_color.default_color_set or na_color.is_fully_transparent()):
710+
return None
711+
return self.source_vector.isin(groups)
712+
701713
def to_rgba(self, cmap_params: CmapParams) -> np.ndarray:
702714
"""Map this layer's color to an ``(N, 4)`` RGBA array; the one fill+outline mapping.
703715

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 30 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,11 @@ def _reparse_points(
180180

181181
def _warn_groups_ignored_continuous(
182182
groups: str | list[str] | None,
183-
colortype: ColorType,
183+
color_spec: ColorSpec,
184184
col_for_color: str | None,
185185
) -> None:
186186
"""Warn when ``groups`` is set but coloring is continuous (no categorical source)."""
187-
if groups is not None and colortype == "continuous" and col_for_color is not None:
187+
if groups is not None and color_spec.is_continuous and col_for_color is not None:
188188
logger.warning(
189189
f"`groups` is ignored when coloring by continuous column '{col_for_color}'. "
190190
"`groups` filters categories of the column specified via `color`; "
@@ -204,22 +204,21 @@ def _reject_continuous_color_under_density(
204204
sdata_filt: sd.SpatialData,
205205
element: str,
206206
col_for_color: str | None,
207-
colortype: ColorType,
208-
color_vector: Any,
207+
color_spec: ColorSpec,
209208
) -> None:
210209
"""Raise before any materialization if density+continuous-color was requested.
211210
212-
Only ``continuous`` coloring is rejected; categorical and none are fine. When the colortype
213-
is not yet decisive we read the dtype from the dask source (points element column) or the
214-
pre-computed color vector — neither forces a ``.compute()``.
211+
Only ``continuous`` coloring is rejected; categorical and none are fine. We read the dtype from
212+
the dask source (points element column) or the pre-computed color vector — neither forces a
213+
``.compute()``.
215214
"""
216-
if col_for_color is None or colortype != "continuous":
215+
if col_for_color is None or not color_spec.is_continuous:
217216
return
218217
points_columns = sdata_filt.points[element].columns
219218
if col_for_color in points_columns:
220219
dtype = sdata_filt.points[element][col_for_color].dtype
221220
else:
222-
dtype = getattr(color_vector, "dtype", None)
221+
dtype = getattr(color_spec.color_vector, "dtype", None)
223222
if dtype is None or _is_categorical_like_dtype(dtype):
224223
return
225224
raise ValueError(
@@ -262,15 +261,14 @@ def _warn_missing_groups(
262261

263262
def _warn_groups(
264263
groups: str | list[str] | None,
265-
colortype: ColorType,
266-
color_source_vector: pd.Categorical | None,
264+
color_spec: ColorSpec,
267265
col_for_color: str | None,
268266
) -> None:
269267
"""Emit the two groups-related warnings every ``_render_*`` preamble shares."""
270-
_warn_groups_ignored_continuous(groups, colortype, col_for_color)
268+
_warn_groups_ignored_continuous(groups, color_spec, col_for_color)
271269
# only a categorical source has `.categories`; the none state (na-array source) must not reach it
272-
if groups is not None and colortype == "categorical":
273-
_warn_missing_groups(groups, color_source_vector, col_for_color)
270+
if groups is not None and color_spec.is_categorical:
271+
_warn_missing_groups(groups, color_spec.source_vector, col_for_color)
274272

275273

276274
def _split_colorbar_params(
@@ -684,13 +682,11 @@ def _render_shapes(
684682
)
685683
outline_color_spec = outline_color_spec.align_to_length(_n_shapes, render_params.cmap_params.na_color)
686684

687-
_warn_groups(groups, colortype, color_spec.source_vector, col_for_color)
685+
_warn_groups(groups, color_spec, col_for_color)
688686

689-
# When groups are specified, filter out non-matching elements by default.
690-
# Only show non-matching elements if the user explicitly sets na_color.
691-
_na = render_params.cmap_params.na_color
692-
if groups is not None and color_spec.is_categorical and (_na.default_color_set or _na.is_fully_transparent()):
693-
keep = color_spec.source_vector.isin(groups)
687+
# groups filters to matching categories by default; a visible na_color keeps non-matching rows.
688+
keep = color_spec.groups_keep_mask(groups, render_params.cmap_params.na_color)
689+
if keep is not None:
694690
color_spec = color_spec.filter(keep)
695691
shapes = shapes[keep].reset_index(drop=True)
696692
if len(shapes) == 0:
@@ -862,7 +858,7 @@ def _render_shapes(
862858

863859
transformed_element[col_for_color] = color_vector if color_source_vector is None else color_source_vector
864860
# Render shapes with datashader
865-
color_by_categorical = col_for_color is not None and color_source_vector is not None
861+
color_by_categorical = color_spec.is_categorical
866862
if color_by_categorical:
867863
cat_series = transformed_element[col_for_color]
868864
if not isinstance(cat_series.dtype, pd.CategoricalDtype):
@@ -1430,16 +1426,13 @@ def _render_points(
14301426
col_for_color,
14311427
)
14321428

1433-
_warn_groups(groups, colortype, color_spec.source_vector, col_for_color)
1429+
_warn_groups(groups, color_spec, col_for_color)
14341430

1435-
# When groups are specified, filter out non-matching elements by default.
1436-
# Only show non-matching elements if the user explicitly sets na_color.
1437-
_na = render_params.cmap_params.na_color
1438-
if groups is not None and color_spec.is_categorical and (_na.default_color_set or _na.is_fully_transparent()):
1439-
keep = color_spec.source_vector.isin(groups)
1431+
# groups filters to matching categories by default; a visible na_color keeps non-matching rows.
1432+
keep = color_spec.groups_keep_mask(groups, render_params.cmap_params.na_color)
1433+
if keep is not None:
14401434
color_spec = color_spec.filter(keep)
1441-
n_points = int(keep.sum())
1442-
if n_points == 0:
1435+
if int(keep.sum()) == 0:
14431436
return
14441437
# filter the materialized points, adata, and re-register in sdata_filt
14451438
points = points[keep].reset_index(drop=True)
@@ -1457,7 +1450,7 @@ def _render_points(
14571450

14581451
if render_params.density:
14591452
method = "datashader"
1460-
_reject_continuous_color_under_density(sdata_filt, element, col_for_color, colortype, color_vector)
1453+
_reject_continuous_color_under_density(sdata_filt, element, col_for_color, color_spec)
14611454
elif method is None:
14621455
method = "datashader" if n_points > 10000 else "matplotlib"
14631456

@@ -2253,7 +2246,6 @@ def _render_labels(
22532246
coordinate_system=coordinate_system,
22542247
)
22552248
colortype = color_spec.colortype
2256-
categorical = color_spec.is_categorical
22572249

22582250
# Outline color lookup must run BEFORE any masking so the returned vector aligns to
22592251
# the original instance_id. The same masks applied to fill below are then applied
@@ -2291,18 +2283,12 @@ def _render_labels(
22912283
if outline_color_spec is not None:
22922284
outline_color_spec = outline_color_spec.filter(mask)
22932285

2294-
_warn_groups(groups, colortype, color_spec.source_vector, col_for_color)
2286+
_warn_groups(groups, color_spec, col_for_color)
22952287

22962288
# When groups are specified, zero out non-matching label IDs so they render as background.
22972289
# Only show non-matching labels if the user explicitly sets na_color.
2298-
_na = render_params.cmap_params.na_color
2299-
if (
2300-
groups is not None
2301-
and categorical
2302-
and color_spec.source_vector is not None
2303-
and (_na.default_color_set or _na.is_fully_transparent())
2304-
):
2305-
keep_vec = color_spec.source_vector.isin(groups)
2290+
keep_vec = color_spec.groups_keep_mask(groups, render_params.cmap_params.na_color)
2291+
if keep_vec is not None:
23062292
matching_ids = instance_id[keep_vec]
23072293
keep_mask = np.isin(label.values, matching_ids)
23082294
label = label.copy()
@@ -2400,8 +2386,10 @@ def _draw_labels(
24002386
cax = ax.imshow(
24012387
labels,
24022388
rasterized=True,
2403-
cmap=None if categorical else render_params.cmap_params.cmap,
2404-
norm=None if categorical else _resolve_continuous_norm(color_vector, render_params.cmap_params),
2389+
cmap=None if color_spec.is_categorical else render_params.cmap_params.cmap,
2390+
norm=None
2391+
if color_spec.is_categorical
2392+
else _resolve_continuous_norm(color_vector, render_params.cmap_params),
24052393
alpha=alpha,
24062394
origin="lower",
24072395
zorder=render_params.zorder,

‎tests/pl/test_render_points.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import spatialdata_plot # noqa: F401
2626
from spatialdata_plot._logging import logger, logger_no_warns, logger_warns
27+
from spatialdata_plot.pl._color import ColorSpec
2728
from spatialdata_plot.pl._datashader import (
2829
_build_datashader_color_key,
2930
_ds_aggregate,
@@ -904,14 +905,16 @@ def test_groups_warns_when_continuous_points(sdata_blobs: SpatialData, caplog):
904905

905906
def test_warn_groups_ignored_continuous_emits(caplog):
906907
"""_warn_groups_ignored_continuous emits when groups is set but data is continuous."""
908+
spec = ColorSpec("continuous", None, np.array([1.0, 2.0]))
907909
with logger_warns(caplog, logger, match="ignored.*continuous"):
908-
_warn_groups_ignored_continuous(["A"], "continuous", "my_col")
910+
_warn_groups_ignored_continuous(["A"], spec, "my_col")
909911

910912

911913
def test_warn_groups_ignored_continuous_silent_for_categorical(caplog):
912914
"""No warning when coloring is categorical."""
915+
spec = ColorSpec("categorical", pd.Categorical(["a", "b"]), np.array(["#ff0000ff", "#00ff00ff"], dtype=object))
913916
with logger_no_warns(caplog, logger, match="ignored"):
914-
_warn_groups_ignored_continuous(["A"], "categorical", "cat_col")
917+
_warn_groups_ignored_continuous(["A"], spec, "cat_col")
915918

916919

917920
def test_color_key_warns_on_short_color_vector(caplog):

0 commit comments

Comments
 (0)