From ac66a15bc18623262243c0bea7981a14b0e08bf5 Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 4 Sep 2026 14:37:15 +0200 Subject: [PATCH 1/2] fix(labels): align instance_id with colour vector for partial tables (#775) render_labels(color=...) raised IndexError when the annotating table had rows for instances absent from the labels (e.g. objects lost when a segmentation is regenerated, or a table from an upstream pipeline). instance_id was derived from all table rows, but the colour vector from get_values is restricted to the element's present instances, so the two diverged and the rasterize mask indexed the shorter colour vector. Restrict instance_id to the canonical instances actually present in the element (get_element_instances) intersected with the table, matching the colour vector's basis independent of rasterize/multiscale display drops. Claude-Session: https://claude.ai/code/session_01U9J5GSXR4XGjPMaQQR5iSj --- src/spatialdata_plot/pl/render.py | 12 +++++-- tests/pl/test_render_labels.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 62987ece..aef44643 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -20,7 +20,7 @@ from matplotlib import patheffects from matplotlib.cm import ScalarMappable from matplotlib.colors import BoundaryNorm, Colormap, ListedColormap, Normalize, to_rgba_array -from spatialdata import get_extent, get_values +from spatialdata import get_element_instances, get_extent, get_values from spatialdata.models import PointsModel, ShapesModel, get_table_keys from spatialdata.transformations import set_transformation from spatialdata.transformations.transformations import Identity @@ -2349,8 +2349,14 @@ def _render_labels( "instance_id=0 before plotting." ) - # get instance id based on subsetted table - instance_id = np.unique(table.obs[instance_key].values) + # Restrict to instances that actually exist in the element (canonical scale-0), + # matching the colour vector's basis from get_values; table rows for absent + # instances are dropped instead of misaligning the mask (#775). Rasterize/multiscale + # display drops are reconciled against both vectors below (~L2405). + instance_id = np.intersect1d( + np.unique(table.obs[instance_key].values), + np.asarray(get_element_instances(sdata_filt[element])), + ) trans, trans_data = _prepare_transformation(label, coordinate_system, ax) diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index bcb66ad5..8c69f655 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -823,6 +823,60 @@ def test_render_labels_disjoint_instance_ids_clear_error(): plt.close(fig) +def _labels_with_partial_table(instance_id, *, scale_factors=None): + # labels contain instances 1, 2; the table's instance_id set is caller-controlled so tests can + # add phantom rows (instances absent from the raster) or omit a present instance. + arr = np.zeros((20, 20), dtype=np.int32) + arr[3:8, 3:8] = 1 + arr[12:17, 12:17] = 2 + obs = pd.DataFrame( + { + "instance_id": instance_id, + "region": pd.Categorical(["lbl"] * len(instance_id)), + "cat": pd.Categorical([c for c, _ in zip("ABCDEFG", instance_id, strict=False)]), + } + ) + obs.index = obs.index.astype(str) + table = TableModel.parse( + AnnData(X=np.zeros((len(instance_id), 1)), obs=obs), + region=["lbl"], + region_key="region", + instance_key="instance_id", + ) + table.obs["value"] = np.arange(len(instance_id), dtype=float) + labels = Labels2DModel.parse(arr, dims=["y", "x"], scale_factors=scale_factors) + return SpatialData(labels={"lbl": labels}, tables={"t": table}) + + +@pytest.mark.parametrize("color", ["value", "cat"]) +@pytest.mark.parametrize( + "instance_id", + [ + [1, 2, 3], # phantom row: instance 3 is absent from the raster (#775) + [1], # present instance 2 has no table row + ], +) +def test_render_labels_partial_table_does_not_raise(color, instance_id): + # Regression test for #775: a table annotating instances that are not present in the labels + # (or missing a present instance) must render as missing, not raise IndexError. + sdata = _labels_with_partial_table(instance_id) + fig, ax = plt.subplots() + try: + sdata.pl.render_labels("lbl", color=color, table_name="t").pl.show(ax=ax) + finally: + plt.close(fig) + + +def test_render_labels_phantom_row_survives_rasterization(): + # #775: the alignment must also hold once rasterization/multiscale drops labels from the raster. + sdata = _labels_with_partial_table([1, 2, 3], scale_factors=[2]) + fig, ax = plt.subplots() + try: + sdata.pl.render_labels("lbl", color="value", table_name="t").pl.show(ax=ax) + finally: + plt.close(fig) + + @pytest.mark.parametrize("scale_factors", [None, [2]]) def test_render_labels_raises_on_3d(scale_factors): # Regression test for #608: 3D labels must raise a clear ValueError, not crash From e5ac1052562eeaa47419533abd6d4671417bc887 Mon Sep 17 00:00:00 2001 From: anon Date: Sat, 5 Sep 2026 13:52:00 +0200 Subject: [PATCH 2/2] refactor(labels): gate present-instance scan behind colour/outline/as_points Address review on #776: the extra get_element_instances scan is only needed when a per-instance colour/outline vector (or as_points) must stay aligned; the uniform color=None path keeps the cheap np.unique(table.obs). Hoist the gate into a shared `needs_aligned_instances` flag reused by the rasterize reconciliation, and drop the stale issue ref / line number from the comment. Claude-Session: https://claude.ai/code/session_01U9J5GSXR4XGjPMaQQR5iSj --- src/spatialdata_plot/pl/render.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index aef44643..f26237a7 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2334,6 +2334,12 @@ def _render_labels( # limits already clip to the box, so return before the instance-overlap/colour machinery. return + # instance_id must line up with the per-instance colour/outline vectors only when one of these + # drives the render; the same gate guards the rasterize reconciliation below. + needs_aligned_instances = ( + col_for_color is not None or render_params.col_for_outline_color is not None or render_params.as_points + ) + if table_name is None: instance_id = unique_labels table = None @@ -2349,14 +2355,19 @@ def _render_labels( "instance_id=0 before plotting." ) - # Restrict to instances that actually exist in the element (canonical scale-0), - # matching the colour vector's basis from get_values; table rows for absent - # instances are dropped instead of misaligning the mask (#775). Rasterize/multiscale - # display drops are reconciled against both vectors below (~L2405). - instance_id = np.intersect1d( - np.unique(table.obs[instance_key].values), - np.asarray(get_element_instances(sdata_filt[element])), - ) + if needs_aligned_instances: + # Restrict to instances that actually exist in the element (canonical scale-0), + # matching the colour vector's basis from get_values; table rows for absent + # instances are dropped instead of misaligning the mask. Rasterize/multiscale + # display drops are reconciled against both vectors by the block below. + # ponytail: this rescans the canonical raster get_values also scans; thread + # get_values' instance index out of resolve_color if the double pass ever bites. + instance_id = np.intersect1d( + np.unique(table.obs[instance_key].values), + np.asarray(get_element_instances(sdata_filt[element])), + ) + else: + instance_id = np.unique(table.obs[instance_key].values) trans, trans_data = _prepare_transformation(label, coordinate_system, ax) @@ -2408,7 +2419,7 @@ def _render_labels( # rasterize/downsampling can drop labels from the raster; remove their (now-absent) instance ids # so per-instance colors stay aligned and as_points does not emit dots for dropped cells. - if rasterize and (col_for_color is not None or col_for_outline_color is not None or render_params.as_points): + if rasterize and needs_aligned_instances: mask = np.isin(instance_id, unique_labels) instance_id = instance_id[mask] if col_for_color is not None: