simind-python-connector 2.0.0__py3-none-any.whl → 2.0.1__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 (53) hide show
  1. simind_python_connector/__init__.py +3 -3
  2. simind_python_connector/builders/_native.py +92 -0
  3. simind_python_connector/builders/acquisition_builder.py +97 -104
  4. simind_python_connector/builders/image_builder.py +16 -72
  5. simind_python_connector/configs/__init__.py +10 -4
  6. simind_python_connector/connectors/__init__.py +0 -2
  7. simind_python_connector/connectors/_native_adaptor.py +471 -0
  8. simind_python_connector/connectors/python_connector.py +1036 -96
  9. simind_python_connector/connectors/pytomography_adaptor.py +94 -28
  10. simind_python_connector/connectors/sirf_adaptor.py +21 -145
  11. simind_python_connector/connectors/stir_adaptor.py +23 -145
  12. simind_python_connector/converters/__init__.py +2 -2
  13. simind_python_connector/converters/attenuation.py +48 -51
  14. simind_python_connector/converters/simind_to_stir.py +97 -395
  15. simind_python_connector/core/config.py +432 -123
  16. simind_python_connector/core/executor.py +289 -56
  17. simind_python_connector/core/types.py +16 -25
  18. simind_python_connector/data/__init__.py +3 -3
  19. simind_python_connector/data/bone.atn +178 -187
  20. simind_python_connector/data/h2o.atn +84 -92
  21. simind_python_connector/datasets.py +67 -0
  22. simind_python_connector/normalisation.py +90 -0
  23. simind_python_connector/phantoms.py +390 -0
  24. simind_python_connector/recon/__init__.py +43 -0
  25. simind_python_connector/recon/cil.py +185 -0
  26. simind_python_connector/recon/corrections.py +150 -0
  27. simind_python_connector/recon/diagnostics.py +41 -0
  28. simind_python_connector/recon/osem.py +59 -0
  29. simind_python_connector/recon/projector.py +141 -0
  30. simind_python_connector/recon/updates.py +88 -0
  31. simind_python_connector/utils/__init__.py +34 -85
  32. simind_python_connector/utils/interfile.py +690 -0
  33. simind_python_connector/utils/simind_utils.py +23 -28
  34. simind_python_connector-2.0.1.dist-info/METADATA +149 -0
  35. simind_python_connector-2.0.1.dist-info/RECORD +47 -0
  36. {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.1.dist-info}/WHEEL +1 -1
  37. simind_python_connector/backends/__init__.py +0 -487
  38. simind_python_connector/backends/base.py +0 -387
  39. simind_python_connector/backends/sirf_backend.py +0 -309
  40. simind_python_connector/backends/stir_backend.py +0 -395
  41. simind_python_connector/connectors/_spacing.py +0 -75
  42. simind_python_connector/converters/dicom_to_stir.py +0 -3
  43. simind_python_connector/utils/backend_access.py +0 -121
  44. simind_python_connector/utils/import_helpers.py +0 -78
  45. simind_python_connector/utils/interfile_numpy.py +0 -205
  46. simind_python_connector/utils/interfile_parser.py +0 -177
  47. simind_python_connector/utils/io_utils.py +0 -14
  48. simind_python_connector/utils/sirf_stir_utils.py +0 -194
  49. simind_python_connector/utils/stir_utils.py +0 -490
  50. simind_python_connector-2.0.0.dist-info/METADATA +0 -283
  51. simind_python_connector-2.0.0.dist-info/RECORD +0 -47
  52. {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.1.dist-info}/licenses/LICENSE +0 -0
  53. {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.1.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__ = "2.0.0"
17
+ __version__ = "2.0.1"
18
18
 
19
19
 
20
20
  def __getattr__(name: str) -> Any:
@@ -25,6 +25,7 @@ def __getattr__(name: str) -> Any:
25
25
  "converters",
26
26
  "core",
27
27
  "data",
28
+ "normalisation",
28
29
  "utils",
29
30
  }:
30
31
  mod = importlib.import_module(f".{name}", __name__)
