2021年我接了一个软件类竞赛的行人跟踪任务,需求用一段顶视角和一段平视角的行人视频,输出实时多目标跟踪结果。当时我的技术选型很直接:PaddlePaddle PPYOLO做目标检测,DeepSort做多目标跟踪,也就是典型的两阶段跟踪方案。这个组合放在今天看依然能打,而且特别适合竞赛场景——既能保证精度的上限,又能在工程上把每个环节拆开调优。
现在回头看,这个项目真正的难点不在"跑通流程",而在"检测与跟踪如何无缝配合"。检测器的漏检会直接撕断裂轨迹,跟踪器的参数设置得不对,即使检测框送对了也会疯狂切ID。这篇文章我把自己从头到尾的选型思路、模块拆解、适配细节和踩过的坑全部写出来,适合正要入门多目标跟踪的学生、想拿PPYOLO做落地项目的开发者,以及准备打竞赛但还在犹豫技术路线的人。
1. 项目整体设计与方案拆解
1.1 为什么是PPYOLO+DeepSort的组合模式
竞赛任务要求对视频中的多个行人同时保持ID,也就是不仅要画框,还要把同一人在不同帧里的框串起来。直接把两帧的检测结果硬怼,会出现ID疯狂切换,所以必须有一个专门做数据关联的模块。
当时摆在我面前的方案有几类:
| 思路 | 代表方法 | 优点 | 缺点 | 竞赛可行性 |
|---|---|---|---|---|
| 两阶段检测+跟踪 | YOLO系/PPYOLO + DeepSort | 检测与跟踪解耦,调试方便 | 速度取决于检测器,流程较长 | 高 |
| 单阶段联合模型 | CenterTrack、JDE | 速度快,端到端 | 精度上限受模型限制,工程复现偏难 | 中 |
| 传统帧差/光流 | Lucas-Kanade、Meanshift | 实现简单 | ID漂移严重,多人场景直接崩 | 极低 |
两阶段方案里,检测器质量直接决定跟踪上限,所以我优先考虑检测器选型。
在PaddleDetection开源仓库里,PPYOLO系列是当时平衡精度和速度的代表。相比同期的YOLOv3和YOLOv4,它有更强的CSPDarkNet骨干、更高效的PAN结构和更稳的Anchor匹配策略。加上官方提供了大量预训练权重,可以直接在COCO大类上迁移,省去了从零训练的时间。DeepSort则是多目标跟踪里的常青树,用卡尔曼滤波做运动状态估计、匈牙利匹配解决最优分配,工程成熟度很高,社区里也有大量修改版可供参考。
还有一个现实原因:竞赛现场软硬件环境不确定,两阶段方案可以在现场单独调检测器的阈值、单独调跟踪器的参数,不会因为耦合过紧导致整个流程崩掉。这对比赛拿分来说非常关键。
1.2 系统链路与模块划分
整个系统从视频帧输入到最终标注轨迹,是下面这条链路:
读入一帧视频 → 图像预处理(缩放、归一化、通道调整) → PPYOLO推理得到候选框 → 后处理(NMS、类别过滤、置信度筛除) → 将检测框送入DeepSort跟踪器 → 卡尔曼预测新位置 → 与检测框做级联匹配 → 输出轨迹ID与历史路径 → 绘制结果并写入视频。
模块划分得很清楚,每个环节可以单独测试。我当时把预处理和后处理封装成一个独立的组件,把DeepSort的update()接口作为跟踪器输入口,这样后续如果换检测器,只需要改前置组件,不用动跟踪逻辑。
1.3 竞赛评测与调优优先级
竞赛评估多目标跟踪时,最看重的通常是MOTA(多目标跟踪准确率)和IDF1(ID保持的准确率),其次才是实时性。因此我的调优优先级是:
- 保证检测召回率,漏检是跟踪断链的头号杀手;
- 降低ID Switch,这直接影响IDF1和主观打分;
- 确保轨迹输出稳定,不要抖框、不要漂移;
- 最后才压缩推理耗时,把帧率稳定在25FPS以上。
如果检测器漏掉连续几帧,再强的跟踪器也续不上轨迹。DeepSort里max_age参数虽然可以保留一段时间的"失联轨迹",但它本质上是靠卡尔曼预测输出位置,预测时间越长,误差越大。
2. 核心模块解析:PPYOLO检测器的工程落地
2.1 PPYOLO的选型理由
PPYOLO系列最核心的优势,是它在COCO数据集上预训练过的权重可以直接用迁移学习的方式微调到自己的场景。行人检测这个任务在COCO里归属于person类别,基础特征通用性很高。
从架构上说,PPYOLO用CSPDarkNet作为骨干网络,这比当时其他YOLO衍生版更注重梯度信息的利用率。后面接的PAN结构让高低层特征充分融合,对小目标和中距离行人的召回有明显提升。再加上SPP模块对多尺度特征的增强,整体检测能力在同等算力下比经典YOLOv3强了不少。
对比Faster R-CNN这类两阶段检测器,PPYOLO的高速优势非常明显。竞赛现场如果只能用CPU反复调试,两阶段检测器可能一秒只能跑一两帧,效率太低。
2.2 从COCO预训练迁移到行人检测
我用的训练方案是:在COCO预训练权重基础上,冻结前几层,解冻后部分,用行人检测数据集微调。
数据方面,竞赛提供的视频片段覆盖的场景比较单一,但为了泛化,我额外引入了公开行人检测数据、自拍的一段走廊场景数据,以及网上收集的俯视行人图片。自采数据尤其关键,因为顶视角下的行人外观和平视角差异很大,纯COCO预训练权重在顶视角场景上直接推理会有不少漏检。
训练配置上有几个参数值得留意:
- 输入尺度:我保持在
416x416,对速度与精度做了平衡。如果换到640x640,小行人的召回会更好,但推理速度下降明显; - 学习率:迁移学习不宜太大,我用
0.001起步,训练10轮后降到0.0001; - 数据增强:Mosaic增强、随机翻转、随机亮度对比度调整是常规操作;
- 类别处理:只保留
person单类,避免其他类别的干扰拖慢后处理。
训练完成后,用验证集算一下mAP和Recall。我当时的经验是,检测器的召回率至少要到85%以上,不然跟踪环节很难救回来。
2.3 检测器输出的工程处理
PaddleDetection的PPYOLO推理输出通常是[N, 6]的Tensor格式,其中6个维度分别是[class_id, score, x1, y1, x2, y2](不同版本可能顺序略有差异)。在正式送入跟踪器之前,必须做以下几件事:
- 类别过滤:只保留
class_id=0(person); - 置信度筛选:我设置为
0.35到0.5之间,过高的阈值会漏掉远距离的小行人,过低则会导致大量误检干扰跟踪; - NMS后处理:阈值设在
0.5~0.6,去除重叠框; - 坐标格式转换:DeepSort内部通常按
xywh转xyxy,需要统一好。
实际操作里,我给置信度阈值留了一个配置项,在平视和顶视两个场景里分别调整成0.45和0.35。顶视角因为行人小、遮挡少,可以稍微放宽阈值来提升召回;平视角遮挡多,太低的阈值容易把背景框进去。
注意:送进DeepSort的检测框不能带太多低质量框。跟踪器本身没有强大的"纠错"能力,乱七八糟的假框会让卡尔曼滤波的状态被污染,出现跟踪框漂移。
3. DeepSort跟踪原理与工程改造
3.1 跟踪器的两个核心:状态预测与数据关联
DeepSort本质上做两件事:预测和匹配。
预测靠卡尔曼滤波,它的状态是8维向量:中心点坐标(cx, cy)、宽高比aspect、高度h,以及对应的速度分量(vx, vy, va, vh)。假设目标匀速运动,用上一帧的最优估计预测当前帧的位置和协方差。你可以把它理解成"我猜这个人下一帧大概会出现在哪里,并且知道这个猜测大概有多不靠谱"。
匹配靠匈牙利算法。当检测器送来一批新框,跟踪器有一批预测框,两者之间会构建一个代价矩阵,代价越小说明越可能属于同一个目标。匈牙利算法在这个矩阵上找出一种最优的一对一配对方案,保证整体代价最小。
3.2 外观特征与级联匹配
如果只靠位置匹配,当两个行人擦肩而过时,算法很容易把两边的ID对调。DeepSort的解决办法是引入外观特征(ReID特征),用一个小型CNN把检测框里的行人图像编码成特征向量,匹配时计算余弦相似度。
所以完整的代价矩阵由两部分组成:
- 运动距离:检测框与预测框之间的马氏距离,用来衡量位置的接近程度;
- 外观距离:1减去检测框内行人的ReID特征与轨迹保存的历史特征之间的余弦相似度。
两者加权合并,再加上一个gate来剔除明显不合理的匹配对。我用的阈值是:马氏距离卡在9.4877(对应95%置信椭圆),余弦距离阈值一般取0.2到0.3,太严容易分裂轨迹,太松则ID切换严重。
级联匹配是DeepSort的细节所在:优先匹配那些连续多个周期都没被匹配上的轨迹,防止一个长时间处于被遮挡状态的目标,被新出现的检测框抢占ID。这在人群密集、频繁遮挡的场景下特别重要。
3.3 将PPYOLO输出接入DeepSort的适配细节
我用的DeepSort版本接口是tracker.update(detections),其中detections是一组封装好的检测框对象,包含xmin, ymin, xmax, ymax, score, feature。
从PPYOLO拿到的是x1, y1, x2, y2,直接构造对象即可。有一个坑在于,DeepSort原版的特征提取网络是基于某一固定图像尺寸(通常是64x128或128x256)训练的,所以送入特征网络前要做Crop和Resize。
我当时自己写了一个适配层,核心代码逻辑如下:
class Detection: def __init__(self, tlwh, score, feature): self.tlwh = tlwh # x, y, w, h self.score = score self.feature = feature def ppyolo_to_detections(bboxes, scores, feature_extractor): detections = [] for box, score in zip(bboxes, scores): x1, y1, x2, y2 = box.astype(int) if (x2 - x1) < 10 or (y2 - y1) < 10: continue # 过滤过小的误检框 crop = frame[y1:y2, x1:x2] if crop.size == 0: continue # 将裁剪区域缩放至特征提取器输入大小后提取特征 feat = feature_extractor(crop) tlwh = (x1, y1, x2 - x1, y2 - y1) detections.append(Detection(tlwh, float(score), feat)) return detections检测框宽高太小的直接丢弃,可以省下一大笔无效计算,也能避免把角落里的背景当成目标送进跟踪器。
另外一个关键点是:如果这一帧检测结果为空,也一定要调用一次tracker.update([]),让跟踪器内的卡尔曼滤波器做一次预测更新。否则轨迹的时间戳会乱掉,等下一帧有人出现时,级联匹配完全失效。
4. 推理链路构建与实机调试
4.1 一帧数据的完整处理链路
我把推理链路固定成下面几个步骤,每帧循环:
- 从视频流中读取一帧BGR图像;
- 缩放至
416x416的PPYOLO输入尺寸,保持宽高比填充; - 执行归一化,将BGR从
0-255映射到0-1; - PPYOLO模型前向推理,得到检测框;
- 过滤意外类别、低置信度框,执行NMS;
- 裁剪每个行人区域,用ReID模型提取外观特征;
- 将检测结果组装成
Detection列表,调用tracker.update(); - 从跟踪器中读回所有已确认的轨迹,绘制框、ID和历史路径;
- 统计当前FPS,写入视频帧。
这里有一个效率优化点:ReID特征提取如果单独做一次前向,会多出一倍耗时。我当时是把ReID的输入放到一个小尺寸模型里,用GPU推理。总共检测器大约30ms一帧,ReID大约5ms一帧,跟踪器小于2ms,整体可以稳定在25FPS左右。
4.2 相机角度与ROI范围对跟踪效果的影响
竞赛给出了两种典型机位:楼门口俯视、走廊平视。俯视场景的行人目标较小、彼此间距大,但比较容易漏检;平视场景的行人目标大、遮挡频繁,ID Switch风险高。
针对这两类场景,我做了不同ROI设定:
| 场景 | 有效区域 | 阈值设置 | 注意事项 |
|---|---|---|---|
| 楼门口俯视 | 只保留画面中部矩形区域 | score=0.35 | 排除画面边缘的残腿框 |
| 走廊平视 | 保留中下部主通道区域 | score=0.45 | 防止远处小目标乱跳 |
最核心的思路是:提前在几何层面把无效区域剪掉,让检测器和跟踪器不要被画面边缘的半人框干扰。半人框一旦进入跟踪器,很可能会导致轨迹位置反复横跳,最终影响整体IDF1。
ROI可以用mask实现,检测前直接把ROI以外的像素置零,后续所有的框自然只会出现在ROI内。实测在俯视场景下,误检率降低了接近一半。
4.3 桌面级与边缘设备的性能取舍
现场机器的配置我提前拿不准,所以做了一版"可降级"的推理管线:
- 检测器支持
416x416和320x320两种分辨率切换; - 推理支持Paddle推理库的GPU预测和CPU预测,现场发现没有GPU就切CPU;
- 跟踪器部分不占太多算力,瓶颈完全在检测器。
如果把输入降到320x320,检测器的速度大约能提升40%,代价是AP下降2到3个点。在CPU环境下,这是可以接受的折衷。我另外把视频写入从每帧写改成每5帧写,避免磁盘IO影响主流程速度。
实机上调试还有一个技巧:用time.time()分别统计检测耗时、特征提取耗时、跟踪耗时,写入到一个日志文件里。这样能快速定位瓶颈,而不是全靠感觉猜。
5. 常见问题与排查实录
5.1 跟踪ID频繁切换
这个是我遇到最多的问题。现象是行人正常走着走着,ID从10变成23再变成47,轨迹断成好几段。
排查下来分两类:
- 检测框与预测框的匹配代价太大:原因是外观特征在遮挡和光照变化下不稳定,或者
iou_threshold设置太高。把余弦距离阈值从0.3放宽到0.35,同时降低马氏距离的权重,能明显减少切换; - 遮挡恢复后的轨迹被误判为新目标:这个要用好级联匹配,适当增大
max_age值(比如从30改到60),让被遮挡的目标有更长的"恢复窗口"。
有一个细节:不要把max_age调得过大。虽然能留住被遮挡目标的ID,但长时间没有真实检测框输入时,卡尔曼预测位置会漂到很远。等目标重新出现,预测位置与实际位置差太多,反而匹配不上。
5.2 静止不动的行人轨迹消失
竞赛视频里有大量行人停下来排队、等人。静止时检测框确实还在,但目标没有运动时,卡尔曼滤波的协方差会越来越大,位置预测产生漂移。
解决方案是降低检测置信度阈值,保证静止行人能被连续检测到;如果检测稳定,跟踪器自然不会丢。另外在卡尔曼滤波初始化时,把速度初始值设低一些,避免把静止目标当成运动目标去外推。
5.3 人群重叠时框跳变
平视场景里两个人互相遮挡,检测框会在两人之间来回跳,导致ID互换。这是经典的"检测框不干净"问题。NMS阶段我没法彻底解决,于是做了一层后处理:对于和已有轨迹距离极近、但置信度又不太高的检测框,直接丢弃,不放进跟踪器。
实测后ID Switch从每500帧出现15次下降到5次左右。
5.4 输出视频中没有轨迹线
这个不是跟踪器的问题,是可视化代码的遗漏。DeepSort本身只维护track_id和bbox,不自动保存历史中心点。我额外维护了一个字典,记录每个ID最近20帧的中心点坐标,绘制时依次连线即可。轨迹线画出来后,评委看效果会直观很多,主观分提升明显。
5.5 常见问题速查表
| 现象 | 可能原因 | 快速应对 |
|---|---|---|
| 同一人ID频繁变化 | 外观特征区分度不够 | 降低外观距离阈值或更换更强ReID模型 |
| 静止目标丢失 | 卡尔曼预测发散 | 降低阈值提升检测召回,减少速度初始值 |
| 检测框跳转 | NMS后仍有重叠 | 提高NMS阈值、增加尺寸过滤 |
| 轨迹线断续 | 跟踪器空更新导致丢失 | 保证每帧都调用update() |
| 近距离串ID | 遮挡触发特征误导 | 增大max_age,让被遮挡轨迹优先恢复 |
| 视频卡顿 | 检测与ReID耗时过高 | 降低输入分辨率、加大视频写入缓冲 |
6. 回头看:一些经验与扩展方向
整个项目做下来,最深的体会是:多目标跟踪工程的坑,大部分不在算法本身,而在检测与跟踪的接口衔接。把检测结果一股脑丢给跟踪器并不难,难的是理解卡尔曼滤波的假设、代价矩阵的含义,以及各个超参数背后对应的物理场景。竞赛最容易拉分的地方,反而不是模型结构有多新,而是你把场景适配做到了什么程度。
如果后续还要扩展,我会沿着几个方向继续做:把ReID特征模型换成针对性训练的行人重识别模型,进一步提升跨相机的ID保持能力;尝试用ByteTrack这类不需要外观特征的跟踪器做对比实验,因为在部分硬件受限的场景下它更轻便;再就是用轨迹结果做人数统计、区域驻留时长等人流分析可视化,把"跟踪"本身变成业务价值的一部分。希望这些经验能帮你少踩几个我踩过的坑。