spatialdata-io 0.1.7__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.github/release.yml +11 -5
  2. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.github/workflows/prepare_test_data.yaml +3 -1
  3. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.github/workflows/test.yaml +2 -8
  4. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.pre-commit-config.yaml +5 -5
  5. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/LICENSE +1 -1
  6. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/PKG-INFO +19 -6
  7. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/README.md +17 -4
  8. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/_version.py +9 -4
  9. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/__main__.py +4 -2
  10. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/_constants/_constants.py +3 -0
  11. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/converters/generic_to_zarr.py +2 -2
  12. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/cosmx.py +1 -9
  13. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/generic.py +2 -0
  14. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/visium.py +17 -7
  15. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/visium_hd.py +154 -33
  16. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/xenium.py +29 -10
  17. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/test_generic.py +1 -1
  18. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.bumpversion.cfg +0 -0
  19. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.codecov.yaml +0 -0
  20. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.editorconfig +0 -0
  21. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.flake8 +0 -0
  22. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.github/codecov.yml +0 -0
  23. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.github/workflows/release.yaml +0 -0
  24. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.gitignore +0 -0
  25. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.mypy.ini +0 -0
  26. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/.readthedocs.yaml +0 -0
  27. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/Makefile +0 -0
  28. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/_static/.gitkeep +0 -0
  29. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/_templates/.gitkeep +0 -0
  30. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/_templates/autosummary/class.rst +0 -0
  31. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/api.md +0 -0
  32. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/changelog.md +0 -0
  33. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/cli.md +0 -0
  34. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/conf.py +0 -0
  35. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/contributing.md +0 -0
  36. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/extensions/.gitkeep +0 -0
  37. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/extensions/typed_returns.py +0 -0
  38. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/index.md +0 -0
  39. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/make.bat +0 -0
  40. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/references.bib +0 -0
  41. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/docs/references.md +0 -0
  42. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/pyproject.toml +0 -0
  43. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/__init__.py +0 -0
  44. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/_constants/__init__.py +0 -0
  45. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/_constants/_enum.py +0 -0
  46. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/_docs.py +0 -0
  47. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/_utils.py +0 -0
  48. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/converters/__init__.py +0 -0
  49. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/converters/legacy_anndata.py +0 -0
  50. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/experimental/__init__.py +0 -0
  51. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/__init__.py +0 -0
  52. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/_utils/__init__.py +0 -0
  53. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/_utils/_read_10x_h5.py +0 -0
  54. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/_utils/_utils.py +0 -0
  55. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/codex.py +0 -0
  56. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/curio.py +0 -0
  57. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/dbit.py +0 -0
  58. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/iss.py +0 -0
  59. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/macsima.py +0 -0
  60. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/mcmicro.py +0 -0
  61. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/merscope.py +0 -0
  62. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/seqfish.py +0 -0
  63. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/steinbock.py +0 -0
  64. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/src/spatialdata_io/readers/stereoseq.py +0 -0
  65. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/__init__.py +0 -0
  66. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/_utils.py +0 -0
  67. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/conftest.py +0 -0
  68. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/converters/__init__.py +0 -0
  69. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/converters/test_legacy_anndata.py +0 -0
  70. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/test_basic.py +0 -0
  71. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/test_macsima.py +0 -0
  72. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/test_seqfish.py +0 -0
  73. {spatialdata_io-0.1.7 → spatialdata_io-0.2.0}/tests/test_xenium.py +0 -0
@@ -5,18 +5,24 @@ changelog:
5
5
  authors:
6
6
  - pre-commit-ci
7
7
  categories:
8
- - title: Major
8
+ - title: Added
9
9
  labels:
10
- - "release-major"
11
- - title: Minor
12
- labels:
13
- - "release-minor"
10
+ - "release-added"
14
11
  - title: Changed
15
12
  labels:
16
13
  - "release-changed"
14
+ - title: Deprecated
15
+ labels:
16
+ - "release-deprecated"
17
+ - title: Removed
18
+ labels:
19
+ - "release-removed"
17
20
  - title: Fixed
18
21
  labels:
19
22
  - "release-fixed"
23
+ - title: Security
24
+ labels:
25
+ - "release-security"
20
26
  - title: Other Changes
21
27
  labels:
22
28
  - "*"
@@ -1,6 +1,8 @@
1
1
  name: Prepare test data
2
2
 
3
3
  on:
4
+ schedule:
5
+ - cron: "0 0 1 * *" # run once a month to prevent artifact expiration
4
6
  workflow_dispatch:
5
7
  # uncomment and adjust the branch name if you need to add new datasets to the artifact
6
8
  # push:
@@ -44,7 +46,7 @@ jobs:
44
46
  done
45
47
 
46
48
  - name: Upload artifacts
47
- uses: actions/upload-artifact@v3
49
+ uses: actions/upload-artifact@v4
48
50
  with:
49
51
  name: data
50
52
  path: ./data
@@ -37,7 +37,7 @@ jobs:
37
37
  run: |
38
38
  echo "::set-output name=dir::$(pip cache dir)"
39
39
  - name: Restore pip cache
40
- uses: actions/cache@v2
40
+ uses: actions/cache@v3
41
41
  with:
42
42
  path: ${{ steps.pip-cache-dir.outputs.dir }}
43
43
  key: pip-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('**/pyproject.toml') }}
@@ -53,18 +53,12 @@ jobs:
53
53
 
54
54
  - name: Download artifact of test data
55
55
  if: matrix.python == '3.12'