@@ -37,7 +38,6 @@ def __getattr__(name: str) -> Any:
37
38
  return obj
38
39
  elif name in {
39
40
  "BaseConnector",
40
- "NumpyConnector",
41
41
  "PyTomographySimindAdaptor",
42
42
  "ProjectionResult",
43
43
  "RuntimeOperator",
@@ -54,7 +54,6 @@ def __getattr__(name: str) -> Any:
54
54
 
55
55
  __all__ = [
56
56
  "BaseConnector",
57
- "NumpyConnector",
58
57
  "ProjectionResult",
59
58
  "PyTomographySimindAdaptor",
60
59
  "RuntimeOperator",
@@ -68,5 +67,6 @@ __all__ = [
68
67
  "converters",
69
68
  "core",
70
69
  "data",
70
+ "normalisation",
71
71
  "utils",
72
72
  ]
@@ -0,0 +1,92 @@
1
+ """Load SIRF or STIR objects for the builders.
2
+
3
+ Backends are imported inside each function so importing the builders never
4
+ imports SIRF or STIR.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from typing import Optional
11
+
12
+ import numpy as np
13
+
14
+ from simind_python_connector.utils import _import_stir_extra
15
+
16
+
17
+ def _stir_available() -> bool:
18
+ """True when STIR Python and its array-conversion module are importable."""
19
+ try:
20
+ importlib.import_module("stir")
21
+ _import_stir_extra()
22
+ except ImportError:
23
+ return False
24
+ return True
25
+
26
+
27
+ def resolve_backend(backend: Optional[str]) -> str:
28
+ """Return "sirf" or "stir". None picks SIRF if importable, else STIR."""
29
+ if backend is None:
30
+ try:
31
+ importlib.import_module("sirf.STIR")
32
+ return "sirf"
33
+ except ImportError:
34
+ pass
35
+ if _stir_available():
36
+ return "stir"
37
+ raise ImportError(
38
+ "Neither SIRF nor STIR Python found. Install one of: "
39
+ "SIRF (sirf.STIR) or STIR Python (stir + stir.extra)."
40
+ )
41
+ if backend == "sirf":
42
+ try:
43
+ importlib.import_module("sirf.STIR")
44
+ except ImportError as exc:
45
+ raise ImportError("SIRF is not available") from exc
46
+ return "sirf"
47
+ if backend == "stir":
48
+ if not _stir_available():
49
+ raise ImportError("STIR Python is not available")
50
+ return "stir"
51
+ raise ValueError("backend must be one of: 'sirf', 'stir', or None")
52
+
53
+
54
+ def load_image(header_path: str, backend: Optional[str]):
55
+ """Read an image header as a native SIRF or STIR image."""
56
+ if resolve_backend(backend) == "sirf":
57
+ import sirf.STIR
58
+
59
+ return sirf.STIR.ImageData(header_path)
60
+ import stir
61
+
62
+ return stir.FloatVoxelsOnCartesianGrid.read_from_file(header_path)
63
+
64
+
65
+ def load_acquisition(header_path: str, backend: Optional[str]):
66
+ """Read projection data as native SIRF or in-memory STIR data.
67
+
68
+ STIR's ProjData reads lazily from disk, so it is copied into memory;
69
+ the builders may delete the files afterwards.
70
+ """
71
+ if resolve_backend(backend) == "sirf":
72
+ import sirf.STIR
73
+
74
+ return sirf.STIR.AcquisitionData(header_path)
75
+ import stir
76
+
77
+ return stir.ProjDataInMemory(stir.ProjData.read_from_file(header_path))
78
+
79
+
80
+ def refill_acquisition(acquisition, array: np.ndarray, header_path: str, backend: str):
81
+ """Fill a copy of acquisition with array, write it to header_path, return it."""
82
+ if backend == "sirf":
83
+ filled = acquisition.clone()
84
+ filled.fill(array)
85
+ filled.write(header_path)
86
+ return filled
87
+ import stir
88
+
89
+ filled = stir.ProjDataInMemory(acquisition)
90
+ filled.fill(array.flat)
91
+ filled.write_to_file(header_path)
92
+ return filled
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import tempfile
3
4
  import warnings
4
5
  from pathlib import Path
5
6
  from typing import Literal, Optional
@@ -7,17 +8,7 @@ from typing import Literal, Optional
7
8
  import numpy as np
8
9
  import pydicom
9
10
 
10
- from simind_python_connector.utils.backend_access import BACKEND_AVAILABLE, BACKENDS
11
-
12
- # Conditional import for SIRF types
13
- from simind_python_connector.utils.import_helpers import get_sirf_types
14
- from simind_python_connector.utils.io_utils import temporary_directory
15
-
16
-
17
- _, AcquisitionData, SIRF_AVAILABLE = get_sirf_types()
18
-
19
- # Unpack interfaces needed by builder
20
- create_acquisition_data = BACKENDS.factories.create_acquisition_data
11
+ from simind_python_connector.builders import _native
21
12
 
22
13
 
23
14
  class STIRSPECTAcquisitionDataBuilder:
@@ -92,29 +83,31 @@ class STIRSPECTAcquisitionDataBuilder:
92
83
  num_projections = int(self.header.get("!number of projections", 1))
93
84
  if self.pixel_array is None:
94
85
  self.pixel_array = np.zeros(
95
- (1, matrix_size_1, num_projections, matrix_size_2), dtype=np.float32
86
+ (1, matrix_size_2, num_projections, matrix_size_1), dtype=np.float32
96
87
  )
97
88
  else:
98
89
  self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
99
90
 
100
91
  expected_shape = (
101
92
  1,
102
- matrix_size_1,
103
- num_projections,
104
93
  matrix_size_2,
94
+ num_projections,
95
+ matrix_size_1,
105
96
  )
106
97
  if self.pixel_array.shape != expected_shape:
107
98
  raise ValueError(
108
99
  f"pixel array shape {self.pixel_array.shape} does not match "
109
100
  f"header dimensions {expected_shape} "
110
- "(tof, bin, view, axial)"
101
+ "(tof, axial, view, bin)"
111
102
  )
112
103
 
113
- def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
104
+ def _write(base_path: Path):
114
105
  header_path = base_path.with_suffix(".hs")
115
106
  raw_file_path = base_path.with_suffix(".s")
116
107
 
117
108
  self.header["name of data file"] = raw_file_path.name
109
+ # Keys added since an earlier build must come before the terminator.
110
+ self.header.pop("!END OF INTERFILE", None)
118
111
  self.header["!END OF INTERFILE"] = ""
119
112
 
120
113
  with open(header_path, "w") as f:
@@ -123,70 +116,17 @@ class STIRSPECTAcquisitionDataBuilder:
123
116
 
124
117
  self.pixel_array.tofile(raw_file_path)
125
118
 
126
- acqdata = self._load_acquisition(str(header_path))
127
-
128
- acqdata = acqdata.clone()
129
- acqdata.fill(self.pixel_array)
130
- acqdata.write(str(header_path))
131
-
132
- if cleanup:
133
- header_path.unlink(missing_ok=True)
134
- raw_file_path.unlink(missing_ok=True)
135
-
136
- return self._unwrap_native(acqdata)
137
-
138
- if output_path is None:
139
- with temporary_directory() as tmp_dir:
140
- return _write(Path(tmp_dir) / "spect_acq", cleanup=False)
141
-
142
- return _write(Path(output_path), cleanup=False)
143
-
144
- def _load_acquisition(self, header_path: str):
145
- """Load acquisition data with optional explicit backend selection."""
146
- if BACKEND_AVAILABLE and create_acquisition_data is not None:
147
- if (
148
- self.backend is not None
149
- and BACKENDS.detection.set_backend is not None
150
- and BACKENDS.detection.get_backend is not None
151
- ):
152
- previous_backend = None
153
- try:
154
- previous_backend = BACKENDS.detection.get_backend()
155
- except Exception:
156
- previous_backend = None
157
- BACKENDS.detection.set_backend(self.backend)
158
- try:
159
- return create_acquisition_data(header_path)
160
- finally:
161
- if (
162
- previous_backend is not None
163
- and previous_backend != self.backend
164
- ):
165
- try:
166
- BACKENDS.detection.set_backend(previous_backend)
167
- except Exception:
168
- pass
169
- return create_acquisition_data(header_path)
170
-
171
- if self.backend == "stir":
172
- raise ImportError(
173
- "Requested STIR backend for acquisition loading, but backend wrappers "
174
- "are unavailable."
119
+ backend = _native.resolve_backend(self.backend)
120
+ acqdata = _native.load_acquisition(str(header_path), backend)
121
+ return _native.refill_acquisition(
122
+ acqdata, self.pixel_array, str(header_path), backend
175
123
  )
176
124
 
177
- if SIRF_AVAILABLE:
178
- return AcquisitionData(header_path)
179
-
180
- raise ImportError(
181
- "Unable to load acquisition data: neither SIRF nor STIR Python "
182
- "backends are available."
183
- )
125
+ if output_path is None:
126
+ with tempfile.TemporaryDirectory() as tmp_dir:
127
+ return _write(Path(tmp_dir) / "spect_acq")
184
128
 
185
- @staticmethod
186
- def _unwrap_native(obj):
187
- """Return native backend object when a wrapper is provided."""
188
- native = getattr(obj, "native_object", None)
189
- return native if native is not None else obj
129
+ return _write(Path(output_path))
190
130
 
191
131
  def build_multi_energy(self, output_path_base="temp", multiple_data_files=True):
192
132
  """
@@ -221,35 +161,65 @@ class STIRSPECTAcquisitionDataBuilder:
221
161
  f"pixel array shape {self.pixel_array.shape} does not match "
222
162
  f"declared {total_projections} projections"
223
163
  )
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})"
164
+ window_vector = getattr(self, "energy_window_vector", None)
165
+ if window_vector is not None:
166
+ window_vector = np.asarray(window_vector)
167
+ if len(window_vector) != total_projections:
168
+ raise ValueError(
169
+ f"EnergyWindowVector has {len(window_vector)} entries for "
170
+ f"{total_projections} frames"
171
+ )
172
+ if not np.issubdtype(window_vector.dtype, np.integer) or not np.all(
173
+ (window_vector >= 1) & (window_vector <= num_windows)
174
+ ):
175
+ raise ValueError(
176
+ f"EnergyWindowVector entries must be window numbers "
177
+ f"1..{num_windows}"
178
+ )
179
+ frames_per_window = [
180
+ np.flatnonzero(window_vector == idx + 1) for idx in range(num_windows)
181
+ ]
182
+ if any(frames.size == 0 for frames in frames_per_window):
183
+ raise ValueError(
184
+ "EnergyWindowVector selects no frames for at least one "
185
+ "energy window"
186
+ )
187
+ else:
188
+ if total_projections % num_windows != 0:
189
+ raise ValueError(
190
+ f"Number of projections ({total_projections}) must be divisible "
191
+ f"by the number of energy windows ({num_windows})"
192
+ )
193
+ # Without a window vector, assume one contiguous block per window.
194
+ frames_per_window = np.array_split(
195
+ np.arange(total_projections), num_windows
228
196
  )
