dicube 0.1.4__cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.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 +140 -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-310-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_complete.cpython-311-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_complete.cpython-38-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_complete.cpython-39-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-310-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-311-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-38-i386-linux-gnu.so +0 -0
- dicube/codecs/jph/ojph_decode_complete.cpython-39-i386-linux-gnu.so +0 -0
- dicube/core/__init__.py +21 -0
- dicube/core/image.py +349 -0
- dicube/core/io.py +354 -0
- dicube/core/pixel_header.py +117 -0
- dicube/dicom/__init__.py +13 -0
- dicube/dicom/dcb_streaming.py +250 -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 +805 -0
- dicube/storage/pixel_utils.py +141 -0
- dicube/utils/__init__.py +6 -0
- dicube/validation.py +380 -0
- dicube-0.1.4.dist-info/METADATA +271 -0
- dicube-0.1.4.dist-info/RECORD +33 -0
- dicube-0.1.4.dist-info/WHEEL +6 -0
@@ -0,0 +1,117 @@
|
|
1
|
+
import json
|
2
|
+
from dataclasses import asdict, dataclass, field
|
3
|
+
from typing import Dict, Optional
|
4
|
+
|
5
|
+
|
6
|
+
@dataclass
|
7
|
+
class PixelDataHeader:
|
8
|
+
"""Header class for storing pixel data information in medical images.
|
9
|
+
|
10
|
+
Stores metadata including:
|
11
|
+
- Rescale factors (slope/intercept)
|
12
|
+
- Original pixel data type
|
13
|
+
- Window settings (center/width)
|
14
|
+
- Value range (min/max)
|
15
|
+
- Additional metadata in EXTRAS
|
16
|
+
|
17
|
+
Attributes:
|
18
|
+
RESCALE_SLOPE (float): Slope for linear transformation.
|
19
|
+
RESCALE_INTERCEPT (float): Intercept for linear transformation.
|
20
|
+
PIXEL_DTYPE (str): Pixel data type string (after convert to dcb file).
|
21
|
+
ORIGINAL_PIXEL_DTYPE (str): Original pixel data type string (before convert to dcb file).
|
22
|
+
WINDOW_CENTER (float, optional): Window center value for display.
|
23
|
+
WINDOW_WIDTH (float, optional): Window width value for display.
|
24
|
+
MAX_VAL (float, optional): Maximum pixel value.
|
25
|
+
MIN_VAL (float, optional): Minimum pixel value.
|
26
|
+
EXTRAS (Dict[str, any]): Dictionary for additional metadata.
|
27
|
+
"""
|
28
|
+
|
29
|
+
RESCALE_SLOPE: float = 1.0
|
30
|
+
RESCALE_INTERCEPT: float = 0.0
|
31
|
+
ORIGINAL_PIXEL_DTYPE: str = "uint16"
|
32
|
+
PIXEL_DTYPE: str = "uint16"
|
33
|
+
WINDOW_CENTER: Optional[float] = None
|
34
|
+
WINDOW_WIDTH: Optional[float] = None
|
35
|
+
MAX_VAL: Optional[float] = None
|
36
|
+
MIN_VAL: Optional[float] = None
|
37
|
+
EXTRAS: Dict[str, any] = field(default_factory=dict)
|
38
|
+
|
39
|
+
def to_dict(self) -> dict:
|
40
|
+
"""Convert the header to a dictionary for serialization.
|
41
|
+
|
42
|
+
Merges EXTRAS field into the main dictionary and removes
|
43
|
+
the redundant EXTRAS key.
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
dict: Dictionary representation of the header.
|
47
|
+
"""
|
48
|
+
data = asdict(self)
|
49
|
+
data.update(self.EXTRAS) # Merge EXTRAS into dictionary
|
50
|
+
data.pop("EXTRAS", None) # Remove redundant EXTRAS field
|
51
|
+
return data
|
52
|
+
|
53
|
+
@classmethod
|
54
|
+
def from_dict(cls, d: dict):
|
55
|
+
"""Create a PixelDataHeader from a dictionary.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
d (dict): Dictionary containing header data.
|
59
|
+
|
60
|
+
Returns:
|
61
|
+
PixelDataHeader: A new instance with values from the dictionary.
|
62
|
+
"""
|
63
|
+
rescale_slope = d.get("RESCALE_SLOPE", 1.0)
|
64
|
+
rescale_intercept = d.get("RESCALE_INTERCEPT", 0.0)
|
65
|
+
original_pixel_dtype = d.get("ORIGINAL_PIXEL_DTYPE", "uint16")
|
66
|
+
window_center = d.get("WINDOW_CENTER") # Defaults to None
|
67
|
+
window_width = d.get("WINDOW_WIDTH") # Defaults to None
|
68
|
+
max_val = d.get("MAX_VAL") # Defaults to None
|
69
|
+
min_val = d.get("MIN_VAL") # Defaults to None
|
70
|
+
|
71
|
+
# All other keys go into EXTRAS
|
72
|
+
extras = {
|
73
|
+
k: v
|
74
|
+
for k, v in d.items()
|
75
|
+
if k
|
76
|
+
not in {
|
77
|
+
"RESCALE_SLOPE",
|
78
|
+
"RESCALE_INTERCEPT",
|
79
|
+
"ORIGINAL_PIXEL_DTYPE",
|
80
|
+
"WINDOW_CENTER",
|
81
|
+
"WINDOW_WIDTH",
|
82
|
+
"MAX_VAL",
|
83
|
+
"MIN_VAL",
|
84
|
+
}
|
85
|
+
}
|
86
|
+
|
87
|
+
return cls(
|
88
|
+
RESCALE_SLOPE=rescale_slope,
|
89
|
+
RESCALE_INTERCEPT=rescale_intercept,
|
90
|
+
ORIGINAL_PIXEL_DTYPE=original_pixel_dtype,
|
91
|
+
WINDOW_CENTER=window_center,
|
92
|
+
WINDOW_WIDTH=window_width,
|
93
|
+
MAX_VAL=max_val,
|
94
|
+
MIN_VAL=min_val,
|
95
|
+
EXTRAS=extras,
|
96
|
+
)
|
97
|
+
|
98
|
+
def to_json(self) -> str:
|
99
|
+
"""Serialize the header to a JSON string.
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
str: JSON string representation of the header.
|
103
|
+
"""
|
104
|
+
return json.dumps(self.to_dict())
|
105
|
+
|
106
|
+
@classmethod
|
107
|
+
def from_json(cls, json_str: str):
|
108
|
+
"""Create a PixelDataHeader from a JSON string.
|
109
|
+
|
110
|
+
Args:
|
111
|
+
json_str (str): JSON string containing header data.
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
PixelDataHeader: A new instance created from the JSON data.
|
115
|
+
"""
|
116
|
+
obj_dict = json.loads(json_str)
|
117
|
+
return cls.from_dict(obj_dict)
|
dicube/dicom/__init__.py
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
from .dicom_meta import DicomMeta, SortMethod, read_dicom_dir
|
2
|
+
from .dicom_status import DicomStatus, get_dicom_status
|
3
|
+
from .dicom_tags import CommonTags
|
4
|
+
from .space_from_meta import get_space_from_DicomMeta
|
5
|
+
__all__ = [
|
6
|
+
"DicomMeta",
|
7
|
+
"read_dicom_dir",
|
8
|
+
"DicomStatus",
|
9
|
+
"get_dicom_status",
|
10
|
+
"CommonTags",
|
11
|
+
"SortMethod",
|
12
|
+
"get_space_from_DicomMeta",
|
13
|
+
]
|
@@ -0,0 +1,250 @@
|
|
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
|
+
# 定义所需的最低PyDicom版本
|
23
|
+
REQUIRED_PYDICOM_VERSION = "3.0.0"
|
24
|
+
|
25
|
+
class DcbStreamingReader:
|
26
|
+
"""
|
27
|
+
PACS Viewer 的 dcb 文件流式读取器
|
28
|
+
保持文件打开状态,支持快速随机访问帧数据
|
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
|
+
"""
|
38
|
+
初始化并预解析所有元数据
|
39
|
+
|
40
|
+
Args:
|
41
|
+
dcb_file_path: dcb 文件路径
|
42
|
+
|
43
|
+
Warnings:
|
44
|
+
UserWarning: 如果 PyDicom 版本低于 3.0.0,HTJ2K 解码可能无法正常工作
|
45
|
+
"""
|
46
|
+
# 检查 PyDicom 版本
|
47
|
+
self._check_pydicom_version()
|
48
|
+
|
49
|
+
self.file_path = dcb_file_path
|
50
|
+
self.file_handle = None
|
51
|
+
self.transfer_syntax_uid = None
|
52
|
+
|
53
|
+
# 预解析的数据
|
54
|
+
self.header = None
|
55
|
+
self.dicom_meta = None
|
56
|
+
self.pixel_header = None
|
57
|
+
self.space = None
|
58
|
+
|
59
|
+
# 帧索引信息
|
60
|
+
self.frame_offsets = []
|
61
|
+
self.frame_lengths = []
|
62
|
+
self.frame_count = 0
|
63
|
+
|
64
|
+
# DcbFile 实例(用于读取元数据)
|
65
|
+
self.dcb_file = None
|
66
|
+
|
67
|
+
# 初始化
|
68
|
+
self._open_and_parse()
|
69
|
+
|
70
|
+
def _check_pydicom_version(self):
|
71
|
+
"""
|
72
|
+
检查 PyDicom 版本,如果不满足要求则发出警告
|
73
|
+
|
74
|
+
Warnings:
|
75
|
+
UserWarning: 如果 PyDicom 版本低于 3.0.0
|
76
|
+
"""
|
77
|
+
current_version = pydicom.__version__
|
78
|
+
if current_version < REQUIRED_PYDICOM_VERSION:
|
79
|
+
warnings.warn(
|
80
|
+
f"DcbStreamingReader 需要 PyDicom >= {REQUIRED_PYDICOM_VERSION} 以完全支持 HTJ2K 传输语法。"
|
81
|
+
f"当前 PyDicom 版本为 {current_version},可能无法读取像素数据。"
|
82
|
+
f"写入功能不受影响,但其他应用读取时可能会出现问题。建议升级: pip install pydicom>={REQUIRED_PYDICOM_VERSION},需要 python 3.10 或更高版本",
|
83
|
+
UserWarning
|
84
|
+
)
|
85
|
+
self._has_pydicom_htj2k_support = False
|
86
|
+
else:
|
87
|
+
self._has_pydicom_htj2k_support = True
|
88
|
+
|
89
|
+
def _open_and_parse(self):
|
90
|
+
"""打开文件并解析所有元数据"""
|
91
|
+
try:
|
92
|
+
# 1. 创建 DcbFile 实例(会自动检测文件类型)
|
93
|
+
self.dcb_file = DcbFile(self.file_path, mode='r')
|
94
|
+
|
95
|
+
# 2. 读取并缓存头部信息
|
96
|
+
self.header = self.dcb_file.header
|
97
|
+
self.frame_count = self.header['frame_count']
|
98
|
+
|
99
|
+
# 3. 读取并缓存元数据
|
100
|
+
self.dicom_meta = self.dcb_file.read_meta()
|
101
|
+
self.pixel_header = self.dcb_file.read_pixel_header()
|
102
|
+
self.space = self.dcb_file.read_space()
|
103
|
+
|
104
|
+
# 4. 获取 transfer syntax UID(从文件类型直接获取)
|
105
|
+
self.transfer_syntax_uid = self.dcb_file.get_transfer_syntax_uid()
|
106
|
+
if not self.transfer_syntax_uid:
|
107
|
+
# 如果文件类型没有定义 transfer syntax,使用默认的未压缩格式
|
108
|
+
self.transfer_syntax_uid = '1.2.840.10008.1.2.1' # Explicit VR Little Endian
|
109
|
+
|
110
|
+
# 5. 打开文件句柄用于读取帧数据
|
111
|
+
self.file_handle = open(self.file_path, 'rb')
|
112
|
+
|
113
|
+
# 6. 读取所有帧的 offset 和 length
|
114
|
+
self._read_frame_indices()
|
115
|
+
|
116
|
+
except Exception as e:
|
117
|
+
self.close()
|
118
|
+
raise RuntimeError(f"Failed to open and parse DCB file: {e}")
|
119
|
+
|
120
|
+
def _read_frame_indices(self):
|
121
|
+
"""读取所有帧的偏移量和长度信息"""
|
122
|
+
self.file_handle.seek(self.header['frame_offsets_offset'])
|
123
|
+
|
124
|
+
# 读取 offsets
|
125
|
+
for _ in range(self.frame_count):
|
126
|
+
offset, = struct.unpack('<Q', self.file_handle.read(8))
|
127
|
+
self.frame_offsets.append(offset)
|
128
|
+
|
129
|
+
# 读取 lengths
|
130
|
+
self.file_handle.seek(self.header['frame_lengths_offset'])
|
131
|
+
for _ in range(self.frame_count):
|
132
|
+
length, = struct.unpack('<Q', self.file_handle.read(8))
|
133
|
+
self.frame_lengths.append(length)
|
134
|
+
|
135
|
+
def get_dicom_for_frame(self, frame_index: int) -> bytes:
|
136
|
+
"""
|
137
|
+
获取指定帧的 DICOM 数据
|
138
|
+
|
139
|
+
Args:
|
140
|
+
frame_index: 帧索引(0-based)
|
141
|
+
|
142
|
+
Returns:
|
143
|
+
bytes: 完整的 DICOM 文件数据
|
144
|
+
|
145
|
+
Raises:
|
146
|
+
IndexError: 如果 frame_index 超出范围
|
147
|
+
RuntimeError: 如果读取失败
|
148
|
+
"""
|
149
|
+
# 验证索引
|
150
|
+
if not 0 <= frame_index < self.frame_count:
|
151
|
+
raise IndexError(f"Frame index {frame_index} out of range [0, {self.frame_count})")
|
152
|
+
|
153
|
+
try:
|
154
|
+
# 1. 读取该帧的编码数据
|
155
|
+
encoded_pixel_data = self._read_encoded_frame(frame_index)
|
156
|
+
|
157
|
+
# 2. 生成该帧的 DICOM Dataset
|
158
|
+
ds = self._create_dicom_dataset(frame_index, encoded_pixel_data)
|
159
|
+
|
160
|
+
# 3. 序列化为 DICOM 文件格式
|
161
|
+
return self._serialize_to_dicom_bytes(ds)
|
162
|
+
|
163
|
+
except Exception as e:
|
164
|
+
raise RuntimeError(f"Failed to create DICOM for frame {frame_index}: {e}")
|
165
|
+
|
166
|
+
def _read_encoded_frame(self, frame_index: int) -> bytes:
|
167
|
+
"""直接读取指定帧的编码数据"""
|
168
|
+
offset = self.frame_offsets[frame_index]
|
169
|
+
length = self.frame_lengths[frame_index]
|
170
|
+
|
171
|
+
self.file_handle.seek(offset)
|
172
|
+
return self.file_handle.read(length)
|
173
|
+
|
174
|
+
def _create_dicom_dataset(self, frame_index: int, encoded_data: bytes) -> Dataset:
|
175
|
+
"""快速创建 DICOM Dataset"""
|
176
|
+
# 1. 从缓存的 DicomMeta 获取该帧的元数据
|
177
|
+
if self.dicom_meta:
|
178
|
+
frame_meta_dict = self.dicom_meta.index(frame_index)
|
179
|
+
else:
|
180
|
+
frame_meta_dict = {}
|
181
|
+
|
182
|
+
# 2. 创建 Dataset
|
183
|
+
ds = Dataset.from_json(frame_meta_dict)
|
184
|
+
|
185
|
+
# 3. 创建并设置文件元信息
|
186
|
+
file_meta = FileMetaDataset()
|
187
|
+
file_meta.MediaStorageSOPClassUID = ds.get('SOPClassUID', '1.2.840.10008.5.1.4.1.1.2')
|
188
|
+
file_meta.MediaStorageSOPInstanceUID = ds.get('SOPInstanceUID', generate_uid())
|
189
|
+
file_meta.TransferSyntaxUID = self.transfer_syntax_uid
|
190
|
+
file_meta.ImplementationClassUID = generate_uid()
|
191
|
+
|
192
|
+
ds.file_meta = file_meta
|
193
|
+
|
194
|
+
# 4. 确保必要的 SOP 信息
|
195
|
+
if not hasattr(ds, 'SOPClassUID'):
|
196
|
+
ds.SOPClassUID = file_meta.MediaStorageSOPClassUID
|
197
|
+
if not hasattr(ds, 'SOPInstanceUID'):
|
198
|
+
ds.SOPInstanceUID = file_meta.MediaStorageSOPInstanceUID
|
199
|
+
|
200
|
+
# 5. 设置像素相关属性
|
201
|
+
if self.pixel_header:
|
202
|
+
ds.RescaleSlope = self.pixel_header.RESCALE_SLOPE
|
203
|
+
ds.RescaleIntercept = self.pixel_header.RESCALE_INTERCEPT
|
204
|
+
|
205
|
+
# 6. 设置像素数据(使用 encapsulated format for compressed data)
|
206
|
+
# 压缩数据需要封装
|
207
|
+
ds.PixelData = encapsulate([encoded_data])
|
208
|
+
|
209
|
+
return ds
|
210
|
+
|
211
|
+
def _serialize_to_dicom_bytes(self, ds: Dataset) -> bytes:
|
212
|
+
"""将 Dataset 序列化为 DICOM 文件字节流"""
|
213
|
+
# 使用 BytesIO 在内存中创建 DICOM 文件
|
214
|
+
buffer = io.BytesIO()
|
215
|
+
save_dicom(ds, buffer)
|
216
|
+
buffer.seek(0)
|
217
|
+
return buffer.read()
|
218
|
+
|
219
|
+
def get_frame_count(self) -> int:
|
220
|
+
"""获取总帧数"""
|
221
|
+
return self.frame_count
|
222
|
+
|
223
|
+
def get_metadata(self) -> Dict[str, Any]:
|
224
|
+
"""获取缓存的元数据信息"""
|
225
|
+
return {
|
226
|
+
'frame_count': self.frame_count,
|
227
|
+
'pixel_header': self.pixel_header.to_dict() if self.pixel_header else {},
|
228
|
+
'has_dicom_meta': self.dicom_meta is not None,
|
229
|
+
'has_space': self.space is not None,
|
230
|
+
'transfer_syntax': self.transfer_syntax_uid,
|
231
|
+
'file_type': self.dcb_file.__class__.__name__,
|
232
|
+
}
|
233
|
+
|
234
|
+
def close(self):
|
235
|
+
"""关闭文件句柄"""
|
236
|
+
if self.file_handle:
|
237
|
+
self.file_handle.close()
|
238
|
+
self.file_handle = None
|
239
|
+
|
240
|
+
def __enter__(self):
|
241
|
+
"""支持 with 语句"""
|
242
|
+
return self
|
243
|
+
|
244
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
245
|
+
"""退出 with 语句时自动关闭"""
|
246
|
+
self.close()
|
247
|
+
|
248
|
+
def __del__(self):
|
249
|
+
"""析构时确保文件关闭"""
|
250
|
+
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.RESCALE_SLOPE
|
90
|
+
ds.RescaleIntercept = pixel_header.RESCALE_INTERCEPT
|
91
|
+
|
92
|
+
return ds
|
93
|
+
|
94
|
+
|
95
|
+
def save_dicom(ds: Dataset, output_path: str):
|
96
|
+
"""保存DICOM文件
|
97
|
+
|
98
|
+
参数:
|
99
|
+
ds: DICOM数据集
|
100
|
+
output_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
|
+
# 确保使用有效的传输语法UID
|
108
|
+
if hasattr(ds, 'file_meta') and hasattr(ds.file_meta, 'TransferSyntaxUID'):
|
109
|
+
# 检查是否有效,如果无效则替换为标准的ExplicitVRLittleEndian
|
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
|
+
# 如果UID无效,使用标准的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
|
+
"""将图像数据保存为DICOM文件夹
|
128
|
+
|
129
|
+
参数:
|
130
|
+
raw_images: 原始图像数据,可以是2D或3D数组
|
131
|
+
dicom_meta: DICOM元数据
|
132
|
+
pixel_header: 像素头信息
|
133
|
+
output_dir: 输出目录
|
134
|
+
filenames: 自定义文件名列表,如果为None则使用默认名称
|
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)
|