简介:这套 Matlab 脚本面向医学图像处理初学者与科研人员,聚焦 DICOM 影像的读取与格式转换,帮助在 Matlab 环境中把 CT、MRI 等 DCM 数据转存为 NIfTI、NPZ 或 MAT,便于后续分析、跨平台交换以及与 Python 流程衔接。压缩包为 rar 格式,共 9 个文件,包含 8 个 .m 脚本与 1 个 txt 说明文档,整体约 20KB,体积轻便,适合直接加入现有工程调用。脚本覆盖 NIfTI 头信息读写、图像数据加载、三维数据获取、坐标变换以及常用 NIfTI 读写与保存环节,配合说明文档可较快理解调用顺序、参数含义与脚本之间的依赖关系。已有 3181 人学习下载,说明其在医学影像格式转换场景中具有一定参考价值。读者可从中获得一套可复用的 DCM 转 NIfTI/NPZ/MAT 工具脚本、NIfTI 文件结构处理思路及元数据保留与转换的实践参考,也适合需要快速搭建数据预处理流程、批量处理影像或排查格式兼容问题的用户参考。
1. 三行代码读完 DICOM,三十行代码才对得起它
把一份 320 张的序列读进 MATLAB 只用三行:dicominfo、dicomread、cat。存成别人能打开的 NIfTI 或者 NPZ,却要先把层间距、方向余弦、LPS 到 RAS 的符号、MATLAB 列主序和 numpy 行主序这几笔账全部算清楚。matlab dcm npz mat 这条链路上真正让人返工的地方从来不是“读不进”,而是读进去了、imshow也正常、交给下游却镜像或压扁——因为imshow只关心像素值,完全不关心体素在病人体内的真实位置。这份资源值得花时间的点就在这里:它把「读」和「存」中间那层几何信息用可以直接抄的脚本钉死,而不是每次靠肉眼和运气。适合手里攥着一堆 DICOM 序列、要做重建、分割、配准,又不想在环境依赖上反复纠缠的人。
2. dicominfo 与 dicomread:哪些字段真正决定后面的成败
DICOM 文件本质上是个标签容器,128 字节前导加DICM魔术字之后,全是 (group, element) 编号的键值对。像素数据只是其中一个标签(7FE0,0010)。大部分人第一次用 MATLAB 处理 DICOM,习惯是img = dicomread('xxx.dcm')然后直接往下走,几何信息全丢。等到要拼体数据、要输出 NIfTI 的时候,才发现手里只有一个三维数组,没有任何东西告诉你这个数组每一维代表几毫米。
2.1 十来个字段撑起整个几何描述
先把头部结构打印出来看一眼,比翻标准文档快得多。下面这段只解析头、不碰像素,跑一千个文件也是秒级。
% 只读元数据,不解析像素数据,速度快一个量级 f = fullfile(dcmDir, 'IM0001.dcm'); info = dicominfo(f); fprintf('Rows x Cols : %d x %d\n', info.Rows, info.Columns); fprintf('PixelSpacing : %.4f (row) %.4f (col)\n', ... info.PixelSpacing(1), info.PixelSpacing(2)); fprintf('SliceThickness: %.4f\n', info.SliceThickness); fprintf('BitsStored : %d\n', info.BitsStored); fprintf('PixelRep : %d (0=unsigned, 1=signed)\n', info.PixelRepresentation); fprintf('Rescale : slope=%g intercept=%g\n', ... info.RescaleSlope, info.RescaleIntercept); fprintf('TransferSyntax: %s\n', info.TransferSyntaxUID); fprintf('Photometric : %s\n', info.PhotometricInterpretation); fprintf('Frames : %d\n', info.NumberOfFrames);逻辑很直白:dicominfo返回的是一个按标签名索引的嵌套结构体,字段名就是 DICOM 关键字。参数上没有太多可调的,'UseDictionaryVR'保持默认即可,只有在遇到私有标签解析异常时才需要考虑换字典文件。真正需要花心思的是从几十个字段里挑出对几何有意义的那一批,其余的先放着。
| 字段 | 标签 | 作用 | 常见问题 |
|---|---|---|---|
Rows/Columns | (0028,0010/0011) | 矩阵尺寸 | 与体素间距顺序容易搞反 |
PixelSpacing | (0028,0030) | [行间距, 列间距],单位 mm | 是间距不是尺寸,别当 FOV 用 |
ImagePositionPatient | (0020,0032) | 该切片左上角体素的 LPS 坐标 | 缺失时无法做几何重建 |
ImageOrientationPatient | (0020,0037) | 前三个方向余弦 + 后三个方向余弦 | 同一序列内必须完全一致 |
SliceThickness | (0018,0050) | 层厚,不是层间距 | 厚层重建时与间距不等 |
SpacingBetweenSlices | (0018,0088) | 层间距 | 经常缺失,或者给的是负值 |
RescaleSlope/Intercept | (0028,1053/1052) | 原始值转物理值的线性变换 | CT 转 HU 必需,MR 常没有 |
PixelRepresentation | (0028,0103) | 有符号 / 无符号 | 决定读出来是 int16 还是 uint16 |
PhotometricInterpretation | (0028,0004) | 灰度 / 彩色 / 调色板 | 彩色序列不能按灰度处理 |
TransferSyntaxUID | (0002,0010) | 传输语法,决定是否压缩 | 压缩语法是读失败的根源 |
PixelSpacing的顺序是最经典的坑:它给的是[相邻行之间的距离, 相邻列之间的距离],也就是沿列方向走一步和沿行方向走一步各是多少毫米。所以PixelSpacing(1)对应的是行索引增加时的步长,写进 NIfTI 的时候要放到第二维,顺序反了图像就会被拉成非等比。
2.2 像素数组、Rescale 和数据类型控制
dicomread返回的是存储值,不是物理值。CT 的存储值通常是 12 位或 16 位整数,要经过HU = raw * slope + intercept才能得到亨氏单位。这一步不做,窗宽窗位全部错位,后面做分割阈值就是灾难。
% 传 info 结构体进去,避免 dicomread 内部再解析一遍文件头 img = dicomread(info); % 多帧序列在部分版本里会多出一个单例维度,先看清 ndims 再决定怎么 squeeze if ndims(img) == 4 && size(img, 3) == 1 img = squeeze(img); end % Rescale 字段不一定存在,MR、超声、部分二次处理过的 CT 都没有 if isfield(info, 'RescaleSlope') && ~isempty(info.RescaleSlope) slope = double(info.RescaleSlope); inter = double(info.RescaleIntercept); else slope = 1; inter = 0; end % 关键:转 single 而不是 double,体积直接减半 imgHU = single(img) * slope + inter;这里最值得说的是数据类型。一个 512×512×600 的体数据,double占 1.2 GB,single占 600 MB,int16只占 300 MB。单例跑没问题,批处理两百例的时候double会让你在第三个受试者上就开始Out of memory。我一般全程保持int16存储,只在做 Rescale 或插值时临时转single,算完立刻clear中间变量。
PixelRepresentation为 1 时,MATLAB 读出来是int16,为 0 时是uint16。这个不要手动强转,强转会把负的 HU 值(比如空气的 -1000)翻成 64536,整个窗位就废了。
2.3 传输语法:压缩序列为什么读出来全黑
TransferSyntaxUID是 DICOM 里最容易被忽略、又最容易让人卡一整天的字段。未压缩的显式 VR 小端是1.2.840.10008.1.2.1,隐式 VR 小端是1.2.840.10008.1.2,这两种 MATLAB 处理起来毫无障碍。麻烦的是压缩语法:JPEG Baseline 是.4.50,JPEG Lossless 是.4.57和.4.70,JPEG 2000 是无损.4.90、有损.4.91,JPEG-LS 是.4.80和.4.81,RLE 是.5。
不同 MATLAB 版本内置的编解码器支持范围不一样,常见现象是dicomread不报错,但返回一个全零或者全 1 的矩阵,imshow出来一片黑。这种时候先别怀疑自己的代码,加一段前置检查:
ts = info.TransferSyntaxUID; plain = {'1.2.840.10008.1.2', '1.2.840.10008.1.2.1', ... '1.2.840.10008.1.2.1.99', '1.2.840.10008.1.2.2'}; if ~any(strcmp(ts, plain)) warning('压缩传输语法 %s,dicomread 可能返回空矩阵', ts); end img = dicomread(info); if isempty(img) || (all(img(:) == 0) && ~all(img(:) == img(1))) error('像素读取失败,检查 TransferSyntaxUID 或换解码后端'); end判断“全零”这一步看着粗糙,实际很有效:正常的 CT 或 MR 切片极少出现整张图全零,一旦出现基本就是解码链路断了。修复路径通常是两条,一条是换 MATLAB 版本重试,另一条是离线把序列转成未压缩显式 VR 小端再交给 MATLAB。后一条更可控,用常见的 DICOM 处理库批量转一遍即可:
import pydicom from pydicom.uid import ExplicitVRLittleEndian # 只改传输语法,不动像素值本身 ds = pydicom.dcmread(src_path) ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian ds.save_as(dst_path, write_like_original=False)这段的前提是运行环境里装了能解码对应压缩格式的插件,否则dcmread阶段就会抛异常,那就得先装解码后端再转。转完之后 MATLAB 侧就不用再操心压缩的事了。
3. 散切片拼体数据:排序、层间距和几何矩阵怎么算
拿到一个目录、几百个.dcm文件,最直觉的做法是按InstanceNumber排序然后cat(3, ...)。这个做法在八成的情况下是对的,剩下两成会让你在复现别人结果时反复怀疑人生——有些设备的InstanceNumber会重复、会跳号,有些序列在导出时被工具重新编号过。
3.1 用投影排序,别信 InstanceNumber
正确的排序依据是每张切片在法向量方向上的投影。ImageOrientationPatient给出六个方向余弦:前三个是列索引增加的方向(图像的水平方向),后三个是行索引增加的方向(图像的垂直方向),两者叉乘就是切片堆叠的法向量。把每张切片的ImagePositionPatient投影到这个法向量上,按投影值排序,物理顺序就唯一确定了。
files = dir(fullfile(dcmDir, '*.dcm')); n = numel(files); ipp = zeros(n, 3); iop = zeros(n, 6); for k = 1:n info = dicominfo(fullfile(dcmDir, files(k).name)); ipp(k, :) = info.ImagePositionPatient(:)'; iop(k, :) = info.ImageOrientationPatient(:)'; end % 同一批切片的朝向必须完全一致,否则说明混进了别的序列或斜扫 if max(std(iop, 0, 1)) > 1e-4 error('方向余弦不一致,先按 SeriesInstanceUID 分组再处理'); end rowDir = iop(1, 1:3); % 列索引增加方向(水平) colDir = iop(1, 4:6); % 行索引增加方向(垂直) normal = cross(rowDir, colDir); normal = normal / norm(normal); proj = ipp * normal(:); % 每张切片在法向上的位置 [projSorted, order] = sort(proj); filesSorted = files(order);参数上唯一需要注意的是cross(rowDir, colDir)的符号约定。这个叉乘得到的方向不保证指向头侧,某些采集协议下它可能朝下。判断方法是看第三分量:如果normal(3) < 0,说明投影值递增对应的是从头顶到脚底,而 NIfTI 的 k 轴约定是从脚底指向头顶,这时候必须把整个体数据沿第三维翻过来,否则存出来的图在冠状位上就是倒的。
% 保证 k 索引增加的方向朝上(RAS 的 S 方向) flipZ = normal(3) < 0; if flipZ projSorted = flip(projSorted); filesSorted = flip(filesSorted); end这几行在单例上看不出差别,批处理时如果不做,前后两百个病例的朝向会随机一半正一半反,做组水平统计时结果全是噪声。
3.2 层间距:SliceThickness 是厚度,不是间距
这两个字段在薄层重建里经常相等,在厚层或者有重叠、有间隙的重建里就分道扬镳。更麻烦的是SpacingBetweenSlices本身是可选的,很多设备根本不写。
我的做法是不信任何一个标签,直接从排序后的投影值算实测间距:
dz = diff(projSorted); medDz = median(d <p> <a href="https://download.csdn.net/download/tianjinyikedaxue/15841878" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>