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 | 生成新对象 UID | SR/GSPS/Secondary Capture 导出会用到 |
自绘图表
<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)" />
小结
pydicom 的价值不只是“读 DICOM”,而是让工程可以把读取拆成层次:扫描时只需要元数据,渲染时才需要像素,导出时才需要写回 Dataset。DicomVision 的加载链路围绕这个层次展开,因此后续的 NumPy、MPR、VTK 都能建立在一个相对稳定的 Series/Instance 模型上。