尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从LPR.cpp看车牌识别:OpenCV预处理与字符分割实战

从LPR.cpp看车牌识别:OpenCV预处理与字符分割实战 简介面向计算机视觉初学者的车牌识别LPRC源码资源聚焦车辆场景中的车牌自动检测与字符识别适用于智能交通监控、停车场管理、辅助驾驶等应用场景的算法学习与实践。整个压缩包仅含1个cpp文件大小约2KB精简的代码结构便于快速梳理车牌识别完整流程也适合直接运行验证。已有107人浏览/学习对正在学习图像处理、模式识别或OCR技术的开发者有一定参考价值。程序通常覆盖车牌识别的关键环节包括图像灰度化、噪声去除、边缘检测与车牌定位、字符分割、模板匹配或特征识别等借助OpenCV等常用图像库读者可以理解C实现LPR的基本思路。更重要的是可在该源码基础上针对光照变化、车牌倾斜、字符遮挡等真实场景问题进行优化与扩展既可作为课程设计的起步代码也能帮助研究者快速验证算法想法。1. 车牌识别项目拆开看LPR.cpp 里藏着的完整视觉流程拿到一个叫LPR.rar的压缩包解压后只有孤零零一个LPR.cpp但标题里的plate recognition和vehicles已经把话说清楚了这是一套用 C 写的车牌识别实现面向的是车辆场景。很多刚入门的开发者以为车牌识别就是把摄像头对准车牌然后调用一个现成的 OCR 接口就完事。但实际上一个能跑的 LPR 系统从图像采集到输出车牌号中间要经过至少五六个阶段灰度化、二值化、边缘检测、定位候选区、字符分割、字符识别。每个阶段都有独立的算法选型和参数调优空间任何一步做得不干净后面都会连锁出错。这篇博文打算从LPR.cpp最常见的组织方式出发把整条视觉管线拆开讲。你如果手里正好有类似的压缩包或者正准备自己写一个 LPR Demo应该关注的重点不是某个神奇的函数而是每一步为什么这样做、参数怎么调、失败时看哪个环节。适合的读者是已经会用 OpenCV 做基本图像处理的 C 开发者或者想从零开始把车牌识别工程化的算法工程师。2. 预处理决定识别上限灰度化、二值化与形态学参数怎么调2.1 为什么第一步不是直接找车牌很多初版 LPR 实现会把cvtColor之后的图像直接扔给边缘检测结果在复杂背景下定位率惨不忍睹。预处理的目的不是让图像“好看”而是把车牌区域和路面、车身、阴影、树木这些背景在特征空间上拉开距离。车牌本身的颜色通常饱和度高、边缘密集、字符与底色的对比度大这些特征在经过灰度化和二值化之后才会变得稳定。LPR.cpp里常见的顺序是灰度化 → 高斯模糊 → 自适应阈值或固定阈值 → 形态学操作。这个顺序不建议随意调整。灰度化是为了把三通道信息压缩成单通道减少计算量高斯模糊是为了抑制传感器噪声同时保留车牌字符的边缘阈值化的目标是让车牌变成白底黑字或者黑底白字的干净连通域形态学操作用于填平字符之间的断裂或去除小块噪声。2.2 灰度化与去噪cvtColor 和 GaussianBlur以 OpenCV 4.x 为例典型的预处理代码块长这样#include opencv2/opencv.hpp using namespace cv; Mat preprocessFrame(const Mat frame) { Mat gray, blurred, binary; // 1. 灰度化丢掉颜色保留亮度梯度 cvtColor(frame, gray, COLOR_BGR2GRAY); // 2. 高斯模糊核大小必须为奇数sigma 根据图像尺寸调整 GaussianBlur(gray, blurred, Size(5, 5), 0); // 3. 自适应阈值适合光照不均匀的场景 adaptiveThreshold(blurred, binary, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 21, 10); return binary; }这段代码的逻辑很直白。cvtColor接受COLOR_BGR2GRAY因为 OpenCV 默认读入的是 BGR 排列。GaussianBlur的Size(5,5)是卷积核尺寸核越大越模糊但字符边缘也会越软sigma设为 0 表示由核尺寸自动计算。adaptiveThreshold按局部邻域计算阈值21是邻域块大小10是从邻域均值减去的常量值越大二值化后保留的前景像素越少。参数怎么改如果车牌在图像中只占很小面积核从 5 提到 7 能更有效地滤掉高频噪点但要注意字符细笔画可能被抹掉。如果拍摄环境是夜间或者有强光灯固定阈值往往没法用adaptiveThreshold是更安全的选择。块大小建议保持在 15 到 31 之间太大会退化成全局阈值太小则会把单个字符内部的灰度变化误判成边界。2.3 形态学操作闭运算连接断裂开运算去掉孤立点二值化之后车牌字符间经常出现细小的断裂尤其当车牌经过透视变形或污损时。常见做法是用morphologyEx做一次闭运算用矩形核把字符笔画连接起来。代码段如下Mat closeImg, openImg; Mat kernel getStructuringElement(MORPH_RECT, Size(17, 3)); morphologyEx(binary, closeImg, MORPH_CLOSE, kernel); Mat smallKernel getStructuringElement(MORPH_RECT, Size(3, 3)); morphologyEx(closeImg, openImg, MORPH_OPEN, smallKernel);闭运算的原理是先膨胀后腐蚀可以把水平方向的断裂填上因为车牌字符是横向排列的核的宽度远大于高度这样就不会把上下两行的干扰粘连起来。Size(17,3)是一个比较激进的横向连接核适合车牌在画面中占比较大的情况如果车牌较小应该把 17 降到 9 或 11避免把左右邻近的字符融成一团。开运算紧跟在后面用于去掉孤立的小噪点核大小 3x3 即可。步骤常用参数作用失败时的现象GaussianBlurSize(5,5)降噪字符边缘断裂adaptiveThreshold块 21, 常量 10二值化背景残留大块白点MORPH_CLOSESize(17,3)连接水平断裂字符粘连成块MORPH_OPENSize(3,3)去孤立点车牌区域不完整这一整套预处理做完车牌的轮廓应该在二值图上形成一块高密度白色区域。但要注意LPR.cpp里如果没有保存中间结果排错会非常困难建议开发阶段把每一步都imwrite到本地对比参数调整前后的差值。3. 车牌定位边缘检测、轮廓筛选与透视矫正3.1 边缘检测选 Sobel 还是 Canny预处理之后的图像是二值图接下来要在里面找到车牌位置。车牌区域最显著的特点是横向边缘密集、区域面积适中、宽高比固定。很多教科书喜欢直接上 Canny但 Canny 对纹理复杂的背景会生成大量碎片边缘导致后续轮廓筛选很痛苦。我的经验是如果预处理已经做了形态学直接用Sobel计算水平梯度反而更干净因为车牌字符本身就是一组规律的水平边缘。Sobel 核在 OpenCV 中通过Sobel(src, dst, ddepth, dx, dy)指定。dx1, dy0表示只算水平方向导数代码是Mat sobelEdge; Sobel(binary, sobelEdge, CV_16S, 1, 0, 3); convertScaleAbs(sobelEdge, sobelEdge); // 再做一次闭运算把水平边缘线段连成整块 Mat edgeKernel getStructuringElement(MORPH_RECT, Size(25, 5)); morphologyEx(sobelEdge, sobelEdge, MORPH_CLOSE, edgeKernel);convertScaleAbs把带符号的 16 位梯度值转回 8 位无符号图像。这一步之后水平梯度大的位置会变白梯度小的地方变黑。紧接着用一个大宽高比的Size(25,5)核做闭运算目的是把字符间的间隙填掉让整个车牌变成一个凸起的白色矩形块。如果车牌在图像里倾斜核的形状也要跟着调整或者先做一次粗略的旋转校正。3.2 用 findContours 按宽高比筛候选区有了候选连通域之后用findContours找出所有轮廓再通过面积、宽高比、填充率筛选。车牌的标准宽高比大约是 3:1 到 4:1但实际画面里因为透视会变成梯形所以筛选条件要放宽。代码逻辑如下vectorvectorPoint contours; findContours(sobelEdge, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); for (const auto c : contours) { double area contourArea(c); if (area 500) continue; RotatedRect rrect minAreaRect(c); float width rrect.size.width; float height rrect.size.height; float ratio max(width, height) / (min(width, height) 1e-5); if (ratio 2.2 ratio 5.0 area 500) { // 记录候选后续做字符分割 candidates.push_back(rrect); } }findContours的RETR_EXTERNAL只提取最外层轮廓能避免把车牌内部字符的轮廓也当成候选。minAreaRect返回最小外接旋转矩形比boundingRect更适合有倾斜角度的车牌。筛选条件里的2.2到5.0允许一定误差因为某些新能源车牌或军牌的比例略有不同同时还要兼顾截图中车牌被裁掉一半的情况。筛选出的RotatedRect并不一定是真正的车牌还需要做一步校验计算矩形内部的白色像素密度或者把矩形区域裁剪出来检查其中是否包含多个分离的字符块。这一步在LPR.cpp里通常体现为一个validatePlateRegion函数具体做法是对裁剪图再做垂直投影数一数波峰数量如果波峰在 5 到 8 个之间才认为是有效车牌。定位阶段常见的坑有两个。第一area 500太绝对如果摄像头分辨率很高车牌像素面积远大于 500但远处的车可能小于这个值最好把面积阈值改为基于图像总面积的百分比。第二倾斜车牌如果直接用矩形裁剪会把背景大量带进来后续字符分割会误切。建议对候选RotatedRect用warpAffine做透视矫正把车牌拉正为水平矩形再利用车牌字符颜色与底色差异做一次精确定位。透视矫正的代码片段如下Point2f srcPts[4]; rrect.points(srcPts); Point2f dstPts[4] { Point2f(0, 0), Point2f(plateWidth, 0), Point2f(plateWidth, plateHeight), Point2f(0, plateHeight) }; Mat M getPerspectiveTransform(srcPts, dstPts); Mat plate; warpPerspective(frame, plate, M, Size(plateWidth, plateHeight));plateWidth和plateHeight可以由rrect.size决定我一般固定为 300x100这样后续字符分割时模板尺寸可以统一。getPerspectiveTransform需要至少四个点对RotatedRect::points恰好返回角点顺序需要确认是左上、右上、右下、左下映射错误会导致翻转。4. 字符分割与识别垂直投影、模板匹配与后续扩展4.1 字符分割为什么不直接二值化拿到矫正后的车牌图并不意味着可以直接送到 OCR。车牌上还有边框、螺钉、防伪标识它们和字符一样都是高对比度区域。常见的错误是直接用整张图做模板匹配结果是模板稍大一点就匹配到边框上去。字符分割的目标是把每个字符独立切出来同时去掉上下边缘和左右干扰。一个实用的分割前置操作是转为灰度后再做一次 Otsu 二值化因为车牌区域在矫正后光照已经相对均匀Otsu 能得到比自适应阈值更干净的字符。然后做一个水平投影统计每一行的白色像素数找到字符的上下边界再去掉上下边框。接着做垂直投影按列统计白色像素波峰之间的波谷就是字符分界点。4.2 垂直投影分割流程垂直投影的代码实现非常直观用reduce就可以完成Mat grayPlate, binaryPlate; cvtColor(plate, grayPlate, COLOR_BGR2GRAY); threshold(grayPlate, binaryPlate, 0, 255, THRESH_BINARY_INV | THRESH_OTSU); Mat verticalProjection; reduce(binaryPlate, verticalProjection, 0, REDUCE_SUM, CV_32S); int startCol -1; vectorRect charRects; for (int i 0; i verticalProjection.cols; i) { int value verticalProjection.atint(0, i) / 255; if (value 0 startCol 0) { startCol i; } else if (value 0 startCol 0) { int width i - startCol; if (width 5) { charRects.push_back(Rect(startCol, 0, width, binaryPlate.rows)); } startCol -1; } }这里用THRESH_BINARY_INV是因为大多数车牌底色深、字符亮反色后字符作为前景更容易投影。reduce的REDUCE_SUM计算每列所有像素值的总和因为二值图只有 0 和 255所以除以 255 就得到该列白色像素数量。width 5用来过滤噪声窄条但如果字符本身特别细这个阈值要降到 3。分割结果里第一个区域可能是车牌省份简称的汉字这个汉字经常比后面的字母窄甚至由于繁体字导致投影粘连需要单独处理。得到charRects之后还需要按字符宽度排序并过滤明显越界的矩形。比如有的区域宽度超过平均宽度的 1.5 倍说明可能存在字符粘连要尝试在垂直投影的局部最小值处切开。粘连切割是传统 LPR 最恼人的问题一个实用的切分条件是当某个区域内部出现两段明显的波峰时在波谷处补充一条切割线。4.3 模板匹配识别与 DNN 扩展字符识别在LPR.cpp里最可能用的是模板匹配因为模板匹配原理简单、不依赖外部依赖库。做法是把每个分割字符缩放到模板大小然后与预先加载的字符模板计算归一化互相关系数取最大值对应的字符作为识别结果。double matchPlateChar(const Mat plateChar, const vectorpairchar, Mat templates) { Mat resizedChar; resize(plateChar, resizedChar, Size(32, 32)); double bestScore -1.0; char bestChar ?; for (const auto t : templates) { Mat result; matchTemplate(resizedChar, t.second, result, TM_CCOEFF_NORMED); double score; minMaxLoc(result, nullptr, score); if (score bestScore) { bestScore score; bestChar t.first; } } return bestChar; }TM_CCOEFF_NORMED对光照变化更鲁棒取值越接近 1 表示相似度越高。模板库需要覆盖汉字、字母、数字其中汉字如“京、沪、粤”等需要按省份准备。模板匹配的局限性在于字体稍有变化就会掉分比如车牌上的字体和模板里的字体不完全一致容易把 0 和 O、1 和 I 混淆。如果要提升识别率可以改用 OpenCV 的 DNN 模块加载一个训练好的 CNN 模型。LPR.cpp如果只是基础版通常不会包含模型训练代码但留出接口即可。用 DNN 做分类的常见做法dnn::Net net dnn::readNetFromONNX(lpr_cnn.onnx); Mat blob dnn::blobFromImage(resizedChar, 1.0/255.0, Size(32, 32), Scalar(0,0,0), true); net.setInput(blob); Mat prob net.forward();这里blobFromImage的size要与训练时一致swapRB参数根据训练数据决定。使用 CNN 的收益是抗干扰能力显著增强但需要预先准备几十万张字符样本对于个人项目成本偏高。所以很多初版LPR.cpp先用模板匹配跑通流程再考虑升级。识别方式准确率量级依赖适用场景模板匹配90% 以下无固定字体、受控光照SVM HOG92% ~ 96%OpenCV ML中等变化CNN97% ~ 99%DNN/ONNX复杂环境无论用哪种方式最后都要把识别出的字符按顺序拼接并且做一次长度合法性校验。中国车牌标准长度为 7 个字符不含新能源的 8 位如果结果不足或多出来应该触发重试机制把当前帧丢回预处理阶段再跑一遍。5. 把 LPR.cpp 改造成实时管线多线程与调试5.1 单线程的瓶颈在哪如果直接在一个while循环里做“采集→定位→分割→识别”帧率一般只有 5 到 8 帧因为字符识别阶段最耗时尤其用模板匹配时需要遍历几百个模板。实时系统的常见做法是把采集和识别分别放到两个线程中间用队列解耦。采集线程只管抓帧识别线程从队列里取最近的一帧处理。5.2 简易帧队列与状态机一个轻量的实现是使用std::mutex和std::condition_variable结构如下std::queueMat frameQueue; std::mutex queueMutex; std::condition_variable cv; void captureLoop(VideoCapture cap) { Mat frame; while (cap.read(frame)) { std::lock_guardstd::mutex lock(queueMutex); if (frameQueue.size() 5) { frameQueue.push(frame.clone()); } else { frameQueue.pop(); frameQueue.push(frame); } cv.notify_one(); } } void processLoop() { while (true) { Mat frame; { std::unique_lockstd::mutex lock(queueMutex); cv.wait(lock, []{ return !frameQueue.empty(); }); frame frameQueue.front(); frameQueue.pop(); } string result runLPR(frame); if (result.size() 7) { // 连续三帧结果一致才输出 } } }这个队列加上限长度 5防止采集线程过快导致内存暴涨当队列满时直接丢弃最旧帧这样处理的是最新场景。notify_one只唤醒一个等待线程够用。关键点是识别结果不能一帧就信因为车速或抖动可能导致误识别常见做法是加一个状态机只有连续三帧识别结果完全相同才判定为最终结果否则进入下一轮。5.3 调试小技巧调试 LPR 管线时最怕黑盒。我会在runLPR内部加一个环境变量LPR_DEBUG当它设为 1 时把每一帧的中间图灰度图、二值图、边缘图、候选框、分割图保存到debug/目录。这样一旦识别失败直接看哪一步的输出不符合预期。另一个技巧是使用 OpenCV 的getTickCount对每个阶段计时找出瓶颈。但如果发现车牌定位耗时超过预处理多半是图像分辨率太高可以在预处理前把图像缩放一半车牌如果能保证辨识度定位速度会快四倍。最后车牌识别系统的性能指标不能只看准确率还要看单帧处理耗时、误触发率以及夜间和逆光场景的表现。LPR.cpp如果缺少这些统计逻辑建议在输出结果时同时记录时间戳和置信度方便后面对真实场景下的算法参数做回放分析。本文还有配套的精品资源点击获取
返回列表