Skip to content

Commit ea44dfd

Browse files
committed
fix(show): dedup redundant coordinate systems for single-axes render (#749)
When an element carries transformations to several coordinate systems that render the same elements (e.g. visium's "<cs>" and "<cs>_downscaled_lowres"; filter_by_coordinate_system can't strip the extra transform, upstream #176), auto-detection produced one panel per coordinate system and `show(ax=single_ax)` raised a "Mismatch between number of matplotlib axes objects and number of panels" ValueError. Deduplicate auto-detected coordinate systems by their renderable-element set when axes are supplied: keep the first of each distinct set so a single axes is satisfied, and warn naming the dropped duplicates (pointing to `coordinate_systems=`). Genuinely distinct coordinate systems keep more sets than axes and still raise the mismatch error. Scoped to the ax-provided, auto-detected path, so the no-ax multi-panel behaviour is unchanged.
1 parent 87beb7f commit ea44dfd

3 files changed

Lines changed: 133 additions & 4 deletions

File tree

‎plans/issue-749.md‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Issue #749: `pl.show()` ValueError — axes/panels count mismatch
2+
3+
## Root cause
4+
5+
When a SpatialElement carries transformations to **multiple coordinate systems**
6+
(e.g. visium's `"<cs>"` and `"<cs>_downscaled_lowres"` pair), `filter_by_coordinate_system`
7+
cannot strip the extra transformation (upstream spatialdata #176). `show()` then
8+
auto-detects **more coordinate systems than the user intended**. When the user
9+
passes a single `ax`, `_plan_panels` raises:
10+
11+
```
12+
ValueError: Mismatch between number of matplotlib axes objects (1) and number of panels (2).
13+
```
14+
15+
PR #580 added a `strict_cs` narrowing (keep only CS with element types for *all*
16+
render commands), but it does **not** help here: both coordinate systems contain
17+
the *same* element, so both survive the filter. The two panels are redundant —
18+
they render the identical element set, differing only by a scale transform.
19+
20+
Confirmed reproducible on current `main` (see `/tmp/repro_issue_749.py`).
21+
22+
## Proposed changes (recommended: Approach 1 — scope to the erroring path)
23+
24+
Extend the existing `ax is not None and cs_was_auto` block in
25+
`_resolve_coordinate_systems` (`src/spatialdata_plot/pl/basic.py`): after the
26+
`strict_cs` step, if `len(coordinate_systems) > n_ax`, **deduplicate coordinate
27+
systems by their renderable-element set** (via `_get_elements_to_be_rendered`),
28+
keeping the first representative of each distinct set. If that brings the count
29+
down to `<= n_ax`, use the deduplicated list and emit a `UserWarning` naming the
30+
dropped (redundant) coordinate systems and pointing to `coordinate_systems=`.
31+
If the sets are genuinely distinct (count still `> n_ax`), fall through to the
32+
existing, correct `ValueError`.
33+
34+
| File | Change | Rationale |
35+
|------|--------|-----------|
36+
| `src/spatialdata_plot/pl/basic.py` (`_resolve_coordinate_systems`) | After `strict_cs`, dedup redundant CS by element set when `ax` given + auto-detected; warn | Fixes the mismatch only on the path that currently errors → strictly backward compatible |
37+
| `tests/pl/test_show.py` | Regression test: multi-CS element + single `ax` no longer raises; distinct-CS case still raises | Lock behavior |
38+
39+
Why scope to the `ax`-provided path only: the no-`ax` case currently produces
40+
one panel per coordinate system (redundant but not an error). Collapsing that
41+
too would change existing multi-panel output / baselines — a backward-compat
42+
break the reporter explicitly asked to avoid. The `ax` path currently *errors*,
43+
so fixing it breaks nothing that worked before.
44+
45+
## Edge cases
46+
- [ ] 2 CS, same element set, single `ax` → collapse to 1, warn, render (main fix).
47+
- [ ] 2 CS, **different** element sets, single `ax` → still raise ValueError (correct; genuinely 2 panels).
48+
- [ ] N CS redundant, `ax` is a list of N → counts already match, no change.
49+
- [ ] N CS redundant, `ax` list shorter than distinct-set count → raise (correct).
50+
- [ ] `coordinate_systems=` passed explicitly → `cs_was_auto` False, block skipped, unchanged.
51+
- [ ] No `ax` → block skipped, unchanged (still multi-panel).
52+
- [ ] Multi-panel `color=[...]` → single CS required already; unaffected.
53+
54+
## Downstream impact
55+
- Public API unchanged (no new/changed kwargs).
56+
- Only converts a previously-raised `ValueError` into a successful render + warning.
57+
- No baseline image changes expected (new path only exercised by the regression test, which is non-visual).
58+
59+
## Test plan
60+
- [ ] Regression test (non-visual): multi-CS element, `filter_by_coordinate_system`, `render_shapes().show(ax=ax)` → no raise; assert 1 axis used.
61+
- [ ] Negative test: 2 CS with different elements + single `ax` → still raises `ValueError`.
62+
- [ ] Assert a `UserWarning` is emitted mentioning the dropped CS.
63+
64+
## Risks
65+
- Representative choice is "first in coordinate-system order", which may be the
66+
`_downscaled_lowres` variant. Visually identical for a standalone plot (axes
67+
autoscale), but a user overlaying multiple `show()` calls on one `ax` should
68+
still pass `coordinate_systems=`. The warning makes the choice explicit.
69+
- Low risk overall: new code runs only where the code previously raised.
70+
71+
## Alternative (Approach 2 — not recommended)
72+
Deduplicate redundant coordinate systems in auto-detect for **both** `ax` and
73+
no-`ax` paths. More internally consistent, but changes existing no-`ax`
74+
multi-panel output and possibly visual baselines → backward-incompatible.

‎src/spatialdata_plot/pl/basic.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,33 @@ def _resolve_coordinate_systems(
17901790
if strict_cs:
17911791
coordinate_systems = strict_cs
17921792

1793+
# If CS still outnumber the axes because an element carries transformations to several
1794+
# coordinate systems that render the *same* elements (e.g. visium's "<cs>" and
1795+
# "<cs>_downscaled_lowres"; upstream #176), those extra panels are redundant. Keep the
1796+
# first coordinate system of each distinct renderable-element set so a single axes can be
1797+
# satisfied, and tell the user which ones were dropped. Genuinely distinct coordinate
1798+
# systems keep more sets than axes and fall through to the mismatch error in _plan_panels.
1799+
if len(coordinate_systems) > n_ax:
1800+
seen_element_sets: set[frozenset[str]] = set()
1801+
deduped: list[str] = []
1802+
dropped: list[str] = []
1803+
for cs in coordinate_systems:
1804+
element_set = frozenset(_get_elements_to_be_rendered(render_cmds, cs_index, cs))
1805+
if element_set in seen_element_sets:
1806+
dropped.append(cs)
1807+
else:
1808+
seen_element_sets.add(element_set)
1809+
deduped.append(cs)
1810+
if dropped and len(deduped) <= n_ax:
1811+
warnings.warn(
1812+
f"Element(s) render identically in coordinate systems {dropped} as in "
1813+
f"{deduped}; rendering {deduped} on the provided axes and dropping the "
1814+
"redundant duplicate(s). Pass `coordinate_systems=` to choose explicitly.",
1815+
UserWarning,
1816+
stacklevel=2,
1817+
)
1818+
coordinate_systems = deduped
1819+
17931820
return coordinate_systems
17941821

17951822

‎tests/pl/test_render.py‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,12 +97,40 @@ def test_single_ax_explicit_multi_cs_raises(sdata_multi_cs):
9797
sdata_multi_cs.pl.render_shapes("shp").pl.show(ax=ax, coordinate_systems=["aligned", "global"])
9898

9999

100-
def test_single_ax_auto_cs_unresolvable_raises(sdata_multi_cs):
101-
"""When strict filtering can't resolve the mismatch, error includes hint."""
100+
def test_single_ax_auto_cs_redundant_duplicates_resolved(sdata_multi_cs):
101+
# Regression test for #749: when an element has transformations to multiple coordinate
102+
# systems that render the *same* elements, a single ax should render one of them (dropping
103+
# the redundant duplicate) with a warning, instead of raising a mismatch error.
102104
_, ax = plt.subplots(1, 1)
103-
with pytest.raises(ValueError, match="coordinate_systems="):
104-
# Only render shapes (present in both CS), so strict filter can't narrow down
105+
with pytest.warns(UserWarning, match="render identically"):
106+
# "shp" is present in both "aligned" and "global" with identical content.
105107
sdata_multi_cs.pl.render_shapes("shp").pl.show(ax=ax)
108+
assert ax.get_title() in ("aligned", "global")
109+
110+
111+
def test_single_ax_auto_cs_distinct_elements_raises():
112+
# Regression test for #749: the dedup only collapses coordinate systems that render the
113+
# *same* elements. When the detected coordinate systems render genuinely different content
114+
# (an image-only CS and a shapes-only CS), they are distinct panels, so a single ax must
115+
# still raise the mismatch error.
116+
from geopandas import GeoDataFrame
117+
from shapely.geometry import Point
118+
from spatialdata.models import ShapesModel
119+
120+
image = Image2DModel.parse(
121+
np.zeros((1, 10, 10)),
122+
dims=("c", "y", "x"),
123+
transformations={"aligned": Identity()},
124+
)
125+
shp = ShapesModel.parse(
126+
GeoDataFrame(geometry=[Point(5, 5)], data={"radius": [2]}),
127+
transformations={"global": Identity()},
128+
)
129+
sdata = SpatialData(images={"img": image}, shapes={"shp": shp})
130+
131+
_, ax = plt.subplots(1, 1)
132+
with pytest.raises(ValueError, match="coordinate_systems="):
133+
sdata.pl.render_images("img").pl.render_shapes("shp").pl.show(ax=ax)
106134

107135

108136
def test_cs_name_with_apostrophe_does_not_crash():

0 commit comments

Comments
 (0)