拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python车牌检测识别实战:从YOLO检测到CRNN识别,落地智慧停车全流程

Python车牌检测识别实战:从YOLO检测到CRNN识别,落地智慧停车全流程

简介:这份资源是一套基于Python的车牌检测与识别系统完整项目,面向计算机视觉入门者、课程设计或毕业设计开发者,帮助理解从图像预处理到字符识别的全流程实现。系统涵盖灰度化、直方图均衡化、二值化与形态学操作等预处理步骤,并包含车牌定位、字符分割及基于SVM或深度学习的字符识别模块,适合作为模式识别与深度学习结合的综合实践案例。压缩包共48个文件,约22.11MB,以png、jpg图像样本和py源码为主,另含dat训练数据、ui界面文件、ico图标、mp4演示视频及txt、md说明文档,结构清晰便于按模块查阅。目前已有150人学习下载。通过该项目可掌握OpenCV图像处理、SVM训练脚本、UI界面调用及多线程与GPU加速思路,并了解光照、倾斜、遮挡等鲁棒性优化与数据增强方法,对提升交通场景下的检测识别能力具有参考价值。

1. 从一张停车场抓拍图说起:Python 车牌检测识别到底能落地到什么程度

地下车库出口,一辆车压过地感线圈,相机抓拍一张 1920×1080 的图,车牌在画面里可能只占 180×60 像素,还带点运动模糊和补光灯反光。要在 300 毫秒内把「粤B·12345」这七个字符吐给道闸系统,这就是基于 Python 的车牌检测和识别系统要干的活。它拆开是两件事:检测负责在整张图里框出车牌位置,识别负责把框里的字符读出来。Python 在这条链路里的价值不是性能,而是生态——OpenCV 做图像预处理、ONNX Runtime 跑推理、FastAPI 把结果吐成 HTTP 接口,一套下来两三百行能跑通原型。这套方案适合谁?做智慧停车、园区门禁、车辆出入管理的后端和算法同学,手上有 GPU 或边缘盒子,想先用 Python 把流程验证清楚再谈工程化。下面按「检测怎么选、识别怎么做、坑在哪」一路讲透。

2. 检测环节:从颜色纹理到深度学习,方案怎么选

2.1 传统方法为什么在真实场景里容易翻车

早期做车牌检测,主流是「边缘检测 + 形态学 + 颜色筛选」三件套。思路很直白:车牌区域边缘密集,用 Sobel 或 Canny 提取垂直边缘,再做闭运算把字符连成块,最后按长宽比 3:1 到 5:1、面积占比筛候选框。OpenCV 里十几行就能写出来,CPU 上跑一张图几十毫秒,看着很美。

但真实场景一上就露馅。补光灯打在蓝底车牌上,HSV 里的蓝色饱和度直接飙到接近白色,颜色阈值全废;阴天逆光时车牌和车身灰度差不到 20,边缘检测出来的全是车身腰线和保险杠。我最早在园区项目里用这套,白天准确率能到 85%,一到傍晚逆光就掉到 50% 以下,属于典型的「实验室能跑、现场翻车」。传统方法的定位应该是:没有训练数据、算力极弱(比如纯 CPU 的旧工控机)时的兜底,或者用来给深度学习模型做候选区域预筛,减少推理次数。真要靠它单独扛生产,血泪经验是别抱期望。

2.2 用 YOLO 系列做车牌检测的最小可跑流程

现在做车牌检测,常见做法是拿 YOLOv5/v8 这类单阶段检测器,把车牌当成一个类别来训。为什么选单阶段而不是 Faster R-CNN 两阶段?车牌检测目标单一、形状规整,单阶段的速度优势(GPU 上 5 毫秒级)远大于那一点点精度损失,而且部署到 ONNX 后边缘设备也吃得消。

