thyra 4.3.2__tar.gz → 4.3.3__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 (149) hide show
  1. {thyra-4.3.2 → thyra-4.3.3}/PKG-INFO +1 -1
  2. {thyra-4.3.2 → thyra-4.3.3}/pyproject.toml +1 -1
  3. {thyra-4.3.2 → thyra-4.3.3}/thyra/__init__.py +1 -1
  4. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/optical_image.py +38 -12
  5. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/base_reader.py +42 -1
  6. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/imzml_extractor.py +6 -3
  7. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/mzpeak_extractor.py +1 -1
  8. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/mzpeak/mzpeak_reader.py +165 -18
  9. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/imzml_coordinate_base.py +85 -1
  10. {thyra-4.3.2 → thyra-4.3.3}/.gitignore +0 -0
  11. {thyra-4.3.2 → thyra-4.3.3}/LICENSE +0 -0
  12. {thyra-4.3.2 → thyra-4.3.3}/README.md +0 -0
  13. {thyra-4.3.2 → thyra-4.3.3}/thyra/__main__.py +0 -0
  14. {thyra-4.3.2 → thyra-4.3.3}/thyra/alignment/__init__.py +0 -0
  15. {thyra-4.3.2 → thyra-4.3.3}/thyra/alignment/affine.py +0 -0
  16. {thyra-4.3.2 → thyra-4.3.3}/thyra/alignment/teaching_points.py +0 -0
  17. {thyra-4.3.2 → thyra-4.3.3}/thyra/config.py +0 -0
  18. {thyra-4.3.2 → thyra-4.3.3}/thyra/convert.py +0 -0
  19. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/__init__.py +0 -0
  20. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/__init__.py +0 -0
  21. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/_chunking.py +0 -0
  22. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/base_spatialdata_converter.py +0 -0
  23. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/converter.py +0 -0
  24. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  25. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/fused_passes.py +0 -0
  26. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  27. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/mobility_table.py +0 -0
  28. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/msms_table.py +0 -0
  29. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/root_attrs.py +0 -0
  30. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  31. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/streaming_converter.py +0 -0
  32. {thyra-4.3.2 → thyra-4.3.3}/thyra/converters/spatialdata/uns_assembler.py +0 -0
  33. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/__init__.py +0 -0
  34. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/base_converter.py +0 -0
  35. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/base_extractor.py +0 -0
  36. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/conversion_state.py +0 -0
  37. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/drop_tally.py +0 -0
  38. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/frames.py +0 -0
  39. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/mass_axis.py +0 -0
  40. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/mobility.py +0 -0
  41. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/msms.py +0 -0
  42. {thyra-4.3.2 → thyra-4.3.3}/thyra/core/registry.py +0 -0
  43. {thyra-4.3.2 → thyra-4.3.3}/thyra/errors.py +0 -0
  44. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/__init__.py +0 -0
  45. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/constants.py +0 -0
  46. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/document.py +0 -0
  47. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/__init__.py +0 -0
  48. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  49. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/imzml_header_extractor.py +0 -0
  50. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/phi_extractor.py +0 -0
  51. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  52. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/extractors/waters_extractor.py +0 -0
  53. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/__init__.py +0 -0
  54. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/_ims.py +0 -0
  55. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/_ms.py +0 -0
  56. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/_uo.py +0 -0
  57. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/cache.py +0 -0
  58. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  59. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/personal_data.py +0 -0
  60. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/__init__.py +0 -0
  61. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/builder.py +0 -0
  62. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/cli.py +0 -0
  63. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/generate.py +0 -0
  64. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/metaspace.py +0 -0
  65. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/models.py +0 -0
  66. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  67. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/msi_metadata_schema_v0_10.json +0 -0
  68. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/store_io.py +0 -0
  69. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/validate.py +0 -0
  70. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/schema/vocab.py +0 -0
  71. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/types.py +0 -0
  72. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/uns_compat.py +0 -0
  73. {thyra-4.3.2 → thyra-4.3.3}/thyra/metadata/validator.py +0 -0
  74. {thyra-4.3.2 → thyra-4.3.3}/thyra/preview.py +0 -0
  75. {thyra-4.3.2 → thyra-4.3.3}/thyra/py.typed +0 -0
  76. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/__init__.py +0 -0
  77. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/__init__.py +0 -0
  78. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  79. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/folder_structure.py +0 -0
  80. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/mis_parser.py +0 -0
  81. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  82. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  83. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/solarix/__init__.py +0 -0
  84. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/solarix/solarix_reader.py +0 -0
  85. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/__init__.py +0 -0
  86. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  87. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  88. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  89. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  90. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  91. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  92. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  93. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  94. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
  95. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/bruker/vendor_db.py +0 -0
  96. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/imzml/__init__.py +0 -0
  97. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  98. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/imzml/header.py +0 -0
  99. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/imzml/imzml_reader.py +0 -0
  100. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/imzml/mobility_array.py +0 -0
  101. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/mzpeak/__init__.py +0 -0
  102. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/mzpeak/chunk_decoding.py +0 -0
  103. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/phi/__init__.py +0 -0
  104. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/phi/event_stream.py +0 -0
  105. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/phi/mass_axis.py +0 -0
  106. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/phi/phi_header.py +0 -0
  107. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/phi/phi_reader.py +0 -0
  108. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/__init__.py +0 -0
  109. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/imaging_grid.py +0 -0
  110. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/instrument.py +0 -0
  111. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/lib/MLReader.dll +0 -0
  112. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  113. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/lib/libMLReader.so +0 -0
  114. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  115. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/masslynx_lib.py +0 -0
  116. {thyra-4.3.2 → thyra-4.3.3}/thyra/readers/waters/waters_reader.py +0 -0
  117. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/__init__.py +0 -0
  118. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/axis_planner.py +0 -0
  119. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/binning.py +0 -0
  120. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/common_axis.py +0 -0
  121. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/constants.py +0 -0
  122. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/data_characteristics.py +0 -0
  123. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/decision_tree.py +0 -0
  124. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/gaps.py +0 -0
  125. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/instrument_detectors.py +0 -0
  126. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/interpolation.py +0 -0
  127. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/__init__.py +0 -0
  128. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/base_generator.py +0 -0
  129. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  130. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  131. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  132. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  133. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  134. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  135. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/mobility_grid.py +0 -0
  136. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/strategies/__init__.py +0 -0
  137. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/strategies/base.py +0 -0
  138. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  139. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/strategies/tic_preserving.py +0 -0
  140. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/tic.py +0 -0
  141. {thyra-4.3.2 → thyra-4.3.3}/thyra/resampling/types.py +0 -0
  142. {thyra-4.3.2 → thyra-4.3.3}/thyra/tools/__init__.py +0 -0
  143. {thyra-4.3.2 → thyra-4.3.3}/thyra/tools/check_ontology.py +0 -0
  144. {thyra-4.3.2 → thyra-4.3.3}/thyra/tools/make_example_data.py +0 -0
  145. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/__init__.py +0 -0
  146. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/bruker_exceptions.py +0 -0
  147. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.3.2 → thyra-4.3.3}/thyra/utils/windows_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 4.3.2
