thyra 3.2.1__tar.gz → 3.3.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.
- {thyra-3.2.1 → thyra-3.3.0}/PKG-INFO +9 -2
- {thyra-3.2.1 → thyra-3.3.0}/README.md +8 -1
- {thyra-3.2.1 → thyra-3.3.0}/pyproject.toml +1 -1
- {thyra-3.2.1 → thyra-3.3.0}/thyra/__init__.py +1 -1
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +115 -56
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +1 -3
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +1 -3
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/streaming_converter.py +10 -4
- {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_reader.py +24 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/core/registry.py +43 -13
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/__init__.py +3 -0
- thyra-3.3.0/thyra/metadata/extractors/phi_extractor.py +178 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/__init__.py +6 -1
- thyra-3.3.0/thyra/readers/phi/__init__.py +27 -0
- thyra-3.3.0/thyra/readers/phi/event_stream.py +327 -0
- thyra-3.3.0/thyra/readers/phi/mass_axis.py +169 -0
- thyra-3.3.0/thyra/readers/phi/phi_header.py +279 -0
- thyra-3.3.0/thyra/readers/phi/phi_reader.py +360 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/windows_paths.py +61 -4
- {thyra-3.2.1 → thyra-3.3.0}/LICENSE +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/__main__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/affine.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/alignment/teaching_points.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/config.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/convert.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/_chunking.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/core/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_converter.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/core/base_extractor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/imzml_extractor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/extractors/waters_extractor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/types.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/metadata/validator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/preview.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/imzml/imzml_reader.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/common_axis.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/constants.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/data_characteristics.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/decision_tree.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/gaps.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/instrument_detectors.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/base.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/tic.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/resampling/types.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/check_ontology.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/tools/make_example_data.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/__init__.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/logging_config.py +0 -0
- {thyra-3.2.1 → thyra-3.3.0}/thyra/utils/zarr_atomic_write.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thyra
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.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
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -81,7 +81,7 @@ for a full walkthrough, including the published example dataset
|
|
|
81
81
|
|
|
82
82
|
## Features
|
|
83
83
|
|
|
84
|
-
- **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories)
|
|
84
|
+
- **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories), PHI SmartSoft-TOF ToF-SIMS (.raw files)
|
|
85
85
|
- **SpatialData Output**: Modern, cloud-ready format with Zarr backend
|
|
86
86
|
- **Memory Efficient**: Handles large datasets (100+ GB) through streaming processing
|
|
87
87
|
- **Optical Alignment**: Automatic MSI-to-optical image registration for Bruker data
|
|
@@ -107,10 +107,17 @@ thyra input.imzML output.zarr
|
|
|
107
107
|
# Bruker data with verbose logging
|
|
108
108
|
thyra data.d output.zarr -v DEBUG
|
|
109
109
|
|
|
110
|
+
# PHI SmartSoft-TOF ToF-SIMS (a .raw file, not a directory)
|
|
111
|
+
thyra tofsims_run.raw output.zarr
|
|
112
|
+
|
|
110
113
|
# Disable resampling
|
|
111
114
|
thyra input.imzML output.zarr --no-resample
|
|
112
115
|
```
|
|
113
116
|
|
|
117
|
+
Thyra auto-detects the input format. Note that `.raw` is claimed by two
|
|
118
|
+
vendors and resolved by shape: Waters writes a directory, PHI writes a single
|
|
119
|
+
file. See [Supported Formats](https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/supported-formats/).
|
|
120
|
+
|
|
114
121
|
### Python API
|
|
115
122
|
|
|
116
123
|
```python
|
|
@@ -27,7 +27,7 @@ for a full walkthrough, including the published example dataset
|
|
|
27
27
|
|
|
28
28
|
## Features
|
|
29
29
|
|
|
30
|
-
- **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories)
|
|
30
|
+
- **Multiple Input Formats**: ImzML, Bruker (.d directories), Waters (.raw directories), PHI SmartSoft-TOF ToF-SIMS (.raw files)
|
|
31
31
|
- **SpatialData Output**: Modern, cloud-ready format with Zarr backend
|
|
32
32
|
- **Memory Efficient**: Handles large datasets (100+ GB) through streaming processing
|
|
33
33
|
- **Optical Alignment**: Automatic MSI-to-optical image registration for Bruker data
|
|
@@ -53,10 +53,17 @@ thyra input.imzML output.zarr
|
|
|
53
53
|
# Bruker data with verbose logging
|
|
54
54
|
thyra data.d output.zarr -v DEBUG
|
|
55
55
|
|
|
56
|
+
# PHI SmartSoft-TOF ToF-SIMS (a .raw file, not a directory)
|
|
57
|
+
thyra tofsims_run.raw output.zarr
|
|
58
|
+
|
|
56
59
|
# Disable resampling
|
|
57
60
|
thyra input.imzML output.zarr --no-resample
|
|
58
61
|
```
|
|
59
62
|
|
|
63
|
+
Thyra auto-detects the input format. Note that `.raw` is claimed by two
|
|
64
|
+
vendors and resolved by shape: Waters writes a directory, PHI writes a single
|
|
65
|
+
file. See [Supported Formats](https://M4i-Imaging-Mass-Spectrometry.github.io/thyra/supported-formats/).
|
|
66
|
+
|
|
60
67
|
### Python API
|
|
61
68
|
|
|
62
69
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
|
|
4
4
|
|
|
5
5
|
[tool.poetry]
|
|
6
6
|
name = "thyra"
|
|
7
|
-
version = "3.
|
|
7
|
+
version = "3.3.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 = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
|
|
10
10
|
maintainers = ["Theodoros Visvikis <t.visvikis@maastrichtuniversity.nl>"]
|
|
@@ -151,7 +151,7 @@ try:
|
|
|
151
151
|
import xarray as xr
|
|
152
152
|
import zarr
|
|
153
153
|
from anndata import AnnData
|
|
154
|
-
from shapely.geometry import
|
|
154
|
+
from shapely.geometry import box
|
|
155
155
|
from spatialdata import SpatialData
|
|
156
156
|
from spatialdata.models import Image2DModel, ShapesModel, TableModel
|
|
157
157
|
from spatialdata.transformations import Affine, Identity, Scale, Sequence
|
|
@@ -614,9 +614,14 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
614
614
|
logger.debug("Added MSI metadata to AnnData .uns: %s", sorted(uns))
|
|
615
615
|
|
|
616
616
|
def _serialize_for_zarr(self, obj):
|
|
617
|
-
"""Recursively convert tuples to lists for Zarr serialization.
|
|
617
|
+
"""Recursively convert tuples to lists for Zarr serialization.
|
|
618
|
+
|
|
619
|
+
Dict keys are coerced to strings: Zarr group members must be named,
|
|
620
|
+
and a non-string key otherwise fails at write time, after the whole
|
|
621
|
+
conversion has already been done.
|
|
622
|
+
"""
|
|
618
623
|
if isinstance(obj, dict):
|
|
619
|
-
return {k: self._serialize_for_zarr(v) for k, v in obj.items()}
|
|
624
|
+
return {str(k): self._serialize_for_zarr(v) for k, v in obj.items()}
|
|
620
625
|
elif isinstance(obj, (list, tuple)):
|
|
621
626
|
return [self._serialize_for_zarr(item) for item in obj]
|
|
622
627
|
elif hasattr(obj, "__dict__"):
|
|
@@ -1434,11 +1439,64 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1434
1439
|
if self._common_mass_axis is None:
|
|
1435
1440
|
raise ValueError("Common mass axis is not initialized")
|
|
1436
1441
|
|
|
1442
|
+
n_channels = len(self._common_mass_axis)
|
|
1443
|
+
columns: Dict[str, Any] = {"mz": self._common_mass_axis}
|
|
1444
|
+
columns.update(self._validated_mass_axis_annotations())
|
|
1445
|
+
|
|
1437
1446
|
return pd.DataFrame(
|
|
1438
|
-
|
|
1439
|
-
index=[f"mz_{i}" for i in range(
|
|
1447
|
+
columns,
|
|
1448
|
+
index=[f"mz_{i}" for i in range(n_channels)],
|
|
1440
1449
|
)
|
|
1441
1450
|
|
|
1451
|
+
def _validated_mass_axis_annotations(self) -> Dict[str, Any]:
|
|
1452
|
+
"""Reader-supplied per-channel columns that fit the axis being written.
|
|
1453
|
+
|
|
1454
|
+
A reader whose native axis is not m/z (flight time, drift time) can
|
|
1455
|
+
keep that axis alongside ``mz`` so the conversion stays reversible.
|
|
1456
|
+
Annotations whose length does not match are dropped rather than
|
|
1457
|
+
raising: that is the expected outcome when resampling rebuilds the
|
|
1458
|
+
axis, and it must not fail an otherwise good conversion.
|
|
1459
|
+
"""
|
|
1460
|
+
if self._common_mass_axis is None:
|
|
1461
|
+
return {}
|
|
1462
|
+
n_channels = len(self._common_mass_axis)
|
|
1463
|
+
|
|
1464
|
+
# getattr rather than a bare call: readers predating this hook, and
|
|
1465
|
+
# test doubles that do not subclass BaseMSIReader, simply have nothing
|
|
1466
|
+
# to contribute and should not have to raise to say so.
|
|
1467
|
+
getter = getattr(self.reader, "get_mass_axis_annotations", None)
|
|
1468
|
+
if getter is None:
|
|
1469
|
+
return {}
|
|
1470
|
+
|
|
1471
|
+
try:
|
|
1472
|
+
annotations = getter()
|
|
1473
|
+
except Exception as exc: # pragma: no cover - reader-defined
|
|
1474
|
+
# Format eagerly. Passing the exception itself to the logger keeps
|
|
1475
|
+
# it alive inside the log record, and with it its traceback, the
|
|
1476
|
+
# caller frames reachable through tb_frame.f_back, and any memmap
|
|
1477
|
+
# those frames hold -- which on Windows leaves the backing file
|
|
1478
|
+
# locked long after the conversion has finished.
|
|
1479
|
+
detail = f"{type(exc).__name__}: {exc}"
|
|
1480
|
+
logger.warning("Reader failed to supply mass axis annotations: %s", detail)
|
|
1481
|
+
return {}
|
|
1482
|
+
|
|
1483
|
+
validated: Dict[str, Any] = {}
|
|
1484
|
+
for name, values in (annotations or {}).items():
|
|
1485
|
+
if name == "mz":
|
|
1486
|
+
logger.warning("Ignoring mass axis annotation named 'mz'")
|
|
1487
|
+
continue
|
|
1488
|
+
if len(values) != n_channels:
|
|
1489
|
+
logger.info(
|
|
1490
|
+
"Dropping mass axis annotation %r: %d values for a "
|
|
1491
|
+
"%d channel axis (expected when resampling rebuilds it)",
|
|
1492
|
+
name,
|
|
1493
|
+
len(values),
|
|
1494
|
+
n_channels,
|
|
1495
|
+
)
|
|
1496
|
+
continue
|
|
1497
|
+
validated[name] = values
|
|
1498
|
+
return validated
|
|
1499
|
+
|
|
1442
1500
|
def _get_pixel_index(self, x: int, y: int, z: int) -> int:
|
|
1443
1501
|
"""Calculate linear pixel index from 3D coordinates.
|
|
1444
1502
|
|
|
@@ -1509,48 +1567,63 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1509
1567
|
|
|
1510
1568
|
coo_arrays["current_idx"] = end_idx
|
|
1511
1569
|
|
|
1512
|
-
def _create_pixel_shapes(
|
|
1513
|
-
self, adata: AnnData, is_3d: bool = False
|
|
1514
|
-
) -> "ShapesModel":
|
|
1570
|
+
def _create_pixel_shapes(self, adata: AnnData) -> "ShapesModel":
|
|
1515
1571
|
"""Create geometric shapes for pixels with proper transformations.
|
|
1516
1572
|
|
|
1517
1573
|
When optical alignment is available (FlexImaging with Area definitions),
|
|
1518
1574
|
shapes are created in optical image pixel coordinates for proper overlay.
|
|
1519
1575
|
Otherwise, shapes use physical (micrometer) coordinates.
|
|
1520
1576
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1577
|
+
**Footprints are two-dimensional, including on a multi-slice
|
|
1578
|
+
volume.** A slice's depth is carried by the TIC image's ``Scale``
|
|
1579
|
+
and by ``obs["spatial_z"]``; it is deliberately not also put on
|
|
1580
|
+
the polygon geometry.
|
|
1581
|
+
|
|
1582
|
+
Three options were measured. All three are imperfect, so what
|
|
1583
|
+
follows is the reasoning rather than a claim that this one is
|
|
1584
|
+
free:
|
|
1585
|
+
|
|
1586
|
+
* **``POLYGON Z``** -- geometrically the most honest, and what
|
|
1587
|
+
7317792 shipped in v3.2.0. It breaks ``spatialdata``'s spatial
|
|
1588
|
+
queries: measured on a 5x3x2 volume, a bounding box enclosing
|
|
1589
|
+
the whole dataset with 1000um of margin returned 26 of 30
|
|
1590
|
+
footprints and 26 of 30 table rows, with no exception and no
|
|
1591
|
+
warning. ``shapely.force_2d`` on the same geometry restored
|
|
1592
|
+
30 of 30. A z-restricted query returned the same rows whether
|
|
1593
|
+
z was inside or far outside the data, so z is ignored by the
|
|
1594
|
+
query path rather than honoured.
|
|
1529
1595
|
* **Flat 2D shapes carrying the depth in a transformation**
|
|
1530
|
-
(one element per slice, each with a ``Translation`` in z).
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
* **
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
in.
|
|
1596
|
+
(one element per slice, each with a ``Translation`` in z).
|
|
1597
|
+
The element parses, coexists with a 3D image and round-trips,
|
|
1598
|
+
but the transform **silently drops z** and every slice comes
|
|
1599
|
+
back at the same depth -- a depth in the metadata that never
|
|
1600
|
+
reaches the geometry. ``test_3d_pixel_shapes_z.py`` pins this,
|
|
1601
|
+
because it is the change someone will otherwise propose.
|
|
1602
|
+
* **Flat, with the depth on the image and in ``obs`` only** --
|
|
1603
|
+
what this does.
|
|
1604
|
+
|
|
1605
|
+
The deciding argument is that ``spatialdata`` itself asks for
|
|
1606
|
+
this. ``ShapesModel.validate`` warns that a 3-dimensional
|
|
1607
|
+
geometry column "could led to unexpected behaviors" and names
|
|
1608
|
+
``force_2d()`` as the remedy; the query result above is that
|
|
1609
|
+
behaviour. 3D shapes are not on the upstream roadmap
|
|
1610
|
+
(scverse/spatialdata#109 has been idle since June 2023 and
|
|
1611
|
+
covers images, labels and transformations only), and the live
|
|
1612
|
+
2.5D discussion (#961) scopes itself to points, images and
|
|
1613
|
+
labels. Serial-section MSI is 2.5D in that taxonomy.
|
|
1614
|
+
|
|
1615
|
+
What this costs: ``docs/coordinate-systems.md``'s promise that
|
|
1616
|
+
every element agrees at ``"global"`` is exact in x and y and
|
|
1617
|
+
silent in z for the shapes element. That is a documented gap
|
|
1618
|
+
rather than a wrong answer, which a truncated query is not. See
|
|
1619
|
+
``docs/output-format.md`` for the consumer-facing statement.
|
|
1620
|
+
|
|
1621
|
+
Reinstating z means restoring the ``is_3d`` parameter at all
|
|
1622
|
+
three call sites as well; it was removed with the geometry so it
|
|
1623
|
+
could not sit unread, which is how the original defect arose.
|
|
1548
1624
|
|
|
1549
1625
|
Args:
|
|
1550
1626
|
adata: AnnData object containing coordinates
|
|
1551
|
-
is_3d: Whether this is the 3D volume route. Combined with
|
|
1552
|
-
:attr:`_is_volume` (which also requires more than one
|
|
1553
|
-
plane), decides whether footprints carry z.
|
|
1554
1627
|
|
|
1555
1628
|
Returns:
|
|
1556
1629
|
SpatialData shapes model
|
|
@@ -1628,29 +1701,15 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1628
1701
|
y_coords: NDArray[np.float64] = adata.obs["spatial_y"].values
|
|
1629
1702
|
half_pixel_um = self.pixel_size_um / 2
|
|
1630
1703
|
|
|
1631
|
-
#
|
|
1632
|
-
#
|
|
1633
|
-
#
|
|
1634
|
-
#
|
|
1635
|
-
# -- the same column the table stores, which is what keeps the
|
|
1636
|
-
# two elements agreeing.
|
|
1637
|
-
z_coords: Optional[NDArray[np.float64]] = None
|
|
1638
|
-
if is_3d and self._is_volume:
|
|
1639
|
-
z_coords = adata.obs["spatial_z"].values
|
|
1640
|
-
|
|
1704
|
+
# Footprints are flat, on every route including volumes. A
|
|
1705
|
+
# slice's depth lives on the TIC image's Scale and in
|
|
1706
|
+
# obs["spatial_z"]; see the docstring for why it is not also
|
|
1707
|
+
# put on the geometry.
|
|
1641
1708
|
for i in range(len(adata)):
|
|
1642
1709
|
x, y = x_coords[i], y_coords[i]
|
|
1643
1710
|
x0, y0 = x - half_pixel_um, y - half_pixel_um
|
|
1644
1711
|
x1, y1 = x + half_pixel_um, y + half_pixel_um
|
|
1645
|
-
|
|
1646
|
-
if z_coords is None:
|
|
1647
|
-
pixel_box = box(x0, y0, x1, y1)
|
|
1648
|
-
else:
|
|
1649
|
-
z = float(z_coords[i])
|
|
1650
|
-
pixel_box = Polygon(
|
|
1651
|
-
[(x0, y0, z), (x1, y0, z), (x1, y1, z), (x0, y1, z)]
|
|
1652
|
-
)
|
|
1653
|
-
geometries.append(pixel_box)
|
|
1712
|
+
geometries.append(box(x0, y0, x1, y1))
|
|
1654
1713
|
|
|
1655
1714
|
# Create GeoDataFrame with appropriate index
|
|
1656
1715
|
if valid_indices is not None:
|
|
@@ -328,9 +328,7 @@ class SpatialData2DConverter(BaseSpatialDataConverter):
|
|
|
328
328
|
|
|
329
329
|
# Add to tables and create shapes
|
|
330
330
|
data_structures["tables"][slice_id] = table
|
|
331
|
-
data_structures["shapes"][region_key] = self._create_pixel_shapes(
|
|
332
|
-
adata, is_3d=False
|
|
333
|
-
)
|
|
331
|
+
data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
|
|
334
332
|
|
|
335
333
|
# Create TIC image for this slice
|
|
336
334
|
tic_values = slice_data["tic_values"]
|
|
@@ -246,9 +246,7 @@ class SpatialData3DConverter(BaseSpatialDataConverter):
|
|
|
246
246
|
|
|
247
247
|
# Add to tables and create shapes
|
|
248
248
|
data_structures["tables"][self.dataset_id] = table
|
|
249
|
-
data_structures["shapes"][region_key] = self._create_pixel_shapes(
|
|
250
|
-
adata, is_3d=True
|
|
251
|
-
)
|
|
249
|
+
data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
|
|
252
250
|
|
|
253
251
|
# Create TIC image
|
|
254
252
|
self._create_tic_image(data_structures)
|
|
@@ -1004,9 +1004,7 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1004
1004
|
|
|
1005
1005
|
# Add to tables and create shapes
|
|
1006
1006
|
data_structures["tables"][slice_id] = table
|
|
1007
|
-
data_structures["shapes"][region_key] = self._create_pixel_shapes(
|
|
1008
|
-
adata, is_3d=False
|
|
1009
|
-
)
|
|
1007
|
+
data_structures["shapes"][region_key] = self._create_pixel_shapes(adata)
|
|
1010
1008
|
|
|
1011
1009
|
# Create TIC image for this slice
|
|
1012
1010
|
tic_values = slice_data["tic_values"]
|
|
@@ -1702,7 +1700,10 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1702
1700
|
var_group.attrs["encoding-type"] = "dataframe"
|
|
1703
1701
|
var_group.attrs["encoding-version"] = "0.2.0"
|
|
1704
1702
|
var_group.attrs["_index"] = "_index"
|
|
1705
|
-
|
|
1703
|
+
# A reader whose native axis is not m/z keeps that axis here too, so
|
|
1704
|
+
# this route stores the same columns as the dataframe-based ones.
|
|
1705
|
+
annotations = self._validated_mass_axis_annotations()
|
|
1706
|
+
var_group.attrs["column-order"] = ["mz"] + sorted(annotations)
|
|
1706
1707
|
|
|
1707
1708
|
mz_values = self._common_mass_axis
|
|
1708
1709
|
if mz_values is None:
|
|
@@ -1725,6 +1726,11 @@ class StreamingSpatialDataConverter(BaseSpatialDataConverter):
|
|
|
1725
1726
|
a.attrs["encoding-type"] = "array"
|
|
1726
1727
|
a.attrs["encoding-version"] = "0.2.0"
|
|
1727
1728
|
|
|
1729
|
+
for name in sorted(annotations):
|
|
1730
|
+
a = var_group.create_array(name, data=np.asarray(annotations[name]))
|
|
1731
|
+
a.attrs["encoding-type"] = "array"
|
|
1732
|
+
a.attrs["encoding-version"] = "0.2.0"
|
|
1733
|
+
|
|
1728
1734
|
# uns (metadata)
|
|
1729
1735
|
uns_group = table_group.create_group("uns")
|
|
1730
1736
|
uns_group.attrs["encoding-type"] = "dict"
|
|
@@ -213,6 +213,30 @@ class BaseMSIReader(ABC):
|
|
|
213
213
|
# Return only the valid indices and their corresponding intensities
|
|
214
214
|
return indices[indices_valid], intensities[indices_valid]
|
|
215
215
|
|
|
216
|
+
def get_mass_axis_annotations(
|
|
217
|
+
self,
|
|
218
|
+
) -> Optional[dict]:
|
|
219
|
+
"""Get extra per-channel columns to store alongside the m/z axis.
|
|
220
|
+
|
|
221
|
+
Returns a mapping of column name to an array with one entry per
|
|
222
|
+
entry of :meth:`get_common_mass_axis`. These are written into the
|
|
223
|
+
table's ``var`` next to ``mz``.
|
|
224
|
+
|
|
225
|
+
This exists so a format whose native axis is not m/z can keep that
|
|
226
|
+
axis in the output. Time-of-flight instruments measure flight time
|
|
227
|
+
and derive m/z from a calibration, so storing the flight time makes
|
|
228
|
+
the conversion reversible without the reader: a later recalibration
|
|
229
|
+
can be applied to the stored times directly.
|
|
230
|
+
|
|
231
|
+
Annotations are dropped if their length does not match the axis the
|
|
232
|
+
converter actually writes, which is what happens when resampling is
|
|
233
|
+
enabled and the axis is rebuilt.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Mapping of column name to per-channel values, or None.
|
|
237
|
+
"""
|
|
238
|
+
return None
|
|
239
|
+
|
|
216
240
|
def get_region_map(self) -> Optional[dict]:
|
|
217
241
|
"""Get per-pixel region mapping for multi-region datasets.
|
|
218
242
|
|
|
@@ -36,11 +36,12 @@ class MSIRegistry:
|
|
|
36
36
|
self._lock = RLock()
|
|
37
37
|
self._readers: Dict[str, Type[BaseMSIReader]] = {}
|
|
38
38
|
self._converters: Dict[str, Type[BaseMSIConverter]] = {}
|
|
39
|
-
# Extension mapping for file-based formats
|
|
39
|
+
# Extension mapping for file-based formats. '.raw' is deliberately
|
|
40
|
+
# absent: it is claimed by both Waters and PHI and can only be
|
|
41
|
+
# resolved by inspecting the path (see _detect_raw_format).
|
|
40
42
|
self._extension_to_format = {
|
|
41
43
|
".imzml": "imzml",
|
|
42
44
|
".d": "bruker",
|
|
43
|
-
".raw": "waters",
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
def register_reader(
|
|
@@ -112,6 +113,25 @@ class MSIRegistry:
|
|
|
112
113
|
pass
|
|
113
114
|
return False
|
|
114
115
|
|
|
116
|
+
def _detect_phi_format(self, path: Path) -> bool:
|
|
117
|
+
"""Check if path is a PHI SmartSoft-TOF .raw file.
|
|
118
|
+
|
|
119
|
+
PHI writes a single file whose ASCII header opens with the four-byte
|
|
120
|
+
``SOFH`` magic. This is what distinguishes it from Waters .raw data,
|
|
121
|
+
which is a directory.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
path: File path to check
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if the file starts with the PHI SOFH magic
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
with path.open("rb") as handle:
|
|
131
|
+
return handle.read(4) == b"SOFH"
|
|
132
|
+
except (OSError, PermissionError):
|
|
133
|
+
return False
|
|
134
|
+
|
|
115
135
|
def detect_format(self, input_path: Path) -> str:
|
|
116
136
|
"""Detect MSI format from input path.
|
|
117
137
|
|
|
@@ -120,6 +140,7 @@ class MSIRegistry:
|
|
|
120
140
|
- .d directories (Bruker timsTOF)
|
|
121
141
|
- Folders with .dat + _poslog.txt (Bruker Rapiflex)
|
|
122
142
|
- .raw directories (Waters MassLynx)
|
|
143
|
+
- .raw files (PHI SmartSoft-TOF ToF-SIMS)
|
|
123
144
|
"""
|
|
124
145
|
if not input_path.exists():
|
|
125
146
|
raise ValueError(f"Input path does not exist: {input_path}")
|
|
@@ -136,7 +157,7 @@ class MSIRegistry:
|
|
|
136
157
|
if extension == ".d":
|
|
137
158
|
return self._detect_bruker_d_format(input_path)
|
|
138
159
|
if extension == ".raw":
|
|
139
|
-
return self.
|
|
160
|
+
return self._detect_raw_format(input_path)
|
|
140
161
|
if input_path.is_dir():
|
|
141
162
|
return self._detect_directory_format(input_path)
|
|
142
163
|
self._raise_unsupported_format(input_path)
|
|
@@ -152,16 +173,25 @@ class MSIRegistry:
|
|
|
152
173
|
return bruker_format
|
|
153
174
|
raise ValueError("Bruker .d directory missing analysis " f"files: {input_path}")
|
|
154
175
|
|
|
155
|
-
def
|
|
156
|
-
"""
|
|
157
|
-
|
|
176
|
+
def _detect_raw_format(self, input_path: Path) -> str:
|
|
177
|
+
"""Resolve a .raw path to the vendor that wrote it.
|
|
178
|
+
|
|
179
|
+
Two vendors claim the extension and they are distinguished by shape:
|
|
180
|
+
Waters .raw is a directory of _FUNC*.DAT files, PHI .raw is a single
|
|
181
|
+
file whose header begins with the SOFH magic.
|
|
182
|
+
"""
|
|
183
|
+
if input_path.is_dir():
|
|
184
|
+
if self._detect_waters_format(input_path):
|
|
185
|
+
return "waters"
|
|
158
186
|
raise ValueError(
|
|
159
|
-
"Waters
|
|
187
|
+
"Waters .raw directory missing " f"_FUNC*.DAT files: {input_path}"
|
|
160
188
|
)
|
|
161
|
-
if self.
|
|
162
|
-
return "
|
|
189
|
+
if self._detect_phi_format(input_path):
|
|
190
|
+
return "phi"
|
|
163
191
|
raise ValueError(
|
|
164
|
-
"
|
|
192
|
+
f"Unrecognised .raw file: {input_path}. Expected either a Waters "
|
|
193
|
+
"directory containing _FUNC*.DAT files, or a PHI SmartSoft-TOF "
|
|
194
|
+
"file beginning with the SOFH magic."
|
|
165
195
|
)
|
|
166
196
|
|
|
167
197
|
def _detect_directory_format(self, input_path: Path) -> str:
|
|
@@ -179,7 +209,8 @@ class MSIRegistry:
|
|
|
179
209
|
".imzml",
|
|
180
210
|
".d (timsTOF)",
|
|
181
211
|
"folder (Rapiflex)",
|
|
182
|
-
".raw (Waters)",
|
|
212
|
+
".raw directory (Waters)",
|
|
213
|
+
".raw file (PHI SmartSoft-TOF)",
|
|
183
214
|
]
|
|
184
215
|
raise ValueError(
|
|
185
216
|
f"Unsupported format for '{input_path}'. "
|
|
@@ -265,8 +296,7 @@ def detect_format(input_path: Path) -> str:
|
|
|
265
296
|
input_path: Path to MSI data file or directory
|
|
266
297
|
|
|
267
298
|
Returns:
|
|
268
|
-
Format name ('imzml', 'bruker', 'rapiflex',
|
|
269
|
-
or 'waters')
|
|
299
|
+
Format name ('imzml', 'bruker', 'rapiflex', 'waters', or 'phi')
|
|
270
300
|
"""
|
|
271
301
|
return _registry.detect_format(input_path)
|
|
272
302
|
|
|
@@ -30,12 +30,14 @@ Example usage:
|
|
|
30
30
|
|
|
31
31
|
from .bruker_extractor import BrukerMetadataExtractor
|
|
32
32
|
from .imzml_extractor import ImzMLMetadataExtractor
|
|
33
|
+
from .phi_extractor import PhiMetadataExtractor
|
|
33
34
|
from .waters_extractor import WatersMetadataExtractor
|
|
34
35
|
|
|
35
36
|
# Public API
|
|
36
37
|
__all__ = [
|
|
37
38
|
"BrukerMetadataExtractor",
|
|
38
39
|
"ImzMLMetadataExtractor",
|
|
40
|
+
"PhiMetadataExtractor",
|
|
39
41
|
"WatersMetadataExtractor",
|
|
40
42
|
]
|
|
41
43
|
|
|
@@ -46,6 +48,7 @@ FORMAT_EXTRACTORS = {
|
|
|
46
48
|
"tsf": BrukerMetadataExtractor,
|
|
47
49
|
"tdf": BrukerMetadataExtractor,
|
|
48
50
|
"waters": WatersMetadataExtractor,
|
|
51
|
+
"phi": PhiMetadataExtractor,
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
|