行人重识别实战:ResNet50与三元组损失在PyTorch中的源码拆解
2026/9/12 10:31:34 网站建设 项目流程

简介:一份重庆理工大学本科毕业设计《基于深度学习的行人重识别》完整项目,提供源代码与文档说明,面向计算机相关专业的在校学生、老师及企业员工,尤其适合作为毕设参考、课程设计或入门进阶的学习样例。项目借鉴了浙江大学罗浩老师的开源课程,所有代码均测试运行成功,答辩评审平均分达96分,可靠性和完成度较高。资源共12个文件,包含11个Python脚本和1个Markdown说明文档(README),脚本覆盖数据管理(data_manager)、数据集加载(dataset_loader)、模型构建(ResNet)、损失函数、优化器、距离度量、评估指标与训练过程等完整流程,包体仅17KB,结构精简,方便查阅和修改。目前已有403人浏览学习。读者可借此理解行人重识别从数据预处理、特征提取到相似度计算与性能评估的全过程,并能基于现有模块快速改造,用于自己的项目或实验。

1. 从 Market1501 到 ResNet50:行人重识别毕设项目拆解

行人重识别(Person Re-ID)看起来只是“在另一个摄像头里找到同一个人”,一旦真正开始训练,会发现最影响结果的往往不是选了哪个分类网络,而是数据是怎么被读进来的、每个 batch 里的行人 ID 是怎么采样的,以及损失函数按哪种 margin 做三元组约束。这套重庆理工大学毕设项目源码正好覆盖了从 data_manager、dataset_loader、transforms 到 ResNet、losses、optimizers、train_class 的完整闭环,代码参考了浙江大学罗浩老师的开源课程,工程上最大的价值在于把学术 baseline 拆成了一个个能独立修改的模块。按数据管理、模型构建、损失与优化、训练评估与应用四步逐层拆解,比直接跑通一个黑盒训练脚本更有意义。适合用 PyTorch 做计算机视觉方向毕设或课程设计的学生,也适合想从源码层面快速掌握一篇 ReID 论文实现细节的从业者。

2. 数据管理与数据增强:ReID 的数据流设计

2.1 data_manager.py 怎么把图像文件名转成标签

几乎所有公开 ReID 数据集都沿用 Market1501 的文件命名约定,例如0002_c1s1_000451_03.jpg0002是行人 ID,c1s1表示来自 camera 1 的 sequence 1,000451是帧序号,03是检测框序号。data_manager.py 的核心任务是把这种文件名解析成pidcamid,再划分出 train、query、gallery 三个子集。常见做法是维护一个 pid 到连续 label 的映射字典,训练集重新编号,query 和 gallery 保持原始 pid 不重编号,这样才能在评估时统一计算指标。

def process_dir(dir_path, relabel=True): pid_container = set() for fname in os.listdir(dir_path): pid = int(fname.split('_')[0]) pid_container.add(pid) pid2label = {pid: label for label, pid in enumerate(pid_container)} data = [] for fname in os.listdir(dir_path): pid = int(fname.split('_')[0]) camid = int(fname.split('_')[1][1]) if relabel: pid = pid2label[pid] data.append((os.path.join(dir_path, fname), pid, camid)) return data

解析逻辑里有个容易被忽略的点:train、query、gallery 必须分开调用process_dir,并且 query 和 gallery 要让relabel=False。很多新手把三份图片放进同一个目录去跑,结果 query 和 train 的 pid 编号相互覆盖,测试时几乎每张 query 都能命中同图的 train 图片,Rank-1 直接虚高到 90 以上。实际操作中,我会额外写一个统计函数,返回每个 pid 的图像数量,训练模块拿到这个数量去初始化分类器维度,也用它发现数据集中个别行人只有一两张图的异常情况。

2.2 transforms.py 中训练与测试的数据增强差异

训练阶段常用 Resize、RandomHorizontalFlip、RandomCrop、RandomErasing 与 Normalize 的叠加。RandomErasing 在 ReID 里比一般分类任务更重要,因为行人遮挡频繁,随机擦除一块矩形区域能迫使模型不只依赖某一块局部外观。这里需要留意 RandomCrop 的 padding 参数:padding 设为 10 时相当于先放大再裁剪,对行人这种比例变化较大的目标有明显增益。测试阶段则只保留 Resize 和 Normalize,任何随机变换都会让同一张 query 在不同次推理时得到不同特征。

