OpenCV+Qt细胞计数工具:连通域统计与分水岭拆粘连
简介:面向生物医学图像分析与计算机视觉入门者的OpenCV细胞计数项目源码包,围绕细胞图像自动识别与数量统计展开,适合希望将图像处理算法与Qt图形界面结合实践的学习者。压缩包共41个文件、约7.6MB,包含C++源码与头文件、Qt界面文件、qmake工程配置、动态链接库、可执行程序以及多张效果图,覆盖从工程编译到结果展示的主要环节。已有1723人学习,说明该案例在课程设计、算法练习和小型检测工具实际项目开发中具备较好的参考价值。内容以二值化、膨胀腐蚀、轮廓检测和连通域分析为主线,配合Qt完成图像加载、显示与计数交互,读者可据此梳理细胞计数流程,理解阈值与形态学参数对计数结果的影响,并参考完整工程结构进行二次开发、调试或功能扩展。
1. 培养皿摆上桌,计数环节的三个真问题就藏不住了
培养皿摆上桌,第一次做细胞计数的同学大多经历过同一件事:对着显微照片用记号笔在纸上点,点到两百个开始怀疑人生,隔天再点一遍,两个数能差出十几个。OpenCV 细胞计数这个题目,网上能跑的 demo 很多,能交出去的少,差别不在算法多花哨,而在三件事:数得准不准、换一张图还准不准、参数由谁来调。这套基于 OpenCV 连通域统计加 Qt 桌面的方案,就是把这三点做成一个能演示、能复现、能交接的小工具:图像进来,前景掩膜出来,连通域标完,面积过滤一遍,粘连的用分水岭拆开,结果叠回原图并导出表格。它适合要交毕设或课设的学生,也适合需要给实验室做内部计数小工具的从业者。
2. 总体架构设计:三层结构与四个模块的职责划分
先把结构定下来,再写算法,这是我在几个类似项目里换来的顺序。反过来做的结果通常是:算法原型在 notebook 里跑得挺漂亮,搬到界面上之后,改一个阈值要重新编译,调参靠改代码里的数字,最后没人敢动。分层不是为了好看,是为了让「调参」这件事从程序员手里转移到使用者手里。
2.1 分层边界:界面层不碰像素,算法层不碰控件
整套东西划成三层。界面层负责文件选择、参数面板、结果列表和图像显示,只认识 Qt 的控件和自定义结构体;算法层是一组纯函数,输入cv::Mat加参数结构体,输出结果结构体加若干张中间图,不引用任何 Qt 头文件;中间靠一层调度连接,负责把任务丢到线程池、把结果用信号发回主线程。
这条边界守住了有两个直接好处。第一,算法层可以脱离界面单独跑批处理脚本,验收时拿一批图跑一遍就能出报告,不用手点。第二,界面改版、换控件、加个暗色主题,都不会碰坏计数逻辑。我一般会在工程里把算法目录编成一个静态库,界面工程只链接它,物理上断绝反向依赖——人都是懒的,只要允许#include到界面头文件,早晚会有人图省事直接在算法里弹一个QMessageBox。
参数结构体是整个系统里唯一被两层共享的东西,它的字段设计决定了参数面板长什么样,也决定了配置文件长什么样。下面这份是我常用的字段集合:
// 计数参数:界面改动只影响这个结构体,算法层不感知任何控件 struct CountParams { double blurSigma = 1.5; // 高斯滤波 sigma,只压噪声,别指望它去粘连 int blockSize = 31; // 自适应阈值窗口,必须为奇数,约等于细胞直径的 2~3 倍 double cOffset = -8.0; // 阈值偏移,负值让前景更"胖",正值更"瘦" bool invertFore = true; // 明场细胞比背景暗,取反后前景为白 int openKernel = 3; // 开运算核,去毛刺与孤立噪点 int closeKernel = 3; // 闭运算核,补细胞内小孔 int minArea = 40; // 面积下限,滤掉碎片与灰尘 int maxArea = 20000; // 面积上限,超过视为成团区域,交给分水岭 bool useWatershed = true; // 粘连分离总开关 double splitRatio = 0.42; // 距离变换峰值系数,越小拆得越狠 double pixelPerMm = 0.0; // 标定比例,0 表示未标定,只输出个数 };字段里没有一个是「神秘数字」。blockSize和细胞直径挂钩,minArea和噪声颗粒大小挂钩,splitRatio和粘连程度挂钩,每个都能在界面上用一句提示文字解释清楚。答辩时被问「这个 0.42 怎么来的」,能答出「距离变换最大值乘以它作为种子阈值,调低会多拆、调高会漏拆」,比答「试出来的」体面得多。
2.2 四个模块与两条数据通道
模块按职责切四块。预处理模块负责灰度化、滤波、阈值、形态学,产出一张二值前景图;统计模块负责连通域标记、面积过滤、质心与等效直径计算;分离模块负责距离变换和分水岭,只在勾选了粘连分离时介入;渲染与导出模块负责伪彩上色、结果叠图、CSV 输出。
数据通道有两条,这点容易被忽略。主通道是「原图 → 前景 → 标记图 → 结果结构体」,只走数值。副通道是「中间图缓存」,预处理结果、距离变换热力图、分水岭种子图这些都要保留一份,供界面切换查看。很多同学的实现里中间图算完就扔,出了问题只能靠打印面积值猜是哪一步坏了,排查效率极低。我会固定留三层:bgr(原图)、mask(二值前景)、labels(标记图),界面上做三个页签随时切换,这个习惯救过我很多次。
结果结构体则尽量扁平,让导出和界面表格都好写:
struct CellItem { int label; // 连通域标号,用于回溯标记图 cv::Rect box; // 外接矩形,画框与裁剪都用它 cv::Point2d center; // 质心,亚像素精度 double area; // 像素面积 double eqDiameter; // 等效圆直径 = 2*sqrt(area/pi) bool fromSplit; // 是否由分水岭拆分产生,便于统计"拆出来多少个" }; struct CountResult { int total = 0; // 最终计数 int rawComponents = 0; // 过滤前的连通域个数 int filteredOut = 0; // 被面积条件剔除的个数 int splitProduced = 0; // 分水岭额外拆出的个数 std::vector<CellItem> items; double elapsedMs = 0.0; // 算法耗时,验收要用 };rawComponents、filteredOut、splitProduced这三个计数字段看着冗余,作用是把「数出来 187 个」变成可解释的账:过滤前 214 个,面积条件剔掉 29 个,分水岭多拆出 2 个,最终 187。演示时被问「为什么这张图比人工数得多」,直接翻这三个数就能定位是噪点没滤干净还是拆过头了。
2.3 一次点击的完整时序
点「开始计数」之后发生的事,固定成一条链:界面收集参数 → 校验参数(blockSize转奇数、minArea < maxArea)→ 提交到线程池 → 工作线程读图、调算法、拿结果 → 通过信号把结果和中间图发回主线程 → 主线程更新表格、刷新显示、写日志。整条链里主线程不碰像素。
// 界面层只做三件事:收参数、派任务、渲染结果 void MainWindow::onRunClicked() { CountParams p = ui->paramPanel->toParams(); const QString path = m_currentPath; if (path.isEmpty()) { log("先选一张图"); return; } ui->btnRun->setEnabled(false); // 防连点,跑完再放开 const auto t0 = std::chrono::steady_clock::now(); // 耗时任务进线程池,主线程不参与任何像素运算 QtConcurrent::run(&m_pool, [this, p, path, t0]() { cv::Mat bgr = cv::imread(path.toLocal8Bit().toStdString(), cv::IMREAD_COLOR); if (bgr.empty()) { emit countFailed(QStringLiteral("读图失败")); return; } cv::Mat mask, labels; cv::Mat fore = Counter::buildForeground(bgr, p, mask); // 预处理 CountResult r = Counter::count(fore, p, labels); // 统计 + 分离 r.elapsedMs = std::chrono::duration<double, std::milli>( std::chrono::steady_clock::now() - t0).count(); emit countFinished(r, bgr, mask, labels); // 中间图一并回传 }); }几个细节值得说。QtConcurrent::run的返回值被忽略是正常的,真正的结果走信号;跨线程信号默认是队列连接,Qt 会自动把参数排队投递到主线程的事件循环,所以槽函数里可以放心操作控件。countFinished的四个参数按值传递,cv::Mat的拷贝是引用计数浅拷贝,代价很低,但要保证算法层之后不再改写这几张图,否则会踩到共享缓冲区的坑,这一点我后面会展开。ui->btnRun->setEnabled(false)这一行是血泪经验,连续点三次会往线程池里塞三个任务,界面上却只显示最后一个结果,排查起来像玄学。
3. 预处理与二值化:把明场照片变成可分离的前景掩膜
计数准不准,八成看二值化。这一步的目标很朴素:细胞是白的,背景是黑的,中间没有灰色地带。实际情况是照明中间亮四角暗、细胞边缘有晕圈、培养皿底部有划痕、还有一堆比细胞小一个数量级的杂质。下面这套顺序是在明场细胞图上磨出来的,换成荧光图或颗粒图,把取反开关一关,思路照样能用。
3.1 灰度化与滤波:核大小为什么不能跟着感觉走
灰度化用COLOR_BGR2GRAY,权重按人眼亮度感知分配,绿色通道占比最高。对细胞这类常被染成紫蓝色的目标,这套权重不算最优,但够用,而且可解释。真遇到对比度极差的图,我会在灰度化之前先做一次通道选择,把对比度最高的单通道抽出来当灰度图,比在 RGB 上瞎调阈值有效得多。
滤波只做一件事:压住传感器噪声和 JPEG 压缩块。GaussianBlur的核大小取 5×5、sigma 取 1.5 左右,是这类图像的经验区间。核再大就会开始吃掉细胞边缘,尤其是直径只有十几个像素的小细胞,模糊之后面积缩水,面积过滤的阈值就得跟着改,一改就连锁反应。这里有个判断标准:滤波后的图像放大看,细胞轮廓还清楚,背景的颗粒感消失了,就对了;如果细胞看起来像隔了层毛玻璃,就是过头了。
顺带说一个反直觉的点。不少人想靠大核中值滤波一步到位把粘连打开,想法可以理解,效果通常很糟:中值滤波会把两个细胞之间的窄桥抹掉,但同时把细胞本身也啃掉一圈,面积统计全乱。粘连要靠后面的分水岭处理,不在滤波阶段解决,这个分工要提前想明白。
3.2 阈值分割:全局 Otsu 与自适应阈值的分工
阈值这块最容易翻车。全局 Otsu 在照明均匀、背景干净的图上表现极稳,一行代码搞定,而且阈值是自动算的,答辩时也好解释。但培养皿照片十有八九照明不均,正中间一片细胞被正确分割,四个角整片变成背景或者整片变成前景,一眼就能看出来。这时候换自适应阈值。
cv::Mat buildForeground(const cv::Mat& bgr, const CountParams& p, cv::Mat& maskOut) { cv::Mat gray; if (bgr.channels() == 3) cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY); else gray = bgr.clone(); // 1) 轻滤波:只为压噪声,核不超过 5x5,否则细胞边缘会被吃掉 cv::GaussianBlur(gray, gray, cv::Size(5, 5), p.blurSigma); // 2) 阈值:blockSize 取细胞直径的 2~3 倍并强制为奇数 int bs = p.blockSize | 1; // 位或保证奇数 cv::Mat bin; cv::adaptiveThreshold(gray, bin, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, p.invertFore ? cv::THRESH_BINARY_INV : cv::THRESH_BINARY, bs, p.cOffset); // 3) 形态学修边:先开后闭,核取 3x3 起步 cv::Mat kOpen = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(p.openKernel, p.openKernel)); cv::Mat kClose = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(p.closeKernel, p.closeKernel)); cv::morphologyEx(bin, bin, cv::MORPH_OPEN, kOpen, cv::Point(-1, -1), 1); cv::morphologyEx(bin, bin, cv::MORPH_CLOSE, kClose, cv::Point(-1, -1), 1); maskOut = bin.clone(); // 单独留一份前景图给界面切换 return bin; }THRESH_BINARY_INV的选择依据是成像方式:明场透射光下细胞吸收光线,比背景暗,取反之后细胞变成 255 的前景,后面所有统计都按「前景为白」来写。荧光图恰好相反,亮的是目标,所以留了invertFore开关,不要写死。
blockSize是这段里最容易设错的参数。它决定局部阈值窗口多大,窗口小于细胞直径时,细胞内部会被判成背景,结果是一个细胞被掏成甜甜圈,面积统计直接崩掉;窗口远大于细胞直径时,自适应退化成近似全局阈值,照明不均又治不了。稳妥的取法是细胞直径的 2 到 3 倍,窗口内至少要完整装下一颗细胞。cOffset是常数偏移,负值让更多像素被判成前景(前景变胖),正值让前景变瘦,通常从 -10 到 +10 之间调,配合面积统计看总数变化。
3.3 形态学修边与背景填充
开运算先做,作用是去掉毛刺和孤立噪点。细胞边缘常有一圈不规则的突起,或者图像里散着几个 3 到 5 像素的灰尘点,开放运算用一个比它们大的结构元腐蚀再膨胀,小东西直接消失,细胞主体基本不变。结构元用椭圆比方形贴合细胞轮廓,尺寸从 3×3 起步,放大到 5×5 就要回来检查一遍细胞有没有被削边。
闭运算随后做,补上细胞内部的小孔。染色不均、细胞核透亮、压缩噪声都会在前景上留下若干黑点,不补的话一个细胞可能被标记成两三个连通域,计数虚高。核同样从 3×3 起,开闭顺序不能反:反了之后先闭会把噪点和细胞连成一片,再开也拆不开。
这两步之后,我会在界面上直接把前景图铺出来给使用者看,并且允许手动在图上框选一块「确认这里是背景」的区域,程序实时统计该区域的前景像素占比。这个占比超过 2% 就提示「背景不干净,建议复查阈值」——一个很小的交互,能把「为什么数出来这么多」的追问挡在演示之前。半自动工具的说服力,很多时候就来自这种边界自检。
4. 连通域统计与粘连分离:从标记图到一颗颗细胞的坐标
前景图做成什么样,后面就只能拿到什么样的结果,所以这一章其实是「在前景已经确定的前提下,怎么把标记结果翻译成可信的计数」。核心 API 只有一个,剩下的都是围绕它的过滤与补救。
4.1 connectedComponentsWithStats 的五个统计量怎么读
connectedComponentsWithStats一次性给出三样东西:标记图、统计矩阵、质心矩阵。统计矩阵每行对应一个连通域,五列分别是最小外接矩形的 x、y、宽、高和像素面积;第 0 行永远是背景,所以遍历从 1 开始。相比findContours加contourArea,这条路省掉了一堆轮廓层级判断,速度快、代码短,还能直接拿到质心。
cv::Mat labels, stats, centroids; // 8 邻接:细胞是不规则团块,4 邻接会把斜对角相连的部分判成两个 int n = cv::connectedComponentsWithStats(mask, labels, stats, centroids, 8, CV_32S); CountResult res; res.rawComponents = n - 1; for (int i = 1; i < n; ++i) { const int area = stats.at<int>(i, cv::CC_STAT_AREA); if (area < p.minArea || area > p.maxArea) { ++res.filteredOut; continue; } CellItem it; it.label = i; it.area = area; it.box = cv::Rect(stats.at<int>(i, cv::CC_STAT_LEFT), stats.at<int>(i, cv::CC_STAT_TOP), stats.at<int>(i, cv::CC_STAT_WIDTH), stats.at<int>(i, cv::CC_STAT_HEIGHT)); it.center = { centroids.at<double>(i, 0), centroids.at<double>(i, 1) }; it.eqDiameter = 2.0 * std::sqrt(area / CV_PI); // 等效圆直径 it.fromSplit = false; res.items.push_back(it); } res.total = static_cast<int>(res.items.size());邻接方式选 8 邻接,原因是细胞轮廓不规则,4 邻接会把细长连接处判成断开,一个细胞变两个,这类错误在镜检图上出现频率不低,而且肉眼很难从结果里看出来。质心矩阵是 double 类型,按亚像素精度给出,画标记点、算最近邻距离都能直接用,不用自己累加坐标求平均。
有一点要留意:cv::Mat的at访问不做边界检查,stats的行数必须严格用n控制。另外,标记图labels的类型是CV_32S不是CV_8U,连通域超过 255 个时用 8 位会溢出——一张高倍视野的细胞图轻松过千,这个坑我踩过一次,画面上的颜色开始循环,计数却少了一大截。
4.2 面积与等效直径的双重过滤
面积上下限是计数结果的第一道闸门。下限滤掉碎片、灰尘、阈值残留的孤点,上限把「明显是好几颗粘在一起」的区域挑出来交给分离模块。阈值怎么定,不靠猜,靠直方图:把全图所有连通域的面积排序,取中位数当典型细胞面积,下限设成中位数的 0.35 倍,上限设成 2.5 倍,再手动微调。
等效直径是面积的一个变换,d = 2√(area/π),好处是量纲直观,能直接和显微镜标尺对上。比如标定下来 1 毫米对应 400 像素,典型细胞直径 12 微米,那等效直径应该落在 4 到 5 像素附近,凡是对不上的直接可疑。我在参数面板上会同时显示「面积区间」和「等效直径区间」,让使用者用自己熟悉的单位思考,而不是对着像素数发愣。
上限的设定要克制。设得太低,本来是一颗大细胞的(比如分裂期体积偏大)也被送进分离流程,平白多拆出一个。稳妥的做法是上限取中位数的 2.5 到 3 倍,超过这个数才认为确实粘连;同时把被上限拦下的区域在图上用醒目的框标出来,让使用者亲眼确认这些确实是团块。
4.3 距离变换加分水岭拆粘连
粘连是细胞计数绕不开的坎。两个细胞挨在一起,二值图上连成一块,连通域只会给出一个计数,人工数却是两个。处理办法是距离变换:对前景里每个像素,算它到最近背景像素的距离,细胞中心距离最大,边缘趋近于零。两个细胞粘连处的距离值会形成一个明显的凹陷,把凹陷以上的区域当成种子,就有机会把团块拆开。
// 仅对面积超限的团块做分离,逐块处理更可控 cv::Mat blobMask = (labels == it.label); // 取当前团块的二值掩膜 cv::Mat dist; cv::distanceTransform(blobMask, dist, cv::DIST_L2, 5); double maxVal = 0; cv::minMaxLoc(dist, nullptr, &maxVal); if (maxVal < 2.0) return; // 太薄,拆了也没意义 cv::Mat seeds = dist > (maxVal * p.splitRatio); // 峰值系数控制拆分力度 seeds.convertTo(seeds, CV_8U, 255); // 转成 0/255 的掩膜 cv::Mat seedLabels; int seedCount = cv::connectedComponents(seeds, seedLabels, 8, CV_32S) - 1; if (seedCount <= 1) return; // 只有一个峰,不拆 // 分水岭:以种子为标记,在原团块上划界 cv::Mat markers = seedLabels + 1; // 背景从 0 挪到 1,避开歧义 markers.convertTo(markers, CV_32S); cv::watershed(blobMask, markers); // 传入三通道图效果更好,这里用单通道splitRatio是这套流程唯一需要手感参数。取 0.5 意味着只认可距离峰值一半以上的区域当种子,保守,拆得少;取 0.3 激进,粘连轻的也能拆开,但一颗形状不规则的单个细胞可能被拆成两半。我的默认值定在 0.42,配合界面上的滑块实时预览,使用者拖到「拆出来的个数和肉眼数对得上」就停手。
分水岭有个特性必须提前说明:它会把整幅图的所有像素都分配出去,包括背景。所以markers里背景标号要先占好位置,处理完再回到标记图里,只保留属于当前团块的标号。另外watershed的结果里,区域之间的边界线会被标成 -1,统计面积时要把这些像素排除,否则每个拆出来的细胞会少算一两行像素,面积普遍偏小。
拆完之后要做一次合理性检查:拆出来的每块面积不能低于原团块面积的 15%,否则说明拆碎了。检查不过就回退,保留原团块并标记为「疑似粘连,需人工复核」,比硬拆出五个碎片诚实得多。这一类复核项在结果表里单独列一列,答辩时反而加分。
4.4 上色、叠加与结果导出
渲染这一步看着是面子工程,实际影响演示效果。做法是给每个连通域分配一个颜色,把标记图按标号映射成伪彩,再和原图做加权叠加,透明度控制在 0.35 左右,既看清细胞形态,又能看到标记区域。调色板用固定种子生成,保证同一张图跑两次颜色一致,前后对比时才不会看花眼。
// 固定种子生成调色板:同图两次运行颜色一致,便于截图对比 cv::RNG rng(0x5EED); std::vector<cv::Vec3b> palette(1024); for (auto& c : palette) c = cv::Vec3b(rng.uniform(60, 255), rng.uniform(60, 255), rng.uniform(60, 255)); cv::Mat canvas = bgr.clone(); for (const auto& it : res.items) { // 半透明填充:按标号取色,255 处为前景 cv::Mat m = (labels == it.label); cv::Mat color(m.size(), CV_8UC3, palette[it.label % palette.size()]); cv::Mat blended; cv::addWeighted(canvas, 0.65, color, 0.35, 0.0, blended); blended.copyTo(canvas, m); // 只在掩膜内覆盖 // 质心十字与序号,字号随图像尺寸缩放 cv::drawMarker(canvas, it.center, cv::Scalar(0, 0, 255), cv::MARKER_CROSS, 8, 1); cv::putText(canvas, std::to_string(it.label), it.center + cv::Point2d(4, -4), cv::FONT_HERSHEY_SIMPLEX, 0.35, cv::Scalar(255, 255, 255), 1, cv::LINE_AA); }导出部分我一般给两份:一份逐细胞的 CSV,字段是标号、质心 x/y、面积、等效直径、是否拆分得到、是否需复核;一份汇总,包含总数、过滤前个数、剔除数、拆分产出、算法耗时、参数快照。参数快照这一列很多人省掉,等到一个月后回看结果,没人记得当时用的什么阈值,等于失去了复现能力。CSV 用 UTF-8 加 BOM 写出,用表格软件打开中文不乱码,这个小细节在演示现场很加分。
5. 常见问题与排查:粘连、漂移、卡顿、花屏这四类翻车现场
下面五条都是我在实际调试里反复遇到的,按「现象 → 原因 → 解决」写清楚,遇到问题可以按顺序对号入座。
5.1 两个细胞被数成一个
现象很直白:图上肉眼能分出两个紧挨的细胞,结果里只有一个框,或者干脆一个大框把两个都圈住。原因是二值化之后两者的边缘连在一起,连通域把它们判成同一块,面积超过了下限但没超过上限,所以连「疑似粘连」都没被标出来。
解决办法是把上限调低一档,让更多团块进入分水岭流程,同时把splitRatio从 0.42 往 0.35 走一点试试。如果拆完还是合并,可以改用腐蚀加开运算先人为制造一道缝,再膨胀回去,代价是轮廓会变形,面积统计需要修正。判断拆得对不对,不看总数,看被拆的团块在图上画的种子点是不是正好落在两颗细胞的中心,这一点用中间图页签一看就明白。
5.2 换一张图参数就全崩
现象是:调好的参数在第一张图上堪称完美,换第二张图总数直接翻倍或者掉一半。原因通常是参数过拟合——所有阈值都是对着那一张图试出来的,块大小、面积区间、拆分系数全都卡在特定照明和特定密度的组合上。
解决办法有两步。第一步把参数全部外置成配置文件,界面上的改动实时写回,不同成像条件各存一套,命名带上物镜倍数和染色方式。第二步做批量回放:选一个文件夹,把里面所有图按同一套参数跑一遍,输出每张图的计数和耗时,哪张图跑飞了一眼就能看出来。我一般要求自己手里的参数至少在 5 张不同批次的图上都稳定,才敢说是默认值。
5.3 点一下按钮界面就白了
现象是点击计数后窗口短暂无响应,图越大白得越久,任务管理器里进程占满一个核。原因很明确:imread加整个算法链全跑在主线程,Qt 的事件循环被卡住,控件没法重绘。
解决办法是把整条链丢进线程池,任务结束用信号回主线程更新 UI,这一点在第 2 章已经给过代码。补充两个细节:第一,跑任务期间要禁用按钮并显示一个忙碌提示,让使用者知道程序还活着;第二,跨线程传递cv::Mat时,工作线程里不要再改这几张图,需要标注就在工作线程里画完再发出来,否则会和主线程的绘制抢同一块内存。
5.4 显示区颜色发花、拖窗口就变形
现象是图像显示出来颜色不对,红蓝互换,或者缩放、拖动窗口之后画面出现错位的斜条纹。原因有两个,一是QImage不接管cv::Mat的内存,Mat一出作用域,画面就变成随机数据;二是没指定每行的字节跨度,OpenCV 的Mat每行有对齐填充,Qt 默认按宽度乘通道数算,两者对不上就错行。
QImage matToQImage(const cv::Mat& mat) { if (mat.type() == CV_8UC3) { cv::Mat rgb; cv::cvtColor(mat, rgb, cv::COLOR_BGR2RGB); // OpenCV 是 BGR,Qt 要 RGB // copy() 深拷贝:QImage 不持有 Mat 内存,不拷贝就会出现花屏 return QImage(rgb.data, rgb.cols, rgb.rows, static_cast<int>(rgb.step), // 用 Mat 的步长,别自己算 QImage::Format_RGB888).copy(); } if (mat.type() == CV_8UC1) { return QImage(mat.data, mat.cols, mat.rows, static_cast<int>(mat.step), QImage::Format_Grayscale8).copy(); } return {}; }step这个参数是关键,它表示一行实际占多少字节,包含填充;用cols * channels替代它,在宽度不是 4 的倍数的图上必然错位。四通道的CV_8UC4要换成Format_ARGB32并且注意 OpenCV 的通道顺序是 BGRA,需要先转一次。
5.5 同一张图连点两次结果对不上
这是最让人心里没底的一类问题。同一个文件、同一套参数,两次运行总数差一两个。原因往往藏在排序和随机数里:伪彩用时间做种子会导致每帧颜色不同,容易被误认为标记不同;连通域标号在个别平台上的顺序不保证稳定;浮点阈值正好卡在某个像素的灰度值上,微小差异引起翻转。
解决三件事:调色板固定种子,标号按质心坐标(先 y 后 x)重排一遍,阈值边界像素做一次形态学清理。做完之后同一张图连跑十次,结果必须完全一致。这个自检我写成了按钮,一键跑十遍,把十次的总数和 MD5 一起打出来,验收时直接展示。
6. 验收与答辩:三个可量化的指标和一段五分钟演示
验收不要用「看着挺准」这种描述。我会准备三个指标,每个都能现场跑出来:重复性,同一张图连跑 10 次,计数结果完全一致,中间图哈希一致;准确率,挑 3 张不同密度的图,人工用网格法逐格点数作为参照,误差控制在 5% 以内,稀疏图要求更严;效率,1920×1080 的图单帧处理在 300 毫秒以内,界面全程可交互。
| 验收项 | 判定方式 | 合格线 | 说明 |
|---|---|---|---|
| 重复性 | 同一图连跑 10 次比对总数与标记图哈希 | 完全一致 | 排除随机种子与排序影响 |
| 准确率 | 与人工网格点数对比,取 3 张不同密度图 | 相对误差 ≤ 5% | 稀疏图放宽到 ≤ 3% |
| 粘连处理 | 构造 10 组已知双细胞粘连样例 | 正确拆分 ≥ 8 组 | 拆错要计入失败 |
| 处理效率 | 1080p 图计时,取 10 次均值 | ≤ 300 ms | 不含读盘与绘制 |
| 稳定性 | 连续切换 20 张图不崩溃、不内存增长 | 无异常 | 观察进程内存曲线 |
演示脚本我一般固定成四步,五分钟走完。第一步放一张干净、稀疏的图,展示基础流程和参数含义,让评委建立直觉。第二步放一张粘连严重的图,先关掉分水岭看合并结果,再打开看拆分效果,这一正一反的对比最有说服力。第三步放一张照明不均的图,把全局 Otsu 和自适应阈值的效果并排展示,说明选型理由。第四步现场改参数,把面积下限调大,让总数实时变化,证明参数是真在起作用,不是写死的。
答辩提问里出现频率最高的是「你这套东西换一批图还能用吗」,标准答法是当场打开批量回放,选一个文件夹跑完,把每张图的计数、耗时和告警列出来,用数据回答。其次是「粘连怎么判断拆得对」,答法是展示种子图和拆分前后的面积分布,说明拆分后有面积下限校验,拆碎的会回退并标为待复核。
从那以后我每次交付这类计数工具,都会强制走一遍三个指标加一段四步演示,宁可少做一个功能,也要保证别人接手时能在十分钟内复现我的结果。这套工程的代码结构、默认参数表和验收脚本我整理在了一起,需要参考的可以按页面提供的方式取用,拿去改成自己课题的版本最省事。希望帮到你。
本文还有配套的精品资源,点击获取