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.
Files changed (24) hide show
  1. simind_python_connector/__init__.py +1 -1
  2. simind_python_connector/backends/__init__.py +7 -0
  3. simind_python_connector/builders/acquisition_builder.py +77 -29
  4. simind_python_connector/builders/image_builder.py +18 -6
  5. simind_python_connector/connectors/__init__.py +23 -4
  6. simind_python_connector/connectors/_spacing.py +13 -7
  7. simind_python_connector/connectors/python_connector.py +114 -22
  8. simind_python_connector/connectors/pytomography_adaptor.py +5 -0
  9. simind_python_connector/connectors/sirf_adaptor.py +3 -0
  10. simind_python_connector/connectors/stir_adaptor.py +3 -0
  11. simind_python_connector/converters/attenuation.py +3 -1
  12. simind_python_connector/converters/simind_to_stir.py +12 -10
  13. simind_python_connector/core/config.py +208 -58
  14. simind_python_connector/core/executor.py +36 -7
  15. simind_python_connector/utils/import_helpers.py +4 -0
  16. simind_python_connector/utils/interfile_numpy.py +18 -8
  17. simind_python_connector/utils/interfile_parser.py +4 -2
  18. simind_python_connector/utils/simind_utils.py +50 -11
  19. simind_python_connector/utils/stir_utils.py +7 -2
  20. {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/METADATA +12 -3
  21. {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/RECORD +24 -24
  22. {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/WHEEL +1 -1
  23. {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.dist-info}/licenses/LICENSE +0 -0
  24. {simind_python_connector-1.0.0.dist-info → simind_python_connector-2.0.0.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__ = "1.0.0"
17
+ __version__ = "2.0.0"
18
18
 
19
19
 
20
20
  def __getattr__(name: str) -> Any:
@@ -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(flipped)
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
- num_projections = int(self.header.get("!number of projections", 1))
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
- pixel_array_list = np.array_split(
204
- self.pixel_array, len(self.energy_windows), axis=2
205
- )
206
-
207
- acqdata_list = []
208
- for idx, ew in enumerate(self.energy_windows):
209
- # Update header for this energy window.
210
- self.header["energy window lower level[1]"] = ew["lower"]
211
- self.header["energy window upper level[1]"] = ew["upper"]
212
- suffix = f"_ew{idx + 1}"
213
- output_path = output_path_base + suffix
214
- self.pixel_array = pixel_array_list[idx]
215
- acqdata = self.build(output_path=output_path)
216
- acqdata_list.append(acqdata)
217
- return acqdata_list
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), 0
367
+ float(time_per_projection) * float(num_frames),
368
+ 0,
331
369
  )
332
370
  )
333
371
  )
334
- else:
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
- self.pixel_array = ds.pixel_array
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
- return np.asarray(self.pixel_array, dtype=np.float32)
83
-
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
- return np.zeros((dim_z, dim_y, dim_x), dtype=np.float32)
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
- from .pytomography_adaptor import PyTomographySimindAdaptor
11
- from .sirf_adaptor import SirfSimindAdaptor
12
- from .stir_adaptor import StirSimindAdaptor
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
- return float(values[3])
21
- if len(values) >= 3:
22
- return float(values[2])
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 (3, 2):
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 (3, 2):
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[2])
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
- routine = (
131
- ScoringRoutine(scoring_routine)
132
- if isinstance(scoring_routine, int)
133
- else scoring_routine
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, dim_y)
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(self.output_dir / self.output_prefix),
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
- self.set_runtime_switches(runtime_operator.switches)
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
- 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)
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(self.output_dir.glob(f"*{self.output_prefix}*.h00"))
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(self.output_dir.glob(f"*{self.output_prefix}*.hs"))
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
- filepath = Path(file_path) / filename
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 = 10.0 # cm to mm
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
- # Assume .h00 files contain radius in mm (SIMIND's inconsistent behavior)
78
- # So use radius value as-is for STIR (which expects mm)
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
- # Multiple .h00 files - this might be scattwin, not penetrate
511
- self.logger.warning(
512
- "Multiple .h00 files found - this may not be penetrate routine output"
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."""