简介:基于YOLOv7加CRNN的车牌检测与中文车牌识别完整项目,专门面向正在进行毕业设计、课程设计或需要项目实战的深度学习视觉图像识别学习者,可用于快速搭建车牌识别系统并完成实验验证。压缩包共192个文件,整体约55.64MB,涵盖99个Python脚本、15个YAML配置文件、PyTorch权重文件、图片样本、Shell脚本及C和CUDA部署源码等,从数据准备、模型训练到推理部署均有涉及,目录结构清晰,便于按功能模块学习。项目已提供训练好的模型和详细操作说明,能够直接运行并识别国内中文车牌;同时附带数据集与标注文件,便于深入理解YOLOv7目标检测和CRNN序列识别原理,也可在此基础上扩展训练其他模型。目前已有1276人学习下载,对于需要完整参考项目的学习者来说,是一份可落地、可复用的优质资源。
1. 车牌识别不是“检测 + 读字”那么简单:为什么偏偏是 YOLOv7 + CRNN
中文车牌识别是一个很有代表性的工业视觉任务:它不要求极致的精度,但要求稳定、可部署、好调试。很多人以为把目标检测和 OCR 拼起来就能做,结果一跑才发现,车牌在画面里往往只有几十个像素,而且可能有倾斜、反光、残缺。用 YOLOv7 负责定位“车牌在哪”,再用 CRNN 负责识别“牌上是什么”,是过去几年在私有环境里最容易复现、也最容易把坑讲明白的组合。这个方案的好处是检测和识别可以单独优化,检测错了不会怪识别,识别错了也不用重训检测模型。本文基于“代码 + 数据集 + 说明文档”这种工程包,把从数据准备到推理部署的关键环节过一遍,适合已经跑过 YOLOv5、想进一步接触 OCR 和序列识别的工程师。
2. YOLOv7 车牌检测模型:以数据为中心的训练思路
真正做过车牌检测的人都知道,难点不在网络结构,而在训练数据怎么组织。YOLOv7 是成熟的目标检测器,官方权重能识别 80 类日常物体,但车牌这种长宽比接近 3:1、又容易出现小目标的目标,必须用专用数据集重新训练。下面按源码工程的标准流程来说。
2.1 车牌检测任务和通用物体检测差在哪
通用检测里,目标通常是方方正正的物体,比如人、车、狗。车牌不一样,它在图片中的形态很极端:
- 尺寸小:一辆车在 1080p 画面里可能占 1/3,但车牌只占其中一小块;
- 长宽比固定且极端:蓝牌比例约 440:140,新能源车牌 480:140;
- 透视形变:来源可能是停车场闸机俯视、道路卡口斜视,车牌可能是个梯形;
- 背景干扰:车灯、进气格栅、保险杠的纹理和车牌颜色接近。
这些特点决定了训练时的三个关键决策:一是输入分辨率不要死守 640,可以提到 960 或 1280;二是 anchor 尺寸要重新聚簇,不能沿用 COCO;三是数据增强里要加重随机旋转和透视变换,模拟不同视角。
2.2 数据标注与目录结构:VOC、COCO 还是 YOLO 格式
源码包里的“数据集”目录,通常不外乎以下几种结构。最常见的是 YOLO 格式,每个图片对应一个同名 txt,每行是class_id cx cy w h,坐标都归一化到 0~1。工程里你会看到类似这样的 layers:
datasets/ ├── annotations/ # 如果有 VOC/COCO 标注,会在这里 ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamldata.yaml是 YOLOv7 训练时读的数据配置。对于一个只识别车牌的项目,里面的内容一般是:
train: datasets/images/train val: datasets/images/val nc: 1 names: ['license_plate']对源码做一些修改时要注意:YOLOv7 的datasets.py会按data.yaml里的路径去加载图片和标签。如果你拿到的是 VOC 标注(JPEGImages+Annotations),我一般会先用voc_label.py转成 YOLO 格式,再开始训练,避免在复现时被格式问题卡住。
2.3 修改模型配置:anchor 必须重新计算
YOLOv7 的官方cfg/training/yolov7.yaml里默认 anchor 是针对 COCO 数据集聚类出来的,长宽比从 0.6 到 6,但车牌这种长宽比 3 左右的对象,其实用默认 anchor 也能训,只是收敛慢、回归不精准。比较稳妥的做法是用 k-means 对训练集中的所有真实框重新聚类。
修改方法是在源码utils/autoanchor.py里跑一次聚类,然后替换yolov7.yaml中的anchors段。以一个车牌数据集为例,聚类出的 9 个 anchor 可能是:
anchors: - [10, 6, 15, 9, 24, 12] # P3 特征图 - [32, 18, 54, 24, 88, 36] # P4 特征图 - [120, 50, 180, 80, 280, 120] # P5 特征图注意车牌虽然整体长宽比固定,但在不同尺度下锚框的宽高可以保持 2.5:1 到 3.5:1。重新聚类后,训练时 mAP 提升虽然可能只有 1~2 个点,但收敛更快,尤其在小目标上是质的变化。
2.4 训练命令与必调参数
训练入口是train.py。一个适合单卡 RTX 3080 的常用命令如下:
python train.py \ --weights yolov7.pt \ --data data/license.yaml \ --hyp data/hyp.scratch.custom.yaml \ --batch-size 16 \ --img 960 \ --epochs 150 \ --workers 8 \ --device 0每个参数都有实际意义。--weights yolov7.pt是 COCO 预训练权重,车牌虽然不属于 COCO 类别,但预训练模型已经学到了边缘、纹理等底层特征,迁移学习比从头训练效果好得多。--img 960是因为车牌小,把输入分辨率从默认 640 提到 960,小目标召回能涨不少;代价是显存占用增加,batch 降到 16 是折中。--hyp传自定义增强参数,重点是开启mosaic=1.0、hsv_h=0.015、degrees=10,其中degrees控制旋转角度,10 度对车牌已经够用,太大容易让车型姿态失真。
训练过程中要盯两组指标:P/R 曲线和验证集 loss。如果 precision 高但 recall 低,说明模型只认得出“正正的蓝色车牌”,需要增加斜视角度的样本;如果 loss 下降很快但 mAP 上不去,优先怀疑 anchor 没更新。一个常见误用是直接把 COCO 的hyp.scratch.yaml拿过来,里面degrees=0,不做旋转增强,导致实拍场景一概漏检。
2.5 用 mAP 衡量模型时,注意 box 坐标的绝对误差
YOLOv7 测试会输出 mAP@0.5 和 mAP@0.5:0.95。对车牌任务来说,mAP@0.5 高于 0.98 不代表完美,因为 0.5 的 IoU 阈值对“检测框只占车牌一半”这种结果也会判为正检。车牌后续要裁剪做识别,框偏了直接导致识别输入缺字。所以在评估时,我一般会把--iou-thres和--conf-thres调出来,单独打印预测框和真实框的偏差,重点关注 IoU 在 0.75 以上的比例。真正落地的模型,应保证 mAP@0.75 在 0.9 以上,否则识别准确率会被检测框的“差不多”拖垮。
3. CRNN 中文车牌识别:用 CTC 处理变长序列
检测模型输出的是一块“疑似车牌”的图像,接下来要识别出车牌上的字符。中文车牌的特殊之处在于字符序列固定但字符集很大:第一位是省份汉字(如“粤”“京”“鄂”),第二位是发牌机关字母,后面是字母和数字,新能源车牌比普通蓝牌多一位。CRNN 是目前工程上比较通用的方案。
3.1 为什么用 CRNN 而不是 CNN 分类
常规 CNN 分类要求输入图像类别数量固定,比如把车牌分成“粤A12345”这种完整字符串,类别数取决于可能组合,分散且组合爆炸,实际不可行。一种替代思路是“定长多分类”,把车牌切成 7 段,每段各自分类,例如 7 个分类器分别输出汉字、字母、数字。这样做的问题是切割位置难定,而且车牌字符并不均匀排列,一旦检测框倾斜,切出来的字符就错位。
CRNN 的关键是允许模型“一口气”读完整张图,输出一个序列,然后通过 CTC Loss 自动对齐到真实标签。它不需要显式切字符,只需要输入“包含一段文字”的图像,网络自己决定每个时间步对应哪个字符。这使得它天然适合车牌这种长度略有变化、字符分布不均匀的识别任务。
3.2 字符集设计与标签编码
中文车牌识别要先确定字符集。通常包括:
省份简称:京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼 字母:A~Z(除去 I、O 等容易混淆的) 数字:0~9字符集长度一般在 60 左右。太多没有必要,因为车牌上只会出现这些字符。在源码工程里,常见的做法是把字符集写在一个char_dict.txt中,按行编号,例如:
0 京 1 津 2 沪 ...标签编码时,每个标签是字符索引的序列。CRNN 训练时,输入是整张车牌图(例如高 32、宽 96),输出是长度为 T 的特征序列,最终通过 softmax 得到每个时间步在字符集上的概率分布。
3.3 基于 PyTorch 构建一个最小 CRNN 训练脚本
假设你已经将车牌图片裁剪成正角度,并缩放到 32×168(高×宽),我们可以用下面的代码片段定义数据加载核心逻辑:
import torch import torch.nn as nn from torch.utils.data import Dataset class PlateDataset(Dataset): def __init__(self, img_paths, labels, char_dict, height=32, width=168): self.img_paths = img_paths self.labels = labels self.char_dict = {c: i for i, c in enumerate(char_dict)} self.height = height self.width = width def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 实际应用中用 cv2.imread 和 resize img = load_and_resize(self.img_paths[idx], self.height, self.width) img = torch.from_numpy(img).float().permute(2, 0, 1) / 255.0 # 标签转为索引列表,例如 [20, 3, 15, ...] target = [self.char_dict[c] for c in self.labels[idx]] target_len = torch.tensor(len(target), dtype=torch.long) target_tensor = torch.tensor(target, dtype=torch.long) return img, target_tensor, target_len代码里需要注意char_dict必须与最后模型全连接层的输出维度一致。CRNN 模型结构一般是“CNN 卷积特征提取 → RNN(LSTM/GRU) → 全连接分类”,最终输出的特征序列长度为W / 4左右,比如输入宽 168,经过 4 次下采样后序列长度是 42,那么 RNN 输出的每个时间步就对应图上大约 4 像素宽的滑动窗口。
训练时用 CTC Loss:
import torch.nn.functional as F from torch.nn import CTCLoss criterion = CTCLoss(blank=0) # blank 是 CTC 用于“空白帧”的类别 optimizer = torch.optim.Adam(model.parameters()) for epoch in range(epochs): for imgs, targets, target_lens in dataloader: logits, logit_lens = model(imgs) # logits shape: [batch, seq_len, num_classes] loss = criterion(logits.transpose(1, 0), targets, logit_lens, target_lens) optimizer.zero_grad() loss.backward() optimizer.step()这里logits的类别数比字符集数多 1,多的一个就是 CTC 的 blank。解码时,把模型输出的概率序列取 argmax,然后“合并重复字符并去掉 blank”就能得到车牌字符串。这种解码方式叫“贪心解码”,速度最快,也是工程里最常用的。
3.4 CRNN 训练的参数陷阱:图像高度和字符间距
CRNN 对输入高度很敏感,因为卷积下采样的倍率是固定的。如果你把车牌图像直接 resize 成任意高度,会导致竖直方向的字符被压缩或拉伸,RNN 读不到足够的细节。常见做法是固定高度为 32,宽度按原始宽高比等比缩放,不是直接拉宽。如果源车牌是从检测框裁出来的,长宽比可能被破坏,最好先做透视校正再缩放。
另外,CTC 训练时不要把所有图片都缩放到同一固定宽度,这样会丢失字符分布信息。更稳妥的做法是在一个 batch 内做 padding,设置最大宽度,然后动态 mask。很多开源项目偷懒直接resize(32, 168),训练出来的模型遇到“宽度比例异常”的车牌(比如新能源 7 位)时,末尾字符经常丢。我会建议按原图比例缩放后再用零填充到统一宽度。
4. 把检测和识别串起来:推理管线与工程化细节
训练完两个模型,真正跑业务时要做的事比训练更多:检测框怎么转成识别输入,识别概率怎么过滤,两个模型怎么组织成一个稳定的调用链。这一节给出一个可以抄走的推理管线。
4.1 从检测框到车牌图像:透视校正与裁剪
YOLOv7 检测输出的是矩形框(x1, y1, x2, y2),但实际车牌可能旋转倾斜。如果直接裁剪矩形框,往往带进车灯、车漆等背景,影响识别。更好的做法是使用 OpenCV 的minAreaRect先提取车牌的旋转外接矩形,再做透视变换。
import cv2 import numpy as np def crop_plate_rect(image, box): """ box: [x1, y1, x2, y2] YOLO检测结果 返回校正后的车牌图像 """ roi = image[int(box[1]):int(box[3]), int(box[0]):int(box[2])] # 转为灰度后找轮廓 gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 100, 200) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(contours) == 0: return roi # 选择最大轮廓 cnt = max(contours, key=cv2.contourArea) rect = cv2.minAreaRect(cnt) # rect 的宽高其中一个是车牌的“高” w = int(rect[1][0]) h = int(rect[1][1]) if w < h: w, h = h, w # 定义目标坐标:按宽度为长边 dst = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32) # 注意 rect 的四个角点顺序需要调整 box_pts = cv2.boxPoints(rect) # 按坐标排序对齐到 dst src = order_points(box_pts) # 自己实现角点排序 M = cv2.getPerspectiveTransform(src, dst) warped = cv2.warpPerspective(image, M, (w, h)) return warped这个函数里order_points需要把最小外接矩形的四个顶点统一为“左上、右上、右下、左下”的顺序,否则透视变换会得到翻转或扭曲的结果。实际工程中,如果检测框本身已经比较准(IoU 0.85 以上),也可以不做轮廓校正,只把裁剪区域等比缩放,这样更快但识别率略低。我一般优先保留透视校正,因为它的耗时增加不到 2ms,但对中文车牌的末位字符识别提升明显。
4.2 识别后处理:贪心解码与置信度过滤
CRNN 输出一个形状为[batch, seq_len, num_classes]的概率矩阵。解码时每个时间步取最大概率类别,再去重去 blank。下面是一段简洁的实现:
def decode_ctc(preds, char_dict): """preds: [seq_len, num_classes]""" indexes = preds.argmax(dim=-1) # 每个时间步的类别 id prev = -1 result = [] for idx in indexes.tolist(): c = char_dict[idx] if c == '_': # 假定 blank 的字符标识为 '_' prev = -1 continue if c != prev: result.append(c) prev = c return ''.join(result)这样解码出的字符串就是候选车牌。注意prev的去重逻辑:CTC 允许同一个字符连续出现两次,但车牌上不会连续出现两个相同字符,所以简单的“相同字符合并”是合适的。如果遇到“粤B88888”这种连续 5 个 8,CTC 合并后只剩下一个 8,那就是识别错误。实际中这个情况不多,但最好在后处理里加上语法校验:第一位是汉字,第二位一定是大写字母,后面 4 到 5 位是字母或数字。不满足时,可以用置信度较低但结构合法的候补结果。
4.3 完整的单张图片推理流程
把检测和识别封装成一个函数,方便在服务或脚本中调用:
def detect_and_recognize(image): # 1. 检测 boxes, scores = yolo_detect(image, conf_thres=0.3, iou_thres=0.45) results = [] for box, score in zip(boxes, scores): warped = crop_plate_rect(image, box) warped = cv2.resize(warped, (168, 32)) # 2. 识别 preds = crnn_model(warped) # [seq_len, num_classes] plate = decode_ctc(preds, char_dict) results.append({ 'box': box, 'confidence': score, 'plate': plate }) return results这个函数看起来简单,但有三个细节会影响整体准确率:
conf_thres别设成 0.5 以上。车牌检测模型经过车牌数据集训练后,置信度普遍偏高,但漏检一张的代价比误检大,建议 0.25~0.35;iou_thres保持 0.45 即可,没必要调到 0.5,因为车牌之间不会重叠,只有误检时才会出现高 IoU 重复框;- 识别前要把
warped做归一化:/255.0,并保证三个通道顺序与训练时一致。很多人直接在 PyTorch 里用transpose(2,0,1),却忘了除以 255,导致识别率骤降 20%。
4.4 推理管线的常见参数调节表
下表是实际项目中比较常用的参数范围,供调试时参考:
| 参数 | 推荐值 | 调节方向 |
|---|---|---|
| det conf_thres | 0.25 ~ 0.35 | 漏检多就调低,误检多就调高 |
| det iou_thres | 0.45 | 固定即可 |
| 检测输入尺寸 | 960 | 小目标多就提高,但注意显存 |
| 识别输入高度 | 32 | 不能随意改,需匹配模型下采样倍数 |
| 识别输入宽度 | 按宽高比缩放后 pad | 不要固定拉宽 |
| 识别置信度过滤 | 无,语法校验优先 | 引擎不行才考虑过滤 |
这个表也是我做故障排查时优先看的一层。如果检测框没框住车牌,问题一定在检测参数或训练数据,而不是识别;如果检测框准但识别字符串非法,才去调 CRNN 输入和后处理。
5. YOLOv7 部署与识别性能优化:从 ONNX 到 TensorRT
识别模型训练好,不加优化地直接用 PyTorch 跑,速度往往只有几十 FPS,在卡口摄像头或边缘盒子上根本不够。把 YOLOv7 和 CRNN 都导出成 ONNX,再做 TensorRT 加速,是近年“yolov7 部署”场景里最普遍的做法。这一节讲清楚整个链路,并提示几个最容易忽略的坑。
5.1 YOLOv7 导出 ONNX 的注意事项
YOLOv7 官方仓库提供export.py,可以导出 ONNX、TensorRT 等格式。但直接运行默认参数,导出的模型可能带有NMS层,导致模型结构不标准,转 TensorRT 时反而变慢。我一般这样做:
python export.py \ --weights runs/train/exp/weights/best.pt \ --img-size 960 \ --batch-size 1 \ --simplify \ --include onnx \ --dynamic--simplify会用onnx-simplifier做图优化,--dynamic允许动态宽高。注意:如果不需要动态分辨率,建议直接固定输入尺寸为 960×960,TensorRT 的兼容性和速度都更好。导出后,用onnxruntime验证一下输出,确认输出张量是[batch, 25200, 85]这种原始预测结果,而不是已经经过 NMS 的框。后续 NMS 逻辑在 TensorRT 之外用 Python 或 C++ 自己实现。
5.2 CRNN 导出 ONNX 和 TensorRT 的序列维度
CRNN 导出相对简单,但要关注输入宽度的动态性。如果你的 CRNN 是固定 32×168 训练的,直接导出 32×168 的静态图,速度最快。但如果想支持各种车牌宽度,就导出动态宽:
import torch dummy_input = torch.randn(1, 3, 32, 168) torch.onnx.export( model, dummy_input, "crnn.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {3: "width"}, "output": {1: "seq_len"}}, opset_version=11, )导出时,opset_version不要低于 11,否则一些 RNN 算子(如 LSTM)在 ONNX Runtime 或 TensorRT 中会报“只支持旧格式”的兼容问题。TensorRT 对动态维度支持不如静态输入,所以生产环境如果图片尺寸固定,尽量用静态导出。
5.3 TensorRT 加速时的精度与形状约束
把两个 ONNX 转成 TensorRT engine,一般用官方自带的trtexec或 Python API。以 TensorRT 8.5 为例,一个直观的命令是:
trtexec --onnx=yolov7.onnx \ --saveEngine=yolov7.engine \ --fp16 \ --minShapes=input:1x3x960x960 \ --optShapes=input:8x3x960x960 \ --maxShapes=input:16x3x960x960注意--fp16模式:YOLOv7 的检测框回归对 FP16 不敏感,车牌识别 CRNN 的 CTC 输出对 FP16 也基本无损。但如果你在训练时用了图像归一化1/255.0,到 TensorRT 里务必保持相同的预处理顺序,比如先在 CPU 上除以 255,再输入网络,否则输出的置信度会整体偏移。
--minShapes/optShapes/maxShapes这些动态维度参数要保证 batch 范围内每个输入的分辨率一致。当使用动态输入时,TensorRT 的 Engine 会针对每个 shape 重新优化一次,第一次推理较慢,生产环境建议提前预热 10~20 次。
5.4 部署后验证与字符级调优
部署完成后,不要只看整体识别率。建议每张测试图输出“检测框坐标 + 置信度 + 识别字符串”的三元组,按错误类型分类。常见的排查技巧:
- 如果识别的字符串第一位不是合法省份简称,检查检测框是否切掉了车牌左边边缘,通常是因为检测框太紧,可以在裁剪时向左右各扩 5 个像素;
- 如果“渝 B12345”被识别成“豫 B12345”,说明训练数据里这两个汉字的样本量不均衡,需要针对性地增加该省份的图像;
- 如果新能源车牌中间多出一个小圆点(分隔符),CRNN 可能会把它识别成一个非法字符,需要在字符集中将该位置设为 blank,或后处理时直接过滤非字母数字和汉字的字符。
下面的这张表是我在部署后做回归测试时用到的错误定位表:
| 现象 | 可能原因 | 调整方向 |
|---|---|---|
| 检测框偏移 5~10 像素 | 训练时标签框边缘太紧 | 标注时外扩 2~3 像素 |
| 识别结果少末尾字符 | 宽度被压缩 | 等比缩放 + pad |
| 省份汉字混淆 | 该省份样本少 | 增强该省车牌图像 |
| 蓝牌和绿牌错识别 | 颜色通道预处理不一致 | 检查 BGR/RGB 顺序 |
| 检测漏掉远处车牌 | 输入分辨率太低 | 提高检测输入尺寸到 1280 |
最后再说一个具体技巧:在 TensorRT 部署 CRNN 时,常见难点是 LSTM 的时间步并行程度不够,导致 GPU 利用率上不去。可以把seq_len固定住(比如 42),并设置 TensorRT 的preview features里允许更激进的图优化。如果延迟还是高,可以考虑把 CRNN 替换成基于 CNN 的 CTC 卷积网络(如不含 RNN 的“全卷积 OCR”),在车牌这种短序列任务上,两者的精度几乎没有差别,但推理速度能快 40%——这也是在边缘设备上值得尝试的方向。
本文还有配套的精品资源,点击获取