thyra 4.3.4__tar.gz → 4.4.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 (149) hide show
  1. {thyra-4.3.4 → thyra-4.4.0}/PKG-INFO +22 -1
  2. {thyra-4.3.4 → thyra-4.4.0}/README.md +20 -0
  3. {thyra-4.3.4 → thyra-4.4.0}/pyproject.toml +2 -1
  4. {thyra-4.3.4 → thyra-4.4.0}/thyra/__init__.py +1 -1
  5. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +2 -2
  6. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/optical_image.py +13 -1
  7. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/root_attrs.py +13 -11
  8. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/streaming_converter.py +20 -2
  9. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/uns_assembler.py +6 -0
  10. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/base_reader.py +74 -3
  11. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/constants.py +35 -1
  12. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/imzml_extractor.py +64 -2
  13. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/imzml_header_extractor.py +10 -1
  14. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/mzpeak_extractor.py +120 -3
  15. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/timstof_reader.py +38 -10
  16. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/imzml/header.py +20 -9
  17. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/imzml/imzml_reader.py +48 -16
  18. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/mzpeak/chunk_decoding.py +108 -21
  19. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/mzpeak/mzpeak_reader.py +526 -19
  20. {thyra-4.3.4 → thyra-4.4.0}/.gitignore +0 -0
  21. {thyra-4.3.4 → thyra-4.4.0}/LICENSE +0 -0
  22. {thyra-4.3.4 → thyra-4.4.0}/thyra/__main__.py +0 -0
  23. {thyra-4.3.4 → thyra-4.4.0}/thyra/alignment/__init__.py +0 -0
  24. {thyra-4.3.4 → thyra-4.4.0}/thyra/alignment/affine.py +0 -0
  25. {thyra-4.3.4 → thyra-4.4.0}/thyra/alignment/teaching_points.py +0 -0
  26. {thyra-4.3.4 → thyra-4.4.0}/thyra/config.py +0 -0
  27. {thyra-4.3.4 → thyra-4.4.0}/thyra/convert.py +0 -0
  28. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/__init__.py +0 -0
  29. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/__init__.py +0 -0
  30. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  31. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/converter.py +0 -0
  32. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  33. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/fused_passes.py +0 -0
  34. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  35. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/mobility_table.py +0 -0
  36. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/msms_table.py +0 -0
  37. {thyra-4.3.4 → thyra-4.4.0}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  38. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/__init__.py +0 -0
  39. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/base_converter.py +0 -0
  40. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/base_extractor.py +0 -0
  41. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/conversion_state.py +0 -0
  42. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/drop_tally.py +0 -0
  43. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/frames.py +0 -0
  44. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/mass_axis.py +0 -0
  45. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/mobility.py +0 -0
  46. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/msms.py +0 -0
  47. {thyra-4.3.4 → thyra-4.4.0}/thyra/core/registry.py +0 -0
  48. {thyra-4.3.4 → thyra-4.4.0}/thyra/errors.py +0 -0
  49. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/__init__.py +0 -0
  50. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/document.py +0 -0
  51. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/__init__.py +0 -0
  52. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  53. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/phi_extractor.py +0 -0
  54. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  55. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  56. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/__init__.py +0 -0
  57. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/_ims.py +0 -0
  58. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/_ms.py +0 -0
  59. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/_uo.py +0 -0
  60. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/cache.py +0 -0
  61. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  62. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/personal_data.py +0 -0
  63. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/__init__.py +0 -0
  64. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/builder.py +0 -0
  65. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/cli.py +0 -0
  66. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/generate.py +0 -0
  67. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/metaspace.py +0 -0
  68. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/models.py +0 -0
  69. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  70. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/msi_metadata_schema_v0_10.json +0 -0
  71. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/store_io.py +0 -0
  72. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/validate.py +0 -0
  73. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/schema/vocab.py +0 -0
  74. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/types.py +0 -0
  75. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/uns_compat.py +0 -0
  76. {thyra-4.3.4 → thyra-4.4.0}/thyra/metadata/validator.py +0 -0
  77. {thyra-4.3.4 → thyra-4.4.0}/thyra/preview.py +0 -0
  78. {thyra-4.3.4 → thyra-4.4.0}/thyra/py.typed +0 -0
  79. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/__init__.py +0 -0
  80. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/__init__.py +0 -0
  81. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  82. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/folder_structure.py +0 -0
  83. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/mis_parser.py +0 -0
  84. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  85. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  86. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/solarix/__init__.py +0 -0
  87. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/solarix/solarix_reader.py +0 -0
  88. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  89. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  90. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  91. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  92. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  93. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  94. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  95. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  96. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  97. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/bruker/vendor_db.py +0 -0
  98. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/imzml/__init__.py +0 -0
  99. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  100. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/imzml/mobility_array.py +0 -0
  101. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/mzpeak/__init__.py +0 -0
  102. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/phi/__init__.py +0 -0
  103. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/phi/event_stream.py +0 -0
  104. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/phi/mass_axis.py +0 -0
  105. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/phi/phi_header.py +0 -0
  106. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/phi/phi_reader.py +0 -0
  107. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/__init__.py +0 -0
  108. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/imaging_grid.py +0 -0
  109. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/instrument.py +0 -0
  110. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  111. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  112. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  113. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  114. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  115. {thyra-4.3.4 → thyra-4.4.0}/thyra/readers/waters/waters_reader.py +0 -0
  116. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/__init__.py +0 -0
  117. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/axis_planner.py +0 -0
  118. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/binning.py +0 -0
  119. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/common_axis.py +0 -0
  120. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/constants.py +0 -0
  121. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/data_characteristics.py +0 -0
  122. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/decision_tree.py +0 -0
  123. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/gaps.py +0 -0
  124. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/instrument_detectors.py +0 -0
  125. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/interpolation.py +0 -0
  126. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  127. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  128. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  129. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  130. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  131. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  132. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  133. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  134. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/mobility_grid.py +0 -0
  135. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/strategies/__init__.py +0 -0
  136. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/strategies/base.py +0 -0
  137. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  138. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  139. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/tic.py +0 -0
  140. {thyra-4.3.4 → thyra-4.4.0}/thyra/resampling/types.py +0 -0
  141. {thyra-4.3.4 → thyra-4.4.0}/thyra/tools/__init__.py +0 -0
  142. {thyra-4.3.4 → thyra-4.4.0}/thyra/tools/check_ontology.py +0 -0
  143. {thyra-4.3.4 → thyra-4.4.0}/thyra/tools/make_example_data.py +0 -0
  144. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/__init__.py +0 -0
  145. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/bruker_exceptions.py +0 -0
  146. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/imzml_coordinate_base.py +0 -0
  147. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.3.4 → thyra-4.4.0}/thyra/utils/windows_paths.py +0 -0
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 4.3.4
3
+ Version: 4.4.0
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
7
7
  Project-URL: Documentation, https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/
