☰
YOLOv5实战:三轮车违规停放检测从训练到树莓派部署全流程
2026/9/28 1:41:14 网站建设 项目流程

简介:这是一份面向目标检测与机器视觉学习者的三轮车识别数据集资源,适用于基于yolov5的非机动车违规停放检测项目。压缩包内为tricycle7分类的1021张三轮车图片及对应的988个XML标注文件,图片涵盖凤凰、飞鸽等品牌车型,场景角度多样,标注内容完整,可直接用于模型训练与验证。数据整体包含自行车、电动车、三轮车共两万余张已标注图片,本包为其中三轮车第七类,便于按类别分步训练或与其他类目组合。资源共2009个文件,以JPEG图像与VOC格式XML标注为主,压缩包大小87.52MB,目录结构清晰,图片与标注一一对应,方便做数据集划分与增强。目前已有257人学习下载,适合需要特定类别非机动车数据用于yolov5训练、违规停放识别、目标检测算法验证等场景的研发人员。

1. 非机动车违规停放检测:为什么yolov5只解决了一半问题

做违规停放检测的都知道,这个需求不能光靠一个目标检测模型收工。yolov5能告诉你画面里有没有三轮车、车在哪个位置,但“停在这算不算违规”是另一套逻辑。城市管理项目里,网格员拍回一张三轮车乱停的照片,后台先做机器视觉识别,把车框出来,再判断它是不是落在禁停区、停了多久。tricycle7_images_xmls 这类已标注数据集,图片配 VOC 格式的 XML 标注,正好用来训练自己的检测模型。这篇笔记按落地顺序拆:环境配置、XML 转 YOLO 格式、训练调参、踩坑记录,最后落到树莓派这种边缘设备上的实时判定。适合正在做智慧城管、园区安防的算法工程师,或者接政企项目的个人开发者。

2. 跑通yolov5环境与tricycle7_images_xmls:conda配置、标注检查和数据划分

2.1 conda搭建yolov5训练环境:Python版本与依赖一次配齐

训练自己的数据集之前,先把环境固定下来。我一般用 conda 单独建一个环境,避免把系统 Python 搞乱,也方便后面多个项目切换。yolov5 源码对 Python 版本不挑,3.8 到 3.10 都能跑,但依赖版本之间有点讲究,个人经验是 Python 3.8 搭配 CUDA 11.x 最稳,老项目多、踩坑案例也多,搜问题容易搜到答案。

# 创建独立环境, 指定 Python 3.8, -y 跳过确认 conda create -n yolov5 python=3.8 -y # 激活环境, 后面所有操作都在这个环境里做 conda activate yolov5 # 在项目根目录安装依赖, requirements.txt 在 yolov5 源码仓库里 pip install -r requirements.txt

逻辑说明:yolov5 的训练链路依赖 torch、opencv、matplotlib、pyyaml 这些包,requirements.txt 已经把版本组合写好了,直接装比手动一个个装少踩很多坑。如果机器没有 NVIDIA 显卡,需要把 torch 换成 CPU 版本再装,否则会因 CUDA 版本不匹配报错。装完可以用python -c "import torch; print(torch.__version__)"验证一下。

参数说明:conda 环境名yolov5可以随意改,但保持和项目名一致比较好认;Python 3.8 不是必须的,但如果你后面要跑旧版 ultralytics 仓库里的脚本,3.8 的兼容性最省心。显存低于 6G 的机器,训练时把 batch size 调小就行,不影响环境搭建。

克隆源码时注意别用 master 分支的最新代码,新提交偶尔会引入问题。我习惯 clone 下来后用git tag看稳定版本,选一个时间久、issue 少的 tag 切过去,训练行为可预期。

2.2 检查tricycle7_images_xmls标注质量:先看XML再决定要不要清洗

