From 1e07786bc211ed317c61e11136c5ec46d44535c9 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Tue, 8 Sep 2026 15:18:01 +0200 Subject: [PATCH 1/2] fix(visium): build the circles from the spot coordinates again `visium()` used `coords` for two things: the raw `tissue_positions` table merged into `adata.obs`, and, after a rebinding, the `(n, 2)` array of spot coordinates that `ShapesModel.parse()` consumes. In #411 the rebinding was inlined into the `adata.obsm["spatial"]` assignment (`coords` changing type is what mypy rejects), so the raw table reached `ShapesModel.parse()` and the reader raised `TypeError: ShapesModel.parse() does not support the type `. Use a separate name for the coordinates, which keeps mypy happy without changing the runtime behaviour, and add a regression test on a minimal synthetic dataset (the plain Visium reader had no test coverage). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +++ src/spatialdata_io/readers/visium.py | 7 ++- tests/test_visium.py | 75 ++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/test_visium.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6521a5dc..82bbd8e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ Release notes for `v0.7.1` and earlier are available on the [Releases][] page. documentation builds, `mypy` type checking of `src` and `tests`, `biome`/`pyproject-fmt`/`zizmor` pre-commit hooks, and Dependabot updates. +### Fixed + +- `visium()`: the circles are built again from the spot coordinates instead of from the raw `tissue_positions` table, + which made the reader raise `TypeError: ShapesModel.parse() does not support the type + `. + ### Removed - Support for Python 3.11. diff --git a/src/spatialdata_io/readers/visium.py b/src/spatialdata_io/readers/visium.py index 5d9fad58..9d784121 100644 --- a/src/spatialdata_io/readers/visium.py +++ b/src/spatialdata_io/readers/visium.py @@ -174,7 +174,10 @@ def visium( assert isinstance(adata.obs, pd.DataFrame) adata.obs = pd.merge(adata.obs, coords, how="left", left_index=True, right_index=True) - adata.obsm["spatial"] = adata.obs[[VisiumKeys.SPOTS_X, VisiumKeys.SPOTS_Y]].values + # `coords` above is the raw `tissue_positions` table; the circles are built from the + # spot coordinates in the order of `adata`, so keep them in a separate variable + spot_coords = adata.obs[[VisiumKeys.SPOTS_X, VisiumKeys.SPOTS_Y]].to_numpy() + adata.obsm["spatial"] = spot_coords adata.obs = pd.DataFrame(adata.obs) adata.obs.drop(columns=[VisiumKeys.SPOTS_X, VisiumKeys.SPOTS_Y], inplace=True) adata.obs["spot_id"] = np.arange(len(adata)) @@ -204,7 +207,7 @@ def visium( ) shapes = {} circles = ShapesModel.parse( - coords, + spot_coords, geometry=0, radius=scalefactors["spot_diameter_fullres"] / 2.0, index=adata.obs["spot_id"].copy(), diff --git a/tests/test_visium.py b/tests/test_visium.py new file mode 100644 index 00000000..d2e5dd56 --- /dev/null +++ b/tests/test_visium.py @@ -0,0 +1,75 @@ +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from shapely import Point +from spatialdata.models import ShapesModel + +from spatialdata_io._constants._constants import VisiumKeys +from spatialdata_io.readers.visium import visium + +SPOT_DIAMETER_FULLRES = 20.0 + +# the spots are given in an order which is neither the order of the counts file nor sorted, and one of them is not +# present in the counts file; this way the test detects the case in which the circles are built from the raw +# `tissue_positions` table instead of from the spot coordinates aligned to the table rows +SPOTS = { + "barcode-2": (1, 0, 1, 210.0, 110.0), + "barcode-0": (1, 0, 0, 200.0, 100.0), + "spot-not-in-counts": (0, 9, 9, 999.0, 999.0), + "barcode-1": (1, 1, 0, 220.0, 120.0), +} +BARCODES = ["barcode-0", "barcode-1", "barcode-2"] + + +@pytest.fixture +def visium_dataset(tmp_path: Path) -> Path: + """Write a minimal Visium dataset (counts, tissue positions, scalefactors) to disk.""" + counts = pd.DataFrame( + np.arange(len(BARCODES) * 2, dtype=np.float32).reshape(len(BARCODES), 2), + index=BARCODES, + columns=["gene-a", "gene-b"], + ) + counts.to_csv(tmp_path / "counts.txt", sep="\t") + + spatial = tmp_path / "spatial" + spatial.mkdir() + positions = pd.DataFrame.from_dict( + SPOTS, + orient="index", + columns=["in_tissue", "array_row", "array_col", VisiumKeys.SPOTS_Y, VisiumKeys.SPOTS_X], + ) + positions.index.name = "barcode" + positions.to_csv(spatial / VisiumKeys.SPOTS_FILE_2) + + scalefactors = { + VisiumKeys.SCALEFACTORS_HIRES: 0.1, + VisiumKeys.SCALEFACTORS_LOWRES: 0.01, + "spot_diameter_fullres": SPOT_DIAMETER_FULLRES, + } + (spatial / VisiumKeys.SCALEFACTORS_FILE).write_text(json.dumps(scalefactors)) + return tmp_path + + +def test_visium_circles_match_spot_coordinates(visium_dataset: Path) -> None: + """The circles are the spot coordinates of the table, in the order of the table. + + Regression test for the case in which the raw `tissue_positions` table was passed to `ShapesModel.parse()`. + """ + sdata = visium(visium_dataset, dataset_id="test", counts_file="counts.txt") + + circles = sdata["test"] + table = sdata["table"] + + # the table contains only the spots which are in the counts file, in the order of the counts file + assert table.obs_names.tolist() == BARCODES + + expected = np.array([[SPOTS[barcode][4], SPOTS[barcode][3]] for barcode in BARCODES]) + assert np.array_equal(table.obsm["spatial"], expected) + assert np.array_equal(circles.get_coordinates().to_numpy(), expected) + + assert circles.index.tolist() == table.obs["spot_id"].tolist() + assert all(isinstance(geometry, Point) for geometry in circles.geometry) + assert np.array_equal(circles[ShapesModel.RADIUS_KEY], np.full(len(BARCODES), SPOT_DIAMETER_FULLRES / 2.0)) From e19b01e03221728c13a805ada69ffcd07275ce20 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 12:58:37 +0200 Subject: [PATCH 2/2] remove ai-generated visium test as it is not using real data --- tests/test_visium.py | 75 -------------------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 tests/test_visium.py diff --git a/tests/test_visium.py b/tests/test_visium.py deleted file mode 100644 index d2e5dd56..00000000 --- a/tests/test_visium.py +++ /dev/null @@ -1,75 +0,0 @@ -import json -from pathlib import Path - -import numpy as np -import pandas as pd -import pytest -from shapely import Point -from spatialdata.models import ShapesModel - -from spatialdata_io._constants._constants import VisiumKeys -from spatialdata_io.readers.visium import visium - -SPOT_DIAMETER_FULLRES = 20.0 - -# the spots are given in an order which is neither the order of the counts file nor sorted, and one of them is not -# present in the counts file; this way the test detects the case in which the circles are built from the raw -# `tissue_positions` table instead of from the spot coordinates aligned to the table rows -SPOTS = { - "barcode-2": (1, 0, 1, 210.0, 110.0), - "barcode-0": (1, 0, 0, 200.0, 100.0), - "spot-not-in-counts": (0, 9, 9, 999.0, 999.0), - "barcode-1": (1, 1, 0, 220.0, 120.0), -} -BARCODES = ["barcode-0", "barcode-1", "barcode-2"] - - -@pytest.fixture -def visium_dataset(tmp_path: Path) -> Path: - """Write a minimal Visium dataset (counts, tissue positions, scalefactors) to disk.""" - counts = pd.DataFrame( - np.arange(len(BARCODES) * 2, dtype=np.float32).reshape(len(BARCODES), 2), - index=BARCODES, - columns=["gene-a", "gene-b"], - ) - counts.to_csv(tmp_path / "counts.txt", sep="\t") - - spatial = tmp_path / "spatial" - spatial.mkdir() - positions = pd.DataFrame.from_dict( - SPOTS, - orient="index", - columns=["in_tissue", "array_row", "array_col", VisiumKeys.SPOTS_Y, VisiumKeys.SPOTS_X], - ) - positions.index.name = "barcode" - positions.to_csv(spatial / VisiumKeys.SPOTS_FILE_2) - - scalefactors = { - VisiumKeys.SCALEFACTORS_HIRES: 0.1, - VisiumKeys.SCALEFACTORS_LOWRES: 0.01, - "spot_diameter_fullres": SPOT_DIAMETER_FULLRES, - } - (spatial / VisiumKeys.SCALEFACTORS_FILE).write_text(json.dumps(scalefactors)) - return tmp_path - - -def test_visium_circles_match_spot_coordinates(visium_dataset: Path) -> None: - """The circles are the spot coordinates of the table, in the order of the table. - - Regression test for the case in which the raw `tissue_positions` table was passed to `ShapesModel.parse()`. - """ - sdata = visium(visium_dataset, dataset_id="test", counts_file="counts.txt") - - circles = sdata["test"] - table = sdata["table"] - - # the table contains only the spots which are in the counts file, in the order of the counts file - assert table.obs_names.tolist() == BARCODES - - expected = np.array([[SPOTS[barcode][4], SPOTS[barcode][3]] for barcode in BARCODES]) - assert np.array_equal(table.obsm["spatial"], expected) - assert np.array_equal(circles.get_coordinates().to_numpy(), expected) - - assert circles.index.tolist() == table.obs["spot_id"].tolist() - assert all(isinstance(geometry, Point) for geometry in circles.geometry) - assert np.array_equal(circles[ShapesModel.RADIUS_KEY], np.full(len(BARCODES), SPOT_DIAMETER_FULLRES / 2.0))