thyra 4.3.3__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.3 → thyra-4.4.0}/.gitignore +2 -0
  2. {thyra-4.3.3 → thyra-4.4.0}/PKG-INFO +22 -1
  3. {thyra-4.3.3 → thyra-4.4.0}/README.md +20 -0
  4. {thyra-4.3.3 → thyra-4.4.0}/pyproject.toml +2 -1
  5. {thyra-4.3.3 → thyra-4.4.0}/thyra/__init__.py +1 -1
  6. {thyra-4.3.3 → thyra-4.4.0}/thyra/alignment/teaching_points.py +434 -4
  7. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +17 -3
  8. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/optical_image.py +82 -41
  9. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/root_attrs.py +13 -9
  10. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/streaming_converter.py +20 -2
  11. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/uns_assembler.py +6 -0
  12. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/base_reader.py +74 -3
  13. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/constants.py +35 -1
  14. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/imzml_extractor.py +64 -2
  15. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/imzml_header_extractor.py +10 -1
  16. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/mzpeak_extractor.py +120 -3
  17. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/mis_parser.py +42 -10
  18. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/timstof_reader.py +38 -10
  19. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/imzml/header.py +20 -9
  20. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/imzml/imzml_reader.py +48 -16
  21. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/mzpeak/chunk_decoding.py +108 -21
  22. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/mzpeak/mzpeak_reader.py +526 -19
  23. {thyra-4.3.3 → thyra-4.4.0}/LICENSE +0 -0
  24. {thyra-4.3.3 → thyra-4.4.0}/thyra/__main__.py +0 -0
  25. {thyra-4.3.3 → thyra-4.4.0}/thyra/alignment/__init__.py +0 -0
  26. {thyra-4.3.3 → thyra-4.4.0}/thyra/alignment/affine.py +0 -0
  27. {thyra-4.3.3 → thyra-4.4.0}/thyra/config.py +0 -0
  28. {thyra-4.3.3 → thyra-4.4.0}/thyra/convert.py +0 -0
  29. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/__init__.py +0 -0
  30. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/__init__.py +0 -0
  31. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/_chunking.py +0 -0
  32. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/converter.py +0 -0
  33. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/csc_assembly.py +0 -0
  34. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/fused_passes.py +0 -0
  35. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/mobility_heatmap.py +0 -0
  36. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/mobility_table.py +0 -0
  37. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/msms_table.py +0 -0
  38. {thyra-4.3.3 → thyra-4.4.0}/thyra/converters/spatialdata/sibling_tables.py +0 -0
  39. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/__init__.py +0 -0
  40. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/base_converter.py +0 -0
  41. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/base_extractor.py +0 -0
  42. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/conversion_state.py +0 -0
  43. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/drop_tally.py +0 -0
  44. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/frames.py +0 -0
  45. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/mass_axis.py +0 -0
  46. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/mobility.py +0 -0
  47. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/msms.py +0 -0
  48. {thyra-4.3.3 → thyra-4.4.0}/thyra/core/registry.py +0 -0
  49. {thyra-4.3.3 → thyra-4.4.0}/thyra/errors.py +0 -0
  50. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/__init__.py +0 -0
  51. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/document.py +0 -0
  52. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/__init__.py +0 -0
  53. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
  54. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/phi_extractor.py +0 -0
  55. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/solarix_extractor.py +0 -0
  56. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
  57. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/__init__.py +0 -0
  58. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/_ims.py +0 -0
  59. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/_ms.py +0 -0
  60. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/_uo.py +0 -0
  61. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/cache.py +0 -0
  62. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/ontology/dump_obo_files.py +0 -0
  63. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/personal_data.py +0 -0
  64. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/__init__.py +0 -0
  65. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/builder.py +0 -0
  66. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/cli.py +0 -0
  67. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/generate.py +0 -0
  68. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/metaspace.py +0 -0
  69. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/models.py +0 -0
  70. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/msi_metadata.linkml.yaml +0 -0
  71. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/msi_metadata_schema_v0_10.json +0 -0
  72. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/store_io.py +0 -0
  73. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/validate.py +0 -0
  74. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/schema/vocab.py +0 -0
  75. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/types.py +0 -0
  76. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/uns_compat.py +0 -0
  77. {thyra-4.3.3 → thyra-4.4.0}/thyra/metadata/validator.py +0 -0
  78. {thyra-4.3.3 → thyra-4.4.0}/thyra/preview.py +0 -0
  79. {thyra-4.3.3 → thyra-4.4.0}/thyra/py.typed +0 -0
  80. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/__init__.py +0 -0
  81. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/__init__.py +0 -0
  82. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
  83. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/folder_structure.py +0 -0
  84. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
  85. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
  86. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/solarix/__init__.py +0 -0
  87. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/solarix/solarix_reader.py +0 -0
  88. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
  89. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
  90. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
  91. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
  92. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
  93. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
  94. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
  95. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
  96. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
  97. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/bruker/vendor_db.py +0 -0
  98. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/imzml/__init__.py +0 -0
  99. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/imzml/_pyimzml_compat.py +0 -0
  100. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/imzml/mobility_array.py +0 -0
  101. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/mzpeak/__init__.py +0 -0
  102. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/phi/__init__.py +0 -0
  103. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/phi/event_stream.py +0 -0
  104. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/phi/mass_axis.py +0 -0
  105. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/phi/phi_header.py +0 -0
  106. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/phi/phi_reader.py +0 -0
  107. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/__init__.py +0 -0
  108. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/imaging_grid.py +0 -0
  109. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/instrument.py +0 -0
  110. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
  111. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
  112. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
  113. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
  114. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/masslynx_lib.py +0 -0
  115. {thyra-4.3.3 → thyra-4.4.0}/thyra/readers/waters/waters_reader.py +0 -0
  116. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/__init__.py +0 -0
  117. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/axis_planner.py +0 -0
  118. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/binning.py +0 -0
  119. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/common_axis.py +0 -0
  120. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/constants.py +0 -0
  121. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/data_characteristics.py +0 -0
  122. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/decision_tree.py +0 -0
  123. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/gaps.py +0 -0
  124. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/instrument_detectors.py +0 -0
  125. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/interpolation.py +0 -0
  126. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/__init__.py +0 -0
  127. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
  128. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
  129. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
  130. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
  131. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
  132. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
  133. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mass_axis/tof_generator.py +0 -0
  134. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/mobility_grid.py +0 -0
  135. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/strategies/__init__.py +0 -0
  136. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/strategies/base.py +0 -0
  137. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
  138. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
  139. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/tic.py +0 -0
  140. {thyra-4.3.3 → thyra-4.4.0}/thyra/resampling/types.py +0 -0
  141. {thyra-4.3.3 → thyra-4.4.0}/thyra/tools/__init__.py +0 -0
  142. {thyra-4.3.3 → thyra-4.4.0}/thyra/tools/check_ontology.py +0 -0
  143. {thyra-4.3.3 → thyra-4.4.0}/thyra/tools/make_example_data.py +0 -0
  144. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/__init__.py +0 -0
  145. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/bruker_exceptions.py +0 -0
  146. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/imzml_coordinate_base.py +0 -0
  147. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/logging_config.py +0 -0
  148. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/pyimzml_direct.py +0 -0
  149. {thyra-4.3.3 → thyra-4.4.0}/thyra/utils/windows_paths.py +0 -0
