Skip to content

Commit 87beb7f

Browse files
authored
fix(labels): align instance_id with colour vector for partial tables (#775) (#776)
1 parent 6c8c6d6 commit 87beb7f

2 files changed

Lines changed: 75 additions & 4 deletions

File tree

‎src/spatialdata_plot/pl/render.py‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from matplotlib import patheffects
2121
from matplotlib.cm import ScalarMappable
2222
from matplotlib.colors import BoundaryNorm, Colormap, ListedColormap, Normalize, to_rgba_array
23-
from spatialdata import get_extent, get_values
23+
from spatialdata import get_element_instances, get_extent, get_values
2424
from spatialdata.models import PointsModel, ShapesModel, get_table_keys
2525
from spatialdata.transformations import set_transformation
2626
from spatialdata.transformations.transformations import Identity
@@ -2340,6 +2340,12 @@ def _render_labels(
23402340
# limits already clip to the box, so return before the instance-overlap/colour machinery.
23412341
return
23422342

2343+
# instance_id must line up with the per-instance colour/outline vectors only when one of these
2344+
# drives the render; the same gate guards the rasterize reconciliation below.
2345+
needs_aligned_instances = (
2346+
col_for_color is not None or render_params.col_for_outline_color is not None or render_params.as_points
2347+
)
2348+
23432349
if table_name is None:
23442350
instance_id = unique_labels
23452351
table = None
@@ -2355,8 +2361,19 @@ def _render_labels(
23552361
"instance_id=0 before plotting."
23562362
)
23572363

2358-
# get instance id based on subsetted table
2359-
instance_id = np.unique(table.obs[instance_key].values)
2364+
if needs_aligned_instances:
2365+
# Restrict to instances that actually exist in the element (canonical scale-0),
2366+
# matching the colour vector's basis from get_values; table rows for absent
2367+
# instances are dropped instead of misaligning the mask. Rasterize/multiscale
2368+
# display drops are reconciled against both vectors by the block below.
2369+
# ponytail: this rescans the canonical raster get_values also scans; thread
2370+
# get_values' instance index out of resolve_color if the double pass ever bites.
2371+
instance_id = np.intersect1d(
2372+
np.unique(table.obs[instance_key].values),
2373+
np.asarray(get_element_instances(sdata_filt[element])),
2374+
)
2375+
else:
2376+
instance_id = np.unique(table.obs[instance_key].values)
23602377

23612378
trans, trans_data = _prepare_transformation(label, coordinate_system, ax)
23622379

@@ -2408,7 +2425,7 @@ def _render_labels(
24082425

24092426
# rasterize/downsampling can drop labels from the raster; remove their (now-absent) instance ids
24102427
# so per-instance colors stay aligned and as_points does not emit dots for dropped cells.
2411-
if rasterize and (col_for_color is not None or col_for_outline_color is not None or render_params.as_points):
2428+
if rasterize and needs_aligned_instances:
24122429
mask = np.isin(instance_id, unique_labels)
24132430
instance_id = instance_id[mask]
24142431
if col_for_color is not None:

‎tests/pl/test_render_labels.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,60 @@ def test_render_labels_disjoint_instance_ids_clear_error():
823823
plt.close(fig)
824824

825825

826+
def _labels_with_partial_table(instance_id, *, scale_factors=None):
827+
# labels contain instances 1, 2; the table's instance_id set is caller-controlled so tests can
828+
# add phantom rows (instances absent from the raster) or omit a present instance.
829+
arr = np.zeros((20, 20), dtype=np.int32)
830+
arr[3:8, 3:8] = 1
831+
arr[12:17, 12:17] = 2
832+
obs = pd.DataFrame(
833+
{
834+
"instance_id": instance_id,
835+
"region": pd.Categorical(["lbl"] * len(instance_id)),
836+
"cat": pd.Categorical([c for c, _ in zip("ABCDEFG", instance_id, strict=False)]),
837+
}
838+
)
839+
obs.index = obs.index.astype(str)
840+
table = TableModel.parse(
841+
AnnData(X=np.zeros((len(instance_id), 1)), obs=obs),
842+
region=["lbl"],
843+
region_key="region",
844+
instance_key="instance_id",
845+
)
846+
table.obs["value"] = np.arange(len(instance_id), dtype=float)
847+
labels = Labels2DModel.parse(arr, dims=["y", "x"], scale_factors=scale_factors)
848+
return SpatialData(labels={"lbl": labels}, tables={"t": table})
849+
850+
851+
@pytest.mark.parametrize("color", ["value", "cat"])
852+
@pytest.mark.parametrize(
853+
"instance_id",
854+
[
855+
[1, 2, 3], # phantom row: instance 3 is absent from the raster (#775)
856+
[1], # present instance 2 has no table row
857+
],
858+
)
859+
def test_render_labels_partial_table_does_not_raise(color, instance_id):
860+
# Regression test for #775: a table annotating instances that are not present in the labels
861+
# (or missing a present instance) must render as missing, not raise IndexError.
862+
sdata = _labels_with_partial_table(instance_id)
863+
fig, ax = plt.subplots()
864+
try:
865+
sdata.pl.render_labels("lbl", color=color, table_name="t").pl.show(ax=ax)
866+
finally:
867+
plt.close(fig)
868+
869+
870+
def test_render_labels_phantom_row_survives_rasterization():
871+
# #775: the alignment must also hold once rasterization/multiscale drops labels from the raster.
872+
sdata = _labels_with_partial_table([1, 2, 3], scale_factors=[2])
873+
fig, ax = plt.subplots()
874+
try:
875+
sdata.pl.render_labels("lbl", color="value", table_name="t").pl.show(ax=ax)
876+
finally:
877+
plt.close(fig)
878+
879+
826880
@pytest.mark.parametrize("scale_factors", [None, [2]])
827881
def test_render_labels_raises_on_3d(scale_factors):
828882
# Regression test for #608: 3D labels must raise a clear ValueError, not crash

0 commit comments

Comments
 (0)