1. 为什么还写这套SPM12的批处理脚本
1.1 它解决什么问题
MATLAB配合SPM12做fMRI预处理,算是神经影像老牌组合了。这两年虽然fMRIPrep、Nipype这类工具越来越流行,但很多课题组的老数据、旧脚本、以及正在跑的纵向研究,仍然跑在SPM12这套流程上。我的情况跟很多同行类似:手头有一大批.nii.gz格式的fMRI数据,需要跑完从时间层校正到空间平滑这一整套标准预处理,而实验室服务器上又只有MATLAB和SPM12,没有配Python环境,更没有fMRIPrep容器。这时候,一套自己写的批量预处理脚本就成了刚需。
这套脚本解决的核心问题有两个。第一,把SPM12图形界面里点点点的操作变成命令行批量执行,几十个被试一次性跑完,不用守在电脑前手动点上千次按钮。第二,处理最常见的压缩格式.nii.gz,SPM12自身识别这种压缩格式偶尔会出问题,所以脚本里要先做解压和整理,把这些“脏活”一并自动化。它不是什么高深的东西,但在只有MATLAB的老环境下,这套办法是最省事、最可靠的一条路。
1.2 适合谁来看这篇东西
如果你正准备用SPM12做一批fMRI数据的预处理,尤其是手头有旧版本SPM12、数据是压缩格式、或者需要批量处理多个被试,这篇内容应该能帮你省下不少折腾的时间。我下面写的脚本思路和代码,不是拿过来就能复制的成品,而是一个可以照着改、照着拼的框架。你需要根据自己的数据实际情况调整TR、扫描层数、参考图像、分割模板路径等参数,但整体流程和坑点我都替你踩过一遍了。
2. 数据准备与预处理流程设计
2.1 数据目录先整理对,后面才省心
拿到一批.nii.gz格式的数据,第一件事不是写预处理脚本,而是先把数据目录整理成统一的结构。我最开始图省事,数据直接从服务器上拷下来一股脑丢进一个文件夹,结果后面的批量循环脚本写得一脸懵,因为不同被试的文件命名规则不统一。后来老老实实按下面的结构重排了一遍,一切顺畅多了。
project_dir/ raw/ sub-01/ func/ sub-01_task-rest_run-1_bold.nii.gz sub-01_task-rest_run-2_bold.nii.gz anat/ sub-01_T1w.nii.gz sub-02/ func/ ... anat/ ... preprocess/ sub-01/ sub-02/这一步花不了多少时间,但能帮你少掉至少三成调试脚本的精力。凡是涉及批处理的任务,目录结构就是地基,地基不稳,后面全白搭。
实际整理的时候,我会先写一个极简的MATLAB脚本,读入原始文件夹下的子目录列表,然后用movefile把每个被试的functional和anatomical文件分别挪到对应位置。这一步不需要什么高级技巧,核心逻辑就是两步:读目录、挪文件。实际上我更推荐在处理之前用Python的os.rename来做,但如果你只有MATLAB,用movefile也一样,只是记得在循环里用fullfile拼路径。
2.2 预处理流程的六个核心环节
SPM12的fMRI预处理,一般围绕下面六个环节展开。我用的这套旧版SPM12流程,顺序是固定的,每个环节之间的依赖关系也很明确。
时间层校正(Slice Timing):fMRI扫描是分层采集的,不同层面的获取时间点不一样,所以需要校正到同一个参考时间点。这一步需要明确的参数有两个,一个是TR(重复时间,即扫描一个完整脑体积所需的时间),另一个是扫描层数以及各层之间的采集顺序。如果你的数据是隔层采集,这里填错了,后面的时间序列分析基本就废了。
头动校正(Realign):被试在扫描仪里不可能完全不动,头动会造成不同时间点上同一体素采集到的组织不一致。Realign的核心操作是把所有时间点上的图像配准到第一个时间点(或者平均图像)上,输出六个头动参数(三个平移、三个旋转)。这六个参数后面做回归分析时也是重要协变量,所以一定要保存。
配准(Coregister):这一步是把功能像和结构像对齐,让fMRI数据能叠加到高分辨率的T1像空间里。核心是选择参考图像(通常是T1像)和源图像(通常是平均功能像),其他文件跟着源图像一起变换。这里最常见的坑是忘记把source文件选对,导致功能像和结构像没对齐。
分割(Segment):把T1结构像分割成灰质、白质、脑脊液等组织类别。SPM12的分割过程还会同时计算一个从个体空间到标准空间的变形场(deformation field),这个文件是下一步归一化必需的。
归一化(Normalize):利用上一步生成的变形场,把每个被试的功能像和结构像都配准到标准空间(MNI空间)。这一步的关键是选择“写出的文件”(write)而不是重新估计参数,否则会重复计算变形场,浪费时间。
空间平滑(Smooth):对归一化后的功能像做高斯核平滑,提高信噪比。最常见的参数是FWHM(半高全宽),一般设置为[6 6 6],但具体要看你的体素大小和分析目的,后面我会详细说怎么选。
一段流程走完,每个人脑都落在同一个标准空间里,不同被试、不同组之间的数据就有了可比性。研究套路深不深另说,这一步的标准程度直接影响后面统计结果的可靠性。
3. 批量预处理脚本的核心实现
3.1 脚本整体骨架
先看一下我用的脚本整体结构。这个脚本不是一次写完的,中间改了很多版,目前这套是在MATLAB R2016b和SPM12旧版(r7219)环境下跑的。如果你用的是更新版本,个别函数接口可能有差异,但整体逻辑完全可以移植。
% 主脚本: spm12_batch_preprocess.m % 功能: 批量fMRI预处理 (.nii.gz) for SPM12旧版 % 环境: MATLAB R2016b+, SPM12 r7219 clear; clc; % 设置SPM路径 spm_path = '/path/to/spm12'; addpath(spm_path); spm('defaults', 'FMRI'); % 设置fMRI默认参数 spm_jobman('initcfg'); % 初始化job管理器 % -------------------------- % 1. 配置全局参数 % -------------------------- TR = 2.0; % 重复时间,单位秒 num_slices = 32; % 扫描层数 slice_order = 'interleaved'; % 隔层采集 ref_slice = 16; % 参考层,通常取中间层 smooth_kernel = [6 6 6]; % 平滑核大小 data_root = '/data/project'; output_root = '/data/project/preprocess'; % 获取所有被试列表 subject_list = dir(fullfile(data_root, 'raw', 'sub-*')); n_subjects = length(subject_list); % -------------------------- % 2. 逐被试处理 % -------------------------- for subj = 1:n_subjects fprintf('Processing %s (%d/%d)\n', subject_list(subj).name, subj, n_subjects); subj_dir = fullfile(data_root, 'raw', subject_list(subj).name); out_dir = fullfile(output_root, subject_list(subj).name); if ~exist(out_dir, 'dir'); mkdir(out_dir); end % 2.1 解压 .nii.gz gunzip_files(subj_dir); % 2.2 生成各步骤的matlabbatch run_preprocessing_pipeline(subj_dir, out_dir, TR, num_slices, slice_order, ref_slice, smooth_kernel); end fprintf('All done.\n');这段主脚本的核心逻辑就是一个循环加上两个自定义函数:gunzip_files负责解压,run_preprocessing_pipeline负责生成并运行各步骤的SPM批处理任务。后面我会把这两个函数的关键代码展开。
3.2 解压.nii.gz,这一步为什么不能省
SPM12在读取.nii.gz压缩格式时,不同版本的支持情况不一样。我用的这套旧版经常出现“can't open file”或者读到一半报错的情况,所以稳妥起见,第一步先把所有.nii.gz解压成.nii,再让SPM去读。
解压函数没什么技术含量,但有几个细节值得注意。第一,解压前先检查目标文件是否已经存在,避免重复解压浪费磁盘IO。第二,解压后要删除原来的.nii.gz文件吗?我的建议是别删,留着原始备份,万一处理过程出问题还能重新来过。第三,解压后的文件命名要和原文件严格一致,只去掉.gz后缀,不额外改动文件名。
function gunzip_files(subj_dir) % 递归找出所有.nii.gz文件 file_list = dir(fullfile(subj_dir, '**', '*.nii.gz')); for i = 1:length(file_list) src_file = fullfile(file_list(i).folder, file_list(i).name); [path, name, ext] = fileparts(src_file); % ext = '.gz' [~, base_name] = fileparts(name); % 去掉.nii后缀 target_file = fullfile(path, [base_name, '.nii']); % 如果已经解压过,跳过 if exist(target_file, 'file') fprintf(' [skip] %s already exists\n', target_file); else fprintf(' [gunzip] %s\n', src_file); gunzip(src_file); end end end用fileparts处理文件名时要注意,name变量拿到的其实是“xxx.nii”,还需要再套一次fileparts才能拿到“xxx”这个纯文件名。这个细节我一开始没注意,解压出来的文件全是乱名,后来用[~, base_name] = fileparts(name)处理了一下才解决问题。
3.3 每个预处理步骤的matlabbatch怎么写
SPM的批处理本质上是构造一个叫matlabbatch的MATLAB结构体,然后调用spm_jobman('run', matlabbatch)执行。这个结构体长得跟SPM图形界面上点出来的配置是一一对应的,所以最保险的写法是先在图形界面上手动配置一次,然后保存batch文件,直接看SPM生成了什么结构,照着改就行。
下面是run_preprocessing_pipeline函数的核心代码,我按步骤拆开讲。
步骤一:Slice Timing
matlabbatch{1}.spm.temporal.st.scans = { {func_files} }; % 所有功能像文件,每一卷作为一个cell matlabbatch{1}.spm.temporal.st.nslices = num_slices; matlabbatch{1}.spm.temporal.st.tr = TR; matlabbatch{1}.spm.temporal.st.ta = TR - TR/num_slices; matlabbatch{1}.spm.temporal.st.so = slice_order_vector; % 实际采集顺序,例如1:2:32, 2:2:32 matlabbatch{1}.spm.temporal.st.refslice = ref_slice; matlabbatch{1}.spm.temporal.st.prefix = 'a';这里有个参数值得一提:ta(temporal acquisition)表示一次完整体积采集时间内,实际用于采集的时间长度。公式是ta = TR - TR/num_slices,这个值SPM不会自动帮你算,必须手动填。忘了填或者填错的后果,就是时间层校正的结果会整体偏移,看起来处理完了,实际上校正了个寂寞。
slice_order_vector需要根据实际的采集方式生成。对于隔层采集(interleaved),常见序列是奇数层先采、偶数层后采,对应向量应该是[1:2:num_slices, 2:2:num_slices]。如果是连续采集(sequential),就是1:num_slices。这个信息可以从扫描仪的参数里查到,或者用dcm2niix转换数据的时候,输出信息里一般会带着。
步骤二:Realign
matlabbatch{2}.spm.spatial.realign.estwrite.data = { {func_files} }; matlabbatch{2}.spm.spatial.realign.estwrite.eoptions.rtm = 1; % 重对齐到平均图像 matlabbatch{2}.spm.spatial.realign.estwrite.eoptions.interp = 4; % 4阶B样条插值 matlabbatch{2}.spm.spatial.realign.estwrite.eoptions.wrap = [0 0 0]; matlabbatch{2}.spm.spatial.realign.estwrite.eoptions.quality = 0.9; matlabbatch{2}.spm.spatial.realign.estwrite.roptions.which = [2 1]; % 输出所有图像+平均图像 matlabbatch{2}.spm.spatial.realign.estwrite.roptions.interp = 4; matlabbatch{2}.spm.spatial.realign.estwrite.roptions.wrap = [0 0 0];这里的rtm(register to mean)建议设成1,也就是把每一时间点的图像对齐到所有时间点的平均图像上,比对齐到第一幅图像更稳定。roptions.which我写的是[2 1],意思是写出的文件包括重新采样后的所有图像(2)和平均图像(1)。平均图像后面做Coregister的时候要用到。
步骤三:Coregister
matlabbatch{3}.spm.spatial.coreg.estwrite.ref = { {t1_file} }; matlabbatch{3}.spm.spatial.coreg.estwrite.source = { {mean_func_file} }; matlabbatch{3}.spm.spatial.coreg.estwrite.other = {func_files_resliced}; % 所有功能像 matlabbatch{3}.spm.spatial.coreg.estwrite.eoptions.cost_fun = 'nmi'; matlabbatch{3}.spm.spatial.coreg.estwrite.eoptions.interp = 4; matlabbatch{3}.spm.spatial.coreg.estwrite.roptions.interp = 4; matlabbatch{3}.spm.spatial.coreg.estwrite.roptions.which = [0 1];Cost function我习惯用nmi(互信息),它对多模态图像(T1和fMRI)的配准效果比ncc(归一化互相关)稳定。other字段里不仅要填所有功能像,还要包含前面Slice Timing和Realign输出的文件。Coregister计算的时候,会以source为基准估计变换参数,然后把source和other里的所有文件一起按这个变换写入新的空间。
步骤四:Segment
matlabbatch{4}.spm.spatial.preproc.channel.vols = {t1_file}; matlabbatch{4}.spm.spatial.preproc.channel.biasreg = 0.001; matlabbatch{4}.spm.spatial.preproc.channel.biasfwhm = 60; matlabbatch{4}.spm.spatial.preproc.tissue(1).tpm = {fullfile(spm_path, 'tpm', 'TPM.nii,1')}; matlabbatch{4}.spm.spatial.preproc.tissue(1).ngaus = 2; % ... 组织类别2到6依此类推 matlabbatch{4}.spm.spatial.preproc.warp.mrf = 1; matlabbatch{4}.spm.spatial.preproc.warp.cleanup = 1; matlabbatch{4}.spm.spatial.preproc.warp.bb = [-78 -112 -70; 78 76 85]; matlabbatch{4}.spm.spatial.preproc.warp.vox = [1.5 1.5 1.5]; matlabbatch{4}.spm.spatial.preproc.warp.write = [1 1]; % 写变形场和反变形场Segment步骤里最需要注意的是TPM(组织概率图)的路径。旧版SPM12的TPM在spm12/tpm/TPM.nii,每个组织类别是一卷,所以引用时要带上,1、,2这样的卷序号。很多报错都出在这个地方——路径不对或者卷标不对,整条处理链直接中断。
warp.write = [1 1]表示同时写出正向和反向变形场。正向是把个体空间映射到MNI空间的变形场,后面Normalize用;反向是把MNI空间映射回个体空间的变形场,通常留作备用。
步骤五:Normalize
matlabbatch{5}.spm.spatial.normalise.write.subj.def = {deformation_field_file}; % 上一步生成的y_*.nii matlabbatch{5}.spm.spatial.normalise.write.subj.resample = {func_files_to_normalize}; matlabbatch{5}.spm.spatial.normalise.write.woptions.bb = [-78 -112 -70; 78 76 85]; matlabbatch{5}.spm.spatial.normalise.write.woptions.vox = [3 3 3]; % 功能像归一化到3mm体素 matlabbatch{5}.spm.spatial.normalise.write.woptions.interp = 4; matlabbatch{5}.spm.spatial.normalise.write.woptions.prefix = 'w';Normalize步骤里同样要注意的是bb(bounding box)和vox(体素大小)这两个参数。功能像归一化通常用[3 3 3]或[2 2 2],结构像用[1 1 1]。它们只需要在MATLAB的job配置里设定一次,SPM会自动应用到你指定的所有文件上。
步骤六:Smooth
matlabbatch{6}.spm.spatial.smooth.data = {normalized_func_files}; matlabbatch{6}.spm.spatial.smooth.fwhm = smooth_kernel; matlabbatch{6}.spm.spatial.smooth.dtype = 0; matlabbatch{6}.spm.spatial.smooth.im = 0; matlabbatch{6}.spm.spatial.smooth.prefix = 's';平滑的fwhm是核心参数。它设置的是高斯核的半高全宽,单位是毫米。常见做法是设置成跟体素大小差不多的值,比如体素3mm时用[6 6 6],体素2mm时用[4 4 4]。这个参数不宜过大,过大会把一些本来就有意义的精细结构也抹掉;也不宜过小,过小起不到平滑的作用。
smooth.dtype = 0表示输出数据类型跟输入一致,一般就是float32,够用。im = 0表示不生成隐含掩膜,这个默认参数不用改。
所有步骤的matlabbatch组装完成后,用多行spm_jobman('run', matlabbatch)依次执行每一步。我建议分步执行而不是把六个job一股脑塞给spm_jobman('run', matlabbatch)一次跑完,原因有两点:一是可以在每两步之间检查输出文件;二是如果某一步参数写错,你马上就能定位到具体是哪一步出的问题,不至于整个batch全部失败后从头排查。
3.4 参数计算和选型实战:以TR、FWHM为例
上面提到的好几个参数,看起来就是填数字,但实际设置时是需要根据数据算的。我以TR和FWHM为例,具体说说怎么算、怎么选。
TR的计算很简单,一般来说就是从扫描仪或者dicom头文件里读出来的值。假设你的扫描参数是TR=2000ms,那就是TR=2.0。填入SPM时单位是秒,不是毫秒。
如果TR信息丢失了,可以用一个小技巧估算:看单个.nii文件的时间维长度和总扫描时长。比如一个volume有300个时间点,总扫描时长是10分钟,那TR≈600/300=2秒。这只是估算,如果有原DICOM文件,还是以DICOM头里的Repetition Time字段为准。
FWHM的选择就比较讲究了。它跟你的体素大小直接相关,同时跟你的分析目标也有关系。做组水平的统计分析时,一般建议FWHM取两倍体素大小左右。例如体素3mm,取6mm合适;体素2mm,取4mm。但如果你做的是高空间分辨率的单个被试分析,或者用MVPA这类保持空间细节的方法,平滑核就应该小一些,甚至不做平滑。一个实用的判断标准是:看你的fMRI数据本身的空间分辨率,平滑核大小不应超过感兴趣区域尺寸的一半。
从参数到代码,这些数值都是直接填进上面的matlabbatch结构里的。我习惯把参数统一定义在主脚本开头,用一个变量名指代,这样后面要改只需要改一处,不用翻遍脚本找数字。
3.5 只处理部分被试:怎么加断点续跑
跑批量预处理经常遇到的情况是,跑到第10个被试时服务器崩了,或者某个被试数据质量差导致spm_jobman报错中断。如果脚本没有断点机制,前面的9个被试就白跑了,浪费时间。
我在主循环里加了一个简单的断点判断:为每个被试建立一个“完成标记文件”。
for subj = 1:n_subjects subj_name = subject_list(subj).name; done_flag = fullfile(output_root, subj_name, 'finished.txt'); if exist(done_flag, 'file') fprintf('[skip] %s already processed.\n', subj_name); continue; end % ... 处理该被试 fid = fopen(done_flag, 'w'); fprintf(fid, 'completed %s\n', datestr(now)); fclose(fid); end这样做的逻辑很简单:每次跑完一个被试就写一个标记文件,下次再跑脚本时自动跳过已经完成的被试。同样的思路可以精细化到每个步骤,比如给Slice Timing、Realign都单独写标记,这样中断后可以从具体步骤重新续跑。我在实际项目中,通常会用“每跑完一个步骤手动把对应job从循环里注释掉”这种最土的办法,但效果也很实在。
4. 常见问题与排查实录
4.1 SPM报错类型和解决方法
我把这几年来用这套脚本时遇到的高频报错整理成了一个表,方便你对照排查。
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
Can't open file xxxx.nii | .nii.gz未解压,或路径中有中文/空格 | 先跑解压步骤;路径全英文,无空格 |
Error using spm_run_normalise | 变形场文件没生成,或者y_*.nii文件名匹配失败 | 检查Segment步骤是否真的完成;确认deformation_field_file变量路径 |
Undefined function 'spm' | SPM12路径没加进MATLAB搜索路径 | 检查addpath(spm_path)是否执行成功;确认SPM是完整解压 |
Out of memory | 功能像文件太大,或同时加载了太多文件 | 减少一次加载的文件数;分批处理run;增加虚拟内存 |
Job execution failed | SPM版本不一致导致matlabbatch字段不兼容 | 大部分旧版脚本的字段多一个write子结构,对照图形界面重新保存新版本batch |
Image dimensions do not match | 不同run或不同被试图像的维数不一致 | 检查原始数据;确认时间层数和矩阵大小一致后才能一起跑Realign |
第一个问题最常见。很多初学者从网盘或课题组服务器拷到数据压缩包后,直接塞给SPM处理,结果SPM读不了.nii.gz。用我这个脚本里的gunzip_files函数先解压,问题就解决了一大半。
第二个问题也很有代表性。SPM12的Segment步骤会生成一个y_*.nii变形场文件,但旧版SPM12生成的变形场文件名前缀不同,如果你用的是更新版本,这个文件名前缀可能变成y_或者别的,导致下一步Normalize找不到文件。我的建议是,在脚本里加一行检查:
def_file = spm_select('FPList', out_dir, '^y_.*\.nii$'); if isempty(def_file) error('Deformation field not found. Check Segment step.'); end用spm_select的FPList模式可以按正则表达式从指定文件夹里筛选文件,比手动拼接路径靠谱得多。类似的,在后面步骤里筛选w*、sw*文件也都可以用这个方式。
4.2 头动参数过大的数据要不要剔除
Realign跑完之后,每个被试都会生成一个rp_*.txt文件,里面是六列头动参数。我通常会在预处理完成后写一个小段代码,批量读取所有被试的头动参数,计算最大平移和最大旋转,然后标记那些超过阈值的被试。
% 读取头动参数 rp_file = spm_select('FPList', subj_dir, '^rp_.*\.txt$'); motion = load(rp_file); max_trans = max(abs(motion(:, 1:3))); % 单位mm max_rot = max(abs(motion(:, 4:6))); % 单位rad % 旋转角度转换为度 max_rot_deg = max_rot * 180 / pi; fprintf('%s: max_trans=%.2fmm, max_rot=%.2f度\n', subj_name, max(max_trans), max(max_rot_deg));我的经验阈值是最大平移超过3mm或者最大旋转超过3度的被试,就需要谨慎处理。这不意味着一定要剔除,但至少要在后续统计分析时作为协变量纳入,或者检查一下是不是存在明显的尖峰。
如果数据本身质量不好,头动超过阈值,可以试着先用ART工具或者tsdiffana之类的质量检查工具跑一遍,看有没有异常时间点,再决定是剔除整个被试还是去掉某些时间点。这已经属于预处理后面的质量检查环节了,但建议在跑完整套流程后立即做,不要等统计分析时才发现数据没法用。
4.3 旧版SPM12的几个特殊问题
“旧版”这两个字,在SPM12的语境下意味着你的代码和某些新版本的行为可能存在差异。我用的是r7219这一版,遇到的主要差异有三个。
第一,matlabbatch结构在旧版接口更繁琐。比如新版SPM12的Realign步骤有时简化了字段,旧版还保留着estwrite.eoptions和estwrite.roptions这些层级,你只要照着图形界面保存的batch结构去写就行,不要凭新版教程照搬。
第二,TPM模板文件的卷标路径格式。旧版SPM12里,TPM模板需要写成fullfile(spm_path, 'tpm', 'TPM.nii,1')这样的格式,有些版本能够自动识别不加卷标的写法,但旧版不行,必须显式带上,1。这个细节导致过很多次“Index exceeds matrix dimensions”之类的报错,排查起来很隐蔽。
第三,旧版对“脏数据”更敏感。比如说.nii文件头缺少某些必要字段,新版会补默认值,旧版直接报错。遇到这种情况,可以用dcm2niix重新转换原始DICOM文件,确保NIfTI头信息完整,再跑预处理。
4.4 并行加速:matlabpool和parfor能省多少时间
批量处理的痛点在时间。一个被试跑完六步,机械硬盘环境下大概需要15分钟到半小时。30个被试就是8到15小时,基本过夜。如果服务器是多核CPU,可以尝试用MATLAB并行计算来加速。
最直接的办法是给主循环换一个parfor。但这里有个前提:每个被试的SPM任务之间不能共享任何工作区变量,matlabbatch结构体必须完全独立构建。我的做法是把run_preprocessing_pipeline这个函数写成无副作用的纯函数,只输入路径和参数,输出状态信息,然后就可以放心用parfor。
parfor subj_idx = 1:n_subjects % 注意不要用subject_list(subj_idx).name这种动态变量名,最好先把属性提取出来 subj_name = subject_names{subj_idx}; fprintf('Processing %s (%d/%d)\n', subj_name, subj_idx, n_subjects); subj_dir = fullfile(data_root, 'raw', subj_name); out_dir = fullfile(output_root, subj_name); if ~exist(out_dir, 'dir'); mkdir(out_dir); end gunzip_files(subj_dir); run_preprocessing_pipeline(subj_dir, out_dir, TR, num_slices, slice_order, ref_slice, smooth_kernel); endparfor的坑也不少。最典型的是每个worker都要初始化SPM——SPM本身不是并行安全的,如果两个worker同时初始化SPM,经常会互相冲突。解决办法是在run_preprocessing_pipeline函数内部调用一次spm('defaults', 'FMRI')和spm_jobman('initcfg'),让每个worker独立初始化。
我实测过的场景是,服务器上8个物理核心并行跑30个被试,从过夜跑缩短到大约3小时。提速很明显,但代价是内存占用大,尤其Smooth和Segment步骤。如果服务器内存不够大,建议并行数控制在4核左右,2个被试一起跑,比强行开8个核然后把内存撑爆划算。
5. 脚本的后续扩展方向
如果你只是要跑完标准六步预处理,上面的内容完全够用了。但实际项目中,经常会碰到需要额外处理的情况。我把自己实验后觉得值得扩展的几个方向列在下面。
加一个ART或DVARS检查环节:预处理完成后,用Artifact Detection Tools或者自己写一个小脚本计算DVARS(frame-wise displacement的导数),识别异常时间点。这可以作为后续回归分析的头动协变量来源,也可以用来筛选被试。
加入去噪步骤:SPM12旧版自带的DARTEL工具可以改进分割和归一化精度。如果你的数据样本量不大、时间预算充足,建议在Segment和Normalize之间插入DARTEL创建模板的步骤。这样做的主要收益是组水平配准精度更高,但流程会拉长很多。
串联conn工具箱做功能连接预处理:如果你的课题是做功能连接,那么预处理之后通常还有一步去噪和带通滤波。CONN工具箱可以复用SPM12的处理结果,直接输入sw*.nii文件即可。我自己做静息态功能连接项目时,就是把这套预处理脚本的输出作为后续CONN分析的输入,衔接得非常顺利。
生成分析报告:给每个被试生成一张预处理质量报告,内容包含头动曲线、配准前后覆盖叠加图、归一化前后对比图。我后期整理文章和对外汇报时,这些图省了我不大事。用SPM自带的spm_check_registration配合MATLAB的print函数就能批量导出。
这些扩展方向不需要重新设计主流程,都是在现有脚本框架上打补丁加模块。预处理管线这种东西,只要骨架搭得稳,往上面挂新组件很容易。
我个人在跑完这么多批数据后最大的体会是,批量预处理的核心不在于某一步参数调得多完美,而在于每一步的输出都能被严格检查、每一次异常都能被快速定位。这套脚本虽然“旧”,但因为每一步之间的文件传递都是显式的、可控的,所以它反而比很多黑盒工具更容易排查问题。如果你正准备上手自己的fMRI数据,建议先把这套流程完整跑通一批,再根据结果去调整参数细节,比一开始就追求“完美预处理”要靠谱得多。