YOLO11+RapidOCR车牌识别实战:两阶段检测与OCR落地全记录
如果你在停车场入口蹲过现场调试大概率见过这种画面烈日把车牌晒得发白雨天泥水把字符糊成一团晚上的LED补光灯又把蓝底牌照打得一片惨白。我第一次把 YOLO11 和 RapidOCR 组合起来做园区车牌识别应用时也踩了不少这种真实的坑。所谓 AI 车牌识别简单说就是用目标检测模型在画面里找到车牌区域再用 OCR 模型把车牌上的字符读出来最后输出一串结构化文本。这篇文章是一份完整项目记录包含方案选型、代码实现、参数调整、性能优化和问题排查适合正在做车辆识别、OCR落地的工程师参考也适合想快速跑通一个“检测识别”串联项目的新手。1. 车牌识别不是“拍个照”那么简单先看清问题再选方案1.1 为什么我最终选择“检测识别”两阶段方案车牌识别这块市面上有不少端到端方案输入一张图直接输出车牌号模型内部自己完成定位和字符序列预测。听起来很省事但实际工程里端到端方案的训练成本和调试成本都很高车牌在整张画面里往往只占几个像素而且角度、光照、遮挡千变万化想让一个模型同时搞定“在哪里”和“是什么”对数据量和算力都不友好。我用的是两阶段方案YOLO11 先做目标检测把车牌框出来RapidOCR 再对裁剪后的区域做字符识别。这样分工很干净——检测模型只负责定位OCR模型只负责读字符。两阶段的好处一个是解耦检测不准改检测识别不准改识别不用动对方。另一个是灵活YOLO11 的检测结果可以做跟踪、计数、车流量统计等附加功能OCR 结果只是其中一个下游消费方式。只要YOLO11框得准RapidOCR 面对的就是一张尺寸相对统一的车牌小图识别难度大大降低。这套组合适合什么场景停车场出入口、园区内部车辆管理、工地闸机、封闭道路的车型统计都可以跑。比如我做的园区试点摄像头固定角度车牌基本是正前方或者略带倾斜既不要求极高帧率也不要求跨摄像头连续追踪YOLO11n RapidOCR 的组合完全够用部署成本也低。1.2 整体流程一张图里的四个环节从输入一张带车的照片到输出“京A12345”这种结果中间其实有四个环节输入画面 → YOLO11检测车牌区域 → 车牌图像预处理裁剪、放大、增强 → RapidOCR字符识别 → 后处理过滤 → 结构化车牌号检测环节YOLO11 输出每个检测框的坐标、置信度和类别。这里我只需要类别是“license_plate”的框。预处理环节拿到框之后不能直接扔给OCR因为检测框往往贴得比较紧字符边缘可能被切掉而且图像可能偏暗、模糊、倾斜需要放大和外扩。识别环节RapidOCR 对预处理后的图像输出文本内容、置信度和字符坐标通常一次性给出整行文本。后处理环节把OCR结果里的空格、低置信字符清掉再用车牌格式校验一下输出干净结果。这四个环节看起来简单但每一个都有不少细节。尤其是“预处理”这一步很多人会忽略直接导致 OCR 准确率掉一半。下一章先说明为什么选 YOLO11 和 RapidOCR再具体讲代码。2. 工具选型解析YOLO11 和 RapidOCR 凭什么能搭2.1 YOLO11新一代轻量检测模型的取舍YOLO11 是 Ultralytics 在 YOLOv8 之后推出的系列模型保留了 YOLOv8 简洁易用的接口但内部结构做了调整。它默认是 anchor-free 检测头用了解耦分类和回归头主干网络里引入了 C3k2 模块整体计算量比同参数量上一代更省。对于车牌这种小目标YOLO11 虽然没有像专门的大模型那种夸张的上下文建模能力但通过调整输入分辨率和训练策略在几百块钱的CPU机器上也能跑稳。我选择 YOLO11nnano版作为基础原因很简单推理速度快。在GPU上单帧检测只要几毫秒CPU上几百毫秒对一个闸机场景完全够用。至于选哪个变体要根据部署环境定。如果跑在带 GPU 的边缘盒子可以考虑 yolo11s 或 yolo11m检测精度会更高如果跑在纯 CPU 的旧电脑上yolo11n 是底线。值得一提的还有“yolo11小目标增强模块”这个方向。YOLO11 默认在 80x80、40x40、20x20 三组特征图上检测车牌在画面中经常小于 32x32 像素属于小目标。如果训练时发现漏检多可以做两件事一是把输入分辨率从 640 提到 960 或 1280让小目标在特征图上变大二是在模型结构中加 P2 检测头在 160x160 特征图上检测小目标这需要改结构并重新训练。后文问题排查部分还会再说。2.2 RapidOCR好用的OCR引擎但别忽略它的CPU胃口RapidOCR 不是单个模型而是一套 OCR 推理管线先做文本检测再对文本区域做方向分类最后做文字识别。它底层跑的是 PaddleOCR 训练出来的模型转换成了 ONNX 格式所以部署时完全不依赖 PaddlePaddle 这个大框架只需要 onnxruntime。对开发者来说这意味着 pip 安装完就能跑没有版本地狱。但“太吃CPU”确实是个真实痛点尤其是用 Python 直接跑的时候。原因有三点RapidOCR 包含了多个模型串联检测模型输入是 960x960识别模型输入是动态宽高加起来计算量不小。默认使用 CPU 的 onnxruntime在多核机器上会尽量占满线程CPU 使用率飙升。如果每一帧都新建 RapidOCR 实例模型反复加载开销直接翻倍。我后面会专门讲怎么把 CPU 占用降下来这里先记住一个大原则RapidOCR 适合“低频识别”不适合对每一帧视频都无脑全图识别。车牌识别场景下先用 YOLO11 把区域裁剪出来再调用 OCR本身就是最好的降载手段。2.3 为什么不用更重的 PaddleOCR / 端到端识别很多朋友会问直接用 PaddleOCR 整图识别不行吗我在早期实验里试过确实能识别出某些车牌但当画面里同时出现广告牌、指示牌等文字时PaddleOCR 会把所有文本区域都识别出来还要额外判断哪一个是车牌逻辑复杂且效率低。更重要的是整图识别会把大量算力消耗在不相关的背景文字上在连续视频流里完全没有性价比。RapidOCR 相比 PaddleOCR 的完整框架优势就是轻、接口简单、部署快。虽然底层模型源自 PaddleOCR但通过 ONNX 格式省掉了框架依赖。当检测模型已经框出精确区域时OCR 只需处理一块小图识别速度非常快。反而没有太大必要引入整套重量级方案。3. 从零搭环境依赖、模型与目录规划3.1 安装依赖三行命令解决我项目环境是 Python 3.10操作系统用 Ubuntu 22.04。核心依赖就四个ultralytics、rapidocr-onnxruntime、opencv-python、numpy。安装命令如下pip install ultralytics rapidocr-onnxruntime opencv-python-headless numpy这里用opencv-python-headless是为了避免在服务器上引入 GUI 依赖如果是本地 Windows 环境直接装opencv-python也行。注意 rapidocr-onnxruntime 和新增的 rapidocr 包不完全一样RapidOCR 新版本做了包名调整早期项目用 old APIfrom rapidocr_onnxruntime import RapidOCR新版统一为from rapidocr import RapidOCR我代码里用旧版 API 写更通用。版本上Ultralytics 迭代很快我实际用的是 ultralytics 8.3.x 版本。如果你遇到 API 不一致优先看官方文档不要盲目升级大版本。3.2 把模型文件准备到位YOLO11 可以从 Ultralytics 官方自动下载预训练权重但我建议先准备一个针对车牌场景微调过的模型精度差异很大。如果你不想从零训练可以先下载yolo11n.pt然后用自己的车牌数据集跑几十个 epoch 微调。# 自动下载 yolo11n.pt yolo predict modelyolo11n.pt sourcetest.jpgRapidOCR 的模型会自动从模型仓库下载保存在用户目录的.rapidocr下。但如果部署环境不能联网需要手动下载 ONNX 模型放到指定目录并通过初始化参数传入。目录结构建议这样规划plate_recognition/ ├── main.py # 主程序 ├── models/ │ ├── yolo11n.pt # 检测模型 │ └── ocr/ # rapidocr模型目录 ├── data/ │ ├── test_images/ # 测试图片 │ └── videos/ # 测试视频 └── output/ └── results.json把模型和代码分开是为了切换不同模型时不用改代码也方便打包部署。实际上训练好的 YOLO 权重最好转成 ONNX 再部署可以减少依赖冲突不过我本地调试用 pt 文件更灵活。3.3 RapidOCR 初始化与模型加载RapidOCR 初始化很简单但要注意只初始化一次。我见过有人写在一个函数里每张图进来都 new 一个引擎CPU 直接被打爆识别速度反而下降。from rapidocr_onnxruntime import RapidOCR ocr_engine RapidOCR() # 如果你下载了离线模型可以用 model_path 参数指定 # ocr_engine RapidOCR(model_path./models/ocr)这段代码放在模块加载时执行一次后面所有识别复用同一个引擎。YOLO 模型也一样全局加载一次即可。4. 核心代码从视频帧到车牌号码的完整流水线4.1 用 YOLO11 快速定位车牌区域先看检测部分。加载模型后对输入图片执行推理拿到检测结果。YOLO11 的model.predict返回的结果对象里boxes包含了框坐标、置信度和类别。import cv2 from ultralytics import YOLO # 加载检测模型全局只加载一次 det_model YOLO(./models/yolo11n.pt) def detect_plate(image): # image 是 BGR 格式的 numpy 数组 results det_model.predict( sourceimage, imgsz640, conf0.25, iou0.45, verboseFalse ) boxes results[0].boxes if boxes is None or len(boxes) 0: return None plate_boxes [] for box in boxes: cls_id int(box.cls[0]) if cls_id plate_cls_id: # 你的模型里车牌类别的ID x1, y1, x2, y2 map(int, box.xyxy[0].tolist()) conf float(box.conf[0]) plate_boxes.append((x1, y1, x2, y2, conf)) # 一般取置信度最高的一个框 plate_boxes.sort(keylambda x: x[4], reverseTrue) return plate_boxes[0] if plate_boxes else None这里的imgsz640是输入到模型的图像尺寸YOLO 会把原始图缩放到 640x640。如果摄像头视角下车辆较远车牌很小建议把 imgsz 调到 960 或 1280加了小目标检测头之后效果更好。conf0.25是低阈值宁可多检几个框也别漏掉如果只想拿最高置信度的一个车牌后面排序处理。类别 ID 取决于你训练时的标签。如果用的是官方 COCO 预训练模型没有“license_plate”类别所以一定要微调数据或者直接训练一个自定义类别。我训练时把车牌统一标为唯一类别类别 ID 为 0。4.2 车牌图像预处理切图、放大、增强检测到框之后很多人直接把img[x1:x2, y1:y2]扔给 OCR结果识别率很低。原因很简单检测框通常紧贴车牌外沿字符边缘会被截断尤其首尾字符。所以预处理第一件事是外扩。def crop_plate(image, box, expand_ratio0.05): x1, y1, x2, y2, _ box w x2 - x1 h y2 - y1 ex_w int(w * expand_ratio) ex_h int(h * expand_ratio) x1 max(0, x1 - ex_w) y1 max(0, y1 - ex_h) x2 min(image.shape[1], x2 ex_w) y2 min(image.shape[0], y2 ex_h) return image[y1:y2, x1:x2]外扩比例我用的 5% 左右。外扩太大会把周围背景也带进来OCR 的文本检测可能被干扰外扩太小又解决不了截断问题。拿到外扩区域后车牌图像往往是倾斜的。我做的固定角度摄像头场景不需要复杂的透视矫正但如果你拍的是不同角度入口可以做一次矫正。简单方式是用 YOLO 检测出的框是轴向矩形如果角度大需要用文本检测或角点回归的方法获取四角坐标。这里不展开实际园区场景用轻度补正就够了。预处理还有一个通用增强步骤把裁剪区域放大到至少 2 倍再转灰度做轻度锐化。RapidOCR 对低分辨率小图特别容易识别错放大之后效果立竿见影。def preprocess_for_ocr(plate_img): # 放大2倍 h, w plate_img.shape[:2] scale 2 resized cv2.resize(plate_img, (w * scale, h * scale), interpolationcv2.INTER_CUBIC) # 转灰度 gray cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) # 锐化核 kernel np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]], dtypenp.float32) sharp cv2.filter2D(gray, -1, kernel) # 轻度对比度增强 enhanced cv2.convertScaleAbs(sharp, alpha1.2, beta0) return enhanced4.3 RapidOCR识别拿到文本和置信度预处理完的图像可以直接传给 RapidOCR。注意 RapidOCR 接受的 numpy 数组是 RGB 格式而 OpenCV 读出来是 BGR需要转换。另外RapidOCR 新版支持use_det参数但如果输入已经是车牌区域不需要再做整行文本检测可以直接跳过检测环节实际上 RapidOCR 的 API 没有直接的“跳过检测”开关即使给一个小图它依然会内部做检测。不过图像越小检测耗时越短。如果想要极致性能可以自己裁剪检测结果但会复杂很多。def recognize_plate(enhanced_img): # BGR - RGB rgb_img cv2.cvtColor(enhanced_img, cv2.COLOR_GRAY2RGB) result, elapse ocr_engine(rgb_img) if not result: return None, None, None # result 是列表每个元素是 [box, text, score] all_text total_conf 0.0 count 0 for box, text, score in result: all_text text total_conf score count 1 avg_conf total_conf / count if count else 0.0 return all_text, avg_conf, elapse这里elapse返回的是一个字典包含各阶段耗时可以用于性能分析。我实测单块车牌区域识别在 CPU 上大约 30-100ms和图像大小成正比。4.4 后处理用正则把OCR结果洗成车牌号OCR 结果经常带一些脏字符空格、竖线被识别成“1”、字母 O 和数字 0 混淆等。所以后处理是整个流水线里不能省的一步。国内常见民用牌照格式是省份汉字 字母 6位字母数字组合总7位。我用正则做校验。import re plate_pattern re.compile(r^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$) def normalize_plate(raw_text): # 去掉所有非中英文、数字字符 clean re.sub(r[^A-Z0-9\u4e00-\u9fa5], , raw_text.upper()) # 去除容易混淆的字符 clean clean.replace(O, 0).replace(I, 1).replace(Z, 2) # 如果过短或过长可能是OCR拼接错误尝试截取合适长度 if len(clean) 7 and plate_pattern.match(clean[:7]): return clean[:7] return None实际使用中不能完全依赖正则因为新能源车牌有8位省份汉字字母6位比普通多一位有些特种车牌格式也不同。我在后处理里做的是先按正则匹配匹配不通过则保留 OCR 原始文本结合置信度决定是否返回。宁可返回“未识别”也不要返回一个错号。另外RapidOCR 返回结果里带有每个字符的坐标如果 OCR 把文字拆成多段可以按 x 坐标排序后拼接这样顺序不会乱。上面代码是简单把所有文本按结果顺序拼起来如果遇到多行识别容易错乱建议按坐标排序。5. 实测性能与调优把CPU占用拉下来的实操记录5.1 不同硬件下的耗时对比我跑过一次横向对比输入是 1080p 的监控截图YOLO11n 先用 640 推理RapidOCR 只识别裁剪后的车牌区域。单帧完整流水线耗时如下硬件YOLO11检测耗时RapidOCR识别耗时总耗时Intel i5-8400 CPU约 250ms约 80ms约 330msIntel i7-12700 CPU约 120ms约 50ms约 170msRTX 3060 GPU约 8ms约 10ms约 18msCPU OpenVINO优化约 90ms约 35ms约 125ms闸机场景一般不需要高帧率单帧处理 200ms 完全可以接受。但如果要做实时视频流CPU 版本会显得比较吃力需要配合抽样检测和跟踪策略。5.2 RapidOCR 不吃CPU的四个调整前面说了 RapidOCR 太吃 CPU我实践下来有三个最有效的调优手段。第一限制 onnxruntime 线程数。ONNX Runtime 默认会使用所有物理核一旦和 YOLO 的推理并发整机 CPU 被打满。给 RapidOCR 设置单线程或双线程可以显著降低占用。from rapidocr_onnxruntime import RapidOCR # 通过 config 设置 intra_op_num_threads config { global: { intra_op_num_threads: 2, use_cuda: False, } } ocr_engine RapidOCR(paramsconfig)不同版本的参数名略有不同但核心就是限制线程数。我实际测过从默认8线程改为2线程CPU占用率能从 90% 降到 35%单次识别耗时只增加几十毫秒。第二不要每帧都调用 OCR。连续视频帧中同一辆车会在多帧画面里出现。我采用“检测帧 识别帧分离”的策略每隔5帧做一次完整识别中间只用 YOLO11 做目标存在性判断。这样 CPU 压力大幅下降车牌号输出频率也足够。第三用 OpenVINO 作为 onnxruntime 的执行后端。如果你部署在 Intel CPU 上可以安装openvino并让 onnxruntime 使用 OpenVINO EP推理速度能提升 40% 以上。具体做法是安装onnxruntime-openvino包然后把 provider 指定为[OpenVINOExecutionProvider]。第四裁剪识别区域时限制 OCR 输入尺寸。车牌区域识别不需要 960x960 大图我在预处理阶段已经将字符区域缩放到合适大小而不会直接对整帧调用 OCR这本身就是最大的省资源手段。5.3 处理视频流时的稳定化技巧车牌识别最怕“抖动”——相邻几帧结果一会儿对一会儿错。我加了一个简单状态机连续 3 帧识别到同一个车牌号才认为这是最终结果之后保持该结果一段时间避免重复输出。class PlateTracker: def __init__(self, window3, cooldown5): self.window window self.cooldown cooldown self.history [] self.current_plate None self.hold_count 0 def update(self, plate): if plate is None: self.history.clear() return self.current_plate self.history.append(plate) if len(self.history) self.window: self.history.pop(0) if len(self.history) self.window and len(set(self.history)) 1: self.current_plate plate self.hold_count self.cooldown elif self.hold_count 0: self.hold_count - 1 else: self.current_plate None return self.current_plate这个代码简单实用。它可以在入口视频里过滤单帧误识别也能避免一辆车在画面内逗留时反复输出同一条记录。6. 常见问题与排查技巧实录6.1 YOLO11 检测不到小目标车牌怎么办最典型的场景车道距离摄像头远车牌在整帧画面里只有 20x20 像素。YOLO11 在 640 输入下可能完全把它漏掉。第一步把 imgsz 提高到 960 或 1280输入分辨率越大小目标信息损失越少。第二步用训练集统计车牌的真实大小。如果大量框宽高小于 32 像素需要在训练时启用基于 Shape 的采样策略让模型多看到小目标。第三步结构上做小目标增强常见做法是添加 P2 检测头让模型在 160x160 特征图上做预测。Ultralytics 的 YOLO11 结构文件里可以自定义但需要重新训练。第四步如果项目允许调整摄像头安装角度让车牌占画面高度超过 40 像素这往往比改模型更省事。另外注意 GPU 显存imgsz 从 640 提到 1280FLOPs 会涨大约 4 倍低端边缘盒子可能带不动。6.2 RapidOCR 把汉字识别错RapidOCR 虽然支持中文识别但对低分辨率、模糊、倾斜的汉字误识别率会明显上升。最常见的是把“京”认成“示”或“京”多了一横把“鲁”认成“鱼”。解决步骤把裁剪图像放大 2 到 3 倍用 INTER_CUBIC 插值这是最有效的办法。车牌图像做锐化但锐化过度会产生白边反而干扰字符。我用的是小的 3x3 拉普拉斯核alpha 控制在 1.2。如果识别结果置信度低于 0.5宁愿丢弃不要硬拼。适当用多模型融合比如同时跑 RapidOCR 和一个轻量字符级分类器对单字符做投票。这个方案适合准确率要求极高的场景但复杂度也更高。我实际测试中只做放大和锐化汉字准确率能从 72% 提升到 90% 左右。6.3 并发场景下 CPU 被打满我接到过一个需求四个道闸同时过车同一个服务器上要跑四个视频流识别。如果每个流都创建独立引擎并并发跑CPU 立刻爆炸。我的做法是全局只创建一套 YOLO OCR 引擎加一个请求队列所有摄像头共用一个推理池。识别请求排队处理利用 deque 批量调度。单个引擎实例是线程安全的吗在 Ultralytics 和 RapidOCR 的官方文档上没有明确保证所以我在实现时加了 threading.Lock 保护牺牲一点并发度换取稳定。另一个办法是分开部署检测用 CPU 或 GPUOCR 单独放在另一台 CPU 机器上通过 HTTP 接口调用。实际项目里这种微服务改造收益明显思路也清晰。6.4 版本兼容与数据格式坑YOLO11 的model.predict返回的results里boxes.xyxy是 Tensor 对象不是普通 list直接做切片或比较时容易碰到类型问题我统一用.tolist()转成 list。另外results的names属性是一个从类别 ID 到名称的字典判断类别时不要写死 ID最好动态读取。RapidOCR 的返回结果格式也有变化。旧版result是 list新版可能是 dict按官方文档为准。我在代码里加了防御性判断如果是 None 就直接返回空避免后续解包报错。还有一个小坑RapidOCR 的elapse返回中包含了每个阶段耗时但如果你只看总耗时会高估实际处理速度因为模型初始化耗时会混进去。建议单独计时推理部分。7. 落地扩展从单张图片到实时通道7.1 接入 RTSP 视频流当项目要处理实时视频流时不能每帧都执行完整流水线。我用 OpenCV 的cv2.VideoCapture或流媒体框架拉流然后按帧率抽帧。最稳妥做法是开一个独立线程拉流一个线程做检测中间用队列解耦。拉流线程只负责最新的帧处理线程不追旧帧保证延迟稳定。import cv2 import threading import queue frame_queue queue.Queue(maxsize2) def capture_loop(stream_url): cap cv2.VideoCapture(stream_url) while True: ret, frame cap.read() if not ret: break if frame_queue.qsize() 2: frame_queue.put(frame) def process_loop(): while True: frame frame_queue.get() run_pipeline(frame)队列 maxsize 设为 2是为了防止处理不及导致内存膨胀。实时性优先于完整性。7.2 输出标准化 JSON 与 Webhook车牌识别最终要对接业务系统。我设计的输出结构如下{ plate: 京A12345, confidence: 0.93, timestamp: 2025-06-18T15:04:2208:00, image_id: 20250618-150422-0001, box: [124, 566, 340, 606] }用 Webhook 方式推送或者直接写 MQTT 消息业务端订阅即可。这里建议在输出前做一次去重用 image_id 加时间戳判断避免重复入库。我经常看到有人只写文件不做消息通知结果系统集成时还要再写一堆适配代码。7.3 模型裁剪与量化如果想进一步降低CPU负载可以把 YOLO11 和 RapidOCR 的 ONNX 模型做 INT8 量化。onnxruntime 支持 PTQ 量化我试过把 YOLO11 的 ONNX 模型量化后体积缩小 50%速度提升约 1.5 倍准确率几乎没有下降。RapidOCR 本身的 ONNX 模型也可以量化但要小心文本检测部分的数值敏感度建议量化后做回归测试。7.4 一个屡试不爽的小技巧识别前放大2倍最后分享一个我每次都会用的技巧无论检测到的车牌在画面里多大裁剪后先放大 2 倍再给 OCR这个操作几乎不增加多少耗时但能把准确率提升一截。原因很简单RapidOCR 的识别模型是在分辨率较高的训练数据上训练的低分辨率输入会造成特征细节丢失。这个技巧我也用在了其他 OCR 项目里值得放在工具箱里。车牌识别这个应用看起来是一个“小功能”真正跑起来之后你会碰到检测、跟踪、性能、并发、脏数据等一系列问题。能用 YOLO11 和 RapidOCR 把这条路走通之后的很多视觉识别项目都可以复用这套框架。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →