Skip to content

Commit d3cce20

Browse files
committed
fix(datashader): route none-state shapes through the categorical color-key (#700)
Review caught a regression from the earlier is_categorical switch: shapes datashader keyed color_by_categorical on is_categorical, dropping the none state (all-NaN color column) into the numeric reduction → ValueError: axes don't match array. Restore the old truth set (categorical OR none when a color column is set) via `col_for_color is not None and not is_continuous`; add a datashader regression test. (Points already kept the none state.) Cleanups from the same review: - labels as_points: classify the fresh point-color spec by its own source vector instead of reusing the parent colortype (was constructing invariant-violating none/categorical specs with source=None). - _get_collection_shape: pass the already-(N,4) RGBA fill through instead of a redundant to_rgba_array round-trip (dropped a full-N copy on the hot path). - to_rgba: compute na_rgba only on the numeric branch that uses it. - collapse with_color_vector/with_source_vector into one evolve(**changes).
1 parent ec78a2d commit d3cce20

4 files changed

Lines changed: 34 additions & 28 deletions

File tree

‎src/spatialdata_plot/pl/_color.py‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -702,13 +702,9 @@ def apply_transfunc(self, transfunc: Any) -> ColorSpec:
702702
return replace(self, color_vector=transfunc(self.color_vector))
703703
return self
704704

705-
def with_color_vector(self, color_vector: ArrayLike) -> ColorSpec:
706-
"""Return a copy with a replaced ``color_vector`` (a post-resolution rewrite: reprocess, compute)."""
707-
return replace(self, color_vector=color_vector)
708-
709-
def with_source_vector(self, source_vector: ArrayLike | pd.Series | None) -> ColorSpec:
710-
"""Return a copy with a replaced ``source_vector`` (e.g. ``remove_unused_categories``, ``compute``)."""
711-
return replace(self, source_vector=source_vector)
705+
def evolve(self, **changes: Any) -> ColorSpec:
706+
"""Return a copy with ``source_vector``/``color_vector`` replaced (post-resolution rewrites)."""
707+
return replace(self, **changes)
712708

713709
def make_palette(self) -> ListedColormap:
714710
"""Build a ``ListedColormap`` from the colors, dropping NaN categories when categorical."""
@@ -734,14 +730,13 @@ def to_rgba(self, cmap_params: CmapParams) -> np.ndarray:
734730
Categorical/none ``color_vector`` is per-row hex -> straight to RGBA; continuous numerics map
735731
via norm+cmap with NaN/non-finite rows painted ``na_color``; an object vector mixes the two.
736732
"""
737-
na_rgba = colors.to_rgba(cmap_params.na_color.get_hex_with_alpha())
738733
if self.source_vector is not None: # categorical or none: color_vector holds per-row hex
739734
return np.asarray(ColorConverter().to_rgba_array(list(self.color_vector)))
740735
arr = np.asarray(self.color_vector)
741736
if arr.ndim == 2 and arr.shape[1] in (3, 4) and np.issubdtype(arr.dtype, np.number):
742737
return np.asarray(ColorConverter().to_rgba_array(arr))
743738
rgba = np.empty((len(arr), 4), dtype=float)
744-
rgba[:] = na_rgba
739+
rgba[:] = colors.to_rgba(cmap_params.na_color.get_hex_with_alpha())
745740
if np.issubdtype(arr.dtype, np.number):
746741
finite = np.isfinite(arr)
747742
if finite.any():

‎src/spatialdata_plot/pl/_geometry.py‎

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,12 @@ def _get_collection_shape(
180180
color / list of color specs (broadcast). ``outline_color`` may be an ``(N, 4)`` float RGBA array,
181181
whose alpha channel is mutated in place to apply ``outline_alpha`` (pass a copy to retain it).
182182
"""
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).
183+
# Per-row fill is pre-mapped to RGBA by ``ColorSpec.to_rgba`` (an (N, 4) float array, used
184+
# as-is); otherwise ``c`` is a single color / list of color specs (e.g. the white outline
185+
# placeholder), which ``to_rgba_array`` broadcasts.
185186
c_arr = np.asarray(c)
186-
if (
187-
c_arr.ndim == 2
188-
and c_arr.shape[0] == len(shapes)
189-
and c_arr.shape[1] in (3, 4)
190-
and np.issubdtype(c_arr.dtype, np.number)
191-
):
192-
fill_c = np.asarray(ColorConverter().to_rgba_array(c_arr))
187+
if c_arr.shape == (len(shapes), 4) and np.issubdtype(c_arr.dtype, np.floating):
188+
fill_c = c_arr
193189
else:
194190
fill_c = np.asarray(ColorConverter().to_rgba_array(c))
195191

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ def _render_shapes(
684684
norm = render_params.cmap_params.fresh_norm()
685685

686686
if len(color_spec.color_vector) == 0:
687-
color_spec = color_spec.with_color_vector([render_params.cmap_params.na_color.get_hex_with_alpha()])
687+
color_spec = color_spec.evolve(color_vector=[render_params.cmap_params.na_color.get_hex_with_alpha()])
688688

689689
# continuous case: leave NaNs as NaNs; utils maps them to na_color during draw
690690
if color_spec.is_continuous:
@@ -708,7 +708,7 @@ def _render_shapes(
708708
f"Found {nan_count} NaN values in color data. "
709709
"These observations will be colored with the 'na_color'."
710710
)
711-
color_spec = color_spec.with_color_vector(cv)
711+
color_spec = color_spec.evolve(color_vector=cv)
712712

713713
palette = color_spec.make_palette()
714714

@@ -718,7 +718,7 @@ def _render_shapes(
718718
)
719719
if has_valid_color and color_spec.is_categorical and col_for_color is not None:
720720
# necessary in case different shapes elements are annotated with one table
721-
color_spec = color_spec.with_source_vector(color_spec.source_vector.remove_unused_categories())
721+
color_spec = color_spec.evolve(source_vector=color_spec.source_vector.remove_unused_categories())
722722

723723
shapes = gpd.GeoDataFrame(shapes, geometry="geometry")
724724

@@ -816,7 +816,7 @@ def _render_shapes(
816816

817817
cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext)
818818

819-
# datashader consumes raw arrays; unpack the carrier for this leaf and re-wrap its result
819+
# datashader leaf consumes raw arrays; re-wrapped into the carrier after aggregation
820820
color_vector = color_spec.color_vector
821821
color_source_vector = color_spec.source_vector
822822

@@ -839,8 +839,9 @@ def _render_shapes(
839839
color_vector = list(color_vector) + [na_color] * (len(transformed_element) - len(color_vector))
840840

841841
transformed_element[col_for_color] = color_vector if color_source_vector is None else color_source_vector
842-
# Render shapes with datashader
843-
color_by_categorical = color_spec.is_categorical
842+
# Render shapes with datashader: categorical AND none (na-array) go through the categorical
843+
# color-key path; only continuous uses the numeric reduction.
844+
color_by_categorical = col_for_color is not None and not color_spec.is_continuous
844845
if color_by_categorical:
845846
cat_series = transformed_element[col_for_color]
846847
if not isinstance(cat_series.dtype, pd.CategoricalDtype):
@@ -861,7 +862,7 @@ def _render_shapes(
861862
default_reduction=_default_reduction,
862863
kind="shapes",
863864
)
864-
color_spec = color_spec.with_color_vector(color_vector)
865+
color_spec = color_spec.evolve(color_vector=color_vector)
865866

866867
_render_ds_outlines(
867868
cvs,
@@ -1106,7 +1107,7 @@ def _render_centroids_as_points(
11061107
as_markers=True,
11071108
axes_extent=axes_extent,
11081109
)
1109-
color_spec = color_spec.with_source_vector(csv).with_color_vector(cv)
1110+
color_spec = color_spec.evolve(source_vector=csv, color_vector=cv)
11101111
else:
11111112
cax = _scatter_points(
11121113
ax,
@@ -1465,7 +1466,7 @@ def _render_points(
14651466
fig_params=fig_params,
14661467
default_reduction=_default_reduction,
14671468
)
1468-
color_spec = color_spec.with_source_vector(csv).with_color_vector(cv)
1469+
color_spec = color_spec.evolve(source_vector=csv, color_vector=cv)
14691470

14701471
elif method == "matplotlib":
14711472
# update axis limits if plot was empty before (necessary if datashader comes after)
@@ -2319,7 +2320,12 @@ def _render_labels(
23192320
render_params,
23202321
x=xy[:, 0],
23212322
y=xy[:, 1],
2322-
color_spec=ColorSpec(color_spec.colortype, point_color_source_vector, point_color_vector),
2323+
# point colours are derived fresh; classify by source so the spec stays self-consistent
2324+
color_spec=ColorSpec(
2325+
"categorical" if point_color_source_vector is not None else "continuous",
2326+
point_color_source_vector,
2327+
point_color_vector,
2328+
),
23232329
norm=render_params.cmap_params.fresh_norm(), # ax.scatter autoscales in place; don't mutate the shared norm
23242330
na_color=na_color,
23252331
adata=table if table_name is not None else None,

‎tests/pl/test_render_shapes.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,15 @@ def test_render_shapes_all_nan_outline_color_does_not_crash(sdata_blobs_shapes_a
11321132
plt.close(fig)
11331133

11341134

1135+
def test_render_shapes_all_nan_color_datashader_does_not_crash(sdata_blobs_shapes_annotated: SpatialData):
1136+
# Regression: all-NaN color is the "none" colortype; the datashader path must route it through
1137+
# the categorical color-key (na_color), not the numeric reduction (which rejects the na column).
1138+
sdata_blobs_shapes_annotated["blobs_polygons"]["nanvals"] = [np.nan] * 5
1139+
fig, ax = plt.subplots()
1140+
sdata_blobs_shapes_annotated.pl.render_shapes("blobs_polygons", color="nanvals", method="datashader").pl.show(ax=ax)
1141+
plt.close(fig)
1142+
1143+
11351144
def test_render_shapes_all_nan_color_with_groups_does_not_crash(sdata_blobs_shapes_annotated: SpatialData):
11361145
# Regression: all-NaN color is the "none" colortype (na-array source, not categorical); the
11371146
# groups preamble must not feed it to _warn_missing_groups, which reads `.categories`.

0 commit comments

Comments
 (0)