From b3e50bf46603c1d1cc77c328a4bec3ff341cb38e Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 13:12:10 +0200 Subject: [PATCH 01/14] fix(merscope): fall back to dask_image when rioxarray cannot be imported `_get_reader()` auto-detection was changed in #411 from a real `import rioxarray` to `importlib.util.find_spec("rioxarray")`. `find_spec` only reports whether the module can be *found*; importing it can still fail, which is what a `rioxarray` with a broken `rasterio` looks like. Before, that raised `ModuleNotFoundError` and the reader silently used the `dask_image` backend. Now the rioxarray backend is selected, and its own internal import turns the failure into "Using rioxarray backend requires to install the rioxarray library", which misdescribes the cause and leaves the user with no working backend. Use `importlib.import_module()` so the import is actually attempted (a plain `import rioxarray` would be flagged as unused), and catch `ImportError` rather than only `ModuleNotFoundError`. Co-Authored-By: Claude Opus 5 (1M context) --- src/spatialdata_io/readers/merscope.py | 12 ++++++---- tests/test_merscope.py | 32 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 tests/test_merscope.py diff --git a/src/spatialdata_io/readers/merscope.py b/src/spatialdata_io/readers/merscope.py index aa703c35..d454d741 100644 --- a/src/spatialdata_io/readers/merscope.py +++ b/src/spatialdata_io/readers/merscope.py @@ -1,6 +1,6 @@ from __future__ import annotations -import importlib.util +import importlib import re import warnings from pathlib import Path @@ -236,9 +236,13 @@ def merscope( def _get_reader(backend: str | None) -> Callable[..., Image2DModel]: if backend is not None: return _rioxarray_load_merscope if backend == "rioxarray" else _dask_image_load_merscope - if importlib.util.find_spec("rioxarray") is not None: - return _rioxarray_load_merscope - return _dask_image_load_merscope + # `find_spec` only reports whether the module can be *found*: importing it can still fail, + # e.g. when `rasterio` is broken, and in that case we want the `dask_image` backend + try: + importlib.import_module("rioxarray") + except ImportError: + return _dask_image_load_merscope + return _rioxarray_load_merscope def _rioxarray_load_merscope( diff --git a/tests/test_merscope.py b/tests/test_merscope.py new file mode 100644 index 00000000..715bdc4e --- /dev/null +++ b/tests/test_merscope.py @@ -0,0 +1,32 @@ +import sys +from pathlib import Path + +import pytest + +from spatialdata_io.readers.merscope import ( + _dask_image_load_merscope, + _get_reader, + _rioxarray_load_merscope, +) + + +@pytest.fixture +def broken_rioxarray(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Shadow `rioxarray` with a module that can be found but not imported. + + This is what a `rioxarray` installation with a broken `rasterio` looks like. + """ + (tmp_path / "rioxarray.py").write_text("raise ModuleNotFoundError(\"No module named 'rasterio'\")\n") + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "rioxarray", raising=False) + + +def test_get_reader_honours_an_explicit_backend() -> None: + assert _get_reader("rioxarray") is _rioxarray_load_merscope + assert _get_reader("dask_image") is _dask_image_load_merscope + + +@pytest.mark.usefixtures("broken_rioxarray") +def test_get_reader_falls_back_when_rioxarray_cannot_be_imported() -> None: + """A `rioxarray` that is installed but raises on import must not select the rioxarray backend.""" + assert _get_reader(None) is _dask_image_load_merscope From 6b908f52dd0570bdecebc4f983da1a6c4f21e127 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 13:12:10 +0200 Subject: [PATCH 02/14] fix(dbit): do not fall back to the current working directory #411 replaced if path is not None: path = Path(path) if not os.path.isdir(path): raise FileNotFoundError(...) with `path = Path() if path is None else Path(path)`, to drop a `# type: ignore` on the `_check_path(path=path)` calls. `Path()` is `.`, which is always a directory, so `dbit()` with no `path` now searches the process's current working directory and reads whatever `.h5ad` and barcode file happen to be there. Previously that combination raised (`Path.joinpath(None, ...)` -> `AttributeError`). Restore the original handling and type `_check_path`'s `path` as `Path | None`, raising there when a directory search is needed but no directory was given. `dbit(anndata_path=..., barcode_position=...)` without a `path` keeps working, as it did before #411. Co-Authored-By: Claude Opus 5 (1M context) --- src/spatialdata_io/readers/dbit.py | 17 ++++++++------- tests/test_dbit.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 7 deletions(-) create mode 100644 tests/test_dbit.py diff --git a/src/spatialdata_io/readers/dbit.py b/src/spatialdata_io/readers/dbit.py index 51f96bd8..3bb744b7 100644 --- a/src/spatialdata_io/readers/dbit.py +++ b/src/spatialdata_io/readers/dbit.py @@ -27,7 +27,7 @@ def _check_path( - path: Path, + path: Path | None, pattern: Pattern[str], key: DbitKeys, path_specific: str | Path | None = None, @@ -85,6 +85,8 @@ def _check_path( raise FileNotFoundError(f"{path_specific} is not a valid path for a {key} file.") else: + if path is None: + raise ValueError(f"Either `path` or a specific path for the {key} file must be provided.") # search for the pattern matching file in path matches = [i for i in os.listdir(path) if pattern.match(i)] if len(matches) > 1: @@ -266,12 +268,13 @@ def dbit( ------- :class:`spatialdata.SpatialData`. """ - path = Path() if path is None else Path(path) - # if path is invalid, raise error - if not os.path.isdir(path): - raise FileNotFoundError( - f"The path you have passed: {path} has not been found. A correct path to the data directory is needed." - ) + if path is not None: + path = Path(path) + # if path is invalid, raise error + if not os.path.isdir(path): + raise FileNotFoundError( + f"The path you have passed: {path} has not been found. A correct path to the data directory is needed." + ) # compile regex pattern to find file name in path, according to _constants.DbitKeys() patt_h5ad = re.compile(f".*{DbitKeys.COUNTS_FILE}") diff --git a/tests/test_dbit.py b/tests/test_dbit.py new file mode 100644 index 00000000..f5833a48 --- /dev/null +++ b/tests/test_dbit.py @@ -0,0 +1,33 @@ +import re +from pathlib import Path + +import pytest + +from spatialdata_io._constants._constants import DbitKeys +from spatialdata_io.readers.dbit import _check_path + + +def test_check_path_without_a_directory_raises() -> None: + """Without a directory to search, `_check_path` must not fall back to the current one.""" + with pytest.raises(ValueError, match="Either `path` or a specific path"): + _check_path( + path=None, + pattern=re.compile(f".*{DbitKeys.COUNTS_FILE}"), + key=DbitKeys.COUNTS_FILE, + ) + + +def test_check_path_uses_the_specific_path_without_a_directory(tmp_path: Path) -> None: + """A file given explicitly is used even when no directory is given.""" + counts_file = tmp_path / f"counts{DbitKeys.COUNTS_FILE}" + counts_file.touch() + + file_path, flag = _check_path( + path=None, + pattern=re.compile(f".*{DbitKeys.COUNTS_FILE}"), + key=DbitKeys.COUNTS_FILE, + path_specific=counts_file, + ) + + assert file_path == counts_file + assert flag From aba347d40d2f40f2368d1bd24f2cef8fb26acc2f Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 13:15:30 +0200 Subject: [PATCH 03/14] fix(macsima): skip files whose OME metadata cannot be parsed, round positions Two problems, both of which abort `macsima()` on a folder containing one truncated TIFF. `create_sdata()`'s physical-size loop exists to tolerate invalid files ("Iterate over path files, as it may still contain invalid files"), but #411 narrowed its `except Exception` to `(OSError, ValueError, IndexError, NotImplementedError)`. The failure modes of `ome_types.from_tiff()` are not enumerable: a TIFF with a valid header truncated before its metadata raises `struct.error`, which is none of those, and tifffile's own `TiffFileError` has changed base class between releases. `MultiChannelImage.from_paths()` has the same problem with `except ValueError` and, being reached first, is what actually raises today. That guard predates #411; fixing only one of the two leaves the reader broken, so both are widened, with a comment explaining why a blind except is correct here. Separately, `_get_translations()` truncates the OME plane positions with `int()`. They are used as `da.pad` widths so they do have to be integers, but truncation biases every offset towards the origin (`0.9 -> 0`); round instead. Co-Authored-By: Claude Opus 5 (1M context) --- src/spatialdata_io/readers/macsima.py | 14 +++++++-- tests/test_macsima.py | 41 +++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/spatialdata_io/readers/macsima.py b/src/spatialdata_io/readers/macsima.py index cb7b3e75..b61f518d 100644 --- a/src/spatialdata_io/readers/macsima.py +++ b/src/spatialdata_io/readers/macsima.py @@ -83,7 +83,10 @@ def from_paths( for p in path_files: try: metadata = parse_metadata(p) - except ValueError as e: + # `path_files` may contain any file the user left in the folder, and the failure modes + # of `from_tiff()` are not enumerable (e.g. `struct.error` for a truncated header), so + # every file whose metadata cannot be parsed is skipped + except Exception as e: # noqa: BLE001 warnings.warn( f"Cannot parse OME metadata from {p}. Error: {e}. Skipping this file.", UserWarning, @@ -494,7 +497,9 @@ def _get_translations(ome: OME) -> dict[str, int]: logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)") translations = {"translation_x": 0, "translation_y": 0} else: - translations = {"translation_x": int(position_x), "translation_y": int(position_y)} + # the translations are used as `da.pad` widths, so they have to be integers; round + # instead of truncating, otherwise the offsets are biased towards the origin + translations = {"translation_x": round(position_x), "translation_y": round(position_y)} # In case the ome is faulty, also default to (0,0) except AttributeError: @@ -798,7 +803,10 @@ def create_sdata( for p in path_files: try: pixels_to_microns = parse_physical_size(p) - except (OSError, ValueError, IndexError, NotImplementedError): + # `path_files` may contain anything, including truncated or non-OME files: the failure + # modes of `from_tiff()` are not enumerable (e.g. `struct.error` for a truncated + # header), so every file that cannot be parsed is skipped + except Exception: # noqa: BLE001 logger.debug(f"Could not parse physical size from {p}. Trying next file.") continue if pixels_to_microns is None: diff --git a/tests/test_macsima.py b/tests/test_macsima.py index f557f8f4..2b30a33c 100644 --- a/tests/test_macsima.py +++ b/tests/test_macsima.py @@ -842,6 +842,29 @@ def test_get_translations_returns_correct_values() -> None: assert translations == expected +def test_get_translations_rounds_fractional_positions() -> None: + """The translations are used as `da.pad` widths, so they must be rounded, not truncated.""" + ome = OME( + images=[ + Image( + pixels=Pixels( + dimension_order=Pixels_DimensionOrder("XYZCT"), + type=PixelType.UINT16, + size_x=1, + size_y=1, + size_z=1, + size_c=1, + size_t=1, + planes=[Plane(position_x=10.7, position_y=0.9, the_z=0, the_t=0, the_c=0)], + ) + ) + ] + ) + + translations = _get_translations(ome) + assert translations == {"translation_x": 11, "translation_y": 1} + + def test_get_translations_defaults_to_0_on_missing_data() -> None: ome = OME( images=[ @@ -913,3 +936,21 @@ def test_parse_ome_metadata_unknown_major_raises() -> None: with pytest.raises(ValueError, match="Unknown software version"): _parse_ome_metadata(ome) + + +def test_macsima_skips_files_whose_physical_size_cannot_be_parsed(tmp_path: Path) -> None: + """A single unreadable file in the folder must not abort the reader. + + `path_files` can contain anything the user left in the folder, and the failure modes of + `ome_types.from_tiff()` are not enumerable: a truncated TIFF header raises `struct.error`. + """ + dataset = tmp_path / "OMAP10_small" + shutil.copytree("./data/OMAP10_small", dataset) + reference = sorted(dataset.glob("*.tif"))[0] + # a TIFF with a valid header but truncated before the metadata + truncated = dataset / "C-099_S-000_S_APC_R-01_W-C-1_ROI-01_A-Junk_C-JUNK.tif" + truncated.write_bytes(reference.read_bytes()[:200]) + + sdata = macsima(dataset, subset=32, c_subset=4, multiscale=False) + + assert "OMAP10_small_image" in sdata.images From 49c5d20cb62aa9650fface386b4d8a0b6d3bf21c Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 13:15:40 +0200 Subject: [PATCH 04/14] test(visium): cover the reader with the CytAssist breast cancer dataset The plain Visium reader had no test, which is how a hard `TypeError` on the first call shipped in #411. Add the 10x Space Ranger 2.0.0 CytAssist FFPE Human Breast Cancer dataset (CC BY 4.0) to the test-data workflow and assert the property that broke: the circles are the spot coordinates of the table, in the order of the table, with the radius taken from the scalefactors. The expected coordinates are read from `spatial/tissue_positions.csv` independently of the reader. Only the filtered matrix and the `spatial/` archive are downloaded (~65 MB); the full-resolution tissue image is ~2 GB and `fullres_image_file` is optional. The test skips until the data artifact is regenerated by running the `Prepare test data` workflow, as its header describes. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/prepare_test_data.yaml | 17 ++++++++ tests/test_visium.py | 55 ++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 tests/test_visium.py diff --git a/.github/workflows/prepare_test_data.yaml b/.github/workflows/prepare_test_data.yaml index 14475c66..bbe649a0 100644 --- a/.github/workflows/prepare_test_data.yaml +++ b/.github/workflows/prepare_test_data.yaml @@ -52,6 +52,23 @@ jobs: # 10x Genomics Xenium 4.0.0 (v1+Protein) Human kidney, multimodal cell segmentation curl -O https://cf.10xgenomics.com/samples/xenium/4.0.0/Xenium_V1_Protein_Human_Kidney_tiny/Xenium_V1_Protein_Human_Kidney_tiny_outs.zip + # ------- + # the Visium dataset is licensed as CC BY 4.0, as shown here + # https://www.10xgenomics.com/support/software/space-ranger/latest/resources/visium-example-data + + # 10x Genomics Space Ranger 2.0.0 CytAssist FFPE Human Breast Cancer. + # The full-resolution tissue image is deliberately not downloaded (~2 GB): `fullres_image_file` + # is optional and the reader path it exercises is shared with the other readers. + VISIUM_DIR=CytAssist_FFPE_Human_Breast_Cancer + VISIUM_URL=https://cf.10xgenomics.com/samples/spatial-exp/2.0.0/CytAssist_FFPE_Human_Breast_Cancer + mkdir -p "$VISIUM_DIR" + curl -o "$VISIUM_DIR/CytAssist_FFPE_Human_Breast_Cancer_filtered_feature_bc_matrix.h5" \ + "$VISIUM_URL/CytAssist_FFPE_Human_Breast_Cancer_filtered_feature_bc_matrix.h5" + # the archive contains a single `spatial/` directory + curl -o spatial.tar.gz "$VISIUM_URL/CytAssist_FFPE_Human_Breast_Cancer_spatial.tar.gz" + tar -xzf spatial.tar.gz -C "$VISIUM_DIR" + rm spatial.tar.gz + # ------- # the Visium HD dataset is licensed as CC BY 4.0, as shown here # https://www.10xgenomics.com/support/software/space-ranger/latest/resources/visium-hd-example-data diff --git a/tests/test_visium.py b/tests/test_visium.py new file mode 100644 index 00000000..5a145967 --- /dev/null +++ b/tests/test_visium.py @@ -0,0 +1,55 @@ +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 + +# This dataset name is used to locate the test data in the './data/' directory. +# See https://github.com/scverse/spatialdata-io/blob/main/.github/workflows/prepare_test_data.yaml +# for instructions on how to download and place the data on disk. +DATASET_FOLDER = "CytAssist_FFPE_Human_Breast_Cancer" +DATASET_ID = "visium_breast_cancer" + +if not (Path("./data") / DATASET_FOLDER).is_dir(): + pytest.skip( + f"Requires the {DATASET_FOLDER} dataset. It can be downloaded from " + "https://www.10xgenomics.com/datasets/human-breast-cancer-ductal-carcinoma-in-situ-invasive-carcinoma-ffpe-1-standard", + allow_module_level=True, + ) + + +@pytest.fixture(scope="module") +def dataset_path() -> Path: + return Path("./data") / DATASET_FOLDER + + +def test_visium_circles_are_the_spot_coordinates(dataset_path: 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()`, which raised + `TypeError: ShapesModel.parse() does not support the type `. + """ + sdata = visium(dataset_path, dataset_id=DATASET_ID) + + circles = sdata[DATASET_ID] + table = sdata["table"] + + # ground truth, read independently of the reader and reordered to follow the table + positions = pd.read_csv(dataset_path / "spatial" / VisiumKeys.SPOTS_FILE_2, index_col=0) + expected = positions.loc[table.obs_names, [VisiumKeys.SPOTS_X, VisiumKeys.SPOTS_Y]].to_numpy() + + assert np.array_equal(circles.get_coordinates().to_numpy(), expected) + assert np.array_equal(table.obsm["spatial"], expected) + assert circles.index.tolist() == table.obs["spot_id"].tolist() + assert all(isinstance(geometry, Point) for geometry in circles.geometry) + + scalefactors = json.loads((dataset_path / "spatial" / VisiumKeys.SCALEFACTORS_FILE).read_bytes()) + expected_radius = scalefactors["spot_diameter_fullres"] / 2.0 + assert np.array_equal(circles[ShapesModel.RADIUS_KEY], np.full(len(table), expected_radius)) From f30c00f1a46803e69b4771979ead7a9cb1cd0ab6 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 13:15:40 +0200 Subject: [PATCH 05/14] docs: changelog entries for the #411 regression fixes Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82bbd8e8..03b70bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,14 @@ Release notes for `v0.7.1` and earlier are available on the [Releases][] page. - `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 `. +- `merscope()`: a `rioxarray` that is installed but cannot be imported (e.g. a broken `rasterio`) falls back to the + `dask_image` backend again, instead of failing with a misleading "requires to install the rioxarray library". +- `dbit()`: without a `path` the reader no longer searches the current working directory; it raises unless the + individual file paths are given. +- `macsima()`: files whose OME metadata cannot be parsed are skipped again rather than aborting the reader; a + truncated TIFF raises `struct.error`, which is neither a `ValueError` nor one of the types the physical-size loop + was catching. +- `macsima()`: the plane positions used as padding widths are rounded instead of truncated towards zero. ### Removed From 3a090213a0081dc432ab0cd64c255554526e7317 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 14:24:19 +0200 Subject: [PATCH 06/14] docs(visium): replace the invented 10x attribution links `space-ranger/latest/resources/visium-example-data` does not exist: only Visium HD has an `*-example-data` page, and the Space Ranger docs point non-HD Visium users to the datasets portal. Cite the portal instead. The dataset URL in the skip message was also a guess, and its slug belongs to a different dataset (Space Ranger 1.3.0, not the CytAssist 2.0.0 one used here). Point at the download prefix the workflow actually uses, which is verifiable, and at the workflow itself for the layout. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/prepare_test_data.yaml | 6 ++++-- tests/test_visium.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare_test_data.yaml b/.github/workflows/prepare_test_data.yaml index bbe649a0..e4a16953 100644 --- a/.github/workflows/prepare_test_data.yaml +++ b/.github/workflows/prepare_test_data.yaml @@ -53,8 +53,10 @@ jobs: curl -O https://cf.10xgenomics.com/samples/xenium/4.0.0/Xenium_V1_Protein_Human_Kidney_tiny/Xenium_V1_Protein_Human_Kidney_tiny_outs.zip # ------- - # the Visium dataset is licensed as CC BY 4.0, as shown here - # https://www.10xgenomics.com/support/software/space-ranger/latest/resources/visium-example-data + # the Visium dataset is licensed as CC BY 4.0, as stated on its page in the datasets portal + # https://www.10xgenomics.com/datasets + # (unlike Visium HD, non-HD Visium has no `*-example-data` page under the Space Ranger docs; + # those docs point to the datasets portal instead) # 10x Genomics Space Ranger 2.0.0 CytAssist FFPE Human Breast Cancer. # The full-resolution tissue image is deliberately not downloaded (~2 GB): `fullres_image_file` diff --git a/tests/test_visium.py b/tests/test_visium.py index 5a145967..5c757ba5 100644 --- a/tests/test_visium.py +++ b/tests/test_visium.py @@ -18,8 +18,10 @@ if not (Path("./data") / DATASET_FOLDER).is_dir(): pytest.skip( - f"Requires the {DATASET_FOLDER} dataset. It can be downloaded from " - "https://www.10xgenomics.com/datasets/human-breast-cancer-ductal-carcinoma-in-situ-invasive-carcinoma-ffpe-1-standard", + f"Requires the {DATASET_FOLDER} dataset (10x Genomics Space Ranger 2.0.0). The files and the " + "layout they are expected in are listed in .github/workflows/prepare_test_data.yaml; they are " + "downloaded from " + "https://cf.10xgenomics.com/samples/spatial-exp/2.0.0/CytAssist_FFPE_Human_Breast_Cancer/", allow_module_level=True, ) From 17f38c4cb097fa617b6bdc92c9ddaff066eca78c Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 14:43:21 +0200 Subject: [PATCH 07/14] fix visium dataset download for tests --- .github/workflows/prepare_test_data.yaml | 27 ++++++++++++------------ 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/.github/workflows/prepare_test_data.yaml b/.github/workflows/prepare_test_data.yaml index e4a16953..a72da9d6 100644 --- a/.github/workflows/prepare_test_data.yaml +++ b/.github/workflows/prepare_test_data.yaml @@ -53,23 +53,17 @@ jobs: curl -O https://cf.10xgenomics.com/samples/xenium/4.0.0/Xenium_V1_Protein_Human_Kidney_tiny/Xenium_V1_Protein_Human_Kidney_tiny_outs.zip # ------- - # the Visium dataset is licensed as CC BY 4.0, as stated on its page in the datasets portal - # https://www.10xgenomics.com/datasets - # (unlike Visium HD, non-HD Visium has no `*-example-data` page under the Space Ranger docs; - # those docs point to the datasets portal instead) + # the Visium dataset is licensed as CC BY 4.0, as shown here + # https://www.10xgenomics.com/datasets/gene-and-protein-expression-library-of-human-breast-cancer-cytassist-ffpe-2-standard - # 10x Genomics Space Ranger 2.0.0 CytAssist FFPE Human Breast Cancer. + # 10x Genomics Visium CytAssist Gene and Protein Expression Library of Human Breast Cancer, IF, 6.5mm (FFPE) + mkdir -p CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer + cd CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer # The full-resolution tissue image is deliberately not downloaded (~2 GB): `fullres_image_file` # is optional and the reader path it exercises is shared with the other readers. - VISIUM_DIR=CytAssist_FFPE_Human_Breast_Cancer - VISIUM_URL=https://cf.10xgenomics.com/samples/spatial-exp/2.0.0/CytAssist_FFPE_Human_Breast_Cancer - mkdir -p "$VISIUM_DIR" - curl -o "$VISIUM_DIR/CytAssist_FFPE_Human_Breast_Cancer_filtered_feature_bc_matrix.h5" \ - "$VISIUM_URL/CytAssist_FFPE_Human_Breast_Cancer_filtered_feature_bc_matrix.h5" - # the archive contains a single `spatial/` directory - curl -o spatial.tar.gz "$VISIUM_URL/CytAssist_FFPE_Human_Breast_Cancer_spatial.tar.gz" - tar -xzf spatial.tar.gz -C "$VISIUM_DIR" - rm spatial.tar.gz + curl -O https://cf.10xgenomics.com/samples/spatial-exp/2.1.0/CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer/CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer_filtered_feature_bc_matrix.h5 + curl -O https://cf.10xgenomics.com/samples/spatial-exp/2.1.0/CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer/CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer_spatial.tar.gz + cd .. # ------- # the Visium HD dataset is licensed as CC BY 4.0, as shown here @@ -100,6 +94,11 @@ jobs: unzip "$file" -d "$dir" rm "$file" done + # the Visium archive contains a single `spatial/` directory, extracted next to the `.h5` + for file in */*.tar.gz; do + tar -xzf "$file" -C "$(dirname "$file")" + rm "$file" + done - name: Upload artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 From 0d82314084cd5aca9207b98d6ece5c6de278dfd8 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:01:32 +0200 Subject: [PATCH 08/14] fix dataset name test_visium.py --- tests/test_visium.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_visium.py b/tests/test_visium.py index 5c757ba5..e84da8f2 100644 --- a/tests/test_visium.py +++ b/tests/test_visium.py @@ -13,15 +13,13 @@ # This dataset name is used to locate the test data in the './data/' directory. # See https://github.com/scverse/spatialdata-io/blob/main/.github/workflows/prepare_test_data.yaml # for instructions on how to download and place the data on disk. -DATASET_FOLDER = "CytAssist_FFPE_Human_Breast_Cancer" +DATASET_FOLDER = "CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer" DATASET_ID = "visium_breast_cancer" if not (Path("./data") / DATASET_FOLDER).is_dir(): pytest.skip( - f"Requires the {DATASET_FOLDER} dataset (10x Genomics Space Ranger 2.0.0). The files and the " - "layout they are expected in are listed in .github/workflows/prepare_test_data.yaml; they are " - "downloaded from " - "https://cf.10xgenomics.com/samples/spatial-exp/2.0.0/CytAssist_FFPE_Human_Breast_Cancer/", + f"Requires the {DATASET_FOLDER} dataset (10x Genomics Space Ranger 2.1.0). The files and the " + "layout they are expected in are listed in .github/workflows/prepare_test_data.yaml.", allow_module_level=True, ) From f921772b521a5d25ffe9b5af39216ec87acb572f Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:10:46 +0200 Subject: [PATCH 09/14] docs: drop the changelog entries for the #411 regressions The five "Fixed" entries described bugs introduced by the cookiecutter migration (#411), which landed after v0.7.1 and has never been released. Reporting them as fixes is misleading: no released version ever had them, so there is nothing for a user to have hit. They are internal churn within the template migration that the "Changed" entry already covers. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03b70bcc..6521a5dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,20 +23,6 @@ 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 - `. -- `merscope()`: a `rioxarray` that is installed but cannot be imported (e.g. a broken `rasterio`) falls back to the - `dask_image` backend again, instead of failing with a misleading "requires to install the rioxarray library". -- `dbit()`: without a `path` the reader no longer searches the current working directory; it raises unless the - individual file paths are given. -- `macsima()`: files whose OME metadata cannot be parsed are skipped again rather than aborting the reader; a - truncated TIFF raises `struct.error`, which is neither a `ValueError` nor one of the types the physical-size loop - was catching. -- `macsima()`: the plane positions used as padding widths are rounded instead of truncated towards zero. - ### Removed - Support for Python 3.11. From 1243e536b1ee338823ce13be69ef54bce12f8480 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:18:05 +0200 Subject: [PATCH 10/14] test(visium): cover the reader end to end instead of only the regression Reshape the single regression test into a reader test suite in the style of the xenium, visium_hd and macsima ones: element names and coordinate systems, the extent of each coordinate system, the two downscaled images, the table and its annotation, the circles (the former regression test, kept as the check that the circles are the spot coordinates in the order of the table), the dataset_id inferred from the counts file, and a CLI roundtrip. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_visium.py | 124 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 9 deletions(-) diff --git a/tests/test_visium.py b/tests/test_visium.py index e84da8f2..3dad176f 100644 --- a/tests/test_visium.py +++ b/tests/test_visium.py @@ -1,20 +1,28 @@ import json +import math from pathlib import Path +from tempfile import TemporaryDirectory import numpy as np import pandas as pd import pytest +from click.testing import CliRunner from shapely import Point -from spatialdata.models import ShapesModel +from spatialdata import SpatialData, get_extent, read_zarr +from spatialdata.models import ShapesModel, get_table_keys +from spatialdata.transformations import get_transformation +from spatialdata_io.__main__ import visium_wrapper from spatialdata_io._constants._constants import VisiumKeys from spatialdata_io.readers.visium import visium +# --- END-TO-END TESTS ON EXAMPLE DATA --- # This dataset name is used to locate the test data in the './data/' directory. # See https://github.com/scverse/spatialdata-io/blob/main/.github/workflows/prepare_test_data.yaml # for instructions on how to download and place the data on disk. DATASET_FOLDER = "CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer" -DATASET_ID = "visium_breast_cancer" +# the reader infers the same value from the name of the counts file +DATASET_ID = "CytAssist_FFPE_Protein_Expression_Human_Breast_Cancer" if not (Path("./data") / DATASET_FOLDER).is_dir(): pytest.skip( @@ -29,15 +37,81 @@ def dataset_path() -> Path: return Path("./data") / DATASET_FOLDER -def test_visium_circles_are_the_spot_coordinates(dataset_path: Path) -> None: - """The circles are the spot coordinates of the table, in the order of the table. +@pytest.fixture(scope="module") +def sdata(dataset_path: Path) -> SpatialData: + return visium(dataset_path, dataset_id=DATASET_ID) + + +def test_visium_elements(sdata: SpatialData) -> None: + """The reader builds the two downscaled images, the spots and the table.""" + assert list(sdata.images) == [f"{DATASET_ID}_hires_image", f"{DATASET_ID}_lowres_image"] + assert list(sdata.shapes) == [DATASET_ID] + assert list(sdata.tables) == ["table"] + # no full resolution image is passed, so `_full_image` is not created + assert f"{DATASET_ID}_full_image" not in sdata.images + assert sorted(sdata.coordinate_systems) == sorted( + [DATASET_ID, f"{DATASET_ID}_downscaled_hires", f"{DATASET_ID}_downscaled_lowres"] + ) + assert sdata.attrs["spatialdata_io_reader"] == "visium" + + +@pytest.mark.parametrize( + "coordinate_system,expected", + [ + (DATASET_ID, {"y": (0, 22630), "x": (-125, 23128)}), + (f"{DATASET_ID}_downscaled_hires", {"y": (0, 1957), "x": (-11, 2000)}), + (f"{DATASET_ID}_downscaled_lowres", {"y": (0, 587), "x": (-4, 600)}), + ], +) +def test_visium_data_extent(sdata: SpatialData, coordinate_system: str, expected: dict[str, tuple[int, int]]) -> None: + """Each coordinate system covers the image, plus the spots that fall outside of it.""" + extent = get_extent(sdata, exact=False, coordinate_system=coordinate_system) + extent = {ax: (math.floor(extent[ax][0]), math.ceil(extent[ax][1])) for ax in extent} + assert extent == expected + - Regression test for the case in which the raw `tissue_positions` table was passed to - `ShapesModel.parse()`, which raised - `TypeError: ShapesModel.parse() does not support the type `. - """ - sdata = visium(dataset_path, dataset_id=DATASET_ID) +def test_visium_images(sdata: SpatialData) -> None: + """The two downscaled images are read as RGB, and each lives in its own coordinate system.""" + hires = sdata[f"{DATASET_ID}_hires_image"] + lowres = sdata[f"{DATASET_ID}_lowres_image"] + + for image in (hires, lowres): + assert image.dims == ("c", "y", "x") + assert image.dtype == np.uint8 + assert image.coords["c"].values.tolist() == ["r", "g", "b"] + + assert hires.shape == (3, 1957, 2000) + assert lowres.shape == (3, 587, 600) + assert np.array_equal(hires.data[:, 1000, 1000].compute(), [8, 5, 92]) + assert np.array_equal(lowres.data[:, 300, 300].compute(), [13, 8, 63]) + + # each image is unscaled in its own coordinate system, and scaled down in the one of the full resolution image + assert sorted(get_transformation(hires, get_all=True)) == sorted([DATASET_ID, f"{DATASET_ID}_downscaled_hires"]) + assert sorted(get_transformation(lowres, get_all=True)) == sorted([DATASET_ID, f"{DATASET_ID}_downscaled_lowres"]) + + +def test_visium_table(sdata: SpatialData) -> None: + """The table holds the filtered counts, annotated by the spots.""" + table = sdata["table"] + assert table.shape == (4169, 18085) + assert table.obs_names[:3].tolist() == ["AACACTTGGCAAGGAA-1", "AACAGGATTCATAGTT-1", "AACAGGCCAACGATTA-1"] + assert table.var_names[:3].tolist() == ["SAMD11", "NOC2L", "KLHL17"] + assert table.obs_names.is_unique + assert table.var_names.is_unique + assert np.array_equal(table.X.indices[:3], [3, 6, 7]) + + # the spot coordinates are moved to `obsm`, the remaining columns of `tissue_positions` are kept + assert table.obs.columns.tolist() == ["in_tissue", "array_row", "array_col", "spot_id", "region"] + assert table.obs["spot_id"].tolist() == list(range(len(table))) + # the filtered matrix only contains the spots under the tissue + assert table.obs["in_tissue"].eq(1).all() + + assert get_table_keys(table) == (DATASET_ID, "region", "spot_id") + + +def test_visium_circles_are_the_spot_coordinates(sdata: SpatialData, dataset_path: Path) -> None: + """The circles are the spot coordinates of the table, in the order of the table.""" circles = sdata[DATASET_ID] table = sdata["table"] @@ -53,3 +127,35 @@ def test_visium_circles_are_the_spot_coordinates(dataset_path: Path) -> None: scalefactors = json.loads((dataset_path / "spatial" / VisiumKeys.SCALEFACTORS_FILE).read_bytes()) expected_radius = scalefactors["spot_diameter_fullres"] / 2.0 assert np.array_equal(circles[ShapesModel.RADIUS_KEY], np.full(len(table), expected_radius)) + + +def test_visium_dataset_id_is_inferred_from_the_counts_file(dataset_path: Path, sdata: SpatialData) -> None: + """Without `dataset_id` the elements are named after the prefix of the counts file.""" + inferred = visium(dataset_path) + + assert list(inferred.images) == list(sdata.images) + assert list(inferred.shapes) == list(sdata.shapes) + assert sorted(inferred.coordinate_systems) == sorted(sdata.coordinate_systems) + + +# --- CLI WRAPPER TEST --- + + +def test_cli_visium(runner: CliRunner, dataset_path: Path) -> None: + """The reader is reachable from the command line and the result can be written and read back.""" + with TemporaryDirectory() as tmpdir: + output_zarr = Path(tmpdir) / "data.zarr" + result = runner.invoke( + visium_wrapper, + [ + "--input", + str(dataset_path), + "--output", + str(output_zarr), + ], + ) + assert result.exit_code == 0, result.output + + sdata = read_zarr(output_zarr) + assert list(sdata.shapes) == [DATASET_ID] + assert sdata["table"].shape == (4169, 18085) From 7ff0ebec5ea38daba50d5f40eeefe5e81ef92591 Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:22:20 +0200 Subject: [PATCH 11/14] revert(macsima): keep truncating the OME plane positions Whether the plane positions can be used as pixel padding widths at all is a question for the MACSima maintainers, not something to change in a regression fix: rounding them only removes the sub-pixel bias of `int()` and leaves the larger question of the unit open. Tracked in an issue instead. The widened `except` clauses stay: without them one unreadable file in the folder aborts the reader. Co-Authored-By: Claude Opus 5 (1M context) --- src/spatialdata_io/readers/macsima.py | 4 +--- tests/test_macsima.py | 23 ----------------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/src/spatialdata_io/readers/macsima.py b/src/spatialdata_io/readers/macsima.py index b61f518d..20d9f859 100644 --- a/src/spatialdata_io/readers/macsima.py +++ b/src/spatialdata_io/readers/macsima.py @@ -497,9 +497,7 @@ def _get_translations(ome: OME) -> dict[str, int]: logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)") translations = {"translation_x": 0, "translation_y": 0} else: - # the translations are used as `da.pad` widths, so they have to be integers; round - # instead of truncating, otherwise the offsets are biased towards the origin - translations = {"translation_x": round(position_x), "translation_y": round(position_y)} + translations = {"translation_x": int(position_x), "translation_y": int(position_y)} # In case the ome is faulty, also default to (0,0) except AttributeError: diff --git a/tests/test_macsima.py b/tests/test_macsima.py index 2b30a33c..9fb5140a 100644 --- a/tests/test_macsima.py +++ b/tests/test_macsima.py @@ -842,29 +842,6 @@ def test_get_translations_returns_correct_values() -> None: assert translations == expected -def test_get_translations_rounds_fractional_positions() -> None: - """The translations are used as `da.pad` widths, so they must be rounded, not truncated.""" - ome = OME( - images=[ - Image( - pixels=Pixels( - dimension_order=Pixels_DimensionOrder("XYZCT"), - type=PixelType.UINT16, - size_x=1, - size_y=1, - size_z=1, - size_c=1, - size_t=1, - planes=[Plane(position_x=10.7, position_y=0.9, the_z=0, the_t=0, the_c=0)], - ) - ) - ] - ) - - translations = _get_translations(ome) - assert translations == {"translation_x": 11, "translation_y": 1} - - def test_get_translations_defaults_to_0_on_missing_data() -> None: ome = OME( images=[ From 7d035972256a965ee236b0b5552c78908e5332ab Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:26:19 +0200 Subject: [PATCH 12/14] test(macsima): assert the skip warning names the unreadable file The test only checked that the reader survives the truncated TIFF; it now also pins the warning that says which file was skipped, so a silent skip fails too. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test_macsima.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_macsima.py b/tests/test_macsima.py index 9fb5140a..e46613d2 100644 --- a/tests/test_macsima.py +++ b/tests/test_macsima.py @@ -1,6 +1,7 @@ import contextlib import math import os +import re import shutil from copy import deepcopy from pathlib import Path @@ -928,6 +929,7 @@ def test_macsima_skips_files_whose_physical_size_cannot_be_parsed(tmp_path: Path truncated = dataset / "C-099_S-000_S_APC_R-01_W-C-1_ROI-01_A-Junk_C-JUNK.tif" truncated.write_bytes(reference.read_bytes()[:200]) - sdata = macsima(dataset, subset=32, c_subset=4, multiscale=False) + with pytest.warns(UserWarning, match=re.escape(f"Cannot parse OME metadata from {truncated}")): + sdata = macsima(dataset, subset=32, c_subset=4, multiscale=False) assert "OMAP10_small_image" in sdata.images From bd5ddfc1e7121aa05bdd983f2c40ab2d9112e4ca Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:30:03 +0200 Subject: [PATCH 13/14] build: exclude the downloaded test data from mypy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `mypy src tests` follows `tests/data`, one of the two gitignored locations the test datasets are downloaded to, and type-checks whatever scripts happen to sit next to the data. CI never sees it — it downloads the artifact to `data/` and the pre-commit job downloads nothing — but locally the hook fails on files that are not ours. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 41f06caf..9c7135e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -146,6 +146,11 @@ lint.per-file-ignores."docs/*" = [ "I" ] lint.per-file-ignores."tests/*" = [ "D" ] lint.pydocstyle.convention = "numpy" +[tool.mypy] +# `/tests/data/` is one of the gitignored locations the test datasets are downloaded to; it is +# not source of ours, and it can hold whatever scripts were used to prepare the data. +exclude = "^tests/data/" + # Dependencies that ship neither inline types nor stubs. Listed explicitly rather than # globally, so that a newly added untyped dependency is still reported. [[tool.mypy.overrides]] From 46ac6bf65f90e5143c7bcaa395cb18a08d0b3dfe Mon Sep 17 00:00:00 2001 From: Luca Marconato Date: Wed, 9 Sep 2026 15:58:17 +0200 Subject: [PATCH 14/14] better docstring test dbit --- tests/test_dbit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dbit.py b/tests/test_dbit.py index f5833a48..6aa32280 100644 --- a/tests/test_dbit.py +++ b/tests/test_dbit.py @@ -8,7 +8,7 @@ def test_check_path_without_a_directory_raises() -> None: - """Without a directory to search, `_check_path` must not fall back to the current one.""" + """Without a directory to search, `_check_path` should raise an exception.""" with pytest.raises(ValueError, match="Either `path` or a specific path"): _check_path( path=None,