2026/9/17 14:33:26

树莓派车牌识别实战:目标检测到OCR识别与NCNN部署全攻略

树莓派车牌识别实战:目标检测到OCR识别与NCNN部署全攻略 简介基于树莓派与PyTorch配合YOLOv5、LPRNet、STNet三个开源模型设计实现的车牌检测与识别系统面向毕业设计、课程设计、工程实训及嵌入式竞赛等场景也适合希望入门深度学习和树莓派开发的初学者资源包含完整源码与工程文件覆盖深度学习环境配置和树莓派端部署说明代码经过严格测试可直接烧录运行复刻与二次开发都很方便。压缩包共117个文件、约7.64MB以Python源码、YAML配置、模型权重、Shell部署脚本和Dockerfile为主另附Markdown说明文档与教程式Notebook示例其中pth文件可直接加载预训练模型界面文件与ipynb示例辅助演示和环境验证目前已有271人学习使用适合参考此项目完成课程作业或竞赛原型。在项目实战中可基于这套方案快速搭建车牌识别流程也可替换或扩展检测与识别模块用于停车场管理、交通监控等场景作者深耕嵌入式领域并为此资源提供答疑配套支持便于快速解决部署与调试中的问题。1. 从一张照片到一块车牌为什么先检测再识别一套「树莓派 深度学习」的车牌系统最容易翻车的地方不是模型准确率而是把「检测」和「识别」当成一件事。检测是回答「车牌在哪」识别是回答「车牌号是什么」两者放在一条流水线里前者输出的是带坐标的候选框后者才真正输出字符串。如果不先把这一步拆清楚后面无论换多大模型都会在光线、倾斜、小目标上反复踩坑。树莓派 4B 的算力决定了它跑不动大而全的端到端模型常见做法是拆成两个阶段先用轻量目标检测模型如 YOLOv5s 或 NanoDet框出车牌区域再把裁剪后的车牌图交给 OCR 模型如 LPRNet 或 CRNN完成字符序列识别。这个方案的现实收益在于检测模型把复杂背景全部过滤掉识别模型面对的输入变得非常干净两个模型各自做小做快最终在边缘设备上延迟可控。这篇文章面向的是要做毕设、课设或竞赛项目的开发者。你不需要一台带 GPU 的服务器——训练可以远程完成部署时树莓派只需加载权重做推理。接下来我会从数据集选型、模型结构、树莓派部署到字符矫正逐层展开所有命令和代码都以能直接复现为底线最后再给出一套可在本地验证识别效果的技巧方便你在答辩或报告中给出可量化的结果。2. 数据与模型选型两阶段训练策略如何适配树莓派算力2.1 车牌检测模型的输入输出与选型边界目标检测模型的任务是输出(x, y, w, h, score)五元组。在车牌场景里这个任务有一个天然优势车牌的宽高比基本固定大约在 3:1 到 4.5:1 之间。这意味着检测模型不需要学习非常复杂的形变可以选用 anchor 设计较简单的网络结构。以 YOLOv5s 为例它的参数量在 7.2M 左右FP16 推理在树莓派 4B 上单帧耗时大约在 200400ms 之间具体取决于输入分辨率和是否使用 NCNN 加速。如果对速度更敏感可以考虑 NanoDet——它使用 Generalized Focal Loss 和轻量 head 设计参数量只有 3.8M 左右CPU 推理速度能再提升 30% 左右。但 NanoDet 的生态成熟度不如 YOLOv5如果项目周期紧张我一般会优先选 YOLOv5s因为从标注格式到部署工具链全部现成。训练数据来源主要有三种公开数据集CCPD中国城市车牌数据集是最常见的选择包含超过 20 万张图像覆盖多种光照和角度。合成数据通过程序将车牌字符渲染到随机背景上适用于识别模型的预训练。这种方式在检测模型上效果有限因为背景的语义多样性很难模拟。自采数据用手机或摄像头在停车场、路边拍摄数量在 5001000 张即可满足微调需求。需要注意CCPD 中很多图像带有明显的标注噪点在训练前我通常会用脚本清洗一遍剔除宽高比小于 2 或置信度可疑的标签。#### 2.1.1 车牌检测模型的输出才是识别模型的输入两阶段设计的核心在于检测模型的输出框决定了识别模型看到什么。如果检测框把车牌的左右边缘切掉字符序列就会缺头少尾识别模型再强也补不回来。所以检测模型的评价指标不只是 mAP更要关注 IoU 在 0.7 以上时的定位精度。另一个实际细节是检测模型输出的是一个带角度的矩形还是水平矩形。真实场景中车牌经常因为拍摄角度而倾斜如果用水平框去裁剪会框进大量背景或切掉字符。常见做法是分两步处理检测模型先输出水平框然后通过透视变换或仿射变换对车牌区域做旋转矫正再送入识别模型。这样比直接训练带角度的检测头更容易收敛因为目标检测模型对角度回归的稳定性不如对位置和尺寸的回归好。2.2 车牌字符识别从分割时代到序列识别时代传统车牌识别算法走的是「字符分割 单字符分类」路线先通过颜色分析锁定车牌区域再用投影法把字符一个个切开最后用分类器逐个识别。这套方案在固定角度、固定光照下表现尚可但一遇到倾斜、反光或字符粘连就崩。深度学习方案的主流做法是「不分割直接序列识别」。以 LPRNet 为例它使用 CNN 提取特征输出一个按时间步排列的序列然后用 CTC Loss 进行对齐训练。CTC 的好处是不需要精确标注每个字符的边界只要给定整串字符内容即可训练这极大的降低了数据标注成本。核心代码如下使用 PyTorch 定义 LPRNet 的网络结构import torch import torch.nn as nn class SmallBasicBlock(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, 3, padding1) self.bn2 nn.BatchNorm2d(out_channels) def forward(self, x): identity x x self.conv1(x) x self.bn1(x) x self.relu(x) x self.conv2(x) x self.bn2(x) return self.relu(x identity) class LPRNet(nn.Module): def __init__(self, class_num, dropout0.2): super().__init__() self.backbone nn.Sequential( nn.Conv2d(3, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(3, 2, padding1), SmallBasicBlock(64, 64), nn.MaxPool2d(3, 2, padding1), SmallBasicBlock(64, 128), nn.MaxPool2d(3, 2, padding1), nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Dropout(dropout), ) self.classifier nn.Conv2d(256, class_num, 13, padding0) self.avg_pool nn.AdaptiveAvgPool2d((1, 8)) def forward(self, x): x self.backbone(x) x self.classifier(x) x self.avg_pool(x) x x.squeeze(2) x x.permute(2, 0, 1) return x代码背后的逻辑是backbone 通过连续的卷积与下采样将输入的(3, H, W)图像压缩成高维特征图最后classifier输出形状为(class_num, 1, W)的特征经全局池化后转成(W, batch, class_num)的序列格式这个形式正是 CTC Loss 要求的输入布局。参数说明class_num是字符类别数包含数字、字母、省份简称以及空白符常见设定为 68 或 78。dropout在 backbone 末端使用用于缓解过拟合因为车牌字符的训练集往往不够大。nn.AdaptiveAvgPool2d((1, 8))将特征图高度压缩为 1宽度缩放到 88 可以理解为输出序列的时间步数。序列长度并非固定要与字符数相等CTC 允许输出长于实际标签。2.3 损失函数与训练流程的配合检测模型的损失包含 box loss、objectness loss 和 classification loss 三部分YOLOv5 分别使用 CIoU、BCEWithLogitsLoss 和分类 BCE。识别模型的损失则是 CTC Loss。这里最需要关注的是学习率策略因为在树莓派这种端侧设备上部署时权重精度对训练的收敛行为影响不大但训练时的优化器设置会直接影响最终权重的质量。训练时我建议按以下顺序操作先用 CCPD 的省份简称和字母数字子集做整体识别训练再用实际场景的照片做微调。微调时把 backbone 冻结只训练分类头可以避免场景变化造成的灾难性遗忘。一个值得注意的细节是车牌字符中「0」和「O」、「1」和「I」经常混淆如果数据集中二者都存在需要在损失函数之外引入一个混淆矩阵监控而不是只盯着总体准确率。3. 在树莓派上跑通最小推理链路从 OpenCV 到 NCNN3.1 树莓派环境初始化与依赖安装树莓派 4B 推荐使用 64 位 Raspberry Pi OS因为深度学习推理库对 aarch64 的支持比 32 位好很多。系统装好后第一步不是急着装 PyTorch而是先把系统源和 pip 源换到国内镜像否则依赖下载时间会占掉整个调试周期的大半。基础依赖安装命令如下sudo apt update sudo apt install -y python3-pip python3-opencv sudo apt install -y libatlas-base-dev libopenblas-dev libblas-dev sudo pip3 install numpy这里选择python3-opencv而不是opencv-python的 pip 包是因为树莓派官方源里预编译的 OpenCV 已经启用了 V4L2 和 GStreamer 支持可以直接接 USB 摄像头或 CSI 摄像头不用自己编译。libatlas-base-dev是很多二进制 wheel 包的运行时依赖缺少它会导致numpy或opencvimport 时报非法指令错误。如果你的项目要求必须在树莓派本地训练可以安装 PyTorch 的 ARM 版本但这只适合做小规模验证。真正合理的分工是在 PC 或云主机上完成训练导出权重后在树莓派上只做推理。#### 3.1.1 使用 NCNN 加速推理的关键参数NCNN 是腾讯开源的神经网络前向计算框架它对 ARM CPU 做了针对性优化在树莓派上的速度通常比 PyTorch 快 35 倍。NCNN 的使用路径是先把 PyTorch 权重导出为 ONNX再用onnx2ncnn工具转换为.param和.bin文件。转换命令如下python3 export_onnx.py --weights best.pt --img-size 320 onnx2ncnn best.onnx model.param model.bin这里面有一个很关键的参数img-size。YOLOv5 官方推荐的输入是 640但在树莓派上跑 640 分辨率即使 NCNN 加速后也需要接近 300ms 一帧。如果车牌在画面中的像素宽度足够大320 分辨率是更好的折中点。实测在 320 分辨率下NCNN 推理耗时可以压到 120ms 左右配合检测框的时序平滑视频流的体验已经相当可用。onnx2ncnn转换时如果遇到不支持的算子会自动替换成Crop或Scale但这种替换有时会造成精度损失。转换完成后建议用同一张测试图对比原模型和 NCNN 模型的输出如果检测框偏移超过 5 个像素就要考虑在网络中避免使用某些高级算子比如部分版本的 Focus 层会被替换成标准卷积。3.2 树莓派摄像头调用与画面采集参数树莓派支持两种摄像头接口CSI 摄像头如 OV5647和 USB 摄像头。CSI 接口走专用通道CPU 占用低延迟也更小USB 摄像头则胜在即插即用且对焦距的选择更自由。使用 OpenCV 读取 CSI 摄像头时不能直接写VideoCapture(0)需要通过 libcamera 相关库。推荐用以下方式import cv2 cap cv2.VideoCapture(0, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FPS, 30)设置CAP_V4L2后OpenCV 直接走 V4L2 协议访问设备可以减少一层封装。分辨率设置为 640x480 是为了配合检测模型的输入尺寸避免摄像头输出高分辨率画面再缩放造成额外延迟。CAP_PROP_FPS设置为 30但实际帧率取决于检测推理速度如果推理 200ms 一帧画面帧率就只有 5 FPS。提示如果摄像头画面偏暗不要在 OpenCV 里做全局亮度调整而是优先调节摄像头的曝光参数。在 V4L2 下可以通过v4l2-ctl -c exposure200设置曝光值这样能保留更多夜间场景的细节比后期图像增强更有效。3.3 加载 NCNN 模型进行端到端推理下面是一段完整的树莓派推理代码包含 YOLOv5 检测与 LPRNet 识别两个阶段import cv2 import numpy as np import ncnn class PlateDetector: def __init__(self, param_path, bin_path, input_size320): self.net ncnn.Net() self.net.load_param(param_path) self.net.load_model(bin_path) self.input_size input_size def detect(self, img): h, w img.shape[:2] scale self.input_size / max(h, w) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(img, (new_w, new_h)) canvas np.zeros((self.input_size, self.input_size, 3), dtypenp.float32) canvas[:new_h, :new_w] resized canvas canvas.transpose(2, 0, 1) mat_in ncnn.Mat.from_pixels_resize( img, ncnn.Mat.PIXEL_BGR2RGB, w, h, new_w, new_h ) mat_in ncnn.Mat.from_pixels(img, ncnn.Mat.PIXEL_BGR2RGB, w, h) mat_in.substract_mean_normalize([0, 0, 0], [1/255.0, 1/255.0, 1/255.0]) ex self.net.create_extractor() ex.input(images, mat_in) ret, mat_out ex.extract(output) return self.postprocess(mat_out, scale)代码逻辑上需要注意几个点substract_mean_normalize的参数mean和norm必须与外网训练时保持一致否则检测效果会明显变差。PIXEL_BGR2RGB表明 NCNN 内部使用 RGB 顺序而 OpenCV 读取的图像默认是 BGR不转换的话颜色通道会错位检测头对颜色特征的响应会发生偏差。在postprocess中需要实现候选框的坐标反变换将模型输出的归一化坐标映射回原图尺寸。NCNN 的 YOLOv5 输出格式通常是(1, 25200, 85)的矩阵需要先做置信度过滤再做 NMS。NMS 的 IoU 阈值一般设在 0.45置信度阈值设在 0.35这两个参数在车牌场景下不需要调得过于激进因为画面中同时出现多块车牌的概率很低。3.4 剪裁与预处理车牌区域到识别模型的最后一道关检测框拿到后不能直接把裁剪区域送入 LPRNet。车牌图像往往存在旋转、尺度和光照差异需要先做一次统一处理。标准流程是根据检测框的四个角点做仿射变换将车牌区域映射到统一的 94x24 尺寸。def align_plate(img, box): src_pts np.float32([ [box[0], box[1]], [box[2], box[1]], [box[0], box[3]], [box[2], box[3]] ]) dst_pts np.float32([[0, 0], [94, 0], [0, 24], [94, 24]]) M cv2.getPerspectiveTransform(src_pts, dst_pts) aligned cv2.warpPerspective(img, M, (94, 24)) aligned cv2.cvtColor(aligned, cv2.COLOR_BGR2GRAY) return aligned这里的getPerspectiveTransform计算的是单应性矩阵它可以将任意四边形映射到目标矩形比仿射变换多一个自由度可以处理车辆朝向造成的透视形变。灰度化后将尺寸变为 94x24这是 LPRNet 常见输入规格。识别模型的前向代码不再展开核心是把 94x24 的灰度图送入网络得到字符序列概率矩阵后使用 CTC 解码贪心解码或者 beam search输出最终字符串。注意warpPerspective前不需要做灰度化因为检测阶段依赖颜色特征但识别阶段用灰度图像即可因为字符形状信息已经足够减少通道数可以降低模型计算量并增强对颜色变化的鲁棒性。4. 实测调优光照、倾斜与字符混淆三大坑的应对方案4.1 增益、曝光与白平衡参数的联动调节现实中树莓派摄像头在不同光照下的表现差异极大。夜间场景最常见的现象是整体偏暗车牌区域过曝或完全看不清。此时与其依赖后处理不如在采集阶段就把图像质量调对。V4L2 控制参数中与图像质量直接相关的是曝光、增益和白平衡。我常用的组合是这样v4l2-ctl -c exposure300 v4l2-ctl -c gain200 v4l2-ctl -c white_balance_automatic1曝光值控制进光量增益控制信号放大二者之间的平衡直接影响噪点水平。如果把增益调得过高图像会布满颗粒噪点检测模型对小目标的响应会变差如果把曝光调得过高画面会过曝字符边缘信息丢失识别模型容易出错。对于多场景适配我建议写一个简单的自动策略计算画面的平均亮度如果低于 80 则提高曝光高于 180 则降低曝光。这个策略不需要任何额外的硬件只花几毫秒 CPU 时间但对整体识别率的影响非常明显。4.2 使用透视变换解决车牌倾斜导致的识别率下降车牌倾斜是一个几乎无法避免的现实问题主要是由拍摄角度造成的。倾斜分为平面内旋转和透视旋转两种。平面内旋转指的是车牌在画面中仍是矩形但角度不为 0透视旋转指的是车牌呈梯形或任意四边形。检测模型输出的水平框在处理平面内旋转时是够用的——只需要根据二值化图像或者边缘信息计算旋转角度再做一次旋转。但透视旋转必须用单应性变换这就是上一节getPerspectiveTransform的用武之地。实际操作中如何获取四个角点常见方案有两种。一种是直接让检测模型输出四边形角点这需要修改检测 Head实现成本较高另一种更常见先做水平检测然后在裁剪区域内用边缘检测找矩形轮廓。下面是基于轮廓的车牌角点提取代码def find_plate_corners(plate_roi): gray cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) blurred cv2.GaussianBlur(gray, (5, 5), 0) edges cv2.Canny(blurred, 50, 150) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return None largest max(contours, keycv2.contourArea) epsilon 0.02 * cv2.arcLength(largest, True) approx cv2.approxPolyDP(largest, epsilon, True) if len(approx) 4: return approx.reshape(4, 2) rect cv2.minAreaRect(largest) return cv2.boxPoints(rect)这段代码先通过 Canny 提取边缘再用approxPolyDP尝试拟合成四边形。如果轮廓本身是平滑弯曲的approxPolyDP得到的可能不是 4 个点此时退回到minAreaRect获取最小外接矩形。需要注意的是minAreaRect处理不了透视形变但对于轻微倾斜的平面内旋转已经足够。在真实的停车场场景中我发现一个规律车牌离摄像头越近透视越明显离摄像头越远透视几乎可以忽略。所以在做角点检测时如果检测框的高度过小比如小于 20 像素这时候可以不用做透视矫正直接按水平框裁剪反而更稳。4.2.1 OCR 识别结果的后处理与规则校验车牌字符串不是任意字符序列它有以下局部的文法规则首位是省份简称汉字属于固定集合如京、津、沪、渝、冀、豫、云、辽、黑、湘、皖、鲁、新、苏、浙、赣、鄂、桂、甘、晋、蒙、陕、吉、闽、贵、粤、青、藏、川、宁、琼。第二位是发牌机关代号通常是 A-Z 大写字母。后续位是字母与数字的混合。中国民用车牌不包含字母「I」和「O」这两个字符在识别结果中出现时可以直接判定为错误。基于这些规则可以在推理后加一个简单的校验函数def validate_plate(text): if len(text) ! 7: return text provinces 京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼 valid_letters ABCDEFGHJKLMNPQRSTUVWXYZ valid_chars valid_letters 0123456789 if text[0] not in provinces: text fix_province(text) if text[1] not in valid_letters: text text[:1] A text[2:] for i in range(2, 7): if text[i] not in valid_chars: text text[:i] 0 text[i1:] return text这个规则看起来很简单但作用很大。在夜间或雨雾天气OCR 很容易把某个字符识别错如果信不信直接输出原结果系统会返回一个不可能存在的车牌号。加上规则校验后至少能让输出保持合法格式。在评判系统性能时合法格式的数量比准确字符数量更容易在报告中量化。更近一步的做法是使用字典树Trie来加速候选字符的搜索在 CTC 解码时就不直接输出贪心结果而是结合字符级别的先验概率做 beam search。这个升级可以从根本上减少「京」被识别成「示」或者「0」被识别成「O」的概率但推理时间会从 5ms 涨到 30ms 左右在树莓派上是否值得取决于你的帧率预算。4.3 混淆分析与针对性数据增强跑完一批测试数据后不要只看准确率我也建议做一个混淆矩阵看清具体是哪几对字符在被相互搞混。车牌识别最常见的混淆对包括混淆对常见诱因对策0 与 O字形高度相似字符间距小数据增强中加入轻微模糊与透视扰动1 与 I衬线字体差异极小增大字符边缘对比度约束颜色通道粤与奥汉字结构接近图像分辨率低提高车牌对齐后的分辨率至 128 宽8 与 B下部结构相似曝光不足时模糊降低夜间增益强度避免过曝针对这些混淆对增强策略要有针对性而不能盲目使用。普遍的数据增强方式是随机旋转、随机亮度和随机噪声这在车牌场景里有一定效果但最有效的方式是模拟真实摄像头的成像退化过程包括运动模糊、镜头失焦和压缩伪影。可以在训练前用 OpenCV 对每张裁剪后的车牌图随机叠加高斯模糊和 JPEG 压缩让模型对低质量输入更加鲁棒。5. 性能压测与项目的可演示性包装5.1 帧率、延迟与功耗的实测口径无论毕设还是竞赛性能数据都需要在报告中体现。树莓派上最值得记录三个指标单帧检测耗时、单帧识别耗时、端到端流水线耗时。前两个指标容易被忽略的是它们可以并行化——当检测模型处理第 N 帧时识别模型可以同时处理第 N-1 帧的裁剪结果。在 Python 里用多线程实现from threading import Thread from queue import Queue def detection_worker(self, input_q, output_q): while True: frame input_q.get() box self.detect(frame) output_q.put((frame, box)) def ocr_worker(self, input_q, output_q): while True: frame, box input_q.get() if box is not None: plate_text self.recognize(frame, box) output_q.put(plate_text)这种双线程流水线设计利用了一个事实检测阶段处理整帧图像而识别阶段只处理裁剪后的小图两者时间不叠加。如果检测耗时 200ms、识别耗时 30ms串行总耗时 230ms流水线化之后理论耗时约 200ms识别时间被完全隐藏。实测帧率会从 4.3 FPS 提升到 5 FPS提升幅度不算大但图形曲线会漂亮很多。5.2 端到端可运行 Demo从摄像头取流到终端显示为了让项目在演示时不翻车我建议准备两套运行模式实时摄像头模式和图片文件夹模式。后者用于演示现场的网络不稳定或摄像头驱动异常时的备选方案。python3 demo.py --source camera --model weights/model.param --bin weights/model.bin python3 demo.py --source ./test_images/ --output ./result/--source参数指定输入源--output只在图片模式下有效。在 demo 脚本里会把每个检测框画到原图上并在框上方绘制识别出的车牌号。对于竞赛答辩这个可视化结果比终端打印的字符串直观得多。建议保存成视频文件格式用 MP4V 编码这样在 Windows 和 Mac 上都能直接播放。5.3 针对毕设与竞赛答辩的指标呈现建议答辩中专家通常关心的是实时性、准确率和系统复杂度。实时性用「端到端延迟」表述准确率可以拆成检测率和识别率两个维度。不要在报告里只写「准确率 95%」而是明确区分「在 100 张测试图上检测出 97 张车牌其中 92 张完全识别正确」。这组数字能让评委快速理解系统的实际瓶颈在检测还是识别也让后续的改进方向有针对性。最后可以展示一个低光照场景的对比结果原始图像、检测框可视化、OCR 结果来体现系统在真实场景下的鲁棒性。这套演示成本极低但视觉冲击力远高于一堆数据表。如果你的项目还有富余时间做一个简单的 Web 界面Flask 摄像头推流会显著提升项目的完整度感知不过需要注意Web 推流会增加延迟演示时强调本地推理延迟和网络传输延迟是两个不同维度即可。本文还有配套的精品资源点击获取