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,485 @@
|
|
|
1
|
+
# This file contains a few useful functions for converting SIMIND output to
|
|
2
|
+
# STIR format.
|
|
3
|
+
### It should probably be cleaned up and object-orientedified at some point.
|
|
4
|
+
|
|
5
|
+
### Author: Sam Porter, Efstathios Varzakis
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from simind_python_connector.utils.backend_access import BACKENDS
|
|
16
|
+
|
|
17
|
+
from . import get_array
|
|
18
|
+
from .import_helpers import get_sirf_types
|
|
19
|
+
from .interfile_parser import parse_interfile_header, parse_interfile_line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Conditional import for SIRF to avoid CI dependencies
|
|
23
|
+
ImageData, AcquisitionData, SIRF_AVAILABLE = get_sirf_types()
|
|
24
|
+
|
|
25
|
+
# Unpack interfaces needed by stir_utils
|
|
26
|
+
create_acquisition_data = BACKENDS.factories.create_acquisition_data
|
|
27
|
+
create_image_data = BACKENDS.factories.create_image_data
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_interfile_text(text: str):
|
|
31
|
+
"""Parse interfile-formatted text and return a dictionary."""
|
|
32
|
+
values = {}
|
|
33
|
+
for line in text.splitlines():
|
|
34
|
+
key, value = parse_interfile_line(line)
|
|
35
|
+
if key is not None:
|
|
36
|
+
values[key] = value
|
|
37
|
+
return values
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_sinogram(template_sinogram):
|
|
41
|
+
"""Parse sinogram metadata without colliding temp files.
|
|
42
|
+
|
|
43
|
+
Accepts either a path to an interfile header or an acquisition object that
|
|
44
|
+
exposes ``get_info`` or ``write``. The parser reuses the shared
|
|
45
|
+
``parse_interfile_header`` helper for consistent behaviour.
|
|
46
|
+
"""
|
|
47
|
+
if isinstance(template_sinogram, (str, os.PathLike)):
|
|
48
|
+
return parse_interfile_header(str(template_sinogram))
|
|
49
|
+
|
|
50
|
+
if hasattr(template_sinogram, "get_info") and callable(
|
|
51
|
+
template_sinogram.get_info # type: ignore[attr-defined]
|
|
52
|
+
):
|
|
53
|
+
return _parse_interfile_text(template_sinogram.get_info()) # type: ignore[attr-defined]
|
|
54
|
+
|
|
55
|
+
if hasattr(template_sinogram, "filename"):
|
|
56
|
+
return parse_interfile_header(template_sinogram.filename)
|
|
57
|
+
|
|
58
|
+
if hasattr(template_sinogram, "write") and callable(template_sinogram.write):
|
|
59
|
+
with tempfile.NamedTemporaryFile(suffix=".hs", delete=False) as tmp:
|
|
60
|
+
tmp_path = tmp.name
|
|
61
|
+
try:
|
|
62
|
+
template_sinogram.write(tmp_path)
|
|
63
|
+
return parse_interfile_header(tmp_path)
|
|
64
|
+
finally:
|
|
65
|
+
with contextlib.suppress(FileNotFoundError):
|
|
66
|
+
os.remove(tmp_path)
|
|
67
|
+
|
|
68
|
+
raise TypeError(
|
|
69
|
+
"template_sinogram must be a path or acquisition object with "
|
|
70
|
+
"get_info()/write() methods"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_interfile(filename):
|
|
75
|
+
"""Parse STIR interfile and return dictionary of key-value pairs.
|
|
76
|
+
|
|
77
|
+
This function is kept for backward compatibility.
|
|
78
|
+
New code should use parse_interfile_header() from interfile_parser module.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
filename: Path to interfile file
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Dictionary of key-value pairs
|
|
85
|
+
"""
|
|
86
|
+
return parse_interfile_header(filename)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_sirf_attenuation_from_simind(
|
|
90
|
+
attn_filename, photopeak_energy=0.12, attn_type="mu"
|
|
91
|
+
):
|
|
92
|
+
"""Reads attenuation data from simind attenuation file and returns SIRF
|
|
93
|
+
ImageData object
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
attn_filename (string): file name of simind attenuation file header
|
|
97
|
+
new_filename (string): file name of new attenuation file header
|
|
98
|
+
attn_type (str, optional): unit of attenuation binary file. Defaults to 'mu'.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
image: SIRF ImageData object containing attenuation data
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
if attn_type == "mu":
|
|
105
|
+
data_type = np.float32
|
|
106
|
+
elif attn_type == "rho*1000":
|
|
107
|
+
data_type = np.uint16
|
|
108
|
+
|
|
109
|
+
# remove suffix from filename if present
|
|
110
|
+
if attn_filename[-3:] in ["ict", "hct"]:
|
|
111
|
+
attn_filename = attn_filename[:-4]
|
|
112
|
+
|
|
113
|
+
attn = np.fromfile(f"{attn_filename}.ict", dtype=data_type)
|
|
114
|
+
image = ImageData()
|
|
115
|
+
|
|
116
|
+
header_dict = parse_interfile(f"{attn_filename}.hct")
|
|
117
|
+
dim = [int(header_dict["!matrix size [%d]" % i]) for i in range(1, 4)][::-1]
|
|
118
|
+
|
|
119
|
+
vsize = [
|
|
120
|
+
float(header_dict["scaling factor (mm/pixel) [%d]" % i]) for i in range(1, 3)
|
|
121
|
+
]
|
|
122
|
+
vsize.append(header_dict["# scaling factor (mm/pixel) [3]"])
|
|
123
|
+
|
|
124
|
+
# origin looks like '-128.0000 -128.0000 128.000000'
|
|
125
|
+
origin_string = header_dict["# Image Position First image"]
|
|
126
|
+
origin = [float(i) for i in origin_string.split(" ")][::-1]
|
|
127
|
+
|
|
128
|
+
image.initialise(dim=tuple(dim), vsize=tuple(vsize), origin=tuple(origin))
|
|
129
|
+
attn = attn.reshape(dim)
|
|
130
|
+
|
|
131
|
+
if attn_type == "mu":
|
|
132
|
+
image.fill(attn)
|
|
133
|
+
elif attn_type == "rho*100":
|
|
134
|
+
image.fill(attn / 1000 * photopeak_energy)
|
|
135
|
+
|
|
136
|
+
return image
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def convert_value(val: str):
|
|
140
|
+
"""
|
|
141
|
+
Attempt to convert a string to int or float.
|
|
142
|
+
Also converts a list of numbers if enclosed in { }.
|
|
143
|
+
Otherwise, returns the stripped string.
|
|
144
|
+
"""
|
|
145
|
+
val = val.strip()
|
|
146
|
+
if val.startswith("{") and val.endswith("}"):
|
|
147
|
+
try:
|
|
148
|
+
return [float(x.strip()) for x in val[1:-1].split(",")]
|
|
149
|
+
except Exception:
|
|
150
|
+
return val
|
|
151
|
+
try:
|
|
152
|
+
if re.fullmatch(r"[-+]?\d+", val):
|
|
153
|
+
return int(val)
|
|
154
|
+
# Try float conversion if the value contains a decimal point or exponent.
|
|
155
|
+
return float(val) if re.search(r"[.\deE+-]", val) else val
|
|
156
|
+
except ValueError:
|
|
157
|
+
return val
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
STIR_ATTRIBUTE_MAPPING = {
|
|
161
|
+
"number_of_views": "number_of_projections",
|
|
162
|
+
"azimuthal_angle_extent": "extent_of_rotation",
|
|
163
|
+
"view_offset": "start_angle",
|
|
164
|
+
"radionuclide": "isotope_name",
|
|
165
|
+
"energy_window_lower": "energy_window_lower",
|
|
166
|
+
"energy_window_upper": "energy_window_upper",
|
|
167
|
+
"scanner_type": "scanner_type",
|
|
168
|
+
"number_of_rings": "number_of_rings",
|
|
169
|
+
"number_of_detectors_per_ring": "number_of_detectors_per_ring",
|
|
170
|
+
"inner_ring_diameter": "height_to_detector_surface",
|
|
171
|
+
"tangential_sampling": "default_bin_size",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def harmonize_stir_attributes(attributes: dict) -> dict:
|
|
176
|
+
"""Apply canonical naming and derived values to raw STIR attributes."""
|
|
177
|
+
harmonized_attributes = {}
|
|
178
|
+
|
|
179
|
+
for key, value in attributes.items():
|
|
180
|
+
standard_key = STIR_ATTRIBUTE_MAPPING.get(key, key)
|
|
181
|
+
harmonized_attributes[standard_key] = value
|
|
182
|
+
|
|
183
|
+
if "inner_ring_diameter" in harmonized_attributes:
|
|
184
|
+
harmonized_attributes["height_to_detector_surface"] = (
|
|
185
|
+
harmonized_attributes["inner_ring_diameter"] / 2
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return harmonized_attributes
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def extract_attributes_from_stir(header_filepath: str) -> dict:
|
|
192
|
+
"""Extract attributes from a STIR header file (.hs)."""
|
|
193
|
+
if not isinstance(header_filepath, str):
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"extract_attributes_from_stir() only accepts string filepaths. "
|
|
196
|
+
f"Got {type(header_filepath)}. If you have an acquisition object, "
|
|
197
|
+
f"write it to a file first using .write() method."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return extract_attributes_from_stir_headerfile(header_filepath)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def extract_attributes_from_stir_headerfile(filename: str) -> dict:
|
|
204
|
+
"""Parse a STIR header file and extract relevant attributes."""
|
|
205
|
+
attributes = {
|
|
206
|
+
"matrix_sizes": {},
|
|
207
|
+
"scaling_factors": {},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
patterns = [
|
|
211
|
+
(
|
|
212
|
+
re.compile(r"!imaging modality\s*:=\s*(.+)", re.IGNORECASE),
|
|
213
|
+
lambda m: m.group(1).strip(),
|
|
214
|
+
"modality",
|
|
215
|
+
),
|
|
216
|
+
(
|
|
217
|
+
re.compile(r"!type of data\s*:=\s*(.+)", re.IGNORECASE),
|
|
218
|
+
lambda m: m.group(1).strip(),
|
|
219
|
+
"data_type",
|
|
220
|
+
),
|
|
221
|
+
(
|
|
222
|
+
re.compile(r"imagedata byte order\s*:=\s*(.+)", re.IGNORECASE),
|
|
223
|
+
lambda m: m.group(1).strip(),
|
|
224
|
+
"byte_order",
|
|
225
|
+
),
|
|
226
|
+
(
|
|
227
|
+
re.compile(r"!number format\s*:=\s*(.+)", re.IGNORECASE),
|
|
228
|
+
lambda m: m.group(1).strip(),
|
|
229
|
+
"number_format",
|
|
230
|
+
),
|
|
231
|
+
(
|
|
232
|
+
re.compile(r"!number of bytes per pixel\s*:=\s*(\d+)", re.IGNORECASE),
|
|
233
|
+
lambda m: int(m.group(1)),
|
|
234
|
+
"bytes_per_pixel",
|
|
235
|
+
),
|
|
236
|
+
(
|
|
237
|
+
re.compile(r"calibration factor\s*:=\s*(.+)", re.IGNORECASE),
|
|
238
|
+
lambda m: float(m.group(1)),
|
|
239
|
+
"calibration_factor",
|
|
240
|
+
),
|
|
241
|
+
(
|
|
242
|
+
re.compile(r"isotope name\s*:=\s*(.+)", re.IGNORECASE),
|
|
243
|
+
lambda m: m.group(1).strip(),
|
|
244
|
+
"isotope_name",
|
|
245
|
+
),
|
|
246
|
+
(
|
|
247
|
+
re.compile(r"number of dimensions\s*:=\s*(\d+)", re.IGNORECASE),
|
|
248
|
+
lambda m: int(m.group(1)),
|
|
249
|
+
"number_of_dimensions",
|
|
250
|
+
),
|
|
251
|
+
(
|
|
252
|
+
re.compile(r"!number of projections\s*:=\s*(\d+)", re.IGNORECASE),
|
|
253
|
+
lambda m: int(m.group(1)),
|
|
254
|
+
"number_of_projections",
|
|
255
|
+
),
|
|
256
|
+
(
|
|
257
|
+
re.compile(r"number of time frames\s*:=\s*(\d+)", re.IGNORECASE),
|
|
258
|
+
lambda m: int(m.group(1)),
|
|
259
|
+
"number_of_time_frames",
|
|
260
|
+
),
|
|
261
|
+
(
|
|
262
|
+
re.compile(
|
|
263
|
+
r"!image duration\s*\(sec\)[\[\w\s]*\]\s*:=\s*(\d+)", re.IGNORECASE
|
|
264
|
+
),
|
|
265
|
+
lambda m: int(m.group(1)),
|
|
266
|
+
"image_duration",
|
|
267
|
+
),
|
|
268
|
+
(
|
|
269
|
+
re.compile(r"!extent of rotation\s*:=\s*(.+)", re.IGNORECASE),
|
|
270
|
+
lambda m: float(m.group(1)),
|
|
271
|
+
"extent_of_rotation",
|
|
272
|
+
),
|
|
273
|
+
(
|
|
274
|
+
re.compile(r"!direction of rotation\s*:=\s*(.+)", re.IGNORECASE),
|
|
275
|
+
lambda m: m.group(1).strip(),
|
|
276
|
+
"direction_of_rotation",
|
|
277
|
+
),
|
|
278
|
+
(
|
|
279
|
+
re.compile(r"start angle\s*:=\s*(.+)", re.IGNORECASE),
|
|
280
|
+
lambda m: float(m.group(1)),
|
|
281
|
+
"start_angle",
|
|
282
|
+
),
|
|
283
|
+
(
|
|
284
|
+
re.compile(r"!name of data file\s*:=\s*(.+)", re.IGNORECASE),
|
|
285
|
+
lambda m: m.group(1).strip(),
|
|
286
|
+
"data_file",
|
|
287
|
+
),
|
|
288
|
+
(
|
|
289
|
+
re.compile(r"energy window lower level\[\d+\]\s*:=\s*(.+)", re.IGNORECASE),
|
|
290
|
+
lambda m: float(m.group(1)),
|
|
291
|
+
"energy_window_lower",
|
|
292
|
+
),
|
|
293
|
+
(
|
|
294
|
+
re.compile(r"energy window upper level\[\d+\]\s*:=\s*(.+)", re.IGNORECASE),
|
|
295
|
+
lambda m: float(m.group(1)),
|
|
296
|
+
"energy_window_upper",
|
|
297
|
+
),
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
with open(filename, "r") as file:
|
|
301
|
+
for line in file:
|
|
302
|
+
if ms_match := re.search(
|
|
303
|
+
r"!matrix size\s*\[(.+?)\]\s*:=\s*(\d+)", line, re.IGNORECASE
|
|
304
|
+
):
|
|
305
|
+
axis = ms_match[1].strip()
|
|
306
|
+
attributes["matrix_sizes"][axis] = int(ms_match[2])
|
|
307
|
+
continue
|
|
308
|
+
|
|
309
|
+
if sf_match := re.search(
|
|
310
|
+
r"!scaling factor\s*\(mm/pixel\)\s*\[(.+?)\]\s*:=\s*(.+)",
|
|
311
|
+
line,
|
|
312
|
+
re.IGNORECASE,
|
|
313
|
+
):
|
|
314
|
+
axis = sf_match[1].strip()
|
|
315
|
+
attributes["scaling_factors"][axis] = float(sf_match[2].strip())
|
|
316
|
+
continue
|
|
317
|
+
|
|
318
|
+
if re.search(r"(radius|radii)\s*:=\s*(.+)", line, re.IGNORECASE):
|
|
319
|
+
r_match = re.search(r"(radius|radii)\s*:=\s*(.+)", line, re.IGNORECASE)
|
|
320
|
+
tmp = r_match[2].strip()
|
|
321
|
+
if tmp.startswith("{") and tmp.endswith("}") or "," in tmp:
|
|
322
|
+
tmp = tmp.strip("{}")
|
|
323
|
+
values = [float(v.strip()) for v in tmp.split(",")]
|
|
324
|
+
mean_value = float(np.mean(values))
|
|
325
|
+
std_of_mean_value = np.std(values) / mean_value
|
|
326
|
+
if std_of_mean_value > 1e-6:
|
|
327
|
+
attributes["orbit"] = "non-circular"
|
|
328
|
+
attributes["radii"] = values
|
|
329
|
+
attributes["height_to_detector_surface"] = mean_value
|
|
330
|
+
else:
|
|
331
|
+
attributes["orbit"] = "Circular"
|
|
332
|
+
attributes["height_to_detector_surface"] = mean_value
|
|
333
|
+
else:
|
|
334
|
+
attributes["orbit"] = "Circular"
|
|
335
|
+
attributes["height_to_detector_surface"] = float(tmp)
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
if orbit_match := re.search(r"orbit\s*:=\s*(.+)", line, re.IGNORECASE):
|
|
339
|
+
attributes["orbit"] = orbit_match[1].strip()
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
for pattern, converter, attr_key in patterns:
|
|
343
|
+
if match := pattern.search(line):
|
|
344
|
+
attributes[attr_key] = converter(match)
|
|
345
|
+
break
|
|
346
|
+
|
|
347
|
+
return harmonize_stir_attributes(attributes)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _normalize_backend_name(backend: Optional[str]) -> Optional[str]:
|
|
351
|
+
"""Normalize explicit backend names for builder helpers."""
|
|
352
|
+
if backend is None:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
normalized = backend.lower()
|
|
356
|
+
if normalized not in {"sirf", "stir"}:
|
|
357
|
+
raise ValueError(
|
|
358
|
+
f"Invalid backend {backend!r}. Expected one of: 'sirf', 'stir', or None."
|
|
359
|
+
)
|
|
360
|
+
return normalized
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def create_stir_image(
|
|
364
|
+
matrix_dim: list, voxel_size: list, backend: Optional[str] = None
|
|
365
|
+
):
|
|
366
|
+
"""Create a uniform (zeros) STIR image object.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
matrix_dim: Three-element matrix size in ``(z, y, x)`` order.
|
|
370
|
+
voxel_size: Three-element voxel size in mm for ``(z, y, x)``.
|
|
371
|
+
backend: Optional explicit backend (``"sirf"`` or ``"stir"``).
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Backend image object created by ``STIRSPECTImageDataBuilder``.
|
|
375
|
+
"""
|
|
376
|
+
try:
|
|
377
|
+
from simind_python_connector.builders import STIRSPECTImageDataBuilder
|
|
378
|
+
except ImportError as exc:
|
|
379
|
+
raise ImportError(
|
|
380
|
+
"STIRSPECTImageDataBuilder requires SIRF/STIR to be installed"
|
|
381
|
+
) from exc
|
|
382
|
+
|
|
383
|
+
builder = STIRSPECTImageDataBuilder(backend=_normalize_backend_name(backend))
|
|
384
|
+
builder.update_header(
|
|
385
|
+
{
|
|
386
|
+
"!matrix size [1]": str(matrix_dim[2]),
|
|
387
|
+
"!matrix size [2]": str(matrix_dim[1]),
|
|
388
|
+
"!matrix size [3]": str(matrix_dim[0]),
|
|
389
|
+
"scaling factor (mm/pixel) [1]": str(voxel_size[2]),
|
|
390
|
+
"scaling factor (mm/pixel) [2]": str(voxel_size[1]),
|
|
391
|
+
"scaling factor (mm/pixel) [3]": str(voxel_size[0]),
|
|
392
|
+
}
|
|
393
|
+
)
|
|
394
|
+
builder.set_pixel_array(np.zeros(matrix_dim, dtype=np.float32))
|
|
395
|
+
return builder.build()
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def create_stir_acqdata(
|
|
399
|
+
proj_matrix: list,
|
|
400
|
+
num_projections: int,
|
|
401
|
+
pixel_size: list,
|
|
402
|
+
backend: Optional[str] = None,
|
|
403
|
+
):
|
|
404
|
+
"""Create a uniform (zeros) STIR acquisition object.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
proj_matrix: Two-element projection matrix size in ``(radial, tangential)``.
|
|
408
|
+
num_projections: Number of projection views.
|
|
409
|
+
pixel_size: Two-element pixel size in mm for ``(radial, tangential)``.
|
|
410
|
+
backend: Optional explicit backend (``"sirf"`` or ``"stir"``).
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
Backend acquisition object created by ``STIRSPECTAcquisitionDataBuilder``.
|
|
414
|
+
"""
|
|
415
|
+
try:
|
|
416
|
+
from simind_python_connector.builders import STIRSPECTAcquisitionDataBuilder
|
|
417
|
+
except ImportError as exc:
|
|
418
|
+
raise ImportError(
|
|
419
|
+
"STIRSPECTAcquisitionDataBuilder requires SIRF/STIR to be installed"
|
|
420
|
+
) from exc
|
|
421
|
+
|
|
422
|
+
builder = STIRSPECTAcquisitionDataBuilder(backend=_normalize_backend_name(backend))
|
|
423
|
+
builder.update_header(
|
|
424
|
+
{
|
|
425
|
+
"!matrix size [1]": str(proj_matrix[0]),
|
|
426
|
+
"!matrix size [2]": str(proj_matrix[1]),
|
|
427
|
+
"!number of projections": str(num_projections),
|
|
428
|
+
"scaling factor (mm/pixel) [1]": str(pixel_size[0]),
|
|
429
|
+
"scaling factor (mm/pixel) [2]": str(pixel_size[1]),
|
|
430
|
+
}
|
|
431
|
+
)
|
|
432
|
+
builder.pixel_array = np.zeros(
|
|
433
|
+
(1, proj_matrix[0], num_projections, proj_matrix[1]), dtype=np.float32
|
|
434
|
+
)
|
|
435
|
+
return builder.build()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def create_simple_phantom():
|
|
439
|
+
"""Create a simple cylindrical phantom with a hot sphere."""
|
|
440
|
+
# Create a 64x64x64 image with 4.42mm voxels
|
|
441
|
+
matrix_dim = [64, 64, 64]
|
|
442
|
+
voxel_size = [4.42, 4.42, 4.42] # mm
|
|
443
|
+
|
|
444
|
+
# Create empty image
|
|
445
|
+
phantom = create_stir_image(matrix_dim, voxel_size)
|
|
446
|
+
phantom_array = get_array(phantom)
|
|
447
|
+
|
|
448
|
+
# Add cylindrical background (body)
|
|
449
|
+
center = [32, 32, 32]
|
|
450
|
+
radius = 10 # pixels
|
|
451
|
+
height = 20 # pixels
|
|
452
|
+
|
|
453
|
+
for z in range(center[0] - height // 2, center[0] + height // 2):
|
|
454
|
+
for y in range(matrix_dim[1]):
|
|
455
|
+
for x in range(matrix_dim[2]):
|
|
456
|
+
if (x - center[1]) ** 2 + (y - center[2]) ** 2 <= radius**2:
|
|
457
|
+
phantom_array[z, y, x] = 10 # Background activity
|
|
458
|
+
|
|
459
|
+
# Add hot sphere (tumor)
|
|
460
|
+
sphere_center = [32, 32, 36]
|
|
461
|
+
sphere_radius = 3 # pixels
|
|
462
|
+
|
|
463
|
+
for z in range(matrix_dim[0]):
|
|
464
|
+
for y in range(matrix_dim[1]):
|
|
465
|
+
for x in range(matrix_dim[2]):
|
|
466
|
+
if (x - sphere_center[2]) ** 2 + (y - sphere_center[1]) ** 2 + (
|
|
467
|
+
z - sphere_center[0]
|
|
468
|
+
) ** 2 <= sphere_radius**2:
|
|
469
|
+
phantom_array[z, y, x] = 40 # Hot spot activity
|
|
470
|
+
|
|
471
|
+
phantom.fill(phantom_array)
|
|
472
|
+
return phantom
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def create_attenuation_map(phantom):
|
|
476
|
+
"""Create a simple attenuation map from the phantom."""
|
|
477
|
+
# For simplicity, use uniform attenuation where phantom > 0
|
|
478
|
+
mu_water_140keV = 0.15 # cm^-1 approximate for 140 keV
|
|
479
|
+
|
|
480
|
+
attn_array = get_array(phantom).copy()
|
|
481
|
+
attn_array[attn_array > 0] = mu_water_140keV
|
|
482
|
+
|
|
483
|
+
mu_map = phantom.clone()
|
|
484
|
+
mu_map.fill(attn_array)
|
|
485
|
+
return mu_map
|