229
- projections_per_window = total_projections // num_windows
230
197
 
231
- # number of projections needs dividing by number of energy windows
232
- self.header["!number of projections"] = str(projections_per_window)
233
-
234
- # split pixel_array into energy windows along 3rd axis
235
198
  original_pixel_array = self.pixel_array
199
+ original_header = dict(self.header)
200
+ duration = original_header.get("!image duration (sec)[1]")
236
201
  try:
237
- pixel_array_list = np.array_split(original_pixel_array, num_windows, axis=2)
238
-
239
202
  acqdata_list = []
240
203
  for idx, ew in enumerate(self.energy_windows):
241
- # Update header for this energy window.
204
+ frames = frames_per_window[idx]
205
+ self.header["!number of projections"] = str(len(frames))
206
+ self.header["!number of energy windows"] = "1"
242
207
  self.header["energy window lower level[1]"] = ew["lower"]
243
208
  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]
209
+ if duration is not None:
210
+ # Windows are acquired simultaneously, so each output
211
+ # covers only its own frames.
212
+ self.header["!image duration (sec)[1]"] = (
213
+ f"{float(duration) * len(frames) / total_projections:g}"
214
+ )
215
+ output_path = output_path_base + f"_ew{idx + 1}"
216
+ self.pixel_array = original_pixel_array[:, :, frames, :]
247
217
  acqdata = self.build(output_path=output_path)
