Skip to content

Commit dfea23b

Browse files
committed
fix(color): correct LogNorm domain + make continuous colorbars reflect the resolved norm
_resolve_continuous_norm preserved the norm subclass (#722) but still derived vmin/vmax from the full data range, so a LogNorm over data containing 0 or negatives produced LogNorm(vmin<=0) and raised 'Invalid vmin or vmax' on render. Derive the range from strictly-positive finite values for LogNorm (mirrors matplotlib's LogNorm.autoscale_None). Once the subclass is preserved, the continuous colorbar sites must attach the resolved norm, not a default linear Normalize: - shapes fill: set_norm(used_norm) instead of set_clim. - labels: imshow of a pre-baked RGB image cannot carry a non-linear norm, so display the RGB without a norm and build the colorbar from a ScalarMappable(norm=used_norm), mirroring the outline path. as_points now carries the upstream colortype instead of re-deriving it from 'source is not None', so an all-NaN ('none') layer is not mislabelled categorical. Adds regression tests feeding LogNorm zeros/negatives/all-NaN (and invoking the norm) plus a shapes colorbar-subclass assertion.
1 parent b4ad6f7 commit dfea23b

5 files changed

Lines changed: 132 additions & 21 deletions

File tree

src/spatialdata_plot/pl/_color.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,16 @@ def _resolve_continuous_norm(values: Any, cmap_params: CmapParams) -> Normalize:
124124
if not np.issubdtype(arr.dtype, np.number):
125125
arr = pd.to_numeric(arr.ravel(), errors="coerce")
126126
finite = np.isfinite(arr)
127-
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
128-
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
127+
if isinstance(base, LogNorm):
128+
# LogNorm's domain excludes 0/negatives; derive the range from strictly-positive
129+
# finite values only (mirrors matplotlib's LogNorm.autoscale_None). Otherwise a
130+
# data_min <= 0 produces a LogNorm that raises "Invalid vmin or vmax" when called.
131+
positive = arr[finite & (arr > 0)]
132+
data_min = float(np.nanmin(positive)) if positive.size else 1.0
133+
data_max = float(np.nanmax(positive)) if positive.size else 1.0
134+
else:
135+
data_min = float(np.nanmin(arr[finite])) if finite.any() else 0.0
136+
data_max = float(np.nanmax(arr[finite])) if finite.any() else 1.0
129137
if vmin is None:
130138
vmin = data_min
131139
if vmax is None:

src/spatialdata_plot/pl/render.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from spatialdata_plot._logging import _log_context, logger
3535
from spatialdata_plot.pl._color import (
3636
ColorSpec,
37+
ColorType,
3738
_get_colors_for_categorical_obs,
3839
_get_linear_colormap,
3940
_map_color_seg,
@@ -968,9 +969,11 @@ def _render_shapes(
968969
path.vertices = trans.transform(path.vertices)
969970

970971
if color_spec.is_continuous:
971-
# Colorbar range from the same resolved norm the fill pixels use.
972+
# Colorbar uses the same resolved norm the fill pixels use, including its subclass
973+
# (LogNorm/PowerNorm) — set_norm, not set_clim, which would leave the collection's
974+
# default linear Normalize in place and mis-scale the bar for non-linear norms.
972975
used_norm = _resolve_continuous_norm(color_spec.color_vector, render_params.cmap_params)
973-
_cax.set_clim(vmin=used_norm.vmin, vmax=used_norm.vmax)
976+
_cax.set_norm(used_norm)
974977

975978
_add_legend_and_colorbar(
976979
ax=ax,
@@ -2297,25 +2300,29 @@ def _render_labels(
22972300
# (`_map_color_seg` Case C) instead of collapsing every dot to a single na_color.
22982301
point_color_vector = np.random.default_rng(42).random((len(point_ids), 3))
22992302
point_color_source_vector = None
2303+
point_colortype: ColorType = "none" # colour is not data-driven
23002304
allow_datashader = False
23012305
elif len(color_spec.color_vector) == len(instance_id):
2302-
# data-driven colour is per-instance
2306+
# data-driven colour is per-instance; carry the upstream classification (invariant under mask)
23032307
point_color_vector = np.asarray(color_spec.color_vector)[keep]
23042308
point_color_source_vector = None if color_spec.source_vector is None else color_spec.source_vector[keep]
2309+
point_colortype = color_spec.colortype
23052310
else:
23062311
# literal colour / user-set na_color -> one colour per centroid
23072312
point_color_vector = np.full(len(point_ids), na_color.get_hex_with_alpha())
23082313
point_color_source_vector = None
2314+
point_colortype = "none" # colour is not data-driven
23092315
# transform rendered-raster intrinsic centroids to coordinate-system coords
23102316
xy = trans.transform(np.column_stack([centroids["x"].to_numpy(), centroids["y"].to_numpy()]))
23112317
_render_centroids_as_points(
23122318
ax,
23132319
render_params,
23142320
x=xy[:, 0],
23152321
y=xy[:, 1],
2316-
# point colours are derived fresh; classify by source so the spec stays self-consistent
2322+
# point colours are derived fresh; carry the resolved colortype so the spec invariant
2323+
# (categorical => pd.Categorical source) holds and `none` is not mislabelled categorical
23172324
color_spec=ColorSpec(
2318-
"categorical" if point_color_source_vector is not None else "continuous",
2325+
point_colortype,
23192326
point_color_source_vector,
23202327
point_color_vector,
23212328
),
@@ -2352,20 +2359,17 @@ def _draw_labels(
23522359
outline_color_source_vector=outline_color_source_vector if seg_boundaries else None,
23532360
)
23542361

2355-
# labels is pre-baked RGB; cmap/norm only drive the colorbar, so feed the same resolved norm.
2356-
cax = ax.imshow(
2357-
labels,
2358-
rasterized=True,
2359-
cmap=None if color_spec.is_categorical else render_params.cmap_params.cmap,
2360-
norm=None
2361-
if color_spec.is_categorical
2362-
else _resolve_continuous_norm(color_spec.color_vector, render_params.cmap_params),
2363-
alpha=alpha,
2364-
origin="lower",
2365-
zorder=render_params.zorder,
2366-
)
2367-
cax.set_transform(trans_data)
2368-
return cax
2362+
# labels is pre-baked RGB, so imshow ignores cmap/norm for display. Passing the resolved
2363+
# norm to imshow would make it try to normalize the RGBA array — which raises for a
2364+
# non-linear norm (LogNorm/PowerNorm). Display the RGB without a norm and build the
2365+
# continuous colorbar mappable separately from the resolved norm (mirrors the outline path),
2366+
# so the colorbar reflects the real norm subclass.
2367+
img = ax.imshow(labels, rasterized=True, alpha=alpha, origin="lower", zorder=render_params.zorder)
2368+
img.set_transform(trans_data)
2369+
if color_spec.is_categorical:
2370+
return img
2371+
used_norm = _resolve_continuous_norm(color_spec.color_vector, render_params.cmap_params)
2372+
return ScalarMappable(norm=used_norm, cmap=render_params.cmap_params.cmap)
23692373

23702374
# When color is a literal (col_for_color is None) and no explicit outline_color,
23712375
# use the literal color for outlines so they are visible (e.g., color='white' on

tests/pl/test_render_labels.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,45 @@ def test_render_labels_all_nan_color_renders_under_rasterize(sdata_blobs: Spatia
540540
plt.close(fig)
541541

542542

543+
def test_render_labels_as_points_all_nan_color_does_not_crash(sdata_blobs: SpatialData):
544+
# Regression: as_points rebuilds the centroid ColorSpec; an all-NaN column is the "none" colortype
545+
# and must not be re-classified "categorical" (which would later call Categorical-only methods on
546+
# the na-array source). It must just render the centroids in na_color.
547+
labels_name = "blobs_labels"
548+
instances = get_element_instances(sdata_blobs[labels_name])
549+
n_obs = len(instances)
550+
adata = AnnData(np.zeros((n_obs, 1)))
551+
adata.obs["instance_id"] = instances.values
552+
adata.obs["nanvals"] = np.full(n_obs, np.nan)
553+
adata.obs["region"] = labels_name
554+
sdata_blobs["label_table"] = TableModel.parse(
555+
adata=adata, region_key="region", instance_key="instance_id", region=labels_name
556+
)
557+
fig, ax = plt.subplots()
558+
sdata_blobs.pl.render_labels(labels_name, color="nanvals", table_name="label_table", as_points=True).pl.show(ax=ax)
559+
plt.close(fig)
560+
561+
562+
def test_render_labels_lognorm_with_zeros_does_not_crash(sdata_blobs: SpatialData):
563+
# Regression: a continuous LogNorm column containing 0 must derive a positive vmin instead of a
564+
# LogNorm(vmin=0) that raises "Invalid vmin or vmax" when the segmentation colors are mapped.
565+
from matplotlib.colors import LogNorm
566+
567+
labels_name = "blobs_labels"
568+
instances = get_element_instances(sdata_blobs[labels_name])
569+
n_obs = len(instances)
570+
adata = AnnData(np.zeros((n_obs, 1)))
571+
adata.obs["instance_id"] = instances.values
572+
adata.obs["counts"] = np.linspace(0.0, 10.0, n_obs) # includes 0
573+
adata.obs["region"] = labels_name
574+
sdata_blobs["label_table"] = TableModel.parse(
575+
adata=adata, region_key="region", instance_key="instance_id", region=labels_name
576+
)
577+
fig, ax = plt.subplots()
578+
sdata_blobs.pl.render_labels(labels_name, color="counts", table_name="label_table", norm=LogNorm()).pl.show(ax=ax)
579+
plt.close(fig)
580+
581+
543582
@pytest.mark.parametrize("dtype", [np.float16, np.float32, np.float64])
544583
def test_render_labels_rejects_float_dtype(dtype):
545584
# Regression test for #606: float-dtype labels must raise a clear

tests/pl/test_render_shapes.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,29 @@ def test_render_shapes_all_nan_color_with_groups_does_not_crash(sdata_blobs_shap
11501150
plt.close(fig)
11511151

11521152

1153+
def test_render_shapes_lognorm_with_zeros_does_not_crash(sdata_blobs_shapes_annotated: SpatialData):
1154+
# Regression: a continuous LogNorm column containing 0 must derive a positive vmin instead of
1155+
# producing a LogNorm(vmin=0) that raises "Invalid vmin or vmax" when the fill is mapped.
1156+
from matplotlib.colors import LogNorm
1157+
1158+
sdata_blobs_shapes_annotated["blobs_polygons"]["counts"] = [0.0, 2.5, 5.0, 7.5, 10.0]
1159+
fig, ax = plt.subplots()
1160+
sdata_blobs_shapes_annotated.pl.render_shapes("blobs_polygons", color="counts", norm=LogNorm()).pl.show(ax=ax)
1161+
plt.close(fig)
1162+
1163+
1164+
def test_render_shapes_continuous_colorbar_reflects_norm_subclass(sdata_blobs_shapes_annotated: SpatialData):
1165+
# Regression: the fill colorbar must use the resolved norm subclass (LogNorm), not the
1166+
# collection's default linear Normalize — i.e. set_norm, not set_clim.
1167+
from matplotlib.colors import LogNorm
1168+
1169+
sdata_blobs_shapes_annotated["blobs_polygons"]["counts"] = [1.0, 2.5, 5.0, 7.5, 10.0]
1170+
fig, ax = plt.subplots()
1171+
sdata_blobs_shapes_annotated.pl.render_shapes("blobs_polygons", color="counts", norm=LogNorm()).pl.show(ax=ax)
1172+
assert any(isinstance(c.norm, LogNorm) for c in ax.collections), "fill colorbar norm was linearized"
1173+
plt.close(fig)
1174+
1175+
11531176
def test_gene_symbols_auto_detect_table(sdata_blobs: SpatialData):
11541177
"""gene_symbols resolves correctly without explicit table_name (#247)."""
11551178
sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs)

tests/pl/test_utils.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,43 @@ def test_object_dtype_coerces_color_strings_to_nan(self):
917917
norm = _resolve_continuous_norm(np.array([1.0, "red", 9.0], dtype=object), self._params())
918918
assert (norm.vmin, norm.vmax) == (1.0, 9.0)
919919

920+
def test_lognorm_with_zero_derives_positive_vmin_and_does_not_raise(self):
921+
# regression: deriving vmin from a LogNorm's data range must skip 0/negatives; otherwise the
922+
# preserved LogNorm has vmin <= 0 and raises "Invalid vmin or vmax" when the renderer calls it
923+
from matplotlib.colors import LogNorm
924+
925+
from spatialdata_plot.pl._color import _resolve_continuous_norm
926+
927+
vals = np.array([0.0, 5.0, 50.0])
928+
params = CmapParams(cmap=self._params().cmap, norm=LogNorm(vmin=None, vmax=None), na_color=Color())
929+
norm = _resolve_continuous_norm(vals, params)
930+
assert isinstance(norm, LogNorm)
931+
assert norm.vmin == 5.0 and norm.vmax == 50.0 # smallest positive, not 0
932+
norm(vals) # must not raise
933+
934+
def test_lognorm_with_negatives_derives_positive_vmin(self):
935+
from matplotlib.colors import LogNorm
936+
937+
from spatialdata_plot.pl._color import _resolve_continuous_norm
938+
939+
vals = np.array([-10.0, 1.0, 100.0])
940+
params = CmapParams(cmap=self._params().cmap, norm=LogNorm(vmin=None, vmax=None), na_color=Color())
941+
norm = _resolve_continuous_norm(vals, params)
942+
assert isinstance(norm, LogNorm)
943+
assert norm.vmin == 1.0 and norm.vmax == 100.0
944+
norm(vals) # must not raise
945+
946+
def test_lognorm_all_nonpositive_falls_back_without_raising(self):
947+
from matplotlib.colors import LogNorm
948+
949+
from spatialdata_plot.pl._color import _resolve_continuous_norm
950+
951+
params = CmapParams(cmap=self._params().cmap, norm=LogNorm(vmin=None, vmax=None), na_color=Color())
952+
norm = _resolve_continuous_norm(np.array([0.0, -1.0, np.nan]), params)
953+
assert isinstance(norm, LogNorm)
954+
assert (norm.vmin, norm.vmax) == (1.0, 1.0)
955+
norm(np.array([1.0])) # must not raise
956+
920957
def test_same_values_give_identical_norm_and_never_mutate_shared(self):
921958
# the core #699 invariant: pixels and colorbar call this with the same vector -> same result,
922959
# and the shared CmapParams.norm is never autoscaled in place.

0 commit comments

Comments
 (0)