C++结合OpenCV实现车牌识别系统:从图像预处理到字符识别的完整流程
2026/9/20 20:45:22 网站建设 项目流程

简介:面向C++与计算机视觉学习者的车牌识别系统完整工程,基于C++与OpenCV实现从图像采集、预处理、车牌定位、字符分割到字符识别的全流程。资源适合有C++基础、正在学习OpenCV或准备智能交通方向课程设计、毕设的开发者,用于理解传统图像处理与字符识别技术在真实场景中的落地方法。包体共125个文件,压缩包大小2.21MB。其中代码以9个cpp与13个h为主,对应功能模块与头文件;附带5个dll与1个exe便于直接运行调试;资源内还有88个bmp样本测试图,以及sln、vcproj、rc等工程配置文件,可供完整复现实验或二次开发。目前已有80人学习下载。通过该工程可掌握车牌定位中的形态学操作、字符分割中的轮廓检测与投影法,并参考SVM等字符识别思路,是一份结构清晰、可直接运行的项目模板,适合对照学习、逐模块调试与功能扩展。 前阵子有个读者私信问我,说想拿“C++结合OpenCV实现的车牌识别系统”当毕业设计,但不知道从哪下手。这让我想起自己第一次做这个项目时的状态:搜了一堆资料,代码跑通的和跑不通的各占一半,看到的全是零散的函数片段,很少有文章把“从图像输入到识别结果输出”这条完整链路讲清楚。所以这篇文章不打算只贴代码,而是从整体架构出发,把车牌定位、字符分割、字符识别三个核心模块的选型理由和实现细节一条条捋出来,顺便把环境配置和参数调试的经验也写进去。

这个系统本身是个特别典型的计算机视觉入门到进阶项目,它同时涉及图像预处理、边缘检测、轮廓分析、形态学操作、机器学习分类这几个OpenCV里最高频的知识点。无论你是准备面试、做课设,还是单纯想练手,这套东西都能让你把零散的函数调用串成一条完整的技术链路。我先说结论:一个能稳定工作的车牌识别系统,核心不在识别那一步,而在于前面车牌定位和字符分割做得够不够干净——这个比例大约占整个项目工作量的百分之七十。

1. 认识车牌识别的基本流程:不是每一步都要上深度学习

很多新手一上来就想用深度学习做端到端识别,结果数据集、GPU、训练时间全是坑。其实对于标准蓝底白字车牌这种结构化目标,传统图像处理方案完全够用,而且可控性强、好调试、运行快。整个流程可以拆成五步:

  1. 图像读取与预处理:转灰度、去噪、边缘增强。
  2. 车牌定位:在整张图中找到车牌所在的矩形区域。
  3. 车牌校正(可选):处理倾斜的车牌,做透视变换。
  4. 字符分割:把车牌区域切成单个字符的小图。
  5. 字符识别:对每个字符图进行分类,输出字符串。

如果你的输入是摄像头实时视频流,那么还要额外加入帧率控制和感兴趣区域裁剪,但核心链路不变。这个流程设计有一个很重要的原则:每步只做一件事,前一步的结果质量直接决定后一步的难度。比如车牌定位如果框得不准,后续字符分割得到的就是一张歪斜或比例不对的图像,识别准确率会断崖式下降。

说句实话,真正做工业级车牌识别系统时,深度学习方案确实是大趋势,但对于学习阶段或中小型项目,传统视觉方案的“可解释性”是无价的——每一步变换都能可视化查看,出了问题马上知道是哪个环节造成的。先用传统方案跑通全流程,再考虑用深度学习替换其中某一个薄弱环节,这才是最稳的成长路径。

2. 环境搭建与技术选型:OpenCV版本和C++标准千万别搞混

2.1 开发环境

我用的组合是Visual Studio 2019 + OpenCV 4.5.5 + C++17标准。这里有个关键经验:OpenCV 4.x系列对C++11以上的支持很友好,而且移除了很多老旧的API,网上大多数教程基于OpenCV 3.x,有些代码(比如cv::findContours的参数)在4.x下要改动,所以你搜索代码时必须先确认版本。

OpenCV的安装本身并不复杂,麻烦的是环境变量配置。Windows下解压OpenCV后,你需要做三件事:把opencv\build\x64\vc15\bin加入系统PATH路径;在VS的项目属性中配置包含目录(include)和库目录(lib);把链接器输入中的附加依赖项改成opencv_world455d.lib(Debug模式)或opencv_world455.lib(Release模式)。这里的d后缀代表Debug库,搞反了会报一堆解析错误。

