ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenCV车牌识别实战:定位、SVM验证与OCR全流程解析

OpenCV车牌识别实战:定位、SVM验证与OCR全流程解析 简介基于OpenCV的车牌识别完整C工程面向计算机视觉入门与进阶开发者解决车牌定位、校正与字符识别的落地实现问题。压缩包共30个文件包含7个cpp源码、3个h头文件、2个xml模型文件SVM与OCR分类器、12张jpg测试车牌图及若干shell脚本覆盖图像预处理、车牌检测、透视校正到字符识别的全流程另有txt说明与构建脚本辅助环境配置。已有566人学习适合交通监控、停车场管理等场景快速搭建原型或研究算法细节。通过阅读源码可理解Canny边缘检测、霍夫变换、仿射/透视变换及SVM字符分类的实际用法配套测试图片与批处理脚本可直接运行验证模块划分清晰便于二次开发。包体仅5.54MB轻量易部署是OpenCV车牌识别实战与教学的良好参考。1. 为什么说车牌识别最大的坑是“找车牌”先看 lpr.rar 里到底有什么做 OpenCV 车牌识别的人十个里有八个是倒在定位这一步的。字符识别不出来至少能看见程序在跑车牌根本没找到那后面 OCR、SVM 全是空中楼阁。lpr.rar 这套 C 工程把整条链路拆成了 DetectRegions定位校正、SVM 验证、OCR 字符识别三个独立模块配好了训练好的 SVM.xml、OCR.xml 和十几张不同角度光线的实拍图适合正在做课设、毕设或者想把传统 CV 车牌识别从 demo 推到真实图片上的人。它不依赖深度学习框架OpenCV 就能跑定位、校正、分类每一步都有源码可以单步调。如果你也想搞明白“车牌识别到底难在哪”这套代码能给你一个完整的答案。2. 先把 CMake 构建跑通OpenCV 版本、编译参数与 demo 首跑2.1 包内文件拆解main.cpp、DetectRegions、OCR 各管哪一段拿到 lpr.rar先别急着编译把文件角色弄清楚。这套工程的结构很典型入口是 main.cpp核心算法分布在 DetectRegions.cpp 和 OCR.cpp 里trainSVM.cpp 和 trainOCR.cpp 负责训练两个模型eval.cpp、evalOCR.cpp 配合 eval.sh 做批量验证。下面这张表对应实际文件职责。文件职责main.cppdemo 入口读图、调用 DetectRegions 得到候选车牌区域再交给 OCR 输出字符串DetectRegions.h / .cpp车牌定位、倾斜校正、SVM 验证候选区域输出裁剪好的车牌图Plate.h / .cpp车牌数据结构保存旋转矩形、车牌图、字符块等中间结果trainSVM.cpp训练“是不是车牌”的二分类 SVM产出 SVM.xmlOCR.h / .cpp字符分割、特征提取、加载 OCR.xml 做字符分类拼出车牌字符串trainOCR.cpp训练字符分类器产出 OCR.xmleval.cpp / evalOCR.cpp批量跑测试图、单独评估字符分类器配合 eval.sh 统计正确率CMakeLists.txt构建脚本*.JPG / *.jpg测试样本覆盖不同光照和拍摄角度这套分层的价值在于定位和识别是解耦的。你要是觉得定位不准只改 DetectRegions觉得字符分错只动 OCR。两个模块之间用 Plate 结构体传递数据不用互相改接口对初学者非常友好也方便你逐步替换成自己的算法。2.2 编译与首跑OpenCV 版本匹配和 cmake 参数编译前先确认 OpenCV 装好了。Ubuntu 下一般装的是 libopencv-dev 这个开发包版本 3.x 或 4.x 都行但下面有个坑要先说OpenCV 4 里find_package(OpenCV)的 CMake 配置和 3 是兼容的如果系统里恰好装了多个版本cmake会优先找最新版这通常是好事但也有可能把 3 和 4 的库混着链接导致运行时报符号错误。我的习惯是编译前先跑一句pkg-config --modversion opencv4或opencv看清楚默认版本。mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j$(nproc)CMake 配置会通过find_package(OpenCV REQUIRED)找到 OpenCV然后target_link_libraries(lpr ${OpenCV_LIBS})。-DCMAKE_BUILD_TYPERelease建议加上Debug 模式下 OpenCV 的断言很多识别速度会被拖慢不少。编译通过后随便挑一张测试图跑./lpr ../3266CNT.JPGmain.cpp 的逻辑一般是这样读图 →DetectRegions返回候选车牌 → 对每个候选调用 OCR 识别 → 把结果打印到终端。输出里能看到识别出的车牌字符串比如3266CNT。如果屏幕上有画框结果可能是用了imshow或把结果写到了磁盘。提示测试图片最好先放到纯英文路径下再跑。OpenCV 的imread在 Windows 上对中文路径支持很差Linux 下也偶尔翻车这是第一个最容易踩的坑。3. 车牌定位、校正与 SVM 验证从 Sobel 边缘到可信区域的完整链路3.1 定位主链路Sobel、形态学闭运算和轮廓筛选先回答一个很多人问过的问题为什么不用颜色过滤直接找蓝色区域蓝底车牌在光线好的时候 HSV 阈值确实好用但傍晚、路灯、白平衡偏移一过来蓝色直接漂成灰蓝甚至紫色阈值怎么调都压不住。所以成熟的做法是用边缘信息做初筛颜色只做辅助。DetectRegions.cpp 的主链路就是 Sobel 边缘 形态学闭运算 连通域筛选。cv::Mat gray, edges; cv::cvtColor(img, gray, cv::COLOR_BGR2GRAY); // 只做 x 方向 Sobel车牌字符的垂直边缘非常密集 cv::Sobel(gray, edges, CV_16S, 1, 0, 3); cv::convertScaleAbs(edges, edges); // OTSU 自适应阈值避免固定阈值在不同光照下失效 cv::threshold(edges, edges, 0, 255, cv::THRESH_OTSU); // 闭运算把离散的字符边缘连成一个整体块 cv::morphologyEx(edges, edges, cv::MORPH_CLOSE, cv::getStructuringElement(cv::MORPH_RECT, cv::Size(17, 3)));这段代码是整套定位的骨架。CV_16S输出是为了保留 Sobel 的负梯度如果直接用 8U 接结果负值会被截断成 0边缘信息少一半这是新手最容易犯的错。闭运算的核尺寸 17×3 是经验值横向拉长把字符边缘连起来纵向保持窄避免把上下两块无关区域粘连。光照条件差的时候可以把核加宽到 21×3但别超过 25否则两个相邻车牌会粘成一团。拿到二值图后下一步是找轮廓并筛选候选区域std::vectorstd::vectorcv::Point contours; cv::findContours(edges, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE); for (auto c : contours) { double area cv::contourArea(c); // 面积过滤太小的噪点不要太大的车身块不要 if (area img.rows * img.cols * 0.005) continue; if (area img.rows * img.cols * 0.2) continue; cv::RotatedRect r cv::minAreaRect(c); float ratio r.size.width / r.size.height; if (ratio 2.0 || ratio 6.0) continue; // 到这里算一个候选后面再做精验证 }面积阈值按全图比例算而不是绝对像素这样换分辨率不用改参数。宽高比 2.0~6.0 覆盖了标准车牌 440×140约 3.14以及一定程度的倾斜变形。但注意倾斜超过 25 度时 minAreaRect 的宽高比会失真这种情况下建议把 ratio 下限放宽到 1.8宁多勿漏把判断压力交给后面的 SVM。3.2 倾斜校正MinAreaRect 与透视变换候选框是旋转矩形直接裁剪出来是斜的字符识别在这种图上基本报废。校正的思路是用 minAreaRect 拿到旋转角度先把图转正再用透视变换拉成标准尺寸。cv::RotatedRect r cv::minAreaRect(contour); float angle r.angle; cv::Size2f sz r.size; // OpenCV 的 angle 有时是负的宽高也可能反着这里统一一下 if (sz.width sz.height) { std::swap(sz.width, sz.height); angle 90.f; } // 按旋转矩形的四个角点做透视变换到 440x140 cv::Point2f vertices[4]; r.points(vertices); std::vectorcv::Point2f src_pts { vertices[0], vertices[1], vertices[2], vertices[3] }; std::vectorcv::Point2f dst_pts { cv::Point2f(0, 0), cv::Point2f(440, 0), cv::Point2f(440, 140), cv::Point2f(0, 140) }; cv::Mat M cv::getPerspectiveTransform(src_pts, dst_pts); cv::Mat plate; cv::warpPerspective(img, plate, M, cv::Size(440, 140));这里有个非常隐蔽的坑RotatedRect::points()返回的四个点顺序不是固定的左上、右上、右下、左下直接按顺序映射到 dst_pts 会导致图像左右翻转或上下颠倒。我一般会先按 y 坐标排序分出上下两组每组再按 x 排序确定左右最后重组成规范的四边形角点。透视变换完成后车牌图里通常还有边框和铆钉残留建议接一步中值滤波cv::medianBlur(plate, plate, 3)能有效减少后面分割时的椒盐噪声。3.3 HOGSVM 验证trainSVM.cpp 里那点特征工程定位找出来的候选框到了这一步还只能说“长得像车牌”。广告牌、车灯、车身的反光区域都可能混进来。如果不做验证OCR 会在垃圾输入上硬跑输出一堆乱码。所以 DetectRegions 后面接了一个二分类 SVM正样本是真实车牌图负样本是各种背景干扰块。特征用的是 HOG因为 HOG 对局部形状敏感恰好能抓住“字符密集排列”这个车牌核心特征。// 以 128x64 检测窗口为例实际尺寸以 trainSVM.cpp 里为准 cv::HOGDescriptor hog(cv::Size(128, 64), cv::Size(16, 16), cv::Size(8, 8), cv::Size(8, 8), 9); cv::Mat sample_img cv::imread(positive.jpg, cv::IMREAD_GRAYSCALE); cv::resize(sample_img, sample_img, cv::Size(128, 64)); std::vectorfloat feats; hog.compute(sample_img, feats); cv::Mat sample(1, (int)feats.size(), CV_32F); memcpy(sample.data, feats.data(), feats.size() * sizeof(float));HOG 参数里最容易影响结果的是 cell 大小和 block 大小。8×8 的 cell 对字符这种小目标足够细block 用 16×16 能保留局部对比度信息。特征向量维度可以自己打印出来看不同窗口尺寸算出来不一样不用背但要保证训练和预测时用的 HOG 参数完全一致差一个 cell 尺寸特征维度就对不上SVM 直接拒绝预测。训练部分没什么玄学线性核和 RBF 都行小样本下线性核更稳cv::Ptrcv::ml::SVM svm cv::ml::SVM::create(); svm-setType(cv::ml::SVM::C_SVC); svm-setKernel(cv::ml::SVM::RBF); svm-setC(10.0); svm-setGamma(0.01); cv::Ptrcv::ml::TrainData td cv::ml::TrainData::create( training_data, cv::ml::ROW_SAMPLE, training_labels); svm-train(td); svm-save(SVM.xml);C 参数控制误分类惩罚太小欠拟合负样本会大量漏进来太大会把边界撑得过拟合稍微换个角度就误杀。10 是我常用的起点。训练样本里正样本最好包含倾斜校正后的、不同光照的真实车牌负样本不要只从纯背景里裁多截一些“边缘密集但又不是车牌”的区域比如栅栏、树叶阴影SVM 才会真正学会“见多识广”。到了预测阶段对每个候选框做同样的 resize 和 HOG 提取然后svm-predict(feats, true)。OpenCV 的 SVM predict 第二个参数传 true 会返回决策值不是简单的 0/1 分类结果我一般会把决策值绝对值很小的候选也过滤掉因为那说明 SVM 自己也拿不准。4. 字符分割与 OCR 识别投影切分、特征提取与 XML 模型配合4.1 垂直投影分割与字符归一化拿到校正后的车牌图下一步是把 7 个字符一个一个切出来。最经典也最稳定的方法是垂直投影统计每一列上白色像素的数量字符区域列和会明显高于背景字符之间的空隙列和接近 0按这个波峰波谷就能切分。cv::Mat gray, binary; cv::cvtColor(plate, gray, cv::COLOR_BGR2GRAY); cv::medianBlur(gray, gray, 3); // 自适应阈值注意用 THRESH_BINARY_INV让字符变白、背景变黑 cv::adaptiveThreshold(gray, binary, 255, cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY_INV, 31, 15); // 按列统计白像素比例 cv::Mat colSum; cv::reduce(binary, colSum, 0, cv::REDUCE_AVG); // 遍历 colSum把连续大于 0 的区间记录为字符候选 std::vectorstd::pairint, int segments; bool in_char false; int start 0; for (int i 0; i colSum.cols; i) { float v colSum.atfloat(0, i); if (v 0.01 !in_char) { in_char true; start i; } else if (v 0.01 in_char) { in_char false; segments.push_back({start, i}); } }REDUCE_AVG返回的是每列均值数值在 0 到 255 之间所以阈值用 0.01 判断“这一列有没有字符”就够了。自适应阈值窗口 31 和偏移 15 是经验值窗口太大会把阴影也二值化成背景太小字符内部会出现大量空洞。黄牌和绿牌的二值化结果和蓝牌差异很大如果跑出来的候选区间特别多先检查是不是把车牌底色当成了前景。切出来的候选区间里有三个典型问题一是铆钉和边框残留通常出现在最左和最右宽度很窄二是字符粘连两个字符中间的白像素没断干净被切成一个超宽区间三是「1」这种窄字符容易被误过滤。我的处理方式是先把所有候选宽度做个统计按宽度中位数筛选明显小于中位数一半的丢掉明显大于中位数 1.5 倍的在区间内再做一次 findContours 找内部轮廓切分。最后统一 resize 到同一尺寸比如归一化到 32×16再送进字符分类器。4.2 字符分类trainOCR.cpp 与 OCR.xml 的配合字符分类这步和车牌验证一样用 SVM只是输入从整张车牌变成了单个字符图像。trainOCR.cpp 做的事情是读入一批预先切好的字符图提取特征训练一个多分类 SVM把模型存成 OCR.xml。OCR.cpp 在运行时加载这个 XML对每个字符块提取同样的特征predict 得到类别最后映射成字符。特征这块包里的常见做法是网格密度特征。把字符图 resize 到固定大小切成 4×4 的格子统计每个格子里的白像素比例得到一个 16 维向量。这个特征虽然简单但对印刷体字符很有效而且计算开销比 HOG 小得多。cv::Mat ch cv::imread(char.png, cv::IMREAD_GRAYSCALE); cv::resize(ch, ch, cv::Size(32, 16)); std::vectorfloat feats; for (int gy 0; gy 4; gy) { for (int gx 0; gx 4; gx) { cv::Mat cell ch(cv::Rect(gx * 8, gy * 4, 8, 4)); feats.push_back(cv::countNonZero(cell) / 32.0f); } }32×16 的字符图配 8×4 的格子每个格子 32 个像素除以 32 归一化到 0~1。训练字符分类器时标签是字符本身注意字符表里通常去掉 I、O 这种容易和 1、0 混淆的字母或者干脆在数据里同时包含它们让模型自己学。如果发现数字和字母互相误判多半是训练样本不够或者字符没有对齐。运行时的推断链路在 OCR.cpp 里核心就两行cv::Ptrcv::ml::SVM ocr_svm cv::ml::SVM::load(OCR.xml); float label ocr_svm-predict(feature_vector);predict 返回的 label 是训练时的类别索引需要映射回字符。不要直接把返回的 float 强转成 char 输出除非训练标签用的就是 ASCII 码。evalOCR.cpp 的意义在于单独评估“分割后的字符”的识别准确率它不经过定位环节这样你能把“分割失败”和“分类失败”两个问题分开排查而不是混在一起瞎猜。5. 避坑与排查跑不起来先查这五处5.1 SVM.xml 加载失败OpenCV 版本序列化不兼容现象编译没问题一跑到加载模型就弹出assertion failed或者 svm 指针是空的再往下调用 predict 直接段错误。原因OpenCV 3 和 4 的 XML 序列化格式不完全兼容另外模型文件路径写错、路径里有中文、当前工作目录不对都会让 load 静默失败。trainSVM.cpp 生成的模型和运行时用的 OpenCV 版本不一致也是高频翻车点。解决加载后立刻做一次判空检查不要裸调 predict。cv::Ptrcv::ml::SVM svm cv::ml::SVM::load(SVM.xml); if (svm.empty()) { std::cerr SVM.xml load failed, check path and OpenCV version std::endl; return -1; }如果确认路径没问题还加载失败就用当前环境的 OpenCV 重新编译并运行 trainSVM.cpp用自己的模型替换包里的 XML。别在原模型上耗时间重新训练十几分钟就完了。5.2 findContours 找不到字符轮廓层级和二值化极性问题现象分割阶段一张车牌只出来一个巨大的轮廓或者字符轮廓完全找不到投影图上一片白。原因RETR_EXTERNAL只提取最外层轮廓。蓝底白字车牌里字符是内部亮区找外层轮廓只能得到车牌边框字符全丢。另一个常见原因是二值化极性反了字符是黑底白字背景被当成前景。解决定位环节用RETR_EXTERNAL没问题但分割环节要用RETR_CCOMP并遍历层级或者直接把二值图取反再找。std::vectorstd::vectorcv::Point contours; std::vectorcv::Vec4i hierarchy; cv::findContours(binary, contours, hierarchy, cv::RETR_CCOMP, cv::CHAIN_APPROX_SIMPLE); // 遍历时只取孔洞层hierarchy[i][2] 0 的内轮廓也就是字符区域5.3 字符粘连投影切不开一个区间里有两三个字符现象垂直投影图上两三个字符连成一段宽度明显是正常字符的一倍半以上直接 crop 出来喂给 OCR识别结果变成乱码。原因二值化阈值偏松字符间细小的噪声把空隙填上了或者车牌本身有磨损字符边界模糊。解决对超宽区间做二次切分。先统计所有候选宽度的中位数把宽度超过 1.5 倍中位数的段提取出来在子图内部重新二值化并做连通域分析找内部的字符轮廓再单独切。int median_w /* 所有 segment 宽度的中位数 */; for (auto seg : segments) { int w seg.second - seg.first; if (w median_w * 1.5) { cv::Mat sub binary(cv::Rect(seg.first, 0, w, binary.rows)); // 在 sub 上重新 findContours按轮廓切分 } }这个办法对付绝大多数粘连够用。如果字符真的完全连在一起没有缝隙那就只能退一步按车牌规范的 7 字符等宽布局做固定宽度切分但这是下策一遇倾斜就废。5.4 蓝底车牌在低照度下整体漏检现象白天跑得好好的傍晚测试图定位到区域明显偏少甚至一张都找不到。原因只靠 HSV 颜色过滤找蓝色区域光照一变蓝色分量漂移阈值外的像素全被丢弃。这属于选型问题。解决不管你后面用不用颜色辅助主定位链路务必走 Sobel 边缘 形态学这条路。边缘响应是灰度变化率对绝对光照不敏感。颜色信息可以留到候选验证阶段做加权评分而不是一开始就硬过滤。5.5 eval.sh 批量跑不出结果相对路径和脚本调用方式现象在工程根目录执行./eval.sh报No such file or directory或者 result 文件是空的。原因脚本里的 for 循环用的是相对路径而脚本从别的目录被调用时相对路径基于当前 shell 的工作目录不是脚本所在目录。还有可能是测试图扩展名大小写不匹配。解决先进到脚本所在目录再执行或者修改脚本开头加一句cd $(dirname $0)让脚本自己切到正确目录。跑之前先ls *.JPG *.jpg确认扩展名。6. 用 eval.sh 和 evalOCR.cpp 做回归验证让准确率可复现6.1 批量评估把十几张图一次跑完再统计单张图调到好看不算本事整套代码能不能在测试集上稳定输出才是关键。eval.sh 的常见做法是遍历目录下的测试图跑一遍 lpr 可执行文件把每张图的识别结果追加到 result 文件里最后人工或脚本统计正确率。#!/bin/bash cd $(dirname $0) rm -f result.txt for f in *.JPG *.jpg; do echo $f result.txt ./lpr $f result.txt 21 done cat result.txt输出里如果有大量空行或乱码先确认中间环节有没有把结果打印到 stdout。evalOCR.cpp 则是另一条评估线它跳过定位直接在已分割的字符图上测分类器准确率。两条线分开跑的好处是定位烂还是识别烂一眼就能分清。我一般先跑 evalOCR准确率低于 90% 就回去补训练数据再跑 eval.sh这时候的错误基本都出在定位环节。6.2 调参顺序一次只动一个变量调这套代码最忌讳拿着单张图一点一点凑参数。正确做法是先跑一遍 eval.sh 建立基线准确率然后每次只改一个参数比如把 Sobel 核从 17×3 改成 21×3跑一遍看整体变化有效就留无效就回滚。优先动的参数顺序是Sobel 核大小、形态学闭运算核、自适应阈值的窗口大小、SVM 的 C 和 gamma。等这三个环节稳定了再去调 OCR 特征和训练样本。这套工程我前后拆过两遍第一遍就是栽在“定位没做验证”上候选框一多 OCR 就跑飞后来强制自己先跑 eval 再谈优化。从那以后我每次改完阈值的第一件事都是把 eval.sh 完整跑一遍盯着整体准确率的波动做事而不是和某一两张图较劲。希望帮到你。本文还有配套的精品资源点击获取
返回列表