spatialdata-io 0.7.0__tar.gz → 0.7.1__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 (80) hide show
  1. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/PKG-INFO +1 -1
  2. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/_version.py +2 -2
  3. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/__main__.py +4 -4
  4. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/macsima.py +121 -24
  5. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/xenium.py +2 -0
  6. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_macsima.py +250 -31
  7. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_xenium.py +3 -0
  8. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.bumpversion.cfg +0 -0
  9. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.codecov.yaml +0 -0
  10. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.editorconfig +0 -0
  11. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.github/codecov.yml +0 -0
  12. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.github/release.yml +0 -0
  13. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.github/workflows/prepare_test_data.yaml +0 -0
  14. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.github/workflows/release.yaml +0 -0
  15. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.github/workflows/test.yaml +0 -0
  16. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.gitignore +0 -0
  17. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.mypy.ini +0 -0
  18. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.pre-commit-config.yaml +0 -0
  19. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/.readthedocs.yaml +0 -0
  20. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/LICENSE +0 -0
  21. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/README.md +0 -0
  22. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/asv.conf.json +0 -0
  23. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/benchmarks/__init__.py +0 -0
  24. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/benchmarks/benchmark_image.py +0 -0
  25. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/benchmarks/benchmark_imports.py +0 -0
  26. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/benchmarks/benchmark_xenium.py +0 -0
  27. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/Makefile +0 -0
  28. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/_static/.gitkeep +0 -0
  29. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/_templates/.gitkeep +0 -0
  30. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/_templates/autosummary/class.rst +0 -0
  31. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/api.md +0 -0
  32. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/changelog.md +0 -0
  33. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/cli.md +0 -0
  34. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/conf.py +0 -0
  35. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/contributing.md +0 -0
  36. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/extensions/.gitkeep +0 -0
  37. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/extensions/typed_returns.py +0 -0
  38. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/index.md +0 -0
  39. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/make.bat +0 -0
  40. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/references.bib +0 -0
  41. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/docs/references.md +0 -0
  42. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/pyproject.toml +0 -0
  43. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/__init__.py +0 -0
  44. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/_constants/__init__.py +0 -0
  45. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/_constants/_constants.py +0 -0
  46. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/_constants/_enum.py +0 -0
  47. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/_docs.py +0 -0
  48. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/_utils.py +0 -0
  49. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/converters/__init__.py +0 -0
  50. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/converters/generic_to_zarr.py +0 -0
  51. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/converters/legacy_anndata.py +0 -0
  52. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/experimental/__init__.py +0 -0
  53. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/__init__.py +0 -0
  54. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/_utils/__init__.py +0 -0
  55. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/_utils/_image.py +0 -0
  56. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/_utils/_utils.py +0 -0
  57. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/codex.py +0 -0
  58. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/cosmx.py +0 -0
  59. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/curio.py +0 -0
  60. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/dbit.py +0 -0
  61. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/generic.py +0 -0
  62. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/iss.py +0 -0
  63. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/mcmicro.py +0 -0
  64. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/merscope.py +0 -0
  65. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/seqfish.py +0 -0
  66. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/steinbock.py +0 -0
  67. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/stereoseq.py +0 -0
  68. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/visium.py +0 -0
  69. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/src/spatialdata_io/readers/visium_hd.py +0 -0
  70. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/__init__.py +0 -0
  71. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/_utils.py +0 -0
  72. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/conftest.py +0 -0
  73. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/converters/__init__.py +0 -0
  74. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/converters/test_legacy_anndata.py +0 -0
  75. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/readers/test_utils_image.py +0 -0
  76. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_cli_alignment.py +0 -0
  77. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_generic.py +0 -0
  78. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_init.py +0 -0
  79. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_seqfish.py +0 -0
  80. {spatialdata_io-0.7.0 → spatialdata_io-0.7.1}/tests/test_visium_hd.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spatialdata-io