增强方法典型参数作用
RandomHorizontalFlipp=0.5缓解视角不对称,行人左右翻转语义不变量强
RandomCroppadding=10模拟检测框偏移,增强行人位置鲁棒性
RandomErasingp=0.5, sl=0.02, sh=0.4模拟遮挡,抑制模型对局部纹理的过拟合
Normalizemean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]对齐 ImageNet 预训练权重输入分布

transforms.py 通常把 train_transform 和 val_transform 分开定义,再在训练循环里根据当前是 train 还是 test 模式选择。有一个常见误用:把 Normalize 放在 RandomErasing 之前或之后结果差异不大,但如果在 val_transform 里也加入 RandomErasing,Rank-1 会掉 3 到 5 个点。这不是模型变差,而是评估时输入分布不一致导致的特征扰动。你可以在答辩时把这个作为数据增强对评价指标影响的实证案例讲出来,比单纯背公式更有说服力。

2.3 dataset_loader.py 的 PK 采样策略

为什么随机采样在 ReID 里容易让三元组损失失效?因为一个 batch 里如果每个人只有一两张图,很难找到正样本对。更稳定的方案是 PK 采样:每个 batch 固定取 P 个行人,每个行人取 K 张图,batch size=P*K。常见配置是 P=16、K=4,也就是 16 个不同身份的人,每人 4 张图,共 64 张。dataset_loader.py 里需要自己实现一个能按 pid 分桶的 sampler。

class PKSampler: def __init__(self, pids, batch_size, num_instances): self.pids = pids self.batch_size = batch_size self.num_instances = num_instances self.num_pids = batch_size // num_instances def __iter__(self): pid_to_indices = {} for idx, pid in enumerate(self.pids): pid_to_indices.setdefault(pid, []).append(idx) while True: batch = [] selected_pids = random.sample(list(pid_to_indices.keys()), self.num_pids) for pid in selected_pids: indices = pid_to_indices[pid] if len(indices) < self.num_instances: indices = indices * (self.num_instances // len(indices) + 1) batch.extend(random.sample(indices, self.num_instances)) yield batch

采样器里做了正样本不足时的重复采样兜底,但不要盲目依赖它。一个 pid 下少于 K 张图时,模型会反复看到同一批图的增强版本,相当于在过拟合单样本。数据清洗阶段我会先按 pid 统计图片数量,把少于 3 张的 id 从训练集剔除,这个操作对最终 mAP 的影响往往比换一个更强的主干网络还要明显。参数设置上,P 和 K 的乘积就是实际 batch size,显存不够时优先降低 P 而不是 K,因为 K 过小会直接破坏三元组损失的采样基础,P 小一点至少还能保证每个身份有足够的正样本。

3. ResNet 骨干与特征表示:ReID 模型怎么搭

3.1 去掉分类头的 ResNet50 到底输出什么

ResNet.py 里最核心的类是带分类分支的 ReID 模型。基础结构是 ImageNet 预训练的 ResNet50,把最后的 global average pooling 之后的 1000 类全连接层替换成三个分支:原始 2048 维特征、BNNeck 后的特征、分类 logits。分类 logits 的维度等于 data_manager 返回的训练集行人数量。注意不要在模型里写死类别数,不然换一个数据集就得改模型定义,答辩演示时也会被质疑模块化设计能力。

import torch.nn as nn import torchvision.models as models class ReIDNet(nn.Module): def __init__(self, num_classes, width=2048): super().__init__() backbone = models.resnet50(pretrained=True) self.features = nn.Sequential(*list(backbone.children())[:-2]) self.gap = nn.AdaptiveAvgPool2d(1) self.bottleneck = nn.BatchNorm1d(width) self.bottleneck.bias.requires_grad_(False) self.fc = nn.Linear(width, num_classes, bias=False) def forward(self, x): f = self.features(x) g = self.gap(f).flatten(1) bn_feat = self.bottleneck(g) logits = self.fc(bn_feat) return logits, bn_feat, g