已标注数据集不等于干净数据集。tricycle7_images_xmls 从命名看是三轮车图片加 XML 标注的集合,这类 XML 通常是 LabelImg 导出的 VOC 格式。拿到手先别急着转格式,花十分钟把标注体检一遍,能省后面几天的排查时间。

import xml.etree.ElementTree as ET from pathlib import Path xml_dir = Path("tricycle7_images_xmls/xmls") cls_counter = {} # 遍历所有 XML 标注文件 for xml_file in xml_dir.glob("*.xml"): tree = ET.parse(xml_file) root = tree.getroot() # 检查图片尺寸信息是否完整 size = root.find("size") img_w = int(size.findtext("width")) img_h = int(size.findtext("height")) if img_w <= 0 or img_h <= 0: print(f"尺寸异常: {xml_file.name}") # 遍历每个标注对象 for obj in root.iter("object"): name = obj.findtext("name").strip() difficult = obj.findtext("difficult", "0") bndbox = obj.find("bndbox") x1 = float(bndbox.findtext("xmin")) y1 = float(bndbox.findtext("ymin")) x2 = float(bndbox.findtext("xmax")) y2 = float(bndbox.findtext("ymax")) # 统计类别分布 cls_counter[name] = cls_counter.get(name, 0) + 1 # 坐标合法性检查: 左上角必须在右下角之前, 且不超图像边界 if not (0 <= x1 < x2 <= img_w and 0 <= y1 < y2 <= img_h): print(f"坐标异常: {xml_file.name} -> {name} [{x1}, {y1}, {x2}, {y2}]") print("类别分布:", cls_counter)

逻辑说明:这个脚本不修改任何文件,只做体检。三个检查点分别对应三类问题:尺寸异常说明图片可能被压缩过但标注没跟着更新;坐标异常说明标注时画框画出了边界;类别分布直接决定你要不要重新组织数据。跑完一遍,心里对这批数据的质量就有数了。

参数说明:findtext("difficult", "0")的第二个参数是默认值,当 XML 里没有 difficult 字段时返回 "0",避免属性缺失报错。坐标检查用的是开区间判断,x1 < x2严格成立,因为 x1 等于 x2 的标注是无效框,训练时会让模型学到错误的位置信息。

如果统计出来类别名不统一,比如既有tricycle又有Tricycle,或者有多余的空格,趁现在一次性清洗掉。这种问题越早处理代价越小,等数据划分完再改就要重新生成一遍标签。

2.3 训练/验证/测试集划分:8比1比1,固定随机种子

数据集划分必须在格式转换之前做。原因很简单:转换后是 txt 标签,跟图片的对应关系靠文件名维系,如果先转换再划分,一旦文件复制出错,图片和标签就错位了。按图片维度划分,保证同一个场景的视频帧不会同时出现在训练集和验证集里。

import random import shutil from pathlib import Path image_dir = Path("tricycle7_images_xmls/images") train_dir = Path("images/train") val_dir = Path("images/val") test_dir = Path("images/test") for d in [train_dir, val_dir, test_dir]: d.mkdir(parents=True, exist_ok=True) images = sorted(image_dir.glob("*.jpg")) random.seed(42) # 固定种子, 保证每次划分结果一致 random.shuffle(images) # 打乱顺序, 避免同类图片扎堆 n = len(images) train_end = int(n * 0.8) val_end = int(n * 0.9) # 按 8:1:1 切分训练/验证/测试 for i, img in enumerate(images): if i < train_end: target = train_dir elif i < val_end: target = val_dir else: target = test_dir shutil.copy2(img, target) # copy2 保留文件元数据

逻辑说明:这里只复制图片,XML 标注先不动。下一步做格式转换时,按图片目录反查 XML,保证标签和图片一一对应。随机种子固定为 42 是习惯,这样你调整数据后重新划分,训练集和验证集的边界不会变化,模型对比才有意义。

