simind-python-connector 1.2.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 (57) hide show
  1. simind_python_connector/__init__.py +13 -4
  2. simind_python_connector/builders/__init__.py +2 -2
  3. simind_python_connector/builders/_native.py +92 -0
  4. simind_python_connector/builders/acquisition_builder.py +76 -93
  5. simind_python_connector/builders/image_builder.py +17 -76
  6. simind_python_connector/configs/__init__.py +12 -5
  7. simind_python_connector/connectors/__init__.py +2 -4
  8. simind_python_connector/connectors/_native_adaptor.py +471 -0
  9. simind_python_connector/connectors/base.py +2 -35
  10. simind_python_connector/connectors/python_connector.py +887 -325
  11. simind_python_connector/connectors/pytomography_adaptor.py +89 -49
  12. simind_python_connector/connectors/sirf_adaptor.py +20 -185
  13. simind_python_connector/connectors/stir_adaptor.py +22 -188
  14. simind_python_connector/converters/__init__.py +2 -2
  15. simind_python_connector/converters/attenuation.py +43 -35
  16. simind_python_connector/converters/simind_to_stir.py +92 -431
  17. simind_python_connector/core/config.py +120 -156
  18. simind_python_connector/core/executor.py +168 -183
  19. simind_python_connector/core/types.py +3 -25
  20. simind_python_connector/data/__init__.py +3 -5
  21. simind_python_connector/data/bone.atn +178 -187
  22. simind_python_connector/data/h2o.atn +84 -92
  23. simind_python_connector/datasets.py +67 -0
  24. simind_python_connector/normalisation.py +90 -0
  25. simind_python_connector/phantoms.py +390 -0
  26. simind_python_connector/recon/__init__.py +43 -0
  27. simind_python_connector/recon/cil.py +185 -0
  28. simind_python_connector/recon/corrections.py +150 -0
  29. simind_python_connector/recon/diagnostics.py +41 -0
  30. simind_python_connector/recon/osem.py +59 -0
  31. simind_python_connector/recon/projector.py +141 -0
  32. simind_python_connector/recon/updates.py +88 -0
  33. simind_python_connector/utils/__init__.py +40 -81
  34. simind_python_connector/utils/interfile.py +690 -0
  35. simind_python_connector/utils/simind_utils.py +8 -36
  36. simind_python_connector-2.0.1.dist-info/METADATA +149 -0
  37. simind_python_connector-2.0.1.dist-info/RECORD +47 -0
  38. {simind_python_connector-1.2.0.dist-info → simind_python_connector-2.0.1.dist-info}/WHEEL +1 -1
  39. simind_python_connector/_version.py +0 -3
  40. simind_python_connector/backends/__init__.py +0 -490
  41. simind_python_connector/backends/base.py +0 -342
  42. simind_python_connector/backends/sirf_backend.py +0 -305
  43. simind_python_connector/backends/stir_backend.py +0 -423
  44. simind_python_connector/connectors/_spacing.py +0 -187
  45. simind_python_connector/converters/dicom_to_stir.py +0 -3
  46. simind_python_connector/utils/backend_access.py +0 -121
  47. simind_python_connector/utils/geometry_phantom.py +0 -42
  48. simind_python_connector/utils/import_helpers.py +0 -105
  49. simind_python_connector/utils/interfile_numpy.py +0 -226
  50. simind_python_connector/utils/interfile_parser.py +0 -185
  51. simind_python_connector/utils/io_utils.py +0 -14
  52. simind_python_connector/utils/sirf_stir_utils.py +0 -194
  53. simind_python_connector/utils/stir_utils.py +0 -484
  54. simind_python_connector-1.2.0.dist-info/METADATA +0 -301
  55. simind_python_connector-1.2.0.dist-info/RECORD +0 -49
  56. {simind_python_connector-1.2.0.dist-info → simind_python_connector-2.0.1.dist-info}/licenses/LICENSE +0 -0
  57. {simind_python_connector-1.2.0.dist-info → simind_python_connector-2.0.1.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,18 @@ simind-python-connector connector/adaptor API.
3
3
  """
4
4
 
5
5
  import importlib
6
+ from importlib import metadata as _meta
6
7
  from typing import Any
7
8
 
8
- from ._version import __version__ as __version__
9
+
10
+ for _dist_name in ("simind-python-connector", __name__):
11
+ try: # installed (pip/poetry)
12
+ __version__ = _meta.version(_dist_name)
13
+ break
14
+ except _meta.PackageNotFoundError:
15
+ continue
16
+ else: # editable / source checkout
17
+ __version__ = "2.0.1"
9
18
 
10
19
 
11
20
  def __getattr__(name: str) -> Any:
@@ -16,6 +25,7 @@ def __getattr__(name: str) -> Any:
16
25
  "converters",
17
26
  "core",
18
27
  "data",
28
+ "normalisation",
19
29
  "utils",
20
30
  }:
21
31
  mod = importlib.import_module(f".{name}", __name__)
@@ -28,7 +38,6 @@ def __getattr__(name: str) -> Any:
28
38
  return obj
29
39
  elif name in {
30
40
  "BaseConnector",
31
- "NumpyConnector",
32
41
  "PyTomographySimindAdaptor",
33
42
  "ProjectionResult",
34
43
  "RuntimeOperator",
@@ -45,13 +54,12 @@ def __getattr__(name: str) -> Any:
45
54
 
46
55
  __all__ = [
47
56
  "BaseConnector",
48
- "NumpyConnector",
49
57
  "ProjectionResult",
50
58
  "PyTomographySimindAdaptor",
51
59
  "RuntimeOperator",
60
+ "SirfSimindAdaptor",
52
61
  "SimindPythonConnector",
53
62
  "SimulationConfig",
54
- "SirfSimindAdaptor",
55
63
  "StirSimindAdaptor",
56
64
  "builders",
57
65
  "configs",
@@ -59,5 +67,6 @@ __all__ = [
59
67
  "converters",
60
68
  "core",
61
69
  "data",
70
+ "normalisation",
62
71
  "utils",
63
72
  ]
@@ -9,10 +9,10 @@ import importlib
9
9
  def __getattr__(name):
10
10
  if name == "STIRSPECTAcquisitionDataBuilder":
11
11
  mod = importlib.import_module(".acquisition_builder", __name__)
12
- return mod.STIRSPECTAcquisitionDataBuilder
12
+ return getattr(mod, "STIRSPECTAcquisitionDataBuilder")
13
13
  elif name == "STIRSPECTImageDataBuilder":
14
14
  mod = importlib.import_module(".image_builder", __name__)
15
- return mod.STIRSPECTImageDataBuilder
15
+ return getattr(mod, "STIRSPECTImageDataBuilder")
16
16
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
17
17
 
18
18
 
@@ -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,23 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import tempfile
3
4
  import warnings
4
5
  from pathlib import Path
5
- from typing import Literal
6
+ from typing import Literal, Optional
6
7
 
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:
@@ -32,7 +23,7 @@ class STIRSPECTAcquisitionDataBuilder:
32
23
  def __init__(
33
24
  self,
34
25
  header_overrides=None,
35
- backend: Literal["sirf", "stir"] | None = None,
26
+ backend: Optional[Literal["sirf", "stir"]] = None,
36
27
  ):
37
28
  if backend is not None and backend not in {"sirf", "stir"}:
38
29
  raise ValueError("backend must be one of: 'sirf', 'stir', or None")
@@ -80,7 +71,7 @@ class STIRSPECTAcquisitionDataBuilder:
80
71
  """
81
72
  self.header.update(updates)
82
73
 
83
- def build(self, output_path: str | Path | None = None):
74
+ def build(self, output_path: Optional[str | Path] = None):
84
75
  """
85
76
  Build and return the STIR AcquisitionData object.
86
77
  """
@@ -110,7 +101,7 @@ class STIRSPECTAcquisitionDataBuilder:
110
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
 
@@ -120,76 +111,22 @@ class STIRSPECTAcquisitionDataBuilder:
120
111
  self.header["!END OF INTERFILE"] = ""
121
112
 
122
113
  with open(header_path, "w") as f:
123
- f.writelines(
124
- f"{key} := {value}\n" for key, value in self.header.items()
125
- )
114
+ for key, value in self.header.items():
115
+ f.write(f"{key} := {value}\n")
126
116
 
127
117
  self.pixel_array.tofile(raw_file_path)
128
118
 
129
- acqdata = self._load_acquisition(str(header_path))
130
-
131
- acqdata = acqdata.clone()
132
- acqdata.fill(self.pixel_array)
133
- acqdata.write(str(header_path))
134
-
135
- if cleanup:
136
- header_path.unlink(missing_ok=True)
137
- raw_file_path.unlink(missing_ok=True)
138
-
139
- return self._unwrap_native(acqdata)
140
-
141
- if output_path is None:
142
- with temporary_directory() as tmp_dir:
143
- return _write(Path(tmp_dir) / "spect_acq", cleanup=False)
144
-
145
- return _write(Path(output_path), cleanup=False)
146
-
147
- def _load_acquisition(self, header_path: str):
148
- """Load acquisition data with optional explicit backend selection."""
149
- if BACKEND_AVAILABLE and create_acquisition_data is not None:
150
- if (
151
- self.backend is not None
152
- and BACKENDS.detection.set_backend is not None
153
- and BACKENDS.detection.get_backend is not None
154
- ):
155
- previous_backend = None
156
- try:
157
- previous_backend = BACKENDS.detection.get_backend()
158
- except Exception:
159
- previous_backend = None
160
- BACKENDS.detection.set_backend(self.backend)
161
- try:
162
- return create_acquisition_data(header_path)
163
- finally:
164
- if (
165
- previous_backend is not None
166
- and previous_backend != self.backend
167
- ):
168
- try:
169
- BACKENDS.detection.set_backend(previous_backend)
170
- except Exception:
171
- pass
172
- return create_acquisition_data(header_path)
173
-
174
- if self.backend == "stir":
175
- raise ImportError(
176
- "Requested STIR backend for acquisition loading, but backend wrappers "
177
- "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
178
123
  )
179
124
 
180
- if SIRF_AVAILABLE:
181
- return AcquisitionData(header_path)
182
-
183
- raise ImportError(
184
- "Unable to load acquisition data: neither SIRF nor STIR Python "
185
- "backends are available."
186
- )
125
+ if output_path is None:
126
+ with tempfile.TemporaryDirectory() as tmp_dir:
127
+ return _write(Path(tmp_dir) / "spect_acq")
187
128
 
188
- @staticmethod
189
- def _unwrap_native(obj):
190
- """Return native backend object when a wrapper is provided."""
191
- native = getattr(obj, "native_object", None)
192
- return native if native is not None else obj
129
+ return _write(Path(output_path))
193
130
 
194
131
  def build_multi_energy(self, output_path_base="temp", multiple_data_files=True):
195
132
  """
@@ -224,7 +161,6 @@ class STIRSPECTAcquisitionDataBuilder:
224
161
  f"pixel array shape {self.pixel_array.shape} does not match "
225
162
  f"declared {total_projections} projections"
226
163
  )
227
-
228
164
  window_vector = getattr(self, "energy_window_vector", None)
229
165
  if window_vector is not None:
230
166
  window_vector = np.asarray(window_vector)
@@ -233,9 +169,21 @@ class STIRSPECTAcquisitionDataBuilder:
233
169
  f"EnergyWindowVector has {len(window_vector)} entries for "
234
170
  f"{total_projections} frames"
235
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
+ )
236
179
  frames_per_window = [
237
180
  np.flatnonzero(window_vector == idx + 1) for idx in range(num_windows)
238
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
+ )
239
187
  else:
240
188
  if total_projections % num_windows != 0:
241
189
  raise ValueError(
@@ -323,14 +271,32 @@ class STIRSPECTAcquisitionDataBuilder:
323
271
  except Exception as e:
324
272
  warnings.warn("Error accessing NumberOfFrames from DICOM: " + str(e))
325
273
 
326
- # 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
327
297
  try:
328
298
  ewi_seq_tag = (0x0054, 0x0012)
329
299
  self.energy_windows = [] # list to hold each energy window's info
330
- vector = ds.get("EnergyWindowVector")
331
- self.energy_window_vector = (
332
- [int(v) for v in np.atleast_1d(vector)] if vector is not None else None
333
- )
334
300
  if ewi_seq_tag in ds:
335
301
  ewi_seq = ds[ewi_seq_tag].value
336
302
  for ewi_item in ewi_seq:
@@ -376,10 +342,12 @@ class STIRSPECTAcquisitionDataBuilder:
376
342
 
377
343
  # Extract basic rotation parameters
378
344
  if "StartAngle" in rot_item:
379
- self.header["start angle"] = str(rot_item.StartAngle)
345
+ self.header["start angle"] = str(
346
+ round(float(rot_item.StartAngle))
347
+ )
380
348
  elif (0x0054, 0x0200) in rot_item:
381
349
  self.header["start angle"] = str(
382
- rot_item[(0x0054, 0x0200)].value
350
+ round(float(rot_item[(0x0054, 0x0200)].value))
383
351
  )
384
352
 
385
353
  if (0x0018, 0x1242) in rot_item:
@@ -454,9 +422,17 @@ class STIRSPECTAcquisitionDataBuilder:
454
422
  self.header.pop("Radius", None)
455
423
 
456
424
  radial_processed = True
425
+ print(
426
+ f"Debug: Direct radii processed - all same: "
427
+ f"{len(set(rp_list)) == 1}, values: {rp_list[:5]}..."
428
+ )
429
+
457
430
  else:
458
431
  # Single value
459
432
  mean_radial_position = float(rp_val)
433
+ print(
434
+ f"Debug: Single radial position: {mean_radial_position}"
435
+ )
460
436
  else:
461
437
  warnings.warn(
462
438
  "Mean radial position not found in Rotation Information "
@@ -467,6 +443,11 @@ class STIRSPECTAcquisitionDataBuilder:
467
443
  # Only process tomo view offset if we haven't already processed
468
444
  # direct radii
469
445
  if not radial_processed:
446
+ print(
447
+ "Debug: Processing tomo view offset since direct radii "
448
+ "not processed"
449
+ )
450
+
470
451
  # Process Detector Information Sequence & Tomo View Offset
471
452
  det_info_seq_tag = (0x0055, 0x1022)
472
453
  tomo_view_offset_tag = (0x0013, 0x101E)
@@ -520,6 +501,11 @@ class STIRSPECTAcquisitionDataBuilder:
520
501
  else:
521
502
  self.header["Radius"] = str(mean_radial_position)
522
503
  self.header["orbit"] = "circular"
504
+ else:
505
+ print(
506
+ "Debug: Skipping tomo view offset processing - direct "
507
+ "radii already processed"
508
+ )
523
509
 
524
510
  else:
525
511
  warnings.warn("Rotation Information Sequence is empty.")
@@ -534,11 +520,11 @@ class STIRSPECTAcquisitionDataBuilder:
534
520
  try:
535
521
  self.header[";#acquisition date"] = ds.AcquisitionDate
536
522
  except AttributeError:
537
- warnings.warn("AcquisitionDate not found in DICOM.")
523
+ warnings.warn("StudyDate not found in DICOM.")
538
524
  try:
539
525
  self.header[";#acquisition time"] = ds.AcquisitionTime
540
526
  except AttributeError:
541
- warnings.warn("AcquisitionTime not found in DICOM.")
527
+ warnings.warn("StudyTime not found in DICOM.")
542
528
  try:
543
529
  acq_num = ds.get("AcquisitionNumber", None)
544
530
  if acq_num is not None:
@@ -578,7 +564,4 @@ class STIRSPECTAcquisitionDataBuilder:
578
564
  raise ValueError(
579
565
  f"pixel data must be 2D or 3D, got {raw_pixel_array.ndim}D"
580
566
  )
581
- self.pixel_array = np.transpose(raw_pixel_array, (2, 0, 1))
582
- # rotate the image by 90 degrees cW in axis 1
583
- self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
584
- 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
- from typing import Literal
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:
@@ -24,7 +20,7 @@ class STIRSPECTImageDataBuilder:
24
20
  def __init__(
25
21
  self,
26
22
  header_overrides=None,
27
- backend: Literal["sirf", "stir"] | None = None,
23
+ backend: Optional[Literal["sirf", "stir"]] = None,
28
24
  ):
29
25
  if backend is not None and backend not in {"sirf", "stir"}:
30
26
  raise ValueError("backend must be one of: 'sirf', 'stir', or None")
@@ -58,7 +54,7 @@ class STIRSPECTImageDataBuilder:
58
54
  "!END OF INTERFILE": "",
59
55
  }
60
56
 
61
- self.pixel_array: np.ndarray | None = None
57
+ self.pixel_array: Optional[np.ndarray] = None
62
58
 
63
59
  # Override defaults if header_overrides is provided.
64
60
  if header_overrides is not None:
@@ -98,16 +94,13 @@ class STIRSPECTImageDataBuilder:
98
94
  )
99
95
  return data
100
96
 
101
- def build(self, output_path: str | Path | None = None):
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
@@ -126,67 +119,13 @@ class STIRSPECTImageDataBuilder:
126
119
  f.write(temp_str)
127
120
 
128
121
  data.tofile(raw_path)
129
-
130
- image_data = self._load_image(str(header_path))
131
-
132
- if cleanup:
133
- header_path.unlink(missing_ok=True)
134
- raw_path.unlink(missing_ok=True)
135
- return self._unwrap_native(image_data)
122
+ return _native.load_image(str(header_path), self.backend)
136
123
 
137
124
  if output_path is None:
138
- with temporary_directory() as tmp_dir:
139
- return _write(Path(tmp_dir) / "spect_image", cleanup=False)
140
-
141
- return _write(Path(output_path), cleanup=False)
142
-
143
- def _load_image(self, header_path: str):
144
- """Load an image with optional explicit backend selection."""
145
- if BACKEND_AVAILABLE and BACKENDS.factories.create_image_data is not None:
146
- if (
147
- self.backend is not None
148
- and BACKENDS.detection.set_backend is not None
149
- and BACKENDS.detection.get_backend is not None
150
- ):
151
- previous_backend = None
152
- try:
153
- previous_backend = BACKENDS.detection.get_backend()
154
- except Exception:
155
- previous_backend = None
156
- BACKENDS.detection.set_backend(self.backend)
157
- try:
158
- return BACKENDS.factories.create_image_data(header_path)
159
- finally:
160
- if (
161
- previous_backend is not None
162
- and previous_backend != self.backend
163
- ):
164
- try:
165
- BACKENDS.detection.set_backend(previous_backend)
166
- except Exception:
167
- pass
168
-
169
- return BACKENDS.factories.create_image_data(header_path)
170
-
171
- if self.backend == "stir":
172
- raise ImportError(
173
- "Requested STIR backend for image loading, "
174
- "but backend wrappers are unavailable."
175
- )
176
-
177
- if SIRF_AVAILABLE:
178
- return ImageData(header_path)
179
-
180
- raise ImportError(
181
- "Unable to load image data: neither SIRF nor STIR Python "
182
- "backends are available."
183
- )
125
+ with tempfile.TemporaryDirectory() as tmp_dir:
126
+ return _write(Path(tmp_dir) / "spect_image")
184
127
 
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
128
+ return _write(Path(output_path))
190
129
 
191
130
  @staticmethod
192
131
  def create_spect_uniform_image_from_sinogram(sinogram, origin=None):
@@ -205,10 +144,12 @@ class STIRSPECTImageDataBuilder:
205
144
  ImageData: A uniform SPECT image initialized with the computed dimensions
206
145
  and voxel sizes.
207
146
  """
208
- if not SIRF_AVAILABLE:
147
+ try:
148
+ import sirf.STIR
149
+ except ImportError as exc:
209
150
  raise ImportError(
210
151
  "create_spect_uniform_image_from_sinogram requires the SIRF backend."
211
- )
152
+ ) from exc
212
153
 
