ARTICLE DETAIL

资讯详情

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

从YOLO到CRNN:车牌检测识别全流程实战与部署调优

从YOLO到CRNN:车牌检测识别全流程实战与部署调优 简介这是一套基于Python的车牌检测与识别系统完整工程代码包面向计算机视觉初学者、算法工程师及相关课题设计者解决从车牌定位到字符识别的全流程实现问题。资源覆盖图像预处理、车牌定位、字符分割与识别四大核心环节附有基于SVM与CNN的识别思路、界面化交互程序及运行脚本。压缩包共48个文件类型涵盖7个Python源码、模型训练数据dat、UI界面文件、演示视频、说明文档整体约22MB。目前已有149人学习与下载。通过完整代码与演示视频可快速掌握OpenCV图像处理与机器学习模型结合落地的技巧直观理解各模块调用关系便于在此基础上修改模型、扩展数据集或集成到交通管理项目中既能用于实验学习也能支撑实际项目二次开发。1. 从一张车辆照片到车牌号的完整链路车牌检测和识别系统在停车、门禁、交警卡口里通常被叫做 LPR。用 Python 落地时大多数人会先找现成的开源车牌检测识别模型但真正换成自己的场景总卡在三个地方检测框把旁边的字或尾灯带进来、OCR 把“京”和“津”混淆、白天正常晚上就漏检。这里的关键是把系统拆成“目标检测 光学字符识别”两级流水线并用参数和前后处理把两级之间的误差吃住。这篇文章的目标读者是已经会用 Python、跑过至少一个深度学习推理脚本的工程师。我不打算写一个不可复现的演示而是给出从检测模型训练、OCR 字符集设计、推理串联到部署验证的完整思路和技术参数。新手可以按章节顺序把代码拼起来资深工程师可以直接跳到参数表和易错点。2. 车牌检测的选型与训练从 YOLO 到超参数设置项目里第一步是确定用什么检测模型。很多团队会直接使用现成的开源车牌检测识别模型但这里的重点是模型输出的是矩形框而不是文字。检测框能否包住整个车牌是后续识别正确的必要条件。如果框只覆盖一半字符再好的 OCR 也无法恢复。因此检测器的目标不是“看见车”而是稳定输出高交并比的车牌框。2.1 检测模型选型车牌检测与通用目标检测的差别车牌检测任务有三个明显不同于通用目标检测的特征。第一目标小尤其在监控画面里车牌高度往往只有 20 到 30 像素输入分辨率低时特征很容易在下采样中消失。第二长宽比相对固定大部分车牌在 1.8:1 到 3.5:1 之间但倾斜会使水平检测框里带进大量背景。第三正负样本差异集中在颜色和纹理深度学习模型比较容易学到边界但容易把车灯、车身广告、矩形 LOGO 当车牌。通用目标检测里的 YOLO 系在车牌检测上用得最多。YOLOv5、YOLOv8 以及后续的 YOLO 系列训练和部署生态完整导出 ONNX 后在 CPU 或 GPU 上都能跑。SSD 在早期项目里也常见但候选框尺度设计在面对小目标时并不比 YOLO 灵活。Faster R-CNN 精度上限高推理速度慢适合离线分析不适合实时视频流。所以我一般会以 YOLOv8s 作为默认起点因为车牌类别单一且形状简单更大的模型容量并不是首要瓶颈。输入分辨率是一个比模型尺寸更容易被忽略的超参数。YOLO 的默认 image size 是 640但车牌检测要明显高于这个值。当检测器的输入分辨率提高到 960 或 1280 时小特征图上的车牌信息保留得更多mAP50 经常能提升 5 到 10 个百分点。代价是推理速度下降因此需要根据视频路数折中。单路实时流一般选 960双路以上选 768。2.2 车牌检测数据集的标注规范与增强策略数据标注直接影响模型能否学会“车牌”的边界。标注时统一使用能包含整个车牌的最小矩形不包含安装车牌的保险杠区域也不要把边缘的铆钉裁掉。框太小会让 OCR 看不到两端的汉字和字母框太大则会把车身亮条等干扰信息带进识别模块。公共数据集的来源包括一些开源车牌检测识别模型项目它们通常以压缩包形式提供训练集和标注文件。使用前需要检查标注格式常见的是 YOLO txt 格式和 VOC XML 格式。YOLO txt 格式中每一行是class x_center y_center width height坐标是归一化值。如果数据集里的框坐标明显超出 0 到 1需要先修正否则训练会直接报错或产生大量无效正样本。数据增强要针对“小目标”和“倾斜”两个难点。这里给出一个用 Albumentations 实现的基础增强配置import albumentations as A train_transform A.Compose( [ A.MotionBlur(blur_limit(3, 5), p0.3), A.RandomBrightnessContrast( brightness_limit0.2, contrast_limit0.3, p0.6 ), A.RandomSizedBBoxSafeCrop( width960, height960, erosion_rate0.3, p0.5 ), A.HueSaturationValue( hue_shift_limit15, sat_shift_limit25, val_shift_limit20, p0.5 ), A.Perspective(scale(0.03, 0.08), p0.2), ], bbox_paramsA.BboxParams(formatyolo, label_fields[class_labels]), )MotionBlur 模拟车辆运动造成的拖影RandomBrightnessContrast 解决白天到夜间的光照变化RandomSizedBBoxSafeCrop 在随机裁剪时保证车牌框不丢Perspective 模拟俯视和侧视角度。这些参数不能直接套用到所有项目。运动模糊概率太高会让原本清晰的图像变难透视变化太大会破坏车牌的刚体约束。通常我会把 Perspective 的 scale 控制在 0.05 左右先保证不丢样本再看验证集的实际结果。标注后还要做一次数据清洗。筛选出两类错误样本一类是框内只有半个车牌一类是框内没有完整字符。这两类样本即使数量少也会严重干扰检测器因为它们会让模型学到错误的边界模式。清洗后的数据集按 8:1:1 划分训练集、验证集和测试集。测试集不能和训练集来自同一段视频尽量包含另一个摄像头或另一段时间采集的图像这样才能反映泛化能力。2.3 车牌检测训练里的关键参数与训练命令训练配置可以直接使用 YOLO 的yolo命令但不要用默认参数直接训练。下面是我常用的一组参数yolo detect train \ dataplate.yaml \ modelyolov8s.pt \ epochs120 \ imgsz960 \ batch16 \ lr00.01 \ close_mosaic10plate.yaml 内容比较简单只需要声明数据集路径和一个类别path: ../datasets/plate train: images/train val: images/val nc: 1 names: [plate]从 COCO 预训练权重开始微调比从头训练收敛快得多。imgsz 必须和推理时保持一致不能训练时写 960、推理时写 640两者不一致会直接影响小目标召回。batch 需要根据 GPU 显存调整16 在 24GB 显存下对 yolov8s 是安全的显存不够时优先降低 batch而不是降低 imgsz。lr0 从 0.01 降到 0.005 也可以但多数情况下 0.01 配合 YOLO 自带调度器已经足够稳定。close_mosaic 表示最后 10 个 epoch 关闭马赛克增强避免模型在收敛阶段始终看到拼接后的异常边界。训练过程中val/box_loss 是比训练 loss 更值得看的指标。如果 box_loss 下降但 mAP50 不涨大概率是检测框位置不准或者标签框太宽。更常见的坑是训练集和验证集里大量车牌来自同一个视频片段导致 mAP 虚高。把连续视频按时间间隔抽样成图像而不是把每一帧都放进数据集能有效避免数据泄漏。注意检测模型训练完成后先跑一遍验证集挑出 20 张异常样本比如漏检和重复检测的确认是框太小、角度太大还是光线不好再进入识别环节。3. 车牌识别 OCR 的字符集、CRNN 与输出后处理检测模型给出来的是一块包含车牌的图像接下来的任务是把这块图像转成字符串。这个环节在工程里也叫车牌识别。很多开源车牌检测识别模型把检测和识别打包在一起使用方便但真到现场调精度还是得回到字符集、模型结构和后处理算法上。3.1 车牌字符集的构成与常见误识别字符中国车牌不是完全随机的字符序列。民用车牌第一位是省份简称汉字第二位是发牌机关字母后面是 5 位字符其中不使用字母 I 和 O避免与数字 1 和 0 混淆。新能源车牌在最后多一位总共 8 个字符。车牌字符集可以这样定义位置字符来源示例第1位省份简称汉字京、沪、苏、粤第2位大写英文字母A、B、C…Z去 I/O第3位起数字与大写英文混合0-9、A-H、J-N、P-Z汉字不能省略否则输出就是“A12345”无法还原真实车牌。省份简称里最容易混淆的是“京”和“津”、“冀”和“鄂”这类笔画接近的汉字。解决方案有两个一是训练数据中对每个省份简称保持均匀采样不能用单一省份的车牌训练二是在后处理阶段把非法字符和可信度低的输出做规则纠正。3.2 CRNN CTC 识别网络的结构与解码车牌 OCR 的输入是一张宽度远大于高度的图片输出是一个不等长字符序列。CRNN 是这类任务最常见的结构。前面用 CNN 提取图像特征中间用双向 LSTM 建模序列上下文最后用 CTC 解决序列对齐问题。CTC 的好处是不需要把每个字符精确分割模型只需要输出按时间展开的字符概率序列解码时去掉重复和空白位即可。车牌子图一般归一化到高度 48宽度可以是 168 或按原始比例动态调整。过小的宽度会让字符特征挤在一起过大的宽度会让模型学到太多背景。这里给出一个 CTC 贪心解码函数它是推理时的基础部件import numpy as np def ctc_best_path(log_probs: np.ndarray, blank: int 0) - list[int]: log_probs: (seq_len, vocab_size) 返回去除 blank 和连续重复的字符索引列表 pred np.argmax(log_probs, axis1) result [] prev blank for p in pred: if p ! prev and p ! blank: result.append(int(p)) prev p return result这个函数把模型输出的每一帧概率向量转换成字符序号。blank 用于分隔同一个字符重复输出的情况比如车牌中连续出现两个“1”CTC 会用 blank 隔开。如果不用这个逻辑连续相同字符会被错误合并成一个。除了贪心解码还可以用 beam search 在概率序列上做更全局的搜索但车牌字符集小贪心解码已经覆盖大部分场景。训练 CRNN 时损失函数使用 CTC Loss。需要说明的是车牌字符长度在不同场景下不一致民用车牌是 7 位新能源是 8 位训练时不需要把固定长度作为约束CTC 天然支持可变长输出。batch 内不同样本宽度不同时需要做 padding并且在计算损失时把 padding 位置的标签设为 ignore避免空白帧贡献噪声。3.3 车牌识别后处理正则匹配与字符纠正OCR 模型输出原始字符后不能直接当作最终结果。在真实场景中模型经常把数字“0”识别成字母“O”把数字“1”识别成字母“I”。因为字母 I 和 O 在车牌字符集中不出现这种替换可以安全纠正。一个基础但有效的后处理函数如下import re CHAR_MAP {O: 0, I: 1} def postprocess(raw_text: str) - str: text .join(CHAR_MAP.get(ch, ch) for ch in raw_text) pattern r^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][\dA-HJ-NP-Z]{5,6}$ return text if re.match(pattern, text) else 这个函数先把非法字符 O 和 I 替换成数字 0 和 1再用正则匹配车牌格式。正则中的[A-HJ-NP-Z]表示去掉 I 和 O 之后的大写字母集合[\dA-HJ-NP-Z]{5,6}表示后面 5 到 6 位混合字符。匹配失败时返回空字符串而不是强行猜测。这样在车辆追踪和停车计费等下游业务中可以显著降低错误匹配率。不要用简单的固定长度截断因为新能源车牌的 8 位长度会把这个逻辑打乱。如果业务需要部分结果可以把正则里对应位置改成[\dA-HJ-NP-Z_]用下划线占位但默认返回空字符串更安全。后续部署阶段还需要定期收集用户反馈来调整字符映射表。不同省份的汉字字形差异不大但摄像头角度、车牌材质和磨损程度会导致同一字符在不同场景下错得不一样。4. 用 OpenCV 和 ONNX Runtime 串起车牌检测与识别全流程检测和识别两个模型单独验证没问题之后真正要花时间的是把它们拼成一条可以对外服务的流水线。实时视频流里每一帧都可能同时出现多辆车检测模型输出的多个框需要依次进入识别模块识别模型输出的字符串还要和检测框坐标一起返回供上层业务使用。4.1 整体流水线与模型加载方式我一般会把系统拆成三个对象检测器、车牌校正器、识别器。检测器负责返回一组坐标框车牌校正器把倾斜的车牌图像转成水平矩形识别器输出规范化字符串。实际部署时识别模型可以导出为 ONNX推理框架用 ONNX Runtime一方面避免 GPU 和 PyTorch 版本绑定另一方面 CPU 上的推理性能更好。常见做法是检测模型继续使用 ultralytics 的 YOLO 接口因为它内置了 NMS、模型导出和预处理调试方便。OCR 部分直接用 ONNX Runtime 加载因为 CRNN 结构相对固定转换一次之后就不再改图结构。有一个容易忽略的问题ONNX 模型的输入名不是固定的需要先读一次模型文件确认输入输出名而不是从训练代码里的变量名猜测。4.2 用车牌边缘做透视矫正而不是直接裁剪检测模型输出的框是轴对齐矩形。车辆倾斜时这个矩形框内除了车牌还包含一部分车身背景。直接把这样的图像送入 OCR会让识别精度下降。正确做法是在检测框内部用边缘提取找到车牌的四个角点然后做透视变换。这里提供一个基于 OpenCV 的校正函数import cv2 import numpy as np def align_plate(plate_bgr: np.ndarray) - np.ndarray: gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) edges cv2.Canny(gray, 120, 200) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return cv2.resize(plate_bgr, (168, 48)) c max(contours, keycv2.contourArea) rect cv2.minAreaRect(c) angle rect[2] if angle 45: angle angle - 90 h, w plate_bgr.shape[:2] center (w // 2, h // 2) M cv2.getRotationMatrix2D(center, angle, 1.0) rotated cv2.warpAffine(plate_bgr, M, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) gray_r cv2.cvtColor(rotated, cv2.COLOR_BGR2GRAY) edges_r cv2.Canny(gray_r, 120, 200) contours_r, _ cv2.findContours(edges_r, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours_r: return cv2.resize(rotated, (168, 48)) c max(contours_r, keycv2.contourArea) x, y, w, h cv2.boundingRect(c) aligned rotated[y:y h, x:x w] return cv2.resize(aligned, (168, 48))这里先用直方图均衡化提高对比度再用 Canny 提取边缘。minAreaRect 返回的角度以水平轴为基准角度大于 45 度时需要减去 90 度否则旋转方向会反过来。旋转后再次提取边缘并裁剪是为了去掉旋转产生的黑边。如果找不到轮廓说明车牌区域可能被强光完全淹没此时退化为轴对齐裁剪给 OCR 一个机会。边缘提取参数不能对所有场景通用。背光环境下Canny 阈值可以降到 80 到 150过曝时需要提高阈值。不建议把这两个值写死在代码里而是做成配置项让现场调试时可以直接调整。4.3 车牌检测与车牌识别串联的完整推理类下面这个类把前面的关键步骤合成一个可运行的流程import cv2 import numpy as np import onnxruntime as ort from ultralytics import YOLO class PlatePipeline: def __init__(self, det_path: str, ocr_path: str, conf_thres: float 0.45, iou_thres: float 0.5, input_size: int 960): self.det YOLO(det_path) self.conf_thres conf_thres self.iou_thres iou_thres self.input_size input_size self.ocr ort.InferenceSession(ocr_path) self.ocr_input_name self.ocr.get_inputs()[0].name # 索引 0 留给 CTC blank字符顺序需和训练字典一致 self.idx2char [blank] list( 京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领 ABCDEFGHJKLMNPQRSTUVWXYZ0123456789 ) def detect(self, img_bgr: np.ndarray): res self.det.predict( img_bgr, imgszself.input_size, confself.conf_thres, iouself.iou_thres, verboseFalse )[0] return res.boxes.xyxy.cpu().numpy().astype(int) def recognize(self, plate_bgr: np.ndarray) - str: plate_bgr align_plate(plate_bgr) img cv2.resize(plate_bgr, (168, 48)) inp np.ascontiguousarray(img).astype(np.float32) / 255.0 inp np.transpose(inp, (2, 0, 1))[None] logits self.ocr.run(None, {self.ocr_input_name: inp})[0] ids ctc_best_path(logits[0]) chars [self.idx2char[i] for i in ids if 0 i len(self.idx2char)] return postprocess(.join(chars)) def __call__(self, img_bgr: np.ndarray): results [] for x1, y1, x2, y2 in self.detect(img_bgr): plate img_bgr[y1:y2, x1:x2] text self.recognize(plate) results.append({ bbox: [int(x1), int(y1), int(x2), int(y2)], plate: text }) return resultsdetect 方法使用 YOLO 的 predict把置信度阈值和 NMS 阈值直接传进去。recognize 在送入 OCR 之前先做透视矫正再转换成 ONNX Runtime 需要的 NCHW 输入格式。postprocess 和 ctc_best_path 来自上一节实际工程中要放在同一个模块里避免逻辑分散。输出结果里包含车牌字符串和检测框坐标空字符串表示识别失败需要在上层业务中决定是丢弃还是之后重查。置信度阈值越低输出框越多但误检也越多。视频流模式下0.35 到 0.45 是常用范围静止帧审计场景可以提高到 0.5。NMS 阈值保持 0.5 即可。如果发现同一个车牌被水平方向拆成两个框再适当调低。注意OCR 模型的索引字典必须和训练时保持一致。如果 idx2char 顺序错位后处理规则再完整也无法得到正确结果。4.4 输入分辨率与置信度参数的联动全流程耗时主要由检测部分决定因为检测是对全图推理OCR 是对局部小图推理。不同配置下的效率关系如下检测输入尺寸推理耗时GPU小目标召回推荐场景640较低一般车辆近景、道闸960中等高监控全场景1280较高最高远距离卡口这个表基于单卡推理的常见经验值不同硬件差异较大。摄像头安装在 6 米以上时车牌宽度在画面中可能不到 60 像素需要较高输入尺寸。摄像头装在道闸杆旁车辆离镜头近640 也能得到不错结果。精度优先时选 960实时路数多时再下调。识别部分虽然耗时低输入尺寸也不能随意缩小。如果检测框质量好单次 OCR 推理往往不到 5 毫秒如果检测框包含大量背景透视矫正后的图像里有多余内容识别时间会略微增加。整体优化时优先减检测输入尺寸而不是动 OCR 结构。5. 落地部署的精度调优与回归验证方法正式上线前我会对整套系统做一次参数回归。重点不是提高最好的那一次准确率而是减少最差场景下的失败。5.1 用置信度阈值与 NMS 做首轮筛选置信度阈值和 NMS 阈值是检测端最容易改的两个参数。常见做法是从真实视频帧中抽出 500 张分别用 0.2、0.3、0.4、0.5 四组置信度跑检测统计每组下的漏检数和误检数。NMS iou 从 0.4 到 0.7 扫描观察同一车牌是否被输出两次。车辆并排且车牌相邻时检测框容易互相重叠这时可以把 iou 降到 0.45减少重复输出。5.2 多帧投票与模糊帧丢弃视频流中单帧识别出的字符串不稳定。可以按检测框的位置做跨帧关联对同一个车牌 ID 保留最近 5 帧的识别结果选择出现次数最多的字符串作为最终结果。连续 3 帧都为空字符串时标记为“识别失败”不要写入数据库。这个方法不需要引入额外跟踪模型只做基于检测框 IoU 的简单关联就能消除一半以上的闪烁问题。5.3 用真实场景样本做回归验证最后准备一个 200 张左右的回归测试集包含夜间、雨雾、倾斜和双车并行四类样本。跑完流水线后统计两个指标检测 IoU 大于 0.75 的比例以及字符级准确率。字符级准确率比整牌准确率更容易暴露问题。如果某个字符连续固定错误可以回到字符映射表增加修正规则。把这两类样本单独拎出来跟踪每次调整增强参数或后处理规则后重跑一次比只看整牌正确率更容易发现回归问题。本文还有配套的精品资源点击获取
返回列表