2.2 项目文件组织

我建议把工程按功能拆成几个模块文件,别把代码全堆积在一个main.cpp里。一个比较合理的组织方式:

  • main.cpp:主流程控制,负责调用各模块。
  • PlateLocator.cpp/h:车牌定位模块。
  • CharSegmentor.cpp/h:字符分割模块。
  • CharRecognizer.cpp/h:字符识别模块。
  • utils.cpp/h:公共工具函数(如图像显示、直方图统计)。

这样组织的好处是后续换识别算法或调参时,你只需要动对应模块,完全不需要重写其他代码。模块化的思想听着简单,但这个项目里真的能帮你省掉大量重复编译和调试时间。

2.3 代码基础配置

打开图像后第一件事就是检查图像是否为空,这个习惯一定要养成。很多报错都源于路径写错或图像读取失败,而第0帧输入就是空指针。

cv::Mat src = cv::imread("car.jpg"); if (src.empty()) { std::cerr << "Failed to load image!" << std::endl; return -1; }

后面所有的图像处理操作,都是在src这幅原图上做各种变换。调试时我习惯加两个辅助窗口:一个实时显示当前处理的中间结果,一个显示最终输出。要想显示中文窗口名,OpenCV老版本有些麻烦,但4.x配合新版Windows一般没这个烦恼。调试完记得关掉这些窗口,不然正式运行时会弹出很多图干扰操作。

3. 车牌定位:颜色是比轮廓更可靠的线索

3.1 为什么先用颜色再找轮廓

车牌定位的主流思路有两种:一种是基于边缘检测找矩形轮廓,另一种是基于车牌底色做颜色阈值分割。单独用边缘检测对复杂背景(树木阴影、车灯、路面标线)极度敏感,很容易把各种矩形物体误判为车牌;单独用颜色阈值又会因为光照变化导致漏检。好的做法是两者结合:先做颜色识别,再用轮廓特征筛选。

国内蓝底白字车牌,HSV颜色空间中蓝色的Hue(色相)范围大概在100到124之间,Saturation(饱和度)通常在100以上。通过cv::inRange函数可以把这个范围内的像素点提取成二值掩码。伪代码如下:

cv::Mat hsv, mask; cv::cvtColor(src, hsv, cv::COLOR_BGR2HSV); cv::inRange(hsv, cv::Scalar(100, 100, 60), cv::Scalar(124, 255, 255), mask);

这里对V(亮度)下限设置到60,是为了滤掉夜间图像中很暗的噪声点。同时,如果场景中有大量蓝色元素(如蓝色货车、蓝天),可以加一个条件:只保留面积在图像总面积某个比例范围内的连通域,避免海选。

3.2 候选区域的轮廓筛选

拿到mask之后,用cv::findContours提取轮廓,然后用几何特征筛选车牌区域。车牌是标准的矩形宽高比,中国车牌宽高比约为440:140,即3.14左右。所以筛选条件设置为:

  • 轮廓的外接矩形宽度大于某个阈值(比如50像素)。
  • 外接矩形的宽高比在2.5到4.5之间。
  • 轮廓面积在图像总面积的某个比例区间。

这一步如果检测到多个候选区域,就把它们全部保留,后续交给字符分割模块逐一验证。不要急于用面积最大原则一刀切,因为同一画面里可能出现多辆车或误检区。

3.3 形态学操作让掩码“连成一片”

原始的颜色掩码中,车牌区域的字符部分(白色)和底色(蓝色)在掩码图像里会形成很多空洞,轮廓可能因此断裂。此时需要做闭运算(先膨胀再腐蚀),把字符和车牌边缘连接成完整矩形。膨胀和腐蚀的内核大小需要根据图像尺寸调整,一般可用cv::getStructuringElement(cv::MORPH_RECT, cv::Size(15, 5)),这个尺寸能较好地横向连通字符区域。

我把车牌定位的完整流程总结成这样一张表,方便你对照检查:

步骤操作关键参数常见问题
1BGR转HSV-光照影响大,需动态调节阈值
2蓝色掩码提取H:100-124, S:100-255, V:60-255蓝天误检、夜间漏检
3闭运算连通核大小(15,5)核太大导致不同区域粘连
4轮廓提取RETR_EXTERNAL + CHAIN_APPROX_SIMPLE内层轮廓干扰
5几何筛选宽高比2.5-4.5车灯、标语误检
6透视校正四点变换矩阵倾斜过大时定位框不准

4. 字符分割:二值化阈值和投影法是关键

