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,526 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+ from pathlib import Path
5
+ from typing import Literal, Optional
6
+
7
+ import numpy as np
8
+ import pydicom
9
+
10
+ from simind_python_connector.utils.backend_access import BACKEND_AVAILABLE, BACKENDS
11
+
12
+ # Conditional import for SIRF types
13
+ from simind_python_connector.utils.import_helpers import get_sirf_types
14
+ from simind_python_connector.utils.io_utils import temporary_directory
15
+
16
+
17
+ _, AcquisitionData, SIRF_AVAILABLE = get_sirf_types()
18
+
19
+ # Unpack interfaces needed by builder
20
+ create_acquisition_data = BACKENDS.factories.create_acquisition_data
21
+
22
+
23
+ class STIRSPECTAcquisitionDataBuilder:
24
+ """
25
+ A builder class for creating a uniform STIR AcquisitionData object.
26
+
27
+ Default header parameters for the STIR format are set during initialization but
28
+ can be
29
+ overridden via constructor parameters or by using the `update_header` method.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ header_overrides=None,
35
+ backend: Optional[Literal["sirf", "stir"]] = None,
36
+ ):
37
+ if backend is not None and backend not in {"sirf", "stir"}:
38
+ raise ValueError("backend must be one of: 'sirf', 'stir', or None")
39
+ self.backend = backend
40
+
41
+ # Define default header for all keys
42
+ self.header = {
43
+ "!INTERFILE": "",
44
+ "!imaging modality": "NM",
45
+ "name of data file": "temp.s",
46
+ "!version of keys": "3.3",
47
+ "data_offset_in_bytes": "0",
48
+ "!GENERAL DATA": "",
49
+ "!GENERAL IMAGE DATA": "",
50
+ "!type of data": "Tomographic",
51
+ "imagedata byte order": "LITTLEENDIAN",
52
+ "!SPECT STUDY (General)": "",
53
+ "!number format": "float",
54
+ "!number of bytes per pixel": "4",
55
+ "!number of projections": "1",
56
+ "!extent of rotation": "360",
57
+ "process status": "acquired",
58
+ "!SPECT STUDY (acquired data)": "",
59
+ "!direction of rotation": "CW",
60
+ "start angle": "180",
61
+ "Radius": "200",
62
+ "!matrix size [1]": "128",
63
+ "scaling factor (mm/pixel) [1]": "1",
64
+ "!matrix size [2]": "128",
65
+ "scaling factor (mm/pixel) [2]": "1",
66
+ }
67
+
68
+ # Apply header overrides if provided
69
+ if header_overrides is not None:
70
+ self.header.update(header_overrides)
71
+
72
+ self.pixel_array = None
73
+
74
+ def update_header(self, updates):
75
+ """
76
+ Update the header dictionary with new key-value pairs.
77
+
78
+ Parameters:
79
+ updates (dict): Dictionary of header key updates.
80
+ """
81
+ self.header.update(updates)
82
+
83
+ def build(self, output_path: Optional[str | Path] = None):
84
+ """
85
+ Build and return the STIR AcquisitionData object.
86
+ """
87
+
88
+ # Create a zeros array for acquisition data.
89
+ # Dimensions follow the order: [ToF (not used), axial, projections, tangential]
90
+ matrix_size_1 = int(self.header.get("!matrix size [1]", 128))
91
+ matrix_size_2 = int(self.header.get("!matrix size [2]", 128))
92
+ num_projections = int(self.header.get("!number of projections", 1))
93
+ if self.pixel_array is None:
94
+ self.pixel_array = np.zeros(
95
+ (1, matrix_size_1, num_projections, matrix_size_2), dtype=np.float32
96
+ )
97
+ else:
98
+ self.pixel_array = np.array(self.pixel_array, dtype=np.float32)
99
+
100
+ def _write(base_path: Path, cleanup: bool) -> AcquisitionData:
101
+ header_path = base_path.with_suffix(".hs")
102
+ raw_file_path = base_path.with_suffix(".s")
103
+
104
+ self.header["name of data file"] = raw_file_path.name
105
+ self.header["!END OF INTERFILE"] = ""
106
+
107
+ with open(header_path, "w") as f:
108
+ for key, value in self.header.items():
109
+ f.write(f"{key} := {value}\n")
110
+
111
+ self.pixel_array.tofile(raw_file_path)
112
+
113
+ acqdata = self._load_acquisition(str(header_path))
114
+
115
+ flipped = np.flip(self.pixel_array, axis=-1)
116
+ acqdata = acqdata.clone()
117
+ acqdata.fill(flipped)
118
+ acqdata.write(str(header_path))
119
+
120
+ if cleanup:
121
+ header_path.unlink(missing_ok=True)
122
+ raw_file_path.unlink(missing_ok=True)
123
+
124
+ return self._unwrap_native(acqdata)
125
+
126
+ if output_path is None:
127
+ with temporary_directory() as tmp_dir:
128
+ return _write(Path(tmp_dir) / "spect_acq", cleanup=False)
129
+
130
+ return _write(Path(output_path), cleanup=False)
131
+
132
+ def _load_acquisition(self, header_path: str):
133
+ """Load acquisition data with optional explicit backend selection."""
134
+ if BACKEND_AVAILABLE and create_acquisition_data is not None:
135
+ if (
136
+ self.backend is not None
137
+ and BACKENDS.detection.set_backend is not None
138
+ and BACKENDS.detection.get_backend is not None
139
+ ):
140
+ previous_backend = None
141
+ try:
142
+ previous_backend = BACKENDS.detection.get_backend()
143
+ except Exception:
144
+ previous_backend = None
145
+ BACKENDS.detection.set_backend(self.backend)
146
+ try:
147
+ return create_acquisition_data(header_path)
148
+ finally:
149
+ if (
150
+ previous_backend is not None
151
+ and previous_backend != self.backend
152
+ ):
153
+ try:
154
+ BACKENDS.detection.set_backend(previous_backend)
155
+ except Exception:
156
+ pass
157
+ return create_acquisition_data(header_path)
158
+
159
+ if self.backend == "stir":
160
+ raise ImportError(
161
+ "Requested STIR backend for acquisition loading, but backend wrappers "
162
+ "are unavailable."
163
+ )
164
+
165
+ if SIRF_AVAILABLE:
166
+ return AcquisitionData(header_path)
167
+
168
+ raise ImportError(
169
+ "Unable to load acquisition data: neither SIRF nor STIR Python "
170
+ "backends are available."
171
+ )
172
+
173
+ @staticmethod
174
+ def _unwrap_native(obj):
175
+ """Return native backend object when a wrapper is provided."""
176
+ native = getattr(obj, "native_object", None)
177
+ return native if native is not None else obj
178
+
179
+ def build_multi_energy(self, output_path_base="temp", multiple_data_files=True):
180
+ """
181
+ If multiple energy windows are available (as extracted in self.energy_windows),
182
+ build and save separate AcquisitionData files for each energy window. At the
183
+ moment,
184
+ this is the only way to do this. In the future, we may consider adding an option
185
+ to change the data offset and save data to a single file.
186
+
187
+ Files are saved with a suffix indicating the energy window number.
188
+
189
+ Returns:
190
+ list: A list of AcquisitionData objects (one per energy window).
191
+ """
192
+ if not hasattr(self, "energy_windows") or not self.energy_windows:
193
+ warnings.warn("No energy window information found. Using standard build().")
194
+ acqdata = self.build(output_path=output_path_base)
195
+ return [acqdata]
196
+
197
+ # number of projections needs dividing by number of energy windows
198
+ num_projections = int(self.header.get("!number of projections", 1))
199
+ num_projections //= len(self.energy_windows)
200
+ self.header["!number of projections"] = str(num_projections)
201
+
202
+ # split pixel_array into energy windows along 3rd axis
203
+ pixel_array_list = np.array_split(
204
+ self.pixel_array, len(self.energy_windows), axis=2
205
+ )
206
+
207
+ acqdata_list = []
208
+ for idx, ew in enumerate(self.energy_windows):
209
+ # Update header for this energy window.
210
+ self.header["energy window lower level[1]"] = ew["lower"]
211
+ self.header["energy window upper level[1]"] = ew["upper"]
212
+ suffix = f"_ew{idx + 1}"
213
+ output_path = output_path_base + suffix
214
+ self.pixel_array = pixel_array_list[idx]
215
+ acqdata = self.build(output_path=output_path)
216
+ acqdata_list.append(acqdata)
217
+ return acqdata_list
218
+
219
+ def update_header_from_dicom(self, dicom_filepath):
220
+ """
221
+ Update header values from a DICOM file. Extracts as many relevant values as
222
+ possible,
223
+ with warnings if an expected tag is missing.
224
+
225
+ Also extracts energy window information into self.energy_windows.
226
+
227
+ Parameters:
228
+ dicom_filepath (str): Path to the DICOM file.
229
+ """
230
+ ds = pydicom.dcmread(dicom_filepath)
231
+
232
+ # (Existing updates for modality, matrix sizes, pixel spacing, number of
233
+ # projections, etc.)
234
+ try:
235
+ self.header["!imaging modality"] = ds.Modality
236
+ except AttributeError:
237
+ warnings.warn(
238
+ "Modality not found in DICOM. Retaining default '!imaging modality'."
239
+ )
240
+ try:
241
+ self.header["!matrix size [1]"] = str(ds.Rows)
242
+ self.header["!matrix size [2]"] = str(ds.Columns)
243
+ except AttributeError:
244
+ warnings.warn(
245
+ "Rows/Columns not found in DICOM. Retaining default matrix sizes."
246
+ )
247
+ try:
248
+ pixel_spacing = ds.PixelSpacing
249
+ self.header["scaling factor (mm/pixel) [1]"] = str(pixel_spacing[0])
250
+ self.header["scaling factor (mm/pixel) [2]"] = str(pixel_spacing[1])
251
+ except AttributeError:
252
+ warnings.warn(
253
+ "PixelSpacing not found in DICOM. Retaining default scaling factors."
254
+ )
255
+ try:
256
+ num_frames = ds.get("NumberOfFrames", None)
257
+ if num_frames is not None:
258
+ self.header["!number of projections"] = str(num_frames)
259
+ else:
260
+ warnings.warn(
261
+ "NumberOfFrames not found in DICOM. Retaining default "
262
+ "'!number of projections'."
263
+ )
264
+ except Exception as e:
265
+ warnings.warn("Error accessing NumberOfFrames from DICOM: " + str(e))
266
+
267
+ # Extract energy window information
268
+ try:
269
+ ewi_seq_tag = (0x0054, 0x0012)
270
+ self.energy_windows = [] # list to hold each energy window's info
271
+ if ewi_seq_tag in ds:
272
+ ewi_seq = ds[ewi_seq_tag].value
273
+ for ewi_item in ewi_seq:
274
+ # Look for the Energy Window Range Sequence (tag 0054,0013)
275
+ rwr_tag = (0x0054, 0x0013)
276
+ if rwr_tag in ewi_item:
277
+ rwr_seq = ewi_item[rwr_tag].value
278
+ # It is common that there is one item per energy window.
279
+ for rwr_item in rwr_seq:
280
+ lower = rwr_item.get((0x0054, 0x0014), None)
281
+ upper = rwr_item.get((0x0054, 0x0015), None)
282
+ if lower is not None and upper is not None:
283
+ self.energy_windows.append(
284
+ {
285
+ "lower": str(lower.value),
286
+ "upper": str(upper.value),
287
+ }
288
+ )
289
+ self.header["!number of energy windows"] = str(len(self.energy_windows))
290
+ if len(self.energy_windows) == 1:
291
+ # For a single window, also set header keys for convenience.
292
+ self.header["energy window lower level[1]"] = self.energy_windows[
293
+ 0
294
+ ]["lower"]
295
+ self.header["energy window upper level[1]"] = self.energy_windows[
296
+ 0
297
+ ]["upper"]
298
+ else:
299
+ warnings.warn("Energy Window Information Sequence not found in DICOM.")
300
+ except Exception as e:
301
+ warnings.warn(
302
+ "Error processing Energy Window Information Sequence: " + str(e)
303
+ )
304
+
305
+ # Rotation Information Sequence processing
306
+ try:
307
+ if (0x0054, 0x0052) in ds:
308
+ rot_seq = ds[(0x0054, 0x0052)].value
309
+ if len(rot_seq) > 0:
310
+ rot_item = rot_seq[0]
311
+
312
+ # Extract basic rotation parameters
313
+ if "StartAngle" in rot_item:
314
+ self.header["start angle"] = str(rot_item.StartAngle)
315
+ elif (0x0054, 0x0200) in rot_item:
316
+ self.header["start angle"] = str(
317
+ rot_item[(0x0054, 0x0200)].value
318
+ )
319
+
320
+ if (0x0018, 0x1242) in rot_item:
321
+ time_per_projection = str(
322
+ rot_item[(0x0018, 0x1242)].value / 1000
323
+ )
324
+
325
+ if num_frames is not None:
326
+ self.header["number of time frames"] = str(1)
327
+ self.header["!image duration (sec)[1]"] = str(
328
+ int(
329
+ np.round(
330
+ float(time_per_projection) * float(num_frames), 0
331
+ )
332
+ )
333
+ )
334
+ else:
335
+ self.header["!time per projection (sec)[1]"] = (
336
+ time_per_projection
337
+ )
338
+
339
+ if "RotationDirection" in rot_item:
340
+ rd = str(rot_item.RotationDirection)
341
+ self.header["!direction of rotation"] = (
342
+ "CCW" if rd == "CC" else ("CW" if rd == "C" else rd)
343
+ )
344
+ elif (0x0018, 0x1140) in rot_item:
345
+ rd = str(rot_item[(0x0018, 0x1140)].value)
346
+ self.header["!direction of rotation"] = (
347
+ "CCW" if rd == "CC" else ("CW" if rd == "C" else rd)
348
+ )
349
+
350
+ if "ScanArc" in rot_item:
351
+ self.header["!extent of rotation"] = str(rot_item.ScanArc)
352
+ elif (0x0018, 0x1143) in rot_item:
353
+ self.header["!extent of rotation"] = str(
354
+ rot_item[(0x0018, 0x1143)].value
355
+ )
356
+
357
+ # Handle radial position - this is the key section
358
+ mean_radial_position = 0.0 # default
359
+ radial_processed = False
360
+
361
+ if (0x0018, 0x1142) in rot_item:
362
+ rp_val = rot_item[(0x0018, 0x1142)].value
363
+
364
+ # Check if rp_val is an array with multiple values
365
+ if (
366
+ hasattr(rp_val, "__iter__")
367
+ and not isinstance(rp_val, str)
368
+ and len(rp_val) > 1
369
+ ):
370
+ # Use the array values directly as the radii of rotation
371
+ rp_list = [float(x) for x in rp_val]
372
+
373
+ # Check if all radii are the same (circular orbit) using
374
+ # proper tolerance
375
+ if len(set(rp_list)) == 1 or all(
376
+ abs(r - rp_list[0]) < 1e-3 for r in rp_list
377
+ ):
378
+ self.header["Radius"] = str(rp_list[0])
379
+ self.header["orbit"] = "circular"
380
+ # Remove Radii key if it exists
381
+ self.header.pop("Radii", None)
382
+ else:
383
+ self.header["Radii"] = (
384
+ "{" + ",".join(str(r) for r in rp_list) + "}"
385
+ )
386
+ self.header["orbit"] = "non-circular"
387
+ # Remove Radius key if it exists
388
+ self.header.pop("Radius", None)
389
+
390
+ radial_processed = True
391
+ print(
392
+ f"Debug: Direct radii processed - all same: "
393
+ f"{len(set(rp_list)) == 1}, values: {rp_list[:5]}..."
394
+ )
395
+
396
+ else:
397
+ # Single value
398
+ mean_radial_position = float(rp_val)
399
+ print(
400
+ f"Debug: Single radial position: {mean_radial_position}"
401
+ )
402
+ else:
403
+ warnings.warn(
404
+ "Mean radial position not found in Rotation Information "
405
+ "Sequence. Using default 0.0."
406
+ )
407
+ mean_radial_position = 0.0
408
+
409
+ # Only process tomo view offset if we haven't already processed
410
+ # direct radii
411
+ if not radial_processed:
412
+ print(
413
+ "Debug: Processing tomo view offset since direct radii "
414
+ "not processed"
415
+ )
416
+
417
+ # Process Detector Information Sequence & Tomo View Offset
418
+ det_info_seq_tag = (0x0055, 0x1022)
419
+ tomo_view_offset_tag = (0x0013, 0x101E)
420
+
421
+ if det_info_seq_tag in ds:
422
+ det_seq = ds[det_info_seq_tag].value
423
+ if len(det_seq) > 0:
424
+ det_item = det_seq[0]
425
+ if tomo_view_offset_tag in det_item:
426
+ tvo = det_item[tomo_view_offset_tag].value
427
+ if (
428
+ hasattr(tvo, "__iter__")
429
+ or isinstance(tvo, (list, tuple))
430
+ ) and len(tvo) > 1:
431
+ # Compute radial positions by adding tomo view
432
+ # offsets to the mean radial position
433
+ radial_positions = [
434
+ mean_radial_position + float(tvo[i])
435
+ for i in range(2, min(len(tvo), 360), 3)
436
+ ]
437
+
438
+ # Handle empty radial_positions list
439
+ if len(radial_positions) == 0:
440
+ self.header["Radius"] = str(
441
+ mean_radial_position
442
+ )
443
+ self.header["orbit"] = "circular"
444
+ elif len(radial_positions) == 1 or all(
445
+ abs(r - radial_positions[0]) < 1e-3
446
+ for r in radial_positions
447
+ ):
448
+ self.header["Radius"] = str(
449
+ radial_positions[0]
450
+ )
451
+ self.header["orbit"] = "circular"
452
+ else:
453
+ self.header["Radii"] = (
454
+ "{"
455
+ + ",".join(
456
+ str(r) for r in radial_positions
457
+ )
458
+ + "}"
459
+ )
460
+ self.header["orbit"] = "non-circular"
461
+ self.header.pop("Radius", None)
462
+ else:
463
+ self.header["Radius"] = str(
464
+ mean_radial_position + float(tvo)
465
+ )
466
+ self.header["orbit"] = "circular"
467
+ else:
468
+ self.header["Radius"] = str(mean_radial_position)
469
+ self.header["orbit"] = "circular"
470
+ else:
471
+ print(
472
+ "Debug: Skipping tomo view offset processing - direct "
473
+ "radii already processed"
474
+ )
475
+
476
+ else:
477
+ warnings.warn("Rotation Information Sequence is empty.")
478
+ else:
479
+ warnings.warn("Rotation Information Sequence not found in DICOM.")
480
+
481
+ except Exception as e:
482
+ warnings.warn("Error processing Rotation Information Sequence: " + str(e))
483
+
484
+ # (Remaining updates: acquisition date & time, acquisition number,
485
+ # manufacturer, etc.)
486
+ try:
487
+ self.header[";#acquisition date"] = ds.AcquisitionDate
488
+ except AttributeError:
489
+ warnings.warn("StudyDate not found in DICOM.")
490
+ try:
491
+ self.header[";#acquisition time"] = ds.AcquisitionTime
492
+ except AttributeError:
493
+ warnings.warn("StudyTime not found in DICOM.")
494
+ try:
495
+ acq_num = ds.get("AcquisitionNumber", None)
496
+ if acq_num is not None:
497
+ self.header[";#acquisition number"] = str(acq_num)
498
+ else:
499
+ warnings.warn("AcquisitionNumber not found in DICOM.")
500
+ except Exception as e:
501
+ warnings.warn("Error updating AcquisitionNumber: " + str(e))
502
+ try:
503
+ self.header[";#manufacturer"] = ds.Manufacturer
504
+ except AttributeError:
505
+ warnings.warn("Manufacturer not found in DICOM.")
506
+ try:
507
+ self.header[";#institution name"] = ds.InstitutionName
508
+ except AttributeError:
509
+ warnings.warn("InstitutionName not found in DICOM.")
510
+ try:
511
+ self.header[";#patient name"] = ds.PatientName
512
+ except AttributeError:
513
+ warnings.warn("PatientName not found in DICOM.")
514
+ try:
515
+ self.header[";#study name"] = ds.StudyDescription
516
+ except AttributeError:
517
+ warnings.warn("StudyDescription not found in DICOM.")
518
+ try:
519
+ self.pixel_array = ds.pixel_array
520
+ print(self.pixel_array.shape)
521
+ self.pixel_array = np.transpose(self.pixel_array, (2, 0, 1))
522
+ # rotate the image by 90 degrees cW in axis 1
523
+ self.pixel_array = np.rot90(self.pixel_array, 3, axes=(0, 2))
524
+ self.pixel_array = np.expand_dims(self.pixel_array, axis=0)
525
+ except AttributeError:
526
+ warnings.warn("Pixel data not found in DICOM.")