元数据存储机制

1. 传统 DICOM 的“历史包袱”:元数据冗余

DICOM 作为医学影像的基石,其设计源于上世纪末,采用了“一文件一图像(切片)”的存储模式。这种模式在当时是合理的,但随着影像设备采集的图像层数急剧增加(从几十层到数千层),其固有的元数据冗余问题成为了一个巨大的性能瓶颈和存储负担。

在一个包含数百个 .dcm 文件的 CT 序列中,绝大部分描述患者身份、检查信息、设备参数的元数据在每个文件中都被完整地复制了一遍。只有像切片位置、实例编号这类与特定切片相关的少数信息是变化的。

import pydicom
import os
from pathlib import Path

# 探查DICOM序列中的元数据冗余
dicom_dir = "dicube-testdata/dicom/sample_200"
# 我们仅选取前两个文件进行对比
dicom_files = list(Path(dicom_dir).glob("*"))[:2]

# 读取元数据(stop_before_pixels=True 避免加载像素数据,加快速度)
ds1 = pydicom.dcmread(dicom_files[0], stop_before_pixels=True)
ds2 = pydicom.dcmread(dicom_files[1], stop_before_pixels=True)

# 校验序列级元数据和实例级元数据的异同
patient_same = ds1.PatientName == ds2.PatientName
series_same = ds1.SeriesInstanceUID == ds2.SeriesInstanceUID
instance_same = ds1.InstanceNumber == ds2.InstanceNumber

print(f"两个切片的 PatientName 相同: {patient_same}")
print(f"两个切片的 SeriesInstanceUID 相同: {series_same}")
print(f"两个切片的 InstanceNumber 相同: {instance_same}")

正如代码所示,绝大多数元数据是序列共享的,而 InstanceNumber 等则是切片独有的。这种设计不仅浪费了大量存储空间,更严重的是,当需要获取整个序列的某个信息时(例如窗宽窗位),程序必须遍历并解析数百个文件,导致了极低的 I/O 效率。

2. 拥抱标准:基于 DICOM JSON 构建

为了让 DICOM 更好地融入现代 Web 和数据科学生态,DICOM 标准委员会在 PS3.18 部分引入了 DICOM JSON 模型。它为 DICOM 元数据提供了标准化、人类可读的 JSON 表示,简化了非医疗软件对 DICOM 信息的解析与利用。

DiCube 没有重新发明轮子,而是完全遵从这一现代化标准来组织其内部的元数据。

import json

# PyDICOM原生支持将元数据导出为DICOM JSON
ds = pydicom.dcmread(dicom_files[0], stop_before_pixels=True)
dicom_json_str = ds.to_json()

# 解析并展示部分关键字段
json_data = json.loads(dicom_json_str)
# 字段的键是其十六进制的Tag编码
key_tags = ["00100010", "00080021", "00200013"]  # PatientName, SeriesDate, InstanceNumber

for tag in key_tags:
    if tag in json_data:
        # VR (Value Representation) 字段描述了值的类型
        vr = json_data[tag]["vr"]
        value = json_data[tag].get("Value", ["N/A"])[0]
        print(f"Tag {tag} (VR: {vr}): {value}")

通过采用DICOM JSON标准,DiCube确保了其元数据的互操作性未来兼容性

3. DicomMeta的核心设计:共享与非共享元数据的分离

DicomMeta 是 DiCube 中负责高效管理元数据的核心类。它通过共享/非共享元数据分离机制直击传统 DICOM 的冗余痛点。

  • 共享 (Shared) 元数据: 在整个图像序列中保持不变的信息,如 PatientIDStudyInstanceUIDModality 等。这部分数据只存储一次。
  • 非共享 (Per-Slice) 元数据: 每个切片独有的信息,如 InstanceNumberImagePositionPatientSliceLocation 等。这部分数据会为每个切片单独存储。

当从DICOM文件夹加载数据时,DiCube会自动分析并分离这两类元数据。

import dicube

# 从DICOM文件夹加载数据,DiCube在内部自动完成元数据分离
dcb_image = dicube.load_from_dicom_folder(dicom_dir)
dicube.save(dcb_image, "temp_demo.dcbs")

# 获取DicomMeta对象有两种方式:
# 方式1:直接从DiCube文件加载元数据
meta = dicube.load_meta("temp_demo.dcbs")
# 方式2:从已加载的DiCubeImage对象中获取
meta = dcb_image.dicom_meta

# display() 方法可以清晰地展示元数据的分层结构
meta.display()

这种智能分离机制带来了立竿见影的好处:元数据存储占用急剧减少,并且访问序列级信息(如患者姓名)时无需遍历,实现了O(1)时间复杂度的查找。

from dicube.dicom import CommonTags

# 检查共享元数据
patient_name = meta.get_shared_value(CommonTags.PatientName)
is_shared = meta.is_shared(CommonTags.PatientName)
print(f"PatientName: '{patient_name}' (是否为共享数据: {is_shared})")