4.1 灰度化与二值化:阈值选择是有讲究的

车牌定位完成后,我们需要从原图中抠出车牌区域,然后做灰度化和二值化。二值化这一步直接影响后面的字符分割质量。如果整幅图像光照均匀,直接用大津法(Otsu)自适应阈值就行;但车牌区域很多时候受光照不均影响(比如半亮半暗),固定阈值会切坏字符。此时我会用cv::adaptiveThreshold,它对每块局部区域计算独立阈值,抗光照变化能力更强。

另外一个经验:如果车牌是蓝底白字,二值化时字符应该是白色、背景是黑色,即直接取mask的反色;但如果是黄底黑字或绿底白字,二值化方向正好相反。基于颜色先判断车牌类型,再做二值化方向选择,能省很多事。

4.2 垂直投影法分割字符

字符分割最经典的方法是垂直投影法。其核心逻辑是:将二值化后的车牌图像按列做像素统计,形成一条投影曲线。字符之间的间隔位置,投影值接近0或极小;字符内部的投影值较高。从曲线中找出这些“低谷”,就能把字符一个个切开。具体代码思路如下:

std::vector<int> columnSum(cols, 0); for (int i = 0; i < rows; i++) { for (int j = 0; j < cols; j++) { if (binary.at<uchar>(i, j) == 255) { columnSum[j]++; } } } // 寻找连续非零区间,作为字符的左右边界

注意一个问题:车牌的上下边缘常有铆钉或边框,在垂直投影时会造成干扰,必须预先做一步“剔除上下边框”的操作——从图像首行开始向上扫描,如果一行整体都是白色且厚度很小,就认定是边框,直接裁剪掉。同理处理最左侧和最右侧,因为车牌边缘常常带有蓝色边框,不剔除也会干扰分割。

4.3 归一化尺寸

分割出来的字符大小各不相同,直接送进分类器会导致识别率飘忽不定。我一般会把所有字符图像统一缩放到20x4028x56像素,同时用cv::copyMakeBorder补边保持字符不拉伸变形。这一步在后续做模板匹配或SVM特征提取时至关重要——特征维度不一样,分类器根本没法训练。

分割阶段值得多说一句的是第二个字符与第三个字符之间有个小圆点,这个小圆点很容易被误认为一个字符。解决方案有两种:一是按宽高比过滤,圆点的高宽比接近1且面积特别小;二是利用车牌的字符位置先验——第2个字符宽3mm左右且间隔与标准字符不同,可以用规则剔除。

5. 字符识别:模板匹配和SVM怎么选

5.1 模板匹配:简单但脆弱

第一个识别方案是模板匹配,它的原理极其简单:把分割出的字符图像与预先保存的每个字符模板做像素级对比,找到相似度最高的那个。OpenCV里用cv::matchTemplate可以算归一化相关系数,相关性最高的模板对应的字符就是识别结果。这个方案的好处是实现快、不用训练,适合做Demo。

坏处也很明显:只要字体微变、噪声稍多、光照异常,相似度就会大幅滑坡。尤其是像“0”和“O”、“1”和“I”这种在不同省份字库中差异很大的字符,匹配很容易翻车。模板匹配还有一个致命问题:它对字符的归一化质量极其敏感,凡是分割时比例不准确的图,匹配结果基本不可信。

5.2 SVM训练:工业级方案的低配版

比模板匹配更鲁棒也更有学习价值的方案是HOG特征 + SVM分类器。HOG(方向梯度直方图)提取的是字符图像的梯度分布特征,对光照变化和微小形变的容忍度比原始像素高得多。OpenCV自带SVM实现,训练流程如下:

先准备每个字符的样本图像,我用的数据来自网上公开的车牌字符数据集,每类字符大约50到100张样本。对每张样本做预处理(灰度化、缩放、归一化),提取HOG特征(cv::HOGDescriptor),把特征向量和对应标签喂给cv::ml::SVM::train,最后保存成XML模型文件。

在实际识别时,加载模型并调用predict得到每个字符的分类结果。这个方案的准确率在标准车牌上能到95%以上,而且SVM推断速度极快,单帧处理不到几个毫秒。基于个人经验,它的最大门槛是数据收集和标注质量,而不在算法本身。

5.3 后处理纠错

