simind-python-connector 1.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 +72 -0
- simind_python_connector/backends/__init__.py +480 -0
- simind_python_connector/backends/base.py +387 -0
- simind_python_connector/backends/sirf_backend.py +309 -0
- simind_python_connector/backends/stir_backend.py +395 -0
- simind_python_connector/builders/__init__.py +19 -0
- simind_python_connector/builders/acquisition_builder.py +526 -0
- simind_python_connector/builders/image_builder.py +217 -0
- simind_python_connector/configs/AnyScan.yaml +420 -0
- simind_python_connector/configs/Discovery670.yaml +412 -0
- simind_python_connector/configs/Example.yaml +420 -0
- simind_python_connector/configs/MLD001_SCAN0.yaml +426 -0
- simind_python_connector/configs/__init__.py +41 -0
- simind_python_connector/configs/input.smc +51 -0
- simind_python_connector/connectors/__init__.py +24 -0
- simind_python_connector/connectors/_spacing.py +69 -0
- simind_python_connector/connectors/base.py +40 -0
- simind_python_connector/connectors/python_connector.py +355 -0
- simind_python_connector/connectors/pytomography_adaptor.py +263 -0
- simind_python_connector/connectors/sirf_adaptor.py +164 -0
- simind_python_connector/connectors/stir_adaptor.py +164 -0
- simind_python_connector/converters/__init__.py +16 -0
- simind_python_connector/converters/attenuation.py +367 -0
- simind_python_connector/converters/dicom_to_stir.py +3 -0
- simind_python_connector/converters/simind_to_stir.py +769 -0
- simind_python_connector/core/__init__.py +7 -0
- simind_python_connector/core/config.py +939 -0
- simind_python_connector/core/executor.py +96 -0
- simind_python_connector/core/types.py +203 -0
- simind_python_connector/data/Schneider2000.json +222 -0
- simind_python_connector/data/__init__.py +25 -0
- simind_python_connector/data/bone.atn +187 -0
- simind_python_connector/data/h2o.atn +92 -0
- simind_python_connector/utils/__init__.py +120 -0
- simind_python_connector/utils/backend_access.py +121 -0
- simind_python_connector/utils/import_helpers.py +74 -0
- simind_python_connector/utils/interfile_numpy.py +195 -0
- simind_python_connector/utils/interfile_parser.py +175 -0
- simind_python_connector/utils/io_utils.py +14 -0
- simind_python_connector/utils/simind_utils.py +70 -0
- simind_python_connector/utils/sirf_stir_utils.py +194 -0
- simind_python_connector/utils/stir_utils.py +485 -0
- simind_python_connector-1.0.0.dist-info/METADATA +274 -0
- simind_python_connector-1.0.0.dist-info/RECORD +47 -0
- simind_python_connector-1.0.0.dist-info/WHEEL +5 -0
- simind_python_connector-1.0.0.dist-info/licenses/LICENSE +195 -0
- simind_python_connector-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Backend-agnostic SIMIND connector returning NumPy projection outputs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Optional, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from simind_python_connector.connectors.base import BaseConnector
|
|
18
|
+
from simind_python_connector.converters.attenuation import attenuation_to_density
|
|
19
|
+
from simind_python_connector.converters.simind_to_stir import SimindToStirConverter
|
|
20
|
+
from simind_python_connector.core.config import RuntimeSwitches, SimulationConfig
|
|
21
|
+
from simind_python_connector.core.executor import SimindExecutor
|
|
22
|
+
from simind_python_connector.core.types import (
|
|
23
|
+
MAX_SOURCE,
|
|
24
|
+
SIMIND_VOXEL_UNIT_CONVERSION,
|
|
25
|
+
PenetrateOutputType,
|
|
26
|
+
ScoringRoutine,
|
|
27
|
+
)
|
|
28
|
+
from simind_python_connector.utils.interfile_numpy import load_interfile_array
|
|
29
|
+
from simind_python_connector.utils.simind_utils import create_window_file
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ConfigSource = Union[str, os.PathLike[str], SimulationConfig]
|
|
33
|
+
PathLike = Union[str, os.PathLike[str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class ProjectionResult:
|
|
38
|
+
"""Projection array together with the header and binary file references."""
|
|
39
|
+
|
|
40
|
+
projection: np.ndarray
|
|
41
|
+
header_path: Path
|
|
42
|
+
data_path: Path
|
|
43
|
+
metadata: dict[str, str]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RuntimeOperator:
|
|
48
|
+
"""Runtime modifiers applied when invoking SIMIND."""
|
|
49
|
+
|
|
50
|
+
switches: Dict[str, Any] = field(default_factory=dict)
|
|
51
|
+
orbit_file: Optional[PathLike] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SimindPythonConnector(BaseConnector):
|
|
55
|
+
"""Pure Python connector for SIMIND with NumPy-first outputs."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
config_source: ConfigSource,
|
|
60
|
+
output_dir: PathLike,
|
|
61
|
+
output_prefix: str = "output",
|
|
62
|
+
quantization_scale: float = 1.0,
|
|
63
|
+
) -> None:
|
|
64
|
+
self.logger = logging.getLogger(__name__)
|
|
65
|
+
self.output_dir = Path(output_dir).expanduser().resolve()
|
|
66
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
self.output_prefix = output_prefix
|
|
68
|
+
self.quantization_scale = float(quantization_scale)
|
|
69
|
+
if self.quantization_scale <= 0:
|
|
70
|
+
raise ValueError("quantization_scale must be > 0")
|
|
71
|
+
|
|
72
|
+
self.config = self._initialize_config(config_source)
|
|
73
|
+
self.runtime_switches = RuntimeSwitches()
|
|
74
|
+
self.executor = SimindExecutor()
|
|
75
|
+
self.converter = SimindToStirConverter()
|
|
76
|
+
|
|
77
|
+
self._outputs: Optional[dict[str, ProjectionResult]] = None
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _initialize_config(config_source: ConfigSource) -> SimulationConfig:
|
|
81
|
+
if isinstance(config_source, SimulationConfig):
|
|
82
|
+
return config_source
|
|
83
|
+
|
|
84
|
+
config_path = Path(config_source).expanduser().resolve()
|
|
85
|
+
if not config_path.exists():
|
|
86
|
+
raise FileNotFoundError(f"Configuration file not found: {config_source}")
|
|
87
|
+
|
|
88
|
+
suffix = config_path.suffix.lower()
|
|
89
|
+
if suffix not in {".smc", ".yaml", ".yml"}:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
f"Unsupported configuration file extension {suffix!r}. "
|
|
92
|
+
"Expected one of .smc, .yaml, .yml"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return SimulationConfig(str(config_path))
|
|
96
|
+
|
|
97
|
+
def add_runtime_switch(self, switch: str, value: Any) -> None:
|
|
98
|
+
"""Set a single runtime switch."""
|
|
99
|
+
self.runtime_switches.set_switch(switch, value)
|
|
100
|
+
|
|
101
|
+
def add_config_value(self, index: int, value: Any) -> None:
|
|
102
|
+
"""Set a SIMIND config value."""
|
|
103
|
+
self.config.set_value(index, value)
|
|
104
|
+
|
|
105
|
+
def configure_voxel_phantom(
|
|
106
|
+
self,
|
|
107
|
+
source: np.ndarray,
|
|
108
|
+
mu_map: np.ndarray,
|
|
109
|
+
voxel_size_mm: float = 4.0,
|
|
110
|
+
scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN,
|
|
111
|
+
) -> tuple[Path, Path]:
|
|
112
|
+
"""
|
|
113
|
+
Configure voxel geometry and write source/density input files.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Tuple of (source_file_path, density_file_path).
|
|
117
|
+
"""
|
|
118
|
+
source_array = np.asarray(source, dtype=np.float32)
|
|
119
|
+
mu_map_array = np.asarray(mu_map, dtype=np.float32)
|
|
120
|
+
|
|
121
|
+
if source_array.ndim != 3 or mu_map_array.ndim != 3:
|
|
122
|
+
raise ValueError("source and mu_map must both be 3D arrays")
|
|
123
|
+
if source_array.shape != mu_map_array.shape:
|
|
124
|
+
raise ValueError("source and mu_map must have identical shapes")
|
|
125
|
+
|
|
126
|
+
vox_cm = float(voxel_size_mm) / SIMIND_VOXEL_UNIT_CONVERSION
|
|
127
|
+
if vox_cm <= 0:
|
|
128
|
+
raise ValueError("voxel_size_mm must be > 0")
|
|
129
|
+
|
|
130
|
+
routine = (
|
|
131
|
+
ScoringRoutine(scoring_routine)
|
|
132
|
+
if isinstance(scoring_routine, int)
|
|
133
|
+
else scoring_routine
|
|
134
|
+
)
|
|
135
|
+
dim_z, dim_y, dim_x = (int(v) for v in source_array.shape)
|
|
136
|
+
|
|
137
|
+
cfg = self.config
|
|
138
|
+
cfg.set_flag(5, True)
|
|
139
|
+
cfg.set_value(15, -1)
|
|
140
|
+
cfg.set_value(14, -1)
|
|
141
|
+
cfg.set_flag(14, True)
|
|
142
|
+
cfg.set_value(84, routine.value)
|
|
143
|
+
|
|
144
|
+
# Source geometry
|
|
145
|
+
cfg.set_value(2, dim_z * vox_cm / 2.0)
|
|
146
|
+
cfg.set_value(3, dim_x * vox_cm / 2.0)
|
|
147
|
+
cfg.set_value(4, dim_y * vox_cm / 2.0)
|
|
148
|
+
cfg.set_value(28, vox_cm)
|
|
149
|
+
cfg.set_value(76, dim_x)
|
|
150
|
+
cfg.set_value(77, dim_y)
|
|
151
|
+
|
|
152
|
+
# Density geometry
|
|
153
|
+
cfg.set_value(5, dim_z * vox_cm / 2.0)
|
|
154
|
+
cfg.set_value(6, dim_x * vox_cm / 2.0)
|
|
155
|
+
cfg.set_value(7, dim_y * vox_cm / 2.0)
|
|
156
|
+
cfg.set_value(31, vox_cm)
|
|
157
|
+
cfg.set_value(33, 1)
|
|
158
|
+
cfg.set_value(34, dim_z)
|
|
159
|
+
cfg.set_value(78, dim_x)
|
|
160
|
+
cfg.set_value(79, dim_y)
|
|
161
|
+
|
|
162
|
+
self.runtime_switches.set_switch("PX", vox_cm)
|
|
163
|
+
|
|
164
|
+
source_max = float(source_array.max())
|
|
165
|
+
if source_max > 0:
|
|
166
|
+
source_scaled = (
|
|
167
|
+
source_array / source_max * (MAX_SOURCE * self.quantization_scale)
|
|
168
|
+
)
|
|
169
|
+
else:
|
|
170
|
+
source_scaled = np.zeros_like(source_array)
|
|
171
|
+
source_u16 = np.clip(np.round(source_scaled), 0, MAX_SOURCE).astype(np.uint16)
|
|
172
|
+
|
|
173
|
+
src_prefix = f"{self.output_prefix}_src"
|
|
174
|
+
source_path = self.output_dir / f"{src_prefix}.smi"
|
|
175
|
+
source_u16.tofile(source_path)
|
|
176
|
+
cfg.set_data_file(6, src_prefix)
|
|
177
|
+
|
|
178
|
+
if cfg.get_flag(11):
|
|
179
|
+
photon_energy = float(cfg.get_value("photon_energy"))
|
|
180
|
+
density = attenuation_to_density(mu_map_array, photon_energy) * 1000.0
|
|
181
|
+
else:
|
|
182
|
+
density = np.zeros_like(mu_map_array)
|
|
183
|
+
|
|
184
|
+
density_u16 = np.clip(np.round(density), 0, np.iinfo(np.uint16).max).astype(
|
|
185
|
+
np.uint16
|
|
186
|
+
)
|
|
187
|
+
dns_prefix = f"{self.output_prefix}_dns"
|
|
188
|
+
density_path = self.output_dir / f"{dns_prefix}.dmi"
|
|
189
|
+
density_u16.tofile(density_path)
|
|
190
|
+
cfg.set_data_file(5, dns_prefix)
|
|
191
|
+
|
|
192
|
+
return source_path, density_path
|
|
193
|
+
|
|
194
|
+
def set_energy_windows(
|
|
195
|
+
self,
|
|
196
|
+
lower_bounds: Union[float, list[float]],
|
|
197
|
+
upper_bounds: Union[float, list[float]],
|
|
198
|
+
scatter_orders: Union[int, list[int]],
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Write a SIMIND window file for this connector run."""
|
|
201
|
+
create_window_file(
|
|
202
|
+
lower_bounds,
|
|
203
|
+
upper_bounds,
|
|
204
|
+
scatter_orders,
|
|
205
|
+
output_filename=str(self.output_dir / self.output_prefix),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def run(
|
|
209
|
+
self, runtime_operator: Optional[RuntimeOperator] = None
|
|
210
|
+
) -> dict[str, ProjectionResult]:
|
|
211
|
+
"""Run SIMIND and return projection outputs as NumPy arrays."""
|
|
212
|
+
self._outputs = None
|
|
213
|
+
|
|
214
|
+
orbit_file = None
|
|
215
|
+
if runtime_operator is not None:
|
|
216
|
+
self.set_runtime_switches(runtime_operator.switches)
|
|
217
|
+
orbit_file = self._prepare_orbit_file(runtime_operator.orbit_file)
|
|
218
|
+
|
|
219
|
+
config_path = self.output_dir / self.output_prefix
|
|
220
|
+
self.config.save_file(config_path)
|
|
221
|
+
|
|
222
|
+
original_cwd = Path.cwd()
|
|
223
|
+
try:
|
|
224
|
+
os.chdir(self.output_dir)
|
|
225
|
+
self.executor.run_simulation(
|
|
226
|
+
self.output_prefix, orbit_file, self.runtime_switches.switches
|
|
227
|
+
)
|
|
228
|
+
finally:
|
|
229
|
+
os.chdir(original_cwd)
|
|
230
|
+
|
|
231
|
+
header_files = self._ensure_interfile_headers()
|
|
232
|
+
self._outputs = self._load_projection_outputs(header_files)
|
|
233
|
+
return self._outputs
|
|
234
|
+
|
|
235
|
+
def get_outputs(self) -> dict[str, ProjectionResult]:
|
|
236
|
+
"""Return cached outputs from the last completed run."""
|
|
237
|
+
if self._outputs is None:
|
|
238
|
+
raise RuntimeError("No outputs are available. Run the connector first.")
|
|
239
|
+
return self._outputs
|
|
240
|
+
|
|
241
|
+
def get_config(self) -> SimulationConfig:
|
|
242
|
+
return self.config
|
|
243
|
+
|
|
244
|
+
def _prepare_orbit_file(self, orbit_file: Optional[PathLike]) -> Optional[Path]:
|
|
245
|
+
if orbit_file is None:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
orbit_path = Path(orbit_file).expanduser().resolve()
|
|
249
|
+
if not orbit_path.exists():
|
|
250
|
+
raise FileNotFoundError(f"Orbit file not found: {orbit_path}")
|
|
251
|
+
|
|
252
|
+
if orbit_path.parent == self.output_dir:
|
|
253
|
+
return orbit_path
|
|
254
|
+
|
|
255
|
+
copied_path = self.output_dir / orbit_path.name
|
|
256
|
+
shutil.copy2(orbit_path, copied_path)
|
|
257
|
+
return copied_path
|
|
258
|
+
|
|
259
|
+
def _ensure_interfile_headers(self) -> list[Path]:
|
|
260
|
+
if self._is_penetrate_routine():
|
|
261
|
+
h00_file = self.converter.find_penetrate_h00_file(
|
|
262
|
+
self.output_prefix, str(self.output_dir)
|
|
263
|
+
)
|
|
264
|
+
if h00_file is None:
|
|
265
|
+
raise FileNotFoundError(
|
|
266
|
+
f"No PENETRATE .h00 file found for prefix {self.output_prefix!r} "
|
|
267
|
+
f"in {self.output_dir}"
|
|
268
|
+
)
|
|
269
|
+
self.converter.create_penetrate_headers_from_template(
|
|
270
|
+
h00_file, self.output_prefix, str(self.output_dir)
|
|
271
|
+
)
|
|
272
|
+
hs_files = sorted(
|
|
273
|
+
self.output_dir.glob(f"{self.output_prefix}_component_*.hs")
|
|
274
|
+
)
|
|
275
|
+
else:
|
|
276
|
+
h00_files = sorted(self.output_dir.glob(f"*{self.output_prefix}*.h00"))
|
|
277
|
+
for h00_file in h00_files:
|
|
278
|
+
hs_file = h00_file.with_suffix(".hs")
|
|
279
|
+
self.converter.convert_file(str(h00_file), str(hs_file))
|
|
280
|
+
|
|
281
|
+
hs_files = sorted(self.output_dir.glob(f"*{self.output_prefix}*.hs"))
|
|
282
|
+
if not hs_files:
|
|
283
|
+
raise FileNotFoundError(
|
|
284
|
+
f"No projection headers (.hs) found for prefix {self.output_prefix!r} "
|
|
285
|
+
f"in {self.output_dir}"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return hs_files
|
|
289
|
+
|
|
290
|
+
def _load_projection_outputs(
|
|
291
|
+
self, header_files: list[Path]
|
|
292
|
+
) -> dict[str, ProjectionResult]:
|
|
293
|
+
outputs: dict[str, ProjectionResult] = {}
|
|
294
|
+
|
|
295
|
+
for header_path in header_files:
|
|
296
|
+
try:
|
|
297
|
+
interfile = load_interfile_array(header_path)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
self.logger.warning(
|
|
300
|
+
"Skipping output %s due to parse/load error: %s",
|
|
301
|
+
header_path,
|
|
302
|
+
exc,
|
|
303
|
+
)
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
key = self._extract_output_key(header_path)
|
|
307
|
+
outputs[key] = ProjectionResult(
|
|
308
|
+
projection=interfile.array,
|
|
309
|
+
header_path=interfile.header_path,
|
|
310
|
+
data_path=interfile.data_path,
|
|
311
|
+
metadata=interfile.metadata,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if not outputs:
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
f"No valid outputs were parsed from headers in {self.output_dir}"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return outputs
|
|
320
|
+
|
|
321
|
+
def _extract_output_key(self, header_path: Path) -> str:
|
|
322
|
+
stem = header_path.stem
|
|
323
|
+
|
|
324
|
+
component_prefix = f"{self.output_prefix}_component_"
|
|
325
|
+
if stem.startswith(component_prefix):
|
|
326
|
+
suffix = stem[len(component_prefix) :]
|
|
327
|
+
if suffix.isdigit():
|
|
328
|
+
component_id = int(suffix)
|
|
329
|
+
with contextlib.suppress(ValueError):
|
|
330
|
+
return PenetrateOutputType(component_id).slug
|
|
331
|
+
return f"b{component_id:02d}"
|
|
332
|
+
|
|
333
|
+
if stem.startswith(self.output_prefix):
|
|
334
|
+
stem = stem[len(self.output_prefix) :]
|
|
335
|
+
return stem.lstrip("_") or header_path.stem
|
|
336
|
+
|
|
337
|
+
def _is_penetrate_routine(self) -> bool:
|
|
338
|
+
try:
|
|
339
|
+
scoring_routine = int(
|
|
340
|
+
round(float(self.config.get_value("scoring_routine")))
|
|
341
|
+
)
|
|
342
|
+
except Exception:
|
|
343
|
+
return False
|
|
344
|
+
return scoring_routine == ScoringRoutine.PENETRATE.value
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
NumpyConnector = SimindPythonConnector
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
__all__ = [
|
|
351
|
+
"NumpyConnector",
|
|
352
|
+
"ProjectionResult",
|
|
353
|
+
"RuntimeOperator",
|
|
354
|
+
"SimindPythonConnector",
|
|
355
|
+
]
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyTomography/SIMIND adaptor.
|
|
3
|
+
|
|
4
|
+
This adaptor does not depend on SIRF/STIR objects for inputs or outputs.
|
|
5
|
+
It accepts torch tensors, configures SIMIND directly, executes the simulation,
|
|
6
|
+
and returns torch tensors for projection outputs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from simind_python_connector.connectors.base import BaseConnector
|
|
18
|
+
from simind_python_connector.connectors.python_connector import (
|
|
19
|
+
ConfigSource,
|
|
20
|
+
RuntimeOperator,
|
|
21
|
+
SimindPythonConnector,
|
|
22
|
+
)
|
|
23
|
+
from simind_python_connector.core.types import (
|
|
24
|
+
ScoringRoutine,
|
|
25
|
+
ValidationError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import torch
|
|
31
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
32
|
+
torch = None # type: ignore[assignment]
|
|
33
|
+
|
|
34
|
+
try: # pragma: no cover - optional dependency
|
|
35
|
+
from pytomography.io.SPECT import simind as pytomo_simind
|
|
36
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
37
|
+
pytomo_simind = None # type: ignore[assignment]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PathLike = Union[str, os.PathLike[str]]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PyTomographySimindAdaptor(BaseConnector):
|
|
44
|
+
"""SIMIND adaptor that consumes and returns PyTomography-native tensors.
|
|
45
|
+
|
|
46
|
+
Public tensor convention for this connector is object space ``(x, y, z)``.
|
|
47
|
+
Internal SIMIND input files are written in SIMIND image order ``(z, y, x)``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
config_source: ConfigSource,
|
|
53
|
+
output_dir: PathLike,
|
|
54
|
+
output_prefix: str = "output",
|
|
55
|
+
photon_multiplier: int = 1,
|
|
56
|
+
voxel_size_mm: float = 4.0,
|
|
57
|
+
quantization_scale: float = 1.0,
|
|
58
|
+
scoring_routine: Union[ScoringRoutine, int] = ScoringRoutine.SCATTWIN,
|
|
59
|
+
) -> None:
|
|
60
|
+
if torch is None:
|
|
61
|
+
raise ImportError(
|
|
62
|
+
"PyTomographySimindAdaptor requires torch to be installed "
|
|
63
|
+
"(and typically pytomography in your environment)."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
self.python_connector = SimindPythonConnector(
|
|
67
|
+
config_source=config_source,
|
|
68
|
+
output_dir=output_dir,
|
|
69
|
+
output_prefix=output_prefix,
|
|
70
|
+
quantization_scale=quantization_scale,
|
|
71
|
+
)
|
|
72
|
+
self.output_dir = Path(output_dir).expanduser().resolve()
|
|
73
|
+
self.output_prefix = output_prefix
|
|
74
|
+
self.voxel_size_mm = float(voxel_size_mm)
|
|
75
|
+
if self.voxel_size_mm <= 0:
|
|
76
|
+
raise ValueError("voxel_size_mm must be > 0")
|
|
77
|
+
|
|
78
|
+
self._scoring_routine = (
|
|
79
|
+
ScoringRoutine(scoring_routine)
|
|
80
|
+
if isinstance(scoring_routine, int)
|
|
81
|
+
else scoring_routine
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._source: Optional[torch.Tensor] = None
|
|
85
|
+
self._mu_map: Optional[torch.Tensor] = None
|
|
86
|
+
self._energy_windows: Optional[tuple[list[float], list[float], list[int]]] = (
|
|
87
|
+
None
|
|
88
|
+
)
|
|
89
|
+
self._outputs: Optional[dict[str, torch.Tensor]] = None
|
|
90
|
+
self._output_metadata: Optional[dict[str, Mapping[str, str]]] = None
|
|
91
|
+
self._output_header_paths: Optional[dict[str, Path]] = None
|
|
92
|
+
|
|
93
|
+
self.add_runtime_switch("NN", photon_multiplier)
|
|
94
|
+
|
|
95
|
+
def set_source(self, source: torch.Tensor) -> None:
|
|
96
|
+
self._source = self._validate_tensor(source, name="source")
|
|
97
|
+
|
|
98
|
+
def set_mu_map(self, mu_map: torch.Tensor) -> None:
|
|
99
|
+
self._mu_map = self._validate_tensor(mu_map, name="mu_map")
|
|
100
|
+
|
|
101
|
+
def set_energy_windows(
|
|
102
|
+
self,
|
|
103
|
+
lower_bounds: Union[float, list[float]],
|
|
104
|
+
upper_bounds: Union[float, list[float]],
|
|
105
|
+
scatter_orders: Union[int, list[int]],
|
|
106
|
+
) -> None:
|
|
107
|
+
lowers = (
|
|
108
|
+
[float(lower_bounds)]
|
|
109
|
+
if isinstance(lower_bounds, (int, float))
|
|
110
|
+
else [float(v) for v in lower_bounds]
|
|
111
|
+
)
|
|
112
|
+
uppers = (
|
|
113
|
+
[float(upper_bounds)]
|
|
114
|
+
if isinstance(upper_bounds, (int, float))
|
|
115
|
+
else [float(v) for v in upper_bounds]
|
|
116
|
+
)
|
|
117
|
+
orders = (
|
|
118
|
+
[int(scatter_orders)]
|
|
119
|
+
if isinstance(scatter_orders, (int, float))
|
|
120
|
+
else [int(v) for v in scatter_orders]
|
|
121
|
+
)
|
|
122
|
+
if not (len(lowers) == len(uppers) == len(orders)):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
"lower_bounds, upper_bounds, and scatter_orders must have equal lengths"
|
|
125
|
+
)
|
|
126
|
+
self._energy_windows = (lowers, uppers, orders)
|
|
127
|
+
|
|
128
|
+
def add_config_value(self, index: int, value: Any) -> None:
|
|
129
|
+
self.python_connector.add_config_value(index, value)
|
|
130
|
+
|
|
131
|
+
def add_runtime_switch(self, switch: str, value: Any) -> None:
|
|
132
|
+
self.python_connector.add_runtime_switch(switch, value)
|
|
133
|
+
|
|
134
|
+
def run(
|
|
135
|
+
self, runtime_operator: Optional[RuntimeOperator] = None
|
|
136
|
+
) -> Dict[str, torch.Tensor]:
|
|
137
|
+
self._validate_inputs()
|
|
138
|
+
assert self._source is not None # for type checkers
|
|
139
|
+
assert self._mu_map is not None
|
|
140
|
+
assert self._energy_windows is not None
|
|
141
|
+
|
|
142
|
+
source_zyx = self.to_simind_image_axes(self._source).numpy()
|
|
143
|
+
mu_map_zyx = self.to_simind_image_axes(self._mu_map).numpy()
|
|
144
|
+
self.python_connector.configure_voxel_phantom(
|
|
145
|
+
source=source_zyx,
|
|
146
|
+
mu_map=mu_map_zyx,
|
|
147
|
+
voxel_size_mm=self.voxel_size_mm,
|
|
148
|
+
scoring_routine=self._scoring_routine,
|
|
149
|
+
)
|
|
150
|
+
self.python_connector.set_energy_windows(*self._energy_windows)
|
|
151
|
+
|
|
152
|
+
raw_outputs = self.python_connector.run(runtime_operator=runtime_operator)
|
|
153
|
+
outputs: dict[str, torch.Tensor] = {}
|
|
154
|
+
metadata: dict[str, Mapping[str, str]] = {}
|
|
155
|
+
header_paths: dict[str, Path] = {}
|
|
156
|
+
|
|
157
|
+
for key, value in raw_outputs.items():
|
|
158
|
+
header_path = self.output_dir / f"{self.output_prefix}_{key}.h00"
|
|
159
|
+
if pytomo_simind is not None and header_path.exists():
|
|
160
|
+
projection = pytomo_simind.get_projections(str(header_path)).to(
|
|
161
|
+
dtype=torch.float32
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
projection = torch.from_numpy(
|
|
165
|
+
value.projection.astype(np.float32, copy=False)
|
|
166
|
+
)
|
|
167
|
+
# Fall back to converter-generated .hs header for metadata access.
|
|
168
|
+
header_path = value.header_path
|
|
169
|
+
|
|
170
|
+
outputs[key] = projection
|
|
171
|
+
metadata[key] = value.metadata
|
|
172
|
+
header_paths[key] = Path(header_path).resolve()
|
|
173
|
+
|
|
174
|
+
self._outputs = outputs
|
|
175
|
+
self._output_metadata = metadata
|
|
176
|
+
self._output_header_paths = header_paths
|
|
177
|
+
return outputs
|
|
178
|
+
|
|
179
|
+
def get_outputs(self) -> Dict[str, torch.Tensor]:
|
|
180
|
+
if self._outputs is None:
|
|
181
|
+
raise RuntimeError("Run the connector first to produce outputs")
|
|
182
|
+
return self._outputs
|
|
183
|
+
|
|
184
|
+
def get_output_metadata(self, key: str) -> Mapping[str, str]:
|
|
185
|
+
if self._output_metadata is None:
|
|
186
|
+
raise RuntimeError("Run the connector first to produce outputs")
|
|
187
|
+
if key not in self._output_metadata:
|
|
188
|
+
available = ", ".join(sorted(self._output_metadata))
|
|
189
|
+
raise KeyError(f"Unknown output key {key!r}. Available: {available}")
|
|
190
|
+
return self._output_metadata[key]
|
|
191
|
+
|
|
192
|
+
def get_output_header_path(self, key: str) -> Path:
|
|
193
|
+
if self._output_header_paths is None:
|
|
194
|
+
raise RuntimeError("Run the connector first to produce outputs")
|
|
195
|
+
if key not in self._output_header_paths:
|
|
196
|
+
available = ", ".join(sorted(self._output_header_paths))
|
|
197
|
+
raise KeyError(f"Unknown output key {key!r}. Available: {available}")
|
|
198
|
+
return self._output_header_paths[key]
|
|
199
|
+
|
|
200
|
+
def get_total_output(self, window: int = 1) -> torch.Tensor:
|
|
201
|
+
return self._get_component("tot", window)
|
|
202
|
+
|
|
203
|
+
def get_scatter_output(self, window: int = 1) -> torch.Tensor:
|
|
204
|
+
return self._get_component("sca", window)
|
|
205
|
+
|
|
206
|
+
def get_primary_output(self, window: int = 1) -> torch.Tensor:
|
|
207
|
+
return self._get_component("pri", window)
|
|
208
|
+
|
|
209
|
+
def get_air_output(self, window: int = 1) -> torch.Tensor:
|
|
210
|
+
return self._get_component("air", window)
|
|
211
|
+
|
|
212
|
+
def get_config(self):
|
|
213
|
+
return self.python_connector.get_config()
|
|
214
|
+
|
|
215
|
+
def _get_component(self, prefix: str, window: int) -> torch.Tensor:
|
|
216
|
+
outputs = self.get_outputs()
|
|
217
|
+
key = f"{prefix}_w{window}"
|
|
218
|
+
if key not in outputs:
|
|
219
|
+
available = ", ".join(sorted(outputs))
|
|
220
|
+
raise KeyError(f"Output {key!r} not available. Available: {available}")
|
|
221
|
+
return outputs[key]
|
|
222
|
+
|
|
223
|
+
def _validate_inputs(self) -> None:
|
|
224
|
+
if self._source is None or self._mu_map is None:
|
|
225
|
+
raise ValidationError("Both source and mu_map tensors must be set")
|
|
226
|
+
if self._source.shape != self._mu_map.shape:
|
|
227
|
+
raise ValidationError("source and mu_map must have identical tensor shapes")
|
|
228
|
+
if self._energy_windows is None:
|
|
229
|
+
raise ValidationError("At least one energy window must be configured")
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _validate_tensor(value: torch.Tensor, name: str) -> torch.Tensor:
|
|
233
|
+
if not isinstance(value, torch.Tensor):
|
|
234
|
+
raise TypeError(f"{name} must be a torch.Tensor")
|
|
235
|
+
if value.ndim != 3:
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"{name} must be a 3D tensor with shape (x, y, z); "
|
|
238
|
+
f"got {tuple(value.shape)}"
|
|
239
|
+
)
|
|
240
|
+
return value.detach().cpu().to(dtype=torch.float32).contiguous()
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def from_simind_image_axes(value: torch.Tensor) -> torch.Tensor:
|
|
244
|
+
"""Convert SIMIND image order ``(z, y, x)`` to PyTomography ``(x, y, z)``."""
|
|
245
|
+
if value.ndim != 3:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
"Expected 3D tensor for axis conversion, "
|
|
248
|
+
f"got shape {tuple(value.shape)}"
|
|
249
|
+
)
|
|
250
|
+
return value.permute(2, 1, 0).contiguous().to(dtype=torch.float32)
|
|
251
|
+
|
|
252
|
+
@staticmethod
|
|
253
|
+
def to_simind_image_axes(value: torch.Tensor) -> torch.Tensor:
|
|
254
|
+
"""Convert PyTomography object order ``(x, y, z)`` to SIMIND ``(z, y, x)``."""
|
|
255
|
+
if value.ndim != 3:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
"Expected 3D tensor for axis conversion, "
|
|
258
|
+
f"got shape {tuple(value.shape)}"
|
|
259
|
+
)
|
|
260
|
+
return value.permute(2, 1, 0).contiguous().to(dtype=torch.float32)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
__all__ = ["PyTomographySimindAdaptor", "RuntimeOperator"]
|