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,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