ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

两段式车牌识别实战:YOLO检测与PaddleOCR识别全流程解析

两段式车牌识别实战:YOLO检测与PaddleOCR识别全流程解析

做车牌识别这件事,我在不少项目里踩过坑。最初的想法特别朴素——拿到一张停车场出入口的抓拍图,直接丢给OCR接口,幻想把整个画面里的字符都读出来。结果自然是灾难性的:广告牌、车身贴纸、远处灯箱上的字全被当成文字,车牌反而没读对。后来老老实实拆成两段流程——先用YOLO把车牌位置框出来并截取,再把裁剪后的车牌图喂给PaddleOCR做识别,同一批测试图的识别准确率才真正到了能用的水平。

这篇内容是对这个项目链路的完整复盘:车牌数据怎么准备、YOLO检测模型怎么训练、裁剪前后有哪些必须处理的图像细节、PaddleOCR识别参数怎么调、以及最终如何串成一条能跑实测的流水线。适合刚接触目标检测和OCR、想把这两样工具真正落地到实际场景中的开发者参考。

1. 为什么说“检测+OCR”两段式是车牌识别的最稳结构

很多初学者会想:既然PaddleOCR本身也带文本检测能力,那直接把整张图丢给它不就行了?我在实际对比后发现,这种“一步到位”的思路用在车牌场景上,效果很不稳定。

整图识别的主要问题有三个:第一,车牌在整幅画面中所占像素比例通常很低。停车场道闸画面里,整个视野可能有七八米宽,而车牌宽度只有几十厘米,算下来目标很小,通用OCR的文本检测器对这样的“小文字块”经常召回不全。第二,场景里的干扰文字太多,车身广告、路牌、商铺LED屏、警示标语都可能被检测出来,我得花大量精力去筛选“哪个才是车牌”。第三,车牌字符有极强的格式约束——省份汉字、发牌机关字母、若干位数字字母组合——通用OCR模型并不知道这种约束,它只是把整幅图里所有文字都翻译一遍,没有任何优先级。

我对比过三种常见方案,最后选了检测+识别两段式:

方案车牌定位能力识别准确性训练/部署成本可维护性
整图直接OCR弱,受场景文字干扰大一般,背景噪声会拉低结果最低,无需自己训练差,调参空间小
端到端专用车牌识别模型中,需自行训练完整pipeline中高,但数据要求高高,检测与识别耦合,难调试差,某个环节出问题很难定位
YOLO检测裁剪 + PaddleOCR识别强,YOLO专门负责锁定目标高,OCR只面对干净小图中,两个模型可分别迭代好,任何一侧性能掉了都能单独优化

实际体验下来,两段式最大的好处是“错可定位”。如果最终识别结果不对,我可以先看YOLO有没有把车牌框准——框都没框准,问题在检测;框准了但识别乱码,问题在OCR。这种拆解能力在项目调试阶段非常珍贵,工作量能省一半以上。

另一个容易被忽略的点是部署稳定性。YOLO检测模型和PaddleOCR识别模型都可以单独替换升级。比如我的视频流里出现了夜间过曝导致YOLO漏检的情况,我可以只重训检测模型;如果发现裁剪图里的字符总被误读,我只调OCR侧即可。两个模型之间用“裁剪后的图片文件/内存图像”解耦,边界干净,联调成本很低。

2. 车牌数据集准备:合成数据与真实场景的混合策略

训练YOLO检测模型首先要解决数据问题。车牌检测虽然是单类别任务,但数据好坏直接决定模型能不能扛住真实场景的光线、角度、遮挡。

2.1 数据来源:先别急着“全网爬图”

我见过不少项目组的第一反应是去网上搜集大量停车场抓拍图,结果整理标注耗时两周,且版权不明、图像良莠不齐。更合理的路径是“合成数据打底+真实数据校正”的组合策略。

合成数据的核心思路是用代码批量生成逼真的车牌图。底层逻辑并不复杂:在纯色底板上绘制车牌字符,再通过仿射变换、添加噪声、叠加到不同背景上。这里给出一个简化版的可运行思路:

import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont # 生成一张普通蓝牌字符画:底图+文字 def draw_plate_text(text: str, bg_color=(12, 48, 160), text_color=(255, 255, 255)): img = Image.new("RGB", (440, 140), bg_color) draw = ImageDraw.Draw(img) font = ImageFont.truetype("simhei.ttf", 80) # 需要准备中文字体文件 draw.text((30, 20), text, fill=text_color, font=font) return np.array(img) def random_background(bg_path: str, size=(640, 480)): bg = cv2.imread(bg_path) bg = cv2.resize(bg, size) return bg def compose_plate(plate_img: np.ndarray, bg: np.ndarray): # 对车牌梯形变形后贴到背景随机位置 h, w = plate_img.shape[:2] pts1 = np.float32([[0, 0], [w, 0], [0, h], [w, h]]) pts2 = np.float32([ [np.random.randint(-10, 10), np.random.randint(-5, 5)], [w + np.random.randint(-10, 10), np.random.randint(-5, 5)], [np.random.randint(-5, 5), h + np.random.randint(-5, 5)], [w + np.random.randint(-5, 5), h + np.random.randint(-5, 5)], ]) matrix = cv2.getPerspectiveTransform(pts1, pts2) warped = cv2.warpPerspective(plate_img, matrix, (w, h)) # 随机贴到背景某处 x = np.random.randint(0, bg.shape[1] - w) y = np.random.randint(0, bg.shape[0] - h) bg[y:y+h, x:x+w] = warped return bg

合成数据时要注意饱和度、对比度和光照变化的随机性。不敢说越多越好,但至少要让模型看过“各种姿势”的车牌:左右倾斜、上下俯仰、亮到发白、暗到发糊、带泥点、带反光、遮挡一部分。这些都能通过代码模拟,而且可以完全自动化生成标注框,不需要人工标注。

2.2 真实数据的标注规范

合成数据虽然高效,但它的“背景分布”和真实场景始终有差距。真实道路、停车场、园区出入口的抓拍图,往往包含合成数据难以模拟的复杂纹理、天气条件和镜头畸变。所以我仍然会采集一批真实画面,做人工标注。

标注时我给自己定了几条规则:

  • 标注框必须把整个车牌包裹进去,不要只框字符。车牌边缘的白色边框、铆钉在检测阶段是有用的上下文,反而能让模型更好地定位。
  • 框体比车牌外缘外扩2到5个像素,避免边缘被裁切。
  • 对倾斜车牌,标注框取车牌外接水平矩形,不必刻意旋转标注。
  • 单张图中存在多辆车时,每个可见车牌都要标注,哪怕它很小。

工具方面,个人项目用开源的标注软件就足够。导出格式选择YOLO格式的txt文件,每行四个归一化数值:class_id center_x center_y width height。

数据量方面我给一个经验值:真实标注图片800到1200张,合成图片3000到5000张,混合使用,效果已经能支撑后续训练和测试。类别暂时只有一个plate,不建议一开始就区分蓝牌、绿牌、黄牌,把“车牌检测”这一个任务做扎实更实在。

2.3 训练集/验证集划分

划分训练集、验证集时,把同一辆车不同角度的图像尽量放到同一边,避免验证集和训练集太像。这个细节常被忽略,如果同集团采集的图像交替出现在两端,验证指标的参考价值会大打折扣。

我习惯按“拍摄来源”分组而非逐张随机划分——比如某一路口的图片要么全放进训练集,要么全放进验证集。这样才能真实反映模型在没见过的路口、没见过的车上的表现。

3. YOLO车牌检测模型的训练配置与落地经验

数据准备好之后,训练本身反而是流程里最“常规”的环节。YOLO系列模型经过这些年的迭代,开箱即用的程度已经很高。车牌检测这个任务只有一类目标,不需要把网络规模堆得太大。

3.1 网络选型:YOLOv8n还是YOLOv8s

我的建议是,除非部署设备性能极弱或帧率要求特别高,否则优先选YOLOv8s。YOLOv8n虽然模型小、推理快,但车牌属于小目标,在小模型上的召回率会比较吃力,尤其在车牌距离远、分辨率低的视频流里。YOLOv8s在精度和速度之间最均衡。

实际训练命令也很标准:

yolo detect train data=plate.yaml model=yolov8s.pt epochs=150 imgsz=640 batch=16 device=0

plate.yaml的内容大致如下:

train: datasets/plate/train val: datasets/plate/val nc: 1 names: ['plate']

关键参数我解释一下:

  • imgsz=640是默认选择。分辨率再高对车牌定位有帮助,但显存占用和训练时间都会显著增加。如果最终部署时输入分辨率较高,可以保持一致。
  • epochs=150通常够用。配合早停机制,验证集指标连续多轮不涨就提前结束。
  • batch根据显存调节,训练时峰值显存不足就把batch减半。

3.2 训练日志怎么看

训练过程中我主要盯三个指标:

  • box_loss、cls_loss、dfl_loss:三类损失稳步下降,说明模型在收敛。
  • mAP50:验证集上IoU阈值0.5时的平均精度,车牌检测任务看它最直观。我一般要求最终mAP50达到0.95以上,低于0.9基本无法落地。
  • mAP50-95:综合精度指标,略低也没关系,不必为了这个指标强行堆算力。

