simind-python-connector 2.0.0__py3-none-any.whl → 2.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simind_python_connector/__init__.py +3 -3
- simind_python_connector/builders/_native.py +92 -0
- simind_python_connector/builders/acquisition_builder.py +97 -104
- simind_python_connector/builders/image_builder.py +16 -72
- simind_python_connector/configs/__init__.py +10 -4
- simind_python_connector/connectors/__init__.py +0 -2
- simind_python_connector/connectors/_native_adaptor.py +471 -0
- simind_python_connector/connectors/python_connector.py +1036 -96
- simind_python_connector/connectors/pytomography_adaptor.py +94 -28
- simind_python_connector/connectors/sirf_adaptor.py +21 -145
- simind_python_connector/connectors/stir_adaptor.py +23 -145
- simind_python_connector/converters/__init__.py +2 -2
- simind_python_connector/converters/attenuation.py +48 -51
- simind_python_connector/converters/simind_to_stir.py +97 -395
- simind_python_connector/core/config.py +432 -123
- simind_python_connector/core/executor.py +289 -56
- simind_python_connector/core/types.py +16 -25
- simind_python_connector/data/__init__.py +3 -3
- 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 +192 -0
- simind_python_connector/recon/diagnostics.py +41 -0
- simind_python_connector/recon/osem.py +63 -0
- simind_python_connector/recon/projector.py +141 -0
- simind_python_connector/recon/updates.py +88 -0
- simind_python_connector/utils/__init__.py +34 -85
- simind_python_connector/utils/interfile.py +690 -0
- simind_python_connector/utils/projection_metrics.py +153 -0
- simind_python_connector/utils/simind_utils.py +23 -28
- simind_python_connector-2.0.2.dist-info/METADATA +130 -0
- simind_python_connector-2.0.2.dist-info/RECORD +48 -0
- {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.2.dist-info}/WHEEL +1 -1
- simind_python_connector/backends/__init__.py +0 -487
- simind_python_connector/backends/base.py +0 -387
- simind_python_connector/backends/sirf_backend.py +0 -309
- simind_python_connector/backends/stir_backend.py +0 -395
- simind_python_connector/connectors/_spacing.py +0 -75
- simind_python_connector/converters/dicom_to_stir.py +0 -3
- simind_python_connector/utils/backend_access.py +0 -121
- simind_python_connector/utils/import_helpers.py +0 -78
- simind_python_connector/utils/interfile_numpy.py +0 -205
- simind_python_connector/utils/interfile_parser.py +0 -177
- 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 -490
- simind_python_connector-2.0.0.dist-info/METADATA +0 -283
- simind_python_connector-2.0.0.dist-info/RECORD +0 -47
- {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.2.dist-info}/licenses/LICENSE +0 -0
- {simind_python_connector-2.0.0.dist-info → simind_python_connector-2.0.2.dist-info}/top_level.txt +0 -0
|
@@ -14,7 +14,7 @@ for _dist_name in ("simind-python-connector", __name__):
|
|
|
14
14
|
except _meta.PackageNotFoundError:
|
|
15
15
|
continue
|
|
16
16
|
else: # editable / source checkout
|
|
17
|
-
__version__ = "2.0.
|
|
17
|
+
__version__ = "2.0.2"
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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.
|
|
277
|
-
self.header["!matrix size [2]"] = str(ds.
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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(
|
|
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, (
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
136
|
-
return _write(Path(tmp_dir) / "spect_image"
|
|
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
|
-
|
|
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
|
-
|
|
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") #
|
|
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) ->
|
|
26
|
+
def get(name: str) -> Traversable:
|
|
22
27
|
"""
|
|
23
|
-
Return
|
|
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",
|