3
+ Version: 4.3.3
4
4
  Summary: A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics
5
5
  Project-URL: Homepage, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
6
6
  Project-URL: Repository, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thyra"
7
- version = "4.3.2"
7
+ version = "4.3.3"
8
8
  description = "A modern Python library for converting Mass Spectrometry Imaging (MSI) data into SpatialData/Zarr format - your portal to spatial omics"
9
9
  authors = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
10
10
  maintainers = [{ name = "Theodoros Visvikis", email = "t.visvikis@maastrichtuniversity.nl" }]
@@ -67,7 +67,7 @@ warnings.filterwarnings(
67
67
  category=FutureWarning,
68
68
  )
69
69
 
70
- __version__ = "4.3.2"
70
+ __version__ = "4.3.3"
71
71
 
72
72
  #: Public name -> the module that defines it, imported on first access.
73
73
  _LAZY_ATTRS = {
@@ -126,7 +126,7 @@ from ._chunking import image_chunks
126
126
 
127
127
  if TYPE_CHECKING: # pragma: no cover - typing only
128
128
  from ...alignment.teaching_points import RegionMapping
129
- from ...core.base_reader import BaseMSIReader
129
+ from ...core.base_reader import BaseMSIReader, OpticalImageLabel
130
130
 
131
131
  logger = logging.getLogger(__name__)
132
132
 
@@ -944,6 +944,11 @@ def _calc_optical_scale_factors(
944
944
  return factors
945
945
 
946
946
 
947
+ def _clean_stem(stem: str) -> str:
948
+ """A file stem made fit for an element name: lowercase, at most 30 long."""
949
+ return stem.lower().replace(" ", "_").replace("-", "_")[:30]
950
+
951
+
947
952
  class OpticalImages:
948
953
  """The optical images of one conversion: alignment, declaration, streaming.
949
954
 
@@ -1459,9 +1464,11 @@ class OpticalImages:
1459
1464
  images: The store's images, to add the placeholder to
1460
1465
  """
1461
1466
  # Generate a clean name for the image layer
1462
- image_name = self._element_name(image_path)
1467
+ label = self._reader_label(image_path)
1468
+ image_name = self._element_name(image_path, label)
1469
+ source_file = label.source_file if label is not None else image_path.name
1463
1470
 
1464
- logger.info(f"Loading optical image: {image_path.name} as '{image_name}'")
1471
+ logger.info(f"Loading optical image: {source_file} as '{image_name}'")
1465
1472
 
1466
1473
  # Only the page header is read here. The pixels never enter this
1467
1474
  # process whole: the element is declared to SpatialData as a lazy
@@ -1584,8 +1591,10 @@ class OpticalImages:
1584
1591
  # rewrites the stem (_0000 -> highres, and a stem over 30
1585
1592
  # characters is truncated), and the alignment image is only
1586
1593
  # distinguishable by its transform, and only when the alignment
1587
- # was applied. Recorded here, written by the root attrs.
1588
- self._sources[image_name] = image_path.name
1594
+ # was applied. Recorded here, written by the root attrs. For an
1595
+ # image a reader copied out of a container, the member it came
1596
+ # from, not the copy.
1597
+ self._sources[image_name] = source_file
1589
1598
  if is_primary:
1590
1599
  self._alignment_element = image_name
1591
1600
 
@@ -1688,18 +1697,39 @@ class OpticalImages:
1688
1697
  "global": {**systems["global"], "reference_element": None},
1689
1698
  }
1690
1699
 
1691
- def _element_name(self, image_path: Path) -> str:
1700
+ def _reader_label(self, image_path: Path) -> Optional[OpticalImageLabel]:
1701
+ """The reader's name for an image it copied out of its source.
1702
+
1703
+ Asked for with ``getattr`` for the reason
1704
+ :meth:`_adopt_reader_primary_optical` gives: a reader here need not
1705
+ inherit from ``BaseMSIReader``.
1706
+ """
1707
+ resolve = getattr(self.reader, "get_optical_image_label", None)
1708
+ if not callable(resolve):
1709
+ return None
1710
+ label: Optional[OpticalImageLabel] = resolve(image_path)
1711
+ return label
1712
+
1713
+ def _element_name(
1714
+ self, image_path: Path, label: Optional[OpticalImageLabel] = None
1715
+ ) -> str:
1692
1716
  """Generate a clean name for an optical image layer.
1693
1717
 
1694
1718
  The suffix is dropped, so the same acquisition exported as a .tif or
1695
- a .jpg lands under the same element name.
1719
+ a .jpg lands under the same element name. The ``_0000`` / ``_0001``
1720
+ rule is for files named by the acquisition software; an image a
1721
+ reader copied out of a container is named by the reader's label.
1696
1722
 
1697
1723
  Args:
1698
1724
  image_path: Path to the optical image
1725
+ label: The reader's name for the image, if it gave one.
1699
1726
 
1700
1727
  Returns:
1701
1728
  Clean name for the image layer (e.g., 'optical_0000', 'optical_deriv')
1702
1729
  """
1730
+ if label is not None:
1731
+ return f"{self.dataset_id}_optical_{_clean_stem(label.name)}"
1732
+
1703
1733
  stem = image_path.stem.lower()
1704
1734
 
1705
1735
  # Extract meaningful suffix from filename
@@ -1710,11 +1740,7 @@ class OpticalImages:
1710
1740
  elif "deriv" in stem:
1711
1741
  suffix = "overview"
1712
1742
  else:
1713
- # Use stem with special chars replaced
1714
- suffix = stem.replace(" ", "_").replace("-", "_")
1715
- # Truncate if too long
1716
- if len(suffix) > 30:
1717
- suffix = suffix[:30]
1743
+ suffix = _clean_stem(stem)
1718
1744
 
1719
1745
  return f"{self.dataset_id}_optical_{suffix}"
1720
1746
 
@@ -52,7 +52,16 @@ for all seven readers at once, so the next change to it is a visible one.
52
52
  import logging
53
53
  from abc import ABC, abstractmethod
54
54
  from pathlib import Path
55
- from typing import TYPE_CHECKING, Dict, Generator, List, Optional, Tuple, Union
55
+ from typing import (
56
+ TYPE_CHECKING,
57
+ Dict,
58
+ Generator,
59
+ List,
60
+ NamedTuple,
61
+ Optional,
62
+ Tuple,
63
+ Union,
64
+ )
56
65
 
57
66
  import numpy as np
58
67
  from numpy.typing import NDArray
@@ -70,6 +79,22 @@ from .mass_axis import validate_max_mass_axis_length
70
79
  logger = logging.getLogger(__name__)
71
80
 
72
81
 
82
+ class OpticalImageLabel(NamedTuple):
83
+ """What a store calls an optical image whose file name is not the source's.
84
+
85
+ A reader that copies images out of a container hands the converter files
86
+ it named itself. Their names say nothing, and the name rule written for
87
+ vendor folders (``_0000`` is the high resolution scan) would read meaning
88
+ into them.
89
+ """
90
+
91
+ name: str
92
+ """The element name after ``<dataset_id>_optical_``."""
93
+
94
+ source_file: str
95
+ """What the store records the element came from."""
96
+
97
+
73
98
  class BaseMSIReader(ABC):
74
99
  """Abstract base class for reading MSI data formats."""
75
100
 
@@ -169,6 +194,22 @@ class BaseMSIReader(ABC):
169
194
  """
170
195
  return None
171
196
 
197
+ def get_optical_image_label(self, path: Path) -> Optional[OpticalImageLabel]:
198
+ """Name an optical image this reader copied out of its source.
199
+
200
+ Default implementation returns None: the file is the source's own,
201
+ and its name is what the store is named after. Override alongside
202
+ :meth:`get_optical_image_paths` when the reader names the files.
203
+
204
+ Args:
205
+ path: One of the paths :meth:`get_optical_image_paths` returned.
206
+
207
+ Returns:
208
+ The element name and the source file to record, or None to name
209
+ the element after the file.
210
+ """
211
+ return None
212
+
172
213
  @abstractmethod
173
214
  def get_common_mass_axis(self) -> NDArray[np.float64]:
174
215
  """Return the common mass axis for all spectra.
@@ -10,7 +10,7 @@ from pyimzml.ImzMLParser import ImzMLParser
10
10
 
11
11
  from ...core.base_extractor import MetadataExtractor
12
12
  from ...errors import ConversionRefused
13
- from ...utils.imzml_coordinate_base import coordinate_bases
13
+ from ...utils.imzml_coordinate_base import coordinate_bases, recorded_offsets
14
14
  from ...utils.pyimzml_direct import read_spectrum_mzs_only
15
15
  from ..constants import (
16
16
  BinaryDataType,
@@ -352,8 +352,11 @@ class ImzMLMetadataExtractor(MetadataExtractor):
352
352
  # store can say where its origin came from: the converter
353
353
  # writes it to coordinate_systems.global.coordinate_offsets_px.
354
354
  # (1, 1, 1) for an ordinary 1-based file, (0, 0, ...) for a
355
- # 0-based one -- see _coordinate_bases() (issue #244).
356
- coordinate_offsets=self._coordinate_bases(coords),
355
+ # 0-based one -- see _coordinate_bases() (issue #244). z is 0
356
+ # when the file states none (D30).
357
+ coordinate_offsets=recorded_offsets(
358
+ self._coordinate_bases(coords), coords[:, 2], self.imzml_path
359
+ ),
357
360
  spectrum_type=spectrum_type,
358
361
  )
359
362
 
@@ -118,7 +118,7 @@ class MzPeakMetadataExtractor(MetadataExtractor):
118
118
  n_spectra=n_spectra,
119
119
  total_peaks=total_peaks,
120
120
  source_path=str(self.data_path),
121
- coordinate_offsets=(index.offsets[0], index.offsets[1], 0),
121
+ coordinate_offsets=(index.offsets[0], index.offsets[1], index.z_offset),
122
122
  spectrum_type=self._spectrum_type(),
123
123
  )