最小流程分三步:准备数据、训练、导出推理。数据这块,公开数据集(如 CCPD)能直接用,但要注意它的标注格式和 YOLO 的 txt 格式不一样,得转。下面是一个把 CCPD 的标注转成 YOLO 格式的脚本,我一般会先跑这个再开训。

import os import cv2 # CCPD 文件名里就带了标注信息,格式如 025-95_113-226&469_448&522-452&527_233&531-233&531_452&527-0.jpg # 解析出车牌框的左上、右下坐标,再归一化成 YOLO 的 cx cy w h def ccpd_name_to_yolo(filename, img_w, img_h): name = os.path.splitext(filename)[0] parts = name.split('-') # parts[2] 是 "226&469_448&522",即 x1&y1_x2&y2 coords = parts[2].split('_') x1, y1 = map(int, coords[0].split('&')) x2, y2 = map(int, coords[1].split('&')) # 归一化并转成中心点+宽高 cx = (x1 + x2) / 2.0 / img_w cy = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h return f"0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}" img_dir = "./ccpd/images" label_dir = "./ccpd/labels" os.makedirs(label_dir, exist_ok=True) for fname in os.listdir(img_dir): if not fname.endswith(".jpg"): continue img = cv2.imread(os.path.join(img_dir, fname)) h, w = img.shape[:2] line = ccpd_name_to_yolo(fname, w, h) with open(os.path.join(label_dir, fname.replace(".jpg", ".txt")), "w") as f: f.write(line + "\n")

这段脚本的关键在parts[2]的解析,CCPD 文件名用-分段,第三段才是框坐标,很多人第一次写会错取成parts[1](那是模糊度和倾斜度)。归一化用图像真实宽高,不是固定 640,否则小图会偏。转完检查一下生成的 txt,坐标应该都在 0 到 1 之间,出现负数或大于 1 就是解析错了。

训练用 YOLOv5 的话,改data/plate.yaml里的nc: 1和names: ['plate'],然后:

python train.py --img 640 --batch 16 --epochs 100 --data plate.yaml --weights yolov5s.pt

--img 640是输入尺寸,车牌在整图里偏小的话可以提到 960,但显存占用翻倍;--batch 16在 8G 显存上比较稳,爆显存就降到 8。训练完看results.png里的 mAP@0.5,车牌这种规整目标,100 轮通常能到 0.95 以上,到不了就回头查标注。

2.3 检测框后处理:NMS 阈值和长宽比过滤怎么调

模型输出一堆候选框,得靠 NMS(非极大值抑制)去重。YOLO 默认iou_thres=0.45,但车牌场景有个坑:相邻两辆车的车牌可能挨得很近,阈值太低会把旁边车的车牌也抑制掉。我一般会把 NMS 的 IoU 阈值提到 0.5 到 0.6,宁可多留几个框,后面再用长宽比过滤。

长宽比过滤是第二道关。中国蓝牌和新能源绿牌的长宽比都在 3:1 到 4.5:1 之间,检测框如果宽高比小于 2 或大于 6,基本是误检,直接丢。这一步能砍掉不少把车灯、格栅误认成车牌的框。代码上就是在拿到boxes后加一层判断:

valid = [] for box in boxes: x1, y1, x2, y2, conf, cls = box w, h = x2 - x1, y2 - y1 ratio = w / h if h > 0 else 0 if 2.0 < ratio < 6.0 and conf > 0.4: valid.append(box)

conf > 0.4这个置信度阈值别设太高,车牌被遮挡时置信度会掉到 0.5 左右,设 0.6 就漏检了。这几个参数没有万能值,得拿自己场景的测试集跑一遍 PR 曲线来定。

3. 识别环节:CRNN 加 CTC 为什么成了车牌识别的主流

3.1 车牌字符识别的难点不在字符本身

检测框裁出来的车牌图,识别看着简单——就七八个字符。但难点在:车牌有倾斜(相机没装正)、有透视变形(车斜着开过来)、有污损和铆钉遮挡、新能源车牌还多一位。传统做法是「字符分割 + 单字分类」,先投影法把每个字符切出来,再送进 CNN 分类。这套在清晰正脸图上没问题,一旦倾斜或字符粘连,分割就崩,一个字符切错后面全错,误差累积。

