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,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.")
|