124
124
 
@@ -65,13 +65,14 @@ from typing import (
65
65
  List,
66
66
  NamedTuple,
67
67
  Optional,
68
+ Set,
68
69
  Tuple,
69
70
  )
70
71
 
71
72
  import numpy as np
72
73
  from numpy.typing import NDArray
73
74
 
74
- from ...core.base_reader import BaseMSIReader
75
+ from ...core.base_reader import BaseMSIReader, OpticalImageLabel
75
76
  from ...core.mass_axis import MassAxisAccumulator
76
77
  from ...core.registry import register_reader
77
78
  from ...errors import ConversionRefused
@@ -104,11 +105,13 @@ ZIP_MAGIC = b"PK\x03\x04"
104
105
  # one axis and misses the other.
105
106
  IMS_POSITION_X = "IMS:1000050"
106
107
  IMS_POSITION_Y = "IMS:1000051"
108
+ IMS_POSITION_Z = "IMS:1000052"
107
109
 
108
110
  #: Conventional column names for the position terms. Used only as a fallback
109
111
  #: when the index carries no column mapping, which the schema permits.
110
112
  DEFAULT_POSITION_X_COLUMN = "opt_IMS_1000050_position_x"
111
113
  DEFAULT_POSITION_Y_COLUMN = "opt_IMS_1000051_position_y"