3
- Version: 0.7.0
3
+ Version: 0.7.1
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
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.7.0'
22
- __version_tuple__ = version_tuple = (0, 7, 0)
21
+ __version__ = version = '0.7.1'
22
+ __version_tuple__ = version_tuple = (0, 7, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -794,9 +794,9 @@ def xenium_wrapper(
794
794
  @_input_output_click_options
795
795
  @click.option(
796
796
  "--parsing-style",
797
- type=click.Choice(["auto", "processed_single_folder", "processed_multiple_folders", "raw"]),
798
- default="auto",
799
- help="Parsing style for MACSima data. [default: auto]",
797
+ type=click.Choice(["processed_single_folder", "processed_multiple_folders", "raw"]),
798
+ default="processed_single_folder",
799
+ help="Parsing style for MACSima data. [default: processed_single_folder]",
800
800
  )
801
801
  @click.option(
802
802
  "--filter-folder-names",
@@ -870,7 +870,7 @@ def macsima_wrapper(
870
870
  input: str,
871
871
  output: str,
872
872
  *,
873
- parsing_style: str = "auto",
873
+ parsing_style: str = "processed_single_folder",
874
874
  filter_folder_names: list[str] | None = None,
875
875
  subset: int | None = None,
876
876
  c_subset: int | None = None,
@@ -48,7 +48,6 @@ class MACSimaParsingStyle(ModeEnum):
48
48
  PROCESSED_SINGLE_FOLDER = "processed_single_folder"
49
49
  PROCESSED_MULTIPLE_FOLDERS = "processed_multiple_folders"
50
50
  RAW = "raw"
51
- AUTO = "auto"
52
51
 
53
52
 
54
53
  @dataclass
@@ -62,6 +61,8 @@ class ChannelMetadata:
62
61
  roi: int
63
62
  fluorophore: str
64
63
  exposure: float
64
+ translation_x: int
65
+ translation_y: int
65
66
  clone: str | None = None # For example DAPI doesnt have a clone
66
67
 
67
68
 
@@ -104,6 +105,8 @@ class MultiChannelImage:
104
105
  fluorophore=metadata["fluorophore"],
105
106
  clone=metadata["clone"],
106
107
  exposure=metadata["exposure"],
108
+ translation_x=metadata["translation_x"],
109
+ translation_y=metadata["translation_y"],
107
110
  )
108
111
  )
109
112
 
@@ -126,12 +129,11 @@ class MultiChannelImage:
126
129
  ),
127
130
  )
128
131
  imgs = [imread(img, **imread_kwargs) for img in valid_files]
129
- for img, path in zip(imgs, valid_files, strict=True):
130
- if img.shape[1:] != imgs[0].shape[1:]:
131
- raise ValueError(
132
- f"Images are not all the same size. Image {path} has shape {img.shape[1:]} while the first image "
133
- f"{valid_files[0]} has shape {imgs[0].shape[1:]}"
134
- )
132
+
133
+ # Pad images to same dimensions if necessary
134
+ if cls._check_for_differing_xy_dimensions(imgs):
135
+ imgs = cls._pad_images(imgs, channel_metadata)
136
+
135
137
  # create MultiChannelImage object with imgs and metadata
136
138
  output = cls(data=imgs, metadata=channel_metadata)
137
139
  return output
@@ -221,10 +223,81 @@ class MultiChannelImage:
221
223
  def get_stack(self) -> da.Array:
222
224
  return da.stack(self.data, axis=0).squeeze(axis=1)
223
225
 
226
+ @staticmethod
227
+ def _check_for_differing_xy_dimensions(imgs: list[da.Array]) -> bool:
228
+ """Checks whether any of the images have differing extent in dimensions X and Y."""
229
+ # Shape has order CYX
230
+ dims_x = [x.shape[2] for x in imgs]
231
+ dims_y = [x.shape[1] for x in imgs]
232
+
233
+ dims_x_different = len(set(dims_x)) != 1
234
+ dims_y_different = len(set(dims_y)) != 1
235
+
236
+ different_dimensions = any([dims_x_different, dims_y_different])
237
+
238
+ warnings.warn(
239
+ "Supplied images have different dimensions!",
240
+ UserWarning,
241
+ stacklevel=2,
242
+ )
243
+
244
+ return different_dimensions
245
+
246
+ @staticmethod
247
+ def _pad_images(imgs: list[da.Array], channel_metadata: list[ChannelMetadata]) -> list[da.Array]:
248
+ """Pad all images to the same dimensions in X and Y with 0s.
249
+
250
+ Padding obeys translations stored in ome metadata.
251
+ If no translations are found, padding is added only away from the origin:
252
+ on the right side for X and at the
253
+ bottom for Y, so the top-left corner of each image stays aligned.
254
+ """
255
+ min_translation_x = min(metadata.translation_x for metadata in channel_metadata)
256
+ min_translation_y = min(metadata.translation_y for metadata in channel_metadata)
257
+ normalized_translations_x = [metadata.translation_x - min_translation_x for metadata in channel_metadata]
258
+ normalized_translations_y = [metadata.translation_y - min_translation_y for metadata in channel_metadata]
259
+
260
+ dims_x_max = max(
261
+ img.shape[2] + translation_x for img, translation_x in zip(imgs, normalized_translations_x, strict=True)
262
+ )
263
+ dims_y_max = max(
264
+ img.shape[1] + translation_y for img, translation_y in zip(imgs, normalized_translations_y, strict=True)
265
+ )
266
+
267
+ warnings.warn(
268
+ f"Padding images with 0s to same size of ({dims_y_max}, {dims_x_max})",
269
+ UserWarning,
270
+ stacklevel=2,
271
+ )
272
+
273
+ padded_imgs = []
274
+ for img, translation_x, translation_y in zip(
275
+ imgs, normalized_translations_x, normalized_translations_y, strict=True
276
+ ):
277
+ pad_y_prepend = translation_y
278
+ pad_x_prepend = translation_x
279
+ # For appending, we check how much space is already covered by the original image, and we take into account how many pixels were prepended
280
+ # If there still is a difference, we append 0 to get to the same image size
281
+ pad_y_append = dims_y_max - img.shape[1] - pad_y_prepend
282
+ pad_x_append = dims_x_max - img.shape[2] - pad_x_prepend
283
+ # Only pad if necessary
284
+ if (pad_x_prepend, pad_x_append, pad_y_prepend, pad_y_append) != (0, 0, 0, 0):
285
+ pad_width = (
286
+ # c axis: no pad
287
+ (0, 0),
288
+ (pad_y_prepend, pad_y_append),
289
+ (pad_x_prepend, pad_x_append),
290
+ )
291
+
292
+ img = da.pad(img, pad_width, mode="constant", constant_values=0)
293
+ padded_imgs.append(img)
294
+
295
+ return padded_imgs
296
+
224
297
 
