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,769 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
from simind_python_connector.core.types import PenetrateOutputType
|
|
9
|
+
from simind_python_connector.utils.backend_access import BACKEND_AVAILABLE, BACKENDS
|
|
10
|
+
from simind_python_connector.utils.import_helpers import get_sirf_types
|
|
11
|
+
from simind_python_connector.utils.interfile_parser import (
|
|
12
|
+
InterfileHeader,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Conditional import for SIRF to avoid CI dependencies
|
|
17
|
+
_, AcquisitionData, SIRF_AVAILABLE = get_sirf_types()
|
|
18
|
+
|
|
19
|
+
# Unpack interfaces needed by converter
|
|
20
|
+
create_acquisition_data = BACKENDS.factories.create_acquisition_data
|
|
21
|
+
AcquisitionDataInterface = BACKENDS.types.AcquisitionDataInterface
|
|
22
|
+
|
|
23
|
+
# Configure logging
|
|
24
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ConversionConfig:
|
|
29
|
+
"""Configuration for SIMIND to STIR conversion."""
|
|
30
|
+
|
|
31
|
+
radius_scale_factor: float = 10.0 # cm to mm
|
|
32
|
+
angle_offset: float = 180.0 # degrees
|
|
33
|
+
default_number_format: str = "float"
|
|
34
|
+
ignored_patterns: List[str] = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self):
|
|
37
|
+
if self.ignored_patterns is None:
|
|
38
|
+
self.ignored_patterns = [
|
|
39
|
+
"program",
|
|
40
|
+
"patient",
|
|
41
|
+
"institution",
|
|
42
|
+
"contact",
|
|
43
|
+
"ID",
|
|
44
|
+
"exam type",
|
|
45
|
+
"detector head",
|
|
46
|
+
"number of images/energy window",
|
|
47
|
+
"time per projection",
|
|
48
|
+
"data description",
|
|
49
|
+
"total number of images",
|
|
50
|
+
"acquisition mode",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConversionRule:
|
|
55
|
+
"""Base class for conversion rules."""
|
|
56
|
+
|
|
57
|
+
def matches(self, line: str) -> bool:
|
|
58
|
+
"""Check if this rule applies to the given line."""
|
|
59
|
+
raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
62
|
+
"""Convert the line and return new line plus updated context."""
|
|
63
|
+
raise NotImplementedError
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RadiusConversionRule(ConversionRule):
|
|
67
|
+
"""Convert radius values with scaling."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, scale_factor: float = 1.0): # Default to no scaling
|
|
70
|
+
self.scale_factor = scale_factor
|
|
71
|
+
|
|
72
|
+
def matches(self, line: str) -> bool:
|
|
73
|
+
return "Radius" in line and ":=" in line
|
|
74
|
+
|
|
75
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
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])
|
|
80
|
+
return f"Radius := {radius_value}", context
|
|
81
|
+
except (ValueError, IndexError) as e:
|
|
82
|
+
logging.warning(f"Failed to convert radius line '{line}': {e}")
|
|
83
|
+
return line, context
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class OrbitFileRule(ConversionRule):
|
|
87
|
+
"""Process non-circular orbit file reference and insert Radii array."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, input_file_dir: Optional[Path] = None):
|
|
90
|
+
self.input_file_dir = input_file_dir
|
|
91
|
+
self.orbit_file_processed = False
|
|
92
|
+
|
|
93
|
+
def matches(self, line: str) -> bool:
|
|
94
|
+
return ";# Non-Uniform Orbit File" in line and not self.orbit_file_processed
|
|
95
|
+
|
|
96
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
97
|
+
try:
|
|
98
|
+
# Extract orbit filename
|
|
99
|
+
orbit_filename = line.split(":=")[-1].strip()
|
|
100
|
+
|
|
101
|
+
# Build full path to orbit file
|
|
102
|
+
if self.input_file_dir:
|
|
103
|
+
orbit_path = self.input_file_dir / orbit_filename
|
|
104
|
+
else:
|
|
105
|
+
orbit_path = Path(orbit_filename)
|
|
106
|
+
|
|
107
|
+
if not orbit_path.exists():
|
|
108
|
+
logging.warning(f"Orbit file not found: {orbit_path}")
|
|
109
|
+
self.orbit_file_processed = True
|
|
110
|
+
return line, context
|
|
111
|
+
|
|
112
|
+
# Read radii from orbit file (first column, in cm)
|
|
113
|
+
radii_cm = []
|
|
114
|
+
with open(orbit_path, "r") as f:
|
|
115
|
+
for file_line in f:
|
|
116
|
+
parts = file_line.strip().split()
|
|
117
|
+
if parts:
|
|
118
|
+
radii_cm.append(float(parts[0]))
|
|
119
|
+
|
|
120
|
+
# Convert cm to mm
|
|
121
|
+
radii_mm = [int(round(r * 10)) for r in radii_cm]
|
|
122
|
+
|
|
123
|
+
# Format as STIR Radii array
|
|
124
|
+
radii_str = ", ".join(str(r) for r in radii_mm)
|
|
125
|
+
radii_line = f"Radii := {{{radii_str}}}"
|
|
126
|
+
|
|
127
|
+
logging.info(
|
|
128
|
+
f"Converted {len(radii_mm)} radii from orbit file {orbit_filename}"
|
|
129
|
+
)
|
|
130
|
+
self.orbit_file_processed = True
|
|
131
|
+
|
|
132
|
+
# Return both the commented orbit file line and the new Radii line
|
|
133
|
+
return (
|
|
134
|
+
f";# Non-Uniform Orbit File := {orbit_filename}\n{radii_line}",
|
|
135
|
+
context,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logging.warning(f"Failed to process orbit file from line '{line}': {e}")
|
|
140
|
+
self.orbit_file_processed = True
|
|
141
|
+
return line, context
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StartAngleConversionRule(ConversionRule):
|
|
145
|
+
"""Convert start angle with offset."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, angle_offset: float = 180.0):
|
|
148
|
+
self.angle_offset = angle_offset
|
|
149
|
+
|
|
150
|
+
def matches(self, line: str) -> bool:
|
|
151
|
+
return "start angle" in line and ":=" in line
|
|
152
|
+
|
|
153
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
154
|
+
try:
|
|
155
|
+
angle = float(line.split()[3]) + self.angle_offset
|
|
156
|
+
return f"start angle := {angle % 360}", context
|
|
157
|
+
except (ValueError, IndexError) as e:
|
|
158
|
+
logging.warning(f"Failed to convert start angle line '{line}': {e}")
|
|
159
|
+
return line, context
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RotationDirectionRule(ConversionRule):
|
|
163
|
+
"""Track rotation direction for context."""
|
|
164
|
+
|
|
165
|
+
def matches(self, line: str) -> bool:
|
|
166
|
+
return "CCW" in line or "CW" in line
|
|
167
|
+
|
|
168
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
169
|
+
if "CCW" in line:
|
|
170
|
+
context["rotation_direction"] = "CCW"
|
|
171
|
+
elif "CW" in line:
|
|
172
|
+
context["rotation_direction"] = "CW"
|
|
173
|
+
return line, context
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class NumberFormatRule(ConversionRule):
|
|
177
|
+
"""Convert number format specifications."""
|
|
178
|
+
|
|
179
|
+
def matches(self, line: str) -> bool:
|
|
180
|
+
return "!number format := short float" in line
|
|
181
|
+
|
|
182
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
183
|
+
return "!number format := float", context
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class OrbitConversionRule(ConversionRule):
|
|
187
|
+
"""Convert orbit specifications."""
|
|
188
|
+
|
|
189
|
+
def matches(self, line: str) -> bool:
|
|
190
|
+
return "orbit" in line and "noncircular" in line
|
|
191
|
+
|
|
192
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
193
|
+
return "orbit := non-circular", context
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ImageDurationRule(ConversionRule):
|
|
197
|
+
"""Convert image duration to STIR format."""
|
|
198
|
+
|
|
199
|
+
def matches(self, line: str) -> bool:
|
|
200
|
+
return "image duration" in line and ":=" in line
|
|
201
|
+
|
|
202
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
203
|
+
try:
|
|
204
|
+
parts = line.split()
|
|
205
|
+
duration = parts[4]
|
|
206
|
+
return (
|
|
207
|
+
f"number of time frames := 1\nimage duration (sec) [1] := {duration}",
|
|
208
|
+
context,
|
|
209
|
+
)
|
|
210
|
+
except (IndexError, ValueError) as e:
|
|
211
|
+
logging.warning(f"Failed to convert image duration line '{line}': {e}")
|
|
212
|
+
return line, context
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class EnergyWindowRule(ConversionRule):
|
|
216
|
+
"""Convert energy window specifications."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, window_type: str):
|
|
219
|
+
self.window_type = window_type # "lower" or "upper"
|
|
220
|
+
|
|
221
|
+
def matches(self, line: str) -> bool:
|
|
222
|
+
return f";energy window {self.window_type} level" in line
|
|
223
|
+
|
|
224
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
225
|
+
try:
|
|
226
|
+
value = line.split()[-1]
|
|
227
|
+
return f"energy window {self.window_type} level[1] := {value}", context
|
|
228
|
+
except IndexError as e:
|
|
229
|
+
logging.warning(f"Failed to convert energy window line '{line}': {e}")
|
|
230
|
+
return line, context
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class DataFileNameRule(ConversionRule):
|
|
234
|
+
"""Convert data file name references."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, override_filename: Optional[str] = None):
|
|
237
|
+
self.override_filename = override_filename
|
|
238
|
+
|
|
239
|
+
def matches(self, line: str) -> bool:
|
|
240
|
+
return "!name of data file" in line
|
|
241
|
+
|
|
242
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
243
|
+
try:
|
|
244
|
+
if self.override_filename:
|
|
245
|
+
# Use the override filename
|
|
246
|
+
return f"!name of data file := {self.override_filename}", context
|
|
247
|
+
else:
|
|
248
|
+
# Use existing filename from the header line
|
|
249
|
+
file = Path(line.split()[5])
|
|
250
|
+
return f"!name of data file := {file.stem + file.suffix}", context
|
|
251
|
+
except IndexError as e:
|
|
252
|
+
logging.warning(f"Failed to convert data file name line '{line}': {e}")
|
|
253
|
+
return line, context
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class IgnorePatternRule(ConversionRule):
|
|
257
|
+
"""Add semicolon to ignored pattern lines."""
|
|
258
|
+
|
|
259
|
+
def __init__(self, patterns: List[str]):
|
|
260
|
+
self.patterns = patterns
|
|
261
|
+
|
|
262
|
+
def matches(self, line: str) -> bool:
|
|
263
|
+
return any(pattern in line for pattern in self.patterns)
|
|
264
|
+
|
|
265
|
+
def convert(self, line: str, context: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
|
266
|
+
return ";" + line, context
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class SimindToStirConverter:
|
|
270
|
+
"""
|
|
271
|
+
Enhanced SIMIND to STIR converter with configurable rules and editing
|
|
272
|
+
capabilities.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
def __init__(self, config: Optional[ConversionConfig] = None):
|
|
276
|
+
self.config = config or ConversionConfig()
|
|
277
|
+
self.input_file_dir = None # Will be set during convert_file
|
|
278
|
+
self.rules = self._create_rules()
|
|
279
|
+
self.logger = logging.getLogger(__name__)
|
|
280
|
+
|
|
281
|
+
def _create_rules(
|
|
282
|
+
self, data_file_override: Optional[str] = None
|
|
283
|
+
) -> List[ConversionRule]:
|
|
284
|
+
"""Create conversion rules in order of priority."""
|
|
285
|
+
return [
|
|
286
|
+
IgnorePatternRule(self.config.ignored_patterns),
|
|
287
|
+
OrbitFileRule(self.input_file_dir), # Process orbit file before other rules
|
|
288
|
+
RadiusConversionRule(self.config.radius_scale_factor),
|
|
289
|
+
StartAngleConversionRule(self.config.angle_offset),
|
|
290
|
+
RotationDirectionRule(),
|
|
291
|
+
NumberFormatRule(),
|
|
292
|
+
OrbitConversionRule(),
|
|
293
|
+
ImageDurationRule(),
|
|
294
|
+
EnergyWindowRule("lower"),
|
|
295
|
+
EnergyWindowRule("upper"),
|
|
296
|
+
DataFileNameRule(data_file_override),
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
def convert_line(
|
|
300
|
+
self, line: str, context: Dict[str, Any]
|
|
301
|
+
) -> Tuple[str, Dict[str, Any]]:
|
|
302
|
+
"""Convert a single line using the first matching rule."""
|
|
303
|
+
line = line.strip()
|
|
304
|
+
|
|
305
|
+
for rule in self.rules:
|
|
306
|
+
if rule.matches(line):
|
|
307
|
+
return rule.convert(line, context)
|
|
308
|
+
|
|
309
|
+
return line, context
|
|
310
|
+
|
|
311
|
+
@contextmanager
|
|
312
|
+
def _safe_file_operation(self, input_file: str, output_file: str):
|
|
313
|
+
"""Context manager for safe file operations with cleanup."""
|
|
314
|
+
temp_file = output_file + ".tmp"
|
|
315
|
+
try:
|
|
316
|
+
with open(input_file, "r") as f_in, open(temp_file, "w") as f_out:
|
|
317
|
+
yield f_in, f_out
|
|
318
|
+
|
|
319
|
+
# Only replace original if conversion succeeded
|
|
320
|
+
if os.path.exists(temp_file):
|
|
321
|
+
if os.path.exists(output_file):
|
|
322
|
+
os.remove(output_file)
|
|
323
|
+
os.rename(temp_file, output_file)
|
|
324
|
+
except Exception:
|
|
325
|
+
# Clean up temp file if something went wrong
|
|
326
|
+
if os.path.exists(temp_file):
|
|
327
|
+
os.remove(temp_file)
|
|
328
|
+
raise
|
|
329
|
+
|
|
330
|
+
def convert_file(
|
|
331
|
+
self,
|
|
332
|
+
input_filename: str,
|
|
333
|
+
output_filename: Optional[str] = None,
|
|
334
|
+
data_file: Optional[str] = None,
|
|
335
|
+
return_object: bool = False,
|
|
336
|
+
) -> Optional[Union[AcquisitionData, AcquisitionDataInterface]]:
|
|
337
|
+
"""Convert a SIMIND header file to STIR format.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Backend-agnostic acquisition data if return_object=True, None otherwise
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
if not input_filename.endswith(".h00"):
|
|
344
|
+
raise ValueError("Input file must have .h00 extension")
|
|
345
|
+
|
|
346
|
+
if output_filename is None:
|
|
347
|
+
output_filename = input_filename.replace(".h00", ".hs")
|
|
348
|
+
|
|
349
|
+
# Set input directory for orbit file resolution
|
|
350
|
+
self.input_file_dir = Path(input_filename).parent
|
|
351
|
+
|
|
352
|
+
# Create rules with optional data file override
|
|
353
|
+
if data_file is not None:
|
|
354
|
+
self.rules = self._create_rules(data_file)
|
|
355
|
+
else:
|
|
356
|
+
# Recreate rules to pick up the new input_file_dir
|
|
357
|
+
self.rules = self._create_rules()
|
|
358
|
+
|
|
359
|
+
context = {"rotation_direction": None}
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
with self._safe_file_operation(input_filename, output_filename) as (
|
|
363
|
+
f_in,
|
|
364
|
+
f_out,
|
|
365
|
+
):
|
|
366
|
+
for line_num, line in enumerate(f_in, 1):
|
|
367
|
+
try:
|
|
368
|
+
converted_line, context = self.convert_line(line, context)
|
|
369
|
+
f_out.write(converted_line + "\n")
|
|
370
|
+
except Exception as e:
|
|
371
|
+
self.logger.error(
|
|
372
|
+
f"Error converting line {line_num}: {line.strip()}"
|
|
373
|
+
)
|
|
374
|
+
self.logger.error(f"Error details: {e}")
|
|
375
|
+
# Write original line as fallback
|
|
376
|
+
f_out.write(line)
|
|
377
|
+
|
|
378
|
+
self.logger.info(
|
|
379
|
+
f"Successfully converted {input_filename} to {output_filename}"
|
|
380
|
+
)
|
|
381
|
+
if data_file:
|
|
382
|
+
self.logger.info(f"Used data file override: {data_file}")
|
|
383
|
+
|
|
384
|
+
if return_object:
|
|
385
|
+
if BACKEND_AVAILABLE:
|
|
386
|
+
# Return wrapped backend-agnostic object
|
|
387
|
+
return create_acquisition_data(output_filename)
|
|
388
|
+
else:
|
|
389
|
+
return AcquisitionData(output_filename)
|
|
390
|
+
|
|
391
|
+
except Exception as e:
|
|
392
|
+
self.logger.error(f"Failed to convert {input_filename}: {e}")
|
|
393
|
+
raise
|
|
394
|
+
finally:
|
|
395
|
+
# Reset rules to default after conversion
|
|
396
|
+
if data_file is not None:
|
|
397
|
+
self.rules = self._create_rules()
|
|
398
|
+
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
def create_penetrate_headers_from_template(
|
|
402
|
+
self, h00_file: str, output_prefix: str, output_dir: str
|
|
403
|
+
) -> Dict[str, AcquisitionData]:
|
|
404
|
+
"""
|
|
405
|
+
Create multiple STIR headers for penetrate routine from single .h00 template.
|
|
406
|
+
|
|
407
|
+
The penetrate routine creates only one .h00 file pointing to a
|
|
408
|
+
non-existent .a00,
|
|
409
|
+
but multiple .bXX binary files. This method creates separate .hs headers
|
|
410
|
+
for each .bXX file.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
h00_file: Path to the single .h00 template file from penetrate routine
|
|
414
|
+
output_prefix: Prefix used for output files
|
|
415
|
+
output_dir: Directory containing .bXX files
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Dictionary mapping component names to AcquisitionData objects
|
|
419
|
+
"""
|
|
420
|
+
output_dir = Path(output_dir)
|
|
421
|
+
outputs = {}
|
|
422
|
+
|
|
423
|
+
# First convert the template .h00 to .hs format
|
|
424
|
+
template_hs = h00_file.replace(".h00", "_template.hs")
|
|
425
|
+
self.convert_file(h00_file, template_hs)
|
|
426
|
+
|
|
427
|
+
template_header = InterfileHeader.from_file(template_hs)
|
|
428
|
+
|
|
429
|
+
# Look for .bXX files and create headers for each
|
|
430
|
+
for component in PenetrateOutputType:
|
|
431
|
+
binary_file = output_dir / f"{output_prefix}.b{component.value:02d}"
|
|
432
|
+
|
|
433
|
+
if binary_file.exists():
|
|
434
|
+
try:
|
|
435
|
+
# Create .hs file for this component
|
|
436
|
+
component_hs = output_dir / (
|
|
437
|
+
f"{output_prefix}_component_{component.value:02d}.hs"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
component_header = template_header.copy()
|
|
441
|
+
study_base = Path(binary_file.name).stem
|
|
442
|
+
component_header.set("!name of data file", binary_file.name)
|
|
443
|
+
component_header.set(
|
|
444
|
+
"patient name", f"{component.slug}_{binary_file.name}"
|
|
445
|
+
)
|
|
446
|
+
component_header.set("!study ID", study_base)
|
|
447
|
+
component_header.set("data description", component.description)
|
|
448
|
+
component_header.write(component_hs)
|
|
449
|
+
|
|
450
|
+
# Create AcquisitionData object (backend-agnostic)
|
|
451
|
+
acquisition_data = self._load_penetrate_output(component_hs)
|
|
452
|
+
|
|
453
|
+
# Generate component name
|
|
454
|
+
outputs[component.slug] = acquisition_data
|
|
455
|
+
|
|
456
|
+
self.logger.info(
|
|
457
|
+
f"Created STIR header for {component.slug}: {component_hs.name}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
except Exception as e:
|
|
461
|
+
self.logger.warning(
|
|
462
|
+
f"Failed to create header for {binary_file}: {e}"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Clean up template file
|
|
466
|
+
if os.path.exists(template_hs):
|
|
467
|
+
os.remove(template_hs)
|
|
468
|
+
|
|
469
|
+
return outputs
|
|
470
|
+
|
|
471
|
+
def _load_penetrate_output(self, header_path: Path):
|
|
472
|
+
"""Best-effort loading of penetrate output respecting missing backends."""
|
|
473
|
+
try:
|
|
474
|
+
if BACKEND_AVAILABLE and create_acquisition_data is not None:
|
|
475
|
+
return create_acquisition_data(str(header_path))
|
|
476
|
+
if SIRF_AVAILABLE and AcquisitionData is not type(None):
|
|
477
|
+
return AcquisitionData(str(header_path))
|
|
478
|
+
except Exception as exc: # pragma: no cover - backend-specific failures
|
|
479
|
+
self.logger.warning(
|
|
480
|
+
"Falling back to file path for %s due to load error: %s",
|
|
481
|
+
header_path,
|
|
482
|
+
exc,
|
|
483
|
+
)
|
|
484
|
+
return str(header_path)
|
|
485
|
+
|
|
486
|
+
def find_penetrate_h00_file(
|
|
487
|
+
self, output_prefix: str, output_dir: str
|
|
488
|
+
) -> Optional[str]:
|
|
489
|
+
"""
|
|
490
|
+
Find the single .h00 file created by penetrate routine.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
output_prefix: Prefix used for output files
|
|
494
|
+
output_dir: Directory containing output files
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
Path to the .h00 file, or None if not found
|
|
498
|
+
"""
|
|
499
|
+
output_dir = Path(output_dir)
|
|
500
|
+
|
|
501
|
+
# Look for .h00 file with the output prefix
|
|
502
|
+
h00_files = list(output_dir.glob(f"{output_prefix}*.h00"))
|
|
503
|
+
|
|
504
|
+
if len(h00_files) == 1:
|
|
505
|
+
return str(h00_files[0])
|
|
506
|
+
elif len(h00_files) == 0:
|
|
507
|
+
self.logger.warning(f"No .h00 file found with prefix {output_prefix}")
|
|
508
|
+
return None
|
|
509
|
+
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
|
+
)
|
|
514
|
+
# Return the first one as fallback
|
|
515
|
+
return str(h00_files[0])
|
|
516
|
+
|
|
517
|
+
def read_parameter(self, filename: str, parameter: str) -> Optional[str]:
|
|
518
|
+
"""Read a parameter from a header file."""
|
|
519
|
+
if not filename.endswith((".hs", ".h00")):
|
|
520
|
+
self.logger.error("File must have .hs or .h00 extension")
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
try:
|
|
524
|
+
header = InterfileHeader.from_file(filename)
|
|
525
|
+
except FileNotFoundError:
|
|
526
|
+
self.logger.error(f"File not found: {filename}")
|
|
527
|
+
return None
|
|
528
|
+
except Exception as e:
|
|
529
|
+
self.logger.error(f"Error reading file {filename}: {e}")
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
return header.get(parameter)
|
|
533
|
+
|
|
534
|
+
def edit_parameter(
|
|
535
|
+
self,
|
|
536
|
+
filename: str,
|
|
537
|
+
parameter: str,
|
|
538
|
+
value: Union[str, float],
|
|
539
|
+
return_object: bool = False,
|
|
540
|
+
) -> Optional[AcquisitionData]:
|
|
541
|
+
"""Edit a parameter in a header file."""
|
|
542
|
+
if not filename.endswith((".hs", ".h00")):
|
|
543
|
+
self.logger.error("File must have .hs or .h00 extension")
|
|
544
|
+
return None
|
|
545
|
+
|
|
546
|
+
try:
|
|
547
|
+
header = InterfileHeader.from_file(filename)
|
|
548
|
+
if header.get(parameter) is None:
|
|
549
|
+
self.logger.warning(f"Parameter '{parameter}' not found in {filename}")
|
|
550
|
+
header.set(parameter, value)
|
|
551
|
+
header.write(filename)
|
|
552
|
+
|
|
553
|
+
self.logger.info(f"Parameter {parameter} set to {value}")
|
|
554
|
+
|
|
555
|
+
if return_object:
|
|
556
|
+
if BACKEND_AVAILABLE:
|
|
557
|
+
return create_acquisition_data(filename)
|
|
558
|
+
else:
|
|
559
|
+
return AcquisitionData(filename)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
except Exception as e:
|
|
563
|
+
self.logger.error(f"Error editing parameter in {filename}: {e}")
|
|
564
|
+
raise
|
|
565
|
+
|
|
566
|
+
def add_parameter(
|
|
567
|
+
self,
|
|
568
|
+
filename: str,
|
|
569
|
+
parameter: str,
|
|
570
|
+
value: Union[str, float],
|
|
571
|
+
line_number: int = 0,
|
|
572
|
+
return_object: bool = False,
|
|
573
|
+
) -> Optional[AcquisitionData]:
|
|
574
|
+
"""Add a parameter at a specific line number in an Interfile header file."""
|
|
575
|
+
if not filename.endswith((".hs", ".h00")):
|
|
576
|
+
self.logger.error("File must have .hs or .h00 extension")
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
header = InterfileHeader.from_file(filename)
|
|
581
|
+
if header.get(parameter) is not None:
|
|
582
|
+
self.logger.info(
|
|
583
|
+
f"Parameter {parameter} already exists, editing instead of adding"
|
|
584
|
+
)
|
|
585
|
+
return self.edit_parameter(filename, parameter, value, return_object)
|
|
586
|
+
|
|
587
|
+
header.insert(line_number, parameter, value)
|
|
588
|
+
header.write(filename)
|
|
589
|
+
self.logger.info(
|
|
590
|
+
f"Parameter {parameter} added with value {value} at line {line_number}"
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if return_object:
|
|
594
|
+
if BACKEND_AVAILABLE:
|
|
595
|
+
return create_acquisition_data(filename)
|
|
596
|
+
else:
|
|
597
|
+
return AcquisitionData(filename)
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
except Exception as e:
|
|
601
|
+
self.logger.error(f"Error adding parameter to {filename}: {e}")
|
|
602
|
+
raise
|
|
603
|
+
|
|
604
|
+
def validate_and_fix_scaling_factors(
|
|
605
|
+
self, filename: str, image_data, tolerance: float = 0.1
|
|
606
|
+
) -> bool:
|
|
607
|
+
"""
|
|
608
|
+
Validate scaling factors against image voxel sizes and fix if they differ
|
|
609
|
+
within tolerance.
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
filename: Path to the header file
|
|
613
|
+
image_data: SIRF ImageData object to get voxel sizes from
|
|
614
|
+
tolerance: Maximum allowed difference in mm (default 0.1mm)
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
bool: True if scaling factors were within tolerance, False if they were
|
|
618
|
+
corrected
|
|
619
|
+
"""
|
|
620
|
+
if not filename.endswith((".hs", ".h00")):
|
|
621
|
+
self.logger.error("File must have .hs or .h00 extension")
|
|
622
|
+
return False
|
|
623
|
+
|
|
624
|
+
# Get voxel sizes from image data
|
|
625
|
+
voxel_sizes = image_data.voxel_sizes()
|
|
626
|
+
image_voxel_x = voxel_sizes[0] # mm
|
|
627
|
+
image_voxel_y = voxel_sizes[1] # mm
|
|
628
|
+
|
|
629
|
+
# Read current scaling factors from file
|
|
630
|
+
current_scaling_x = self.read_parameter(
|
|
631
|
+
filename, "scaling factor (mm/pixel) [1]"
|
|
632
|
+
)
|
|
633
|
+
current_scaling_y = self.read_parameter(
|
|
634
|
+
filename, "scaling factor (mm/pixel) [2]"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if current_scaling_x is None or current_scaling_y is None:
|
|
638
|
+
self.logger.warning(f"Could not read scaling factors from {filename}")
|
|
639
|
+
# Set them to image voxel sizes
|
|
640
|
+
self.edit_parameter(
|
|
641
|
+
filename, "scaling factor (mm/pixel) [1]", image_voxel_x
|
|
642
|
+
)
|
|
643
|
+
self.edit_parameter(
|
|
644
|
+
filename, "scaling factor (mm/pixel) [2]", image_voxel_y
|
|
645
|
+
)
|
|
646
|
+
self.logger.info(
|
|
647
|
+
f"Set scaling factors to image voxel sizes: "
|
|
648
|
+
f"[{image_voxel_x}, {image_voxel_y}]"
|
|
649
|
+
)
|
|
650
|
+
return False
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
current_x = float(current_scaling_x)
|
|
654
|
+
current_y = float(current_scaling_y)
|
|
655
|
+
|
|
656
|
+
# Check if they're different but within tolerance
|
|
657
|
+
diff_x = abs(current_x - image_voxel_x)
|
|
658
|
+
diff_y = abs(current_y - image_voxel_y)
|
|
659
|
+
|
|
660
|
+
if diff_x <= tolerance and diff_y <= tolerance:
|
|
661
|
+
self.logger.debug(
|
|
662
|
+
f"Scaling factors are within tolerance: current=[{current_x}, "
|
|
663
|
+
f"{current_y}], image=[{image_voxel_x}, {image_voxel_y}]"
|
|
664
|
+
)
|
|
665
|
+
return True
|
|
666
|
+
else:
|
|
667
|
+
# Fix the scaling factors to match image voxel sizes
|
|
668
|
+
self.logger.info(
|
|
669
|
+
f"Scaling factors differ beyond tolerance ({tolerance}mm)"
|
|
670
|
+
)
|
|
671
|
+
self.logger.info(f" Current: [{current_x}, {current_y}]")
|
|
672
|
+
self.logger.info(f" Image: [{image_voxel_x}, {image_voxel_y}]")
|
|
673
|
+
self.logger.info(f" Diff: [{diff_x:.6f}, {diff_y:.6f}]")
|
|
674
|
+
|
|
675
|
+
self.edit_parameter(
|
|
676
|
+
filename, "scaling factor (mm/pixel) [1]", image_voxel_x
|
|
677
|
+
)
|
|
678
|
+
self.edit_parameter(
|
|
679
|
+
filename, "scaling factor (mm/pixel) [2]", image_voxel_y
|
|
680
|
+
)
|
|
681
|
+
self.logger.info("Updated scaling factors to match image voxel sizes")
|
|
682
|
+
return False
|
|
683
|
+
|
|
684
|
+
except ValueError as e:
|
|
685
|
+
self.logger.error(f"Error parsing scaling factors: {e}")
|
|
686
|
+
# Set them to image voxel sizes as fallback
|
|
687
|
+
self.edit_parameter(
|
|
688
|
+
filename, "scaling factor (mm/pixel) [1]", image_voxel_x
|
|
689
|
+
)
|
|
690
|
+
self.edit_parameter(
|
|
691
|
+
filename, "scaling factor (mm/pixel) [2]", image_voxel_y
|
|
692
|
+
)
|
|
693
|
+
return False
|
|
694
|
+
|
|
695
|
+
def add_custom_rule(self, rule: ConversionRule, priority: int = None):
|
|
696
|
+
"""Add a custom conversion rule."""
|
|
697
|
+
if priority is None:
|
|
698
|
+
self.rules.append(rule)
|
|
699
|
+
else:
|
|
700
|
+
self.rules.insert(priority, rule)
|
|
701
|
+
|
|
702
|
+
def validate_and_correct_radius(
|
|
703
|
+
self,
|
|
704
|
+
output_file: str,
|
|
705
|
+
template_file: Optional[str] = None,
|
|
706
|
+
tolerance_factor: float = 2.0,
|
|
707
|
+
) -> bool:
|
|
708
|
+
"""
|
|
709
|
+
Validate radius in output file against template and correct if needed.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
output_file: Path to the output .hs file
|
|
713
|
+
template_file: Path to the template .hs file for comparison
|
|
714
|
+
tolerance_factor: Factor by which radius can differ before correction
|
|
715
|
+
|
|
716
|
+
Returns:
|
|
717
|
+
bool: True if radius was within tolerance, False if corrected
|
|
718
|
+
"""
|
|
719
|
+
if template_file is None or not os.path.exists(template_file):
|
|
720
|
+
self.logger.debug(
|
|
721
|
+
"No template file provided or found - skipping radius validation"
|
|
722
|
+
)
|
|
723
|
+
return True
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
# Read radius from template
|
|
727
|
+
template_radius = self.read_parameter(template_file, "Radius")
|
|
728
|
+
if template_radius is None:
|
|
729
|
+
self.logger.debug("No radius found in template file")
|
|
730
|
+
return True
|
|
731
|
+
|
|
732
|
+
template_radius = float(template_radius)
|
|
733
|
+
|
|
734
|
+
# Read radius from output
|
|
735
|
+
output_radius = self.read_parameter(output_file, "Radius")
|
|
736
|
+
if output_radius is None:
|
|
737
|
+
self.logger.warning(f"No radius found in output file {output_file}")
|
|
738
|
+
return True
|
|
739
|
+
|
|
740
|
+
output_radius = float(output_radius)
|
|
741
|
+
|
|
742
|
+
# Check if radii are within tolerance
|
|
743
|
+
ratio = output_radius / template_radius
|
|
744
|
+
if 1 / tolerance_factor <= ratio <= tolerance_factor:
|
|
745
|
+
self.logger.debug(
|
|
746
|
+
"Radius validation passed: "
|
|
747
|
+
f"template={template_radius}, output={output_radius}"
|
|
748
|
+
)
|
|
749
|
+
return True
|
|
750
|
+
|
|
751
|
+
# Radius mismatch detected - attempt correction
|
|
752
|
+
self.logger.warning(
|
|
753
|
+
f"Radius mismatch detected in {output_file}:\n"
|
|
754
|
+
f" Template: {template_radius} mm\n"
|
|
755
|
+
f" Output: {output_radius} mm\n"
|
|
756
|
+
f" Ratio: {ratio:.2f}"
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
# Correct the radius by setting it to template value
|
|
760
|
+
self.edit_parameter(output_file, "Radius", template_radius)
|
|
761
|
+
self.logger.info(
|
|
762
|
+
f"Corrected radius in {output_file} to {template_radius} mm"
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
return False
|
|
766
|
+
|
|
767
|
+
except (ValueError, TypeError) as e:
|
|
768
|
+
self.logger.error(f"Error during radius validation: {e}")
|
|
769
|
+
return True
|