simind-python-connector 2.0.0__py3-none-any.whl → 3.0.0__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 (51) hide show
  1. simind_python_connector/__init__.py +3 -3
  2. simind_python_connector/builders/_native.py +86 -0
  3. simind_python_connector/builders/acquisition_builder.py +21 -81
  4. simind_python_connector/builders/image_builder.py +13 -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 +354 -0
  8. simind_python_connector/connectors/python_connector.py +584 -61
  9. simind_python_connector/connectors/pytomography_adaptor.py +26 -23
  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 +42 -48
  14. simind_python_connector/converters/simind_to_stir.py +16 -328
  15. simind_python_connector/core/config.py +65 -25
  16. simind_python_connector/core/executor.py +42 -50
  17. simind_python_connector/core/types.py +5 -24
  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/normalisation.py +90 -0
  22. simind_python_connector/phantoms.py +386 -0
  23. simind_python_connector/recon/__init__.py +41 -0
  24. simind_python_connector/recon/cil.py +185 -0
  25. simind_python_connector/recon/corrections.py +108 -0
  26. simind_python_connector/recon/diagnostics.py +41 -0
  27. simind_python_connector/recon/osem.py +59 -0
  28. simind_python_connector/recon/projector.py +112 -0
  29. simind_python_connector/recon/updates.py +88 -0
  30. simind_python_connector/utils/__init__.py +8 -83
  31. simind_python_connector/utils/interfile.py +659 -0
  32. simind_python_connector/utils/simind_utils.py +4 -32
  33. {simind_python_connector-2.0.0.dist-info → simind_python_connector-3.0.0.dist-info}/METADATA +10 -5
  34. simind_python_connector-3.0.0.dist-info/RECORD +46 -0
  35. simind_python_connector/backends/__init__.py +0 -487
  36. simind_python_connector/backends/base.py +0 -387
  37. simind_python_connector/backends/sirf_backend.py +0 -309
  38. simind_python_connector/backends/stir_backend.py +0 -395
  39. simind_python_connector/connectors/_spacing.py +0 -75
  40. simind_python_connector/converters/dicom_to_stir.py +0 -3
  41. simind_python_connector/utils/backend_access.py +0 -121
  42. simind_python_connector/utils/import_helpers.py +0 -78
  43. simind_python_connector/utils/interfile_numpy.py +0 -205
  44. simind_python_connector/utils/interfile_parser.py +0 -177
  45. simind_python_connector/utils/io_utils.py +0 -14
  46. simind_python_connector/utils/sirf_stir_utils.py +0 -194
  47. simind_python_connector/utils/stir_utils.py +0 -490
  48. simind_python_connector-2.0.0.dist-info/RECORD +0 -47
  49. {simind_python_connector-2.0.0.dist-info → simind_python_connector-3.0.0.dist-info}/WHEEL +0 -0
  50. {simind_python_connector-2.0.0.dist-info → simind_python_connector-3.0.0.dist-info}/licenses/LICENSE +0 -0
  51. {simind_python_connector-2.0.0.dist-info → simind_python_connector-3.0.0.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__ = "3.0.0"
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,86 @@
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
+
15
+ def resolve_backend(backend: Optional[str]) -> str:
16
+ """Return "sirf" or "stir". None picks SIRF if importable, else STIR."""
17
+ if backend is None:
18
+ try:
19
+ importlib.import_module("sirf.STIR")
20
+ return "sirf"
21
+ except ImportError:
22
+ pass
23
+ try:
24
+ importlib.import_module("stir")
25
+ importlib.import_module("stirextra")
26
+ return "stir"
27
+ except ImportError:
28
+ raise ImportError(
29
+ "Neither SIRF nor STIR Python found. Install one of: "
30
+ "SIRF (sirf.STIR) or STIR Python (stir + stirextra)."
31
+ ) from None
32
+ if backend == "sirf":
33
+ try:
34
+ importlib.import_module("sirf.STIR")
35
+ except ImportError as exc:
36
+ raise ImportError("SIRF is not available") from exc
37
+ return "sirf"
38
+ if backend == "stir":
39
+ try:
40
+ importlib.import_module("stir")
41
+ importlib.import_module("stirextra")
42
+ except ImportError as exc:
43
+ raise ImportError("STIR Python is not available") from exc
44
+ return "stir"
45
+ raise ValueError("backend must be one of: 'sirf', 'stir', or None")
46
+
47
+
48
+ def load_image(header_path: str, backend: Optional[str]):
49
+ """Read an image header as a native SIRF or STIR image."""
50
+ if resolve_backend(backend) == "sirf":
51
+ import sirf.STIR
52
+
53
+ return sirf.STIR.ImageData(header_path)
54
+ import stir
55
+
56
+ return stir.FloatVoxelsOnCartesianGrid.read_from_file(header_path)
57
+
58
+
59
+ def load_acquisition(header_path: str, backend: Optional[str]):
60
+ """Read projection data as native SIRF or in-memory STIR data.
61
+
62
+ STIR's ProjData reads lazily from disk, so it is copied into memory;
63
+ the builders may delete the files afterwards.
64
+ """
65
+ if resolve_backend(backend) == "sirf":
66
+ import sirf.STIR
67
+
68
+ return sirf.STIR.AcquisitionData(header_path)
69
+ import stir
70
+
71
+ return stir.ProjDataInMemory(stir.ProjData.read_from_file(header_path))
72
+
73
+
74
+ def refill_acquisition(acquisition, array: np.ndarray, header_path: str, backend: str):
75
+ """Fill a copy of acquisition with array, write it to header_path, return it."""
76
+ if backend == "sirf":
77
+ filled = acquisition.clone()
78
+ filled.fill(array)
79
+ filled.write(header_path)
80
+ return filled
81
+ import stir
82
+
83
+ filled = stir.ProjDataInMemory(acquisition)
84
+ filled.fill(array.flat)
85
+ filled.write_to_file(header_path)
86
+ 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,25 +83,25 @@ 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
 
@@ -123,70 +114,17 @@ class STIRSPECTAcquisitionDataBuilder:
123
114
 
124
115
  self.pixel_array.tofile(raw_file_path)
125
116
 
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."
117
+ backend = _native.resolve_backend(self.backend)
118
+ acqdata = _native.load_acquisition(str(header_path), backend)
119
+ return _native.refill_acquisition(
120
+ acqdata, self.pixel_array, str(header_path), backend
175
121
  )
176
122
 
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
- )
123
+ if output_path is None:
124
+ with tempfile.TemporaryDirectory() as tmp_dir:
125
+ return _write(Path(tmp_dir) / "spect_acq")
184
126
 
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
127
+ return _write(Path(output_path))
190
128
 