训练完成后,测试阶段我会单独抽一批“极端场景”图片做人工目检:夜间、强逆光、雨雪天气、车牌歪斜严重、车牌被泥污遮挡。这些图哪怕不在训练集里,模型表现也能初步反映泛化能力。

3.3 训练中的三个典型坑

第一个坑是“把车灯/车标/广告灯箱当车牌”。这通常是数据里正样本背景不够多样导致的。对策是合成数据阶段多换背景,真实数据里刻意收集一些带着车灯、车标特写的干扰样本。

第二个坑是“强光下漏检”。大晴天中午,金属车牌反光会使颜色严重过曝,YOLO学过的大部分蓝牌特征失效。我的处理办法是数据增强里加大亮度变换幅度,同时采集真实中午时段的抓拍图补充训练。

第三个坑是“小目标漏检”。道闸摄像机拍摄的画面通常车近牌大,但如果用的是高位全景监控,车牌可能只有几十个像素。此时可以把输入分辨率提到imgsz=960,但更推荐的做法是部署时对画面下半部分区域单独放大检测,而不是全图盲目放大。

训练完成后,导出部署格式可以按需选择,我用得最多的是ONNX和TensorRT engine。ultralytics框架导出起来很顺手,但注意导出时要固定输入尺寸,并关闭多batch,以免部署环境出现尺寸动态变化的性能损耗。

4. 裁剪车牌前后的图像处理:识别率的分水岭

很多教程会在YOLO出框后直接裁图丢给OCR,然后发现识别率死活上不去。问题多半出在“裁剪”这个环节——它看似简单,但细节极为影响结果。

4.1 检测框坐标的还原与边界处理

YOLO输出的是归一化坐标,裁剪前需要乘回原图尺寸。坐标是浮点数,取整操作和边界越界处理都要小心。一个健壮的裁剪函数至少包含以下逻辑:

def crop_plate_patch(img: np.ndarray, box, extend_ratio=0.05): """ box: [x1, y1, x2, y2],原始像素坐标 extend_ratio: 向外扩展比例,避免字符被切断 """ x1, y1, x2, y2 = [float(v) for v in box] w = x2 - x1 h = y2 - y1 # 向外扩展 pad_w = w * extend_ratio pad_h = h * extend_ratio x1 = max(0, int(x1 - pad_w)) y1 = max(0, int(y1 - pad_h)) x2 = min(img.shape[1], int(x2 + pad_w)) y2 = min(img.shape[0], int(y2 + pad_h)) return img[y1:y2, x1:x2]

向外扩展这一点很重要。车牌识别模型需要看到字符边缘的上下文,如果裁得太紧,第一个汉字的左边笔画或最后一个字符的右边笔画很容易被切割,导致识别直接少一位。

4.2 裁剪后先别急:灰度化、CLAHE和尺寸归一

在喂给PaddleOCR之前,我会对裁剪图做三步预处理。这不是必须流程,但对真实场景识别率的提升非常显著。

第一步是灰度化。车牌识别本质上依赖字符与底色之间的亮度差异,灰度图能减少颜色通道的干扰,尤其在夜间偏色严重的画面里。第二步是CLAHE自适应直方图均衡化,这是“阴阳牌”的救星——白天的车牌常常一半处于阴影、一半被阳光直射,对比度相差悬殊,CLAHE能把两半区域的局部对比度都拉起来。第三步是判断裁剪块的最小边尺寸,如果车牌像素边长小于某个阈值(比如高小于20像素),需要用插值放大到合适尺寸,否则OCR会因字符过小而识别失败。

我做过一个对照实验:同一批夜间停车场图像,不做预处理直接识别,准确率大约在82%;加CLAHE并适当放大后,准确率提升到93%左右。这个提升在后期调优过程中是性价比最高的一步。

4.3 倾斜车牌的矫正思路

YOLO输出的框是水平矩形,但真实画面中的车牌经常因为拍摄视角而呈梯形。如果倾斜角度很小(不超过15度),PaddleOCR通常还能扛住;倾斜过大时,识别率会明显掉。

彻底解法是做四点透视矫正:先用轮廓分析或者颜色阈值找到车牌四角,再做透视变换。但这里有个现实问题——YOLO给的是水平框,四角信息只能从裁剪图里继续提取,链路变复杂。我的选择是分场景处理:道闸抓拍这类固定机位,车牌倾斜角度通常可控,直接水平裁剪即可;如果是手持相机或移动监控,再上透视矫正。

