C++ OpenCV气泡检测实战:从预处理到轮廓分析的完整项目

发布时间:2026/10/23 8:39:09
C++ OpenCV气泡检测实战:从预处理到轮廓分析的完整项目 简介这份资源面向具备一定C与图像处理基础、希望上手计算机视觉实战的开发者聚焦液体分析与工业检测场景中的气泡识别问题。包内共36个文件以cpp源码、sln与vcxproj工程配置、props属性表、exe可执行程序及jpg测试图片为主另含pdb、obj等编译中间产物压缩包约59.82MB可直接在Visual Studio中打开工程运行调试。资源围绕图像预处理、Canny边缘检测、形态学开闭运算、连通成分标记、气泡面积与圆度等特征提取展开并预留机器学习分类与后处理优化空间配套全局阈值法示例与多张气泡测试图便于对照验证算法效果。目前已有473人学习下载适合作为OpenCV气泡检测的入门参考与二次开发起点帮助读者快速理解从原始图像到气泡信息提取的完整处理链路。1. 气泡检测这件事为什么值得单独拆一个 C OpenCV 项目工业视觉里有一类需求特别磨人液体或透明介质里混进了气泡肉眼能看见但要用程序稳定地数出来、量出直径、判断是否超标就变得玄学起来。气泡本身对比度低、边缘模糊、大小不一还容易被反光、容器壁、液面波动干扰。我见过不少团队一开始想用深度学习硬怼结果标注成本高、推理还慢最后又回到传统图像处理。这个 C OpenCV 气泡检测项目走的就是传统视觉路线用 C 调用 OpenCV对采集到的图像做预处理、分割、轮廓提取最终输出气泡的数量、位置和尺寸。它适合两类人一类是刚接触机器视觉、想找一个完整可跑的 OpenCV 实战练手的新手另一类是在产线或实验室里真要做气泡计数、需要拿一份 C 代码二次开发的工程师。整份资源是源码形式拿到手就能编译改几个参数就能适配自己的相机和光照。2. 技术选型与整体架构为什么是 C 加 OpenCV 而不是 Python2.1 选型逻辑性能、部署与依赖的三角权衡先把这个项目的技术底座说清楚。气泡检测在算法层面并不复杂核心就是「预处理 → 分割 → 形态学 → 轮廓分析」这条链路。真正决定选型的是部署环境。Python 加 OpenCV 写起来快但到了产线上工控机往往要同时跑采集、通信、运动控制Python 的 GIL 和解释器开销会让帧率不稳。C 编译成原生二进制没有运行时依赖直接扔到工控机上就能跑这是很多现场项目的硬要求。OpenCV 在这里承担了几乎全部图像处理工作。它的imgproc模块提供了滤波、阈值、形态学、轮廓查找这些气泡检测必需的能力highgui负责调试时看图videoio负责接相机或读视频。项目没有引入额外的第三方库意味着编译依赖很干净常见做法是用 CMake 管理构建链接opencv_core、opencv_imgproc、opencv_highgui、opencv_videoio这几个模块就够了。提示如果你的 OpenCV 是源码编译的注意pkg-config --modversion opencv4能查到的版本和 CMake 里find_package(OpenCV REQUIRED)找到的要一致否则会出现头文件和库版本对不上的链接错误。2.2 处理流水线一张图从进入到出结果的完整链路整个项目的架构可以理解成一条单向流水线每一级只做一件事方便单独调试和替换。链路是这样的图像输入(相机/视频/图片) → 灰度化 → 去噪(高斯/中值) → 增强对比度(可选 CLAHE) → 阈值分割(自适应/固定) → 形态学开闭运算 → 轮廓查找 findContours → 轮廓筛选(面积/圆度) → 输出数量、位置、直径这条链路里每一级的输出都可以存成图片人工核对这是传统视觉相比深度学习最大的好处——可解释、可回退。下面用一段代码把主流程串起来这也是项目里最核心的一个函数。#include opencv2/opencv.hpp #include vector #include iostream // 单帧气泡检测主流程 // 输入: 原始 BGR 图像 // 输出: 检测到的气泡轮廓列表 std::vectorstd::vectorcv::Point detectBubbles(const cv::Mat src) { cv::Mat gray, blurred, binary, morph; // 1. 灰度化气泡检测不依赖颜色信息 cv::cvtColor(src, gray, cv::COLOR_BGR2GRAY); // 2. 中值滤波去噪对椒盐噪声和细小杂点效果好 // kernel 取 5太大糊掉小气泡太小去不干净 cv::medianBlur(gray, blurred, 5); // 3. 自适应阈值应对光照不均 // blockSize 必须是奇数C 为常数项从均值里减去 cv::adaptiveThreshold(blurred, binary, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, cv::THRESH_BINARY_INV, 21, 5); // 4. 形态学开运算去掉噪点保留气泡主体 cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(binary, morph, cv::MORPH_OPEN, kernel); // 5. 查找轮廓只取外轮廓简化后续计算 std::vectorstd::vectorcv::Point contours; cv::findContours(morph, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); // 6. 按面积和圆度筛选剔除干扰 std::vectorstd::vectorcv::Point result; for (const auto c : contours) { double area cv::contourArea(c); if (area 20 || area 5000) continue; // 面积阈值 double peri cv::arcLength(c, true); double circularity 4 * CV_PI * area / (peri * peri); if (circularity 0.6) continue; // 圆度阈值 result.push_back(c); } return result; }这段代码里有几个参数是调优的关键。medianBlur的核大小决定了去噪强度气泡直径如果只有十几个像素核取 5 已经接近极限。adaptiveThreshold的blockSize控制局部邻域范围光照均匀时可以调大到 31 甚至 51光照不均时反而要调小。circularity用4πA/P²计算完美圆形为 1气泡一般能到 0.7 以上低于 0.6 的基本是条状杂质或边缘残留。这些阈值没有万能值必须结合你的实际图像调这也是为什么项目把每一步都拆开——方便你单独看中间结果。2.3 目录结构一份能直接编译的工程长什么样项目按功能拆成几个文件避免所有逻辑堆在一个main.cpp里。典型结构如下bubble_detect/ ├── CMakeLists.txt # 构建脚本 ├── include/ │ ├── detector.h # 检测类声明 │ └── utils.h # 图像读写、结果保存 ├── src/ │ ├── main.cpp # 程序入口参数解析 │ ├── detector.cpp # 核心检测算法 │ └── utils.cpp ├── config/ │ └── params.yaml # 阈值参数外置 └── data/ └── sample.jpg # 测试图把参数放到params.yaml是血泪经验。早期我把阈值写死在代码里换一批图就要重新编译后来改成运行时读配置调参效率高了一个量级。detector.h里通常定义一个BubbleDetector类把detectBubbles作为成员函数参数通过构造函数或setParams注入这样二次开发时替换算法只动一个类。3. 从零跑起来编译、配置与第一张检测结果3.1 环境准备与依赖安装跑这个项目需要三样东西C 编译器、CMake、OpenCV。Linux 下最省事Ubuntu 系直接装# 安装编译工具链 sudo apt update sudo apt install -y build-essential cmake # 安装 OpenCV 开发包 sudo apt install -y libopencv-dev # 验证版本4.x 都可以 pkg-config --modversion opencv4Windows 下建议用 vcpkg 装 OpenCV或者直接下载官方预编译包然后在 CMake 里指定OpenCV_DIR。这里有个常见的翻车点Windows 上预编译包分x64/vc16、x64/vc17等目录和你用的 Visual Studio 版本必须对应否则find_package会报找不到。我一般会在 CMake 里打印OpenCV_DIR确认路径对不对。3.2 CMakeLists 与编译项目的CMakeLists.txt核心就几行把 OpenCV 找出来、把源文件编成可执行文件cmake_minimum_required(VERSION 3.10) project(bubble_detect CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 查找 OpenCVREQUIRED 表示找不到就报错退出 find_package(OpenCV REQUIRED) # 头文件目录 include_directories(${OpenCV_INCLUDE_DIRS} include) # 源文件 add_executable(bubble_detect src/main.cpp src/detector.cpp src/utils.cpp) # 链接 OpenCV 库 target_link_libraries(bubble_detect ${OpenCV_LIBS})编译三步走mkdir build cd build cmake .. make -j4make -j4用 4 个线程并行编译源文件多的时候能省不少时间。编译成功后build/下会生成bubble_detect可执行文件。如果报undefined reference to cv::...基本是target_link_libraries没写全或者 OpenCV 版本和头文件不匹配。3.3 运行与结果解读程序入口一般支持三种输入单张图片、视频文件、相机。用命令行参数区分# 检测单张图片结果保存到 output/ ./bubble_detect --input ../data/sample.jpg --output ../output/ # 处理视频 ./bubble_detect --input test.mp4 --output ../output/ # 用摄像头索引 0 ./bubble_detect --camera 0运行后output/下会生成两类文件一张标注图把检测到的气泡用圆框出来并标上序号一个文本或 CSV记录每个气泡的质心坐标和等效直径。等效直径用2 * sqrt(area / π)算这是面积等效直径比直接量外接矩形更接近真实尺寸。注意第一次跑建议先用data/sample.jpg这种已知结果的图确认流程通了再换自己的图。直接上相机很容易因为曝光、对焦问题得到一堆噪点误以为是算法不行。3.4 参数怎么调三个最常动的旋钮调参是这个项目里最花时间的部分。按影响从大到小排最常动的是这三个参数位置作用调大后果调小后果blockSizeadaptiveThreshold局部阈值邻域抗噪强但漏小气泡灵敏但噪点多面积下限轮廓筛选过滤小噪点漏检小气泡误检噪点圆度阈值轮廓筛选过滤非圆形漏检变形气泡误检杂质调参的正确姿势是先把中间结果存图看。在detectBubbles里加一句cv::imwrite(debug_binary.png, morph);看二值图里气泡是不是干净地分离出来了。如果二值图里气泡粘连成一片说明阈值或形态学核有问题如果二值图很干净但轮廓筛选后数量不对那就是面积或圆度阈值的问题。分步定位比盲目改参数快得多。4. 核心模块拆解预处理、分割与轮廓分析的关键实现4.1 预处理去噪与对比度增强的取舍预处理的目标只有一个让气泡和背景的差异尽可能大。气泡在图像里通常表现为比背景亮或暗的圆形区域但受光照影响同一个气泡可能一半亮一半暗。这时候全局阈值就废了必须上自适应。去噪和增强是有取舍的。中值滤波去椒盐噪声强但会糊掉小气泡边缘高斯滤波平滑但保留边缘稍好双边滤波保边最好但慢。项目默认用中值因为工业图像里传感器噪点多是脉冲型。如果气泡特别小直径小于 10 像素我会把中值核降到 3甚至跳过滤波直接阈值宁可留噪点也不能丢气泡。对比度增强用 CLAHE限制对比度自适应直方图均衡比较稳它不会像全局直方图均衡那样把噪声也放大// CLAHE 增强clipLimit 控制对比度上限tileGridSize 是分块大小 cv::Ptrcv::CLAHE clahe cv::createCLAHE(2.0, cv::Size(8, 8)); clahe-apply(gray, enhanced);clipLimit取 2.0 到 4.0 之间太大噪声会爆太小没效果。tileGridSize一般 8x8图像大就调大。这一步是可选的光照均匀的图加了反而引入伪影所以项目里做成开关。4.2 阈值分割自适应阈值的参数含义adaptiveThreshold是气泡检测的分水岭。它的原理是对每个像素取它周围blockSize × blockSize邻域算加权平均高斯或平均减去常数C作为该像素的阈值。这样光照不均时暗区和亮区各自有各自的阈值。cv::adaptiveThreshold(blurred, binary, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, cv::THRESH_BINARY_INV, 21, // blockSize必须奇数 5); // C从局部均值里减去的常数THRESH_BINARY_INV表示暗背景亮气泡时气泡被置为 255。如果你的气泡是暗的、背景是亮的就换成THRESH_BINARY。blockSize要大于气泡直径否则气泡内部会被阈值切成两半。经验值是气泡直径的 2 到 3 倍且必须是奇数。C越大阈值越严格检测到的区域越小适合背景有渐变的情况。4.3 形态学处理开运算与闭运算各管什么二值化之后气泡区域往往不干净内部可能有空洞边缘可能有毛刺周围可能有孤立噪点。形态学就是来收拾这些的。开运算是先腐蚀后膨胀作用是去掉小的亮噪点、断开细连接。闭运算是先膨胀后腐蚀作用是填补内部空洞、连接断裂。气泡检测里通常先开运算去噪如果气泡内部有空洞再补一次闭运算cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(binary, opened, cv::MORPH_OPEN, kernel); // 如果气泡内部有空洞补一次闭运算 cv::morphologyEx(opened, closed, cv::MORPH_CLOSE, kernel);核的形状选椭圆最贴合气泡尺寸 3x3 是默认气泡大可以到 5x5。核太大相邻气泡会被连成一个这是最常见的翻车点。判断标准很简单看形态学后的图如果两个气泡粘在一起了核就太大了。4.4 轮廓筛选面积、圆度与等效直径计算findContours会把所有连通区域都找出来包括噪点和杂质所以必须筛选。项目里用两个指标面积和圆度。面积过滤掉太小和太大的圆度过滤掉条状、不规则形状。for (const auto c : contours) { double area cv::contourArea(c); if (area minArea || area maxArea) continue; // 等效直径把轮廓面积换算成等面积圆的直径 double diameter 2.0 * std::sqrt(area / CV_PI); // 圆度4πA/P²完美圆为 1 double peri cv::arcLength(c, true); double circularity 4 * CV_PI * area / (peri * peri); if (circularity minCircularity) continue; // 质心用于定位 cv::Moments m cv::moments(c); cv::Point2f center(m.m10 / m.m00, m.m01 / m.m00); // 保存结果... }minArea和maxArea要根据你的图像分辨率和气泡实际尺寸定。一个实用技巧先不做面积筛选跑一遍把所有轮廓面积打印出来看气泡和噪点的面积分布中间那个明显的断层就是阈值。圆度阈值一般 0.6 到 0.8气泡越圆可以设越高。质心用图像矩算比外接矩形中心更准尤其是气泡边缘不完整的时候。5. 避坑与排查气泡检测里最容易翻车的几个地方5.1 气泡粘连成一个形态学核太大或阈值太松现象是检测数量明显少于肉眼看到的标注图里几个气泡被一个大圈框住。原因是二值化后相邻气泡区域连在一起或者形态学膨胀把间隙填上了。解决办法是先看二值图如果二值图里就粘连说明阈值太松或blockSize太小调紧阈值、调大blockSize如果二值图是分开的但形态学后粘连就把核从 5x5 降到 3x3或者干脆去掉闭运算。极端情况下可以用分水岭算法做分割但那是最后手段能调参数解决就别上复杂算法。5.2 小气泡漏检面积下限设太高或滤波糊掉了现象是大都检到了就是最小的那几个没出来。先查面积下限把minArea调到 10 甚至 5 试试。如果还不行看是不是中值滤波把气泡糊没了把核降到 3 或跳过滤波。还有一个隐蔽原因是blockSize太大小气泡在局部邻域里被平均掉了调小blockSize到气泡直径的 1.5 倍左右。漏检和误检是一对矛盾调参时要有取舍先保证不漏关键尺寸的气泡。5.3 边缘气泡被截断轮廓不完整导致圆度不达标现象是靠近图像边缘的气泡检测不到或者直径算出来偏小。原因是气泡被图像边界截断轮廓不闭合contourArea和arcLength都算不准圆度自然低。解决办法有两个一是采集时留边距让气泡完整落在视野内二是在筛选时对靠近边缘的轮廓放宽圆度要求或者用cv::boundingRect判断是否贴边贴边的单独处理。我一般选第一种从源头解决比在算法里打补丁干净。5.4 光照变化导致参数失效固定阈值换图就崩现象是同一套参数上午的图能跑下午的图就一堆误检。这是自适应阈值也扛不住的全局光照漂移。根本原因是C常数是固定的光照整体变暗后局部均值也变但C没跟着变。解决办法是加一步自动曝光控制或者用大津法Otsu先估一个全局阈值再结合自适应。更工程化的做法是把相机曝光锁死用固定光源从硬件上消除变量。视觉项目里打光往往比算法重要这话不夸张。5.5 处理速度跟不上分辨率太高或每帧重复分配内存现象是视频或相机模式下帧率很低。常见原因是图像分辨率太高比如 4K而气泡检测不需要那么高的分辨率先缩放到 1080p 甚至 720p 再处理速度能翻几倍。另一个原因是每帧都在函数里cv::Mat重新分配内存改成在循环外声明、循环内复用能减少内存分配开销。如果还慢把findContours的CHAIN_APPROX_SIMPLE保持住别用CHAIN_APPROX_NONE后者点数多好几倍。6. 二次开发把这份代码改成你自己的气泡检测工具拿到源码后最直接的改造是替换输入源和输出格式。如果你的相机是工业相机把main.cpp里读图片的部分换成相机 SDK 的取流回调把cv::Mat喂给detectBubbles就行。输出如果要对接 MES 或数据库把 CSV 写入换成 socket 发送或 SQL insert逻辑不变。再进一步是改算法。传统视觉的天花板在复杂背景如果你发现调参怎么都调不好可以考虑在预处理后加一个 ROI 掩膜把非检测区域比如容器壁、标签涂黑只留液体区域。ROI 可以用固定坐标也可以用颜色分割自动生成。这一步能砍掉大部分误检性价比极高。参数外置这块我建议把params.yaml用 OpenCV 的FileStorage读进来支持运行时热加载。这样调参不用重编译现场工程师自己就能改。下面是一个读取配置的示例cv::FileStorage fs(params.yaml, cv::FileStorage::READ); int blockSize, minArea; double minCircularity; fs[blockSize] blockSize; fs[minArea] minArea; fs[minCircularity] minCircularity; fs.release();验证改造是否成功别只看数量对不对要建立一个小的回归测试集挑 20 张有代表性的图人工标好气泡数量每次改完跑一遍看准确率和召回率。我一般会写个脚本自动比对省得每次手动数。这个习惯是从一次翻车来的——当时改了个阈值测试图看着没问题上线后才发现某类气泡全漏了返工查了两天。从那以后我每次改检测参数都强制走一遍回归集哪怕只改了一个数。希望这份拆解能帮你少走点弯路把气泡检测这件事真正落地。本文还有配套的精品资源点击获取