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