参数说明:8:1:1 是检测任务最常见的划分比例。如果总图片数只有几百张,可以改 9:1:0,把测试集省下来,因为验证集每天训练完都在用,测试集只在最后评估一次。shutil.copy2比copy多复制元数据,对图片无所谓,但对后面排查文件来源有帮助。

注意:划分完成后数一下三个目录的图片数量,加总必须等于总数。目录里混入非图片文件是常见问题,glob 只匹配.jpg,如果数据集里混有.png、.bmp,需要先统一格式再划分。

3. 把VOC XML转成YOLO标签:转换脚本与四个边界坑

3.1 转换脚本:像素坐标bndbox到归一化cx,cy,w,h

yolov5 训练时不读 XML,它要求每个标注对象一行:类别ID + 归一化中心点坐标 + 归一化宽高。所以转换脚本是整个流程里最不能出错的一环,坐标错一个像素,训练出来的模型框就会偏。

import xml.etree.ElementTree as ET from pathlib import Path # names 的顺序必须和后续 tricycle.yaml 里的 names 完全一致 names = ["tricycle"] name2id = {name: i for i, name in enumerate(names)} xml_dir = Path("tricycle7_images_xmls/xmls") label_dir = Path("labels") label_dir.mkdir(exist_ok=True) def voc_to_yolo(size, box): """VOC像素坐标转YOLO归一化坐标, 返回 cx, cy, w, h""" dw, dh = 1.0 / size[0], 1.0 / size[1] x1, y1, x2, y2 = box # 归一化到 0~1 cx = (x1 + x2) / 2.0 * dw cy = (y1 + y2) / 2.0 * dh w = abs(x2 - x1) * dw h = abs(y2 - y1) * dh # 标注工具偶尔会画出图像边界, 越界值直接截断 cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) return cx, cy, w, h for xml_file in sorted(xml_dir.glob("*.xml")): root = ET.parse(xml_file).getroot() size_el = root.find("size") img_w = int(size_el.findtext("width")) img_h = int(size_el.findtext("height")) lines = [] for obj in root.iter("object"): name = obj.findtext("name").strip() cls_id = name2id.get(name) # 类别不在映射表里就跳过并打印, 不要静默放过 if cls_id is None: print(f"未知类别: {name} in {xml_file.name}") continue # difficult=1 的样本直接跳过, 这些通常是遮挡严重的难例 if obj.findtext("difficult", "0") == "1": continue bndbox = obj.find("bndbox") box = ( float(bndbox.findtext("xmin")), float(bndbox.findtext("ymin")), float(bndbox.findtext("xmax")), float(bndbox.findtext("ymax")), ) cx, cy, w, h = voc_to_yolo((img_w, img_h), box) lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") if lines: out = label_dir / f"{xml_file.stem}.txt" out.write_text("\n".join(lines) + "\n")

逻辑说明:脚本遍历所有 XML,把每个<object>的 bndbox 像素坐标转成归一化的中心点坐标和宽高。核心是两个公式:中心点取左上角和右下角的平均值再除以图片宽高;宽高取差值再除以图片宽高。name2id映射表保证类别名到数字 ID 的转换是显式的,哪里错了打印哪里。

参数说明:cx:.6f保留六位小数足够,yolov5 训练时读取的精度就是 float32;difficult=1的样本跳过是保守做法,这些样本通常遮挡严重、边界框不准确,混进训练集反而让模型困惑。如果你样本总量太少,可以把 difficult 样本单独筛出来做验证集,不要直接删掉。输出文件用 XML 的文件名(stem),保证和图片同名,后面训练才能对上。

3.2 四个边界坑:越界坐标、大小写、空XML、文件对不上

第一个坑是坐标越界。LabelImg 画框时鼠标稍微拖出图片边界,xmax 就会大于图片宽度。转换脚本里必须做 clamp,否则训练时模型输出的框也会学着超出边界。判断标注是否越界最直接的方法是看 box 的 x2 减 x1 是不是负数,负数说明画反了,这种框不能 clamp 了事,要回到原图重新标注。