56
- uses: dawidd6/action-download-artifact@v2
56
+ uses: dawidd6/action-download-artifact@v9
57
57
  with:
58
58
  workflow: prepare_test_data.yaml
59
59
  name: data
60
60
  path: ./data
61
61
 
62
- - name: List the data directory
63
- if: matrix.python == '3.12'
64
- run: |
65
- ls -l ./data
66
- pwd
67
-
68
62
  - name: Test
69
63
  env:
70
64
  MPLBACKEND: agg
@@ -7,11 +7,11 @@ default_stages:
7
7
  minimum_pre_commit_version: 2.16.0
8
8
  repos:
9
9
  - repo: https://github.com/psf/black
10
- rev: 24.10.0
10
+ rev: 25.1.0
11
11
  hooks:
12
12
  - id: black
13
13
  - repo: https://github.com/rbubley/mirrors-prettier
14
- rev: v3.4.2
14
+ rev: v3.5.1
15
15
  hooks:
16
16
  - id: prettier
17
17
  - repo: https://github.com/asottile/blacken-docs
@@ -19,11 +19,11 @@ repos:
19
19
  hooks:
20
20
  - id: blacken-docs
21
21
  - repo: https://github.com/PyCQA/isort
22
- rev: 5.13.2
22
+ rev: 6.0.0
23
23
  hooks:
24
24
  - id: isort
25
25
  - repo: https://github.com/pre-commit/mirrors-mypy
26
- rev: v1.14.1
26
+ rev: v1.15.0
27
27
  hooks:
28
28
  - id: mypy
29
29
  additional_dependencies: [numpy, types-PyYAML]
@@ -59,7 +59,7 @@ repos:
59
59
  - --remove-unused-variable
60
60
  - --ignore-init-module-imports
61
61
  - repo: https://github.com/PyCQA/flake8
62
- rev: 7.1.1
62
+ rev: 7.1.2
63
63
  hooks:
64
64
  - id: flake8
65
65
  additional_dependencies:
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2022, scverse
3
+ Copyright (c) 2022, scverse®
4
4
  All rights reserved.
5
5
 
6
6
  Redistribution and use in source and binary forms, with or without
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdata-io
3
- Version: 0.1.7
3
+ Version: 0.2.0
4
4
  Summary: SpatialData IO for common techs
5
5
  Project-URL: Documentation, https://spatialdata-io.readthedocs.io/
6
6
  Project-URL: Source, https://github.com/scverse/spatialdata-io
@@ -9,7 +9,7 @@ Author: scverse
9
9
  Maintainer-email: scverse <scverse@scverse.scverse>
10
10
  License: BSD 3-Clause License
11
11
 
12
- Copyright (c) 2022, scverse
12
+ Copyright (c) 2022, scverse®
13
13
  All rights reserved.
14
14
 
15
15
  Redistribution and use in source and binary forms, with or without
@@ -181,7 +181,20 @@ Marconato, L., Palla, G., Yamauchi, K.A. et al. SpatialData: an open and univers
181
181
 
182
182
  [scverse-discourse]: https://discourse.scverse.org/
183
183
  [issue-tracker]: https://github.com/scverse/spatialdata-io/issues
