datamint 1.2.4__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.

Potentially problematic release.


This version of datamint might be problematic. Click here for more details.

@@ -0,0 +1,27 @@
1
+ version: 1
2
+ disable_existing_loggers: False
3
+
4
+ handlers:
5
+ console:
6
+ class: rich.logging.RichHandler
7
+ level: WARNING
8
+ show_time: False
9
+ console_user:
10
+ class: datamintapi.utils.logging_utils.ConditionalRichHandler
11
+ level: INFO
12
+ show_path: False
13
+ show_time: False
14
+
15
+ loggers:
16
+ datamintapi:
17
+ level: ERROR
18
+ handlers: [console]
19
+ propagate: no
20
+ user_logger:
21
+ level: INFO
22
+ handlers: [console_user]
23
+ propagate: no
24
+
25
+ root:
26
+ level: WARNING
27
+ handlers: [console]
@@ -0,0 +1,640 @@
1
+ from pydicom.pixels import pixel_array
2
+ import pydicom
3
+ from pydicom.uid import generate_uid
4
+ from typing import Sequence, Generator, IO, TypeVar, Generic
5
+ import warnings
6
+ from copy import deepcopy
7
+ import logging
8
+ from pathlib import Path
9
+ from pydicom.misc import is_dicom as pydicom_is_dicom
10
+ from io import BytesIO
11
+ import os
12
+ import numpy as np
13
+ from collections import defaultdict
14
+ import uuid
15
+ import hashlib
16
+ from tqdm import tqdm
17
+
18
+ import pydicom.uid
19
+
20
+ _LOGGER = logging.getLogger(__name__)
21
+
22
+ CLEARED_STR = "CLEARED_BY_DATAMINT"
23
+
24
+ T = TypeVar('T')
25
+
26
+
27
+ class GeneratorWithLength(Generic[T]):
28
+ def __init__(self, generator: Generator[T, None, None], length: int):
29
+ self.generator = generator
30
+ self.length = length
31
+
32
+ def __len__(self):
33
+ return self.length
34
+
35
+ def __iter__(self):
36
+ return self.generator
37
+
38
+ def __next__(self) -> T:
39
+ return next(self.generator)
40
+
41
+ def close(self):
42
+ self.generator.close()
43
+
44
+ def throw(self, *args):
45
+ return self.generator.throw(*args)
46
+
47
+ def send(self, *args):
48
+ return self.generator.send(*args)
49
+
50
+
51
+ class TokenMapper:
52
+ def __init__(self, seed: int = 42):
53
+ self.seed = seed
54
+
55
+ def get_token(self, tag: tuple, value: str, simple_id=False) -> str:
56
+ """Get a consistent token for a given tag and value pair."""
57
+ if value is None or value == CLEARED_STR:
58
+ return CLEARED_STR
59
+
60
+ # Use a hash function to generate a consistent token
61
+ token = hashlib.md5(f"{tag}{value}{self.seed}".encode()).hexdigest()
62
+ if simple_id:
63
+ return token
64
+ return generate_uid(entropy_srcs=['DATAMINT', token])
65
+
66
+
67
+ _TOKEN_MAPPER = TokenMapper()
68
+
69
+
70
+ def anonymize_dicom(ds: pydicom.Dataset,
71
+ retain_codes: Sequence[tuple] = [],
72
+ copy=False,
73
+ token_mapper: TokenMapper = None) -> pydicom.Dataset:
74
+ """
75
+ Anonymize a DICOM file by clearing all the specified DICOM tags
76
+ according to the DICOM standard https://www.dicomstandard.org/News-dir/ftsup/docs/sups/sup55.pdf.
77
+ This function will generate a new UID for the new DICOM file and clear the specified DICOM tags
78
+ with consistent tokens for related identifiers.
79
+
80
+ Args:
81
+ ds: pydicom Dataset object.
82
+ retain_codes: A list of DICOM tag codes to retain the value of.
83
+ copy: If True, the function will return a copy of the input Dataset object.
84
+ token_mapper: TokenMapper instance to maintain consistent tokens across calls.
85
+ If None, uses a global instance.
86
+
87
+ Returns:
88
+ pydicom Dataset object with specified DICOM tags cleared
89
+ """
90
+ if copy:
91
+ ds = deepcopy(ds)
92
+
93
+ if token_mapper is None:
94
+ token_mapper = _TOKEN_MAPPER
95
+
96
+ # https://www.dicomstandard.org/News-dir/ftsup/docs/sups/sup55.pdf
97
+ tags_to_clear = [
98
+ (0x0008, 0x0014), (0x0008, 0x0050), (0x0008, 0x0080), (0x0008, 0x0081), (0x0008, 0x0090),
99
+ (0x0008, 0x0092), (0x0008, 0x0094), (0x0008, 0x1010), (0x0008, 0x1030), (0x0008, 0x103E),
100
+ (0x0008, 0x1040), (0x0008, 0x1048), (0x0008, 0x1050), (0x0008, 0x1060), (0x0008, 0x1070),
101
+ (0x0008, 0x1080), (0x0008, 0x1155), (0x0008, 0x2111), (0x0010, 0x0010), (0x0010, 0x0020),
102
+ (0x0010, 0x0030), (0x0010, 0x0032), (0x0010, 0x0040), (0x0010, 0x1000), (0x0010, 0x1001),
103
+ (0x0010, 0x1010), (0x0010, 0x1020), (0x0010, 0x1030), (0x0010, 0x1090), (0x0010, 0x2160),
104
+ (0x0010, 0x2180), (0x0010, 0x21B0), (0x0010, 0x4000), (0x0018, 0x1000), (0x0018, 0x1030),
105
+ (0x0020, 0x000D), (0x0020, 0x000E), # StudyInstanceUID and SeriesInstanceUID
106
+ (0x0020, 0x0010), (0x0020, 0x0052), (0x0020, 0x0200), (0x0020, 0x4000), (0x0008, 0x0018),
107
+ (0x0040, 0x0275), (0x0040, 0xA730), (0x0088, 0x0140), (0x3006, 0x0024), (0x3006, 0x00C2)
108
+ ]
109
+
110
+ # Frame of Reference UID, Series Instance UID, Concatenation UID, and Instance UID, and StudyInstanceUID are converted to new UIDs
111
+ uid_tags = [(0x0020, 0x0052), (0x0020, 0x000E), (0x0020, 0x9161),
112
+ (0x0010, 0x0020), (0x0008, 0x0018), (0x0020, 0x000D)]
113
+ simple_id_tags = [(0x0010, 0x0020)] # Patient ID
114
+
115
+ for code in retain_codes:
116
+ if code in tags_to_clear:
117
+ tags_to_clear.remove(code)
118
+
119
+ # Clear the specified DICOM tags
120
+ with warnings.catch_warnings(): # Supress UserWarning from pydicom
121
+ warnings.filterwarnings("ignore", category=UserWarning, module='pydicom')
122
+ for tag in tags_to_clear:
123
+ if tag in ds:
124
+ if tag == (0x0008, 0x0094): # Phone number
125
+ ds[tag].value = "000-000-0000"
126
+ # If tag is a floating point number, set it to 0.0
127
+ elif ds[tag].VR in ['FL', 'FD', 'DS']:
128
+ ds[tag].value = 0
129
+ elif ds[tag].VR == 'SQ':
130
+ del ds[tag]
131
+ else:
132
+ if tag in uid_tags:
133
+ try:
134
+ # Use consistent token mapping for identifiers
135
+ original_value = ds[tag].value
136
+ ds[tag].value = token_mapper.get_token(tag, original_value, simple_id=tag in simple_id_tags)
137
+ tag_name = pydicom.datadict.keyword_for_tag(tag)
138
+ except ValueError as e:
139
+ ds[tag].value = CLEARED_STR
140
+ else:
141
+ ds[tag].value = CLEARED_STR
142
+ if hasattr(ds, 'file_meta') and hasattr(ds, 'SOPInstanceUID'):
143
+ ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
144
+ return ds
145
+
146
+
147
+ def is_dicom(f: str | Path | BytesIO) -> bool:
148
+ if isinstance(f, BytesIO):
149
+ fp = BytesIO(f.getbuffer()) # Avoid modifying the original BytesIO object
150
+ fp.read(128) # preamble
151
+
152
+ return fp.read(4) == b"DICM"
153
+
154
+ if isinstance(f, Path):
155
+ f = str(f)
156
+ if os.path.isdir(f):
157
+ return False
158
+
159
+ fname = f.lower()
160
+ if fname.endswith('.dcm') or fname.endswith('.dicom'):
161
+ return True
162
+
163
+ # Check if the file has an extension
164
+ if os.path.splitext(f)[1] != '':
165
+ return False
166
+
167
+ try:
168
+ return pydicom_is_dicom(f)
169
+ except FileNotFoundError as e:
170
+ return None
171
+
172
+
173
+ def to_bytesio(ds: pydicom.Dataset, name: str) -> BytesIO:
174
+ """
175
+ Convert a pydicom Dataset object to BytesIO object.
176
+ """
177
+ dicom_bytes = BytesIO()
178
+ pydicom.dcmwrite(dicom_bytes, ds)
179
+ dicom_bytes.seek(0)
180
+ dicom_bytes.name = name
181
+ dicom_bytes.mode = 'rb'
182
+ return dicom_bytes
183
+
184
+
185
+ def load_image_normalized(dicom: pydicom.Dataset, index: int = None) -> np.ndarray:
186
+ """
187
+ Normalizes the shape of an array of images to (n, c, y, x)=(#slices, #channels, height, width).
188
+ It uses dicom.Rows, dicom.Columns, and other information to determine the shape.
189
+
190
+ Args:
191
+ dicom: A dicom with images of varying shapes.
192
+
193
+ Returns:
194
+ A numpy array of shape (n, c, y, x)=(#slices, #channels, height, width).
195
+ """
196
+ n = dicom.get('NumberOfFrames', 1)
197
+ if index is None:
198
+ images = dicom.pixel_array
199
+ else:
200
+ if index is not None and index >= n:
201
+ raise ValueError(f"Index {index} is out of bounds. The number of frames is {n}.")
202
+ images = pixel_array(dicom, index=index)
203
+ n = 1
204
+ shape = images.shape
205
+
206
+ c = dicom.get('SamplesPerPixel')
207
+
208
+ # x=width, y=height
209
+ if images.ndim == 2:
210
+ # Single grayscale image (y, x)
211
+ # Reshape to (1, 1, y, x)
212
+ return images.reshape((1, 1) + images.shape)
213
+ elif images.ndim == 3:
214
+ # (n, y, x) or (y, x, c)
215
+ if shape[0] == 1 or (n is not None and n > 1):
216
+ # (n, y, x)
217
+ return images.reshape(shape[0], 1, shape[1], shape[2])
218
+ if shape[2] in (1, 3, 4) or (c is not None and c > 1):
219
+ # (y, x, c)
220
+ images = images.transpose(2, 0, 1)
221
+ return images.reshape(1, *images.shape)
222
+ elif images.ndim == 4:
223
+ if shape[3] == c or shape[3] in (1, 3, 4) or (c is not None and c > 1):
224
+ # (n, y, x, c) -> (n, c, y, x)
225
+ return images.transpose(0, 3, 1, 2)
226
+
227
+ raise ValueError(f"Unsupported DICOM normalization with shape: {shape}, SamplesPerPixel: {c}, NumberOfFrames: {n}")
228
+
229
+
230
+ def assemble_dicoms(files_path: list[str | IO],
231
+ return_as_IO: bool = False) -> GeneratorWithLength[pydicom.Dataset | IO]:
232
+ """
233
+ Assemble multiple DICOM files into a single multi-frame DICOM file.
234
+ This function will merge the pixel data of the DICOM files and generate a new DICOM file with the combined pixel data.
235
+
236
+ Args:
237
+ files_path: A list of file paths to the DICOM files to be merged.
238
+
239
+ Returns:
240
+ A generator that yields the merged DICOM files.
241
+ """
242
+ dicoms_map = defaultdict(list)
243
+
244
+ for file_path in tqdm(files_path, desc="Reading DICOMs metadata", unit="file"):
245
+ dicom = pydicom.dcmread(file_path,
246
+ specific_tags=['FrameOfReferenceUID', 'InstanceNumber', 'Rows', 'Columns'])
247
+ fr_uid = dicom.get('FrameOfReferenceUID', None)
248
+ if fr_uid is None:
249
+ # generate a random uid
250
+ fr_uid = pydicom.uid.generate_uid()
251
+ instance_number = dicom.get('InstanceNumber', 0)
252
+ rows = dicom.get('Rows', None)
253
+ columns = dicom.get('Columns', None)
254
+ dicoms_map[fr_uid].append((instance_number, file_path, rows, columns))
255
+ if hasattr(file_path, "seek"):
256
+ file_path.seek(0)
257
+
258
+ # Validate that all DICOMs with the same FrameOfReferenceUID have matching dimensions
259
+ for fr_uid, dicom_list in dicoms_map.items():
260
+ if len(dicom_list) <= 1:
261
+ continue
262
+
263
+ # Get dimensions from first DICOM
264
+ first_rows = dicom_list[0][2]
265
+ first_columns = dicom_list[0][3]
266
+
267
+ # Check all other DICOMs have the same dimensions
268
+ for instance_number, file_path, rows, columns in dicom_list:
269
+ if rows != first_rows or columns != first_columns:
270
+ msg = (
271
+ f"Dimension mismatch in FrameOfReferenceUID {fr_uid}: "
272
+ f"Expected {first_rows}x{first_columns}, got {rows}x{columns} "
273
+ f"for file {file_path} and {dicom_list[0][1]}"
274
+ )
275
+ _LOGGER.error(msg)
276
+ raise ValueError(msg)
277
+
278
+ # filter out the two last elements of the tuple (rows, columns)
279
+ dicoms_map = {fr_uid: [(instance_number, file_path) for instance_number, file_path, _, _ in dicoms]
280
+ for fr_uid, dicoms in dicoms_map.items()}
281
+
282
+ gen = _generate_merged_dicoms(dicoms_map, return_as_IO=return_as_IO)
283
+ return GeneratorWithLength(gen, len(dicoms_map))
284
+
285
+
286
+ def _create_multiframe_attributes(merged_ds: pydicom.Dataset,
287
+ all_dicoms: list[pydicom.Dataset]) -> pydicom.Dataset:
288
+ ### Shared Functional Groups Sequence ###
289
+ shared_seq_dataset = pydicom.dataset.Dataset()
290
+
291
+ # check if pixel spacing or spacing between slices are equal for all dicoms
292
+ pixel_spacing = merged_ds.get('PixelSpacing', None)
293
+ all_pixel_spacing_equal = all(ds.get('PixelSpacing', None) == pixel_spacing
294
+ for ds in all_dicoms)
295
+ spacing_between_slices = merged_ds.get('SpacingBetweenSlices', None)
296
+ all_spacing_b_slices_equal = all(ds.get('SpacingBetweenSlices', None) == spacing_between_slices
297
+ for ds in all_dicoms)
298
+
299
+ # if they are equal, add them to the shared functional groups sequence
300
+ if (pixel_spacing is not None and all_pixel_spacing_equal) or (spacing_between_slices is not None and all_spacing_b_slices_equal):
301
+ pixel_measure = pydicom.dataset.Dataset()
302
+ if pixel_spacing is not None:
303
+ pixel_measure.PixelSpacing = pixel_spacing
304
+ if spacing_between_slices is not None:
305
+ pixel_measure.SpacingBetweenSlices = spacing_between_slices
306
+ pixel_measures_seq = pydicom.Sequence([pixel_measure])
307
+ shared_seq_dataset.PixelMeasuresSequence = pixel_measures_seq
308
+
309
+ if len(shared_seq_dataset) > 0:
310
+ shared_seq = pydicom.Sequence([shared_seq_dataset])
311
+ merged_ds.SharedFunctionalGroupsSequence = shared_seq
312
+ #######
313
+
314
+ ### Per-Frame Functional Groups Sequence ###
315
+ perframe_seq_list = []
316
+ for ds in all_dicoms:
317
+ per_frame_dataset = pydicom.dataset.Dataset() # root dataset for each frame
318
+ pos_dataset = pydicom.dataset.Dataset()
319
+ orient_dataset = pydicom.dataset.Dataset()
320
+ pixel_measure = pydicom.dataset.Dataset()
321
+ framenumber_dataset = pydicom.dataset.Dataset()
322
+
323
+ if 'ImagePositionPatient' in ds:
324
+ pos_dataset.ImagePositionPatient = ds.ImagePositionPatient
325
+ if 'ImageOrientationPatient' in ds:
326
+ orient_dataset.ImageOrientationPatient = ds.ImageOrientationPatient
327
+ if 'PixelSpacing' in ds and all_pixel_spacing_equal == False:
328
+ pixel_measure.PixelSpacing = ds.PixelSpacing
329
+ if 'SpacingBetweenSlices' in ds and all_spacing_b_slices_equal == False:
330
+ pixel_measure.SpacingBetweenSlices = ds.SpacingBetweenSlices
331
+
332
+ # Add datasets to the per-frame dataset
333
+ per_frame_dataset.PlanePositionSequence = pydicom.Sequence([pos_dataset])
334
+ per_frame_dataset.PlaneOrientationSequence = pydicom.Sequence([orient_dataset])
335
+ per_frame_dataset.PixelMeasuresSequence = pydicom.Sequence([pixel_measure])
336
+ per_frame_dataset.FrameContentSequence = pydicom.Sequence([framenumber_dataset])
337
+
338
+ perframe_seq_list.append(per_frame_dataset)
339
+ if len(perframe_seq_list[0]) > 0:
340
+ perframe_seq = pydicom.Sequence(perframe_seq_list)
341
+ merged_ds.PerFrameFunctionalGroupsSequence = perframe_seq
342
+ merged_ds.FrameIncrementPointer = (0x5200, 0x9230)
343
+
344
+ return merged_ds
345
+
346
+
347
+ def _generate_dicom_name(ds: pydicom.Dataset) -> str:
348
+ """
349
+ Generate a meaningful name for a DICOM dataset using its attributes.
350
+
351
+ Args:
352
+ ds: pydicom Dataset object
353
+
354
+ Returns:
355
+ A string containing a descriptive name with .dcm extension
356
+ """
357
+ components = []
358
+
359
+ # if hasattr(ds, 'filename'):
360
+ # components.append(os.path.basename(ds.filename))
361
+ if hasattr(ds, 'SeriesDescription'):
362
+ components.append(ds.SeriesDescription)
363
+ if hasattr(ds, 'SeriesNumber'):
364
+ components.append(f"ser{ds.SeriesNumber}")
365
+ if hasattr(ds, 'StudyDescription'):
366
+ components.append(ds.StudyDescription)
367
+ if hasattr(ds, 'StudyID'):
368
+ components.append(ds.StudyID)
369
+
370
+ # Join components and add extension
371
+ if len(components) > 0:
372
+ description = "_".join(str(x) for x in components) + ".dcm"
373
+ # Clean description - remove special chars and spaces
374
+ description = "".join(c if c.isalnum() else "_" for c in description)
375
+ if len(description) > 0:
376
+ return description
377
+
378
+ if hasattr(ds, 'FrameOfReferenceUID'):
379
+ return ds.FrameOfReferenceUID + ".dcm"
380
+
381
+ # Fallback to generic name if no attributes found
382
+ return ds.filename if hasattr(ds, 'filename') else f"merged_dicom_{uuid.uuid4()}.dcm"
383
+
384
+
385
+ def _generate_merged_dicoms(dicoms_map: dict[str, list],
386
+ return_as_IO: bool = False) -> Generator[pydicom.Dataset, None, None]:
387
+ for _, dicoms in dicoms_map.items():
388
+ dicoms.sort(key=lambda x: x[0])
389
+ files_path = [file_path for _, file_path in dicoms]
390
+
391
+ all_dicoms = [pydicom.dcmread(file_path) for file_path in files_path]
392
+
393
+ # Use the first dicom as a template
394
+ merged_dicom = all_dicoms[0]
395
+
396
+ # Combine pixel data
397
+ pixel_arrays = np.stack([ds.pixel_array for ds in all_dicoms], axis=0)
398
+
399
+ # Update the merged dicom
400
+ merged_dicom.PixelData = pixel_arrays.tobytes()
401
+ merged_dicom.NumberOfFrames = len(pixel_arrays) # Set number of frames
402
+ merged_dicom.SOPInstanceUID = pydicom.uid.generate_uid() # Generate new SOP Instance UID
403
+ # Removed deprecated attributes and set Transfer Syntax UID instead:
404
+ merged_dicom.file_meta.TransferSyntaxUID = pydicom.uid.ImplicitVRLittleEndian
405
+
406
+ # Free up memory
407
+ for ds in all_dicoms[1:]:
408
+ del ds.PixelData
409
+
410
+ # create multi-frame attributes
411
+ # check if FramTime is equal for all dicoms
412
+ frame_time = merged_dicom.get('FrameTime', None)
413
+ all_frame_time_equal = all(ds.get('FrameTime', None) == frame_time for ds in all_dicoms)
414
+ if frame_time is not None and all_frame_time_equal:
415
+ merged_dicom.FrameTime = frame_time # (0x0018,0x1063)
416
+ merged_dicom.FrameIncrementPointer = (0x0018, 0x1063) # points to 'FrameTime'
417
+ else:
418
+ # TODO: Sometimes FrameTime is present but not equal for all dicoms. In this case, check out 'FrameTimeVector'.
419
+ merged_dicom = _create_multiframe_attributes(merged_dicom, all_dicoms)
420
+
421
+ # Remove tags of single frame dicoms
422
+ for attr in ['ImagePositionPatient', 'SliceLocation', 'ImageOrientationPatient',
423
+ 'PixelSpacing', 'SpacingBetweenSlices', 'InstanceNumber']:
424
+ if hasattr(merged_dicom, attr):
425
+ delattr(merged_dicom, attr)
426
+
427
+ if return_as_IO:
428
+ name = _generate_dicom_name(merged_dicom)
429
+ yield to_bytesio(merged_dicom, name=name)
430
+ else:
431
+ yield merged_dicom
432
+
433
+
434
+ """
435
+ - The Slice Location (0020,1041) is usually a derived attribute,
436
+ typically computed from Image Position (Patient) (0020,0032)
437
+ """
438
+
439
+
440
+ def get_space_between_slices(ds: pydicom.Dataset) -> float:
441
+ """
442
+ Get the space between slices from a DICOM dataset.
443
+
444
+ Parameters:
445
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
446
+
447
+ Returns:
448
+ float: Space between slices in millimeters.
449
+ """
450
+ # Get the Spacing Between Slices attribute
451
+ if 'SpacingBetweenSlices' in ds:
452
+ return ds.SpacingBetweenSlices
453
+
454
+ if 'SharedFunctionalGroupsSequence' in ds:
455
+ shared_group = ds.SharedFunctionalGroupsSequence[0]
456
+ if 'PixelMeasuresSequence' in shared_group and 'SpacingBetweenSlices' in shared_group.PixelMeasuresSequence[0]:
457
+ return shared_group.PixelMeasuresSequence[0].SpacingBetweenSlices
458
+
459
+ if 'SliceThickness' in ds:
460
+ return ds.SliceThickness
461
+
462
+ return 1.0 # Default value if not found
463
+
464
+
465
+ def get_image_orientation(ds: pydicom.Dataset, slice_index: int) -> np.ndarray:
466
+ """
467
+ Get the image orientation from a DICOM dataset.
468
+
469
+ Parameters:
470
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
471
+
472
+ Returns:
473
+ numpy.ndarray: Image orientation (X, Y, Z) for the specified slice.
474
+ """
475
+ # Get the Image Orientation Patient attribute
476
+ if 'ImageOrientationPatient' in ds:
477
+ return ds.ImageOrientationPatient
478
+
479
+ if 'PerFrameFunctionalGroupsSequence' in ds:
480
+ if 'PlaneOrientationSequence' in ds.PerFrameFunctionalGroupsSequence[slice_index]:
481
+ return ds.PerFrameFunctionalGroupsSequence[slice_index].PlaneOrientationSequence[0].ImageOrientationPatient
482
+
483
+ if 'SharedFunctionalGroupsSequence' in ds:
484
+ return ds.SharedFunctionalGroupsSequence[0].PlaneOrientationSequence[0].ImageOrientationPatient
485
+
486
+ raise ValueError("ImageOrientationPatient not found in DICOM dataset.")
487
+
488
+
489
+ def get_slice_orientation(ds: pydicom.Dataset, slice_index: int) -> np.ndarray:
490
+ """
491
+ Get the slice orientation from a DICOM dataset.
492
+
493
+ Parameters:
494
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
495
+ slice_index (int): 0-based index of the slice in the 3D volume. This is the `InstanceNumber-1`.
496
+
497
+ Returns:
498
+ numpy.ndarray: Slice orientation (X, Y, Z) for the specified slice.
499
+ """
500
+ # Get the Image Orientation Patient attribute
501
+
502
+ x_orient, y_orient = np.array(get_image_orientation(ds, slice_index), dtype=np.float64).reshape(2, 3)
503
+ # compute the normal vector of the slice
504
+ slice_orient = np.cross(x_orient, y_orient)
505
+ # normalize the vector to space_between_slices
506
+ space_between_slices = get_space_between_slices(ds)
507
+ slice_orient = slice_orient / np.linalg.norm(slice_orient) * space_between_slices
508
+
509
+ return slice_orient
510
+
511
+
512
+ def _get_instance_number(ds: pydicom.Dataset, slice_index: int | None = None) -> int:
513
+ if slice_index is None:
514
+ if 'InstanceNumber' in ds and ds.InstanceNumber is not None:
515
+ return ds.InstanceNumber
516
+ elif 'NumberOfFrames' in ds and ds.NumberOfFrames == 1:
517
+ return 0
518
+ else:
519
+ raise ValueError("Slice index is required for multi-frame images.")
520
+ else:
521
+ if slice_index < 0:
522
+ raise ValueError("Slice index must be a non-negative integer.")
523
+ if 'NumberOfFrames' in ds and slice_index >= ds.NumberOfFrames:
524
+ _LOGGER.warning(f"Slice index {slice_index} exceeds number of frames {ds.NumberOfFrames}.")
525
+ root_instance_number = ds.get('InstanceNumber', 1)
526
+ if root_instance_number is None:
527
+ root_instance_number = 1
528
+ return root_instance_number + slice_index
529
+
530
+
531
+ def get_image_position(ds: pydicom.Dataset,
532
+ slice_index: int | None = None) -> np.ndarray:
533
+ """
534
+ Get the image position for a specific slice in a DICOM dataset.
535
+
536
+ Parameters:
537
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
538
+ slice_index (int): Index of the slice in the 3D volume.
539
+
540
+ Returns:
541
+ numpy.ndarray: Image position (X, Y, Z) for the specified slice.
542
+ """
543
+
544
+ instance_number = _get_instance_number(ds, slice_index)
545
+
546
+ if 'PerFrameFunctionalGroupsSequence' in ds:
547
+ if slice_index is not None:
548
+ frame_groups = ds.PerFrameFunctionalGroupsSequence[slice_index]
549
+ if 'PlanePositionSequence' in frame_groups and 'ImagePositionPatient' in frame_groups.PlanePositionSequence[0]:
550
+ return frame_groups.PlanePositionSequence[0].ImagePositionPatient
551
+ else:
552
+ logging.warning("PerFrameFunctionalGroupsSequence is available, but slice_index is not provided.")
553
+
554
+ # Get the Image Position Patient attribute
555
+ if 'ImagePositionPatient' in ds:
556
+ if 'SliceLocation' in ds:
557
+ _LOGGER.debug("SliceLocation attribute is available, but not accounted for in calculation.")
558
+ x = np.array(ds.ImagePositionPatient, dtype=np.float64)
559
+ sc_orient = get_slice_orientation(ds, slice_index)
560
+ return x + sc_orient*(instance_number-ds.get('InstanceNumber', 1))
561
+
562
+ raise ValueError("ImagePositionPatient not found in DICOM dataset.")
563
+
564
+
565
+ def get_pixel_spacing(ds: pydicom.Dataset, slice_index: int) -> np.ndarray:
566
+ """
567
+ Get the pixel spacing from a DICOM dataset.
568
+
569
+ Parameters:
570
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
571
+ slice_index (int): Index of the slice in the 3D volume.
572
+
573
+ Returns:
574
+ numpy.ndarray: Pixel spacing (X, Y) for the specified slice.
575
+ """
576
+ # Get the Pixel Spacing attribute
577
+ if 'PixelSpacing' in ds:
578
+ return np.array(ds.PixelSpacing, dtype=np.float64)
579
+
580
+ if 'PerFrameFunctionalGroupsSequence' in ds:
581
+ if 'PixelMeasuresSequence' in ds.PerFrameFunctionalGroupsSequence[slice_index]:
582
+ return ds.PerFrameFunctionalGroupsSequence[slice_index].PixelMeasuresSequence[0].PixelSpacing
583
+
584
+ if 'SharedFunctionalGroupsSequence' in ds:
585
+ if 'PixelMeasuresSequence' in ds.SharedFunctionalGroupsSequence[0]:
586
+ return ds.SharedFunctionalGroupsSequence[0].PixelMeasuresSequence[0].PixelSpacing
587
+
588
+ raise ValueError("PixelSpacing not found in DICOM dataset.")
589
+
590
+
591
+ def pixel_to_patient(ds: pydicom.Dataset,
592
+ pixel_x, pixel_y,
593
+ slice_index: int | None = None,
594
+ instance_number: int | None = None) -> np.ndarray:
595
+ """
596
+ Convert pixel coordinates (pixel_x, pixel_y) to patient coordinates in DICOM.
597
+
598
+ Parameters:
599
+ ds (pydicom.Dataset): The DICOM dataset containing image metadata.
600
+ pixel_x (float): X coordinate in pixel space.
601
+ pixel_y (float): Y coordinate in pixel space.
602
+ slice_index (int): Index of the slice of the `ds.pixel_array`.
603
+ instance_number (int): Instance number of the slice in the 3D volume.
604
+
605
+
606
+ Returns:
607
+ numpy.ndarray: Patient coordinates (X, Y, Z).
608
+ """
609
+
610
+ # - image_position is the origin of the image in patient coordinates (ImagePositionPatient)
611
+ # - row_vector and col_vector are the direction cosines from ImageOrientationPatient
612
+ # - pixel_spacing is the physical distance between the centers of adjacent pixels
613
+
614
+ if slice_index is not None and instance_number is not None:
615
+ raise ValueError("Either slice_index or instance_number should be provided, not both.")
616
+
617
+ if slice_index is None:
618
+ if instance_number is None:
619
+ instance_number = _get_instance_number(ds)
620
+ root_instance_number = ds.get('InstanceNumber', 1)
621
+ if root_instance_number is None:
622
+ root_instance_number = 1
623
+ slice_index = instance_number - root_instance_number
624
+
625
+ # Get required DICOM attributes
626
+ image_position = np.array(get_image_position(ds, slice_index), dtype=np.float64)
627
+ image_orientation = np.array(get_image_orientation(ds, slice_index), dtype=np.float64).reshape(2, 3)
628
+ # image_position = np.array(ds.ImagePositionPatient, dtype=np.float64) # (0020,0032)
629
+ # image_orientation = np.array(ds.ImageOrientationPatient, dtype=np.float64).reshape(2, 3) # (0020,0037)
630
+ # pixel_spacing = np.array(ds.PixelSpacing, dtype=np.float64) # (0028,0030)
631
+ pixel_spacing = np.array(get_pixel_spacing(ds, slice_index), dtype=np.float64) # (0028,0030)
632
+
633
+ # Compute row and column vectors from image orientation
634
+ row_vector = image_orientation[0]
635
+ col_vector = image_orientation[1]
636
+
637
+ # Compute patient coordinates
638
+ patient_coords = image_position + pixel_x * pixel_spacing[0] * row_vector + pixel_y * pixel_spacing[1] * col_vector
639
+
640
+ return patient_coords