第二个坑是类别名大小写不一致。VOC 里写Tricycle,yaml 里写tricycle,name2id.get(name)返回 None,脚本打印一行提示然后跳过。最怕的是你不打印直接跳过,训练时发现某张图没有标签文件,排查半天才意识到是类别名大小写问题。所以转换脚本里的打印语句不是可有可无的装饰,它是你数据清洗的最后一道防线。

第三个坑是空 XML。有些图片标注到一半没保存完整,XML 里没有任何<object>。转换后生成一个空的 txt 文件,yolov5 训练时不会报错,但那张图相当于没参与训练,白白浪费一个样本。体检脚本里加一个计数:XML 有、但 object 为空的文件列出来,要么补标要么剔除。

第四个坑是文件名对不上。图片叫IMG_0421.jpg,XML 叫IMG_0421.xml是正常的;但有人标注完以后批量重命名了图片,XML 没跟着改,转换后标签和图片错位。这个必须靠代码检查:遍历 images 目录,对每张图查对应 XML 是否存在,反过来也查一遍,找出对方缺失的文件清单。

from pathlib import Path image_files = {p.stem: p for p in Path("images").glob("*.jpg")} xml_files = {p.stem: p for p in Path("xmls").glob("*.xml")} # 图片有但标注缺失 orphan_images = set(image_files) - set(xml_files) # 标注有但图片缺失 orphan_xmls = set(xml_files) - set(image_files) print("缺少标注的图片:", orphan_images) print("缺少图片的标注:", orphan_xmls)

逻辑说明:两个集合相减,把失配的文件一次性找出来。这一步要在转换之前跑,因为转换脚本是按 XML 生成的,某个 XML 对应的图片不存在,它的标签文件就会成为一个孤儿标签,训练时起不到任何作用。

3.3 可视化验证:把txt标签反算回像素坐标画框检查

转换完不能直接开训练,先抽样几步看一眼效果是值得的。把归一化的 txt 标签反算成像素坐标画到图上,一眼就能看出转换有没有问题。

import cv2 img_path = "images/train/IMG_0421.jpg" label_path = "labels/IMG_0421.txt" img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path) as f: for line in f: parts = line.split() cls_id = int(parts[0]) cx, cy, bw, bh = map(float, parts[1:]) # 归一化坐标反算回像素坐标 x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) # 越界画框会画到图像外面, 用 clip 限制显示范围 x1, y1 = max(x1, 0), max(y1, 0) x2, y2 = min(x2, w), min(y2, h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, f"cls{cls_id}", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imwrite("check.jpg", img) print("已生成 check.jpg, 请打开检查框是否贴合车身")

逻辑说明:反算公式是转换公式的逆运算,中心点加减宽高的一半得到左上角和右下角。检查重点是两个:框是不是紧贴三轮车车身边缘,以及类别 ID 是不是正确。抽一两张正面、一张侧面、一张远处小目标的图看,基本就能确认转换质量。

参数说明:这里为了检查方便,直接把框画到原图上。实际数据集里三轮车会出现在不同距离,远处的框偏小是正常的,但如果所有框都比车身大一圈,大概率是 XML 里存的本来就是外接矩形,需要回到标注源头处理,不要靠代码硬调。

4. 用yolov5训练自己的三轮车检测模型:超参数设置与训练不收敛排查

4.1 tricycle.yaml与训练命令:先小图跑通再上大图

数据转换完,接下来就是 yolov5 训练自己的数据集的常规流程:写一个数据配置文件,然后跑 train.py。如果你只想把 yolov5 基础笔记里的命令抄一遍跑通流程,这段可以跳过;但如果要训练一个能落地的三轮车检测模型,yaml 里的细节值得抠一下。

