简介:基于MFC框架开发的车型与车颜色识别系统源码,面向计算机视觉、智能交通系统(ITS)方向的学习者和开发者,用于解决道路上车辆自动检测、分类与颜色判别等问题。包内共117个文件,压缩包约24.86MB,文件类型涵盖lib静态库、dll动态库、dat数据文件、cpp源代码、h头文件等,其中lib与dll用于支撑算法运行与外部依赖,dat数据文件与训练好的模型文件保存了分类器参数,cpp与h则对应SVM分类器实现、车辆目标提取、对象跟踪及MFC界面交互等核心模块。目前已有一百余人学习下载。源码完整呈现了从图像特征提取、SVM模型训练、车辆轮廓定位到颜色判别的全流程代码结构,并配有Visual Studio编译配置文件,方便直接打开构建运行。对希望在交通监控或自动驾驶场景中快速搭建识别原型、了解MFC与SVM等视觉算法如何集成开发的学习者而言,有直接参考价值。
1. MFC_windows.zip:一个把车型和车颜色识别做成桌面工具的压缩包
你手上如果拿到一个叫MFC_windows.zip的压缩包,那我猜它八成是某个交付物:一个基于 MFC 的 Windows 桌面程序,双击 exe 后选一张车辆图片,界面上能输出“车型:轿车 / SUV / 卡车”和“颜色:白色 / 红色 / 黑色”这样的结果。车型识别和车颜色识别是车辆属性识别里最常被要求的两件事,尤其在不方便部署 Web 服务的场景——停车场道闸、二手车拍照录入、保险定损辅助工具,都需要一个能离线跑的桌面识别端。这个包适合两类人:一类是把老项目捡起来改需求的工程师,另一类是刚接到“做个 Windows 识别小工具”而打算参考现有代码的开发者。但我要先给你泼盆冷水:这类交付包往往能在作者电脑上跑,到你手上就卡在编译或 DLL 缺失上,而这恰恰是这篇文章要带你逐个解决的问题。
2. 解开压缩包先看懂工程:MFC 项目结构与三个必查文件
zip 解压之后,你面对的不只一个exe,而是一整个 MFC 源码工程。先别急着按 F5 编译,我建议你先把工程结构认清楚。MFC 项目虽然老,但它的骨架几十年来没变过:解决方案文件、项目文件、资源脚本、头文件和实现文件。任何一个环节不对,后面跑不起来你都无从下手。
2.1 从 .zip 到可运行 exe:MFC 工程的标准打开方式
我一般会把压缩包解压到一个纯英文路径下,比如D:\Project\MFC_windows,不要带中文,也不要带空格。这不是洁癖,而是 MFC 的资源编译器在处理中文路径时偶尔会抽风,报一些莫名其妙的“RC fatal error”或者“cannot open file”。
解压后先看根目录下有没有.sln文件。有.sln直接用 Visual Studio 打开;如果只看到.dsp或.vcxproj,那要分两种情况:
.vcxproj:用较新版本的 VS 直接打开即可,VS 会提示你进行一次“平台工具集升级”,点确定,但后面我会告诉你可能要手动改回旧工具集。.dsp:这是 Visual Studio 6.0 的旧工程文件。新版 VS 打开时会弹转换向导,转换完后建议立刻另存一份备份,因为转换过程偶尔会丢失自定义编译参数。
打开后第一件事不是编译,而是点开解决方案资源管理器,核对工程名和代码文件是否完整。一个典型的 MFC 对话框程序文件清单大概长这样:
| 文件 | 作用 | 打开后最先看哪里 |
|---|---|---|
.sln | 解决方案,组织多个工程 | 确认有没有缺失的项目 |
.vcxproj | 项目配置:字符集、工具集、链接库 | 右键打开.vcxproj或属性页 |
.rc | 资源脚本:界面布局、控件 ID、图标菜单 | 找主对话框模板IDD_xxx_DIALOG |
targetver.h | 平台 SDK 版本 | 确认 Windows 版本宏 |
stdafx.h | 预编译头 | 确认是否包含 OpenCV、ONNX Runtime 头文件 |
xxxDlg.cpp | 主对话框实现 | 找OnBnClickedXxx按钮事件 |
先确认这些文件都在,再谈下一步。缺.rc会导致编译报“找不到资源”,缺stdafx.h会被预编译头机制卡住。这时候你基本可以判断这个 zip 是否完整。
2.2 对话框程序还是文档视图:先分清主框架再改代码
MFC 有两套主流界面框架:CDialog对话框程序和CView/CFrameWnd文档视图程序。车型识别和车颜色识别这种工具类应用,十个里有九个是对话框程序,因为交互简单:一个按钮选图、一个 Picture Control 预览、一个文本控件显示结果。
怎么一眼识别?打开.rc文件,找到DIALOGEX关键字,或者看资源视图里有没有IDD_*_DIALOG。如果有,这就是对话框程序;如果看到IDR_MAINFRAME且里面关联了Menu和Toolbar,那就是文档视图程序。
这一步你必须确认,因为它决定了你改代码的位置:对话框程序的事件入口在OnBnClickedOk、OnBnClickedOpenImage这类函数里;文档视图程序的绘图和消息分发在OnDraw和OnMouseMove里。把代码改错地方,功能永远不触发。
老项目里偶尔还会见到把对话框程序伪装成文档视图的做法——主窗口是CFrameWnd,内部硬塞一个对话框控件。这种结构最恶心,因为消息路由两个体系叠加。遇到这种工程,我建议你直接重写主界面为纯对话框,别在旧骨架上修修补补。
2.3 三个必查文件:.sln、.vcxproj 和 .rc 资源脚本
.sln文件这个季度你可能只需要打开一次,但.vcxproj和.rc后面要反复看。.vcxproj是 XML 格式,里面写死了平台工具集、字符集、预处理器定义、库目录。右键工程名 -> 属性,图形界面看到的其实都是.vcxproj里的参数。
这里有一个容易踩的坑:两个.vcxproj分别对应 Debug 和 Release 配置,但有些交付包只写了一个配置,或者两个配置不同步。你要做的是在属性页左上角“配置”下拉框里分别把 Debug 和 Release 都点一遍,确认以下三项一致:
- 平台工具集:例如
Visual Studio 2022 (v143)还是Visual Studio 2015 (v140)。 - 字符集:
使用 Unicode 字符集还是使用多字节字符集。 - 运行库:
多线程调试 DLL (/MDd)与多线程 DLL (/MD)不要混。
.rc资源脚本里则藏着控件 ID。比如IDC_PIC_VEHICLE、IDC_STATIC_TYPE、IDC_STATIC_COLOR这些 ID 会在代码里被GetDlgItem、SetDlgItemText和DDX_Control引用。如果.rc缺失或版本不对,这些 ID 全部失效,编译报IDD_*_DIALOG undeclared identifier。解决办法是把.rc文件重新添加进工程,同时检查resource.h里宏定义是否和.rc匹配。
2.4 编译不过时先看这里:字符集与平台工具集
我遇到过最多次的翻车现场就是:双击解决方案,编译,报一堆LNK2019: unresolved external symbol _main或者_wcsupr_s。这些都是字符集不匹配的典型症状。
老 MFC 项目默认是多字节字符集,而新版 VS 默认 Unicode。代码里如果用了TCHAR和宏_T()还好,就怕有人直接写char*和strcpy。你把字符集切到多字节后,很多报错会自然消失。但也有反过来的情况——代码里用了CString与std::string混写,多字节下没问题,切到 Unicode 后CString变成了宽字符,直接赋值给char*就编译失败。这时候你可以给工程同时保留两套配置,一个叫 Debug-ANSI,一个叫 Debug-Unicode,需要哪个用哪个。
另一个高频问题是平台工具集。老工程用v120(VS2013)甚至v90(VS2008),你的电脑上没装对应组件。VS 会提示“需要添加到 Visual Studio 安装程序”,但我建议直接升级工具集到当前版本,同时把 C/C++ 语言标准改成默认,不要强行用旧标准。升级后会出现一堆警告,多半是strcpy改strcpy_s之类,不值得手工全改,在“预处理器定义”里加上_CRT_SECURE_NO_WARNINGS即可压掉。
如果报错指向fatal error C1083: Cannot open include file: 'afxwin.h',那不是工程问题,是你装的是不带 MFC 组件的 VS 版本。去 Visual Studio Installer 里勾选“使用 C++ 的桌面开发”和“用于 x86/x64 的 Microsoft 基础类 (MFC)”,装完重启 VS 再试。
3. 车型识别怎么做:从图像输入到分类结果的完整链路
车型识别是这个工具的核心之一。你选一张图,程序输出“轿车”“SUV”“卡车”等类别。这一步看着简单,但把准确率从“能跑”做到“能用”,中间要过预处理、特征/模型、推理引擎三道关。我用我自己的踩坑经历来拆这条链路。
3.1 车型识别不是目标检测:先定好你的分类粒度
很多人在标题里看到“车型识别”就以为要做目标检测,在图上画框框。但交货通常只要一个分类标签:对着车辆正前或斜前 45° 的照片,判断它是轿车、SUV、MPV 还是货车。也就是说,你要做的是图像分类,不是检测。
粒度决定了你所有后续方案。如果你只分 4 个大类(轿车/SUV/卡车/客车),HOG + SVM 就够了;如果要求分品牌型号,比如“大众帕萨特”“丰田凯美瑞”,那必须上深度学习,别幻想手工特征能区分同角度的不同年份款。
我一般会先问需求方:输出是“车头照类型”还是“任意角度车的型号”?前者我可以直接按分类做;后者建议接一个检测器先定位车体,再裁剪送进分类网络。在 MFC 里做检测会拖慢速度,所以更常见的做法是:选择图片前,要求用户先手动裁剪或者提供已经居中的车图。界面里加一个“从整图裁剪”按钮,用鼠标框选区域,拿这个区域做识别。你可以把这个交互逻辑放到OnBnClickedCropImage里,MFC 的CRectTracker能帮你快速实现画框选区的交互。
3.2 图像预处理:缩放、灰度与直方图均衡的基准参数
无论用什么模型,预处理不能省。我常用的基准参数是:把输入图统一缩放到 64×64 或 128×128,转灰度,再做一次直方图均衡化。这个流程对 HOG 特征尤其重要,因为它能压低光照差异。这里给一段我用 OpenCV 在 MFC 下写预处理的核心代码:
#include <opencv2/opencv.hpp> cv::Mat PreprocessForVehicleType(const cv::Mat& src, int targetSize = 128) { cv::Mat resized; // 车型分类不care长宽比,直接压扁成正方形,代价是形变,但对大类识别影响不大 cv::resize(src, resized, cv::Size(targetSize, targetSize), 0, 0, cv::INTER_LINEAR); cv::Mat gray; if (resized.channels() == 3) cv::cvtColor(resized, gray, cv::COLOR_BGR2GRAY); // OpenCV 默认 BGR else resized.copyTo(gray); cv::Mat equalized; // 直方图均衡提升对比度,避免逆光或阴影把轮廓吃掉 cv::equalizeHist(gray, equalized); // 转成 float 并归一化到 [0,1],HOG / NN 输入都更安稳 cv::Mat floatImg; equalized.convertTo(floatImg, CV_32FC1, 1.0 / 255.0); return floatImg; }逻辑说明:第一步resize直接丢长宽比,对 SUV/轿车这种整体形状差异大的分类没问题,但如果你的类别里有“MPV”和“旅行车”这种轮廓接近的,建议改成保持比例的resize加灰色填充,避免形变导致误判。第二步转灰度是因为 HOG 本身就是梯度特征,颜色信息反而会干扰。第三步直方图均衡,是把过暗或过亮的图像拉伸到整个灰度范围,让车身边缘更清晰。
参数说明:targetSize我常在 96 和 128 之间选。太小会丢失细节,太大增加计算量且容易过拟合。INTER_LINEAR是双线性插值,对分类任务足够;不要用INTER_NEAREST,会产生锯齿,影响后续梯度计算。
3.3 特征提取与分类器选型:HOG+SVM 还是深度学习
预处理完之后,接分类器。老式 MFC 工程里最常见的是 OpenCV 的 HOG + SVM,因为代码简单,依赖少。新工程则习惯接 ONNX Runtime 跑一个 MobileNet 或 ResNet 分类模型。我对这两套方案的选择标准是这样:
| 维度 | HOG + SVM | CNN + ONNX Runtime |
|---|---|---|
| 准确率 | 数据集简单时有 90%+,复杂场景会掉到 80% | 模型训练得好可达 95%+ |
| 推理速度 | 单张 128×128 约 30~80ms | 单张约 10~30ms(MobileNet) |
| 依赖 | 只需 OpenCV | 需要 ONNX Runtime +.onnx模型文件 |
| 训练难度 | 需要自己提取 HOG 特征训练 SVM | 需要准备图片数据集做迁移学习 |
| 交付体积 | 小,几 MB | 模型文件可能 20~100MB |
如果这个MFC_windows.zip是最近几年的交付物,我猜它更可能用了深度学习;如果是十年前的老代码,大概率是 HOG+SVM。但不管哪种,你在工程里都要找到对应的初始化函数。HOG+SVM 的代码里会有一个HOGDescriptor和一个cv::Ptr<cv::ml::SVM>;深度学习的代码里会有sessionOptions和Ort::Session对象。
我自己倾向深度学习,因为泛化能力强太多。但我必须要提醒你:一旦用了 ONNX Runtime,交付的 zip 里就必须带上.onnx文件和对应的 DLL,否则换电脑就废。如果你拿到包里没有模型文件,多半是作者忘了,去联系交付方要,不然只能自己训练。
3.4 在 MFC 里集成 OpenCV 和 ONNX Runtime 的关键配置
集成这一步最容易卡死。先说 OpenCV:如果你用的是预编译的opencv_world4xx.dll,在“配置属性 -> VC++ 目录 -> 包含目录”里加OpenCV\include,在库目录里加OpenCV\x64\vc15\lib,然后链接器 -> 输入 -> 附加依赖项加opencv_world460.lib(版本号按你实际的来)。注意 Debug 和 Release 不可以混用:Release 链接opencv_world460.lib,Debug 要链接opencv_world460d.lib,否则崩给你看。
ONNX Runtime 的集成稍微麻烦一点。我通常直接用 NuGet 包Microsoft.ML.OnnxRuntime,在 VS 里给它自动配置好 include 和 lib。如果你非要手动下载 DLL,那么记得把onnxruntime.dll放到和 exe 同一个目录,并把include\onnxruntime_cxx_api.h所在的目录加入包含目录。这里给出一个最小可用的 ONNX Runtime 分类调用代码:
#include <onnxruntime_cxx_api.h> std::string RunONNXClassifier(const cv::Mat& inputImg, const std::string& modelPath) { // 1. 创建环境:全局一个即可,不要每次识别都创建 static Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "vehicle_type"); static Ort::SessionOptions sessionOptions; sessionOptions.SetIntraOpNumThreads(1); // 单线程推理,避免抢占UI static Ort::Session session(env, modelPath.c_str(), sessionOptions); // 2. 构造输入矩阵:ONNX要求 NCHW,先把HWC转CHW cv::Mat resized, floatImg; cv::resize(inputImg, resized, cv::Size(128, 128)); resized.convertTo(floatImg, CV_32FC3, 1.0 / 255.0); // 归一化到[0,1] std::vector<float> inputTensorValues; inputTensorValues.reserve(128 * 128 * 3); for (int c = 0; c < 3; ++c) // 通道循环 for (int h = 0; h < 128; ++h) // 高 for (int w = 0; w < 128; ++w) // 宽 inputTensorValues.push_back(floatImg.at<cv::Vec3f>(h, w)[c]); // BGR顺序 // 3. 创建 Ort::Value std::array<int64_t, 4> inputShape{ 1, 3, 128, 128 }; Ort::MemoryInfo memoryInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); Ort::Value inputTensor = Ort::Value::CreateTensor<float>( memoryInfo, inputTensorValues.data(), inputTensorValues.size(), inputShape.data(), inputShape.size()); // 4. 推理并取 softmax 后最大概率 const char* inputNames[] = { "input" }; const char* outputNames[] = { "output" }; auto outputTensors = session.Run(Ort::RunOptions{ nullptr }, inputNames, &inputTensor, 1, outputNames, 1); float* outputData = outputTensors[0].GetTensorMutableData<float>(); // 简单的 argmax int bestIdx = 0; float bestScore = 0.0f; for (int i = 0; i < 4; ++i) { if (outputData[i] > bestScore) { bestScore = outputData[i]; bestIdx = i; } } return std::string("type_") + std::to_string(bestIdx); }逻辑说明:代码里前两行static保证了模型只加载一次,避免每张图都重新读模型文件。——这是 MFC 程序卡死的第一大元凶:在按钮事件里每次创建Ort::Session,导致预处理和加载耗掉好几秒。这里把推理线程限制为 1,配合后面讲的工作线程,保证 UI 不冻结。
参数说明:inputShape是{1, 3, 128, 128},对应模型训练时的输入尺寸,如果你的模型是 224 就要改成 224,同时把循环里的128一并改掉。inputNames和outputNames必须和.onnx模型的实际节点名一致,否则session.Run直接抛异常。查节点名的方式是打开模型文件做一次 netron 可视化,或者用 Python 简单打印。
3.5 输出结果:在 Picture Control 上画框和贴标签
识别完之后,你要把结果喂给界面。MFC 里显示图片的标准姿势是把cv::Mat转成CImage,再设置到CStatic控件上。给一段我在项目里反复用的转换函数:
#include <atlimage.h> void ShowMatInControl(CStatic& control, const cv::Mat& mat) { cv::Mat display; if (mat.channels() == 1) cv::cvtColor(mat, display, cv::COLOR_GRAY2BGR); else mat.copyTo(display); // 已经是BGR // 把OpenCV的BGR转换成CImage需要的BGRA cv::Mat bgra; cv::cvtColor(display, bgra, cv::COLOR_BGR2BGRA); CImage img; img.Create(bgra.cols, bgra.rows, 32); // 拷贝像素:注意CImage的扫描行可能有对齐 for (int y = 0; y < bgra.rows; ++y) memcpy(img.GetPixelAddress(0, y), bgra.ptr(y), bgra.cols * 4); // 清理旧图并设置新图 control.SetBitmap((HBITMAP)img.Detach()); control.RedrawWindow(); // 这一句必须,否则界面残留旧图 }逻辑说明:ShowMatInControl做的事情是把 Mat 复制到 CImage 的内存里。GetPixelAddress返回第 y 行的起始地址,bgra.ptr(y)是 OpenCV 该行数据。由于 CImage 默认的 32 位像素是 BGRA,和 OpenCV 的 BGRA 顺序一致,可以直接拷贝。Detach是必须的,否则CImage析构会把 HBITMAP 删掉,控件显示会黑屏。
参数说明:如果你显示的是灰度图或三通道 BGR,先统一转成 BGRA。CImage 的 32 位模式需要每行对齐到 4 字节,bgra.cols * 4已经满足这个要求,所以memcpy一次性整行拷贝没有问题。如果图上有画框需求,在display上先用cv::rectangle画完之后再做转换。这个函数我建议做成公共工具,颜色识别和车型识别都会用到。
4. 车颜色识别:从 BGR 到稳定色名车的落地细节
车颜色识别看着比车型简单,实际坑更大。你以为是“取个平均色”,结果拍出来的车一会儿偏蓝一会儿偏黄。我在停车场场景里翻过好几次车以后,摸索出一套相对稳妥的颜色识别流程:先做白平衡,再转 Lab 空间,然后聚类取主色,最后映射到中文色名。
4.1 颜色识别为什么比车型更容易翻车
人的眼睛会根据环境自动校正色温,比如白墙在黄昏时看起来还是白色,但相机拍出来是橙色。算法没有这个本能。同一个红色车漆,在阴天、路灯、阳光下分别拍出来是暗红、黄红、艳红。如果你直接把 RGB 像素值映射到颜色名,稍微有一点环境光偏移,结果就会跨到另一个颜色分类。
另一个坑是车漆本身有金属颗粒和镜面反射,高光区域会变成接近白色。如果你统计整张图的平均颜色,出来往往是白花花的灰。这就是很多颜色识别程序“总是偏灰”的直接原因。所以颜色识别不能取全局平均,必须挑出车身主区域的像素来算。
4.2 光线归一化:Lab 空间与白平衡的取舍
我采取的做法是两步归一化:先用灰度世界算法做白平衡,再转 Lab 颜色空间,之后只使用 a 和 b 通道。L通道代表亮度,丢之后可以大幅降低光照和下阴影的干扰。
灰度世界算法假设整张图的平均颜色应该是灰色,然后按通道增益把平均色拉回中性灰。代码不长:
cv::Mat WhiteBalanceGrayWorld(const cv::Mat& src) { cv::Mat dst; std::vector<cv::Mat> channels; cv::cvtColor(src, dst, cv::COLOR_BGR2Lab); // 先在Lab里算均值比较稳 cv::split(dst, channels); // 计算 a、b 通道均值 cv::Scalar meanL = cv::mean(channels[0]); // 亮度均值,备用 cv::Scalar meanA = cv::mean(channels[1]); cv::Scalar meanB = cv::mean(channels[2]); // 灰度世界目标:a和b都归到128附近(Lab中性轴值) float gainA = 128.0f / (float)(meanA[0] + 1e-6f); float gainB = 128.0f / (float)(meanB[0] + 1e-6f); channels[1] *= gainA; channels[2] *= gainB; cv::merge(channels, dst); cv::Mat result; cv::cvtColor(dst, result, cv::COLOR_Lab2BGR); return result; }逻辑说明:这里在 Lab 空间做增益而不是直接在 RGB 上乘,是因为 Lab 的 a、b 通道在数值上接近独立的色彩维度。meanA如果大于 128,说明画面偏红,把增益调低一点;如果小于 128,说明偏绿,把增益调高。这样处理后,整个画面的整体色偏被强行拉回中性。
参数说明:128是 Lab 空间中 a、b 通道的中性值,对应颜色学上的灰色点,这个值是固定的,不要随便改。+1e-6f只是防止除零。白平衡不是万能的,如果整张图本来就是一辆绿色车,灰度世界算法会把绿色拉向灰色——所以你最好是先裁剪出车身区域,再做白平衡,而不是对整张图做。界面上建议加一个“选择车身区域”的按钮,裁剪范围后保存 ROI。
4.3 颜色直方图 + K-Means 聚类提取主色
白平衡之后,我们要从车身区域里提取主色。直接取平均还是会被高光和阴影污染,所以我用 K-Means 聚类:把像素分成几簇,取面积最大的一簇中心作为该车的主色。这相当于在颜色层面做了一次“多数投票”。
cv::Mat ExtractMainColor(const cv::Mat& roiBgr, int clusterCount = 3) { cv::Mat lab; cv::cvtColor(roiBgr, lab, cv::COLOR_BGR2Lab); // 将Lab图像展开成 N x 3 的样本矩阵 std::vector<cv::Mat> channels; cv::split(lab, channels); cv::Mat samples(lab.rows * lab.cols, 3, CV_32F); for (int y = 0; y < lab.rows; ++y) { for (int x = 0; x < lab.cols; ++x) { int idx = y * lab.cols + x; samples.at<cv::Vec3f>(idx, 0) = channels[0].at<uchar>(y, x); samples.at<cv::Vec3f>(idx, 1) = channels[1].at<uchar>(y, x); samples.at<cv::Vec3f>(idx, 2) = channels[2].at<uchar>(y, x); } } cv::Mat labels, centers; cv::kmeans(samples, clusterCount, labels, cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::COUNT, 10, 1.0), 3, cv::KMEANS_PP_CENTERS, centers); // 统计每一簇的像素数 std::vector<int> counts(clusterCount, 0); for (int i = 0; i < labels.rows; ++i) { int clusterId = labels.at<int>(i); counts[clusterId]++; } // 取计数最大的簇中心 int bestCluster = 0; for (int i = 1; i < clusterCount; ++i) { if (counts[i] > counts[bestCluster]) bestCluster = i; } // 转回 BGR 方便后面映射颜色名 cv::Mat centerLab(1, 1, CV_8UC3); centerLab.at<cv::Vec3b>(0, 0) = cv::Vec3b( (uchar)centers.at<float>(bestCluster, 0), (uchar)centers.at<float>(bestCluster, 1), (uchar)centers.at<float>(bestCluster, 2)); cv::Mat centerBgr; cv::cvtColor(centerLab, centerBgr, cv::COLOR_Lab2BGR); return centerBgr; }逻辑说明:samples矩阵的每行是一个像素的 Lab 值。kmeans的第三个参数labels会返回每个像素的簇序号,然后我统计每个簇的像素数量,取最大簇的中心。这样做的好处是高光(通常聚成亮度很高的一簇)和阴影(亮度很低的一簇)会被自动区隔开,而不至于把主色拉偏。
参数说明:clusterCount=3是经验值。2 簇时如果车身有轻微反光会被干扰,4 簇及以上容易把同一颜色切碎。TermCriteria里10是最大迭代次数,1.0是位置变化阈值,收敛足够快。KMEANS_PP_CENTERS是 K-Means++ 初始化,比随机初始化稳定很多,建议永远用这个。
4.4 颜色映射表:从 RGB 到中文色名怎么定阈值
拿到主色centerBgr之后,你要把它翻译成中文色名。常见做法是维护一张色卡表,每种颜色给一个代表性的 RGB 值,然后用欧氏距离找最近的颜色。下面是我常用的一个简化映射表:
| 中文色名 | 参考 RGB(BGR顺序) | 适应场景 |
|---|---|---|
| 黑色 | (30, 30, 30) | 黑、深灰 |
| 白色 | (240, 240, 240) | 白、银白 |
| 银灰 | (160, 160, 160) | 银、浅灰 |
| 红色 | (60, 40, 220) | 红、酒红 |
| 橙色 | (40, 140, 240) | 橙色、橙红 |
| 黄色 | (90, 200, 255) | 黄、金黄 |
| 绿色 | (80, 180, 70) | 绿、墨绿 |
| 蓝色 | (210, 130, 50) | 蓝、深蓝 |
| 紫色 | (200, 80, 140) | 紫、紫红 |
| 棕色 | (60, 100, 150) | 棕、咖啡色 |
映射函数可以写成这样:
std::string MapColorName(const cv::Mat& bgrColor) { struct ColorRef { std::string name; int b, g, r; }; static const ColorRef kColorTable[] = { {"黑色", 30, 30, 30}, {"白色", 240, 240, 240}, {"银色", 160, 160, 160}, {"红色", 60, 40, 220}, {"橙色", 40, 140, 240}, {"黄色", 90, 200, 255}, {"绿色", 80, 180, 70}, {"蓝色", 210, 130, 50}, {"紫色", 200, 80, 140}, {"棕色", 60, 100, 150} }; cv::Vec3b bgr = bgrColor.at<cv::Vec3b>(0, 0); int minDist = INT_MAX; std::string best = "未知"; for (const auto& ref : kColorTable) { int db = bgr[0] - ref.b; int dg = bgr[1] - ref.g; int dr = bgr[2] - ref.r; int dist = db * db + dg * dg + dr * dr; if (dist < minDist) { minDist = dist; best = ref.name; } } // 如果最远距离都很大,说明颜色明显不在表内,给出灰度等级 if (minDist > 30000) { int gray = cv::mean(bgrColor)[0]; if (gray < 80) best = "深色"; else if (gray > 180) best = "浅色"; } return best; }逻辑说明:这个函数对聚类得到的主色进行最近邻查找。static const数组里的颜色参考值我用的是 BGR 顺序,方便直接与 OpenCV 的Vec3b比较。minDist > 30000是一个经验阈值,当所有色卡颜色的欧氏距离都很大,说明这辆车是一个不太常见的颜色(比如荧光绿),此时退回到按亮度分深浅。
参数说明:色卡表要按你的实际需求调整。比如二手评估工具只需要 7 种主要颜色,你可以删掉棕色和紫色。如果你需要区分“银灰”和“香槟金”,就再加入一个参考点(120, 140, 170)。欧氏距离在 RGB 空间并不是知觉均匀的,但在 10 种常用颜色这个尺度上足够用;要求更高的话,把表和距离计算都改到 Lab 空间做,效果会好一截,但代码成本也高。
4.5 一个可用的颜色识别流程代码(C++)
把上面这几段串起来的完整流程我整理成下面这个函数:
std::string DetectVehicleColor(const cv::Mat& vehicleRoi, bool enableWhiteBalance = true) { cv::Mat working; if (enableWhiteBalance) { working = WhiteBalanceGrayWorld(vehicleRoi); } else { vehicleRoi.copyTo(working); } cv::Mat mainColor = ExtractMainColor(working, 3); return MapColorName(mainColor); }逻辑说明:DetectVehicleColor是给 UI 层调用的统一入口。vehicleRoi是你裁剪得到的车身区域,如果还没有裁剪,可以在外层先用检测框或手动选择框切出来。白平衡开关留一个参数,是因为某些高饱和颜色车(比如亮黄色)被白平衡修正后反而失真,此时可以关掉它再识别。
参数说明:如果你想调试,建议在ExtractMainColor里把mainColor显示到画面上,让你能看到程序认为的“主色”到底是什么样。很多误判是聚类拿到的颜色和肉眼看到的不一致,这时把clusterCount调大一个试试,看最大簇是否变化。颜色识别永远不可能百分百准确,能做到 90% 以上的稳定命中在工程上就已经是可交付的。
5. 五件让交付物变成废品的事:避坑与排查
我把这个标题相关的 MFC 工具从编译到上线遇到的典型问题列成五条踩坑记录,每一条都是我或同行真实碰过的。碰到类似现象,按这个顺序排查,能帮你省下半天时间。
5.1 现象:Debug 能跑 Release 崩溃
- 原因:工程里链接的 OpenCV/ONNX Runtime 库版本与运行库方式不匹配。Debug 默认使用
/MDd,Release 使用/MD,而你把 Release 工程也配置成了/MDd,或者反过来链接了 Debug 版 DLL。 - 解决:打开项目属性 -> C/C++ -> 代码生成 -> 运行库,Debug 选“多线程调试 DLL (/MDd)”,Release 选“多线程 DLL (/MD)”。然后检查附加依赖项,Release 只链接
opencv_worldxx.lib,别带d后缀。如果崩溃发生在 ONNX Runtime 内部,同样检查onnxruntime.dll的版本是 Release 还是 Debug,并把所有用到它的工程配置统一。
5.2 现象:识别结果一直不变
- 原因:最常见的是你在按钮事件里调用了
ShowMatInControl,但这个函数每次只是覆盖了CStatic的位图,而 MFC 的控件重绘机制没有触发,于是界面一直显示第一张图。另一个可能:你把输入图缓存在一个全局cv::Mat中,但每次选图时那个全局变量没有被更新。 - 解决:在
ShowMatInControl末尾加RedrawWindow(),强制重绘。不要在OnPaint里读取全局 Mat 做识别,而是在按钮事件里完成识别后把结果直接写入控件。识别结果文本也不要写到CStatic后不管,同样用SetDlgItemText拉动触发UpdateWindow。
5.3 现象:从文件对话框选图就卡死
- 原因:你在
CFileDialog后面的按钮事件里同步执行了图像解码、预处理和推理。解码一张 4000 万像素的 JPEG 加上 ONNX Runtime 推理,耗时可能超过两秒,UI 线程被阻塞,窗口失去响应,系统弹“无响应”。 - 解决:把识别工作丢给工作线程。用
AfxBeginThread启动一个线程,线程函数里做所有耗时操作,完成后用PostMessage给主窗口发送自定义消息。给你一段框架代码:
UINT RecognizeThreadProc(LPVOID param) { CMyDlg* dlg = (CMyDlg*)param; cv::Mat img = cv::imread(dlg->m_currentPath); std::string result = RunONNXClassifier(PreprocessForVehicleType(img), dlg->m_modelPath); // 通过 PostMessage 通知主线程,wParam 或 lParam 传结果 ::PostMessage(dlg->GetSafeHwnd(), WM_RECOGNIZE_DONE, (WPARAM)new std::string(result), 0); return 0; }- 逻辑说明:
AfxBeginThread创建线程后立即返回,UI 线程继续处理消息。推理完成后PostMessage把结果字符串的指针作为wParam传给主窗口,主窗口在消息响应函数里负责更新控件。注意线程函数里不要直接调用任何 UI 相关函数,只能PostMessage。
5.4 现象:颜色总是偏灰
- 原因:没有做白平衡。直接对原始 JPEG 做像素统计,平均色被环境色温拉向灰色或黄色。另一个原因是你在聚类前忘了转 Lab,直接在 RGB 上聚类,RGB 空间里亮度变化会污染色相判断。
- 解决:严格按我第 4 章的流程来:先裁车身区域,再白平衡,再转 Lab,再聚类。如果依然偏灰,检查
ExtractMainColor输入的是不是 ROI,而不是整张图。整张图包含天空、路面、玻璃反光,聚类结果一定会被这些非车身像素带偏。
5.5 现象:换台电脑无法启动
- 原因:目标机器缺少 VC++ 运行库,或者你的 exe 依赖的 OpenCV、ONNX Runtime DLL 没有随包交付。MFC 程序如果动态链接到 MFC DLL,目标机器也需要装着对应版本的 MFC 组件。
- 解决:在 Release 配置下把项目属性 -> 常规 -> MFC 的使用改为“在静态库中使用 MFC”,同时把运行库改为
/MT。然后把opencv_world4xx.dll和onnxruntime.dll直接复制到 exe 同目录。最后用 Dependency Walker 或 VS 自带的 dumpbin 检查 exe 依赖项。如果你倾向动态库,那就在交付包中附上vcredist_x64.exe并把 DLL 全部打进去。
6. 验证识别效果的一种土办法:批量跑图生成报告
手工一张张点按钮测试只会让你陷入自我怀疑。我建议在 MFC 工具里加一个“批量测试”按钮:选一个文件夹,程序遍历所有图片,把识别结果写到一个 CSV 里,然后你再用 Excel 或脚本统计准确率。在正式确认这个方向值不值得做之前,这个验证手段能让你找到模型和预处理哪个环节拖后腿。
批量测试的代码逻辑并不复杂,也适合脱离 MFC 用控制台单独跑,拿一份 C++/C 脚本就能处理。核心就是遍历目录文件、调用识别函数、写入文件。下面是遍历的核心片段(MFC 里可以用CFileDialog选文件夹):
void CMyDlg::OnBnClickedBatchTest() { // 选择文件夹 CFolderPickerDialog dlgFolder(NULL, OFN_FILEMUSTEXIST, this); if (dlgFolder.DoModal() != IDOK) return; CString folder = dlgFolder.GetPathName(); CString csvPath = folder + _T("\\result.csv"); FILE* fp = _tfopen(csvPath, _T("w")); if (!fp) return; fprintf(fp, "filename,type,color\n"); CFileFind finder; CString wildcard = folder + _T("\\*.jpg"); BOOL hasFile = finder.FindFile(wildcard); while (hasFile) { hasFile = finder.FindNextFile(); if (!finder.IsDots()) { CString path = finder.GetFilePath(); cv::Mat img = cv::imread(LPCSTR(path)); std::string type = RunONNXClassifier(PreprocessForVehicleType(img), m_modelPath); std::string color = DetectVehicleColor(img, true); fprintf(fp, "%s,%s,%s\n", LPCSTR(A2CT((LPCSTR)path)), type.c_str(), color.c_str()); } } fclose(fp); MessageBox(_T("批量测试完成,结果已写入 result.csv")); }逻辑说明:CFolderPickerDialog是 MFC 里选择文件夹的封装。CFileFind遍历指定目录下的.jpg文件。每张图分别调用车型识别和颜色识别,结果写入 CSV。这里没有开线程,批量识别几百张图可能耗时较长,但用于测试没问题;如果你要处理几千张,再把遍历也放入工作线程。
参数说明:*.jpg只匹配 JPG 扩展名,你可以改成*.png或*.bmp,或者写两次循环拼接。_tfopen在 Unicode 配置下自动使用宽字符版 fopen,注意这里我用了fprintf,这是 ANSI 输出,而_tfopen的_T在 Unicode 下是宽字符,两者混用时会写出一堆乱码;更稳的方式是把_tfopen换成fopen,或者在fopen后用setlocale设置本地化。这是我实际踩过的坑,你最好直接用fopen并传ANSI路径。
验证完准确率后,你要能回答这几个问题:车型识别准确率是否达到 95%?颜色识别是否在白天和夜晚场景都稳定?CPU 单线程推理一张图是否低于 200ms?如果答案是肯定的,这个方案值得投入;如果不行,回去检查预处理和训练集,而不要继续堆界面。
我自己的习惯是:把一个项目交付前,总会在两个不同文件夹里放至少 200 张真实场景图跑一遍,生成报告,再把报告发给需求方一起看。这样做过一次之后,客户现场翻车的概率大幅降低。希望帮到你。
(全文完)
本文还有配套的精品资源,点击获取