From 46b80ce27faa1169788220778a2e799010d679c9 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Sun, 29 Mar 2026 18:07:43 +0200 Subject: [PATCH 1/2] Fix render_shapes losing transformation after groups filtering (#420) Move _prepare_transformation() call to before the groups filtering block so the coordinate-system transformation is captured while the element's metadata is still intact. The GeoDataFrame re-wrap that follows groups filtering strips .attrs, which would lose the transformation if read afterwards. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/spatialdata_plot/pl/render.py | 7 ++--- tests/pl/test_render_shapes.py | 45 +++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index f0e7a1e8..7be11544 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -350,6 +350,10 @@ def _render_shapes( shapes = sdata_filt[element] + # Capture the transformation *before* any groups filtering that may strip + # coordinate-system metadata from the element (see #420, #447). + trans, trans_data = _prepare_transformation(sdata_filt.shapes[element], coordinate_system) + # get color vector (categorical or continuous) color_source_vector, color_vector, _ = _set_color_source_vec( sdata=sdata_filt, @@ -425,9 +429,6 @@ def _render_shapes( # necessary in case different shapes elements are annotated with one table color_source_vector = color_source_vector.remove_unused_categories() - # Apply the transformation to the PatchCollection's paths - trans, trans_data = _prepare_transformation(sdata_filt.shapes[element], coordinate_system) - shapes = gpd.GeoDataFrame(shapes, geometry="geometry") # convert shapes if necessary if render_params.shape is not None: diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 409d4487..baadb37f 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1067,6 +1067,51 @@ def test_plot_can_handle_non_numeric_radius_values(sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes(element="blobs_circles", color="red").pl.show() +def test_groups_filtering_preserves_transformation(sdata_blobs: SpatialData): + """Regression test for #420: groups filtering must not strip coordinate-system metadata. + + Simulates the exact sequence that ``_render_shapes`` performs — + ``filter_by_coordinate_system`` ➜ groups boolean-index ➜ ``reset_index`` ➜ + re-assign to ``sdata_filt`` — then asserts that ``_prepare_transformation`` + can still retrieve the non-global transformation with the correct scale. + """ + from spatialdata.transformations import set_transformation + + from spatialdata_plot.pl.utils import _prepare_transformation + + scale_factor = 2.5 + cs = "not_global" + set_transformation( + sdata_blobs["blobs_polygons"], + transformation={cs: Scale([scale_factor, scale_factor], axes=("x", "y"))}, + set_all=True, + ) + sdata_blobs.shapes["blobs_polygons"]["cluster"] = pd.Categorical(["c1", "c2", "c1", "c2", "c1"]) + + sdata_filt = sdata_blobs.filter_by_coordinate_system(coordinate_system=cs, filter_tables=False) + + # --- replicate the groups-filtering path from _render_shapes (lines 382-389) --- + shapes = sdata_filt.shapes["blobs_polygons"] + keep = shapes["cluster"] == "c1" + shapes = shapes[keep].reset_index(drop=True) + sdata_filt["blobs_polygons"] = shapes + # also replicate the GeoDataFrame re-wrap that follows (line 432), which strips .attrs + shapes = gpd.GeoDataFrame(shapes, geometry="geometry") + + # The sdata_filt element must still carry the correct transformation + # (this is where _render_shapes reads the transform after the fix). + trans, _ = _prepare_transformation(sdata_filt.shapes["blobs_polygons"], cs) + matrix = trans.get_matrix() + np.testing.assert_allclose(matrix[0, 0], scale_factor, err_msg="x-scale lost after groups filtering") + np.testing.assert_allclose(matrix[1, 1], scale_factor, err_msg="y-scale lost after groups filtering") + + # The GeoDataFrame re-wrap (which _render_shapes does right after) strips + # attrs — prove that reading the transform from *that* object would fail, + # demonstrating why early capture matters. + with pytest.raises((KeyError, AssertionError)): + _prepare_transformation(shapes, cs) + + def test_plot_can_handle_mixed_numeric_and_color_data(sdata_blobs: SpatialData): """Test that mixed numeric and color-like data raises a clear error.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) From 5b1f7e383cbe0332757ebf246df70ccda1e61d91 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Mon, 30 Mar 2026 09:55:27 +0200 Subject: [PATCH 2/2] Clean up test: module-level import, remove stale line refs, pin exception type Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/pl/test_render_shapes.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index baadb37f..b7e31503 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -13,7 +13,7 @@ from shapely.geometry import MultiPolygon, Point, Polygon from spatialdata import SpatialData, deepcopy from spatialdata.models import ShapesModel, TableModel -from spatialdata.transformations import Affine, Identity, MapAxis, Scale, Sequence, Translation +from spatialdata.transformations import Affine, Identity, MapAxis, Scale, Sequence, Translation, set_transformation from spatialdata.transformations._utils import _set_transformations import spatialdata_plot # noqa: F401 @@ -1071,12 +1071,10 @@ def test_groups_filtering_preserves_transformation(sdata_blobs: SpatialData): """Regression test for #420: groups filtering must not strip coordinate-system metadata. Simulates the exact sequence that ``_render_shapes`` performs — - ``filter_by_coordinate_system`` ➜ groups boolean-index ➜ ``reset_index`` ➜ - re-assign to ``sdata_filt`` — then asserts that ``_prepare_transformation`` - can still retrieve the non-global transformation with the correct scale. + filter_by_coordinate_system -> groups boolean-index -> reset_index -> + re-assign to sdata_filt -> GeoDataFrame re-wrap — then asserts that + ``_prepare_transformation`` can still retrieve the correct transformation. """ - from spatialdata.transformations import set_transformation - from spatialdata_plot.pl.utils import _prepare_transformation scale_factor = 2.5 @@ -1090,25 +1088,23 @@ def test_groups_filtering_preserves_transformation(sdata_blobs: SpatialData): sdata_filt = sdata_blobs.filter_by_coordinate_system(coordinate_system=cs, filter_tables=False) - # --- replicate the groups-filtering path from _render_shapes (lines 382-389) --- + # Replicate groups filtering: boolean-index -> reset_index -> re-assign shapes = sdata_filt.shapes["blobs_polygons"] keep = shapes["cluster"] == "c1" shapes = shapes[keep].reset_index(drop=True) sdata_filt["blobs_polygons"] = shapes - # also replicate the GeoDataFrame re-wrap that follows (line 432), which strips .attrs + # GeoDataFrame re-wrap strips .attrs (this is what _render_shapes does next) shapes = gpd.GeoDataFrame(shapes, geometry="geometry") - # The sdata_filt element must still carry the correct transformation - # (this is where _render_shapes reads the transform after the fix). + # sdata_filt's element must still carry the correct transformation trans, _ = _prepare_transformation(sdata_filt.shapes["blobs_polygons"], cs) matrix = trans.get_matrix() np.testing.assert_allclose(matrix[0, 0], scale_factor, err_msg="x-scale lost after groups filtering") np.testing.assert_allclose(matrix[1, 1], scale_factor, err_msg="y-scale lost after groups filtering") - # The GeoDataFrame re-wrap (which _render_shapes does right after) strips - # attrs — prove that reading the transform from *that* object would fail, - # demonstrating why early capture matters. - with pytest.raises((KeyError, AssertionError)): + # The GeoDataFrame re-wrap strips attrs — reading the transform from + # the re-wrapped object must fail, proving why early capture matters. + with pytest.raises(AssertionError): _prepare_transformation(shapes, cs)