资讯动态

车牌识别实战:YOLOv5+PPOCR两段式模型的生产级部署与调优

发布时间:2026/10/18 14:56:37 来源:尧图企业网站定制
前阵子在一个模拟停车场项目中做车牌识别接入试了一圈技术路线最后是YOLOv5抓车牌位置、PPOCR抠文字这套组合拳跑通了业务流。今天不聊高深理论直接讲怎么把它从实验代码变成能扛业务的接口以及哪些环节会坑、哪些参数值得调。先看一个直观的例子一张倾斜角度不小、环境光线偏暗的车辆尾部照片YOLOv5的检测框稳稳扣在车牌四周PPOCR顺着字符区域把文字一行行抠出来最终输出一条带置信度的结构化记录。这就是整条链路最朴素也最实际的样子。这篇文章适合谁看一是准备做车牌识别但还在选型的人二是已经跑通模型 Demo、但不知道怎么接进业务系统的开发者三是对 OCR 服务化部署、性能调优有需求的一线工程师。我会把方案选型的账、流程设计、踩坑排障、生产部署和代码骨架都过一遍尽量说人话能直接拿去用。1. 选型背后的账为什么是YOLOv5加PPOCR而不是端到端全家桶1.1 两段式与端到端的核心差异做车牌识别市面上比较常见的路子其实可以分成两类。一类是端到端模型输入一张车辆图直接输出车牌号码。另一类就是标题里说的两段式先用目标检测模型把车牌的位置找出来再交给 OCR 模型去识别字符。端到端的方案表面上看很省事模型只有一个推理链路短理论上延迟更低。但实际用起来有几个绕不开的问题。第一个是排障困难当识别结果出错的时候你很难知道到底是在定位环节丢的还是在字符识别环节丢的。第二个是数据成本高端到端模型对训练数据的多样性要求非常苛刻车牌角度、光照、遮挡稍微一变效果就掉得厉害。第三个是扩展性差如果你今天想从“识别车牌号码”扩展到“识别车牌类型”或者想接入其他类型的卡证识别端到端模型基本要重新训练。两段式的思路正好相反它把问题拆成两个独立环节每个环节都可以单独优化、单独替换、单独做数据积累。这也符合业务系统里一个很朴素的工程原则哪一环出了问题就只改哪一环不要把所有东西都搅在一起。1.2 我实测过的几个方案对比在确定这套组合拳之前我实际上手对比过几种路线测试数据是基于一个模拟场景的 2000 张标注样本做的只作为参考。方案定位稳定性字符识别正确率训练数据需求可维护性端到端字符识别模型一般中等复杂场景掉点严重高需要大量整图标注低问题难定位检测模型轻量OCR高较高中等两个模型独立积累数据高各环节可单独替换自训练字符分类器中低受字符切分影响大高需要精细切分标注低切分错误会累积我印象最深的一点是端到端模型在白天顺光场景下表现还不错但到了傍晚逆光、车牌略微倾斜的时候错误率上升非常明显。而两段式的好处在于YOLOv5 负责把车牌区域从复杂背景中“抠”出来PPOCR 面对的输入已经是一个相对干净的局部图像字符识别的压力小了很多。1.3 为什么是这两兄弟而不是别的YOLOv5 选中它不是因为它的 mAP 数据一定比别的检测模型高多少而是因为它在这个场景里特别合适。车牌检测本质上是一个小目标检测任务一辆车在画面里可能只占三分之一车牌在整张图里可能只有百分之几的面积。YOLOv5 的多尺度检测头对小尺寸目标相对友好而且它有一套成熟的训练、导出、部署工具链社区资料多有问题也容易搜到答案。PPOCR 选中它则是因为它在中文场景里确实能打。车牌字符包含省份简称、字母、数字里面有大量容易混淆的字符比如“0 和 O”“1 和 I”“B 和 8”。PPOCR 的识别模型对这些字符的区分能力在我实测里要比通用 OCR 工具好一些。再加上它自带方向分类车牌如果是倾斜甚至旋转的PPOCR 会先把文本方向归正再识别这一步对车牌场景帮助很大。两段式还有一个隐性好处就是可以分别控制两个模型的置信度阈值。检测模型只负责“在哪”识别模型只负责“是什么”两个环节的置信度分开看出问题的时候能一眼定位。2. 从图像入口到结构化结果四个环节把模型焊进业务流2.1 图像输入层的统一抽象业务系统和模型之间需要一个稳定的接口否则摄像头来路图、业务方传 JPG、历史库里拉视频帧每种输入写一套处理逻辑迟早会乱。我建议在入口层做一个统一抽象所有图像都先转成标准格式再做后续处理。具体做法是定义这样一个函数import cv2 import numpy as np def load_image(source): if isinstance(source, str): img cv2.imread(source) elif isinstance(source, bytes): img cv2.imdecode(np.frombuffer(source, np.uint8), cv2.IMREAD_COLOR) elif isinstance(source, np.ndarray): img source.copy() else: raise ValueError(不支持的图像输入类型) if img is None: raise ValueError(图像读取失败) return img这个抽象层的价值在于不管上游是 Python 代码直接调用、HTTP 请求传二进制还是从视频流里截帧到最后都统一成一个 BGR 数组。业务逻辑不会再因为图像来源不同而写出散落的兼容代码。图像进去之后还有一步容易被忽略的预处理宽高判断。如果画面里车牌本身就很小直接整图丢给检测模型很容易漏检。我一般会保留原始分辨率同时根据检测模型输入尺寸做一次按比例缩放记录缩放系数后面把检测框坐标映射回原图时要用。2.2 车牌区域检测的关键参数检测环节YOLOv5 推理之后拿到的是一堆候选框每个框有类别、置信度、坐标。要做三件事。第一是置信度过滤。这个阈值一般不能拍脑袋定。我最初设到 0.5结果漏了不少小目标。后来把阈值降到 0.3虽然多了一些误检但可以在后面的字符识别环节用置信度二次过滤掉。检测环节宁可多召回也不要早早就把正确车牌丢掉。第二是 NMS非极大值抑制。同一个车牌可能被多个框命中NMS 会合并重叠框。这个步骤 YOLOv5 自己的后处理里已经带了但要注意 NMS IoU 阈值。默认值通常没什么问题不过如果画面上出现多个车牌挨得比较近的情况IoU 阈值可以稍微调低避免把两个车牌的框合并成一个。第三是坐标处理。检测框的坐标是在模型输入尺寸下的坐标必须映射回原图。同时要对框做一次越界裁剪确保不会出现负数坐标或者超出图像宽高的值否则裁剪时报错非常折磨人。裁剪出来的车牌区域也需要预处理。我会统一缩放到一个固定高度比如 48 像素宽度按原比例缩放然后适度做一次灰度化。不过这里有个细节PPOCR 的识别模型默认接受彩色图灰度化相当于把颜色信息丢了对于颜色相关性强的场景反而不利。所以我一般保留彩色图交给 PPOCR只有做传统图像增强的备用分支时才用灰度图。2.3 字符识别和车牌规则校验PPOCR 拿到裁剪后的车牌图内部会先做文本检测找到字符区域再做文本识别输出文字和置信度。对于车牌这种固定版式的场景可以跳过它内部的文本检测直接把整张裁剪图作为文本区域传入识别。这样能省掉一部分计算量效率更高。识别结果是一串字符可能带空格也可能带乱码。到了这一层业务规则就该上场了。我总结了一个固定的校验流程首先清理字符去掉空格和特殊符号。然后按车牌正则规则过滤。中国大陆的普通蓝牌是 7 位新能源车牌是 8 位第一位是省份汉字简称第二位通常是字母。可以写一个简单的规则函数import re def validate_plate(text): # 普通蓝牌/黄牌省份简称 字母 5位数字字母 pattern_normal re.compile(r^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5}$) # 新能源绿牌省份简称 字母 6位数字字母比普通牌多一位 pattern_ev re.compile(r^[\u4e00-\u9fa5][A-Z][A-Z0-9]{6}$) if pattern_normal.match(text): return normal, 1.0 if pattern_ev.match(text): return new_energy, 1.0 return None, 0.0正则校验的作用不是“挡掉所有错误”而是给后续流程一个早期的判断依据。如果识别结果连车牌格式都不满足说明要么是检测框选歪了要么是识别置信度太低了。此时不应该硬着头皮把这个结果写进业务日志而是应该走重试或人工兜底流程。2.4 结果结构与失败重试机制整个流水线的输出应该是一个结构化对象而不只是字符串。我通常会定义这样的数据结构dataclass class PlateResult: plate: str plate_type: str det_conf: float ocr_conf: float bbox: list image_id: str status: str # success / low_conf / no_plate / invalid把检测置信度和 OCR 置信度分开存后面做监控和调优非常有用。举个实际例子某天业务方反馈“有车识别不出来”如果你只存了最终号码排查时根本不知道是检测丢了还是识别丢了。两个置信度分开记录就能直接筛选出是 det_conf 低还是 ocr_conf 低定位问题快很多。失败重试也要设计好。针对低置信度的情况我的策略是自动重试一次但这次会把裁剪区域做一些轻微旋转校正再做一次识别。如果连续两次都失败就不再盲目重试而是转入人工兜底也就是把这帧图、检测框、识别结果截图归档留给后续人工查看。这看起来多花了一点存储成本但实际省掉了大量排查时间。业务流里有“自动失败”和“静默失败”两种状态静默失败尤其要人命因为没有报错、没有日志只表现为某些车辆计数不对。所以说结果结构化、状态显式化这套设计从第一天就该做。3. 翻车扎心三连漏检、歪牌、慢推理的逐项排查3.1 小目标车牌漏检的处理链路漏检是车牌识别接入业务流之后第一个会遇到的问题。最典型的场景是摄像机架得比较高画面里同时出现多辆车远处的车牌在图像里只有大概 20×10 像素YOLOv5 直接没检出来。排查思路不能上来就调模型而是先复现。我把出问题的帧单独抽出来用脚本把检测框和置信度全部可视化。如果车牌位置根本没有框就是检测环节丢了。这时候有几个手段可以依次试第一个手段是切图。原始图太大车牌太小直接把整图缩放给模型吃小目标的信息在缩放过程中损失很厉害。我做的做法是把图像切成 2×2 的重叠块每块放大后再分别检测最后把检测框坐标映射回原图再做一次合并。这个手段效果立竿见影代价是推理耗时上升。所以我会让切图逻辑只在“小目标检测模式”下启用正常场景不打开。第二个手段是多尺度推理。把图像按原始尺寸、0.5 倍缩小、1.5 倍放大分别推理把结果合并。这本质上就是检测里的 TTA测试时增强工程上可以用一个循环解决但耗时也要相应增加。第三个手段才是重新训练或者微调。如果小目标漏检是系统性的那么切图和 TTA 是缓解手段根本解法是补充标注数据专门标注一批小尺寸车牌样本提高模型在小目标上的表现。这里要说一句很多人跳过前两步直接重新训练数据没加多少效果自然也不会好。3.2 倾斜、模糊、夜间三种硬场景的修法歪牌可以说是车牌识别里最普遍的失败来源。车牌稍微倾斜检测框或许还能找到位置但字符识别就会出问题因为 PPOCR 的识别模型对水平文本最擅长倾斜厉害的时候字符之间互相干扰。我的修复链路是这样的。当 OCR 置信度偏低时先判断车牌框的倾斜角度。简单做法是拿检测框的四个角点坐标或者对裁剪图求最小外接矩形算出一个角度然后做仿射变换把车牌摆正再交给 PPOCR 识别。这一步值得单独写成一个后处理函数因为业务流里最常见的翻车点就出在这儿。模糊问题也一样。车辆行驶中抓拍运动模糊会造成字符边缘糊在一起。我会在裁剪后对图像做一次轻度锐化常用的方法是拉普拉斯锐化叠加原图。要注意锐化强度别太大否则会产生白边干扰识别。夜间的核心问题是噪点先做一次降噪再做识别比直接硬识别效果好得多。我用的是高斯模糊配合亮度归一化让字符和背景的对比度更明显。值得强调的是这些图像增强手段不能一股脑全用。我的经验是做一个“由浅入深”的尝试链先原图识别如果置信度不够再摆正摆正后还不行再锐化或降噪。每一步记录置信度的变化既方便排查也方便以后针对特定场景做策略配置。3.3 慢推理与高并发下的延迟瓶颈把模型接进业务流之后最让人头疼的是推理速度。YOLOv5 的模型本身不算重但如果在 CPU 上跑每帧推理耗时可能到几百毫秒。PPOCR 虽然轻量完整流程加起来也会有明显延迟。业务流里如果每辆车要等一整条链路跑完才放行那体验会非常差。我踩过的坑是给每个请求都跑一次完整检测的完整链导致并发一高服务直接卡死。后来做了两层优化。第一层是批量推理。检测模型可以一次吃多张图把多个请求的图攒成一个 batch明显提高 GPU 利用率。我实现了一个简单的请求合并器最多攒 8 张图或者等 50 毫秒凑齐就一起推理。这个优化在实际模拟压测中把吞吐量提升了接近一倍。第二层是条件触发。不是每个业务请求都需要全精度的识别。比如闸机场景车辆停在杆前车牌一般比较大、比较正完全可以用更轻量的识别策略而高速抓拍场景车辆速度快、角度随机才需要更重的处理策略。我把两条处理链路拆开根据业务场景参数选择耗时和准确率得到更好的平衡。4. 生产环境部署服务化、并发压测和模型减肥4.1 用轻量服务封装两个模型模型直接写死在业务进程里不是不行但一旦模型更新或者需要单独扩容就要恢复发布业务服务很痛苦。我建议用独立服务把推理能力封装起来开放 HTTP 接口。做法也不复杂用 FastAPI 写一个推理服务内部加载一次模型对外提供两个接口检测接口和识别接口。from fastapi import FastAPI, File, UploadFile import numpy as np import cv2 app FastAPI() det_model load_yolov5_model() ocr_model load_ppocr_model() app.post(/detect) async def detect(file: UploadFile File(...)): img_bytes await file.read() img cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) boxes, confs det_model.predict(img) return {boxes: boxes.tolist(), confs: confs.tolist()} app.post(/recognize) async def recognize(file: UploadFile File(...)): img_bytes await file.read() img cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) text, conf ocr_model.recognize(img) return {text: text, conf: conf}这样做的好处有几点模型只在服务启动时加载一次不会因为频繁请求而反复加载业务系统只需要关心 HTTP 调用不需要关心模型内部逻辑两个模型可以分别部署在不同机器上按需扩容。对于低并发场景一个服务同时加载两个模型也没问题只要注意内存占用即可。4.2 CPU/GPU 推理配置和模型导出部署环境不同推理配置差别很大。如果只有 CPU我建议把 YOLOv5 导出成 ONNX再用 OpenVINO 的推理后端加速这个组合在酷睿级别的 CPU 上通常能把单帧推理压到几十毫秒。PPOCR 自身提供 Paddle Inference 的 CPU 优化实测也比单纯用 Python 循环快不少。如果有 GPU我建议优先考虑把 YOLOv5 导出成 TensorRT 引擎。同一模型在 TensorRT 下的推理速度一般能比原生 PyTorch 快两到三倍而且显存占用更小。导出时要注意 batch size 的设置最好和实际部署时用的批大小一致。PPOCR 也提供 TensorRT 支持不过配置稍繁琐第一次跑通之后后面就稳定了。我整理了一张简易的部署选项表供参考场景硬件推荐配置备注低并发、CPU 强8 核 CPUONNX OpenVINO内存占用控制好高并发、GPU 强单张普通 GPUTensorRT 引擎充分压榨硬件性能中间态4 核 CPUPaddle Inference简单稳定调参少4.3 监控指标与失败样本回放部署完模型只是开始更重要的是一套监控机制。我平时最关注的指标不是 mAP而是业务侧的几个数字平均识别延迟、置信度分布、失败率、重试率。置信度分布尤其有价值如果大量结果的置信度集中在 0.95 以上那说明主要流量比较简单如果置信度分布很分散说明场景复杂度高模型可能在硬撑。失败样本回放这一招很管用。我会把每次识别失败或者低置信度的图像和检测框信息存下来定期做一次人工筛选找出系统性的失败模式。比如连续一段时间发现某一类角度下的车牌识别不出来就专门针对这个角度做数据增强或补充标注。这套机制比盲目追求更高的模型指标更贴近业务目标。监控日志的格式也要注意。不要只记录“识别失败”要记录完整上下文图像 ID、检测置信度、OCR 置信度、原始识别文本、调用方场景、图像尺寸、耗时。这样排查问题时所有信息都在一条日志里不用来回翻别的系统。5. 代码骨架与边界处理能动手别只看5.1 一段完整的流水线骨架下面是一段简化的主流程代码可以直接照着改。它把前面说的检测、裁剪、识别、校验、结果输出都串起来了同时也包含了置信度分层和失败标识。class PlateRecognizer: def __init__(self): self.det_model load_yolov5_model() self.ocr_model load_ppocr_model() def process_image(self, image_id, img): # 1. 检测车牌区域 results self.det_model.detect(img) if not results: return PlateResult(, , 0.0, 0.0, [], image_id, no_plate) best max(results, keylambda x: x[conf]) box best[box] # [x1, y1, x2, y2] det_conf best[conf] # 2. 裁剪车牌区域 x1, y1, x2, y2 [max(0, int(v)) for v in box] plate_img img[y1:y2, x1:x2] # 3. 识别字符 text, ocr_conf self.ocr_model.recognize(plate_img) # 4. 规则校验 plate_type, valid validate_plate(text) status success if valid and ocr_conf 0.6 else invalid return PlateResult(text, plate_type, det_conf, ocr_conf, box, image_id, status)这段骨架的问题在于把所有步骤都放在一个进程里但作为一个可运行的最小实现它的结构是清晰的。在实际工程里我会再拆出图像读取、预处理、后处理规则每个模块单独写测试用例。这样无论是换检测模型还是换 OCR 模型都不会牵一发动全身。5.2 多车、无牌、遮挡等边界情况业务流里不可能只有一个车牌。多车同框时检测模型可能输出多个框这时要决定是只取置信度最高的一个还是全部返回。我的建议是全部返回并且让调用方决定用哪个。因为在停车场场景里可能需要同时识别多个车位里的车牌只取一个就丢信息了。无牌车是另一个高频情况。业务系统必须能区分“图像里没有车牌”和“有车牌但识别失败”。这个要依赖 status 字段来区分。最简单可靠的策略是检测阶段如果连候选框都没有直接返回 no_plate有框但识别校验失败返回 invalid。这两者在业务层面的含义完全不同前者可能是车辆本身没牌后者是技术问题需要人工排查。遮挡情况就不细说了核心原则是不要硬识别。当车牌被遮挡一大半识别模型给出的字符串虽然看起来格式合法但其实是瞎猜。此时置信度通常会偏低所以要设置一个合理的置信度阈值宁可漏一点也不要给业务方错误的号码。错误号码比“没识别到”的危害大得多。5.3 从代码骨架到业务接入的最后一公里代码跑通只是第一步真正接入业务系统时还需要做几件额外的事情。第一是超时控制模型服务如果长时间无响应业务不能傻等。我给 HTTP 客户端设置了一个 2 秒超时超过就走降级流程。第二是幂等性如果一个图像被重复提交系统要能判断出来避免重复计费或者重复记录。第三是链路追踪给每次识别请求一个唯一的 request_id从头到尾透传到模型服务的日志里方便对账。最后一公里的另外一个注意点是模型版本的灰度发布。改进了模型之后不要直接全部替换。我会先让新模型在 10% 的流量上跑一段时间对比新旧模型的识别结果差异。如果新模型的低置信度比例明显上升说明它在当前业务场景里还没有旧模型稳就回滚。这个流程虽然土但极其有效能避免一次“看起来更强但实际不匹配”的模型发布事故。回到开头那句话这套 YOLOv5 加 PPOCR 的组合拳真正跑通业务之后我最强烈的感受是模型本身的精度当然重要但更关键的是工程结构能不能让你快速定位问题、快速调整策略、快速回滚。把这套循环跑顺了车牌识别从“一个能跑的 Demo”到“一个谁都能维护的业务模块”其实没有想象中那么远。最后分享一个小技巧在识别结果落库时顺手把检测框和识别文本叠加画到图上存档作为可视化日志下次任何人说“这里识别错了”你直接看图片就能明白发生了什么比查十行日志都快。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