做车牌识别这个需求,很多人第一反应就是找个开源 OCR 直接怼上去,结果发现识别率惨不忍睹。真正要落地一个能用的 AI 车牌识别应用,核心其实是两件事:先解决“车牌在哪”,再解决“牌上的字是什么”。我在这套基于 YOLO11 + RapidOCR 的 AI 工坊项目里,就是把这两个问题拆开做的——YOLO11 负责从画面里把车牌框出来,RapidOCR 负责把框里的字符读出来。这个组合的好处是每一段都可以单独调优,哪里不行改哪里,不用整个推倒重来。这篇博文就把完整的技术选型、模型训练、性能调优和工程化细节都写出来,适合想自己动手做一个完整 CV 项目的开发者参考。
1. 技术选型:三种车牌识别方案对比,为什么最终选了 YOLO11 + RapidOCR
1.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( data="plate.yaml", epochs=150, imgsz=640, batch=32, device=0, patience=20, cache=True, project="runs/train", name="plate_detector" )几个参数的解释:imgsz 默认 640,如果显存够用,我建议直接上 1280,对车牌这种小目标来说,提高输入分辨率是最直接有效的提升手段。epochs 不需要太多,150 轮左右足够,配合 patience=20 做早停,防止过拟合。batch 大小看显存,卡不够就用 16,主要影响是训练速度,对最终精度影响不大。
训练完成以后看两个指标:mAP@0.5 和 recall。mAP@0.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_type="ultralytics", model_path="best.pt", confidence_threshold=0.25, image_size=640, ) results = get_sliced_prediction( "frame.jpg", detection_model, slice_height=640, slice_width=640, overlap_height_ratio=0.2, overlap_width_ratio=0.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_options=sess_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]]], dtype="float32") M = cv2.getPerspectiveTransform(np.array(pts, dtype="float32"), 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(clipLimit=2.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, conf=0.25, iou=0.5, imgsz=1280) 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。我在项目里加了一个兜底逻辑:如果当前帧识别失败或置信度低于阈值,不急着丢弃,而是保留检测框里的图片,等下一帧再试。如果连续几帧都是同一个位置,我用一个简单的小技巧——把最近几帧的裁剪图做加法平均,相当于多帧降噪,然后再重新走一遍 OCR:
def average_roi(roi_list): merged = np.zeros_like(roi_list[0], dtype=np.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 检测 | CPU,640x640 | 约 40 至 70 ms | 适合无 GPU 设备 |
| YOLO11n 检测 | RTX 3060,640x640 | 约 5 至 10 ms | 生成环境首选 |
| YOLO11n 检测 | RTX 3060,1280x1280 | 约 15 至 25 ms | 小目标召回更好 |
| RapidOCR 完整引擎 | CPU,车牌裁剪图 | 约 150 至 300 ms | 含检测和方向分类,最慢 |
| RapidOCR 仅识别模块 | CPU,车牌裁剪图 | 约 30 至 50 ms | 优化后,CPU 只占 1 至 2 核 |
| 全流程 | CPU,YOLO11n + Rec-only | 约 70 至 120 ms | 3 到 10 FPS,勉强够用 |
结论很明确:如果要做视频实时识别,GPU 是必要条件;如果只是做单张图片识别或者低帧率抓拍,CPU 优化后也能跑。我最终把 YOLO11 导出成 ONNX,用 ONNX Runtime 替换了原来 ultralytics 的 Python 推理,推理耗时又省了一截。导出命令很简单:
det_model.export(format="onnx", dynamic=True)5.2 踩坑清单:这些问题我几乎每个都遇到过
我把这个项目从零到能跑的过程中踩过的坑整理成一个清单,你照着排查可以少走很多弯路:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| CPU 满载 | 程序一跑 CPU 就拉满 | RapidOCR 默认跑三个模型 + 全核推理 | 只加载 rec 模型,限制线程数 |
| 字符误识别 | “0”和“O”、“1”和“I”混淆 | 车牌字体特殊,OCR 字典无约束 | 正则过滤 + 字符映射表 |
| 夜间反光 | 识别结果为空 | 强光导致字符对比度不足 | CLAHE 增强 + 多帧叠加 |
| 重复上报 | 一辆车输出多条记录 | 没有时间窗口去重 | 用 IoU 匹配 + 多帧投票 |
| 远距离漏检 | 车辆在画面远端无检测框 | 小目标特征信息不足 | imgsz 提到 1280,或 SAHI 切片 |
| 倾斜车牌识别率低 | 侧向车辆字符识别乱 | 检测框是正矩形,文字是斜的 | 透视矫正后再过 OCR |
5.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 只是两个起点,它们之间那层预处理、后处理、去重、兜底的逻辑,才是让整个系统从“能跑”变成“好用”的关键。