225
298
  def macsima(
226
299
  path: str | Path,
227
- parsing_style: MACSimaParsingStyle | str = MACSimaParsingStyle.AUTO,
300
+ parsing_style: MACSimaParsingStyle | str = MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER,
228
301
  filter_folder_names: list[str] | None = None,
229
302
  imread_kwargs: Mapping[str, Any] = MappingProxyType({}),
230
303
  subset: int | None = None,
@@ -245,6 +318,8 @@ def macsima(
245
318
  This function reads images from a MACSima cyclic imaging experiment. MACSima data follows the OME-TIFF specificiation.
246
319
  All metadata is parsed from the OME metadata. The exact metadata schema can change between software versions of MACSiQView.
247
320
  As there is no public specification of the metadata fields used, please consider the provided test data sets as ground truth to guide development.
321
+ If images from different cycles differ in spatial dimensions, they are zero-padded on the right (X) and bottom (Y) to match
322
+ the largest dimensions, keeping the top-left origin aligned; a warning is emitted in that case.
248
323
 
249
324
  .. seealso::
250
325
 
@@ -255,7 +330,8 @@ def macsima(
255
330
  path
256
331
  Path to the directory containing the data.
257
332
  parsing_style
258
- Parsing style to use. If ``auto``, the parsing style is determined based on the contents of the path.
333
+ Parsing style to use. If ``processed_single_folder``, all subfolders of ``path`` are combined into a stack.
334
+ If ``processed_multiple_folders``, a stack is created for each folder directly beneath ``path``.
259
335
  filter_folder_names
260
336
  List of folder names to filter out when parsing multiple folders.
261
337
  imread_kwargs
@@ -295,19 +371,13 @@ def macsima(
295
371
  if not isinstance(parsing_style, MACSimaParsingStyle):
296
372
  parsing_style = MACSimaParsingStyle(parsing_style)
297
373
 
298
- if parsing_style == MACSimaParsingStyle.AUTO:
299
- assert path.is_dir(), f"Path {path} is not a directory."
300
-
301
- if any(p.suffix in [".tif", ".tiff"] for p in path.iterdir()):
302
- # if path contains tifs, do parse_processed_folder on path
303
- parsing_style = MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER
304
- elif all(p.is_dir() for p in path.iterdir() if not p.name.startswith(".")):
305
- # if path contains only folders or hidden files, do parse_processed_folder on each folder
306
- parsing_style = MACSimaParsingStyle.PROCESSED_MULTIPLE_FOLDERS
307
- else:
308
- raise ValueError(f"Cannot determine parsing style for path {path}. Please specify the parsing style.")
309
-
310
374
  if parsing_style == MACSimaParsingStyle.PROCESSED_SINGLE_FOLDER:
375
+ if filter_folder_names:
376
+ warnings.warn(
377
+ "single_processed_folder was requested but filter_folder_names was specified. Note that it is ignored here, filtering only happens for processed_multi_folders!",
378
+ UserWarning,
379
+ stacklevel=2,
380
+ )
311
381
  return parse_processed_folder(
312
382
  path=path,
313
383
  imread_kwargs=imread_kwargs,
@@ -332,6 +402,9 @@ def macsima(
332
402
  for p in path.iterdir()
333
403
  if p.is_dir() and (not filter_folder_names or not any(f in p.name for f in filter_folder_names))
334
404
  ]:
405
+ if not len(list(p.glob("*.tif*"))):
406
+ warnings.warn(f"No tif files found in {p}, skipping it!", UserWarning, stacklevel=2)
407
+ continue
335
408
  sdatas[p.stem] = parse_processed_folder(
336
409
  path=p,
337
410
  imread_kwargs=imread_kwargs,
@@ -415,6 +488,25 @@ def _get_software_major_version(version: str) -> int:
415
488
  return major
416
489
 
417
490
 
491
+ def _get_translations(ome: OME) -> dict[str, int]:
492
+ try:
493
+ translations = {
494
+ "translation_x": ome.images[0].pixels.planes[0].position_x,
495
+ "translation_y": ome.images[0].pixels.planes[0].position_y,
496
+ }
497
+ # If the position attributes are not present the values will be None and we default to (0,0)
498
+ if any(v is None for v in translations.values()):
499
+ logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)")
500
+ translations = {"translation_x": 0, "translation_y": 0}
501
+
502
+ # In case the ome is faulty, also default to (0,0)
503
+ except AttributeError:
504
+ logger.debug(f"No translation found for {ome.images[0].name}, defaulting to (0, 0)")
505
+ translations = {"translation_x": 0, "translation_y": 0}
506
+
507
+ return translations
508
+
509
+
418
510
  def _parse_v0_ome_metadata(ome: OME) -> dict[str, Any]:
419
511
  """Parse Legacy Format of OME Metadata (software version 0.x.x)."""
420
512
  logger.debug("Parsing OME metadata expecting version 0 format")
@@ -592,12 +684,16 @@ def _parse_ome_metadata(ome: OME) -> dict[str, Any]:
592
684
  major = _get_software_major_version(version_str)
593
685
 
594
686
  if major == 0:
595
- return _parse_v0_ome_metadata(ome)
687
+ metadata = _parse_v0_ome_metadata(ome)
596
688
  elif major == 1:
597
- return _parse_v1_ome_metadata(ome)
689
+ metadata = _parse_v1_ome_metadata(ome)
598
690
  else:
599
691
  raise ValueError("Unknown software version, cannot determine parser")
600
692
 
693
+ translations = _get_translations(ome)
694
+ metadata.update(translations)
695
+ return metadata
696
+
601
697
 
602
698
  def parse_metadata(path: Path) -> dict[str, Any]:
603
699
  """Parse metadata for a file.
@@ -625,7 +721,7 @@ def parse_processed_folder(
625
721
  nuclei_channel_name: str = "DAPI",
626
722
  split_threshold_nuclei_channel: int | None = 2,
627
723
  skip_rounds: list[int] | None = None,
628
- file_pattern: str = "*.tif*",
724
+ file_pattern: str = "**/*.tif*",
629
725
  include_cycle_in_channel_name: bool = False,
630
726
  ) -> SpatialData:
631
727
  """Parse a single folder containing images from a cyclical imaging platform."""
@@ -760,6 +856,7 @@ def create_table(mci: MultiChannelImage) -> ad.AnnData:
760
856
  clones = mci.get_clones()
761
857
  exposures = mci.get_exposures()
762
858
 
859
+ # We dont add the translations, because they are usually not interesting for the user
763
860
  df = pd.DataFrame(
764
861
  {
765
862
  "name": names,
@@ -605,6 +605,7 @@ def _get_polygons(
605
605
  else:
606
606
  # Use cell_id (str) as GeoDataFrame index.
607
607
  geo_df = GeoDataFrame({"geometry": geoms}, index=indices_mapping["cell_id"].values)
608
+ geo_df.index.name = "cell_id"
608
609
  else:
609
610
  # Fall back to extracting unique cell IDs from parquet (slow for large_string columns).
610
611
  # Triggered when indices_mapping is None: v < 1.3.0 (both nuclei and cells, because
@@ -617,6 +618,7 @@ def _get_polygons(
617
618
  unique_ids = id_col.filter(np.concatenate([[True], change_mask])).to_pylist()
618
619
  index = _decode_cell_id_column(pd.Series(unique_ids))
619
620
  geo_df = GeoDataFrame({"geometry": geoms}, index=index.values)
621
+ geo_df.index.name = "cell_id"
620
622
 
621
623
  scale = Scale([1.0 / specs["pixel_size"], 1.0 / specs["pixel_size"]], axes=("x", "y"))
622
624
  return ShapesModel.parse(geo_df, transformations={"global": scale})
@@ -1,4 +1,6 @@
1
+ import contextlib
1
2
  import math
3
+ import os
2
4
  import shutil
3
5
  from copy import deepcopy
4
6
  from pathlib import Path
@@ -12,7 +14,12 @@ import pytest
12
14
  from click.testing import CliRunner
13
15
  from ome_types import OME
14
16
  from ome_types.model import (
17
+ Image,
15
18
  MapAnnotation,
19
+ Pixels,
20
+ Pixels_DimensionOrder,
21
+ PixelType,
22
+ Plane,
16
23
  Plate,
17
24
  Reagent,
18
25
  Screen,
@@ -30,12 +37,12 @@ from spatialdata_io.readers.macsima import (
30
37
  _collect_map_annotation_values,
31
38
  _get_software_major_version,
32
39
  _get_software_version,
40
+ _get_translations,
33
41
  _parse_ome_metadata,
34
42
  _parse_v0_ome_metadata,
35
43
  _parse_v1_ome_metadata,
36
44
  macsima,
37
45
  )
38
- from tests._utils import skip_if_below_python_version
39
46
 
40
47
  RNG = da.random.default_rng(seed=0)
41
48
 
@@ -52,21 +59,25 @@ if not (Path("./data/OMAP10_small").exists() or Path("./data/OMAP23_small").exis
52
59
  def make_ChannelMetadata(
53
60
  name: str,
54
61
  cycle: int,
55
- fluorophore: str | None = None,
56
- exposure: float | None = None,
57
- imagetype: str | None = None,
58
- well: str | None = None,
59
- roi: int | None = None,
62
+ fluorophore: str = "",
63
+ exposure: float = 0.0,
64
+ imagetype: str = "StainCycle",
65
+ well: str = "A01",
66
+ roi: int = 0,
67
+ translation_x: int = 0,
68
+ translation_y: int = 0,
60
69
  ) -> ChannelMetadata:
61
70
  """Helper to construct ChannelMetadata with required defaults."""
62
71
  return ChannelMetadata(
63
72
  name=name,
64
73
  cycle=cycle,
65
- fluorophore=fluorophore or "",
66
- exposure=exposure if exposure is not None else 0.0,
67
- imagetype=imagetype or "StainCycle",
68
- well=well or "A01",
69
- roi=roi if roi is not None else 0,
74
+ fluorophore=fluorophore,
75
+ exposure=exposure,
76
+ imagetype=imagetype,
77
+ well=well,
78
+ translation_x=translation_x,
79
+ translation_y=translation_y,
80
+ roi=roi,
70
81
  )
71
82
 
72
83
 
@@ -93,7 +104,7 @@ def test_exception_on_no_valid_files(tmp_path: Path) -> None:
93
104
  # Write a tiff file without metadata
94
105
  height = 10
95
106
  width = 10
96
- arr = np.zeros((height, width, 1), dtype=np.uint16)
107
+ arr = np.zeros((1, height, width), dtype=np.uint16)
97
108
  path_no_metadata = Path(tmp_path) / "tiff_no_metadata.tiff"
98
109
  imwrite(path_no_metadata, arr, metadata=None, description=None, software=None, datetime=None)
99
110
 
@@ -101,7 +112,114 @@ def test_exception_on_no_valid_files(tmp_path: Path) -> None:
101
112
  macsima(tmp_path)
102
113
 
103
114
 
104
- @skip_if_below_python_version()
115
+ def test_multiple_subfolder_parsing_skips_emtpy_folders(tmp_path: Path) -> None:
116
+ parent_folder = tmp_path / "test_folder"
117
+ shutil.copytree("./data/OMAP23_small", parent_folder / "OMAP23_small")
118
+ os.makedirs(parent_folder / "empty_folder")
119
+
120
+ with pytest.warns(UserWarning, match="No tif files found in .* skipping it"):
121
+ sdata = macsima(parent_folder, parsing_style="processed_multiple_folders")
122
+ assert len(sdata.images.keys()) == 1
123
+
124
+
125
+ @pytest.mark.parametrize(
126
+ "dimensions,expected",
127
+ [
128
+ (((10, 10), (10, 10)), False),
129
+ (((10, 10), (15, 10)), True),
130
+ (((10, 10), (10, 15)), True),
131
+ (((15, 10), (10, 15)), True),
132
+ ],
133
+ )
134
+ def test_check_differing_dimensions_works(dimensions: tuple[tuple[int, int], tuple[int, int]], expected: bool) -> None:
135
+ imgs = []
136
+ for img_dim in dimensions:
137
+ arr = da.from_array(np.ones((1, img_dim[0], img_dim[1]), dtype=np.uint16))
138
+ imgs.append(arr)
139
+
140
+ ctx = (
141
+ pytest.warns(UserWarning, match="Supplied images have different dimensions!")
142
+ if expected
143
+ else contextlib.nullcontext()
144
+ )
145
+ with ctx:
146
+ assert MultiChannelImage._check_for_differing_xy_dimensions(imgs) == expected
147
+
148
+
149
+ def test_padding_on_differing_dimensions() -> None:
150
+ # Simple test where all translations are 0
151
+ # Here we expect to pad to the largest element.
152
+ heights = [10, 10, 15, 20]
153
+ widths = [10, 15, 10, 20]
154
+
155
+ imgs = []
156
+ for height, width in zip(heights, widths, strict=True):
157
+ arr = da.from_array(np.ones((1, height, width), dtype=np.uint16))
158
+ imgs.append(arr)
159
+
160
+ channel_metadata = [make_ChannelMetadata(name="test", cycle=1)] * 4
161
+ with pytest.warns(UserWarning, match="Padding images with 0s to same size of \\(20, 20\\)"):
162
+ imgs_padded = MultiChannelImage._pad_images(imgs, channel_metadata)
163
+ for img in imgs_padded:
164
+ assert img.shape == (1, 20, 20)
165
+
166
+ # More complex with non-zero translations
167
+ # First test that padding does the minimal padding necessary.
168
+ # To do this create images with very large, but identical translations. Since all of these should be normalized out we expect size 20x20 again.
169
+ heights = [10, 10, 15, 20]
170
+ widths = [10, 15, 10, 20]
171
+
172
+ imgs = []
173
+ for height, width in zip(heights, widths, strict=True):
174
+ arr = da.from_array(np.ones((1, height, width), dtype=np.uint16))
175
+ imgs.append(arr)
176
+ channel_metadata = channel_metadata = [
177
+ make_ChannelMetadata(name="test", cycle=1, translation_x=100, translation_y=100)
178
+ ] * 4
179
+ with pytest.warns(UserWarning, match="Padding images with 0s to same size of \\(20, 20\\)"):
180
+ imgs_padded = MultiChannelImage._pad_images(imgs, channel_metadata)
181
+ for img in imgs_padded:
182
+ assert img.shape == (1, 20, 20)
183
+
184
+ # Test with differing translations but same size.
185
+ # As we translate the first image by 2 in x and 3 in y, we expect a 13x12 image
186
+ heights = [10, 10]
187
+ widths = [10, 10]
188
+
189
+ imgs = []
190
+ for height, width in zip(heights, widths, strict=True):
191
+ arr = da.from_array(np.ones((1, height, width), dtype=np.uint16))
192
+ imgs.append(arr)
193
+ channel_metadata = channel_metadata = [
194
+ make_ChannelMetadata(name="test", cycle=1, translation_x=2, translation_y=3),
195
+ make_ChannelMetadata(name="test", cycle=1, translation_x=0, translation_y=0),
196
+ ]
197
+ with pytest.warns(UserWarning, match="Padding images with 0s to same size of \\(13, 12\\)"):
198
+ imgs_padded = MultiChannelImage._pad_images(imgs, channel_metadata)
199
+ for img in imgs_padded:
200
+ assert img.shape == (1, 13, 12)
201
+
202
+ # Final test with differing image sizes, and translations that need to be normalized
203
+ # For the total size, we need to check the sum of each image dimension + normalized translation
204
+ # Here that would be image 2, with y = 15 + 5 - 3 = 17 (normalized to other image!) and x = 15 + 5 - 2 = 18
205
+
206
+ heights = [10, 15]
207
+ widths = [10, 15]
208
+
209
+ imgs = []
210
+ for height, width in zip(heights, widths, strict=True):
211
+ arr = da.from_array(np.ones((1, height, width), dtype=np.uint16))
212
+ imgs.append(arr)
213
+ channel_metadata = channel_metadata = [
214
+ make_ChannelMetadata(name="test", cycle=1, translation_x=2, translation_y=3),
215
+ make_ChannelMetadata(name="test", cycle=1, translation_x=5, translation_y=5),
216
+ ]
217
+ with pytest.warns(UserWarning, match="Padding images with 0s to same size of \\(17, 18\\)"):
218
+ imgs_padded = MultiChannelImage._pad_images(imgs, channel_metadata)
219
+ for img in imgs_padded:
220
+ assert img.shape == (1, 17, 18)
221
+
222
+
105
223
  @pytest.mark.parametrize(
106
224
  "dataset,expected",
107
225
  [
@@ -123,7 +241,6 @@ def test_image_size(dataset: str, expected: dict[str, Any]) -> None:
123
241
  assert extent == expected
124
242
 
125
243
 
126
- @skip_if_below_python_version()
127
244
  @pytest.mark.parametrize(
128
245
  "dataset,expected",
129
246
  [("OMAP10_small", 4), ("OMAP23_small", 5)],
@@ -139,7 +256,6 @@ def test_total_channels(dataset: str, expected: int) -> None:
139
256
  assert channels == expected
140
257
 
141
258
 
142
- @skip_if_below_python_version()
143
259
  @pytest.mark.parametrize(
144
260
  "dataset,expected",
145
261
  [
@@ -161,7 +277,6 @@ def test_channel_names_with_cycle_in_name(dataset: str, expected: list[str]) ->
161
277
  assert list(channels) == expected
162
278
 
163
279
 
164
- @skip_if_below_python_version()
165
280
  @pytest.mark.parametrize(
166
281
  "dataset,expected",
167
282
  [
@@ -178,7 +293,6 @@ def test_total_rounds(dataset: str, expected: list[int]) -> None:
178
293
  assert max_cycle == expected
179
294
 
180
295
 
181
- @skip_if_below_python_version()
182
296
  @pytest.mark.parametrize(
183
297
  "dataset,skip_rounds,expected",
184
298
  [
@@ -201,6 +315,57 @@ def test_skip_rounds(dataset: str, skip_rounds: list[int], expected: list[str])
201
315
  assert list(channels) == expected, f"Expected {expected}, got {list(channels)}"
202
316
 
203
317
 
318
+ def test_unsupported_parsing_styles() -> None:
319
+ with pytest.raises(ValueError, match="Invalid option `not_a_parsing_style` for `MACSimaParsingStyle`."):
320
+ macsima(Path(), parsing_style="not_a_parsing_style")
321
+
322
+
323
+ def test_processed_single_folder_parsing_returns_a_single_image_stack(tmp_path: Path) -> None:
324
+ omap10_path = Path("./data/OMAP10_small")
325
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
326
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
327
+
328
+ sdata = macsima(tmp_path, parsing_style="processed_single_folder")
329
+
330
+ assert len(sdata.images) == 1
331
+ # omap10_small has 4 channels, so we expect 8 here
332
+ el = sdata[list(sdata.images.keys())[0]]
333
+ assert len(get_channel_names(el)) == 8
334
+ assert len(sdata.tables) == 1
335
+
336
+
337
+ def test_processed_single_folder_parsing_warns_when_specifying_filtered_folders(tmp_path: Path) -> None:
338
+ omap10_path = Path("./data/OMAP10_small")
339
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
340
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
341
+ with pytest.warns(UserWarning, match="filtering only happens for processed_multi_folders"):
342
+ macsima(tmp_path, parsing_style="processed_single_folder", filter_folder_names=["OMAP10_small_2"])
343
+
344
+
345
+ def test_processed_multiple_folders_returns_an_image_stack_per_subfolder(tmp_path: Path) -> None:
346
+ omap10_path = Path("./data/OMAP10_small")
347
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_1")
348
+ shutil.copytree(omap10_path, tmp_path / "OMAP10_small_2")
349
+
350
+ sdata = macsima(tmp_path, parsing_style="processed_multiple_folders")
351
+
352
+ assert len(sdata.images) == 2
353
+ for el in sdata.images.keys():
354
+ assert len(get_channel_names(sdata[el])) == 4
355
+ assert len(sdata.tables) == 2
356
+
357
+
358
+ def test_processed_multiple_folders_skips_filtered_folder_names(tmp_path: Path) -> None:
359
+ shutil.copytree(Path("./data/OMAP10_small"), tmp_path / "OMAP10_small")
360
+ shutil.copytree(Path("./data/OMAP23_small"), tmp_path / "OMAP23_small")
361
+
362
+ sdata = macsima(tmp_path, parsing_style="processed_multiple_folders", filter_folder_names=["OMAP10_small"])
363
+ assert len(sdata.images) == 1
364
+ assert list(sdata.images.keys()) == ["OMAP23_small_image"]
365
+ assert len(sdata.tables) == 1
366
+ assert list(sdata.tables.keys()) == ["OMAP23_small_table"]
367
+
368
+
204
369
  METADATA_COLUMN_ORDER = [
205
370
  "cycle",
206
371
  "imagetype",
@@ -242,7 +407,6 @@ EXPECTED_METADATA_OMAP23 = pd.DataFrame(
242
407
  )
243
408
 
244
409
 
245
- @skip_if_below_python_version()
246
410
  @pytest.mark.parametrize(
247
411
  "dataset,expected_df",
248
412
  [
@@ -262,11 +426,6 @@ def test_metadata_table(dataset: str, expected_df: pd.DataFrame) -> None:
262
426
  pd.testing.assert_frame_equal(actual, expected_df)
263
427
 
264
428
 
265
- def test_parsing_style() -> None:
266
- with pytest.raises(ValueError):
267
- macsima(Path(), parsing_style="not_a_parsing_style")
268
-
269
-
270
429
  def test_mci_sort_by_channel() -> None:
271
430
  sizes = [100, 200, 300]
272
431
  c_names = ["test11", "test3", "test2"]
@@ -315,7 +474,6 @@ def test_mci_array_reference() -> None:
315
474
  assert da.all(mci.data[0] == orig_arr1)
316
475
 
317
476
 
318
- @skip_if_below_python_version()
319
477
  @pytest.mark.parametrize("dataset", ["OMAP10_small", "OMAP23_small"])
320
478
  def test_cli_macsima(runner: CliRunner, dataset: str) -> None:
321
479
  f = Path("./data") / dataset
@@ -645,15 +803,77 @@ def test_parse_v1_ome_metadata_handles_unknown_imagetypes() -> None:
645
803
  assert md["imagetype"] == "NOT A VALID TYPE"
646
804
 
647
805
 
648
- def make_ome_with_version(version_value: str, extra_ma: dict[str, Any] | None = None) -> OME:
649
- base = {"SoftwareVersion": version_value}
806
+ def test_get_translations_returns_correct_values() -> None:
807
+ ome = OME(
808
+ images=[
809
+ Image(
810
+ pixels=Pixels(
811
+ dimension_order=Pixels_DimensionOrder("XYZCT"),
812
+ type=PixelType.UINT16,
813
+ size_x=1,
814
+ size_y=1,
815
+ size_z=1,
816
+ size_c=1,
817
+ size_t=1,
818
+ planes=[Plane(position_x=1, position_y=2, the_z=0, the_t=0, the_c=0)],
819
+ )
820
+ )
821
+ ]
822
+ )
823
+ expected = {"translation_x": 1, "translation_y": 2}
824
+
825
+ translations = _get_translations(ome)
826
+ assert translations == expected
827
+
828
+
829
+ def test_get_translations_defaults_to_0_on_missing_data() -> None:
830
+ ome = OME(
831
+ images=[
832
+ Image(
833
+ pixels=Pixels(
834
+ dimension_order=Pixels_DimensionOrder("XYZCT"),
835
+ type=PixelType.UINT16,
836
+ size_x=1,
837
+ size_y=1,
838
+ size_z=1,
839
+ size_c=1,
840
+ size_t=1,
841
+ planes=[Plane(the_z=0, the_t=0, the_c=0)],
842
+ )
843
+ )
844
+ ],
845
+ )
846
+ expected = {"translation_x": 0, "translation_y": 0}
847
+
848
+ translations = _get_translations(ome)
849
+ assert translations == expected
850
+
851
+
852
+ def make_ome(extra_ma: dict[str, Any] | None = None) -> OME:
853
+ base = {}
650
854
  if extra_ma:
651
855
  base.update(extra_ma)
652
- return OME(structured_annotations=StructuredAnnotations(map_annotations=[MapAnnotation(value=base)]))
856
+ return OME(
857
+ images=[
858
+ Image(
859
+ pixels=Pixels(
860
+ dimension_order=Pixels_DimensionOrder("XYZCT"),
861
+ type=PixelType.UINT16,
862
+ size_x=1,
863
+ size_y=1,
864
+ size_z=1,
865
+ size_c=1,
866
+ size_t=1,
867
+ planes=[Plane(the_z=0, the_t=0, the_c=0)],
868
+ )
869
+ )
870
+ ],
871
+ structured_annotations=StructuredAnnotations(map_annotations=[MapAnnotation(value=base)]),
872
+ )
653
873
 
654
874
 
655
875
  def test_parse_ome_metadata_dispatches_to_v0() -> None:
656
- ome = make_ome_with_version("0.9.0")
876
+ ome = make_ome(extra_ma={"SoftwareVersion": "0.9.0"})
657
877
  # enrich some so v0 parser has something to see
658
878
  ome.screens = [Screen(reagents=[Reagent(name="Marker0")])]
659
879
 
@@ -666,15 +886,14 @@ def test_parse_ome_metadata_dispatches_to_v0() -> None:
666
886
 
667
887
 
668
888
  def test_parse_ome_metadata_dispatches_to_v1() -> None:
669
- ome = make_ome_with_version("1.0.0", extra_ma={"Biomarker": "CD3"})
670
-
889
+ ome = make_ome(extra_ma={"SoftwareVersion": "1.0.0", "Biomarker": "CD3"})
671
890
  md = _parse_ome_metadata(ome)
672
891
 
673
892
  assert md["name"] == "CD3"
674
893
 
675
894
 
676
895
  def test_parse_ome_metadata_unknown_major_raises() -> None:
677
- ome = make_ome_with_version("2.0.0")
896
+ ome = make_ome(extra_ma={"SoftwareVersion": "2.0.0"})
678
897
 
679
898
  with pytest.raises(ValueError, match="Unknown software version"):
680
899
  _parse_ome_metadata(ome)
@@ -114,6 +114,7 @@ def test_example_data_index_integrity(dataset: str) -> None:
114
114
  assert sdata["nucleus_labels"]["scale0"]["image"].sel(y=3515.5, x=4618.5).data.compute() == 6392
115
115
  assert np.allclose(sdata['transcripts'].compute().loc[[0, 10000, 1113949]]['x'], [2.608911, 194.917831, 1227.499268])
116
116
  assert np.isclose(sdata['cell_boundaries'].loc['oipggjko-1'].geometry.centroid.x,736.4864931162789)
117
+ assert sdata['cell_boundaries'].index.name == 'cell_id'
117
118
  index = sdata['nucleus_boundaries']['cell_id'].index[sdata['nucleus_boundaries']['cell_id'].eq('oipggjko-1')][0]
118
119
  assert np.isclose(sdata['nucleus_boundaries'].loc[index].geometry.centroid.x,736.4931256878282)
119
120
  assert np.array_equal(sdata['table'].X.indices[:3], [1, 3, 34])
@@ -140,6 +141,7 @@ def test_example_data_index_integrity(dataset: str) -> None:
140
141
  assert sdata["nucleus_labels"]["scale0"]["image"].sel(y=18.5, x=3015.5).data.compute() == 2764
141
142
  assert np.allclose(sdata['transcripts'].compute().loc[[0, 10000, 20000]]['x'], [174.258392, 12.210024, 214.759186])
142
143
  assert np.isclose(sdata['cell_boundaries'].loc['aaanbaof-1'].geometry.centroid.x, 43.96894317275074)
144
+ assert sdata['cell_boundaries'].index.name == 'cell_id'
143
145
  index = sdata['nucleus_boundaries']['cell_id'].index[sdata['nucleus_boundaries']['cell_id'].eq('aaanbaof-1')][0]
144
146
  assert np.isclose(sdata['nucleus_boundaries'].loc[index].geometry.centroid.x,43.31874577809517)
145
147
  assert np.array_equal(sdata['table'].X.indices[:3], [1, 8, 19])
@@ -167,6 +169,7 @@ def test_example_data_index_integrity(dataset: str) -> None:
167
169
  assert sdata["nucleus_labels"]["scale0"]["image"].sel(y=4039.5, x=93.5).data.compute() == 274
168
170
  assert np.allclose(sdata['transcripts'].compute().loc[[0, 10000, 20000]]['x'], [43.296875, 62.484375, 93.125])
169
171
  assert np.isclose(sdata['cell_boundaries'].loc['aadmbfof-1'].geometry.centroid.x, 64.54541104696033)
172
+ assert sdata['cell_boundaries'].index.name == 'cell_id'
170
173
  index = sdata['nucleus_boundaries']['cell_id'].index[sdata['nucleus_boundaries']['cell_id'].eq('aadmbfof-1')][0]
171
174
  assert np.isclose(sdata['nucleus_boundaries'].loc[index].geometry.centroid.x, 65.43305896114295)
172
175
  assert np.array_equal(sdata['table'].X.indices[:3], [3, 49, 53])
File without changes
File without changes
File without changes