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,195 @@
1
+ """
2
+ Utilities for loading Interfile projection data directly into NumPy.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Mapping, Optional, Sequence, Union
11
+
12
+ import numpy as np
13
+
14
+ from .interfile_parser import parse_interfile_header
15
+
16
+
17
+ MatrixShape = Sequence[int]
18
+ HeaderInput = Union[str, Path]
19
+
20
+ _MATRIX_SIZE_PATTERN = re.compile(r"!?matrix size\s*\[(\d+)\]", re.IGNORECASE)
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class InterfileArray:
25
+ """NumPy payload and file references parsed from an Interfile header."""
26
+
27
+ array: np.ndarray
28
+ header_path: Path
29
+ data_path: Path
30
+ metadata: dict[str, str]
31
+
32
+
33
+ def _lookup_header_value(header: Mapping[str, str], *keys: str) -> Optional[str]:
34
+ """Return the first matching value for a header key (case-insensitive)."""
35
+ lower_map = {key.lower(): value for key, value in header.items()}
36
+ for key in keys:
37
+ value = lower_map.get(key.lower())
38
+ if value is not None:
39
+ return value
40
+ return None
41
+
42
+
43
+ def _extract_matrix_shape(header: Mapping[str, str]) -> tuple[int, ...]:
44
+ """Extract matrix sizes and return NumPy shape in memory order."""
45
+ sizes_by_index: dict[int, int] = {}
46
+
47
+ for key, raw_value in header.items():
48
+ match = _MATRIX_SIZE_PATTERN.fullmatch(key.strip())
49
+ if not match:
50
+ continue
51
+ axis = int(match.group(1))
52
+ sizes_by_index[axis] = int(raw_value)
53
+
54
+ if not sizes_by_index:
55
+ raise ValueError("No 'matrix size [i]' entries found in Interfile header")
56
+
57
+ ordered_axes = [sizes_by_index[idx] for idx in sorted(sizes_by_index)]
58
+ if any(size <= 0 for size in ordered_axes):
59
+ raise ValueError(f"Invalid matrix sizes in header: {ordered_axes}")
60
+
61
+ # Interfile stores axis [1] as the fastest changing index.
62
+ return tuple(reversed(ordered_axes))
63
+
64
+
65
+ def _extract_numpy_dtype(header: Mapping[str, str]) -> np.dtype:
66
+ """Infer the binary payload dtype from Interfile metadata."""
67
+ number_format = (
68
+ _lookup_header_value(header, "!number format", "number format") or "float"
69
+ ).strip()
70
+ bytes_per_pixel_raw = _lookup_header_value(
71
+ header, "!number of bytes per pixel", "number of bytes per pixel"
72
+ )
73
+ bytes_per_pixel = int(float(bytes_per_pixel_raw)) if bytes_per_pixel_raw else 4
74
+
75
+ number_format_lc = number_format.lower()
76
+ if "float" in number_format_lc:
77
+ kind = "f"
78
+ elif "unsigned" in number_format_lc:
79
+ kind = "u"
80
+ elif "signed" in number_format_lc or "integer" in number_format_lc:
81
+ kind = "i"
82
+ else:
83
+ raise ValueError(f"Unsupported Interfile number format: {number_format!r}")
84
+
85
+ dtype = np.dtype(f"{kind}{bytes_per_pixel}")
86
+ byte_order = (
87
+ _lookup_header_value(header, "imagedata byte order", "!imagedata byte order")
88
+ or ""
89
+ ).lower()
90
+
91
+ if "big" in byte_order:
92
+ return dtype.newbyteorder(">")
93
+ if "little" in byte_order:
94
+ return dtype.newbyteorder("<")
95
+ return dtype.newbyteorder("=")
96
+
97
+
98
+ def _extract_projection_count(header: Mapping[str, str]) -> Optional[int]:
99
+ """Extract projection/image count used for 3D SPECT sinograms."""
100
+ raw = _lookup_header_value(
101
+ header,
102
+ "!number of projections",
103
+ "number of projections",
104
+ "!total number of images",
105
+ "total number of images",
106
+ "!number of images/energy window",
107
+ "number of images/energy window",
108
+ )
109
+ if raw is None:
110
+ return None
111
+ try:
112
+ value = int(float(raw))
113
+ except (TypeError, ValueError):
114
+ return None
115
+ return value if value > 0 else None
116
+
117
+
118
+ def _infer_leading_axis_count(
119
+ metadata: Mapping[str, str], plane_elements: int, payload_elements: int
120
+ ) -> Optional[int]:
121
+ """Infer leading stack axis for headers that only declare 2D planes."""
122
+ if plane_elements <= 0:
123
+ return None
124
+
125
+ # Prefer explicit projection/image-count fields from the header.
126
+ projection_count = _extract_projection_count(metadata)
127
+ if projection_count is not None:
128
+ return projection_count
129
+
130
+ # Otherwise infer directly from payload length when possible.
131
+ if payload_elements % plane_elements == 0:
132
+ inferred = payload_elements // plane_elements
133
+ if inferred > 0:
134
+ return inferred
135
+
136
+ # Fall back to a single-frame stack rather than treating projections as 2D.
137
+ return 1
138
+
139
+
140
+ def load_interfile_array(header_path: HeaderInput) -> InterfileArray:
141
+ """Load projection data referenced by an Interfile header into NumPy."""
142
+ header_path = Path(header_path).expanduser().resolve()
143
+ metadata = parse_interfile_header(str(header_path))
144
+
145
+ data_filename = _lookup_header_value(
146
+ metadata, "!name of data file", "name of data file"
147
+ )
148
+ if not data_filename:
149
+ raise ValueError(
150
+ f"Interfile header {header_path} does not define 'name of data file'"
151
+ )
152
+
153
+ data_path = (header_path.parent / data_filename).resolve()
154
+ if not data_path.exists():
155
+ raise FileNotFoundError(
156
+ f"Interfile data file referenced by header does not exist: {data_path}"
157
+ )
158
+
159
+ dtype = _extract_numpy_dtype(metadata)
160
+ shape = _extract_matrix_shape(metadata)
161
+ expected_elements = int(np.prod(shape))
162
+
163
+ flat = np.fromfile(data_path, dtype=dtype)
164
+
165
+ # Projection and tomographic payloads are treated as at least 3D:
166
+ # [leading_axis, axis2, axis1]. If headers only declare matrix sizes [1],[2],
167
+ # recover the leading axis from projection count fields or payload length.
168
+ if len(shape) == 2:
169
+ leading_count = _infer_leading_axis_count(
170
+ metadata, expected_elements, flat.size
171
+ )
172
+ if leading_count is not None:
173
+ shape = (leading_count, *shape)
174
+ expected_elements = int(np.prod(shape))
175
+
176
+ if flat.size < expected_elements:
177
+ raise ValueError(
178
+ f"Data size mismatch for {data_path}: expected {expected_elements} "
179
+ f"elements for shape {shape}, found {flat.size}"
180
+ )
181
+
182
+ # SIMIND files can contain trailing payload not represented by Interfile keys.
183
+ # Use the declared geometry and discard trailing elements.
184
+ if flat.size > expected_elements:
185
+ flat = flat[:expected_elements]
186
+
187
+ return InterfileArray(
188
+ array=flat.reshape(shape),
189
+ header_path=header_path,
190
+ data_path=data_path,
191
+ metadata=dict(metadata),
192
+ )
193
+
194
+
195
+ __all__ = ["InterfileArray", "load_interfile_array"]
@@ -0,0 +1,175 @@
1
+ """
2
+ Shared interfile parsing utilities.
3
+
4
+ This module provides consistent parsing for STIR interfile header files,
5
+ eliminating duplicate parsing logic across the codebase.
6
+ """
7
+
8
+ import re
9
+ from dataclasses import dataclass
10
+ from typing import List, Optional, Tuple, Union
11
+
12
+
13
+ Number = Union[tuple, list, float, int, str]
14
+
15
+
16
+ def parse_interfile_line(line: str) -> Tuple[Optional[str], Optional[str]]:
17
+ """Parse a single interfile line and return (key, value) tuple.
18
+
19
+ Args:
20
+ line: A line from an interfile header file
21
+
22
+ Returns:
23
+ Tuple of (key, value) or (None, None) if line is not parseable
24
+
25
+ Examples:
26
+ >>> parse_interfile_line("matrix size [1] := 128")
27
+ ('matrix size [1]', '128')
28
+ >>> parse_interfile_line("; This is a comment")
29
+ (None, None)
30
+ >>> parse_interfile_line("!INTERFILE :=")
31
+ (None, None)
32
+ """
33
+ line = line.strip()
34
+
35
+ # Skip comments, empty lines, and section headers
36
+ if not line or line.startswith(";") or line.startswith("#") or line.endswith(":="):
37
+ return None, None
38
+
39
+ # Handle := separator (preferred)
40
+ if ":=" in line:
41
+ key, _, value = line.partition(":=")
42
+ return key.strip(), value.strip()
43
+
44
+ return None, None
45
+
46
+
47
+ def parse_interfile_header(filename: str) -> dict[str, str]:
48
+ """Parse an entire interfile header file and return key-value dict.
49
+
50
+ Args:
51
+ filename: Path to interfile header file (.hs, .hv, .hct, etc.)
52
+
53
+ Returns:
54
+ Dictionary of key-value pairs from the header
55
+
56
+ Examples:
57
+ >>> attrs = parse_interfile_header("template.hs")
58
+ >>> attrs["number of projections"]
59
+ '64'
60
+ """
61
+ values = {}
62
+ with open(filename, "r") as file:
63
+ for line in file:
64
+ key, value = parse_interfile_line(line)
65
+ if key is not None:
66
+ values[key] = value
67
+ return values
68
+
69
+
70
+ def parse_interfile_with_regex(filename: str) -> dict[str, str]:
71
+ """Parse interfile using regex pattern (legacy compatibility).
72
+
73
+ This is the original parsing method from stir_utils.py.
74
+ Use parse_interfile_header() for new code.
75
+
76
+ Args:
77
+ filename: Path to interfile header file
78
+
79
+ Returns:
80
+ Dictionary of key-value pairs
81
+ """
82
+ values = {}
83
+ with open(filename, "r") as file:
84
+ for line in file:
85
+ if match := re.search(r"([^;].*?)\s*:=\s*(.*)", line):
86
+ key = match[1].strip()
87
+ value = match[2].strip()
88
+ values[key] = value
89
+ return values
90
+
91
+
92
+ @dataclass
93
+ class InterfileEntry:
94
+ """Represents a single line inside an interfile header."""
95
+
96
+ text: str
97
+ key: Optional[str]
98
+ value: Optional[str]
99
+
100
+ @classmethod
101
+ def from_line(cls, line: str) -> "InterfileEntry":
102
+ key, value = parse_interfile_line(line)
103
+ # Preserve newline from source to avoid formatting churn
104
+ if not line.endswith("\n"):
105
+ line = line + "\n"
106
+ return cls(text=line, key=key, value=value)
107
+
108
+ @classmethod
109
+ def from_key_value(cls, key: str, value: Number) -> "InterfileEntry":
110
+ value_str = cls._coerce_value(value)
111
+ return cls(text=f"{key} := {value_str}\n", key=key, value=value_str)
112
+
113
+ @staticmethod
114
+ def _coerce_value(value: Number) -> str:
115
+ if isinstance(value, (tuple, list)):
116
+ return "{" + ", ".join(str(v) for v in value) + "}"
117
+ return str(value)
118
+
119
+ def set_value(self, value: Number) -> None:
120
+ value_str = self._coerce_value(value)
121
+ self.value = value_str
122
+ if self.key is None:
123
+ raise ValueError("Cannot set value on entry without a key")
124
+ self.text = f"{self.key} := {value_str}\n"
125
+
126
+ def copy(self) -> "InterfileEntry":
127
+ return InterfileEntry(text=self.text, key=self.key, value=self.value)
128
+
129
+
130
+ class InterfileHeader:
131
+ """Editable representation of a parsed interfile header."""
132
+
133
+ def __init__(self, entries: List[InterfileEntry]):
134
+ self._entries = entries
135
+
136
+ @classmethod
137
+ def from_file(cls, filename: str) -> "InterfileHeader":
138
+ with open(filename, "r") as file:
139
+ entries = [InterfileEntry.from_line(line) for line in file]
140
+ return cls(entries)
141
+
142
+ def copy(self) -> "InterfileHeader":
143
+ return InterfileHeader([entry.copy() for entry in self._entries])
144
+
145
+ def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
146
+ for entry in reversed(self._entries):
147
+ if entry.key == key:
148
+ return entry.value
149
+ return default
150
+
151
+ def set(self, key: str, value: Number) -> None:
152
+ for entry in self._entries:
153
+ if entry.key == key:
154
+ entry.set_value(value)
155
+ return
156
+ self._entries.append(InterfileEntry.from_key_value(key, value))
157
+
158
+ def insert(self, index: int, key: str, value: Number) -> None:
159
+ entry = InterfileEntry.from_key_value(key, value)
160
+ index = max(0, min(index, len(self._entries)))
161
+ self._entries.insert(index, entry)
162
+
163
+ def write(self, filename: str) -> None:
164
+ with open(filename, "w") as file:
165
+ for entry in self._entries:
166
+ file.write(entry.text)
167
+
168
+
169
+ __all__ = [
170
+ "InterfileHeader",
171
+ "InterfileEntry",
172
+ "parse_interfile_line",
173
+ "parse_interfile_header",
174
+ "parse_interfile_with_regex",
175
+ ]
@@ -0,0 +1,14 @@
1
+ import shutil
2
+ import tempfile
3
+ from contextlib import contextmanager
4
+ from pathlib import Path
5
+
6
+
7
+ @contextmanager
8
+ def temporary_directory():
9
+ """Context manager for creating and cleaning up a temporary directory."""
10
+ temp_dir = tempfile.mkdtemp()
11
+ try:
12
+ yield Path(temp_dir)
13
+ finally:
14
+ shutil.rmtree(temp_dir, ignore_errors=True)
@@ -0,0 +1,70 @@
1
+ import os
2
+ from numbers import Number
3
+
4
+
5
+ class SimindError(Exception):
6
+ """Base exception for SIMIND-related errors."""
7
+
8
+
9
+ class SimindNotFoundError(SimindError):
10
+ """Raised when SIMIND executable is not found."""
11
+
12
+
13
+ def create_window_file(
14
+ lower_bounds: list,
15
+ upper_bounds: list,
16
+ scatter_orders: list,
17
+ output_filename: str = "input",
18
+ energy_window=None,
19
+ lower_ew=None,
20
+ upper_ew=None,
21
+ ):
22
+ """
23
+ Creates a window file for simind simulation
24
+
25
+ Args:
26
+ lower_bounds (list): lower bounds of energy windows
27
+ upper_bounds (list): upper bounds of energy windows
28
+ scatter_orders (list): scatter orders of energy windows
29
+ output_filename (str, optional): name of output file. Defaults to 'input'.
30
+ ! energy_window (str, optional): energy window type can be dew or dew.
31
+ Defaults to None.
32
+ ! lower_ew (list, optional): lower energy window lower and upper bounds.
33
+ Defaults to None.
34
+ ! upper_ew (list, optional): upper energy window lower and upper bounds.
35
+ Defaults to None.
36
+ ! Note that dual and triple energy windows are not yet supported by this
37
+ wrapper. Please define your own energy windows and work out yourself
38
+ """
39
+
40
+ # if path suffix is not. win, add it
41
+ if not output_filename.endswith(".win"):
42
+ output_filename += ".win"
43
+
44
+ if isinstance(lower_bounds, Number):
45
+ lower_bounds = [lower_bounds]
46
+ if isinstance(upper_bounds, Number):
47
+ upper_bounds = [upper_bounds]
48
+ if isinstance(scatter_orders, Number):
49
+ scatter_orders = [scatter_orders]
50
+
51
+ assert len(lower_bounds) == len(upper_bounds) == len(scatter_orders), (
52
+ "lower_bounds, upper_bounds and scatter_orders must have same length"
53
+ )
54
+
55
+ # remove previous window file if present
56
+ if os.path.exists(output_filename):
57
+ os.remove(output_filename)
58
+
59
+ with open(output_filename, "w") as file:
60
+ for i in range(len(lower_bounds)):
61
+ # for some reason simind doesn't like the last line to have a newline
62
+ # character
63
+ file.write(
64
+ f"{float(lower_bounds[i])},{float(upper_bounds[i])},{int(scatter_orders[i])}\n"
65
+ )
66
+ # simind sometimes doesn't output scatter files unless there's one line
67
+ # with a dedicated scatter order
68
+ # annoyingle this will create one extra total, air, scatter file
69
+ if max(scatter_orders) < 1:
70
+ file.write(f"{float(lower_bounds[i])},{float(upper_bounds[i])},1")
@@ -0,0 +1,194 @@
1
+ """
2
+ Helpers for working with SIRF/STIR native objects alongside the backend interfaces.
3
+
4
+ These utilities hide the wrapping/unwrapping boilerplate so callers can pass the
5
+ objects they already have, while the simulator continues to interact with the
6
+ uniform interface layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Optional, Union
12
+
13
+ from simind_python_connector.backends import (
14
+ AcquisitionDataInterface,
15
+ ImageDataInterface,
16
+ create_acquisition_data,
17
+ create_image_data,
18
+ detect_acquisition_backend,
19
+ detect_backend_from_interface,
20
+ detect_image_backend,
21
+ get_backend,
22
+ set_backend,
23
+ unwrap,
24
+ )
25
+
26
+
27
+ image_like = Union[str, ImageDataInterface, Any]
28
+ acquisition_like = Union[str, AcquisitionDataInterface, Any]
29
+
30
+
31
+ def _validate_backend_name(name: str) -> str:
32
+ if name not in ("sirf", "stir"):
33
+ raise ValueError(f"Backend must be 'sirf' or 'stir', got {name!r}")
34
+ return name
35
+
36
+
37
+ def _ensure_backend(preferred: Optional[str]) -> None:
38
+ """Ensure a particular backend is active if explicitly requested."""
39
+ if preferred is None:
40
+ return
41
+
42
+ preferred = _validate_backend_name(preferred)
43
+ if get_backend() != preferred:
44
+ set_backend(preferred)
45
+
46
+
47
+ def ensure_image_interface(
48
+ value: image_like, preferred_backend: Optional[str] = None
49
+ ) -> ImageDataInterface:
50
+ """Return an ImageDataInterface for the provided value."""
51
+ if isinstance(value, ImageDataInterface):
52
+ if preferred_backend:
53
+ backend = detect_backend_from_interface(value)
54
+ preferred_backend = _validate_backend_name(preferred_backend)
55
+ if backend and backend != preferred_backend:
56
+ raise ValueError(
57
+ f"Image is backed by {backend}, expected {preferred_backend}"
58
+ )
59
+ return value
60
+
61
+ if preferred_backend:
62
+ _ensure_backend(preferred_backend)
63
+ return create_image_data(value)
64
+
65
+
66
+ def ensure_acquisition_interface(
67
+ value: acquisition_like, preferred_backend: Optional[str] = None
68
+ ) -> AcquisitionDataInterface:
69
+ """Return an AcquisitionDataInterface for the provided value."""
70
+ if isinstance(value, AcquisitionDataInterface):
71
+ if preferred_backend:
72
+ backend = detect_backend_from_interface(value)
73
+ preferred_backend = _validate_backend_name(preferred_backend)
74
+ if backend and backend != preferred_backend:
75
+ raise ValueError(
76
+ f"Acquisition data is backed by {backend}, "
77
+ f"expected {preferred_backend}"
78
+ )
79
+ return value
80
+
81
+ if preferred_backend:
82
+ _ensure_backend(preferred_backend)
83
+ return create_acquisition_data(value)
84
+
85
+
86
+ def to_native_image(
87
+ value: image_like,
88
+ preferred_backend: Optional[str] = None,
89
+ ensure_interface: bool = True,
90
+ ) -> Any:
91
+ """
92
+ Retrieve a native SIRF/STIR image from any supported input.
93
+
94
+ Args:
95
+ value: Path, interface, or native object.
96
+ preferred_backend: Optional backend to enforce. When provided and value
97
+ is a path, the backend will be switched before loading.
98
+ ensure_interface: When True, non-interface values are wrapped so that
99
+ the simulator's interface guarantees remain intact before unwrapping.
100
+ """
101
+ if ensure_interface or not isinstance(value, ImageDataInterface):
102
+ wrapper = ensure_image_interface(value, preferred_backend)
103
+ else:
104
+ wrapper = value
105
+
106
+ native = unwrap(wrapper)
107
+ if preferred_backend:
108
+ expected = _validate_backend_name(preferred_backend)
109
+ actual = detect_image_backend(native)
110
+ if actual and actual != expected:
111
+ raise ValueError(f"Image native backend is {actual}, expected {expected}")
112
+ return native
113
+
114
+
115
+ def to_native_acquisition(
116
+ value: acquisition_like,
117
+ preferred_backend: Optional[str] = None,
118
+ ensure_interface: bool = True,
119
+ ) -> Any:
120
+ """Retrieve a native acquisition object from any supported input."""
121
+ if ensure_interface or not isinstance(value, AcquisitionDataInterface):
122
+ wrapper = ensure_acquisition_interface(value, preferred_backend)
123
+ else:
124
+ wrapper = value
125
+
126
+ native = unwrap(wrapper)
127
+ if preferred_backend:
128
+ expected = _validate_backend_name(preferred_backend)
129
+ actual = detect_acquisition_backend(native)
130
+ if actual and actual != expected:
131
+ raise ValueError(
132
+ f"Acquisition native backend is {actual}, expected {expected}"
133
+ )
134
+ return native
135
+
136
+
137
+ def register_and_enforce_backend(
138
+ detected_backend: Optional[str], current_backend: Optional[str]
139
+ ) -> Optional[str]:
140
+ """Register and enforce backend consistency across simulator inputs.
141
+
142
+ This helper manages backend hints and ensures that all inputs to the
143
+ simulator use the same backend (either SIRF or STIR). Once a backend
144
+ is detected from the first input, subsequent inputs must match.
145
+
146
+ Args:
147
+ detected_backend: Backend detected from current input ('sirf', 'stir', or None)
148
+ current_backend: Currently registered backend preference (or None)
149
+
150
+ Returns:
151
+ Updated backend preference (either current_backend or detected_backend)
152
+
153
+ Raises:
154
+ ValueError: If detected_backend conflicts with current_backend
155
+
156
+ Example:
157
+ >>> backend = None
158
+ >>> backend = register_and_enforce_backend('sirf', backend) # Returns 'sirf'
159
+ >>> backend = register_and_enforce_backend('sirf', backend) # OK, matches
160
+ >>> backend = register_and_enforce_backend('stir', backend) # Raises ValueError
161
+ """
162
+ if detected_backend:
163
+ detected_backend = detected_backend.lower()
164
+
165
+ # Validate backend name
166
+ if detected_backend not in ("sirf", "stir"):
167
+ raise ValueError(
168
+ f"Backend must be 'sirf' or 'stir', got {detected_backend!r}"
169
+ )
170
+
171
+ # Check for conflicts
172
+ if current_backend and current_backend != detected_backend:
173
+ raise ValueError(
174
+ f"Backend mismatch: simulator already configured for "
175
+ f"{current_backend.upper()} backend but received "
176
+ f"{detected_backend.upper()} data."
177
+ )
178
+
179
+ # Register the backend globally
180
+ current_backend = detected_backend
181
+ current = get_backend()
182
+ if current != detected_backend:
183
+ set_backend(detected_backend)
184
+
185
+ return current_backend
186
+
187
+
188
+ __all__ = [
189
+ "ensure_image_interface",
190
+ "ensure_acquisition_interface",
191
+ "to_native_image",
192
+ "to_native_acquisition",
193
+ "register_and_enforce_backend",
194
+ ]