最近在做一个园区行人跟踪项目,需要把FastReID训练出来的模型权重轻量化之后塞进DeepSORT框架里。理想很丰满:FastReID在行人重识别上的精度优势,配合DeepSORT的卡尔曼滤波和级联匹配,听起来是1加1大于2的组合。但第一版Demo直接把原版ResNet50权重加载进DeepSORT,实测单路1080P视频只能跑到7帧每秒,画面里超过5个行人时ID就开始乱跳,整个系统完全不具备实时性。后面花了两周时间,沿着“轻量化—导出—嵌入—调参”这条路一点点填坑,总算把单路处理拉到了25毫秒以内,跟踪指标也稳定住了。这篇文章就是把这段经历里的关键坑整理成集锦,希望能帮准备做同样整合的同行少走弯路。
1. 为什么不能把FastReID的原始权重文件直接塞给DeepSORT用
很多人听到“把FastReID嵌入DeepSORT”,第一反应就是:把训练好的.pth加载进DeepSORT的特征提取器不就行了?看起来只有几行代码的差别,但实际跑起来后才会发现,这条链路里每一环都暗藏问题。
1.1 DeepSORT对ReID模型的隐藏要求
DeepSORT本身不是一个端到端的神经网络,它是一个多模块组合系统:检测器负责输出目标框,卡尔曼滤波负责预测轨迹运动状态,匈牙利算法负责检测框和轨迹的匹配。而ReID模型在其中扮演的角色,是提供“外观描述子”——把每一帧截出来的行人图像编码成一个向量,用来在遮挡、交叉、短暂丢失之后重新找回同一个目标。
这个角色对模型有非常明确的要求:推理速度必须快,特征维度不能太大。原版DeepSORT的实现里,特征提取器通常是一个小型CNN,输出128维或512维的特征向量,单张行人图在GPU上的推理耗时被压缩到几毫秒级别。因为跟踪场景里每一帧可能同时存在十几个甚至几十个目标,每一个目标都要单独做一次推理,特征提取的延迟会被放大十倍以上。
FastReID恰恰相反。它出身于行人重识别竞赛和学术评测,设计目标是刷Market1501、DukeMTMC这些数据集上的mAP和Rank-1指标,模型骨干是ResNet50甚至更重的结构,输出的特征通常是2048维。精度确实漂亮,但训练目标和部署目标根本不匹配。
1.2 FastReID的精度优势和它带来的实时性代价
为什么要用FastReID?因为在遮挡恢复、跨镜头跟踪这些场景里,小型CNN提取的特征确实不够用。我用一个MobileNet特征提取器跑过同一段视频,行人一旦互相遮挡三五帧,再出现时外观特征的距离就超过了匹配阈值,Tracker只能重新给一个新ID,ID Switch特别多。FastReID这种专门为行人ReID设计的模型,对服装颜色、背包、体型这些判别性特征的学习要深入得多,匹配的稳定性明显更好。
可问题也很现实。我第一版直接把FastReID的ResNet50权重接进DeepSORT的ReID模块,单张256×128的输入图,PyTorch推理在GTX 1080上大约要20到30毫秒。听着还能接受,但你要知道这是一张图的时间。一帧画面里有10个检测框,那就是200多毫秒,整条视频只能跑不到10帧。如果再叠加上目标检测的前处理、后处理,以及卡尔曼滤波和级联匹配的计算,整个流程已经不能叫实时系统了,只能叫幻灯片播放器。
1.3 项目硬件约束与目标帧率
我的项目跑在一台低功耗GPU上,不是数据中心里的旗舰卡。硬件算力大概只有主流游戏显卡的一半不到,显存8GB还得同时跑检测模型。在这种条件下,项目验收要求单路视频稳定25帧以上,模型还不能吃掉全部显存,否则检测模型就要被挤出去。
这就逼着我必须回答一个问题:FastReID的精度到底能在多大程度上牺牲,才能换回实时性?这其实是所有ReID加Tracking落地方案的核心矛盾——纯学术模型追求指标上限,工程部署追求性价比。这篇踩坑集锦里所有后续工作,本质上都是在找那个性价比平衡点。
2. 轻量化方案选型:蒸馏、剪枝还是量化
面对“权重文件轻量化”这个需求,行业内通常有三条主流路线:知识蒸馏、结构化剪枝、模型量化。每条路都有自己的适用场景,我逐个试过之后才确定最终方案。
2.1 知识蒸馏方案:精度诱惑与标注依赖
第一次尝试的是知识蒸馏。思路是让FastReID当教师网络,把学习到的特征分布“教”给一个轻量学生网络,比如MobileNetV3或者ShuffleNetV2。训练的时候,学生模型不仅学习真实标签的交叉熵损失,还要在特征层去逼近教师模型的输出,通过蒸馏温度系数来控制软标签的平滑程度。
原理讲起来很顺,但实际操作时我遇到的第一道坎就是数据标注。ReID模型训练需要的是“同一身份在多个镜头下的图片对”,这种标注成本比分类任务高得多。我手里并没有足够多的已标注ReID数据来训练一个效果好到能满足跟踪要求的学生模型,最后只能把已训练好的FastReID模型当作标注器,在原始视频上自动提取特征并聚类生成伪标签。
伪标签方案跑了一轮之后,效果确实还不错,学生模型在验证集上的Rank-1只掉了三四个点。但整个蒸馏训练流程加上调参花掉了太多时间,而且伪标签里混入的错误会让学生模型学到一些偏差。这个方案最终被我定位为“有足够标注数据时的最优解”,而不是眼下最快的落地路线。
2.2 通道剪枝方案:压缩比与恢复训练成本
第二条路线是通道剪枝。对已经训练好的ResNet50做结构化剪枝,把贡献较小的卷积通道移除,得到一个更窄的网络,再通过微调来恢复精度。这条路的优势是推理速度提升明显,因为剪枝后卷积的计算量实打实降下来了。
但我在实际操作中发现,剪枝方案有几个绕不开的麻烦。一是通道重要性评估需要用到BN层的缩放因子,剪枝比例怎么定、哪些层该多剪哪些层该少剪,都依赖经验;二是剪枝之后必须重新训练恢复精度,不然特征质量会崩得非常厉害。我拿权重文件做了20%剪枝的试验,微调了十几个epoch,Rank-1还是掉了将近8个点,部署后跟踪ID Switch增加非常明显。
剪枝需要有充足的数据和算力去做恢复训练,这两个条件在我这个项目里都不算宽裕,所以这条路也被我暂时放下了。
2.3 ONNX导出加INT8量化:落地最快的路线
第三条路线,也是我最终选择的主干方案:把FastReID模型导出成ONNX,再借助推理引擎做FP16和INT8量化。这条路最大的吸引力在于——它不需要重新训练,只需要一个校准数据集。
INT8量化的原理并不复杂。模型权重和激活值原本是FP32类型,占用4字节,量化成INT8之后只占1字节,模型体积直接缩到四分之一。同时INT8的矩阵运算在支持硬件上可以走专用的低精度计算单元,推理速度会有成倍提升。因为ReID特征提取对量化误差有一定容忍度,只要校准数据集选得好,特征向量和原始FP32计算的差距可以控制在一个很小的范围。
我一开始担心量化会把ReID特征“破坏”到没法用的程度,实测之后发现担心是多余的。在校准集上对比了量化前后输出的2048维特征向量,平均余弦相似度还在0.95以上,完全够DeepSORT的匹配逻辑使用。
2.4 我的最终组合方案与理由
最终的方案是“ONNX导出为底座,FP16和INT8两套推理配置并存,知识蒸馏作为后续精度增强手段”。FP16用在检测点比较密集、对精度要求更高的场景,INT8用在日常监控场景,同时保留了一版蒸馏调优后的轻量模型作对比测试。
选这条路的核心逻辑有三点:第一,不依赖额外标注数据,只要原始训练集里抽一部分做校准集就行;第二,改造链路短,PyTorch到ONNX再到推理引擎是成熟流程,踩坑有迹可循;第三,量化带来的速度收益是结构性的,INT8模型在GPU上的推理速度几乎是FP32的3到4倍,直接解决了实时性问题。后面所有踩坑记录,都是围绕这套方案展开的。
3. 导出ONNX时那些让人血压升高的算子坑
FastReID的训练代码和部署代码之间有一道鸿沟。训练时的模型结构包含Backbone、瓶颈层、分类头、池化层等多个组件,而部署时只需要其中的特征提取部分。如果你直接把整个模型丢给torch.onnx.export,导出的ONNX文件不仅体积大,还会包含一堆用不到的计算节点,推理引擎加载时反而更慢。
3.1 导出前的模型结构裁剪:只保留backbone特征提取部分
FastReID的推理输出通常是在ImageNet预训练Backbone之上接一个全局池化,再经过BN和全连接层得到分类logits。我们要嵌入DeepSORT的部分是“图片到特征向量”的映射,所以分类头、甚至瓶颈层里的某些分支都可以去掉。
我踩的第一个坑就是没有做结构裁剪,直接把包含分类头的完整模型导了出去。DeepSORT喂进来一张行人图,模型除了算特征向量,还额外算了一遍全连接分类层,白白多出将近30%的计算量,推理速度又掉了一截。
正确的做法是继承模型类之后重写forward,只保留Backbone和全局池化,输出层命名为feat。大致可以这样写:
import torch import torch.nn as nn from fastreid.modeling.meta_arch import build_model class ReIDFeatureExtractor(nn.Module): def __init__(self, cfg): super().__init__() self.backbone = build_model(cfg).backbone def forward(self, x): feat = self.backbone(x) feat = torch.flatten(feat, 1) return feat model = ReIDFeatureExtractor(cfg) model.load_state_dict(torch.load("fastreid_weight.pth")["model"], strict=False) model.eval()strict=False这一步很关键,因为裁剪掉分类头之后,权重字典里多出来的键需要被忽略。如果你忘了加这个参数,加载权重会直接报错,而且报错信息还很误导人,会让你误以为是权重文件本身损坏了。
3.2 torch.onnx.export参数与opset选择
导出这一步看似简单,实际上参数选择非常讲究。我开始时用默认的opset版本导出,在ONNX Runtime上跑没问题,但换到TensorRT做FP16推理时就报错说某些算子不支持。后来才意识到,不同推理引擎对不同opset版本的支持度不完全一致。
我的建议是直接使用opset 12以上,并且在做torch.onnx.export时显式指定opset_version:
torch.onnx.export( model, dummy_input, "fastreid_feat.onnx", input_names=["input"], output_names=["feat"], opset_version=12, do_constant_folding=True, )do_constant_folding这个参数也容易忽略。它打开之后,PyTorch会在导出前把一些固定的计算提前算好,ONNX图里就会少很多冗余节点。默认值是True,但如果你在某个环节把自定义算子加进模型,最好检查一下ONNX图里是不是多出了奇怪的节点。
3.3 动态尺寸、Resize和BatchNorm的坑
接下来是动态尺寸问题。DeepSORT内部会把检测框裁剪出来的行人图resize成一个固定尺寸再送入ReID模型,所以我理想中应该让ONNX模型支持任意尺寸输入。在导出时可以通过dynamic_axes参数指定动态维度:
torch.onnx.export( model, dummy_input, "fastreid_feat_dyn.onnx", input_names=["input"], output_names=["feat"], dynamic_axes={"input": {0: "batch", 2: "height", 3: "width"}}, opset_version=12, )这个做法在ONNX Runtime上可以跑通,但换到TensorRT之后我才发现,动态尺寸意味着每次遇到新的输入尺寸都可能触发engine重新构建,推理延迟会忽高忽低,不稳定。后来我放弃了动态尺寸,统一把ReID输入固定为128×256,并在裁剪时保持宽高比、填充到目标尺寸,这样推理引擎加载的engine就是固定的,延迟曲线非常平稳。
BatchNorm是另一个阴坑。PyTorch模型有train和eval两种模式,在train模式下BN层会使用当前batch的统计量做归一化,而eval模式下会使用训练时保存的running_mean和running_var。如果你导出ONNX前忘了调用model.eval(),导出的模型里BN层计算逻辑就是错的,部署后特征向量和训练时的分布完全对不上。
我一开始就是忘了加model.eval(),导出后跑测试集,特征相似度变得非常离谱,排查了半天才发现是BN统计量的问题。这个教训后来成了我检查导出模型的固定动作。
3.4 导出后一致性校验方法
ONNX导出完不能直接拿去用,必须做一致性校验。我的做法是准备一组真实的行人图片,分别用PyTorch模型和ONNX Runtime推理,计算输出特征向量的最大绝对误差和余弦相似度。
import onnxruntime as ort import numpy as np ort_session = ort.InferenceSession("fastreid_feat.onnx", providers=["CPUExecutionProvider"]) def run_onnx(x): return ort_session.run(["feat"], {"input": x.numpy()})[0] def run_torch(x): with torch.no_grad(): return model(x).numpy() x = torch.randn(1, 3, 256, 128) max_diff = np.abs(run_torch(x) - run_onnx(x)).max() cos_sim = np.dot(run_torch(x).flatten(), run_onnx(x).flatten()) / ( np.linalg.norm(run_torch(x)) * np.linalg.norm(run_onnx(x)) )经验上,FP32导出的模型和PyTorch输出之间最大绝对误差不应超过1e-3,余弦相似度应接近1.0。如果误差偏大,优先检查是否有自定义算子没有被正确映射,以及BN层统计量是否被冻结。
4. 嵌入DeepSORT时的特征空间对齐问题
ONNX导出完成只是万里长征第一步。把轻量化后的FastReID模型真正接到DeepSORT里,最隐蔽的问题集中在一个被很多人忽视的环节——特征空间对齐。
4.1 特征维度和归一化方式的差异
DeepSORT原版的ReID模型输出特征维度通常是128维,而FastReID的ResNet50 Backbone输出2048维。这个差异不是简单改一个数字就能解决的,它会影响后面所有依赖特征维度的数据结构。
我当时用的DeepSORT开源实现里,有一个nn_budget机制来存储轨迹的历史特征,特征维度写死在代码的初始化逻辑里。如果直接把2048维的特征塞进去,要么报shape不匹配,要么在计算距离矩阵时内存占用激增。
解决的思路是显式修改特征维度相关的常量,并把外观特征做L2归一化。FastReID训练时一般已经做过归一化处理,但DeepSORT拿到的特征是后续又要算余弦距离的,所以归一化这步不能省。
4.2 距离度量不能想当然沿用默认值
DeepSORT的匹配逻辑里有两个关键距离:马氏距离(通过卡尔曼滤波的协方差计算)和外观特征距离。原版默认的外观距离度量是欧氏距离,但对于FastReID这种用余弦相似度训练出来的特征,直接套欧氏距离会让距离分布变得很奇怪。
我在实测中发现,同一行人的两个不同检测框,FastReID特征之间的欧氏距离也许在0.7左右,而不同行人的欧氏距离也可能在0.9左右,区分度很差。但如果改用余弦距离1 - cos_sim,同一个人往往在0.1到0.2之间,不同人通常在0.5以上,区分度一目了然。
这个经验后来被我用一句话总结:训练时的度量方式必须和部署时的度量方式保持一致,不然模型学到的特征空间就白学了。
4.3 图像预处理必须跟训练时保持一致
这个坑是我踩得最冤枉的一个。FastReID在训练时对输入图片做了固定的Resize和归一化,mean和std分别接近[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]。而DeepSORT原版实现里,图片被读取后只是简单除以255,再缩放到[0,1]区间。
我一开始没改DeepSORT的预处理逻辑,直接把FastReID模型接进去,结果跟踪效果比原来的小模型还差。后来对比了预处理的差异,改成和FastReID训练时一致的Resize、归一化顺序后,同一个模型的效果立刻提升了一大截。
正确的预处理流程大致是:
from torchvision import transforms transform = transforms.Compose([ transforms.Resize((256, 128)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])注意Resize时有两种模式:直接拉伸和保持宽高比填充。我的经验是保持宽高比再填充灰色边,比直接拉伸的效果要好,特别是当检测框本身比较扁或者比较高的时候,拉伸会严重破坏行人的身体比例。
4.4 特征队列与更新策略的连带修改
DeepSORT的轨迹中有一个特征队列,会保留最近N帧的ReID特征,用于和当前帧检测框特征做距离计算。N在开源实现里默认是100,但这里有个隐藏问题:FastReID特征维度是2048维,如果每个轨迹都要保存100帧的特征,内存占用会非常夸张。
假设画面里有20个活跃轨迹,每个轨迹100帧,每帧特征占用2048个float32也就是8KB,总的特征存储大约是100×20×8KB等于16MB。看起来不算什么,但如果再考虑到距离矩阵的计算,使用2048维特征做余弦相似度,计算量会比128维特征高出16倍。
我的处理方式是把nn_budget从100下调到60,并且只在轨迹状态为确认(confirmed)时更新特征队列。这样做之后,内存占用降下来了,级联匹配的耗时也稳定在2毫秒以内。实测下调特征队列长度对ID Switch的影响不大,因为FastReID特征本身判别力已经很强了。
5. 级联匹配参数和追踪逻辑的适配
模型嵌入之后,并不意味着DeepSORT就能自动发挥FastReID的全部能力。级联匹配是整个跟踪器的核心决策环节,它的阈值和策略直接决定了跟踪是否稳定。
5.1 检测框裁剪区域对ReID的影响
DeepSORT的特征提取输入来自目标检测器输出的边界框。但检测框往往贴得很紧,行人的头部、肩膀、脚部都可能被截断,还有一定的定位抖动。直接拿原始检测框做ReID,特征质量的波动会很大。
我的做法是在检测框的基础上向外扩展10%到15%的像素,让ReID模型能看到更多上下文信息。尤其是在行人被部分遮挡时,稍微外扩一点往往能让模型捕捉到更多可以用于匹配的线索。扩展比例需要控制在合理范围内,太大容易把背景行人混进来,反而降低特征区分度。
这个细节在调参时非常有效。同样的模型权重,只靠外扩检测框这一个操作,就把ID Switch降低了差不多8%。
5.2 级联匹配阈值的调整思路
DeepSORT里有两个关键参数和外观特征有关:max_cosine_distance和max_iou_distance。前者用于判断两个特征是否属于同一个人,后者用于判断检测框和预测位置的交并比是否足够接近。
原版实现的max_cosine_distance通常取0.2,这是基于小模型的弱特征设定的,因为小模型特征区分度差,阈值设高了容易把不同人匹配到一起。但FastReID的特征判别力更强,同一个人的特征距离通常能压到0.1以内,不同人的特征距离往往在0.5以上,所以可以把阈值放宽到0.3甚至0.35。
阈值调整得太激进也不行。我试过直接把阈值放到0.6,因为FastReID的特征实在太有判别力了,反而出现了大量误匹配,ID Switch暴增。最终经验值是0.3到0.35之间比较合适。
5.3 遮挡、误检和特征过期问题
还有一个容易被忽略的问题是特征过期。DeepSORT在目标被遮挡后,卡尔曼滤波会继续预测轨迹位置,但此时不再更新外观特征队列。如果遮挡时间太长,轨迹里保存的特征还是几秒前的样子,目标外观可能已经发生变化,比如转了个身、换了个角度,等目标重新出现时,级联匹配可能失败。
FastReID的特征鲁棒性虽然比小模型好,但并不是无敌的。我在嵌入后注意到,当目标遮挡超过3秒再出现时,匹配度明显下降。解决思路是维护一个“最近匹配时间”标志,遮挡超过一定帧数后,适当降低外观距离的权重,把更多信任交给运动信息。
我最后实现的是一个简单的线性衰减策略:目标正常追踪时,外观特征权重是1.0;遮挡帧数增多后,权重逐步降到0.6,同时把IOU匹配的权重提高。这个策略虽然没有论文里那么花哨,但在实际视频上效果立竿见影,ID Switch又降了不少。
6. 轻量化后的实测数据与效果对比
所有踩坑经验落实到代码里之后,最后一步就是看数据说话。这里分享一组我在自己项目里的实测对比。
6.1 模型体积和推理耗时对比
先看轻量化前后的模型体积和特征提取耗时,测试硬件是低功耗GPU,输入尺寸统一为128×256。
| 项目 | 原始PyTorch权重 | ONNX FP32 | TensorRT FP16 | TensorRT INT8 |
|---|---|---|---|---|
| 文件体积 | 约105MB | 约101MB | 约51MB | 约28MB |
| 单张特征提取耗时 | 约21ms | 约17ms | 约8ms | 约4ms |
| 显存占用 | 约800MB | 约700MB | 约400MB | 约250MB |
从FP32到INT8,特征提取速度提升了5倍左右,模型体积压缩到将近四分之一。显存占用从800MB降到250MB,这为同时运行检测模型留下了充足空间。
6.2 跟踪指标实测
再来看跟踪指标。我在一段5分钟、包含多次遮挡的监控视频上做了对比,使用同一检测器、同一DeepSORT逻辑,只切换不同格式的FastReID特征。
| 配置 | IDF1 | MOTA | ID Switch |
|---|---|---|---|
| DeepSORT原版小模型 | 58.2% | 51.3% | 42 |
| FastReID PyTorch | 66.9% | 55.1% | 27 |
| FastReID ONNX FP32 | 67.1% | 55.0% | 27 |
| FastReID TensorRT INT8 | 65.8% | 53.9% | 31 |
INT8和FP32相比,IDF1掉了1.3个百分点,ID Switch多了4次,这个损失对于实际监控场景来说完全可以接受,换来的却是将近5倍的推理速度提升。值得说明的是,这个结果建立在前面提到的预处理对齐、距离度量调整和外扩检测框这几个操作都做完的基础上。如果跳过那些步骤,直接做INT8量化后再嵌入,IDF1掉得会更多。
6.3 后续可继续优化的方向
关于轻量化,我踩完这一圈坑之后,其实还有几个可以继续深挖的方向。第一个是量化感知训练,在训练阶段就让模型适应INT8的精度损失,通常能再追回一两个百分点的准确率;第二个是把ReID模型和检测模型合并到同一个推理引擎里,共享计算资源,减少数据在内存和显存之间的拷贝次数;第三个是结合跟踪置信度动态调节ReID输入频率,在画面中目标数量较少时降低提取频率,进一步拉低整体延迟。
不过这些都需要更多的时间和算力投入,属于进阶优化,不是每个项目都有必要做。对我这个项目来说,当前这套“ONNX导出加TensorRT INT8量化”的配置已经满足了实时跟踪的要求。
最后分享一个个人体会:在ReID加Tracking的项目里,模型精度只决定效果的上限,真正决定成败的是工程链路上那些看似琐碎的适配细节——预处理、度量方式、阈值、特征队列更新策略。轻量化本身并不难,难的是轻量化之后整个跟踪系统还能不能保持稳定。希望这份踩坑集锦能帮你把这段路走得快一些。