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.
@@ -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"