@@ -56,6 +56,8 @@ Archive/
56
56
  tests/data/fixtures/.synthetic_tims-build-*/
57
57
  tests/data/fixtures/.synthetic_tims-old-*/
58
58
  *.mis
59
+ # ...except two public flexImaging sequence files the alignment tests read.
60
+ !tests/data/fixtures/fleximaging_msv000088438/*.mis
59
61
  *.slx
60
62
  *.tiff
61
63
  *.ipynb
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thyra
3
- Version: 4.3.3
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.3"
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.3"
70
+ __version__ = "4.4.0"
71
71
 
72
72
  #: Public name -> the module that defines it, imported on first access.
73
73
  _LAZY_ATTRS = {
@@ -207,6 +207,320 @@ class RegionMapping:
207
207
  return self._get_pixel_scale_x() / 2, self._get_pixel_scale_y() / 2
208
208
 
209
209
 
210
+ #: Raster axis directions tried against the teaching-point frame, in order of
211
+ #: preference. FlexImaging's teaching frame has y pointing up the image while
212
+ #: raster Y counts down it, so (+1, -1) is what every measured run uses.
213
+ _LATTICE_AXIS_SIGNS: Tuple[Tuple[int, int], ...] = ((1, -1), (1, 1), (-1, -1), (-1, 1))
214
+
215
+ #: A spot this close to its Area's outline, in raster steps, still counts as
216
+ #: inside it. FlexImaging draws rectangles whose edges pass through the
217
+ #: outermost spots, so an exact test would reject them.
218
+ _AREA_EDGE_TOLERANCE_STEPS = 0.1
219
+
220
+ #: How far around the estimated reference node the integer search looks.
221
+ _NODE_SEARCH_RADIUS = 3
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class LatticeFit:
226
+ """Where FlexImaging put each raster spot on its alignment image (D31).
227
+
228
+ FlexImaging places every spot of a run on one lattice: one raster step
229
+ apart in the teaching-point frame, through the ``.mis`` reference
230
+ point. An Area outline only selects which nodes are measured, so the
231
+ outline's size says nothing about the pitch.
232
+
233
+ Attributes:
234
+ cell_to_image: 3x3 affine from the TIC image's own coordinates to
235
+ alignment-image pixels. Cell ``(i, j)`` spans ``[i, i + 1) x
236
+ [j, j + 1)``, the SpatialData convention, so its centre
237
+ ``(i + 0.5, j + 0.5)`` lands on the spot at raster
238
+ ``(first_raster_x + i, first_raster_y + j)``.
239
+ reference_node: Raster ``(X, Y)`` of the node on the reference point.
240
+ axis_signs: Direction of raster X and Y in the teaching frame.
241
+ raster_step_um: ``(x, y)`` lattice step in micrometres.
242
+ spots_outside: Measured spots that fall outside their own Area.
243
+ nodes_unmeasured: Lattice nodes inside an Area that were not measured.
244
+ reference_from: ``"ReferencePoint"``, or ``"first teaching point"``
245
+ when the ``.mis`` has none.
246
+ """
247
+
248
+ cell_to_image: np.ndarray
249
+ reference_node: Tuple[int, int]
250
+ axis_signs: Tuple[int, int]
251
+ raster_step_um: Tuple[float, float]
252
+ spots_outside: int
253
+ nodes_unmeasured: int
254
+ reference_from: str
255
+
256
+ def cell_centre(self, norm_x: float, norm_y: float) -> Tuple[float, float]:
257
+ """Image pixel of the spot in TIC cell ``(norm_x, norm_y)``."""
258
+ x, y, _ = self.cell_to_image @ np.array([norm_x + 0.5, norm_y + 0.5, 1.0])
259
+ return float(x), float(y)
260
+
261
+ def cell_corners(self, norm_x: float, norm_y: float) -> List[Tuple[float, float]]:
262
+ """The four image-pixel corners of TIC cell ``(norm_x, norm_y)``."""
263
+ corners = np.array(
264
+ [
265
+ [norm_x, norm_y, 1.0],
266
+ [norm_x + 1, norm_y, 1.0],
267
+ [norm_x + 1, norm_y + 1, 1.0],
268
+ [norm_x, norm_y + 1, 1.0],
269
+ ]
270
+ )
271
+ out = corners @ self.cell_to_image.T
272
+ return [(float(x), float(y)) for x, y, _ in out]
273
+
274
+
275
+ def _area_outline(area: Dict[str, Any]) -> Optional[List[Tuple[float, float]]]:
276
+ """An Area's outline in image pixels: its polygon, or its rectangle."""
277
+ points = area.get("points")
278
+ if points and len(points) >= 3:
279
+ return [(float(x), float(y)) for x, y in points]
280
+ corners = points if points and len(points) == 2 else None
281
+ if corners is None and "p1" in area and "p2" in area:
282
+ corners = [area["p1"], area["p2"]]
283
+ if corners is None:
284
+ return None
285
+ (x0, y0), (x1, y1) = corners
286
+ return [
287
+ (float(x0), float(y0)),
288
+ (float(x1), float(y0)),
289
+ (float(x1), float(y1)),
290
+ (float(x0), float(y1)),
291
+ ]
292
+
293
+
294
+ class _LatticeSearch:
295
+ """The whole-node search behind :func:`fit_raster_lattice`.
296
+
297
+ Holds the teaching-point affine, the reference point in stage
298
+ micrometres, the measured spots and each measured region's Area, and
299
+ scores a candidate ``(node, axis signs)`` by how many spots fall outside
300
+ their Area and how many Area nodes went unmeasured.
301
+ """
302
+
303
+ def __init__(
304
+ self,
305
+ to_stage: np.ndarray,
306
+ ref: np.ndarray,
307
+ step: Tuple[float, float],
308
+ positions: List[Dict[str, Any]],
309
+ areas: List[Dict[str, Any]],
310
+ tolerance_px: float,
311
+ ) -> None:
312
+ from shapely.geometry import Polygon
313
+
314
+ self.to_stage = to_stage
315
+ self.to_image = np.linalg.inv(to_stage)
316
+ self.ref = ref
317
+ self.step_x, self.step_y = step
318
+ self.raster_x = np.array([p["raster_x"] for p in positions], dtype=np.float64)
319
+ self.raster_y = np.array([p["raster_y"] for p in positions], dtype=np.float64)
320
+ self.regions = np.array([p["region"] for p in positions], dtype=np.int64)
321
+ # Each measured region's Area as drawn, and padded by the edge
322
+ # tolerance for the inside test.
323
+ self.exact: Dict[int, Any] = {}
324
+ self.padded: Dict[int, Any] = {}
325
+ for region in np.unique(self.regions).tolist():
326
+ if 0 <= region < len(areas):
327
+ vertices = _area_outline(areas[region])
328
+ if vertices is not None:
329
+ self.exact[region] = Polygon(vertices)
330
+ self.padded[region] = self.exact[region].buffer(tolerance_px)
331
+
332
+ def to_px(
333
+ self, node: Tuple[int, int], signs: Tuple[int, int], xs: Any, ys: Any
334
+ ) -> Tuple[Any, Any]:
335
+ """Image pixels of raster nodes ``(xs, ys)`` on the lattice."""
336
+ stage_x = self.ref[0] + signs[0] * self.step_x * (xs - node[0])
337
+ stage_y = self.ref[1] + signs[1] * self.step_y * (ys - node[1])
338
+ m = self.to_image
339
+ return (
340
+ m[0, 0] * stage_x + m[0, 1] * stage_y + m[0, 2],
341
+ m[1, 0] * stage_x + m[1, 1] * stage_y + m[1, 2],
342
+ )
343
+
344
+ def outside(self, node: Tuple[int, int], signs: Tuple[int, int]) -> int:
345
+ """Measured spots that fall outside their own (padded) Area."""
346
+ import shapely
347
+
348
+ count = 0
349
+ for region, polygon in self.padded.items():
350
+ sel = self.regions == region
351
+ px, py = self.to_px(node, signs, self.raster_x[sel], self.raster_y[sel])
352
+ count += int((~shapely.contains_xy(polygon, px, py)).sum())
353
+ return count
354
+
355
+ def unmeasured(self, node: Tuple[int, int], signs: Tuple[int, int]) -> int:
356
+ """Lattice nodes inside an Area as drawn that no spot was measured on.
357
+
358
+ Against the outline as drawn: FlexImaging measures the nodes inside
359
+ it, and the edge tolerance would count nodes it never meant to.
360
+ """
361
+ import shapely
362
+
363
+ count = 0
364
+ for region, polygon in self.exact.items():
365
+ sel = self.regions == region
366
+ measured = set(
367
+ zip(self.raster_x[sel].astype(int), self.raster_y[sel].astype(int))
368
+ )
369
+ # The Area's outline in raster coordinates bounds the nodes to try.
370
+ verts = np.array(polygon.exterior.coords)
371
+ stage = verts @ self.to_stage[:2, :2].T + self.to_stage[:2, 2]
372
+ gx = node[0] + (stage[:, 0] - self.ref[0]) / (signs[0] * self.step_x)
373
+ gy = node[1] + (stage[:, 1] - self.ref[1]) / (signs[1] * self.step_y)
374
+ xs, ys = np.meshgrid(
375
+ np.arange(np.floor(gx.min()), np.ceil(gx.max()) + 1),
376
+ np.arange(np.floor(gy.min()), np.ceil(gy.max()) + 1),
377
+ )
378
+ xs, ys = xs.ravel(), ys.ravel()
379
+ px, py = self.to_px(node, signs, xs, ys)
380
+ inside = shapely.contains_xy(polygon, px, py)
381
+ nodes = zip(xs[inside].astype(int), ys[inside].astype(int))
382
+ count += sum(1 for xy in nodes if xy not in measured)
383
+ return count
384
+
385
+ def candidates(self, signs: Tuple[int, int]) -> List[Tuple[int, int]]:
386
+ """Nodes near where each region's centre puts the reference node."""
387
+ guesses = set()
388
+ for region, polygon in self.padded.items():
389
+ sel = self.regions == region
390
+ centre = self.to_stage @ np.array(
391
+ [polygon.centroid.x, polygon.centroid.y, 1.0]
392
+ )
393
+ gx = self.raster_x[sel].mean() - (centre[0] - self.ref[0]) / (
394
+ signs[0] * self.step_x
395
+ )
396
+ gy = self.raster_y[sel].mean() - (centre[1] - self.ref[1]) / (
397
+ signs[1] * self.step_y
398
+ )
399
+ guesses.add((int(round(gx)), int(round(gy))))
400
+ span = range(-_NODE_SEARCH_RADIUS, _NODE_SEARCH_RADIUS + 1)
401
+ return sorted(
402
+ {(gx + dx, gy + dy) for gx, gy in guesses for dx in span for dy in span}
403
+ )
404
+
405
+ def best(self) -> Tuple[int, int, Tuple[int, int], Tuple[int, int]]:
406
+ """``(spots outside, unmeasured nodes, node, signs)`` of the best fit.
407
+
408
+ Fewest spots outside first, then fewest unmeasured nodes, then the
409
+ usual axis directions.
410
+ """
411
+ scored = [
412
+ (self.outside(node, signs), preference, node, signs)
413
+ for preference, signs in enumerate(_LATTICE_AXIS_SIGNS)
414
+ for node in self.candidates(signs)
415
+ ]
416
+ fewest = min(s[0] for s in scored)
417
+ n_unmeasured, _, node, signs, n_outside = min(
418
+ (self.unmeasured(node, signs), preference, node, signs, n_out)
419
+ for n_out, preference, node, signs in scored
420
+ if n_out == fewest
421
+ )
422
+ return n_outside, n_unmeasured, node, signs
423
+
424
+
425
+ def fit_raster_lattice(
426
+ teaching_points: List[Dict[str, Any]],
427
+ reference_point: Optional[Tuple[float, float]],
428
+ raster_step: Tuple[float, float],
429
+ areas: List[Dict[str, Any]],
430
+ positions: List[Dict[str, Any]],
431
+ first_raster_x: int,
432
+ first_raster_y: int,
433
+ ) -> Optional[LatticeFit]:
434
+ """Place the run's raster on the alignment image the way FlexImaging does.
435
+
436
+ The teaching points give the image-to-stage affine. The lattice runs
437
+ through the reference point with the raster step. Which raster node
438
+ sits on the reference point is a whole-number question: it is the node
439
+ that puts every measured spot inside its own Area, and of those the one
440
+ that leaves the fewest unmeasured nodes inside the Areas. On the public
441
+ MassIVE MSV000088438 runs this reproduces flexImaging's own spot list
442
+ within 1 um.
443
+
444
+ Args:
445
+ teaching_points: ``.mis`` teaching points, ``{"image", "stage"}``.
446
+ reference_point: ``.mis`` ``<ReferencePoint>`` in image pixels, or
447
+ ``None`` to use the first teaching point (FlexImaging puts it
448
+ there in every file seen).
449
+ raster_step: ``(x, y)`` raster step in micrometres.
450
+ areas: ``.mis`` Areas, in file order. Region ``N`` is Area ``N``.
451
+ positions: Measured spots, ``{"raster_x", "raster_y", "region"}``.
452
+ first_raster_x: Raster X of TIC cell 0.
453
+ first_raster_y: Raster Y of TIC cell 0.
454
+
455
+ Returns:
456
+ The fit, or ``None`` when the inputs cannot place a lattice: fewer
457
+ than three teaching points, no positive step, no Area that any
458
+ measured region maps to.
459
+ """
460
+ step_x, step_y = (float(raster_step[0]), float(raster_step[1]))
461
+ if len(teaching_points) < 3 or step_x <= 0 or step_y <= 0 or not positions:
462
+ return None
463
+
464
+ points = [TeachingPoint.from_dict(tp) for tp in teaching_points]
465
+ image_to_stage = AffineTransform.from_points(
466
+ [(float(p.image_x), float(p.image_y)) for p in points],
467
+ [(float(p.stage_x), float(p.stage_y)) for p in points],
468
+ )
469
+ if reference_point is None:
470
+ reference_point = (float(points[0].image_x), float(points[0].image_y))
471
+ reference_from = "first teaching point"
472
+ else:
473
+ reference_from = "ReferencePoint"
474
+ to_stage = image_to_stage.matrix
475
+ ref = to_stage @ np.array([reference_point[0], reference_point[1], 1.0])
476
+
477
+ # The edge tolerance in image pixels: steps times pixels per micrometre.
478
+ um_per_px = 0.5 * (image_to_stage.scale_x + image_to_stage.scale_y)
479
+ tolerance_px = _AREA_EDGE_TOLERANCE_STEPS * min(step_x, step_y) / um_per_px
480
+ search = _LatticeSearch(
481
+ to_stage, ref, (step_x, step_y), positions, areas, tolerance_px
482
+ )
483
+ if not search.padded:
484
+ return None
485
+ n_outside, n_unmeasured, node, signs = search.best()
486
+
487
+ sx, sy = signs
488
+ lattice = np.array(
489
+ [
490
+ [sx * step_x, 0.0, ref[0] + sx * step_x * (first_raster_x - node[0] - 0.5)],
491
+ [0.0, sy * step_y, ref[1] + sy * step_y * (first_raster_y - node[1] - 0.5)],
492
+ [0.0, 0.0, 1.0],
493
+ ]
494
+ )
495
+ fit = LatticeFit(
496
+ cell_to_image=search.to_image @ lattice,
497
+ reference_node=(int(node[0]), int(node[1])),
498
+ axis_signs=(int(sx), int(sy)),
499
+ raster_step_um=(step_x, step_y),
500
+ spots_outside=int(n_outside),
501
+ nodes_unmeasured=int(n_unmeasured),
502
+ reference_from=reference_from,
503
+ )
504
+ logger.info(
505
+ f"FlexImaging lattice: reference node {fit.reference_node} "
506
+ f"(from {reference_from}), step {step_x:g} x {step_y:g} um, "
507
+ f"axes {fit.axis_signs}, {n_outside} of {len(positions)} spots outside "
508
+ f"their Area, {n_unmeasured} unmeasured nodes inside the Areas"
509
+ )
510
+ if n_outside:
511
+ logger.warning(
512
+ f"{n_outside} of {len(positions)} measured spots fall outside their "
513
+ f".mis Area on the best lattice; check that the .mis belongs to this "
514
+ f"run. The optical alignment may be off by whole raster steps."
515
+ )
516
+ if signs != _LATTICE_AXIS_SIGNS[0]:
517
+ logger.warning(
518
+ f"FlexImaging raster axes {signs} differ from the usual (1, -1); "
519
+ "the optical alignment follows the Areas."
520
+ )
521
+ return fit
522
+
523
+
210
524
  @dataclass
211
525
  class AreaAlignmentResult:
212
526
  """Result of area-based alignment computation.
@@ -219,12 +533,21 @@ class AreaAlignmentResult:
219
533
  first_raster_x: First raster X offset from header
220
534
  first_raster_y: First raster Y offset from header
221
535
  pos_to_region: Mapping from (raster_x, raster_y) to region_id
536
+ lattice: The teaching-point lattice (D31). ``None`` only when the
537
+ ``.mis`` lacks what it needs, and then each region is stretched
538
+ over its Area's bounding box instead, which is approximate.
222
539
  """
223
540
 
224
541
  region_mappings: List[RegionMapping]
225
542
  first_raster_x: int
226
543
  first_raster_y: int
227
544
  pos_to_region: Dict[Tuple[int, int], int] = field(default_factory=dict)
545
+ lattice: Optional[LatticeFit] = None
546
+
547
+ @property
548
+ def cell_to_image(self) -> Optional[np.ndarray]:
549
+ """The lattice's TIC-cell-to-image affine, or ``None`` without one."""
550
+ return self.lattice.cell_to_image if self.lattice is not None else None
228
551
 
229
552
  def transform_point(
230
553
  self, norm_x: int, norm_y: int
@@ -247,6 +570,9 @@ class AreaAlignmentResult:
247
570
  if region_id is None:
248
571
  return None
249
572
 
573
+ if self.lattice is not None:
574
+ return self.lattice.cell_centre(norm_x, norm_y)
575
+
250
576
  # Find the region mapping
251
577
  for mapping in self.region_mappings:
252
578
  if mapping.region_id == region_id:
@@ -254,6 +580,33 @@ class AreaAlignmentResult:
254
580
 
255
581
  return None
256
582
 
583
+ def cell_corners(
584
+ self, norm_x: int, norm_y: int
585
+ ) -> Optional[List[Tuple[float, float]]]:
586
+ """The image-pixel outline of a measured pixel, or ``None``.
587
+
588
+ Under the lattice this is the exact cell the TIC image draws, a
589
+ parallelogram when the photo is rotated against the stage. Without
590
+ one it is the axis-aligned box of the bounding-box stretch.
591
+ """
592
+ if (norm_x + self.first_raster_x, norm_y + self.first_raster_y) not in (
593
+ self.pos_to_region
594
+ ):
595
+ return None
596
+ if self.lattice is not None:
597
+ return self.lattice.cell_corners(norm_x, norm_y)
598
+ centre = self.transform_point(norm_x, norm_y)
599
+ half = self.get_half_pixel_size(norm_x, norm_y)
600
+ if centre is None or half is None:
601
+ return None
602
+ (cx, cy), (hx, hy) = centre, half
603
+ return [
604
+ (cx - hx, cy - hy),
605
+ (cx + hx, cy - hy),
606
+ (cx + hx, cy + hy),
607
+ (cx - hx, cy + hy),
608
+ ]
609
+
257
610
  def get_half_pixel_size(
258
611
  self, norm_x: int, norm_y: int
259
612
  ) -> Optional[Tuple[float, float]]:
@@ -280,6 +633,13 @@ class AreaAlignmentResult:
280
633
  if region_id is None:
281
634
  return None
282
635
 
636
+ if self.lattice is not None:
637
+ m = self.lattice.cell_to_image
638
+ return (
639
+ float(np.hypot(m[0, 0], m[1, 0])) / 2.0,
640
+ float(np.hypot(m[0, 1], m[1, 1])) / 2.0,
641
+ )
642
+
283
643
  # Find the region mapping and get its half-pixel size
284
644
  for mapping in self.region_mappings:
285
645
  if mapping.region_id == region_id:
@@ -685,19 +1045,29 @@ class TeachingPointAlignment:
685
1045
  poslog_positions: List[Dict[str, Any]],
686
1046
  first_raster_x: int,
687
1047
  first_raster_y: int,
1048
+ teaching_points: Optional[List[Dict[str, Any]]] = None,
1049
+ reference_point: Optional[Tuple[float, float]] = None,
1050
+ raster_step: Optional[Tuple[float, float]] = None,
688
1051
  ) -> AreaAlignmentResult:
689
1052
  """Compute alignment using Area definitions from .mis file.
690
1053
 
691
- This is the preferred alignment method when Area definitions are
692
- available, as they provide direct image pixel coordinates for each
693
- acquisition region without requiring coordinate system transformations.
1054
+ With three teaching points and a raster step, every spot is placed
1055
+ on FlexImaging's lattice (:func:`fit_raster_lattice`, D31). Without
1056
+ them, each region is stretched over its Area's bounding box, which
1057
+ is approximate: the outline is drawn by hand and is not the extent
1058
+ of the spots.
694
1059
 
695
1060
  Args:
696
1061
  areas: List of Area dictionaries with 'name', 'p1', 'p2' keys
697
- where p1 and p2 are (x, y) tuples of image pixel coordinates
1062
+ where p1 and p2 are (x, y) tuples of image pixel coordinates,
1063
+ and optionally 'points', 'type' and 'raster'
698
1064
  poslog_positions: List of position dictionaries from poslog parsing
699
1065
  first_raster_x: First raster X offset from header
700
1066
  first_raster_y: First raster Y offset from header
1067
+ teaching_points: ``.mis`` teaching points
1068
+ reference_point: ``.mis`` ``<ReferencePoint>`` in image pixels
1069
+ raster_step: Raster step in micrometres, used when the Areas
1070
+ state none of their own
701
1071
 
702
1072
  Returns:
703
1073
  AreaAlignmentResult with region mappings and coordinate transform
@@ -773,9 +1143,69 @@ class TeachingPointAlignment:
773
1143
  f"({mapping.image_max_x}, {mapping.image_max_y})"
774
1144
  )
775
1145
 
1146
+ lattice = self._fit_lattice(
1147
+ areas,
1148
+ poslog_positions,
1149
+ regions,
1150
+ first_raster_x,
1151
+ first_raster_y,
1152
+ teaching_points,
1153
+ reference_point,
1154
+ raster_step,
1155
+ )
1156
+
776
1157
  return AreaAlignmentResult(
777
1158
  region_mappings=region_mappings,
778
1159
  first_raster_x=first_raster_x,
779
1160
  first_raster_y=first_raster_y,
780
1161
  pos_to_region=pos_to_region,
1162
+ lattice=lattice,
1163
+ )
1164
+
1165
+ @staticmethod
1166
+ def _fit_lattice(
1167
+ areas: List[Dict[str, Any]],
1168
+ positions: List[Dict[str, Any]],
1169
+ regions: Dict[int, Dict[str, Any]],
1170
+ first_raster_x: int,
1171
+ first_raster_y: int,
1172
+ teaching_points: Optional[List[Dict[str, Any]]],
1173
+ reference_point: Optional[Tuple[float, float]],
1174
+ raster_step: Optional[Tuple[float, float]],
1175
+ ) -> Optional[LatticeFit]:
1176
+ """The lattice fit, or ``None`` (with the reason logged) to fall back."""
1177
+ if not teaching_points or len(teaching_points) < 3:
1178
+ logger.warning(
1179
+ "The .mis has fewer than three teaching points; optical "
1180
+ "alignment stretches each region over its Area outline, which "
1181
+ "can be off by up to a raster step."
1182
+ )
1183
+ return None
1184
+ steps = {
1185
+ tuple(float(v) for v in areas[r]["raster"])
1186
+ for r in regions
1187
+ if 0 <= r < len(areas) and areas[r].get("raster")
1188
+ }
1189
+ if len(steps) > 1:
1190
+ logger.warning(
1191
+ f"The measured Areas use different raster steps {sorted(steps)}; "
1192
+ "one lattice cannot place them, so optical alignment stretches "
1193
+ "each region over its Area outline instead."
1194
+ )
1195
+ return None
1196
+ step = next(iter(steps)) if steps else raster_step
1197
+ if step is None:
1198
+ logger.warning(
1199
+ "No raster step for the optical alignment lattice; it stretches "
1200
+ "each region over its Area outline instead."
1201
+ )
1202
+ return None
1203
+ return fit_raster_lattice(
1204
+ teaching_points=teaching_points,
1205
+ reference_point=reference_point,
1206
+ raster_step=(float(step[0]), float(step[1])),
1207
+ areas=areas,
1208
+ positions=positions,
1209
+ first_raster_x=first_raster_x,
1210
+ first_raster_y=first_raster_y,
781
1211
  )
@@ -53,7 +53,7 @@ import pandas as pd
53
53
  import zarr
54
54
  from anndata import AnnData
55
55
  from numpy.typing import NDArray
56
- from shapely.geometry import box
56
+ from shapely.geometry import Polygon, box
57
57
  from spatialdata import SpatialData
58
58
  from spatialdata.models import ShapesModel
59
59
  from spatialdata.transformations import Identity
@@ -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
 
@@ -984,9 +984,23 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
984
984
  rm = self.optical.alignment.region_mappings[0]
985
985
  default_half_pixel = rm.get_half_pixel_size()
986
986
 
987
+ # Under the teaching-point lattice (D31) a pixel's polygon is the
988
+ # exact cell the TIC image draws, through the same matrix, so the
989
+ # two cannot disagree; it is a parallelogram when the photo is
990
+ # rotated against the stage.
991
+ cell_corners = getattr(self.optical.alignment, "cell_corners", None)
992
+ lattice = getattr(self.optical.alignment, "lattice", None)
993
+
987
994
  valid_indices = []
988
995
  for i in range(len(adata)):
989
996
  rx, ry = int(raster_x[i]), int(raster_y[i])
997
+ if lattice is not None and cell_corners is not None:
998
+ corners = cell_corners(rx, ry)
999
+ if corners is not None:
1000
+ geometries.append(Polygon(corners))
1001
+ valid_indices.append(i)
1002
+ continue
1003
+
990
1004
  img_coords = self.optical.alignment.transform_point(rx, ry)
991
1005
 
992
1006
  if img_coords is not None:
@@ -1028,7 +1042,7 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
1028
1042
  half_y_um = self.pixel_size_y_um / 2
1029
1043
 
1030
1044
  # Footprints are flat, on every route including volumes. A
1031
- # 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
1032
1046
  # obs["spatial_z"]; see the docstring for why it is not also
1033
1047
  # put on the geometry. Built through shapely's vectorised box
1034
1048
  # constructor -- one C call for the whole table instead of one