simind-python-connector 1.1.0__py3-none-any.whl → 1.1.2__py3-none-any.whl
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.
- simind_python_connector/__init__.py +1 -1
- simind_python_connector/backends/__init__.py +4 -3
- simind_python_connector/backends/sirf_backend.py +3 -3
- simind_python_connector/backends/stir_backend.py +36 -17
- simind_python_connector/builders/acquisition_builder.py +51 -24
- simind_python_connector/builders/image_builder.py +3 -0
- simind_python_connector/connectors/_spacing.py +158 -34
- simind_python_connector/connectors/python_connector.py +442 -106
- simind_python_connector/connectors/pytomography_adaptor.py +65 -30
- simind_python_connector/connectors/sirf_adaptor.py +62 -7
- simind_python_connector/connectors/stir_adaptor.py +62 -7
- simind_python_connector/converters/attenuation.py +12 -14
- simind_python_connector/converters/simind_to_stir.py +89 -48
- simind_python_connector/core/config.py +442 -90
- simind_python_connector/core/executor.py +277 -28
- simind_python_connector/core/types.py +21 -8
- simind_python_connector/data/__init__.py +5 -3
- simind_python_connector/utils/__init__.py +3 -3
- simind_python_connector/utils/geometry_phantom.py +42 -0
- simind_python_connector/utils/import_helpers.py +33 -5
- simind_python_connector/utils/interfile_numpy.py +15 -1
- simind_python_connector/utils/interfile_parser.py +11 -2
- simind_python_connector/utils/simind_utils.py +31 -1
- simind_python_connector/utils/stir_utils.py +4 -4
- {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/METADATA +27 -12
- simind_python_connector-1.1.2.dist-info/RECORD +48 -0
- simind_python_connector-1.1.0.dist-info/RECORD +0 -47
- {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/WHEEL +0 -0
- {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/licenses/LICENSE +0 -0
- {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/top_level.txt +0 -0
|
@@ -22,6 +22,7 @@ import logging
|
|
|
22
22
|
import os
|
|
23
23
|
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
|
24
24
|
|
|
25
|
+
from ..utils.import_helpers import _import_stir_extra
|
|
25
26
|
from .base import AcquisitionDataInterface, ImageDataInterface
|
|
26
27
|
|
|
27
28
|
|
|
@@ -150,7 +151,7 @@ def get_backend() -> str:
|
|
|
150
151
|
except ImportError:
|
|
151
152
|
try:
|
|
152
153
|
importlib.import_module("stir")
|
|
153
|
-
|
|
154
|
+
_import_stir_extra()
|
|
154
155
|
|
|
155
156
|
_backend = "stir"
|
|
156
157
|
logger.info("Auto-detected STIR Python backend")
|
|
@@ -159,7 +160,7 @@ def get_backend() -> str:
|
|
|
159
160
|
"Neither SIRF nor STIR Python found. "
|
|
160
161
|
"Please install one of: "
|
|
161
162
|
"\n - SIRF (sirf.STIR)"
|
|
162
|
-
"\n - STIR Python (stir +
|
|
163
|
+
"\n - STIR Python (stir + stir.extra)"
|
|
163
164
|
)
|
|
164
165
|
|
|
165
166
|
_backend_initialized = True
|
|
@@ -190,7 +191,7 @@ def set_backend(backend: Literal["sirf", "stir"]) -> None:
|
|
|
190
191
|
elif backend == "stir":
|
|
191
192
|
try:
|
|
192
193
|
importlib.import_module("stir")
|
|
193
|
-
|
|
194
|
+
_import_stir_extra()
|
|
194
195
|
except ImportError:
|
|
195
196
|
raise ImportError("STIR Python is not available")
|
|
196
197
|
|
|
@@ -88,7 +88,7 @@ class SirfImageData(ImageDataInterface):
|
|
|
88
88
|
|
|
89
89
|
def maximum(self, value: float) -> None:
|
|
90
90
|
"""Apply element-wise maximum."""
|
|
91
|
-
self._obj.maximum(value)
|
|
91
|
+
self._obj.maximum(value, out=self._obj)
|
|
92
92
|
|
|
93
93
|
@property
|
|
94
94
|
def native_object(self):
|
|
@@ -150,7 +150,7 @@ class SirfImageData(ImageDataInterface):
|
|
|
150
150
|
SirfImageData: self after modification (for chaining)
|
|
151
151
|
"""
|
|
152
152
|
y_obj = y._obj if isinstance(y, SirfImageData) else y
|
|
153
|
-
self._obj.sapyb(a, y_obj, b)
|
|
153
|
+
self._obj.sapyb(a, y_obj, b, out=self._obj)
|
|
154
154
|
return self
|
|
155
155
|
|
|
156
156
|
|
|
@@ -305,5 +305,5 @@ class SirfAcquisitionData(AcquisitionDataInterface):
|
|
|
305
305
|
SirfAcquisitionData: self after modification (for chaining)
|
|
306
306
|
"""
|
|
307
307
|
y_obj = y._obj if isinstance(y, SirfAcquisitionData) else y
|
|
308
|
-
self._obj.sapyb(a, y_obj, b)
|
|
308
|
+
self._obj.sapyb(a, y_obj, b, out=self._obj)
|
|
309
309
|
return self
|
|
@@ -12,7 +12,10 @@ import numpy as np
|
|
|
12
12
|
|
|
13
13
|
try:
|
|
14
14
|
import stir
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
from ..utils.import_helpers import _import_stir_extra
|
|
17
|
+
|
|
18
|
+
_import_stir_extra()
|
|
16
19
|
|
|
17
20
|
STIR_AVAILABLE = True
|
|
18
21
|
except ImportError:
|
|
@@ -92,7 +95,7 @@ class StirImageData(ImageDataInterface):
|
|
|
92
95
|
def dimensions(self) -> Tuple[int, int, int]:
|
|
93
96
|
"""Get dimensions as (z, y, x)."""
|
|
94
97
|
# Extract from numpy array shape
|
|
95
|
-
arr =
|
|
98
|
+
arr = _import_stir_extra().to_numpy(self._obj)
|
|
96
99
|
# STIR arrays are typically (z, y, x)
|
|
97
100
|
return arr.shape
|
|
98
101
|
|
|
@@ -104,8 +107,8 @@ class StirImageData(ImageDataInterface):
|
|
|
104
107
|
return (grid_spacing[1], grid_spacing[2], grid_spacing[3])
|
|
105
108
|
|
|
106
109
|
def as_array(self) -> np.ndarray:
|
|
107
|
-
"""Convert to numpy array using
|
|
108
|
-
return
|
|
110
|
+
"""Convert to numpy array using stir.extra."""
|
|
111
|
+
return _import_stir_extra().to_numpy(self._obj)
|
|
109
112
|
|
|
110
113
|
def sum(self) -> float:
|
|
111
114
|
"""Sum all values."""
|
|
@@ -169,19 +172,27 @@ class StirImageData(ImageDataInterface):
|
|
|
169
172
|
return StirImageData(self._obj / float(other))
|
|
170
173
|
|
|
171
174
|
def __radd__(self, other):
|
|
172
|
-
|
|
175
|
+
if isinstance(other, stir.FloatVoxelsOnCartesianGrid):
|
|
176
|
+
return StirImageData(other) + self
|
|
177
|
+
return self + float(other)
|
|
173
178
|
|
|
174
179
|
def __rsub__(self, other):
|
|
175
|
-
|
|
180
|
+
if isinstance(other, stir.FloatVoxelsOnCartesianGrid):
|
|
181
|
+
return StirImageData(other) - self
|
|
182
|
+
return (self * -1.0) + float(other)
|
|
176
183
|
|
|
177
184
|
def __rmul__(self, other):
|
|
178
|
-
|
|
185
|
+
if isinstance(other, stir.FloatVoxelsOnCartesianGrid):
|
|
186
|
+
return StirImageData(other) * self
|
|
187
|
+
return self * float(other)
|
|
179
188
|
|
|
180
189
|
def __rtruediv__(self, other):
|
|
181
|
-
|
|
190
|
+
if isinstance(other, stir.FloatVoxelsOnCartesianGrid):
|
|
191
|
+
return StirImageData(other) / self
|
|
192
|
+
return self.get_uniform_copy(float(other)) / self
|
|
182
193
|
|
|
183
194
|
def __neg__(self):
|
|
184
|
-
return
|
|
195
|
+
return self * -1.0
|
|
185
196
|
|
|
186
197
|
def sapyb(self, a: float, y: "StirImageData", b: float) -> "StirImageData":
|
|
187
198
|
"""Compute self * a + y * b (element-wise), modifying self in place.
|
|
@@ -281,12 +292,12 @@ class StirAcquisitionData(AcquisitionDataInterface):
|
|
|
281
292
|
def dimensions(self) -> Tuple:
|
|
282
293
|
"""Get dimensions."""
|
|
283
294
|
# Extract from numpy array
|
|
284
|
-
arr =
|
|
295
|
+
arr = _import_stir_extra().to_numpy(self._obj)
|
|
285
296
|
return arr.shape
|
|
286
297
|
|
|
287
298
|
def as_array(self) -> np.ndarray:
|
|
288
|
-
"""Convert to numpy array using
|
|
289
|
-
return
|
|
299
|
+
"""Convert to numpy array using stir.extra."""
|
|
300
|
+
return _import_stir_extra().to_numpy(self._obj)
|
|
290
301
|
|
|
291
302
|
def sum(self) -> float:
|
|
292
303
|
"""Sum all values."""
|
|
@@ -363,19 +374,27 @@ class StirAcquisitionData(AcquisitionDataInterface):
|
|
|
363
374
|
return StirAcquisitionData(self._obj / float(other))
|
|
364
375
|
|
|
365
376
|
def __radd__(self, other):
|
|
366
|
-
|
|
377
|
+
if isinstance(other, stir.ProjData):
|
|
378
|
+
return StirAcquisitionData(other) + self
|
|
379
|
+
return self + float(other)
|
|
367
380
|
|
|
368
381
|
def __rsub__(self, other):
|
|
369
|
-
|
|
382
|
+
if isinstance(other, stir.ProjData):
|
|
383
|
+
return StirAcquisitionData(other) - self
|
|
384
|
+
return (self * -1.0) + float(other)
|
|
370
385
|
|
|
371
386
|
def __rmul__(self, other):
|
|
372
|
-
|
|
387
|
+
if isinstance(other, stir.ProjData):
|
|
388
|
+
return StirAcquisitionData(other) * self
|
|
389
|
+
return self * float(other)
|
|
373
390
|
|
|
374
391
|
def __rtruediv__(self, other):
|
|
375
|
-
|
|
392
|
+
if isinstance(other, stir.ProjData):
|
|
393
|
+
return StirAcquisitionData(other) / self
|
|
394
|
+
return self.get_uniform_copy(float(other)) / self
|
|
376
395
|
|
|
377
396
|
def __neg__(self):
|
|
378
|
-
return
|
|
397
|
+
return self * -1.0
|
|
379
398
|
|
|
380
399
|
def sapyb(
|
|
381
400
|
self, a: float, y: "StirAcquisitionData", b: float
|
|
@@ -92,22 +92,22 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
92
92
|
num_projections = int(self.header.get("!number of projections", 1))
|
|
93
93
|
if self.pixel_array is None:
|
|
94
94
|
self.pixel_array = np.zeros(
|
|
95
|
-
(1,
|
|
95
|
+
(1, matrix_size_2, num_projections, matrix_size_1), dtype=np.float32
|
|
96
96
|
)
|
|
97
97
|
else:
|
|
98
98
|
self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
|
|
99
99
|
|
|
100
100
|
expected_shape = (
|
|
101
101
|
1,
|
|
102
|
-
matrix_size_1,
|
|
103
|
-
num_projections,
|
|
104
102
|
matrix_size_2,
|
|
103
|
+
num_projections,
|
|
104
|
+
matrix_size_1,
|
|
105
105
|
)
|
|
106
106
|
if self.pixel_array.shape != expected_shape:
|
|
107
107
|
raise ValueError(
|
|
108
108
|
f"pixel array shape {self.pixel_array.shape} does not match "
|
|
109
109
|
f"header dimensions {expected_shape} "
|
|
110
|
-
"(tof,
|
|
110
|
+
"(tof, axial, view, bin)"
|
|
111
111
|
)
|
|
112
112
|
|
|
113
113
|
def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
|
|
@@ -115,6 +115,8 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
115
115
|
raw_file_path = base_path.with_suffix(".s")
|
|
116
116
|
|
|
117
117
|
self.header["name of data file"] = raw_file_path.name
|
|
118
|
+
# Keys added since an earlier build must come before the terminator.
|
|
119
|
+
self.header.pop("!END OF INTERFILE", None)
|
|
118
120
|
self.header["!END OF INTERFILE"] = ""
|
|
119
121
|
|
|
120
122
|
with open(header_path, "w") as f:
|
|
@@ -221,35 +223,54 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
221
223
|
f"pixel array shape {self.pixel_array.shape} does not match "
|
|
222
224
|
f"declared {total_projections} projections"
|
|
223
225
|
)
|
|
224
|
-
if total_projections % num_windows != 0:
|
|
225
|
-
raise ValueError(
|
|
226
|
-
f"Number of projections ({total_projections}) must be divisible "
|
|
227
|
-
f"by the number of energy windows ({num_windows})"
|
|
228
|
-
)
|
|
229
|
-
projections_per_window = total_projections // num_windows
|
|
230
226
|
|
|
231
|
-
|
|
232
|
-
|
|
227
|
+
window_vector = getattr(self, "energy_window_vector", None)
|
|
228
|
+
if window_vector is not None:
|
|
229
|
+
window_vector = np.asarray(window_vector)
|
|
230
|
+
if len(window_vector) != total_projections:
|
|
231
|
+
raise ValueError(
|
|
232
|
+
f"EnergyWindowVector has {len(window_vector)} entries for "
|
|
233
|
+
f"{total_projections} frames"
|
|
234
|
+
)
|
|
235
|
+
frames_per_window = [
|
|
236
|
+
np.flatnonzero(window_vector == idx + 1) for idx in range(num_windows)
|
|
237
|
+
]
|
|
238
|
+
else:
|
|
239
|
+
if total_projections % num_windows != 0:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"Number of projections ({total_projections}) must be divisible "
|
|
242
|
+
f"by the number of energy windows ({num_windows})"
|
|
243
|
+
)
|
|
244
|
+
# Without a window vector, assume one contiguous block per window.
|
|
245
|
+
frames_per_window = np.array_split(
|
|
246
|
+
np.arange(total_projections), num_windows
|
|
247
|
+
)
|
|
233
248
|
|
|
234
|
-
# split pixel_array into energy windows along 3rd axis
|
|
235
249
|
original_pixel_array = self.pixel_array
|
|
250
|
+
original_header = dict(self.header)
|
|
251
|
+
duration = original_header.get("!image duration (sec)[1]")
|
|
236
252
|
try:
|
|
237
|
-
pixel_array_list = np.array_split(original_pixel_array, num_windows, axis=2)
|
|
238
|
-
|
|
239
253
|
acqdata_list = []
|
|
240
254
|
for idx, ew in enumerate(self.energy_windows):
|
|
241
|
-
|
|
255
|
+
frames = frames_per_window[idx]
|
|
256
|
+
self.header["!number of projections"] = str(len(frames))
|
|
257
|
+
self.header["!number of energy windows"] = "1"
|
|
242
258
|
self.header["energy window lower level[1]"] = ew["lower"]
|
|
243
259
|
self.header["energy window upper level[1]"] = ew["upper"]
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
260
|
+
if duration is not None:
|
|
261
|
+
# Windows are acquired simultaneously, so each output
|
|
262
|
+
# covers only its own frames.
|
|
263
|
+
self.header["!image duration (sec)[1]"] = (
|
|
264
|
+
f"{float(duration) * len(frames) / total_projections:g}"
|
|
265
|
+
)
|
|
266
|
+
output_path = output_path_base + f"_ew{idx + 1}"
|
|
267
|
+
self.pixel_array = original_pixel_array[:, :, frames, :]
|
|
247
268
|
acqdata = self.build(output_path=output_path)
|
|
248
269
|
acqdata_list.append(acqdata)
|
|
249
270
|
return acqdata_list
|
|
250
271
|
finally:
|
|
251
272
|
self.pixel_array = original_pixel_array
|
|
252
|
-
self.header
|
|
273
|
+
self.header = original_header
|
|
253
274
|
|
|
254
275
|
def update_header_from_dicom(self, dicom_filepath):
|
|
255
276
|
"""
|
|
@@ -273,16 +294,18 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
273
294
|
"Modality not found in DICOM. Retaining default '!imaging modality'."
|
|
274
295
|
)
|
|
275
296
|
try:
|
|
276
|
-
self.header["!matrix size [1]"] = str(ds.
|
|
277
|
-
self.header["!matrix size [2]"] = str(ds.
|
|
297
|
+
self.header["!matrix size [1]"] = str(ds.Columns)
|
|
298
|
+
self.header["!matrix size [2]"] = str(ds.Rows)
|
|
278
299
|
except AttributeError:
|
|
279
300
|
warnings.warn(
|
|
280
301
|
"Rows/Columns not found in DICOM. Retaining default matrix sizes."
|
|
281
302
|
)
|
|
282
303
|
try:
|
|
283
304
|
pixel_spacing = ds.PixelSpacing
|
|
284
|
-
|
|
285
|
-
|
|
305
|
+
# DICOM PixelSpacing is [row spacing, column spacing]; Interfile
|
|
306
|
+
# [1] is the column/bin axis and [2] the row/axial axis.
|
|
307
|
+
self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[1])
|
|
308
|
+
self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[0])
|
|
286
309
|
except AttributeError:
|
|
287
310
|
warnings.warn(
|
|
288
311
|
"PixelSpacing not found in DICOM. Retaining default scaling factors."
|
|
@@ -303,6 +326,10 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
303
326
|
try:
|
|
304
327
|
ewi_seq_tag = (0x0054, 0x0012)
|
|
305
328
|
self.energy_windows = [] # list to hold each energy window's info
|
|
329
|
+
vector = ds.get("EnergyWindowVector")
|
|
330
|
+
self.energy_window_vector = (
|
|
331
|
+
[int(v) for v in np.atleast_1d(vector)] if vector is not None else None
|
|
332
|
+
)
|
|
306
333
|
if ewi_seq_tag in ds:
|
|
307
334
|
ewi_seq = ds[ewi_seq_tag].value
|
|
308
335
|
for ewi_item in ewi_seq:
|
|
@@ -111,6 +111,9 @@ class STIRSPECTImageDataBuilder:
|
|
|
111
111
|
header_path = base_path.with_suffix(".hv")
|
|
112
112
|
raw_path = base_path.with_suffix(".v")
|
|
113
113
|
self.header["!name of data file"] = raw_path.name
|
|
114
|
+
# Keys added after construction must come before the terminator.
|
|
115
|
+
self.header.pop("!END OF INTERFILE", None)
|
|
116
|
+
self.header["!END OF INTERFILE"] = ""
|
|
114
117
|
|
|
115
118
|
with open(header_path, "w") as f:
|
|
116
119
|
line = 0
|
|
@@ -2,74 +2,198 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import Any
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_voxel_size_mm(voxel_size_mm: Any) -> tuple[float, float, float]:
|
|
11
|
+
"""Return positive voxel spacing in canonical ``(z, y, x)`` order."""
|
|
12
|
+
try:
|
|
13
|
+
values = np.asarray(voxel_size_mm, dtype=float)
|
|
14
|
+
except (TypeError, ValueError) as exc:
|
|
15
|
+
raise ValueError(
|
|
16
|
+
"voxel_size_mm must be a scalar or a (z, y, x) sequence"
|
|
17
|
+
) from exc
|
|
18
|
+
if values.ndim == 0:
|
|
19
|
+
z = y = x = float(values)
|
|
20
|
+
elif values.ndim == 1 and values.size == 3:
|
|
21
|
+
z, y, x = (float(value) for value in values)
|
|
22
|
+
else:
|
|
23
|
+
raise ValueError("voxel_size_mm must be a scalar or a (z, y, x) sequence")
|
|
24
|
+
if not np.isfinite((z, y, x)).all() or min(z, y, x) <= 0:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"voxel_size_mm must be > 0 and all spacing values must be finite"
|
|
27
|
+
)
|
|
28
|
+
if abs(y - x) > 1e-3:
|
|
29
|
+
raise ValueError(
|
|
30
|
+
f"SIMIND requires square in-plane voxels; got y={y} mm and x={x} mm"
|
|
31
|
+
)
|
|
32
|
+
return z, y, x
|
|
6
33
|
|
|
7
34
|
|
|
8
35
|
def _call_or_value(value: Any) -> Any:
|
|
9
36
|
return value() if callable(value) else value
|
|
10
37
|
|
|
11
38
|
|
|
12
|
-
def
|
|
39
|
+
def _spacing_zyx(spacing: Any) -> Optional[tuple[float, float, float]]:
|
|
13
40
|
try:
|
|
14
41
|
values = tuple(float(v) for v in spacing)
|
|
15
42
|
except Exception:
|
|
16
43
|
values = ()
|
|
17
44
|
|
|
18
|
-
if values:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
return float(values[0])
|
|
45
|
+
if len(values) >= 4:
|
|
46
|
+
# Raw STIR-style coordinate sequence: (unused, z, y, x)
|
|
47
|
+
return values[1], values[2], values[3]
|
|
48
|
+
if len(values) == 3:
|
|
49
|
+
# Public (z, y, x) convention
|
|
50
|
+
return values[0], values[1], values[2]
|
|
25
51
|
|
|
26
|
-
if hasattr(spacing, "z"):
|
|
52
|
+
if all(hasattr(spacing, axis) for axis in ("z", "y", "x")):
|
|
27
53
|
try:
|
|
28
|
-
|
|
54
|
+
z, y, x = (float(_call_or_value(getattr(spacing, axis))) for axis in "zyx")
|
|
55
|
+
return z, y, x
|
|
29
56
|
except Exception:
|
|
30
57
|
pass
|
|
31
58
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for index in (1, 2, 3):
|
|
59
|
+
# Non-iterable STIR coordinates index (z, y, x) from 1.
|
|
60
|
+
for accessor in (
|
|
61
|
+
getattr(spacing, "at", None),
|
|
62
|
+
getattr(spacing, "__getitem__", None),
|
|
63
|
+
):
|
|
64
|
+
if accessor is None:
|
|
65
|
+
continue
|
|
40
66
|
try:
|
|
41
|
-
|
|
67
|
+
z, y, x = (float(accessor(index)) for index in (1, 2, 3))
|
|
68
|
+
return z, y, x
|
|
42
69
|
except Exception:
|
|
43
70
|
continue
|
|
44
71
|
|
|
45
72
|
return None
|
|
46
73
|
|
|
47
74
|
|
|
48
|
-
def
|
|
49
|
-
|
|
75
|
+
def extract_voxel_spacing_mm(
|
|
76
|
+
image: Any, backend_name: str
|
|
77
|
+
) -> tuple[float, float, float]:
|
|
78
|
+
"""Return voxel spacing in mm from backend image metadata, in (z, y, x).
|
|
50
79
|
|
|
51
|
-
|
|
52
|
-
|
|
80
|
+
SIMIND supports axial spacing distinct from the in-plane image spacing;
|
|
81
|
+
the in-plane dimensions must remain square.
|
|
53
82
|
"""
|
|
83
|
+
zyx = None
|
|
54
84
|
if hasattr(image, "voxel_sizes"):
|
|
55
|
-
voxel_sizes = image.voxel_sizes()
|
|
56
85
|
try:
|
|
86
|
+
voxel_sizes = tuple(float(v) for v in image.voxel_sizes())
|
|
57
87
|
if len(voxel_sizes) >= 3:
|
|
58
|
-
|
|
88
|
+
zyx = voxel_sizes[:3]
|
|
59
89
|
except Exception:
|
|
60
90
|
pass
|
|
61
91
|
|
|
62
|
-
if hasattr(image, "get_grid_spacing"):
|
|
63
|
-
|
|
64
|
-
if
|
|
65
|
-
|
|
92
|
+
if zyx is None and hasattr(image, "get_grid_spacing"):
|
|
93
|
+
zyx = _spacing_zyx(image.get_grid_spacing())
|
|
94
|
+
if zyx is None:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Unable to read voxel spacing from {backend_name} get_grid_spacing()."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if zyx is None:
|
|
66
100
|
raise ValueError(
|
|
67
|
-
f"
|
|
101
|
+
f"{backend_name} source object must expose voxel_sizes() or "
|
|
102
|
+
"get_grid_spacing()."
|
|
68
103
|
)
|
|
104
|
+
try:
|
|
105
|
+
return normalize_voxel_size_mm(zyx)
|
|
106
|
+
except ValueError as exc:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"{backend_name} voxel spacing is invalid (z, y, x) = {tuple(zyx)} mm: "
|
|
109
|
+
f"{exc}"
|
|
110
|
+
) from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_voxel_size_mm(image: Any, backend_name: str) -> float:
|
|
114
|
+
"""Return a scalar for legacy callers when backend voxels are cubic."""
|
|
115
|
+
z, y, x = extract_voxel_spacing_mm(image, backend_name)
|
|
116
|
+
if max(z, y, x) - min(z, y, x) > 1e-3:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"{backend_name} images must have cubic voxels; got (z, y, x) = "
|
|
119
|
+
f"{(z, y, x)} mm"
|
|
120
|
+
)
|
|
121
|
+
return z
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _numeric_array(value: Any) -> Optional[np.ndarray]:
|
|
125
|
+
try:
|
|
126
|
+
return np.asarray(value, dtype=float).reshape(-1)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _spatial_geometry(image: Any) -> Optional[tuple[Any, ...]]:
|
|
132
|
+
"""Read native geometry where the backend exposes a documented API.
|
|
133
|
+
|
|
134
|
+
SIRF's ImageData returns a GeometricalInfo from
|
|
135
|
+
``get_geometrical_info()``, with offset, spacing, and direction-matrix getters.
|
|
136
|
+
STIR's VoxelsOnCartesianGrid exposes origin and signed grid spacing.
|
|
137
|
+
"""
|
|
138
|
+
native_object = getattr(image, "native_object", image)
|
|
139
|
+
image = native_object() if callable(native_object) else native_object
|
|
140
|
+
|
|
141
|
+
get_geometrical_info = getattr(image, "get_geometrical_info", None)
|
|
142
|
+
if callable(get_geometrical_info):
|
|
143
|
+
info = get_geometrical_info()
|
|
144
|
+
getters = ("get_offset", "get_spacing", "get_direction_matrix")
|
|
145
|
+
if not all(callable(getattr(info, name, None)) for name in getters):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"Unable to read SIRF geometry from get_geometrical_info()."
|
|
148
|
+
)
|
|
149
|
+
arrays = [_numeric_array(getattr(info, name)()) for name in getters]
|
|
150
|
+
if any(array is None or not array.size for array in arrays):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
"Unable to read SIRF geometry from get_geometrical_info()."
|
|
153
|
+
)
|
|
154
|
+
if any(not np.isfinite(array).all() for array in arrays if array is not None):
|
|
155
|
+
raise ValueError("SIRF geometry values must be finite.")
|
|
156
|
+
return ("sirf", np.concatenate(arrays))
|
|
157
|
+
|
|
158
|
+
get_origin = getattr(image, "get_origin", None)
|
|
159
|
+
get_grid_spacing = getattr(image, "get_grid_spacing", None)
|
|
160
|
+
if callable(get_origin) and callable(get_grid_spacing):
|
|
161
|
+
origin = _spacing_zyx(get_origin())
|
|
162
|
+
spacing = _spacing_zyx(get_grid_spacing())
|
|
163
|
+
if origin is None or spacing is None:
|
|
164
|
+
return None
|
|
165
|
+
minimum_indices = []
|
|
166
|
+
for axis in "zyx":
|
|
167
|
+
getter = getattr(image, f"get_min_{axis}", None)
|
|
168
|
+
if not callable(getter):
|
|
169
|
+
return None
|
|
170
|
+
minimum_indices.append(float(getter()))
|
|
171
|
+
physical_start = np.asarray(origin) + np.asarray(minimum_indices) * np.asarray(
|
|
172
|
+
spacing
|
|
173
|
+
)
|
|
174
|
+
return (
|
|
175
|
+
"stir",
|
|
176
|
+
physical_start,
|
|
177
|
+
)
|
|
178
|
+
return None
|
|
179
|
+
|
|
69
180
|
|
|
70
|
-
|
|
71
|
-
|
|
181
|
+
def same_spatial_geometry(source: Any, mu_map: Any) -> bool:
|
|
182
|
+
"""Compare origins, spacing, and axis directions when both APIs expose them."""
|
|
183
|
+
source_geometry = _spatial_geometry(source)
|
|
184
|
+
mu_geometry = _spatial_geometry(mu_map)
|
|
185
|
+
if source_geometry is None and mu_geometry is None:
|
|
186
|
+
return True
|
|
187
|
+
if source_geometry is None or mu_geometry is None:
|
|
188
|
+
return False
|
|
189
|
+
return source_geometry[0] == mu_geometry[0] and np.allclose(
|
|
190
|
+
source_geometry[1], mu_geometry[1], rtol=0.0, atol=1e-3
|
|
72
191
|
)
|
|
73
192
|
|
|
74
193
|
|
|
75
|
-
__all__ = [
|
|
194
|
+
__all__ = [
|
|
195
|
+
"extract_voxel_size_mm",
|
|
196
|
+
"extract_voxel_spacing_mm",
|
|
197
|
+
"normalize_voxel_size_mm",
|
|
198
|
+
"same_spatial_geometry",
|
|
199
|
+
]
|