8
+ Project-URL: Paper, https://doi.org/10.1021/jasms.6c00169
8
9
  Project-URL: Bug Tracker, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/issues
9
10
  Project-URL: Discussions, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/discussions
10
11
  Project-URL: Changelog, https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/blob/main/CHANGELOG.md
@@ -172,6 +173,26 @@ MIT -- see [LICENSE](LICENSE).
172
173
 
173
174
  ## Citation
174
175
 
176
+ If you use Thyra, please cite the paper:
177
+
178
+ > Visvikis, T.; Vierdag, W.-M.; Marconato, L.; Birmpili, A.; Heeren, R. M. A.;
179
+ > Cuypers, E. Native, Memory-Bounded Conversion of Mass Spectrometry Imaging
180
+ > Data into the SpatialData Ecosystem. *J. Am. Soc. Mass Spectrom.* **2026**.
181
+ > [doi:10.1021/jasms.6c00169](https://doi.org/10.1021/jasms.6c00169)
182
+
183
+ ```bibtex
184
+ @article{visvikis2026thyra,
185
+ title = {Native, Memory-Bounded Conversion of Mass Spectrometry Imaging Data into the {SpatialData} Ecosystem},
186
+ author = {Visvikis, Theodoros and Vierdag, Wouter-Michiel and Marconato, Luca and Birmpili, Angeliki and Heeren, Ron M. A. and Cuypers, Eva},
187
+ journal = {Journal of the American Society for Mass Spectrometry},
188
+ year = {2026},
189
+ doi = {10.1021/jasms.6c00169}
190
+ }
191
+ ```
192
+
193
+ The same entry is in [CITATION.cff](CITATION.cff), which GitHub's "Cite this
194
+ repository" button reads. To cite the software itself:
195
+
175
196
  ```bibtex
176
197
  @software{thyra2024,
177
198
  title = {Thyra: Modern Mass Spectrometry Imaging Data Conversion},
@@ -119,6 +119,26 @@ MIT -- see [LICENSE](LICENSE).
119
119
 
120
120
  ## Citation
121
121
 
122
+ If you use Thyra, please cite the paper:
123
+
124
+ > Visvikis, T.; Vierdag, W.-M.; Marconato, L.; Birmpili, A.; Heeren, R. M. A.;
125
+ > Cuypers, E. Native, Memory-Bounded Conversion of Mass Spectrometry Imaging
126
+ > Data into the SpatialData Ecosystem. *J. Am. Soc. Mass Spectrom.* **2026**.
127
+ > [doi:10.1021/jasms.6c00169](https://doi.org/10.1021/jasms.6c00169)
128
+
129
+ ```bibtex
130
+ @article{visvikis2026thyra,
131
+ title = {Native, Memory-Bounded Conversion of Mass Spectrometry Imaging Data into the {SpatialData} Ecosystem},
132
+ author = {Visvikis, Theodoros and Vierdag, Wouter-Michiel and Marconato, Luca and Birmpili, Angeliki and Heeren, Ron M. A. and Cuypers, Eva},
133
+ journal = {Journal of the American Society for Mass Spectrometry},
134
+ year = {2026},
135
+ doi = {10.1021/jasms.6c00169}
136
+ }
137
+ ```
138
+
139
+ The same entry is in [CITATION.cff](CITATION.cff), which GitHub's "Cite this
140
+ repository" button reads. To cite the software itself:
141
+
122
142
  ```bibtex
123
143
  @software{thyra2024,
124
144
  title = {Thyra: Modern Mass Spectrometry Imaging Data Conversion},
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thyra"
7
- version = "4.3.4"
7
+ version = "4.4.0"
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" }]
@@ -243,6 +243,7 @@ dependencies = [
243
243
  Homepage = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra"
244
244
  Repository = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra"
245
245
  Documentation = "https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/"
246
+ Paper = "https://doi.org/10.1021/jasms.6c00169"
246
247
  "Bug Tracker" = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/issues"
247
248
  Discussions = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/discussions"
248
249
  Changelog = "https://github.com/M4i-Imaging-Mass-Spectrometry/thyra/blob/main/CHANGELOG.md"
@@ -67,7 +67,7 @@ warnings.filterwarnings(
67
67
  category=FutureWarning,
68
68
  )
69
69
 
70
- __version__ = "4.3.4"
70
+ __version__ = "4.4.0"
71
71
 
72
72
  #: Public name -> the module that defines it, imported on first access.
73
73
  _LAZY_ATTRS = {
@@ -892,7 +892,7 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
892
892
  Otherwise, shapes use physical (micrometer) coordinates.
893
893
 
894
894
  **Footprints are two-dimensional, including on a multi-slice
895
- volume.** A slice's depth is carried by the TIC image's ``Scale``
895
+ volume.** A slice's depth is carried by the TIC image's transform
896
896
  and by ``obs["spatial_z"]``; it is deliberately not also put on
897
897
  the polygon geometry.
898
898
 
@@ -1042,7 +1042,7 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1042
1042
  half_y_um = self.pixel_size_y_um / 2
1043
1043
 
1044
1044
  # Footprints are flat, on every route including volumes. A
1045
- # slice's depth lives on the TIC image's Scale and in
1045
+ # slice's depth lives on the TIC image's transform and in
1046
1046
  # obs["spatial_z"]; see the docstring for why it is not also
1047
1047
  # put on the geometry. Built through shapely's vectorised box
1048
1048
  # constructor -- one C call for the whole table instead of one
@@ -1183,6 +1183,18 @@ class OpticalImages:
1183
1183
  transformation that maps MSI raster coordinates to optical image
1184
1184
  pixel coordinates.
1185
1185
  """
1186
+ # A source that states its own registration (an mzPeak archive with
1187
+ # a teach_points affine) hands it over whole.
1188
+ stated = getattr(self.reader, "get_image_alignment", None)
1189
+ alignment = stated() if callable(stated) else None
1190
+ if alignment is not None:
1191
+ self._alignment = alignment
1192
+ logger.info(
1193
+ "Optical alignment stated by the source, with "
1194
+ f"{len(alignment.region_mappings)} region mappings"
1195
+ )
1196
+ return
1197
+
1186
1198
  # Check if reader has FlexImaging-specific metadata
1187
1199
  if not hasattr(self.reader, "mis_metadata"):
1188
1200
  logger.debug("Reader does not have mis_metadata, skipping alignment")
@@ -1399,7 +1411,7 @@ class OpticalImages:
1399
1411
  primary = resolve() if callable(resolve) else None
1400
1412
  if primary is not None:
1401
1413
  self.primary_filename = Path(primary).stem.lower()
1402
- logger.info(f"Primary alignment image from .mis: {primary.name}")
1414
+ logger.info(f"Primary alignment image named by the reader: {primary.name}")
1403
1415
 
1404
1416
  def _is_primary_optical(self, image_path: Path) -> bool:
1405
1417
  """Check if an image file is the primary alignment image from .mis.
@@ -232,12 +232,13 @@ class RootAttrsBuilder:
232
232
  Two variants are emitted depending on whether FlexImaging optical
233
233
  alignment was applied during conversion:
234
234
 
235
- - No alignment (``global = micrometer``): the TIC image carries a
236
- ``Scale(pixel_size_um)`` and pixel-polygon shapes are stored in
237
- micrometers with ``Identity``. Both elements agree at
238
- ``global``. ``pixel_size_um_x/y`` are filled with the MSI grid
239
- pixel size, since "global" is in physical micrometers and there
240
- is no canonical raster image other than the MSI itself.
235
+ - No alignment (``global = micrometer``): the TIC image carries
236
+ a half-cell ``Translation`` then ``Scale(pixel_size_um)``, and
237
+ pixel-polygon shapes are stored in micrometers with
238
+ ``Identity``. Both elements agree at ``global``.
239
+ ``pixel_size_um_x/y`` are filled with the MSI grid pixel size,
240
+ since "global" is in physical micrometers and there is no
241
+ canonical raster image other than the MSI itself.
241
242
 
242
243
  - With alignment (``global = pixel``): the TIC image carries an
243
244
  ``Affine`` mapping raster indices to optical-image pixels and
@@ -311,9 +312,10 @@ class RootAttrsBuilder:
311
312
  # the explicit 3x3 row-major affine from TIC raster indices to
312
313
  # "global" (the same mapping the TIC element's transform
313
314
  # expresses), so a consumer that reads only attrs still gets
314
- # the full placement. In the pixel variant it takes TIC
315
- # coordinates, where cell i spans [i, i + 1) and its centre
316
- # i + 0.5 is the spot (D31). `coordinate_offsets_px` preserves the
315
+ # the full placement. In both variants it takes TIC coordinates,
316
+ # where cell i spans [i, i + 1): its centre i + 0.5 lands on the
317
+ # spot (D31), or on obs["spatial_x"] = i * px in micrometres
318
+ # (D32). `coordinate_offsets_px` preserves the
317
319
  # source's raw acquisition-index offsets, which 0-based
318
320
  # normalisation otherwise erases; `stage_offset_um` is their
319
321
  # physical equivalent, written only when "global" is in
@@ -325,8 +327,8 @@ class RootAttrsBuilder:
325
327
  ]
326
328
  else:
327
329
  global_cs["raster_to_global_affine"] = [
328
- [px, 0.0, 0.0],
329
- [0.0, py, 0.0],
330
+ [px, 0.0, -0.5 * px],
331
+ [0.0, py, -0.5 * py],
330
332
  [0.0, 0.0, 1.0],
331
333
  ]
332
334
  offsets = self._source_coordinate_offsets()
@@ -47,6 +47,8 @@ from anndata import AnnData
47
47
  from numpy.typing import NDArray
48
48
  from spatialdata.models import Image2DModel, Image3DModel, TableModel
49
49
  from spatialdata.transformations import Affine, Scale
50
+ from spatialdata.transformations import Sequence as SequenceTransform
51
+ from spatialdata.transformations import Translation
50
52
  from tqdm import tqdm
51
53
 
52
54
  from ...core.conversion_state import ConversionState
@@ -62,6 +64,17 @@ logger = logging.getLogger(__name__)
62
64
  _NOT_ACQUIRED = np.iinfo(np.int64).max
63
65
 
64
66
 
67
+ def _cell_centred_scale(pitches: List[float], axes: Tuple[str, ...]) -> Any:
68
+ """Raster cells to micrometres, each cell centred on its own position.
69
+
70
+ Cell ``i`` spans ``[i, i + 1)``; moving it back half a cell first puts
71
+ its centre on ``i * pitch``, the position ``obs`` records (D32).
72
+ """
73
+ return SequenceTransform(
74
+ [Translation([-0.5] * len(axes), axes=axes), Scale(pitches, axes=axes)]
75
+ )
76
+
77
+
65
78
  class _TableUnit:
66
79
  """One table of the store in the making: a z plane, or the whole volume.
67
80
 
@@ -1127,6 +1140,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1127
1140
  frame -- optical-image pixels when FlexImaging alignment is
1128
1141
  applied, physical micrometres otherwise, so that ``"global"``
1129
1142
  agrees with the pixel-polygon shapes.
1143
+
1144
+ SpatialData draws cell ``i`` over ``[i, i + 1)``. In micrometres
1145
+ the cell is moved back half a step before it is scaled, so its
1146
+ centre lands on ``obs["spatial_x"] = i * pixel_size_um``, where
1147
+ the pixel's polygon is centred (D32).
1130
1148
  """
1131
1149
  n_z = self._dimensions[2]
1132
1150
  if unit.plane is None and n_z > 1:
@@ -1134,7 +1152,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1134
1152
  # spacing (see BaseMSIConverter._resolve_z_spacing). Scale
1135
1153
  # pairs values with axis *names*, so ("x", "y", "z") against
1136
1154
  # a (c, z, y, x) image is deliberate.
1137
- transform: Any = Scale(
1155
+ transform: Any = _cell_centred_scale(
1138
1156
  [self.pixel_size_um, self.pixel_size_y_um, self.z_spacing_um],
1139
1157
  axes=("x", "y", "z"),
1140
1158
  )
@@ -1155,7 +1173,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
1155
1173
  output_axes=("x", "y"),
1156
1174
  )
1157
1175
  else:
1158
- transform = Scale(
1176
+ transform = _cell_centred_scale(
1159
1177
  [self.pixel_size_um, self.pixel_size_y_um], axes=("x", "y")
1160
1178
  )
1161
1179
  return Image2DModel.parse(
@@ -37,6 +37,7 @@ every CSC scratch memmap the finalize path's frames still hold (issue
37
37
 
38
38
  import json
39
39
  import logging
40
+ import numbers
40
41
  from dataclasses import dataclass
41
42
  from typing import (
42
43
  TYPE_CHECKING,
@@ -548,6 +549,11 @@ class UnsAssembler:
548
549
  and getattr(self.reader, "file_type", None) == "tdf"
549
550
  ):
550
551
  conversion_parameters["tdf_spectrum"] = str(tdf_spectrum)
552
+ # Peaks below it are not in the store, so a store made with one is
553
+ # not the source's whole signal, and must say so (D34).
554
+ intensity_threshold = getattr(self.reader, "intensity_threshold", None)
555
+ if isinstance(intensity_threshold, numbers.Real):
556
+ conversion_parameters["intensity_threshold"] = float(intensity_threshold)
551
557
  steps = [
552
558
  ProcessingStep(
553
559
  name="conversion",
@@ -69,6 +69,7 @@ from numpy.typing import NDArray
69
69
  from ..metadata.types import ComprehensiveMetadata, EssentialMetadata
70
70
 
71
71
  if TYPE_CHECKING:
72
+ from ..alignment import AreaAlignmentResult
72
73
  from .base_extractor import MetadataExtractor
73
74
  from .frames import FrameScans
74
75
  from .mobility import MobilityAxis
@@ -78,6 +79,53 @@ from .mass_axis import validate_max_mass_axis_length
78
79
 
79
80
  logger = logging.getLogger(__name__)
80
81
 
82
+ #: Two m/z of one spectrum this close, relative, are one peak listed twice.
83
+ #: A source that lists one point per mobility scan repeats a peak's m/z
84
+ #: (an archive's chunk bound can differ from the decoded value in the last
85
+ #: bit), while the closest distinct peaks of any analyser are some 1e-6
86
+ #: apart.
87
+ SAME_PEAK_RELATIVE = 1e-12
88
+
89
+
90
+ def summed_peak_mask(
91
+ mzs: NDArray[np.float64], intensities: NDArray[np.float64], threshold: float
92
+ ) -> NDArray[np.bool_]:
93
+ """Which points belong to a peak whose summed intensity reaches ``threshold``.
94
+
95
+ The intensity threshold's one unit (D34): a peak's intensity summed over
96
+ every point that lists its m/z. A spectrum that lists each m/z once is
97
+ tested point by point, as before; one that lists a peak once per mobility
98
+ scan keeps or drops all of that peak's points together, so the threshold
99
+ acts on the peak the store holds, not on one scan's share of it.
100
+
101
+ Args:
102
+ mzs: m/z of each point, in any order.
103
+ intensities: Intensity of each point.
104
+ threshold: The lowest summed intensity kept.
105
+
106
+ Returns:
107
+ A mask over the points, in their order.
108
+ """
109
+ if mzs.size < 2:
110
+ return intensities >= threshold
111
+ steps = np.diff(mzs)
112
+ if np.all(steps > SAME_PEAK_RELATIVE * np.abs(mzs[1:])):
113
+ # Strictly ascending with no repeat: every point is its own peak.
114
+ return intensities >= threshold
115
+ order = np.argsort(mzs, kind="stable")
116
+ ordered = mzs[order]
117
+ starts = np.concatenate(
118
+ ([True], np.diff(ordered) > SAME_PEAK_RELATIVE * np.abs(ordered[1:]))
119
+ )
120
+ # argsort puts NaN last; each NaN point is its own peak, never part of
121
+ # the largest real one.
122
+ starts |= np.isnan(ordered)
123
+ group = np.cumsum(starts) - 1
124
+ sums = np.bincount(group, weights=intensities[order])
125
+ keep = np.empty(mzs.size, dtype=bool)
126
+ keep[order] = sums[group] >= threshold
127
+ return keep
128
+
81
129
 
82
130
  class OpticalImageLabel(NamedTuple):
83
131
  """What a store calls an optical image whose file name is not the source's.
@@ -194,6 +242,20 @@ class BaseMSIReader(ABC):
194
242
  """
195
243
  return None
196
244
 
245
+ def get_image_alignment(self) -> Optional["AreaAlignmentResult"]:
246
+ """The registration the source states for its alignment image.
247
+
248
+ Default implementation returns None. A format whose file states
249
+ where each pixel sits on its alignment image overrides it; the
250
+ converter then places the pixels by it, as it places a FlexImaging
251
+ run's by its ``.mis``. The image is the one
252
+ :meth:`get_primary_optical_image_path` names.
253
+
254
+ Returns:
255
+ The alignment, or None when the source states none.
256
+ """
257
+ return None
258
+
197
259
  def get_optical_image_label(self, path: Path) -> Optional[OpticalImageLabel]:
198
260
  """Name an optical image this reader copied out of its source.
199
261
 
@@ -265,20 +327,29 @@ class BaseMSIReader(ABC):
265
327
  ) -> Tuple[NDArray[np.float64], NDArray[np.float64]]:
266
328
  """Apply intensity threshold filtering to spectrum data.
267
329
 
330
+ A peak is kept when its intensity, summed over every point that
331
+ lists its m/z, reaches the threshold (:func:`summed_peak_mask`).
332
+
268
333
  Args:
269
334
  mzs: m/z values array
270
335
  intensities: Intensity values array
271
336
 
272
337
  Returns:
273
- Tuple of (filtered_mzs, filtered_intensities) with values below
274
- threshold removed. Returns original arrays if no threshold is set.
338
+ Tuple of (filtered_mzs, filtered_intensities) with the peaks
339
+ below the threshold removed. Returns original arrays if no
340
+ threshold is set.
275
341
  """
276
342
  if self._intensity_threshold is None:
277
343
  return mzs, intensities
278
344
 
279
- mask = intensities >= self._intensity_threshold
345
+ mask = summed_peak_mask(mzs, intensities, self._intensity_threshold)
280
346
  return mzs[mask], intensities[mask]
281
347
 
348
+ @property
349
+ def intensity_threshold(self) -> Optional[float]:
350
+ """The ``--intensity-threshold`` this reader applies, ``None`` for none."""
351
+ return self._intensity_threshold
352
+
282
353
  @staticmethod
283
354
  def map_mz_to_common_axis(
284
355
  mzs: NDArray[np.float64],
@@ -15,7 +15,7 @@ module re-exports every name here, so both spellings keep working;
15
15
  ``Thresholds``, the one genuine resampling number in it, stayed there.
16
16
  """
17
17
 
18
- from typing import Dict, Optional
18
+ from typing import Dict, Iterable, Optional
19
19
 
20
20
  from ..errors import ConversionRefused
21
21
 
@@ -27,6 +27,10 @@ class ImzMLAccessions:
27
27
  CENTROID_SPECTRUM = "MS:1000127"
28
28
  PROFILE_SPECTRUM = "MS:1000128"
29
29
 
30
+ # Scan polarity (MS ontology)
31
+ POSITIVE_SCAN = "MS:1000130"
32
+ NEGATIVE_SCAN = "MS:1000129"
33
+
30
34
  # Binary data type (imzML ontology)
31
35
  CONTINUOUS_BINARY = "IMS:1000030"
32
36
  PROCESSED_BINARY = "IMS:1000031"
@@ -99,6 +103,36 @@ def normalize_spectrum_type(value: Optional[object]) -> Optional[str]:
99
103
  )
100
104
 
101
105
 
106
+ #: The polarity each scan polarity term states.
107
+ POLARITY_OF_ACCESSION: Dict[str, str] = {
108
+ ImzMLAccessions.POSITIVE_SCAN: "positive",
109
+ ImzMLAccessions.NEGATIVE_SCAN: "negative",
110
+ }
111
+
112
+
113
+ #: What an extractor records for a source that states both polarities:
114
+ #: pyimzml's word for it. No polarity term matches it, so the document's
115
+ #: field stays unset, and the file-level terms cannot fill it instead.
116
+ MIXED_POLARITY = "mixed"
117
+
118
+
119
+ def agreed_polarity(stated: Iterable[str]) -> Optional[str]:
120
+ """The polarity every statement of a source agrees on (D35).
121
+
122
+ ``stated`` holds ``"positive"`` or ``"negative"`` once for each place
123
+ the source states one: its file-level terms, a parameter group, a
124
+ spectrum. One value is the answer, and None means the source says
125
+ nothing. Both give :data:`MIXED_POLARITY`: the source alternated or
126
+ contradicts itself, which has no single truthful value.
127
+ """
128
+ values = set(stated)
129
+ if not values:
130
+ return None
131
+ if len(values) > 1:
132
+ return MIXED_POLARITY
133
+ return next(iter(values))
134
+
135
+
102
136
  class BinaryDataType:
103
137
  """Binary data type string constants."""
104
138
 
@@ -2,7 +2,7 @@
2
2
  import gc
3
3
  import logging
4
4
  from pathlib import Path
5
- from typing import Any, Dict, List, Optional, Tuple
5
+ from typing import Any, Dict, List, Optional, Set, Tuple
6
6
 
7
7
  import numpy as np
8
8
  from numpy.typing import NDArray
@@ -13,9 +13,12 @@ from ...errors import ConversionRefused
13
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
+ MIXED_POLARITY,
17
+ POLARITY_OF_ACCESSION,
16
18
  BinaryDataType,
17
19
  ImzMLAccessions,
18
20
  SpectrumType,
21
+ agreed_polarity,
19
22
  normalize_spectrum_type,
20
23
  )
21
24
  from ..ontology.cache import ONTOLOGY
@@ -911,7 +914,7 @@ class ImzMLMetadataExtractor(MetadataExtractor):
911
914
 
912
915
  def _extract_acquisition_params(self) -> Dict[str, Any]:
913
916
  """Extract acquisition parameters from XML metadata."""
914
- params = {}
917
+ params: Dict[str, Any] = {}
915
918
 
916
919
  # Extract pixel size with full XML parsing if not found in fast
917
920
  # extraction
@@ -921,6 +924,12 @@ class ImzMLMetadataExtractor(MetadataExtractor):
921
924
  params["pixel_size_x_um"] = pixel_size[0]
922
925
  params["pixel_size_y_um"] = pixel_size[1]
923
926
 
927
+ # Only when the file states one. "mixed" when it states both, so
928
+ # the builder cannot fall back to the file-level terms alone (D35).
929
+ polarity = self._extract_polarity()
930
+ if polarity is not None:
931
+ params["polarity"] = polarity
932
+
924
933
  # Add other acquisition parameters from imzmldict
925
934
  if hasattr(self.parser, "imzmldict") and self.parser.imzmldict:
926
935
  acquisition_keys = [
@@ -937,6 +946,59 @@ class ImzMLMetadataExtractor(MetadataExtractor):
937
946
 
938
947
  return params
939
948
 
949
+ def _extract_polarity(self) -> Optional[str]:
950
+ """The polarity every statement in the file agrees on (D35).
951
+
952
+ An imzML can state it in three places: the ``fileContent`` terms,
953
+ a referenceable parameter group, and each spectrum. Writers differ
954
+ (pyimzml's writer uses the group), so all three are read. The
955
+ spectra's terms come from the parser's own pass over them. A file
956
+ that states both values, anywhere, is left unset and says so.
957
+ """
958
+ stated: List[str] = []
959
+ metadata = getattr(self.parser, "metadata", None)
960
+ groups = [getattr(metadata, "file_description", None)]
961
+ param_groups = getattr(metadata, "referenceable_param_groups", None)
962
+ if isinstance(param_groups, dict):
963
+ groups.extend(param_groups.values())
964
+ for group in groups:
965
+ by_accession = getattr(group, "param_by_accession", None)
966
+ if isinstance(by_accession, dict):
967
+ stated.extend(
968
+ polarity
969
+ for accession, polarity in POLARITY_OF_ACCESSION.items()
970
+ if accession in by_accession
971
+ )
972
+ stated.extend(
973
+ POLARITY_OF_ACCESSION[accession]
974
+ for accession in self._spectrum_polarity_accessions()
975
+ )
976
+ polarity = agreed_polarity(stated)
977
+ if polarity == MIXED_POLARITY:
978
+ logger.warning(
979
+ "%s states both positive and negative scan polarity; no "
980
+ "polarity is recorded.",
981
+ self.imzml_path.name,
982
+ )
983
+ return polarity
984
+
985
+ def _spectrum_polarity_accessions(self) -> Set[str]:
986
+ """The scan polarity terms any spectrum states itself.
987
+
988
+ Collected by the parser during its one pass over the spectra, when
989
+ it was built with ``include_spectra_metadata`` as the reader builds
990
+ it. A parser built without it gives none, and the polarity then
991
+ rests on ``fileContent`` and the groups.
992
+ """
993
+ fields = getattr(self.parser, "spectrum_metadata_fields", None)
994
+ if not isinstance(fields, dict):
995
+ return set()
996
+ return {
997
+ accession
998
+ for accession in POLARITY_OF_ACCESSION
999
+ if any(value is not None for value in fields.get(accession, ()))
1000
+ }
1001
+
940
1002
  def _extract_instrument_info(self) -> Dict[str, Any]:
941
1003
  """Extract instrument information from the instrumentConfiguration blocks.
942
1004
 
@@ -38,7 +38,7 @@ from __future__ import annotations
38
38
 
39
39
  import logging
40
40
  from pathlib import Path
41
- from typing import Any, Optional, Tuple, cast
41
+ from typing import Any, Optional, Set, Tuple, cast
42
42
 
43
43
  from ...readers.imzml.header import ImzMLHeaderParser, scan_observed_mz_range
44
44
  from ..types import EssentialMetadata
@@ -148,6 +148,15 @@ class ImzMLHeaderExtractor(ImzMLMetadataExtractor):
148
148
  """The count the file declares, not one taken over coordinates."""
149
149
  return int(self.header.n_spectra or 0)
150
150
 
151
+ def _spectrum_polarity_accessions(self) -> Set[str]:
152
+ """The polarity terms the first spectrum states, as pyimzml reads it.
153
+
154
+ The head ends at the second spectrum. A conversion reads every
155
+ spectrum's terms, so the two differ only for a file whose later
156
+ spectra state the other polarity (D35).
157
+ """
158
+ return set(cast(ImzMLHeaderParser, self.parser).first_spectrum_polarity)
159
+
151
160
 
152
161
  def head_shortfall(parser: ImzMLHeaderParser) -> Optional[str]:
153
162
  """Why the head cannot describe this file, or ``None`` if it can.