另外,PaddleOCR里有一个方向分类器参数use_angle_cls,很多项目会随手打开。车牌场景下我建议直接关掉,因为车牌图像方向是固定的,方向分类器反而可能把竖拍的车牌图误判后旋转,引入额外错误。

5. PaddleOCR车牌识别的参数调优与后处理规则

模型选型和推理环节同样有不少细节,这里单独拎出来讲清楚。PaddleOCR的整体识别能力很强,但要让它输出“车牌格式”的结果,不能只靠默认参数。

5.1 初始化与关键参数设置

安装部分不展开多说,常规做法是安装paddlepaddle和paddleocr,然后按官方模型库下载识别模型。初始化时我推荐这样写:

from paddleocr import PaddleOCR ocr = PaddleOCR( use_angle_cls=False, lang="ch", det=False, # 我们已经用YOLO裁好图了,不需要再做文本检测 rec_model_dir="models/ch_PP-OCRv4_rec", # 指定本地识别模型 )

这里det=False非常关键。很多教程把PaddleOCR直接当整图OCR用,保留了文本检测,但我们的场景里输入已经是裁剪好的车牌小图,如果还开det,模型可能会把车牌上的铆钉、边框、甚至字母之间的间隔误判成文本区域,反而干扰识别。关掉det后,输入图会直接走识别分支,输出就是一行字符。

如果你用的是PaddleOCR 3.x版本,接口命名可能会有差异,具体以官方文档为准。但核心思想不变:检测任务已经由YOLO完成了,OCR侧只负责“读字符”。

5.2 识别模型选中文版还是英文版

车牌字符包含汉字、数字和字母,所以模型要选中文识别模型,用lang="ch"加载。英文模型只认识拉丁字符,会把省份汉字识别成乱码或漏识别。

5.3 不要轻率“换车牌自定义字典”

我见过不少博客建议直接把识别模型的字典改成“省份汉字+字母+数字”,理由是缩小输出类别空间能提高准确率。这个思路理论上没错,但实践中有个大坑:PaddleOCR识别模型的输出层类别数是在训练时固定的,预训练模型的权重和你自定义的字典无法直接匹配,强行指定rec_char_dict_path大概率会报shape不匹配的错,或者识别结果完全混乱。

要让自定义字典真正生效,必须带着新字典去微调识别模型,这是一个额外的训练任务,工作量和环境要求都不低。我的建议分两步走:

  • 第一步先用预训练模型+后处理过滤。实测中预训练模型对车牌字符的识别能力已经足够,少数错误可以通过规则校验修正。
  • 第二步如果识别率还是不满足业务要求,再考虑用PaddleOCR官方文档里的识别模型微调方案,准备几万张车牌字符图,用新字典训练一个专用识别头。

不要一上来就被“自定义字典更准”的说法带偏,工程落地最怕的是选择了一条看起来更优但代价高昂的路径。

5.4 识别后处理:格式校验与常见字符纠错

这一步是整个流程里最有“项目味”的部分。PaddleOCR输出的字符串往往带有空格、点号或其他特殊符号,我们不能拿来直接用。

我写了一个车牌规则过滤函数,核心逻辑包括这三层:

import re PLATE_PROVINCES = "京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云陕甘青宁新藏" def normalize_plate(raw_text: str): # 1. 去掉所有非中文字母数字的字符 text = re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", raw_text.upper()) # 2. 长度与首位校验:蓝牌7位,新能源8位 if len(text) < 7: return None if text[0] not in PLATE_PROVINCES: return None # 3. 常见易混字符纠错:车牌中没有I和O,遇到多半是1和0 text = text.replace("I", "1") text = text.replace("O", "0") return text

几个容易被忽略的细节:

  • 车牌字符集里没有字母I和O,所以识别结果中出现I、O时,几乎可以断定是1和0的误识别,直接替换。
  • 第一位必须是省份汉字,且必须是合法简称,这一步能滤掉大量把“京”识别成“京”以外同音字或形近字的错误。虽然偶尔模型也会把省份汉字认错,但至少规则上能拦住一部分。
  • 普通蓝牌是7位,新能源绿牌是8位。识别结果长度不对时,要么补0,要么直接丢弃等待重试,不能带病入库。

还有一个更细的纠错思路:结合颜色判断车牌类型。检测框内蓝色为主的对应7位蓝牌,绿色渐变对应8位新能源,黄色对应大型车双行牌。用颜色先给识别结果一个“期望位数”,再与OCR输出的长度比对,异常情况优先进入重试或人工复核队列。这块逻辑可以加在检测阶段返回的颜色特征上,我实测下来能明显降低误识别入库率。

6. 端到端串联:一个可用于实际场景的识别脚本

前面的章节从数据、训练、预处理到识别,分别拆开了每个环节。最后把它们串成一条完整的推理流水线,这样在真实项目中才能直接使用。

6.1 完整推理脚本

我习惯于把流程封装成一个类,输入一张图像,输出检测到的车牌和识别结果。核心代码如下:

import cv2 import numpy as np from ultralytics import YOLO from paddleocr import PaddleOCR class PlateRecognizer: def __init__(self, det_weights="plate_detect.pt", rec_model_dir="models/ch_PP-OCRv4_rec"): # YOLO负责车牌定位 self.detector = YOLO(det_weights) # PaddleOCR负责字符识别,关闭文本检测 self.ocr = PaddleOCR(use_angle_cls=False, lang="ch", det=False, rec_model_dir=rec_model_dir) def detect_and_crop(self, img, conf_thres=0.25, extend_ratio=0.05): results = self.detector(img, conf=conf_thres, verbose=False) crops = [] if not results or len(results) == 0: return crops boxes = results[0].boxes.xyxy.cpu().numpy() for box in boxes: x1, y1, x2, y2 = [float(v) for v in box] w, h = x2 - x1, y2 - y1 pad_w, pad_h = w * extend_ratio, h * extend_ratio x1 = max(0, int(x1 - pad_w)); y1 = max(0, int(y1 - pad_h)) x2 = min(img.shape[1], int(x2 + pad_w)); y2 = min(img.shape[0], int(y2 + pad_h)) crops.append(img[y1:y2, x1:x2]) return crops def recognize(self, img, conf_thres=0.25, extend_ratio=0.05): crops = self.detect_and_crop(img, conf_thres, extend_ratio) results = [] for crop in crops: # 预处理:灰度 + CLAHE gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) gray = clahe.apply(gray) gray = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) # OCR识别 ocr_output = self.ocr.ocr(gray, det=False) raw_text = "" score = 0.0 if ocr_output and ocr_output[0]: raw_text = ocr_output[0][0][0] score = float(ocr_output[0][0][1]) plate = normalize_plate(raw_text) results.append({"plate": plate, "score": score}) return results

代码并不复杂,但有几个设计点值得解释:YOLO检测出来的多个车牌会逐一裁剪并分别识别,这对一辆车前后同时出现在画面里的情况很有用;单次调用只创建一个OCR实例,避免每帧重新加载模型带来的性能开销;预处理放在OCR之前,且与训练阶段保持一致。

6.2 批量图片与视频流场景的实测要点

如果要对一批图片做离线识别,直接循环调用recognize即可。但视频流场景要单独考虑两件事:抽帧策略和误报抑制。

道闸项目里,车辆进入画面到停在闸杆前通常有几秒时间,不必每一帧都识别。我习惯做“触发识别+结果去重”:先用运动检测或YOLO检测到车框后,再对连续若干帧取识别结果,并以置信度加权投票的方式输出最终车牌。单帧识别偶然错误很常见,投票机制可以显著降低换车误判。

如果目标是多路摄像头并发识别,性能优化是绕不开的话题:

  • YOLO侧可以导出TensorRT engine,在GPU上推理速度能提升数倍。
  • PaddleOCR在CPU上部署时打开mkldnn加速参数,GPU上则对应设置FP16推理。
  • 对于固定机位场景,还可以缓存背景,只对运动区域做检测,减少无效计算。

6.3 实测效果与局限性

一个真实的道闸或卡口项目里,影响识别率的因素非常集中:白天顺光、车牌干净的情况下,蓝牌识别率基本能跑到95%以上;夜间依靠补光也能维持90%左右;但遇到车牌被污泥遮挡、大角度倾斜、强光直射导致过曝时,漏检和误识还是会出现的,尤其在雨夜,“湿车牌反光”是所有车牌识别系统的老大难问题。

这类情况我的处理经验是:不要试图用单一模型解决所有问题。可以通过多帧追认降低漏检影响,也可以把识别置信度过低的图推给人工复核队列,而不是直接吞掉结果。一个系统的好坏,往往不在“大多数时候的准确率”,而在“少数异常场景下是否具备兜底能力”。

把YOLO和PaddleOCR这条链路真正跑通之后,后续还可以往几个方向扩展:多颜色车牌分类、双行黄牌的特殊处理、基于车辆检测的结构化输出、识别结果的时序关联。先把最核心的定位与识别做稳,依使用场景逐步补全其他模块,才是这类视觉工程项目的正确推进方式。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进