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,248 @@
1
+ """
2
+ DCB Streaming Reader for PACS Viewer
3
+
4
+ Provides efficient streaming access to DCB files for on-demand DICOM frame delivery.
5
+ Keeps files open and metadata cached for low-latency responses.
6
+ """
7
+
8
+ import io
9
+ import struct
10
+ import warnings
11
+ from typing import Dict, Any
12
+
13
+ from pydicom import Dataset
14
+ from pydicom.dataset import FileMetaDataset
15
+ from pydicom.encaps import encapsulate
16
+ from pydicom.uid import generate_uid
17
+ import pydicom
18
+
19
+ from ..storage.dcb_file import DcbFile
20
+ from .dicom_io import save_dicom
21
+
22
+ # Required minimum PyDicom version
23
+ REQUIRED_PYDICOM_VERSION = "3.0.0"
24
+
25
+ class DcbStreamingReader:
26
+ """DCB file streaming reader for PACS Viewer.
27
+
28
+ Keeps files open and supports fast random access to frame data.
29
+
30
+ Example:
31
+ reader = DcbStreamingReader('study.dcbs')
32
+ dicom_bytes = reader.get_dicom_for_frame(50)
33
+ reader.close()
34
+ """
35
+
36
+ def __init__(self, dcb_file_path: str):
37
+ """Initialize and preparse all metadata.
38
+
39
+ Args:
40
+ dcb_file_path: Path to DCB file
41
+
42
+ Warnings:
43
+ UserWarning: If PyDicom version is below 3.0.0, HTJ2K decoding may not work properly
44
+ """
45
+ # Check PyDicom version
46
+ self._check_pydicom_version()
47
+
48
+ self.file_path = dcb_file_path
49
+ self.file_handle = None
50
+ self.transfer_syntax_uid = None
51
+
52
+ # Pre-parsed data
53
+ self.header = None
54
+ self.dicom_meta = None
55
+ self.pixel_header = None
56
+ self.space = None
57
+
58
+ # Frame index information
59
+ self.frame_offsets = []
60
+ self.frame_lengths = []
61
+ self.frame_count = 0
62
+
63
+ # DcbFile instance (for reading metadata)
64
+ self.dcb_file = None
65
+
66
+ # Initialize
67
+ self._open_and_parse()
68
+
69
+ def _check_pydicom_version(self):
70
+ """Check PyDicom version and warn if requirements not met.
71
+
72
+ Warnings:
73
+ UserWarning: If PyDicom version is below 3.0.0
74
+ """
75
+ current_version = pydicom.__version__
76
+ if current_version < REQUIRED_PYDICOM_VERSION:
77
+ warnings.warn(
78
+ f"DcbStreamingReader requires PyDicom >= {REQUIRED_PYDICOM_VERSION} for full HTJ2K transfer syntax support. "
79
+ f"Current PyDicom version is {current_version}, which may not be able to read pixel data. "
80
+ f"Write functionality is not affected, but other applications may have issues reading. "
81
+ f"Recommended upgrade: pip install pydicom>={REQUIRED_PYDICOM_VERSION}, requires python 3.10 or higher",
82
+ UserWarning
83
+ )
84
+ self._has_pydicom_htj2k_support = False
85
+ else:
86
+ self._has_pydicom_htj2k_support = True
87
+
88
+ def _open_and_parse(self):
89
+ """Open file and parse all metadata."""
90
+ try:
91
+ # 1. Create DcbFile instance (will auto-detect file type)
92
+ self.dcb_file = DcbFile(self.file_path, mode='r')
93
+
94
+ # 2. Read and cache header information
95
+ self.header = self.dcb_file.header
96
+ self.frame_count = self.header['frame_count']
97
+
98
+ # 3. Read and cache metadata
99
+ self.dicom_meta = self.dcb_file.read_meta()
100
+ self.pixel_header = self.dcb_file.read_pixel_header()
101
+ self.space = self.dcb_file.read_space()
102
+
103
+ # 4. Get transfer syntax UID (directly from file type)
104
+ self.transfer_syntax_uid = self.dcb_file.get_transfer_syntax_uid()
105
+ if not self.transfer_syntax_uid:
106
+ # If file type doesn't define transfer syntax, use default uncompressed format
107
+ self.transfer_syntax_uid = '1.2.840.10008.1.2.1' # Explicit VR Little Endian
108
+
109
+ # 5. Open file handle for reading frame data
110
+ self.file_handle = open(self.file_path, 'rb')
111
+
112
+ # 6. Read all frame offsets and lengths
113
+ self._read_frame_indices()
114
+
115
+ except Exception as e:
116
+ self.close()
117
+ raise RuntimeError(f"Failed to open and parse DCB file: {e}")
118
+
119
+ def _read_frame_indices(self):
120
+ """Read all frame offset and length information."""
121
+ self.file_handle.seek(self.header['frame_offsets_offset'])
122
+
123
+ # Read offsets
124
+ for _ in range(self.frame_count):
125
+ offset, = struct.unpack('<Q', self.file_handle.read(8))
126
+ self.frame_offsets.append(offset)
127
+
128
+ # Read lengths
129
+ self.file_handle.seek(self.header['frame_lengths_offset'])
130
+ for _ in range(self.frame_count):
131
+ length, = struct.unpack('<Q', self.file_handle.read(8))
132
+ self.frame_lengths.append(length)
133
+
134
+ def get_dicom_for_frame(self, frame_index: int) -> bytes:
135
+ """
136
+ Get DICOM data for the specified frame.
137
+
138
+ Args:
139
+ frame_index: Frame index (0-based)
140
+
141
+ Returns:
142
+ bytes: Complete DICOM file data
143
+
144
+ Raises:
145
+ IndexError: If frame_index is out of range
146
+ RuntimeError: If reading fails
147
+ """
148
+ # Validate index
149
+ if not 0 <= frame_index < self.frame_count:
150
+ raise IndexError(f"Frame index {frame_index} out of range [0, {self.frame_count})")
151
+
152
+ try:
153
+ # 1. Read encoded data for the frame
154
+ encoded_pixel_data = self._read_encoded_frame(frame_index)
155
+
156
+ # 2. Generate DICOM Dataset for the frame
157
+ ds = self._create_dicom_dataset(frame_index, encoded_pixel_data)
158
+
159
+ # 3. Serialize to DICOM file format
160
+ return self._serialize_to_dicom_bytes(ds)
161
+
162
+ except Exception as e:
163
+ raise RuntimeError(f"Failed to create DICOM for frame {frame_index}: {e}")
164
+
165
+ def _read_encoded_frame(self, frame_index: int) -> bytes:
166
+ """Read encoded data for the specified frame directly."""
167
+ offset = self.frame_offsets[frame_index]
168
+ length = self.frame_lengths[frame_index]
169
+
170
+ self.file_handle.seek(offset)
171
+ return self.file_handle.read(length)
172
+
173
+ def _create_dicom_dataset(self, frame_index: int, encoded_data: bytes) -> Dataset:
174
+ """Quickly create DICOM Dataset."""
175
+ # 1. Get metadata for the frame from cached DicomMeta
176
+ if self.dicom_meta:
177
+ frame_meta_dict = self.dicom_meta.index(frame_index)
178
+ else:
179
+ frame_meta_dict = {}
180
+
181
+ # 2. Create Dataset
182
+ ds = Dataset.from_json(frame_meta_dict)
183
+
184
+ # 3. Create and set file metadata
185
+ file_meta = FileMetaDataset()
186
+ file_meta.MediaStorageSOPClassUID = ds.get('SOPClassUID', '1.2.840.10008.5.1.4.1.1.2')
187
+ file_meta.MediaStorageSOPInstanceUID = ds.get('SOPInstanceUID', generate_uid())
188
+ file_meta.TransferSyntaxUID = self.transfer_syntax_uid
189
+ file_meta.ImplementationClassUID = generate_uid()
190
+
191
+ ds.file_meta = file_meta
192
+
193
+ # 4. Ensure necessary SOP information
194
+ if not hasattr(ds, 'SOPClassUID'):
195
+ ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
196
+ if not hasattr(ds, 'SOPInstanceUID'):
197
+ ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
198
+
199
+ # 5. Set pixel-related attributes
200
+ if self.pixel_header:
201
+ ds.RescaleSlope = self.pixel_header.RescaleSlope
202
+ ds.RescaleIntercept = self.pixel_header.RescaleIntercept
203
+
204
+ # 6. Set pixel data (using encapsulated format for compressed data)
205
+ ds.PixelData = encapsulate([encoded_data])
206
+
207
+ return ds
208
+
209
+ def _serialize_to_dicom_bytes(self, ds: Dataset) -> bytes:
210
+ """Serialize Dataset to DICOM file byte stream."""
211
+ # Use BytesIO to create DICOM file in memory
212
+ buffer = io.BytesIO()
213
+ save_dicom(ds, buffer)
214
+ buffer.seek(0)
215
+ return buffer.read()
216
+
217
+ def get_frame_count(self) -> int:
218
+ """Get total number of frames."""
219
+ return self.frame_count
220
+
221
+ def get_metadata(self) -> Dict[str, Any]:
222
+ """Get cached metadata information."""
223
+ return {
224
+ 'frame_count': self.frame_count,
225
+ 'pixel_header': self.pixel_header.to_dict() if self.pixel_header else {},
226
+ 'has_dicom_meta': self.dicom_meta is not None,
227
+ 'has_space': self.space is not None,
228
+ 'transfer_syntax': self.transfer_syntax_uid,
229
+ 'file_type': self.dcb_file.__class__.__name__,
230
+ }
231
+
232
+ def close(self):
233
+ """Close file handle."""
234
+ if self.file_handle:
235
+ self.file_handle.close()
236
+ self.file_handle = None
237
+
238
+ def __enter__(self):
239
+ """Support with statement."""
240
+ return self
241
+
242
+ def __exit__(self, exc_type, exc_val, exc_tb):
243
+ """Automatically close when exiting with statement."""
244
+ self.close()
245
+
246
+ def __del__(self):
247
+ """Ensure file is closed on destruction."""
248
+ self.close()
@@ -0,0 +1,153 @@
1
+ import os
2
+ import warnings
3
+ import inspect
4
+ from typing import List, Optional
5
+
6
+ import numpy as np
7
+ from pydicom import Dataset
8
+ from pydicom.dataset import FileMetaDataset
9
+ from pydicom.uid import JPEG2000, ExplicitVRLittleEndian, JPEG2000Lossless, generate_uid
10
+
11
+ from ..dicom.dicom_meta import DicomMeta
12
+
13
+
14
+ def prepare_output_dir(output_dir: str):
15
+ """Prepare output directory"""
16
+ if os.path.exists(output_dir):
17
+ import shutil
18
+
19
+ shutil.rmtree(output_dir)
20
+ os.makedirs(output_dir, exist_ok=True)
21
+
22
+
23
+ def create_file_meta(ds):
24
+ """Create file meta information"""
25
+ file_meta = FileMetaDataset()
26
+
27
+ MODALITY_SOP_CLASS_MAP = {
28
+ "CT": "1.2.840.10008.5.1.4.1.1.2",
29
+ "MR": "1.2.840.10008.5.1.4.1.1.4",
30
+ "US": "1.2.840.10008.5.1.4.1.1.6.1",
31
+ "PT": "1.2.840.10008.5.1.4.1.1.128",
32
+ "CR": "1.2.840.10008.5.1.4.1.1.1",
33
+ "DX": "1.2.840.10008.5.1.4.1.1.1.1",
34
+ "NM": "1.2.840.10008.5.1.4.1.1.20",
35
+ }
36
+
37
+ modality = ds.Modality if hasattr(ds, "Modality") else "CT"
38
+ default_sop_uid = MODALITY_SOP_CLASS_MAP.get(modality, MODALITY_SOP_CLASS_MAP["CT"])
39
+
40
+ file_meta.MediaStorageSOPClassUID = (
41
+ ds.SOPClassUID if hasattr(ds, "SOPClassUID") else default_sop_uid
42
+ )
43
+ file_meta.MediaStorageSOPInstanceUID = (
44
+ ds.SOPInstanceUID if hasattr(ds, "SOPInstanceUID") else generate_uid()
45
+ )
46
+ file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
47
+ file_meta.ImplementationClassUID = generate_uid()
48
+
49
+ return file_meta
50
+
51
+
52
+ def ensure_required_tags(ds):
53
+ """Ensure required DICOM tags exist"""
54
+ if not hasattr(ds, "SOPClassUID"):
55
+ ds.SOPClassUID = ds.file_meta.MediaStorageSOPClassUID
56
+ if not hasattr(ds, "SOPInstanceUID"):
57
+ ds.SOPInstanceUID = ds.file_meta.MediaStorageSOPInstanceUID
58
+
59
+
60
+ def set_dicom_pixel_attributes(img, ds):
61
+ """Set DICOM pixel attributes"""
62
+ if np.issubdtype(img.dtype, np.integer):
63
+ bits = img.dtype.itemsize * 8
64
+ ds.BitsAllocated = bits
65
+ ds.BitsStored = bits
66
+ ds.HighBit = bits - 1
67
+ ds.PixelRepresentation = 1 if np.issubdtype(img.dtype, np.signedinteger) else 0
68
+ else:
69
+ warnings.warn(f"Converting float dtype {img.dtype} to uint16")
70
+ img = img.astype(np.uint16)
71
+ ds.BitsAllocated = 16
72
+ ds.BitsStored = 16
73
+ ds.HighBit = 15
74
+ ds.PixelRepresentation = 0
75
+
76
+ ds.SamplesPerPixel = 1
77
+ return img
78
+
79
+
80
+ def create_dicom_dataset(meta_dict: dict, pixel_header):
81
+ """Create DICOM dataset"""
82
+ ds = Dataset.from_json(meta_dict)
83
+
84
+ if hasattr(ds, "file_meta"):
85
+ warnings.warn("Found original file metadata, will be overridden")
86
+
87
+ ds.file_meta = create_file_meta(ds)
88
+ ensure_required_tags(ds)
89
+ ds.RescaleSlope = pixel_header.RescaleSlope
90
+ ds.RescaleIntercept = pixel_header.RescaleIntercept
91
+
92
+ return ds
93
+
94
+
95
+ def save_dicom(ds: Dataset, output_path: str):
96
+ """Save DICOM file.
97
+
98
+ Args:
99
+ ds: DICOM dataset
100
+ output_path: Output file path
101
+ """
102
+
103
+ sig = inspect.signature(Dataset.save_as)
104
+ if "enforce_file_format" in sig.parameters: # pydicom >= 3.0
105
+ ds.save_as(output_path, enforce_file_format=True)
106
+ else:
107
+ # Ensure valid transfer syntax UID
108
+ if hasattr(ds, 'file_meta') and hasattr(ds.file_meta, 'TransferSyntaxUID'):
109
+ # Check if valid, replace with standard ExplicitVRLittleEndian if invalid
110
+ try:
111
+ from pydicom.uid import UID
112
+ uid = UID(ds.file_meta.TransferSyntaxUID)
113
+ if not hasattr(uid, 'is_little_endian'):
114
+ ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
115
+ except (ValueError, AttributeError):
116
+ # If UID is invalid, use standard ExplicitVRLittleEndian
117
+ ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian
118
+ ds.save_as(output_path, write_like_original=False)
119
+
120
+ def save_to_dicom_folder(
121
+ raw_images: np.ndarray,
122
+ dicom_meta: DicomMeta,
123
+ pixel_header,
124
+ output_dir: str,
125
+ filenames: Optional[List[str]] = None,
126
+ ):
127
+ """Save image data as DICOM folder.
128
+
129
+ Args:
130
+ raw_images: Raw image data, can be 2D or 3D array
131
+ dicom_meta: DICOM metadata
132
+ pixel_header: Pixel header information
133
+ output_dir: Output directory
134
+ filenames: Custom filename list, if None default names will be used
135
+ """
136
+ prepare_output_dir(output_dir)
137
+
138
+ if raw_images.ndim == 2:
139
+ raw_images = raw_images[np.newaxis, ...]
140
+
141
+ for idx in range(len(raw_images)):
142
+ ds = create_dicom_dataset(dicom_meta.index(idx), pixel_header)
143
+ img = raw_images[idx]
144
+
145
+ if img.dtype != np.uint16:
146
+ img = set_dicom_pixel_attributes(img, ds)
147
+
148
+ ds.PixelData = img.tobytes()
149
+
150
+ output_path = os.path.join(
151
+ output_dir, filenames[idx] if filenames else f"slice_{idx:04d}.dcm"
152
+ )
153
+ save_dicom(ds, output_path)