Skip to content

Commit 525dffa

Browse files
committed
fix: render shapes unannotated by the table with na_color instead of dropping them (#710)
render_shapes joined the element to its table with how="inner", silently dropping shapes whose instance had no table row. Points (how="left" merge) and labels (raster + na_color) keep unannotated elements, so shapes were the inconsistent outlier (and it broke the labels<->shapes interchangeability). Switch _join_table_for_element to how="left": all shapes survive, and the per-shape color lookup (_extract_color_column, reindexed to the element) yields NaN -> na_color for the unannotated ones, via the existing NaN-handling path. Fully-annotated data is unaffected (verified pixel-identical: inner == left when nothing is dropped), so only partial-annotation output changes. Adds a regression test (fails on the old drop behavior: 12/20 rendered; passes now: 20/20 with 8 na_color).
1 parent 8d74219 commit 525dffa

3 files changed

Lines changed: 34 additions & 4 deletions

File tree

src/spatialdata_plot/pl/render.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ def _render_shapes(
646646
outline_color_vector: Any = None
647647
if col_for_outline_color is not None:
648648
# When the outline column lives in a table that hasn't been joined yet
649-
# (no fill table, or a different table than fill's), inner-join it onto
649+
# (no fill table, or a different table than fill's), left-join it onto
650650
# the element so the lookup is aligned and the element row count matches
651651
# the outline vector length.
652652
if outline_table_name is not None and outline_table_name != table_name:

src/spatialdata_plot/pl/utils.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,7 +444,11 @@ def _join_table_for_element(
444444
element: str,
445445
table_name: str,
446446
) -> tuple[Any, AnnData]:
447-
"""Inner-join ``element`` with its annotating ``table_name``.
447+
"""Left-join ``element`` with its annotating ``table_name``.
448+
449+
A left join keeps every shape, including those without a table row (they get no color value and
450+
are rendered with ``na_color``), matching the points/labels behaviour instead of silently dropping
451+
unannotated shapes.
448452
449453
Wraps the workaround for scverse/spatialdata#1099: ``join_spatialelement_table``
450454
calls ``table.obs.reset_index()`` which fails when the obs index name matches
@@ -470,7 +474,7 @@ def _join_table_for_element(
470474

471475
try:
472476
element_dict, joined_table = join_spatialelement_table(
473-
sdata, spatial_element_names=element, table_name=table_name, how="inner"
477+
sdata, spatial_element_names=element, table_name=table_name, how="left"
474478
)
475479
finally:
476480
if _saved_index is not None:

tests/pl/test_render_shapes.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import pytest
1010
import scanpy as sc
1111
from anndata import AnnData
12-
from matplotlib.colors import Normalize
12+
from matplotlib.colors import Normalize, to_rgba
1313
from shapely.geometry import MultiPolygon, Point, Polygon
1414
from spatialdata import SpatialData, deepcopy
1515
from spatialdata.models import ShapesModel, TableModel
@@ -439,6 +439,32 @@ def test_render_shapes_raises_for_missing_column_in_table(self, sdata_blobs_shap
439439
element="blobs_polygons", color="not_a_column", table_name="table"
440440
)
441441

442+
def test_render_shapes_unannotated_shapes_get_na_color_not_dropped(self):
443+
# Regression test for #710: shapes whose instance is not in the annotating table must render
444+
# with na_color, not be silently dropped (consistent with the points/labels paths).
445+
rng = np.random.default_rng(0)
446+
n = 20
447+
geom = gpd.GeoDataFrame(
448+
{"geometry": [Point(*xy) for xy in rng.random((n, 2)) * 100], "radius": np.ones(n) * 2},
449+
)
450+
adata = AnnData( # only the first 12 of 20 shapes are annotated
451+
X=rng.random((12, 1)).astype("float32"),
452+
obs=pd.DataFrame({"region": pd.Categorical(["shapes"] * 12), "instance_id": np.arange(12), "val": rng.random(12)}),
453+
)
454+
adata.var_names = ["g0"]
455+
sdata = SpatialData(
456+
shapes={"shapes": ShapesModel.parse(geom)},
457+
tables={"t": TableModel.parse(adata, region="shapes", region_key="region", instance_key="instance_id")},
458+
)
459+
fig, ax = plt.subplots()
460+
sdata.pl.render_shapes("shapes", color="val", na_color="red").pl.show(ax=ax)
461+
fig.canvas.draw()
462+
facecolors = ax.collections[0].get_facecolors()
463+
plt.close(fig)
464+
assert len(facecolors) == n, f"expected all {n} shapes rendered, got {len(facecolors)} (unannotated dropped)"
465+
n_na = int(np.isclose(facecolors, to_rgba("red")).all(axis=1).sum())
466+
assert n_na == 8, f"expected 8 unannotated shapes in na_color, got {n_na}"
467+
442468
def test_plot_can_plot_shapes_after_spatial_query(self, sdata_blobs: SpatialData):
443469
# subset to only shapes, should be unnecessary after rasterizeation of multiscale images is included
444470
blob = SpatialData.init_from_elements(

0 commit comments

Comments
 (0)