1. 项目概述从零到一用VC构建一个车牌识别系统最近在整理硬盘里的老项目翻出来一个十几年前用VC6.0写的车牌识别系统源码。当时为了一个智能停车场项目硬啃了几个月图像处理和模式识别现在回头看虽然代码风格有点“复古”但核心思路依然不过时。今天我就把这个项目的核心代码和实现逻辑掰开揉碎了讲一讲特别是车牌定位和字符分割这两个最关键的环节。无论你是刚接触图像处理的VC新手还是想了解传统车牌识别算法原理的老手这篇文章都能给你一份可以直接参考、甚至复现的“实战手册”。我们不用那些庞大复杂的开源库就靠最基础的C和Windows GDI一步步把车牌从图片里“揪”出来再把上面的字符一个个“切”明白。2. 核心思路与方案选型为什么选择经典的“灰度化-二值化-定位-分割”流程在动手写代码之前得先想清楚路怎么走。车牌识别License Plate Recognition, LPR通常分为几个标准步骤图像预处理、车牌定位、字符分割、字符识别。我们这个项目聚焦在前三步用VC实现最后一步的识别可以对接简单的模板匹配或后续的机器学习模型。2.1 技术路线选择传统图像处理为何仍是学习基石你可能听说过现在流行用深度学习做端到端的车牌识别效果确实好。但对于学习、对于在资源受限的环境比如某些嵌入式设备或遗留系统中部署以及对于理解计算机视觉的基本功来说传统的图像处理方法依然价值巨大。它不依赖海量数据计算量相对可控并且每一步的结果都清晰可见非常利于调试和理解算法本质。我们的方案基于以下考量可控性与可解释性每一步操作如灰度化、边缘检测、形态学处理其原理和效果都能直观地分析和调整。这对于学习和项目调试至关重要。对开发环境友好纯C实现依赖极少主要就是Windows GDI在经典的VC6到最新的Visual Studio 2022中都能顺利编译运行避免了复杂第三方库的配置冲突。聚焦核心逻辑剥离了深度学习框架的复杂性让我们能更专注于车牌识别领域本身的特殊性问题比如如何应对光照变化、车牌倾斜、复杂背景等。2.2 核心流程拆解我们确定的流程也是业界最经典、最稳健的流程之一原始彩色图像 - 灰度化 - 图像增强可选 - 边缘检测 - 二值化 - 形态学处理 - 候选区域查找 - 车牌区域筛选 - 车牌矫正 - 字符分割 - 输出分割结果。这个流程像一条流水线每一步都为下一步做准备。灰度化减少数据量边缘检测突出车牌边框二值化简化图像形态学处理连接断点、去除噪点最后通过车牌的几何特征长宽比、面积等从一堆候选框中把它找出来。分割阶段则利用车牌字符的排列规律进行投影切割。注意这个流程并非万能对于极端光照、严重污损或特殊车牌样式的图片效果会打折扣。但它提供了解决这类问题的标准“工具箱”和思考框架。3. 开发环境搭建与核心工具准备工欲善其事必先利其器。虽然我们说依赖少但一个顺手的开发环境和必要的工具类还是要准备的。3.1 开发环境配置IDEVisual Studio 2019 或 2022。我强烈建议使用新版本对C标准支持更好调试功能也更强大。不用担心兼容性我们的核心代码是标准C和Windows API。项目类型创建一个Win32控制台应用程序或MFC应用程序。为了专注于算法控制台程序更轻量。如果需要展示图像处理中间结果MFC的Picture Control或CImage类会很方便。字符集建议将项目属性中的“字符集”设置为“使用多字节字符集”以避免Unicode字符串处理带来的一些麻烦尤其是处理文件路径时。3.2 必备工具类图像数据的“搬运工”我们需要一个简单的类来加载、保存和在内存中操作图像。这里不直接用OpenCV而是自己封装更能理解底层。// ImageProcess.h - 一个简易的图像处理封装类 #pragma once #include windows.h #include vector class CSimpleImage { public: CSimpleImage(); ~CSimpleImage(); // 从文件加载图像支持BMP, JPEG, PNG等依赖GDI bool LoadFromFile(const char* szFilePath); // 创建指定大小的空图像 bool Create(int width, int height, int channels 1); // 保存图像到文件 bool SaveToFile(const char* szFilePath); // 获取图像信息 int GetWidth() const { return m_nWidth; } int GetHeight() const { return m_nHeight; } int GetChannels() const { return m_nChannels; } BYTE* GetData() { return m_pData.empty() ? NULL : m_pData[0]; } // 基本的像素访问这里以灰度图为例彩色图需扩展 BYTE GetPixel(int x, int y) const; void SetPixel(int x, int y, BYTE value); // 核心图像处理函数 bool ConvertToGray(); // 彩色转灰度 bool Binaryzation(int threshold); // 二值化 bool SobelEdgeDetect(); // Sobel边缘检测 bool MorphologyOperation(bool isErosion, int kernelSize 3); // 形态学操作腐蚀/膨胀 private: int m_nWidth; int m_nHeight; int m_nChannels; // 1:灰度 3:BGR std::vectorBYTE m_pData; // 存储图像数据一行一行存储 };这个类使用std::vectorBYTE管理图像数据内存自动释放。使用GDIGdiplus.h来解码和编码常见图片格式。实现ConvertToGray、Binaryzation等函数就是我们算法演练的舞台。实操心得自己封装图像类初期会有点繁琐但好处是每一步内存操作都清清楚楚。在调试时你可以随时把中间图像比如二值化后的结果保存下来查看对定位问题有奇效。务必注意图像数据是逐行存储的计算像素偏移量时公式是y * m_nWidth * m_nChannels x * m_nChannels。4. 车牌定位算法详解与VC实现定位是车牌识别的第一步也是难点所在。我们的目标是输入一张可能包含车辆的图片输出一个精确包围车牌的矩形框。4.1 图像预处理为定位创造好条件灰度化将彩色图转为灰度图减少三分之二的数据量。常用加权公式Gray 0.299*R 0.587*G 0.114*B。这个在ConvertToGray中实现。图像增强如果图像太暗或对比度太低可以先进行直方图均衡化或对比度拉伸。对于光照不均的情况可以采用Retinex算法或同态滤波但初期为了简化可以暂时跳过或尝试简单的灰度线性变换。边缘检测目的是突出车牌的矩形边框。我们使用Sobel算子。Sobel可以分别计算x和y方向的梯度合成后的梯度图能很好地保留边缘。// CSimpleImage 中 SobelEdgeDetect 的简化实现思路 bool CSimpleImage::SobelEdgeDetect() { if (m_nChannels ! 1) return false; // 确保是灰度图 std::vectorBYTE tempData(m_pData.size()); // Sobel 核 int sobelX[3][3] { {-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1} }; int sobelY[3][3] { {-1, -2, -1}, {0, 0, 0}, {1, 2, 1} }; for (int y 1; y m_nHeight - 1; y) { for (int x 1; x m_nWidth - 1; x) { int gx 0, gy 0; // 卷积计算 for (int ky -1; ky 1; ky) { for (int kx -1; kx 1; kx) { BYTE pixel GetPixel(x kx, y ky); gx pixel * sobelX[ky 1][kx 1]; gy pixel * sobelY[ky 1][kx 1]; } } int magnitude (int)sqrt(float(gx*gx gy*gy)); magnitude min(255, max(0, magnitude)); // 限制范围 tempData[y * m_nWidth x] (BYTE)magnitude; } } m_pData.swap(tempData); return true; }二值化将灰度图或梯度图转为黑白图便于后续处理。关键在于阈值的选择。全局阈值如OTSU大津法自动计算一个最佳阈值。适用于前景背景对比明显的图像。局部阈值自适应对图像分块每块计算一个阈值能更好地处理光照不均。我们可以在Binaryzation函数里实现OTSU算法作为默认选项。4.2 形态学处理与候选区域提取经过边缘检测和二值化我们得到了一个黑白图其中白色是边缘像素。但车牌边框可能不连续背景也有杂散噪声。形态学闭操作先膨胀dilation再腐蚀erosion可以连接相邻的白色区域填充小孔洞使得断开的车牌边框能连成一个整体。这通过调用MorphologyOperation(false, 5)先膨胀和MorphologyOperation(true, 5)后腐蚀来实现内核大小如5需要根据图像分辨率调整。查找轮廓这是提取候选区域的关键。我们需要找到图像中所有白色的连通区域。VC中可以使用FindContours函数如果使用OpenCV的话但为了纯粹我们可以自己实现一个简单的连通组件标记算法如Two-Pass算法。第一次扫描给每个前景像素分配一个临时标签并记录标签间的等价关系。第二次扫描根据等价关系将临时标签解析为最终的连通区域标签。最终每个连通区域可以用一个外接矩形boundingRect来描述。候选区域筛选上一步会找到很多矩形大部分不是车牌。我们需要根据车牌的先验知识进行过滤几何特征长宽比中国车牌标准尺寸为440mm*140mm长宽比约为3.14。考虑到透视变形可以设定一个范围例如2.0 ~ 4.0。面积区域像素面积不能太小排除噪点也不能太大排除整个车头。矩形度区域面积与其最小外接矩形面积的比值接近1说明形状更规整。颜色特征可选但有效在原始彩色图上检查候选区域内的颜色是否符合车牌底色蓝、黄、白、绿、黑。可以统计HSV颜色空间中H色调分量的分布。通过设置这些条件的阈值可以筛掉大部分错误区域。通常一个场景下最后会剩下1-3个候选框。4.3 代码实现从轮廓查找到区域筛选由于自己实现完整的连通域分析代码较长这里给出一个使用标准库vector存储矩形并进行筛选的框架#include vector #include algorithm struct ContourRect { int x, y, width, height; double aspectRatio; // 长宽比 int area; // 像素面积 // 可以添加其他特征如颜色置信度 }; std::vectorContourRect FindLicensePlateCandidates(CSimpleImage binaryImage) { std::vectorContourRect candidates; std::vectorContourRect allRects; // 假设这里已经通过连通域分析将结果填充到了allRects中 // allRects TwoPassLabeling(binaryImage); for (const auto rect : allRects) { // 计算特征 rect.aspectRatio (double)rect.width / rect.height; rect.area rect.width * rect.height; // 筛选条件 bool condAspect (rect.aspectRatio 2.0 rect.aspectRatio 4.0); bool condArea (rect.area 500 rect.area 50000); // 阈值需根据图像大小调整 bool condSize (rect.width 40 rect.height 15); // 最小像素尺寸 if (condAspect condArea condSize) { candidates.push_back(rect); } } // 如果还有多个候选可以按面积排序取最大的一个或几个 std::sort(candidates.begin(), candidates.end(), [](const ContourRect a, const ContourRect b) { return a.area b.area; }); return candidates; }5. 车牌字符分割技术精讲定位到车牌区域后下一步就是把“京A·12345”这样的字符串切成“京”、“A”、“1”、“2”、“3”、“4”、“5”这7个独立的字符图像。这是影响识别率的关键一步。5.1 分割前的预处理矫正与再二值化车牌矫正定位出的车牌矩形可能是倾斜的透视变形。我们需要进行仿射变换将其拉正。核心是找到车牌的四个角点可以通过查找定位区域的最小外接旋转矩形minAreaRect获得然后映射到一个标准大小的正矩形上。区域提取与灰度/二值化从矫正后的图像中抠出车牌区域并专门针对这个区域再次进行灰度化和二值化。这次二值化可以采用局部自适应阈值如adaptiveThreshold因为车牌区域内部的光照可能仍然不均。5.2 基于投影法的字符分割这是最经典、最有效的分割方法利用了车牌字符排列整齐、间距相对均匀的特点。垂直投影确定字符左右边界统计二值化车牌图像中每一列的白色前景像素数得到一个投影直方图。字符所在的列白色像素多投影值高字符间的间隙白色像素少投影值低甚至为0。分割点就在投影值从高到低或从正到零的波谷位置。std::vectorint GetVerticalProjection(const CSimpleImage plateImage) { std::vectorint vProjection(plateImage.GetWidth(), 0); for (int x 0; x plateImage.GetWidth(); x) { int colSum 0; for (int y 0; y plateImage.GetHeight(); y) { if (plateImage.GetPixel(x, y) 128) { // 假设白色前景 colSum; } } vProjection[x] colSum; } return vProjection; } std::vectorContourRect SplitByVerticalProjection(const std::vectorint vProj, int height, int minCharWidth 5) { std::vectorContourRect charRects; bool inChar false; int startX 0; for (int x 0; x vProj.size(); x) { if (vProj[x] 0 !inChar) { // 进入一个字符区域 startX x; inChar true; } else if (vProj[x] 0 inChar) { // 离开一个字符区域 int endX x - 1; int width endX - startX 1; if (width minCharWidth) { // 过滤掉太窄的噪声 charRects.push_back({startX, 0, width, height}); } inChar false; } } // 处理最后一个字符 if (inChar) { int width vProj.size() - startX; if (width minCharWidth) { charRects.push_back({startX, 0, width, height}); } } return charRects; }水平投影确定字符上下边界同理统计每一行的白色像素数。用于去除车牌上下边框和确定字符的高度范围。可以先根据水平投影找到字符集中的核心行范围然后在垂直分割时只使用这个范围内的图像能有效排除上下边框的干扰。处理粘连字符对于像“京”这样左右结构的汉字或者因为二值化问题导致两个字符连在一起垂直投影可能无法分开。此时需要更复杂的策略滴水算法模拟水滴从上往下流遇到字符分叉处就分开适合分割粘连字符。模板匹配滑动用一个字符宽度的滑动窗口计算匹配度在匹配度最低的位置切分。先验知识中国车牌有固定格式汉字字母5位数字/字母可以预估字符宽度。如果某个分割出的区域宽度明显大于平均宽度的1.5倍就可能是粘连需要特殊处理。5.3 分割后处理与归一化分割出的字符图像大小不一需要归一化到统一尺寸例如20x20像素以便后续识别。常用方法是边界扩展将字符图像放在一个稍大的正方形画布中央。缩放使用双线性插值等算法将画布缩放到目标尺寸。避坑指南字符分割最大的坑在于光照和污渍导致的二值化错误以及车牌倾斜矫正不彻底。务必在分割前花大力气做好车牌的矫正和高质量的二值化。可以尝试多种二值化算法全局OTSU、局部自适应、Sauvola等并对比效果。保存每一个中间步骤的图像进行可视化检查是调试的不二法门。6. 实战将代码模块串联成完整流程现在我们把上述所有模块组合起来形成一个完整的车牌定位与分割程序。// main.cpp 主流程示例 int main() { // 1. 初始化 CSimpleImage srcImage; if (!srcImage.LoadFromFile(car_image.jpg)) { printf(Failed to load image!\n); return -1; } // 2. 定位车牌 CSimpleImage grayImage srcImage; grayImage.ConvertToGray(); grayImage.SobelEdgeDetect(); grayImage.Binaryzation(128); // 或使用OTSU自动阈值 grayImage.MorphologyOperation(false, 5); // 膨胀 grayImage.MorphologyOperation(true, 5); // 腐蚀 std::vectorContourRect candidates FindLicensePlateCandidates(grayImage); if (candidates.empty()) { printf(No license plate found!\n); return -1; } ContourRect plateRect candidates[0]; // 取最可能的候选 // 3. 提取并矫正车牌区域 (此处简化假设矩形是正的) CSimpleImage plateRegion; plateRegion.Create(plateRect.width, plateRect.height, 1); // ... 从srcImage的plateRect位置拷贝数据到plateRegion ... // 4. 车牌区域再处理 plateRegion.ConvertToGray(); plateRegion.Binaryzation(0); // 使用自适应阈值效果更好 // plateRegion.AdaptiveBinaryzation(15, 5); // 假设实现了自适应二值化 // 5. 字符分割 std::vectorint vProj GetVerticalProjection(plateRegion); std::vectorContourRect charRects SplitByVerticalProjection(vProj, plateRegion.GetHeight()); printf(Found %d character regions.\n, charRects.size()); for (int i 0; i charRects.size(); i) { // 6. 归一化并保存每个字符 CSimpleImage charImg; // ... 从plateRegion中根据charRects[i]提取子图像 ... // charImg.Normalize(20, 20); // 归一化到20x20 // charImg.SaveToFile(format(char_%d.bmp, i).c_str()); } return 0; }7. 常见问题排查与性能优化技巧在实际运行中你肯定会遇到各种问题。下面是一些典型问题及其解决思路问题现象可能原因排查与解决思路定位不到车牌1. 图像质量太差过暗/过曝/模糊2. 边缘检测阈值不合适3. 形态学内核大小不对4. 筛选条件太严格1. 增加图像预处理增强对比度、直方图均衡化。2. 尝试Canny边缘检测需高低双阈值或调整Sobel后的二值化阈值。3. 根据图像分辨率调整膨胀腐蚀的内核大小可通过可视化中间结果边缘图、二值图、闭操作图来观察。4. 适当放宽长宽比和面积的筛选范围先确保能框到再考虑优化精度。定位到错误区域1. 背景中有类似矩形的物体窗户、广告牌2. 车牌边框不清晰1. 加入颜色验证。在定位前或定位后在原始图的候选框内检查主要颜色是否符合车牌底色蓝、黄等。2. 结合多种特征综合判断如矩形度、边缘密度区域内边缘像素占比。字符分割数量不对1. 车牌倾斜未矫正2. 二值化效果差字符断裂或粘连3. 投影法对间隔不敏感1.务必先矫正倾斜的车牌会导致垂直投影失效。2. 优化车牌区域的二值化算法尝试自适应阈值或更高级的方法。3. 对于粘连字符实现滴水算法或宽度判断逻辑进行二次分割。对于断裂字符可在分割前对二值图进行轻微的膨胀操作。分割出的字符包含边框水平投影未正确去除上下边框在垂直分割前先使用水平投影确定字符的垂直起始和结束行只在这个行范围内做垂直投影。程序运行慢1. 图像尺寸太大2. 算法复杂度高未优化1. 先将图像缩放至一个固定宽度如800像素进行处理定位到车牌后再在原图或稍大的图上精细切割。2. 避免在多重循环中进行重复计算。例如Sobel算子的卷积操作可以优化。连通域标记算法也有高效的优化版本。性能优化小技巧图像金字塔在大图上先进行下采样快速粗略定位车牌区域再回溯到原图精细定位可以大幅加速。积分图如果需要频繁计算矩形区域内像素的和比如颜色验证、计算平均亮度使用积分图能实现O(1)时间的查询。多线程对于视频流或批量图片处理可以将图像预处理、候选区域筛选等任务并行化。8. 项目扩展与进阶思考实现基础版本后你可以从以下几个方向深化这个项目集成字符识别分割出的字符图像可以送入一个识别模块。可以从简单的模板匹配开始预先制作一个包含所有汉字、字母、数字的标准字符模板库然后对每个分割块进行归一化与模板库逐一比对计算相关系数或距离取最相似的作为结果。进阶方向是使用机器学习如提取字符的HOG特征然后用SVM分类或者直接搭建一个轻量级的CNN如LeNet-5进行识别。应对更复杂场景夜间或低光照需要更强的图像增强或尝试使用Retinex算法。大角度倾斜需要更鲁棒的角点检测和透视变换算法。多车牌修改定位算法使其能返回多个候选框并逐一处理。工程化改进参数可配置化将二值化阈值、形态学内核大小、长宽比范围等参数写成配置文件便于针对不同场景调整而无需重新编译。模块化与日志将定位、分割、识别写成独立的类或DLL并增加详细的日志输出记录每一步的处理结果和耗时方便调试和性能分析。图形界面使用MFC或Qt开发一个简单的GUI可以加载图片、实时显示处理中间结果、调整参数并查看效果这对于算法调试和演示非常有帮助。这个用VC从头实现的车牌识别与分割项目就像搭积木一样把图像处理的基本操作组合起来解决一个具体问题。过程中你会深刻理解每一行代码对最终结果的影响。虽然它可能不如最新的深度学习模型那样“智能”但这份对底层原理的掌控感是未来学习更高级技术时最宝贵的财富。
VC++车牌识别系统实战:传统图像处理算法精解与工程实现
发布时间:2026/7/18 9:15:18
1. 项目概述从零到一用VC构建一个车牌识别系统最近在整理硬盘里的老项目翻出来一个十几年前用VC6.0写的车牌识别系统源码。当时为了一个智能停车场项目硬啃了几个月图像处理和模式识别现在回头看虽然代码风格有点“复古”但核心思路依然不过时。今天我就把这个项目的核心代码和实现逻辑掰开揉碎了讲一讲特别是车牌定位和字符分割这两个最关键的环节。无论你是刚接触图像处理的VC新手还是想了解传统车牌识别算法原理的老手这篇文章都能给你一份可以直接参考、甚至复现的“实战手册”。我们不用那些庞大复杂的开源库就靠最基础的C和Windows GDI一步步把车牌从图片里“揪”出来再把上面的字符一个个“切”明白。2. 核心思路与方案选型为什么选择经典的“灰度化-二值化-定位-分割”流程在动手写代码之前得先想清楚路怎么走。车牌识别License Plate Recognition, LPR通常分为几个标准步骤图像预处理、车牌定位、字符分割、字符识别。我们这个项目聚焦在前三步用VC实现最后一步的识别可以对接简单的模板匹配或后续的机器学习模型。2.1 技术路线选择传统图像处理为何仍是学习基石你可能听说过现在流行用深度学习做端到端的车牌识别效果确实好。但对于学习、对于在资源受限的环境比如某些嵌入式设备或遗留系统中部署以及对于理解计算机视觉的基本功来说传统的图像处理方法依然价值巨大。它不依赖海量数据计算量相对可控并且每一步的结果都清晰可见非常利于调试和理解算法本质。我们的方案基于以下考量可控性与可解释性每一步操作如灰度化、边缘检测、形态学处理其原理和效果都能直观地分析和调整。这对于学习和项目调试至关重要。对开发环境友好纯C实现依赖极少主要就是Windows GDI在经典的VC6到最新的Visual Studio 2022中都能顺利编译运行避免了复杂第三方库的配置冲突。聚焦核心逻辑剥离了深度学习框架的复杂性让我们能更专注于车牌识别领域本身的特殊性问题比如如何应对光照变化、车牌倾斜、复杂背景等。2.2 核心流程拆解我们确定的流程也是业界最经典、最稳健的流程之一原始彩色图像 - 灰度化 - 图像增强可选 - 边缘检测 - 二值化 - 形态学处理 - 候选区域查找 - 车牌区域筛选 - 车牌矫正 - 字符分割 - 输出分割结果。这个流程像一条流水线每一步都为下一步做准备。灰度化减少数据量边缘检测突出车牌边框二值化简化图像形态学处理连接断点、去除噪点最后通过车牌的几何特征长宽比、面积等从一堆候选框中把它找出来。分割阶段则利用车牌字符的排列规律进行投影切割。注意这个流程并非万能对于极端光照、严重污损或特殊车牌样式的图片效果会打折扣。但它提供了解决这类问题的标准“工具箱”和思考框架。3. 开发环境搭建与核心工具准备工欲善其事必先利其器。虽然我们说依赖少但一个顺手的开发环境和必要的工具类还是要准备的。3.1 开发环境配置IDEVisual Studio 2019 或 2022。我强烈建议使用新版本对C标准支持更好调试功能也更强大。不用担心兼容性我们的核心代码是标准C和Windows API。项目类型创建一个Win32控制台应用程序或MFC应用程序。为了专注于算法控制台程序更轻量。如果需要展示图像处理中间结果MFC的Picture Control或CImage类会很方便。字符集建议将项目属性中的“字符集”设置为“使用多字节字符集”以避免Unicode字符串处理带来的一些麻烦尤其是处理文件路径时。3.2 必备工具类图像数据的“搬运工”我们需要一个简单的类来加载、保存和在内存中操作图像。这里不直接用OpenCV而是自己封装更能理解底层。// ImageProcess.h - 一个简易的图像处理封装类 #pragma once #include windows.h #include vector class CSimpleImage { public: CSimpleImage(); ~CSimpleImage(); // 从文件加载图像支持BMP, JPEG, PNG等依赖GDI bool LoadFromFile(const char* szFilePath); // 创建指定大小的空图像 bool Create(int width, int height, int channels 1); // 保存图像到文件 bool SaveToFile(const char* szFilePath); // 获取图像信息 int GetWidth() const { return m_nWidth; } int GetHeight() const { return m_nHeight; } int GetChannels() const { return m_nChannels; } BYTE* GetData() { return m_pData.empty() ? NULL : m_pData[0]; } // 基本的像素访问这里以灰度图为例彩色图需扩展 BYTE GetPixel(int x, int y) const; void SetPixel(int x, int y, BYTE value); // 核心图像处理函数 bool ConvertToGray(); // 彩色转灰度 bool Binaryzation(int threshold); // 二值化 bool SobelEdgeDetect(); // Sobel边缘检测 bool MorphologyOperation(bool isErosion, int kernelSize 3); // 形态学操作腐蚀/膨胀 private: int m_nWidth; int m_nHeight; int m_nChannels; // 1:灰度 3:BGR std::vectorBYTE m_pData; // 存储图像数据一行一行存储 };这个类使用std::vectorBYTE管理图像数据内存自动释放。使用GDIGdiplus.h来解码和编码常见图片格式。实现ConvertToGray、Binaryzation等函数就是我们算法演练的舞台。实操心得自己封装图像类初期会有点繁琐但好处是每一步内存操作都清清楚楚。在调试时你可以随时把中间图像比如二值化后的结果保存下来查看对定位问题有奇效。务必注意图像数据是逐行存储的计算像素偏移量时公式是y * m_nWidth * m_nChannels x * m_nChannels。4. 车牌定位算法详解与VC实现定位是车牌识别的第一步也是难点所在。我们的目标是输入一张可能包含车辆的图片输出一个精确包围车牌的矩形框。4.1 图像预处理为定位创造好条件灰度化将彩色图转为灰度图减少三分之二的数据量。常用加权公式Gray 0.299*R 0.587*G 0.114*B。这个在ConvertToGray中实现。图像增强如果图像太暗或对比度太低可以先进行直方图均衡化或对比度拉伸。对于光照不均的情况可以采用Retinex算法或同态滤波但初期为了简化可以暂时跳过或尝试简单的灰度线性变换。边缘检测目的是突出车牌的矩形边框。我们使用Sobel算子。Sobel可以分别计算x和y方向的梯度合成后的梯度图能很好地保留边缘。// CSimpleImage 中 SobelEdgeDetect 的简化实现思路 bool CSimpleImage::SobelEdgeDetect() { if (m_nChannels ! 1) return false; // 确保是灰度图 std::vectorBYTE tempData(m_pData.size()); // Sobel 核 int sobelX[3][3] { {-1, 0, 1}, {-2, 0, 2}, {-1, 0, 1} }; int sobelY[3][3] { {-1, -2, -1}, {0, 0, 0}, {1, 2, 1} }; for (int y 1; y m_nHeight - 1; y) { for (int x 1; x m_nWidth - 1; x) { int gx 0, gy 0; // 卷积计算 for (int ky -1; ky 1; ky) { for (int kx -1; kx 1; kx) { BYTE pixel GetPixel(x kx, y ky); gx pixel * sobelX[ky 1][kx 1]; gy pixel * sobelY[ky 1][kx 1]; } } int magnitude (int)sqrt(float(gx*gx gy*gy)); magnitude min(255, max(0, magnitude)); // 限制范围 tempData[y * m_nWidth x] (BYTE)magnitude; } } m_pData.swap(tempData); return true; }二值化将灰度图或梯度图转为黑白图便于后续处理。关键在于阈值的选择。全局阈值如OTSU大津法自动计算一个最佳阈值。适用于前景背景对比明显的图像。局部阈值自适应对图像分块每块计算一个阈值能更好地处理光照不均。我们可以在Binaryzation函数里实现OTSU算法作为默认选项。4.2 形态学处理与候选区域提取经过边缘检测和二值化我们得到了一个黑白图其中白色是边缘像素。但车牌边框可能不连续背景也有杂散噪声。形态学闭操作先膨胀dilation再腐蚀erosion可以连接相邻的白色区域填充小孔洞使得断开的车牌边框能连成一个整体。这通过调用MorphologyOperation(false, 5)先膨胀和MorphologyOperation(true, 5)后腐蚀来实现内核大小如5需要根据图像分辨率调整。查找轮廓这是提取候选区域的关键。我们需要找到图像中所有白色的连通区域。VC中可以使用FindContours函数如果使用OpenCV的话但为了纯粹我们可以自己实现一个简单的连通组件标记算法如Two-Pass算法。第一次扫描给每个前景像素分配一个临时标签并记录标签间的等价关系。第二次扫描根据等价关系将临时标签解析为最终的连通区域标签。最终每个连通区域可以用一个外接矩形boundingRect来描述。候选区域筛选上一步会找到很多矩形大部分不是车牌。我们需要根据车牌的先验知识进行过滤几何特征长宽比中国车牌标准尺寸为440mm*140mm长宽比约为3.14。考虑到透视变形可以设定一个范围例如2.0 ~ 4.0。面积区域像素面积不能太小排除噪点也不能太大排除整个车头。矩形度区域面积与其最小外接矩形面积的比值接近1说明形状更规整。颜色特征可选但有效在原始彩色图上检查候选区域内的颜色是否符合车牌底色蓝、黄、白、绿、黑。可以统计HSV颜色空间中H色调分量的分布。通过设置这些条件的阈值可以筛掉大部分错误区域。通常一个场景下最后会剩下1-3个候选框。4.3 代码实现从轮廓查找到区域筛选由于自己实现完整的连通域分析代码较长这里给出一个使用标准库vector存储矩形并进行筛选的框架#include vector #include algorithm struct ContourRect { int x, y, width, height; double aspectRatio; // 长宽比 int area; // 像素面积 // 可以添加其他特征如颜色置信度 }; std::vectorContourRect FindLicensePlateCandidates(CSimpleImage binaryImage) { std::vectorContourRect candidates; std::vectorContourRect allRects; // 假设这里已经通过连通域分析将结果填充到了allRects中 // allRects TwoPassLabeling(binaryImage); for (const auto rect : allRects) { // 计算特征 rect.aspectRatio (double)rect.width / rect.height; rect.area rect.width * rect.height; // 筛选条件 bool condAspect (rect.aspectRatio 2.0 rect.aspectRatio 4.0); bool condArea (rect.area 500 rect.area 50000); // 阈值需根据图像大小调整 bool condSize (rect.width 40 rect.height 15); // 最小像素尺寸 if (condAspect condArea condSize) { candidates.push_back(rect); } } // 如果还有多个候选可以按面积排序取最大的一个或几个 std::sort(candidates.begin(), candidates.end(), [](const ContourRect a, const ContourRect b) { return a.area b.area; }); return candidates; }5. 车牌字符分割技术精讲定位到车牌区域后下一步就是把“京A·12345”这样的字符串切成“京”、“A”、“1”、“2”、“3”、“4”、“5”这7个独立的字符图像。这是影响识别率的关键一步。5.1 分割前的预处理矫正与再二值化车牌矫正定位出的车牌矩形可能是倾斜的透视变形。我们需要进行仿射变换将其拉正。核心是找到车牌的四个角点可以通过查找定位区域的最小外接旋转矩形minAreaRect获得然后映射到一个标准大小的正矩形上。区域提取与灰度/二值化从矫正后的图像中抠出车牌区域并专门针对这个区域再次进行灰度化和二值化。这次二值化可以采用局部自适应阈值如adaptiveThreshold因为车牌区域内部的光照可能仍然不均。5.2 基于投影法的字符分割这是最经典、最有效的分割方法利用了车牌字符排列整齐、间距相对均匀的特点。垂直投影确定字符左右边界统计二值化车牌图像中每一列的白色前景像素数得到一个投影直方图。字符所在的列白色像素多投影值高字符间的间隙白色像素少投影值低甚至为0。分割点就在投影值从高到低或从正到零的波谷位置。std::vectorint GetVerticalProjection(const CSimpleImage plateImage) { std::vectorint vProjection(plateImage.GetWidth(), 0); for (int x 0; x plateImage.GetWidth(); x) { int colSum 0; for (int y 0; y plateImage.GetHeight(); y) { if (plateImage.GetPixel(x, y) 128) { // 假设白色前景 colSum; } } vProjection[x] colSum; } return vProjection; } std::vectorContourRect SplitByVerticalProjection(const std::vectorint vProj, int height, int minCharWidth 5) { std::vectorContourRect charRects; bool inChar false; int startX 0; for (int x 0; x vProj.size(); x) { if (vProj[x] 0 !inChar) { // 进入一个字符区域 startX x; inChar true; } else if (vProj[x] 0 inChar) { // 离开一个字符区域 int endX x - 1; int width endX - startX 1; if (width minCharWidth) { // 过滤掉太窄的噪声 charRects.push_back({startX, 0, width, height}); } inChar false; } } // 处理最后一个字符 if (inChar) { int width vProj.size() - startX; if (width minCharWidth) { charRects.push_back({startX, 0, width, height}); } } return charRects; }水平投影确定字符上下边界同理统计每一行的白色像素数。用于去除车牌上下边框和确定字符的高度范围。可以先根据水平投影找到字符集中的核心行范围然后在垂直分割时只使用这个范围内的图像能有效排除上下边框的干扰。处理粘连字符对于像“京”这样左右结构的汉字或者因为二值化问题导致两个字符连在一起垂直投影可能无法分开。此时需要更复杂的策略滴水算法模拟水滴从上往下流遇到字符分叉处就分开适合分割粘连字符。模板匹配滑动用一个字符宽度的滑动窗口计算匹配度在匹配度最低的位置切分。先验知识中国车牌有固定格式汉字字母5位数字/字母可以预估字符宽度。如果某个分割出的区域宽度明显大于平均宽度的1.5倍就可能是粘连需要特殊处理。5.3 分割后处理与归一化分割出的字符图像大小不一需要归一化到统一尺寸例如20x20像素以便后续识别。常用方法是边界扩展将字符图像放在一个稍大的正方形画布中央。缩放使用双线性插值等算法将画布缩放到目标尺寸。避坑指南字符分割最大的坑在于光照和污渍导致的二值化错误以及车牌倾斜矫正不彻底。务必在分割前花大力气做好车牌的矫正和高质量的二值化。可以尝试多种二值化算法全局OTSU、局部自适应、Sauvola等并对比效果。保存每一个中间步骤的图像进行可视化检查是调试的不二法门。6. 实战将代码模块串联成完整流程现在我们把上述所有模块组合起来形成一个完整的车牌定位与分割程序。// main.cpp 主流程示例 int main() { // 1. 初始化 CSimpleImage srcImage; if (!srcImage.LoadFromFile(car_image.jpg)) { printf(Failed to load image!\n); return -1; } // 2. 定位车牌 CSimpleImage grayImage srcImage; grayImage.ConvertToGray(); grayImage.SobelEdgeDetect(); grayImage.Binaryzation(128); // 或使用OTSU自动阈值 grayImage.MorphologyOperation(false, 5); // 膨胀 grayImage.MorphologyOperation(true, 5); // 腐蚀 std::vectorContourRect candidates FindLicensePlateCandidates(grayImage); if (candidates.empty()) { printf(No license plate found!\n); return -1; } ContourRect plateRect candidates[0]; // 取最可能的候选 // 3. 提取并矫正车牌区域 (此处简化假设矩形是正的) CSimpleImage plateRegion; plateRegion.Create(plateRect.width, plateRect.height, 1); // ... 从srcImage的plateRect位置拷贝数据到plateRegion ... // 4. 车牌区域再处理 plateRegion.ConvertToGray(); plateRegion.Binaryzation(0); // 使用自适应阈值效果更好 // plateRegion.AdaptiveBinaryzation(15, 5); // 假设实现了自适应二值化 // 5. 字符分割 std::vectorint vProj GetVerticalProjection(plateRegion); std::vectorContourRect charRects SplitByVerticalProjection(vProj, plateRegion.GetHeight()); printf(Found %d character regions.\n, charRects.size()); for (int i 0; i charRects.size(); i) { // 6. 归一化并保存每个字符 CSimpleImage charImg; // ... 从plateRegion中根据charRects[i]提取子图像 ... // charImg.Normalize(20, 20); // 归一化到20x20 // charImg.SaveToFile(format(char_%d.bmp, i).c_str()); } return 0; }7. 常见问题排查与性能优化技巧在实际运行中你肯定会遇到各种问题。下面是一些典型问题及其解决思路问题现象可能原因排查与解决思路定位不到车牌1. 图像质量太差过暗/过曝/模糊2. 边缘检测阈值不合适3. 形态学内核大小不对4. 筛选条件太严格1. 增加图像预处理增强对比度、直方图均衡化。2. 尝试Canny边缘检测需高低双阈值或调整Sobel后的二值化阈值。3. 根据图像分辨率调整膨胀腐蚀的内核大小可通过可视化中间结果边缘图、二值图、闭操作图来观察。4. 适当放宽长宽比和面积的筛选范围先确保能框到再考虑优化精度。定位到错误区域1. 背景中有类似矩形的物体窗户、广告牌2. 车牌边框不清晰1. 加入颜色验证。在定位前或定位后在原始图的候选框内检查主要颜色是否符合车牌底色蓝、黄等。2. 结合多种特征综合判断如矩形度、边缘密度区域内边缘像素占比。字符分割数量不对1. 车牌倾斜未矫正2. 二值化效果差字符断裂或粘连3. 投影法对间隔不敏感1.务必先矫正倾斜的车牌会导致垂直投影失效。2. 优化车牌区域的二值化算法尝试自适应阈值或更高级的方法。3. 对于粘连字符实现滴水算法或宽度判断逻辑进行二次分割。对于断裂字符可在分割前对二值图进行轻微的膨胀操作。分割出的字符包含边框水平投影未正确去除上下边框在垂直分割前先使用水平投影确定字符的垂直起始和结束行只在这个行范围内做垂直投影。程序运行慢1. 图像尺寸太大2. 算法复杂度高未优化1. 先将图像缩放至一个固定宽度如800像素进行处理定位到车牌后再在原图或稍大的图上精细切割。2. 避免在多重循环中进行重复计算。例如Sobel算子的卷积操作可以优化。连通域标记算法也有高效的优化版本。性能优化小技巧图像金字塔在大图上先进行下采样快速粗略定位车牌区域再回溯到原图精细定位可以大幅加速。积分图如果需要频繁计算矩形区域内像素的和比如颜色验证、计算平均亮度使用积分图能实现O(1)时间的查询。多线程对于视频流或批量图片处理可以将图像预处理、候选区域筛选等任务并行化。8. 项目扩展与进阶思考实现基础版本后你可以从以下几个方向深化这个项目集成字符识别分割出的字符图像可以送入一个识别模块。可以从简单的模板匹配开始预先制作一个包含所有汉字、字母、数字的标准字符模板库然后对每个分割块进行归一化与模板库逐一比对计算相关系数或距离取最相似的作为结果。进阶方向是使用机器学习如提取字符的HOG特征然后用SVM分类或者直接搭建一个轻量级的CNN如LeNet-5进行识别。应对更复杂场景夜间或低光照需要更强的图像增强或尝试使用Retinex算法。大角度倾斜需要更鲁棒的角点检测和透视变换算法。多车牌修改定位算法使其能返回多个候选框并逐一处理。工程化改进参数可配置化将二值化阈值、形态学内核大小、长宽比范围等参数写成配置文件便于针对不同场景调整而无需重新编译。模块化与日志将定位、分割、识别写成独立的类或DLL并增加详细的日志输出记录每一步的处理结果和耗时方便调试和性能分析。图形界面使用MFC或Qt开发一个简单的GUI可以加载图片、实时显示处理中间结果、调整参数并查看效果这对于算法调试和演示非常有帮助。这个用VC从头实现的车牌识别与分割项目就像搭积木一样把图像处理的基本操作组合起来解决一个具体问题。过程中你会深刻理解每一行代码对最终结果的影响。虽然它可能不如最新的深度学习模型那样“智能”但这份对底层原理的掌控感是未来学习更高级技术时最宝贵的财富。