简介:一款基于MFC框架的车型与车颜色识别系统源码资源,面向计算机视觉、智能交通及C++桌面应用开发者,可解决车辆类型自动分类、车身颜色判别与界面交互相结合的工程实现问题。包内共117个文件,以lib、dll运行库和cpp、h源码为主,另含dat训练数据、训练模型及VC工程配置文件,压缩包约24.86MB,便于在Visual Studio中直接打开编译与二次拓展。代码中提供了svm分类器、目标提取、车辆跟踪和颜色识别等模块,train.dat作为样本数据,覆盖从特征训练到识别结果返回的完整流程,适合学习传统图像处理与机器学习结合的落地方式。已有111人学习使用,对想快速上手MFC+视觉识别项目的开发者而言,这套源码能提供可运行的工程骨架和调试验证思路。
1. 一个 ZIP 名字背后的项目:MFC_windows.zip 到底要解决什么问题
如果你在 Windows 环境里做视觉项目,大概率接过类似MFC_windows.zip这样的压缩包:名字很直白,打开后是一个基于 MFC 的桌面程序,里面除了.exe还有模型文件、DLL 和一堆自以为精简的依赖。标题里的“车型识别”和“车颜色识别”点明了这个系统的两个核心任务:先检测画面里的汽车是哪一款,再判断这辆车的车身颜色。这类需求常见于停车场管理、无人值守岗亭、监控稽查等场景,难点往往不在算法本身,而在于把 Python 里的模型推理包装成一个双击就能跑的 Windows 程序。MFC 在这里不是最时髦的选择,却是交付现场最稳的选择:不依赖 Python 环境、不要求目标机器装 CUDA,用 C++ 编译出来后直接塞进 zip 就能分发。下面按“工程集成 → 车型推理 → 颜色分类 → 打包发布”四个阶段讲清楚,新手能照做,老手可以重点关注参数边界和打包排错。
2. MFC 工程里集成推理引擎:先让识别代码跑起来
2.1 选型:OpenCV 和 ONNX Runtime 怎么分工
车型识别系统的算法链路通常是“图像预处理 → 检测模型 → 后处理 → 颜色判断”。在 C++ 环境里,我选择用 OpenCV 做图像读取、缩放、颜色空间转换和画框,用 ONNX Runtime 加载导出的检测模型。理由很实际:OpenCV 的 DNN 模块也能跑 ONNX,但 ONNX Runtime 在 CPU 上的线程调度更可控,还能在推理时挂上 profiling,方便排查哪一帧超时。
OpenCV -> cv::Mat, cv::dnn::blobFromImage, cv::resize, cv::cvtColor ONNX RT -> Ort::Session, Run() 输出 float 数组 MFC -> Picture 控件显示结果,按钮触发 Capture/Detect这套分工保证界面线程不被推理阻塞。如果检测耗时超过 100ms,我一般会把推理放到工作线程里,再用PostMessage把结果回传给主线程刷新控件。直接在主线程跑推理,窗口拖动时会卡顿,现场体验很差。
顺带说一个容易被坑的点:OpenCV 默认按 BGR 读取图像,ONNX Runtime 模型训练时通常用 RGB。你必须在blobFromImage里设置swapRB=true,否则车型识别准率会断崖式下跌。这不是模型问题,是通道顺序错了。
2.2 让控制台程序支持 MFC:工程配置与依赖准备
基于 MFC 的 Windows 应用最省事的起点是 Visual Studio 的 MFC 应用程序向导,选“基于对话框”即可。但很多团队手头已经有控制台程序,想把识别逻辑直接塞进 MFC 工程。这时先让控制台程序支持 MFC,比迁移代码更高效。
常见做法是复制原main.cpp中的算法函数到 MFC 对话框类的按钮回调里,然后做三步配置:
- 项目属性 → 常规 → “使用 MFC”选“在共享 DLL 中使用 MFC”。
- 项目属性 → C/C++ → 预处理器定义里加
_AFXDLL。 - 链接器 → 输入 → 附加依赖项里添加:
opencv_world480.lib、onnxruntime.lib,Debug 版本对应opencv_world480d.lib。
如果你不想装完整 Visual Studio,想让 VSCode 编译 MFC,也能做:用 VSCode 打开工程后配置tasks.json,调用vcvars64.bat里导出的cl.exe和nmake编译。但 MFC 向导生成的资源文件.rc在 VSCode 里没有可视化编辑器,改对话框布局很痛苦,所以我只在纯算法调试时用 VSCode,界面调整还是回到 Visual Studio。
下面是一个最小 MFC 对话框按钮回调,把图片文件加载成cv::Mat并显示到控件里:
void CCarDlg::OnBnClickedButtonOpen() { CFileDialog dlg(TRUE, NULL, _T("*.jpg"), OFN_FILEMUSTEXIST, _T("Image Files (*.jpg;*.png;*.bmp)|*.jpg;*.png;*.bmp|All Files (*.*)|*.*||"), this); if (dlg.DoModal() != IDOK) return; CString path = dlg.GetPathName(); cv::Mat mat = cv::imread(CT2A(path.GetString())); if (mat.empty()) { AfxMessageBox(_T("img empty")); return; } // 将 Mat 转成 CImage 再贴到 Picture 控件 CImage image; MatToCImage(mat, image); CRect rect; m_pictureCtrl.GetClientRect(&rect); CDC* dc = m_pictureCtrl.GetDC(); image.StretchBlt(dc->m_hDC, 0, 0, rect.Width(), rect.Height(), SRCCOPY); m_pictureCtrl.ReleaseDC(dc); }CT2A用于把宽字符CString转成 UTF-8 传给 OpenCV。MatToCImage的逻辑是把cv::Mat逐通道拷贝到BITMAPINFO里,这里省略了实现,但要注意 BGR 到 RGB 的转换,否则车辆涂装颜色会偏蓝。
2.3 CImage 与 Mat 的转换:从 MFC 控件到模型输入
MFC 里拿图像有三种来源:文件对话框、摄像头、视频文件。文件方式见上面代码;摄像头和视频文件统一推荐cv::VideoCapture:
cv::VideoCapture cap; if (cap.open(0)) { // 0 表示默认摄像头 cv::Mat frame; cap >> frame; }但 MFC 的Picture控件需要的是CImage,所以帧数据每次都要转一次。我封装了一个MatToCImage,核心思路是创建CImage后用SetPixelAddress直接接管cv::Mat的内存,避免拷贝。注意cv::Mat的step可能大于宽度乘以通道数,CImage 的 pitch 也有对齐,直接按行拷贝最稳。
提示:不要在每一帧里频繁
new CImage,否则内存碎片会让程序跑十分钟后崩掉。建议在对话框初始化时创建好 CImage,后续只更新像素数据。
模型推理前还有一步预处理:cv::dnn::blobFromImage。输入尺寸要根据模型决定,常见 YOLO 系列用 640x640:
cv::Mat blob = cv::dnn::blobFromImage(mat, 1.0 / 255.0, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false);参数含义:1.0/255.0做归一化,Size(640,640)是模型输入分辨率,Scalar是均值,swapRB=true是因为 OpenCV BGR 转模型需要的 RGB。把blob塞进 ONNX Runtime 的Run()后,拿到输出数组,下一章处理检测框。
3. 车型识别模型的输出与后处理:从张量到检测框
3.1 YOLO/ONNX 输出张量布局
检测模型导出成 ONNX 后,输出张量通常是一个三维数组:[1, 84, 8400]或者[1, 25200, 85],取决于导出代码里有没有转置。前一个维度是 batch size,中间维度是“4 个框坐标 + 类别数”,最后一个维度是候选框数量。车型识别如果是 COCO 的 80 类,那就是4 + 80 = 84,如果是自定义的轿车、SUV、卡车等 5 类,中间维度就是4 + 5 = 9。
拿到 ONNX Runtime 输出后,要判断数据布局:
C 是通道维度 = 4 + num_classes N 是候选框数量 如果输出 shape 是 (1, C, N) => 坐标按列访问,输出名称通常为 output0 如果输出 shape 是 (1, N, C) => 坐标按行访问,输出名称可能带 transpose我的习惯是在 C++ 里打印session.GetOutputTypeInfo()的 shape,确认后再写解析代码。直接硬编码(1, 25200, 85),换一个模型就崩。
3.2 解码检测框、置信度过滤与 NMS
ONNX 输出的坐标一般是归一化的cx, cy, w, h,需要映射回原图尺寸。下面是一段可编译的 C++ 后处理核心代码,使用 ONNX Runtime 输出到std::vector<float>:
void DecodeOutput(const float* data, int numCandidates, int numClasses, float confThreshold, float iouThreshold, float scaleX, float scaleY, std::vector<cv::Rect>& boxes, std::vector<float>& scores, std::vector<int>& classIds) { std::vector<cv::Rect> tmpBoxes; std::vector<float> tmpScores; std::vector<int> tmpIds; for (int i = 0; i < numCandidates; i++) { const float* ptr = data + i * (4 + numClasses); float objConf = ptr[4]; // 如果模型没有单独 objectness,则这里可能是类别分数 if (objConf < confThreshold) continue; float cx = ptr[0]; float cy = ptr[1]; float w = ptr[2]; float h = ptr[3]; // 归一化坐标转换为原图像素坐标 int x = static_cast<int>((cx - w / 2) * scaleX); int y = static_cast<int>((cy - h / 2) * scaleY); int bw = static_cast<int>(w * scaleX); int bh = static_cast<int>(h * scaleY); tmpBoxes.push_back(cv::Rect(x, y, bw, bh)); tmpScores.push_back(objConf); tmpIds.push_back(0); // 如果做多类别,这里需要读取类别分数 argmax } // NMS 抑制重叠框 std::vector<int> keep; cv::dnn::NMSBoxes(tmpBoxes, tmpScores, confThreshold, iouThreshold, keep); boxes.clear(); scores.clear(); classIds.clear(); for (int idx : keep) { boxes.push_back(tmpBoxes[idx]); scores.push_back(tmpScores[idx]); classIds.push_back(tmpIds[idx]); } }注意上面代码我简化成了单类别检测,实际车型识别需要从ptr[4+classIndex]里取最大类别分数。参数scaleX和scaleY是原图宽高除以输入尺寸(640)。如果模型是矩形推理,比如1280x736,必须分别计算宽高比,不能用同一个缩放值。NMSBoxes最后一个参数keep是保留框的索引,iouThreshold通常设 0.45~0.5,车挨得近时设 0.4 更稳。
3.3 三个要调的核心参数:conf_thres、iou_thres、输入尺寸
车型识别系统调到什么参数,取决于现场机位和视角。下表是我从多个项目里总结出的默认区间,后续按场景微调:
| 参数 | 默认值 | 范围 | 调参逻辑 |
|---|---|---|---|
| conf_thres | 0.45 | 0.25~0.6 | 摄像头俯拍、车小,降到 0.3;闸机正面近景,0.5 以上 |
| iou_thres | 0.45 | 0.35~0.6 | 车辆重叠多时降,比如排队堵车场景,降到 0.4 |
| 输入尺寸 | 640x640 | 320~1280 | 模型支持范围,越大越准但延迟线性增加 |
| 类别分数 | argmax | 无 | 多类别时取分数最高的类,不要超过 conf_thres 的类别也画框 |
| swapRB | true | false/true | 训练时用 RGB 图像则必须 true |
一个很容易踩的坑是:把conf_thres同时用于“整框置信度”和“类别置信度”。我见过有同事用 YOLOv5 导出的 ONNX,输出头里没有 objectness,直接拿ptr[4]当整框置信度,结果阳光强烈的场景下误检暴增。正确做法是先看模型文档输出头是五维还是四维。为了保险,我在代码里加了一个开关,用于决定第 4 位是否参与过滤。
提示:现场调试时把检测框和分数直接画到 Picture 控件上,不要只看日志。肉眼确认漏检和误检的速度比调阈值快得多。
4. 车颜色识别:用 HSV 统计而不是训练一个分类网
4.1 车身区域怎么取:检测框内避开玻璃和影子
颜色识别通常在车型检测框基础上做,而不是把整张图喂给分类网络。原因很简单:车身颜色受车顶、侧面、阴影、高光影响,整图统计会被天空和路面污染。常见做法是把检测框的上半部分沿水平方向切出“车身带”。
我的经验是取检测框左上角(x + w * 0.1, y + h * 0.15),宽度w * 0.8,高度h * 0.3。这个区域覆盖车顶和前机盖的一部分,避开了挡风玻璃和保险杠。夜间场景下这个区域偏黑,需要额外开补光灯或用多帧融合,不能只靠颜色判断。
切出 ROI 后先做高斯模糊,再用cv::cvtColor转到 HSV 色彩空间。HSV 比 RGB 更适合光照变化大的室外,因为 V 分量受亮度影响,S 和 H 相对稳定。
4.2 常见车色的 HSV 范围表
车身颜色不可能用一个精确点表示,必须给每个色系一个 HSV 范围。下面是我在白天场景下使用的经验表:
| 颜色 | H 范围 | S 范围 | V 范围 |
|---|---|---|---|
| 黑色 | 0~180 | 0~80 | 0~60 |
| 白色 | 0~180 | 0~30 | 60~255 |
| 灰色 | 0~180 | 0~60 | 60~180 |
| 红色 | 0~10 / 156~180 | 100~255 | 70~255 |
| 橙色 | 11~25 | 100~255 | 70~255 |
| 黄色 | 26~34 | 80~255 | 80~255 |
| 绿色 | 35~80 | 60~255 | 60~255 |
| 蓝色 | 81~125 | 80~255 | 60~255 |
| 紫色 | 126~155 | 60~255 | 60~255 |
注意 H 在 OpenCV 里是 0~180,不是 HSL 的 360。红色横跨 0 附近,需要两个区间合并统计。白色和灰色、黑色之间的边界很容易混,关键差异在 V 和 S 上,所以把 V 阈值拆细。
4.3 在 MFC 工程里实现颜色投票
颜色判断我用投票法:把 ROI 的 HSV 像素逐点分类,统计每个色系的像素数,最大者胜出。下面是可放进 MFC 类的成员函数:
CString CCarDlg::GetCarColor(const cv::Mat& frame, const cv::Rect& carRect) { cv::Mat roi = frame(carRect); cv::Mat hsv; cv::cvtColor(roi, hsv, cv::COLOR_BGR2HSV); cv::GaussianBlur(hsv, hsv, cv::Size(5, 5), 0); int colorCounter[9] = { 0 }; for (int row = 0; row < hsv.rows; ++row) { const cv::Vec3b* p = hsv.ptr<cv::Vec3b>(row); for (int col = 0; col < hsv.cols; ++col) { int H = p[col][0]; int S = p[col][1]; int V = p[col][2]; colorCounter[0] += (S <= 30 && V <= 60) ? 1 : 0; // 黑 colorCounter[1] += (S <= 30 && V > 60) ? 1 : 0; // 白 colorCounter[2] += (S <= 60 && V > 60 && V <= 180) ? 1 : 0; // 灰 colorCounter[3] += ((H >= 0 && H <= 10) || (H >= 156)) && S >= 100 ? 1 : 0; // 红 colorCounter[4] += (H >= 26 && H <= 34 && S >= 80) ? 1 : 0; // 黄 colorCounter[5] += (H >= 35 && H <= 80 && S >= 60) ? 1 : 0; // 绿 colorCounter[6] += (H >= 81 && H <= 125 && S >= 80) ? 1 : 0; // 蓝 colorCounter[7] += (H >= 126 && H <= 155 && S >= 60) ? 1 : 0; // 紫 colorCounter[8] += (H >= 11 && H <= 25 && S >= 100) ? 1 : 0; // 橙 } } int maxIdx = static_cast<int>( std::max_element(colorCounter, colorCounter + 9) - colorCounter); static const CString names[] = { _T("黑色"), _T("白色"), _T("灰色"), _T("红色"), _T("黄色"), _T("绿色"), _T("蓝色"), _T("紫色"), _T("橙色") }; return names[maxIdx]; }这段代码遍历 ROI 每个像素,手写条件判断,避免用cv::inRange循环 9 次,效率更高。参数S <= 30 && V <= 60是黑色,白色则 V 必须大于 60;灰色单独用 S 和 V 夹逼。实际使用时,白色车在阴天 V 可能只有 100,所以 V 上限放宽到 255 没问题。下一步把颜色文字用DrawText画到检测框上方,加上置信度一起显示。
提示:夜间场景建议先判断整帧平均亮度,如果低于某个阈值,直接返回“未知”,不要强行把深蓝色识别成黑色。很多现场验收不合格都是颜色模块在夜班乱报造成的。
5. 发布阶段:把 MFC_windows.zip 做对,并排掉三个典型坑
5.1 依赖打包:减少 DLL 地狱
交付现场最头疼的是 exe 双击后提示缺少 DLL。用 MFC 动态库编译时,依赖至少包括mfc140u.dll、vcruntime140.dll、msvcp140.dll,再加上 OpenCV 和 ONNX Runtime 的 DLL。我不建议把这些 DLL 直接扔到C:\Windows\System32,正确做法是在 zip 包里建一个bin目录,exe 和 DLL 全放一起,用SetDllDirectory或者启动时切换当前目录。
打包前检查依赖可以使用 Visual Studio 自带的dumpbin /dependents命令:
dumpbin /dependents 车型识别.exe把输出里的 DLL 名复制出来,再逐个检查这些 DLL 是否已在目标系统存在。OpenCV 的opencv_world480.dll和 ONNX Runtime 的onnxruntime.dll必须随包发布。模型文件放在models/子目录,程序启动时用PathAppend拼接绝对路径,不要假设当前工作目录是 exe 所在目录,因为 MFC 程序可能从快捷方式启动。
5.2 “此项目需要 MFC 库”的两种解决方式
目标机器运行时报“此项目需要 MFC 库”,本质是缺少对应版本的 MFC 运行库。解决方式有两种:
第一,改项目为“在静态库中使用 MFC”,这会让 exe 体积增加 10~20MB,但分发时不需要带 MFC DLL。缺点是 ONNX Runtime 和 OpenCV 如果同时动态链接,静态 MFC 可能导致 CRT 重复初始化,需要确认两者都使用/MT。第二,保留动态 MFC,在打包 zip 时附带vc_redist.x64.exe,或者从开发机拷贝mfc140u.dll到应用程序目录。我通常选第二种,因为 ONNX Runtime 官方二进制已经依赖动态 CRT,硬改成静态容易出诡异崩溃。
检查 mfc140u.dll 是否缺失,可以在 zip 解压后在命令行里:
where mfc140u.dll如果没输出,说明系统里没有,必须在安装包附带运行库。
5.3 报错 error read zip archive 和启动闪退怎么查
用户反馈“zip 解压失败,报 error read zip archive”,多半不是程序问题,而是压缩包在传输过程中损坏,或者下载工具提前中止。我一般让用户先看文件大小是否和发布时的说明一致。用 7-Zip 打开 zip,如果能看到文件列表但解压时中途报错,说明 zip 中心目录完好但某个分卷数据损坏,手动跳过损坏文件重新解压。MFC 程序本身启动闪退,先用事件查看器,看 Windows 日志里应用程序错误的异常代码是不是0xc000007b。这个异常码表示 DLL 位数不匹配,最常见原因是把 32 位版本的 OpenCV DLL 和 64 位 exe 混在一起。另一个高频原因是模型文件路径中包含中文,ONNX Runtime 的Session::Session接收std::wstring时可以处理中文路径,但如果你用的是std::string且系统代码页不是 UTF-8,就会读不到模型而闪退。
最后还有一个我每次发布都会验证的步骤:把 zip 拷贝到一台只装原版 Windows 的虚拟机里,用“解压到桌面”后直接双击运行,不设任何环境变量和系统路径。这一步能过滤掉大多数“在我机器上好好的”问题。真正在目标机器排查时,先用%PATH%里的依赖诊断工具确认 exe 加载了哪些 DLL,再决定是补 DLL 还是换静态编译。把 zip 里的models路径写成相对路径并基于 exe 目录解析,基本不会再出现识别功能正常但模型加载失败的情况。
本文还有配套的精品资源,点击获取