# tricycle.yaml # 训练数据根目录, 相对路径以 train.py 所在位置为基准 path: ./ train: images/train val: images/val test: images/test # 类别数量与类别名, 顺序必须和转换脚本的 names 一致 nc: 1 names: 0: tricycle
# 先用 320 输入尺寸跑通流程, 确认数据没配错, 再上 640 python train.py \ --img 320 \ --batch 16 \ --epochs 50 \ --data tricycle.yaml \ --weights yolov5s.pt \ --hyp hyp.scratch-low.yaml \ --cache

逻辑说明:--weights yolov5s.pt用的是 COCO 预训练权重,迁移学习能大幅缩短收敛时间。单类检测任务用 s 模型足够,n 模型太小容易欠拟合,m 模型对边缘部署不友好。第一次跑用 320 输入不是为了省时间,是为了快速验证数据链路:如果数据配置有错,小图几十个 epoch 就能看到 loss 不降、mAP 为 0,比跑 640 省一半时间。

参数说明:--batch 16在 8G 显存下比较安全,显存不足时先降 batch 到 8,再不行才降--img到 320。--cache把图片缓存到内存,加速训练,前提是机器内存不能太小。hyp.scratch-low.yaml是 yolov5 自带的低增强超参组合,适合数据量不大、不想让增强把标注搞歪的场景。

4.2 真正影响检测效果的超参数:lr0、mosaic与类别权重

超参数不是玄学,但很多人直接改数值而不理解为什么改。对于三轮车这种单类、大目标、样本量几百到几千的任务,真正需要手动调的就三个地方。

超参数默认值建议值调整理由
lr0 初始学习率0.010.005数据量小,学习率太大容易震荡,loss 曲线像锯齿
mosaic 马赛克增强1.00.5~1.0三轮车在画面里通常占比较大,mosaic 把目标缩小反而失真
cls_pw 类别权重1.00.8~1.0单类检测不需要刻意加大正样本权重,保持默认
# hyp.scratch-low.yaml 里改这三个位置 lr0: 0.005 # 初始学习率, 数据少就调小一半 mosaic: 0.8 # 马赛克增强概率, 0.8 保留部分真实场景 cls_pw: 0.9 # 类别权重, 单类任务保持接近 1.0

逻辑说明:lr0是最值得动的一个参数。默认 0.01 在 COCO 这种百万级数据上没问题,换成几百张三轮车图片,梯度更新方向不稳定,loss 容易下不去。降到 0.005 之后,训练曲线会明显平滑。mosaic是 yolov5 的招牌增强,把四张图拼成一张,但对于目标本身占比就大的场景,拼图后目标被缩小,小目标训练不足。三轮车检测的典型场景是近景,mosaic 开太高反而有害。

参数说明:cls_pw只对多类不平衡有意义。你只有一个tricycle类,把它调大反而会让模型对背景的抑制变弱,所以保持接近 1.0 就好。真正影响单类检测的是fliplr,默认 0.5,三轮车左右对称,水平翻转增强是安全的;flipud保持默认 0,车辆上下翻转在语义上说不通,不要开。

4.3 训练不收敛与过拟合排查:loss曲线和mAP怎么读

训练开始后,yolov5 会把 loss 曲线画在runs/train/exp目录下。常见翻车有两种:loss 一直降但 val 的 mAP 不涨,这是过拟合;loss 直接变 NaN,这是数据问题。

过拟合的判断标准不是 loss 本身,是 train 和 val 的差距。训练集 loss 降到 0.02,验证集 mAP 只有 0.5,说明模型把训练集背下来了。三轮车样本量少,增强又不强,很容易走到这一步。解决办法按优先级排:补数据 > 增强增强 > 换小模型。先看训练集里有没有大量重复背景的图片,比如同一个路口拍了几十张,去掉重复再训一轮。

loss 变成 NaN 的排查顺序:先看是不是学习率太大,把 lr0 调到 0.001 再试;如果还 NaN,回去跑第 2 章的标注体检脚本,确认没有异常坐标;最后检查图片本身有没有损坏,用 OpenCV 读一遍,返回 None 的就是坏图,删掉重训。