无论用哪种识别方案,都应该加一个后处理纠错步骤。比如中国车牌的字符结构是固定的:第一位是省份汉字;第二位是发牌机关字母;后面5位通常是字母和数字混合。利用这个先验知识,可以给每个位置的字符集加上约束,例如第一位只能是特定的汉字集合,不会出现数字或普通字母。识别结果不在合理集合内时,可以强制换用置信度第二高的选项。 我实际调试时遇到过这样一个案例:一个“京”字被识别成了“示”,因为取阈值时抠掉了下半部分结构。这个错误完全可以通过字符结构先验修正,代价只是多写几行过滤逻辑。

6. 完整运行流程与常见失败图像分析

6.1 端到端代码骨架

把前几节的模块串起来,主流程的骨架比较简洁:

int main() { cv::Mat frame = cv::imread("test.jpg"); PlateLocator locator; CharSegmentor segmentor; CharRecognizer recognizer("svm_model.xml"); std::vector<cv::Rect> plates = locator.locate(frame); for (auto& rect : plates) { cv::Mat plateImg = frame(rect); std::vector<cv::Mat> chars = segmentor.segment(plateImg); std::string plateNumber = recognizer.recognize(chars); std::cout << "识别结果: " << plateNumber << std::endl; cv::rectangle(frame, rect, cv::Scalar(0, 255, 0), 2); } cv::imshow("Result", frame); cv::waitKey(0); return 0; }

这个流程看起来顺理成章,但实际跑起来你会发现大量图像让人崩溃:车牌被遮阳板挡住一半、车辆倾斜超过45度、夜间灯光造成的反光、雨天雨滴干扰等等。我建议你准备一个“困难测试集”,专门放这些极端情况,然后逐个分析哪一步出了问题,这个过程学到的比跑通100张正常图还有用。

6.2 三类典型失败场景与优化策略

为了让你避坑,我把常见的失败场景和解决方案整理成一张表:

失败场景根因定位优化方向
蓝车车身被误检为车牌颜色掩码只靠HSV阈值,轮廓筛选过宽增加矩形度、面积、宽高比约束;用字符密度进一步筛选
倾斜车牌导致字符分割失败定位后直接裁剪,未做透视校正对车牌四个角点做透视变换,校正为正视图再分割
夜间车灯直射造成反光,蓝色区域泛白HSV阈值固定动态调整V通道阈值,或先做直方图均衡化再转换颜色空间

我在实际项目里最常遇到的Bug不是算法本身,而是cv::findContours在不同OpenCV版本下的返回值差异。4.x版本中这个函数的第二个参数是std::vector<std::vector<cv::Point>>,不再返回cv::findContours的第一个返回值层次结构,如果照抄老代码,编译器直接报错。这类问题排查虽然不难,但确实很消耗时间。

6.3 调试工具与性能瓶颈的量化

除了处理流程,还有一个常被忽略的点——性能测试。我建议你在每一帧处理前用cv::getTickCount做计时,把车牌定位、字符分割、字符识别三个阶段的耗时分别统计出来。标准720p图像在Release模式下,我用上述方案实测的耗时大约是:定位45ms、分割10ms、识别20ms,总耗时约75ms,基本能达到12到15帧的处理速度。如果帧率要求更高,瓶颈通常在颜色转换和形态学操作上,可以考虑缩小输入图像或换用GPU加速。

7. 后续优化方向

这个项目做完后,如果你想继续往深走,我建议按下面的顺序扩展:

  • 加入多角度车牌检测:用边缘检测加直线检测(Hough变换)寻找车牌边框的四条直线,再做透视变换,能解决一定程度的倾斜问题。
  • 引入深度学习检测器:用YOLO或更轻量化的MobileNet-SSD替换颜色定位,可以大幅提升复杂场景下的召回率。
  • 车牌类型扩展:处理新能源绿牌、黄牌、黑牌港澳车,本质上是扩展颜色阈值集合和字符集合。
  • 接入真实视频流:从静态图片切换到视频后,需要加入跟踪和帧间平滑逻辑,否则识别结果会闪跳。

单从技术参数来看,这套系统处理标准蓝牌能达到95%的识别准确率,处理速度在CPU上也能跑出12帧以上,性能和精度都足以支撑一个小型演示项目。我在实际调试过程中最大的体会是,传统图像处理方案的调试成本其实比预想中高,每一个阈值参数都可能引起误检的连锁反应,值得你耐心地用直方图和中间结果图多观察几次。

最后再分享一个调试小技巧:每次改参数之前,把当前处理结果截图保存下来,形成一个测试报告文件夹。这样当准确率突然下降时,你可以快速回滚对比找出是哪一步参数改坏了,而不用靠回忆。做个开源项目也好、纯粹练手也罢,这套排查方法会一直跟着你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询