
简介一份重庆理工大学本科毕业设计《基于深度学习的行人重识别》完整项目提供源代码与文档说明面向计算机相关专业的在校学生、老师及企业员工尤其适合作为毕设参考、课程设计或入门进阶的学习样例。项目借鉴了浙江大学罗浩老师的开源课程所有代码均测试运行成功答辩评审平均分达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.jpg。0002是行人 IDc1s1表示来自 camera 1 的 sequence 1000451是帧序号03是检测框序号。data_manager.py 的核心任务是把这种文件名解析成pid和camid再划分出 train、query、gallery 三个子集。常见做法是维护一个 pid 到连续 label 的映射字典训练集重新编号query 和 gallery 保持原始 pid 不重编号这样才能在评估时统一计算指标。def process_dir(dir_path, relabelTrue): 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 要让relabelFalse。很多新手把三份图片放进同一个目录去跑结果 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 在不同次推理时得到不同特征。增强方法典型参数作用RandomHorizontalFlipp0.5缓解视角不对称行人左右翻转语义不变量强RandomCroppadding10模拟检测框偏移增强行人位置鲁棒性RandomErasingp0.5, sl0.02, sh0.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 里也加入 RandomErasingRank-1 会掉 3 到 5 个点。这不是模型变差而是评估时输入分布不一致导致的特征扰动。你可以在答辩时把这个作为数据增强对评价指标影响的实证案例讲出来比单纯背公式更有说服力。2.3 dataset_loader.py 的 PK 采样策略为什么随机采样在 ReID 里容易让三元组损失失效因为一个 batch 里如果每个人只有一两张图很难找到正样本对。更稳定的方案是 PK 采样每个 batch 固定取 P 个行人每个行人取 K 张图batch sizeP*K。常见配置是 P16、K4也就是 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, width2048): super().__init__() backbone models.resnet50(pretrainedTrue) 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, biasFalse) 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_feat和g是 ReID baseline 中常见的 trick。g是 BN 之前的特征bn_feat用于三元组损失时做距离计算logits走交叉熵。如果直接拿g做三元组损失容易因为特征尺度大导致梯度不稳定如果全用bn_feat分类损失的收敛会被 BN 层扰动。两个特征分开用是罗浩老师开源课程里反复强调的工程细节。bottleneck.bias.requires_grad_(False)是为了避免 BN 层偏置带来的 scale 偏移本质原因在于后面的fc是无 bias 全连接分类器。3.2 为什么用 GAP 而不是 GMPReID 特征聚合的取舍ResNet50 最后的空间分辨率是 7x7常见聚合操作有 Global Average PoolingGAP和 Global Max PoolingGMP。分类任务里 GMP 往往能保留更锐利的判别性响应但在行人重识别中GMP 会过度放大最显著的单点特征导致同一行人换摄像头后特征漂移。GAP 偏向整体纹理的平均响应对姿态变化更稳定所以项目里选择nn.AdaptiveAvgPool2d(1)是合理的。业界也有 PCBPart-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 提到 64backbone 的梯度开销就扛不住了。显存紧张时常见做法是冻结 layer1 之前的参数只训练 layer3、layer4 和新增分支。PyTorch 里可以通过设置requires_gradFalse实现。for name, param in model.features.named_parameters(): if layer1 in name or layer2 in name: param.requires_grad False冻结低层特征后训练步数需要适当增加否则高层的特征分布来不及适应目标数据集。一个更彻底的方案是用torchvision.models.resnet50(weightsResNet50_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, margin0.3): super().__init__() self.margin margin def forward(self, feat, labels): dist torch.cdist(feat, feat, p2) 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(dim1) neg_min, _ dist.masked_fill(~neg_mask, 1e6).min(dim1) loss torch.clamp(pos_max - neg_min self.margin, min0) return loss.mean()这里用torch.cdist算所有样本两两欧氏距离再在 batch 内部挖 hardest positive 和 hardest negative。fill_diagonal_用来屏蔽对角线上的自相似度如果不屏蔽pos_max会被 0 污染因为每个样本与自己的距离是 0。当 PK 采样不充分导致某个 pid 在 batch 里只有一张图时pos_mask在该行全为 Falsepos_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_lr3e-4, weight_decay5e-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, lrbase_lr, weight_decayweight_decay)Adam 默认对每个参数调整学习率所以分组带来的增益不如 SGD 明显但把 BNNeck 和 fc 的学习率放大 10 倍在实践里能显著加速收敛。训练步数通常取 60 到 120 epochWarmup 占前 10 个 epoch。如果你的显存只允许小 batchAdam 的 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_norm10)它防的是三元组损失中 outlier batch 导致的梯度爆炸尤其当某个 batch 里 hardest positive 距离异常大时即使损失值被 clamp反向梯度也可能瞬间放大。第二是 BatchNorm 的统计量问题ReID 常用 P16、K4不同视频帧的分布差异可能使 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, metriceuclidean): if metric euclidean: m, n qf.size(0), gf.size(0) dist (qf.pow(2).sum(1, keepdimTrue).expand(m, n) gf.pow(2).sum(1, keepdimTrue).expand(n, m).t()) dist.addmm_(qf, gf.t(), beta1, alpha-2) return dist.clamp(min1e-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, p2, dim1)导出时要注意模型里若有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 是否一致然后看损失曲线有没有先降后升最后才去动损失函数和模型结构。第一次跑通后把随机种子固定并保存一份完整的数据集划分文件后续每次试验都能基于同一个划分复现。本文还有配套的精品资源点击获取