尧图精选

基于YOLO11与RapidOCR的车牌识别:技术选型与工程优化实战

🕒 发布时间:2026/10/1 19:29:48 📁 来源:尧图网络
做车牌识别这个需求很多人第一反应就是找个开源 OCR 直接怼上去结果发现识别率惨不忍睹。真正要落地一个能用的 AI 车牌识别应用核心其实是两件事先解决“车牌在哪”再解决“牌上的字是什么”。我在这套基于 YOLO11 RapidOCR 的 AI 工坊项目里就是把这两个问题拆开做的——YOLO11 负责从画面里把车牌框出来RapidOCR 负责把框里的字符读出来。这个组合的好处是每一段都可以单独调优哪里不行改哪里不用整个推倒重来。这篇博文就把完整的技术选型、模型训练、性能调优和工程化细节都写出来适合想自己动手做一个完整 CV 项目的开发者参考。1. 技术选型三种车牌识别方案对比为什么最终选了 YOLO11 RapidOCR1.1 端到端、传统图像处理、两段式各自的实际表现车牌识别这个领域市面上能看到的方案基本可以分成三条路线。先把我实际对比过的结果摆出来方案优点缺点典型代表端到端模型推理快、一步到位字符序列直接输出数据需求量大可解释性差换一个车牌样式就要重训LPRNet、PaddleOCR 的车牌专用模型传统 OpenCV 模板匹配/OCR无需标注训练逻辑透明对光照、倾斜、遮挡极其敏感换场景就崩边缘检测 轮廓筛选 模板匹配YOLO11 检测 RapidOCR 识别本文两段独立调优、故障定位清晰、准确性最高多一次模型调用延迟略高需要做好两段衔接自定义工程方案端到端模型我试过在公开数据集上效果确实不错那种训练好的权重拿来跑测试图片准确率能到 95% 以上。但问题在于一旦换到自己拍的真实场景——比如不同角度、不同光照、不同省份的车牌——需要重新标注大量数据去微调而且你很难说是哪里出了问题是特征提取没学好还是序列解码错了调试起来非常痛苦。传统 OpenCV 方案我在更早的版本里用过那时候还是用 Sobel 边缘检测加形态学操作来找车牌区域。说实话固定机位、光线稳定的场景下够用但稍微遇到逆光、反光、车在弯道上倾斜轮廓就断裂了最后你能检测到的车牌框七扭八歪后面 OCR 再好也白搭。1.2 YOLO11 作为检测端为什么比上一代更适合这种项目YOLO11 是 Ultralytics 在 2024 年发布的检测模型虽然名字里没有 v但它就是 YOLOv8 的正统后续。相比前代它的 Backbone 用上了 C3k2 模块和 C2PSA 结构整体参数量下降但精度没有牺牲官方提供了 n、s、m、l、x 五个尺度覆盖了从嵌入式设备到服务器 GPU 的部署场景。我对 YOLO11 的评价是它把“好用”这个词拉满了。训练、验证、导出、推理全部封装在 ultralytics 这个包里你传一个数据集路径就能跑起来。而且它的检测头是 anchor-free 加解耦结构对小目标的响应比老版本更稳这对车牌检测非常关键——因为车牌在整张画面里往往只占很小的面积。另外YOLO11 的后处理是内置的你只需要设置 conf-thresh 和 iou-thresh 两个参数非极大值抑制NMS会自动处理好重叠框的问题。我后面会专门讲后处理和置信度阈值怎么调这直接决定了你的检测器会不会把车尾的装饰条、车灯误判成车牌。1.3 RapidOCR 作为识别端能解决什么、不能解决什么RapidOCR 这个项目把飞桨的 PP-OCR 系列模型转换成了 ONNX 格式然后封装了非常简洁的 Python API。它最大的优势是不需要安装 PaddlePaddle 这个厚重的深度学习框架直接用 ONNX Runtime 就能跑离线环境下部署很方便中文印刷体识别能力也够用。但这里要泼一盆冷水RapidOCR 本身是通用文本识别工具不是车牌识别专用模型。直接拿它识别一张车牌图你会遇到两个典型问题一是车牌是特殊的字体和排列方式OCR 会把它识别成乱七八糟的普通文字二是 RapidOCR 是全字典识别输出的字符范围包含常用汉字和字母数字车牌上的省份简称、字母、数字混在一起没有约束就容易出错。所以我在项目里对 RapidOCR 做了一层针对性改造包括限制输出字典、自定义后处理正则、以及只加载识别模块来节省 CPU。这些细节我在第 3 节里全部展开。你在自己的项目里如果只是单纯调 RapidOCR 的 API那离一个能用的车牌识别系统还有不小的距离。2. 训练 YOLO11 车牌检测器数据准备、参数调整与小目标不丢的三个手段2.1 数据从哪来公开数据集和自采样本怎么配合车牌检测模型的训练数据我建议不要一上来就想“自己拍一万张”。比较好的组合是公开数据集打底 少量自采数据做补充。公开数据集首推 CCPD中国城市停车场车牌数据集里面是从停车场监控视角采集的真实车牌图片包含了蓝牌、绿牌、黄牌场景覆盖各种角度和光照。这个数据集的好处是标注干净、数量充足拿来预训练或者直接微调都很合适。不过现实场景总是比数据集复杂。我当时的做法是用 CCPD 作为主要训练数据再从自己的摄像头机位拍了大概 200 个片段按帧抽图只标注那些 CCPD 里没有的视角和光照条件。这个操作很有必要因为不同机位的俯仰角不同YOLO11 需要见足够多“你实际部署视角”的样本才能在真实环境里稳定发挥。标注工具我用的是 CVAT开源、支持多人协同。注意标注时只有一个类别license_plate不要顺手把“车”“车牌颜色”都标进去类别越多样本平衡越难做反而会拉低车牌本身的召回率。而且标签框要紧贴车牌边缘留太多边距会干扰后面的识别步骤。2.2 数据格式和训练参数照着抄就能跑的配置YOLO 系列的数据集结构是固定的一个 images 目录放图片一个 labels 目录放同名的 txt 标注文件。标注内容每行是类别 x_center y_center width height坐标和宽高都是相对于图片宽高的归一化值。我的数据集目录长这样plate_dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── plate.yamlplate.yaml 的内容非常简单path: plate_dataset train: images/train val: images/val names: 0: license_plate训练脚本我用的是 ultralytics 的 Python API而不是命令行因为方便在训练循环前后插自定义逻辑from ultralytics import YOLO model YOLO(yolo11n.pt) # 用官方预训练权重做初始化 model.train( dataplate.yaml, epochs150, imgsz640, batch32, device0, patience20, cacheTrue, projectruns/train, nameplate_detector )几个参数的解释imgsz 默认 640如果显存够用我建议直接上 1280对车牌这种小目标来说提高输入分辨率是最直接有效的提升手段。epochs 不需要太多150 轮左右足够配合 patience20 做早停防止过拟合。batch 大小看显存卡不够就用 16主要影响是训练速度对最终精度影响不大。训练完成以后看两个指标mAP0.5 和 recall。mAP0.5 达到 0.9 以上基本说明检测器合格了。但我想特别提醒这个场景下 recall 比 precision 重要。因为漏检意味着这辆车彻底不会被识别到而误检测的框后面还有 OCR 和正则做兜底大不了被过滤掉。所以我在训练时宁可让模型“多框出来一些”也不想让它“该框的没框”。2.3 车牌在画面里真的很小我用这三个手段让小目标不丢车牌检测最头疼的问题就是目标太小。假设监控画面分辨率是 1920x1080车牌宽度通常只有 100 到 200 像素经过 YOLO11 的 32 倍下采样特征图上的车牌区域只有大约 3 到 6 个像素宽特征信息非常有限。我实测下来如果不做任何处理单纯用 640 输入训练漏检率大概在 10% 左右主要集中在远距离车辆上。我的第一个手段是提高输入分辨率。训练和推理都把 imgsz 从 640 提到 1280漏检率能降一大截。代价是推理速度变慢如果你的视频流帧率要求高这一条不一定能直接上生产需要做权衡。第二个手段是切片推理SAHI。SAHI 的原理很简单把一张大图切分成多个 640x640 的小块分别推理再把结果合并回原图。这样可以绕开输入分辨率限制让模型在原始大图上充分发挥检测能力。我用 SAHI 做过对比测试对远距离小目标的召回率提升非常明显代价是推理次数变多耗时成倍增加。from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction detection_model AutoDetectionModel.from_pretrained( model_typeultralytics, model_pathbest.pt, confidence_threshold0.25, image_size640, ) results get_sliced_prediction( frame.jpg, detection_model, slice_height640, slice_width640, overlap_height_ratio0.2, overlap_width_ratio0.2, )第三个手段更土但很有效在训练阶段对含车牌的图片做了 1.25 倍的中心裁剪再缩放到 640相当于人为把车牌放大后再喂给模型。我写了个简单的预处理脚本批量生成增强后的训练样本。这个方法把“训练时看见的车牌”和“测试时真实的车牌”拉到了更接近的尺度实测对小目标的稳定效果比调一堆增强参数更直接。3. RapidOCR 接入与 CPU 性能解围车牌字符识别的针对性优化3.1 最直接的接入方式和“CPU 直接拉满”的现象RapidOCR 的安装和调用非常简单pip install rapidocr_onnxruntime然后from rapidocr_onnxruntime import RapidOCR engine RapidOCR() result, elapse engine(cropped_plate.jpg) for box, text, score in result: print(text, score)就这么几行OCR 就能跑了。但我第一次把它接到项目里的时候发现一个明显问题程序一跑CPU 占用直接冲到接近 100%而且处理一张裁剪出来的车牌图要耗时几百毫秒。如果你要做视频流实时识别这个性能完全不能接受。这个话题在很多技术社区里也被反复吐槽RapidOCR 在 Python 里跑实在是太吃 CPU 了。我们得先搞明白它到底在“吃什么”。3.2 RapidOCR 为什么这么吃 CPU拆开看它的内部结构RapidOCR 默认的完整引擎内部实际加载了三个模型文本检测模型DBNet用于定位图中所有文本区域方向分类模型cls用于判断文本方向文本识别模型CRNN/SVTR用于识别文本内容。当你调用engine(img)的时候它会先跑检测找到图像里的文本区域然后跑方向分类最后才进入识别。这三个模型一个都不少而且 ONNX Runtime 默认会使用 CPU 的所有物理核心进行推理多线程同时跑看起来就是“CPU 吃满”。但回到我们的场景车牌区域已经被 YOLO11 裁剪出来了它本身就是一个明确的文本区域根本不需要 DBNet 再做一次文本检测更不需要方向分类。所以最直接的优化思路是把 RapidOCR 当成一个纯识别器来用跳过检测和方向分类。如果你用的 RapidOCR 版本支持只加载识别模型可以查阅官方 README 里的参数说明。我自己的做法更“硬核”一点——直接把 rec 模型对应的 ONNX 文件拿出来用 onnxruntime 自己加载推理。这样可控性最高线程数、输入尺寸、字典全部掌握在自己手里。3.3 针对车牌的三个关键优化裁剪输入、白名单字符集、只跑识别模型先说裁剪输入。YOLO11 输出的检测框是水平的矩形框我裁剪的时候会额外扩展一些边距把车牌上的螺丝、边缘的反光都包进来。如果直接把严格的车牌区域丢给 OCR周围一点背景都没有识别器反而容易因为字符太满而漏掉边缘字符。用我的经验上下各扩展 20%左右各扩展 10%效果比较稳。然后是白名单字符集。车牌上的字符种类是高度受限的省份简称加字母加数字字母中还要排除 I 和 O因为和数字 1、0 容易混淆。如果 RapidOCR 的字典是全量中文识别器会在很多无关字符上分配概率导致最终结果不稳定。建议在识别后立刻用正则过滤把所有非法字符剔除import re def clean_plate_text(raw_text): text raw_text.upper() text re.sub(r[^0-9A-Z\u4e00-\u9fa5], , text) plate_pattern re.compile( r^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼] r[A-Z][A-Z0-9]{5,6}$ ) if plate_pattern.match(text): return text return 只要不符合这个判断直接丢弃不进行上报。这一步能过滤掉大量 OCR 的错误输出比反复调模型参数管用得多。最后是只跑识别模型的性能优化。onnxruntime 支持通过 SessionOptions 控制线程数import onnxruntime as ort sess_options ort.SessionOptions() sess_options.intra_op_num_threads 4 session ort.InferenceSession(rec.onnx, sess_optionssess_options)如果你用的是 RapidOCR 官方封装看它是否透传 session_options如果直接用 onnxruntime 加载模型这个控制方式完全由你说了算。加上只保留识别模型这一步我之前单帧 OCR 从 200 到 300 毫秒降到了 30 到 50 毫秒CPU 占用也从吃满变成稳定在 1 到 2 个核。这个提升非常可观。3.4 车牌图像的预处理不能省透视矫正、灰度增强和反光处理YOLO11 给我的是正矩形检测框但真实世界里的车牌经常是倾斜的。尤其是车辆在弯道、坡道上的时候拍出来的车牌是梯形或者平行四边形。直接把这种图像丢给 OCR字符行线是歪的识别率会掉得很厉害。我处理的流程是这样的先看检测框的长宽比是否符合车牌特征普通蓝牌长宽比大约是 3:1 到 4:1新能源车牌更长一些如果明显偏离说明车辆可能有侧倾或透视变形。这时候需要做透视矫正最简单粗暴的方式是用 OpenCV 人工选四个角点然后cv2.getPerspectiveTransform映射到标准矩形import cv2 def four_point_transform(image, pts): rect cv2.minAreaRect(pts) # 或者手动指定四个角点 dst np.array([[0, 0], [rect[1][0], 0], [rect[1][0], rect[1][1]], [0, rect[1][1]]], dtypefloat32) M cv2.getPerspectiveTransform(np.array(pts, dtypefloat32), dst) return cv2.warpPerspective(image, M, (int(rect[1][0]), int(rect[1][1])))说实话这个步骤做起来有点繁琐但对识别率的提升是肉眼可见的。我在夜间场景测试过不做矫正的 OCR 正确率不到 60%矫正之后直接跳到 85% 以上。光照方面车牌在夜间最容易出现反光蓝底白字在强光下白字会看不清。我的经验是用 CLAHE 自适应直方图均衡代替简单的灰度化gray cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(gray) enhanced cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)这里注意转换回三通道是因为 RapidOCR 的识别模型输入是 RGB 三通道。这个预处理在夜间和逆光场景下能明显拉开字符和背景的对比度对 OCR 识别率帮助很大。4. 视频流水线的工程细节从检测框到稳定车牌号再到重复上报控制4.1 完整主循环检测、裁剪、识别、过滤一个都不能少我把整条链路写成了一个主循环处理一帧视频的流程是读帧 - YOLO11 检测 - 遍历检测框 - 裁剪并预处理 - RapidOCR 识别 - 正则过滤 - 进入去重逻辑。核心代码框架如下import cv2 import numpy as np from ultralytics import YOLO from rapidocr_onnxruntime import RapidOCR det_model YOLO(best.pt) ocr RapidOCR() def process_frame(frame): results det_model.predict(frame, conf0.25, iou0.5, imgsz1280) boxes results[0].boxes.xyxy.cpu().numpy() detected_plates [] for box in boxes: x1, y1, x2, y2 [int(v) for v in box] roi frame[y1:y2, x1:x2] roi expand_margin(roi) roi enhance_plate(roi) result, _ ocr(roi) if not result: continue text result[0][1] cleaned clean_plate_text(text) if cleaned: detected_plates.append((cleaned, result[0][2], (x1, y1, x2, y2))) return detected_platesexpand_margin就是前面说的扩展边距enhance_plate是 CLAHE 预处理clean_plate_text是正则过滤。这段代码每帧都会跑所以性能压力集中在两个模型调用上。YOLO11 推理和 RapidOCR 识别如果都走的 CPU一帧的耗时是非常可观的后面我会专门讲怎么在性能和准确性之间找平衡。4.2 多帧投票与去重避免同一辆车被重复上报单独一帧的识别结果是不能直接信任的。YOLO11 可能在某一帧漏检OCR 也可能在某一帧识别错字符。这时候如果直接把单帧结果上报你会发现一辆车经过卡口时系统会输出五六条记录而且车牌号还时不时变一下——最常见的是“0”和“O”来回跳“B”和“8”偶尔互串。我的做法是引入一个简单的滑动窗口去重。核心逻辑是保留最近 N 帧的识别结果只有当某个车牌号在连续多帧中稳定出现或者在一段时间窗口内累计出现次数超过阈值才判定识别成功并上报。车牌号虽然可能抖动但检测框的位置是相对稳定的我可以用检测框的重叠度IoU来判断是不是同一辆车def iou(box1, box2): x1 max(box1[0], box2[0]) y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]) y2 min(box1[3], box2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) return inter / (area1 area2 - inter 1e-6)我维护一个列表存着当前“候选车辆”的最近位置和识别结果。每来一帧先计算新检测框和候选框的 IoU如果大于 0.5 就认为是同一辆车更新候选框位置然后把新识别的车牌号和之前的结果做比较如果是同一个车牌号计数加 1如果不是记录下来但暂时不覆盖。等到连续 5 帧以上识别到同一个车牌号才输出一次最终结果。这个策略做下来重复上报问题基本解决了单帧识别抖动也都被平滑掉了。4.3 夜间、逆光和倾斜车牌的兜底策略多帧叠加和重试机制即使做了 CLAHE夜间的某些极端反光还是会干翻 OCR。我在项目里加了一个兜底逻辑如果当前帧识别失败或置信度低于阈值不急着丢弃而是保留检测框里的图片等下一帧再试。如果连续几帧都是同一个位置我用一个简单的小技巧——把最近几帧的裁剪图做加法平均相当于多帧降噪然后再重新走一遍 OCRdef average_roi(roi_list): merged np.zeros_like(roi_list[0], dtypenp.float32) for roi in roi_list: merged roi merged / len(roi_list) return merged.astype(np.uint8)实测下来这个多帧叠加对夜间车牌的高光溢出有一定缓解作用但要注意如果车辆在动叠加会产生重影所以只对静止排队场景使用。车辆在运动状态下我选择用下一帧的自然补拍而不是硬叠。5. 实测性能、坑位清单与部署建议5.1 在我这台机器上的实际性能数据先说测试环境Intel i5-12400 CPU、RTX 3060 显卡、内存 32GB。推理耗时数据如下模块配置耗时说明YOLO11n 检测CPU640x640约 40 至 70 ms适合无 GPU 设备YOLO11n 检测RTX 3060640x640约 5 至 10 ms生成环境首选YOLO11n 检测RTX 30601280x1280约 15 至 25 ms小目标召回更好RapidOCR 完整引擎CPU车牌裁剪图约 150 至 300 ms含检测和方向分类最慢RapidOCR 仅识别模块CPU车牌裁剪图约 30 至 50 ms优化后CPU 只占 1 至 2 核全流程CPUYOLO11n Rec-only约 70 至 120 ms3 到 10 FPS勉强够用结论很明确如果要做视频实时识别GPU 是必要条件如果只是做单张图片识别或者低帧率抓拍CPU 优化后也能跑。我最终把 YOLO11 导出成 ONNX用 ONNX Runtime 替换了原来 ultralytics 的 Python 推理推理耗时又省了一截。导出命令很简单det_model.export(formatonnx, dynamicTrue)5.2 踩坑清单这些问题我几乎每个都遇到过我把这个项目从零到能跑的过程中踩过的坑整理成一个清单你照着排查可以少走很多弯路问题现象原因解决方案CPU 满载程序一跑 CPU 就拉满RapidOCR 默认跑三个模型 全核推理只加载 rec 模型限制线程数字符误识别“0”和“O”、“1”和“I”混淆车牌字体特殊OCR 字典无约束正则过滤 字符映射表夜间反光识别结果为空强光导致字符对比度不足CLAHE 增强 多帧叠加重复上报一辆车输出多条记录没有时间窗口去重用 IoU 匹配 多帧投票远距离漏检车辆在画面远端无检测框小目标特征信息不足imgsz 提到 1280或 SAHI 切片倾斜车牌识别率低侧向车辆字符识别乱检测框是正矩形文字是斜的透视矫正后再过 OCR5.3 部署方向从 Python 脚本到 FastAPI 服务跑通主循环之后我把它包成了一个简单的 FastAPI 服务通过 HTTP 接口接收图片返回车牌号。这个架构在小型项目里完全够用也方便和业务系统对接from fastapi import FastAPI, UploadFile import cv2 import numpy as np app FastAPI() app.post(/recognize) async def recognize(file: UploadFile): data await file.read() img cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) plates process_frame(img) return {plates: [p[0] for p in plates]}如果要在生产环境跑多路摄像头我建议把检测和识别拆成两个独立服务中间走消息队列或者直接共享内存。因为检测的算力需求集中在 GPU识别的算力需求集中在 CPU分开部署可以各自扩缩容不会互相拖累。另外车牌号属于个人敏感信息存储端一定要做脱敏接口调用也要有权限控制别把车牌数据裸奔到业务系统里。5.4 最后分享一个我的土办法项目做完以后我养成了一个习惯把每次 OCR 识别失败的车牌图片单独保存下来定期翻出来看看。你会发现很多失败案例是重复的——比如某个角度的反光、某种字体的“皖”字识别不出来。把这些失败样本挑出来做数据增强后塞回训练集比盲目调整模型参数有效得多。我的检测器从第一版到最终版召回率从 88% 提升到 97%靠的就是这个“失败样本回灌”的笨办法而不是什么高深的技巧。这个项目做到最后我的体会是AI 车牌识别真正的工作量不在模型本身而是在数据和工程细节上。YOLO11 和 RapidOCR 只是两个起点它们之间那层预处理、后处理、去重、兜底的逻辑才是让整个系统从“能跑”变成“好用”的关键。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →