ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

YOLOv11+CRNN车牌识别实战:从训练到部署全流程

2026/9/19 3:50:18 拓冰建站 浏览量
YOLOv11+CRNN车牌识别实战:从训练到部署全流程 1. 车牌识别系统整体架构与选型思路车牌识别这个方向说它是计算机视觉落地最经典的场景之一一点都不夸张。停车场闸机、高速卡口、小区门禁、移动执法终端背后跑的核心逻辑基本都是同一套先找到车牌在哪再把车牌上的字符读出来。我做过好几个类似的项目从最早的模板匹配加颜色分割到后来用SSD、YOLOv5再到现在YOLOv11配CRNN踩过的坑足够写一本小册子。这篇文章就把我最近一次从零搭建的完整过程拆开讲代码会给全参数会解释清楚环境配置、训练技巧、推理保存、小目标优化这些环节一个不落。先把这个系统的骨架说清楚。整个车牌识别系统拆成两个独立阶段检测阶段负责在整张图里定位车牌的位置输出车牌的边界框识别阶段负责把裁剪出来的车牌区域做字符序列识别输出具体的车牌号码。这种两阶段设计是工业界最稳的方案原因很简单——检测和识别对模型的要求完全不同。检测要的是对位置敏感、对小目标友好、速度快识别要的是对字符序列的上下文建模能力强、对字符切分不敏感。硬塞进一个端到端网络里当然也能做但训练难度和调参成本会成倍上升出了问题也不好定位到底是检测错了还是识别错了。选YOLOv11做检测是因为它在小目标检测上的表现比前几代明显更扎实。车牌在整张监控画面里往往只占很小一块区域尤其是远距离卡口场景车牌可能就几十个像素宽。YOLOv11在neck部分的结构调整和标签分配策略的优化让它在小目标召回率上有实打实的提升。而且Ultralytics这套框架的工程化做得太好了从训练到导出到部署一条命令的事省下来的时间可以花在数据上。选CRNN做识别是因为车牌字符识别本质上是一个不定长序列识别问题。传统做法是先做字符分割再逐个分类但分割这一步在车牌有污损、有倾斜、有光照不均的时候极其脆弱一个字符切歪了后面全错。CRNN用CNN提特征、RNN建序列依赖、CTC解码头做对齐整个流程不需要字符级别的标注只需要整张车牌的文本标签标注成本直接降一个量级。这个组合我用了好几年稳定性和准确率都经得起考验。提示两阶段方案的核心优势在于可解释性和可维护性。检测框偏了可以单独调检测模型字符识别错了可以单独补识别数据互不干扰。适合谁来参考这篇内容如果你已经了解Python基础、跑过至少一次深度学习训练那可以直接照着复现。如果你是完全的新手建议先把PyTorch的基本训练流程过一遍再回来不然环境配置那一步就会卡住。下面从环境搭建开始一步步往下走。2. 环境配置与数据准备实操2.1 YOLOv11环境配置的完整步骤环境配置这一步看起来简单实际上是最容易劝退人的环节。我见过太多人卡在CUDA版本不匹配、torch和torchvision版本对不上、ultralytics装完了但opencv又冲突了。这里给一套我实测下来最稳的组合按这个来基本不会出问题。先建虚拟环境这一步别省。用conda或者venv都行我个人习惯conda因为后面装一些科学计算库的时候依赖解析更省心。conda create -n lpr python3.10 -y conda activate lprPython版本选3.10这个版本在深度学习生态里兼容性最好3.11和3.12有些库还没跟上。接下来装PyTorch这里要注意先去PyTorch官网查一下跟你CUDA版本对应的安装命令。假设你用的是CUDA 11.8命令是这样的pip install torch2.1.0 torchvision0.16.0 torchaudio2.1.0 --index-url https://download.pytorch.org/whl/cu118装完之后验证一下GPU能不能用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果cuda.is_available()返回False别急着往下走先把这个问题解决。常见原因有三个驱动版本太老、CUDA版本和torch不匹配、装成了CPU版本的torch。驱动版本用nvidia-smi看右上角那个CUDA Version是你驱动支持的最高版本装的torch的CUDA版本不能超过它。然后是ultralyticsYOLOv11就靠它了pip install ultralytics装完之后跑一句yolo checks它会把你环境里的依赖版本都列出来有问题的会标红。这个命令我每次配完环境都会跑一遍比手动一个个查快多了。CRNN这边需要的依赖不多主要是几个文本处理和数据增强的库pip install opencv-python pillow numpy pandas matplotlib pip install albumentations pip install editdistanceeditdistance是用来算字符错误率的训练过程中监控指标要用。albumentations做数据增强比torchvision自带的transforms灵活很多尤其是对车牌这种需要保持宽高比的目标。注意不要在同一环境里装多个版本的opencvultralytics会依赖opencv-python如果你又装了opencv-contrib-python或者opencv-python-headless很容易出现cv2导入报错或者函数找不到的问题。装之前先pip list | grep opencv看一眼。2.2 车牌数据集的获取与标注规范数据这块是整个项目里最花时间的部分模型结构再漂亮数据不行都是白搭。车牌数据集的来源无非几种公开数据集、自己采集、合成生成。公开数据集像CCPDChinese City Parking Dataset是绕不开的它包含了几十万张各种场景下的中文车牌图片标注信息也很全包括边界框和车牌号码。但CCPD有个问题它的标注格式跟YOLO需要的格式不一样需要写脚本转换。自己采集的话用手机或者摄像头在停车场、路边拍就行注意覆盖不同光照条件——白天顺光、逆光、傍晚、夜间补光、地下车库这些场景都要有。车牌类型也要覆盖蓝牌、黄牌、绿牌新能源、白牌都要有样本不然模型遇到没见过的牌型直接懵。标注用LabelImg或者X-AnyLabeling都行画框的时候有个经验框要贴着车牌边缘画不要留太多空白也不要切掉车牌边缘。留白太多会让检测框偏大裁剪出来给CRNN的图里包含太多背景切掉边缘会让字符不完整识别直接出错。我一般会让框比车牌实际边缘大2到3个像素给裁剪留一点余量。标注文件是YOLO格式的txt每行是class_id x_center y_center width height坐标都是归一化到0到1之间的。车牌检测只有一个类别所以class_id都是0。转换CCPD标注的脚本大概长这样import os import cv2 import numpy as np def ccpd_to_yolo(ccpd_root, output_dir): os.makedirs(output_dir, exist_okTrue) for filename in os.listdir(ccpd_root): if not filename.endswith(.jpg): continue # CCPD的文件名里编码了边界框信息 parts filename.split(-) bbox_str parts[2] x1, y1, x2, y2 map(int, bbox_str.split(_)) img_path os.path.join(ccpd_root, filename) img cv2.imread(img_path) h, w img.shape[:2] x_center (x1 x2) / 2.0 / w y_center (y1 y2) / 2.0 / h bw (x2 - x1) / w bh (y2 - y1) / h label_path os.path.join(output_dir, filename.replace(.jpg, .txt)) with open(label_path, w) as f: f.write(f0 {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}\n)这个脚本只是示意实际CCPD的文件名编码规则更复杂还包含了车牌号码等信息需要根据具体版本调整解析逻辑。数据集划分按8:1:1分训练集、验证集、测试集。划分的时候要注意同一辆车的图片不能同时出现在训练集和验证集里不然验证指标会虚高。如果数据量够大最好按采集场景或者时间段来划分这样验证集更能反映真实泛化能力。2.3 数据增强策略与小目标优化车牌检测的数据增强跟通用目标检测不太一样有些增强手段要慎用。比如随机裁剪很容易把车牌裁掉一半这种样本喂给模型反而有害。水平翻转也要小心翻转之后车牌号码就反了虽然检测阶段只关心位置不关心内容但翻转后的车牌在现实中不存在模型学到的特征可能不自然。我常用的增强组合是这样的import albumentations as A train_transform A.Compose([ A.RandomBrightnessContrast(brightness_limit0.3, contrast_limit0.3, p0.5), A.HueSaturationValue(hue_shift_limit10, sat_shift_limit20, val_shift_limit20, p0.3), A.MotionBlur(blur_limit5, p0.2), A.GaussNoise(var_limit(10.0, 50.0), p0.2), A.RandomRain(p0.1), A.RandomFog(p0.1), A.Resize(640, 640), ], bbox_paramsA.BboxParams(formatyolo, label_fields[class_labels]))亮度对比度、色相饱和度这些颜色层面的增强对车牌很友好因为实际场景里光照变化就是最大的干扰因素。运动模糊模拟车辆行驶中的拍摄雨雾模拟恶劣天气这些都是真实卡口场景会遇到的。小目标优化是车牌检测的重中之重。YOLOv11默认的输入尺寸是640如果你的车牌在图中占比很小可以考虑把输入尺寸提到1280。但尺寸翻倍计算量是四倍增长推理速度会明显下降需要根据实际硬件权衡。另一个手段是调整anchor或者用YOLOv11自带的自动anchor计算让先验框更贴合车牌的宽高比。车牌的宽高比大概在3:1到5:1之间跟通用目标差别很大默认anchor不一定合适。还有一个技巧是在数据层面做文章把包含小目标的图片复制多份在训练时提高这类样本的采样概率。Ultralytics框架里可以通过在数据集yaml里设置fraction参数来控制使用多少比例的数据但更精细的采样需要自己写sampler。我一般会在训练前统计一下所有标注框的面积分布如果面积小于32x32的框占比超过30%就会考虑上采样这些小目标样本。3. YOLOv11检测模型训练与调优3.1 训练配置文件与关键参数设置Ultralytics的训练入口非常简洁一个yaml文件描述数据集一行命令启动训练。先写数据集配置文件license_plate.yamlpath: /data/license_plate train: images/train val: images/val test: images/test names: 0: license_plate路径可以用绝对路径也可以用相对路径相对路径是相对于这个yaml文件所在目录。names里只有一个类别就是车牌。然后启动训练yolo detect train \ modelyolo11n.pt \ datalicense_plate.yaml \ epochs200 \ imgsz640 \ batch16 \ device0 \ workers8 \ projectruns/lpr \ nameyolo11n_plate这里几个参数值得展开说。modelyolo11n.pt用的是YOLOv11的nano版本参数量最小推理最快适合部署在边缘设备上。如果对精度要求更高可以换成yolo11s.pt或者yolo11m.pt但速度会相应下降。我的经验是车牌检测这个任务nano版本在大部分场景下已经够用了因为车牌的特征相对固定不需要太大的模型容量。epochs200这个数字不是拍脑袋定的。车牌检测数据集如果有一万张左右的图片200轮基本能收敛。但要注意看训练曲线如果验证集loss在150轮之后还在降可以再加如果100轮就平了200轮纯属浪费。Ultralytics默认会保存最好的模型和最后一个模型训练完可以看results.csv里的mAP曲线来判断。batch16取决于你的显存。YOLOv11n在640尺寸下16的batch大概占4到6G显存。如果显存不够就往下调但batch太小会影响BN层的统计稳定性一般不建议低于8。显存够的话可以往上加batch大一些训练更稳。workers8是数据加载的进程数设成你CPU核心数的一半到三分之二比较合适。设太大了反而会因为进程切换开销导致加载变慢。提示训练之前先用yolo detect train modelyolo11n.pt datalicense_plate.yaml epochs1跑一轮确认数据路径、标注格式、显存占用都没问题再启动完整训练。这个习惯帮我省过很多次白跑一晚上的时间。3.2 训练过程监控与指标解读训练启动之后Ultralytics会在runs/lpr/yolo11n_plate/目录下生成一堆文件其中最重要的是results.csv和weights/目录。results.csv里记录了每一轮的loss和指标可以用pandas读出来画图import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(runs/lpr/yolo11n_plate/results.csv) df.columns df.columns.str.strip() fig, axes plt.subplots(1, 2, figsize(14, 5)) axes[0].plot(df[epoch], df[train/box_loss], labeltrain box loss) axes[0].plot(df[epoch], df[val/box_loss], labelval box loss) axes[0].set_xlabel(epoch) axes[0].set_ylabel(loss) axes[0].legend() axes[0].set_title(Box Loss) axes[1].plot(df[epoch], df[metrics/mAP50(B)], labelmAP50) axes[1].plot(df[epoch], df[metrics/mAP50-95(B)], labelmAP50-95) axes[1].set_xlabel(epoch) axes[1].set_ylabel(mAP) axes[1].legend() axes[1].set_title(mAP) plt.tight_layout() plt.savefig(training_curves.png)看训练曲线有几个关键判断点。train loss和val loss同时下降说明模型在正常学习。train loss继续降但val loss开始升说明过拟合了需要加数据增强、加正则化或者早停。两个loss都震荡不降可能是学习率太大或者数据有问题。mAP50能到0.95以上mAP50-95能到0.7以上对于车牌检测来说就算不错了。如果mAP50很高但mAP50-95很低说明框的位置不够准可能是标注质量有问题或者anchor设置不合理。训练完之后用验证集跑一下评估yolo detect val modelruns/lpr/yolo11n_plate/weights/best.pt datalicense_plate.yaml它会输出每个类别的precision、recall、mAP还有混淆矩阵。车牌检测只有一个类别主要看recall因为漏检比误检更致命。漏检意味着有车牌没被找到后面识别再准也没用。如果recall偏低优先检查小目标样本是不是不够或者输入尺寸是不是太小。3.3 模型导出与推理结果保存训练完的模型要部署到实际环境里通常需要导出成ONNX或者TensorRT格式。ONNX通用性好TensorRT在NVIDIA设备上速度快很多。yolo export modelruns/lpr/yolo11n_plate/weights/best.pt formatonnx opset12 simplifyTrue yolo export modelruns/lpr/yolo11n_plate/weights/best.pt formatengine halfTrue device0导出TensorRT的时候加halfTrue用FP16精度速度能再快一截精度损失很小。但要注意TensorRT引擎是跟具体GPU型号绑定的在A100上导出的引擎拿到4090上跑不了需要重新导出。推理的时候Ultralytics的API用起来很顺手from ultralytics import YOLO model YOLO(runs/lpr/yolo11n_plate/weights/best.pt) results model.predict( sourcetest_images/, conf0.25, iou0.45, imgsz640, saveTrue, save_txtTrue, save_cropTrue, projectruns/inference, nameplate_detection )saveTrue保存画了框的图片save_txtTrue保存检测框的坐标save_cropTrue保存裁剪出来的车牌区域。这三个参数在调试阶段特别有用save_crop出来的图可以直接拿去看识别效果。conf0.25是置信度阈值低于这个值的框会被过滤掉。车牌检测我一般设0.25到0.3之间设太高会漏检设太低会误检。iou0.45是NMS的IoU阈值车牌一般不会密集重叠这个值不用调。如果要处理视频流把source换成视频路径或者摄像头索引就行。Ultralytics会自动处理帧的读取和结果保存。但实时场景下要注意推理速度YOLOv11n在4090上跑640尺寸大概2到3毫秒一帧在Jetson Orin上大概15到20毫秒都能满足实时要求。注意save_crop保存的图片是按检测框裁剪的但检测框可能不是完全水平的如果车牌有倾斜裁剪出来的图会包含背景。CRNN对轻微的倾斜有一定鲁棒性但如果倾斜角度超过15度最好先做一下透视校正再送识别。4. CRNN识别模型搭建与训练4.1 CRNN网络结构解析与代码实现CRNN的结构分三块卷积层提特征、循环层建序列、转录层做解码。卷积层用的是VGG风格的backbone但做了简化去掉了全连接层只保留卷积和池化。输入是一张归一化到固定高度的灰度图宽度按比例缩放。经过卷积之后特征图的高度变成1宽度变成W/4通道数变成512。然后把这个特征图按宽度维度展开成序列每个时间步对应原图的一个水平条带。循环层用双向LSTM因为字符识别需要同时看左边和右边的上下文。比如“京A”和“京B”只看“京”是分不出来的必须结合后面的字母。双向LSTM的隐藏层维度一般设256两层堆叠。转录层用CTC这是CRNN能端到端训练的关键。CTC引入了一个blank符号允许网络在某个时间步输出“没有字符”这样就不需要预先知道每个字符的位置。解码的时候用贪心解码或者beam search贪心解码就是每个时间步取概率最大的字符然后合并重复字符并去掉blank。下面是PyTorch实现的CRNNimport torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden_size256, num_layers2): super(CRNN, self).__init__() # CNN backbone self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True), nn.Conv2d(256, 256, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d((2,2), (2,1), (0,1)), nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(True), nn.Conv2d(512, 512, 3, 1, 1), nn.ReLU(True), nn.MaxPool2d((2,2), (2,1), (0,1)), nn.Conv2d(512, 512, 2, 1, 0), nn.ReLU(True) ) self.rnn nn.LSTM(512, hidden_size, num_layers, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): conv self.cnn(x) b, c, h, w conv.size() assert h 1, 特征图高度必须为1 conv conv.squeeze(2) conv conv.permute(0, 2, 1) # (b, w, c) rnn_out, _ self.rnn(conv) output self.fc(rnn_out) output output.log_softmax(2) return output输入图片的高度统一到32宽度按比例缩放到不超过320不足的补白。这个尺寸是我试过在精度和速度之间平衡得比较好的。高度再小会丢失字符细节再大计算量上去了收益不明显。字符集要包含所有可能出现的字符。中文车牌包括省份简称京、沪、粤等、字母A-Z去掉I和O因为容易和1和0混淆、数字0-9再加上一个blank符号。总共大概65个字符左右。字符到索引的映射要固定下来训练和推理必须用同一套映射。CHARS 京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新 CHARS ABCDEFGHJKLMNPQRSTUVWXYZ CHARS 0123456789 CHARS blank CHARS # blank放在索引0 char_to_idx {c: i for i, c in enumerate(CHARS)} idx_to_char {i: c for c, i in char_to_idx.items()}4.2 CTC损失函数与训练技巧CTC损失PyTorch自带用起来很简单ctc_loss nn.CTCLoss(blank0, reductionmean, zero_infinityTrue)blank0指定blank符号的索引zero_infinityTrue防止出现无穷大的loss这个参数在训练初期特别重要因为网络还没学会对齐的时候很容易产生无效路径。训练的时候有几个坑要注意。输入长度必须大于等于标签长度CTC要求T L其中T是时间步数L是标签长度。车牌一般是7到8个字符CNN输出的时间步数大概在W/4左右如果输入宽度是320时间步数就是80远大于8没问题。但如果输入宽度设得太小比如64时间步数只有16还是够的但再小就可能出问题。学习率要设小一点CRNN比纯CNN难训学习率大了容易震荡。我一般用1e-3起步配合余弦退火或者ReduceLROnPlateau。优化器用Adam比SGD收敛快。optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100)batch size不要太大CTC loss对batch内的对齐比较敏感batch太大梯度方向会互相干扰。我一般用32到64之间。训练循环的核心逻辑for epoch in range(num_epochs): model.train() for images, labels, label_lengths in train_loader: images images.to(device) labels labels.to(device) label_lengths label_lengths.to(device) outputs model(images) # (T, B, C) outputs outputs.permute(1, 0, 2) # (B, T, C) input_lengths torch.full( size(outputs.size(0),), fill_valueoutputs.size(1), dtypetorch.long, devicedevice ) loss ctc_loss( outputs.log_softmax(2).permute(1, 0, 2), labels, input_lengths, label_lengths ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() scheduler.step()梯度裁剪那一步别省CRNN的梯度有时候会爆炸裁剪到5.0能有效防止训练崩溃。提示训练初期loss可能先降后升再降这是CTC在寻找对齐路径的正常现象不要急着停。一般到20轮之后loss才会稳定下降。4.3 识别效果评估与错误分析评估CRNN不能只看loss要看字符准确率和整牌准确率。字符准确率是逐字符对比整牌准确率是整张车牌完全正确才算对。实际业务里整牌准确率更重要因为错一个字符整个车牌就错了。用编辑距离算字符错误率import editdistance def compute_accuracy(model, dataloader, idx_to_char, device): model.eval() total_chars 0 total_edits 0 total_plates 0 correct_plates 0 with torch.no_grad(): for images, labels, label_lengths in dataloader: images images.to(device) outputs model(images) outputs outputs.permute(1, 0, 2) # 贪心解码 preds outputs.argmax(2) # (B, T) for i in range(preds.size(0)): pred_seq [] prev -1 for p in preds[i]: p p.item() if p ! prev and p ! 0: pred_seq.append(idx_to_char[p]) prev p pred_str .join(pred_seq) label_str .join([idx_to_char[l.item()] for l in labels[i][:label_lengths[i]]]) total_edits editdistance.eval(pred_str, label_str) total_chars len(label_str) total_plates 1 if pred_str label_str: correct_plates 1 char_acc 1 - total_edits / total_chars plate_acc correct_plates / total_plates return char_acc, plate_acc字符准确率能到99%以上整牌准确率能到95%以上就算训练得不错了。如果整牌准确率明显低于字符准确率说明错误集中在某些特定字符上比如“0”和“O”、“1”和“I”、“8”和“B”这些形近字符需要额外补数据。错误分析的时候把预测错的样本单独存下来按错误类型分类形近字错误、漏字、多字、完全错乱。形近字错误补对应样本就行漏字多字通常是CTC解码的问题可以试试beam search解码完全错乱说明模型没学好检查数据标注和训练配置。5. 检测与识别串联及系统集成5.1 两阶段串联的完整推理流程检测和识别分别训好之后要把它们串起来。流程很直接原图送YOLOv11拿到车牌框按框裁剪出车牌区域做预处理送CRNN拿到识别结果。import cv2 import numpy as np import torch from ultralytics import YOLO class LicensePlateRecognizer: def __init__(self, det_model_path, rec_model_path, devicecuda): self.det_model YOLO(det_model_path) self.rec_model CRNN(num_classeslen(CHARS)).to(device) self.rec_model.load_state_dict(torch.load(rec_model_path, map_locationdevice)) self.rec_model.eval() self.device device def preprocess_plate(self, plate_img): h, w plate_img.shape[:2] target_h 32 target_w int(w * target_h / h) target_w min(target_w, 320) plate_img cv2.resize(plate_img, (target_w, target_h)) plate_img cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) plate_img plate_img.astype(np.float32) / 255.0 plate_img (plate_img - 0.5) / 0.5 plate_img np.expand_dims(plate_img, axis0) plate_img np.expand_dims(plate_img, axis0) return torch.from_numpy(plate_img).float() def decode(self, output): preds output.argmax(2) result [] prev -1 for p in preds[0]: p p.item() if p ! prev and p ! 0: result.append(idx_to_char[p]) prev p return .join(result) def recognize(self, image_path): results self.det_model.predict(image_path, conf0.25, verboseFalse) img cv2.imread(image_path) plates [] for r in results: boxes r.boxes for box in boxes: x1, y1, x2, y2 map(int, box.xyxy[0].tolist()) plate_img img[y1:y2, x1:x2] if plate_img.size 0: continue input_tensor self.preprocess_plate(plate_img).to(self.device) with torch.no_grad(): output self.rec_model(input_tensor) plate_text self.decode(output) plates.append({ bbox: [x1, y1, x2, y2], text: plate_text, confidence: float(box.conf[0]) }) return plates这个类封装了完整的推理流程输入图片路径输出车牌列表每个元素包含边界框、识别文本和检测置信度。5.2 车牌校正与预处理细节实际场景里检测出来的车牌框往往不是水平的有倾斜、有透视变形。直接裁剪送CRNN识别率会打折扣。加一步校正能明显提升效果。校正的思路是找到车牌的四个角点做透视变换。角点可以用检测框的四个角近似但更准的做法是在裁剪出来的车牌区域里做边缘检测找最小外接矩形。OpenCV的minAreaRect就能干这个事def correct_plate(self, plate_img): gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return plate_img max_contour max(contours, keycv2.contourArea) rect cv2.minAreaRect(max_contour) box cv2.boxPoints(rect) box np.int0(box) # 按左上、右上、右下、左下排序 box self.order_points(box) width int(max(np.linalg.norm(box[0] - box[1]), np.linalg.norm(box[2] - box[3]))) height int(max(np.linalg.norm(box[0] - box[3]), np.linalg.norm(box[1] - box[2]))) dst np.array([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypenp.float32) M cv2.getPerspectiveTransform(box.astype(np.float32), dst) warped cv2.warpPerspective(plate_img, M, (width, height)) return warped校正之后再做灰度化、归一化送CRNN。这一步在倾斜角度大的场景下能提升好几个点的整牌准确率。注意校正不是万能的如果车牌本身污损严重或者被遮挡边缘检测可能找不到正确的轮廓这时候直接返回原图更稳妥。加个判断如果检测到的轮廓面积跟原图面积比小于0.5就跳过校正。5.3 批量推理与结果保存实际部署的时候往往需要批量处理图片或者视频流。批量推理要注意显存管理不要把整个文件夹的图片一次性读进来。import os import json from tqdm import tqdm def batch_recognize(recognizer, input_dir, output_json): results {} image_files [f for f in os.listdir(input_dir) if f.lower().endswith((.jpg, .png, .jpeg))] for filename in tqdm(image_files): image_path os.path.join(input_dir, filename) try: plates recognizer.recognize(image_path) results[filename] plates except Exception as e: results[filename] {error: str(e)} with open(output_json, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) return results结果存成JSON方便后续对接业务系统。如果要做可视化把识别文本画到原图上def draw_results(image_path, plates, output_path): img cv2.imread(image_path) for plate in plates: x1, y1, x2, y2 plate[bbox] cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, plate[text], (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imwrite(output_path, img)视频流处理的话用cv2.VideoCapture逐帧读每帧跑一次识别结果画到帧上再用cv2.VideoWriter写出去。实时性要求高的场景可以隔帧检测中间帧用跟踪算法补YOLOv11自带的跟踪功能就能用。6. 常见问题排查与实战避坑经验6.1 检测阶段典型问题速查问题现象可能原因排查方法解决方案小目标车牌漏检严重输入尺寸太小、小目标样本不足统计标注框面积分布提高imgsz到1280、上采样小目标样本检测框偏大包含太多背景标注框留白过多可视化标注框重新标注框贴紧车牌边缘误检率高把相似矩形当车牌负样本不足看误检图片加入不含车牌的负样本图片训练loss不降学习率太大、数据标注错误检查标注格式和坐标范围降低学习率、修正标注验证集mAP远低于训练集过拟合对比训练和验证曲线加数据增强、加dropout、早停推理速度慢模型太大、输入尺寸太大测单帧耗时换nano模型、导出TensorRT小目标漏检是我遇到最多的反馈。有个项目里车牌在1080p画面里只有40个像素宽用640输入尺寸训练recall只有0.7出头。后来把输入提到1280recall直接到0.92。但速度从3毫秒掉到12毫秒好在业务对实时性要求不高能接受。如果速度不能妥协另一个思路是用SAHISlicing Aided Hyper Inference做切片推理把大图切成小块分别检测小目标就变成大目标了代价是推理次数增加。6.2 识别阶段典型问题速查问题现象可能原因排查方法解决方案形近字混淆0/O、1/I训练样本中形近字对不足统计混淆矩阵补充形近字样本、加字符级数据增强整牌准确率远低于字符准确率错误集中在特定位置按位置统计错误率针对性补数据、检查车牌类型覆盖CTC loss不收敛学习率太大、输入长度不足检查T和L的关系降低学习率、增大输入宽度识别结果多字或少字CTC解码问题对比贪心和beam search用beam search解码、调整blank权重中文省份简称识别差中文样本不足按字符统计准确率补充中文车牌样本、单独采样夜间车牌识别率低训练数据缺少夜间样本按光照条件分组评估补充夜间样本、加亮度增强形近字问题是CRNN的老大难。我试过在字符集里直接把容易混淆的字符合并比如把O和0都映射到同一个类别识别出来之后再根据车牌规则做后处理。中国车牌里字母O和数字0不会同时出现在容易混淆的位置省份简称后面跟的是字母字母后面跟的是数字或字母根据位置规则能反推。这个方法能把形近字错误率降一半以上。6.3 端到端联调的经验教训检测和识别单独评估都挺好串起来效果打折扣这是最常见的情况。原因通常出在裁剪环节。检测框稍微偏一点裁剪出来的车牌就缺边或者多背景CRNN的输入分布跟训练时不一致识别率就掉了。我的做法是在训练CRNN的时候不直接用标注框裁剪而是对标注框做随机扰动——随机平移几个像素、随机缩放一下模拟检测框不完美的情形。这样CRNN对框的偏差就有鲁棒性了。扰动幅度控制在10%以内太大了反而学不到正确特征。另一个经验是检测和识别的输入分辨率要匹配。检测用640训练识别用32x320训练如果检测框裁剪出来的车牌高度只有20个像素放大到32会模糊识别率就低。解决办法是在检测阶段就把小目标处理好确保裁剪出来的车牌高度至少30个像素。如果达不到要么提高检测输入尺寸要么在裁剪后做超分辨率重建但后者会增加计算量。提示联调阶段一定要把中间结果存下来看。检测框画出来、裁剪图存出来、识别结果打出来逐环节对比。我见过有人检测框是反的x1x2裁剪出来是空图CRNN输出全是blank查了半天以为是模型问题。6.4 模型部署与性能优化建议部署到生产环境性能是绕不开的。几个优化方向模型量化。PyTorch支持动态量化和静态量化INT8量化能把模型体积压到四分之一推理速度提升2到3倍精度损失通常在1个点以内。CRNN的LSTM层对量化比较敏感建议只量化CNN部分。TensorRT加速。YOLOv11导出TensorRT引擎后在NVIDIA GPU上速度能提升2到5倍。CRNN也可以导出ONNX再转TensorRT但LSTM在TensorRT里的支持有限可能需要把LSTM换成卷积或者注意力结构。批处理。如果业务允许攒批把多张图拼成一个batch送推理GPU利用率能上去吞吐量提升明显。但延迟会增加实时场景要权衡。多线程流水线。检测和识别串行执行的话GPU在检测的时候识别在等反之亦然。可以用两个线程分别跑检测和识别中间用队列传递数据GPU利用率能提升30%以上。我在一个停车场项目里用了TensorRT加多线程流水线单卡T4能跑到200路视频的实时车牌识别每路大概5毫秒的处理时间。这个性能对于大部分中小型停车场完全够用了。最后分享一个数据层面的经验持续收集badcase并迭代。系统上线之后把识别置信度低的结果、检测框异常的图片自动存下来定期人工审核后加入训练集。我负责的一个项目迭代了五轮整牌准确率从最初的89%提到了97.5%靠的就是持续的数据闭环。模型结构没怎么动就是数据越来越贴合实际场景。这个思路比调参管用得多也是工业界做视觉系统最实在的方法。