1. 项目整体设计与思路拆解
1.1 这个项目到底在做什么
TrackFormer 是一个把目标检测和轨迹跟踪揉进同一个 Transformer 框架里的开源项目。传统做法通常是“先检测、再关联”,也就是先用一个检测器把每一帧里的目标框出来,再用匈牙利匹配、卡尔曼滤波或者 ReID 特征去把跨帧的同一个目标串起来。这套流程能跑,但链路长、超参多、后处理重,一旦遮挡或者目标外观突变,关联环节就容易崩。TrackFormer 的思路是:把“检测”和“跟踪”统一成序列预测问题,让模型在解码阶段直接输出带轨迹 ID 的框,轨迹身份不是后处理硬凑出来的,而是模型在注意力机制里自己学出来的。
我第一次复现这个项目的时候,最直观的感受是:它不像 SORT、DeepSORT 那种“工程拼装型”方案,而更像一个端到端的序列建模系统。输入是一段视频帧序列,输出是每一帧里每个目标的边界框和轨迹编号。训练时,前一帧的轨迹查询会被传到当前帧,和当前帧的新生查询一起参与解码,模型通过自注意力去决定“这个查询应该继续跟旧目标,还是激活一个新目标”。这种设计的好处是轨迹身份在时间维度上是连续的,不需要额外训练一个 ReID 网络,也不需要手工设计匹配代价矩阵。
适合谁来参考这个项目?如果你已经做过目标检测,想往多目标跟踪方向走,或者你正在找一个能同时处理检测和跟踪的 Transformer 基线,这个项目很合适。但它不适合完全没有深度学习基础的人直接上手,因为里面涉及 DETR 系列的匈牙利匹配、查询设计、时序传播等概念,需要你对 Transformer 解码器和二分图匹配有一定了解。
1.2 为什么选择 Transformer 而不是传统跟踪范式
传统多目标跟踪(MOT)的经典范式是 tracking-by-detection。检测器负责每帧出框,跟踪器负责跨帧关联。这个范式统治了很多年,原因很简单:模块解耦,检测器可以用 YOLO 系列、Faster R-CNN 等成熟模型,跟踪器可以用卡尔曼滤波做运动预测,用 ReID 做外观匹配。但它的痛点也很明显:检测器的漏检和误检会直接传给跟踪器;关联阶段需要调大量阈值,比如 IoU 阈值、外观距离阈值、最大丢失帧数;遮挡场景下卡尔曼滤波的运动假设容易失效。
TrackFormer 选择 Transformer 路线,核心动机是让模型自己学“什么时候该延续轨迹,什么时候该新建轨迹”。在解码器里,每一层都会对轨迹查询和当前帧特征做交叉注意力,轨迹查询携带了历史信息,当前帧特征提供了新观测,模型通过注意力权重决定信息融合方式。这相当于把关联逻辑隐式地学进了网络参数里,而不是靠人工规则。
另一个关键设计是“轨迹查询”的复用。在 DETR 里,查询是一组可学习的向量,每个查询负责预测一个目标。TrackFormer 把查询分成两类:新生查询和轨迹查询。新生查询负责发现新目标,轨迹查询从上一次解码结果继承过来,负责延续已有轨迹。训练时通过匈牙利匹配把预测和真值对齐,匹配代价同时考虑分类、框回归和轨迹 ID 一致性。这样模型在训练中就会学到:如果一个轨迹查询和某个真值轨迹匹配上了,它就应该继续输出那个轨迹的 ID。
1.3 复现前需要想清楚的几个问题
复现这个项目之前,我建议先问自己三个问题。第一,你的数据是什么格式?TrackFormer 官方代码通常基于 MOTChallenge 格式或者 COCO 格式做适配,如果你手里是自定义视频数据,需要先转成帧序列加标注的格式。第二,你的算力够不够?Transformer 类跟踪模型对显存和训练时长要求不低,单卡 12G 显存跑小分辨率还行,想跑高分辨率或者长序列,至少得 24G 以上。第三,你是想复现指标还是想改结构?如果只是想跑通看看效果,可以用官方提供的预训练权重做推理;如果想从头训练,那数据量、学习率调度、数据增强策略都得仔细调。
我见过不少人一上来就 clone 代码、装依赖、跑训练脚本,结果卡在数据加载或者显存溢出,折腾几天就放弃了。比较稳的做法是:先用官方权重跑一遍推理,确认环境没问题、输出格式能看懂;再拿一小部分数据做过拟合测试,确认训练链路通畅;最后才上全量数据。这个顺序能帮你快速定位问题出在环境、数据还是模型本身。
2. 核心细节解析与实操要点
2.1 轨迹查询机制到底怎么工作
TrackFormer 的核心创新点就在轨迹查询上。你可以把查询想象成一群“探测器”,每个探测器负责盯住一个目标。在 DETR 里,这些探测器是固定的、可学习的向量,每帧重新初始化。TrackFormer 不一样:上一帧的轨迹查询会被保留下来,经过一个时序传播模块后,和当前帧的新生查询拼接在一起,送入解码器。
具体来说,假设上一帧有 N 个活跃轨迹,每个轨迹对应一个查询向量。当前帧来了之后,系统会生成 M 个新生查询。解码器的输入就是 N+M 个查询。解码器内部,每个查询会先做自注意力,让轨迹查询和新生查询互相交换信息;然后做交叉注意力,从当前帧的 CNN 或 Transformer 特征里提取观测信息;最后经过前馈网络输出分类、框回归和轨迹 ID 预测。
这里的关键是:轨迹查询不是简单复制上一帧的输出,而是经过了一个传播模块。常见实现是用一个轻量级的 Transformer 解码层或者 MLP 来更新查询,让它携带历史运动信息。这样即使当前帧目标被遮挡,轨迹查询仍然能靠历史信息维持预测,等目标重新出现时再匹配上。
注意:轨迹查询的数量是动态变化的。如果某一帧新生查询激活了新轨迹,下一帧的查询数量就会增加。实现时需要用掩码机制来处理变长查询序列,否则 batch 内不同样本的查询数量不一致会报错。
2.2 匈牙利匹配在训练中的角色
训练阶段,模型输出一堆预测,真值也是一堆框和轨迹 ID。怎么知道哪个预测对应哪个真值?答案是匈牙利匹配。它会在预测和真值之间找一个最优二分图匹配,使得总匹配代价最小。代价通常由三部分组成:分类损失、框回归损失(L1 和 GIoU)、轨迹 ID 损失。
这里有个细节很容易被忽略:轨迹 ID 损失只在匹配上的预测和真值之间计算。如果一个预测匹配到了某个真值轨迹,但预测的轨迹 ID 错了,这个损失就会惩罚模型。如果预测没匹配上任何真值,它会被当作背景,只计算分类损失。这种设计让模型在训练中逐渐学会把轨迹查询和正确的轨迹 ID 绑定起来。
我实测下来,匈牙利匹配的实现效率对训练速度影响很大。官方 DETR 用的 scipy 线性求和分配在 CPU 上跑,batch 大了之后会成为瓶颈。可以考虑用 CUDA 实现的匹配核,或者把匹配放在 GPU 上做。另外,匹配代价的权重需要调,分类权重太高会导致模型只关注检测,轨迹 ID 权重太高又会让模型过早锁定错误轨迹。一般分类、L1、GIoU、轨迹 ID 的权重比例在 1:5:2:1 左右比较稳,但具体还得看数据集。
2.3 时序传播模块的实现选择
时序传播模块决定了历史信息怎么传给当前帧。最简单的做法是直接把上一帧的轨迹查询拿过来用,不做任何变换。但这样历史信息太“陈旧”,目标运动快了就跟不上。稍微好一点的做法是加一个线性层或者 MLP,把上一帧查询映射到当前帧的特征空间。更复杂的做法是用一个独立的 Transformer 解码层,让轨迹查询和上一帧的特征做自注意力,提取时序上下文。
我在复现时试过三种方案。第一种是直接复用,速度快但指标一般,尤其是快速运动场景下轨迹容易断。第二种是加 MLP 变换,参数量小,指标有提升,适合算力有限的场景。第三种是加一个时序解码层,效果最好,但显存占用和训练时间都上去了。如果你只是想做基线复现,建议从第二种开始,稳定之后再换第三种。
提示:时序传播模块的输入应该只包含上一帧的轨迹查询,不要混入当前帧的新生查询。否则模型会分不清哪些是历史信息、哪些是新观测,训练容易不稳定。
2.4 数据加载与增强的坑
MOT 数据集的加载比图像分类复杂得多。一个视频序列有几百帧,每帧有多个目标,每个目标有轨迹 ID。加载时不能简单地把所有帧打乱,因为轨迹信息依赖时间顺序。常见的做法是按序列组织数据,每个 batch 取一个或者几个序列的连续帧片段。片段长度是个超参,太短了学不到长时序依赖,太长了显存吃不消。一般 5 到 10 帧比较合适。
数据增强方面,MOT 不能随便用随机裁剪和翻转,因为会破坏轨迹的时空连续性。常用的增强有:随机缩放、颜色抖动、随机擦除。随机擦除要小心,如果擦掉了目标,轨迹就断了,模型会学到错误的“目标消失”信号。我一般只在目标框周围小范围擦除,或者干脆不用擦除,靠模型自身的遮挡鲁棒性。
还有一个坑是帧采样。如果视频帧率很高,相邻帧几乎一样,全部拿来训练会导致冗余。通常会隔几帧采样,但间隔太大会让运动模型学不准。我一般用间隔 1 到 3 帧,具体看视频帧率和目标运动速度。
3. 实操过程与核心环节实现
3.1 环境搭建与依赖安装
先说我用的环境配置,这套配置在单卡 24G 显存的机器上跑通过,比较稳。
# 创建虚拟环境 conda create -n trackformer python=3.8 -y conda activate trackformer # 安装 PyTorch,版本要和 CUDA 匹配 pip install torch==1.10.0+cu113 torchvision==0.11.0+cu113 -f https://download.pytorch.org/whl/torch_stable.html # 安装其他依赖 pip install opencv-python pillow matplotlib scipy pycocotools pip install cython这里有个细节:TrackFormer 的可变形注意力模块需要编译 CUDA 算子。如果你用的是官方代码,通常有一个make.sh或者setup.py需要跑。编译前确认CUDA_HOME环境变量指向正确的 CUDA 路径,否则会报找不到nvcc。
export CUDA_HOME=/usr/local/cuda-11.3 cd models/ops python setup.py build install编译过程可能比较慢,而且对 GCC 版本有要求。我遇到过 GCC 版本太高导致编译失败的情况,换成 GCC 7 或 8 就过了。如果实在编译不过,可以找找有没有预编译的 wheel,或者用纯 PyTorch 实现的可变形注意力替代,但速度会慢一些。
3.2 数据准备与格式转换
假设你手里是 MOTChallenge 格式的数据,目录结构通常是这样:
data/ MOT17/ train/ MOT17-02/ img1/ 000001.jpg 000002.jpg gt/ gt.txt seqinfo.inigt.txt每行格式是:frame, id, x, y, w, h, conf, class, visibility。TrackFormer 需要把它转成 COCO 风格的标注或者自定义的序列标注。我一般写一个转换脚本,把每个序列的标注读进来,按帧组织成列表,每个元素包含该帧所有目标的框和轨迹 ID。
import os import cv2 import numpy as np def load_mot_gt(gt_path): data = {} with open(gt_path, 'r') as f: for line in f: parts = line.strip().split(',') frame = int(parts[0]) tid = int(parts[1]) x, y, w, h = map(float, parts[2:6]) conf = float(parts[6]) if conf == 0: continue if frame not in data: data[frame] = [] data[frame].append({ 'track_id': tid, 'bbox': [x, y, x + w, y + h] }) return data转换完之后,建议先可视化几帧,确认框和 ID 对得上。我踩过一次坑:标注里的坐标是 1-based 还是 0-based 没注意,导致训练时框整体偏移了一个像素,指标怎么调都上不去。后来写了个小脚本把标注画到图上,一眼就看出来了。
3.3 模型配置与训练参数
TrackFormer 的配置文件通常包含几个关键部分:backbone、transformer、matcher、loss、数据集。backbone 一般用 ResNet-50 或者 ResNet-101,预训练权重从 ImageNet 加载。transformer 部分要设置编码器层数、解码器层数、隐藏维度、注意力头数。官方配置通常是 6 层编码器、6 层解码器、256 维隐藏层、8 个注意力头。
训练参数方面,我用的配置是:
| 参数 | 值 | 说明 |
|---|---|---|
| batch_size | 2 | 每个 batch 2 个序列片段 |
| clip_length | 5 | 每个片段 5 帧 |
| lr | 1e-4 | 主干网络学习率更低,1e-5 |
| lr_backbone | 1e-5 | 避免预训练权重被破坏 |
| weight_decay | 1e-4 | 防止过拟合 |
| epochs | 50 | 通常 50 到 100 轮 |
| lr_drop | 40 | 第 40 轮学习率降 10 倍 |
学习率调度用 step 或者 cosine 都行,我习惯用 step,简单可控。优化器用 AdamW,比 Adam 更稳。梯度裁剪设 0.1,防止梯度爆炸。
optimizer = torch.optim.AdamW([ {'params': model.backbone.parameters(), 'lr': 1e-5}, {'params': model.transformer.parameters(), 'lr': 1e-4} ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=40, gamma=0.1)注意:backbone 和 transformer 的学习率要分开设。如果统一用 1e-4,预训练的 ResNet 权重会被快速破坏,训练初期指标会掉得很厉害。
3.4 推理与结果输出
推理阶段,模型按序列逐帧处理。第一帧只有新生查询,没有轨迹查询。从第二帧开始,上一帧的轨迹查询被传进来。每帧输出一组框和轨迹 ID,需要把轨迹 ID 映射回原始视频的帧号。
model.eval() track_queries = None results = [] with torch.no_grad(): for frame_idx, frame in enumerate(video_frames): img = preprocess(frame) outputs = model(img, track_queries) boxes = outputs['boxes'] scores = outputs['scores'] track_ids = outputs['track_ids'] # 过滤低分框 keep = scores > 0.5 boxes = boxes[keep] track_ids = track_ids[keep] # 更新轨迹查询 track_queries = outputs['track_queries'] for box, tid in zip(boxes, track_ids): results.append({ 'frame': frame_idx + 1, 'track_id': tid, 'bbox': box.tolist() })输出格式可以转成 MOTChallenge 的提交格式,方便用官方评估工具算 MOTA、IDF1 等指标。我一般会先把结果可视化一遍,看看轨迹有没有频繁跳变或者 ID 切换。如果发现某个目标 ID 频繁变,可能是轨迹查询传播出了问题,或者匹配阈值设得太高。
4. 常见问题与排查技巧实录
4.1 训练不收敛或者指标震荡
这是最常见的问题。表现是 loss 下降很慢,或者 MOTA 在某个值附近来回跳。原因可能有几个:学习率太大、匹配代价权重不合理、数据增强太激进、batch size 太小导致梯度噪声大。
我的排查顺序是:先把学习率降一个数量级,看 loss 是否稳定下降;如果还不行,检查匹配代价权重,把分类权重调低一点,让模型先学好框回归;再不行就关掉数据增强,用原始数据过拟合一个小数据集,确认模型本身能学会。如果小数据集都过拟合不了,那可能是模型结构或者数据加载有问题。
还有一个隐蔽的坑:轨迹 ID 的编码方式。如果轨迹 ID 是连续整数,直接做分类预测没问题;但如果 ID 跨度很大,比如 1 到 1000,分类头会很大,训练也慢。我一般会把轨迹 ID 重新映射成 0 到 N-1 的连续整数,推理时再映射回去。
4.2 显存溢出(OOM)怎么破
Transformer 类模型显存占用和序列长度、查询数量、特征图分辨率都相关。OOM 的时候,优先降 batch size 和 clip length。如果还不行,降输入分辨率,比如从 800x1333 降到 640x1067。再不行就减少解码器层数或者隐藏维度,但这会损失精度。
另一个技巧是用梯度累积。比如你想用 batch size 8,但显存只够 2,那就设 batch size 2,累积 4 次梯度再更新。这样等效 batch size 是 8,但显存占用只有 2。
accum_steps = 4 for i, batch in enumerate(dataloader): loss = model(batch) loss = loss / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()提示:梯度累积时,学习率要按等效 batch size 缩放。如果等效 batch size 翻倍,学习率也可以适当调大,但不要线性放大,一般开方缩放比较稳。
4.3 轨迹 ID 频繁切换
推理时如果发现同一个目标在不同帧之间 ID 变来变去,说明轨迹查询没有正确延续。可能的原因有:轨迹查询传播模块太弱、匹配阈值太高、新生查询太多导致轨迹查询被淹没。
我一般先检查轨迹查询的传播逻辑,确认上一帧的查询确实传到了当前帧。然后看匹配阈值,如果分类分数阈值设到 0.7 以上,很多轨迹查询会因为分数不够被丢弃,导致轨迹断裂。可以适当降到 0.5 左右。另外,新生查询的数量要控制,太多新生查询会让模型倾向于每帧新建轨迹,而不是延续旧轨迹。官方实现里通常有一个“新生查询比例”超参,我一般设 0.3 到 0.5 之间。
还有一个技巧是加轨迹平滑。推理时对同一轨迹的框做滑动平均,减少抖动。虽然不能解决 ID 切换,但能让输出更稳定。
4.4 常见问题速查表
| 问题 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 训练 loss 不降 | 学习率太大、数据有问题 | 小数据集过拟合测试 | 降学习率、检查标注 |
| 显存溢出 | batch 太大、分辨率太高 | 逐步降参数 | 梯度累积、降分辨率 |
| 轨迹 ID 频繁切换 | 传播模块弱、阈值高 | 可视化轨迹 | 调阈值、增强传播 |
| 推理速度慢 | 查询太多、层数太深 | 计时各模块 | 减查询、减层数 |
| 指标远低于论文 | 数据格式、评估方式 | 对齐官方评估 | 检查坐标格式、匹配方式 |
4.5 几个我踩过的坑和独家技巧
第一个坑是坐标格式。MOTChallenge 的标注是x, y, w, h,但很多检测框架用的是x1, y1, x2, y2。转换的时候如果忘了加w和h,框会小一圈,指标直接掉一半。我现在的习惯是转换完先画图,肉眼确认没问题再训练。
第二个坑是帧号对齐。视频帧通常从 1 开始,但 Python 列表从 0 开始。如果推理时帧号没对齐,评估工具会认为你漏了第一帧或者多了一帧,指标会异常。我一般会在输出结果里显式加 1,确保和标注一致。
第三个技巧是学习率 warmup。Transformer 训练初期梯度很大,直接上大学习率容易发散。我一般加 3 到 5 轮的 warmup,学习率从 1e-6 线性升到设定值,训练稳定很多。
def warmup_lr(epoch, warmup_epochs, base_lr): if epoch < warmup_epochs: return base_lr * (epoch + 1) / warmup_epochs return base_lr第四个技巧是模型 EMA。训练时维护一个指数移动平均的模型权重,推理时用 EMA 权重,指标通常能涨 0.5 到 1 个点。实现很简单,每次更新完参数后,用ema_weight = decay * ema_weight + (1 - decay) * current_weight更新一份影子权重。decay 一般设 0.999。
第五个技巧是推理时的多尺度测试。把输入图像缩放到不同尺寸分别推理,再把结果融合。虽然速度慢,但指标能涨一点。如果对速度没要求,可以试试。
5. 项目扩展与二次开发方向
5.1 换 backbone 提升特征提取能力
官方代码默认用 ResNet-50,如果你追求更高精度,可以换成 ResNet-101 或者 Swin Transformer。换 backbone 的时候要注意特征图通道数和解码器的输入维度对齐。ResNet-50 最后一层是 2048 维,Swin-T 可能是 768 维,需要加一个投影层把维度统一到 transformer 的隐藏维度。
我试过把 backbone 换成 Swin-T,指标涨了大概 1.5 个 MOTA,但训练时间多了 40%。如果算力有限,ResNet-101 是更划算的选择,指标涨 0.8 左右,时间只多 20%。
5.2 加入 ReID 特征增强轨迹关联
虽然 TrackFormer 本身不依赖 ReID,但你可以在轨迹查询里额外注入外观特征。具体做法是:用一个轻量级的 ReID 网络提取每个目标的 appearance embedding,然后把它和轨迹查询拼接或者相加。这样轨迹查询不仅携带运动信息,还携带外观信息,遮挡场景下更鲁棒。
实现时要注意 ReID 特征的维度和轨迹查询维度匹配,不匹配就加一个线性投影。另外 ReID 网络可以和主模型联合训练,也可以冻结预训练权重只训练投影层。我一般先冻结 ReID 训练几轮,再解冻联合微调。
5.3 部署到边缘设备的可行性
TrackFormer 的参数量和计算量都不小,直接部署到边缘设备比较吃力。如果非要上边缘端,可以考虑几个方向:用 TensorRT 做推理加速,把 FP32 量化成 FP16 或者 INT8;用知识蒸馏把大模型的能力迁移到小模型;或者只保留检测分支,跟踪分支用轻量级关联算法替代。
我实测过 TensorRT FP16 加速,推理速度能提升 2 到 3 倍,精度掉 0.3 左右,基本可接受。INT8 量化掉点比较多,需要仔细校准,不然 MOTA 可能掉 2 个点以上。
5.4 多类别跟踪的适配
官方代码通常只跟踪“行人”一个类别。如果你想跟踪多类别,比如车辆、行人、自行车,需要改几个地方:分类头输出维度从 2 改成类别数加 1;数据加载时保留类别标签;匹配代价里加入类别一致性。另外,不同类别的运动模式差异很大,轨迹查询可能需要按类别分组,或者加一个类别嵌入向量。
这个改动工作量不小,但思路是清晰的。我建议先在一个类别上跑通,再逐步加类别,不要一上来就搞多类别,否则问题定位会很困难。
6. 一些个人体会
复现 TrackFormer 这个过程,让我对“端到端跟踪”有了更具体的认识。它不是一个拿来就能用的工程方案,而是一个研究性质的基线。它的价值在于展示了一种可能性:跟踪可以不依赖手工设计的关联规则,而是让模型在序列建模中自己学会身份保持。但它的工程落地还有距离,推理速度、显存占用、训练成本都是现实问题。
如果你只是想快速做一个能用的跟踪系统,DeepSORT 或者 ByteTrack 可能更合适,调参少、速度快、社区支持好。但如果你想深入理解 Transformer 在时序任务上的建模方式,或者你想做一个不依赖 ReID 的跟踪方案,TrackFormer 值得花时间研究。我自己的经验是:先跑通推理,再复现训练,最后改结构。每一步都做可视化验证,不要只看 loss 和指标。很多时候,画几张轨迹图比看一堆数字更能发现问题。