dicube 0.2.2__cp38-cp38-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.
- dicube/__init__.py +174 -0
- dicube/codecs/__init__.py +152 -0
- dicube/codecs/jph/__init__.py +15 -0
- dicube/codecs/jph/codec.py +161 -0
- dicube/codecs/jph/ojph_complete.cpython-38-aarch64-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-38-aarch64-linux-gnu.so +0 -0
- dicube/core/__init__.py +21 -0
- dicube/core/image.py +349 -0
- dicube/core/io.py +408 -0
- dicube/core/pixel_header.py +120 -0
- dicube/dicom/__init__.py +13 -0
- dicube/dicom/dcb_streaming.py +248 -0
- dicube/dicom/dicom_io.py +153 -0
- dicube/dicom/dicom_meta.py +740 -0
- dicube/dicom/dicom_status.py +259 -0
- dicube/dicom/dicom_tags.py +121 -0
- dicube/dicom/merge_utils.py +283 -0
- dicube/dicom/space_from_meta.py +70 -0
- dicube/exceptions.py +189 -0
- dicube/storage/__init__.py +17 -0
- dicube/storage/dcb_file.py +824 -0
- dicube/storage/pixel_utils.py +259 -0
- dicube/utils/__init__.py +6 -0
- dicube/validation.py +380 -0
- dicube-0.2.2.dist-info/METADATA +272 -0
- dicube-0.2.2.dist-info/RECORD +27 -0
- dicube-0.2.2.dist-info/WHEEL +6 -0
@@ -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
|