简介:基于MATLAB的红细胞计数项目,面向数字图像处理课程实习及生物医学图像分析初学者,解决血液涂片图像中红细胞自动识别与计数的实际问题。资源包共1个文件,为MATLAB主程序m文件,压缩后大小仅1KB,代码精简紧凑,便于读者直接运行与调试,尤其适合对照课程实验报告逐行理解算法实现。该项目覆盖图像读取、对比度调整、灰度化、中值滤波与高斯平滑等预处理操作,并通过阈值分割、形态学膨胀腐蚀及边缘检测实现细胞分离与识别,整体流程完整。已有1236人学习下载,适合需要完成数字图像处理作业或希望掌握MATLAB图像分割基础流程的读者,也可作为课程设计参考模板。通过阅读源码,可理解红细胞计数的经典实现思路,并在此基础上引入Canny算子、K-means聚类或CNN深度学习模型进行算法优化,为后续医学图像分析研究打下基础。
1. 红细胞计数为什么值得用 MATLAB 重做一遍:从人工估算到可复现的自动统计
在检验科或科研实验室待过的人都知道,红细胞计数是看似简单、实际最容易翻车的一类图像任务。一张涂片几百上千个细胞,人工在目镜下数完,眼睛花还是其次,同一个视野不同的人数,结果能差出百分之十几。用 MATLAB 做红细胞计数,本质是把“肉眼认细胞”换成“固定流程算像素”,从读图、灰度化、背景校正到二值化、分割,最后用连通域统计个数。这个方案的好处是可复现、可批量,不受疲劳和主观影响;坏处是参数设不对,结果比人工还离谱。这篇笔记就把完整链路拆开,讲清楚每一步在做什么、参数怎么调、哪些地方最容易踩坑,让新手能跑通,熟手能调稳。
2. 图像预处理:把血液涂片转成干净二值图的四个环节
二值化质量基本决定了计数上限。我见过不少同学拿到图像直接imbinarize,结果要么把背景染成一整片,要么把细胞边缘啃掉一圈,最后只能靠面积过滤硬救。其实红细胞计数真正的门槛不在计数那一步,而在预处理有没有把“细胞”和“背景”的边界交代清楚。这一章按照我习惯的落地顺序拆开讲,每一步都会直接影响后面的连通域统计。
2.1 读图与灰度化:先分清细胞是亮还是暗
用imread把图像读进来之后,第一件事不是急着做形态学操作,而是看像素分布。血液涂片染色质量参差不齐,有的背景偏蓝,有的背景偏黄,直接扔到二值化函数里,阈值容易被背景主导。我的固定做法是先转灰度,再用灰度直方图确认细胞和背景谁亮谁暗。多数染色较均匀的血涂片里,红细胞与背景的灰度有明确落差,细胞偏暗、背景偏亮,但也有染色深到背景几乎和细胞一样暗的情况,这时后面就要考虑取反。
% 读取血液涂片图像,常见格式为 tif/png/jpg rawImg = imread('blood_smear.tif'); % 转成灰度图,MATLAB 按亮度加权,也可单独取 R 通道观察 grayImg = rgb2gray(rawImg); % 预览灰度图和直方图,判断细胞与背景的明暗关系 figure('Name', '灰度检查'); subplot(1, 2, 1); imshow(grayImg); title('灰度图像'); subplot(1, 2, 2); imhist(grayImg); title('灰度直方图');这里有一个值得养成的习惯:显示直方图时留意波峰数量。理想情况下,直方图出现“背景峰”和“细胞峰”两个峰;如果只有一个大峰,说明背景光照不均已经严重到抹平了对比度,后面的背景校正就必须做得更重。如果图像本身是灰度图,size(rawImg, 3)会返回 1,rgb2gray也不再适用,用条件分支处理更稳妥。
2.2 形态学背景校正:解决光照不均的根本办法
涂片最常见的毛病是边缘厚、中间薄,载玻片上血膜厚度不一致,导致整张图的背景亮度并不均匀。直接做全局二值化,厚的地方会被整体切成一团暗色,薄的地方细胞和背景混在一起。常规做法是用半径足够大的圆盘结构元素对灰度图做形态学开运算,估出背景亮度,再把原图减去背景。开运算会抹掉那些比结构元素小的亮结构,剩下的就是一个平滑变化的背景估计。
% 用半径 30 的圆盘做形态学开运算,得到背景估计 se = strel('disk', 30); bg = imopen(grayImg, se); % 原图减去背景,抵消光照不均带来的亮度漂移 correctedImg = imsubtract(grayImg, bg); % 做一次对比度拉伸,让后续二值化更容易 correctedImg = imadjust(correctedImg);核半径是这里最关键的自由参数。半径太小,开运算会把细胞本身也算进背景里,减完之后细胞残影变淡;半径太大,开运算对背景变化的拟合又不够,光照不均的残留还要留给后面的自适应阈值去处理。经验上,圆盘半径取细胞直径的 5 到 8 倍比较稳:细胞直径 25 像素左右时,半径 120 到 200 都可以;我常用半径 100 到 150 起步,再根据计数稳定性上下扫。
2.3 二值化:全局阈值与自适应阈值怎么选
背景校正之后,常规样本用 Otsu 全局阈值就能得到不错的结果。graythresh计算的是让类间方差最大的阈值,对双峰明显的图像很可靠。但涂片的染色问题往往不会只靠一次背景校正就完全解决,局部还可能有暗角或染液残留,这时全局阈值会局部失灵。自适应阈值会按每个像素邻域分别计算阈值,对光照残留更稳,代价是速度慢一些,而且 Sensitivity 参数需要额外调。
% 方案一:Otsu 全局阈值 th = graythresh(correctedImg); bwGlobal = imbinarize(correctedImg, th); % 方案二:自适应阈值,Sensitivity 默认 0.5 bwAdapt = imbinarize(correctedImg, 'adaptive', 'Sensitivity', 0.5); % 如果染色背景偏暗,二值化后前景背景颠倒,取反一次 if mean(bwAdapt(:)) > 0.5 bwAdapt = ~bwAdapt; end提示:自适应阈值的 Sensitivity 越大,前景越容易被选中。Sensitivity 0.3 到 0.4 适合背景脏、染色深的图,0.6 到 0.7 适合染色偏淡的图。判断极性是否反了,最简单的方法是显示二值图后看细胞是不是变成了白色背景里的黑色连通块。
2.4 形态学清洗:去噪点、补孔洞、平滑边缘
二值图上通常残留三类问题:染色残渣形成的孤立小点、红细胞中心因为染色不均留下的孔洞、以及二值化边界上的锯齿。我的处理顺序是固定的:先用bwareaopen按面积滤掉小噪点,再用imfill填充内部孔洞,最后用半径为 1 的圆盘做闭运算把边缘收圆。
% 去掉面积小于 30 像素的孤立噪点 bw = bwareaopen(bw, 30); % 填充红细胞内部可能出现的孔洞 bw = imfill(bw, 'holes'); % 用半径 1 的圆盘做闭运算,平滑细胞边缘并接合细小裂缝 bw = imclose(bw, strel('disk', 1));顺序不能乱。先做面积开运算,是避免孔洞填充把杂质小点扩大;先填孔再闭运算,是让闭运算不要填掉真正的细胞间隙。bwareaopen的参数 30 是按 512 分辨率、细胞直径约 25 像素的经验值设定的,图像分辨率变化时需要按面积比例重新算。判断标准是:预设一个你最想保留的最小正常细胞,量出它的像素面积再打五折作为下限。
3. 分割与计数:连通域统计和重叠细胞的分水岭拆分
预处理结束后的二值图,白色连通块基本对应细胞。最简单的计数方式就是数连通域,但现实涂片里细胞经常黏连、重叠,直接数会把多个细胞算成一个。这一章先讲常规连通域路径,再讲重叠细胞怎么用分水岭拆开,最后给出一个可落地的过滤逻辑。
3.1 连通域统计:最容易上手的计数路径
bwconncomp会把所有 8 邻域相连的白色像素归为一个连通域,regionprops再从这个结果里提取面积、质心等属性。多数情况下,这里的输出数量是“上限参考”,因为重叠导致的漏数会让它小于真实值。直接用numel(stats)做最终结果,只适用于完全没有重叠的稀疏涂片。
% 找出所有 8 邻域连通的白细胞区域 cc = bwconncomp(bw); % 提取每个连通域的面积和质心 stats = regionprops(cc, 'Area', 'Centroid'); % 这一步是原始连通域个数,先不急着当最终结果 rawCount = numel(stats);我一般会把rawCount打印出来,和后面分水岭之后的结果做对比。如果两个数字差很多,说明重叠细胞占比较高,预处理或分水岭参数需要重看。纯粹看数量无法说明问题,但这个差值能提供“要不要处理重叠”的判断依据。
3.2 用距离变换加分水岭拆开重叠细胞
当两个红细胞重叠在一起,连通域会把它们合并成一个。常见做法是先计算二值图到背景的距离变换:离背景越远的地方值越高,所以每个细胞中心会形成一个“山峰”,而两个细胞的重叠边界处会出现“山谷”。取负号再分水岭,水就从这些山谷灌进去,把相邻细胞切开。
% 计算距离变换:远离背景的像素值越高 D = bwdist(~bw); % 取负后,细胞边界对应低洼处 Dcomp = -D; % 分水岭分割 L = watershed(Dcomp); % 只保留原二值图内部的区域,背景和外边界清零 bwSep = bw; bwSep(L == 0) = 0;这里要说明一个常见误区:watershed会对所有连通域都做一遍分割,包括本身就是圆形的单个细胞。单个正常细胞如果没有明显的凹痕,不会被切开;一旦细胞边缘锯齿明显或中心有两个局部极大值,就可能被误切成两块。这时先对二值图做一次轻微闭运算,把边缘平滑掉,会比降低分水岭阈值更有效。
3.3 分水岭后重新计数:别把过度分割算成真细胞
分水岭之后需要重新统计连通域,并再一次使用面积下限。因为分水岭切出来的碎片面积只有正常细胞的一半左右,若无面积过滤,过度分割会直接变成虚高计数。我把这一步叫作“计完数再清洗一次”,它解决的是误分割问题,不是误检噪声问题。
% 重新统计切分后的连通域 ccSep = bwconncomp(bwSep); statsSep = regionprops(ccSep, 'Area', 'Centroid'); % 按面积下限过滤 areasSep = [statsSep.Area]; validSep = (areasSep >= 60) & (areasSep <= 5000); finalCount = sum(validSep); % 可视化分割结果,确认拆分边界是否落在两张细胞的缝隙上 rgbLabel = label2rgb(labelmatrix(ccSep), 'lines'); figure('Name', '分割验证'); imshowpair(bw, rgbLabel, 'montage');提示:面积范围不要照抄我这里写的 60 和 5000。正确做法是从原图里手动量一个正常红细胞的直径,换算成像素面积,下限取正常面积的 0.5 倍,上限取 1.8 倍左右,这样既放走小碎屑,也能容纳轻度偏大的细胞个体。
4. 三个必调参数:阈值 Sensitivity、核半径、面积边界
这套方案最大的黑匣子不在算法,而在参数。每个图的染色风格、分辨率、细胞密度不同,参数一旦定死,换个批次就翻车。这一章只讲最影响最终数量的三个参数,并给出我实际调参时用的方法。
4.1 阈值 Sensitivity:全局与自适应的分界点
先用全局阈值还是自适应阈值,取决于背景校正后的均匀程度。我的做法是:先用graythresh跑一遍,如果计数结果与人工目测相差很小,就不折腾自适应;如果局部暗角明显,再用自适应并把 Sensitivity 当作主要调节杆。Sensitivity 的本质是控制“多暗才算前景”,它每变化 0.1,计数可能差出 5% 到 15%。
| Sensitivity 区间 | 表现 | 适用情况 |
|---|---|---|
| 0.3 ~ 0.4 | 前景偏少,细胞边缘偏紧 | 染色深、背景杂质多 |
| 0.5 | 前景背景均衡 | 大多数常规染色涂片 |
| 0.6 ~ 0.7 | 前景偏多,细胞边缘膨胀 | 染色浅、细胞对比度低 |
我调这个参数不看感觉,直接把一档一档 Sensitivity 分别跑完,输出对应计数,画一条“数量-灵敏度”曲线,取曲线平缓段的中点。曲线如果一直陡峭上升,说明预处理不行,靠阈值救不回来。
4.2 背景校正核半径:太大会丢细节,太小会留背景
imopen的核半径决定背景估计的平滑程度。我见过有人把半径设成 5,结果开运算把整个细胞当成背景减掉了,二值图里只剩下细胞核和杂质。反过来,半径设成 300,光照不均恢复不了,暗角还是暗角,自适应阈值也兜不住。正确范围与细胞直径强相关,常见做法是取细胞直径的 5 到 8 倍。
% 对一系列半径做扫描,观察计数变化趋势 for r = 10:10:80 se = strel('disk', r); bg = imopen(grayImg, se); corrected = imadjust(imsubtract(grayImg, bg)); bw = imbinarize(corrected, graythresh(corrected)); cc = bwconncomp(bw); fprintf('r = %d, 连通域数量 = %d\n', r, numel(cc.PixelIdxList)); end这个循环不会花太长时间,却能把核半径的影响直接摊开。正常情况下,半径从 20 到 50 变化时数量会明显下降;超过某个值后,数量变化趋缓,这个拐点所在的半径就是背景校正开始稳定的位置。拐点之后继续增加半径,改善有限,反而增加计算量。
4.3 面积上下限与圆度下限:把误检挡在计数门外
面积过滤的作用是去掉杂质和碎片,但要承认它不解决全部问题。比如血小板和红细胞在二值图里都是圆点,面积可能只有红细胞的四分之一,面积下限设得够高就能滤掉;可如果碎片凑巧和半个红细胞差不多大,面积就救不回来,需要圆度参与。
statsInfo = regionprops(ccSep, 'Area', 'Circularity', 'Centroid'); validIdx = arrayfun(@(s) s.Area >= 60 && s.Area <= 5000 && ... s.Circularity >= 0.75, statsInfo); countFiltered = sum(validIdx);| 过滤维度 | 典型范围 | 说明 |
|---|---|---|
| 面积 | 60 ~ 5000 像素 | 按实测细胞直径换算,不要直接照抄 |
| 圆度 | 0.75 ~ 1.0 | 线状杂质、破裂细胞残片圆度很低 |
| 偏心率 | < 0.85 | 过滤明显细长的粘连残骸 |
有一点容易被忽略:过度分割切出来的半个细胞,圆度通常不低,因为它还是近似圆形。所以在验证过度分割时,更重要的是看面积分布有没有在正常细胞面积一半附近堆积,而不是盯着圆度。
5. 避坑清单:红细胞计数最容易翻车的 4 个地方
把算法跑通不难,难的是换一张图不翻车。下面四条是我在调参和帮别人排查时最常遇到的问题,按现象、原因、解决的顺序写清楚,可以直接当排查手册用。
5.1 整张图数成一个连通域:背景校正在作祟
现象:二值化后大面积区域连成一片,几乎找不到独立细胞边界,连通域数量低到离谱。
原因:染色背景本身偏暗,或者背景估计的核半径太小,开运算没有把细胞纹理从背景里分离干净。减掉一个还带着细胞的背景估计后,暗区残留把相邻细胞桥接起来。
解决:先把圆盘半径提到细胞直径的 5 倍以上,再看correctedImg的直方图是否变成明显的双峰。如果还是连片,检查原图是否存在对焦模糊,模糊才是这类现象最常见的根本原因。
5.2 两个细胞黏在一起只算了一个:分水岭没有起到拆分作用
现象:肉眼能明显看出多个重叠细胞,但计数比人工数少很多,分水岭代码跑了等于没跑。
原因:二值图上重叠细胞之间没有凹痕时,距离变换在边界处没有形成足够的低谷,分水岭找不到分割位置。圆形细胞完全相切时,它们的轮廓在接触点是一个平滑接缝,需要人为制造凹进去的标记。
解决:改用标记控制的分水岭。先提取距离变换上的局部极大值作为“每个细胞中心”的标记,再用imimposemin把这些标记强制植入距离图,最后才做分水岭,这样重叠边界大概率会被切开。
D = bwdist(~bw); % 提取距离图中的显著局部极大值作为内部标记 marker = imextendedmax(D, 1); % 将距离图反转并强制极小值落在标记位置 Dc = imimposemin(-D, marker); % 分水岭分割 L = watershed(Dc); bwSep = bw; bwSep(L == 0) = 0;使用注意:imextendedmax的第二个参数控制“显著”程度,1 表示只保留比周围高 1 个灰度级的峰。如果设得太大,有些正常细胞没有标记,不会被切开;设得太小,一个细胞里可能出现多个标记,导致过度分割。
5.3 计数虚高:把染色杂质和碎屑也算进来了
现象:自动计数比人工数多出近一倍,且多出来的区域都是一些小而不规则的点。
原因:染色沉淀、血小板、灰尘在二值化后同样变成白色前景。血小板直径只有红细胞的四分之一左右,圆度不低,和正常细胞同时存在时很难靠圆度区分。
解决:提升面积下限是最直接的手段,把下限设到正常细胞面积的 0.5 倍,滤掉血小板那一类小圆点。如果碎屑集中在图像边缘,用imclearborder(bw)直接擦掉贴边连通域,代价是边缘真细胞也会丢,批量处理时选择保守方案更安全。
5.4 一批图像换了个染色风格,数字马上飘了
现象:第一张图调好的参数结果很准,换到第二批染色明显偏深的图,计数突然偏高或偏低,偏差能到 30%。
原因:全局阈值完全依赖灰度统计分布,染色风格一变,前景和背景的对比度区间整体平移,原阈值不再适配。硬编码面积范围同理,不同批次细胞铺展程度不同,像素面积分布也会变。
解决:把预处理改成自适应阈值,净值不依赖整张图的全局统计;再用直方图匹配(imhistmatch)把整批图像对齐到一张参考图,染色差异能被压到很小。最重要的习惯是把所有参数封装成结构体传入函数,而不是散在脚本里反复改。
6. 用人工计数验证结果:误差评估和批量处理的最后一步
6.1 算相对误差:自动计数到底可不可信
算法调得再好,没有人工基准也说明不了问题。常见做法是选 5 到 10 张具有代表性的涂片,每张圈出几个固定视野,请同事人工数,取中位数,再把自动计数结果换算成同口径数值,计算相对误差。
manual = [102 98 105 96 110]; % 人工计数结果 auto = [98 94 109 101 104]; % 自动计数结果 relErr = abs(auto - manual) ./ manual * 100; meanErr = mean(relErr); fprintf('平均相对误差 = %.2f%%\n', meanErr);我的判断标准是:平均相对误差稳定在 5% 以内,算法可以进入批量流程;达到 10% 附近,优先怀疑重叠细胞没拆开,而不是纠结阈值。原因很简单:阈值造成的数量偏移通常是整体平移,偏差会均匀地出现在所有视野;分水岭没拆好,误差则集中在某些特定视野,往往是那个视野恰好有大量重叠细胞。
6.2 批量处理多张涂片:把脚本包装成函数
调参完成后,我会把整套流程整理成一个函数,输入图像路径和参数结构体,输出计数量和处理后的二值图,这样批量跑才可控。函数体里保留预处理、分水岭、过滤三段,参数全部走params,避免每个变量散落在工作区里。
function [count, bwSep] = countRBC(imgPath, params) % 读取图像并转灰度 raw = imread(imgPath); if size(raw, 3) == 3 gray = rgb2gray(raw); else gray = raw; end % 背景校正 se = strel('disk', params.bgRadius); corrected = imadjust(imsubtract(gray, imopen(gray, se))); % 二值化 if params.useAdaptive bw = imbinarize(corrected, 'adaptive', 'Sensitivity', params.sensitivity); else bw = imbinarize(corrected, graythresh(corrected)); end % 形态学清洗 bw = bwareaopen(bw, params.minArea); bw = imfill(bw, 'holes'); % 分水岭拆分重叠细胞 D = bwdist(~bw); L = watershed(-D); bwSep = bw; bwSep(L == 0) = 0; % 过滤并计数 stats = regionprops(bwSep, 'Area', 'Circularity'); areas = [stats.Area]; circ = [stats.Circularity]; count = sum(areas >= params.minArea & ... areas <= params.maxArea & ... circ >= params.circThresh); end批量运行时,用dir收集文件列表,循环调用函数并把结果存成表格,同时保存每张图像的分割结果。这样出问题还能回去查是哪一张图、哪一步参数引起的。
fileList = dir(fullfile('涂片文件夹', '*.tif')); results = zeros(length(fileList), 1); for i = 1:length(fileList) imgPath = fullfile(fileList(i).folder, fileList(i).name); results(i) = countRBC(imgPath, params); end批量流程稳定后,我还会把自动结果和人工结果画在一张散点图上,观察斜率是否接近 1。这个习惯救过我很多次:只看平均误差,会被一个偏大一个偏小的结果掩盖系统偏差。现在回想起来,这套流程里我翻车最多的反而是最“简单”的环节——没先做人工基准就直接调阈值。先用几组人工数把算法拴住,再去调参数,比我当年边改脚本边猜结果的状态可健康多了,这份后悔药我吃了不少才总结出来。希望这篇笔记能帮到你,也让你在提交结果时能讲清楚“我怎么做”,而不是说“默认参数就是这样”。
本文还有配套的精品资源,点击获取