1. 第四篇要解决的是从单模型到多模型串行的问题1.1 DNN 模块在整套方案里的位置OpenCV 的 dnn 模块是你把神经网络塞进计算机视觉项目时最省事的那道门。前面三篇我们已经把模型读取、预处理、前向传播和输出解析都拆开讲过单张图片上的目标检测应该不难跑通了。但到了第四篇我想换一个视角不聊单个模型而是聊一条能连续处理视频流的解决方案。现实中没人会满足于一张一张地检测大家真正需要的是像用 OpenCV 做园区监控识别物体并持续追踪这一类一整条链路的活。这一篇的定位是合龙。我会把前面讲过的 dnn 加载、blob 预处理、forward 推理、输出解码和 OpenCV 自带的图像处理、目标跟踪能力拼在一起搭出一个能连续读摄像头、能画框、能给目标分配 ID、还能判断这个目标到底在移动还是静止的完整管线。整个方案用 Python 写核心依赖只有 opencv-python 和 numpy你不需要在本机装 PyTorch 或 TensorFlow 才能跑起来这对部署环境非常友好。适合的读者也很明确你已经能跑通一个简单的 DNN 检测 demo但下一步不知道怎么做成项目或者你是做计算机视觉大作业老师要的不是单张图效果而是一段能跑的视频处理程序。看完这篇你可以直接把里面的 Detector、Tracklet、主循环这套骨架拿过去改。1.2 为什么要做检测 跟踪 状态判断的组合先说一个很多人踩过的坑以为计算机视觉项目就是把模型往摄像头流上一套每帧都跑一遍检测。实话说这样跑也能出效果但代价很现实——一个稍微像样的 YOLO 模型在普通 CPU 上跑一帧可能要 100 毫秒甚至更多如果输入分辨率再高一点连 10 FPS 都很难保住。而如果加入跟踪模块情况完全不一样检测器不需要每帧都运行可以每 5 帧或者每 10 帧跑一次中间帧全部交给跟踪器去预测目标位置。跟踪器一次 update 通常只要几毫秒这样整条管线的处理速度能翻好几倍。另一个原因是稳定性。神经网络再强也有漏检的时候连续视频里突然有一帧没检测到目标如果逻辑是完全依赖检测结果目标框就会闪一下消失。跟踪器相当于一个短时记忆它知道上一帧目标在哪里、大概往哪个方向运动检测漏掉的那几帧它能把位置补出来。用句生活化的话说检测是喊一嗓子确认你在哪跟踪是盯着你走两条腿配合才走得稳。再往上叠加业务逻辑比如判断目标是移动还是静止统计目标在某个区域停留了多久这些都不需要再依赖模型直接利用跟踪器维护的轨迹数据就能算出来。这也是我把这一篇做成综合篇的原因DNN 模块负责理解画面OpenCV 的传统视觉模块负责组织数据各干各最擅长的活。1.3 为什么选择 OpenCV DNN而不是专用推理框架每次做技术选型都要面对这个问题。我个人的态度很明确如果你的目标平台已经固定、对极致性能有硬指标那就上 TensorRT 或 ONNX Runtime性能确实好但如果你要的是快速落地、跨平台、不折腾环境OpenCV DNN 是性价比最高的起点。我把几个方案的差异列一张表方案额外依赖CPU 推理速度GPU 支持上手难度适合场景OpenCV DNN无opencv 自带中等支持 CUDA需编译低快速原型、教学、跨平台部署ONNX Runtime需安装 onnxruntime中等偏快支持中生产级、希望跨框架换模型TensorRT需安装 TensorRT快NVIDIA 专用高追求极致延迟的工业场景PyTorch JIT需安装 PyTorch较慢支持中与训练代码耦合很深时对大多数项目来说DNN 模块最大的优势是数据零拷贝你从摄像头读到的 frame 本身就是 BGR 的 numpy 数组预处理完直接变成 blob 喂给网络结果又还是同一个内存空间里的数组后面接 cv2.rectangle、cv2.putText 这类图像绘制函数无缝衔接。如果你用 ONNX Runtime中间还要考虑输入矩阵的格式转换、设备间的拷贝代码量一下子就上去了。当然OpenCV DNN 也不是万能的。它对某些新 op 的支持会滞后比如导出 ONNX 时用了特别新的算子版本OpenCV 可能读不了。这个我在第四篇的故障排查部分会专门展开遇到之后我们该怎么办。但作为一套解决方案的主干它完全够用。2. 核心 API 和预处理细节先对齐底层逻辑2.1 模型加载readNet 家族怎么选OpenCV dnn 模块对模型格式的支持一直做得比较全。传统上最常用的是这几种加载方式import cv2 # ONNX 格式现在最通用 net cv2.dnn.readNetFromONNX(yolov5s.onnx) # Caffe 格式需要 prototxt caffemodel 两个文件 # net cv2.dnn.readNetFromCaffe(deploy.prototxt, model.caffemodel) # TensorFlow 冻结图旧项目里很常见 # net cv2.dnn.readNetFromTensorflow(frozen_graph.pb) # Torch 7 格式注意不是 PyTorch 的 .pt # net cv2.dnn.readNetFromTorch(model.pt) # 让 OpenCV 根据文件后缀自己猜 # net cv2.dnn.readNet(model.onnx)经验之谈现在新拿到的模型十有八九是 ONNX 格式所以readNetFromONNX是优先级最高的选择。它把训练框架和推理环境彻底解耦——你在 PyTorch 里训练好的模型导出成 ONNX 后目标机器上完全可以不装 PyTorch只要 opencv-python 版本够新就能跑。有两个坑要提前说。第一个readNetFromTensorflow对 OpenCV 编译时使用的 protobuf 版本很敏感经常出现模型读进来了但 forward 报错或者直接抛异常排查起来很折腾所以我一般不建议新项目走这条路。第二个OpenCV 读取 ONNX 时对 opset 版本有兼容上限太新的算子不认识很正常。导出模型时如果没有特殊要求opset_version12是相对稳妥的选择。2.2 blobFromImage五个参数决定检测准不准预处理是整个链路里最容易出问题、也最不容易被重视的环节。很多同学模型加载没问题、代码也没报错但检测结果全乱套回过头来查十有八九是预处理和训练时不一致。blobFromImage是 DNN 推理的入口它的完整签名和常见用法如下import cv2 import numpy as np bgr_img cv2.imread(scene.jpg) blob cv2.dnn.blobFromImage( imagebgr_img, scalefactor1.0 / 255.0, # 像素值缩放通常是归一化 size(640, 640), # 网络要求的输入尺寸 mean(0, 0, 0), # 各通道减去的均值 swapRBTrue, # 是否交换 R 和 B 通道 cropFalse # 是否裁剪推理时一般 False )这里每个参数都会影响最终结果逐个说清楚。第一个scalefactor它的作用是先对每个像素做乘法。如果你在训练时习惯把图像从 [0, 255] 缩放到 [0, 1]这里就要传1.0/255.0。注意浮点精度用1/255写出来的结果是整数除法Python 里是 0所以你经常会看到有人写1.0/255.0或者1./255.就是这个原因。size是网络输入尺寸通常直接取模型训练时要求的尺寸。YOLOv5 系列常见 640x640分类模型可能是 224x224。OpenCV 的做法是直接把图像拉伸到目标尺寸注意它不保持宽高比。但这里有一个非常重要的对齐问题如果模型训练时使用的是 letterbox等比缩放加填充而你推理时直接暴力拉伸即便识别没报错精度也会明显下降。所以我后面做实操时会先把 letterbox 逻辑补上再把坐标还原回来。这是精度提升最明显的一步。mean是每个通道要减掉的均值。训练脚本里如果写的是transform.Normalize(mean[0.485, 0.456, 0.406], std[...])你需要知道blobFromImage的 mean 参数是按输入图像的通道顺序来的。因为cv2.imread读进来是 BGR所以这里最好传 BGR 顺序的均值。如果你的模型没有减均值就用(0, 0, 0)。swapRB这一项常常被忽略。深度学习框架处理彩色图片时默认通道顺序是 RGB而 OpenCV 读出来是 BGR。所以blobFromImage专门提供了swapRBTrue这个开关先把 BGR 图的 R 和 B 交换成 RGB再传给网络。如果模型训练时用的是 RGB你推理时漏掉这个参数最典型的表现是识别率骤降尤其是对颜色敏感的任务比如交通灯识别。最后说一个blobFromImage做不了的事——标准差归一化。函数没有 std 参数。如果你的模型在训练时用了 ImageNet 那种mean std归一化你得自己手动算img bgr_img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406]) * 255) / (np.array([0.229, 0.224, 0.225]) * 255) blob cv2.dnn.blobFromImage(img, 1.0, (640, 640), (0, 0, 0), swapRBTrue, cropFalse)注意这里的细节因为我们已经手动减均值做除法了blobFromImage的scalefactor就传 1.0mean 传 0。这种方式稍微绕一点但能保证和训练时完全一致。绝大多数开源检测模型不需要做这么复杂的归一化YOLO 系列就是除以 255 就完了。2.3 forward 前后的输出结构和高维向量到底意味着什么神经网络对一张图的计算过程可以理解成把一堆像素映射成一个高维向量再从这个高维向量里还原出我们需要的信息。用 OpenCV DNN 做推理代码只有两行net.setInput(blob) outputs net.forward()但这两行背后有几个值得说清楚的点。forward()不带参数时返回的是网络最后一个输出层的张量。一个模型往往不止一个输出比如某些 SSD 会有多个检测头。这时最稳妥的写法是output_names net.getUnconnectedOutLayersNames() outputs net.forward(output_names)getUnconnectedOutLayersNames()会返回所有没有继续往下接的输出层名字也就是推理真正需要拿到的那些输出。用这种方式不管你模型里有几个输出头都能一次性拿全后面再统一做解码。我强烈建议所有项目都用这种写法不要偷懒直接forward()否则会遇到输出数量对不上的诡异问题。拿 YOLOv5 举例一个典型的 ONNX 输出 shape 是(1, 25200, 85)。这里的 25200 代表 640x640 输入下所有可能的目标候选位置85 是每个候选中包含的信息4 个坐标中心点 x、y、宽、高 1 个目标置信度 80 个类别得分。这就是一个高维向量落地的形态。你在博文或者课程里看到类似图像进入神经网络后变成高维特征的说法落到代码层面就是一个多维 numpy 数组。拿到输出后经常需要做reshape。因为输出可能是(1, 25200, 85)也可能是(1, 85, 25200)取决于导出和解析方式。通用的处理思路是先把所有输出统一拉成二维import numpy as np def flatten_outputs(outputs): rows [] for out in outputs: # 最后一维一般是属性数前面所有维度都看成候选数 rows.append(out.reshape(-1, out.shape[-1])) return np.concatenate(rows, axis0)这里用out.shape[-1]是因为 85 这个属性维度通常排在最后。如果遇到排在前面的模型转置一下就好技巧是看一眼训练仓库的导出脚本它对输出的描述最权威。2.4 后处理坐标换算、置信度过滤和 NMS网络输出的 85 维向量不是直接能用的检测框这一节我们把它翻译成人能看懂的结果。首先要从每个候选里取出坐标和得分。YOLO 系列的坐标是中心点加宽高的形式(cx, cy, w, h)而 OpenCV 画框要的是左上角和右下角(x1, y1, x2, y2)换算一下x1 cx - w / 2 y1 cy - h / 2 x2 cx w / 2 y2 cy h / 2接着做置信度过滤。一个候选的最终得分通常是目标置信度 * 类别置信度只有同时满足两者才算有效目标。然后就要用到 NMS非极大值抑制把重叠严重的重复框去掉。OpenCV dnn 自带NMSBoxesindices cv2.dnn.NMSBoxes( boxes, # 所有候选框格式是 [[x1, y1, x2, y2], ...] scores, # 每个框的得分 score_threshold0.25, nms_threshold0.45 )这里有两个细节。第一NMSBoxes的返回值在不同 OpenCV 版本里形态不一样有的返回[[0], [2]]这种嵌套列表有的返回一维数组。稳妥的做法是拿到后用numpy转成数组再flatten()。第二score_threshold和前面自己做的过滤是重复的可以理解为双保险。NMS 算法本身做的事情是选得分最高的框删掉所有与它 IoU 大于阈值的框再重复这个过程。nms_threshold设得越小重叠框被删得越狠一般 0.45 到 0.5 比较常见。3. 实操搭一个检测 跟踪 状态判断的完整管线3.1 项目场景假设和模型准备为了不让这篇变成纯 API 罗列我准备了一个具体的落地场景假设要在某个区域的入口监控视频里区分画面里的人物和车辆并且持续跟踪它们最后判断每个目标当前是移动还是静止。这个需求其实很常见比如无人值守的库房、园区的安防巡逻。整个方案的技术栈是这样用 YOLOv5s 作为检测器把 PyTorch 权重导出为 ONNX交给 OpenCV DNN 加载目标跟踪不依赖额外库用基于 IoU 的关联匹配自己做轻量多目标跟踪状态判断通过目标的轨迹位移量计算。所有代码基于常见实践你可以直接抄去改。模型准备这步先说清楚。如果你手头已经有训练好的模型需要知道怎么导出 ONNX。以 YOLOv5 官方仓库为例导出脚本大致是import torch from models.experimental import attempt_load model attempt_load(yolov5s.pt, map_locationcpu) model.eval() dummy_input torch.zeros(1, 3, 640, 640) torch.onnx.export( model, dummy_input, yolov5s.onnx, opset_version12, input_names[images], output_names[output] )导出时注意三点。第一opset_version不是越高越好OpenCV 对太新的算子支持可能滞后我一般先用 12读不了再往下降。第二如果你不想依赖 YOLOv5 源码可以直接用 OpenCV 或者 ultralytics 的导出接口思路一样。第三导出后先用一个最简单的脚本验证模型能读能跑别等到整套管线写完才发现模型有问题。3.2 Detector 类把预处理、推理、解码全部封装起来我习惯把检测器封装成一个类这样主循环代码会非常干净。核心逻辑是这样的import cv2 import numpy as np class Detector: def __init__(self, model_path, input_size(640, 640), conf_thres0.25, nms_thres0.45): self.net cv2.dnn.readNetFromONNX(model_path) self.input_size input_size self.conf_thres conf_thres self.nms_thres nms_thres self.output_names self.net.getUnconnectedOutLayersNames() def detect(self, bgr_img): letterbox_img, ratio, (dw, dh) self.letterbox(bgr_img) blob cv2.dnn.blobFromImage( letterbox_img, scalefactor1.0 / 255.0, sizeself.input_size, mean(0, 0, 0), swapRBTrue, cropFalse ) self.net.setInput(blob) outputs self.net.forward(self.output_names) preds self.flatten(outputs) boxes, scores, class_ids [], [], [] for row in preds: obj_conf row[4] if obj_conf self.conf_thres: continue class_scores row[5:] class_id int(np.argmax(class_scores)) class_conf float(class_scores[class_id]) score float(obj_conf * class_conf) if score self.conf_thres: continue cx, cy, w, h row[:4] x1 (cx - w / 2 - dw) / ratio y1 (cy - h / 2 - dh) / ratio x2 (cx w / 2 - dw) / ratio y2 (cy h / 2 - dh) / ratio boxes.append([x1, y1, x2, y2]) scores.append(score) class_ids.append(class_id) if boxes: indices np.array(cv2.dnn.NMSBoxes(boxes, scores, self.conf_thres, self.nms_thres)).flatten() results [(class_ids[i], scores[i], boxes[i]) for i in indices] else: results [] return results staticmethod def letterbox(img, new_shape(640, 640), color(114, 114, 114)): shape img.shape[:2] r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad (int(round(shape[1] * r)), int(round(shape[0] * r))) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, r, (dw, dh) staticmethod def flatten(outputs): rows [] for out in outputs: rows.append(out.reshape(-1, out.shape[-1])) return np.concatenate(rows, axis0)这段代码里有两个地方值得划重点。第一个是letterbox。因为 YOLOv5 训练时用等比缩放加灰色填充推理时不保持这个习惯就会掉精度。我们这里自己实现了 letterbox并且在换算坐标时把填充偏移(dw, dh)和缩放比例r乘回去这样检测框才能回到原始图像坐标系。第二个是输出解析的兼容性。flatten函数把网络所有输出都拉成统一的二维数组再拼接不管你导出模型时是单输出还是多输出都能兼容。坐标换算那一段用的是 YOLOv5 常见的(cx, cy, w, h)格式如果你换成其他检测模型只需要改这里前后处理骨架不用动。3.3 轻量多目标跟踪用 IoU 关联维护轨迹跟踪部分我不能直接丢一个cv2.TrackerKCF_create()给你就完事因为内置跟踪器在不同 OpenCV 版本里 API 挪过位置而且它们大多面向单目标跟踪设计多目标要自己管理生命周期。所以这里我用一个更通用的方案基于 IoU 的关联匹配。每次检测器给出新框我拿新框和现有目标的历史框算 IoU匹配上了就更新位置没匹配上就当作新目标出现长时间没匹配上的目标就删除。先定义轨迹对象class Tracklet: def __init__(self, box, class_id, score, track_id): self.box box # [x1, y1, x2, y2] self.class_id class_id self.score score self.track_id track_id self.history [box] self.last_seen 0 self.matched False def update(self, box, score): self.box box self.score score self.history.append(box) if len(self.history) 30: self.history.pop(0)然后写 IoU 函数和匹配函数。IoU 就是两个框交集面积除以并集面积代码不复杂def compute_iou(box1, box2): x1 max(box1[0], box2[0]) y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]) y2 min(box1[3], box2[3]) inter_w max(0.0, x2 - x1) inter_h max(0.0, y2 - y1) inter_area inter_w * inter_h area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) union area1 area2 - inter_area return inter_area / union if union 0 else 0.0 def match_tracklets(tracklets, detections, iou_threshold0.3): unmatched_det [] for det in detections: class_id, score, box det best_iou 0.0 best_track None for track in tracklets: if track.class_id ! class_id: continue iou compute_iou(track.box, box) if iou best_iou: best_iou iou best_track track if best_track is not None and best_iou iou_threshold: best_track.update(box, score) best_track.last_seen 0 else: unmatched_det.append(det) for track in tracklets: track.last_seen 1 return unmatched_det这里我特意只按类别过滤再算 IoU因为不同类别的目标就算框重叠也不应该匹配成同一个 ID。iou_threshold设到 0.3 左右比较合适太小容易把两个挨得近的目标识别成同一个太大会在目标移动快时跟丢。这个值可以在实测中调。每帧没有检测结果时已跟踪目标的位置怎么处理简单方案是用速度外推或者直接用上一帧位置。对大多数低速场景上一帧位置就够用了。如果目标移动很快可以在 Tracklet 里保存最近几个中心点用差分估算速度再预测位置。这个实现我留作练习主线代码用上一帧位置顶着就行。3.4 主循环把检测、跟踪、绘制串成一条线现在把上面这些拼进主循环。思路是读取一帧如果当前帧是检测帧就跑 Detector否则只更新已有轨迹然后根据轨迹历史判断运动状态最后把所有结果画到帧上。import cv2 import time cap cv2.VideoCapture(test_video.mp4) detector Detector(yolov5s.onnx) tracklets [] next_track_id 1 detect_interval 5 frame_index 0 while True: ret, frame cap.read() if not ret: break frame_index 1 start_time time.time() if frame_index % detect_interval 0: detections detector.detect(frame) new_dets match_tracklets(tracklets, detections) for class_id, score, box in new_dets: tracklets.append(Tracklet(box, class_id, score, next_track_id)) next_track_id 1 else: # 非检测帧不做关联直接更新存活时间 for track in tracklets: track.last_seen 1 # 删除很久没被更新的轨迹 tracklets [t for t in tracklets if t.last_seen 10] # 绘制和状态判断 for track in tracklets: x1, y1, x2, y2 map(int, track.box) color (0, 255, 0) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) # 运动状态通过历史中心点位移判断 state static if len(track.history) 5: first_center track.history[0] last_center track.history[-1] cx1 (first_center[0] first_center[2]) / 2 cy1 (first_center[1] first_center[3]) / 2 cx2 (last_center[0] last_center[2]) / 2 cy2 (last_center[1] last_center[3]) / 2 dist ((cx2 - cx1) ** 2 (cy2 - cy1) ** 2) ** 0.5 if dist 30: state moving label fID:{track.track_id} {state} cv2.putText(frame, label, (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) fps 1.0 / (time.time() - start_time) cv2.putText(frame, fFPS: {fps:.1f}, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imshow(DNN pipeline, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()这段代码的运行效果是检测器负责认出新目标跟踪器负责记住旧目标状态判断利用轨迹累计位移区分移动和静止。整套东西跑起来后你会发现 FPS 比每帧检测高了不少而且画面里目标 ID 基本稳定不会一闪一闪。3.5 性能还能怎么优化如果你实测下来觉得速度还是不够我按优先级给几条优化路径。第一把detect_interval调大比如从 5 改成 10这是最直接的提速方式代价是目标新出现时反应会慢半拍。第二把检测模型的输入尺寸从 640 降到 480 或 416预处理计算量和网络计算量都会明显下降精度损失通常不大。第三检查一下编译环境如果你用的是 Linux 服务器可以考虑自己编译带 CUDA 的 OpenCV然后把 backend 切到 CUDA。net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)这两行代码放在读模型之后就能生效。注意 CUDA 后端不是所有设备都支持Windows 上自己编带 CUDA 的 OpenCV 比较折腾新手不建议一上来就搞这个先用 CPU 跑通逻辑才是正事。4. 常见问题与排查技巧实录4.1 模型读进来了但 forward() 直接报错这个问题在 DNN 相关提问里出现频率非常高。报错信息五花八门常见的有 Cant create layer ... because input or output is not registered、Unsupported ops 这类。根因基本都指向同一个方向OpenCV 编译时内置的 ONNX 解析器对某些算子实现不全或者网络里用了太新的算子。我的排查顺序是固定的。第一步确认 opencv 版本print(cv2.__version__)如果低于 4.5先升级到最新的稳定版。第二步把 ONNX 模型的 opset 降级导出时改成 11 或 12重新导出再试。第三步如果还报错用print(net.getLayerNames())看看哪些层被正确加载了定位到报错的具体层。第四步如果问题层是某种不常见算子优先回到训练框架换一种导出方式比如把某些自定义 op 提前固化而不是指望 OpenCV 去支持。4.2 推理慢到没法用该从哪里下手先算一笔账一个 YOLOv5s 在 640x640 输入下CPU 上单帧推理大约需要 80 到 200 毫秒直接跑视频肯定不到 10 FPS。我建议先用一个计时脚本对每个环节做 profiling不要凭感觉猜。常见瓶颈有三个。第一个是输入尺寸太大。很多同学习惯用 1280 甚至更大的输入精度确实高但推理时间成倍上涨。先降到 640再试 416找一个精度还能接受的速度拐点。第二个是每一帧都跑检测。用了跟踪之后检测频率降到五分之一速度提升立竿见影。第三个是后端没有开启。如果你的设备有独立显卡试试 CUDA backend如果是集成显卡试试 OpenCL。在 AMD APU 或 Intel 核显上setPreferableTarget(cv2.dnn.DNN_TARGET_OPENCL)有时候能带来惊喜。4.3 检测结果错得离谱八成是预处理没对齐抛开模型本身没训练好不谈DNN 推理结果差最常见的三个原因按概率排swapRB用错了、scalefactor没按 1/255 归一化、图像输入方式与训练不一致。我见过最典型的案例是有人用 YOLOv5 检测但预处理时没做 letterbox直接拉伸到 640x640结果小目标几乎全丢。排查方法也很有套路。先看训练仓库的预处理代码YOLOv5 的 detect.py 里一定有一行 letterbox你看它怎么写的照着抄。再看它归一化用没用 mean/stdYOLO 系一般只除以 255。最后把预处理后的 blob 保存成图片看看确认不是一团黑或者颜色诡异。具体方法blob是 NCHW 格式转回 HWC 再保存确认预处理流没有 bug。4.4 输出解析的通用兜底套路检测模型那么多输出格式也各有不同但有一个通用套路可以对付绝大多数情况不管网络输出几个张量都先取getUnconnectedOutLayersNames()然后挨个reshape(-1, last_dim)再拼接成一个大矩阵。之后无论你是取坐标还是取类别都是在同一张大表里做行过滤。这套逻辑我用过在很多模型上从 SSD 到 YOLO 再到自定义关键点模型都能跑通。唯一要注意的是最后维度的含义可能不同YOLO 是xywh obj_conf class_scoresSSD 的输出布局又是另外的方式。遇到新模型第一步永远是去读训练代码里的 decode 部分搞清楚每个数字的含义然后再动手写解析函数。这不丢人反而是最省时间的路。4.5 三个调试小技巧省掉一半排查时间第一个打印 shape。无论是在 readNet 之后还是 forward 之后先把模型的输入和输出 shape 打出来。shape 错了后面再怎么调参数都是白费。第二个核对 NMSBoxes 的返回值类型。这个函数在不同版本里的返回形式不一样如果不确定直接np.array(...).flatten()然后用一个只有两个高度重叠的目标的测试图验证。第三个用静态图调试主循环。视频流里出现 Bug 很难复现我建议先把cap cv2.VideoCapture(test_video.mp4)固定到一段本地视频逐帧调试等逻辑稳定后再换到摄像头。最后分享一个我的习惯把检测器、跟踪器、绘制这三层彻底分开检测器只负责返回框和类别跟踪器只负责维护轨迹和 ID绘制层只负责显示。这样做的好处是任何一个环节出错都能快速定位。这个项目后续如果你要扩展比如加一个分类模型来判断目标持有什么物体只需要在跟踪器的 update 里额外调一次第二个 DNN 模型完全不需要动主循环。DNN 模块真正值钱的地方不在某个 API 多炫而在于它能成为整个计算机视觉方案里最稳定、最容易被替换的那块积木。