# 训练结束后, 结果都在 runs/train/exp 目录下 # 重点关注两个指标: # mAP@0.5: 0.5 IoU 下的平均精度, 单类检测 0.85 以上算可用 # mAP@0.5:0.95: 严格指标, 0.5 到 0.95 的积分平均, 能到 0.5 说明框很准

逻辑说明:mAP@0.5 对单类检测来说,0.85 以上才能上生产。低于 0.7 先别调参,此数据质量的问题大概率比模型问题大。mAP@0.5:0.95 偏低但 mAP@0.5 很高,说明框不够精确——三轮车检测对框的精度要求没那么苛刻,后续违规停放判定用的是中心点,所以 mAP@0.5 优先看。

注意:训练过程中不要频繁中断去改超参数。一个训练任务跑完再改,半途改参会让对比失去意义。

5. 避坑:非机动车检测从训练到部署最常踩的5个翻车现场

5.1 loss=NaN:先查标签坐标,别动网络结构

现象:训练跑了不到 10 个 epoch,loss 突然变成 NaN,后面直接雪崩,训练进程没法继续。

原因:90% 的情况出在标签数据上。XML 转 YOLO 格式时,某个框的坐标算出了 0 或者负数,比如 xmax 小于 xmin,归一化后 w 为 0,模型回传梯度时除零导致数值爆炸。剩下 10% 是图片损坏,某张图读出来是全黑的或者文件不完整。

解决:先跑第 2 章的体检脚本检查坐标,再跑第 3 章的转换脚本把越界坐标 clamp 掉。如果你已经把有问题的标签删了,确认 labels 目录下没有空 txt 文件对应的图片还在训练列表里。检查顺序是数据先行,网络结构一般不用动。

5.2 把自行车也当三轮车:类别错位看confusion matrix

现象:训练完的模型在测试图上把自行车、电动车全都框成 tricycle,置信度还很高。

原因:最常见的是数据集划分时的污染——标注 XML 里把bicycle和tricycle混在一起,或者转换脚本的name2id映射表漏了某个类别名,模型没见过真正的 tricycle 负样本。另一个隐蔽原因是 data.yaml 的 names 顺序和转换脚本不一致,类别 ID 错位。

解决:训练结束后去看runs/train/exp下的 confusion matrix 图。如果你的类别和背景混在一起,说明标签有误;如果 categories 之间互混,说明类间特征太像。确认 names 顺序,重点看第 3 章转换脚本里的names = ["tricycle"]和 yaml 里的names是否完全一致。类别错位这个问题,代码不会报错,只能靠混淆矩阵暴露。

5.3 现场漏检侧后方车辆:训练集角度太单一

现象:训练集和验证集 mAP 都到 0.9,但拿到现场素材一测,三轮车侧后方角度大量漏检,置信度普遍低于 0.3。

原因:标注数据大多来自同一个方向的拍摄,比如都是正面或正侧面。模型学到的其实是“这个角度的三轮车长这样”,换一个角度特征分布偏移,直接失效。这是机器视觉识别项目里最常见的“数据集分布偏差”。

解决:调参数没用,唯一的解法是补数据。去现场拍一段三轮车绕行的视频,抽帧后按第 2 章的流程补进训练集。如果暂时拍不了,用 yolov5 自带的增强加大旋转和透视强度,但这是临时补救,真实场景数据永远比增强生成的数据可靠。我一般会先在 hyp 文件里把degrees从 0 调到 30,perspective从 0 调到 0.0005,运行一轮看是否缓解。

5.4 树荫下和夜间的黑色三轮车漏检:对比度不足

现象:白天光线好的时候检测一切正常,一到树荫、傍晚、夜间,黑色的三轮车直接消失,框不出来。

原因:标注数据里大多是白天光照充足的图片,模型没学过低对比度场景下三轮车的特征。黑色车身和深色背景融合后,卷积特征响应变弱,置信度跌破阈值。

