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.
Files changed (47) hide show
  1. simind_python_connector/__init__.py +72 -0
  2. simind_python_connector/backends/__init__.py +480 -0
  3. simind_python_connector/backends/base.py +387 -0
  4. simind_python_connector/backends/sirf_backend.py +309 -0
  5. simind_python_connector/backends/stir_backend.py +395 -0
  6. simind_python_connector/builders/__init__.py +19 -0
  7. simind_python_connector/builders/acquisition_builder.py +526 -0
  8. simind_python_connector/builders/image_builder.py +217 -0
  9. simind_python_connector/configs/AnyScan.yaml +420 -0
  10. simind_python_connector/configs/Discovery670.yaml +412 -0
  11. simind_python_connector/configs/Example.yaml +420 -0
  12. simind_python_connector/configs/MLD001_SCAN0.yaml +426 -0
  13. simind_python_connector/configs/__init__.py +41 -0
  14. simind_python_connector/configs/input.smc +51 -0
  15. simind_python_connector/connectors/__init__.py +24 -0
  16. simind_python_connector/connectors/_spacing.py +69 -0
  17. simind_python_connector/connectors/base.py +40 -0
  18. simind_python_connector/connectors/python_connector.py +355 -0
  19. simind_python_connector/connectors/pytomography_adaptor.py +263 -0
  20. simind_python_connector/connectors/sirf_adaptor.py +164 -0
  21. simind_python_connector/connectors/stir_adaptor.py +164 -0
  22. simind_python_connector/converters/__init__.py +16 -0
  23. simind_python_connector/converters/attenuation.py +367 -0
  24. simind_python_connector/converters/dicom_to_stir.py +3 -0
  25. simind_python_connector/converters/simind_to_stir.py +769 -0
  26. simind_python_connector/core/__init__.py +7 -0
  27. simind_python_connector/core/config.py +939 -0
  28. simind_python_connector/core/executor.py +96 -0
  29. simind_python_connector/core/types.py +203 -0
  30. simind_python_connector/data/Schneider2000.json +222 -0
  31. simind_python_connector/data/__init__.py +25 -0
  32. simind_python_connector/data/bone.atn +187 -0
  33. simind_python_connector/data/h2o.atn +92 -0
  34. simind_python_connector/utils/__init__.py +120 -0
  35. simind_python_connector/utils/backend_access.py +121 -0
  36. simind_python_connector/utils/import_helpers.py +74 -0
  37. simind_python_connector/utils/interfile_numpy.py +195 -0
  38. simind_python_connector/utils/interfile_parser.py +175 -0
  39. simind_python_connector/utils/io_utils.py +14 -0
  40. simind_python_connector/utils/simind_utils.py +70 -0
  41. simind_python_connector/utils/sirf_stir_utils.py +194 -0
  42. simind_python_connector/utils/stir_utils.py +485 -0
  43. simind_python_connector-1.0.0.dist-info/METADATA +274 -0
  44. simind_python_connector-1.0.0.dist-info/RECORD +47 -0
  45. simind_python_connector-1.0.0.dist-info/WHEEL +5 -0
  46. simind_python_connector-1.0.0.dist-info/licenses/LICENSE +195 -0
  47. 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"]