返回系列

DICOM 加载:pydicom 如何把文件变成可用序列

复盘 DicomVision 中从目录扫描、header-only 读取、序列分组到像素延迟解码的后端加载链路。

问题引入

加载 DICOM 文件最容易写错的地方,是把“识别序列”和“解码像素”混在一起。一个目录里可能有几百个 CT instance,也可能混着 SR、GSPS、缩略图、压缩传输语法、不同 phase 或不同子目录。用户点击加载时,真正需要先完成的是建立目录里有什么,而不是立刻把所有 PixelData 解码到内存。

DicomVision 的加载策略是两阶段:扫描阶段只读 header,建立 SeriesRecord 和 InstanceRecord;渲染阶段才按当前视口需要解码具体 instance 的像素。这个设计让打开目录更快,也给缓存和错误处理留下空间。

背景概念

pydicom 的 dcmread 可以读取完整 Dataset,也可以只读 header。stop_before_pixels=True 会在 PixelData 之前停止,适合扫描目录。force=True 可以容忍一些没有标准 DICOM preamble 或格式不完整的文件,实用价值很高,但也意味着代码必须自己判断这个 Dataset 是否真的可显示。defer_size 则可以延迟读取较大的元素,适合 Tag 查看场景。

真正解码像素发生在访问 dataset.pixel_array 时。它会根据 Transfer Syntax、BitsAllocated、PhotometricInterpretation 等信息返回 NumPy 数组。此时才会触发压缩图像解码和较大的内存分配。因此,pixel_array 不应该出现在目录扫描的热路径里。

项目实现

项目的扫描入口会把输入路径规范化为“扫描根目录 + 文件列表”。如果用户传入的是单个文件,就把它的父目录作为根目录,只扫描这个文件;如果传入目录,就递归枚举目录下文件。每个候选文件通过 pydicom.dcmread(str(path), stop_before_pixels=True, force=True) 读取 header。如果失败,就跳过。

接着项目判断这个 Dataset 是否是普通可显示影像。SR 和 GSPS 是标准对象,但它们不是普通像素序列,所以不会进入常规 Series 列表。对于普通影像,项目根据 SeriesInstanceUID、相对目录和 4D phase 文本构造 series key。这一点很重要:现实数据里不同 phase 可能复用同一个 SeriesInstanceUID,如果只按 UID 分组,就会把不同时间相位错误合并。

创建 InstanceRecord 时,项目保留 instance path、SOPInstanceUID、InstanceNumber、Rows、Columns、spacing、position 等摘要信息。真正渲染时,dicom_cache.get(instance_uid, path) 才读取完整 Dataset,访问 dataset.pixel_array,转 float32,应用 RescaleSlope 和 RescaleIntercept。如果是 MONOCHROME1,再做反相。

关键函数表

函数或字段用法注意点
pydicom.dcmread(path, stop_before_pixels=True, force=True)目录扫描时只读 header快,但要自己判断是否可显示
pydicom.dcmread(path, force=True)渲染时读完整 Dataset可能触发大文件读取
Dataset.pixel_array解码 PixelData 为 NumPy 数组可能需要额外 pixel data handler
Dataset.get / getattr(dataset, "...")安全读取 Tag现实 DICOM 可能缺字段
Dataset.save_as(..., write_like_original=False)写出标准对象或修改后的 DICOM导出时要补充 file meta
pydicom.uid.generate_uid生成新对象 UIDSR/GSPS/Secondary Capture 导出会用到

自绘图表

输入路径 规范化 文件或目录 只读 header stop_before_pixels 可显示判断 过滤 SR / GSPS
<rect x="260" y="245" width="180" height="78" rx="14" fill="var(--surface)" stroke="var(--line)" />
<text x="350" y="278" text-anchor="middle" fill="currentColor" font-weight="800">构造 series key</text>
<text x="350" y="304" text-anchor="middle" fill="var(--muted)" font-size="13">UID + 目录 + phase</text>
<rect x="510" y="245" width="180" height="78" rx="14" fill="var(--surface)" stroke="var(--line)" />
<text x="600" y="278" text-anchor="middle" fill="currentColor" font-weight="800">注册记录</text>
<text x="600" y="304" text-anchor="middle" fill="var(--muted)" font-size="13">Series / Instance</text>
<rect x="760" y="245" width="155" height="78" rx="14" fill="var(--surface)" stroke="var(--line)" />
<text x="837" y="278" text-anchor="middle" fill="currentColor" font-weight="800">返回列表</text>
<text x="837" y="304" text-anchor="middle" fill="var(--muted)" font-size="13">不含 PixelData</text>

<line x1="205" y1="105" x2="260" y2="105" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
<line x1="430" y1="105" x2="490" y2="105" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
<line x1="670" y1="105" x2="735" y2="105" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
<path d="M825 140 C825 198 350 188 350 245" fill="none" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
<line x1="440" y1="284" x2="510" y2="284" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
<line x1="690" y1="284" x2="760" y2="284" stroke="var(--accent)" stroke-width="3" marker-end="url(#arrow-load)" />
加载目录时只建立索引,不在扫描阶段解码所有像素。
Dataset 完整读取 pixel_array 解码 PixelData float32 统一计算类型 HU 转换 slope / intercept 缓存 LRU 渲染时才进入像素路径 扫描阶段保存路径和摘要,避免一次性把整套检查解码到内存。
DicomCache 把完整像素读取延迟到视口真正需要渲染某张图像时。

小结

pydicom 的价值不只是“读 DICOM”,而是让工程可以把读取拆成层次:扫描时只需要元数据,渲染时才需要像素,导出时才需要写回 Dataset。DicomVision 的加载链路围绕这个层次展开,因此后续的 NumPy、MPR、VTK 都能建立在一个相对稳定的 Series/Instance 模型上。