解决:数据集里补入低光照样本,这是最直接的办法。如果样本短期内补不到,训练时在 hyp 里开hsv_h、hsv_s增强,把饱和度扰动调大一点,同时把推理端的置信度阈值从 0.25 降到 0.15,配合后面要讲的停留计时逻辑来滤掉误检。这个组合拳能撑到收集到足够夜间数据为止,但不要指望它彻底解决。

5.5 违停判定反复横跳:检测框做平滑,停留做计时

现象:三轮车停在禁停区边缘,检测框轻微抖动,判定结果一会违停一会正常,截图工单都没法出。

原因:直接用单帧检测框的中心点判断是否在禁停区内,而检测框本身有随机抖动,中心点在地理上可能来回跨越 ROI 边界。另一个问题是把“短暂停留”和“缓慢行驶”混为一谈——电动车在禁停区前减速等红灯,也被误判成违停。

解决:两个手段配合。第一,对检测框中心点做指数移动平均(EMA)平滑,消掉单帧抖动;第二,加停留计时逻辑,连续 N 帧(对应实际时间 5~10 秒)检测到目标中心点在禁停区内才触发违停。这套逻辑的具体实现在第 6 章给出,这里想强调的是:判定“违规”必须有时间维度,单帧判定是血泪经验换来的教训。

6. 树莓派5上部署自训练的yolov5模型:onnx导出、违规停车判定与回放验证

6.1 导出onnx:输入尺寸、动态轴与半精度的取舍

训练完的best.pt不能直接给树莓派用,PyTorch 在 ARM CPU 上推理太慢。常见做法是先用 official 仓库自带的 export 脚本转成 onnx,再在边缘设备上用 onnxruntime 或 ncnn 推理。

# 输入尺寸降到 416, 和训练尺寸尽量接近, 不要直接上 640 python export.py \ --weights runs/train/exp/weights/best.pt \ --img 416 \ --include onnx \ --simplify

逻辑说明:--img 416是部署时的常用折中,原图 640 输入树莓派5 CPU 跑不动,降到 416 能保证准确率不损失太多。--simplify用 onnx-simplifier 去掉冗余算子,模型体积和推理时间都有改善。这里不推荐用--half转 FP16,树莓派 CPU 对 FP16 支持不好,反而比 FP32 慢。

参数说明:导出时如果提示动态轴问题,检查 onnx 输出是不是把 batch 维度固定成了 1。固定 batch 1 对部署是好事,省掉动态 shape 的额外开销。导出的 onnx 文件可以用 onnxruntime 在电脑上先跑一遍,确认输出张量和 PyTorch 原模型一致,再拷到树莓派上。

6.2 推理与违规停车判定:letterbox还原、ROI射线法与停留计时

树莓派5 上部署自己训练的 yolov5 模型,推理脚本的核心就三件事:letterbox 预处理、ONNX 推理加 NMS 后处理、违停判定逻辑。yolov5 后处理看着简单,部署到边缘设备后才知道 NMS 和坐标还原才是影响帧率的关键。

