简介:面向生物医学图像处理学习者与计算机视觉初学者的一套细胞计数实战工程,围绕细胞图像的二值化、膨胀腐蚀、轮廓提取与连通域统计展开,并配套图形界面完成图像加载、显示及计数结果查看。压缩包共41个文件、约7.6MB,包含源代码、头文件、界面文件、工程配置、可执行程序、运行库及效果图,便于直接运行并对照源码理解处理流程。已有1723人学习,适合作为课程设计、毕业设计或图像处理入门项目的参考。读者可从中获得完整工程结构、细胞计数实现思路、界面与算法结合方式以及依赖库配置线索,也能借助效果图判断二值化、形态学处理与轮廓统计各步骤的预期输出,对噪声抑制、边界增强和相邻细胞区分等常见难点形成直观认识,减少环境搭建与调试中的摸索成本。
1. 一张产线照片背后的 137 个目标:OpenCV 连通域计数能解决什么
质检工位上最容易出问题的一环,往往是人眼。师傅盯着传送带数袋装物料,一小时两千件,数到下午就容易出错。这个 OpenCV + Qt 的小工具就是为这件事做的:把工业相机或手机拍的图拖进窗口,拖两个滑块调阈值,右边立刻给出总数,并把每个目标用编号圈出来。同一套连通域流程,换成显微镜下的细胞图、电镜下的颗粒图,照样能数,只是预处理参数得重调。它适合两类人:手上有重复计数任务、想做个桌面工具自己用的开发者;以及想找一个结构干净、能直接改成自己检测器的 OpenCV 工程的人。
2. 技术栈与工程骨架:Qt Widgets 处理界面,OpenCV 只负责计算
2.1 为什么是 C++ 的 Qt + OpenCV,而不是网页或脚本
这类工具的需求很朴素:本地跑、不联网、拖进去就能看结果、能批量跑一个文件夹。选 Qt Widgets 而不是 Web 前端,核心原因是图像链路短。cv::Mat到QImage的转换在进程内完成,不用序列化、不用起服务,一张 4000×3000 的图从读取到标注回显能压在一百多毫秒内。MFC 也能做,但布局代码写起来太费劲,后面加参数面板会很难受。
算法侧只用 OpenCV 的core、imgproc、imgcodecs三个模块,不依赖contrib。分水岭在imgproc里,connectedComponentsWithStats也在imgproc里,这意味着编译出来的可执行文件不带额外扩展库,拷到产线机器上不用配环境。C++17 标准,OpenCV 4.x,Qt 5.15 或 Qt 6 都能编译,QImage::Format_BGR888这个枚举是 Qt 5.14 之后才有的,用老版本 Qt 的话得改成先cvtColor到 RGB 再构造。
2.2 目录结构与模块职责
工程按“界面不碰算法、算法不碰 Qt”这条线切,初学者最容易犯的错是把阈值处理和按钮点击写在一个槽函数里,加第二个功能时就得推倒重写。
CountingLab/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── ui/ │ │ ├── MainWindow.h / .cpp # 组装界面、串信号槽、管线程 │ │ ├── ImageView.h / .cpp # QGraphicsView 缩放拖动、画标注 │ │ └── ParamPanel.h / .cpp # 参数控件与默认值 │ ├── core/ │ │ ├── CountPipeline.h / .cpp # 总调度:预处理→分割→过滤→标注 │ │ ├── Preprocess.h / .cpp # 灰度、模糊、自适应阈值 │ │ ├── Segment.h / .cpp # 形态学清理、距离变换、分水岭 │ │ └── BlobFilter.h / .cpp # 连通域统计与面积/长宽比过滤 │ └── io/ │ ├── ImageLoader.h / .cpp # 中文路径安全的图像读取 │ └── ResultExporter.h / .cpp # 导出 CSV 与标注图 └── resources/ └── icons.qrc| 模块 | 输入 | 输出 | 是否依赖 Qt |
|---|---|---|---|
Preprocess | 原始cv::Mat | 单通道二值图 | 否 |
Segment | 二值图 | 切开粘连后的掩膜 | 否 |
BlobFilter | 掩膜 | 数量、包围盒、质心 | 否 |
CountPipeline | cv::Mat+ 参数结构体 | CountResult | 否 |
MainWindow | 用户操作 | 界面状态 | 是 |
core整个目录零 Qt 依赖,好处是算法可以单独拉出来写单元测试,也可以原样塞进命令行批处理工具里。我一般会把core编成一个静态库counting_core,GUI 和 CLI 各链一次。
2.3 CMake 与依赖配置
cmake_minimum_required(VERSION 3.16) project(CountingLab LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 的类必须开,否则链接期报 vtable 未定义 set(CMAKE_AUTORCC ON) find_package(OpenCV 4 REQUIRED COMPONENTS core imgproc imgcodecs) find_package(Qt5 5.15 REQUIRED COMPONENTS Widgets Concurrent) add_library(counting_core STATIC src/core/CountPipeline.cpp src/core/Preprocess.cpp src/core/Segment.cpp src/core/BlobFilter.cpp src/io/ImageLoader.cpp ) target_include_directories(counting_core PUBLIC src) target_link_libraries(counting_core PUBLIC ${OpenCV_LIBS}) add_executable(CountingLab src/main.cpp src/ui/MainWindow.cpp src/ui/ImageView.cpp src/ui/ParamPanel.cpp src/io/ResultExporter.cpp ) target_link_libraries(CountingLab PRIVATE counting_core Qt5::Widgets Qt5::Concurrent)AUTOMOC这一行是新手第一个坑:如果写在add_executable之后、或者漏了,编译能过但链接会报undefined reference to vtable for MainWindow,报错信息完全看不出跟 moc 有关。Qt5::Concurrent是为批量处理准备的,单张预览用QThread就够了,一次跑几百张时换QtConcurrent::mapped省事。
3. 算法主链路拆解:从灰度图到带编号的连通域统计
3.1 预处理:灰度、模糊、自适应阈值
预处理这三个步骤的顺序不能换。先转灰度是因为阈值函数只接受单通道;先模糊再阈值是因为先阈值会把噪点固化成黑白硬边,后面再模糊也救不回来。
// Preprocess.cpp —— 三步固定顺序,参数全部来自界面 cv::Mat Preprocess::run(const cv::Mat& src, const PreParams& p) { cv::Mat gray; if (src.channels() == 3) { cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); // 加权公式 0.299R+0.587G+0.114B } else { gray = src.clone(); // 深拷贝,避免和外部共享同一块像素内存 } cv::Mat blurred; // 核必须是奇数,3 适合小目标,5 适合有反光的表面 cv::GaussianBlur(gray, blurred, cv::Size(p.blurKernel, p.blurKernel), 0); cv::Mat binary; // 自适应阈值:blockSize 必须是 >=3 的奇数,代表局部邻域边长 // cOffset 是从局部均值里减掉的偏置,越大越保守(前景越少) cv::adaptiveThreshold(blurred, binary, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, cv::THRESH_BINARY_INV, // 目标比背景暗时用 INV p.blockSize, p.cOffset); return binary; }THRESH_BINARY_INV这个选择跟拍摄方式强相关。产线上的背光拍摄,目标是黑色剪影,用 INV 让目标变成 255;如果是显微镜明场,细胞比背景暗,也是 INV。反过来说,如果换成深色背景上的亮目标,就得去掉 INV,否则整张图计数直接归零,这是最容易被误判成“算法坏了”的情况。
参数取值的经验范围:blurKernel取 3 或 5,再大就把小目标抹平了;blockSize取图像短边的 1/20 到 1/10,2000 像素宽的图大概 101 到 201;cOffset取 5 到 15,光照不均匀时往上加。界面上把blockSize的滑块步长设成 2,代码里再兜一层奇数校正。
3.2 形态学清理与孔洞填充
阈值出来的图一定带毛刺,目标是环形或带高光的还会中间破洞。开运算削掉外凸的小尖刺,闭运算补上内部的小缺口,这两个用椭圆核比矩形核更贴合圆形目标。
// Segment.cpp —— 开运算去毛刺,闭运算补缺口,再从角点灌水填内部孔洞 cv::Mat Segment::clean(const cv::Mat& binary, int openSize, int closeSize) { cv::Mat out; cv::Mat kOpen = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(openSize, openSize)); cv::morphologyEx(binary, out, cv::MORPH_OPEN, kOpen); cv::Mat kClose = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(closeSize, closeSize)); cv::morphologyEx(out, out, cv::MORPH_CLOSE, kClose); // 填孔:从 (0,0) 开始把外部背景灌成 255,取反后就是所有"被目标包围"的封闭区域 cv::Mat flood = out.clone(); cv::floodFill(flood, cv::Point(0, 0), 255); cv::Mat holes; cv::bitwise_not(flood, holes); cv::bitwise_or(out, holes, out); // 原图与孔洞相或,洞就变成了实心 return out; }floodFill这一步有个前提:图像左上角必须是背景。如果目标贴着左上角,灌水起点落在目标内部,整张图会被判成“全是孔洞”,输出一片白。稳妥做法是先copyMakeBorder扩一圈黑边,填完再裁回来。核尺寸openSize一般取 3,噪点特别多时取 5;closeSize要略小于目标直径,否则相邻目标会被闭运算粘在一起,这时候再靠分水岭去拆就得多花一道功夫。
3.3 距离变换 + 分水岭拆粘连
粘连是计数类项目的老大难。两个目标挨在一起,连通域只算一个,数量直接少一半。分水岭的思路是先找每个目标的“芯”,再沿着目标之间的山脊切开。
// Segment.cpp —— 距离变换找种子点,分水岭沿山脊切开粘连目标 cv::Mat Segment::splitTouching(const cv::Mat& binary, int minDistPercent, int& seedCount) { cv::Mat dist; // DIST_L2 欧氏距离,最后一个参数 5 是距离图的掩码尺寸,3 更快但精度略差 cv::distanceTransform(binary, dist, cv::DIST_L2, 5); // minDistPercent 用百分比暴露给界面,0~100 对应 0~1.0,避免用户直接看到小数 cv::Mat seeds = dist > (minDistPercent / 100.0); seeds.convertTo(seeds, CV_8U, 255); cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(seeds, seeds, cv::MORPH_OPEN, kernel); // 去掉单个像素的伪种子 cv::Mat markers; seedCount = cv::connectedComponents(seeds, markers) - 1; // 减掉背景标签 0 cv::Mat color; cv::cvtColor(binary, color, cv::COLOR_GRAY2BGR); cv::watershed(color, markers); // 原地修改 markers,边界像素变成 -1 cv::Mat mask = markers > 0; // 只保留目标,丢掉 -1 的边界和 0 的背景 mask.convertTo(mask, CV_8U, 255); return mask; }minDistPercent是这个算法唯一的调参入口,物理含义是“目标的半径下限”。设太大,只有芯特别明显的目标能留下,一个目标被切成几块,计数偏多;设太小,种子连成一片,分水岭切不开,计数偏少。实际操作时先把滑块拉到 30 左右,看seeds的可视化图,让每个目标中心正好剩一个小圆点,就是合适值。
watershed要求输入是三通道 8 位图,markers是CV_32S,这两个类型不符是最常见的崩溃原因,报错通常是Assertion failed (!src.empty())之外的断言,不容易一眼看出问题。
3.4 connectedComponentsWithStats 过滤与计数
拿到干净的掩膜之后,统计这一步其实是整个流程里最短的一段,但过滤规则写得粗,前面所有功夫都白费。
// BlobFilter.cpp —— 一次遍历同时拿到数量、包围盒、质心 CountResult BlobFilter::count(const cv::Mat& mask, const FilterParams& p) { cv::Mat labels, stats, centroids; // 8 邻接比 4 邻接更贴近真实目标,斜向相连的像素不会被拆成两个 int n = cv::connectedComponentsWithStats(mask, labels, stats, centroids, 8, CV_32S); CountResult result; result.image = cv::Mat::zeros(mask.size(), CV_8UC3); int kept = 0; for (int i = 1; i < n; ++i) { // 标签 0 永远是背景,必须跳过 int area = stats.at<int>(i, cv::CC_STAT_AREA); int w = stats.at<int>(i, cv::CC_STAT_WIDTH); int h = stats.at<int>(i, cv::CC_STAT_HEIGHT); if (area < p.minArea || area > p.maxArea) continue; // 噪点和粘连大块一起卡掉 if (std::max(w, h) > p.maxSide) continue; // 长条划痕不是目标 double ratio = static_cast<double>(std::min(w, h)) / std::max(w, h); if (ratio < p.minRatio) continue; // 0.2 以下基本是细长噪声 ++kept; cv::rectangle(result.image, cv::Rect(stats.at<int>(i, cv::CC_STAT_LEFT), stats.at<int>(i, cv::CC_STAT_TOP), w, h), cv::Scalar(0, 255, 0), 1); cv::putText(result.image, std::to_string(kept), cv::Point(static_cast<int>(centroids.at<double>(i, 0)) - 6, static_cast<int>(centroids.at<double>(i, 1)) + 4), cv::FONT_HERSHEY_SIMPLEX, 0.4, cv::Scalar(0, 0, 255), 1); } result.count = kept; return result; }stats每行五个int:CC_STAT_LEFT、CC_STAT_TOP、CC_STAT_WIDTH、CC_STAT_HEIGHT、CC_STAT_AREA,索引不能错位。centroids是CV_64F,取值必须用at<double>,用at<float>会读出垃圾值,质心直接飘到图像外面去,标注图看起来像乱画。
参数表按目标尺寸换算更靠谱:
| 参数 | 含义 | 参考取值 | 调大的后果 |
|---|---|---|---|
minArea | 最小像素面积 | 目标平均面积的 40% | 小目标被漏掉 |
maxArea | 最大像素面积 | 目标平均面积的 250% | 粘连块被当成正常目标 |
maxSide | 包围盒最长边 | 图像短边的 1/8 | 长条噪声被计入 |
minRatio | 短边/长边 | 0.3~0.5 | 扁形目标被误杀 |
4. 界面与线程:让参数调整不卡死窗口的四个关键点
4.1 cv::Mat 与 QImage 的转换,深拷贝不能省
这个问题必须放在第一条。QImage有个构造函数直接接管外部内存指针,不拷贝数据,如果这时cv::Mat是局部变量,函数一返回,QImage就指着已经释放的内存。
// ImageView.cpp —— 只支持 8 位单通道和三通道,够用且不踩类型坑 QImage matToQImage(const cv::Mat& mat) { switch (mat.type()) { case CV_8UC3: { // OpenCV 的内存布局是 BGR,对应 Qt 的 Format_BGR888,别写成 RGB888 QImage img(mat.data, mat.cols, mat.rows, static_cast<int>(mat.step), QImage::Format_BGR888); return img.copy(); // copy() 复制像素,断开对 mat 内存的引用 } case CV_8UC1: { QImage img(mat.data, mat.cols, mat.rows, static_cast<int>(mat.step), QImage::Format_Grayscale8); return img.copy(); } default: return QImage(); } }mat.step是每行的字节数,因为可能存在行对齐填充,不等于cols * channels,直接把cols * 3传给QImage会让图像斜切。copy()这一行的开销是几十毫秒,看起来浪费,但它能省掉后面所有难以定位的随机崩溃。
4.2 参数面板与预览防抖
滑块每动一像素就触发一次完整算法,2000×2000 的图跑一遍要 300 毫秒,用户拖一下就会排几十个任务,界面卡成一帧一帧。
// MainWindow.cpp —— 连续拖动只跑最后一次 debounceTimer_ = new QTimer(this); debounceTimer_->setSingleShot(true); // 只触发一次,不循环 connect(ui->sliderBlockSize, &QSlider::valueChanged, this, [this](int v) { pendingParams_.blockSize = (v % 2 == 0) ? v + 1 : v; // 保证奇数,界面不作要求 debounceTimer_->start(120); // 120ms 内的连续改动被合并 }); connect(debounceTimer_, &QTimer::timeout, this, &MainWindow::requestPreview); // 松手后立即出图,不等 120ms,手感更跟手 connect(ui->sliderBlockSize, &QSlider::sliderReleased, this, [this]() { debounceTimer_->stop(); requestPreview(); });120 毫秒是个经验值:低于 80 毫秒基本起不到合并作用,高于 200 毫秒松手后明显感觉迟钝。requestPreview里还要做一个判断——如果工作线程正忙,就把这次请求标记为“待处理”,等上一次结果回来再发,而不是排队。这样即使用户疯狂拖动,队列长度也永远不超过 1。
4.3 工作线程与信号回传
算法一律不进主线程。跨线程传数据只传值,不传引用,QImage是隐式共享的,传值本身开销很小。
// CountWorker.h —— 算法类对象 moveToThread 之后,槽函数在子线程执行 class CountWorker : public QObject { Q_OBJECT public slots: void process(const QString& path, const CountParams& params) { cv::Mat src = ImageLoader::load(path); // 内部用 QFile 读字节 + imdecode if (src.empty()) { emit failed(path); return; } CountResult r = pipeline_.run(src, params); // 纯计算,不碰任何 Qt 对象 emit finished(path, r.count, matToQImage(r.image)); } signals: void finished(const QString& path, int count, const QImage& annotated); void failed(const QString& path); };CountParams必须是可拷贝的纯数据结构,别塞QWidget*进去。connect用默认的Qt::AutoConnection即可,跨线程会自动转成队列连接,但前提是QImage和QString都注册过元类型,这两个内置类型不用手动注册,自定义结构体才需要qRegisterMetaType。
4.4 批量处理与进度反馈
单张预览走QThread,批量跑文件夹换成QtConcurrent::mapped更省代码,配合QFutureWatcher拿进度。要点是限制并发数,mapped默认按核数开线程,同时解码十几个大图内存会飙到几个 G。用QThreadPool::globalInstance()->setMaxThreadCount(2)压到 2 到 4 之间,吞吐反而更稳,因为磁盘和内存带宽先到瓶颈。
5. 避坑现场:计数偏多、偏少、卡顿的 5 个排查记录
5.1 计数结果比肉眼多一倍
现象:标注图上目标周围散落一圈小框,编号排到实际数量的两倍。
原因:高光反射或表面纹理在阈值后形成独立的小连通域,面积刚好过了minArea的下限。
解决:先开MORPH_OPEN,核取 3;再把minArea抬到目标平均面积的 40%;最后加一道minRatio过滤,反光点通常是不规则细长形。三步都不需要改算法,只调参数。我习惯在界面上加一个“显示掩膜”的勾选框,出问题时第一眼看的是掩膜而不是标注图,能立刻分辨是分割问题还是过滤问题。
5.2 两个挨着的目标只算了一个
现象:标注框明显比别的宽一倍,编号却只加一。
原因:两个目标边缘接触,8 邻接把整块连成了一个连通域。
解决:打开分水岭,把minDistPercent从 0 往上调到 25 到 35 之间;如果目标本身是椭圆的,还得把距离变换前的掩膜做一次MORPH_OPEN,否则接触处的桥接像素会让距离图出现一条浅谷,种子连成一片切不开。判断是否切成功,看标注框的数量密度,切对了每个框应该恰好裹住一个目标。
5.3 拖滑块时界面整体卡住
现象:拖动参数滑块,窗口变成灰色,鼠标点击没反应,松手几秒后才恢复。
原因:算法直接在valueChanged槽里同步执行,主线程被distanceTransform和watershed占满。
解决:把CountPipeline::run移到工作线程,槽里只更新参数并发信号;再加一层防抖定时器。这里有个隐藏问题:cv::Mat是引用计数的,如果在工作线程里对主线程持有的cv::Mat做原地操作(比如morphologyEx(src, src, ...)),会同时改到界面上正在显示的那份数据。规矩是工作线程入口先clone()一份。
5.4 换一台机器,同一张图结果不一样
现象:开发机上数出来 137,产线机器上 141。
原因:大概率是 OpenCV 版本或 SIMD 指令集差异导致adaptiveThreshold的浮点累加结果有微小偏差,刚好卡在阈值边缘的像素翻转了。也可能是imread在两次调用里用了不同参数,默认IMREAD_COLOR会做一次 EXIF 旋转,带方向信息的手机照片会被转 90 度,blockSize相对目标尺寸就变了。
解决:读取统一用cv::imdecode(QFile 读出的字节, cv::IMREAD_GRAYSCALE)并显式忽略 EXIF 方向;编译时锁死 OpenCV 版本;参数存成 JSON 跟图一起走,不接受“默认参数跑一下”这种操作。边缘像素翻转没法彻底消除,但把cOffset从 5 抬到 10 能显著降低敏感度。
5.5 批量跑几百张时内存一路上涨
现象:处理到第 200 张左右进程被系统杀掉,或者报std::bad_alloc。
原因:为了支持“上一张/下一张”回看,把每张原图和标注图都塞进了std::vector<cv::Mat>,一张 4000×3000 的三通道图就是 36MB,几百张直接吃掉十几个 G。
解决:只缓存缩略图(长边压到 512),原图按需重读;cv::Mat用完主动release(),别指望析构时机;批量模式下每处理完一张就发一次信号让界面刷新,不要攒到最后统一更新。另外imdecode之前先判断文件大小,超大图可以先降采样再算,计数精度损失很小。
6. 二次开发:把它改成你自己的检测器
拿到这套工程之后,改造成本最低的路径是“只换参数和过滤规则,不动主链路”。预处理、形态学、分水岭、连通域统计这四段是通用的,换场景时真正要动的只有三个地方:通道判断(目标是亮还是暗)、核尺寸(跟目标直径挂钩)、过滤条件(面积、长宽比、圆度)。
举个具体例子。假如要数的目标从方形包装袋换成圆形颗粒,长宽比接近 1,这时候minRatio卡到 0.3 就没意义了,改用圆度更准。圆度定义是4πA/P²,A 是面积,P 是周长,完美圆是 1。这段可以加在BlobFilter::count的循环里,用cv::arcLength和cv::contourArea算:
// BlobFilter.cpp 追加:按轮廓算圆度,过滤掉方形碎片 std::vector<std::vector<cv::Point>> contours; cv::findContours(mask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); for (const auto& c : contours) { double area = cv::contourArea(c); double peri = cv::arcLength(c, true); // true 表示轮廓闭合 if (peri < 1e-6) continue; // 单像素轮廓,除零保护 double circularity = 4.0 * CV_PI * area / (peri * peri); if (circularity < 0.6) continue; // 0.6 以下当碎片丢掉 }注意这段和connectedComponentsWithStats是两条并行路线,选一条就行,别混用。连通域统计快,适合目标多、形状规则的场景;轮廓法能拿到形状描述子,适合需要按形状分类的场景,代价是慢一倍左右。目标超过五千个时,轮廓法的findContours会成为瓶颈,这时候回到连通域加形态学过滤更实际。
常见改造点的落位整理如下:
| 改造需求 | 落点文件 | 需要额外注意 |
|---|---|---|
| 换目标尺寸 | ParamPanel.cpp默认值 | blockSize与图像分辨率绑定,别写死 |
| 目标是亮底暗物 | Preprocess.cpp阈值类型 | 去掉THRESH_BINARY_INV |
| 加颜色筛选 | BlobFilter.cpp | 需要保留原图,用labels做掩膜取均值 |
| 输出检测框坐标 | ResultExporter.cpp | CSV 用 UTF-8 BOM,否则表格软件打开是乱码 |
| 接相机实时流 | MainWindow.cpp | 采集和计算分两个线程,队列丢旧帧不排队 |
验证环节容易被跳过,但它决定了这套东西能不能交付。我的做法是准备 20 张图,人工数出真值写进一个 CSV,跑一遍脚本比对,统计绝对误差和相对误差。相对误差稳定在 1% 以内算可用,个别图偏差大就把那张单独拎出来看掩膜,十有八九是光照或反光问题,不是算法问题。这个对照表比任何参数经验都可靠,因为不同批次的物料表面状态会变,参数得跟着变。
从那以后我每接一个计数类需求,都强制先做那张 20 张图的对照表,再谈界面和打包,不然调参数全靠感觉,改一次崩一次。希望帮到你。
本文还有配套的精品资源,点击获取