拓冰建站拓冰建站
首页 / 资讯中心 / 正文

OpenCV图像处理实战:米粒计数与特征分析全流程

简介针对使用VS2008与OpenCV、MFC进行图像处理的学习者该资源提供了一个完整的米粒识别工程可自动计算图像中米粒的总个数并测量最大米粒的面积与周长。工程基于MFC对话框搭建包含源码、工程配置、可执行程序及样张图片压缩包共29个文件、约9.42MB主要涵盖C源文件.cpp/.h、资源脚本.rc、VS解决方案.sln/.vcproj以及用于验证效果的图像素材。作者在说明中特意记录了编译过程中LPCTSTR与const char转换问题指出在UNICODE环境下LPCTSTR为宽字符通过把项目字符集改为多字节即可将其视为const char从而顺利生成Debug版本对初涉字符编码转换的开发者很有参考价值。目前已有1110人浏览/学习适合需要参考图像计数与几何测量实现或想避开同类编译坑的读者。 做视觉检测这几年我一直觉得米粒计数是最适合新人完整跑通图像处理流程的项目。别看它只是数个数、算个面积、量个周长就这么一个需求基本上把读图、灰度化、二值化、形态学处理、连通域分析、几何特征计算这一条流水线全串起来了。当年我在 VS2008 上配 OpenCV 2.4.9 做这个功能时最大的感受是算法本身不难最难的是让老掉牙的工程环境和图像处理新思路和平共处。这篇东西我就把当时踩过的坑、最后跑通的方案、以及关键代码全部整理出来。这个需求最终的成果很简单给一张米粒散落的图程序自动输出总粒数、最大米粒的面积和周长并且在界面上把最大米粒框出来。听起来非常基础但它背后涉及的思路放到任何零件计数、颗粒检测、甚至农产品质检场景里都是一模一样的。适合刚接触 OpenCV 的初学者照着练手也适合被老 MFC 工程折腾到崩溃的同学拿来做参考。1. 项目需求与整体方案设计1.1 核心需求拆解先看清楚这个项目到底要算哪几样东西米粒的总个数。所有米粒中面积最大的那一粒。这粒最大米粒的面积。这粒最大米粒的周长。如果只是表面理解拿个连通域分析找一下轮廓就完事了。但实际做起来你会发现思路得稍微绕一下面积和周长不是直接算出来的而是先通过图像预处理把米粒从背景里干干净净地分离出来再用 findContours 提取轮廓最后用 OpenCV 自带的 contourArea 和 arcLength 去算。换句话说预处理做得好不好直接决定了数字准不准。这个项目放到实际生产里对应的就是粮食质检里的“千粒重”测量或者饲料颗粒的尺寸统计。不只是单个米粒换成螺丝、药片、种子逻辑完全通用。唯一要记住的是像素面积不等于物理面积真要测实际大小后面还得做标定换算这个我放到后面细说。1.2 为什么选 VS2008 OpenCV MFC 这套老组合看到 VS2008很多新人第一反应是这也太古董了。但说实话到现在还有大量工控机、检测设备的上位机软件是用 VS2008 写的。老项目的界面是 MFC设备 SDK 是老接口数据库是老 ODBC 连接整套东西都绑定在 32 位 Windows 上谁都不会轻易把编译器升级到 VS2015 甚至 VS2022怕的就是牵一发动全身。OpenCV 的好处在于图像处理能力完整、接口稳定、社区资料多。用 OpenCV 2.4.x 在 VS2008 里跑属于那个时代非常标准的配置。而 MFC 只负责一个事给算法一个展示窗口。所以整个项目结构非常清晰——MFC 做界面和交互OpenCV 做图像处理核心两边通过一个按钮事件串起来。这个组合最大的问题是版本适配。VS2008 对应的是 VC9 编译器而 OpenCV 从 3.0 开始就放弃 VC9 了所以必须用 OpenCV 2.4.x。我这边用的是 2.4.9在官方下载页能找到对应的 win 包安装好之后记住安装路径就行。1.3 整体算法流程设计整个处理流程我设计成了六个步骤顺序是固定的每步看起来简单但都有讲究读图用 cv::imread 加载米粒图片使用彩色或灰度模式。灰度化把三通道图转成单通道减少计算量。二值化选一个合适的阈值把米粒和背景彻底分开。光照均匀的情况下大津法Otsu基本够用。形态学开运算去掉小的噪点切断细小的粘连让每一个米粒成为独立的连通域。findContours 提取轮廓拿到所有米粒的边界。遍历轮廓计算特征用 contourArea 算面积用 arcLength 算周长同时记录最大值。这个流程是经典的不能再经典的套路了你后面做任何目标检测都能用上。关键是在每一步之间想清楚上一步的输出到底给下一步解决了什么问题。2. 环境搭建VS2008 OpenCV MFC 的兼容配置2.1 版本匹配是第一步VS2008 里配 OpenCV 最常犯的错是下了一个 3.x 甚至 4.x 的版本然后发现一堆接口对不上或者编译直接报错。因为 OpenCV 3.0 以后就不再提供 vc9 的预编译库了。所以第一步必须确认VS2008 用 OpenCV 2.4.9VS2010 可以用 OpenCV 2.4.10 或 2.4.11VS2013 建议升级到 3.x。版本对不上后面全是坑。我用的目录是 D:\OpenCV2.4.9里面有两个关键目录D:\OpenCV2.4.9\opencv\build\include头文件目录。D:\OpenCV2.4.9\opencv\build\x86\vc9\lib静态库和导入库目录。配置对话框工程的“属性”里在“VC 目录”下把 include 目录和 lib 目录分别加进去。然后在“链接器-输入-附加依赖项”里手动加上 opencv_core249d.lib、opencv_imgproc249d.lib、opencv_highgui249d.lib 这三个 Debug 库。注意库文件名最后的 d 表示 Debug 版Release 编译时要去掉这个 d。还有个细节如果程序跑起来提示找不到 opencv_core249d.dll那就需要把 D:\OpenCV2.4.9\opencv\build\x86\vc9\bin 目录加入系统 PATH或者直接把 dll 复制到 exe 同目录。我在实际项目里更推荐后者分发软件的时候不用在客户机器上设一堆环境变量。2.2 MFC 工程里如何显示 OpenCV 图像这里有个老生常谈的分叉路口一种是用 cv::imshow 直接弹 OpenCV 自己的窗口简单但是界面很违和另一种是把 cv::Mat 转成 CImage然后在对话框的 Picture Control 上显示这才是 MFC 项目的正经做法。我用的是第二种整体界面统一客户看着也舒服。核心是写一个转换函数把 cv::Mat 变成 CImage。CImage 在 MFC 里可以直接 SetBitmap 到 Picture Control 上。我第一次写这个转换函数的时候没注意像素对齐的问题结果图像显示出来是歪的。CImage 内部每行字节数默认是 4 字节对齐的所以填像素的时候不能简单地按宽度乘通道数去跳行需要用 GetPitch 拿真实的行字节数。下面这个函数是我后来一直在用的版本CImage Mat2CImage(const cv::Mat mat) { cv::Mat tmp; if (mat.channels() 1) { cv::cvtColor(mat, tmp, CV_GRAY2BGR); } else { tmp mat.clone(); } CImage img; img.Create(tmp.cols, tmp.rows, 24); int pitch img.GetPitch(); for (int y 0; y tmp.rows; y) { uchar* dst (uchar*)img.GetPixelAddress(0, y); for (int x 0; x tmp.cols; x) { cv::Vec3b pixel tmp.atcv::Vec3b(y, x); dst[x * 3] pixel[0]; dst[x * 3 1] pixel[1]; dst[x * 3 2] pixel[2]; } } return img; }注意 CImage 用的是 BGR 顺序OpenCV 的 Mat 默认也是 BGR所以这里像素通道的拷贝顺序就是 B、G、R不需要反转。当时我在这里随手写了个 RGB出来的图颜色完全不对排查了半天才发现是这个问题。2.3 电荷指针和 Mat 的选择VS2008 时代的 OpenCV 2.4.x 已经能很好地支持 C 接口 cv::Mat完全没必要再用老的 IplImage。我看网上很多老教程还在用 cvLoadImage、cvFindContours 这种 C 接口不是说不能用而是内存管理太折磨人。IplImage* 必须手动 cvReleaseImage一旦忘了释放程序跑几十次就会崩。用 cv::Mat 有自动内存管理配合 vector 用起来舒服很多。不过要特别提醒一下MFC 的对话框工程默认是使用 Unicode 字符集的而 cv::imread 接收的是 const string 类型。如果直接传 CString编译会报错。我当时是在按钮响应函数里先用 CT2A 把 CString 转成了窄字节字符串再传给 imread这个坑几乎每个 MFC OpenCV 的新手都会踩。3. 图像预处理让米粒从背景中干净地分离出来3.1 灰度化与二值化的阈值选择米粒和背景的灰度差异通常比较大但光照不可能绝对均匀。我用过固定阈值例如直接 threshold(gray, binary, 120, 255, CV_THRESH_BINARY)结果在图片边缘处米粒和背景直接混在一起。后来改成了 Otsu 大津法让它根据整张图的灰度分布自动选阈值稳定性高很多cv::Mat gray, binary; cv::cvtColor(src, gray, CV_BGR2GRAY); cv::threshold(gray, binary, 0, 255, CV_THRESH_BINARY | CV_THRESH_OTSU);这里 threshold 的参数里第一个阈值传 0是因为 Otsu 会忽略这个值自动计算最优阈值。如果你的背景是深色、米粒是浅色那二值化之后米粒是白色像素255背景是黑色像素0。如果做出来发现相反加一个 CV_THRESH_BINARY_INV 反转就行。对于光照严重不均的图Otsu 也有可能出错。我遇到过侧面打光导致图片半边亮半边暗的情况全局阈值怎么选都不好。后来是先用高斯模糊加大核做背景估计然后用原图减去背景做光照补偿再接 Otsu。如果只是本项目的米粒图光均匀一点Otsu 足够。3.2 形态学开运算去掉噪点米粒图像里最常见的噪点来源有两个一是 CCD 传感器上的灰尘二是细微的背景杂质。我试过二值化后直接用 findContours结果会出现一堆边缘破烂的小轮廓面积可能只有十几个像素但也被当成了一粒米。解决办法是做一个形态学开运算也就是先腐蚀再膨胀。腐蚀会把细小的白点直接消掉膨胀再把米粒边缘恢复回来。用 OpenCV 写起来非常简单cv::Mat kernel cv::getStructuringElement(cv::MORPH_ELLIPSE, cv::Size(3, 3)); cv::morphologyEx(binary, binary, CV_MOP_OPEN, kernel);为什么用椭圆核不用矩形核因为米粒本身是椭圆形的用椭圆核对形状的破坏最小。核大小建议 3x3 或者 5x5如果开运算的核设得太大比如 15x15米粒的边缘会被磨掉一圈面积和周长都跟着缩水这个细节直接影响到最终测量精度。3.3 粘连米粒的处理思路这个项目里最影响计数准确率的是几粒米挨在一起导致连通域合并。开运算能切开非常细的粘连但如果是两粒米紧贴在一起开运算基本无能为力。这时需要更高级的分割手段比方说形态学距离变换 分水岭算法。我当时在这个项目里做了一版分水岭处理先对二值图做距离变换找出局部极大值作为种子点然后跑分水岭。效果对大部分粘连情况都不错但算法复杂度比单纯连通域分析高不少。如果项目定位是教学练手可以先不做那么深但心里要有数凡是做颗粒计数的实际项目粘连分割永远是绕不开的大头。另外还有一种思路是从源头解决拍照的时候把米粒尽量摊开不要堆叠。我做项目时先对摆放方式做了规范拍摄距离和光源固定。这个操作看起来是“非技术手段”但实际效果比任何算法都稳定。4. 连通域分析与特征计算核心实现4.1 findContours 提取轮廓的正确姿势预处理搞定之后就到了核心的轮廓提取环节。OpenCV 2.4.x 里写法如下std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; cv::findContours(binary.clone(), contours, hierarchy, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_NONE);注意两个点。第一findContours 会修改输入图像所以如果后面还用 binary 这个变量一定要先 clone 一份再传进去。第二轮廓提取模式选了 CV_RETR_EXTERNAL意思是只取最外层轮廓。因为米粒内部可能会有纹理或者反光造成的空洞如果选 CV_RETR_LIST内部轮廓也会被提取出来计数就会多。CV_CHAIN_APPROX_NONE 表示保存轮廓上的所有点。如果你想压缩存储可以用 CV_CHAIN_APPROX_SIMPLE它只保留水平、垂直、对角线方向的端点。但对周长计算来说用 NONE 保留全部点结果更逼近真实边界。面积计算受这个参数影响不大但周长会所以追求测量精度时别贪图省存储。4.2 面积和周长的计算与单位换算OpenCV 直接提供了两个函数contourArea 返回轮廓面积arcLength 返回轮廓周长。double area cv::contourArea(contours[i]); double perimeter cv::arcLength(contours[i], true);arcLength 的第二个参数传 true 表示轮廓是闭合的。对米粒轮廓来说边界自然是闭环所以必须传 true。如果传 false算出来的是曲线长度而不是周长会小很多。但是像素单位不能直接当物理单位用。假设拍照视野是 10cm x 10cm图片宽度是 1000 像素那一个像素对应的物理尺寸就是 0.1mm。最稳妥的标定方式是在同样的拍摄高度和焦距下放一个已知直径的硬币或者标准标定板算出“每毫米等于多少像素”的像素当量然后把 contourArea 的结果除以当量的平方周长除以当量这样得到的就是平方毫米和毫米了。我在这个项目里没有做这一步因为需求只要像素面积和周长。但如果你要输出“最大米粒面积是多少平方毫米”就一定要在流程里加上标定环节否则数字没有物理意义。4.3 找出最大米粒并可视化遍历所有轮廓把面积最大的一粒记录下来然后在原图或结果图上标出来。这部分的完整代码我贴在下面int maxIdx -1; double maxArea 0.0; int count 0; for (size_t i 0; i contours.size(); i) { double area cv::contourArea(contours[i]); if (area 50) continue; count; if (area maxArea) { maxArea area; maxIdx (int)i; } } if (maxIdx 0) { double perimeter cv::arcLength(contours[maxIdx], true); // 在结果图上绘制最大米粒的轮廓和标记 cv::drawContours(result, contours, maxIdx, cv::Scalar(0, 0, 255), 2); char text[128]; sprintf(text, Max Area: %.1f px^2 Perim: %.1f px, maxArea, perimeter); cv::putText(result, text, cv::Point(10, 30), CV_FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 255, 0), 2); }这里为什么要先过滤面积小于 50 的轮廓因为形态学之后仍然可能残留极小的噪点。如果不对面积做下限过滤几个噪点就能让计数翻倍。面积阈值取多少要看你图片的分辨率我的图里 50 像素以下的基本可以确定是噪声。整张图的统计结果可以拼成一个多行文本在画面上一次显示出来比如总粒数、最大面积、最大周长。红笔把最大米粒的轮廓描出来绿字把数值写在左上角界面上的效果一目了然。有些客户还要在打印报告里带图我就直接把这张 result 图保存成 bmp 或 jpg跟检测数据一起导出比较省事。5. 常见问题与排查技巧实录5.1 高频问题速查表下面这些问题是当时同事和网友问得最多的我整理一个对照表现象可能原因解决方法计数明显偏多噪点被当成米粒形态学开运算加面积下限过滤计数明显偏少米粒粘连尝试腐蚀或分水岭分割最大面积数值偏大米粒没有正确分离连成一片检查二值化阈值和形态学核大小最大面积数值偏小形态学核过大边缘被腐蚀核大小改用 3x3 或 5x5imread 返回空路径中文、Unicode 字符集问题用 CT2A 转换后传入 imread界面无图像显示CImage 行字节对齐错误用 GetPitch 计算行字节数Debug 能跑 Release 报错附加依赖项使用了带 d 的库Release 配置改为 opencv_core249.lib这里特别说一下 Release 报错的问题。很多人只配置了 Debug 的附加依赖项Release 编译时链接器找不到 lib随手动加上了带 d 后缀的 Debug 库结果 Release 运行时报各种找不到入口或者内存错误。正确做法是在“解决方案配置管理器”里把 Release 和 Debug 的附加依赖项分开配Debug 用带 d 的Release 用不带 d 的。5.2 MFC 消息循环与 waitKey 的冲突cv::imshow 和 cv::waitKey 是配套使用的但在 MFC 里直接用会有一个很烦人的现象界面卡死。因为 waitKey(0) 会阻塞线程等键盘输入而 MFC 的消息循环也被这个阻塞卡住了窗口拖动不了按钮点了没反应。我的处理办法是如果坚持用 imshow 弹窗就把 waitKey 放到工作线程里并且只阻塞很短的时间比如 waitKey(1)让 OpenCV 窗口能刷新图像又不影响 MFC 主线程。但更好的方案还是上面说的直接把 Mat 转成 CImage 画在对话框控件上彻底摆脱 waitKey 的束缚。在控件上刷新还有个好处多个检测结果可以并排显示界面上能同时看到原图、二值图、结果图三张图排查问题特别方便。5.3 中文路径和图片加载失败VS2008 的 MFC 工程默认使用 Unicode 字符集CFileDialog 选出来的文件路径是宽字符 CString。cv::imread 只接受窄字符串如果你强行类型转换中文路径会变成乱码imread 直接返回空矩阵。我当时是用 CT2A 转换再塞给 imreadCString strPath; // 假设 strPath 来自 CFileDialog CT2A asciiPath(strPath); cv::Mat src cv::imread((const char*)asciiPath);这个方法在中文路径下实测没问题。如果转换后仍然打不开检查一下 OpenCV 的 bin 目录有没有在 PATH 里因为 imread 内部依赖的高gui 相关 dll 找不到也会导致加载失败。5.4 内存泄漏与崩溃排查VS2008 的调试器对 cv::Mat 支持并不友好但不代表会出大问题。我遇到过的崩溃基本都是老的 C 接口代码导致的cvReleaseImage 少写一次程序运行一会儿就挂。用 cv::Mat 以后这类问题少了很多。不过有一个坑要小心Mat2CImage 返回的是 CImage 临时对象如果你直接在 SetBitmap 的时候用 Detach 拿到 HBITMAP临时对象销毁时 HBITMAP 可能已经被释放。正确做法是把 CImage 保存成对话框类的成员变量保证它和对话框生命周期一致不然可能图像显示一次就变黑或者程序闪退。6. 一些实操心得和扩展想法这个项目做完之后我个人最大的体会是图像处理项目的难点往往不在算法本身而在数据的“进”和“出”。怎么把一张真实拍摄的图稳定地变成干净的二值图这一步决定成败怎么在 MFC 老工程里平滑地和其他模块对接这一步决定开发效率。米粒计数只是把这条链路完整走了一遍后面你做螺丝计数、零件缺陷检测、甚至细胞统计流程一点都不会变。如果想把项目扩展一下有几个方向性价比很高一是把面积阈值做成界面上的可调参数方便现场调试二是把统计结果导出到 Excel 或者 CSV方便后续数据分析三是加一个相机采集模块从本地读图变成实时拍摄。尤其是第三个实际产线上根本不会给你现成的图片文件都是相机实时抓拍这个改动会让项目立刻有实用价值。最后再分享一个实用技巧测试阶段不要只拿一张图调参。我当年是从不同角度多拍了几十张米粒照片有的稀疏、有的密集、有的光照强、有的光照弱统一放在一个文件夹里。每次改完算法参数就把整个文件夹跑一遍查看统计表里有没有明显异常。因为单张图调参非常容易过拟合看着那张图结果完美换一张图就露馅。这种做法养成习惯以后你会发现自己的代码稳定性和交付信心都提升了一大截。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门