dicube 0.2.2__cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.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.
@@ -0,0 +1,740 @@
1
+ import json
2
+ import os
3
+ import warnings
4
+ from enum import Enum
5
+ from typing import Any, Dict, List, Optional, Tuple, Union
6
+
7
+ import numpy as np
8
+ import pydicom
9
+ from pydicom.tag import Tag
10
+ from pydicom import datadict
11
+ from pydicom.uid import generate_uid
12
+
13
+ from .dicom_tags import CommonTags, get_tag_key
14
+
15
+
16
+ ###############################################################################
17
+ # Enum: Specify sorting methods
18
+ ###############################################################################
19
+ class SortMethod(Enum):
20
+ """Enumeration of available sorting methods for DICOM datasets.
21
+
22
+ Attributes:
23
+ INSTANCE_NUMBER_ASC (int): Sort by instance number in ascending order.
24
+ INSTANCE_NUMBER_DESC (int): Sort by instance number in descending order.
25
+ POSITION_RIGHT_HAND (int): Sort by position using right-hand coordinate system.
26
+ POSITION_LEFT_HAND (int): Sort by position using left-hand coordinate system.
27
+ """
28
+
29
+ INSTANCE_NUMBER_ASC = 1
30
+ INSTANCE_NUMBER_DESC = 2
31
+ POSITION_RIGHT_HAND = 3
32
+ POSITION_LEFT_HAND = 4
33
+
34
+
35
+ def _get_projection_location(meta: "DicomMeta") -> List[float]:
36
+ """Calculate projection locations for each dataset along the slice direction.
37
+
38
+ Uses the normal vector from ImageOrientationPatient and positions from
39
+ ImagePositionPatient to compute the projection distance for each slice.
40
+
41
+ Args:
42
+ meta: DicomMeta instance containing the required tags
43
+
44
+ Returns:
45
+ List[float]: List of projection locations, one for each dataset
46
+
47
+ Raises:
48
+ ValueError: If ImageOrientationPatient is not found or invalid
49
+ """
50
+ # Get ImageOrientationPatient - should be shared
51
+ if not meta.is_shared(CommonTags.ImageOrientationPatient):
52
+ raise ValueError("ImageOrientationPatient is not shared across datasets.")
53
+
54
+ orientation = meta.get_shared_value(CommonTags.ImageOrientationPatient)
55
+ if not orientation:
56
+ raise ValueError("ImageOrientationPatient not found or invalid.")
57
+
58
+ # Convert orientation values to float
59
+ orientation = [float(v) for v in orientation]
60
+ row_orientation = np.array(orientation[:3])
61
+ col_orientation = np.array(orientation[3:])
62
+ normal_vector = np.cross(row_orientation, col_orientation)
63
+
64
+ # Get positions for each dataset
65
+ positions = meta.get_values(CommonTags.ImagePositionPatient)
66
+
67
+ projection_locations = []
68
+ for pos in positions:
69
+ if pos:
70
+ position_array = np.array([float(v) for v in pos])
71
+ # Project position onto normal vector
72
+ projection = np.dot(position_array, normal_vector)
73
+ projection_locations.append(projection)
74
+ else:
75
+ projection_locations.append(None)
76
+
77
+ return projection_locations
78
+
79
+
80
+ ###############################################################################
81
+ # Helper functions: Create metadata tables for display
82
+ ###############################################################################
83
+
84
+
85
+ def _display(meta, show_shared=True, show_non_shared=True):
86
+ """Display the shared and non-shared metadata in tabular format.
87
+
88
+ Creates two separate tables:
89
+ 1. Shared metadata table with columns: Tag, Name, Value
90
+ 2. Non-shared metadata table where:
91
+ - First row: Tag
92
+ - Second row: Name
93
+ - Following rows: Values for each dataset
94
+ - Row labels: Filenames (without paths)
95
+
96
+ Args:
97
+ meta (DicomMeta): DicomMeta object to display.
98
+ show_shared (bool): If True, display shared metadata. Defaults to True.
99
+ show_non_shared (bool): If True, display non-shared metadata. Defaults to True.
100
+
101
+ Returns:
102
+ pandas.DataFrame: Formatted metadata tables.
103
+ """
104
+ import pandas as pd
105
+ from .dicom_tags import CommonTags
106
+
107
+ # Prepare shared and non-shared data
108
+ shared_data = []
109
+ non_shared_data = {}
110
+ non_shared_tags = []
111
+
112
+ # Ensure filenames are available and extract base filenames without paths
113
+ if meta.filenames:
114
+ filenames = meta.filenames
115
+ else:
116
+ # If filenames are not stored, generate default filenames
117
+ filenames = [f"Dataset_{i}" for i in range(meta.slice_count)]
118
+
119
+ # Define priority tags for ordering
120
+ priority_shared_tags = [
121
+ CommonTags.PatientName,
122
+ CommonTags.PatientID,
123
+ CommonTags.StudyDate,
124
+ CommonTags.StudyDescription,
125
+ CommonTags.SeriesDescription,
126
+ CommonTags.Modality,
127
+ # Add other common shared tags as needed
128
+ ]
129
+
130
+ priority_non_shared_tags = [
131
+ CommonTags.InstanceNumber,
132
+ CommonTags.SliceLocation,
133
+ CommonTags.ImagePositionPatient,
134
+ # Add other common non-shared tags as needed
135
+ ]
136
+
137
+ # Process each tag
138
+ for tag_key in meta.keys():
139
+ tag = Tag(int(tag_key[:4], 16), int(tag_key[4:], 16))
140
+ tag_name = datadict.dicom_dict_summary.get(tag, {}).get("name", f"({tag.group:04X},{tag.element:04X})")
141
+ vr = meta.get_vr(tag)
142
+
143
+ if meta.is_shared(tag):
144
+ if show_shared and vr != "SQ": # Skip sequences for simplicity
145
+ value = meta.get_shared_value(tag)
146
+ shared_data.append({
147
+ "Tag": f"({tag.group:04X},{tag.element:04X})",
148
+ "Name": tag_name,
149
+ "Value": value,
150
+ })
151
+ else:
152
+ if show_non_shared and vr != "SQ":
153
+ values = meta.get_values(tag)
154
+ non_shared_tags.append(tag)
155
+ non_shared_data[tag_key] = {
156
+ "Name": tag_name,
157
+ "Values": values,
158
+ }
159
+
160
+ # Sort shared tags, prioritizing common tags
161
+ def tag_sort_key(tag_info):
162
+ tag_str = tag_info["Tag"]
163
+ tag_obj = Tag(int(tag_str[1:5], 16), int(tag_str[6:10], 16))
164
+ if any(tag_obj == priority_tag for priority_tag in priority_shared_tags):
165
+ for i, priority_tag in enumerate(priority_shared_tags):
166
+ if tag_obj == priority_tag:
167
+ return (0, i)
168
+ return (1, tag_str)
169
+
170
+ shared_data.sort(key=tag_sort_key)
171
+
172
+ # Sort non-shared tags, prioritizing common tags
173
+ def non_shared_sort_key(tag_obj):
174
+ if any(tag_obj == priority_tag for priority_tag in priority_non_shared_tags):
175
+ for i, priority_tag in enumerate(priority_non_shared_tags):
176
+ if tag_obj == priority_tag:
177
+ return (0, i)
178
+ return (1, f"({tag_obj.group:04X},{tag_obj.element:04X})")
179
+
180
+ non_shared_tags.sort(key=non_shared_sort_key)
181
+
182
+ # Display shared metadata
183
+ if show_shared:
184
+ print("Shared Metadata:")
185
+ if shared_data:
186
+ shared_df = pd.DataFrame(shared_data)
187
+ print(shared_df.to_string(index=False))
188
+ else:
189
+ print("No shared metadata.")
190
+
191
+ # Display non-shared metadata
192
+ if show_non_shared:
193
+ print("\nNon-Shared Metadata:")
194
+ if non_shared_tags:
195
+ # Create the tag and name rows
196
+ tag_row = {
197
+ f"({tag.group:04X},{tag.element:04X})": f"({tag.group:04X},{tag.element:04X})"
198
+ for tag in non_shared_tags
199
+ }
200
+ name_row = {
201
+ f"({tag.group:04X},{tag.element:04X})": non_shared_data[tag.key]["Name"]
202
+ for tag in non_shared_tags
203
+ }
204
+
205
+ # Collect values for each dataset
206
+ values_rows = []
207
+ for idx in range(meta.slice_count):
208
+ row = {
209
+ f"({tag.group:04X},{tag.element:04X})": non_shared_data[tag.key]["Values"][idx]
210
+ for tag in non_shared_tags
211
+ }
212
+ values_rows.append(row)
213
+
214
+ # Create DataFrame with tag, name, and values
215
+ non_shared_df = pd.DataFrame([tag_row, name_row] + values_rows)
216
+ # Set index with filenames starting from the third row
217
+ non_shared_df.index = ["Tag", "Name"] + filenames
218
+
219
+ print(non_shared_df.to_string())
220
+ else:
221
+ print("No non-shared metadata.")
222
+
223
+
224
+ ###############################################################################
225
+ # DicomMeta Class
226
+ ###############################################################################
227
+ class DicomMeta:
228
+ """A class for managing metadata from multiple DICOM datasets.
229
+
230
+ Uses pydicom's to_json_dict() to extract information from all levels (including sequences)
231
+ of multiple DICOM datasets. Recursively determines which fields are:
232
+ - Shared (identical across all datasets)
233
+ - Non-shared (different across datasets)
234
+
235
+ Provides methods to access, modify, and serialize this metadata.
236
+
237
+ Attributes:
238
+ _merged_data (Dict[str, Dict[str, Any]]): The merged metadata from all datasets.
239
+ filenames (List[str], optional): List of filenames for the datasets.
240
+ slice_count (int): Number of datasets represented.
241
+ """
242
+
243
+ def __init__(
244
+ self,
245
+ merged_data: Dict[str, Dict[str, Any]],
246
+ filenames: Optional[List[str]] = None,
247
+ ):
248
+ """Initialize a DicomMeta instance.
249
+
250
+ Args:
251
+ merged_data (Dict[str, Dict[str, Any]]): The merged metadata from all datasets.
252
+ filenames (List[str], optional): List of filenames for the datasets. Defaults to None.
253
+ """
254
+ self._merged_data = merged_data
255
+ self.filenames = filenames
256
+ # Calculate number of datasets from the first non-shared field
257
+ for tag_entry in merged_data.values():
258
+ if tag_entry.get("shared") is False and "Value" in tag_entry:
259
+ self.slice_count = len(tag_entry["Value"])
260
+ break
261
+ else:
262
+ # If no non-shared fields are found, default to 1
263
+ if filenames is not None:
264
+ self.slice_count = len(filenames)
265
+ else:
266
+ warnings.warn("No filenames provided, defaulting to 1 dataset")
267
+ self.slice_count = 1
268
+
269
+ @classmethod
270
+ def from_datasets(
271
+ cls, datasets: List[pydicom.Dataset], filenames: Optional[List[str]] = None
272
+ ):
273
+ """Create a DicomMeta instance from a list of pydicom datasets.
274
+
275
+ Args:
276
+ datasets (List[pydicom.Dataset]): List of pydicom datasets.
277
+ filenames (List[str], optional): List of filenames corresponding to the datasets.
278
+ Defaults to None.
279
+
280
+ Returns:
281
+ DicomMeta: A new DicomMeta instance created from the datasets.
282
+ """
283
+ from .merge_utils import _merge_dataset_list
284
+
285
+ if not datasets:
286
+ return cls({}, filenames)
287
+
288
+ # Convert each dataset to a dict representation
289
+ dicts = []
290
+ for ds in datasets:
291
+ dicts.append(ds.to_json_dict())
292
+
293
+ # Merge the dictionaries
294
+ merged_data = _merge_dataset_list(dicts)
295
+ return cls(merged_data, filenames)
296
+
297
+ def to_json(self) -> str:
298
+ """Serialize the DicomMeta to a JSON string.
299
+
300
+ Returns:
301
+ str: JSON string representation of the DicomMeta.
302
+ """
303
+ data = {"_merged_data": self._merged_data, "slice_count": self.slice_count}
304
+ return json.dumps(data)
305
+
306
+ @classmethod
307
+ def from_json(cls, json_str: str, filenames: List[str] = None):
308
+ """Create a DicomMeta instance from a JSON string.
309
+
310
+ Args:
311
+ json_str (str): JSON string containing DicomMeta data.
312
+ filenames (List[str], optional): List of filenames corresponding to the datasets.
313
+ Defaults to None.
314
+
315
+ Returns:
316
+ DicomMeta: A new DicomMeta instance created from the JSON data.
317
+ """
318
+ data = json.loads(json_str)
319
+ merged_data = data["_merged_data"]
320
+ return cls(merged_data, filenames)
321
+
322
+
323
+ def get_values(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> List[Any]:
324
+ """Get values for a tag across all datasets.
325
+
326
+ Args:
327
+ tag_input: The tag to retrieve, can be a Tag object, string, or (group, element) tuple
328
+
329
+ Returns:
330
+ List[Any]: List of values, one for each dataset. May contain None for datasets
331
+ where the tag is not present.
332
+ """
333
+ tag = Tag(tag_input)
334
+ tag_key = get_tag_key(tag)
335
+
336
+ # Get tag entry
337
+ tag_entry = self._merged_data.get(tag_key)
338
+ if tag_entry is None or "Value" not in tag_entry:
339
+ return [None] * self.slice_count
340
+
341
+ # Return values based on shared status
342
+ if tag_entry.get("shared", False):
343
+ # For shared tags, return the same value for all datasets
344
+ return [tag_entry["Value"]] * self.slice_count
345
+ else:
346
+ # For non-shared tags, return the list of values
347
+ return tag_entry["Value"]
348
+
349
+ def is_shared(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> bool:
350
+ """Check if a tag has consistent values across all datasets.
351
+
352
+ Args:
353
+ tag_input: The tag to check, can be a Tag object, string, or (group, element) tuple
354
+
355
+ Returns:
356
+ bool: True if tag is shared (same value across all datasets), False otherwise
357
+ """
358
+ tag = Tag(tag_input) # pydicom's Tag constructor handles various input formats
359
+ tag_key = get_tag_key(tag)
360
+
361
+ tag_entry = self._merged_data.get(tag_key)
362
+ if tag_entry is None:
363
+ return False
364
+
365
+ return tag_entry.get("shared", False)
366
+
367
+
368
+ def is_missing(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> bool:
369
+ """Check if a tag is missing from the metadata.
370
+
371
+ Args:
372
+ tag_input: The tag to check, can be a Tag object, string, or (group, element) tuple
373
+
374
+ Returns:
375
+ bool: True if tag is missing or has no value, False if present with a value
376
+ """
377
+ tag = Tag(tag_input)
378
+ tag_key = get_tag_key(tag)
379
+
380
+ tag_entry = self._merged_data.get(tag_key)
381
+ return tag_entry is None or "Value" not in tag_entry
382
+
383
+
384
+ def get_shared_value(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> Any:
385
+ """Get the shared value for a tag if it's shared across all datasets.
386
+
387
+ Args:
388
+ tag_input: The tag to retrieve, can be a Tag object, string, or (group, element) tuple
389
+
390
+ Returns:
391
+ Any: The shared value if tag is shared, None if not shared or missing
392
+ """
393
+ tag = Tag(tag_input)
394
+ tag_key = get_tag_key(tag)
395
+
396
+ tag_entry = self._merged_data.get(tag_key)
397
+ if tag_entry is None or "Value" not in tag_entry:
398
+ return None
399
+
400
+ if tag_entry.get("shared", False):
401
+ value = tag_entry["Value"]
402
+ # If the value is a single-item list, extract the item
403
+ if isinstance(value, list) and len(value) == 1:
404
+ return value[0]
405
+ return value
406
+
407
+ return None
408
+
409
+
410
+ def get_vr(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> str:
411
+ """Get the Value Representation (VR) for a tag.
412
+
413
+ Args:
414
+ tag_input: The tag to check, can be a Tag object, string, or (group, element) tuple
415
+
416
+ Returns:
417
+ str: The VR code (e.g., "CS", "LO", "SQ") or empty string if not found
418
+ """
419
+ tag = Tag(tag_input)
420
+ tag_key = get_tag_key(tag)
421
+
422
+ tag_entry = self._merged_data.get(tag_key)
423
+ if tag_entry is None:
424
+ return ""
425
+
426
+ return tag_entry.get("vr", "")
427
+
428
+
429
+ def __getitem__(self, tag_input: Union[str, Tag, Tuple[int, int]]) -> Tuple[Any, Optional[str]]:
430
+ """Get a value and status for a tag (dictionary-style access).
431
+
432
+ This method is useful for compatibility with status checkers that
433
+ need to know both the value and whether it's shared across datasets.
434
+
435
+ Args:
436
+ tag_input: The tag to retrieve, can be a Tag object, string, or (group, element) tuple
437
+
438
+ Returns:
439
+ Tuple[Any, Optional[str]]: A tuple containing:
440
+ - The value or list of values
441
+ - Status string ('shared', 'non_shared', or None if missing)
442
+ """
443
+ tag = Tag(tag_input)
444
+ tag_key = get_tag_key(tag)
445
+
446
+ tag_entry = self._merged_data.get(tag_key)
447
+ if tag_entry is None or "Value" not in tag_entry:
448
+ return (None, None)
449
+
450
+ if tag_entry.get("shared", False):
451
+ return (tag_entry["Value"], "shared")
452
+ else:
453
+ return (tag_entry["Value"], "non_shared")
454
+
455
+ def keys(self) -> List[str]:
456
+ """Get all tag keys in the DicomMeta.
457
+
458
+ Returns:
459
+ List[str]: List of tag keys.
460
+ """
461
+ return list(self._merged_data.keys())
462
+
463
+ def items(self):
464
+ """Get all (key, value) pairs in the DicomMeta.
465
+
466
+ Returns:
467
+ Iterator: Iterator over (key, value) pairs.
468
+ """
469
+ return self._merged_data.items()
470
+
471
+ def __len__(self) -> int:
472
+ """Get the number of tags in the DicomMeta.
473
+
474
+ Returns:
475
+ int: Number of tags.
476
+ """
477
+ return len(self._merged_data)
478
+
479
+ def set_shared_item(self, tag_input: Union[str, Tag, Tuple[int, int]], value: Any) -> None:
480
+ """Set a shared metadata item for all datasets.
481
+
482
+ Args:
483
+ tag_input: The tag to set, can be a Tag object, string, or (group, element) tuple
484
+ value: The value to set for the tag across all datasets
485
+ """
486
+ tag = Tag(tag_input)
487
+ tag_key = get_tag_key(tag)
488
+ vr = datadict.dictionary_VR(tag)
489
+
490
+ # Get existing entry or create new one
491
+ tag_entry = self._merged_data.get(tag_key, {})
492
+
493
+ # Update the entry
494
+ if not isinstance(value, list):
495
+ value = [value]
496
+ tag_entry["Value"] = value
497
+ tag_entry["vr"] = vr
498
+ tag_entry["shared"] = True
499
+
500
+ # Store the updated entry
501
+ self._merged_data[tag_key] = tag_entry
502
+
503
+
504
+ def set_nonshared_item(self, tag_input: Union[str, Tag, Tuple[int, int]], values: List[Any]) -> None:
505
+ """Set a non-shared metadata item with different values for each dataset.
506
+
507
+ Args:
508
+ tag_input: The tag to set, can be a Tag object, string, or (group, element) tuple
509
+ values: List of values, one for each dataset
510
+
511
+ Raises:
512
+ ValueError: If the number of values doesn't match the number of datasets
513
+ """
514
+ if len(values) != self.slice_count:
515
+ raise ValueError(
516
+ f"Number of values ({len(values)}) does not match number of datasets ({self.slice_count})"
517
+ )
518
+
519
+ tag = Tag(tag_input)
520
+ tag_key = get_tag_key(tag)
521
+ vr = datadict.dictionary_VR(tag)
522
+
523
+ # Get existing entry or create new one
524
+ tag_entry = self._merged_data.get(tag_key, {})
525
+
526
+ # Update the entry
527
+ tag_entry["Value"] = values
528
+ tag_entry["vr"] = vr
529
+ tag_entry["shared"] = False
530
+
531
+ # Store the updated entry
532
+ self._merged_data[tag_key] = tag_entry
533
+
534
+ def sort_files(
535
+ self,
536
+ sort_method: SortMethod = SortMethod.INSTANCE_NUMBER_ASC,
537
+ ):
538
+ """Sort the files in the DicomMeta.
539
+
540
+ Args:
541
+ sort_method (SortMethod): Method to use for sorting. Defaults to
542
+ SortMethod.INSTANCE_NUMBER_ASC.
543
+
544
+ Raises:
545
+ ValueError: If the sort method is not supported.
546
+ """
547
+ from .dicom_tags import CommonTags
548
+
549
+ def safe_int(v):
550
+ """Convert a value to integer safely.
551
+
552
+ Args:
553
+ v (Any): Value to convert.
554
+
555
+ Returns:
556
+ int: Converted integer value, or None if conversion fails.
557
+ """
558
+ try:
559
+ return int(v)
560
+ except (ValueError, TypeError):
561
+ return None
562
+
563
+ # Determine sort order based on method
564
+ if sort_method == SortMethod.INSTANCE_NUMBER_ASC:
565
+ # Get instance numbers
566
+ instance_numbers = self.get_values(CommonTags.InstanceNumber)
567
+ indices = list(range(self.slice_count))
568
+ # Convert to integers for sorting
569
+ int_values = [safe_int(v) for v in instance_numbers]
570
+ # Sort based on instance numbers
571
+ sorted_indices = [
572
+ i for _, i in sorted(zip(int_values, indices), key=lambda x: (x[0] is None, x[0]))
573
+ ]
574
+
575
+ elif sort_method == SortMethod.INSTANCE_NUMBER_DESC:
576
+ # Get instance numbers
577
+ instance_numbers = self.get_values(CommonTags.InstanceNumber)
578
+ indices = list(range(self.slice_count))
579
+ # Convert to integers for sorting
580
+ int_values = [safe_int(v) for v in instance_numbers]
581
+ # Sort based on instance numbers (reverse)
582
+ sorted_indices = [
583
+ i
584
+ for _, i in sorted(
585
+ zip(int_values, indices),
586
+ key=lambda x: (x[0] is None, -float("inf") if x[0] is None else -x[0]),
587
+ )
588
+ ]
589
+
590
+ elif sort_method in (SortMethod.POSITION_RIGHT_HAND, SortMethod.POSITION_LEFT_HAND):
591
+ # Calculate projection location along normal vector
592
+ projection_locations = _get_projection_location(self)
593
+ indices = list(range(self.slice_count))
594
+ # Sort based on projection locations
595
+ if sort_method == SortMethod.POSITION_RIGHT_HAND:
596
+ sorted_indices = [
597
+ i
598
+ for _, i in sorted(
599
+ zip(projection_locations, indices),
600
+ key=lambda x: (x[0] is None, x[0]),
601
+ )
602
+ ]
603
+ else: # SortMethod.POSITION_LEFT_HAND
604
+ sorted_indices = [
605
+ i
606
+ for _, i in sorted(
607
+ zip(projection_locations, indices),
608
+ key=lambda x: (x[0] is None, -float("inf") if x[0] is None else -x[0]),
609
+ )
610
+ ]
611
+ else:
612
+ raise ValueError(f"Unsupported sort method: {sort_method}")
613
+
614
+ # Reorder all non-shared values according to the sorted indices
615
+ for tag_key, tag_entry in self._merged_data.items():
616
+ if tag_entry.get("shared") is False:
617
+ # Reorder the values
618
+ values = tag_entry.get("Value", [])
619
+ tag_entry["Value"] = [values[i] if i < len(values) else None for i in sorted_indices]
620
+
621
+ # Reorder filenames if available
622
+ if self.filenames:
623
+ self.filenames = [
624
+ self.filenames[i] if i < len(self.filenames) else None for i in sorted_indices
625
+ ]
626
+ return sorted_indices
627
+
628
+ def display(self, show_shared=True, show_non_shared=True):
629
+ """Display the DicomMeta in a tabular format.
630
+
631
+ Args:
632
+ show_shared (bool): If True, display shared metadata. Defaults to True.
633
+ show_non_shared (bool): If True, display non-shared metadata. Defaults to True.
634
+ """
635
+ _display(self, show_shared, show_non_shared)
636
+
637
+ def _get_projection_location(self):
638
+ """Calculate projection locations for all datasets.
639
+
640
+ Returns:
641
+ List[float]: Projection locations for all datasets.
642
+ """
643
+ return _get_projection_location(self)
644
+
645
+ def index(self, index):
646
+ """Create a new DicomMeta with only the specified dataset.
647
+
648
+ Args:
649
+ index (int): Index of the dataset to extract.
650
+
651
+ Returns:
652
+ DicomMeta: A new DicomMeta containing only the specified dataset.
653
+ """
654
+ from .merge_utils import _slice_merged_data
655
+ return _slice_merged_data(self, index)
656
+
657
+
658
+ def read_dicom_dir(
659
+ directory: str,
660
+ stop_before_pixels=False,
661
+ sort_method: SortMethod = SortMethod.INSTANCE_NUMBER_ASC,
662
+ ):
663
+ """Read all DICOM files from a directory.
664
+
665
+ Args:
666
+ directory (str): Path to the directory containing DICOM files.
667
+ stop_before_pixels (bool): If True, don't read pixel data. Defaults to False.
668
+ sort_method (SortMethod): Method to sort the DICOM files.
669
+ Defaults to SortMethod.INSTANCE_NUMBER_ASC.
670
+
671
+ Returns:
672
+ Tuple[DicomMeta, List[pydicom.Dataset]]: A tuple containing:
673
+ - The merged DicomMeta object
674
+ - The list of pydicom datasets
675
+
676
+ Raises:
677
+ ImportError: If pydicom is not installed.
678
+ FileNotFoundError: If the directory doesn't exist.
679
+ ValueError: If no DICOM files are found in the directory.
680
+ """
681
+ import glob
682
+
683
+ try:
684
+ import pydicom
685
+ except ImportError:
686
+ raise ImportError("pydicom is required to read DICOM files")
687
+
688
+ # Check if directory exists
689
+ if not os.path.isdir(directory):
690
+ raise FileNotFoundError(f"Directory not found: {directory}")
691
+
692
+ # Find all DICOM files in the directory - using a set to avoid duplicates
693
+ dicom_files_set = set()
694
+ for file_extension in ["", ".dcm", ".DCM", ".ima", ".IMA"]:
695
+ pattern = os.path.join(directory, f"*{file_extension}")
696
+ dicom_files_set.update(glob.glob(pattern))
697
+
698
+ dicom_files = list(dicom_files_set) # Convert back to list
699
+
700
+ # Filter out non-DICOM files
701
+ valid_files = []
702
+ for file_path in dicom_files:
703
+ try:
704
+ # Try to read the file as DICOM
705
+ dataset = pydicom.dcmread(
706
+ file_path, stop_before_pixels=stop_before_pixels, force=True
707
+ )
708
+ # Check if it has basic DICOM attributes
709
+ if (0x0008, 0x0016) in dataset: # SOP Class UID
710
+ valid_files.append(file_path)
711
+ except Exception:
712
+ # Not a valid DICOM file, skip
713
+ continue
714
+
715
+ if not valid_files:
716
+ raise ValueError(f"No valid DICOM files found in: {directory}")
717
+
718
+ # Read the valid DICOM files
719
+ datasets = []
720
+ filenames = []
721
+ for file_path in valid_files:
722
+ try:
723
+ dataset = pydicom.dcmread(
724
+ file_path, stop_before_pixels=stop_before_pixels, force=True
725
+ )
726
+ datasets.append(dataset)
727
+ filenames.append(os.path.basename(file_path))
728
+ except Exception as e:
729
+ warnings.warn(f"Error reading {file_path}: {e}")
730
+
731
+ # Create DicomMeta from datasets
732
+ meta = DicomMeta.from_datasets(datasets, filenames)
733
+
734
+ # Sort the files if needed
735
+ if sort_method is not None:
736
+ sorted_indices = meta.sort_files(sort_method)
737
+ # Reorder datasets to match the meta order
738
+ datasets = [datasets[i] for i in sorted_indices]
739
+
740
+ return meta, datasets