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,824 @@
|
|
1
|
+
import json
|
2
|
+
import os
|
3
|
+
import struct
|
4
|
+
|
5
|
+
# from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
7
|
+
from typing import List, Optional, Tuple
|
8
|
+
|
9
|
+
import numpy as np
|
10
|
+
import zstandard as zstd
|
11
|
+
from spacetransformer import Space
|
12
|
+
|
13
|
+
from ..codecs import get_codec
|
14
|
+
from ..core.pixel_header import PixelDataHeader
|
15
|
+
from ..dicom import DicomMeta
|
16
|
+
from ..dicom.dicom_status import DicomStatus
|
17
|
+
from ..validation import (
|
18
|
+
validate_not_none,
|
19
|
+
validate_parameter_type,
|
20
|
+
validate_string_not_empty
|
21
|
+
)
|
22
|
+
from ..exceptions import (
|
23
|
+
InvalidCubeFileError,
|
24
|
+
CodecError,
|
25
|
+
MetaDataError,
|
26
|
+
DataConsistencyError
|
27
|
+
)
|
28
|
+
|
29
|
+
"""File Format Specification for DiCube (DCB) Files
|
30
|
+
|
31
|
+
-----------------------------------------------------------------
|
32
|
+
| File Header (Fixed length: 100 bytes) |
|
33
|
+
| magic: 8 bytes (e.g. b"DICUBE") |
|
34
|
+
| version: 4 bytes (unsigned int) |
|
35
|
+
| dicom_status_offset: 8 bytes (Q) |
|
36
|
+
| dicom_status_length: 8 bytes (Q) |
|
37
|
+
| dicommeta_offset: 8 bytes (Q) |
|
38
|
+
| dicommeta_length: 8 bytes (Q) |
|
39
|
+
| space_offset: 8 bytes (Q) |
|
40
|
+
| space_length: 8 bytes (Q) |
|
41
|
+
| pixel_header_offset: 8 bytes (Q) |
|
42
|
+
| pixel_header_length: 8 bytes (Q) |
|
43
|
+
| encoded_frame_offsets_offset: 8 bytes (Q) |
|
44
|
+
| encoded_frame_offsets_length: 8 bytes (Q) |
|
45
|
+
| encoded_frame_lengths_offset: 8 bytes (Q) |
|
46
|
+
| encoded_frame_lengths_length: 8 bytes (Q) |
|
47
|
+
| encoded_frame_count: 8 bytes (Q) |
|
48
|
+
-----------------------------------------------------------------
|
49
|
+
| DicomMeta (compressed JSON, optional) |
|
50
|
+
-----------------------------------------------------------------
|
51
|
+
| Space (JSON) |
|
52
|
+
-----------------------------------------------------------------
|
53
|
+
| PixelDataHeader (JSON, RescaleIntercept/Slope, status etc.) |
|
54
|
+
-----------------------------------------------------------------
|
55
|
+
| encoded_frame_offsets (encoded_frame_count Q values) |
|
56
|
+
-----------------------------------------------------------------
|
57
|
+
| encoded_frame_lengths (encoded_frame_count Q values) |
|
58
|
+
-----------------------------------------------------------------
|
59
|
+
| encoded_frame_data[0] |
|
60
|
+
-----------------------------------------------------------------
|
61
|
+
| encoded_frame_data[1] ... |
|
62
|
+
-----------------------------------------------------------------
|
63
|
+
| ... |
|
64
|
+
-----------------------------------------------------------------
|
65
|
+
| encoded_frame_data[n-1] |
|
66
|
+
-----------------------------------------------------------------
|
67
|
+
|
68
|
+
This format demonstrates how to store multi-frame images in a single file,
|
69
|
+
with offsets and lengths recorded in the header for random access.
|
70
|
+
"""
|
71
|
+
|
72
|
+
|
73
|
+
class DcbFile:
|
74
|
+
"""Base class implementing common file I/O logic for DiCube files.
|
75
|
+
|
76
|
+
This class provides core functionality for:
|
77
|
+
- Header structure management
|
78
|
+
- write() workflow (header, metadata, space, header, offsets/lengths, images)
|
79
|
+
- Common file operations (read/write)
|
80
|
+
|
81
|
+
Subclasses should implement frame encoding via _encode_one_frame() and
|
82
|
+
_decode_one_frame() methods, and set appropriate MAGIC and VERSION values.
|
83
|
+
|
84
|
+
Attributes:
|
85
|
+
HEADER_STRUCT (str): Struct format string for the header.
|
86
|
+
MAGIC (bytes): Magic bytes for file identification.
|
87
|
+
VERSION (int): File format version.
|
88
|
+
TRANSFER_SYNTAX_UID (str, optional): DICOM transfer syntax UID.
|
89
|
+
FILE_EXTENSION (str): File extension for this format.
|
90
|
+
"""
|
91
|
+
|
92
|
+
HEADER_STRUCT = "<8sI13Q"
|
93
|
+
MAGIC = b"DCMCUBE\x00"
|
94
|
+
VERSION = 1
|
95
|
+
TRANSFER_SYNTAX_UID = None # Base class has no specific transfer syntax
|
96
|
+
FILE_EXTENSION = ".dcb" # Default extension
|
97
|
+
|
98
|
+
def __init__(self, filename: str, mode: str = "r"):
|
99
|
+
"""Initialize a DCB file object.
|
100
|
+
|
101
|
+
Args:
|
102
|
+
filename (str): The file path.
|
103
|
+
mode (str): "r" for reading, "w" for writing, "a" for appending.
|
104
|
+
"""
|
105
|
+
# Validate required parameters
|
106
|
+
validate_string_not_empty(filename, "filename", "DcbFile constructor", InvalidCubeFileError)
|
107
|
+
validate_parameter_type(mode, str, "mode", "DcbFile constructor", InvalidCubeFileError)
|
108
|
+
|
109
|
+
if mode not in ("r", "w", "a"):
|
110
|
+
raise InvalidCubeFileError(
|
111
|
+
f"Invalid file mode: {mode}",
|
112
|
+
context="DcbFile constructor",
|
113
|
+
details={"mode": mode, "supported_modes": ["r", "w", "a"]},
|
114
|
+
suggestion="Use 'r' for reading, 'w' for writing, or 'a' for appending"
|
115
|
+
)
|
116
|
+
|
117
|
+
# For write mode, ensure filename has correct extension
|
118
|
+
if mode == "w":
|
119
|
+
filename = self._ensure_correct_extension(filename)
|
120
|
+
|
121
|
+
self.filename = filename
|
122
|
+
self.mode = mode
|
123
|
+
self._header = None # Delay reading header until needed
|
124
|
+
|
125
|
+
if os.path.exists(filename) and mode in ("r", "a"):
|
126
|
+
self._read_header_and_check_type()
|
127
|
+
|
128
|
+
def _ensure_correct_extension(self, filename: str) -> str:
|
129
|
+
"""Ensure the filename has the correct extension for this file type.
|
130
|
+
|
131
|
+
Args:
|
132
|
+
filename (str): The original filename.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
str: The filename with correct extension.
|
136
|
+
"""
|
137
|
+
if not filename.endswith(self.FILE_EXTENSION):
|
138
|
+
return filename + self.FILE_EXTENSION
|
139
|
+
return filename
|
140
|
+
|
141
|
+
def _read_header_and_check_type(self):
|
142
|
+
"""Read file header and determine the correct subclass."""
|
143
|
+
try:
|
144
|
+
hdr = self.read_header(verify_magic=False) # Lazy read
|
145
|
+
magic = hdr["magic"]
|
146
|
+
version = hdr["version"]
|
147
|
+
|
148
|
+
if magic != self.MAGIC:
|
149
|
+
if magic == DcbSFile.MAGIC and version == DcbSFile.VERSION:
|
150
|
+
self.__class__ = DcbSFile
|
151
|
+
else:
|
152
|
+
raise InvalidCubeFileError(
|
153
|
+
f"Unsupported file format",
|
154
|
+
context="file header validation",
|
155
|
+
details={"magic_number": magic, "file_path": self.filename},
|
156
|
+
suggestion="Ensure the file is a valid DicomCube file"
|
157
|
+
)
|
158
|
+
self.VERSION = version
|
159
|
+
except Exception as e:
|
160
|
+
if isinstance(e, InvalidCubeFileError):
|
161
|
+
raise
|
162
|
+
raise InvalidCubeFileError(
|
163
|
+
f"Failed to read file header: {str(e)}",
|
164
|
+
context="file header validation",
|
165
|
+
details={"file_path": self.filename}
|
166
|
+
) from e
|
167
|
+
|
168
|
+
@property
|
169
|
+
def header(self):
|
170
|
+
"""Get the file header, reading it from disk if not already loaded.
|
171
|
+
|
172
|
+
Returns:
|
173
|
+
dict: Dictionary containing header fields.
|
174
|
+
"""
|
175
|
+
if self._header is None:
|
176
|
+
self._header = self.read_header()
|
177
|
+
return self._header
|
178
|
+
|
179
|
+
def read_header(self, verify_magic: bool = True):
|
180
|
+
"""Read and parse the file header.
|
181
|
+
|
182
|
+
Args:
|
183
|
+
verify_magic (bool): If True, verify the magic number. Defaults to True.
|
184
|
+
|
185
|
+
Returns:
|
186
|
+
dict: Dictionary containing header fields.
|
187
|
+
|
188
|
+
Raises:
|
189
|
+
InvalidCubeFileError: If the file is not a valid DicomCube file.
|
190
|
+
"""
|
191
|
+
if self._header:
|
192
|
+
return self._header
|
193
|
+
|
194
|
+
try:
|
195
|
+
header_size = struct.calcsize(self.HEADER_STRUCT)
|
196
|
+
with open(self.filename, "rb") as f:
|
197
|
+
header_data = f.read(header_size)
|
198
|
+
|
199
|
+
if len(header_data) < header_size:
|
200
|
+
raise InvalidCubeFileError(
|
201
|
+
f"File too small to contain valid header",
|
202
|
+
context="read_header operation",
|
203
|
+
details={"expected_size": header_size, "actual_size": len(header_data)},
|
204
|
+
suggestion="Ensure the file is a complete DicomCube file"
|
205
|
+
)
|
206
|
+
|
207
|
+
unpacked = struct.unpack(self.HEADER_STRUCT, header_data)
|
208
|
+
(
|
209
|
+
magic,
|
210
|
+
version,
|
211
|
+
dicom_status_offset,
|
212
|
+
dicom_status_length,
|
213
|
+
dicommeta_offset,
|
214
|
+
dicommeta_length,
|
215
|
+
space_offset,
|
216
|
+
space_length,
|
217
|
+
pixel_header_offset,
|
218
|
+
pixel_header_length,
|
219
|
+
frame_offsets_offset,
|
220
|
+
frame_offsets_length,
|
221
|
+
frame_lengths_offset,
|
222
|
+
frame_lengths_length,
|
223
|
+
frame_count,
|
224
|
+
) = unpacked
|
225
|
+
|
226
|
+
if verify_magic and magic != self.MAGIC:
|
227
|
+
raise InvalidCubeFileError(
|
228
|
+
f"Invalid file format magic number",
|
229
|
+
context="read_header operation",
|
230
|
+
details={"expected_magic": self.MAGIC, "actual_magic": magic},
|
231
|
+
suggestion="Ensure the file is a valid DicomCube file"
|
232
|
+
)
|
233
|
+
|
234
|
+
self._header = {
|
235
|
+
"magic": magic,
|
236
|
+
"version": version,
|
237
|
+
"dicom_status_offset": dicom_status_offset,
|
238
|
+
"dicom_status_length": dicom_status_length,
|
239
|
+
"dicommeta_offset": dicommeta_offset,
|
240
|
+
"dicommeta_length": dicommeta_length,
|
241
|
+
"space_offset": space_offset,
|
242
|
+
"space_length": space_length,
|
243
|
+
"pixel_header_offset": pixel_header_offset,
|
244
|
+
"pixel_header_length": pixel_header_length,
|
245
|
+
"frame_offsets_offset": frame_offsets_offset,
|
246
|
+
"frame_offsets_length": frame_offsets_length,
|
247
|
+
"frame_lengths_offset": frame_lengths_offset,
|
248
|
+
"frame_lengths_length": frame_lengths_length,
|
249
|
+
"frame_count": frame_count,
|
250
|
+
}
|
251
|
+
return self._header
|
252
|
+
except Exception as e:
|
253
|
+
if isinstance(e, InvalidCubeFileError):
|
254
|
+
raise
|
255
|
+
raise InvalidCubeFileError(
|
256
|
+
f"Failed to read file header: {str(e)}",
|
257
|
+
context="read_header operation",
|
258
|
+
details={"file_path": self.filename}
|
259
|
+
) from e
|
260
|
+
|
261
|
+
def _prepare_metadata(
|
262
|
+
self,
|
263
|
+
pixel_header: PixelDataHeader,
|
264
|
+
dicom_meta: Optional[DicomMeta] = None,
|
265
|
+
space: Optional[Space] = None,
|
266
|
+
dicom_status: Optional[DicomStatus] = None,
|
267
|
+
):
|
268
|
+
"""Prepare metadata for writing to DCB file.
|
269
|
+
|
270
|
+
Args:
|
271
|
+
pixel_header (PixelDataHeader): Pixel data header information.
|
272
|
+
dicom_meta (DicomMeta, optional): DICOM metadata. Defaults to None.
|
273
|
+
space (Space, optional): Spatial information. Defaults to None.
|
274
|
+
dicom_status (DicomStatus, optional): DICOM status. Defaults to None.
|
275
|
+
|
276
|
+
Returns:
|
277
|
+
dict: Dictionary containing prepared metadata components.
|
278
|
+
"""
|
279
|
+
# Process dicom_status
|
280
|
+
if dicom_status is None:
|
281
|
+
# If not provided, try to get from pixel_header (backward compatibility)
|
282
|
+
if hasattr(pixel_header, "DicomStatus"):
|
283
|
+
dicom_status = pixel_header.DicomStatus
|
284
|
+
else:
|
285
|
+
dicom_status = DicomStatus.CONSISTENT
|
286
|
+
|
287
|
+
# Convert DicomStatus enum to string for storage
|
288
|
+
# If None was provided, dicom_status will be a DicomStatus enum at this point
|
289
|
+
dicom_status_bin = dicom_status.value.encode("utf-8")
|
290
|
+
|
291
|
+
# Process dicom_meta
|
292
|
+
if dicom_meta:
|
293
|
+
dicommeta_json = dicom_meta.to_json().encode("utf-8")
|
294
|
+
dicommeta_gz = zstd.compress(dicommeta_json)
|
295
|
+
else:
|
296
|
+
dicommeta_gz = b""
|
297
|
+
|
298
|
+
# Process space
|
299
|
+
if space:
|
300
|
+
# Convert space from internal (z,y,x) to file (x,y,z) format
|
301
|
+
space_xyz = space.reverse_axis_order()
|
302
|
+
space_json = space_xyz.to_json().encode("utf-8")
|
303
|
+
else:
|
304
|
+
space_json = b""
|
305
|
+
|
306
|
+
# Process pixel_header
|
307
|
+
pixel_header_bin = pixel_header.to_json().encode("utf-8")
|
308
|
+
|
309
|
+
return {
|
310
|
+
'dicom_status_bin': dicom_status_bin,
|
311
|
+
'dicommeta_gz': dicommeta_gz,
|
312
|
+
'space_json': space_json,
|
313
|
+
'pixel_header_bin': pixel_header_bin
|
314
|
+
}
|
315
|
+
|
316
|
+
def _encode_frames(self, images: List):
|
317
|
+
"""Encode frames using parallel or serial processing.
|
318
|
+
|
319
|
+
Args:
|
320
|
+
images (List): List of frames to encode.
|
321
|
+
|
322
|
+
Returns:
|
323
|
+
List[bytes]: List of encoded frame data.
|
324
|
+
"""
|
325
|
+
# Import get_num_threads function to avoid circular import
|
326
|
+
from .. import get_num_threads
|
327
|
+
num_threads = get_num_threads()
|
328
|
+
|
329
|
+
if num_threads > 1:
|
330
|
+
# Parallel encoding
|
331
|
+
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
332
|
+
encoded_blobs = list(
|
333
|
+
executor.map(lambda x: self._encode_one_frame(x), images)
|
334
|
+
)
|
335
|
+
return encoded_blobs
|
336
|
+
else:
|
337
|
+
# Serial encoding
|
338
|
+
encoded_blobs = []
|
339
|
+
for one_frame in images:
|
340
|
+
encoded_bytes = self._encode_one_frame(one_frame)
|
341
|
+
encoded_blobs.append(encoded_bytes)
|
342
|
+
return encoded_blobs
|
343
|
+
|
344
|
+
def _write_file_structure(self, f, metadata_components, encoded_frames, frame_count):
|
345
|
+
"""Write the complete file structure including metadata and frames.
|
346
|
+
|
347
|
+
Args:
|
348
|
+
f: File handle for writing.
|
349
|
+
metadata_components (dict): Prepared metadata components.
|
350
|
+
encoded_frames (List[bytes]): List of encoded frame data.
|
351
|
+
frame_count (int): Number of frames.
|
352
|
+
|
353
|
+
Returns:
|
354
|
+
dict: Dictionary containing offset and length information for header.
|
355
|
+
"""
|
356
|
+
# Write dicom_status
|
357
|
+
dicom_status_offset = f.tell()
|
358
|
+
f.write(metadata_components['dicom_status_bin'])
|
359
|
+
dicom_status_length = f.tell() - dicom_status_offset
|
360
|
+
|
361
|
+
# Write dicommeta_gz
|
362
|
+
dicommeta_offset = f.tell()
|
363
|
+
f.write(metadata_components['dicommeta_gz'])
|
364
|
+
dicommeta_length = f.tell() - dicommeta_offset
|
365
|
+
|
366
|
+
# Write space_json
|
367
|
+
space_offset = f.tell()
|
368
|
+
f.write(metadata_components['space_json'])
|
369
|
+
space_length = f.tell() - space_offset
|
370
|
+
|
371
|
+
# Write pixel_header_bin
|
372
|
+
pixel_header_offset = f.tell()
|
373
|
+
f.write(metadata_components['pixel_header_bin'])
|
374
|
+
pixel_header_length = f.tell() - pixel_header_offset
|
375
|
+
|
376
|
+
# Reserve offsets / lengths space
|
377
|
+
frame_offsets_offset = f.tell()
|
378
|
+
f.write(b"\x00" * (8 * frame_count))
|
379
|
+
frame_offsets_length = 8 * frame_count
|
380
|
+
|
381
|
+
frame_lengths_offset = f.tell()
|
382
|
+
f.write(b"\x00" * (8 * frame_count))
|
383
|
+
frame_lengths_length = 8 * frame_count
|
384
|
+
|
385
|
+
# Write frames and collect offset/length info
|
386
|
+
offsets = []
|
387
|
+
lengths = []
|
388
|
+
|
389
|
+
for encoded_bytes in encoded_frames:
|
390
|
+
offset_here = f.tell()
|
391
|
+
f.write(encoded_bytes)
|
392
|
+
length_here = f.tell() - offset_here
|
393
|
+
|
394
|
+
offsets.append(offset_here)
|
395
|
+
lengths.append(length_here)
|
396
|
+
|
397
|
+
# Backfill offsets & lengths
|
398
|
+
current_pos = f.tell()
|
399
|
+
f.seek(frame_offsets_offset)
|
400
|
+
for off in offsets:
|
401
|
+
f.write(struct.pack("<Q", off))
|
402
|
+
|
403
|
+
f.seek(frame_lengths_offset)
|
404
|
+
for lng in lengths:
|
405
|
+
f.write(struct.pack("<Q", lng))
|
406
|
+
|
407
|
+
# Go back to the end of the file
|
408
|
+
f.seek(current_pos)
|
409
|
+
|
410
|
+
return {
|
411
|
+
'dicom_status_offset': dicom_status_offset,
|
412
|
+
'dicom_status_length': dicom_status_length,
|
413
|
+
'dicommeta_offset': dicommeta_offset,
|
414
|
+
'dicommeta_length': dicommeta_length,
|
415
|
+
'space_offset': space_offset,
|
416
|
+
'space_length': space_length,
|
417
|
+
'pixel_header_offset': pixel_header_offset,
|
418
|
+
'pixel_header_length': pixel_header_length,
|
419
|
+
'frame_offsets_offset': frame_offsets_offset,
|
420
|
+
'frame_offsets_length': frame_offsets_length,
|
421
|
+
'frame_lengths_offset': frame_lengths_offset,
|
422
|
+
'frame_lengths_length': frame_lengths_length,
|
423
|
+
}
|
424
|
+
|
425
|
+
def write(
|
426
|
+
self,
|
427
|
+
images: List, # Can be List[np.ndarray] or List[Tuple] for ROI data
|
428
|
+
pixel_header: PixelDataHeader,
|
429
|
+
dicom_meta: Optional[DicomMeta] = None,
|
430
|
+
dicom_status: DicomStatus = DicomStatus.CONSISTENT,
|
431
|
+
space: Optional[Space] = None,
|
432
|
+
):
|
433
|
+
"""Write image data and metadata to a DCB file.
|
434
|
+
|
435
|
+
This is a generic write method that subclasses can reuse, customizing
|
436
|
+
single-frame encoding via _encode_one_frame().
|
437
|
+
|
438
|
+
Args:
|
439
|
+
images (List): List of frames to write. Can be List[np.ndarray] for standard files,
|
440
|
+
or List[Tuple[np.ndarray, np.ndarray, np.ndarray]] for ROI files.
|
441
|
+
pixel_header (PixelDataHeader): PixelDataHeader instance containing pixel metadata.
|
442
|
+
dicom_meta (DicomMeta, optional): DICOM metadata. Defaults to None.
|
443
|
+
dicom_status (DicomStatus): DICOM status enumeration. Defaults to DicomStatus.CONSISTENT.
|
444
|
+
space (Space, optional): Spatial information. Defaults to None.
|
445
|
+
"""
|
446
|
+
if images is None:
|
447
|
+
images = []
|
448
|
+
frame_count = len(images)
|
449
|
+
|
450
|
+
# Prepare metadata components
|
451
|
+
metadata_components = self._prepare_metadata(
|
452
|
+
pixel_header, dicom_meta, space, dicom_status
|
453
|
+
)
|
454
|
+
|
455
|
+
# Encode frames
|
456
|
+
encoded_frames = self._encode_frames(images)
|
457
|
+
|
458
|
+
# Write file structure
|
459
|
+
header_size = struct.calcsize(self.HEADER_STRUCT)
|
460
|
+
|
461
|
+
with open(self.filename, "wb") as f:
|
462
|
+
# Write placeholder header
|
463
|
+
f.write(b"\x00" * header_size)
|
464
|
+
|
465
|
+
# Write file structure and get offset information
|
466
|
+
offset_info = self._write_file_structure(
|
467
|
+
f, metadata_components, encoded_frames, frame_count
|
468
|
+
)
|
469
|
+
|
470
|
+
# Backfill header
|
471
|
+
f.seek(0)
|
472
|
+
header_data = struct.pack(
|
473
|
+
self.HEADER_STRUCT,
|
474
|
+
self.MAGIC,
|
475
|
+
self.VERSION,
|
476
|
+
offset_info['dicom_status_offset'],
|
477
|
+
offset_info['dicom_status_length'],
|
478
|
+
offset_info['dicommeta_offset'],
|
479
|
+
offset_info['dicommeta_length'],
|
480
|
+
offset_info['space_offset'],
|
481
|
+
offset_info['space_length'],
|
482
|
+
offset_info['pixel_header_offset'],
|
483
|
+
offset_info['pixel_header_length'],
|
484
|
+
offset_info['frame_offsets_offset'],
|
485
|
+
offset_info['frame_offsets_length'],
|
486
|
+
offset_info['frame_lengths_offset'],
|
487
|
+
offset_info['frame_lengths_length'],
|
488
|
+
frame_count,
|
489
|
+
)
|
490
|
+
f.write(header_data)
|
491
|
+
|
492
|
+
def read_meta(self, DicomMetaClass=DicomMeta):
|
493
|
+
"""Read DICOM metadata from the file.
|
494
|
+
|
495
|
+
Args:
|
496
|
+
DicomMetaClass (class): The class to use for creating the DicomMeta object.
|
497
|
+
Defaults to DicomMeta.
|
498
|
+
|
499
|
+
Returns:
|
500
|
+
DicomMeta: The DICOM metadata, or None if not present in the file.
|
501
|
+
"""
|
502
|
+
hdr = self.header
|
503
|
+
dicommeta_offset = hdr["dicommeta_offset"]
|
504
|
+
dicommeta_length = hdr["dicommeta_length"]
|
505
|
+
|
506
|
+
if dicommeta_length == 0:
|
507
|
+
return None
|
508
|
+
|
509
|
+
with open(self.filename, "rb") as f:
|
510
|
+
f.seek(dicommeta_offset)
|
511
|
+
dicommeta_gz = f.read(dicommeta_length)
|
512
|
+
|
513
|
+
dicommeta_json = zstd.decompress(dicommeta_gz)
|
514
|
+
dicommeta_dict = json.loads(dicommeta_json.decode("utf-8"))
|
515
|
+
|
516
|
+
try:
|
517
|
+
return DicomMetaClass.from_json(json.dumps(dicommeta_dict))
|
518
|
+
except Exception as e:
|
519
|
+
# Backwards compatibility for older file format
|
520
|
+
return DicomMetaClass(
|
521
|
+
dicommeta_dict, ["slice_{i:04d}.dcm" for i in range(hdr["frame_count"])]
|
522
|
+
)
|
523
|
+
|
524
|
+
def read_space(self, SpaceClass=Space):
|
525
|
+
"""Read spatial information from the file.
|
526
|
+
|
527
|
+
Args:
|
528
|
+
SpaceClass (class): The class to use for creating the Space object.
|
529
|
+
Defaults to Space.
|
530
|
+
|
531
|
+
Returns:
|
532
|
+
Space: The spatial information, or None if not present in the file.
|
533
|
+
"""
|
534
|
+
hdr = self.header
|
535
|
+
space_offset = hdr["space_offset"]
|
536
|
+
space_length = hdr["space_length"]
|
537
|
+
|
538
|
+
if space_length == 0:
|
539
|
+
return None
|
540
|
+
|
541
|
+
with open(self.filename, "rb") as f:
|
542
|
+
f.seek(space_offset)
|
543
|
+
space_json = f.read(space_length)
|
544
|
+
|
545
|
+
try:
|
546
|
+
space = SpaceClass.from_json(space_json.decode("utf-8"))
|
547
|
+
# Convert from file (x,y,z) format to internal (z,y,x) format
|
548
|
+
return space.reverse_axis_order()
|
549
|
+
except Exception as e:
|
550
|
+
# If space reading fails, return None
|
551
|
+
return None
|
552
|
+
|
553
|
+
def read_pixel_header(self, HeaderClass=PixelDataHeader):
|
554
|
+
"""Read pixel header information from the file.
|
555
|
+
|
556
|
+
Args:
|
557
|
+
HeaderClass (class): The class to use for creating the PixelDataHeader object.
|
558
|
+
Defaults to PixelDataHeader.
|
559
|
+
|
560
|
+
Returns:
|
561
|
+
PixelDataHeader: The pixel header information.
|
562
|
+
|
563
|
+
Raises:
|
564
|
+
ValueError: If the pixel header is not found in the file.
|
565
|
+
"""
|
566
|
+
hdr = self.header
|
567
|
+
pixel_header_offset = hdr["pixel_header_offset"]
|
568
|
+
pixel_header_length = hdr["pixel_header_length"]
|
569
|
+
|
570
|
+
if pixel_header_length == 0:
|
571
|
+
raise ValueError("Pixel header not found in file.")
|
572
|
+
|
573
|
+
with open(self.filename, "rb") as f:
|
574
|
+
f.seek(pixel_header_offset)
|
575
|
+
pixel_header_bin = f.read(pixel_header_length)
|
576
|
+
|
577
|
+
pixel_header_json = pixel_header_bin.decode("utf-8")
|
578
|
+
return HeaderClass.from_json(pixel_header_json)
|
579
|
+
|
580
|
+
def read_images(self):
|
581
|
+
"""Read all image frames from the file.
|
582
|
+
|
583
|
+
Returns:
|
584
|
+
List[np.ndarray] or np.ndarray: The decoded image frames. If the number of frames is 1,
|
585
|
+
returns a single numpy array, otherwise returns a list of arrays.
|
586
|
+
"""
|
587
|
+
# Import get_num_threads function to avoid circular import
|
588
|
+
from .. import get_num_threads
|
589
|
+
|
590
|
+
hdr = self.header
|
591
|
+
frame_count = hdr["frame_count"]
|
592
|
+
|
593
|
+
if frame_count == 0:
|
594
|
+
# No frames to read
|
595
|
+
pixel_header = self.read_pixel_header()
|
596
|
+
return np.array([], dtype=pixel_header.OriginalPixelDtype)
|
597
|
+
|
598
|
+
# Read frame offsets and lengths
|
599
|
+
frame_offsets_offset = hdr["frame_offsets_offset"]
|
600
|
+
frame_offsets_length = hdr["frame_offsets_length"]
|
601
|
+
frame_lengths_offset = hdr["frame_lengths_offset"]
|
602
|
+
frame_lengths_length = hdr["frame_lengths_length"]
|
603
|
+
|
604
|
+
with open(self.filename, "rb") as f:
|
605
|
+
# Read frame offsets
|
606
|
+
f.seek(frame_offsets_offset)
|
607
|
+
frame_offsets_bin = f.read(frame_offsets_length)
|
608
|
+
frame_offsets = struct.unpack(f"<{frame_count}Q", frame_offsets_bin)
|
609
|
+
|
610
|
+
# Read frame lengths
|
611
|
+
f.seek(frame_lengths_offset)
|
612
|
+
frame_lengths_bin = f.read(frame_lengths_length)
|
613
|
+
frame_lengths = struct.unpack(f"<{frame_count}Q", frame_lengths_bin)
|
614
|
+
|
615
|
+
# Read each frame data
|
616
|
+
frame_data_list = []
|
617
|
+
for offset, length in zip(frame_offsets, frame_lengths):
|
618
|
+
if offset == 0 or length == 0:
|
619
|
+
# Skip empty frames
|
620
|
+
frame_data_list.append(None)
|
621
|
+
continue
|
622
|
+
|
623
|
+
f.seek(offset)
|
624
|
+
encoded_bytes = f.read(length)
|
625
|
+
frame_data_list.append(encoded_bytes)
|
626
|
+
|
627
|
+
# Decode frames (with parallelization if needed)
|
628
|
+
frames = []
|
629
|
+
|
630
|
+
num_threads = get_num_threads()
|
631
|
+
if num_threads > 1 and frame_count > 1:
|
632
|
+
# Parallel decoding
|
633
|
+
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
634
|
+
frames = list(
|
635
|
+
executor.map(
|
636
|
+
lambda x: None if x is None else self._decode_one_frame(x),
|
637
|
+
frame_data_list,
|
638
|
+
)
|
639
|
+
)
|
640
|
+
else:
|
641
|
+
# Serial decoding
|
642
|
+
frames = [
|
643
|
+
None if data is None else self._decode_one_frame(data)
|
644
|
+
for data in frame_data_list
|
645
|
+
]
|
646
|
+
|
647
|
+
# Filter out None frames
|
648
|
+
frames = [f for f in frames if f is not None]
|
649
|
+
|
650
|
+
if len(frames) == 0:
|
651
|
+
# Return empty array if no frames were decoded
|
652
|
+
pixel_header = self.read_pixel_header()
|
653
|
+
return np.array([], dtype=pixel_header.OriginalPixelDtype)
|
654
|
+
elif len(frames) == 1:
|
655
|
+
# Return single frame directly
|
656
|
+
return frames[0]
|
657
|
+
else:
|
658
|
+
# Return list of frames
|
659
|
+
return frames
|
660
|
+
|
661
|
+
def _encode_one_frame(self, frame_data: np.ndarray) -> bytes:
|
662
|
+
"""Encode a single frame to bytes.
|
663
|
+
|
664
|
+
Default implementation returns empty bytes.
|
665
|
+
Subclasses should override this method to implement specific encoding.
|
666
|
+
|
667
|
+
Args:
|
668
|
+
frame_data (np.ndarray): The image frame to encode.
|
669
|
+
|
670
|
+
Returns:
|
671
|
+
bytes: The encoded frame data.
|
672
|
+
"""
|
673
|
+
return np.array([], dtype=self.pixel_header.OriginalPixelDtype)
|
674
|
+
|
675
|
+
def _decode_one_frame(self, bytes) -> np.ndarray:
|
676
|
+
"""Decode a single frame from bytes.
|
677
|
+
|
678
|
+
Default implementation returns an empty array with the correct data type.
|
679
|
+
Subclasses should override this method to implement specific decoding.
|
680
|
+
|
681
|
+
Args:
|
682
|
+
bytes (bytes): The encoded frame data.
|
683
|
+
|
684
|
+
Returns:
|
685
|
+
np.ndarray: The decoded image frame.
|
686
|
+
"""
|
687
|
+
return np.array([], dtype=self.header_data['pixel_header'].OriginalPixelDtype)
|
688
|
+
|
689
|
+
def read_dicom_status(self) -> DicomStatus:
|
690
|
+
"""Read DICOM status information from the file.
|
691
|
+
|
692
|
+
Returns:
|
693
|
+
DicomStatus: The DICOM status enum value, or DicomStatus.CONSISTENT if not present.
|
694
|
+
"""
|
695
|
+
hdr = self.header
|
696
|
+
dicom_status_offset = hdr["dicom_status_offset"]
|
697
|
+
dicom_status_length = hdr["dicom_status_length"]
|
698
|
+
|
699
|
+
if dicom_status_length == 0:
|
700
|
+
return DicomStatus.CONSISTENT
|
701
|
+
|
702
|
+
with open(self.filename, "rb") as f:
|
703
|
+
f.seek(dicom_status_offset)
|
704
|
+
dicom_status_bin = f.read(dicom_status_length)
|
705
|
+
|
706
|
+
return DicomStatus(dicom_status_bin.decode("utf-8"))
|
707
|
+
|
708
|
+
def get_transfer_syntax_uid(self) -> Optional[str]:
|
709
|
+
"""Get the DICOM transfer syntax UID for this file.
|
710
|
+
|
711
|
+
Returns:
|
712
|
+
str or None: The transfer syntax UID, or None if not defined.
|
713
|
+
"""
|
714
|
+
return self.TRANSFER_SYNTAX_UID
|
715
|
+
|
716
|
+
|
717
|
+
|
718
|
+
|
719
|
+
|
720
|
+
class DcbSFile(DcbFile):
|
721
|
+
"""DICOM cube file implementation optimized for speed.
|
722
|
+
|
723
|
+
This format prioritizes quick read/write operations while maintaining
|
724
|
+
lossless compression with average compression ratio.
|
725
|
+
|
726
|
+
Attributes:
|
727
|
+
MAGIC (bytes): Magic bytes for file identification ("DCMCUBES").
|
728
|
+
VERSION (int): File format version.
|
729
|
+
TRANSFER_SYNTAX_UID (str): DICOM transfer syntax UID for HTJ2K Lossless.
|
730
|
+
CODEC_NAME (str): Codec name used for compression.
|
731
|
+
FILE_EXTENSION (str): File extension for speed-optimized format.
|
732
|
+
"""
|
733
|
+
|
734
|
+
MAGIC = b"DCMCUBES"
|
735
|
+
VERSION = 1
|
736
|
+
TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.4.201" # HTJ2K Lossless
|
737
|
+
CODEC_NAME = "jph"
|
738
|
+
FILE_EXTENSION = ".dcbs"
|
739
|
+
|
740
|
+
def _encode_one_frame(self, frame_data: np.ndarray) -> bytes:
|
741
|
+
"""Encode a single frame using the HTJ2K codec.
|
742
|
+
|
743
|
+
Args:
|
744
|
+
frame_data (np.ndarray): The frame data to encode.
|
745
|
+
|
746
|
+
Returns:
|
747
|
+
bytes: The encoded frame data.
|
748
|
+
|
749
|
+
Raises:
|
750
|
+
CodecError: If encoding fails.
|
751
|
+
"""
|
752
|
+
try:
|
753
|
+
codec = get_codec(self.CODEC_NAME)
|
754
|
+
return codec.encode_lossless(frame_data)
|
755
|
+
except Exception as e:
|
756
|
+
raise CodecError(
|
757
|
+
f"Failed to encode frame using {self.CODEC_NAME} codec: {str(e)}",
|
758
|
+
context="frame encoding operation",
|
759
|
+
details={"codec_name": self.CODEC_NAME, "frame_shape": frame_data.shape if hasattr(frame_data, 'shape') else None}
|
760
|
+
) from e
|
761
|
+
|
762
|
+
def _decode_one_frame(self, bytes) -> np.ndarray:
|
763
|
+
"""Decode a single frame using the HTJ2K codec.
|
764
|
+
|
765
|
+
Args:
|
766
|
+
bytes (bytes): The encoded frame data.
|
767
|
+
|
768
|
+
Returns:
|
769
|
+
np.ndarray: The decoded frame data.
|
770
|
+
|
771
|
+
Raises:
|
772
|
+
CodecError: If decoding fails.
|
773
|
+
"""
|
774
|
+
try:
|
775
|
+
codec = get_codec(self.CODEC_NAME)
|
776
|
+
return codec.decode(bytes)
|
777
|
+
except Exception as e:
|
778
|
+
raise CodecError(
|
779
|
+
f"Failed to decode frame using {self.CODEC_NAME} codec: {str(e)}",
|
780
|
+
context="frame decoding operation",
|
781
|
+
details={"codec_name": self.CODEC_NAME, "data_size": len(bytes) if bytes else 0}
|
782
|
+
) from e
|
783
|
+
|
784
|
+
|
785
|
+
class DcbAFile(DcbFile):
|
786
|
+
"""DICOM cube file implementation optimized for archiving.
|
787
|
+
|
788
|
+
This format prioritizes high compression ratio for long-term storage
|
789
|
+
while maintaining lossless compression, at the expense of speed.
|
790
|
+
|
791
|
+
Attributes:
|
792
|
+
MAGIC (bytes): Magic bytes for file identification ("DCMCUBEA").
|
793
|
+
VERSION (int): File format version.
|
794
|
+
TRANSFER_SYNTAX_UID (str, optional): DICOM transfer syntax UID, set when codec is selected.
|
795
|
+
CODEC_NAME (str, optional): Codec name, set when codec is selected.
|
796
|
+
FILE_EXTENSION (str): File extension for archive-optimized format.
|
797
|
+
"""
|
798
|
+
|
799
|
+
MAGIC = b"DCMCUBEA"
|
800
|
+
VERSION = 1
|
801
|
+
TRANSFER_SYNTAX_UID = None # To be defined when codec is selected
|
802
|
+
CODEC_NAME = None # To be defined when codec is selected
|
803
|
+
FILE_EXTENSION = ".dcba"
|
804
|
+
|
805
|
+
|
806
|
+
class DcbLFile(DcbFile):
|
807
|
+
"""DICOM cube file implementation with lossy compression.
|
808
|
+
|
809
|
+
This format prioritizes very high compression ratio by using lossy compression,
|
810
|
+
sacrificing some image quality and perfect reconstruction.
|
811
|
+
|
812
|
+
Attributes:
|
813
|
+
MAGIC (bytes): Magic bytes for file identification ("DCMCUBEL").
|
814
|
+
VERSION (int): File format version.
|
815
|
+
TRANSFER_SYNTAX_UID (str, optional): DICOM transfer syntax UID, set when codec is selected.
|
816
|
+
CODEC_NAME (str, optional): Codec name, set when codec is selected.
|
817
|
+
FILE_EXTENSION (str): File extension for lossy compression format.
|
818
|
+
"""
|
819
|
+
|
820
|
+
MAGIC = b"DCMCUBEL"
|
821
|
+
VERSION = 1
|
822
|
+
TRANSFER_SYNTAX_UID = None # To be defined when codec is selected
|
823
|
+
CODEC_NAME = None # To be defined when codec is selected
|
824
|
+
FILE_EXTENSION = ".dcbl"
|