thyra 2.2.4__tar.gz → 2.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thyra-2.2.4 → thyra-2.3.1}/PKG-INFO +1 -1
- {thyra-2.2.4 → thyra-2.3.1}/pyproject.toml +1 -1
- {thyra-2.2.4 → thyra-2.3.1}/thyra/__init__.py +1 -1
- {thyra-2.2.4 → thyra-2.3.1}/thyra/__main__.py +28 -4
- {thyra-2.2.4 → thyra-2.3.1}/thyra/convert.py +44 -9
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/base_spatialdata_converter.py +74 -46
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/spatialdata_3d_converter.py +5 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/streaming_converter.py +22 -34
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/extractors/imzml_extractor.py +75 -4
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/imzml/imzml_reader.py +503 -7
- thyra-2.3.1/thyra/resampling/constants.py +91 -0
- thyra-2.2.4/thyra/resampling/constants.py +0 -44
- {thyra-2.2.4 → thyra-2.3.1}/LICENSE +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/README.md +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/alignment/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/alignment/affine.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/alignment/teaching_points.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/config.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/_chunking.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/converters/spatialdata/spatialdata_2d_converter.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/core/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/core/base_converter.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/core/base_extractor.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/core/base_reader.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/core/registry.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/extractors/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/extractors/waters_extractor.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/types.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/metadata/validator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/preview.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/common_axis.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/data_characteristics.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/decision_tree.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/gaps.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/instrument_detectors.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/strategies/base.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/strategies/tic_preserving.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/tic.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/resampling/types.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/tools/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/tools/check_ontology.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/tools/make_example_data.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/utils/__init__.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/utils/logging_config.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/utils/windows_paths.py +0 -0
- {thyra-2.2.4 → thyra-2.3.1}/thyra/utils/zarr_atomic_write.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "thyra"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.1"
|
|
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 = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
|
|
10
10
|
maintainers = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
|
|
@@ -261,11 +261,21 @@ def _build_resampling_config(
|
|
|
261
261
|
def _build_reader_options(
|
|
262
262
|
use_recalibrated: bool,
|
|
263
263
|
intensity_threshold: Optional[float],
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
264
|
+
spectrum_type: str = "auto",
|
|
265
|
+
) -> dict[str, bool | float | str]:
|
|
266
|
+
"""Build reader options dictionary from CLI parameters.
|
|
267
|
+
|
|
268
|
+
``spectrum_type="auto"`` is the CLI's way of saying "no override", so it is
|
|
269
|
+
omitted entirely rather than forwarded -- readers take ``None``/absent to
|
|
270
|
+
mean detect, and ``"auto"`` is not a representation.
|
|
271
|
+
"""
|
|
272
|
+
options: dict[str, bool | float | str] = {
|
|
273
|
+
"use_recalibrated_state": use_recalibrated
|
|
274
|
+
}
|
|
267
275
|
if intensity_threshold is not None:
|
|
268
276
|
options["intensity_threshold"] = intensity_threshold
|
|
277
|
+
if spectrum_type != "auto":
|
|
278
|
+
options["spectrum_type"] = spectrum_type
|
|
269
279
|
return options
|
|
270
280
|
|
|
271
281
|
|
|
@@ -548,6 +558,17 @@ class GroupedCommand(click.Command):
|
|
|
548
558
|
"(default: no limit)"
|
|
549
559
|
),
|
|
550
560
|
)
|
|
561
|
+
@click.option(
|
|
562
|
+
"--spectrum-type",
|
|
563
|
+
type=click.Choice(["auto", "profile", "centroid"]),
|
|
564
|
+
default="auto",
|
|
565
|
+
help=(
|
|
566
|
+
"Spectrum representation (default: auto-detect from the file). "
|
|
567
|
+
"Overrides what the imzML declares via MS:1000127/MS:1000128; "
|
|
568
|
+
"contradicting a declaration is logged as a warning. SCiLS Lab spells "
|
|
569
|
+
"this --rep_type. imzML only."
|
|
570
|
+
),
|
|
571
|
+
)
|
|
551
572
|
# -- Bruker-specific --
|
|
552
573
|
@click.option(
|
|
553
574
|
"--use-recalibrated/--no-recalibrated",
|
|
@@ -597,6 +618,7 @@ def main(
|
|
|
597
618
|
resample_reference_mz: float,
|
|
598
619
|
resample_gap_tolerance: Optional[float],
|
|
599
620
|
mass_axis_type: str,
|
|
621
|
+
spectrum_type: str,
|
|
600
622
|
sparse_format: str,
|
|
601
623
|
include_optical: bool,
|
|
602
624
|
intensity_threshold: Optional[float],
|
|
@@ -664,7 +686,9 @@ def main(
|
|
|
664
686
|
)
|
|
665
687
|
|
|
666
688
|
# Build reader options for format-specific settings
|
|
667
|
-
reader_options = _build_reader_options(
|
|
689
|
+
reader_options = _build_reader_options(
|
|
690
|
+
use_recalibrated, intensity_threshold, spectrum_type
|
|
691
|
+
)
|
|
668
692
|
|
|
669
693
|
# Perform conversion
|
|
670
694
|
success = convert_msi(
|
|
@@ -147,27 +147,51 @@ def _determine_pixel_size(
|
|
|
147
147
|
|
|
148
148
|
|
|
149
149
|
def _should_use_streaming(streaming: Union[bool, Literal["auto"]], reader: Any) -> bool:
|
|
150
|
-
"""Determine if streaming converter should be used.
|
|
150
|
+
"""Determine if streaming converter should be used.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
streaming: True to force streaming, False to force the standard
|
|
154
|
+
converter, ``"auto"`` to pick on estimated size.
|
|
155
|
+
reader: The reader for the input.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
True if the streaming converter should be used.
|
|
159
|
+
|
|
160
|
+
Raises:
|
|
161
|
+
Exception: Whatever ``reader.get_essential_metadata()`` raises. A
|
|
162
|
+
reader that refuses its input must not be silenced here.
|
|
163
|
+
"""
|
|
151
164
|
if streaming is True:
|
|
152
165
|
return True
|
|
153
166
|
if streaming != "auto":
|
|
154
167
|
return False
|
|
155
168
|
|
|
156
|
-
#
|
|
169
|
+
# Deliberately outside the try below. This is often the first call that
|
|
170
|
+
# builds the reader's parser, so it is the call a refused file fails on --
|
|
171
|
+
# and swallowing it here logged the reason at DEBUG, returned False, and
|
|
172
|
+
# let the converter parse the whole file a second time before failing the
|
|
173
|
+
# same way. On a 2.1 GB imzML that is about two minutes of apparent
|
|
174
|
+
# progress with the real reason invisible.
|
|
175
|
+
essential_meta = reader.get_essential_metadata()
|
|
176
|
+
|
|
177
|
+
# Auto-detect based on estimated dataset size (>10GB). Only the estimate
|
|
178
|
+
# itself is best-effort: a reader whose dimensions are missing or oddly
|
|
179
|
+
# shaped simply does not get the automatic upgrade.
|
|
157
180
|
try:
|
|
158
|
-
essential_meta = reader.get_essential_metadata()
|
|
159
181
|
dims = essential_meta.dimensions
|
|
160
182
|
n_pixels = dims[0] * dims[1] * dims[2]
|
|
161
183
|
# Rough estimate: assume average 10k peaks per spectrum, 8 bytes each
|
|
162
184
|
estimated_gb = (n_pixels * 10000 * 8) / (1024**3)
|
|
163
|
-
if estimated_gb > 10:
|
|
164
|
-
logger.info(
|
|
165
|
-
f"Auto-detected large dataset (~{estimated_gb:.1f} GB), "
|
|
166
|
-
"using streaming converter"
|
|
167
|
-
)
|
|
168
|
-
return True
|
|
169
185
|
except Exception as e:
|
|
170
186
|
logger.debug(f"Could not estimate dataset size for auto-streaming: {e}")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
if estimated_gb > 10:
|
|
190
|
+
logger.info(
|
|
191
|
+
f"Auto-detected large dataset (~{estimated_gb:.1f} GB), "
|
|
192
|
+
"using streaming converter"
|
|
193
|
+
)
|
|
194
|
+
return True
|
|
171
195
|
return False
|
|
172
196
|
|
|
173
197
|
|
|
@@ -290,6 +314,17 @@ def convert_msi(
|
|
|
290
314
|
which is the limit SCiLS Lab places on the same
|
|
291
315
|
quantity (2026b User Guide, p.76). Pass None for
|
|
292
316
|
no limit.
|
|
317
|
+
- spectrum_type: str - For imzML, declare the spectrum
|
|
318
|
+
representation explicitly: 'profile' or 'centroid'
|
|
319
|
+
(the full CV names are accepted too). Outranks the
|
|
320
|
+
file's own MS:1000127/MS:1000128, so it can correct a
|
|
321
|
+
file that declares the wrong thing; contradicting a
|
|
322
|
+
declaration is logged as a warning. SCiLS Lab spells
|
|
323
|
+
this --rep_type (2026b User Guide, p.81). Default:
|
|
324
|
+
None, meaning detect. **Changes stored values** for
|
|
325
|
+
files where it disagrees with what detection would
|
|
326
|
+
have chosen, because the representation feeds
|
|
327
|
+
instrument and axis-type selection.
|
|
293
328
|
sparse_format: Sparse matrix format ('csc' or 'csr')
|
|
294
329
|
include_optical: Include optical images (default: True)
|
|
295
330
|
apply_optical_alignment: If True (default) and the MSI source
|
|
@@ -494,56 +494,72 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
494
494
|
except Exception as e:
|
|
495
495
|
logger.debug(f"Could not extract spectrum metadata: {e}")
|
|
496
496
|
|
|
497
|
-
def
|
|
498
|
-
"""
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
-
|
|
503
|
-
|
|
504
|
-
-
|
|
505
|
-
|
|
497
|
+
def build_uns_metadata(self) -> Dict[str, Any]:
|
|
498
|
+
"""The provenance block every write path must persist, identically.
|
|
499
|
+
|
|
500
|
+
Single source of truth for what lands in the table's ``uns``:
|
|
501
|
+
|
|
502
|
+
- ``essential_metadata`` -- dimensions, mass range, source path,
|
|
503
|
+
spectrum type and the Thyra version that wrote the store.
|
|
504
|
+
- ``format_specific`` -- vendor metadata (FlexImaging areas,
|
|
505
|
+
teaching points, imzML file mode, ...).
|
|
506
|
+
- ``acquisition_params`` / ``instrument_info`` -- when the reader
|
|
507
|
+
has them.
|
|
508
|
+
- ``raw_metadata`` -- the source metadata as read.
|
|
509
|
+
- ``regions`` -- the acquisition region summary, as JSON.
|
|
510
|
+
|
|
511
|
+
This exists because the converters have two write paths and they
|
|
512
|
+
drifted. The in-memory and streaming-COO paths hand the table to
|
|
513
|
+
``anndata``'s writer, which serialises whatever is in
|
|
514
|
+
``adata.uns``; the streaming-PCS path hand-writes the Zarr layout
|
|
515
|
+
and used to compose its own, much smaller block -- with
|
|
516
|
+
``spectrum_type`` hardcoded to ``"processed"``, which is not even
|
|
517
|
+
a value the extractors produce. A dataset that crossed
|
|
518
|
+
``StreamingSpatialDataConverter.PCS_SIZE_THRESHOLD_GB`` therefore
|
|
519
|
+
came out claiming a spectrum representation it did not have, and
|
|
520
|
+
without any of the other sections, while a slightly smaller one
|
|
521
|
+
from the same instrument came out complete. Both paths now render
|
|
522
|
+
this mapping, so a section added here reaches every store.
|
|
523
|
+
|
|
524
|
+
Sections the reader has nothing for are omitted rather than
|
|
525
|
+
written empty, so consumers can tell "not available from this
|
|
526
|
+
format" from "available and empty".
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Mapping of ``uns`` key to the value to store. Empty if the
|
|
530
|
+
reader cannot produce comprehensive metadata at all.
|
|
506
531
|
"""
|
|
507
532
|
try:
|
|
508
533
|
comp_meta = self.reader.get_comprehensive_metadata()
|
|
534
|
+
except Exception as e:
|
|
535
|
+
# Non-fatal: a store without provenance still holds the
|
|
536
|
+
# spectra. But it is not a debug-level event -- the whole
|
|
537
|
+
# point of the block is that a consumer can say where the
|
|
538
|
+
# data came from, so losing it has to be visible in the log.
|
|
539
|
+
logger.warning("Could not read metadata for uns provenance: %s", e)
|
|
540
|
+
return {}
|
|
541
|
+
|
|
542
|
+
uns: Dict[str, Any] = {}
|
|
543
|
+
try:
|
|
544
|
+
self._collect_essential_metadata(uns, comp_meta)
|
|
545
|
+
self._collect_optional_sections(uns, comp_meta)
|
|
546
|
+
self._collect_region_info(uns)
|
|
547
|
+
except Exception as e:
|
|
548
|
+
logger.warning("Could not build the full uns provenance block: %s", e)
|
|
509
549
|
|
|
510
|
-
|
|
511
|
-
self._store_acquisition_params(adata, comp_meta)
|
|
512
|
-
self._store_instrument_info(adata, comp_meta)
|
|
513
|
-
self._store_essential_metadata(adata, comp_meta)
|
|
514
|
-
self._store_raw_metadata(adata, comp_meta)
|
|
515
|
-
self._store_region_info(adata)
|
|
550
|
+
return uns
|
|
516
551
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
def _store_format_specific(self, adata, comp_meta) -> None:
|
|
522
|
-
"""Store format-specific metadata (FlexImaging areas, teaching points)."""
|
|
523
|
-
if hasattr(comp_meta, "format_specific") and comp_meta.format_specific:
|
|
524
|
-
adata.uns["format_specific"] = comp_meta.format_specific
|
|
525
|
-
|
|
526
|
-
def _store_acquisition_params(self, adata, comp_meta) -> None:
|
|
527
|
-
"""Store acquisition parameters."""
|
|
528
|
-
if hasattr(comp_meta, "acquisition_params") and comp_meta.acquisition_params:
|
|
529
|
-
adata.uns["acquisition_params"] = comp_meta.acquisition_params
|
|
530
|
-
|
|
531
|
-
def _store_instrument_info(self, adata, comp_meta) -> None:
|
|
532
|
-
"""Store instrument information."""
|
|
533
|
-
if hasattr(comp_meta, "instrument_info") and comp_meta.instrument_info:
|
|
534
|
-
adata.uns["instrument_info"] = comp_meta.instrument_info
|
|
535
|
-
|
|
536
|
-
def _store_essential_metadata(self, adata, comp_meta) -> None:
|
|
537
|
-
"""Store essential metadata (convert tuples to lists for Zarr)."""
|
|
538
|
-
if not hasattr(comp_meta, "essential"):
|
|
552
|
+
def _collect_essential_metadata(self, uns: Dict[str, Any], comp_meta: Any) -> None:
|
|
553
|
+
"""Add ``essential_metadata`` (tuples become lists for Zarr)."""
|
|
554
|
+
essential = getattr(comp_meta, "essential", None)
|
|
555
|
+
if essential is None:
|
|
539
556
|
return
|
|
540
557
|
|
|
541
|
-
essential = comp_meta.essential
|
|
542
558
|
dims = essential.dimensions
|
|
543
559
|
mrange = essential.mass_range
|
|
544
560
|
from thyra import __version__
|
|
545
561
|
|
|
546
|
-
|
|
562
|
+
uns["essential_metadata"] = {
|
|
547
563
|
"source_path": str(essential.source_path),
|
|
548
564
|
"dimensions": list(dims) if dims else None,
|
|
549
565
|
"mass_range": list(mrange) if mrange else None,
|
|
@@ -551,13 +567,19 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
551
567
|
"thyra_version": __version__,
|
|
552
568
|
}
|
|
553
569
|
|
|
554
|
-
def
|
|
555
|
-
"""
|
|
556
|
-
|
|
557
|
-
|
|
570
|
+
def _collect_optional_sections(self, uns: Dict[str, Any], comp_meta: Any) -> None:
|
|
571
|
+
"""Add the vendor sections the reader actually populated."""
|
|
572
|
+
for key in ("format_specific", "acquisition_params", "instrument_info"):
|
|
573
|
+
value = getattr(comp_meta, key, None)
|
|
574
|
+
if value:
|
|
575
|
+
uns[key] = self._serialize_for_zarr(value)
|
|
576
|
+
|
|
577
|
+
raw_metadata = getattr(comp_meta, "raw_metadata", None)
|
|
578
|
+
if raw_metadata:
|
|
579
|
+
uns["raw_metadata"] = self._serialize_for_zarr(raw_metadata)
|
|
558
580
|
|
|
559
|
-
def
|
|
560
|
-
"""
|
|
581
|
+
def _collect_region_info(self, uns: Dict[str, Any]) -> None:
|
|
582
|
+
"""Add the acquisition region summary as JSON.
|
|
561
583
|
|
|
562
584
|
Stored as a JSON string because AnnData/zarr cannot round-trip
|
|
563
585
|
a list of dicts (they get stringified individually). JSON
|
|
@@ -568,7 +590,13 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
568
590
|
"""
|
|
569
591
|
region_info = getattr(self, "_region_info", None)
|
|
570
592
|
if region_info:
|
|
571
|
-
|
|
593
|
+
uns["regions"] = json.dumps(self._serialize_for_zarr(region_info))
|
|
594
|
+
|
|
595
|
+
def _add_metadata_to_uns(self, adata) -> None:
|
|
596
|
+
"""Apply :meth:`build_uns_metadata` to an AnnData about to be written."""
|
|
597
|
+
uns = self.build_uns_metadata()
|
|
598
|
+
adata.uns.update(uns)
|
|
599
|
+
logger.debug("Added MSI metadata to AnnData .uns: %s", sorted(uns))
|
|
572
600
|
|
|
573
601
|
def _serialize_for_zarr(self, obj):
|
|
574
602
|
"""Recursively convert tuples to lists for Zarr serialization."""
|
|
@@ -221,6 +221,11 @@ class SpatialData3DConverter(BaseSpatialDataConverter):
|
|
|
221
221
|
per_region[str(r)] = total / max(count, 1)
|
|
222
222
|
adata.uns["average_spectrum_per_region"] = per_region
|
|
223
223
|
|
|
224
|
+
# Add MSI metadata to .uns. The 2D and streaming paths have
|
|
225
|
+
# always done this; the 3D one never did, so a volume came
|
|
226
|
+
# out with no record of where it came from at all.
|
|
227
|
+
self._add_metadata_to_uns(adata)
|
|
228
|
+
|
|
224
229
|
# Make sure region column exists and is correct
|
|
225
230
|
region_key = f"{self.dataset_id}_pixels"
|
|
226
231
|
if "region" not in adata.obs.columns:
|
|
@@ -1296,6 +1296,27 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1296
1296
|
|
|
1297
1297
|
return mm_indices, mm_data
|
|
1298
1298
|
|
|
1299
|
+
def _write_uns_provenance(self, uns_group: "zarr.Group") -> None:
|
|
1300
|
+
"""Write the shared provenance block into a hand-written ``uns`` group.
|
|
1301
|
+
|
|
1302
|
+
This path composes the Zarr layout itself, so it cannot reach
|
|
1303
|
+
``adata.uns`` the way ``_save_output`` does. It renders the same
|
|
1304
|
+
mapping through anndata's own element writer instead, which is
|
|
1305
|
+
what produces the ``encoding-type`` / ``encoding-version`` pairs
|
|
1306
|
+
``read_lazy`` needs -- getting those right by hand is exactly
|
|
1307
|
+
what this path used to have to do, and exactly where it drifted
|
|
1308
|
+
from the other one.
|
|
1309
|
+
|
|
1310
|
+
Only ``uns`` goes through anndata here. ``obs``/``var`` stay
|
|
1311
|
+
hand-written: they carry the pandas string dtypes this path
|
|
1312
|
+
exists to sidestep, whereas the provenance block is plain dicts,
|
|
1313
|
+
strings, lists and numbers.
|
|
1314
|
+
"""
|
|
1315
|
+
from anndata.io import write_elem
|
|
1316
|
+
|
|
1317
|
+
for key, value in self.build_uns_metadata().items():
|
|
1318
|
+
write_elem(uns_group, key, value)
|
|
1319
|
+
|
|
1299
1320
|
def _write_csc_arrays_to_zarr(
|
|
1300
1321
|
self,
|
|
1301
1322
|
mm_indices: np.memmap,
|
|
@@ -1540,40 +1561,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1540
1561
|
a.attrs["encoding-type"] = "string"
|
|
1541
1562
|
a.attrs["encoding-version"] = "0.2.0"
|
|
1542
1563
|
|
|
1543
|
-
|
|
1544
|
-
em_group.attrs["encoding-type"] = "dict"
|
|
1545
|
-
em_group.attrs["encoding-version"] = "0.1.0"
|
|
1546
|
-
a = em_group.create_array(
|
|
1547
|
-
"dimensions", data=np.asarray(np.array(self._dimensions))
|
|
1548
|
-
)
|
|
1549
|
-
a.attrs["encoding-type"] = "array"
|
|
1550
|
-
a.attrs["encoding-version"] = "0.2.0"
|
|
1551
|
-
a = em_group.create_array(
|
|
1552
|
-
"mass_range",
|
|
1553
|
-
data=np.array([mz_values.min(), mz_values.max()]),
|
|
1554
|
-
)
|
|
1555
|
-
a.attrs["encoding-type"] = "array"
|
|
1556
|
-
a.attrs["encoding-version"] = "0.2.0"
|
|
1557
|
-
a = em_group.create_array(
|
|
1558
|
-
"source_path",
|
|
1559
|
-
data=np.array(str(self.reader.data_path), dtype=str_dtype),
|
|
1560
|
-
)
|
|
1561
|
-
a.attrs["encoding-type"] = "string"
|
|
1562
|
-
a.attrs["encoding-version"] = "0.2.0"
|
|
1563
|
-
a = em_group.create_array(
|
|
1564
|
-
"spectrum_type",
|
|
1565
|
-
data=np.array("processed", dtype=str_dtype),
|
|
1566
|
-
)
|
|
1567
|
-
a.attrs["encoding-type"] = "string"
|
|
1568
|
-
a.attrs["encoding-version"] = "0.2.0"
|
|
1569
|
-
from thyra import __version__
|
|
1570
|
-
|
|
1571
|
-
a = em_group.create_array(
|
|
1572
|
-
"thyra_version",
|
|
1573
|
-
data=np.array(__version__, dtype=str_dtype),
|
|
1574
|
-
)
|
|
1575
|
-
a.attrs["encoding-type"] = "string"
|
|
1576
|
-
a.attrs["encoding-version"] = "0.2.0"
|
|
1564
|
+
self._write_uns_provenance(uns_group)
|
|
1577
1565
|
|
|
1578
1566
|
a = uns_group.create_array("average_spectrum", data=avg_spectrum)
|
|
1579
1567
|
a.attrs["encoding-type"] = "array"
|
|
@@ -9,7 +9,12 @@ from numpy.typing import NDArray
|
|
|
9
9
|
from pyimzml.ImzMLParser import ImzMLParser
|
|
10
10
|
|
|
11
11
|
from ...core.base_extractor import MetadataExtractor
|
|
12
|
-
from ...resampling.constants import
|
|
12
|
+
from ...resampling.constants import (
|
|
13
|
+
BinaryDataType,
|
|
14
|
+
ImzMLAccessions,
|
|
15
|
+
SpectrumType,
|
|
16
|
+
normalize_spectrum_type,
|
|
17
|
+
)
|
|
13
18
|
from ..types import ComprehensiveMetadata, EssentialMetadata
|
|
14
19
|
|
|
15
20
|
logger = logging.getLogger(__name__)
|
|
@@ -18,16 +23,32 @@ logger = logging.getLogger(__name__)
|
|
|
18
23
|
class ImzMLMetadataExtractor(MetadataExtractor):
|
|
19
24
|
"""ImzML-specific metadata extractor with optimized two-phase extraction."""
|
|
20
25
|
|
|
21
|
-
def __init__(
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
parser: ImzMLParser,
|
|
29
|
+
imzml_path: Path,
|
|
30
|
+
spectrum_type: Optional[str] = None,
|
|
31
|
+
):
|
|
22
32
|
"""Initialize ImzML metadata extractor.
|
|
23
33
|
|
|
24
34
|
Args:
|
|
25
35
|
parser: Initialized ImzML parser
|
|
26
36
|
imzml_path: Path to the ImzML file
|
|
37
|
+
spectrum_type: Optional override for the spectrum representation,
|
|
38
|
+
``"profile"`` or ``"centroid"`` (SCiLS Lab spells this
|
|
39
|
+
``--rep_type``). When given, it wins over anything the file
|
|
40
|
+
says. ``None`` -- the default -- leaves detection alone.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If ``spectrum_type`` is not a recognised
|
|
44
|
+
representation.
|
|
27
45
|
"""
|
|
28
46
|
super().__init__(parser)
|
|
29
47
|
self.parser = parser
|
|
30
48
|
self.imzml_path = imzml_path
|
|
49
|
+
# Normalised at construction so a typo fails here rather than silently
|
|
50
|
+
# falling through to auto-detection during extraction.
|
|
51
|
+
self.spectrum_type_override = normalize_spectrum_type(spectrum_type)
|
|
31
52
|
|
|
32
53
|
def _extract_essential_impl(self) -> EssentialMetadata:
|
|
33
54
|
"""Extract essential metadata optimized for speed."""
|
|
@@ -524,12 +545,19 @@ class ImzMLMetadataExtractor(MetadataExtractor):
|
|
|
524
545
|
def _detect_centroid_spectrum(self) -> Optional[str]:
|
|
525
546
|
"""Detect spectrum type by looking for MS:1000127 (centroid) or MS:1000128 (profile).
|
|
526
547
|
|
|
527
|
-
|
|
528
|
-
file declares
|
|
548
|
+
An explicit ``spectrum_type`` override wins over everything, so a user
|
|
549
|
+
can correct a file that declares the wrong thing. Otherwise what the
|
|
550
|
+
file *declares* wins, wherever it is written, and only when the file
|
|
551
|
+
declares nothing does Thyra guess -- see
|
|
529
552
|
:meth:`_guess_spectrum_type_from_storage_mode` for what that guess is
|
|
530
553
|
worth.
|
|
531
554
|
"""
|
|
532
555
|
try:
|
|
556
|
+
# 0. An explicit override outranks the file.
|
|
557
|
+
if self.spectrum_type_override is not None:
|
|
558
|
+
self._report_spectrum_type_override()
|
|
559
|
+
return self.spectrum_type_override
|
|
560
|
+
|
|
533
561
|
# 1. The declaration, from metadata the parser already holds.
|
|
534
562
|
result = self._check_parser_metadata_for_spectrum_type()
|
|
535
563
|
if result:
|
|
@@ -547,6 +575,49 @@ class ImzMLMetadataExtractor(MetadataExtractor):
|
|
|
547
575
|
logger.debug(f"Could not detect spectrum type: {e}")
|
|
548
576
|
return None
|
|
549
577
|
|
|
578
|
+
def _report_spectrum_type_override(self) -> None:
|
|
579
|
+
"""Log an override, loudly when it contradicts the file's declaration.
|
|
580
|
+
|
|
581
|
+
Overriding a declared ``MS:1000127``/``MS:1000128`` is a legitimate
|
|
582
|
+
thing to want -- files do declare the wrong representation -- and it is
|
|
583
|
+
also a good way to corrupt a conversion silently. So the two cases are
|
|
584
|
+
not logged alike: agreeing with the file, or overriding a file that
|
|
585
|
+
declares nothing, is INFO; contradicting an explicit declaration is
|
|
586
|
+
WARNING and names both values.
|
|
587
|
+
|
|
588
|
+
This still reads the file's *declarations* (steps 1 and 2) in order to
|
|
589
|
+
compare, but never its guess -- there is nothing informative about
|
|
590
|
+
contradicting a guess. That costs at most the same bounded XML scan
|
|
591
|
+
detection would have done, and only on the opt-in path.
|
|
592
|
+
"""
|
|
593
|
+
override = self.spectrum_type_override
|
|
594
|
+
declared = self._check_parser_metadata_for_spectrum_type()
|
|
595
|
+
if declared is None:
|
|
596
|
+
declared = self._check_xml_for_spectrum_type()
|
|
597
|
+
|
|
598
|
+
if declared is None:
|
|
599
|
+
logger.info(
|
|
600
|
+
"Using spectrum_type override %r; the file declares neither %s "
|
|
601
|
+
"(centroid) nor %s (profile).",
|
|
602
|
+
override,
|
|
603
|
+
ImzMLAccessions.CENTROID_SPECTRUM,
|
|
604
|
+
ImzMLAccessions.PROFILE_SPECTRUM,
|
|
605
|
+
)
|
|
606
|
+
elif declared == override:
|
|
607
|
+
logger.info(
|
|
608
|
+
"Using spectrum_type override %r, which agrees with the file.",
|
|
609
|
+
override,
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
logger.warning(
|
|
613
|
+
"spectrum_type override %r CONTRADICTS the file, which declares "
|
|
614
|
+
"%r. Proceeding with the override -- the stored spectrum type, "
|
|
615
|
+
"and any decision Thyra makes from it, will not match what the "
|
|
616
|
+
"file says about itself. Remove the override to trust the file.",
|
|
617
|
+
override,
|
|
618
|
+
declared,
|
|
619
|
+
)
|
|
620
|
+
|
|
550
621
|
def _check_xml_for_spectrum_type(self) -> Optional[str]:
|
|
551
622
|
"""Check XML for spectrum type markers using streaming parser.
|
|
552
623
|
|