dicube 0.2.2__cp39-cp39-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_complete.cpython-39-aarch64-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-38-aarch64-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-39-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 +29 -0
- dicube-0.2.2.dist-info/WHEEL +6 -0
@@ -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()
|
dicube/dicom/dicom_io.py
ADDED
@@ -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)
|