248
218
  acqdata_list.append(acqdata)
249
219
  return acqdata_list
250
220
  finally:
251
221
  self.pixel_array = original_pixel_array
252
- self.header["!number of projections"] = str(total_projections)
222
+ self.header = original_header
253
223
 
254
224
  def update_header_from_dicom(self, dicom_filepath):
255
225
  """
@@ -273,16 +243,18 @@ class STIRSPECTAcquisitionDataBuilder:
273
243
  "Modality not found in DICOM. Retaining default '!imaging modality'."
274
244
  )
275
245
  try:
276
- self.header["!matrix size [1]"] = str(ds.Rows)
277
- self.header["!matrix size [2]"] = str(ds.Columns)
246
+ self.header["!matrix size [1]"] = str(ds.Columns)
247
+ self.header["!matrix size [2]"] = str(ds.Rows)
278
248
  except AttributeError:
279
249
  warnings.warn(
280
250
  "Rows/Columns not found in DICOM. Retaining default matrix sizes."
281
251
  )
282
252
  try:
283
253
  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])
254
+ # DICOM PixelSpacing is [row spacing, column spacing]; Interfile
255
+ # [1] is the column/bin axis and [2] the row/axial axis.
256
+ self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[1])
257
+ self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[0])
286
258
  except AttributeError:
287
259
  warnings.warn(
288
260
  "PixelSpacing not found in DICOM. Retaining default scaling factors."
@@ -299,7 +271,29 @@ class STIRSPECTAcquisitionDataBuilder:
299
271
  except Exception as e:
300
272
  warnings.warn("Error accessing NumberOfFrames from DICOM: " + str(e))
301
273
 
302
- # Extract energy window information
274
+ # A malformed EnergyWindowVector must propagate: the broad handler around
275
+ # the sequence parsing below would otherwise swallow it and leave an
276
+ # empty energy_windows, silently falling back to the ordinary build.
277
+ vector = ds.get("EnergyWindowVector")
278
+ if vector is None:
279
+ self.energy_window_vector = None
280
+ else:
281
+ self.energy_window_vector = []
282
+ for value in np.atleast_1d(vector):
283
+ try:
284
+ number = float(value)
285
+ except (TypeError, ValueError) as exc:
286
+ raise ValueError(
287
+ f"Invalid EnergyWindowVector entry {value!r}"
288
+ ) from exc
289
+ if not number.is_integer():
290
+ raise ValueError(
291
+ "EnergyWindowVector entries must be whole window "
292
+ f"numbers, got {value!r}"
293
+ )
294
+ self.energy_window_vector.append(int(number))
295
+
296
+ # Extract energy window information
303
297
  try:
304
298
  ewi_seq_tag = (0x0054, 0x0012)
305
299
  self.energy_windows = [] # list to hold each energy window's info
@@ -348,10 +342,12 @@ class STIRSPECTAcquisitionDataBuilder:
348
342
 
349
343
  # Extract basic rotation parameters
350
344
  if "StartAngle" in rot_item:
351
- self.header["start angle"] = str(rot_item.StartAngle)
345
+ self.header["start angle"] = str(
346
+ round(float(rot_item.StartAngle))
347
+ )
352
348
  elif (0x0054, 0x0200) in rot_item:
353
349
  self.header["start angle"] = str(
354
- rot_item[(0x0054, 0x0200)].value
350
+ round(float(rot_item[(0x0054, 0x0200)].value))
355
351
  )
356
352
 
357
353
  if (0x0018, 0x1242) in rot_item:
@@ -568,7 +564,4 @@ class STIRSPECTAcquisitionDataBuilder:
568
564
  raise ValueError(
569
565
  f"pixel data must be 2D or 3D, got {raw_pixel_array.ndim}D"
570
566
  )
571
- self.pixel_array = np.transpose(raw_pixel_array, (2, 0, 1))
572
- # rotate the image by 90 degrees cW in axis 1
573
- self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
574
- self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
567
+ self.pixel_array = np.transpose(raw_pixel_array, (1, 0, 2))[np.newaxis]
@@ -1,16 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import tempfile
3
4
  from pathlib import Path
4
5
  from typing import Literal, Optional
5
6
 
6
7
  import numpy as np
7
8
 
8
- from simind_python_connector.utils.backend_access import BACKEND_AVAILABLE, BACKENDS
9
- from simind_python_connector.utils.import_helpers import get_sirf_types
10
- from simind_python_connector.utils.io_utils import temporary_directory
11
-
12
-
13
- ImageData, _, SIRF_AVAILABLE = get_sirf_types()
9
+ from simind_python_connector.builders import _native
14
10
 
15
11
 
16
12
  class STIRSPECTImageDataBuilder:
@@ -100,17 +96,17 @@ class STIRSPECTImageDataBuilder:
100
96
 
101
97
  def build(self, output_path: Optional[str | Path] = None):
102
98
  """
