ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC车型识别系统实战:图像差分与轮廓匹配解析

MFC车型识别系统实战:图像差分与轮廓匹配解析 简介这是一份基于 C 实现的车型识别系统源码包覆盖图像载入、车辆提取、轮廓提取和车型识别四个核心环节主要面向数字图像处理、模式识别方向的初学者以及需要完成课程设计或毕业设计的开发者。压缩包共包含 26 个文件核心代码集中在 8 个头文件和 6 个 cpp 源文件中另附 5 张 bmp 示例图像、2 个 ico 图标以及 rc、aps、vcproj 等工程资源整体仅 59KB工程结构清晰便于直接加载编译也可根据需要自行改造和扩展。系统实现了传统图像处理方式的完整识别链路载入背景与前景图像后依次执行图像做差、二值化、开运算、去噪、填充进而提取车辆轮廓并完成车型识别。随包提供的示例图像保存在 image 文件夹中可逐环节观察每个处理步骤的输出效果有助于理解背景差分、形态学操作和轮廓提取在车辆识别中的实际应用。目前已有 258 人学习下载适合希望快速上手车型识别项目、复现经典流程或将其作为二次开发模板的开发者。1. 为什么需要把车型识别做进 MFC 界面一个能跑通的图像差法示例拿到 CarShapeIdentify.rar 这个资源时我的第一反应是这年头还有人用 MFC 做车型识别但实际跑通以后我发现它把传统图像处理的完整链路展示得相当扎实。停车场道闸经常因为相机角度或者逆光拍出轮廓模糊的车辆云端识别接口又有时延边缘设备就需要一个不依赖网络的轻量方案。这个车型识别系统做的正是这件事把背景和前景图像载入用图像做差提取运动车辆再做二值化、开运算、去噪、填充最后提取轮廓匹配车型。它适合刚接触车辆识别系统的本科生、准备做课程设计的工程师以及想快速验证传统视觉思路的从业者。资源本身是 Visual C 工程结构清晰几个菜单项对应完整处理步骤改起来也很趁手。2. 读懂 CarShapeIdentify 的工程骨架从文件清单到图像载入2.1 工程文件不是摆设每个文件在流程里的角色资源解压后是一整套 MFC 单文档工程不是零散代码片段。这里面有很多文件但真正需要理解的核心其实就几个。先说CarShapeIdentifyView.cpp它负责响应菜单命令比如“载入图像”“车辆提取”“轮廓提取”“车型识别”每点一次菜单就调用对应的处理函数这是整个交互层。MyImg.cpp/MyImg.h封装了图像处理的底层算法包括图像做差、二值化、形态学操作等这是资源里最值得移植的部分。CarShapeIdentifyDoc.cpp是文档类负责存储当前图像数据以及处理后的结果在 MFC 的 Document/View 架构里视图负责显示文档负责数据。其余文件大多是 IDE 自动生成的stdafx.h是预编译头resource.h管理菜单和图标资源 IDMainFrm.cpp处理主框架窗口。还有图像文件0.bmp、1.bmp、2.bmp、3.bmp放在image文件夹下这些是测试用的背景图和前景图背景通常是空车道前景是包含车辆的同一角度画面。运行逻辑就是先载入一张背景和一张前景再依次执行处理步骤。我习惯先看ReadMe.txt和菜单资源里的字符串能大概知道作者预设的操作顺序比直接编译工程要快很多。调试时我一般会重新生成解决方案因为这个.rar里自带的是旧版.vcproj如果你用的是新版 Visual Studio会提示转换。转换后需要把工程属性里的字符集从“使用 Unicode 字符集”改成“未设置”或者把代码里的宽字符转换处理补齐否则CImage路径加载会报错。这个我放在后面避坑章节细说。2.2 用 CImage 载入背景与前景图代码与初始化验证在这个工程里载入图像不是靠文件对话框拖拽而是通过菜单项触发然后在视图类里调用CImage加载 BMP。常见做法是这样// CarShapeIdentifyView.cpp 中的菜单响应函数 void CCarShapeIdentifyView::OnLoadBackground() { CFileDialog dlg(TRUE, _T(bmp), _T(0.bmp), OFN_FILEMUSTEXIST | OFN_HIDEREADONLY, _T(BMP Files (*.bmp)|*.bmp||)); if (dlg.DoModal() IDOK) { CString path dlg.GetPathName(); if (m_bgImage.IsNull()) m_bgImage.Destroy(); HRESULT hr m_bgImage.Load(path); if (FAILED(hr)) { AfxMessageBox(_T(背景图加载失败)); return; } Invalidate(); } }参数说明m_bgImage是CImage成员变量用来持有背景图像数据m_fgImage类似持有前景图。Load是CImage的顶层加载接口支持 BMP、JPEG、PNG但在这个项目里测试图是 BMP路径不要带中文否则旧版 MFC 的CImage会因编码问题读不出来。加载完以后调用Invalidate()触发视图重绘在视图的OnDraw里把m_bgImage绘制到窗口上。我常用的验证方法是加载后马上看GetWidth()和GetHeight()如果返回 0说明图像其实没读进去只是CImage没抛异常。这个工程要求先载入背景和前景两张图再执行车辆提取就是因为做差需要两张尺寸完全一致的图像。如果你在菜单里只载入一张就去点“车辆提取”程序多半会崩溃因为做差时需要按像素取两个图像的地址空指针怎么算都躲不过去。3. 车辆提取的五步流水线做差、二值化、开运算、去噪、填充3.1 图像做差与二值化阈值选择车辆提取的核心起点是背景差分。假设背景图像固定当前帧里出现的车辆会和背景产生灰度差。这个工程里“车辆提取”菜单做的事就是先做差再二值化。做差公式很简单// MyImg.cpp 中图像做差与二值化简化示例 void MyImg::ImageDiff(BYTE* bg, BYTE* fg, BYTE* out, int width, int height, int threshold) { for (int i 0; i width * height; i) { int diff abs((int)fg[i] - (int)bg[i]); // 逐像素差 out[i] (diff threshold) ? 255 : 0; // 超过阈值置白否则置黑 } }注意这里用的是单字节灰度图如果原图是 24 位真彩要先转灰度。转换时常见做法是按亮度加权gray 0.299 * r 0.587 * g 0.114 * b。这个工程里0.bmp和3.bmp都是灰度 BMP所以直接按字节处理不会出错。阈值是二值化的关键。阈值太小路面阴影、树叶晃动都会被当成车辆阈值太大车体原本的灰度差异被抹平提取出来的区域会缺胳膊少腿。在这个示例里阈值通常在 3050 之间。我一般会把阈值写成可调参数用 Trackbar 调试而不是写死在代码里。图像做差期间还有一个经典问题两帧图像对齐。如果背景和前景不是同一相机位置拍的哪怕差一个像素边缘都会形成一条高亮带二值化后全是噪声。3.2 开运算与去噪形态学操作怎么改内核做差二值化之后画面上通常是一块白色车辆区域加一堆椒盐噪声。这时就要用到开运算先腐蚀再膨胀。腐蚀让白色区域缩小去掉边缘孤立的亮点膨胀再把区域补回来保持原来尺寸。这个工程里“开运算”菜单对应的正是这样一组操作。// 腐蚀操作像素周围 3x3 邻域内有一个黑色就把中心置黑 void MyImg::Erode(BYTE* img, int width, int height, int kernelSize) { int offset kernelSize / 2; BYTE* temp new BYTE[width * height]; memcpy(temp, img, width * height); for (int y offset; y height - offset; y) { for (int x offset; x width - offset; x) { bool ok true; for (int j -offset; j offset ok; j) for (int k -offset; k offset ok; k) if (temp[(y j) * width (x k)] 0) ok false; if (!ok) img[y * width x] 0; } } delete[] temp; }膨胀就是反过来邻域内有一个白点就把中心置白。开运算的内核大小这个工程用的是 3×3对应kernelSize 3。我觉得这个值偏小因为 3×3 只能去掉单像素噪声对一两个像素宽的椒盐噪声没什么抵抗。你可以改成 5×5但代价是车体边缘会被磨圆。这里没有绝对标准按照实际画面中噪声颗粒的大小来定。我在类似项目里一般会先用 3×3 跑一遍如果发现去噪不彻底再升级到 5×5。3.3 图像填充与连通域处理开运算之后白色车辆区域内部经常还有黑色空洞。原因是车身反光、玻璃区域在二值化时灰度差没到阈值被判定为背景。不把这些孔洞填上轮廓提取时车顶会缺一块识别特征会失真。这个工程里的“图像填充”就是处理这个问题的。最简单的填充是使用种子填充算法从车辆区域内部某一点开始扩散把封闭区域内的黑色像素改成白色。但种子点选在哪儿这里有个前提经过开运算后车辆区域是最大连通域可以先用连通域标记找出最大的白色连通块然后对该连通块所在的包围盒做内部填充。void MyImg::FloodFill(BYTE* img, int width, int height, int seedX, int seedY, BYTE oldColor, BYTE newColor) { std::queuestd::pairint, int q; q.push({seedX, seedY}); while (!q.empty()) { auto [x, y] q.front(); q.pop(); if (x 0 || x width || y 0 || y height) continue; if (img[y * width x] ! oldColor) continue; img[y * width x] newColor; q.push({x1, y}); q.push({x-1, y}); q.push({x, y1}); q.push({x, y-1}); } }逻辑说明这个遍历顺序是上下左右四邻域只填充和种子点颜色相同且连通的区域。如果车辆和背景还有细小连接填充会从那个连接处漏出去把外面也填了。所以传统做法是先用连通域分析提取最大连通域再做“孔洞填充”——也就是把该连通域包围盒内所有非白色的点置白。这样不会填到背景里去。填充完成之后车辆区域变成一整块实心白色轮廓干净后续提取才能稳定。这一步做完我建议立刻把img保存成临时 BMP 到磁盘用看图工具检查有没有把路面也填进来。如果路面被填了多半是开运算后路面的白色噪点连到了车体上这时需要回到形态学核大小去调整而不是强行调填充函数。4. 轮廓提取与车型识别当特征匹配遇上传统视觉4.1 车辆轮廓提取与轮廓筛选填充后的二值图白色区域就是候选车辆。轮廓提取这个菜单项本质是从二值图里找出白色区域的边界点。在 MFC 工程里最容易实现的是“八邻域边界追踪”从左到右、从上到下扫描遇到第一个白点就从该点出发沿着边界顺时针走一圈回到起点。void MyImg::TraceContour(BYTE* img, int width, int height, std::vectorPOINT contour) { int startX -1, startY -1; for (int y 0; y height startX 0; y) for (int x 0; x width; x) if (img[y * width x] 255) { startX x; startY y; break; } if (startX 0) return; int dx[8] {1,1,0,-1,-1,-1,0,1}; // 八方向偏移 int dy[8] {0,1,1,1,0,-1,-1,-1}; int curX startX, curY startY; int dir 7; // 从左上角开始找 do { contour.push_back({curX, curY}); bool found false; for (int i 0; i 8; i) { int ndir (dir i) % 8; int nx curX dx[ndir]; int ny curY dy[ndir]; if (nx 0 nx width ny 0 ny height img[ny * width nx] 255) { curX nx; curY ny; dir (ndir 5) % 8; // 转向下个搜索方向 found true; break; } } if (!found) break; } while (curX ! startX || curY ! startY); }这个函数返回的contour是边界像素点集合。参数说明startX/startY是扫描找到的第一个白色点通常位于车辆区域左上角。dx/dy是八个方向的偏移按顺时针排列。dir控制搜索方向采用“回溯优先”策略保证沿着边界走。这个算法对单个连通域有效如果图里有多个白色区域需要在外层循环里把已经标记过的区域清零再找下一个起始点。轮廓筛选的逻辑一般包括三样轮廓长度不得小于某个阈值比如画面宽度的十分之一轮廓包围盒的宽高比要在合理范围内轿车通常 1.02.5卡车 2.54.0轮廓面积占整个画面的比例不能太小。这个工程里如果直接提取可能会同时拿到路肩或标识牌的小轮廓不筛选的话识别就乱了。所以我一般会加一个步骤遍历所有轮廓只保留面积最大的那个作为车辆轮廓其他全部丢弃。4.2 特征设计与模板匹配轮廓出来后怎么识别车型这个工程没有用深度学习它用的是特征匹配。先把标准车型的轮廓特征算好存下来运行时等比例缩放后再算一遍特征找最近邻。我常用的特征有四个特征名计算方式说明宽高比包围盒宽 / 包围盒高轿车扁平卡车高瘦矩形度轮廓包围面积 / 包围盒面积轿车的车顶和后备箱阶梯让矩形度偏低轮廓长度相邻轮廓点距离之和轿车比 SUV 的轮廓周长略小面积比轮廓面积 / 图像总面积从相机视角看车的占比模板匹配的代码框架可以这样写struct CarFeature { float aspectRatio; float rectDegree; float contourLen; float areaRatio; }; CarFeature ExtractFeature(const std::vectorPOINT contour) { // 计算包围盒 int minX INT_MAX, maxX 0, minY INT_MAX, maxY 0; for (const auto p : contour) { minX min(minX, p.x); maxX max(maxX, p.x); minY min(minY, p.y); maxY max(maxY, p.y); } float width maxX - minX 1; float height maxY - minY 1; float contourLen 0; for (size_t i 0; i contour.size(); i) { auto a contour[i]; auto b contour[(i1) % contour.size()]; contourLen sqrt((float)((a.x-b.x)*(a.x-b.x)(a.y-b.y)*(a.y-b.y))); } return {width/height, 0.0f, contourLen, 0.0f}; } float Distance(const CarFeature a, const CarFeature b) { return fabs(a.aspectRatio - b.aspectRatio) * 2.0f fabs(a.contourLen - b.contourLen) / 1000.0f; }逻辑说明ExtractFeature里rectDegree和areaRatio我没有填因为计算面积需要多边形填充代码会多两屏。你要真用就把轮廓点集拿去FillPolygon再统计白点像素个数。Distance对宽高比加重权重因为宽高比是区分轿车和卡车最稳定的指标轮廓长度也要归一到同等画面宽度下否则相机远近一变就失效。这个工程在识别时会拿提取的车轮廓特征和预置的0.bmp或1.bmp等模板算距离距离小的那类就是识别结果。这里有个关键选型为什么不直接用相似度匹配因为轿车和卡车在不同角度下轮廓差异极大。正侧位时卡车像砖头轿车像扁盒子宽高比差异明显但正前位时两者都接近方形这时仅靠几何特征就分不开。所以我建议在这个工程基础上把特征维度再加一个“下方轮眉像素分布”或者用两到三个视角的模板做投票。这个工程虽然简单但作为课程设计或入门测试跑通后替换成自己的模板特征并不难。5. 避坑手记让 CarShapeIdentify 在新环境跑起来的常见问题5.1 载入图像后界面空白CImage 与 DC 的绑定时机现象菜单点击“载入图像”以后视图窗口一片灰白看不到图像。原因CImage数据加载成功了但在OnDraw里没有把图像贴到窗口 DC。或者Invalidate()在CImage::Load之后调用了但OnDraw里CImage还没有绑定到内存 DC绘制函数执行失败。解决在OnDraw里用双缓冲绘制。常见做法是先创建兼容内存 DC把CImage绘制到内存 DC再整体拷贝到窗口 DC。同时检查CImage::IsNull()如果返回 true 就直接return避免BitBlt崩掉。代码上先CDC* pDC GetDC(); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.Attach(m_bgImage.Detach()); // 把 CImage 句柄交给 CBitmap memDC.SelectObject(bmp); pDC-BitBlt(0, 0, width, height, memDC, 0, 0, SRCCOPY); bmp.Detach(); ReleaseDC(pDC);注意Detach之后不要忘记重新关联否则下次加载会出错。5.2 二值化后全是噪点阈值和环境光在打架现象点“车辆提取”后二值图除了车体整片路面都是密密麻麻的白点。原因阈值设得太低背景差异在阴影、光照不均处也被判定为前景。另一个原因是背景和前景图像拍摄时间间隔太长光线已经明显变化简单做差法在这种情况下天然失效。解决把阈值调到 50 甚至 80看噪点是否减少。实际项目里我会把做差方式从灰度差改成 Sobel 边缘差或者对差图像做一次高斯模糊再二值化这样噪声会平滑很多。在这个工程里最快的验证方式是先手动把threshold参数从默认值上调直到噪点消失。如果你改完后车体也快消失了说明图像补齐光线变化的问题不是调阈值能解决的需要重新采集背景。5.3 开运算把车体消除了内核大小不能拍脑袋现象执行开运算后车辆区域变成只剩半截像是被推土机铲了一刀。原因结构元素大小大于车体面积。如果画面中车辆本身就很小比如只有 30×40 像素你用 9×9 的内核去做腐蚀腐蚀一遍车体就没了。解决把kernelSize改成 3 试试。如果噪声还很多就改用两次 3×3 开运算而不是一次 7×7。多次小内核开运算能逐级去噪而单次大内核容易损伤目标边缘。这也是我血泪经验后来我统一规则内核大小不要超过目标最小宽度的一半。5.4 车型识别错乱轮廓内部空洞导致特征失真现象明明是一辆轿车识别结果却是卡车或者提示识别失败。原因填充步骤没有填干净轮廓提取把车顶和车窗的黑色空洞当成了内部边界提取出来的轮廓形状复杂化宽高比被拉大矩形度急剧变化最后特征距离计算错得离谱。解决回到“图像填充”菜单确认填充后的图中车辆区域是纯白色实心。如果是半黑半白先做一次连通域分析只保留最大连通域然后在这个连通域的包围盒里把所有非白像素置白。实际我在这个项目里更偏向使用FloodFill从包围盒中心开始填充中心点大概率在车体内部四邻域不会漏到外面去。5.5 工程转换后编译错误字符集不兼容现象在 VS2015 及以上版本打开.vcproj转换后编译报错错误集中在CString与char*的转换。原因旧工程默认使用多字节字符集新工程向导默认使用 Unicode 字符集。CImage::Load接受LPCTSTR在 Unicode 下char*字符串不能直接传入。解决在项目属性 - 常规 - 字符集里改为“使用多字节字符集”重新编译。如果代码里还有TCHAR相关的宏一般都能自动适应。另一个办法是把所有字符串改成_T(...)包裹这个工程量会稍大但更有利于跨平台复用。6. 把示例改成可用的验证工具记录每一帧的识别中间结果这个工程跑通以后你会发现一个问题菜单点太快处理过程的中间状态全都一闪而过图像做差后到底是车体提取有效还是噪声爆炸根本没时间检查。所以我建议你加一个“保存中间结果”的功能每执行完一步就把当前MyImg里的数据写成 BMP 文件到本地。具体实现可以放在菜单响应函数末尾复用CImage的保存接口void CCarShapeIdentifyView::OnVehicleExtract() { // 已有的做差、二值化、开运算、填充流程代码... CImage stepImg; stepImg.Create(width, height, 8); // 8位灰度图 memcpy(stepImg.GetBits(), processedData, width * height); stepImg.SetColorTable(0, rgb, 256); // 设置灰度调色板 stepImg.Save(_T(step_after_fill.bmp)); }这里GetBits()拿到的是像素存储区直接拷贝处理后的字节数组。SetColorTable是必须的否则 8 位图没调色板保存出来的 BMP 全是黑色。我一般会在开头加一个时间戳命名避免每次覆盖同一个文件。这样你就能在每次跑流程后打开step_*.bmp对比看哪一步出了问题。这个习惯源于一次我很狼狈的调试经历当时我在另一个项目里用这个工程做原型识别率突然从 80% 掉到 30%查了半天没找到原因后来把中间步骤的图全部存下来才发现是二值化阈值被一个全局变量污染了导致开运算前就已经把车底阴影和车体黏在一起。从那以后我每次做传统视觉流程都会强制把中间结果落盘改算法参数前先留一份“后悔药”。这个车型识别系统虽然代码结构偏老但你把它当作一个可视化的传统图像处理流水线学习载体再把日志和中间结果输出补上它能发挥的价值比我第一眼看到时预想的大得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表