forward里同时返回bn_featg,是 ReID baseline 中常见的 trick。g是 BN 之前的特征,bn_feat用于三元组损失时做距离计算,logits走交叉熵。如果直接拿g做三元组损失,容易因为特征尺度大导致梯度不稳定;如果全用bn_feat,分类损失的收敛会被 BN 层扰动。两个特征分开用,是罗浩老师开源课程里反复强调的工程细节。bottleneck.bias.requires_grad_(False)是为了避免 BN 层偏置带来的 scale 偏移,本质原因在于后面的fc是无 bias 全连接分类器。

3.2 为什么用 GAP 而不是 GMP:ReID 特征聚合的取舍

ResNet50 最后的空间分辨率是 7x7,常见聚合操作有 Global Average Pooling(GAP)和 Global Max Pooling(GMP)。分类任务里 GMP 往往能保留更锐利的判别性响应,但在行人重识别中,GMP 会过度放大最显著的单点特征,导致同一行人换摄像头后特征漂移。GAP 偏向整体纹理的平均响应,对姿态变化更稳定,所以项目里选择nn.AdaptiveAvgPool2d(1)是合理的。业界也有 PCB(Part-based Convolutional Baseline)把特征图水平切六块、每块单独过分类器的方案,但它要求输入图片高度足够大,而且训练时若行人检测框有遮挡,局部特征会相互干扰。

聚合方式输出维度优缺点
GAP2048抗视角变化强,特征平滑,相机域差异下更稳
GMP2048突出判别性区域,但对遮挡和噪声敏感
PCB 六等分6x2048增加空间粒度,需要更精细的对齐策略

模型代码里没有用 PCB,不代表不能改。我一般会先跑一版 GAP 的全局特征作为基准,确认 mAP 能到 80 以上后,再尝试把最后一层特征图切成两到三块加局部特征,这样对比实验才有说服力,而不是一开始就追求高复杂度结构。还要注意我当时踩过的一个坑:如果用普通AvgPool2d(7)而不是AdaptiveAvgPool2d,一旦输入分辨率从 256x128 改成 224x112,最后一层特征图的尺寸会从 7x7 变成 6x6,池化输出直接报 shape 错误。自适应池化在骨干网络 stride 变化或输入尺度变化时都能兜底,这就是为什么 PyTorch 的 ReID 实现几乎都偏好它。

3.3 预训练权重的冻结策略在显存受限时的用法

显存只有 6G 时 ResNet50 配合 256x128 输入往往可以塞下,但如果 PK 采样把 batch size 提到 64,backbone 的梯度开销就扛不住了。显存紧张时常见做法是冻结 layer1 之前的参数,只训练 layer3、layer4 和新增分支。PyTorch 里可以通过设置requires_grad=False实现。

for name, param in model.features.named_parameters(): if 'layer1' in name or 'layer2' in name: param.requires_grad = False

冻结低层特征后,训练步数需要适当增加,否则高层的特征分布来不及适应目标数据集。一个更彻底的方案是用torchvision.models.resnet50(weights=ResNet50_Weights.DEFAULT)加载权重,然后把layer4的 stride 改成 1 以获得更密的特征图,但这时感受野会变小,对行人这种中等尺度目标偶尔有提升,也可能导致细节噪声放大。代码里如果直接迁移这个项目,建议先跑通再动这些配置,不要一开始就同时改骨干结构和损失函数。冻结参数后训练日志里可训练参数量会明显下降,这个数字能用来回答答辩老师关于“模型复杂度”的提问。

4. 三元组损失与优化器:训练 ReID 的收敛密码

4.1 losses.py 中 CrossEntropy 和 Triplet Loss 怎么协作

ReID 里的分类损失可以把每个 pid 当做一个类别,让模型学会区分不同人;三元组损失则负责把同类拉近、异类推开。两者互补,但尺度差异很大:交叉熵的 logits 经过 BNNeck 归一化后通常是小数值,三元组损失直接作用在欧氏距离上。常见的组合方式是total_loss = ce_loss + triplet_losstriplet_loss权重设成 1 并不一定最优,很多 baseline 会把它调到 0.3 到 1 之间。关键原因是交叉熵的梯度会让特征向量分布在类中心附近,而三元组损失会持续压缩类内距离,二者权重失衡会让其中一种约束失效。