所以现在主流直接上 CRNN(CNN + RNN + CTC)。CNN 提特征,RNN 学字符间的序列关系,CTC 解决「不知道每个字符在哪一列」的对齐问题,端到端输出字符序列,不用先分割。这个结构对不定长车牌(蓝牌 7 位、新能源 8 位)天然友好,是车牌识别里最稳的选择。

3.2 用 CRNN 训练车牌识别的数据准备和标签编码

CRNN 的输入是归一化到固定高度(常用 32)的灰度或彩色图,宽度按比例缩放。标签是字符序列,需要建一个字符表。中国车牌字符集包括:省份汉字(31 个)、字母(去掉 I 和 O,24 个)、数字(10 个),加上 CTC 的 blank 符,总共约 66 类。

# 字符表,index 0 留给 CTC 的 blank CHARS = "京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新" CHARS += "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" char_to_idx = {c: i + 1 for i, c in enumerate(CHARS)} # 0 是 blank idx_to_char = {i + 1: c for i, c in enumerate(CHARS)} def encode_label(plate_text): # 把 "粤B12345" 编成 [idx, idx, ...] return [char_to_idx[c] for c in plate_text if c in char_to_idx]

注意字母表去掉了 I 和 O,因为车牌里不用它们(怕和 1、0 混),如果你的数据集里出现了,要么是标注错,要么是特殊车牌,得单独处理。char_to_idx从 1 开始,0 留给 CTC blank,这个偏移写错会导致训练 loss 一直不降,是新手最常见的坑之一。

3.3 CTC 损失和推理解码:从 logits 到车牌字符串

训练时 CRNN 输出的是(T, batch, num_classes)的 logits,T 是时间步(宽度方向的特征列数)。CTC 损失直接吃 logits 和标签序列,不用对齐。PyTorch 里用nn.CTCLoss:

import torch import torch.nn as nn ctc_loss = nn.CTCLoss(blank=0, reduction='mean') # logits: (T, N, C),需要 log_softmax log_probs = torch.log_softmax(logits, dim=2) # targets 是拼接后的标签,target_lengths 是每张图的字符数 loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)

blank=0必须和字符表偏移对上。input_lengths是每张图的时间步数,如果 batch 里图宽不一样,得 pad 到同一宽度并记录真实长度,否则 CTC 会把 padding 也算进去。

推理时用贪心解码:每个时间步取 argmax,去掉重复和 blank。比如输出--粤-粤-B-B-1-2-3-4-5--,合并重复再去 blank 得到粤B12345。贪心解码够用,追求更高精度可以上 beam search,但车牌场景贪心通常就差 0.5 个点,不值得那点延迟。

def greedy_decode(log_probs): # log_probs: (T, C) indices = torch.argmax(log_probs, dim=1) # 每步取最大 result = [] prev = -1 for idx in indices: idx = idx.item() if idx != prev and idx != 0: # 去重且非 blank result.append(idx_to_char.get(idx, '')) prev = idx return ''.join(result)

这套解码逻辑简单,但要注意prev的更新时机——不管当前是不是 blank 都要更新,否则连续两个相同字符中间夹一个 blank 会被误合并。这个细节我在第一次写的时候踩过,识别结果里「AA」变成「A」,查了半天。

4. 检测和识别怎么串起来:端到端推理与性能取舍

4.1 两阶段串联的推理管线

检测和识别是两个模型,串起来就是:整图送检测模型 → 拿到车牌框 → 按框裁剪 → 裁剪图送识别模型 → 输出字符串。中间要做透视矫正(如果检测框是倾斜的)和尺寸归一化。透视矫正用检测框的四个角点做cv2.getPerspectiveTransform,把车牌拉正,这一步对识别准确率提升明显,尤其是相机斜装的场景。