114
+ DEFAULT_POSITION_Z_COLUMN = "opt_IMS_1000052_position_z"
112
115
 
113
116
  #: Base of the positions when the archive does not declare one in
114
117
  #: ``imaging.coordinate_base``: the imzML convention, which every archive
@@ -132,6 +135,17 @@ CHUNK_BATCH_ROWS = (16, 65536)
132
135
  #: Image formats the optical image loader reads.
133
136
  OPTICAL_IMAGE_SUFFIXES = (".tif", ".tiff", ".jpg", ".jpeg", ".png", ".bmp")
134
137
 
138
+ #: The suffix an embedded image is copied out under, by the media type the
139
+ #: archive declares for it. The loader picks its decoder by suffix, and a
140
+ #: slide scanner's TIFF is a TIFF under a name of its own: the glioma example
141
+ #: embeds an Aperio ``.svs`` declared ``image/tiff``.
142
+ MEDIA_TYPE_SUFFIXES = {
143
+ "image/tiff": ".tif",
144
+ "image/jpeg": ".jpg",
145
+ "image/png": ".png",
146
+ "image/bmp": ".bmp",
147
+ }
148
+
135
149
  #: Metadata column giving each spectrum's MS level.
136
150
  MS_LEVEL_COLUMN = "ms_level"
