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.
Files changed (30) hide show
  1. simind_python_connector/__init__.py +1 -1
  2. simind_python_connector/backends/__init__.py +4 -3
  3. simind_python_connector/backends/sirf_backend.py +3 -3
  4. simind_python_connector/backends/stir_backend.py +36 -17
  5. simind_python_connector/builders/acquisition_builder.py +51 -24
  6. simind_python_connector/builders/image_builder.py +3 -0
  7. simind_python_connector/connectors/_spacing.py +158 -34
  8. simind_python_connector/connectors/python_connector.py +442 -106
  9. simind_python_connector/connectors/pytomography_adaptor.py +65 -30
  10. simind_python_connector/connectors/sirf_adaptor.py +62 -7
  11. simind_python_connector/connectors/stir_adaptor.py +62 -7
  12. simind_python_connector/converters/attenuation.py +12 -14
  13. simind_python_connector/converters/simind_to_stir.py +89 -48
  14. simind_python_connector/core/config.py +442 -90
  15. simind_python_connector/core/executor.py +277 -28
  16. simind_python_connector/core/types.py +21 -8
  17. simind_python_connector/data/__init__.py +5 -3
  18. simind_python_connector/utils/__init__.py +3 -3
  19. simind_python_connector/utils/geometry_phantom.py +42 -0
  20. simind_python_connector/utils/import_helpers.py +33 -5
  21. simind_python_connector/utils/interfile_numpy.py +15 -1
  22. simind_python_connector/utils/interfile_parser.py +11 -2
  23. simind_python_connector/utils/simind_utils.py +31 -1
  24. simind_python_connector/utils/stir_utils.py +4 -4
  25. {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/METADATA +27 -12
  26. simind_python_connector-1.1.2.dist-info/RECORD +48 -0
  27. simind_python_connector-1.1.0.dist-info/RECORD +0 -47
  28. {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/WHEEL +0 -0
  29. {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/licenses/LICENSE +0 -0
  30. {simind_python_connector-1.1.0.dist-info → simind_python_connector-1.1.2.dist-info}/top_level.txt +0 -0
@@ -14,7 +14,7 @@ for _dist_name in ("simind-python-connector", __name__):
14
14
  except _meta.PackageNotFoundError:
15
15
  continue
16
16
  else: # editable / source checkout
17
- __version__ = "1.1.0"
17
+ __version__ = "1.1.2"
18
18
 
19
19
 
20
20
  def __getattr__(name: str) -> Any:
@@ -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
- importlib.import_module("stirextra")
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 + stirextra)"
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
- importlib.import_module("stirextra")
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
- import stirextra
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 = stirextra.to_numpy(self._obj)
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 stirextra."""
108
- return stirextra.to_numpy(self._obj)
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
- return StirImageData(float(other) + self._obj)
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
- return StirImageData(float(other) - self._obj)
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
- return StirImageData(float(other) * self._obj)
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
- return StirImageData(float(other) / self._obj)
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 StirImageData(-self._obj)
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 = stirextra.to_numpy(self._obj)
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 stirextra."""
289
- return stirextra.to_numpy(self._obj)
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
- return StirAcquisitionData(float(other) + self._obj)
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
- return StirAcquisitionData(float(other) - self._obj)
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
- return StirAcquisitionData(float(other) * self._obj)
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
- return StirAcquisitionData(float(other) / self._obj)
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 StirAcquisitionData(-self._obj)
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, matrix_size_1, num_projections, matrix_size_2), dtype=np.float32
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, bin, view, axial)"
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
- # number of projections needs dividing by number of energy windows
232
- self.header["!number of projections"] = str(projections_per_window)
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
- # Update header for this energy window.
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
- suffix = f"_ew{idx + 1}"
245
- output_path = output_path_base + suffix
246
- self.pixel_array = pixel_array_list[idx]
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["!number of projections"] = str(total_projections)
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.Rows)
277
- self.header["!matrix size [2]"] = str(ds.Columns)
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
- self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[0])
285
- self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[1])
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 _voxel_size_from_spacing(spacing: Any) -> float | None:
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
- if len(values) >= 4:
20
- # Raw STIR-style coordinate sequence: (unused, z, y, x)
21
- return float(values[1])
22
- if len(values) == 3:
23
- # Public (z, y, x) convention
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
- return float(_call_or_value(getattr(spacing, "z")))
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
- if hasattr(spacing, "at"):
33
- for index in (1, 2, 3):
34
- try:
35
- return float(spacing.at(index))
36
- except Exception:
37
- continue
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
- return float(spacing[index])
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 extract_voxel_size_mm(image: Any, backend_name: str) -> float:
49
- """Extract z voxel spacing in mm from backend image metadata.
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
- Backend images report ``voxel_sizes()`` in ``(z, y, x)`` order, so the
52
- first element is the z spacing.
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
- return float(voxel_sizes[0])
88
+ zyx = voxel_sizes[:3]
59
89
  except Exception:
60
90
  pass
61
91
 
62
- if hasattr(image, "get_grid_spacing"):
63
- voxel_size_mm = _voxel_size_from_spacing(image.get_grid_spacing())
64
- if voxel_size_mm is not None:
65
- return voxel_size_mm
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"Unable to read voxel spacing from {backend_name} get_grid_spacing()."
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
- raise ValueError(
71
- f"{backend_name} source object must expose voxel_sizes() or get_grid_spacing()."
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__ = ["extract_voxel_size_mm"]
194
+ __all__ = [
195
+ "extract_voxel_size_mm",
196
+ "extract_voxel_spacing_mm",
197
+ "normalize_voxel_size_mm",
198
+ "same_spatial_geometry",
199
+ ]