213
154
  # Create a uniform image from the sinogram and adjust z-voxel size.
214
155
  image = sinogram.create_uniform_image(1)
@@ -227,6 +168,6 @@ class STIRSPECTImageDataBuilder:
227
168
  origin = (0, 0, 0)
228
169
 
229
170
  # Initialize a new image with computed dimensions, voxel sizes, and origin.
230
- new_image = ImageData()
171
+ new_image = sirf.STIR.ImageData()
231
172
  new_image.initialise(tuple(dims), tuple(voxel_size), tuple(origin))
232
173
  return new_image
@@ -4,22 +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
14
+ from typing import Union
15
+
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
15
21
 
16
22
 
17
23
  __all__ = ["get", "list"]
18
24
 
19
25
 
20
- def get(name: str) -> Path:
26
+ def get(name: str) -> Traversable:
21
27
  """
22
- 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).
23
30
 
24
31
  Parameters
25
32
  ----------
@@ -29,7 +36,7 @@ def get(name: str) -> Path:
29
36
  return _res.files(__package__).joinpath(name)
30
37
 
31
38
 
32
- def list(ext: str | tuple[str, ...] = (".yaml", ".smc")) -> list[str]:
39
+ def list(ext: Union[str, tuple[str, ...]] = (".yaml", ".smc")) -> list[str]:
33
40
  """
34
41
  List files in this package (default: *.yaml / *.smc).
35
42
  """
@@ -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,11 +32,10 @@ def __getattr__(name):
33
32
 
34
33
  __all__ = [
35
34
  "BaseConnector",
36
- "NumpyConnector",
37
- "ProjectionResult",
38
35
  "PyTomographySimindAdaptor",
36
+ "ProjectionResult",
39
37
  "RuntimeOperator",
40
- "SimindPythonConnector",
41
38
  "SirfSimindAdaptor",
39
+ "SimindPythonConnector",
42
40
  "StirSimindAdaptor",
43
41
  ]