2. DICOM文件结构与数据元素解析

上一节我们建立了对DICOM整体生态的认识,这一节要把镜头拉近到单个文件内部。DICOM文件并不是一个黑盒,它遵循严格的二进制布局:从128字节的前导码开始,经过"DICM"魔数,进入文件元信息组,再进入数据集,数据集由一个个数据元素串联而成。理解这条结构链,是后续用代码读写、用工具排障、用十六进制手工解析的基础。

一、文件的三段式布局

一个标准DICOM文件(Part 10格式)由三部分组成。第一部分是128字节的前导码(Preamble),内容通常为全0,历史上用于兼容不支持DICOM的旧系统,可以写入任意数据。第二部分是4字节的DICM魔数,固定为ASCII字符"DICM",它是识别一个文件是否为DICOM的关键标志。第三部分是文件元信息(File Meta Information),以组号0002开头,采用显式VR小端字节序编码,包含传输语法、媒体存储SOP类UID、SOP实例UID等关键信息。元信息之后就是数据集(Dataset),也就是真正的影像与患者信息,其编码方式由元信息中声明的传输语法决定。

这里有一个容易踩坑的点:文件元信息组永远使用显式VR小端编码,不受传输语法影响;而数据集部分才遵循传输语法。很多初学者在解析时把两者混为一谈,导致读出的标签错位。

二、数据元素:DICOM的最小语义单元

数据集由数据元素(Data Element)顺序排列组成。每个数据元素包含四个字段:标签(Tag)、VR(值表示)、值长度(Value Length)和值域(Value Field)。标签由组号(Group Number)和元素号(Element Number)两个16位无符号整数构成,例如(0010,0010)表示患者姓名,(7FE0,0010)表示像素数据。

VR决定了值域如何被解释。常见VR包括:PN(人员姓名)、DA(日期)、TM(时间)、UI(唯一标识符)、US(无符号短整型)、UL(无符号长整型)、OB(字节串)、SQ(序列)等。VR的编码方式分两种:显式VR在标签后直接写2字节VR字符;隐式VR不写VR,由标签查数据字典推断,标签后直接跟4字节长度。

值长度字段的宽度也随VR变化:显式VR下,OB、OW、OF、SQ、UT、UN使用4字节长度(前2字节保留为0),其余VR使用2字节长度;隐式VR下统一使用4字节长度。这个细节直接决定了手工解析时该往后跳几个字节。

三、传输语法:数据集的编码契约

传输语法(Transfer Syntax)是元信息中(0002,0010)标签的值,它是一个UID,规定了数据集的字节序(小端/大端)、VR编码方式(显式/隐式)以及是否压缩。最常用的三种是:

  • 1.2.840.10008.1.2:隐式VR小端,DICOM默认传输语法;
  • 1.2.840.10008.1.2.1:显式VR小端,现代设备最常用;
  • 1.2.840.10008.1.2.2:显式VR大端,较少见。

压缩传输语法(如JPEG Baseline、JPEG 2000)会在UID中体现,此时像素数据需要先解压才能还原图像。解析时必须先读元信息拿到传输语法,再据此切换数据集的解析策略。

DICOM文件二进制布局与数据元素结构 文件整体布局(Part 10) 前导码 128 字节 DICM 4 字节 文件元信息组 组号 0002 · 显式VR小端 数据集 Dataset 编码方式由传输语法决定 数据元素结构(显式VR小端示例) Tag 4 字节 VR 2 字节 Value Length 2 或 4 字节 Value Field 长度由 VL 决定 十六进制实例:(0010,0010) 患者姓名 "ZHANG^SAN" 10 00 10 00 50 4E 0A 00 5A 48 41 4E 47 5E 53 41 4E 20 └─Tag─┘ └VR┘ └VL─┘ └──────── Value ────────┘ Tag: 组号 0010 + 元素号 0010 VR: "PN" = 人员姓名 VL: 0x000A = 10 字节 值: "ZHANG^SAN " (含尾空格) PN 用 ^ 分隔姓与名 偶数长度不足时补空格 传输语法 UID 决定: 字节序 / 显隐式VR / 压缩 先读元信息再解析数据集

四、手工解析一个真实DICOM文件

下面用十六进制工具(如HxD、xxd或Python的hexdump)走一遍解析流程。假设我们拿到一个名为CT_small.dcm的文件,目标是读出患者姓名和传输语法。

第一步:确认魔数。用十六进制工具打开文件,跳到偏移量0x80(即第128字节),查看接下来4个字节。如果显示44 49 43 4D,对应ASCII就是"DICM",说明这是一个合法的DICOM Part 10文件。如果不是,可能是裸数据集或非DICOM文件。

第二步:读取元信息组。从偏移0x84开始,第一个数据元素通常是(0002,0000)文件元信息组长度。以显式VR小端为例,字节序列为02 00 00 00 55 4C 04 00 XX XX XX XX:标签组号0x0002、元素号0x0000,VR为"UL",值长度4字节,值域是元信息组的总字节数。接着是(0002,0001)文件元信息版本、(0002,0002)媒体存储SOP类UID、(0002,0003)媒体存储SOP实例UID,最后是(0002,0010)传输语法UID。

第三步:定位传输语法。找到标签02 00 10 00,后面跟VR55 49("UI"),再跟2字节长度。假设长度是0x0013(19字节),值域为31 2E 32 2E 38 34 30 2E 31 30 30 30 38 2E 31 2E 32 2E 31 00,去掉末尾的00填充,得到字符串1.2.840.10008.1.2.1,即显式VR小端。UI类型值以00填充到偶数长度。

第四步:解析数据集。元信息组结束后,数据集开始。由于传输语法是显式VR小端,每个数据元素按"标签(4字节) + VR(2字节) + 长度(2或4字节) + 值"读取。例如患者姓名标签10 00 10 00,VR50 4E("PN"),长度0A 00(10字节),值域5A 48 41 4E 47 5E 53 41 4E 20,解码为"ZHANG^SAN "。注意末尾的空格是DICOM为保持偶数长度而填充的,解析时应去除。

实操提示:用Python的pydicom可以快速验证手工解析结果:ds = pydicom.dcmread("CT_small.dcm"); print(ds.PatientName, ds.file_meta.TransferSyntaxUID)。如果手工读出的值与pydicom一致,说明解析逻辑正确。

五、常见解析陷阱

手工解析时最容易出错的地方有三处。其一是字节序:小端下10 00表示0x0010,大端下表示0x1000,读错会导致标签完全错位。其二是VR长度字段宽度:OB、OW、SQ等VR在显式编码下长度字段是4字节,若按2字节读会少跳2字节,后续全部错乱。其三是序列(SQ)嵌套:SQ的值域内部是若干项(Item),每项以FFFE,E000开头,项内又是完整的数据元素集合,需要递归解析。遇到SQ时不能简单按长度跳过,而要进入项结构逐层读取。