# 检查非共享元数据
# get_values 会返回一个包含所有切片该字段值的列表
instance_numbers = meta.get_values(CommonTags.InstanceNumber)
is_shared_instance = meta.is_shared(CommonTags.InstanceNumber)
print(f"InstanceNumber (是否为共享数据: {is_shared_instance})")
print(f"共找到 {len(instance_numbers)} 个InstanceNumber")
print(f"范围从: {min(instance_numbers)}{max(instance_numbers)}")

4. 极致压缩:JSON + Zstandard

分离元数据解决了冗余,但如何高效地存储这些结构化的文本信息呢?DiCube 选择了由Facebook开发的现代化压缩算法 Zstandard (zstd)

Zstd 擅长压缩具有重复模式的结构化文本数据(如 JSON),在压缩比与解压速度上均显著优于传统 gzip。

# 估算原始DICOM头文件的总大小
dicom_header_total_size = 0
all_files = list(Path(dicom_dir).glob("*"))

for dcm_file in all_files:
    # 获取文件总大小
    total_size = os.path.getsize(dcm_file)
    
    # 读取DICOM文件获取pixel_array大小
    ds = pydicom.dcmread(dcm_file)
    if hasattr(ds, 'pixel_array'):
        pixel_size = ds.pixel_array.nbytes
    else:
        pixel_size = 0
    
    # 头文件大小 = 总大小 - pixel_array大小
    header_size = total_size - pixel_size
    dicom_header_total_size += header_size



# 获取DiCube压缩后的元数据大小
meta_json_str = meta.to_json()
import zstandard as zstd
compressor = zstd.ZstdCompressor(level=9) # 使用较高的压缩级别
compressed_meta = compressor.compress(meta_json_str.encode('utf-8'))
dicube_meta_size = len(compressed_meta)

print(f"原始DICOM头文件总大小 (估算): {dicom_header_total_size / 1024:.2f} KB")
print(f"DiCube Zstd压缩后元数据大小: {dicube_meta_size / 1024:.2f} KB")
print(f"元数据压缩比高达: {dicom_header_total_size / dicube_meta_size:.1f}x")

超过 80 倍的压缩比!这表明 DicomMeta 的共享机制与 zstd 压缩形成了完美的组合拳,将元数据存储开销降至最低。

5. 提升开发体验:CommonTags 枚举

直接操作 (0010,0010) 这样的十六进制标签不仅难以记忆,而且容易出错。为了提升代码的可读性和健壮性,DiCube 提供了一个便捷的 CommonTags 枚举类,收录了绝大部分常用的DICOM标签。

开发者可以通过语义化的名称来访问元数据,使代码更加清晰、易于维护。

# 使用CommonTags枚举类,代码更具可读性
instance_numbers = meta.get_values(CommonTags.InstanceNumber)
positions = meta.get_values(CommonTags.ImagePositionPatient)

# positions 是一个包含每个切片 [X, Y, Z] 坐标的列表
print(f"实例编号范围: {min(instance_numbers)} - {max(instance_numbers)}")
print(f"切片Z轴位置范围: 从 {positions[0][2]:.2f}{positions[-1][2]:.2f}")

6. 性能的飞跃:秒级与毫秒级的较量

DicomMeta 的设计优势最终体现在了性能上。让我们来实际对比一下,从一个包含200个切片的序列中读取所有 InstanceNumber 所需的时间。

  • 传统方式:需要循环打开、解析200个独立的文件。
  • DiCube方式:只需打开1个文件,解压一小块元数据,并直接访问解析好的列表。
import time

# 传统DICOM方式:遍历所有文件
start_time = time.time()
dicom_instance_numbers = []
for dcm_file in all_files:
    ds = pydicom.dcmread(dcm_file, stop_before_pixels=True)
    dicom_instance_numbers.append(int(ds.InstanceNumber))
dicom_time = time.time() - start_time

# DiCube方式:一次性加载
start_time = time.time()
dicube_meta = dicube.load_meta("temp_demo.dcbs")
dicube_instance_numbers = dicube_meta.get_values(CommonTags.InstanceNumber)
dicube_time = time.time() - start_time

print(f"传统DICOM I/O耗时: {dicom_time * 1000:.2f} 毫秒")
print(f"DiCube DicomMeta 耗时: {dicube_time * 1000:.2f} 毫秒")
print(f"\n性能提升: {dicom_time / dicube_time:.1f} 倍")

# 清理临时文件
os.remove("temp_demo.dcbs")

几十倍的性能提升是显而易见的。这背后是 I/O 模式的根本性变革:从多次、琐碎的文件操作,转变为一次性、集中的数据块读取。

7. 总结

DiCube 的 DicomMeta 机制并非简单的元数据存储,而是一套针对现代医学影像数据处理特点精心设计的解决方案。其核心优势在于:

  1. 智能去冗余:通过分离共享与非共享元数据,从根源上解决了DICOM的冗余问题,大幅减少存储占用。
  2. 拥抱现代化标准:基于DICOM JSON标准,并采用高效的Zstandard压缩,确保了兼容性与极致性能。
  3. 数量级的性能提升:将元数据读取从“遍历文件系统”的慢速操作,转变为“内存访问”级别的高速操作,为大规模数据分析和AI训练扫清了障碍。

这些设计共同构成了 DiCube 处理医学影像数据的坚实基础,使其在效率和易用性上远超传统的工作流。