做实时摄像头目标检测,很多人一开始都是直接跑YOLOv5官方那个detect.py,双击运行,看到窗口弹出来框住几个目标,感觉挺顺利。但真把模型扔进自己的摄像头场景里,问题一下子就全冒出来了:画面卡到每秒钟只能跳几帧、检测框莫名其妙飞走、人明明站在镜头正中央却标不出来、背景里的反光招牌反而被死死框住。作为一个把YOLOv5部署到实际监控和视频流项目里反复折腾过的人,这篇教程我打算把卡顿、漏检、误检这三类问题一次性拆开讲透。
本文会先从原理层面告诉你问题出在流水线的哪一环,再给出多线程解耦、模型推理侧优化、阈值调参、预处理对齐等一系列可以直接抄的完整方案,最后附上一份复制即用的实时检测代码。适合已经能跑通YOLOv5官方demo、但希望在摄像头或视频文件场景中稳定实时运行的人,无论你是刚入门还是已经在调优路上踩过坑,应该都能从这里拿到一些实在的东西。
1. 卡顿和不准确,先搞清楚病灶在哪里
1.1 实时视频卡顿:真正的瓶颈往往不是模型,而是流水线
很多人一看到画面卡顿,第一反应就是“模型太大了,算力不够”,然后急着去换更轻量的网络。但我在实际项目里排查过不少类似问题,发现大部分情况根本不是模型本身慢,而是整个程序的数据流水线是串行的。
典型的写法长这样:一个while循环里,先cap.read()读一帧,然后把这一帧送进模型推理,再在同一个循环里把结果画出来、显示到窗口。这个流程看起来没问题,但每一帧都要依次等待摄像头读取完成、等待推理计算完成、等待画面渲染完成。摄像头一秒钟产生30帧,但模型推理一帧需要100毫秒,那么下一帧就得排队等着,时间一长,延迟就不断累积,看起来就像是“画面越来越卡,越跑越慢”。
更麻烦的是,OpenCV 的VideoCapture内部有一个缓冲队列。如果你推理太慢,来不及读取的帧会堆积在这个缓冲区里,等你把当前帧处理完再去read()时,拿到的可能已经是好几秒之前的画面了。也就是说,系统没有在“实时检测”,而是在“播放一段延迟越来越大的录像”。很多人觉得是模型跑不动,其实是被这个缓冲机制坑了。
所以,解决卡顿的第一步从来不是换小模型,而是先把流水线拆开:采集归采集,推理归推理,显示归显示,三个环节用队列解耦,让它们各跑各的。我们把这种设计叫做生产者-消费者模型,后面第2章会给出完整实现。
1.2 检测不准:漏检、误检、框不准,对应的是完全不同的原因
平时我们说的“检测不准”,其实可以分为三种现象,很多新手喜欢一刀切地改参数,结果越改越乱。
漏检是目标明明在画面里,却没有被标出来。常见原因是置信度阈值设置得太高,模型其实已经检测到了,只是得分没超过你的门槛,被当成背景丢掉了。另外,如果目标很小(比如画面远处的人、车辆),而输入给模型的图片分辨率太低,小目标进入深层特征图时只剩一个像素点,模型根本“看不见”,这种情况也会漏检。
误检是画面里明明没有的东西,被标出了一个框。这个更多是置信度阈值设置得太低,导致模型把一些纹理相似、看起来“有点像”的背景区域也当成目标输出了。比如墙面花纹、树荫里的光斑、远处的人形电线杆,在低阈值下很容易被误报出来。此外,用官方预训练权重去检测和训练数据分布差异很大的场景时,误检也会明显增加。
框不准则是检测框位置跟实际目标有偏差,或者置信度分数忽高忽低。这个很大概率出在预处理环节,比如颜色通道顺序搞反了、letterbox填充没做、坐标没有映射回原始画面。这些问题往往被忽略,但一旦出现就是整体性的“大乱”,比调阈值严重得多。
1.3 为什么官方 detect.py 不能直接拿来跑视频流
官方 detect.py 原本是为了演示和验证模型效果而写的,它默认是“逐张图片、逐帧推理”,并没有考虑实时视频场景下的延迟问题。它会把读取、推理、画框、保存结果都串在一起,再加上各种日志输出和文件写入逻辑,直接拿它跑摄像头,体验自然不会好。
我见过不少人把 detect.py 里的source参数直接设成 0 来开摄像头,结果发现又卡又慢。不是官方代码不能用,而是它面向的场景是离线视频分析,不是低延迟实时检测。如果你需要在摄像头画面中持续稳定地输出检测结果,比较好的做法是参考官方代码里的模型加载方式,但自己重新搭一个精简的检测流水线。这个流水线长什么样,咱们一步步来拆。
2. 根治卡顿:多线程解耦 + 推理侧优化
2.1 用生产者-消费者模型拆开采集、推理、显示
既然卡顿的根源是串行阻塞,那么方案就是把三个环节分到不同线程里,让它们像流水线一样并行执行。用一个专门的线程去读摄像头,读到的帧放进一个队列;主线程或另一个推理线程从这个队列里取帧做检测;检测结果再交给显示线程绘制并且展示。这样即使某一帧推理特别慢,也不会阻塞摄像头继续读取,程序始终拿到的都是尽可能新鲜的画面。
我在实际项目里常用一个非常简化的ThreadedCamera类,它只负责两件事:后台线程不停read(),然后把帧放到一个长度受控的队列里。这里最关键的一点是,队列容量不能设置得太大。很多新手会直接把队列设成无限大,觉得缓存越多越不容易丢帧,但这样恰恰会重新引入延迟积累。你缓存了100帧,处理完最后一帧时,现实世界已经往前走了好几秒,这不叫实时,叫回放。
正确的做法是把队列容量设成1到3之间,并且在写入新帧前,如果队列满了,就直接丢弃最旧的帧。这样程序永远在处理“最新的一帧”,而不是堆积在缓冲区里的旧数据。说白了就是:宁可丢帧,也不要延迟。这个策略在实时交互场景中非常关键,画面偶尔有点跳动,但整体跟手,比一切正常但延迟三秒的体验好得多。
下面是 ThreadedCamera 类的核心实现,我做过精简,可以直接嵌到你的项目里。
import cv2 import queue import threading class ThreadedCamera: """ 摄像头采集线程:读取帧并放入队列,队列满时丢弃旧帧。 max_queue_size 建议设为 1~3,太小容易丢帧,太大增加延迟。 """ def __init__(self, source=0, max_queue_size=2): self.cap = cv2.VideoCapture(source) # 关键一:主动降低 OpenCV 内部缓冲区大小 # 很多摄像头驱动默认会缓存多帧,设成 1 能大幅减少读取延迟 self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 根据需要设置分辨率 / 帧率,推荐先取 640x480 或 1280x720 self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) self.queue = queue.Queue(maxsize=max_queue_size) self.running = True self.thread = threading.Thread(target=self._reader_loop, daemon=True) self.thread.start() def _reader_loop(self): while self.running: ret, frame = self.cap.read() if not ret: continue # 关键二:队列满时删掉旧帧,保证拿到的永远是最新画面 if self.queue.full(): try: self.queue.get_nowait() except queue.Empty: pass self.queue.put(frame) def read(self): """ 非阻塞读取,没有新帧时返回 None。 调用方不需要 sleep,让它自然循环即可。 """ try: return self.queue.get_nowait() except queue.Empty: return None def release(self): self.running = False self.thread.join(timeout=1.0) self.cap.release()这个类用起来非常简单,在主循环里调用read()就行:
cam = ThreadedCamera(0) while True: frame = cam.read() if frame is None: continue # 把 frame 交给推理模块处理…… # cv2.imshow(...)实际测试下来,把采集线程拆分出去之后,画面延迟立刻就能降下来一大截,哪怕推理本身没有提速,感官上也会觉得“顺畅很多”。这个优化对USB摄像头尤其明显,因为很多USB摄像头的读取调用本身就很慢,等待时间可能超过20毫秒。
2.2 推理侧优化:FP16 半精度、输入尺寸和跳帧策略
多线程解耦解决的是“流水线堵车”的问题,但如果推理本身耗时太长,哪怕流水线再顺,画面帧率也上不去。这里我给出三个维度,你可以按自己的硬件条件组合使用。
第一是半精度推理。在支持FP16的GPU上,把模型切换到half模式,推理速度通常能有显著提升,显存占用也会降低。YOLOv5 官方代码里只需要一行:
model = model.half()注意,半精度模式下输入图像也要做相应转换,否则会报类型不匹配。如果你用的是torch.hub.load加载的模型,输入是 PIL 或 ndarray 倒还好,results = model(frame)内部会自动处理。但如果自己写预处理并用model(frame_tensor)直接推理,就得手动把张量转成half()。
第二是输入尺寸选择。YOLOv5 默认输入是640x640,这个尺寸在检测精度和速度之间比较平衡。但如果你的画面里目标都比较大,或者你的算力实在有限,可以降到416甚至320,速度提升非常明显。相反,如果你经常漏检小目标,就需要从640往上升,比如960或1280。输入尺寸对帧率的影响几乎是平方级别的,因为模型计算量和图片像素数基本成正比,640→320,计算量直接降到原来的四分之一。
第三是跳帧策略。如果单帧推理要150毫秒,而你的视频流是30帧每秒,无论如何都不可能实时处理所有帧。这时候可以直接不处理每一帧,比如轮询到第1帧时做检测,第2、3帧直接沿用上一帧的检测结果或者干脆跳过,只负责显示画面。检测框更新慢一点,但画面本身是流畅的,视觉体验反而更好。跳帧间隔可以根据检测耗时动态调整,逻辑参考下面这段:
frame_id = 0 detect_every = 2 # 每 2 帧做一次检测 while True: frame = cam.read() if frame is None: continue if frame_id % detect_every == 0: boxes = detector.detect(frame) # 返回检测结果 last_boxes = boxes else: boxes = last_boxes # 中间帧沿用上次结果 draw_boxes(frame, boxes) cv2.imshow("result", frame) frame_id += 1不过要提醒一下,跳帧本质上是用精度换流畅度。如果场景里目标移动非常快,中间帧沿用上一帧的检测框,框的位置会明显滞后。实际项目中可以考虑做一次轻量追踪(比如根据上一帧的位置做个线性预测),但这属于进阶内容,入门阶段先做到“不卡”更重要。
2.3 显示端优化:别让绘图层吃掉帧率
很多人在优化了采集和推理之后,发现帧率还是上不去。这时候就要看看显示和绘制环节了。
首先是显示窗口尺寸。如果你在代码里直接imshow一个1920x1080的原始帧,窗口渲染本身就会消耗不少CPU和GPU资源。实战中我会先把帧resize到适合预览的尺寸,比如960x540或1280x720,再用窗口显示,既保证能看清细节,又不会因为绘制太慢拖累整个流程。
其次是绘制开销。cv2.putText、cv2.rectangle这类操作如果每帧调用几十次,在Python里也是不小的开销,尤其是文本渲染。建议文字内容尽量精简,框的线条粗细不要太离谱,并且不要一次性绘制大量不必要的辅助信息。如果检测到的目标数量很多,可以考虑只画置信度较高的前N个框,后续的都以数值或文字汇总方式显示。
最后还要注意cv2.waitKey(1)和窗口刷新节奏。如果你在imshow之后没有合理控制主循环速度,窗口可能因为刷新太快导致渲染来不及,看起来像卡顿。通常我会统计FPS并在画面上显示出来,这样每一次改动都能直观看到效果。
3. 检测不准、漏检、误检的调优与避坑
3.1 conf 和 iou 不是玄学,搞清楚各自的职责再说
很多初学者拿到检测结果不好,第一件事就是乱调conf_thres和iou_thres,问为什么要调,答不上来。这两个参数分别管的是两件完全不同的事情。
conf_thres是置信度阈值,决定了一个候选框要“够自信”才会被输出。它的优先级非常高。如果你把阈值设成0.95,那基本上只有泰山压顶般清晰的样本才会显示出来;设成0.05,那背景里的任何一点风吹草动都会被你觉得“像目标”。
它和三种现象的关系是这样的:漏检多,优先降低conf_thres,比如从0.45降到0.3甚至0.2,多放出来一些低分框;误检多,优先提高conf_thres,把那些低分假阳性全部过滤掉。我在实际项目里调试时,会先把conf_thres降得比较低(比如0.15),让所有候选框都暴露出来,观察到底哪些是漏检、哪些是误检,再逐步调高,找到那个平衡点。
iou_thres是NMS阶段用来判断“两个框算同一个目标”的阈值。NMS会把和最高分框重叠度足够高的框删掉,iou_thres就是这个“足够高”的判定标准。如果你发现同一个目标被输出了两个甚至多个框,说明NMS抑制得不够狠,可以适当降低iou_thres,让更多重叠框被合并掉。反过来,如果两个靠得很近的目标被合并成了一个框,说明抑制得太狠,可以适当提高iou_thres。
下面这张表是我日常调试时使用的经验初始值,你可以根据自己的场景在这个基础上微调。
| 现象 | 优先调整参数 | 调整方向 | 说明 |
|---|---|---|---|
| 漏检明显 | conf_thres | 降低,如0.45降到0.2 | 让低置信度目标也有机会输出 |
| 误检太多 | conf_thres | 提高,如0.3升到0.5 | 过滤低分假阳性 |
| 同一目标多个框 | iou_thres | 降低,如0.45降到0.3 | 增强NMS合并力度 |
| 邻近目标被并成一个 | iou_thres | 提高,如0.45升到0.6 | 减轻NMS抑制力度 |
还要注意,这两个参数是联动的。conf_thres降得越低,会有越多的候选框进入NMS,iou_thres的重要性就越高。如果只调conf_thres不调iou_thres,可能从“漏检”变成“重复框爆炸”,所以调参时要把它们当成一个组合来试。
3.2 小目标检测优化:从输入尺寸到图像切块
远距离小目标检测,可以说是YOLO系列最容易翻车的地方。摄像头画面里一个人可能只占几十个像素,640分辨率的输入经过5次下采样后,这个人只有几个像素宽,特征信息几乎消失,模型能检出才怪。
我实际用过比较有效的方案有三个。第一个是直接提高输入分辨率,把imgsz从640调到960或1280。代价是推理时间明显增加,但对小目标的召回率提升显著。如果你卡在性能上,可以先试试只对感兴趣区域做高分辨率切块检测,而不是整帧都升分辨率。
第二个方案是图像切块。把一帧画面切成两个或四个子区域,分别送进模型检测,最后把检测框坐标平移到原图上。这个方法对小目标比较有效,因为每个子区域的分辨率相对目标来说变高了。切块时需要注意子区域之间要有一定的重叠,否则目标正好卡在切缝上时容易被切断导致漏检。我一般会设置10%~15%的重叠率,最后再做一次NMS合并所有子区域的检测框,消除重叠区域里可能出现的重复框。
第三个方案是模型层面的选择。YOLOv5的s/m/l/x系列中,大模型对小目标的特征描述能力更强,但速度变慢。如果你既要小目标检测又要实时,建议用yolov5s配合高分辨率输入,而不是用yolov5x加低分辨率输入。另外,如果目标是行人和车辆这类固定类别,用目标检测模型本身也可能不够,后续接一个DeepSort之类的追踪器,能显著提升小目标的稳定性,这个后面扩展部分再展开说。
3.3 预处理细节:BGR/RGB、letterbox、归一化一个都不能错
预处理问题是我见过的“最隐形杀手”,因为它不报错,但结果出来就是一团糟。YOLOv5官方训练和推理时使用RGB通道顺序,而OpenCV读取图片默认是BGR。如果你直接用cv2.imread或cam.read()得到的帧喂给模型,不做通道转换,模型看到的就是一副颜色完全错乱的图,检测框会乱跳,置信度也会很低。
使用torch.hub.load加载官方模型时,内部会自动完成RGB转换,踩坑概率小一些。但如果自己写推理循环,一定要在做预处理时手动转换:
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)第二个关键点是letterbox处理。YOLOv5训练时会把输入图像等比缩放到目标尺寸,并用灰色填充剩余区域,这个过程叫letterbox。如果推理时直接用cv2.resize把帧硬压成640x640,目标的长宽比会失真,直接影响检测准确率。使用官方torch.hub接口时,model(frame)会自动完成letterbox,但如果你自己写前处理,就一定要沿用训练时的letterbox方式。
第三个是归一化。输入进模型之前,像素值要除以255,变换到0到1之间。很多从其他框架转过来的开发者容易漏掉这一步,导致检测效果异常。如果你用的是官方代码封装好的推理入口,这些细节它都处理了,怕就怕你对源码做改动时不小心破坏掉。
我自己在排查检测异常时,会先打印出预处理之后的图像张量,快速检查尺寸、dtype、范围是否正常。养成这个习惯之后,很多“莫名其妙”的检测效果问题都能在五分钟内定位。
3.4 模型和业务场景差异过大时,调参救不了你
如果你用的是YOLOv5官方COCO预训练权重,检测的是行人、车辆、猫狗这些常见类别,精度通常还好。但如果你要检测的场景非常垂直,比如抓取缺陷零件、识别工地安全帽、数快递箱子,官方权重基本不认,你怎么调conf和iou都无济于事。这就像让一个只会法语的人去做英语听力考试,你提高耳机音量没用,得先让他学英语。
这个场景下,调参只能作为临时应急手段,真正要解决问题只能用自己的数据去微调模型。考虑到本文重点是部署和调优,训练部分我不展开太多,只提醒一点:微调时数据标注质量决定上限,宁可少一点、但标得准,也不要为了数量批量刷出歪歪斜斜的框来,否则模型检测结果怎么调都不会稳定。
4. 复制即用的完整代码:从打开摄像头到输出结果
4.1 完整可运行的检测流水线
这一节我把上面的优化方案整合成一个单文件示例,包含多线程摄像头采集、YOLOv5模型加载、检测参数设置、跳帧绘制和FPS统计。你可以直接复制,按自己的路径和需求修改权重路径即可。
import cv2 import queue import threading import torch import time class ThreadedCamera: """摄像头采集线程,队列满时自动丢弃旧帧。""" def __init__(self, source=0, max_queue_size=2): self.cap = cv2.VideoCapture(source) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) self.queue = queue.Queue(maxsize=max_queue_size) self.running = True self.thread = threading.Thread(target=self._reader_loop, daemon=True) self.thread.start() def _reader_loop(self): while self.running: ret, frame = self.cap.read() if not ret: continue if self.queue.full(): try: self.queue.get_nowait() except queue.Empty: pass self.queue.put(frame) def read(self): try: return self.queue.get_nowait() except queue.Empty: return None def release(self): self.running = False self.thread.join(timeout=1.0) self.cap.release() class YOLOv5Detector: """封装 YOLOv5 推理,支持 GPU 半精度。""" def __init__(self, weights_path, conf=0.35, iou=0.45, imgsz=640, device="0"): # 如果本地已 clone 官方仓库,也可以换成: # model = torch.hub.load('本地仓库路径', 'custom', # path=weights_path, source='local') self.model = torch.hub.load("ultralytics/yolov5", "custom", path=weights_path, force_reload=False) self.model.conf = conf self.model.iou = iou self.model.max_det = 100 self.model.imgsz = imgsz self.names = self.model.names self.device = device if device.startswith("cuda") or device.isdigit(): self.model = self.model.to("cuda") self.model.half() else: self.model = self.model.to("cpu") # CPU 下限制线程数,避免资源争抢导致系统卡顿 torch.set_num_threads(4) def detect(self, frame): """ 输入 BGR ndarray,返回检测结果列表。 每一项是 [x1, y1, x2, y2, conf, cls_id] """ results = self.model(frame) boxes = results.xyxy[0].cpu().numpy() return boxes def draw_boxes(frame, boxes_xyxy, names): for x1, y1, x2, y2, conf, cls_id in boxes_xyxy: x1, y1, x2, y2 = int(x1), int(y1), int(x2), int(y2) label = f"{names[int(cls_id)]} {conf:.2f}" cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) # 文字背景,避免文字和画面混在一起 (w, h), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) cv2.rectangle(frame, (x1, y1 - h - 6), (x1 + w, y1), (0, 255, 0), -1) cv2.putText(frame, label, (x1, y1 - 4), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1, cv2.LINE_AA) return frame def main(): # 如果要用视频文件做测试,把 0 换成文件路径即可 cam = ThreadedCamera(0) detector = YOLOv5Detector( weights_path="yolov5s.pt", # 替换成你自己的权重路径 conf=0.35, iou=0.45, imgsz=640, device="0" ) frame_id = 0 detect_every = 1 # 每 1 帧检测一次,稠密场景建议保持 1 last_boxes = [] fps = 0.0 last_time = time.time() while True: frame = cam.read() if frame is None: continue # 每 detect_every 帧做一次检测,中间帧沿用上次结果 if frame_id % detect_every == 0: boxes = detector.detect(frame) last_boxes = boxes # 降低显示分辨率,减少渲染开销 display = frame if frame.shape[1] > 1280: scale = 1280 / frame.shape[1] display = cv2.resize(frame, (1280, int(frame.shape[0] * scale))) display = draw_boxes(display, last_boxes, detector.names) # FPS 统计 now = time.time() fps = fps * 0.9 + (1.0 / (now - last_time)) * 0.1 last_time = now cv2.putText(display, f"FPS: {fps:.1f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imshow("YOLOv5 Realtime", display) key = cv2.waitKey(1) & 0xFF if key == ord("q"): break frame_id += 1 cam.release() cv2.destroyAllWindows() if __name__ == "__main__": main()这份代码里的YOLOv5Detector使用了官方torch.hub.load加载本地权重,也就是custom模式。如果你的网络环境不方便访问GitHub,建议先把ultralytics/yolov5仓库clone到本地,然后把torch.hub.load的第一个参数改成仓库的本地路径,再加一个source='local',这样完全离线也能运行。
4.2 每个参数怎么改,改成多少,取决于你的场景
conf和iou这两个参数前面已经详细说过,不再重复。这里单独强调一下max_det和imgsz。
max_det限制单帧最多输出的检测框数量,默认100。如果你的场景里目标数量很少,比如检测单个工件,可以把max_det设成20甚至10,NMS后处理速度会明显提升。反过来,如果场景是密集人群,max_det设太小会把后面的检测框直接截掉,造成“人为漏检”。
imgsz的选取要综合考虑目标大小和帧率。我一般会先跑一个基线测试,用640输入,统计检测效果和FPS。如果FPS够用但漏检多,就往上升;如果FPS不够,就先降输入尺寸或调跳帧,再考虑换更小的模型。
还有一个容易忽略的技巧:如果你只需要检测某一个或某几个类别,可以在YOLOv5Detector的__init__里设置self.model.classes = [0, 2]这样的列表。这样模型只会输出指定类别的框,后处理阶段能过滤掉大量无关框,变相减轻NMS压力,检测速度也能有所提升。
4.3 CPU环境也能跑,但思路要换一下
如果你的机器没有NVIDIA独立显卡,只能用CPU跑,上面那份代码直接把device="cpu"就能运行,但帧率会非常难看。这里给你一套CPU环境下的降级方案,按优先级排序:先用yolov5n或yolov5s这种小模型,然后把imgsz降到320,再把detect_every设成2或3。这样组合下来,在普通办公电脑上也能跑到接近10到15帧,基本满足交互场景的实时性要求。
如果这样还嫌慢,那就得绕过PyTorch,把模型导出成ONNX格式,用onnxruntime跑CPU推理。ONNX Runtime在很多CPU上的算子优化比PyTorch更好,同等硬件条件下往往能获得一到两倍的性能提升。导出命令用官方YOLOv5仓库自带的export.py就能完成,推理时用onnxruntime加载.onnx文件,输入输出都走numpy数组,代码改动也不大。
如果你跑的是树莓派这类嵌入式设备,或者基于Rockchip、Jetson之类的硬件平台,方案就更特殊了:要么换轻量模型,要么走硬件厂商提供的推理引擎。这部分内容水很深,等真正走到那一步,建议再单独研究硬件平台相关的部署方案。
5. 实战中常见问题速查与避坑清单
5.1 摄像头读取相关的经典问题
摄像头打不开、黑屏、画面闪烁,这些其实是部署中最常见、却最让人头大的问题。我把自己踩过和帮别人排查过的经验整理了一下,放在下面这张速查表里。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
cap.read()一直返回 False | 摄像头被其他程序占用,或接口电源供电不足 | 关闭其他占用程序;换一个USB接口;用lsusb或设备管理器确认设备是否被系统识别 |
| 画面黑屏但程序不报错 | 默认分辨率摄像头不支持 | 不设置CAP_PROP_FRAME_WIDTH/HEIGHT,先看摄像头默认输出;或者改成640x480 |
| 画面闪烁 / 忽明忽暗 | 自动曝光、自动白平衡在持续调整 | 手动设置CAP_PROP_BRIGHTNESS、CAP_PROP_AUTO_EXPOSURE固定参数 |
| 延迟非常大 | OpenCV缓冲 + 采集线程未拆 | 参考上一章ThreadedCamera,队列设小,丢弃旧帧 |
| 帧率远低于标称值 | USB带宽不足,或驱动问题 | 降低分辨率到640x480;换USB3.0接口;检查摄像头是否在采集高码率H264流时未正确解码 |
特别要说一下自动曝光这个参数。很多笔记本摄像头的自动曝光算法在面对高光或逆光场景时,会不断调整亮度,导致画面忽亮忽暗,检测框也跟着忽大忽小。我通常会在初始化摄像头时固定一组曝光参数,让画面亮度相对稳定,这样检测效果能稳定不少。
5.2 推理过程常见报错与应对
CUDA out of memory 是所有人第一次上GPU都会碰到的报错。解决办法顺序是:先把imgsz降下来,再把batch_size设成1,接着考虑换更小的模型yolov5s → yolov5n,最后才是升级硬件或者关闭其他显存占用程序。如果用了多进程数据加载,记得把num_workers设成0,这个有时候能瞬间释放好几个GB显存。
模型加载报错也很常见。如果你用torch.hub.load加载本地权重时提示找不到文件,先确认权重路径是否真的存在。还有一点,如果你自己训练时改动过类别数量,加载预训练模型后出现模型结构不匹配,通常是因为best.pt里保存的是训练时的整个checkpoint,而不是纯模型权重。用torch.hub.load的custom模式加载官方或自己训练的标准权重一般没问题,卡住的话就先检查权重文件大小,过小说明下载不完整。
检测框坐标越界是另一个容易被忽视的问题。虽然YOLOv5输出的坐标理论上限制在原图范围内,但因为letterbox padding的坐标映射如果没处理好,框的坐标可能会超出图像边界。实际项目里可以在画框时做一次clip,把坐标限制在图像宽高范围内,避免后续操作报错。
x1 = max(0, min(int(x1), frame.shape[1] - 1)) y1 = max(0, min(int(y1), frame.shape[0] - 1)) x2 = max(0, min(int(x2), frame.shape[1] - 1)) y2 = max(0, min(int(y2), frame.shape[0] - 1))5.3 检测效果调试路径:别上来就乱调
如果检测效果不好,我的建议是走一条相对固定的排查路径,而不是陷入“调参-变差-再调参”的循环里。
首先,固定输入画面,用一张截图或一段短视频做离线测试。你这样就能排除画面动态变化的干扰,专心比较每次调整前后的检测结果。然后,把conf_thres先调低,看清模型到底检测出了哪些候选框,哪些是真目标、哪些是背景误检。接着按现象调整iou_thres和输入尺寸,确认漏检是阈值问题还是模型能力问题。最后,如果确定是模型能力极限导致的漏检,才考虑换大模型或做数据微调。
这里有一张简单的决策表,可以帮助你快速定位:
| 现状 | 下一步动作 |
|---|---|
| 漏检但FPS充足 | 提高imgsz,或降低conf观察是否有低分正确框 |
| 漏检且FPS不足 | 先降低输入尺寸保证流畅,再考虑换模型或加追踪器 |
| 误检多但FPS充足 | 提高conf,检查iou是否过低 |
| 误检多且FPS不足 | 过滤类别范围classes,提高max_det后观察 |
| 框不准且整体混乱 | 优先检查预处理流程:通道顺序、letterbox、归一化 |
这套路径的核心思想是先隔离变量,再动手调整。绝大多数新手翻车,都是因为一次性改了好几个参数,最后根本不知道是哪一个起作用,也不知道是哪一个导致的副作用。
6. 一些个人体会和后续可以继续做的方向
做实时检测项目到现在,我最大的体会是“延迟问题永远要先于精度问题解决”。画面卡顿的时候,你连目标有没有被检测到都很难看清,更别提微调阈值了。所以我在任何新环境里部署YOLOv5,做的第一件事永远是打一套基线数据:跑一段固定时长的视频,记录平均帧率、检测延迟和GPU占用,然后再针对结果去优化。这个习惯帮我省了很多瞎调参时间。
后续如果你想让这套实时检测方案真正落地,有几个方向我觉得非常值得深入。第一是模型加速,把PyTorch权重转成TensorRT或ONNX,在同一块GPU上推理速度通常还能再快不少。第二是目标跟踪,在检测的基础上接一个多目标追踪器,比如经典的DeepSort,可以让每个目标拥有稳定的ID,彻底解决摄像头场景下框乱跳、目标遮挡后丢失的问题。第三是多路摄像头并发,如果你需要同时处理多个视频流,建议不要把每个摄像头都单独开一个PyTorch模型,而是把多路画面拼成一个batch一起送进模型推理,这样显存和算力的利用率会高很多。
最后再分享一个小技巧:调试阶段不要直接在摄像头画面上写一大堆绘制代码。先把原始帧、检测结果和FPS分别记录下来,调好之后再统一叠加上去。这样改动任何环节时,你都能快速知道到底是哪个变量影响了最终效果。