
简介面向生物医学图像处理学习者与计算机视觉初学者的一套细胞计数实战工程围绕细胞图像的二值化、膨胀腐蚀、轮廓提取与连通域统计展开并配套图形界面完成图像加载、显示及计数结果查看。压缩包共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里这意味着编译出来的可执行文件不带额外扩展库拷到产线机器上不用配环境。C17 标准OpenCV 4.xQt 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模块输入输出是否依赖 QtPreprocess原始cv::Mat单通道二值图否Segment二值图切开粘连后的掩膜否BlobFilter掩膜数量、包围盒、质心否CountPipelinecv::Mat 参数结构体CountResult否MainWindow用户操作界面状态是core整个目录零 Qt 依赖好处是算法可以单独拉出来写单元测试也可以原样塞进命令行批处理工具里。我一般会把core编成一个静态库counting_coreGUI 和 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.299R0.587G0.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/102000 像素宽的图大概 101 到 201cOffset取 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噪点特别多时取 5closeSize要略小于目标直径否则相邻目标会被闭运算粘在一起这时候再靠分水岭去拆就得多花一道功夫。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.atint(i, cv::CC_STAT_AREA); int w stats.atint(i, cv::CC_STAT_WIDTH); int h stats.atint(i, cv::CC_STAT_HEIGHT); if (area p.minArea || area p.maxArea) continue; // 噪点和粘连大块一起卡掉 if (std::max(w, h) p.maxSide) continue; // 长条划痕不是目标 double ratio static_castdouble(std::min(w, h)) / std::max(w, h); if (ratio p.minRatio) continue; // 0.2 以下基本是细长噪声 kept; cv::rectangle(result.image, cv::Rect(stats.atint(i, cv::CC_STAT_LEFT), stats.atint(i, cv::CC_STAT_TOP), w, h), cv::Scalar(0, 255, 0), 1); cv::putText(result.image, std::to_string(kept), cv::Point(static_castint(centroids.atdouble(i, 0)) - 6, static_castint(centroids.atdouble(i, 1)) 4), cv::FONT_HERSHEY_SIMPLEX, 0.4, cv::Scalar(0, 0, 255), 1); } result.count kept; return result; }stats每行五个intCC_STAT_LEFT、CC_STAT_TOP、CC_STAT_WIDTH、CC_STAT_HEIGHT、CC_STAT_AREA索引不能错位。centroids是CV_64F取值必须用atdouble用atfloat会读出垃圾值质心直接飘到图像外面去标注图看起来像乱画。参数表按目标尺寸换算更靠谱参数含义参考取值调大的后果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_castint(mat.step), QImage::Format_BGR888); return img.copy(); // copy() 复制像素断开对 mat 内存的引用 } case CV_8UC1: { QImage img(mat.data, mat.cols, mat.rows, static_castint(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::vectorcv::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::vectorstd::vectorcv::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.cppCSV 用 UTF-8 BOM否则表格软件打开是乱码接相机实时流MainWindow.cpp采集和计算分两个线程队列丢旧帧不排队验证环节容易被跳过但它决定了这套东西能不能交付。我的做法是准备 20 张图人工数出真值写进一个 CSV跑一遍脚本比对统计绝对误差和相对误差。相对误差稳定在 1% 以内算可用个别图偏差大就把那张单独拎出来看掩膜十有八九是光照或反光问题不是算法问题。这个对照表比任何参数经验都可靠因为不同批次的物料表面状态会变参数得跟着变。从那以后我每接一个计数类需求都强制先做那张 20 张图的对照表再谈界面和打包不然调参数全靠感觉改一次崩一次。希望帮到你。本文还有配套的精品资源点击获取