调 IOU 阈值调到第七版的深夜,我盯着屏幕上那段跟踪结果发呆:检测框几乎每一帧都贴合得没话说,可 ID 号还是在 213 和 214 之间来回横跳。明明是同一个人,被一根杆子挡了两秒,再出来就成了"新目标"。那会儿我把关联阶段的阈值、寿命、卡尔曼增益翻来覆去改,改一版测一版,指标涨 0.3 掉 0.5,跟按了葫芦起了瓢一样。后来我索性停下来想明白一件事:问题根本不在那几个超参上,而在我把"检测"和"关联"当成两个独立环节拼起来这条链路上。这也就是我第一次认真去看MOTR这个方向的动机——它用一组Track Query把多目标跟踪的对象身份直接塞进网络里,让整条流程变成一次前向,把启发式匹配那一整层给拆了。这套思路说白了就是端到端多目标跟踪:网络自己在时间维度上记住"谁是谁",而不是让下游代码去猜。适合已经能跑通目标检测、但被 ID 指标卡住的中高级读者,也适合想搞懂 query-based tracking 到底在做什么的同学。
1. 调参调到怀疑人生之后,我重新审视了关联这一层
1.1 检测涨点、跟踪不涨点的怪现象
行业里干了几年的人大概都见过这个场景:检测器换了个 backbone,mAP 从 42 涨到 46,开心得不行,结果一上跟踪评估,MOTA 只涨了一个点出头,IDF1 甚至原地踏步。我第一次遇到这种情况时以为是评估脚本写错了,反复核对后发现不是——问题出在关联环节把检测多出来的那点召回,几乎全被错误匹配吃掉了。
原因其实不复杂。跟踪的短板从来不在"看见",而在"认人"。检测每帧都是独立的,它只告诉你这一帧这里有个人;而跟踪要在时间轴上一个一个串起来,任何一次关联错误都会污染整条轨迹,误差还会累积。检测器越激进,低分框越多,关联阶段的选择就越难,两个长得差不多的候选框挨在一起时,靠 IOU 和外观特征算出来的代价矩阵几乎没法区分。
我后来做过一个粗略统计:在一段 500 帧、平均每帧 12 个目标的序列里,关联阶段产生的 ID switch 里,大约六成发生在目标交叉、遮挡恢复、以及画面边缘进出这几个时刻。这些恰恰是 IOU 值不稳定、外观特征又因为姿态变化而失真的区间。检测器再准也救不了这一层,因为这里的根子是"两帧之间怎么建立对应关系"这个问题本身的建模方式不够。
1.2 启发式关联的三个硬伤
把 tracking-by-detection 的链路摊开看,核心就是三步:检测、算代价、匈牙利匹配或贪心匹配,中间夹杂卡尔曼预测和寿命管理。这套东西能跑,但它有三个绕不过去的地方。
第一,关联是逐帧独立的。第 t 帧怎么和第 t-1 帧匹配,跟第 t-2 帧几乎没有关系,除非你额外维护一个外观特征库或者卡尔曼状态。信息被切碎了,长时序上的约束很难加进去。目标被遮挡三帧再出来,前三帧的运动模型早就飘了。
第二,代价矩阵是手工设计的。IOU 权重、外观权重、运动权重,三个数怎么调基本靠经验和网格搜索。换一个场景(比如从白天换到夜间、从稀疏换到拥挤),最优权重就变了。我在一个密集场景上把外观权重调上去,结果因为行人衣服颜色相近,反而引入了更多误关联。
第三,误差不可微。检测出来什么就是什么,关联错了也没法反馈给检测网络去修正。整条链路是串行的,前级的问题后级只能硬扛。这也是为什么很多方案做到头,指标就卡在一个天花板上。
1.3 Track Query 把这套链路压成了一次前向
MOTR 的思路换个说法就很好理解:与其在帧与帧之间做"事后对齐",不如让网络在训练阶段就学会"维持身份"这件事。具体做法是给每一个被跟踪的目标分配一条向量,这条向量跟着目标在时间上走,每一帧经过 decoder 更新一次,输出当前帧的框和类别。这条向量就是Track Query。
你可以把它想象成每个目标随身带的"身份证"。传统方案是每帧重新拍照,然后拿照片去数据库里比对相似度;Track Query 是这个人自己带着证走,每一帧只需要把证件更新一下,不需要重新找人。匹配这一步被内化成了 query 和图像特征之间的注意力交互,不再是一个独立的、不可微的后处理模块。
端到端的"端",我理解是两层含义:一是从输入视频帧到输出带 ID 的轨迹,中间没有手工规则;二是损失可以从跟踪结果直接反传回特征提取,训练信号是完整的。这一点在我后面调优时体现得特别明显——当我把匹配策略改了一下,检测分支的响应也跟着变了,这在传统链路上是不可能发生的。
2. Track Query 的真面目:一条向量怎么就变成了一个"人"
2.1 从 object query 到 track query 的继承关系
如果熟悉 DETR 系列,理解 Track Query 会轻松很多。DETR 里有一组 object query,每个 query 负责"认领"图里的一个目标,通过 cross-attention 从图像特征里抽取信息,最后输出框和类别。这组 query 是可学习的参数,跟具体图像无关。
MOTR 在这个基础上做了个很自然的延伸:既然 object query 能在空间上定位目标,那让它跨帧传递,不就能在时间上定位目标了吗?于是有了两类 query 并存的结构——Detect Query负责发现当前帧新出现的目标,Track Query负责延续上一帧已经存在的目标。两者一起进 decoder,一起输出,输出完之后 Track Query 会带着更新后的状态进入下一帧,Detect Query 则重新初始化。
这个设计有个很关键的地方:Track Query 不是简单地把上一帧的检测框坐标传下来,而是把 decoder 里的 query embedding 传下去。这意味着它携带的信息维度远高于一个四元组(x, y, w, h),它包含了这个目标在过去所有帧里累积的视觉特征、运动趋势、以及和其他目标的相对关系。我一开始没意识到这个区别有多重要,直到我看到遮挡恢复的实验结果——传统方案在遮挡期间完全丢失外观,而 Track Query 因为保留了历史 embedding,重新出现时还能接得上。
2.2 query 里到底编码了哪些信息
这一点论文里讲得比较抽象,我在实际调试时是通过消融实验慢慢摸清的。一条 Track Query 在进入第 t 帧的 decoder 之前,大致携带三类信息。
第一类是身份特征,也就是这个目标"长什么样"。这部分主要来自历史帧的 cross-attention 输出,对光照变化有一定鲁棒性,但对剧烈形变还是敏感的。第二类是位置先验,它不像卡尔曼滤波那样显式建模速度,而是隐式地编码在 embedding 里,decoder 的 self-attention 会让它和当前帧的图像特征对齐。第三类是存在性状态,用来判断这个目标当前是否还在画面里,这个状态直接影响它是否输出有效框。
这里有个容易忽略的细节:Track Query 和 Detect Query 在 decoder 里是放在一起做 self-attention 的。这意味着新出现的目标和被跟踪的目标之间会互相"看见",拥挤场景下的相互遮挡、目标间的相对位置关系,都会通过 attention 传播。我最初以为这只是实现上的方便,后来发现这是 MOTR 在密集场景里比纯匹配方案稳的一个重要原因——它天然地建模了目标之间的交互。
2.3 新目标入场与老目标退场的管理
端到端不等于没有状态管理,只是状态管理从"外挂代码"变成了"网络内部的一部分"。MOTR 里有一套 enter/exit 机制,我把它理解成 query 池的进出规则。
每个 Track Query 有一个置信度分数和存活计数器。当某一帧的分数低于阈值或者被判定为不存在时,这个 query 不会立刻销毁,而是进入"休眠"状态,保留一个固定的帧数(常见配置是 30 帧左右),在这段时间里如果它的分数重新涨回来,就恢复跟踪,ID 不变;超过这个窗口还没恢复,才真正释放。这个设计直接对应的就是短时遮挡——人走过一根柱子,两三秒内再出现,ID 应该保持;但如果他走了半分钟才回来,那就当新目标处理。
Detect Query 的输出则负责补充新目标。每一帧都会有若干个 Detect Query,它们只有在被判定为"有效检测"且不与现有 Track Query 冲突时,才会被"升格"成新的 Track Query 加入池子。这里的冲突判定是个容易踩坑的地方,判得太松会出现重复轨迹,判得太紧又会漏掉真正的新目标。说实话,这部分我调了很久,后面在第 5 节会细讲。
3. 训练一个能用的 MOTR:损失、采样,以及论文里没明说的那些配置
3.1 集体平均损失解决的是什么问题
如果直接照搬 DETR 的训练方式,会撞上一个很现实的问题:一个训练 clip 里如果有 T 帧,每帧有 N 个 query,那总共有 T×N 个预测,但每个目标只在其中一部分帧里真实存在。传统做法是只对当前帧做匹配和损失,其他帧的预测全都浪费掉了。这不但训练效率低,而且长 clip 根本训不动。
MOTR 提出的集体平均损失(collective average loss)是我认为这篇工作里最实用的一处工程贡献。它的做法是:对一个 clip 里的所有帧,先各自做匈牙利匹配,然后把所有帧的匹配损失加起来除以帧数,得到一个平均损失。这样,即使某些帧的匹配不稳定,其他帧的正确匹配也能稀释掉噪声;同时长序列训练时,梯度不会被序列长度撑爆。
我实际跑的时候对比过:不开集体平均损失,5 帧 clip 训练时 loss 曲线抖得很厉害,尤其是序列中间帧遮挡的时候;开了之后曲线明显平滑,收敛步数大概少了 20% 左右。这个收益在小序列上不明显,但 clip 长度一上去就非常关键,因为长序列里单帧匹配出错的概率是指数级上升的。
另外还有一个细节是匹配时的排序。因为 Track Query 之间有明确的时间对应关系(第 t 帧的某个 query 对应第 t-1 帧的某条轨迹),匹配的时候要把这个先验考虑进去,不能单纯按预测顺序分配给 GT。实现上一般是把上一帧的匹配结果作为参考,当前帧优先匹配同一目标,剩下的再走匈牙利。这一步如果偷懒不做,早期训练收敛会慢很多。
3.2 序列采样长度与 query 对齐
训练 clip 的长度选择是个权衡。短了(比如 2 帧),query 学不到长时序上的身份一致性,退化成帧间匹配;长了(比如 10 帧以上),显存吃不消,而且 clip 内的目标数量波动大,容易导致 query 池爆炸。
我实测下来 3 到 5 帧是个比较甜蜜的区间。用一个简单的估算:单帧特征图按 800×1344 分辨率、ResNet-50 最后一层 stride 32 算,大概是 25×42 的位置,Deformable DETR 的多尺度采样点再加一层开销。批大小设为 1、clip 5 帧的情况下,一张 24G 的卡大概能剩下 4G 左右余量,够训。如果降到 3 帧,可以开批大小 2,我建议新手从这个配置起步。
采样策略上,相邻帧采样和随机间隔采样都有人用。相邻帧的好处是运动一致性强,query 学到的运动先验更真实;随机间隔能增加时间跨度上的多样性,但对齐难度大。我一般在训练前期用相邻帧,后期混入少量间隔采样,算是一种课程学习的变体。
还有一个必须处理的问题是 query 对齐时的 padding。一个 clip 里不同帧的目标数量不一样,多出来的 query 要么丢弃要么 mask 掉。我见过有人偷懒直接用 zeros 填充,结果训练时这些伪 query 参与了 self-attention,污染了正常 query 的特征。正确做法是用 attention mask 把它们屏蔽掉,这一点在实现时特别容易忽略。
3.3 匹配策略与负样本
匹配这一环在 MOTR 里比 DETR 复杂,因为多了时间维度。我用下来觉得要处理好三层。
第一层是 clip 内部的帧间匹配。就是上面说的,利用上一帧的匹配结果做先验,减少误匹配。实现上通常维护一个 GT 到 Track Query 的索引表,每帧更新。
第二层是新目标判定。Detect Query 输出后,要判断它是不是一个真正的新目标,还是现有 Track Query 漏检后重复检测出来的。这里通常用 IOU 做一次抑制,超过阈值的不新增。阈值我一般设 0.5 到 0.6,设太低会把并排走的人判成一个,设太高会出现双胞胎轨迹。
第三层是负样本。没有被匹配上的 Track Query 要当成背景或者"不存在"来处理,损失上表现为分类分支输出低分。这部分如果权重给太大,模型会变得过于保守,新目标难以入场;给太小又会抑制不住重复轨迹。实践中我一般让分类损失里负样本的 focal loss 权重保持默认,主要靠 enter/exit 的阈值来兜底。
4. 工程落地:从数据准备到推理时的 query 调度
4.1 数据格式与预处理
输入数据的组织方式直接影响训练效率。MOTR 训练需要的是 clip 形式的样本,每一帧带框和 ID 标注。我先说数据格式:通常用一份 JSON 记录每个视频序列的分辨率、帧数,另一份记录每个目标在每帧的框坐标和 ID。把它们预加载成 clip 索引,训练时按 video_id 和起始帧号去取。
预处理上,和 Deformable DETR 一脉相承,主要是多尺度数据增强。我实测过,多尺度增强对跟踪指标的贡献比对检测指标更明显,因为它让 Track Query 学会在不同尺度下识别同一个目标。不过尺度抖动范围别设太大,我在一次实验里把短边范围设成 480 到 1200,结果小目标在部分帧里直接消失,query 跟踪断掉,反而掉点。调到 640 到 1024 比较稳。
归一化用 ImageNet 均值方差就行。需要注意的是,如果训练集和推理集的相机参数差异大(比如车载场景和监控场景混训),最好做一次相机标定相关的归一化,或者干脆分场景训,别硬揉在一起。我踩过一次坑,混合训练后 ID 指标比单独训都低,原因是同一个 Track Query 在不同视角下学到的是互相矛盾的身份特征。
4.2 训练配置与显存账
给一份我实际跑通过的基础配置,供参考:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| backbone | ResNet-50 + Deformable Encoder | 6 层 encoder,stride 32 |
| clip 长度 | 5 帧 | 长短权衡点,显存友好 |
| decoder 层数 | 6 | 和 DETR 一致 |
| Track Query 数 | 200-300 | 拥挤场景取上限 |
| Detect Query 数 | 100 | 稀疏场景可减半 |
| 学习率 | 1e-4(backbone 1e-5) | AdamW,weight decay 1e-4 |
| 训练轮数 | 60-80 epoch | 前 20 轮用线性 warmup |
| 批大小 | 1 clip | 单卡 24G 可行 |
| 数据增强 | 多尺度 + 随机翻转 | 尺度范围 640-1024 |
显存估算这里给个粗略口径:batch=1、clip=5、输入 800×1344、backbone 冻结前几个 stage 的情况下,前向加反向大约占 18 到 20G。开梯度检查点能再省 3 到 4G,代价是训练慢 25% 左右。如果你卡在 16G,我建议先降 clip 到 3 帧、把 Detect Query 数减到 50,这两刀下去基本就能跑起来。
训练顺序上,我的建议是先在大规模检测数据集或者行人检测数据集上预训练 backbone,再在跟踪数据上微调,比从 ImageNet 权重大海捞针式冷启动快得多。冷启动时 Track Query 完全是随机初始化的,前几个 epoch 会出现大量重复轨迹,别慌,那是正常的,等 collective average loss 稳定下来会自己收敛。
4.3 推理阶段的 query 调度
推理阶段没有 clip 的概念,是真正的流式,所以 query 池的维护逻辑要写清楚。我的实现大概是这样:
track_queries = [] # 当前活跃的 Track Query 列表 next_id = 0 for frame in video: feats = backbone(frame) detect_q = initial_detect_queries track_q = torch.stack([q.embedding for q in track_queries]) if track_queries else None outputs = decoder(feats, detect_q, track_q) # 更新已存在的 Track Query for q, out in zip(track_queries, outputs.track): if out.score > LOW_THRESH: q.embedding = out.embedding q.age += 1 q.miss = 0 else: q.miss += 1 if q.miss > MAX_MISS: track_queries.remove(q) # 处理新的检测 for out in outputs.detect: if out.score > HIGH_THRESH and not dup_of_existing(out): track_queries.append(TrackQuery(embedding=out.embedding, id=next_id)) next_id += 1 # 输出轨迹 emit_tracks(track_queries)这段代码里有两个阈值很关键:LOW_THRESH 用于维持已有 query(设低一点,容忍短暂漏检),HIGH_THRESH 用于新建 query(设高一点,防止噪声入场)。我常用的组合是 0.3 和 0.7。这个不对称设计是有讲究的:已有轨迹断了代价很大,宁愿多容忍;新轨迹开错了代价也不小,宁可少开。
5. 实测中反复踩到的几个坑
5.1 遮挡恢复之后的 ID 跳变
这是我最开始遇到、也最头疼的一个问题。一段监控视频里,两个行人交错而过,遮挡结束后跟踪结果里 ID 全乱了。排查了好几天,发现根因不在 decoder,而在进入 decoder 之前的 query 池管理——遮挡期间两个 Track Query 的位置先验变得非常接近,self-attention 里互相干扰,导致恢复后两个 query 对应的 embedding 发生了交换。
我的处理办法有两个。一是在遮挡期间降低 Track Query 之间 self-attention 的强度,或者干脆对分数低的 query 做 attention mask,减少干扰。二是把 enter/exit 的休眠窗口从 20 帧拉到 30 帧,给恢复留出更多时间。第二个办法简单但有效,代价是池子里的 query 数量变多,推理稍微慢一点点。两个叠加之后,遮挡恢复的 ID switch 大概降了四成。
还有一个更细的点:遮挡恢复时 query 输出的分数往往偏低(因为特征确实不完整),如果这时候刚好碰上新建阈值判定,就容易开新轨迹。我的做法是对"休眠中恢复"的 query 单独设一个更低的输出阈值,让它更容易重新挂上。这个逻辑听起来绕,但实测确实有用。
5.2 长序列里的 query 漂移
训练 clip 只有 5 帧,但推理时一个视频可能上千帧。我最初没意识到这个 gap,结果在 200 帧以后,ID 开始逐渐偏移:本来跟着 A 的 query 慢慢跑到 B 身上去了。
这个现象本质上是累计误差的另一种表现。每一帧的屑小偏差被 embedding 传下去,越滚越大。我试过几个缓解手段:
- 周期性重锚定:每 50 帧做一次外观特征校准,用早期的特征做参考,把漂移往回拉。效果明显,但实现麻烦,需要在池子里存一份参考 embedding。
- 缩短 Track Query 的更新步长:embedding 不是完全用新值覆盖,而是保留一部分历史(类似 momentum 更新)。我用的 momentum 系数是 0.9,也就是新 embedding 只占 10%。这个改动很小但收益很大。
- 限制 query 之间的 attention 范围:只让空间上靠近的 query 互相 attend,减少远距离干扰。
我最推荐第二个,改动小、见效快,是个性价比很高的 trick。
5.3 指标和调参之间的对应关系
调参阶段特别容易迷糊的一点是:不知道该盯着哪个指标调。我用下来的经验是这样:
| 症状 | 优先看的指标 | 对应调整方向 |
|---|---|---|
| 轨迹整体对但 ID 频繁切换 | IDF1、IDSW | 调 query momentum、放宽休眠窗口 |
| 大量轨迹缺失 | MOTA、FN 高 | 降新生阈值、加 Detect Query 数 |
| 重复轨迹多 | FP 高、轨迹数虚高 | 提新生阈值、加强 dup 抑制 |
| 短序列还行长序列崩 | 分序列统计 IDF1 | 上周期性重锚定 |
按这个对应关系来调,能省掉一大半盲目试错的时间。我一开始是全部指标一起盯,改了参数也不知道是谁的功劳,后来分症状分指标之后效率高了很多。
6. 什么时候该上 Track Query,什么时候反而更麻烦
6.1 和 TransTrack、TrackFormer 这类同门方案的差别
同样是用 query 做跟踪,几个方案在 query 的来源和传递方式上有本质差异。TrackFormer把前一帧的 query 直接作为当前帧的初始 query,新目标靠额外的 detect query 补充,思路和 MOTR 接近但时序建模更弱,长序列上容易断。TransTrack用两套独立分支分别做当前帧检测和上一帧传播,耦合度更低,但两套分支的一致性要做额外约束。MOTR的关键差异在于它显式维护了目标从入场到退场的完整生命周期,query 本身带了状态,这让它在遮挡场景和长序列上表现更稳。
代价也很明显:MOTR 的状态管理逻辑复杂度高于另外两者。TrackFormer 那种"只传一帧"的写法实现起来极简,代码量可能只有 MOTR 的三分之一。如果场景本来就简单(比如行人稀疏、遮挡少),用 MOTR 属于杀鸡用牛刀,调参成本反而更高。
6.2 适合和不适合的场景
想清楚适用边界能省掉很多无用功。我总结下来:
适合的场景一般是:序列较长(几百帧以上)、遮挡频繁、目标数量中等(每帧 10 到 50 个)、对 ID 稳定性要求高。比如监控、体育赛事分析、自动驾驶里的行人轨迹预测,这些都很吃 ID 一致性。
不适合的场景:目标数量极大且每个都只出现几帧(比如人群密度统计里那种),Track Query 池会爆炸,或者目标形状变化剧烈(比如细胞分裂、粒子运动),外观特征传递的意义不大。另外如果算力非常紧张(比如要跑在嵌入式设备上),这套方案的参数量和推理延迟都达不到要求,老老实实用轻量检测加卡尔曼反而更合适。
一个中间方案是用 MOTR 做离线处理、生成高质量标注,再蒸馏成一个轻量模型跑在线。我试过这个路子,在离线阶段把长序列的 ID 做得很干净,然后拿这些轨迹去训一个帧级的关联网络,线上性能能接受,这个思路值得试试。
最后分享一个我自己的体会:这套方案最值钱的地方其实不是省掉了多少代码,而是它给了你一个可以持续优化的统一目标——所有改动都通过同一个损失函数反映出来,改完立刻能看出来是变好还是变差。传统链路上你不光要改,还要反复验证每一步改动的连锁反应,那种"改了 A 破坏了 B"的循环才真正拖垮人。我在调 Track Query 的 momentum 参数时,从 0.5 试到 0.95 一共跑了七组实验,两天就定了,换到以前那种手工关联的方案,这个量级的实验没有一周打不住。