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