103
- Build and return the STIR ImageData object.
104
-
105
- Returns:
106
- ImageData: The constructed STIR ImageData object.
99
+ Build and return the image as a native SIRF or STIR object.
107
100
  """
108
101
  data = self._resolve_data_array()
109
102
 
110
- def _write(base_path: Path, cleanup: bool):
103
+ def _write(base_path: Path):
111
104
  header_path = base_path.with_suffix(".hv")
112
105
  raw_path = base_path.with_suffix(".v")
113
106
  self.header["!name of data file"] = raw_path.name
107
+ # Keys added after construction must come before the terminator.
108
+ self.header.pop("!END OF INTERFILE", None)
109
+ self.header["!END OF INTERFILE"] = ""
114
110
 
115
111
  with open(header_path, "w") as f:
116
112
  line = 0
@@ -123,67 +119,13 @@ class STIRSPECTImageDataBuilder:
123
119
  f.write(temp_str)
124
120
 
125
121
  data.tofile(raw_path)
126
-
127
- image_data = self._load_image(str(header_path))
128
-
129
- if cleanup:
130
- header_path.unlink(missing_ok=True)
131
- raw_path.unlink(missing_ok=True)
132
- return self._unwrap_native(image_data)
122
+ return _native.load_image(str(header_path), self.backend)
133
123
 
134
124
  if output_path is None:
135
- with temporary_directory() as tmp_dir:
136
- return _write(Path(tmp_dir) / "spect_image", cleanup=False)
137
-
138
- return _write(Path(output_path), cleanup=False)
139
-
140
- def _load_image(self, header_path: str):
141
- """Load an image with optional explicit backend selection."""
142
- if BACKEND_AVAILABLE and BACKENDS.factories.create_image_data is not None:
143
- if (
144
- self.backend is not None
145
- and BACKENDS.detection.set_backend is not None
146
- and BACKENDS.detection.get_backend is not None
147
- ):
148
- previous_backend = None
149
- try:
150
- previous_backend = BACKENDS.detection.get_backend()
151
- except Exception:
152
- previous_backend = None
153
- BACKENDS.detection.set_backend(self.backend)
154
- try:
155
- return BACKENDS.factories.create_image_data(header_path)
156
- finally:
157
- if (
158
- previous_backend is not None
159
- and previous_backend != self.backend
160
- ):
161
- try:
162
- BACKENDS.detection.set_backend(previous_backend)
163
- except Exception:
164
- pass
165
-
166
- return BACKENDS.factories.create_image_data(header_path)
167
-
168
- if self.backend == "stir":
169
- raise ImportError(
170
- "Requested STIR backend for image loading, "
171
- "but backend wrappers are unavailable."
172
- )
173
-
174
- if SIRF_AVAILABLE:
175
- return ImageData(header_path)
176
-
177
- raise ImportError(
178
- "Unable to load image data: neither SIRF nor STIR Python "
179
- "backends are available."
180
- )
125
+ with tempfile.TemporaryDirectory() as tmp_dir:
126
+ return _write(Path(tmp_dir) / "spect_image")
181
127
 
182
- @staticmethod
183
- def _unwrap_native(obj):
184
- """Return native backend object when a wrapper is provided."""
185
- native = getattr(obj, "native_object", None)
186
- return native if native is not None else obj
128
+ return _write(Path(output_path))
187
129
 
188
130
  @staticmethod
189
131
  def create_spect_uniform_image_from_sinogram(sinogram, origin=None):
@@ -202,10 +144,12 @@ class STIRSPECTImageDataBuilder:
202
144
  ImageData: A uniform SPECT image initialized with the computed dimensions
203
145
  and voxel sizes.
204
146
  """