184
- [changelog]: https://spatialdata.scverse.org/projects/io/en/latest/changelog.html
185
- [link-docs]: https://spatialdata.scverse.org/projects/io/en/latest/
186
- [link-api]: https://spatialdata.scverse.org/projects/io/en/latest/api.html
187
- [link-cli]: https://spatialdata.scverse.org/projects/io/en/latest/cli.html
184
+ [changelog]: https://spatialdata.scverse.org/projects/io/en/stable/changelog.html
185
+ [link-docs]: https://spatialdata.scverse.org/projects/io/en/stable/
186
+ [link-api]: https://spatialdata.scverse.org/projects/io/en/stable/api.html
187
+ [link-cli]: https://spatialdata.scverse.org/projects/io/en/stable/cli.html
188
+ [//]: # "numfocus-fiscal-sponsor-attribution"
189
+
190
+ spatialdata-io is part of the scverse® project ([website](https://scverse.org), [governance](https://scverse.org/about/roles)) and is fiscally sponsored by [NumFOCUS](https://numfocus.org/).
191
+ If you like scverse® and want to support our mission, please consider making a tax-deductible [donation](https://numfocus.org/donate-to-scverse) to help the project pay for developer time, professional services, travel, workshops, and a variety of other needs.
192
+
193
+ <div align="center">
194
+ <a href="https://numfocus.org/project/scverse">
195
+ <img
196
+ src="https://raw.githubusercontent.com/numfocus/templates/master/images/numfocus-logo.png"
197
+ width="200"
198
+ >
199
+ </a>
200
+ </div>
@@ -107,7 +107,20 @@ Marconato, L., Palla, G., Yamauchi, K.A. et al. SpatialData: an open and univers
107
107
 
108
108
  [scverse-discourse]: https://discourse.scverse.org/
109
109
  [issue-tracker]: https://github.com/scverse/spatialdata-io/issues
110
- [changelog]: https://spatialdata.scverse.org/projects/io/en/latest/changelog.html
111
- [link-docs]: https://spatialdata.scverse.org/projects/io/en/latest/
112
- [link-api]: https://spatialdata.scverse.org/projects/io/en/latest/api.html
113
- [link-cli]: https://spatialdata.scverse.org/projects/io/en/latest/cli.html
110
+ [changelog]: https://spatialdata.scverse.org/projects/io/en/stable/changelog.html
111
+ [link-docs]: https://spatialdata.scverse.org/projects/io/en/stable/
112
+ [link-api]: https://spatialdata.scverse.org/projects/io/en/stable/api.html
113
+ [link-cli]: https://spatialdata.scverse.org/projects/io/en/stable/cli.html
114
+ [//]: # "numfocus-fiscal-sponsor-attribution"
115
+
116
+ spatialdata-io is part of the scverse® project ([website](https://scverse.org), [governance](https://scverse.org/about/roles)) and is fiscally sponsored by [NumFOCUS](https://numfocus.org/).
117
+ If you like scverse® and want to support our mission, please consider making a tax-deductible [donation](https://numfocus.org/donate-to-scverse) to help the project pay for developer time, professional services, travel, workshops, and a variety of other needs.
118
+
119
+ <div align="center">
120
+ <a href="https://numfocus.org/project/scverse">
121
+ <img
122
+ src="https://raw.githubusercontent.com/numfocus/templates/master/images/numfocus-logo.png"
123
+ width="200"
124
+ >
125
+ </a>
126
+ </div>
@@ -1,8 +1,13 @@
1
- # file generated by setuptools_scm
1
+ # file generated by setuptools-scm
2
2
  # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
3
6
  TYPE_CHECKING = False
4
7
  if TYPE_CHECKING:
5
- from typing import Tuple, Union
8
+ from typing import Tuple
9
+ from typing import Union
10
+
6
11
  VERSION_TUPLE = Tuple[Union[int, str], ...]
7
12
  else:
8
13
  VERSION_TUPLE = object
@@ -12,5 +17,5 @@ __version__: str
12
17
  __version_tuple__: VERSION_TUPLE
13
18
  version_tuple: VERSION_TUPLE
14
19
 
15
- __version__ = version = '0.1.7'
16
- __version_tuple__ = version_tuple = (0, 1, 7)
20
+ __version__ = version = '0.2.0'
21
+ __version_tuple__ = version_tuple = (0, 2, 0)
@@ -621,8 +621,8 @@ def macsima_wrapper(
621
621
  @click.option("--name", "-n", type=str, help="name of the element to be stored")
622
622
  @click.option(
623
623
  "--data-axes",
624
- type=click.Choice(["cyx", "czyx"], case_sensitive=False),
625
- help="Axes of the data for image files. Valid values are 'cyx' and 'czyx'.",
624
+ type=str,
625
+ help="Axes of the data for image files. Valid values are permutations of 'cyx' and 'czyx'.",
626
626
  )
627
627
  @click.option(
628
628
  "--coordinate-system",
@@ -638,6 +638,8 @@ def read_generic_wrapper(
638
638
  coordinate_system: str | None = None,
639
639
  ) -> None:
640
640
  """Read generic data to SpatialData"""
641
+ if data_axes is not None and "".join(sorted(data_axes)) not in ["cxy", "cxyz"]:
642
+ raise ValueError("data_axes must be a permutation of 'cyx' or 'czyx'.")
641
643
  generic_to_zarr(input=input, output=output, name=name, data_axes=data_axes, coordinate_system=coordinate_system)
642
644
 
643
645
 
@@ -148,6 +148,9 @@ class XeniumKeys(ModeEnum):
148
148
 
149
149
  # specs keys
150
150
  ANALYSIS_SW_VERSION = "analysis_sw_version"
151
+ # spec which contains xeniumranger version whenever xeniumranger is used to resegment the data; the new, resegmented data
152
+ # needs to be parsed by considering the xeniumranger version
153
+ XENIUM_RANGER = "xenium_ranger"
151
154
 
152
155
  # zarr file with labels file and cell summary keys
153
156
  CELLS_ZARR = "cells.zarr.zip"
@@ -69,14 +69,14 @@ def generic_to_zarr(
69
69
  )
70
70
 
71
71
  if output.exists():
72
- sdata = SpatialData.read_zarr(output)
72
+ sdata = SpatialData.read(output)
73
73
  if name in sdata:
74
74
  raise ValueError(
75
75
  f"Name {name} already exists in {output}; please provide a different name or delete the "
76
76
  f"existing element."
77
77
  )
78
78
  sdata[name] = element
79
- sdata.write_element(name, element_name=name)
79
+ sdata.write_element(element_name=name)
80
80
  print(f"Element {name} written to {output}")
81
81
  else:
82
82
  sdata = SpatialData.init_from_elements(elements={name: element})
@@ -15,14 +15,10 @@ from anndata import AnnData
15
15
  from dask.dataframe import DataFrame as DaskDataFrame
16
16
  from dask_image.imread import imread
17
17
  from scipy.sparse import csr_matrix
18
-
19
- # from spatialdata._core.core_utils import xy_cs
20
18
  from skimage.transform import estimate_transform
21
19
  from spatialdata import SpatialData
22
20
  from spatialdata._logging import logger
23
21
  from spatialdata.models import Image2DModel, Labels2DModel, PointsModel, TableModel
24
-
25
- # from spatialdata._core.ngff.ngff_coordinate_system import NgffAxis # , CoordinateSystem
26
22
  from spatialdata.transformations.transformations import Affine, Identity
27
23
 
28
24
  from spatialdata_io._constants._constants import CosmxKeys
@@ -30,10 +26,6 @@ from spatialdata_io._docs import inject_docs
30
26
 
31
27
  __all__ = ["cosmx"]
32
28
 
33
- # x_axis = NgffAxis(name="x", type="space", unit="discrete")
34
- # y_axis = NgffAxis(name="y", type="space", unit="discrete")
35
- # c_axis = NgffAxis(name="c", type="channel", unit="index")
36
-
37
29
 
38
30
  @inject_docs(cx=CosmxKeys)
39
31
  def cosmx(
@@ -177,7 +169,7 @@ def cosmx(
177
169
  fovs_images_and_labels = set(fovs_images).intersection(set(fovs_labels))
178
170
  fovs_diff = fovs_images_and_labels.difference(set(fovs_counts))
179
171
  if len(fovs_diff):
180
- raise logger.warning(
172
+ logger.warning(
181
173
  f"Found images and labels for {len(fovs_images)} FOVs, but only {len(fovs_counts)} FOVs in the counts file.\n"
182
174
  + f"The following FOVs are missing: {fovs_diff} \n"
183
175
  + "... will use only fovs in Table."
@@ -49,6 +49,8 @@ def generic(
49
49
  -------
50
50
  Parsed spatial element.
51
51
  """
52
+ if isinstance(input, str):
53
+ input = Path(input)
52
54
  if coordinate_system is None:
53
55
  coordinate_system = DEFAULT_COORDINATE_SYSTEM
54
56
  if input.suffix in VALID_SHAPE_TYPES:
@@ -33,6 +33,7 @@ def visium(
33
33
  fullres_image_file: str | Path | None = None,
34
34
  tissue_positions_file: str | Path | None = None,
35
35
  scalefactors_file: str | Path | None = None,
36
+ var_names_make_unique: bool = True,
36
37
  imread_kwargs: Mapping[str, Any] = MappingProxyType({}),
37
38
  image_models_kwargs: Mapping[str, Any] = MappingProxyType({}),
38
39
  **kwargs: Any,
@@ -72,6 +73,8 @@ def visium(
72
73
  Path to the tissue positions file.
73
74
  scalefactors_file
74
75
  Path to the scalefactors file.
76
+ var_names_make_unique
77
+ If `True`, call `.var_names_make_unique()` on each `AnnData` table.
75
78
  imread_kwargs
76
79
  Keyword arguments passed to :func:`dask_image.imread.imread`.
77
80
  image_models_kwargs
@@ -114,7 +117,10 @@ def visium(
114
117
  assert counts_file is not None
115
118
 
116
119
  if library_id is None and dataset_id is None:
117
- raise ValueError("Cannot determine the `library_id`. Please provide `dataset_id`.")
120
+ raise ValueError(
121
+ "Cannot determine the `library_id`. Please provide `dataset_id`; the `dataset_id` value will be used to "
122
+ "name the elements in the `SpatialData` object."
123
+ )
118
124
 
119
125
  if dataset_id is not None:
120
126
  if dataset_id != library_id and library_id is not None:
@@ -202,14 +208,16 @@ def visium(
202
208
  radius=scalefactors["spot_diameter_fullres"] / 2.0,
203
209
  index=adata.obs["spot_id"].copy(),
204
210
  transformations={
205
- "global": transform_original,
206
- "downscaled_hires": transform_hires,
207
- "downscaled_lowres": transform_lowres,
211
+ dataset_id: transform_original,
212
+ f"{dataset_id}_downscaled_hires": transform_hires,
213
+ f"{dataset_id}_downscaled_lowres": transform_lowres,
208
214
  },
209
215
  )
210
216
  shapes[dataset_id] = circles
211
217
  adata.obs["region"] = dataset_id
212
218
  table = TableModel.parse(adata, region=dataset_id, region_key="region", instance_key="spot_id")
219
+ if var_names_make_unique:
220
+ table.var_names_make_unique()
213
221
 
214
222
  images = {}
215
223
  if fullres_image_file is not None:
@@ -220,7 +228,7 @@ def visium(
220
228
  images[dataset_id + "_full_image"] = Image2DModel.parse(
221
229
  full_image,
222
230
  scale_factors=[2, 2, 2, 2],
223
- transformations={"global": transform_original},
231
+ transformations={dataset_id: transform_original},
224
232
  rgb=None,
225
233
  **image_models_kwargs,
226
234
  )
@@ -231,14 +239,16 @@ def visium(
231
239
  image_hires = imread(path / VisiumKeys.IMAGE_HIRES_FILE, **imread_kwargs).squeeze().transpose(2, 0, 1)
232
240
  image_hires = DataArray(image_hires, dims=("c", "y", "x"))
233
241
  images[dataset_id + "_hires_image"] = Image2DModel.parse(
234
- image_hires, transformations={"downscaled_hires": Identity(), "global": transform_hires.inverse()}, rgb=None
242
+ image_hires,
243
+ transformations={f"{dataset_id}_downscaled_hires": Identity(), dataset_id: transform_hires.inverse()},
244
+ rgb=None,
235
245
  )
236
246
  if (path / VisiumKeys.IMAGE_LOWRES_FILE).exists():
237
247
  image_lowres = imread(path / VisiumKeys.IMAGE_LOWRES_FILE, **imread_kwargs).squeeze().transpose(2, 0, 1)
238
248
  image_lowres = DataArray(image_lowres, dims=("c", "y", "x"))
239
249
  images[dataset_id + "_lowres_image"] = Image2DModel.parse(
240
250
  image_lowres,
241
- transformations={"downscaled_lowres": Identity(), "global": transform_lowres.inverse()},
251
+ transformations={f"{dataset_id}_downscaled_lowres": Identity(), dataset_id: transform_lowres.inverse()},
242
252
  rgb=None,
243
253
  )
244
254
 
@@ -17,16 +17,17 @@ from geopandas import GeoDataFrame
17
17
  from imageio import imread as imread2
18
18
  from multiscale_spatial_image import MultiscaleSpatialImage
19
19
  from numpy.random import default_rng
20
+ from skimage.transform import ProjectiveTransform, warp
20
21
  from spatial_image import SpatialImage
21
- from spatialdata import SpatialData, rasterize_bins, rasterize_bins_link_table_to_labels
22
- from spatialdata.models import Image2DModel, ShapesModel, TableModel
23
- from spatialdata.transformations import (
24
- Affine,
25
- Identity,
26
- Scale,
27
- Sequence,
28
- set_transformation,
22
+ from spatialdata import (
23
+ SpatialData,
24
+ get_extent,
25
+ rasterize_bins,
26
+ rasterize_bins_link_table_to_labels,
29
27
  )
28
+ from spatialdata._types import ArrayLike
29
+ from spatialdata.models import Image2DModel, ShapesModel, TableModel
30
+ from spatialdata.transformations import Affine, Identity, Scale, set_transformation
30
31
  from xarray import DataArray
31
32
 
32
33
  from spatialdata_io._constants._constants import VisiumHDKeys
@@ -45,6 +46,7 @@ def visium_hd(
45
46
  annotate_table_by_labels: bool = False,
46
47
  fullres_image_file: str | Path | None = None,
47
48
  load_all_images: bool = False,
49
+ var_names_make_unique: bool = True,
48
50
  imread_kwargs: Mapping[str, Any] = MappingProxyType({}),
49
51
  image_models_kwargs: Mapping[str, Any] = MappingProxyType({}),
50
52
  anndata_kwargs: Mapping[str, Any] = MappingProxyType({}),
@@ -61,7 +63,8 @@ def visium_hd(
61
63
  path
62
64
  Path to directory containing the *10x Genomics* Visium HD output.
63
65
  dataset_id
64
- Unique identifier of the dataset. If `None`, it tries to infer it from the file name of the feature slice file.
66
+ Unique identifier of the dataset, used to name the elements of the `SpatialData` object. If `None`, it tries to
67
+ infer it from the file name of the feature slice file.
65
68
  filtered_counts_file
66
69
  It sets the value of `counts_file` to ``{vx.FILTERED_COUNTS_FILE!r}`` (when `True`) or to
67
70
  ``{vx.RAW_COUNTS_FILE!r}`` (when `False`).
@@ -80,6 +83,8 @@ def visium_hd(
80
83
  load_all_images
81
84
  If `False`, load only the full resolution, high resolution and low resolution images. If `True`, also the
82
85
  following images: ``{vx.IMAGE_CYTASSIST!r}``.
86
+ var_names_make_unique
87
+ If `True`, call `.var_names_make_unique()` on each `AnnData` table.
83
88
  imread_kwargs
84
89
  Keyword arguments for :func:`imageio.imread`.
85
90
  image_models_kwargs
@@ -100,7 +105,8 @@ def visium_hd(
100
105
 
101
106
  if dataset_id is None:
102
107
  dataset_id = _infer_dataset_id(path)
103
- filename_prefix = f"{dataset_id}_"
108
+
109
+ filename_prefix = _get_filename_prefix(path, dataset_id)
104
110
 
105
111
  def load_image(path: Path, suffix: str, scale_factors: list[int] | None = None) -> None:
106
112
  _load_image(
@@ -236,9 +242,9 @@ def visium_hd(
236
242
  shapes_name = dataset_id + "_" + bin_size_str
237
243
  radius = scalefactors[VisiumHDKeys.SCALEFACTORS_SPOT_DIAMETER_FULLRES] / 2.0
238
244
  transformations = {
239
- "global": transform_original,
240
- "downscaled_hires": transform_hires,
241
- "downscaled_lowres": transform_lowres,
245
+ dataset_id: transform_original,
246
+ f"{dataset_id}_downscaled_hires": transform_hires,
247
+ f"{dataset_id}_downscaled_lowres": transform_lowres,
242
248
  }
243
249
  circles = ShapesModel.parse(
244
250
  adata.obsm["spatial"],
@@ -265,6 +271,8 @@ def visium_hd(
265
271
  region_key=str(VisiumHDKeys.REGION_KEY),
266
272
  instance_key=str(VisiumHDKeys.INSTANCE_KEY),
267
273
  )
274
+ if var_names_make_unique:
275
+ tables[bin_size_str].var_names_make_unique()
268
276
 
269
277
  # read full resolution image
270
278
  if fullres_image_file is not None:
@@ -314,7 +322,10 @@ def visium_hd(
314
322
  )
315
323
  set_transformation(
316
324
  images[dataset_id + "_hires_image"],
317
- {"downscaled_hires": Identity(), "global": transform_hires.inverse()},
325
+ {
326
+ f"{dataset_id}_downscaled_hires": Identity(),
327
+ dataset_id: transform_hires.inverse(),
328
+ },
318
329
  set_all=True,
319
330
  )
320
331
 
@@ -332,7 +343,10 @@ def visium_hd(
332
343
  )
333
344
  set_transformation(
334
345
  images[dataset_id + "_lowres_image"],
335
- {"downscaled_lowres": Identity(), "global": transform_lowres.inverse()},
346
+ {
347
+ f"{dataset_id}_downscaled_lowres": Identity(),
348
+ dataset_id: transform_lowres.inverse(),
349
+ },
336
350
  set_all=True,
337
351
  )
338
352
 
@@ -351,15 +365,60 @@ def visium_hd(
351
365
  )
352
366
  image = images[dataset_id + "_cytassist_image"]
353
367
  transform_matrices = _get_transform_matrices(metadata, hd_layout)
354
- affine0 = transform_matrices["cytassist_colrow_to_spot_colrow"]
355
- affine1 = transform_matrices["spot_colrow_to_microscope_colrow"]
356
- set_transformation(image, Sequence([affine0, affine1]), "global")
368
+ projective0 = transform_matrices["cytassist_colrow_to_spot_colrow"]
369
+ projective1 = transform_matrices["spot_colrow_to_microscope_colrow"]
370
+ projective = projective1 @ projective0
371
+ projective /= projective[2, 2]
372
+ if _projective_matrix_is_affine(projective):
373
+ affine = Affine(projective, input_axes=("x", "y"), output_axes=("x", "y"))
374
+ set_transformation(image, affine, dataset_id)
375
+ else:
376
+ # the projective matrix is not affine, we will separate the affine part and the projective shift, and apply
377
+ # the projective shift to the image
378
+ affine_matrix, projective_shift = _decompose_projective_matrix(projective)
379
+ affine = Affine(affine_matrix, input_axes=("x", "y"), output_axes=("x", "y"))
380
+
381
+ # determine the size of the transformed image
382
+ bounding_box = get_extent(image, coordinate_system=dataset_id)
383
+ x0, x1 = bounding_box["x"]
384
+ y0, y1 = bounding_box["y"]
385
+ x1 -= 1
386
+ y1 -= 1
387
+ corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
388
+
389
+ transformed_corners = []
390
+ for x, y in corners:
391
+ px, py = _projective_matrix_transform_point(projective_shift, x, y)
392
+ transformed_corners.append((px, py))
393
+ transformed_corners_array = np.array(transformed_corners)
394
+ transformed_bounds = (
395
+ np.min(transformed_corners_array[:, 0]),
396
+ np.min(transformed_corners_array[:, 1]),
397
+ np.max(transformed_corners_array[:, 0]),
398
+ np.max(transformed_corners_array[:, 1]),
399
+ )
400
+ # the first two components are <= 0, we just discard them since the cytassist image has a lot of padding
401
+ # and therefore we can safely discard pixels with negative coordinates
402
+ transformed_shape = (np.ceil(transformed_bounds[2]), np.ceil(transformed_bounds[3]))
403
+
404
+ # flip xy
405
+ transformed_shape = (transformed_shape[1], transformed_shape[0])
406
+
407
+ # the cytassist image is a small, single-scale image, so we can compute it in memory
408
+ numpy_data = image.transpose("y", "x", "c").data.compute()
409
+ warped = warp(
410
+ numpy_data, ProjectiveTransform(projective_shift).inverse, output_shape=transformed_shape, order=1
411
+ )
412
+ warped = np.round(warped * 255).astype(np.uint8)
413
+ warped = Image2DModel.parse(warped, dims=("y", "x", "c"), transformations={dataset_id: affine}, rgb=True)
414
+
415
+ # we replace the cytassist image with the warped image
416
+ images[dataset_id + "_cytassist_image"] = warped
357
417
 
358
418
  sdata = SpatialData(tables=tables, images=images, shapes=shapes, labels=labels)
359
419
 
360
420
  if annotate_table_by_labels:
361
421
  for bin_size_str in bin_sizes:
362
-
363
422
  shapes_name = dataset_id + "_" + bin_size_str
364
423
 
365
424
  # add labels layer (rasterized bins).
@@ -388,7 +447,8 @@ def _infer_dataset_id(path: Path) -> str:
388
447
  files = [file.name for file in path.iterdir() if file.is_file() and file.name.endswith(suffix)]
389
448
  if len(files) == 0 or len(files) > 1:
390
449
  raise ValueError(
391
- f"Cannot infer `dataset_id` from the feature slice file in {path}, please pass `dataset_id` as an argument."
450
+ f"Cannot infer `dataset_id` from the feature slice file in {path}, please pass `dataset_id` as an "
451
+ f"argument. The `dataset_id` value will be used to name the elements in the `SpatialData` object."
392
452
  )
393
453
  return files[0].replace(suffix, "")
394
454
 
@@ -424,20 +484,63 @@ def _load_image(
424
484
  ), "When the image is not in RGB, the first dimension should be the number of channels."
425
485
 
426
486
  image = DataArray(data, dims=("c", "y", "x"))
427
- parsed = Image2DModel.parse(image, scale_factors=scale_factors, rgb=None, **image_models_kwargs)
487
+ parsed = Image2DModel.parse(
488
+ image,
489
+ scale_factors=scale_factors,
490
+ rgb=None,
491
+ transformations={dataset_id: Identity()},
492
+ **image_models_kwargs,
493
+ )
428
494
  images[dataset_id + suffix] = parsed
429
495
  else:
430
496
  warnings.warn(f"File {path} does not exist, skipping it.", UserWarning, stacklevel=2)
431
497
  return None
432
498
 
433
499
 
434
- def _get_affine(coefficients: list[int]) -> Affine:
435
- matrix = np.array(coefficients).reshape(3, 3)
436
- # the last row doesn't match with machine precision, let's check the matrix it's still close to a homogeneous
437
- # matrix, and fix this
438
- assert np.allclose(matrix[2], [0, 0, 1], atol=1e-2), matrix
439
- matrix[2] = [0, 0, 1]
440
- return Affine(matrix, input_axes=("x", "y"), output_axes=("x", "y"))
500
+ def _projective_matrix_transform_point(projective_shift: ArrayLike, x: float, y: float) -> tuple[float, float]:
501
+ v = np.array([x, y, 1])
502
+ v = projective_shift @ v
503
+ v /= v[2]
504
+ return v[0], v[1]
505
+
506
+
507
+ def _projective_matrix_is_affine(projective_matrix: ArrayLike) -> bool:
508
+ assert np.allclose(projective_matrix[2, 2], 1), "A projective matrix should have a 1 in the bottom right corner."
509
+ return np.allclose(projective_matrix[2, :2], [0, 0])
510
+
511
+
512
+ def _decompose_projective_matrix(projective_matrix: ArrayLike) -> tuple[ArrayLike, ArrayLike]:
513
+ """
514
+ Decompose a projective transformation matrix into an affine transformation and a projective shift.
515
+
516
+ Parameters
517
+ ----------
518
+ projective_matrix
519
+ Projective transformation matrix.
520
+
521
+ Returns
522
+ -------
523
+ A tuple where the first element is the affine matrix and the second element is the projective shift.
524
+
525
+ Let P be the initial projective matrix and A the affine matrix. The projective shift S is defined as: S = A^-1 @ P.
526
+ """
527
+ assert np.allclose(projective_matrix[2, 2], 1), "A projective matrix should have a 1 in the bottom right corner."
528
+ affine_matrix = projective_matrix.copy()
529
+ affine_matrix[2] = [0, 0, 1]
530
+ # equivalent to np.linalg.inv(affine_matrix) @ projective_matrix, but more numerically stable
531
+ projective_shift = np.linalg.solve(affine_matrix, projective_matrix)
532
+ projective_shift /= projective_shift[2, 2]
533
+ return affine_matrix, projective_shift
534
+
535
+
536
+ def _get_filename_prefix(path: Path, dataset_id: str) -> str:
537
+ if (path / f"{dataset_id}_{VisiumHDKeys.FEATURE_SLICE_FILE.value}").exists():
538
+ return f"{dataset_id}_"
539
+ assert (path / VisiumHDKeys.FEATURE_SLICE_FILE.value).exists(), (
540
+ f"Cannot locate the feature slice file, please ensure the file is present in the {path} directory and/or adjust"
541
+ "the `dataset_id` parameter"
542
+ )
543
+ return ""
441
544
 
442
545
 
443
546
  def _parse_metadata(path: Path, filename_prefix: str) -> tuple[dict[str, Any], dict[str, Any]]:
@@ -447,11 +550,29 @@ def _parse_metadata(path: Path, filename_prefix: str) -> tuple[dict[str, Any], d
447
550
  return metadata, hd_layout
448
551
 
449
552
 
450
- def _get_transform_matrices(metadata: dict[str, Any], hd_layout: dict[str, Any]) -> dict[str, Affine]:
553
+ def _get_transform_matrices(metadata: dict[str, Any], hd_layout: dict[str, Any]) -> dict[str, ArrayLike]:
554
+ """
555
+ Gets 4 projective transformation matrices, describing how to align the CytAssist, spots and microscope coordinates.
556
+
557
+ Parameters
558
+ ----------
559
+ metadata
560
+ Metadata of the Visium HD dataset parsed using `_parse_metadata()` from the feature slice file.
561
+ hd_layout
562
+ Layout of the Visium HD dataset parsed using `_parse_metadata()` from the feature slice file.
563
+
564
+ Returns
565
+ -------
566
+ A dictionary containing four projective transformation matrices:
567
+ - CytAssist col/row to Spot col/row
568
+ - Spot col/row to CytAssist col/row
569
+ - Microscope col/row to Spot col/row
570
+ - Spot col/row to Microscope col/row
571
+ """
451
572
  transform_matrices = {}
452
573
 
453
- # not used
454
- transform_matrices["hd_layout_transform"] = _get_affine(hd_layout[VisiumHDKeys.TRANSFORM])
574
+ # this transformation is parsed but not used in the current implementation
575
+ transform_matrices["hd_layout_transform"] = np.array(hd_layout[VisiumHDKeys.TRANSFORM]).reshape(3, 3)
455
576
 
456
577
  for key in [
457
578
  VisiumHDKeys.CYTASSIST_COLROW_TO_SPOT_COLROW,
@@ -459,7 +580,7 @@ def _get_transform_matrices(metadata: dict[str, Any], hd_layout: dict[str, Any])
459
580
  VisiumHDKeys.MICROSCOPE_COLROW_TO_SPOT_COLROW,
460
581
  VisiumHDKeys.SPOT_COLROW_TO_MICROSCOPE_COLROW,
461
582
  ]:
462
- data = metadata[VisiumHDKeys.TRANSFORM_MATRICES][key]
463
- transform_matrices[key.value] = _get_affine(data)
583
+ coefficients = metadata[VisiumHDKeys.TRANSFORM_MATRICES][key]
584
+ transform_matrices[key] = np.array(coefficients).reshape(3, 3)
464
585
 
465
586
  return transform_matrices
@@ -298,7 +298,6 @@ def xenium(
298
298
  f"{XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_IMAGE.value.format(0)} to "
299
299
  f"{XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_IMAGE.value.format(len(files) - 1)}, found {files}"
300
300
  )
301
- # the 'dummy' channel is a temporary workaround, see _get_images() for more details
302
301
  if len(files) == 1:
303
302
  channel_names = {
304
303
  0: XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_0.value,
@@ -309,7 +308,6 @@ def xenium(
309
308
  1: XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_1.value,
310
309
  2: XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_2.value,
311
310
  3: XeniumKeys.MORPHOLOGY_FOCUS_CHANNEL_3.value,
312
- 4: "dummy",
313
311
  }
314
312
  # this reads the scale 0 for all the 1 or 4 channels (the other files are parsed automatically)
315
313
  # dask.image.imread will call tifffile.imread which will give a warning saying that reading multi-file
@@ -601,6 +599,8 @@ def xenium_aligned_image(
601
599
  imread_kwargs: Mapping[str, Any] = MappingProxyType({}),
602
600
  image_models_kwargs: Mapping[str, Any] = MappingProxyType({}),
603
601
  dims: tuple[str, ...] | None = None,
602
+ rgba: bool = False,
603
+ c_coords: list[str] | None = None,
604
604
  ) -> DataTree:
605
605
  """
606
606
  Read an image aligned to a Xenium dataset, with an optional alignment file.
@@ -619,6 +619,13 @@ def xenium_aligned_image(
619
619
  Example: for an image with shape (1, y, 1, x, 3), use dims=("anystring", "y", "dummy", "x", "c"). Values that
620
620
  are not "c", "x" or "y" are considered dummy dimensions and will be squeezed (the data must have len 1 for
621
621
  those axes).
622
+ rgba
623
+ Interprets the `c` channel as RGBA, by setting the channel names to `r`, `g`, `b` (`a`). When `c_coords` is not
624
+ `None`, this argument is ignored.
625
+ c_coords
626
+ Channel names for the image. By default, the function will try to infer the channel names from the image
627
+ shape and name (by detecting if the name suggests that the image is a H&E image). Example: for an RGB image with
628
+ shape (3, y, x), use c_coords=["r", "g", "b"].
622
629
 
623
630
  Returns
624
631
  -------
@@ -645,10 +652,6 @@ def xenium_aligned_image(
645
652
  else:
646
653
  assert len(image.shape) == 3
647
654
  assert image.shape[0] in [3, 4]
648
- if image.shape[0] == 4:
649
- # as explained before in _get_images(), we need to add a dummy channel until we support 4-channel images as
650
- # non-RGBA images in napari
651
- image = da.concatenate([image, da.zeros_like(image[0:1])], axis=0)
652
655
  dims = ("c", "y", "x")
653
656
  else:
654
657
  logging.info(f"Image has shape {image.shape}, parsing with dims={dims}.")
@@ -667,10 +670,19 @@ def xenium_aligned_image(
667
670
  alignment = pd.read_csv(alignment_file, header=None).values
668
671
  transformation = Affine(alignment, input_axes=("x", "y"), output_axes=("x", "y"))
669
672
 
673
+ if c_coords is None and (rgba or image_path.name.endswith(XeniumKeys.ALIGNED_HE_IMAGE_SUFFIX)):
674
+ c_index = dims.index("c")
675
+ n_channels = image.shape[c_index]
676
+ if n_channels == 3:
677
+ c_coords = ["r", "g", "b"]
678
+ elif n_channels == 4:
679
+ c_coords = ["r", "g", "b", "a"]
680
+
670
681
  return Image2DModel.parse(
671
682
  image,
672
683
  dims=dims,
673
684
  transformations={"global": transformation},
685
+ c_coords=c_coords,
674
686
  **image_models_kwargs,
675
687
  )
676
688
 
@@ -719,7 +731,17 @@ def _parse_version_of_xenium_analyzer(
719
731
  specs: dict[str, Any],
720
732
  hide_warning: bool = True,
721
733
  ) -> packaging.version.Version | None:
722
- string = specs[XeniumKeys.ANALYSIS_SW_VERSION]
734
+
735
+ # After using xeniumranger (e.g. 3.0.1.1) to resegment data from previous versions (e.g. xenium-1.6.0.7), a new dict is added to
736
+ # `specs`, named 'xenium_ranger', which contains the key 'version' and whose value specifies the version of xeniumranger used to
737
+ # resegment the data (e.g. 'xenium-3.0.1.1').
738
+ # When parsing the outs/ folder from the resegmented data, this version (rather than the original 'analysis_sw_version') is used
739
+ # whenever a code branch is dependent on the data version
740
+ if specs.get(XeniumKeys.XENIUM_RANGER):
741
+ string = specs[XeniumKeys.XENIUM_RANGER]["version"]
742
+ else:
743
+ string = specs[XeniumKeys.ANALYSIS_SW_VERSION]
744
+
723
745
  pattern = r"^(?:x|X)enium-(\d+\.\d+\.\d+(\.\d+-\d+)?)"
724
746
 
725
747
  result = re.search(pattern, string)
@@ -780,6 +802,3 @@ def prefix_suffix_uint32_from_cell_id_str(cell_id_str: ArrayLike) -> tuple[Array
780
802
  cell_id_prefix = [int(x, 16) for x in cell_id_prefix_hex]
781
803
 
782
804
  return np.array(cell_id_prefix, dtype=np.uint32), np.array(dataset_suffix_int)
783
-
784
-
785
- ##
@@ -87,7 +87,7 @@ def test_cli_read_generic_image_invalid_data_axes(runner: CliRunner) -> None:
87
87
  ],
88
88
  )
89
89
  assert result.exit_code != 0, result.output
90
- assert "'invalid_axes' is not one of 'cyx', 'czyx'." in result.output
90
+ assert "data_axes must be a permutation of 'cyx' or 'czyx'." in result.exc_info[1].args[0]
91
91
 
92
92
 
93
93
  @pytest.mark.parametrize("cli", [True, False])
File without changes
File without changes