yolov5机器视觉练手:非机动车违停检测从VOC标注到树莓派部署全流程
简介面向YOLOv5目标检测与机器视觉的非机动车违规停放识别任务该压缩包提供已标注自行车数据集中的bicycles4子集专门用于自行车型号识别与违规停放检测的模型训练和验证。包内共1515个文件包含766张JPEG原图和749个XML标注文件压缩包大小94.44MBXML文件记录了每个目标的位置框与类别名称便于直接转换为YOLO格式进行训练也可用于评估模型在真实场景下的检测精度。该子集属于自行车十类数据中的第五类图像内容以山地自行车等细分车型为主适合作为细粒度识别的补充数据帮助提升算法对非机动车类型的区分能力。更完整的自行车、电动车、三轮车数据集还涵盖共享单车、公路自行车等多个类别便于后续扩展多目标检测任务。目前已有164人学习下载适合算法研究者、竞赛队伍以及需要高质量标注数据完成YOLOv5项目落地的开发者。1. yolov5目标检测盯上非机动车违停一条值得照做的机器视觉识别落地链路每天早晚高峰小区出入口、园区消防通道、地铁口自行车道成片停着共享单车和外卖电动车。保安挪了又停物业想装系统又怕识别不准。这套方案要做的事就是用yolov5这个主流目标检测模型配合一份已经标注好的数据集比如bicycles4_images_xmls这种图片加XML标注的包训练出一个能识别自行车、电动车等非机动车目标的模型再叠加禁停区域判定逻辑把机器视觉识别违规停放报警完整跑通。它的价值有三点不用换摄像头普通监控的RTSP流就能喂给模型不用从零标数据直接拿已标注的XML数据集起步检测模型和后处理分离识别不准时可以先调告警逻辑不用重训模型。适合有Python基础、正在做园区或街道智能化改造的工程师照着复现也适合想验证yolov5在自己业务里是否值得投入的团队。如果你只想要一个demo这份指引两三天就能跑通。2. 吃透bicycles4_images_xmls把已标注数据集的VOC标注转成YOLO训练格式2.1 先读懂一个标注数据集里有什么拿到bicycles4_images_xmls命名里已经透露关键信息bicycles 说明目标以自行车为主4 通常指类别数量或采集场景数images 和 xmls 说明它是一批图片配 Pascal VOC 格式的标注文件。我习惯先把目录完整列一遍不要急着训练。重点看任一个 XML 的标签结构确认它是不是标准 VOC 文件因为有的数据集会在object里塞进difficult、truncated、pose这些字段而 YOLO 训练只关心类别名和框坐标。下面是一个典型的 VOC 标注文件内容annotation folderimages/folder filenameIMG_001.jpg/filename size width1280/width height720/height depth3/depth /size object namebicycle/name truncated0/truncated difficult0/difficult bndbox xmin210/xmin ymin180/ymin xmax520/xmax ymax620/ymax /bndbox /object /annotation解读这份 XML 只需要抓住三个部分name是目标的类别名bndbox里四个坐标是左上角和右下角的像素坐标size是原始图片宽高YOLO 训练时会把坐标归一化到 01所以后面转换脚本必须从这里读宽高而不是自己用 OpenCV 去量量错了会让所有标注框一起偏移。另一个容易忽略的点是标题里写 4 不一定就代表四个类别务必先对 XML 里的name去重统计一遍我曾经拿到一个名字带 6 的数据集实际只有 3 类剩下的类别因为标注量太少被原作者丢弃了。2.2 XML 转 YOLO归一化坐标与类别映射脚本这一步做的是把 VOC 的左上右下坐标转成 YOLO 需要的class cx cy w h格式。yolov5 源码里自带类似工具但独立写一个更可控也方便以后换数据集复用。下面这个脚本按目录遍历所有 XML输出到labels_yolo目录# voc_to_yolo.py import xml.etree.ElementTree as ET import os from glob import glob # 按你的xml里name实际枚举修改这里只是常见示例 CLASS_MAP {bicycle: 0, ebike: 1, motorcycle: 2, tricycle: 3} def convert_one(xml_path, out_dir): tree ET.parse(xml_path) root tree.getroot() size root.find(size) w int(size.find(width).text) h int(size.find(height).text) lines [] for obj in root.iter(object): name obj.find(name).text if name not in CLASS_MAP: print(f[skip] {xml_path} 里出现未映射类别: {name}) continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) cx (xmin xmax) / 2 / w cy (ymin ymax) / 2 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{CLASS_MAP[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) if lines: txt_name os.path.basename(xml_path).replace(.xml, .txt) with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(lines)) return len(lines) xml_dir bicycles4_images_xmls/Annotations out_dir bicycles4_images_xmls/labels_yolo os.makedirs(out_dir, exist_okTrue) total 0 for xml_path in glob(os.path.join(xml_dir, *.xml)): total convert_one(xml_path, out_dir) print(f转换完成共处理 {total} 个目标)逻辑本身不复杂但几个地方决定了转换质量。w和h必须优先从size里读如果某个 XML 里没写size宁可直接报错也不要跳过因为归一化错一个数据训练时模型会学到错误位置。CLASS_MAP是整个流程的地基它的值决定训练时每个类别的数字 ID后面data.yaml里的names列表顺序必须和它一致顺序错位是最隐蔽的错误训练能跑、loss 能降但推理出来的类别名全部对不上。另外脚本里遇到未映射类别直接打印跳过我建议跑完后把输出里的[skip]全部收集一遍看看到底还有哪些类别没进映射表。2.3 划分训练集、验证集与测试集并生成 data.yaml转换完标注以后要做数据划分。yolov5 官方支持在data.yaml里直接指定文件夹路径我一般把数据集整理成images/train、images/val、images/test加对应labels目录的结构。下面这个脚本按 8:1:1 划分# split_dataset.py import os, random, shutil img_root bicycles4_images_xmls/images label_root bicycles4_images_xmls/labels_yolo dst datasets/bicycles for split in (train, val, test): os.makedirs(f{dst}/images/{split}, exist_okTrue) os.makedirs(f{dst}/labels/{split}, exist_okTrue) imgs [f for f in os.listdir(img_root) if f.lower().endswith((.jpg, .jpeg, .png))] random.seed(42) random.shuffle(imgs) n len(imgs) train_imgs imgs[:int(n * 0.8)] val_imgs imgs[int(n * 0.8):int(n * 0.9)] test_imgs imgs[int(n * 0.9):] def move(img_name, split): shutil.copy(os.path.join(img_root, img_name), f{dst}/images/{split}/{img_name}) label_name img_name.rsplit(., 1)[0] .txt src_lab os.path.join(label_root, label_name) if os.path.exists(src_lab): shutil.copy(src_lab, f{dst}/labels/{split}/{label_name}) for img in train_imgs: move(img, train) for img in val_imgs: move(img, val) for img in test_imgs: move(img, test) print(ftrain{len(train_imgs)} val{len(val_imgs)} test{len(test_imgs)})我特意用copy而不是move因为原始标注文件是资产如果后面发现某张图有问题还能回源检查。随机种子固定成 42保证每次划分结果一致便于复现实验。把测试集独立出来而不是只用训练和验证是为了最后做一次没见过的数据评估很多新手省掉 test 集结果模型过拟合了都不知道。然后创建数据描述文件# datasets/bicycles/data.yaml path: ../bicycles train: images/train val: images/val test: images/test nc: 4 names: [bicycle, ebike, motorcycle, tricycle]path用相对路径这样整个数据集目录挪到哪里都不需要改配置。names的顺序必须和CLASS_MAP的值对应也就是CLASS_MAP里 bicycle 是 0names第一项就得是 bicycle错一个全错。2.4 画框检查标注数据最大的坑是看着对但实际错转换完再急也要先画一遍框。标注数据这个环节最大的坑不是格式而是看着对但实际错坐标单位不对、类别名拼写不一致、漏标、有些 XML 根本没有 object 标签。写一个可视化脚本把标注框直接画出来逐张看# check_labels.py import cv2, os from glob import glob label_dir datasets/bicycles/labels/val img_dir datasets/bicycles/images/val out_dir check_output os.makedirs(out_dir, exist_okTrue) for txt in glob(os.path.join(label_dir, *.txt)): name os.path.basename(txt).replace(.txt, .jpg) img_path os.path.join(img_dir, name) img cv2.imread(img_path) if img is None: continue h, w img.shape[:2] for line in open(txt): parts line.strip().split() if len(parts) 5: continue cls_id, cx, cy, bw, bh map(float, parts) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(os.path.join(out_dir, name), img) print(检查图已输出到, out_dir)画框检查要重点看三件事框是不是精确贴住目标还是大了一圈、偏了一半小目标、遮挡目标有没有被漏标同一张图里类别名是否明显标错比如把共享单车标成 motorcycle。这一步偷懒的后果会直接体现在训练指标上你会在第 5 章看到 mAP 虚高但真实场景没法用的典型症状。3. 训练自己的数据集conda环境、yolov5超参数与训练日志判读3.1 用 conda 建一个干净的 yolov5 环境训练自己的数据集之前先把环境隔离好。我见过太多人把依赖装在系统 Python 里最后 torch、opencv、numpy 版本互相打架排查半天发现是环境问题。用 conda 一步到位conda create -n yolov5 python3.9 -y conda activate yolov5 # 拿到yolov5源码并进入项目目录 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txtPython 3.9 对 torch 和 onnxruntime 的兼容性都比较稳新版本某些依赖可能还没跟上。requirements.txt里列的依赖以 torch 为核心建议在装之前先按官方命令装好对应 CUDA 版本的 torch再运行pip install -r requirements.txt这样不会让 pip 擅自装一个 CPU 版 torch 进去。用 CPU 跑小数据集不是不行但一个 300 epoch 的训练可能要跑十几个小时有 NVIDIA 显卡哪怕 4G 显存也是天壤之别。一条血泪经验conda 环境里pip install不要加-U把所有依赖强制升级yolov5 对 numpy 版本有要求升级到新版 numpy 后训练时经常报_ARRAY_API not found这个报错我在不同机器上碰到了三次。3.2 data.yaml 组装完成后训练命令与首轮超参数数据集按第 2 章整理好环境也通了就可以启动第一次训练。我用的是 yolov5s 权重起步因为它尺寸小、迭代快适合先用它评估数据质量。如果数据量小千张级直接上 yolov5m 或 l 反而容易过拟合。python train.py \ --data /path/to/datasets/bicycles/data.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 300 \ --patience 50 \ --device 0--img 640是 yolov5 的默认输入分辨率对自行车、电动车这种中等尺度的目标足够了。--batch 16在 8G 显存左右比较稳如果你只有 4G 显存就降到 8batch 显存不够时程序会直接 OOM 而不是自动降级。--patience 50是早停参数意思是 val 指标连续 50 个 epoch 不提升就停止配合--epochs 300用不用干等 300 轮。首轮训练不要急着改超参数先用默认值跑完拿到一套 baseline 再说。yolov5 的超参数都在data/hyps/hyp.scratch-low.yaml这个文件里直接改文件或者用--hyp指定。我常改的是下面这几个超参数默认值我的建议值说明hsv_h0.0150.05调大色相扰动抗光照变化hsv_s0.70.9增强饱和度扰动改善夜间degrees0.010.0加旋转扰动适应摄像头视角变化fliplr0.50.5左右翻转保持默认mosaic1.01.0前 70% 的 epoch 保持开启hsv_h和hsv_s是夜间场景的关键。监控摄像头白天和晚上画面色调差异很大默认的色相扰动只能覆盖小范围光线变化调到 0.05 后模型能更快适应傍晚和夜间环境。degrees加 10 度是因为非机动车停放经常有斜向角度不加旋转扰动模型遇到 45 度角停放的自行车框会飘。3.3 训练日志怎么判读P、R、mAP50 与 mAP50-95训练过程中每个 epoch 都会输出一张表新手习惯只看 loss但真正有用的信号是 PrecisionP、RecallR、mAP50 和 mAP50-95 这四个指标的组合。P 高 R 低说明模型检测出来基本都是对的但漏了很多目标常见原因是训练数据里难例太少比如带遮挡、夜间、小目标的样本不够。R 高 P 低说明模型把不相干的东西也框进来了可能是标注噪声大或者两类目标本身长得像。mAP50 和 mAP50-95 差距大说明框的位置不够精确这时候优先把--img提到 768 看有没有改善。一个需要留意的地方是 val loss 曲线。训练集 loss 持续下降但 val 的 box_loss 或 cls_loss 在 150 epoch 后开始反弹这是过拟合的典型特征。遇到这种情况先别调模型回去看训练集和验证集是不是有重复图片很多划分脚本会把同一场景的不同帧同时分进两个集合导致验证指标虚高。等 test 集跑完发现指标大跌才知道数据泄漏了。训练结束后真正有用的是runs/train/exp*/weights/best.pt和last.pt。best 是 val 指标最好的权重last 是最后一个 epoch 的权重我一般只用 best除非 last 在某个类别上 recall 明显更好才会单独测试。3.4 第一次推理在验证集上跑 detect.py训练完第一步用 detect.py 对验证集做一次推理直观感受模型表现python detect.py \ --weights runs/train/exp/weights/best.pt \ --source datasets/bicycles/images/val/ \ --conf-thres 0.55 \ --iou-thres 0.45 \ --save-txt--conf-thres我首轮就设 0.55 而不是默认的 0.25因为非机动车违规停放场景宁可少检出也不希望误报刷屏后续还有后处理环节能补一部分漏检。--iou-thres0.45 控制两个检测框重叠多少会被合并目标密集区域如果发现一个目标被压下半个框说明该调低这个值。--save-txt会输出每张图的检测结果 txt这在第 4 章做后处理时需要用到。推理结束后抽几张图看看重点关注漏检和误检是不是集中在某些特定视角或光线条件下这些信息后面调参直接对号入座。4. 机器视觉识别不只输出检测框违规停放的区域判定与后处理去抖4.1 为什么检测框本身不等于违规停放结论yolov5 目标检测的输出是这张图哪个位置有非机动车、属于哪个类别、置信度多少但违规停放是一个时序业务结论它要求目标出现在禁停区域里并且持续停留超过设定时长。如果直接把单帧检测结果当告警触发条件任何骑自行车路过禁停区的人都会被误报监控画面里人员流动大的时候告警能刷到保安直接关掉系统。这是 Machine Vision 项目从 demo 到生产最常跨不过去的一道坎。所以部署时的常见做法是把检测模型当作前端感知后处理单独写一个业务模块负责区域判定和时序判定。这一章节就围绕后处理展开输入的检测结果可以是 detect.py 保存的 txt也可以是直接调用模型 forward 输出的 tensor。在工程上我习惯用后者因为端到端延迟更短但调试阶段用 txt 更直观。4.2 禁停区域判定用射线法判断目标是否进入区域禁停区域从哪来最简单的办法是让运维在监控画面里用鼠标标一个多边形保存顶点像素坐标到配置文件。判定一个目标的中心点是否落在多边形内用射线法就够了不需要引入 shapely 这种额外依赖。原理是从目标点向右画一条水平射线统计它与多边形边的交点数交点为奇数则在区域内偶数则在区域外。下面是纯 Python 实现# zone_tracker.py import numpy as np # 多边形按图像原始分辨率标定假设画面是1280x720 FORBIDDEN_ZONE [(300, 250), (1000, 250), (1000, 620), (300, 620)] def point_in_polygon(x, y, poly): inside False n len(poly) j n - 1 for i in range(n): xi, yi poly[i] xj, yj poly[j] if ((yi y) ! (yj y)) and (x (xj - xi) * (y - yi) / (yj - yi) xi): inside not inside j i return inside # 示例检测框中心(500, 400)是否在禁停区 print(point_in_polygon(500, 400, FORBIDDEN_ZONE)) # True用检测框中心点判断是否进区域比用整个框判断更稳因为监控画面里目标经常有遮挡框体边缘会抖动中心点相对稳定。如果你画的禁停区形状很复杂比如一条带弧度的道路那么多边形顶点尽量取密一些射线法对凹多边形也有效不必担心。4.3 帧间 IOU 跟踪与停留时长统计目标进了禁停区只是一帧的事必须证明它持续停留才算违规。最简单可靠的实现是维护一个 track 字典每个检测框用帧间 IOU 匹配到上一帧的某个 track然后统计该 track 连续出现在禁停区内的帧数。下面是核心逻辑# zone_tracker.py 继续 class ZoneTracker: def __init__(self, conf_thres0.55, iou_thres0.45, min_frames30): self.conf_thres conf_thres self.iou_thres iou_thres self.min_frames min_frames self.tracks {} def update(self, results, frame_id): # results: 每行为 [x1, y1, x2, y2, conf, cls_id]坐标按原图分辨率 alerts [] new_tracks {} for det in results: x1, y1, x2, y2, conf, cls_id det if conf self.conf_thres: continue cx (x1 x2) / 2 cy (y1 y2) / 2 if not point_in_polygon(cx, cy, FORBIDDEN_ZONE): continue # 与上一帧的track按IOU匹配 best_id None best_iou self.iou_thres for tid, tr in self.tracks.items(): iou self._iou(tr[box], [x1, y1, x2, y2]) if iou best_iou: best_iou iou best_id tid if best_id is not None: new_tracks[best_id] self.tracks[best_id] new_tracks[best_id][box] [x1, y1, x2, y2] new_tracks[best_id][frames] 1 if new_tracks[best_id][frames] self.min_frames: alerts.append(best_id) else: # 新目标分配新track id new_tracks[len(self.tracks)] {box: [x1, y1, x2, y2], frames: 1} self.tracks new_tracks return alerts def _iou(self, box1, box2): # 标准IOU区间为0~1 xa max(box1[0], box2[0]); ya max(box1[1], box2[1]) xb min(box1[2], box2[2]); yb min(box1[3], box2[3]) inter max(0, xb - xa) * max(0, yb - ya) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) union area1 area2 - inter return inter / union if union 0 else 0这段代码故意做了最简处理每帧只保留在禁停区内的目标不会做全局跟踪。优点是逻辑清楚、好排错缺点是目标被完全遮挡再出现时会丢掉 track id。生产环境我一般换成 ByteTrack但核心思想完全一致帧间 IOU 匹配、维护 track、统计持续帧数。这里的min_frames30示意在 10fps 的监控流上代表 3 秒具体值要按实际帧率和业务要求换算这个参数是业务参数不是一个随便填的技术参数后面会细说。4.4 告警抑制与去抖别让后处理把模型的小抖动放大模型检测框在连续帧里偶尔会抖有时目标明明没动框本身上下浮动几像素。区域边界上的目标最容易踩中这个问题这一帧中心点在区域内下一帧在区域外track 的frames一直被清零永远触发不了告警这是漏报型抖动。反过来如果目标正好贴着区域边界中心点在区域内、区域外反复横跳又被误触发这是误报型抖动。我的做法有两层。第一层是在ZoneTracker里给frames一个容错允许 track 在区域内连续出现 N 帧后即使中间有个别帧中心点落在区域外也不马上清零而是给一个lost_count连续超过 3 帧才重置。第二层是告警冷却同一个 track 触发告警后进入冷却时间比如 120 秒内不重复发送否则高频场景下后台告警能刷到崩溃。用一个简单字典就能实现alerted_track {} last_alert_frame {} COOLDOWN_SECS 120 def should_alert(track_id, frame_id, fps): if track_id in alerted_track: if frame_id - last_alert_frame[track_id] COOLDOWN_SECS * fps: return False alerted_track[track_id] True last_alert_frame[track_id] frame_id return True冷却时间的设定取决于业务方的容忍度。物业希望违停发生后尽快收到通知但又不希望同一个车被反复提醒我一般给默认 120 秒配合通知渠道的限流策略一起调。还有个小细节告警消息里除了 track_id最好带上触发时的检测框截图没有截图的后处理系统出了问题根本没法回溯。5. 常见问题排查训练、标注和部署阶段最典型的 5 个翻车现场5.1 训练 loss 不降反升先查数据再查超参数现象是训练进行到第 20 个 epoch 时box_loss 和 obj_loss 不但没降反而比第 1 个 epoch 还高。原因大概率不是学习率而是标注数据里混入了大量空标签或错误标签。比如有的图片尺寸只有 640x480但 XML 里标的是 1920x1080 的坐标换算后框落在画面外训练等于在教模型学错误目标。解决方法是回到第 2 章的画框检查把所有 txt 里 cx、cy 在 01 范围之外的样本挑出来删掉再用它们原图的 XML 重新转换一遍。检查后如果数据没问题再考虑把--lr0从默认值调低到 0.005一次只改一个变量。5.2 所有标注转好了训练却报 no labels found现象是训练启动后很快提示找不到标签或者某个类别完全没有参与 loss 计算。原因多半是data.yaml里names的顺序和CLASS_MAP的映射不一致或者数据集目录里的labels/val是空的图片复制过去了但对应 txt 因为 XML 为空没生成。我的排查路径是这样的先在labels/val里随便找几个 txt用cat看内容确认每行第一个数字在 0 到nc-1之间再用find . -name *.txt | wc -l和find ./images -name *.jpg | wc -l对比数量数量对不上就是划分脚本漏复制了空标签文件。这个问题在换数据集时最容易复发所以我的转换脚本里特意加了if lines:才写文件避免生成 0 字节 txt。5.3 白天 mAP 很高晚上识别全翻车现象是训练权重在白天测试集上 mAP50 有 0.85但傍晚光线一差漏检率直接翻倍。原因是训练数据的直方图太集中默认数据增强里的色相、饱和度扰动强度不足以模拟夜间光照。解决分两步先调data/hyps/hyp.scratch-low.yaml里的hsv_h到 0.05、hsv_s到 0.9同时把degrees加到 10如果还不行就去采集一些夜间样本补充到训练集里数量不需要多500 张夜间图就能带来明显改善。另外推理侧可以做一个简单的预处理把夜间帧做一次 CLAHE 对比度增强再喂给模型这套组合基本能压住夜间翻车。5.4 检测框抖动导致告警刷屏现象是禁停区域边缘的车辆明明停着没动后台却收到好几条告警且告警里抓到的截图框位置每次都有偏移。原因是单帧检测框不稳定连续帧的框在目标附近小幅浮动后处理又用了触发即告警的逻辑没有冷却时间。还有一个隐蔽推手是--iou-thres设得太低或太高导致帧间匹配错乱同一个目标被当成两个 track分别触发告警。解决方法是先调后处理把第 4 章的should_alert冷却逻辑加上再把 IOU 阈值固定在 0.45 附近最后去模型侧查输入分辨率如果训练时用 640、推理时用 960框的坐标尺度不同会产生抖动需要保持推理尺寸和训练尺寸一致。5.5 验证集表现好真实监控视角下检测率腰斩现象是同样的权重在验证集图片上效果不错部署到真实监控画面后角度高、距离远的目标漏检严重。原因是数据集里的图大多是近景平视角度而真实监控摄像头挂在 3 到 6 米高度俯视角度下自行车和电动车的视觉特征变化很大模型没见过这类样本。解决方法是收集一段真实监控视频做二次标注把俯视样本加进训练集重训数量控制在原数据集的 30% 到 50% 就能显著改善。另一个更经济的做法是先用第 2 章的脚本把真实视频抽帧用训练好的模型做预标注再用 LabelImg 人工修正这个半自动流程能省一半标注工时。这里要特别提醒如果摄像头安装位置、高度、角度和训练数据差异过大靠调参是救不回来的必须补数据。6. 部署到树莓派5模型导出、帧率优化与监控录像回放验证6.1 用 export.py 导出 ONNX 模型树莓派5 上没有 NVIDIA GPU官方 TensorRT 方案跑不了最省事的是导出 ONNX 模型后在 arm64 Linux 上用 onnxruntime 做 CPU 推理。导出命令python export.py --weights runs/train/exp/weights/best.pt --include onnx --img 640导出后在同目录生成best.onnx用它替代原来的 PyTorch 权重可以脱离训练环境运行。导出时留意--img必须和训练时的输入尺寸一致不一致会导致输出框坐标换算偏差。6.2 树莓派5上的推理效率优化推理侧先用 OpenCV 把帧缩放到 640x640做 BGR 到 RGB 转换和归一化。onnxruntime 在树莓派5 上默认走 CPU设置线程数和执行模式能减少延迟。下面是加载和推理的核心代码import onnxruntime as ort import cv2 import numpy as np session ort.InferenceSession( best.onnx, providers[CPUExecutionProvider], sess_optionsort.SessionOptions() ) session.set_providers([CPUExecutionProvider], [{arena_extend_strategy: kSameAsRequested}]) img cv2.imread(frame.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (640, 640)) img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1)[None] input_name session.get_inputs()[0].name outputs session.run(None, {input_name: img})输出是一个包含检测框坐标、置信度的列表栅格解码这一步可以从训练好的模型源码里拿现成函数不要自己重新造轮子坐标解码错了排查起来非常耗时。效率优化的空间主要有三个一是考虑把输入尺寸从 640 降到 480mAP50 最多掉一两个点但帧率能提升 30% 以上二是预处理不要逐像素用 Python 循环全部用 OpenCV 的矩阵操作三是把检测模型的推理线程数设成树莓派5 的性能核数量用ps -o psr查绑核情况避免推理线程在大小核之间乱跳。6.3 用一周监控录像回放做验收而不是只看几张测试图最后一步是验收。只拿测试集图片算 mAP 没有说服力真实业务要统计误报率和漏报率我的方法是把模型和后处理脚本指向录制好的监控录像回放一周逐帧跑一遍然后人工核对告警记录。用表格记录指标指标统计口径参考范围误报率非停放车辆触发告警次数 / 总告警次数低于 5%漏报率停放超过 N 秒未触发告警次数 / 实际违规总数低于 10%平均延迟从车辆停稳到告警生成的时间差3 到 10 秒这里的 N 秒和告警冷却时间直接决定两个指标的口径验收前先和业务方敲定否则后面反复返工。我自己的教训是第一次上线把置信度阈值放到了 0.25后台告警多到保安直接把通知权限关了后来把conf-thres调到 0.55、min_frames调到 6 秒误报率才控制在可接受范围。回放的另一个价值是能发现摄像头被树枝遮挡、夜间补光不足这类环境问题这些不是模型能解决的但如果你在验收阶段就暴露出来至少知道该找物业还是找算法工程师。这套从数据集到部署的完整链路跑通之后再遇到类似的目标检测项目你只需要换数据和区域配置愿不愿意为它投入时间看完验收数据再决定希望帮到你。本文还有配套的精品资源点击获取