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.
- simind_python_connector/__init__.py +13 -4
- simind_python_connector/builders/__init__.py +2 -2
- simind_python_connector/builders/_native.py +92 -0
- simind_python_connector/builders/acquisition_builder.py +76 -93
- simind_python_connector/builders/image_builder.py +17 -76
- simind_python_connector/configs/__init__.py +12 -5
- simind_python_connector/connectors/__init__.py +2 -4
- simind_python_connector/connectors/_native_adaptor.py +471 -0
- simind_python_connector/connectors/base.py +2 -35
- simind_python_connector/connectors/python_connector.py +887 -325
- simind_python_connector/connectors/pytomography_adaptor.py +89 -49
- simind_python_connector/connectors/sirf_adaptor.py +20 -185
- simind_python_connector/connectors/stir_adaptor.py +22 -188
- simind_python_connector/converters/__init__.py +2 -2
- simind_python_connector/converters/attenuation.py +43 -35
- simind_python_connector/converters/simind_to_stir.py +92 -431
- simind_python_connector/core/config.py +120 -156
- simind_python_connector/core/executor.py +168 -183
- simind_python_connector/core/types.py +3 -25
- simind_python_connector/data/__init__.py +3 -5
- simind_python_connector/data/bone.atn +178 -187
- simind_python_connector/data/h2o.atn +84 -92
- simind_python_connector/datasets.py +67 -0
- simind_python_connector/normalisation.py +90 -0
- simind_python_connector/phantoms.py +390 -0
- simind_python_connector/recon/__init__.py +43 -0
- simind_python_connector/recon/cil.py +185 -0
- simind_python_connector/recon/corrections.py +150 -0
- simind_python_connector/recon/diagnostics.py +41 -0
- simind_python_connector/recon/osem.py +59 -0
- simind_python_connector/recon/projector.py +141 -0
- simind_python_connector/recon/updates.py +88 -0
- simind_python_connector/utils/__init__.py +40 -81
- simind_python_connector/utils/interfile.py +690 -0
- simind_python_connector/utils/simind_utils.py +8 -36
- simind_python_connector-2.0.1.dist-info/METADATA +149 -0
- simind_python_connector-2.0.1.dist-info/RECORD +47 -0
- {simind_python_connector-1.2.0.dist-info → simind_python_connector-2.0.1.dist-info}/WHEEL +1 -1
- simind_python_connector/_version.py +0 -3
- simind_python_connector/backends/__init__.py +0 -490
- simind_python_connector/backends/base.py +0 -342
- simind_python_connector/backends/sirf_backend.py +0 -305
- simind_python_connector/backends/stir_backend.py +0 -423
- simind_python_connector/connectors/_spacing.py +0 -187
- simind_python_connector/converters/dicom_to_stir.py +0 -3
- simind_python_connector/utils/backend_access.py +0 -121
- simind_python_connector/utils/geometry_phantom.py +0 -42
- simind_python_connector/utils/import_helpers.py +0 -105
- simind_python_connector/utils/interfile_numpy.py +0 -226
- simind_python_connector/utils/interfile_parser.py +0 -185
- simind_python_connector/utils/io_utils.py +0 -14
- simind_python_connector/utils/sirf_stir_utils.py +0 -194
- simind_python_connector/utils/stir_utils.py +0 -484
- simind_python_connector-1.2.0.dist-info/METADATA +0 -301
- simind_python_connector-1.2.0.dist-info/RECORD +0 -49
- {simind_python_connector-1.2.0.dist-info → simind_python_connector-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {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
|
-
|
|
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
|
|
12
|
+
return getattr(mod, "STIRSPECTAcquisitionDataBuilder")
|
|
13
13
|
elif name == "STIRSPECTImageDataBuilder":
|
|
14
14
|
mod = importlib.import_module(".image_builder", __name__)
|
|
15
|
-
return mod
|
|
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.
|
|
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"]
|
|
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
|
|
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
|
|
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
|
-
|
|
124
|
-
f"{key} := {value}\n"
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
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("
|
|
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, (
|
|
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.
|
|
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"]
|
|
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
|
|
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
|
|
97
|
+
def build(self, output_path: Optional[str | Path] = None):
|
|
102
98
|
"""
|
|
103
|
-
Build and return the STIR
|
|
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
|
|
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
|
|
139
|
-
return _write(Path(tmp_dir) / "spect_image"
|
|
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
|
-
|
|
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
|
-
|
|
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") #
|
|
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
|
|
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) ->
|
|
26
|
+
def get(name: str) -> Traversable:
|
|
21
27
|
"""
|
|
22
|
-
Return
|
|
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
|
|
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
|
]
|