137
151
 
@@ -281,6 +295,8 @@ class MzPeakSpatialIndex(NamedTuple):
281
295
  complete: Whether every spectrum the archive lists is kept. When
282
296
  one is left out (no position, or an MSn spectrum beside MS1),
283
297
  the axis, the mass range and the peak count skip it too.
298
+ z_offset: The position z the archive states for its one plane, 0
299
+ when it states none (D30).
284
300
  """
285
301
 
286
302
  spectrum_indices: NDArray[np.int64]
@@ -289,6 +305,7 @@ class MzPeakSpatialIndex(NamedTuple):
289
305
  point_counts: NDArray[np.int64]
290
306
  offsets: Tuple[int, int]
291
307
  complete: bool = True
308
+ z_offset: int = 0
292
309
 
293
310
 
294
311
  class MzPeakArchive:
@@ -696,6 +713,25 @@ class MzPeakArchive:
696
713
  return None
697
714
  return (x_column, y_column)
698
715
 
716
+ def position_z_column(self) -> Optional[str]:
717
+ """Resolve the scan column holding position z, the way x and y are.
718
+
719
+ mzpeak-convert writes one when its source states z (an imzML with
720
+ ``IMS:1000052`` on its spectra) and none otherwise.
721
+
722
+ Returns:
723
+ The column name, or ``None`` when the archive has none.
724
+ """
725
+ entry = self.entry("spectrum", "scans")
726
+ if entry is None:
727
+ return None
728
+ names = set(self.parquet("spectrum", "scans").schema_arrow.names)
729
+ z_column = (
730
+ self.column_for_accession(entry, IMS_POSITION_Z)
731
+ or DEFAULT_POSITION_Z_COLUMN
732
+ )
733
+ return z_column if z_column in names else None
734
+
699
735
  def spatial_index(self) -> MzPeakSpatialIndex:
700
736
  """Read positions and per-spectrum point counts.
701
737
 
@@ -723,9 +759,14 @@ class MzPeakArchive:
723
759
  f"position columns. Thyra converts imaging acquisitions only."
724
760
  )
725
761
  x_column, y_column = columns