205
- if not SIRF_AVAILABLE:
147
+ try:
148
+ import sirf.STIR
149
+ except ImportError as exc:
206
150
  raise ImportError(
207
151
  "create_spect_uniform_image_from_sinogram requires the SIRF backend."
208
- )
152
+ ) from exc
209
153
 
210
154
  # Create a uniform image from the sinogram and adjust z-voxel size.
211
155
  image = sinogram.create_uniform_image(1)
@@ -224,6 +168,6 @@ class STIRSPECTImageDataBuilder:
224
168
  origin = (0, 0, 0)
225
169
 
226
170
  # Initialize a new image with computed dimensions, voxel sizes, and origin.
227
- new_image = ImageData()
171
+ new_image = sirf.STIR.ImageData()
228
172
  new_image.initialise(tuple(dims), tuple(voxel_size), tuple(origin))
229
173
  return new_image
@@ -4,23 +4,29 @@ Packaged scanner presets (YAML or SMC).
4
4
  Example
5
5
  -------
6
6
  >>> from simind_python_connector.configs import get
7
- >>> cfg = get("AnyScan.yaml") # Path object
7
+ >>> cfg = get("AnyScan.yaml") # Traversable
8
8
 
9
9
  >>> from simind_python_connector.core import SimulationConfig
10
10
  >>> sim_cfg = SimulationConfig(cfg) # load file