import cv2 import numpy as np import onnxruntime as ort session = ort.InferenceSession("tricycle.onnx", providers=["CPUExecutionProvider"]) def letterbox(img, new_size=416): """等比缩放 + 灰色填充, 记录缩放比和填充偏移用于反算坐标""" h, w = img.shape[:2] scale = min(new_size / h, new_size / w) nh, nw = int(round(h * scale)), int(round(w * scale)) resized = cv2.resize(img, (nw, nh)) canvas = np.full((new_size, new_size, 3), 114, dtype=np.uint8) top = (new_size - nh) // 2 left = (new_size - nw) // 2 canvas[top:top + nh, left:left + nw] = resized return canvas, scale, top, left def point_in_polygon(x, y, roi): """射线法判断坐标点是否在多边形禁停区内""" inside = False n = len(roi) for i in range(n): x1, y1 = roi[i] x2, y2 = roi[(i + 1) % n] if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1: inside = not inside return inside # 禁停区多边形, 像素坐标, 需要根据实际监控画面标定 roi = [(100, 200), (500, 200), (500, 600), (100, 600)] track = {} # 目标ID -> 连续帧计数 cap = cv2.VideoCapture("test.mp4") fps = cap.get(cv2.CAP_PROP_FPS) while True: ret, frame = cap.read() if not ret: break # 预处理: letterbox 后归一化, 转 NCHW input_img, scale, top, left = letterbox(frame, 416) blob = input_img[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 # ONNX 推理, 输出形状为 (1, 25200, 5+类别数) outputs = session.run(None, {session.get_inputs()[0].name: blob})[0][0] boxes, scores = [], [] for det in outputs: score = det[4] if score < 0.25: # 置信度阈值, 树荫场景可降到 0.15 continue # 按 letterbox 的逆变换还原到原图坐标 cx, cy, w, h = det[:4] x1 = int((cx - w / 2 - left) / scale) y1 = int((cy - h / 2 - top) / scale) x2 = int((cx + w / 2 - left) / scale) y2 = int((cy + h / 2 - top) / scale) boxes.append([x1, y1, x2, y2, score]) # 省略 NMS 实现, 用最简单的按置信度排序后抑制重叠框即可 for box in boxes: x1, y1, x2, y2, score = box center_x, center_y = (x1 + x2) / 2, (y1 + y2) / 2 if point_in_polygon(center_x, center_y, roi): track_id = f"{(center_x // 20) * 20}-{(center_y // 20) * 20}" track[track_id] = track.get(track_id, 0) + 1 # 连续 fps*10 帧(约10秒)都在禁停区, 判定为违规停放 if track[track_id] > fps * 10: cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 0, 255), 2) cv2.putText(frame, "ILLEGAL PARKING", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) else: cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("test", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break

逻辑说明:这段代码把整个链路串起来了。letterbox 保持宽高比,避免拉伸变形导致检测精度下降;推理结果反算回原图坐标,才能在原图上画框和做 ROI 判断。point_in_polygon用射线法判断中心点是否落在禁停区内,这个方法在凸多边形和凹多边形上都能用,比简单的矩形判断通用。停留计时用track字典按中心点网格做简易 ID 匹配,没有做真正的多目标跟踪,但对“一个路口盯一辆三轮车”的场景够用。

参数说明:fps * 10表示连续 10 秒都在禁停区内才触发,这个值按业务调整。网格 20 像素的宽容度是给中心点抖动留的余量,配合 EMA 平滑,基本不会误判。置信度阈值 0.25 是通用值,如果你的模型在特定场景置信度普遍偏低,降到 0.15 配合计时逻辑,效果反而更好。

6.3 上生产前的验证技巧:拿现场录像回放跑一遍

模型在测试集上 mAP 再高,也不代表现场能用。我的习惯是训练完先不着急接摄像头,找一段现场真实录像回放跑一遍。录像比照片多一个时间维度,能验证停留计时逻辑,也能暴露照片测试发现不了的问题——比如树荫下的推车、临时停靠的货车、画面里飘过的塑料袋被误检成目标。

回放验证时重点记录两类现象:误报(把不是三轮车的东西框成三轮车)和漏报(现场有三轮车但没框出来)。误报多就看置信度阈值和 ROI 范围,漏报多就回到训练数据补样本。调完一轮再跑一遍,直到连续十分钟录像没有明显误报才算过。

我现在接到类似的违规停放检测项目,第一件事一定是去现场拍一段录像,而不是先把模型跑起来。这个习惯帮我省了太多返工的时间——数据集分布、光线条件、摄像头角度,这些在办公室里想是想不出来的,只有录像回放能暴露问题。训练模型只是第一步,把模型放进真实场景里还站得住,这个项目才算了结了一半。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询