762
+ z_column = self.position_z_column()
726
763
 
727
764
  scans_member = self.parquet("spectrum", "scans")
728
- scans = scans_member.read(columns=["source_index", x_column, y_column])
765
+ scans = scans_member.read(
766
+ columns=[
767
+ name for name in ("source_index", x_column, y_column, z_column) if name
768
+ ]
769
+ )
729
770
  self._report_ion_mobility(scans_member)
730
771
  source = np.asarray(scans.column("source_index").to_numpy(), dtype=np.int64)
731
772
  xs, has_x = _positions(scans.column(x_column))
@@ -743,6 +784,10 @@ class MzPeakArchive:
743
784
  f"both, and a scan on no pixel has neither."
744
785
  )
745
786
  placed = has_x & has_y
787
+ # Which row of the scans member each entry came from, carried through
788
+ # the reordering below so that position z can be read for the scans
789
+ # that end up on a pixel.
790
+ scan_row = np.arange(source.size, dtype=np.int64)
746
791
 
747
792
  # One spectrum may own several scans; imaging acquisitions write one.
748
793
  # Keeping the first per spectrum means a multi-scan file resolves to a
@@ -750,14 +795,21 @@ class MzPeakArchive:
750
795
  # position goes before one without, so a spectrum is only unplaced
751
796
  # when none of its scans is on a pixel.
752
797
  first_placed = np.argsort(~placed, kind="stable")
753
- source, xs, ys, placed = (
798
+ source, xs, ys, placed, scan_row = (
754
799
  source[first_placed],
755
800
  xs[first_placed],
756
801
  ys[first_placed],
757
802
  placed[first_placed],
803
+ scan_row[first_placed],
758
804
  )
759
805
  _, first = np.unique(source, return_index=True)
760
- source, xs, ys, placed = source[first], xs[first], ys[first], placed[first]
806
+ source, xs, ys, placed, scan_row = (
807
+ source[first],
808
+ xs[first],
809
+ ys[first],
810
+ placed[first],
811
+ scan_row[first],
812
+ )
761
813
 
762
814
  spectrum_index, counts, levels = self._read_point_counts()
763
815
  order = np.argsort(spectrum_index, kind="stable")
@@ -819,6 +871,7 @@ class MzPeakArchive:
819
871
  complete = spectrum_index.size == n_listed
820
872
 
821
873
  raw = np.stack([xs[rows], ys[rows]], axis=1)
874
+ z_offset = self._z_offset(scans, z_column, scan_row[rows])
822
875
  self._report_shared_pixels(raw)
823
876
  bases = self._position_bases(raw)
824
877
  coordinates = raw - np.array(bases, dtype=np.int64)
@@ -831,9 +884,52 @@ class MzPeakArchive:
831
884
  point_counts=counts,
832
885
  offsets=(bases[0] + shift[0], bases[1] + shift[1]),
833
886
  complete=complete,
887
+ z_offset=z_offset,
834
888
  )
835
889
  return self._spatial_index
836
890
 
891
+ def _z_offset(
892
+ self, scans: Any, z_column: Optional[str], placed_rows: NDArray[np.int64]
893
+ ) -> int:
894
+ """The position z of the archive's plane, 0 when it states none.
895
+
896
+ Recorded as the z offset, the way D14 records the smallest z of an
897
+ imzML. An archive without z records 0, as every format without z
898
+ does (D30). The spectra on a pixel all sit on index 0 of z either
899
+ way: this reader yields one plane.
900
+
901
+ Args:
902
+ scans: The scans member as read, with the z column when there is
903
+ one.
904
+ z_column: The column holding position z, or ``None``.
905
+ placed_rows: The rows of ``scans`` whose spectra are on a pixel.
906
+
907
+ Raises:
908
+ ConversionRefused: If some of those spectra state z and the
909
+ others do not, or if they state more than one z. Spectra on
910
+ several planes would otherwise be summed into one.
911
+ """
912
+ if z_column is None:
913
+ return 0
914
+ zs, has_z = _positions(scans.column(z_column))
915
+ zs, has_z = zs[placed_rows], has_z[placed_rows]
916
+ if not has_z.any():
917
+ return 0
918
+ if not has_z.all():
919
+ raise ConversionRefused(
920
+ f"{self.path} gives {int(has_z.sum())} of {has_z.size} spectra "
921
+ f"on a pixel a position z ({IMS_POSITION_Z}) and the others "
922
+ f"none. A plane is stated for every spectrum or for none."
923
+ )
924
+ planes = np.unique(zs)
925
+ if planes.size > 1:
926
+ raise ConversionRefused(
927
+ f"{self.path} places its spectra on {planes.size} planes "
928
+ f"(position z from {int(planes[0])} to {int(planes[-1])}). "
929
+ f"Thyra reads one plane of an mzPeak archive."
930
+ )
931
+ return int(planes[0])
932
+
837
933
  def _report_shared_pixels(self, raw: NDArray[np.int64]) -> None:
838
934
  """Warn when several spectra sit on one pixel; they are summed there."""
839
935
  pixels = np.unique(raw, axis=0).shape[0]
@@ -877,7 +973,7 @@ class MzPeakArchive:
877
973
  self.path.name,
878
974
  )
879
975
 
880
- def _imaging_metadata(self) -> Dict[str, Any]:
976
+ def imaging_metadata(self) -> Dict[str, Any]:
881
977
  """The archive's ``imaging`` block, or an empty one."""
882
978
  imaging = self.file_level_metadata().get("imaging")
883
979
  return imaging if isinstance(imaging, dict) else {}
@@ -892,7 +988,7 @@ class MzPeakArchive:
892
988
  smallest position instead moved it to the corner and cut the grid
893
989
  to its bounding box.
894
990
  """
895
- declared = self._imaging_metadata().get("coordinate_base")
991
+ declared = self.imaging_metadata().get("coordinate_base")
896
992
  if not isinstance(declared, int) or isinstance(declared, bool):
897
993
  declared = SPEC_BASE
898
994
  smallest = (int(raw[:, 0].min()), int(raw[:, 1].min()))
@@ -917,7 +1013,7 @@ class MzPeakArchive:
917
1013
  frame of the instrument's raster, where a conversion of the same
918
1014
  run from its ``.d`` puts it.
919
1015
  """
920
- offset = self._imaging_metadata().get("position_offset")
1016
+ offset = self.imaging_metadata().get("position_offset")
921
1017
  if not isinstance(offset, dict):
922
1018
  return (0, 0)
923
1019
  values = (offset.get("x", 0), offset.get("y", 0))
@@ -1090,6 +1186,8 @@ class MzPeakReader(BaseMSIReader):
1090
1186
  #: Folder the embedded images are copied to, made on first use.
1091
1187
  self._image_dir: Optional[Path] = None
1092
1188
  self._image_paths: Optional[List[Path]] = None
1189
+ #: What the store calls each copied image, by the copy's path.
1190
+ self._image_labels: Dict[Path, OpticalImageLabel] = {}
1093
1191
  self._regions_read = False