class TripletLoss(nn.Module): def __init__(self, margin=0.3): super().__init__() self.margin = margin def forward(self, feat, labels): dist = torch.cdist(feat, feat, p=2) pos_mask = labels.unsqueeze(0) == labels.unsqueeze(1) pos_mask.fill_diagonal_(False) neg_mask = ~pos_mask pos_max, _ = dist.masked_fill(~pos_mask, 0).max(dim=1) neg_min, _ = dist.masked_fill(~neg_mask, 1e6).min(dim=1) loss = torch.clamp(pos_max - neg_min + self.margin, min=0) return loss.mean()

这里用torch.cdist算所有样本两两欧氏距离,再在 batch 内部挖 hardest positive 和 hardest negative。fill_diagonal_用来屏蔽对角线上的自相似度,如果不屏蔽,pos_max会被 0 污染,因为每个样本与自己的距离是 0。当 PK 采样不充分导致某个 pid 在 batch 里只有一张图时,pos_mask在该行全为 False,pos_max直接是 0,整行损失退化成max(0, margin - neg_min),模型会把锚点推向假想的正样本。损失曲线出现断层时,优先检查的应该是采样器而不是 margin。

损失函数输出维度主要作用
CrossEntropyLossnum_pids提供类间判别信号,加速早期收敛
TripletLossbatch_size拉近同身份特征,推远不同身份特征
CenterLoss特征维度约束每个类别的特征中心,可选

4.2 optimizers.py 中 Warmup 为什么几乎必用

ReID 的训练常分为两个阶段:骨干网络加载 ImageNet 预训练权重,新增的 BNNeck 和 fc 层是随机初始化的。如果所有参数用同一个学习率更新,随机初始化的新层梯度变化剧烈,会在前几个 iteration 里破坏掉预训练特征。Warmup 让学习率从 0 或很小的值线性爬升到目标值,给新层一个适应期。optimizers.py 里通常把参数分组,骨干层用小学习率,新增层用大学习率。

def create_optimizer(model, base_lr=3e-4, weight_decay=5e-4): param_groups = [ {'params': [p for n, p in model.named_parameters() if 'fc' not in n and 'bottleneck' not in n]}, {'params': [p for n, p in model.named_parameters() if 'fc' in n or 'bottleneck' in n], 'lr': base_lr * 10}, ] return torch.optim.Adam(param_groups, lr=base_lr, weight_decay=weight_decay)

Adam 默认对每个参数调整学习率,所以分组带来的增益不如 SGD 明显,但把 BNNeck 和 fc 的学习率放大 10 倍在实践里能显著加速收敛。训练步数通常取 60 到 120 epoch,Warmup 占前 10 个 epoch。如果你的显存只允许小 batch,Adam 的 beta2 建议保持默认 0.999,因为 batch 太小导致梯度噪声大时,指数滑动平均反而能平滑更新方向。学习率衰减曲线用torch.optim.lr_scheduler.MultiStepLR,在 40 和 70 epoch 各衰减 0.1,比余弦退火更直接,也更容易和论文里的 baseline 对齐。有的项目把 Warmup 和 CosineAnnealingLR 结合,收益其实有限,但对答辩里的消融实验来说,warmup 的迭代步数、最大学习率、margin 三个参数是必调的。

4.3 train_class.py 里的两个隐藏开关:梯度裁剪与 BN 同步

train_class.py 作为训练控制类,负责组装模型、优化器、损失函数和 dataloader。除了 forward 和 backward,两个容易被人忽略的配置会明显影响最终指标。第一是梯度裁剪,torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10),它防的是三元组损失中 outlier batch 导致的梯度爆炸,尤其当某个 batch 里 hardest positive 距离异常大时,即使损失值被 clamp,反向梯度也可能瞬间放大。第二是 BatchNorm 的统计量问题,ReID 常用 P=16、K=4,不同视频帧的分布差异可能使 BN 的 running mean 抖动剧烈,小 batch 场景下可将 BN momentum 从默认 0.1 调低到 0.01。

训练循环里我习惯每 20 个 iteration 打印一次损失和各梯度的 L2 范数,如果 norm 超过 100,优先把 margin 调小而不是增大梯度裁剪阈值。还有一种情况是 loss 在 10 epoch 后卡在 0.3 附近不动,这往往不是模型不学习,而是 BN 层在 eval 模式下使用了训练期未收敛的 running mean。解决办法是训练结束后额外跑一次全量数据正向传播来更新 BN 统计量,或者保存模型时直接把 backbone 的 BN 层设置为 eval 状态。train_class 里保存 checkpoint 时最好同时保存优化器状态和 sampler 的随机种子,否则复现训练曲线时会在第 30 个 epoch 出现无法解释的分叉。

