ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于OpenCV的车牌识别:从定位、校正到字符分割的完整实战指南

2026/9/12 13:37:01 拓冰建站 浏览量
基于OpenCV的车牌识别:从定位、校正到字符分割的完整实战指南 简介这是一套基于OpenCV的车牌识别完整示例代码面向计算机视觉初学者以及自动驾驶、交通监控相关开发者涵盖车牌定位、透视校正与字符识别全流程解决从图像输入到车牌输出的核心实现问题。资源共30个文件包含C源码.cpp/.h、OpenCV训练模型.xml、测试图片.jpg/png以及Shell脚本.sh和说明文档.txt压缩包5.54MB结构清晰便于按模块阅读。目前已有564人学习下载。代码中提供图像预处理、车牌检测如边缘检测、霍夫变换、仿射/透视校正、SVM/神经网络字符识别等功能模块并附带训练脚本trainSVM.cpp、trainOCR.cpp及评估脚本eval.sh可帮助读者快速跑通示例并继续调优模型适合用于课程设计或项目起步。1. 从 lpr.rar 开始OpenCV 车牌定位识别的完整工程结构打开 lpr.rar目录里是 Plate.cpp、DetectRegions.cpp、trainSVM.cpp、trainOCR.cpp外加一堆以车牌号命名的测试图片。这套代码的意义不在于直接跑出高准确率的商用系统而在于把车牌识别从图像输入到字符串输出的经典链路拆到了最小可运行单元先定位候选车牌区域再做透视校正最后用 SVM 分类字符。虽然现在很多项目已经转向深度学习端到端但定位和分割的工程思路依然有效尤其是当你需要把识别器嵌入 C 生产环境、要求可解释性和低延迟时。我会从源码出发把每个环节的关键参数和常见坑位都过一遍适合想基于 OpenCV 做车牌识别原型或正在调试传统视觉方案的开发者。2. 车牌定位DetectRegions.cpp 里的颜色、边缘与候选框筛选2.1 定位思路与图像预处理车牌定位是整个识别流程里最容易出错的一环。OpenCV 里常见方案有三种基于边缘的 Canny 形态学、基于 HSV 颜色阈值的纯色提取、以及基于梯度形态学的连通域分析。DetectRegions.cpp 走的是“形态学梯度 Otsu 闭运算”的路线。这个选择很有代表性Canny 对噪声敏感会得到大量零散边缘HSV 颜色阈值在黄昏、车灯反光条件下容易出现色相偏移。而形态学梯度能保留字符与背景之间的高频边界对光照具有一定鲁棒性。代码先读入图像再转灰度然后做形态学梯度再自动阈值:// DetectRegions.cpp 定位第一步 Mat src imread(3266CNT.JPG); if (src.empty()) return -1; Mat gray; cvtColor(src, gray, COLOR_BGR2GRAY); Mat grad; morphologyEx(gray, grad, MORPH_GRADIENT, getStructuringElement(MORPH_RECT, Size(17, 3))); Mat binary; threshold(grad, binary, 0, 255, THRESH_BINARY | THRESH_OTSU);这里的核心是getStructuringElement(MORPH_RECT, Size(17, 3))。为什么是 17x3 而不是 3x3因为形态学梯度的核宽度要与字符笔画宽度匹配宽度 17 足以提取横向字符边缘高度 3 避免把整张图垂直方向的干扰都放大。实际应用时如果图像分辨率在 1280x720 以上我会把核宽放大到 23缩小到 640 以下则用 11否则字符边缘会和背景连在一起或者被滤掉。2.2 闭运算与连通域筛选得到二值图之后字符之间还存在大量断口需要用闭运算将相邻的字符边缘连接成一个整体方便找出完整车牌区域:// 闭运算将断开的字符区域连成块 Mat closed; morphologyEx(binary, closed, MORPH_CLOSE, getStructuringElement(MORPH_RECT, Size(25, 5))); vectorvectorPoint contours; findContours(closed.clone(), contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); vectorRotatedRect candidates; for (auto c : contours) { RotatedRect box minAreaRect(c); double w max(box.size.width, box.size.height); double h min(box.size.width, box.size.height); double area w * h; double ratio w / h; if (area 2000 ratio 2.5 ratio 6.5) { candidates.push_back(box); } }闭运算的核宽度需要大于字符之间的白缝通常 25 左右。但这里有个容易被忽略的坑minAreaRect返回的RotatedRect宽高顺序不保证一定是 width 大于 height所以 ratio 的计算要先取std::minmax否则横向车牌可能被判成纵向而漏掉。我在调试时见过大量候选框因此被滤掉。面积下限 2000 是基于测试图像 300x400 左右设定的如果输入是 1920x1080要把面积阈值按缩放比例平方放大否则车牌区域因为小于阈值直接漏检。2.3 候选区域验证与输出对每个候选区域还需要验证它是否真的包含多个字符。最常见的做法是垂直投影统计黑白跳变次数:bool verifyPlateRegion(const Mat region, int minTransitions 8) { Mat gray, binary; cvtColor(region, gray, COLOR_BGR2GRAY); threshold(gray, binary, 0, 255, THRESH_BINARY | THRESH_OTSU); vectorint proj(binary.cols, 0); for (int x 0; x binary.cols; x) for (int y 0; y binary.rows; y) proj[x] binary.atuchar(y, x) 0; int transitions 0; for (int i 1; i proj.size(); i) if ((proj[i] 0) ! (proj[i - 1] 0)) transitions; return transitions minTransitions; }这一步能滤掉大部分路牌、车灯和车身贴纸的干扰。跳变次数下限我一般设为 8因为 7 个字符至少形成 14 个边界考虑粘连和汉字结构8 是安全下界。如果字符分割后仍然有区域过宽说明可能是多块候选合并到了一起此时可以按白色点列投影像素宽度把区域切开。定位环节的参数对最终识别率影响最大我根据不同的输入分辨率整理过一组经验值:图像宽度梯度核闭运算核最小面积宽高比64011x319x58002.5~6.0128017x325x520002.5~6.5192023x531x740002.5~6.5这组参数可以直接复用但需要根据实际摄像头角度微调因为广角镜头边缘的形变会让宽高比偏离标准车牌。3. 车牌校正从透视变换到 SVM 分类器前处理3.1 为什么要做透视变换车牌定位阶段得到的是RotatedRect或四个角点但由于相机俯角和侧角车牌在图像里往往是一个不规则的四边形。如果直接进行字符分割字符的高度和宽度比例会失真垂直投影也会失效。因此很多车牌识别系统在定位之后都会加一步透视校正将任意四边形车牌映射到一个固定尺寸的矩形。lpr 源码里的 Plate.cpp 做的就是这个工作它存储了原始车牌图和修正后的矩形供后续 OCR 使用。3.2 角点排序与透视映射RotatedRect有四个顶点但顺序取决于旋转角不能直接当成srcPoints使用。我的做法是先按x y排序左上角最小、右下角最大然后根据y值再区分右上和左下:// 车牌校正输出 136x36 的矩形车牌 Mat rectifyPlate(const Mat src, const RotatedRect rr) { Point2f pts[4]; rr.points(pts); sort(pts, pts 4, [](const Point2f a, const Point2f b) { return a.x a.y b.x b.y; }); Point2f tl pts[0], br pts[3]; Point2f tr (pts[1].y pts[2].y) ? pts[1] : pts[2]; Point2f bl (pts[1].y pts[2].y) ? pts[2] : pts[1]; Point2f srcPts[4] {tl, tr, br, bl}; Point2f dstPts[4] {Point2f(0, 0), Point2f(136, 0), Point2f(136, 36), Point2f(0, 36)}; Mat M getPerspectiveTransform(srcPts, dstPts); Mat result; warpPerspective(src, result, M, Size(136, 36)); return result; }目标尺寸这里选了 136x36这是很多开源车牌识别项目常用的尺寸既保留字符细节又不会让 HOG 特征维度过高。如果你做的是新能源车牌8 个字符或者摩托车牌上下两行目标尺寸需要相应调整比如新能源车牌用 180x40两行车牌要单独裁剪后再拼接不能直接套 136x36。3.3 校正后的特征提取与 SVM 接口校正后的车牌图要进入 SVM 分类器。lpr 包的 trainSVM.cpp 训练的区域分类器 SVM.xml 主要用来区分“这是不是车牌”这个二分类问题。它提取的是整张 136x36 图像的 HOG 特征:HOGDescriptor hog(Size(136, 36), Size(8, 8), Size(4, 4), Size(4, 4), 9); vectorfloat descriptors; hog.compute(plateImg, descriptors); Mat feature(1, (int)descriptors.size(), CV_32F); memcpy(feature.data, descriptors.data(), descriptors.size() * sizeof(float));HOG 参数里winSize必须和图像尺寸一致blockSize是 8x8cellSize是 4x4blockStride也是 4x4九个 bin 的方向直方图。这套默认参数对车牌这类刚体目标足够。如果发现误检率偏高我会降低blockSize到 4x4特征维数上升但能更好刻画字符边界。特征向量喂给SVM.predict之前需要保证类型是CV_32F否则 OpenCV 会抛断言异常。SVM.xml 里的参数和训练方式可以在 trainSVM.cpp 中看到基本配置如下:参数值说明SVM TypeCvSVM::C_SVC多分类二分类也用这个KernelRBF处理非线性边界C10较大倾向减少错分Gamma0.05RBF 半径如果你把 lpr 的 SVM.xml 换成 OpenCV 4 加载需要使用ml::SVM::load而不是CvSVM::load并且predict返回值要转成int后再映射类别。这里的坑是 OpenCV 3/4 的ml::SVM默认输出浮点值而旧代码往往直接赋值给char导致中文字符全乱。4. 字符分割与 OCROCR.cpp 如何把车牌变成字符串4.1 二值化是字符分割的前置条件字符分割的任务是把校正后的车牌图片拆成单个字符。前提是预处理能把字符和背景区分开。OCR.cpp 中的做法是灰度化后用自适应阈值二值化:// OCR.cpp 预处理 Mat gray; cvtColor(plate, gray, COLOR_BGR2GRAY); Mat binary; adaptiveThreshold(gray, binary, 255, ADAPTIVE_THRESH_MEAN_C, THRESH_BINARY_INV, 17, 3);自适应阈值比全局 Otsu 更适合车牌原因在于蓝底渐变、金属镀膜反光都会造成局部亮度不均匀全局阈值容易把字符“漂白”。blockSize17 表示用 17x17 邻域计算平均亮度C3 是减掉一个常数避免噪声点被保留为前景。THRESH_BINARY_INV 是为了让字符为白色、背景为黑色因为原始车牌蓝底白字需要反相后才方便投影统计。4.2 垂直投影与粘连字符的处理二值化后对每列白点计数得到垂直投影。字符间的空隙对应投影值为 0 的区域因此可以通过扫描找出字符起止列:vectorRect splitCharacters(const Mat binary) { vectorint proj(binary.cols, 0); for (int x 0; x binary.cols; x) for (int y 0; y binary.rows; y) proj[x] binary.atuchar(y, x) 0; vectorRect chars; bool inChar false; int start 0; for (int x 0; x binary.cols; x) { if (proj[x] 0 !inChar) { inChar true; start x; } else if (proj[x] 0 inChar) { inChar false; int w x - start; if (w 8) // 过滤噪声列 chars.push_back(Rect(start, 0, w, binary.rows)); } } // 合并过窄字符处理汉字左右结构 for (size_t i 1; i chars.size(); i) { if (chars[i].x - (chars[i-1].x chars[i-1].width) 4) { chars[i-1].width chars[i].x chars[i].width - chars[i-1].x; chars.erase(chars.begin() i); --i; } } return chars; }这里两个关键过滤条件最小宽度 8防止车牌边缘的盐粒噪声被当作字符相邻间隔小于 4 像素时合并解决“京”、“沪”等汉字左右部分投影断开的问题。如果你处理的车牌是新能源 8 位字符数会多一个需要把最后两位的宽度约束放宽否则经常把前一位的噪声并进去。字符宽度经验范围可以看下表:字符类型宽度参考(px)备注首个汉字24~40左右结构多容易切成两块中间字母数字12~28正常字符间隔符5通常被忽略4.3 trainOCR.cpp 与 OCR.xml 的字符分类字符分割完成后每个字符图像需要统一尺寸再进 SVM 分类。trainOCR.cpp 里将字符缩放到 32x16然后直接拉平成一维特征这样写很简单但缺点是丢失了字符的笔画结构信息。如果想提高准确率我一般会把拉平特征替换为 8x8 cell 的 HOG 或者直接使用图像的 16x32 像素再做一个归一化:Mat char32x16; resize(charImg, char32x16, Size(32, 16)); Mat flat char32x16.reshape(1, 1); flat.convertTo(flat, CV_32F, 1.0 / 255.0); // 归一化到 [0,1] float label ocrModel-predict(flat);OCR.xml 的类别顺序和省份简称映射表有关源码里有类似0123456789AB...Z京沪津...的字符串通过mapToChar查找下标。实际使用经常发生两个问题一是省份简称中加入的新省份如“渝”在代码早期模型里没有会导致错分二是数字“0”和字母“O”在训练样本不足时无法区分。解决办法就是重新训练 OCR.xml用 segmentAllFiles.sh 分割出的真值字符做训练集把容易混淆的字符拉成单独类别并扩充样本。5. 识别率提升eval.sh、样本增强与常见坑位5.1 用 eval.sh 评估真实识别率eval.sh 只是批量循环调用识别程序再把结果用字符串比较计算正确率但它至少能帮你建立一个可度量的迭代基线。通常我还会在 eval.sh 里加一行把结果和图片名输出到文件方便手工看错误样本。核心逻辑如下:for img in *.JPG; do truth$(basename $img .JPG | tr a-z A-Z) result$(./lpr $img | tail -n 1 | tr -d ) [ $truth $result ] ok$((ok1)) done echo $ok / $(ls *.JPG | wc -l)注意把图片名中的-、空格等符号去掉因为真实车牌不会出现这些字符。如果识别输出带换行符要去掉再比较。5.2 用 segmentAllFiles.sh 做数据增强segmentAllFiles.sh 的作用是批量分割字符。分割后每张字符图单独落盘作为训练样本。但原始测试图只有十几张字符样本远远不够。我会做三种增强随机偏移 1~2 像素、随机缩放 0.9~1.1 倍、叠加高斯噪声。增强后字符样本通常能覆盖 90% 以上的真实光照形态SVM 的识别率能从 85% 提到 97% 左右。对于容易混淆的0/O、Z/2单独复制并旋转微小角度效果更明显。5.3 旧代码移植到 OpenCV 4 的适配源码里CvSVM和CvBoost是 OpenCV 2 风格在 OpenCV 4 中编译会报错。替换方法很直接#include opencv2/ml.hpp使用Ptrml::SVM加载模型用ml::SVM::load预测用samplesMat为CV_32F。另外从摄像头流读入时建议设置CAP_PROP_FRAME_WIDTH和CAP_PROP_FRAME_HEIGHT控制分辨率以匹配定位参数表避免实时画面分辨率变化导致阈值失效。批处理场景下把imshow和waitKey注释掉避免无 GUI 环境下进程挂起。5.4 在 lpr 基础上扩展如果你想把这套代码用到路口监控场景至少要改两处一是用背景差或目标跟踪先粗检车辆缩小车牌搜索区域二是对连续视频帧做识别结果投票单帧置信度不高时取最多出现的字符串。我一般会维护一个mapstring, int计数器连续 5 帧输出同一字符串才提交这样能把误识别率降低一个数量级。最终 eval.sh 的结果可以按准确率、每类错误次数两个维度输出定位到是分割问题还是分类问题。本文还有配套的精品资源点击获取