简介:行人重识别(Person Re-ID)在公共安全、智慧城市、视频检索等场景中应用广泛,但跨视角、光照变化、遮挡等问题使其颇具挑战。这一代码包面向对此技术感兴趣的开发者与计算机视觉学习者,提供一份轻量可运行的参考示例。压缩包共3个文件、大小仅6KB,包含HTML演示页面、代码运行配置及工程辅助文件,便于快速浏览效果并理解基础流程。目前已有101人学习使用。代码内容可与ResNet、PCB、MGN、Transformer等主流网络结构的相关知识配合阅读,帮助读者将行人重识别的基本方法、度量学习与常见损失函数落实到具体示例中;同时也可作为入门者搭建验证环境、进行后续功能扩展的起点。
1. 行人重识别技术:跨摄像头找人为什么比你想的更依赖细节
行人重识别(ReID)解决的是一个很具体的检索问题:同一个行人出现在不同的摄像头画面里,系统要在一大堆时间、角度、光线都不一致的全身图像里,把属于同一个人的图全部捞出来。它和人脸识别的最大差别在于,ReID 通常在面部不可用的场景下工作——低分辨率监控、背对镜头、戴着口罩,靠的是“认全身”而不是“认脸”。这个领域有个反直觉的结论:很多开发者在训练集上能把准确率刷到 99%,一换到实际场景就崩盘,rank-1 直接掉到 60% 出头。原因往往出在特征迁移失败、损失函数设置不当和数据划分作弊这三处。这份代码包是完整可复现的 ReID 工程,包含数据准备、模型训练、评估脚本和调参配置,适合想快速跑通并理解原理的开发者。
2. 原理先行:ReID 为什么靠特征嵌入+度量学习,而不是简单分类
2.1 从“分出是谁”到“算出有多像”:嵌入空间的设计逻辑
很多人上手 ReID 时,第一反应是“这不就是一个多分类问题吗?训练的时候给每个人一个类别,学习完成后就能识别”。确实,训练阶段我们会借用分类的思想,给模型接一个分类头,用交叉熵去约束特征。但测试阶段出现的人 ID 是训练集里没有出现过的,如果模型最终只输出类别概率,你就没法回答“这张图和那一堆图哪个更像同一个人”。
所以 ReID 网络真正的输出是一个高维特征向量,业内叫 embedding 或描述子(descriptor)。整个目标是把图像映射到一个嵌入空间里:同一个人的向量距离近,不同人的向量距离远。这里的“距离”可以选欧氏距离或余弦距离,代码包里默认用余弦距离,因为测试前会对特征做 L2 归一化,让所有向量落在单位球面上,这时余弦距离和归一化后的欧氏距离在排序意义下等价,但数值稳定性更好。
主干网络的选择是第一个关键决策。ResNet50 能成为默认选项,是因为它的预训练权重稳定、感受野足够覆盖全身信息,而且 2048 维特征对 ReID 来说是一个不冗余也不会丢信息的维度。ResNet101 感受野更大,但训练时间和显存成本高一截,收益在常规数据集上并不明显。轻量网络适合端侧部署,但收敛速度和迁移能力都要弱一些。代码包的实际做法是把 ResNet 最后的平均池化和全连接层全部去掉,只保留卷积主干,再自己接一个池化层、一个 BatchNorm1d 和一个分类头。
# 以 ResNet50 为骨干构建 ReID 模型,输出 2048 维归一化描述子 class ReidNet(nn.Module): def __init__(self, num_train_ids=751, feat_dim=2048): super().__init__() self.backbone = self._build_backbone("resnet50") # 去掉最后的 FC 层 self.pool = nn.AdaptiveAvgPool2d((1, 1)) self.bn = nn.BatchNorm1d(feat_dim) # 对齐特征尺度 self.classifier = nn.Linear(feat_dim, num_train_ids) # 仅训练时使用 def forward(self, x, training=True): feat = self.backbone(x) # 卷积特征图 [B, 2048, 16, 8] feat = self.pool(feat).flatten(1) # 全局平均池化后压平 if not training: # 测试分支:过 BN 后做 L2 归一化,检索时用余弦距离 return F.normalize(self.bn(feat)) # 训练分支:分类头输出 logits,同时返回原始特征供三元组损失使用 return self.classifier(feat), feat注意这个模型把训练和测试分支分开了:训练时输出两个值,一个是给交叉熵用的 logits,一个是给三元组损失用的原始特征;测试时只走归一化分支。很多初学者直接把分类头输出的 feature 拿去检索,效果奇差,原因就在这里——分类层的特征被类别边界拉住了,但它不会主动把同类特征压到很小的距离范围内。
还有一个容易被忽略的点是 BatchNorm1d 的行为差异。训练阶段这个 BN 层用当前 batch 的均值和方差做归一化;测试阶段用的是训练过程中累积的全局滑动均值。所以评估前一定要调用model.eval(),否则 BN 统计值不一致,特征分布直接跑偏。代码包里所有评估脚本都强制走测试分支,就是为了避免这个暗坑。
2.2 损失函数的三层配合:分类损失、三元组损失与难样本挖掘
嵌入空间能不能达到“同类近、异类远”的目标,关键看损失函数怎么写。只用交叉熵分类损失,模型确实能学会区分训练集里的每个人,但特征没有受到距离约束,检索时不同人的边界很容易糊在一起;只用三元组损失,训练前期收敛慢,而且如果某个 batch 里恰好没有难样本,loss 会长时间卡在 0 附近,给你一种“模型已经收敛”的错觉。
代码包里的做法是让两个损失并行:ID Loss 提供稳定的分类信息,Triplet Loss 在嵌入空间做细粒度调整。Triplet Loss 的主体思想是锚点、正样本、负样本三元组:让锚点与正样本的距离至少比锚点与负样本的距离小一个 margin。但随机采三元组效率太低,工程上普遍用难样本挖掘(hard example mining):对每个锚点,选同类里距离最远的正样本作为“最难正样本”,选异类里距离最近的负样本作为“最难负样本”,这样每个样本都会产生有效梯度。
# 难样本三元组损失:每个锚点取最难正样本和最难负样本 def batch_hard_triplet_loss(feats, labels, margin=0.3): n = feats.size(0) # 输入为 L2 归一化后的特征,用余弦距离构造距离矩阵 dist = 2 - 2 * torch.mm(feats, feats.t()) same = labels.unsqueeze(0) == labels.unsqueeze(1) same = same.masked_fill(torch.eye(n, dtype=torch.bool), False) # 排除锚点自身 # 最难正样本:同类中距离最大的那个值 pos = dist.masked_fill(~same, -1e9) hard_pos = pos.max(dim=1).values # 最难负样本:异类中距离最小的那个值 neg = dist.masked_fill(same, 1e9) hard_neg = neg.min(dim=1).values loss = F.relu(hard_pos - hard_neg + margin) return loss.mean()这段代码有四个可以调的细节。第一,masked_fill 里用于填充的值要足够极端,不然 max/min 操作会被掩盖;第二,必须排除对角线上的自身距离,否则最难负样本恒等于 0;第三,当某个 ID 在一批图里只有一张时,这类样本要预先剔除,否则 pos 全被掩掉,这一行的梯度就是 0;第四,margin 越大越强调类间间隔,但过大容易训练不稳,常规取值在 0.3 到 0.5 之间。
两组损失相加时,一般把 ID Loss 的权重设为 1.0,Triplet Loss 的权重保持 1.0 即可,不推荐把 Triplet Loss 权重调得过高。我试过把三元组权重调到 2.0 想加速收敛,结果是特征分布被拉得过于紧凑,验证集的泛化能力反而下降。嵌入空间不是越“挤”越好。
3. 代码包复现:从数据整理到训练评估的完整流程
3.1 目录结构与数据准备:训练集、查询集与底库怎么摆
拿到代码包后第一步不是跑训练,而是先把数据摆成脚本认识的格式。绝大多数 ReID 工程采用一种通用目录约定:train 目录里每个子文件夹代表一个人,文件夹名就是 ID;query 是需要检索的样本集;gallery 是被检索的底库。这个约定清晰直观,很多公开数据集和后续的后续模型都沿用同一套结构。
# 一份标准 ReID 数据集的目录结构 dataset/ ├── train/ │ ├── 0001/ │ │ ├── 0001_01_01.jpg # 第一个行人的第一张图 │ │ └── 0001_01_02.jpg │ ├── 0002/ │ └── ... # ID 从 0001 顺序递增 ├── query/ │ ├── 0001/ │ │ └── 0001_02_01.jpg # 同一行人、另一个摄像头下的样本 ├── gallery/ │ ├── 0001/ │ │ ├── 0001_03_01.jpg │ └── ...数据集图片文件名的含义建议在解压后先看一眼,很多数据集把“摄像头编号”和“帧序号”编码在文件名里。这些信息决定了你能否正确划分 query 和 gallery,后续在第 5 章的避坑部分会展开讲。
在开始训练前,代码包里通常会有一个数据预检脚本,用来统计训练集有多少个 ID、每个 ID 平均多少张图、图片尺寸分布是否异常。如果某个 ID 只有一两张图,这个 ID 在训练时很难被 Triplet Loss 约束,容易变成噪声样本。常见做法是直接把样本数少于 3 的 ID 过滤掉,或者在采样时对它们做重复采样。
3.2 训练脚本:主干参数、断点续训与权重保存
数据就绪之后,训练入口一般是一个train.py。训练循环的核心就是把两个损失一起回传,同时按周期调整学习率。代码包里提供的训练逻辑是标准的 ReID 训练流程:每个 epoch 先用难样本挖掘的采样器生成 batch,然后计算分类损失和三元组损失,合并后回传梯度。
# 训练主循环的核心片段:两个损失共同驱动梯度更新 model = ReidNet(num_train_ids=len(train_pids)).cuda() optimizer = torch.optim.Adam(model.parameters(), lr=3.5e-4, weight_decay=5e-4) for epoch in range(total_epochs): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() logits, feats = model(images, training=True) # 分类损失与度量损失的梯度会叠加到同一张计算图上 loss_cls = nn.CrossEntropyLoss()(logits, labels) loss_tri = batch_hard_triplet_loss(F.normalize(feats, dim=1), labels, margin=0.3) loss = loss_cls + loss_tri optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) if (epoch + 1) % save_step == 0: # 只保存特征提取部分的权重,评估和部署时不依赖分类头 torch.save(model.state_dict(), f"checkpoint_epoch{epoch + 1}.pth")这里有一个代码包设计上的细节值得注意:模型保存的是完整 state_dict,包含分类层和 BN 层的参数。但实际部署时用不到分类头,所以评估脚本里加载模型时会指定一个strict=False参数,或者单独构造一个不含分类头的模型再加载权重。如果你的部署代码直接拿训练权重加载一个全新模型,大概率报“键不匹配”错误,这是非常常见的翻车点。
weight_decay=5e-4是常规值,不宜设得过大,否则 BN 层的均值滑动会被抑制。total_epochs一般取 60 到 120 之间的值,太短模型还没收敛,太长反而对性能提升无益。断点续训的代码包会在启动时检测checkpoint/目录下是否有历史权重,有就直接model.load_state_dict(torch.load(ckpt_path)),并把 epoch 计数恢复到对应位置。
3.3 评估脚本:rank-1、mAP 是怎么算出来的
训练结束后,评估脚本会做两件事:一是把每个 query 图像分别和 gallery 里的所有图像计算距离,得到一个排序列表;二是用排序列表计算两个核心指标——CMC 曲线和 mAP。CMC 曲线的 rank-1 代表“排名第一就命中的概率”,它衡量的是系统能不能第一时间把目标找对;mAP 则关注“整个排序列表里目标都被排得多靠前”,它对漏检更敏感。
# 评估阶段的核心逻辑:提取特征、生成距离矩阵、计算指标 def evaluate(model, q_loader, g_loader): model.eval() q = extract_features(model, q_loader) # 归一化后的 [Nq, dim] 矩阵 g = extract_features(model, g_loader) # 归一化后的 [Ng, dim] 矩阵 # 余弦距离 = 1 - 向量内积(特征已归一化) dist = 1 - torch.mm(q, g.t()) # 对每个 query,按距离从小到大给整库排序 _, sort_idx = dist.topk(g.size(0), dim=1, largest=False) # 代码包内部用 q_labels 和 g_labels 计算 rank-k 与 AP cmc, mAP = compute_metrics(sort_idx, q_labels, g_labels) return cmc, mAP提取特征时最容易犯的错是忘记归一化。代码包里的extract_features统一走model(x, training=False),所以返回的向量已经是归一化后的。如果你自己写推理脚本,一定要确定模型走的是测试分支。
mAP 的计算逻辑可以用一句话概括:对每个 query,先找到它所有真实匹配图片在排序列表中的位置,算出每个位置的准确率和召回率,再画出 PR 曲线求面积,最后对全部 query 取平均。这个指标比 rank-1 更能反映检索系统的整体质量。实际评测时,如果 rank-1 和 mAP 都很高,说明模型的排序能力比较均衡;如果 rank-1 高、mAP 明显偏低,说明模型只是能把第一个目标找对,后面的匹配位置却很分散,这种情况通常要回看损失函数权重和采样器设计。
4. 参数调优:把 baseline 模型往上拉的 7 个关键开关
4.1 优化器与学习率:warmup 与余弦退火的正确组合
ReID 训练里学习率是最容易出问题的超参数。很多人直接套用分类任务的 0.1 学习率配 SGD,结果 loss 直接冲上 10,后面一路震荡下不来。ReID 模型的主干通常加载的是 ImageNet 预训练权重,全局池化和 BN 层是随机初始化的,一开始就用大学习率,BN 的统计量会被打得七零八落。
代码包默认使用 warmup 加余弦退火的组合策略:前 10 个 epoch 让学习率线性地从很小值升到目标值,给 BN 层一个“热身”过程;之后按余弦曲线平滑衰减到接近 0。这个策略不需要人工干预就能在训练后期稳定收敛。
# warmup + 余弦退火的组合学习率策略 def adjust_lr(optimizer, epoch, warmup=10, total=60, base_lr=3.5e-4): if epoch < warmup: # 预热区:学习率从 base_lr 的 1/10 线性升到 base_lr lr = base_lr * (0.1 + 0.9 * epoch / warmup) else: # 余弦区:从 base_lr 平滑降到 1e-6 progress = (epoch - warmup) / (total - warmup) lr = base_lr * 0.5 * (1 + math.cos(math.pi * progress)) for param_group in optimizer.param_groups: param_group["lr"] = lr这里有两个参数值得根据显存调整。base_lr在 batch size 为 64 时设为 3.5e-4 是经验值;如果你把 batch size 翻倍到 128,学习率可以按线性缩放规则调整到 7e-4,但不能翻太多倍,否则前期的 warmup 阶段就形同虚设。warmup的时长与数据集规模有关,ID 数量多时预热要更长,我一般设 10 到 15 个 epoch。
还有一个容易忽略的点:优化器选 Adam 还是 SGD。代码包里默认用 Adam,因为 ReID 的损失函数往往有两个分量,Adam 对梯度尺度不敏感,训练更稳定。如果你追求更高的收敛上限,可以尝试 SGD + momentum=0.9,但要把 base_lr 降到 1e-2 量级,并且把 warmup 拉长,否则前期不稳定。两种方案我都跑过,结论是:短平快用 Adam,时间充裕、想要极限精度可以试 SGD。
4.2 Batch Size 与采样方式:P×K 策略为什么是 ReID 的默认选项
普通图像分类的 DataLoader 随机采样即可,但 ReID 不行。Triplet Loss 需要一个 batch 内同时存在多个 ID 和多张同 ID 图像,否则“最难正样本”和“最难负样本”都无法计算。如果完全随机采 64 张图,大概率出现几十个不同的人,每个人只有一两张图,三元组损失就没法有效计算。
解决方案是 P×K 采样:每个 batch 固定选 P 个行人,每个行人选 K 张图,batch size 恒等于 P×K。典型配置是 P=16、K=4,batch size 64。这个设计保证每个 batch 里至少有 16 个 ID,每个 ID 有 4 张图,Triplet Loss 有足够的正负样本组合。
# P×K 采样器:每个 batch 内包含 P 个行人的 K 张图 class PKSampler: def __init__(self, pid_to_paths, p=16, k=4): self.pid_to_paths = pid_to_paths self.pids = list(pid_to_paths.keys()) self.p = p self.k = k def produce_one_batch(self): selected = random.sample(self.pids, self.p) # 无放回抽 P 个 ID batch = [] for pid in selected: pool = self.pid_to_paths[pid] # 某 ID 图少于 k 张时重复采样补齐,避免该 ID 在 batch 里缺样本 batch.extend(random.choices(pool, k=self.k) if len(pool) < self.k else random.sample(pool, self.k)) return batchP 和 K 的配比直接影响训练效果。P 太小,比如 P=8、K=8,每个 batch 里只有 8 个 ID,Triplet Loss 的负样本多样性不足,模型容易被少量难负样本带偏;P 太大,比如 P=32、K=2,每个 ID 只有 2 张图,正样本对的选择受限。工程上 P=16、K=4 是一个平衡点,在大多数数据集上都能得到稳定的收敛曲线。如果你的显存足够支撑 batch 128,可以尝试 P=16、K=8,正样本数量翻倍对检索精度有一定提升。
注意采样器里的random.choices是带放回的,会产生重复图。图重复本身对训练影响不大,但如果你开启了数据增强,同一张图会生成两个不同增强版本,相当于变相扩样,效果还可以。不过不要在验证阶段使用采样器,验证必须用普通顺序遍历,保证每个样本只被评估一次。
4.3 数据增强与正则化:随机擦除、水平翻转与标签平滑
ReID 模型有过拟合风险,因为训练集的 ID 数量有限,但检索场景的多样性远超训练集。代码包里默认开启了几种数据增强,按重要性排序:随机擦除、水平翻转、随机裁剪和色彩抖动。
随机擦除(Random Erasing)在 ReID 里效果出奇好,因为实际监控场景经常出现遮挡,贴着这个先验做增强,能迫使模型不依赖某一个局部区域。它的原理很简单:训练时随机选图像里一个矩形区域,用随机像素覆盖,让模型学会用剩余区域识别行人。
# 随机擦除增强:用随机尺寸矩形块遮挡图像,迫使模型学习全局特征 class RandomErasing: def __init__(self, prob=0.5, sl=0.02, sh=0.4, mean=(0.4914, 0.4822, 0.4465)): self.prob = prob self.mean = mean self.sl, self.sh = sl, sh # 擦除区域面积占全图比例的下限与上限 def __call__(self, img): if random.random() > self.prob: return img h, w = img.shape[1:] area = h * w for _ in range(100): target = random.uniform(self.sl, self.sh) * area ratio = random.uniform(0.3, 1 / 0.3) ew = int(round((target * ratio) ** 0.5)) eh = int(round((target / ratio) ** 0.5)) if ew < w and eh < h: x = random.randint(0, w - ew) y = random.randint(0, h - eh) img[:, y:y + eh, x:x + ew] = torch.tensor(self.mean).view(3, 1, 1) break return img参数sl和sh控制擦除面积的范围,0.02 到 0.4 意味着最多擦掉全图 40% 的区域。擦除比例过大,模型会学到“被遮挡的人只能看残肢”,反而影响正常图像的识别;比例过小则形同虚设。我建议保持在 0.1 到 0.4 之间,概率设 0.5。
标签平滑也是代码包里的一个隐藏开关。ReID 分类头的类别数往往有几百上千,但每个类别样本很少,模型很容易对训练集过度自信,表现为 logits 输出接近 0 或 1。把交叉熵的标签从 one-hot 改成平滑值(例如 0.1 的概率分配给其他类),能明显缓解过拟合。注意标签平滑系数不宜超过 0.2,否则分类损失提供的监督信号会被稀释,rank-1 不升反降。
5. 避坑排查:训练与部署中容易翻车的 5 个具体场景
5.1 数据划分作弊:query 与 gallery 出现了重叠帧
现象:训练完模型,评估 rank-1 直接到 98%,换到真实场景却奇差无比。进一步检查发现 query 和 gallery 里包含大量来自同一视频段的相邻帧。
原因:ReID 数据集的目录结构虽然按 ID 组织,但很多公开数据集的原始文件是按“摄像头 ID + 帧序号”命名的。如果划分脚本没有按摄像头交叉分离,而是简单地把每个 ID 的图像前一半放 query、后一半放 gallery,那么 query 中的某一帧和 gallery 里相邻几帧几乎就是同一张图,检索时等于在“找自己”。
解决:划分数据前先用脚本统计每个 ID 的摄像头编号分布。同一行人的不同摄像头图像应尽量不重叠放入 query 和 gallery;同一摄像头同一时间窗口内的帧序列,要么全部放 query 要么全部放 gallery。划分完成后随机抽样 20 对 query-gallery 图像做人眼检查,如果发现 5 对以上明显是同帧内容,这份划分就不能用。代码包里的数据预检脚本专门做了一件事:统计训练集和测试集之间重叠图片路径,发现重复直接报错退出。
5.2 Loss 震荡不收敛:输入没做归一化,BN 层像在坐过山车
现象:训练第一轮 loss 就冲上 10 以上,后面几十个 epoch 一直在震荡,验证集 rank-1 纹丝不动。
原因:最常出现的两个原因,一是数据加载时没有对图像做标准化(减均值、除标准差),导致网络输入的像素分布和预训练权重期望的分布不匹配,BN 层的滑动统计量反复横跳;二是学习率没有 warmup,随机初始化的 BN 层和分类头在第一个 epoch 就吃了大范围的梯度更新。
解决:检查数据预处理流水线,确认transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])确实被应用了,且顺序在 ToTensor 之后。代码包的配置里把“是否启用 Normalize”单独设成了一个开关,默认开启。如果你自己写 dataloader,很容易漏掉这一步。另外把学习率从 3.5e-4 降低到 1e-4 试跑 10 个 epoch,如果 loss 能稳步降到 1 以下,说明原学习率偏高或 warmup 太短。
5.3 多卡训练精度反而下降:BN 统计量没同步
现象:单卡训练 60 个 epoch 后 rank-1 是 90,换成 8 卡训练相同数据,rank-1 反而跌了 2 到 3 个点。
原因:PyTorch 默认的 DataParallel 和 DistributeDataParallel 在训练时,每张卡独立计算自己的 BN 均值和方差。ReID 使用的 P×K 采样会让每张卡上的数据分布不完全一致,如果某个 ID 集中落在卡 A 而没落到卡 B,卡 A 的 BN 统计就会偏。推理时模型用滑动均值做归一化,这个均值是各卡局部统计的混合结果,方差被放大,特征分布就不像单卡那样稳定。
解决:如果代码包支持--syncbn,多卡训练时务必开启,让每张卡的 BN 统计量做跨卡同步。没有这个选项的话,可以尝试把 batch size 加倍、P×K 采样改为全局采样器而不是每卡本地采样。我的习惯是 ReID 任务直接用单卡训练,因为输入图像普遍是 256×128 这种小尺寸,一张 24G 显存卡足够跑 64 甚至 128 的 batch。多卡带来的加速在 epoch 数不多时并不明显,反而引入更多变量。
5.4 推理输出特征没归一化:距离计算和训练时对不上
现象:训练时用余弦距离,推理时的排序结果一团糟;打印特征检查发现每个向量的模长并不等于 1,而是各种长度都有。
原因:模型 forward 的训练分支和测试分支对特征的处理不同。训练分支返回原始特征(供三元组损失在内部归一化计算),测试分支才做 BN + L2 归一化。如果你在推理脚本里直接调用model.forward(x)并使用了训练分支输出,距离就不是余弦距离,而是与向量模长混合的欧氏距离。模长越大或越小,排序结果越离谱。
解决:给推理脚本加一道校验,打印特征矩阵每行的 L2 范数,确认全部落在 0.999 到 1.001 范围内。如果不在这个范围,就说明你调用的是训练分支,或者model.eval()没有在 BN 层切换统计模式。代码包提供一个extract_embedding函数,内部强制执行model.eval()并走测试分支,外部调它就不会出问题。另外,把特征保存成 npy 文件时也要保留归一化前的版本,方便排查。
5.5 迁移到新场景崩盘:训练集和部署场景分布差异太大
现象:公开数据集上 rank-1 在 90 以上,部署到商超或地铁摄像机画面后,rank-1 掉到 60 多。这个现象在业内被称为“域差距”。
原因:公开数据集里的行人图像大多来自校园或开阔街道,摄像头视角偏高、行人占画面比例大;实际部署场景的摄像头高度、俯仰角、分辨率和光照都可能不同。主干网络在公开数据集上学习到的特征,会在颜色分布、姿态角度和背景纹理上有明显的“数据集偏好”。
解决:至少要收集目标场景的一小批有标注数据做微调,不需要很多,每个 ID 8 到 10 张图、一共几百个 ID 就能显著改善。微调时把学习率降到 1e-5 量级,否则会把预训练特征冲刷掉。如果完全没有标注数据,可以考虑无监督域适应的思路:用预训练模型在目标场景上提取特征,把置信度高的结果作为伪标签微调模型,同时加强色彩抖动和随机擦除,提升模型对光照变化的鲁棒性。我见过一个工程实践:只加了一组 Camera 风格的色彩扰动增强,新场景 rank-1 就从 63 拉到 75,效果比换更大模型明显得多。
6. 进阶实战:从单帧 ReID 到视频序列检索的落地技巧
单帧 ReID 把每个行人归结为一张图像的特征向量,但在真实监控场景里,一个行人会出现在连续多帧画面中。单帧里可能存在运动模糊、遮挡或姿态异常,检索质量不稳定。一个非常有效的改造是把同一段视频的多个帧特征做一个时间融合,用视频级的描述子去做检索,这个方向叫视频行人重识别。
时间融合最简单且稳定的做法是平均池化:取出同一 ID 在连续帧中的特征,做逐维平均后进行 L2 归一化。平均池化的好处是能平滑掉单帧的噪声,但也会把一些信息量高的帧拉低权重。更好的方案是加权融合:先用一个轻量网络对每帧特征打分,得分高的帧权重更大。代码包提供了一个基于平均池化的基线实现,用起来就够。
# 视频级描述子:把同一段视频连续帧的特征做时间平均池化 def video_feature(model, frames): # frames: [T, 3, H, W],T 为帧数 with torch.no_grad(): # 每帧独立走测试分支,输出归一化描述子 frame_feats = [ model(frame.unsqueeze(0).cuda(), training=False) for frame in frames ] feats = torch.cat(frame_feats, dim=0) # [T, dim] video_feat = feats.mean(dim=0) # 时间平均池化 return F.normalize(video_feat, dim=0) # 再次归一化,确保模长为 1将这段视频级特征用于检索时,还有两个落地的实用技巧。第一个技巧是提前构建底库特征并缓存在本地。如果在部署环境里每次检索都重新跑一遍 gallery 的几百上千张图,延迟和计算开销都不可接受。做法是把 gallery 特征一次性提取后存成 npy 文件,检索时只加载并计算与 query 的距离。第二个技巧是检索库的排序可以用“先粗排后精排”的策略:先用降维后的特征或更小的特征向量快速筛出 Top 100,再对 Top 100 计算完整特征距离,整体延迟能降一半多。
从单帧到多帧,我的一个血泪教训是:别在一开始就在视频级模型上做复杂的时序网络,先跑通平均池化基线,确认指标的提升来自多帧信息而不是模型结构复杂度。我见过有人花了三周把时序注意力模块接到主干上,最后发现平均池化就已经把 rank-1 从 88 推到 91,注意力模块只多贡献了 0.2 个点。从那以后,我每次做完单帧 ReID 都会强制走一遍“帧级特征 + 平均池化”的对照实验,再决定要不要上更重的时序模块。过程难免踩坑,但把每一步的坑记下来,再复现时就能少绕很多弯。希望帮到你。
本文还有配套的精品资源,点击获取