
做车牌识别这件事我在不少项目里踩过坑。最初的想法特别朴素——拿到一张停车场出入口的抓拍图直接丢给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, filltext_color, fontfont) 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:yh, x:xw] 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 dataplate.yaml modelyolov8s.pt epochs150 imgsz640 batch16 device0plate.yaml的内容大致如下train: datasets/plate/train val: datasets/plate/val nc: 1 names: [plate]关键参数我解释一下imgsz640是默认选择。分辨率再高对车牌定位有帮助但显存占用和训练时间都会显著增加。如果最终部署时输入分辨率较高可以保持一致。epochs150通常够用。配合早停机制验证集指标连续多轮不涨就提前结束。batch根据显存调节训练时峰值显存不足就把batch减半。3.2 训练日志怎么看训练过程中我主要盯三个指标box_loss、cls_loss、dfl_loss三类损失稳步下降说明模型在收敛。mAP50验证集上IoU阈值0.5时的平均精度车牌检测任务看它最直观。我一般要求最终mAP50达到0.95以上低于0.9基本无法落地。mAP50-95综合精度指标略低也没关系不必为了这个指标强行堆算力。训练完成后测试阶段我会单独抽一批“极端场景”图片做人工目检夜间、强逆光、雨雪天气、车牌歪斜严重、车牌被泥污遮挡。这些图哪怕不在训练集里模型表现也能初步反映泛化能力。3.3 训练中的三个典型坑第一个坑是“把车灯/车标/广告灯箱当车牌”。这通常是数据里正样本背景不够多样导致的。对策是合成数据阶段多换背景真实数据里刻意收集一些带着车灯、车标特写的干扰样本。第二个坑是“强光下漏检”。大晴天中午金属车牌反光会使颜色严重过曝YOLO学过的大部分蓝牌特征失效。我的处理办法是数据增强里加大亮度变换幅度同时采集真实中午时段的抓拍图补充训练。第三个坑是“小目标漏检”。道闸摄像机拍摄的画面通常车近牌大但如果用的是高位全景监控车牌可能只有几十个像素。此时可以把输入分辨率提到imgsz960但更推荐的做法是部署时对画面下半部分区域单独放大检测而不是全图盲目放大。训练完成后导出部署格式可以按需选择我用得最多的是ONNX和TensorRT engine。ultralytics框架导出起来很顺手但注意导出时要固定输入尺寸并关闭多batch以免部署环境出现尺寸动态变化的性能损耗。4. 裁剪车牌前后的图像处理识别率的分水岭很多教程会在YOLO出框后直接裁图丢给OCR然后发现识别率死活上不去。问题多半出在“裁剪”这个环节——它看似简单但细节极为影响结果。4.1 检测框坐标的还原与边界处理YOLO输出的是归一化坐标裁剪前需要乘回原图尺寸。坐标是浮点数取整操作和边界越界处理都要小心。一个健壮的裁剪函数至少包含以下逻辑def crop_plate_patch(img: np.ndarray, box, extend_ratio0.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_clsFalse, langch, detFalse, # 我们已经用YOLO裁好图了不需要再做文本检测 rec_model_dirmodels/ch_PP-OCRv4_rec, # 指定本地识别模型 )这里detFalse非常关键。很多教程把PaddleOCR直接当整图OCR用保留了文本检测但我们的场景里输入已经是裁剪好的车牌小图如果还开det模型可能会把车牌上的铆钉、边框、甚至字母之间的间隔误判成文本区域反而干扰识别。关掉det后输入图会直接走识别分支输出就是一行字符。如果你用的是PaddleOCR 3.x版本接口命名可能会有差异具体以官方文档为准。但核心思想不变检测任务已经由YOLO完成了OCR侧只负责“读字符”。5.2 识别模型选中文版还是英文版车牌字符包含汉字、数字和字母所以模型要选中文识别模型用langch加载。英文模型只认识拉丁字符会把省份汉字识别成乱码或漏识别。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_weightsplate_detect.pt, rec_model_dirmodels/ch_PP-OCRv4_rec): # YOLO负责车牌定位 self.detector YOLO(det_weights) # PaddleOCR负责字符识别关闭文本检测 self.ocr PaddleOCR(use_angle_clsFalse, langch, detFalse, rec_model_dirrec_model_dir) def detect_and_crop(self, img, conf_thres0.25, extend_ratio0.05): results self.detector(img, confconf_thres, verboseFalse) 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_thres0.25, extend_ratio0.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(clipLimit3.0, tileGridSize(8, 8)) gray clahe.apply(gray) gray cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) # OCR识别 ocr_output self.ocr.ocr(gray, detFalse) 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这条链路真正跑通之后后续还可以往几个方向扩展多颜色车牌分类、双行黄牌的特殊处理、基于车辆检测的结构化输出、识别结果的时序关联。先把最核心的定位与识别做稳依使用场景逐步补全其他模块才是这类视觉工程项目的正确推进方式。