import cv2 import numpy as np def crop_and_rectify(img, box_points): # box_points 是检测框四个角点,顺序:左上、右上、右下、左下 pts_src = np.float32(box_points) w = int(max(np.linalg.norm(pts_src[0] - pts_src[1]), np.linalg.norm(pts_src[2] - pts_src[3]))) h = int(max(np.linalg.norm(pts_src[0] - pts_src[3]), np.linalg.norm(pts_src[1] - pts_src[2]))) pts_dst = np.float32([[0, 0], [w, 0], [w, h], [0, h]]) M = cv2.getPerspectiveTransform(pts_src, pts_dst) return cv2.warpPerspective(img, M, (w, h))

如果检测模型只输出水平矩形框(YOLO 默认),没有四个角点,那透视矫正做不了,只能直接裁剪。想要角点就得用带旋转框的检测器,或者用分割模型出车牌 mask 再拟合四边形。这是个取舍:水平框简单快,旋转框精度高但标注和训练都更麻烦。

4.2 单阶段和两阶段的延迟对比

实际部署时,延迟是硬指标。我拿一张 1080P 图在 RTX 3060 上测过:YOLOv5s 检测约 8 毫秒,CRNN 识别单张车牌约 5 毫秒,加上前后处理,端到端 25 到 40 毫秒,一秒钟能处理 25 到 40 帧,够道闸场景用。如果换 CPU(比如 i5),检测会涨到 80 到 150 毫秒,识别 30 毫秒,端到端 200 毫秒左右,勉强能接受但没余量。

优化方向有两个:一是把两个模型都导出成 ONNX,用 ONNX Runtime 的 GPU 或 TensorRT 加速,检测能压到 3 毫秒;二是检测和识别共享 backbone,但车牌场景两个任务差异大,共享收益不明显,不推荐。边缘设备上(如 Jetson Nano),老老实实用 YOLOv5n 这种 nano 模型,精度掉 2 到 3 个点,速度能翻倍。

4.3 用 FastAPI 把整套流程包成接口

验证阶段用脚本跑就行,要给别人用就得包成服务。FastAPI 是最省事的,异步、自带文档、部署简单。

from fastapi import FastAPI, UploadFile import numpy as np import cv2 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) boxes = detect(img) # 检测 results = [] for box in boxes: plate_img = crop_and_rectify(img, box) text = recognize(plate_img) # 识别 results.append({"box": box.tolist(), "text": text}) return {"plates": results}

detect和recognize里加载的模型要在服务启动时初始化一次,别每次请求都 load,否则光加载模型就几百毫秒。用@app.on_event("startup")把模型挂到全局。并发上,GPU 推理要加锁或限制并发数,不然多个请求同时抢显存会 OOM。

5. 避坑与排查:车牌系统上线前必须过的五道坎

5.1 检测框抖动导致识别结果跳变

现象:同一辆车连续几帧,识别结果在「粤B12345」和「粤B1234S」之间跳。原因:检测框每帧有几像素偏移,裁剪出来的图边界不同,识别模型对边界敏感。解决:对检测框做时序平滑,比如用最近 5 帧的框做加权平均,或者识别时把裁剪区域往外扩 5% 再送模型,给字符留点余量。我在道闸项目里加了 3 帧投票,跳变基本消失。

5.2 新能源绿牌识别率明显低于蓝牌

现象:蓝牌准确率 97%,绿牌只有 88%。原因:绿牌是 8 位,比蓝牌多一位,且绿牌底色和字符对比度低,训练数据里绿牌样本少。解决:数据层面,绿牌样本至少补到总样本的 30%;模型层面,输入图别转灰度,保留颜色信息,绿牌的绿色通道对字符分割有帮助。另外 CTC 的input_lengths要够长,8 位车牌需要的时间步比 7 位多,宽度归一化时别压太狠。

5.3 训练 loss 不降或降了但识别全是 blank

