ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++和OpenCV车牌识别实战:从定位到识别的完整流程

C++和OpenCV车牌识别实战:从定位到识别的完整流程 简介C结合OpenCV实现的车牌识别系统是一份面向计算机视觉初学者与智能交通项目开发者的完整工程资料适用于高速公路收费、停车场管理、城市交通监控等场景的学习与原型验证。系统贯穿图像采集、预处理、车牌定位、字符分割、字符识别全流程帮助读者理解灰度化、边缘检测、形态学操作、轮廓提取以及SVM/神经网络等算法在字符识别中的实际落地。资源包共125个文件压缩后约2.21MB包含9个cpp源文件、13个头文件、5个动态库和88张bmp车牌样本图片另有sln与vcproj工程文件可直接打开编译或二次开发其中dll用于运行时依赖bmp图片覆盖蓝牌、黄牌等常见样本。目前已有80人学习下载。资源内附带了多张不同省份车牌的测试位图便于验证算法效果。通过源码阅读与运行测试可以掌握OpenCV常用图像处理函数、车牌定位与字符分割的工程化实现思路是一份兼顾原理与代码的参考资源。 车牌识别这事儿放在停车场闸机、小区门禁、高速收费口大家早就习以为常了。真到自己动手写一个才发现里面的门道比想象中多得多。网上搜“车牌识别系统”出来的大多是Python版或者直接调百度云的API真正用C从底层一点点抠出来的教程反而不多。但这套东西恰恰是入门计算机视觉最实在的练手项目流程完整、算法经典、踩坑点多、做完特别有成就感。这篇博文我就把自己用C结合OpenCV实现车牌识别系统的完整过程、思路和坑全部整理出来给正在做课程设计或者准备入门CV的朋友一个可以直接照着走的参考。先说清楚我这里做的是传统视觉方案没有上深度学习。识别过程分三步走车牌定位、字符分割、字符识别。不涉及神经网络不需要标注数据一台普通电脑就能跑。整个项目代码量不大核心逻辑全在OpenCV的经典函数上适合想搞清楚图像处理原理的朋友。如果你正在纠结毕业设计选什么题或者想从零开始学OpenCV这个项目我可以说是非常对口的。1. 项目概述与总体设计思路车牌识别系统英文叫License Plate Recognition又被简写为LPR是计算机视觉里一个非常经典的方向。它解决的问题很简单给定一张包含车辆的图片或者一段路面视频系统能自动把车牌号码“读”出来。应用场景那是真的广——智慧停车场的出口收费、违章抓拍、小区安防道闸、高速ETC的辅助校验都是它的地盘。可以说只要是涉及“车”和“管理”的地方背后都有这套技术在撑腰。1.1 车牌识别的核心处理流程整个识别链路我自己把它拆成了四个核心环节按顺序走一遍逻辑非常清晰图像预处理读取图像之后先把无关噪声去掉恢复图像质量为后续定位做准备。车牌定位从整幅图中找到车牌在哪。这一环最核心的技术就是颜色分析加轮廓检测有时还要加上形态学处理。字符分割把定位到的车牌区域进一步细分切出独立的汉字、字母、数字为识别做准备。字符识别逐字符比对模板库输出最终的车牌号码文本。这四步走完一个车牌就“读”出来了。其中最难、也最容易翻车的就是车牌定位。你想想一张照片里可能有树影、车灯、保险杠、周围的行人背景乱七八糟怎么把车牌从这一堆信息里干净利落地揪出来这是整个系统的核心难点。1.2 为什么选择传统视觉方案而不是深度学习很多朋友一上来就问“现在不都流行用深度学习做车牌识别吗YOLO一检测OCR一识别多省事”这话确实没毛病深度学习方案在复杂场景下的鲁棒性肯定更强。但我依然坚持用传统视觉方案作为首选教学和落地方案原因有三可解释性强每一个处理步骤都看得见摸得着。哪一步导致定位失败了、为什么分割错了都能准确排查不会像神经网络那样出问题只能瞎调参。硬件要求低深度模型要跑得流畅至少得有个不错的GPU。传统方案一张纯CPU的工控机就能跑几万块钱的嵌入式设备也能实时处理这在工业落地时是成本优势。算法经典车牌定位用到的HSV颜色空间、轮廓提取、形态学操作、透视变换这些是视觉领域的基本功。把这些玩明白了后面学什么视觉项目都事半功倍。当然传统方案也有它的天花板比如在极端光照、车牌倾斜严重、遮挡比较多的时候就容易翻车。我的态度是入门和学习阶段先用传统方案把原理吃透实际工程中如果场景复杂再叠加深度学习也不迟。2. 开发环境搭建与工具选型这块是很多新手第一个卡壳的地方。我见过太多代码写得好好的结果卡在环境配置上三天没进展的同学了。这里我把自己的配置过程完整写一遍你看完照着弄就行。2.1 VS OpenCV环境配置的完整步骤我用的组合是Visual Studio 2019 OpenCV 4.5.5Windows 10系统。先说为什么选这两个版本——VS2019对C标准的支持很稳定社区版免费而且网上资料最多OpenCV 4.5.5属于4.x时代比较稳定的版本不用太激进也不用太老导致很多新函数用不了。配置步骤从OpenCV官网下载Windows版安装包比如opencv-4.5.5-windows.exe双击解压到指定目录我习惯放在D:\opencv。打开VS2019创建一个空的C控制台应用项目。打开项目属性页右键项目 → 属性VC目录 → 包含目录添加D:\opencv\build\include和D:\opencv\build\include\opencv2VC目录 → 库目录添加D:\opencv\build\x64\vc15\lib链接器 → 输入 → 附加依赖项添加opencv_world455.lib注意Debug模式下是opencv_world455d.lib带d的才是调试库把D:\opencv\build\x64\vc15\bin目录加入系统环境变量Path。注意这里的vc15目录对应VS2017/2019的运行时库老教程里还会看到vc14、vc12之类的目录那是给VS2015和VS2013用的别选错。另外如果你的VS提示找不到opencv_world455d.lib多半是下成了Release版的库或者库文件名和版本号对不上。配置完后写一段简单代码验证环境是否OK#include opencv2/opencv.hpp #include iostream int main() { cv::Mat img cv::Mat::zeros(300, 300, CV_8UC3); cv::circle(img, cv::Point(150, 150), 100, cv::Scalar(0, 0, 255), 2); cv::imshow(test, img); cv::waitKey(0); return 0; }能弹出窗口并且显示一个红色圆圈说明环境OK了。这里有个小细节控制台程序如果没有cv::waitKey()图像窗口会一闪而过这个是OpenCV高GUI的特性不要慌。2.2 关于运行时库和平台选择的常见误区好多同学在环境一旦没跑通第一反应是代码写错了其实很多时候是“运行时库”的问题。什么是运行时库你可以把它理解成一个翻译官我们编译好的程序在运行的时候需要它来和操作系统沟通。OpenCV在Windows下是动态链接的也就是说运行的时候必须要依赖对应的DLL文件。这里有两个最常踩的坑Debug模式没设置调试库Debug模式下链接了Release的库或者反过来大概率会报一堆link error。记住Debug对应xxxd.libRelease对应xxx.lib千万别混。x64和x86平台混淆OpenCV官方库默认提供x64版本但VS默认的解决方案平台可能是x86这样链接的时候照样找不到库。解决办法就是在项目属性页里把“平台”切换成x64。另外还有一点容易被忽略如果你用的是绿色版/免安装版的VS或者网上找的“精简版”开发环境缺失Redistributable的情况会更常见。报错表现就是打开程序时提示“vcruntime140.dll缺失”之类。这种问题的标准解法就是安装对应版本的Microsoft Visual C Redistributable包可以从微软官网下载64位与32位版本都装上图个省心。3. 车牌定位从图像中找出车牌区域定位环节做好了后面的分割和识别就顺水推舟定位做不好后面全是白搭。我用的策略是“颜色优先、轮廓辅助、几何筛选”的三板斧。3.1 颜色空间转换与HSV阈值筛选国内常见的蓝底白字车牌最明显的特征就是那个蓝底。但是如果直接在BGR颜色空间里用固定阈值去卡蓝色效果很糟糕。因为BGR三个通道的数值受光照影响非常大同一块蓝色在阴影里和在大太阳底下RGB数值能差出一大截阈值根本没法设。所以第一步是把图像从BGR转到HSV颜色空间。HSV把颜色分解成色调Hue、饱和度Saturation、明度Value其中色调对人眼感知的光照变化相对不敏感。这也正是OpenCV里cvtColor的经典用法cv::Mat hsv, blueMask; cv::cvtColor(src, hsv, cv::COLOR_BGR2HSV); cv::inRange(hsv, cv::Scalar(100, 80, 60), cv::Scalar(124, 255, 255), blueMask);这里的H范围我用的是100~124对应OpenCV中蓝色区域的色相区间。S和V的下限设得比较低80/60是为了把一些暗光下饱和度不高的蓝色也尽量保留下来。S和V的上限直接拉满因为蓝底正常情况下饱和度不会低。实际调参的时候你可以把这几个阈值暴露成滑动条cv::createTrackbar一边动一边看效果调起来非常直观。3.2 形态学操作与轮廓筛选拿到纯蓝区域的二值掩码mask之后你会发现里面除车牌外还可能有一堆蓝色车贴、车身装饰条之类的干扰区域。这时候需要在二值图上做形态学操作。先说结论我用的组合是先做一次开运算去掉孤立小噪点再做一次闭运算把车牌上因为螺丝孔、字体遮挡产生的断缝连起来。cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); cv::morphologyEx(blueMask, blueMask, cv::MORPH_OPEN, kernel); cv::morphologyEx(blueMask, blueMask, cv::MORPH_CLOSE, kernel);形态学操作本质上就是“膨胀”和“腐蚀”的各种组合。开运算是先腐蚀后膨胀能把细小的白色噪点去掉闭运算是先膨胀后腐蚀能把白色区域的小缺口补上。在车牌定位场景下闭运算尤其重要因为车牌中间有字符的地方蓝色被占掉了不做闭运算的话车牌区域会变成一块一块断裂的碎片轮廓检测就很难找到完整的外边框。做完形态学重头戏就来了——cv::findContours提取轮廓。std::vectorstd::vectorcv::Point contours; cv::findContours(blueMask, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);这里我用的是RETR_EXTERNAL只提取最外层轮廓避免车牌的汉字和数字形成内部轮廓干扰筛选。然后遍历所有轮廓用cv::boundingRect获取最小外接矩形再用车牌的几何先验做筛选。车牌的长宽比是我筛选的第一道关卡。国内蓝牌的标准尺寸是440mm×140mm长宽比在3.14左右实际拍摄中因为视角原因会有点变化所以我一般把筛选范围放宽到2.5~4.5。同时还会加上面积约束——太小或者太大的轮廓直接排除这样可以干掉很大一部分噪声。3.3 倾斜校正与透视变换筛选出车牌区域之后别急着分割字符。如果车辆是在转弯或者相机安装角度不正车牌在图像里可能是倾斜的。直接对倾斜的车牌做字符分割投影法会完全失效。这时候就需要做透视矫正把歪歪扭扭的车牌“扶正”。我的做法是使用cv::minAreaRect拿到车牌区域的最小外接旋转矩形然后根据矩形的旋转角度判断是否需要校正。如果角度绝对值大于某个阈值就用cv::getPerspectiveTransform算出变换矩阵再做透视变换cv::RotatedRect rotatedRect cv::minAreaRect(contour); float angle rotatedRect.angle; if (angle -45) angle 90; // OpenCV的angle范围特殊处理 // 计算车牌四个角点用getPerspectiveTransform做矫正这里有个小坑需要注意OpenCV的RotatedRect::angle返回值的范围是[-90, 0)而且长边和宽边的判断跟直觉不太一样直接拿这个角度去转很容易得到转进沟里的结果。我一般会根据返回矩形的宽高哪个大来决定是转angle还是转angle 90。透视矫正做完车牌区域就变成一张规规矩矩的正面矩形图这时候再交给字符分割成功率能提升一大截。4. 字符分割把车牌拆成一个个独立的字符字符分割这个环节我的理解是像是在一堆排队的人里画线你要找出每个人之间的“空隙”。对一张规整的二值化车牌图来说字符和字符之间本来就有天然的间隙把这些间隙找到自然就切开了。4.1 二值化与垂直投影法切分在分割之前先把车牌灰度图转成二值图。这里推荐用大津法Otsu也就是cv::threshold的THRESH_OTSU模式。它可以根据图像的灰度分布自动算出最优阈值省去了手工调参的麻烦。不过要注意的是如果车牌图片光照不均整幅图直接做大津法效果可能一般可以先做一次顶帽变换或者高斯滤波把光照先抹平。拿到二值图以后最经典的分割方法就是垂直投影法。简单来说就是把二值图像的每一列像素点加起来得到一条“投影曲线”。字符所在列白色像素点的数量会很大字符间隙那几列加和值几乎为零。顺着这条曲线找到波谷的位置就是字符的边界。我自己没有直接用OpenCV封装好的函数因为垂直投影这个逻辑它没有现成的API但是用Mat的colRange加countNonZero组合起来几行代码就能搞定std::vectorint colSum std::vectorint(plateImg.cols, 0); for (int x 0; x plateImg.cols; x) { int sum 0; for (int y 0; y plateImg.rows; y) { if (plateImg.atuchar(y, x) 255) sum; } colSum[x] sum; }然后遍历colSum找到从0突然变大的位置作为字符起点从非零掉回0的位置作为字符终点这样就能得到每个字符的左右边界了。4.2 常规尺寸归一化与边界处理切出来的字符宽度大小不一不能直接丢给模板匹配去比对必须先统一成一样的尺寸。我这里统一缩放到20×40像素的大小对应模板库中字符的尺寸。缩放前注意用cv::resize做等比例缩放然后填充到目标尺寸的中央避免直接拉伸导致字符变形。还有一个细节车牌的第二个字符和第三个字符之间有一个圆点分隔符。这个小圆点如果按字符切出来宽度很窄容易和数字“1”之类的混淆。我的处理方式是在投影法分割之后把宽度异常小的区域直接丢弃或者合并到相邻区域。一般车牌的排版规则是第一个字符是汉字省份简称第二个是字母发牌机关代号第三位开始是字母和数字混合。如果识别结果总是多一位或者少一位先检查这里是不是把小圆点当作字符切出来了。投影法这套逻辑理论上对清晰图片效果很好但是遇到字符断裂、粘连就会头疼。字符断裂时投影曲线会有“假波谷”本来一个字符被切成两半。字符粘连时投影曲线又找不到明显的波谷。遇到这种情况可以结合连通域分析cv::connectedComponentsWithStats来辅助切分先把字符的连通域找出来再根据连通域尺寸和位置关系合并或拆分。这一招我亲测对车牌字符的鲁棒性提升很明显。5. 字符识别模板匹配与结果输出字符分割完成之后整张车牌图已经变成了一个一个单独的字符小图。接下来就是最后一步——让程序认出每一个字符是什么。5.1 模板库的构建与匹配算法选择传统视觉方案里字符识别最朴素的思路就是模板匹配。你需要提前准备一套字符模板图片覆盖所有可能出现的汉字、字母和数字比如“京”、“津”、“沪”、“A”、“B”、“C”、“0”、“1”、“2”等。模板库的整理是个费工夫的活儿需要你去搜集足够清晰的车牌字符字体图片统一缩放成20×40的尺寸。匹配过程用的是cv::matchTemplate函数把待识别字符和模板库里的每一个模板依次做比对计算相似度。相似度最高的那个模板对应的字符就是识别结果。相似度的度量方式有很多相关性、相关系数、归一化相关等。我实际用下来TM_CCOEFF_NORMED这个度量方式表现最稳定它对光照变化不那么敏感返回的相似度值在[-1, 1]之间取最大值对应的字符即可。cv::Mat result; cv::matchTemplate(charImg, tmplImg, result, cv::TM_CCOEFF_NORMED); double maxVal; cv::minMaxLoc(result, nullptr, maxVal);这里实际跑了之后发现汉字比如“京”、“苏”、“粤”之间的相似度有时会异常高因为汉字笔画结构复杂模板的字体来源稍微不一样匹配结果就容易飘。一个亲测有用的技巧是在模板库里多存几种同字不同粗细的字体或者对模板做几次形态学膨胀把模板的笔画变粗一点点。这样可以让匹配的容错性大大提升。5.2 识别结果的置信度判断很多初学朋友拿到识别结果后发现识别错了第一反应就是换个模板。这当然是一个方向但更重要的是学会判断结果“可不可信”。单纯拿到一个相似度最高的模板是不够的我还要求程序把相似度结果排序看第一名和第二名的差距。如果最高相似度低于0.6我直接判为识别失败宁可输出一个?也不硬猜。如果第一名和第二名的相似度差距小于0.05说明这个字符在模板库里有“撞脸”嫌疑我同样会标记为低置信度提示人工复核。这个置信度策略对实际工程的意义很大。因为车牌识别系统一旦接入到收费系统或者门禁系统识别错误造成的影响远大于识别不出来造成的。识别不出来人工帮一下忙就行识别错误可能导致扣错费、放错行。我的经验是宁可输出“未识别”也不要输出一个错误的车牌号。这个思路在工业项目里非常重要大家可以仔细体会一下。6. 完整流程整合与代码实现前面各个环节都拆开讲了这里我整合一段主流程代码把预处理、定位、分割、识别串起来。完整的工程代码量太大这里我给出核心流程的骨架相当于把各个模块串联起来#include opencv2/opencv.hpp #include vector #include string #include iostream using namespace cv; using namespace std; int main() { Mat src imread(car.jpg); if (src.empty()) { cout 读取图片失败 endl; return -1; } resize(src, src, Size(800, 600)); // 1. 图像预处理 Mat hsv; cvtColor(src, hsv, COLOR_BGR2HSV); // 2. 蓝色车牌提取 Mat blueMask; inRange(hsv, Scalar(100, 80, 60), Scalar(124, 255, 255), blueMask); // 3. 形态学操作 Mat kernel getStructuringElement(MORPH_RECT, Size(5, 5)); morphologyEx(blueMask, blueMask, MORPH_OPEN, kernel); morphologyEx(blueMask, blueMask, MORPH_CLOSE, kernel); // 4. 轮廓检测与筛选 vectorvectorPoint contours; findContours(blueMask, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); Mat plateRegion; for (size_t i 0; i contours.size(); i) { RotatedRect rotated minAreaRect(contours[i]); double ratio rotated.size.width / rotated.size.height; if (ratio 2.0 || ratio 5.0) continue; // 拿到车牌ROI后做透视矫正 // ... // 矫正结果存入plateRegion break; } // 5. 灰度、二值化 Mat gray, binary; cvtColor(plateRegion, gray, COLOR_BGR2GRAY); threshold(gray, binary, 0, 255, THRESH_BINARY_INV | THRESH_OTSU); // 6. 垂直投影分割字符 vectorMat charImgs; // ... 垂直投影切分逻辑 ... // 7. 模板匹配识别 for (const auto ch : charImgs) { // 统一尺寸、matchTemplate、取最高置信度字符 // 拼接结果字符串 } // 8. 输出结果 cout 识别车牌号: result endl; return 0; }这段代码是核心流程的框架每个注释标记的位置都需要填充对应的模块实现。比如透视矫正部分需要根据minAreaRect的返回值计算旋转矩阵字符分割部分需要在二值图上做逐列扫描。6.1 参数调优的实操心得整个系统跑下来我最大的感悟是没有一套万能参数场景变了参数必须跟着变。从HSV阈值、形态学核大小到轮廓筛选的长宽比范围没有一个值是绝对不能改的。比如普通停车场和高速卡口的相机安装高度和角度不一样同一个HSV阈值可能一个效果好一个效果差。调试的时候我建议不要一条路走到黑多利用调试工具看中间结果。我习惯在代码里加一个drawContours的调试窗口把每次定位到的候选区域都画出来大概有哪几个、长什么样子、为什么会被筛选出去。这个过程比瞎猜阈值要有用一百倍。另外cv::createTrackbar也是非常顺手的调参神器把几个核心阈值暴露成滑动条实时看效果调出合适的数值之后再写死到代码里。6.2 性能优化与多线程的简单讨论在PC上跑单张图片的处理时间大概在80到150毫秒左右其中大部分时间花在图像缩放、形态学操作和模板匹配的遍历上。如果以后要接实时视频流建议把模板库的匹配过程用多线程并行化把模板库拆成几组每个线程匹配一组最后合并结果。std::vectorstd::thread threads; // 按模板库划分每个线程处理一部分模板最后汇总maxVal这里涉及C多线程调度是另一个比较大的话题了。对于单张图片识别的场景其实单线程完全够用不用过度设计。如果你真的想做实时视频流识别建议把定位步骤缩小感兴趣区域ROI只在画面中心区域附近做检测能省下不少时间。7. 常见问题与排查技巧实录写这部分的时候我回想了自己实际调试过程中遇到的各种问题整理成一张速查表方便大家对照排查。这里面很多问题都是不亲自踩一遍很难发现的属于拿时间换来的经验。现象可能原因解决思路定位不到车牌区域HSV阈值范围不对或形态学开运算核太大把车牌滤掉了先用滑动条实时预览blueMask确认车牌区域是白色像素且连通轮廓检测到很多碎片闭运算没做或核太小车牌区域断裂增大闭运算核尺寸比如从5×5改成9×9定位到了错误的区域车身颜色接近蓝色的车辆干扰加严长宽比条件增加面积限制必要时用边缘检测辅助验证字符分割多出一个小碎片车牌中间的圆点分隔符被当作字符切出来了对过窄的字符区域做丢弃或与相邻区域合并汉字识别老是混淆模板字体来源不一致或者模板太少多存几种字体对模板做膨胀处理增加容错全部识别结果置信度低预处理不到位图像太暗或模糊检查灰度化和二值化步骤考虑加直方图均衡化程序一运行就崩溃读取图片路径错误或者Mat为空就继续处理每次imread之后都要检查empty()调试时用绝对路径7.1 定位不到车牌时的排查思路这是出现频率最高的问题。我的排查顺序是先看blueMask长什么样。如果mask里压根没有车牌区域的白色块说明HSV阈值完全不合适打开滑动条调如果mask里有白色块但分散不成片说明闭运算不够车牌区域没连起来如果mask里块很多很杂说明阈值太宽松把路边的蓝色指示牌、车身装饰都划进来了。总之先定位先用mask图说话一步步把中间结果都看一遍就能精确找到是哪一步出了问题。7.2 字符分割与识别阶段的避坑指南分割阶段最值得注意的就是字符与字符之间的间隔处理。车牌字符里第二个字符和第三个字符之间有个小圆点如果车牌本身拍得不清晰这个小圆点的宽度特征很不稳定要么过窄被丢弃要么因为投影曲线噪声和相邻字符粘连在一起导致本该有的间隔被漏掉结果字符数量不对。识别阶段则要记住模板库的完整性和统一性决定了识别上限。如果你的模板库没有“京”这个字那“京”永远识别成别的这种事神仙算法也救不了。最后分享一个实际操作中的技巧你可以在工程里把识别过程和结果可视化地画在原始图上。用cv::rectangle画出定位框然后用cv::putText直接把识别结果显示在原图上方。这样不管是调试还是测试一眼就能看到识别效果到底如何。这个输出的可视化环节也算是个小门面课程设计答辩或者项目展示时特别加分。这套东西我断断续续磨了两周最开始识别准确率也就六成后来一步步把预处理、定位、分割每个环节都调到位才慢慢提到九成五以上。做这个过程收获最大的不是最后那个识别结果而是整条链路里每一个函数、每一个参数背后意味着什么。希望这篇笔记能帮你少踩几个坑早点跑通自己的第一套车牌识别系统。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进