11
11
  """
12
12
 
13
13
  from importlib import resources as _res
14
- from pathlib import Path
15
14
  from typing import Union
16
15
 
17
16
 
17
+ try: # Python 3.11 moved Traversable into importlib.resources.abc
18
+ from importlib.resources.abc import Traversable
19
+ except ImportError: # pragma: no cover - Python 3.10
20
+ from importlib.abc import Traversable
21
+
22
+
18
23
  __all__ = ["get", "list"]
19
24
 
20
25
 
21
- def get(name: str) -> Path:
26
+ def get(name: str) -> Traversable:
22
27
  """
23
- Return a **Path** to a bundled config file.
28
+ Return the bundled config file as an importlib.resources Traversable (a Path for
29
+ normal installs).
24
30
 
25
31
  Parameters
26
32
  ----------
@@ -8,7 +8,6 @@ import importlib
8
8
 
9
9
  from .base import BaseConnector
10
10
  from .python_connector import (
11
- NumpyConnector,
12
11
  ProjectionResult,
13
12
  RuntimeOperator,
14
13
  SimindPythonConnector,
@@ -33,7 +32,6 @@ def __getattr__(name):
33
32
 
34
33
  __all__ = [
35
34
  "BaseConnector",
36
- "NumpyConnector",
37
35
  "PyTomographySimindAdaptor",
38
36
  "ProjectionResult",
39
37
  "RuntimeOperator",