现象:训练几个 epoch,loss 卡在 4.0 左右不动,或者降了但推理输出空字符串。原因:九成是 CTC 的 blank 索引和字符表偏移对不上。CTCLoss(blank=0)要求字符索引从 1 开始,如果char_to_idx从 0 开始,模型学到的全是 blank。解决:检查char_to_idx的起始值,确保 0 是 blank,字符从 1 开始。另一个可能是input_lengths传错,传成了 batch size 而不是时间步数。

5.4 补光灯反光把车牌打成一片白

现象:夜间抓拍,车牌区域过曝,检测框能框到但识别全错。原因:补光灯直射车牌,蓝底反光后饱和度丢失。解决:硬件上把补光灯角度调偏 15 到 30 度,别直射;软件上在预处理加一步自适应直方图均衡(CLAHE),对过曝区域做局部对比度拉伸。CLAHE 的clipLimit设 2.0 到 3.0,tileGridSize设 8×8,太大反而引入噪声。

5.5 模型导出 ONNX 后精度掉点

现象:PyTorch 里识别准确率 96%,导出 ONNX 后用 ONNX Runtime 跑只有 91%。原因:导出时的动态轴设置不对,或者某些算子(如自定义的 CTC 解码)没被正确转换。解决:导出时明确指定dynamic_axes,把 batch 和宽度维度设为动态;CTC 解码别放进模型图里,在 Python 侧做,模型只输出 logits。导出后用onnxruntime跑一遍和 PyTorch 对比,逐层查输出差异,通常问题出在 resize 或 normalize 的算子实现上。

6. 把识别率从 95% 推到 99%:三个我压箱底的技巧

第一个技巧是「检测框外扩 + 多尺度裁剪投票」。检测框往外扩 8%,同时再裁一个扩 15% 的版本,两个版本分别送识别,结果一致才采纳,不一致就取置信度高的。这招对边界模糊的车牌特别管用,我在一个老旧小区项目里靠它把识别率从 95.2% 拉到 97.8%,代价是识别耗时增加 60%,但道闸场景对延迟不敏感,值。

第二个技巧是「字符级置信度过滤 + 二次识别」。CRNN 贪心解码时能拿到每个字符的置信度,如果某个字符置信度低于 0.6,说明这一位可能错了。这时候把车牌图做一次锐化或对比度增强,再识别一遍,两次结果取置信度总和高的。这个逻辑写起来就十几行:

def recognize_with_retry(plate_img, model, threshold=0.6): text1, conf1 = model(plate_img) if min(conf1) >= threshold: return text1 # 有低置信字符,增强后重试 enhanced = cv2.convertScaleAbs(plate_img, alpha=1.3, beta=10) text2, conf2 = model(enhanced) return text1 if sum(conf1) > sum(conf2) else text2

alpha=1.3是对比度增益,beta=10是亮度偏移,这两个值别设太大,否则过曝。这招对污损车牌和轻度模糊有效,但如果是严重运动模糊,增强也救不回来,得从快门速度下手。

第三个技巧是「按省份分组微调」。中国车牌第一个字是省份汉字,31 个汉字里有些字形接近(比如「湘」和「浙」在某些字体下),全国模型容易混。我的做法是先训一个通用模型,然后按省份把数据分组,对识别错的省份单独微调最后几层。微调时学习率降到 1e-4,只跑 10 个 epoch,防止过拟合。这个操作让「湘/浙」「赣/皖」的混淆率降了一半以上。

最后说个习惯:每次改完模型或参数,别只看整体准确率,一定要按「蓝牌/绿牌」「白天/夜间」「正脸/倾斜」分维度看。整体 98% 可能掩盖了夜间绿牌只有 85% 的事实,而道闸场景恰恰夜间流量不小。我现在的做法是维护一个 500 张的回归测试集,覆盖各种边角场景,每次上线前跑一遍,掉点超过 1% 就回滚。这套流程比任何调参技巧都管用。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表