1094
1192
  self._region_cache: Optional[
1095
1193
  Tuple[Dict[Tuple[int, int], int], List[Dict[str, Any]]]
@@ -1626,8 +1724,10 @@ class MzPeakReader(BaseMSIReader):
1626
1724
 
1627
1725
  mzpeak-convert stores an image as an ``image`` member, verbatim.
1628
1726
  The optical image loader reads files, so each one is copied to a
1629
- folder of this reader's own, which :meth:`close` removes. A member
1630
- in a format the loader does not read is skipped with a warning.
1727
+ folder of this reader's own, which :meth:`close` removes. The copy
1728
+ takes the suffix of the media type the archive declares, so a slide
1729
+ scanner's TIFF is read as the TIFF it is. A member in a format the
1730
+ loader does not read is skipped with a warning.
1631
1731
 
1632
1732
  The images are not aligned to the pixels. The archive gives an
1633
1733
  affine for each, but the ones seen so far are marked
@@ -1639,6 +1739,16 @@ class MzPeakReader(BaseMSIReader):
1639
1739
  self._image_paths = self._extract_images()
1640
1740
  return list(self._image_paths)
1641
1741
 
1742
+ def get_optical_image_label(self, path: Path) -> Optional[OpticalImageLabel]:
1743
+ """Name a copied image after its member, not by the vendor rule.
1744
+
1745
+ mzpeak-convert numbers its members ``images/image_0000.<ext>``, and
1746
+ the rule for vendor folders would call the first one the high
1747
+ resolution scan and the second the derived image, whatever they
1748
+ show.
1749
+ """
1750
+ return self._image_labels.get(Path(path))
1751
+
1642
1752
  def _image_members(self) -> List[str]:
1643
1753
  """Names of the members the index lists as images, in index order."""
1644
1754
  entries = self.archive.index.get("files")
@@ -1653,34 +1763,70 @@ class MzPeakReader(BaseMSIReader):
1653
1763
  names.append(name)
1654
1764
  return names
1655
1765
 
1766
+ def _image_media_types(self) -> Dict[str, str]:
1767
+ """The media type the archive declares for each image member.
1768
+
1769
+ From ``imaging.images``, where each entry names its member in
1770
+ ``archive_path``.
1771
+ """
1772
+ entries = self.archive.imaging_metadata().get("images")
1773
+ media_types: Dict[str, str] = {}
1774
+ for entry in entries if isinstance(entries, list) else []:
1775
+ if not isinstance(entry, dict):
1776
+ continue
1777
+ path, media_type = entry.get("archive_path"), entry.get("media_type")
1778
+ if isinstance(path, str) and isinstance(media_type, str):
1779
+ media_types[path] = media_type.strip().lower()
1780
+ return media_types
1781
+
1782
+ @staticmethod
1783
+ def _copy_suffix(name: str, media_type: Optional[str]) -> Optional[str]:
1784
+ """The suffix the loader reads a member under, or ``None`` for none.
1785
+
1786
+ The declared media type decides, so a TIFF named ``.svs`` is read as
1787
+ a TIFF. Without one, the member's own suffix does.
1788
+ """
1789
+ if media_type in MEDIA_TYPE_SUFFIXES:
1790
+ return MEDIA_TYPE_SUFFIXES[media_type]
1791
+ suffix = Path(name).suffix.lower()
1792
+ return suffix if suffix in OPTICAL_IMAGE_SUFFIXES else None
1793
+
1656
1794
  def _extract_images(self) -> List[Path]:
1657
1795
  """Copy every readable image member out of the archive."""
1658
1796
  members = self._image_members()
1659
1797
  if not members:
1660
1798
  return []
1799
+ media_types = self._image_media_types()
1661
1800
  paths: List[Path] = []
1801
+ taken: Set[str] = set()
1662
1802
  for name in members:
1663
- suffix = Path(name).suffix.lower()
1664
- if suffix not in OPTICAL_IMAGE_SUFFIXES:
1803
+ suffix = self._copy_suffix(name, media_types.get(name))
1804
+ if suffix is None:
1665
1805
  logger.warning(
1666
- "%s embeds the image %s, in a format the optical image "
1667
- "loader does not read; it is not carried over.",
1806
+ "%s embeds the image %s (%s), in a format the optical "
1807
+ "image loader does not read; it is not carried over.",
1668
1808
  self.data_path.name,
1669
1809
  name,
1810
+ media_types.get(name, "no media type declared"),
1670
1811
  )
1671
1812
  continue
1672
1813
  if self._image_dir is None:
1673
1814
  self._image_dir = Path(tempfile.mkdtemp(prefix="thyra-mzpeak-images-"))
1674
1815
  # The base name only: a member name is a path inside the ZIP
1675
- # and must not choose where on this disk the copy goes.
1676
- target = self._image_dir / Path(name).name
1677
- stem, number = target.stem, 1
1678
- while target.exists():
1679
- target = self._image_dir / f"{stem}_{number}{suffix}"
1816
+ # and must not choose where on this disk the copy goes. Unique
1817
+ # whatever the case, because the store names the element after
1818
+ # it, lowercased.
1819
+ stem = candidate = Path(name).stem
1820
+ number = 1
1821
+ while candidate.lower() in taken:
1822
+ candidate = f"{stem}_{number}"
1680
1823
  number += 1
1824
+ taken.add(candidate.lower())
1825
+ target = self._image_dir / f"{candidate}{suffix}"
1681
1826
  with self.archive.open_member(name) as source, target.open("wb") as sink:
1682
1827
  shutil.copyfileobj(source, sink)
1683
1828
  paths.append(target)
1829
+ self._image_labels[target] = OpticalImageLabel(candidate, name)
1684
1830
  if paths:
1685
1831
  logger.info(
1686
1832
  "%s embeds %d optical image(s); they are carried over without "
@@ -1699,3 +1845,4 @@ class MzPeakReader(BaseMSIReader):
1699
1845
  shutil.rmtree(self._image_dir, ignore_errors=True)
1700
1846
  self._image_dir = None
1701
1847
  self._image_paths = None
1848
+ self._image_labels = {}