5. 训练评估与实际应用:从 Market1501 mAP 到部署

5.1 eval_metrics.py 与 distance.py 共同决定的 mAP

训练结束后最直观的指标是 Rank-1 和 mAP。Rank-1 是 gallery 中第一张最相似图属于 query 行人的精度,mAP 则考虑所有正确匹配在排序列表中的位置。eval_metrics.py 中会先由 distance.py 计算 query 和 gallery 特征的两两距离,常见的有欧氏距离和余弦距离。使用 BNNeck 输出特征时,欧氏距离更稳定;如果直接用 2048 维全局特征,一般先做 L2 归一化再算余弦距离。

def compute_dist_matrix(qf, gf, metric='euclidean'): if metric == 'euclidean': m, n = qf.size(0), gf.size(0) dist = (qf.pow(2).sum(1, keepdim=True).expand(m, n) + gf.pow(2).sum(1, keepdim=True).expand(n, m).t()) dist.addmm_(qf, gf.t(), beta=1, alpha=-2) return dist.clamp(min=1e-12).sqrt() return 1 - torch.mm(qf, gf.t())

compute_dist_matrix用二次展开避免两层循环,这在 query 和 gallery 都上万张时能省下不少计算时间。注意 eval 阶段不要在特征上做数据增强,同一个 query 的不同增强特征会直接干扰排序。实测时如果 Rank-1 正常但 mAP 偏低,说明模型把部分正确匹配排到了靠后位置,此时可以检查 query 与 gallery 是否来自不同摄像头,以及 gallery 中是否有重复检测框。query 若和某张 gallery 来自同一摄像头同一帧,相当于测试时泄露了身份信息,这样的 mAP 没有说服力。

5.2 部署最小可用推理:TorchScript 导出与行人框输入

毕设展示阶段不需要搭复杂服务,把训练好的模型转成 TorchScript 就能在摄像头片段上跑推理。Person ReID 的前置条件是检测器输出行人框,常见做法是用 YOLOv8 先检测再裁图,然后把裁图 Resize 到 256x128 输入 ReID 模型。

import torch model = torch.jit.load("reid_scripted.pt") model.eval() with torch.no_grad(): img = preprocess(frame_crop).unsqueeze(0) _, bn_feat, _ = model(img) feat = torch.nn.functional.normalize(bn_feat, p=2, dim=1)

导出时要注意模型里若有nn.Dropout,必须固定随机种子或用 eval 模式再执行torch.jit.trace,否则每次推理特征都会抖动。实际部署中 gallery 特征提前算好存成.npy,查询时只计算当前帧特征,再用矩阵乘求余弦相似度,单张图耗时通常在 5 毫秒以内。如果想要更高帧率,可以把 gallery 特征放到 FAISS 的IndexFlatIP里做近邻检索,效果和全量暴力搜索完全一致。识别阈值可以设在 0.7 附近,低于该值判定为陌生人,这个阈值需要根据测试集分布微调。

5.3 把毕业设计项目改造成自己的课设:三个必须动的地方

拿到这套源码后不建议直接替换成自己的封面就交。评分老师更在意你有没有理解数据流、模型和 loss 之间的关系。第一个要改的是data_manager.py里的数据集路径和类别数,把 Market1501 换成自定义数据集时,必须保证图片文件名的 pid 和 camid 位置一致,否则标签解析会错位。第二个是transforms.py中 train_transform 和 val_transform 的差异,很多答辩演示只用训练集里的图片做查询,mAP 看着很高,实际上 query 和 gallery 存在同帧图片,这种结果经不起追问。第三个是train_class.py里的保存逻辑,建议每 10 个 epoch 保存一次 checkpoint,并记录最后一次的全量输出,而不是只保存最优模型,这样后期做消融实验时才能回退到任意阶段的权重。

实战排错顺序可以这样定:先确认 dataloader 返回的标签是否和图像对应,再检查 train 和 val 的 transform 是否一致,然后看损失曲线有没有先降后升,最后才去动损失函数和模型结构。第一次跑通后把随机种子固定,并保存一份完整的数据集划分文件,后续每次试验都能基于同一个划分复现。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询