simind-python-connector 1.0.0__py3-none-any.whl → 2.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.
- simind_python_connector/__init__.py +1 -1
- simind_python_connector/backends/__init__.py +7 -0
- simind_python_connector/builders/acquisition_builder.py +77 -29
- simind_python_connector/builders/image_builder.py +18 -6
- simind_python_connector/connectors/__init__.py +23 -4
- simind_python_connector/connectors/_spacing.py +13 -7
- simind_python_connector/connectors/python_connector.py +114 -22
- simind_python_connector/connectors/pytomography_adaptor.py +5 -0
- simind_python_connector/connectors/sirf_adaptor.py +3 -0
- simind_python_connector/connectors/stir_adaptor.py +3 -0
- simind_python_connector/converters/attenuation.py +3 -1
- simind_python_connector/converters/simind_to_stir.py +12 -10
- simind_python_connector/core/config.py +208 -58
- simind_python_connector/core/executor.py +36 -7
- simind_python_connector/utils/import_helpers.py +4 -0
- simind_python_connector/utils/interfile_numpy.py +18 -8
- simind_python_connector/utils/interfile_parser.py +4 -2
- simind_python_connector/utils/simind_utils.py +50 -11
- simind_python_connector/utils/stir_utils.py +7 -2
- {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/METADATA +12 -3
- {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/RECORD +24 -24
- {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/WHEEL +1 -1
- {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/licenses/LICENSE +0 -0
- {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/top_level.txt +0 -0
|
@@ -19,6 +19,7 @@ Usage:
|
|
|
19
19
|
|
|
20
20
|
import importlib
|
|
21
21
|
import logging
|
|
22
|
+
import os
|
|
22
23
|
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
|
23
24
|
|
|
24
25
|
from .base import AcquisitionDataInterface, ImageDataInterface
|
|
@@ -233,6 +234,9 @@ def create_image_data(
|
|
|
233
234
|
if isinstance(filepath_or_object, ImageDataInterface):
|
|
234
235
|
return filepath_or_object
|
|
235
236
|
|
|
237
|
+
if isinstance(filepath_or_object, os.PathLike):
|
|
238
|
+
filepath_or_object = os.fspath(filepath_or_object)
|
|
239
|
+
|
|
236
240
|
backend_hint = detect_image_backend(filepath_or_object)
|
|
237
241
|
if backend_hint is None:
|
|
238
242
|
backend_hint = detect_backend_from_interface(filepath_or_object)
|
|
@@ -314,6 +318,9 @@ def create_acquisition_data(
|
|
|
314
318
|
if isinstance(filepath_or_object, AcquisitionDataInterface):
|
|
315
319
|
return filepath_or_object
|
|
316
320
|
|
|
321
|
+
if isinstance(filepath_or_object, os.PathLike):
|
|
322
|
+
filepath_or_object = os.fspath(filepath_or_object)
|
|
323
|
+
|
|
317
324
|
backend_hint = detect_acquisition_backend(filepath_or_object)
|
|
318
325
|
if backend_hint is None:
|
|
319
326
|
backend_hint = detect_backend_from_interface(filepath_or_object)
|
|
@@ -97,6 +97,19 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
97
97
|
else:
|
|
98
98
|
self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
|
|
99
99
|
|
|
100
|
+
expected_shape = (
|
|
101
|
+
1,
|
|
102
|
+
matrix_size_1,
|
|
103
|
+
num_projections,
|
|
104
|
+
matrix_size_2,
|
|
105
|
+
)
|
|
106
|
+
if self.pixel_array.shape != expected_shape:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"pixel array shape {self.pixel_array.shape} does not match "
|
|
109
|
+
f"header dimensions {expected_shape} "
|
|
110
|
+
"(tof, bin, view, axial)"
|
|
111
|
+
)
|
|
112
|
+
|
|
100
113
|
def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
|
|
101
114
|
header_path = base_path.with_suffix(".hs")
|
|
102
115
|
raw_file_path = base_path.with_suffix(".s")
|
|
@@ -112,9 +125,8 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
112
125
|
|
|
113
126
|
acqdata = self._load_acquisition(str(header_path))
|
|
114
127
|
|
|
115
|
-
flipped = np.flip(self.pixel_array, axis=-1)
|
|
116
128
|
acqdata = acqdata.clone()
|
|
117
|
-
acqdata.fill(
|
|
129
|
+
acqdata.fill(self.pixel_array)
|
|
118
130
|
acqdata.write(str(header_path))
|
|
119
131
|
|
|
120
132
|
if cleanup:
|
|
@@ -194,27 +206,50 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
194
206
|
acqdata = self.build(output_path=output_path_base)
|
|
195
207
|
return [acqdata]
|
|
196
208
|
|
|
209
|
+
if self.pixel_array is None:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
"build_multi_energy requires pixel_array to be set "
|
|
212
|
+
"(use update_header_from_dicom or set it directly)"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
num_windows = len(self.energy_windows)
|
|
216
|
+
total_projections = int(self.header.get("!number of projections", 1))
|
|
217
|
+
if self.pixel_array.ndim != 4 or (
|
|
218
|
+
self.pixel_array.shape[2] != total_projections
|
|
219
|
+
):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"pixel array shape {self.pixel_array.shape} does not match "
|
|
222
|
+
f"declared {total_projections} projections"
|
|
223
|
+
)
|
|
224
|
+
if total_projections % num_windows != 0:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Number of projections ({total_projections}) must be divisible "
|
|
227
|
+
f"by the number of energy windows ({num_windows})"
|
|
228
|
+
)
|
|
229
|
+
projections_per_window = total_projections // num_windows
|
|
230
|
+
|
|
197
231
|
# number of projections needs dividing by number of energy windows
|
|
198
|
-
|
|
199
|
-
num_projections //= len(self.energy_windows)
|
|
200
|
-
self.header["!number of projections"] = str(num_projections)
|
|
232
|
+
self.header["!number of projections"] = str(projections_per_window)
|
|
201
233
|
|
|
202
234
|
# split pixel_array into energy windows along 3rd axis
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
235
|
+
original_pixel_array = self.pixel_array
|
|
236
|
+
try:
|
|
237
|
+
pixel_array_list = np.array_split(original_pixel_array, num_windows, axis=2)
|
|
238
|
+
|
|
239
|
+
acqdata_list = []
|
|
240
|
+
for idx, ew in enumerate(self.energy_windows):
|
|
241
|
+
# Update header for this energy window.
|
|
242
|
+
self.header["energy window lower level[1]"] = ew["lower"]
|
|
243
|
+
self.header["energy window upper level[1]"] = ew["upper"]
|
|
244
|
+
suffix = f"_ew{idx + 1}"
|
|
245
|
+
output_path = output_path_base + suffix
|
|
246
|
+
self.pixel_array = pixel_array_list[idx]
|
|
247
|
+
acqdata = self.build(output_path=output_path)
|
|
248
|
+
acqdata_list.append(acqdata)
|
|
249
|
+
return acqdata_list
|
|
250
|
+
finally:
|
|
251
|
+
self.pixel_array = original_pixel_array
|
|
252
|
+
self.header["!number of projections"] = str(total_projections)
|
|
218
253
|
|
|
219
254
|
def update_header_from_dicom(self, dicom_filepath):
|
|
220
255
|
"""
|
|
@@ -303,6 +338,8 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
303
338
|
)
|
|
304
339
|
|
|
305
340
|
# Rotation Information Sequence processing
|
|
341
|
+
num_frames = ds.get("NumberOfFrames", None)
|
|
342
|
+
time_per_projection = None
|
|
306
343
|
try:
|
|
307
344
|
if (0x0054, 0x0052) in ds:
|
|
308
345
|
rot_seq = ds[(0x0054, 0x0052)].value
|
|
@@ -322,16 +359,17 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
322
359
|
rot_item[(0x0018, 0x1242)].value / 1000
|
|
323
360
|
)
|
|
324
361
|
|
|
325
|
-
if num_frames is not None:
|
|
362
|
+
if time_per_projection is not None and num_frames is not None:
|
|
326
363
|
self.header["number of time frames"] = str(1)
|
|
327
364
|
self.header["!image duration (sec)[1]"] = str(
|
|
328
365
|
int(
|
|
329
366
|
np.round(
|
|
330
|
-
float(time_per_projection) * float(num_frames),
|
|
367
|
+
float(time_per_projection) * float(num_frames),
|
|
368
|
+
0,
|
|
331
369
|
)
|
|
332
370
|
)
|
|
333
371
|
)
|
|
334
|
-
|
|
372
|
+
elif time_per_projection is not None:
|
|
335
373
|
self.header["!time per projection (sec)[1]"] = (
|
|
336
374
|
time_per_projection
|
|
337
375
|
)
|
|
@@ -516,11 +554,21 @@ class STIRSPECTAcquisitionDataBuilder:
|
|
|
516
554
|
except AttributeError:
|
|
517
555
|
warnings.warn("StudyDescription not found in DICOM.")
|
|
518
556
|
try:
|
|
519
|
-
|
|
520
|
-
print(self.pixel_array.shape)
|
|
521
|
-
self.pixel_array = np.transpose(self.pixel_array, (2, 0, 1))
|
|
522
|
-
# rotate the image by 90 degrees cW in axis 1
|
|
523
|
-
self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
|
|
524
|
-
self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
|
|
557
|
+
raw_pixel_array = ds.pixel_array
|
|
525
558
|
except AttributeError:
|
|
526
559
|
warnings.warn("Pixel data not found in DICOM.")
|
|
560
|
+
return
|
|
561
|
+
|
|
562
|
+
raw_pixel_array = np.asarray(raw_pixel_array)
|
|
563
|
+
if raw_pixel_array.ndim == 2:
|
|
564
|
+
# Single frame: promote to (frames=1, rows, columns) so the
|
|
565
|
+
# transpose below sees the same layout as multiframe data.
|
|
566
|
+
raw_pixel_array = raw_pixel_array[np.newaxis, :, :]
|
|
567
|
+
elif raw_pixel_array.ndim != 3:
|
|
568
|
+
raise ValueError(
|
|
569
|
+
f"pixel data must be 2D or 3D, got {raw_pixel_array.ndim}D"
|
|
570
|
+
)
|
|
571
|
+
self.pixel_array = np.transpose(raw_pixel_array, (2, 0, 1))
|
|
572
|
+
# rotate the image by 90 degrees cW in axis 1
|
|
573
|
+
self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
|
|
574
|
+
self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
|
|
@@ -79,12 +79,24 @@ class STIRSPECTImageDataBuilder:
|
|
|
79
79
|
|
|
80
80
|
def _resolve_data_array(self) -> np.ndarray:
|
|
81
81
|
if self.pixel_array is not None:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
data = np.asarray(self.pixel_array, dtype=np.float32)
|
|
83
|
+
else:
|
|
84
|
+
dim_x = int(self.header["!matrix size [1]"])
|
|
85
|
+
dim_y = int(self.header["!matrix size [2]"])
|
|
86
|
+
dim_z = int(self.header["!matrix size [3]"])
|
|
87
|
+
data = np.zeros((dim_z, dim_y, dim_x), dtype=np.float32)
|
|
88
|
+
|
|
89
|
+
expected_shape = (
|
|
90
|
+
int(self.header["!matrix size [3]"]),
|
|
91
|
+
int(self.header["!matrix size [2]"]),
|
|
92
|
+
int(self.header["!matrix size [1]"]),
|
|
93
|
+
)
|
|
94
|
+
if data.shape != expected_shape:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"pixel array shape {data.shape} does not match header "
|
|
97
|
+
f"dimensions {expected_shape} (z, y, x)"
|
|
98
|
+
)
|
|
99
|
+
return data
|
|
88
100
|
|
|
89
101
|
def build(self, output_path: Optional[str | Path] = None):
|
|
90
102
|
"""
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
"""Connector/adaptor APIs.
|
|
1
|
+
"""Connector/adaptor APIs.
|
|
2
|
+
|
|
3
|
+
Backend-specific adaptors are exposed lazily so that importing this package
|
|
4
|
+
never imports SIRF, STIR, or PyTomography.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import importlib
|
|
2
8
|
|
|
3
9
|
from .base import BaseConnector
|
|
4
10
|
from .python_connector import (
|
|
@@ -7,9 +13,22 @@ from .python_connector import (
|
|
|
7
13
|
RuntimeOperator,
|
|
8
14
|
SimindPythonConnector,
|
|
9
15
|
)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_LAZY_ADAPTORS = {
|
|
19
|
+
"PyTomographySimindAdaptor": "pytomography_adaptor",
|
|
20
|
+
"SirfSimindAdaptor": "sirf_adaptor",
|
|
21
|
+
"StirSimindAdaptor": "stir_adaptor",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name):
|
|
26
|
+
if name in _LAZY_ADAPTORS:
|
|
27
|
+
module = importlib.import_module(f".{_LAZY_ADAPTORS[name]}", __name__)
|
|
28
|
+
obj = getattr(module, name)
|
|
29
|
+
globals()[name] = obj
|
|
30
|
+
return obj
|
|
31
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
13
32
|
|
|
14
33
|
|
|
15
34
|
__all__ = [
|
|
@@ -17,9 +17,11 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
|
|
|
17
17
|
|
|
18
18
|
if values:
|
|
19
19
|
if len(values) >= 4:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
# Raw STIR-style coordinate sequence: (unused, z, y, x)
|
|
21
|
+
return float(values[1])
|
|
22
|
+
if len(values) == 3:
|
|
23
|
+
# Public (z, y, x) convention
|
|
24
|
+
return float(values[0])
|
|
23
25
|
|
|
24
26
|
if hasattr(spacing, "z"):
|
|
25
27
|
try:
|
|
@@ -28,13 +30,13 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
|
|
|
28
30
|
pass
|
|
29
31
|
|
|
30
32
|
if hasattr(spacing, "at"):
|
|
31
|
-
for index in (
|
|
33
|
+
for index in (1, 2, 3):
|
|
32
34
|
try:
|
|
33
35
|
return float(spacing.at(index))
|
|
34
36
|
except Exception:
|
|
35
37
|
continue
|
|
36
38
|
|
|
37
|
-
for index in (
|
|
39
|
+
for index in (1, 2, 3):
|
|
38
40
|
try:
|
|
39
41
|
return float(spacing[index])
|
|
40
42
|
except Exception:
|
|
@@ -44,12 +46,16 @@ def _voxel_size_from_spacing(spacing: Any) -> float | None:
|
|
|
44
46
|
|
|
45
47
|
|
|
46
48
|
def extract_voxel_size_mm(image: Any, backend_name: str) -> float:
|
|
47
|
-
"""Extract z voxel spacing in mm from backend image metadata.
|
|
49
|
+
"""Extract z voxel spacing in mm from backend image metadata.
|
|
50
|
+
|
|
51
|
+
Backend images report ``voxel_sizes()`` in ``(z, y, x)`` order, so the
|
|
52
|
+
first element is the z spacing.
|
|
53
|
+
"""
|
|
48
54
|
if hasattr(image, "voxel_sizes"):
|
|
49
55
|
voxel_sizes = image.voxel_sizes()
|
|
50
56
|
try:
|
|
51
57
|
if len(voxel_sizes) >= 3:
|
|
52
|
-
return float(voxel_sizes[
|
|
58
|
+
return float(voxel_sizes[0])
|
|
53
59
|
except Exception:
|
|
54
60
|
pass
|
|
55
61
|
|
|
@@ -6,6 +6,7 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import contextlib
|
|
8
8
|
import logging
|
|
9
|
+
import math
|
|
9
10
|
import os
|
|
10
11
|
import shutil
|
|
11
12
|
from dataclasses import dataclass, field
|
|
@@ -62,11 +63,12 @@ class SimindPythonConnector(BaseConnector):
|
|
|
62
63
|
quantization_scale: float = 1.0,
|
|
63
64
|
) -> None:
|
|
64
65
|
self.logger = logging.getLogger(__name__)
|
|
66
|
+
self._validate_output_prefix(output_prefix)
|
|
65
67
|
self.output_dir = Path(output_dir).expanduser().resolve()
|
|
66
68
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
67
69
|
self.output_prefix = output_prefix
|
|
68
70
|
self.quantization_scale = float(quantization_scale)
|
|
69
|
-
if self.quantization_scale <= 0:
|
|
71
|
+
if not math.isfinite(self.quantization_scale) or self.quantization_scale <= 0:
|
|
70
72
|
raise ValueError("quantization_scale must be > 0")
|
|
71
73
|
|
|
72
74
|
self.config = self._initialize_config(config_source)
|
|
@@ -75,12 +77,31 @@ class SimindPythonConnector(BaseConnector):
|
|
|
75
77
|
self.converter = SimindToStirConverter()
|
|
76
78
|
|
|
77
79
|
self._outputs: Optional[dict[str, ProjectionResult]] = None
|
|
80
|
+
self._window_file_path: Optional[Path] = None
|
|
81
|
+
|
|
82
|
+
@staticmethod
|
|
83
|
+
def _validate_output_prefix(prefix: str) -> None:
|
|
84
|
+
"""Reject prefixes that could escape the output directory."""
|
|
85
|
+
if (
|
|
86
|
+
not isinstance(prefix, str)
|
|
87
|
+
or not prefix
|
|
88
|
+
or prefix in {".", ".."}
|
|
89
|
+
or "/" in prefix
|
|
90
|
+
or "\\" in prefix
|
|
91
|
+
or Path(prefix).is_absolute()
|
|
92
|
+
):
|
|
93
|
+
raise ValueError(f"output_prefix must be a plain filename, got {prefix!r}")
|
|
78
94
|
|
|
79
95
|
@staticmethod
|
|
80
96
|
def _initialize_config(config_source: ConfigSource) -> SimulationConfig:
|
|
81
97
|
if isinstance(config_source, SimulationConfig):
|
|
82
98
|
return config_source
|
|
83
99
|
|
|
100
|
+
if not isinstance(config_source, (str, os.PathLike)):
|
|
101
|
+
# importlib.resources Traversable from configs.get(): hand it to
|
|
102
|
+
# SimulationConfig untouched (supports zipped installs).
|
|
103
|
+
return SimulationConfig(config_source)
|
|
104
|
+
|
|
84
105
|
config_path = Path(config_source).expanduser().resolve()
|
|
85
106
|
if not config_path.exists():
|
|
86
107
|
raise FileNotFoundError(f"Configuration file not found: {config_source}")
|
|
@@ -124,14 +145,30 @@ class SimindPythonConnector(BaseConnector):
|
|
|
124
145
|
raise ValueError("source and mu_map must have identical shapes")
|
|
125
146
|
|
|
126
147
|
vox_cm = float(voxel_size_mm) / SIMIND_VOXEL_UNIT_CONVERSION
|
|
127
|
-
if vox_cm <= 0:
|
|
148
|
+
if not math.isfinite(vox_cm) or vox_cm <= 0:
|
|
128
149
|
raise ValueError("voxel_size_mm must be > 0")
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
150
|
+
if source_array.size == 0 or mu_map_array.size == 0:
|
|
151
|
+
raise ValueError("source and mu_map must not be empty")
|
|
152
|
+
if not np.isfinite(source_array).all() or not np.isfinite(mu_map_array).all():
|
|
153
|
+
raise ValueError("source and mu_map must contain only finite values")
|
|
154
|
+
if (source_array < 0).any() or (mu_map_array < 0).any():
|
|
155
|
+
raise ValueError("source and mu_map must be non-negative")
|
|
156
|
+
|
|
157
|
+
if isinstance(scoring_routine, ScoringRoutine):
|
|
158
|
+
routine = scoring_routine
|
|
159
|
+
elif isinstance(scoring_routine, int) and not isinstance(scoring_routine, bool):
|
|
160
|
+
try:
|
|
161
|
+
routine = ScoringRoutine(scoring_routine)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"scoring_routine {scoring_routine!r} is not a valid "
|
|
165
|
+
"ScoringRoutine value"
|
|
166
|
+
) from exc
|
|
167
|
+
else:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
"scoring_routine must be a ScoringRoutine or int, got "
|
|
170
|
+
f"{type(scoring_routine).__name__}"
|
|
171
|
+
)
|
|
135
172
|
dim_z, dim_y, dim_x = (int(v) for v in source_array.shape)
|
|
136
173
|
|
|
137
174
|
cfg = self.config
|
|
@@ -156,8 +193,10 @@ class SimindPythonConnector(BaseConnector):
|
|
|
156
193
|
cfg.set_value(31, vox_cm)
|
|
157
194
|
cfg.set_value(33, 1)
|
|
158
195
|
cfg.set_value(34, dim_z)
|
|
159
|
-
cfg.set_value(78, dim_x)
|
|
160
|
-
cfg.set_value(79,
|
|
196
|
+
cfg.set_value(78, dim_x) # density map i
|
|
197
|
+
cfg.set_value(79, dim_x) # source map i
|
|
198
|
+
cfg.set_value(81, dim_y) # density map j
|
|
199
|
+
cfg.set_value(82, dim_y) # source map j
|
|
161
200
|
|
|
162
201
|
self.runtime_switches.set_switch("PX", vox_cm)
|
|
163
202
|
|
|
@@ -198,12 +237,14 @@ class SimindPythonConnector(BaseConnector):
|
|
|
198
237
|
scatter_orders: Union[int, list[int]],
|
|
199
238
|
) -> None:
|
|
200
239
|
"""Write a SIMIND window file for this connector run."""
|
|
240
|
+
window_path = self.output_dir / f"{self.output_prefix}.win"
|
|
201
241
|
create_window_file(
|
|
202
242
|
lower_bounds,
|
|
203
243
|
upper_bounds,
|
|
204
244
|
scatter_orders,
|
|
205
|
-
output_filename=str(
|
|
245
|
+
output_filename=str(window_path),
|
|
206
246
|
)
|
|
247
|
+
self._window_file_path = window_path
|
|
207
248
|
|
|
208
249
|
def run(
|
|
209
250
|
self, runtime_operator: Optional[RuntimeOperator] = None
|
|
@@ -211,22 +252,27 @@ class SimindPythonConnector(BaseConnector):
|
|
|
211
252
|
"""Run SIMIND and return projection outputs as NumPy arrays."""
|
|
212
253
|
self._outputs = None
|
|
213
254
|
|
|
255
|
+
# Runtime-operator switches apply to this run only; merge them into
|
|
256
|
+
# a throwaway switch set instead of persistent connector state.
|
|
257
|
+
run_switches_holder = RuntimeSwitches()
|
|
258
|
+
for key, value in self.runtime_switches.switches.items():
|
|
259
|
+
run_switches_holder.set_switch(key, value)
|
|
214
260
|
orbit_file = None
|
|
215
261
|
if runtime_operator is not None:
|
|
216
|
-
|
|
262
|
+
for key, value in runtime_operator.switches.items():
|
|
263
|
+
run_switches_holder.set_switch(key, value)
|
|
217
264
|
orbit_file = self._prepare_orbit_file(runtime_operator.orbit_file)
|
|
218
265
|
|
|
219
266
|
config_path = self.output_dir / self.output_prefix
|
|
220
267
|
self.config.save_file(config_path)
|
|
221
268
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
os.chdir(original_cwd)
|
|
269
|
+
self._clear_previous_outputs()
|
|
270
|
+
self.executor.run_simulation(
|
|
271
|
+
self.output_prefix,
|
|
272
|
+
orbit_file,
|
|
273
|
+
run_switches_holder.switches,
|
|
274
|
+
cwd=self.output_dir,
|
|
275
|
+
)
|
|
230
276
|
|
|
231
277
|
header_files = self._ensure_interfile_headers()
|
|
232
278
|
self._outputs = self._load_projection_outputs(header_files)
|
|
@@ -241,6 +287,46 @@ class SimindPythonConnector(BaseConnector):
|
|
|
241
287
|
def get_config(self) -> SimulationConfig:
|
|
242
288
|
return self.config
|
|
243
289
|
|
|
290
|
+
_OUTPUT_SUFFIXES = {".h00", ".hs", ".a00", ".s", ".win"}
|
|
291
|
+
|
|
292
|
+
def _clear_previous_outputs(self) -> None:
|
|
293
|
+
"""Delete stale outputs from earlier runs sharing this prefix.
|
|
294
|
+
|
|
295
|
+
Connector-written inputs (``{prefix}_src.smi`` / ``{prefix}_dns.dmi``)
|
|
296
|
+
are protected because SIMIND still needs them on disk.
|
|
297
|
+
"""
|
|
298
|
+
protected = {
|
|
299
|
+
f"{self.output_prefix}_src.smi",
|
|
300
|
+
f"{self.output_prefix}_dns.dmi",
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
self._window_file_path is not None
|
|
304
|
+
and self._window_file_path.parent == self.output_dir
|
|
305
|
+
):
|
|
306
|
+
# Energy-window input written via set_energy_windows(); protect
|
|
307
|
+
# only that exact file, never a pre-existing one at the current
|
|
308
|
+
# prefix/location.
|
|
309
|
+
protected.add(self._window_file_path.name)
|
|
310
|
+
for slot in (5, 6):
|
|
311
|
+
try:
|
|
312
|
+
protected.add(self.config.get_data_file(slot))
|
|
313
|
+
except KeyError:
|
|
314
|
+
pass
|
|
315
|
+
for path in sorted(self.output_dir.iterdir()):
|
|
316
|
+
if not path.is_file() or path.name in protected:
|
|
317
|
+
continue
|
|
318
|
+
stem_ok = path.stem == self.output_prefix or path.stem.startswith(
|
|
319
|
+
f"{self.output_prefix}_"
|
|
320
|
+
)
|
|
321
|
+
if not stem_ok:
|
|
322
|
+
continue
|
|
323
|
+
if path.suffix in self._OUTPUT_SUFFIXES or (
|
|
324
|
+
path.suffix.startswith(".b")
|
|
325
|
+
and path.suffix[2:].isdigit()
|
|
326
|
+
and path.stem == self.output_prefix
|
|
327
|
+
):
|
|
328
|
+
path.unlink()
|
|
329
|
+
|
|
244
330
|
def _prepare_orbit_file(self, orbit_file: Optional[PathLike]) -> Optional[Path]:
|
|
245
331
|
if orbit_file is None:
|
|
246
332
|
return None
|
|
@@ -273,12 +359,18 @@ class SimindPythonConnector(BaseConnector):
|
|
|
273
359
|
self.output_dir.glob(f"{self.output_prefix}_component_*.hs")
|
|
274
360
|
)
|
|
275
361
|
else:
|
|
276
|
-
h00_files = sorted(
|
|
362
|
+
h00_files = sorted(
|
|
363
|
+
set(self.output_dir.glob(f"{self.output_prefix}_*.h00"))
|
|
364
|
+
| set(self.output_dir.glob(f"{self.output_prefix}.h00"))
|
|
365
|
+
)
|
|
277
366
|
for h00_file in h00_files:
|
|
278
367
|
hs_file = h00_file.with_suffix(".hs")
|
|
279
368
|
self.converter.convert_file(str(h00_file), str(hs_file))
|
|
280
369
|
|
|
281
|
-
hs_files = sorted(
|
|
370
|
+
hs_files = sorted(
|
|
371
|
+
set(self.output_dir.glob(f"{self.output_prefix}_*.hs"))
|
|
372
|
+
| {h00_file.with_suffix(".hs") for h00_file in h00_files}
|
|
373
|
+
)
|
|
282
374
|
if not hs_files:
|
|
283
375
|
raise FileNotFoundError(
|
|
284
376
|
f"No projection headers (.hs) found for prefix {self.output_prefix!r} "
|
|
@@ -134,6 +134,11 @@ class PyTomographySimindAdaptor(BaseConnector):
|
|
|
134
134
|
def run(
|
|
135
135
|
self, runtime_operator: Optional[RuntimeOperator] = None
|
|
136
136
|
) -> Dict[str, torch.Tensor]:
|
|
137
|
+
# Clear all output caches up front so failed reruns cannot leak
|
|
138
|
+
# results from a previous successful run.
|
|
139
|
+
self._outputs = None
|
|
140
|
+
self._output_metadata = None
|
|
141
|
+
self._output_header_paths = None
|
|
137
142
|
self._validate_inputs()
|
|
138
143
|
assert self._source is not None # for type checkers
|
|
139
144
|
assert self._mu_map is not None
|
|
@@ -79,6 +79,9 @@ class SirfSimindAdaptor(BaseConnector):
|
|
|
79
79
|
self.python_connector.add_runtime_switch(switch, value)
|
|
80
80
|
|
|
81
81
|
def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, Any]:
|
|
82
|
+
# Drop cached outputs before validation so a failed rerun can never
|
|
83
|
+
# expose results from a previous successful run.
|
|
84
|
+
self._outputs = None
|
|
82
85
|
self._validate_inputs()
|
|
83
86
|
assert self._source is not None
|
|
84
87
|
assert self._mu_map is not None
|
|
@@ -79,6 +79,9 @@ class StirSimindAdaptor(BaseConnector):
|
|
|
79
79
|
self.python_connector.add_runtime_switch(switch, value)
|
|
80
80
|
|
|
81
81
|
def run(self, runtime_operator: Optional[RuntimeOperator] = None) -> dict[str, Any]:
|
|
82
|
+
# Drop cached outputs before validation so a failed rerun can never
|
|
83
|
+
# expose results from a previous successful run.
|
|
84
|
+
self._outputs = None
|
|
82
85
|
self._validate_inputs()
|
|
83
86
|
assert self._source is not None
|
|
84
87
|
assert self._mu_map is not None
|
|
@@ -57,7 +57,9 @@ def get_attenuation_coefficient(material, energy, file_path=None):
|
|
|
57
57
|
raise ValueError("Unknown material. Accepted values are 'water' or 'bone'.")
|
|
58
58
|
|
|
59
59
|
if file_path:
|
|
60
|
-
|
|
60
|
+
# filename may already be an absolute packaged path; join only its
|
|
61
|
+
# basename so the override directory is honoured.
|
|
62
|
+
filepath = Path(file_path) / Path(str(filename)).name
|
|
61
63
|
else:
|
|
62
64
|
filepath = get_package_data_path(filename)
|
|
63
65
|
|
|
@@ -28,7 +28,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
|
28
28
|
class ConversionConfig:
|
|
29
29
|
"""Configuration for SIMIND to STIR conversion."""
|
|
30
30
|
|
|
31
|
-
radius_scale_factor: float =
|
|
31
|
+
radius_scale_factor: float = 1.0 # pass-through; SIMIND writes Radius in mm
|
|
32
32
|
angle_offset: float = 180.0 # degrees
|
|
33
33
|
default_number_format: str = "float"
|
|
34
34
|
ignored_patterns: List[str] = None
|
|
@@ -74,9 +74,9 @@ class RadiusConversionRule(ConversionRule):
|
|
|
74
74
|
|
|
75
75
|
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
76
76
|
try:
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
radius_value = float(line.split()[-1])
|
|
77
|
+
# Apply the configured scale factor (default 1.0 keeps SIMIND's
|
|
78
|
+
# millimetre Radius values unchanged).
|
|
79
|
+
radius_value = float(line.split()[-1]) * self.scale_factor
|
|
80
80
|
return f"Radius := {radius_value}", context
|
|
81
81
|
except (ValueError, IndexError) as e:
|
|
82
82
|
logging.warning(f"Failed to convert radius line '{line}': {e}")
|
|
@@ -283,6 +283,10 @@ class SimindToStirConverter:
|
|
|
283
283
|
) -> List[ConversionRule]:
|
|
284
284
|
"""Create conversion rules in order of priority."""
|
|
285
285
|
return [
|
|
286
|
+
# Data-file names must be rewritten before ignore rules run:
|
|
287
|
+
# ignored substrings such as "patient" otherwise comment out
|
|
288
|
+
# data files whose paths happen to contain them.
|
|
289
|
+
DataFileNameRule(data_file_override),
|
|
286
290
|
IgnorePatternRule(self.config.ignored_patterns),
|
|
287
291
|
OrbitFileRule(self.input_file_dir), # Process orbit file before other rules
|
|
288
292
|
RadiusConversionRule(self.config.radius_scale_factor),
|
|
@@ -293,7 +297,6 @@ class SimindToStirConverter:
|
|
|
293
297
|
ImageDurationRule(),
|
|
294
298
|
EnergyWindowRule("lower"),
|
|
295
299
|
EnergyWindowRule("upper"),
|
|
296
|
-
DataFileNameRule(data_file_override),
|
|
297
300
|
]
|
|
298
301
|
|
|
299
302
|
def convert_line(
|
|
@@ -507,12 +510,11 @@ class SimindToStirConverter:
|
|
|
507
510
|
self.logger.warning(f"No .h00 file found with prefix {output_prefix}")
|
|
508
511
|
return None
|
|
509
512
|
else:
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
"
|
|
513
|
+
raise ValueError(
|
|
514
|
+
f"Multiple .h00 files match prefix {output_prefix!r} in "
|
|
515
|
+
f"{output_dir}: {[f.name for f in sorted(h00_files)]}. "
|
|
516
|
+
"Remove stale outputs or use a distinct output prefix."
|
|
513
517
|
)
|
|
514
|
-
# Return the first one as fallback
|
|
515
|
-
return str(h00_files[0])
|
|
516
518
|
|
|
517
519
|
def read_parameter(self, filename: str, parameter: str) -> Optional[str]:
|
|
518
520
|
"""Read a parameter from a header file."""
|