thyra 2.3.0__tar.gz → 3.0.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-2.3.0 → thyra-3.0.0}/PKG-INFO +1 -1
- {thyra-2.3.0 → thyra-3.0.0}/pyproject.toml +1 -1
- {thyra-2.3.0 → thyra-3.0.0}/thyra/__init__.py +1 -1
- {thyra-2.3.0 → thyra-3.0.0}/thyra/convert.py +33 -9
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/base_spatialdata_converter.py +145 -22
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/spatialdata_2d_converter.py +1 -10
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/streaming_converter.py +194 -56
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/imzml_extractor.py +123 -31
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/waters_extractor.py +11 -2
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/imzml/imzml_reader.py +538 -12
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/constants.py +6 -0
- {thyra-2.3.0 → thyra-3.0.0}/LICENSE +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/README.md +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/__main__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/affine.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/alignment/teaching_points.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/config.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/_chunking.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/converter.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/converters/spatialdata/spatialdata_3d_converter.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/core/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_converter.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_extractor.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/core/base_reader.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/core/registry.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/extractors/bruker_extractor.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_ims.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_ms.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/_uo.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/ontology/cache.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/types.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/metadata/validator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/preview.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/base_bruker_reader.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/folder_structure.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/mis_parser.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/rapiflex/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/rapiflex/rapiflex_reader.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/LICENCE-BRUKER.txt +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/README.md +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.dll +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll/timsdata.so +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/dll_manager.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/platform_detector.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/sdk/sdk_functions.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/timstof_reader.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/batch_processor.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/coordinate_cache.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/mass_axis_builder.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/bruker/timstof/utils/memory_manager.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/imzml/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/imaging_grid.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/MLReader.dll +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/MassLynxRaw.dll +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/libMLReader.so +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/lib/libMassLynxRaw.so +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/masslynx_lib.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/readers/waters/waters_reader.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/common_axis.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/data_characteristics.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/decision_tree.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/gaps.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/instrument_detectors.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/base_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/fticr_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/linear_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/linear_tof_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/orbitrap_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/mass_axis/reflector_tof_generator.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/base.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/nearest_neighbor.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/strategies/tic_preserving.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/tic.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/resampling/types.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/check_ontology.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/tools/make_example_data.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/__init__.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/bruker_exceptions.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/logging_config.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/thyra/utils/windows_paths.py +0 -0
- {thyra-2.3.0 → thyra-3.0.0}/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 = "
|
|
7
|
+
version = "3.0.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>"]
|
|
@@ -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
|
|
|
@@ -306,6 +306,11 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
306
306
|
)
|
|
307
307
|
|
|
308
308
|
self._non_empty_pixel_count: int = 0
|
|
309
|
+
# Peaks discarded for falling outside the target mass axis, and
|
|
310
|
+
# whether the one-line summary has been emitted yet. See
|
|
311
|
+
# _count_out_of_range().
|
|
312
|
+
self._out_of_range_peaks: int = 0
|
|
313
|
+
self._out_of_range_warned: bool = False
|
|
309
314
|
self._pixel_size_detection_info = pixel_size_detection_info
|
|
310
315
|
self._resampling_config = (
|
|
311
316
|
_normalize_resampling_config(resampling_config)
|
|
@@ -1071,6 +1076,43 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1071
1076
|
# This method is only called for TIC-preserving resampling (dense path)
|
|
1072
1077
|
return self._tic_preserving_resample(mzs, intensities)
|
|
1073
1078
|
|
|
1079
|
+
def _count_out_of_range(self, n_dropped: int, n_total: int) -> None:
|
|
1080
|
+
"""Record peaks discarded for lying outside the target mass axis.
|
|
1081
|
+
|
|
1082
|
+
Narrowing the mass range is deliberate, so dropping the peaks
|
|
1083
|
+
outside it is the correct answer and not an error -- but it is not
|
|
1084
|
+
something a user should have to infer either, since the previous
|
|
1085
|
+
behaviour conserved the total exactly and so left no trace a TIC
|
|
1086
|
+
check could find.
|
|
1087
|
+
|
|
1088
|
+
Warns once per conversion rather than once per spectrum: a
|
|
1089
|
+
narrowed range typically excludes peaks in every spectrum, and on
|
|
1090
|
+
xenium that is 918,855 identical lines. ``_out_of_range_peaks``
|
|
1091
|
+
keeps the running total; note it counts resample *calls*, and the
|
|
1092
|
+
PCS route resamples every spectrum twice, so it is a lower bound
|
|
1093
|
+
on nothing and an upper bound on nothing -- read the warning, not
|
|
1094
|
+
the counter, if you want a per-spectrum figure.
|
|
1095
|
+
|
|
1096
|
+
Args:
|
|
1097
|
+
n_dropped: Peaks outside the axis in this spectrum.
|
|
1098
|
+
n_total: Peaks in this spectrum before filtering.
|
|
1099
|
+
"""
|
|
1100
|
+
self._out_of_range_peaks += n_dropped
|
|
1101
|
+
|
|
1102
|
+
if self._out_of_range_warned or self._common_mass_axis is None:
|
|
1103
|
+
return
|
|
1104
|
+
self._out_of_range_warned = True
|
|
1105
|
+
logger.warning(
|
|
1106
|
+
"Dropping peaks that fall outside the target mass axis "
|
|
1107
|
+
"[%.4f, %.4f] m/z -- %d of %d in the first spectrum affected. "
|
|
1108
|
+
"They are discarded, not folded into the edge bins. Widen the "
|
|
1109
|
+
"resampling range to keep them.",
|
|
1110
|
+
float(self._common_mass_axis[0]),
|
|
1111
|
+
float(self._common_mass_axis[-1]),
|
|
1112
|
+
n_dropped,
|
|
1113
|
+
n_total,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1074
1116
|
def _nearest_neighbor_resample(
|
|
1075
1117
|
self, mzs: NDArray[np.float64], intensities: NDArray[np.float64]
|
|
1076
1118
|
) -> Tuple[NDArray[np.int_], NDArray[np.float64]]:
|
|
@@ -1079,6 +1121,23 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1079
1121
|
Maps each m/z value to its nearest bin in the common mass axis and
|
|
1080
1122
|
accumulates intensities. Returns only non-zero bins for efficiency.
|
|
1081
1123
|
|
|
1124
|
+
Peaks outside the axis are **dropped**, not folded into the edge
|
|
1125
|
+
bins. They used to be clipped to bin 0 or the last bin and then
|
|
1126
|
+
accumulated there, so narrowing the mass range -- the most ordinary
|
|
1127
|
+
thing ``--resample-min-mz`` / ``--resample-max-mz`` are for --
|
|
1128
|
+
piled everything below the floor onto the first bin and everything
|
|
1129
|
+
above the ceiling onto the last. On real ``pea.imzML`` resampled to
|
|
1130
|
+
400-800 m/z, bin 0 held 654,158 counts where a real peak there is
|
|
1131
|
+
around 80. The total was conserved exactly, so a TIC check could
|
|
1132
|
+
not see it; the peak was simply in the wrong place, 1,634x the
|
|
1133
|
+
median interior bin.
|
|
1134
|
+
|
|
1135
|
+
"In range" is the strict axis span, ``[axis[0], axis[-1]]``: a peak
|
|
1136
|
+
is kept when the axis covers it, not when it is within half a bin of
|
|
1137
|
+
the end. That is the same rule ``_tic_preserving_resample`` already
|
|
1138
|
+
follows -- ``np.interp(..., left=0, right=0)`` and
|
|
1139
|
+
``thyra.resampling.tic.preserved_tic`` both cut at the endpoints.
|
|
1140
|
+
|
|
1082
1141
|
Args:
|
|
1083
1142
|
mzs: Original m/z values from spectrum
|
|
1084
1143
|
intensities: Corresponding intensity values
|
|
@@ -1092,20 +1151,32 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1092
1151
|
# Ensure common mass axis is initialized
|
|
1093
1152
|
if self._common_mass_axis is None:
|
|
1094
1153
|
raise ValueError("Common mass axis is not initialized")
|
|
1154
|
+
|
|
1155
|
+
axis = self._common_mass_axis
|
|
1156
|
+
in_range = (mzs >= axis[0]) & (mzs <= axis[-1])
|
|
1157
|
+
if not in_range.all():
|
|
1158
|
+
self._count_out_of_range(int(mzs.size - in_range.sum()), int(mzs.size))
|
|
1159
|
+
mzs = mzs[in_range]
|
|
1160
|
+
intensities = intensities[in_range]
|
|
1161
|
+
if mzs.size == 0:
|
|
1162
|
+
return np.array([], dtype=np.int_), np.array([], dtype=np.float64)
|
|
1163
|
+
|
|
1095
1164
|
# Find insertion points using vectorized binary search
|
|
1096
|
-
indices = np.searchsorted(
|
|
1165
|
+
indices = np.searchsorted(axis, mzs)
|
|
1097
1166
|
|
|
1098
1167
|
# OPTIMIZED: Handle boundary and nearest neighbor in one pass
|
|
1099
|
-
# Clip to valid range
|
|
1100
|
-
|
|
1168
|
+
# Clip to valid range. Everything reaching here is inside the axis,
|
|
1169
|
+
# so this only pins searchsorted's one-past-the-end result for a
|
|
1170
|
+
# value equal to axis[-1]; it can no longer pull an outside peak in.
|
|
1171
|
+
indices_clipped = np.clip(indices, 0, len(axis) - 1)
|
|
1101
1172
|
|
|
1102
1173
|
# For non-boundary points, check if left is closer
|
|
1103
1174
|
# Only check where we're not at the left edge
|
|
1104
1175
|
check_left = indices > 0
|
|
1105
1176
|
if np.any(check_left):
|
|
1106
1177
|
# Get distances only for points that need checking
|
|
1107
|
-
mz_values =
|
|
1108
|
-
mz_values_left =
|
|
1178
|
+
mz_values = axis[indices_clipped[check_left]]
|
|
1179
|
+
mz_values_left = axis[indices_clipped[check_left] - 1]
|
|
1109
1180
|
mz_query = mzs[check_left]
|
|
1110
1181
|
|
|
1111
1182
|
# Use left if it's closer
|
|
@@ -1299,18 +1370,42 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1299
1370
|
)
|
|
1300
1371
|
coords_df.set_index("instance_id", inplace=True)
|
|
1301
1372
|
|
|
1302
|
-
|
|
1303
|
-
if region_map is not None:
|
|
1304
|
-
keys = list(zip(x_idx.tolist(), y_idx.tolist()))
|
|
1305
|
-
region_numbers = np.array(
|
|
1306
|
-
[region_map.get(k, -1) for k in keys], dtype=np.int32
|
|
1307
|
-
)
|
|
1308
|
-
coords_df["region_number"] = region_numbers
|
|
1309
|
-
else:
|
|
1310
|
-
coords_df["region_number"] = np.ones(n_pixels, dtype=np.int32)
|
|
1373
|
+
coords_df["region_number"] = self.build_region_numbers(x_idx, y_idx)
|
|
1311
1374
|
|
|
1312
1375
|
return coords_df
|
|
1313
1376
|
|
|
1377
|
+
def build_region_numbers(self, x_values, y_values) -> NDArray[np.int32]:
|
|
1378
|
+
"""``obs["region_number"]`` for the given pixel positions, in row order.
|
|
1379
|
+
|
|
1380
|
+
Written on every dataset for a consistent schema, so a consumer
|
|
1381
|
+
does not have to branch on whether the acquisition had regions:
|
|
1382
|
+
without a region map every pixel is region 1, which is also what
|
|
1383
|
+
``uns["regions"]`` reports for that case. Only the Bruker timsTOF
|
|
1384
|
+
reader produces a map today; a position missing from it gets -1.
|
|
1385
|
+
|
|
1386
|
+
Shared because there are four places that build an obs table --
|
|
1387
|
+
the in-memory 2D and 3D converters, the streaming-COO slice
|
|
1388
|
+
builder and the hand-written PCS layout -- and each had its own
|
|
1389
|
+
copy of this rule. The PCS one had no copy at all and simply
|
|
1390
|
+
omitted the column.
|
|
1391
|
+
|
|
1392
|
+
Args:
|
|
1393
|
+
x_values: Pixel x index per obs row.
|
|
1394
|
+
y_values: Pixel y index per obs row.
|
|
1395
|
+
|
|
1396
|
+
Returns:
|
|
1397
|
+
Region number per obs row.
|
|
1398
|
+
"""
|
|
1399
|
+
n = len(x_values)
|
|
1400
|
+
region_map = getattr(self, "_region_map", None)
|
|
1401
|
+
if region_map is None:
|
|
1402
|
+
return np.ones(n, dtype=np.int32)
|
|
1403
|
+
|
|
1404
|
+
keys = zip(x_values.tolist(), y_values.tolist())
|
|
1405
|
+
return np.fromiter(
|
|
1406
|
+
(region_map.get(key, -1) for key in keys), dtype=np.int32, count=n
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1314
1409
|
def _create_mass_dataframe(self) -> pd.DataFrame:
|
|
1315
1410
|
"""Create m/z dataframe for variable metadata.
|
|
1316
1411
|
|
|
@@ -1987,6 +2082,41 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1987
2082
|
# Add comprehensive dataset metadata if supported
|
|
1988
2083
|
self._add_comprehensive_metadata(metadata)
|
|
1989
2084
|
|
|
2085
|
+
def build_root_attrs(
|
|
2086
|
+
self, comprehensive_metadata_obj: Any = None
|
|
2087
|
+
) -> Dict[str, Any]:
|
|
2088
|
+
"""The root-level attributes every write path must persist, identically.
|
|
2089
|
+
|
|
2090
|
+
The store's own attrs, as opposed to the table's ``uns`` block
|
|
2091
|
+
:meth:`build_uns_metadata` owns. Sibling of that method and here for
|
|
2092
|
+
the same reason: the streaming-PCS path hand-writes its Zarr layout
|
|
2093
|
+
and composed its own, shorter set -- 7 attributes against 10 on real
|
|
2094
|
+
``pea.imzML``, missing ``coordinate_systems``,
|
|
2095
|
+
``format_specific_metadata`` and ``msi_dataset_info``.
|
|
2096
|
+
|
|
2097
|
+
``coordinate_systems`` is the one that matters most in practice: it
|
|
2098
|
+
is the structured contract saying what unit ``"global"`` is in, and
|
|
2099
|
+
Ousia and the registration tooling read it rather than guessing.
|
|
2100
|
+
A PCS store simply did not have it, and the route is chosen by size,
|
|
2101
|
+
so the datasets that lost it are the largest ones.
|
|
2102
|
+
|
|
2103
|
+
Sections the reader has nothing for are omitted rather than written
|
|
2104
|
+
empty, matching :meth:`build_uns_metadata`.
|
|
2105
|
+
|
|
2106
|
+
Args:
|
|
2107
|
+
comprehensive_metadata_obj: Already-read comprehensive metadata,
|
|
2108
|
+
when the caller has it. ``None`` reads it from the reader.
|
|
2109
|
+
|
|
2110
|
+
Returns:
|
|
2111
|
+
Mapping of root attribute name to value.
|
|
2112
|
+
"""
|
|
2113
|
+
if comprehensive_metadata_obj is None:
|
|
2114
|
+
comprehensive_metadata_obj = self.reader.get_comprehensive_metadata()
|
|
2115
|
+
|
|
2116
|
+
attrs = self._create_pixel_size_attrs()
|
|
2117
|
+
self._add_comprehensive_sections(attrs, comprehensive_metadata_obj)
|
|
2118
|
+
return attrs
|
|
2119
|
+
|
|
1990
2120
|
def _setup_spatialdata_attrs(
|
|
1991
2121
|
self, metadata: "SpatialData", comprehensive_metadata_obj
|
|
1992
2122
|
) -> None:
|
|
@@ -1996,14 +2126,7 @@ class BaseSpatialDataConverter(BaseMSIConverter, ABC):
|
|
|
1996
2126
|
|
|
1997
2127
|
logger.info("Adding comprehensive metadata to SpatialData.attrs")
|
|
1998
2128
|
|
|
1999
|
-
|
|
2000
|
-
pixel_size_attrs = self._create_pixel_size_attrs()
|
|
2001
|
-
|
|
2002
|
-
# Add comprehensive metadata sections
|
|
2003
|
-
self._add_comprehensive_sections(pixel_size_attrs, comprehensive_metadata_obj)
|
|
2004
|
-
|
|
2005
|
-
# Update SpatialData attributes
|
|
2006
|
-
metadata.attrs.update(pixel_size_attrs)
|
|
2129
|
+
metadata.attrs.update(self.build_root_attrs(comprehensive_metadata_obj))
|
|
2007
2130
|
|
|
2008
2131
|
def _create_pixel_size_attrs(self) -> Dict[str, Any]:
|
|
2009
2132
|
"""Create pixel size and conversion metadata attributes."""
|
|
@@ -158,16 +158,7 @@ class SpatialData2DConverter(BaseSpatialDataConverter):
|
|
|
158
158
|
# Always add per-pixel region numbers for a consistent schema.
|
|
159
159
|
# Multi-region datasets use the reader's region map; single-region
|
|
160
160
|
# or unknown-region datasets default to region 1.
|
|
161
|
-
|
|
162
|
-
if region_map is not None:
|
|
163
|
-
region_numbers = np.full(pixel_count, -1, dtype=np.int32)
|
|
164
|
-
for i in range(pixel_count):
|
|
165
|
-
key = (int(x_values[i]), int(y_values[i]))
|
|
166
|
-
if key in region_map:
|
|
167
|
-
region_numbers[i] = region_map[key]
|
|
168
|
-
coords_df["region_number"] = region_numbers
|
|
169
|
-
else:
|
|
170
|
-
coords_df["region_number"] = np.ones(pixel_count, dtype=np.int32)
|
|
161
|
+
coords_df["region_number"] = self.build_region_numbers(x_values, y_values)
|
|
171
162
|
|
|
172
163
|
return coords_df
|
|
173
164
|
|