191
129
  def build_multi_energy(self, output_path_base="temp", multiple_data_files=True):
192
130
  """
@@ -273,16 +211,18 @@ class STIRSPECTAcquisitionDataBuilder:
273
211
  "Modality not found in DICOM. Retaining default '!imaging modality'."
274
212
  )
275
213
  try:
276
- self.header["!matrix size [1]"] = str(ds.Rows)
277
- self.header["!matrix size [2]"] = str(ds.Columns)
214
+ self.header["!matrix size [1]"] = str(ds.Columns)
215
+ self.header["!matrix size [2]"] = str(ds.Rows)
278
216
  except AttributeError:
279
217
  warnings.warn(
280
218
  "Rows/Columns not found in DICOM. Retaining default matrix sizes."
281
219
  )
282
220
  try:
283
221
  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])
222
+ # DICOM PixelSpacing is [row spacing, column spacing]; Interfile
223
+ # [1] is the column/bin axis and [2] the row/axial axis.
224
+ self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[1])
225
+ self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[0])
286
226
  except AttributeError:
287
227
  warnings.warn(
288
228
  "PixelSpacing not found in DICOM. Retaining default scaling factors."
@@ -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,14 +96,11 @@ 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
@@ -123,67 +116,13 @@ class STIRSPECTImageDataBuilder:
123
116
  f.write(temp_str)
124
117
 
125
118
  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)
119
+ return _native.load_image(str(header_path), self.backend)
133
120
 
134
121
  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
- )
122
+ with tempfile.TemporaryDirectory() as tmp_dir:
123
+ return _write(Path(tmp_dir) / "spect_image")
181
124
 
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
125
+ return _write(Path(output_path))
187
126
 
188
127
  @staticmethod
189
128
  def create_spect_uniform_image_from_sinogram(sinogram, origin=None):
@@ -202,10 +141,12 @@ class STIRSPECTImageDataBuilder:
202
141
  ImageData: A uniform SPECT image initialized with the computed dimensions
203
142
  and voxel sizes.
204
143
  """
205
- if not SIRF_AVAILABLE:
144
+ try:
145
+ import sirf.STIR
146
+ except ImportError as exc:
206
147
  raise ImportError(
207
148
  "create_spect_uniform_image_from_sinogram requires the SIRF backend."
208
- )
149
+ ) from exc
209
150
 
210
151
  # Create a uniform image from the sinogram and adjust z-voxel size.
211
152
  image = sinogram.create_uniform_image(1)
@@ -224,6 +165,6 @@ class STIRSPECTImageDataBuilder:
224
165
  origin = (0, 0, 0)
225
166
 
226
167
  # Initialize a new image with computed dimensions, voxel sizes, and origin.
227
- new_image = ImageData()
168
+ new_image = sirf.STIR.ImageData()
228
169
  new_image.initialise(tuple(dims), tuple(voxel_size), tuple(origin))
229
170
  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",