VS2008+OpenCV+MFC实战:米粒自动计数与尺寸测量完整工程解析

发布时间:2026/10/18 14:37:19
VS2008+OpenCV+MFC实战:米粒自动计数与尺寸测量完整工程解析 简介面向VS2008OpenCVMFC开发者的图像处理示例工程以米粒图像为输入演示如何统计颗粒个数并计算最大米粒的面积与周长。工程代码清晰Debug版本可直接运行适合学习轮廓提取、连通域标记、面积与周长量测以及OpenCV在MFC对话框程序中的集成方式同时记录了LPCTSTR与const char*在字符集切换下的转换要点。压缩包共29个文件主要由H/CPP源码、RC界面资源、JPG/PNG/BMP测试图片和EXE调试程序构成APS/NCB/SUO等IDE辅助文件一并打包整体大小约9.42MB。已有1112人学习过该工程可作为计算机视觉课程设计或OpenCVMFC入门的参考。作者针对Release编译不通过的排错经验也有说明将项目字符集改为多字节即可避开宽字符转换问题方便读者少走弯路。1. 实验室数米粒的真实痛点这套VS2008OpenCV代码解决了什么年前接了个某食品加工厂的质检小工具需求场景特别朴素摄像头拍一盘米粒自动数出有多少颗找出最大的一颗报出它的面积和周长。第一次听到这需求我也觉得这算什么项目直到动手才发现在 VS2008 OpenCV MFC 这套老环境里要把图像预处理、粘连分割、连通域分析、面积周长测量全部串起来还要在 MFC 界面里实时展示原图和结果坑比想象中多得多。这套代码就是为了解决这个场景准备的一个基于 MFC 对话框的完整工程用 OpenCV 做图像处理链路最终输出米粒总数、最大米粒的面积和周长并且在图片控件上把最大米粒用红色框标出来。它适合正被老工程绑架、需要在 VS2008 环境里做图像处理的开发者也适合农粮检测、颗粒计数方向的初学者拿来当模板。2. 工程结构和技术选型一个对话框程序如何撑起完整的图像处理链路2.1 文件划分与模块职责这个工程不是把所有代码塞进 MFC 的 Dlg 文件里而是把界面和算法拆成了三层。打开工程后你会看到这样的文件组织RiceCounter/ ├── RiceCounterDlg.h / .cpp // MFC 对话框按钮、图片控件、结果列表 ├── CRiceProcessor.h / .cpp // 图像处理核心预处理、分割、计数、测量 ├── CImageConvert.h / .cpp // Mat 与 CImage 的显示转换辅助函数 ├── res/ // 工程资源文件 └── test_images/ // 测试用米粒图片RiceCounterDlg 只做界面交互响应按钮点击、刷新 Picture Control、往 ListBox 里塞结果。CRiceProcessor 是一个不依赖 MFC 的普通 C 类输入是图片路径输出是米粒的结构体数组和结果图这样设计的好处是图像算法部分可以在控制台工程里独立测试不必每次改算法都打开满屏 MFC 的调试环境。CImageConvert 负责把 OpenCV 的 Mat 转成能在 MFC 控件上显示的形式。这个分层我一般会保留因为后续如果要加批量文件夹处理、或者把算法替换成其他方案只需要改 CRiceProcessor 内部Dlg 层的代码可以完全不动。2.2 处理链路的算法选型为什么是阈值 形态学 连通域米粒计数的核心难点不在算法多高级而在目标特征很明确米粒是浅色白/米黄背景是深色托盘这是典型的灰度阈值分割场景。完整的处理链路是灰度化 → 中值滤波 → 二值化 → 形态学开运算 → 连通域分析 → 面积/周长测量最开始我也考虑过边缘检测Canny加轮廓查找的方案但米粒表面有纹理和胚芽痕迹Canny 经常会在一颗米粒上检测出好几条碎边缘后续合并轮廓的工作量比计数本身还大。而固定阈值分割对这类高对比度场景极其稳定一两次标定就能用。分水岭算法适合米粒重度粘连的情况但参数爆炸对现场操作人员不友好这版代码里没有用。形态学开运算是整套链路里最容易被忽略的一步。两颗米粒靠在一起拍摄时二值化之后经常连成一个白色连通区域如果不加开运算这两颗米会被数成一粒。开运算先腐蚀后膨胀能把细小的连接处断开同时保留每颗米的主体形状。2.3 环境配置VS2008 OpenCV 2.3.1a 的工程属性设置这套代码用的是 OpenCV 2.3.1a这个版本是官方二进制包最后一个完整支持 VS2008VC9的版本之后 OpenCV 2.4 系列的官方包重心就转向 VS2010 了。如果项目里装了新版 OpenCV 并自己编译过 VC9 版本代码同样可以编译只是宏名略有差异。配置时在工程属性里按三步走。第一步是在“VC 目录”里把 Include 指向opencv\include\opencv和opencv\include\opencv2库目录指向opencv\lib。第二步是在“链接器 → 输入 → 附加依赖项”里填写库文件这一步最讲究。Debug 版本 opencv_core231d.lib opencv_imgproc231d.lib opencv_highgui231d.lib Release 版本 opencv_core231.lib opencv_imgproc231.lib opencv_highgui231.lib注意Debug 配置必须用带d后缀的库Release 配置必须用不带d的库两者混用编译时可能通过运行时崩得毫无规律。第三步是把opencv\bin目录下的opencv_core231d.dll、opencv_imgproc231d.dll、opencv_highgui231d.dll复制到 exe 输出目录。这一步不做的话程序启动会直接弹“缺少 DLL”的黑匣子错误。3. 核心算法拆解从灰度图到米粒清单的完整代码3.1 图像读取和预处理灰度化、中值滤波与二值化CRiceProcessor 的入口函数接收图片路径第一件事是读图并转灰度。RGB 三通道对计数没有任何帮助只会增加计算量。中值滤波在这里比高斯滤波更合适因为摄像头传感器在低照度下会产生椒盐噪声中值滤波对这类噪声的抑制效果明显好于线性滤波而且对米粒边缘的模糊程度更低。// CRiceProcessor.cpp #include CRiceProcessor.h bool CRiceProcessor::Process(const char* path, std::vectorRiceItem results) { // 直接读灰度图避免后续重复做颜色转换 cv::Mat gray cv::imread(path, CV_LOAD_IMAGE_GRAYSCALE); if (gray.empty()) { return false; // 路径无效或文件损坏 } // 5x5 中值滤波去掉拍摄噪声同时保留米粒边缘 cv::Mat blur; cv::medianBlur(gray, blur, 5); // 固定阈值二值化米粒亮、背景暗超过阈值记 255 cv::Mat binary; cv::threshold(blur, binary, 150, 255, CV_THRESH_BINARY); }imread的第二个参数CV_LOAD_IMAGE_GRAYSCALE是 OpenCV 2.3 时代的写法新版可以写成IMREAD_GRAYSCALE效果相同。medianBlur的5是卷积核边长必须是大于 1 的奇数核越大去噪越强但边缘越模糊对米粒这类较大目标直径普遍在 80 像素以上5 是比较稳的起点。threshold的150是分割阈值像素灰度大于 150 的置为 255低于的置为 0这个值依赖现场光照情况第 6 章会对它做自适应改造。3.2 形态学开运算断开粘连米粒二值化之后粘连问题开始变得明显。如果直接对二值图做 findContours两个挨在一起的米粒轮廓连成一个大扁圆计数结果会严重偏少。开运算的作用就是先腐蚀断连、再膨胀还原尺寸。// 椭圆核比矩形核更贴近米粒形状避免把边缘磨成直角 cv::Mat kernel cv::getStructuringElement( cv::MORPH_ELLIPSE, cv::Size(7, 7)); cv::Mat opened; cv::morphologyEx(binary, opened, cv::MORPH_OPEN, kernel);getStructuringElement的第一个参数MORPH_ELLIPSE生成一个椭圆形状的结构元素比矩形核更适合处理米粒这类近椭圆形目标。cv::Size(7, 7)是结构元素尺寸这个参数是整个流程里最吃经验的设太小3x3起不到断开作用设太大15x15则小碎米粒会被直接腐蚀消失。我一般按目标直径的 1/10 到 1/5 取这个值。例如画面里单颗米粒宽度约 100 像素粘连缝隙大概 5~10 像素用 7x7 的核就既能切断粘连又不会伤到米粒主体。如果实测发现开运算之后还有米粒连在一起优先加大结构元素而不是去改阈值。3.3 连通域分析计数、面积、周长一次算清预处理完成后最核心的一步是用findContours提取所有米粒的外轮廓。这个函数会自动给每个白色连通区域生成一个轮廓点集轮廓的数量就是计数结果。面积用contourArea计算周长用arcLength计算。// 注意这里必须 clone()findContours 会修改输入图像 std::vectorstd::vectorcv::Point contours; cv::findContours(opened.clone(), contours, CV_RETR_EXTERNAL, CV_CHAIN_APPROX_SIMPLE); // 原图转彩色用于把最大米粒框出来 cv::Mat color; cv::cvtColor(gray, color, CV_GRAY2BGR); int maxIndex -1; double maxArea 0.0; results.clear(); for (size_t i 0; i contours.size(); i) { double area fabs(cv::contourArea(contours[i])); // 过滤碎屑面积小于 30 平方像素的噪声点直接丢弃 if (area 30.0) continue; double perimeter cv::arcLength(contours[i], true); RiceItem item; item.index (int)results.size() 1; item.area area; item.perimeter perimeter; results.push_back(item); // 记录面积最大的米粒下标 if (area maxArea) { maxArea area; maxIndex (int)i; } } // 把最大米粒用红色轮廓标记 if (maxIndex 0) { cv::drawContours(color, contours, maxIndex, CV_RGB(255, 0, 0), 2); cv::Rect r cv::boundingRect(contours[maxIndex]); cv::rectangle(color, r, CV_RGB(255, 0, 0), 1); }findContours修改传入的输入图是 OpenCV 长期以来的一个行为它会为了加速把源图内部标记改写所以传进去之前必须clone()一份否则接下来所有操作都会在脏数据上进行。CV_RETR_EXTERNAL只提取最外层轮廓米粒表面如果有凹陷纹理内部轮廓不会被计入避免重复计数。CV_CHAIN_APPROX_SIMPLE表示只保留端点压缩轮廓存储。contourArea返回的是像素面积也就是这个米粒在图像里占了多少个像素点。要换算成真实物理面积需要用已知尺寸的物体拍一张标定图算出每个像素对应的毫米数再换算。arcLength的第二个参数true表示轮廓是闭合的这符合米粒区域的实际形状。fabs是对面积取绝对值因为轮廓点的方向不同时contourArea可能返回负值虽然findContours的输出方向通常是固定的但这一步可以避免玄学问题。3.4 结果输出形式处理结束后results数组里保存了每颗米粒的编号、面积和周长MFC 界面端把它逐条填进 ListBox。一次典型输出长这样编号面积px²周长px1523.691.22601.497.83488.286.54712.8107.35659.1101.6同时color这个 Mat 已经画上了最大米粒的红色轮廓和外接矩形直接交给显示模块即可。如果现场还需要把像素单位换算成毫米可以在 CRiceProcessor 里增加一个scale成员变量从外部传入标定值。4. MFC界面串联从按钮点击到图片回显的完整链路4.1 界面布局和消息映射MFC 对话框的资源编辑器里放三类控件两个按钮打开图片、开始处理、一个 Picture Control图片显示区域、一个 ListBox结果列表。在RiceCounterDlg.h里给 Picture Control 添加CStatic类型的控件变量m_pictureCtrl给 ListBox 添加CListBox类型变量m_resultList。// RiceCounterDlg.h class CRiceCounterDlg : public CDialog { CStatic m_pictureCtrl; CListBox m_resultList; CString m_imagePath; };按钮消息映射写在 Dlg 的 BEGIN_MESSAGE_MAP 里BEGIN_MESSAGE_MAP(CRiceCounterDlg, CDialog) ON_BN_CLICKED(IDC_BTN_OPEN, CRiceCounterDlg::OnBnClickedBtnOpen) ON_BN_CLICKED(IDC_BTN_PROCESS, CRiceCounterDlg::OnBnClickedBtnProcess) END_MESSAGE_MAP()这套映射规则在 VS2008 里已经高度自动化双击按钮控件VS 会自动生成空函数体不用手写消息映射宏。4.2 打开图片CFileDialog 与路径转换“打开图片”按钮弹出 CFileDialog用户选择图片后路径存在CString m_imagePath里。这里有一个 VS2008 老工程特有的坑工程字符集如果是“使用 Unicode 字符集”CString内部是wchar_t*而 OpenCV 2.3 的cv::imread接收const char*。void CRiceCounterDlg::OnBnClickedBtnOpen() { CFileDialog dlg(TRUE, NULL, NULL, OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST, _T(图片文件|*.jpg;*.png;*.bmp|所有文件|*.*||), this); if (dlg.DoModal() IDOK) { m_imagePath dlg.GetPathName(); m_pictureCtrl.SetWindowText(_T(图片已加载点击开始处理)); } }这里只保存路径不立即加载图片。真正要传给cv::imread的时候再做字符集转换转换放在处理按钮里避免对话框流程混杂算法代码。4.3 处理按钮调用 CRiceProcessor 并回显结果处理按钮是整个交互链路的枢纽它把路径转换、算法调用、界面回显三段粘在一起。void CRiceCounterDlg::OnBnClickedBtnProcess() { if (m_imagePath.IsEmpty()) { AfxMessageBox(_T(请先打开图片)); return; } // Unicode 路径转 ANSIimread 在 VS2008 环境里走的是系统 ANSI 编码 char szPath[MAX_PATH] {0}; WideCharToMultiByte(CP_ACP, 0, m_imagePath, -1, szPath, MAX_PATH, NULL, NULL); std::vectorRiceItem results; CRiceProcessor processor; if (!processor.Process(szPath, results)) { AfxMessageBox(_T(图像处理失败请检查图片路径)); return; } // 清空旧列表并写入结果 m_resultList.ResetContent(); for (size_t i 0; i results.size(); i) { CString line; line.Format(_T(第 %d 粒 面积 %.1f 周长 %.1f), results[i].index, results[i].area, results[i].perimeter); m_resultList.AddString(line); } // 显示结果图 ShowResultImage(processor.GetResultMat()); }WideCharToMultiByte(CP_ACP, ...)的作用是把CStringW转成 GBK 编码的char*这与 Windows 上 ANSI 路径的文件系统调用一致。GetResultMat()是 CRiceProcessor 对外暴露的接口返回前文画好红色标记的 Mat。4.4 图片显示用 StretchDIBits 替代 CvvImage网上大量老 MFC OpenCV 教程用的是 OpenCV 1.x 时代的CvvImage类这个类在 OpenCV 2.0 之后被移出官方库代码里硬塞进来反而要自己维护一堆头文件。我习惯直接用StretchDIBits把 Mat 数据绘制到 Picture Control干净利落。// CImageConvert.cpp void ShowMatInPicture(CWnd* pWnd, const cv::Mat src) { cv::Mat img; if (src.channels() 1) cv::cvtColor(src, img, CV_GRAY2BGR); else img src.clone(); CRect rc; pWnd-GetClientRect(rc); CClientDC dc(pWnd); BITMAPINFO bmi; memset(bmi, 0, sizeof(bmi)); BITMAPINFOHEADER head bmi.bmiHeader; head.biSize sizeof(BITMAPINFOHEADER); head.biWidth img.cols; head.biHeight -img.rows; // 负值表示自顶向下存储 head.biPlanes 1; head.biBitCount 24; head.biCompression BI_RGB; StretchDIBits(dc.GetSafeHdc(), 0, 0, rc.Width(), rc.Height(), 0, 0, img.cols, img.rows, img.data, bmi, DIB_RGB_COLORS, SRCCOPY); }两处关键细节第一biHeight必须取负值因为 Windows DIB 默认自底向上存储而 OpenCV 的 Mat 数据是从左上角开始的自顶向下排列不取负值图像会上下颠倒第二Mat 的通道顺序是 BGRbiBitCount 24的 DIB 像素排列也是 BGR两者天然一致不需要手动调换红蓝通道。调用端只需在 Dlg 里获得结果图的 Mat然后传入控件指针即可void CRiceCounterDlg::ShowResultImage(const cv::Mat mat) { ShowMatInPicture(m_pictureCtrl, mat); }5. 避坑记录VS2008 OpenCV 组合的五个经典问题5.1 编译链接失败Debug 和 Release 的 lib 混用现象编译时报一堆LNK2019: unresolved external symbol定位到cv::imread这类基础函数都找不到。有时候 Debug 编译通过一运行就弹“内存不能为 read”然后崩溃。原因工程在 Debug 配置下链接了不带d后缀的 release 库或者反过来。OpenCV 的库文件命名里opencv_core231d.lib的d是 debug 标志两个版本的内部符号和内存分配器不同一旦混用链接器要么找不到符号要么生成一个运行时行为随机的 exe。解决项目属性 → 配置管理器里分别选中 Debug 和 Release各自配置附加依赖项。Debug 统一用带d的 libRelease 统一用不带d的并且确认 VC 目录里的库路径在两种配置下都生效。5.2 中文路径图片加载失败Unicode 路径转 GBK现象cv::imread返回的 Mat 是空gray.empty()为 true但同样的图片放到纯英文路径下就能正常读取。原因VS2008 默认新工程用 Unicode 字符集CString是wchar_t*而cv::imread在 Windows 上调用的是 ANSI 版本的fopen。直接把宽字符串强转成char*只能拿到第一个字符路径完全错乱。解决在用cv::imread之前执行一次WideCharToMultiByte(CP_ACP, ...)把宽字符转成 GBK 编码的窄字符串。如果项目本身用的是多字节字符集就不需要这步转换CString直接就能给imread用。5.3 阈值靠猜导致计数不稳定同样一盘米换个光源结果差几十颗现象上午数出来 230 粒下午补了个灯光再跑一次变成 190 粒轮廓面积也整体变小。原因固定阈值 150 是“拍脑袋”定的光照变强时背景灰度抬升部分米粒和背景混在一起光照变弱时米粒边缘被吞掉面积缩小。阈值分割的适用前提是前景和背景灰度分布稳定照片拍摄环境一变这个前提就没了。解决先调出灰度直方图观察米粒峰值和背景峰值的位置再决定阈值落在哪个谷底。更省事的做法是直接用 Otsu 自适应阈值算法根据直方图自动寻找最优分割点这个改造在第 6 章给出代码。5.4 粘连米粒数成一颗开运算结构元素太小现象实际 20 粒米结果只数出 17 粒查看输出图发现两颗米挤在一起红色矩形框把两块区域框成一个整体。原因二值化之后相邻米粒的白色区域连成一片findContours把它们识别成一个连通区域。开运算结构元素 3x3 太小没能把连接处切断。解决把getStructuringElement的核从cv::Size(3, 3)加大到cv::Size(7, 7)甚至cv::Size(9, 9)观察输出图里粘连是否断开。核的尺寸不要超过单颗米粒宽度的三分之一否则小颗米粒会被腐蚀成一团噪声然后被面积过滤器丢进碎屑列表。5.5 Picture Control 显示错乱DIB 存储顺序和 Mat 串行布局现象处理结果图显示出来上下颠倒或者颜色看起来像红蓝通道互换图像边缘还有斜向撕裂。原因DIB 默认按自底向上的扫描线顺序存储如果BITMAPINFO里biHeight填了正数而 Mat 的 data 是自顶向下布局图像就会翻转。颜色撕裂问题通常是因为biBitCount写成 32 但 Mat 还是 24 位像素步长对不上。解决biHeight统一填负数告诉 GDI 数据是自顶向下排列。biBitCount保持 24 并与CV_8UC3的 Mat 对应。如果 Mat 是 ROI 或子矩阵它的data字段指向的不是连续内存用之前要clone()否则StretchDIBits会把行尾对齐空隙当成像素读进去图中会出现斜条纹。6. 二次开发思路把固定阈值改成自适应再加一个 CSV 导出先用第 5 章最常见的痛点开刀固定阈值 150 换个光源就废。改法很简单在cv::threshold的 flags 参数里加上CV_THRESH_OTSU阈值参数填 0 就行。// 原来cv::threshold(blur, binary, 150, 255, CV_THRESH_BINARY); double otsuThreshold cv::threshold(blur, binary, 0, 255, CV_THRESH_BINARY | CV_THRESH_OTSU); CString info; info.Format(_T(自动阈值: %.0f), otsuThreshold); AfxMessageBox(info);CV_THRESH_OTSU会让函数遍历所有灰度级按类间方差最大原则自动选出最优阈值返回值就是计算出的分割点。现场测试时先弹一次这个值记录不同灯光下的波动范围如果波动小于 20就放心把这版交给操作员了。第二个实用功能是把结果批量导出成 CSV。修改 CRiceProcessor 的接口增加一个SaveResults方法bool CRiceProcessor::SaveResults(const std::vectorRiceItem results, const char* csvPath) { FILE* fp NULL; fopen_s(fp, csvPath, w); if (!fp) return false; // 加 UTF-8 BOM否则 Excel 打开中文表头会乱码 fprintf(fp, \xEF\xBB\xBF); fprintf(fp, 编号,面积px,周长px\n); for (size_t i 0; i results.size(); i) { fprintf(fp, %d,%.2f,%.2f\n, results[i].index, results[i].area, results[i].perimeter); } fclose(fp); return true; }这段代码注意两个细节fopen_s是 VS2008 的 CRT 安全函数直接用fopen会被编译器警告文件开头写 UTF-8 BOM 是为了让 Excel 正确识别中文表头和列内容。这两个改造做完这个工程已经从“能跑的数米粒 Demo”升级成现场可用的质检工具。操作员打开软件、选图片、点处理结果自动存成 CSV回到工位上用 Excel 做统计。回看整个项目最大的经验其实在第 5 章那些翻车记录里老环境配老库出问题时先怀疑配置再怀疑代码逻辑。从那以后我每写一个图像处理工程都会先用直方图把灰度分布看清楚再决定阈值策略而不是直接抄上一张图的参数。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询