ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于深度学习的行人重识别系统Python实现:从毕设源码到mAP调优

基于深度学习的行人重识别系统Python实现:从毕设源码到mAP调优 简介这份资源是理工大学本科毕业设计项目——基于深度学习的行人重识别系统Python源码面向计算机相关专业正在准备毕业设计的学生以及需要项目实战练习的学习者。项目经导师指导并认可通过评审分98分难度适中适合作为毕设参考或深度学习入门实践。压缩包为zip格式共12个文件包含11个py源码文件与1个md说明文档整体约18KB体量轻便。源码围绕行人重识别任务展开涵盖数据加载与预处理、ResNet骨干网络、损失函数、优化器、距离度量、评估指标以及训练主流程等模块结构清晰便于理解深度学习项目的完整组织方式。所有代码均经过本地编译与严格调试确保可运行。目前已有120人学习下载读者可借此快速掌握行人重识别的基本流程与实现思路并在此基础上进行修改与扩展完成自己的毕业设计或课程实践。1. 从一份本科毕设源码说起行人重识别到底在解决什么问题行人重识别Person Re-Identification简称 ReID要干的事用一句话说清楚给定一张某个摄像头拍到的行人照片在其它摄像头的历史画面里把同一个人找出来。注意关键词是「其它摄像头」——如果只是同一个摄像头里追踪那叫单目标跟踪跨摄像头、跨视角、跨光照、跨遮挡还能认出是同一个人才是 ReID 的难点所在。很多同学拿到「基于深度学习的行人重识别系统 python 源码」这类毕业设计题目时第一反应是去搜「深度学习入门」「python 安装教程」然后发现装完环境、跑通一个 MNIST 手写数字识别跟这个题目根本不是一回事。ReID 属于度量学习 图像检索的交叉问题它的输出不是「这是不是人」而是「这两张图是不是同一个人」评价指标也不是准确率而是 mAP 和 Rank-1。这个认知差是绝大多数人翻车的第一步。这篇内容面向三类人正在做计算机毕业设计、需要一套能跑通、能答辩、能讲清楚原理的 ReID 系统的同学想从图像分类转向图像检索方向的工程师以及需要快速验证 ReID 方案可行性的从业者。我会按「数据怎么组织 → 模型怎么搭 → 训练怎么调 → 指标怎么算 → 坑在哪」的顺序把一套可复现的 Python 实现讲透代码基于 PyTorch数据集用学术界最常用的 Market-1501你照着敲能跑出接近论文的基线结果。2. 行人重识别系统的数据管线与模型骨架2.1 为什么 ReID 的数据组织方式和分类任务完全不同普通图像分类任务一个文件夹放一类图标签就是文件夹名。ReID 不是这样。以 Market-1501 为例它的目录结构是bounding_box_train、bounding_box_test、query三个文件夹文件名格式是0002_c1s1_000451_03.jpg这串名字里藏着关键信息0002是行人 IDperson IDc1是摄像头编号camera 1s1是序列号sequence 1000451是帧号03是检测框编号训练集里每个 ID 会出现在多个摄像头下测试时 query 里的每张图要在 gallery即bounding_box_test里找出同 ID 的所有图。同一个人在不同摄像头下的外观差异就是模型要克服的核心矛盾。这也是为什么 ReID 不能简单套用分类网络——分类只要求区分不同 IDReID 还要求同一个 ID 在不同视角下特征足够接近。常见做法是写一个 Dataset 类把文件名解析成(img_path, pid, camid)三元组。下面是我一般会用的解析逻辑import os import glob from PIL import Image from torch.utils.data import Dataset import torchvision.transforms as T class ReIDDataset(Dataset): def __init__(self, data_dir, transformNone): self.data_dir data_dir self.transform transform self.samples [] # (path, pid, camid) self.pid2label {} # 原始 pid 映射到 0~N-1 连续标签 self._load() def _load(self): # Market-1501 文件名: 0002_c1s1_000451_03.jpg for path in glob.glob(os.path.join(self.data_dir, *.jpg)): name os.path.basename(path) pid int(name.split(_)[0]) camid int(name.split(_)[1][1]) - 1 # c1 - 0 if pid -1 or pid 0: # 忽略 junk 和 distracter continue if pid not in self.pid2label: self.pid2label[pid] len(self.pid2label) self.samples.append((path, self.pid2label[pid], camid)) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, pid, camid self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, pid, camid这段代码有三个参数点必须注意。第一pid -1和pid 0的图要过滤掉前者是 junk既不属于训练 ID 也不属于测试 ID 的干扰图后者是 distracter混进去会污染损失。第二pid2label做了一次重映射因为原始 ID 不连续比如 0002、0005、0011直接拿去当分类标签会让 CrossEntropy 的类别数虚高。第三camid从 1 开始计数减 1 是为了后续做跨摄像头采样时索引方便。数据增强方面ReID 的标配是Resize((256,128))RandomHorizontalFlipRandomCropPad最后归一化。不要用 RandomRotation 和 ColorJitter 开太大前者会破坏行人的竖直结构先验后者会让颜色特征失真——而颜色恰恰是 ReID 里区分度很高的线索过度扰动反而掉点。2.2 主干网络选 ResNet50 还是轻量网络主干网络backbone的选择直接决定你的显存占用和最终 mAP。本科毕设的算力条件通常是单张 8G 或 12G 显卡我建议分两档Backbone输入尺寸参数量单卡 batchMarket-1501 mAP 参考ResNet50256x12825.6M6485% 左右ResNet50-IBN-a256x12825.6M6488% 左右OSNet-x1.0256x1282.2M12887% 左右MobileNetV3256x1285.4M12880% 左右ResNet50 是基线首选因为预训练权重好找、社区实现多、答辩时老师也认。如果你显存紧张OSNet 是性价比最高的选择它专门为 ReID 设计了多尺度特征聚合结构参数量只有 ResNet50 的十分之一精度却更高。改造 ResNet50 做 ReID 的关键动作是去掉最后的全连接分类层保留全局池化前的特征图。具体做法import torch import torch.nn as nn from torchvision.models import resnet50, ResNet50_Weights class ReIDBackbone(nn.Module): def __init__(self, num_classes, pretrainedTrue): super().__init__() weights ResNet50_Weights.IMAGENET1K_V1 if pretrained else None base resnet50(weightsweights) # 去掉 avgpool 和 fc保留到 layer4 self.backbone nn.Sequential(*list(base.children())[:-2]) self.pool nn.AdaptiveAvgPool2d(1) self.bnneck nn.BatchNorm1d(2048) self.bnneck.bias.requires_grad_(False) # 关键bias 不参与梯度 self.classifier nn.Linear(2048, num_classes, biasFalse) def forward(self, x): feat_map self.backbone(x) # [B, 2048, 8, 4] global_feat self.pool(feat_map).flatten(1) feat_bn self.bnneck(global_feat) if self.training: return self.classifier(feat_bn), feat_bn return feat_bn这里有个容易被忽略的细节bnneck的 bias 要冻结。原因是 ReID 训练时用了一个叫 BNNeck 的技巧——分类损失作用在 BN 之后的特征上而三元组损失作用在 BN 之前的特征上。如果 BN 的 bias 参与梯度更新会破坏这个分离设计实测 mAP 会掉 1~2 个点。这个坑我在第一次复现时踩过训练 loss 看着正常测试指标就是上不去排查了半天才发现是 BN 参数没冻。3. 损失函数与训练策略ReID 精度提升的主战场3.1 ID Loss 加 Triplet Loss 的组合为什么是标配ReID 的损失函数几乎都是「分类损失 度量损失」的联合形式。分类损失用 CrossEntropy也叫 ID Loss负责让模型学会区分不同 ID度量损失用 Triplet Loss负责把同一个人的特征拉近、不同人的特征推远。两者缺一不可只用 ID Loss特征在类内不够紧凑跨摄像头检索时容易匹配错只用 Triplet Loss训练不稳定收敛慢。Triplet Loss 的核心是「难样本挖掘」Hard Mining。一个 batch 里对每张 anchor 图选一个同 ID 但距离最远的正样本再选一个不同 ID 但距离最近的负样本用这两个最难样本算损失。这样梯度信号最强模型学得最快。实现上通常用BatchHardTripletLossimport torch import torch.nn as nn class BatchHardTripletLoss(nn.Module): def __init__(self, margin0.3): super().__init__() self.margin margin self.ranking_loss nn.MarginRankingLoss(marginmargin) def forward(self, features, labels): # features: [B, D] 已 L2 归一化 # labels: [B] n features.size(0) dist torch.cdist(features, features, p2) # 成对欧氏距离 mask_pos labels.unsqueeze(0) labels.unsqueeze(1) mask_neg ~mask_pos dist_pos dist.clone() dist_neg dist.clone() dist_pos[~mask_pos] 0 dist_neg[~mask_neg] 1e9 # 每个 anchor 的最远正样本、最近负样本 hardest_pos, _ dist_pos.max(dim1) hardest_neg, _ dist_neg.min(dim1) y torch.ones(n, devicefeatures.device) loss self.ranking_loss(hardest_neg, hardest_pos, y) return lossmargin这个参数很关键。设太小比如 0.1正负样本区分度不够mAP 上不去设太大比如 0.5 以上训练早期大量 triplet 被 margin 截断梯度消失loss 卡住不动。0.3 是 Market-1501 上比较稳的经验值如果你换数据集可以按 0.3 起步上下微调。3.2 采样器Sampler比损失函数更容易被忽视ReID 训练有一个硬性要求一个 batch 里必须同时包含多个 ID每个 ID 至少 4 张图。否则 Triplet Loss 找不到有效的正负样本对。但 DataLoader 默认是随机采样一个 batch 里可能全是不同 IDTriplet 直接失效。解决办法是自定义RandomIdentitySampler每个 batch 采样 P 个 ID每个 ID 采 K 张图通常 P16、K4batch size 就是 64。这个 P×K 的设定直接影响训练效果from torch.utils.data import Sampler import numpy as np class RandomIdentitySampler(Sampler): def __init__(self, data_source, batch_size, num_instances4): self.data_source data_source self.batch_size batch_size self.num_instances num_instances self.num_pids_per_batch batch_size // num_instances # 建立 pid - 索引列表 的映射 self.index_dic {} for idx, (_, pid, _) in enumerate(data_source.samples): self.index_dic.setdefault(pid, []).append(idx) self.pids list(self.index_dic.keys()) def __iter__(self): batch_idxs [] for _ in range(len(self.data_source) // self.batch_size): chosen_pids np.random.choice( self.pids, self.num_pids_per_batch, replaceFalse) for pid in chosen_pids: idxs np.random.choice( self.index_dic[pid], self.num_instances, replaceTrue) batch_idxs.extend(idxs) return iter(batch_idxs) def __len__(self): return len(self.data_source) // self.batch_size注意replaceTrue这一行。如果某个 ID 在训练集里只有 2 张图而 K4不设 replace 会直接报错。设成 True 意味着允许重复采样虽然会引入少量重复样本但保证了训练不中断。这是工程上的妥协实测对最终指标影响很小。训练超参方面我一般用 SGD 优化器初始学习率 0.00035对就是这么小weight decay 5e-4momentum 0.9配合 CosineAnnealingLR 或 WarmupMultiStepLR。学习率千万别照搬分类任务的 0.1ReID 的 batch 里样本相关性高学习率大了直接发散。训练 60~120 个 epoch前 10 个 epoch 用 warmup 线性升温之后余弦衰减。4. 评测指标与推理流程mAP 和 Rank-1 到底怎么算4.1 从特征提取到距离矩阵的完整推理链路训练完之后评测流程分四步提取 query 特征、提取 gallery 特征、算距离矩阵、按距离排序算指标。很多人卡在「特征提取时忘了切 eval 模式」导致 BN 层用 batch 统计量同一张图每次提取的特征都不一样指标自然乱跳。import torch from torch.nn import functional as F torch.no_grad() def extract_features(model, dataloader, device): model.eval() # 必须切 eval固定 BN 统计量 feats, pids, camids [], [], [] for imgs, pid, camid in dataloader: imgs imgs.to(device) f model(imgs) # eval 模式下返回 BN 后的特征 f F.normalize(f, p2, dim1) # L2 归一化让内积等价于余弦相似度 feats.append(f.cpu()) pids.extend(pid.numpy()) camids.extend(camid.numpy()) return torch.cat(feats), np.array(pids), np.array(camids) def compute_distmat(qf, gf): # 余弦距离 1 - 余弦相似度 return 1 - torch.mm(qf, gf.t())L2 归一化这一步不能省。归一化之后欧氏距离和余弦距离单调等价检索时用矩阵乘法就能算完所有 query-gallery 对速度比逐对算欧氏距离快一个数量级。4.2 mAP 的计算逻辑与常见实现错误算 mAP 之前要先处理一个规则同摄像头、同 ID 的 gallery 图要排除。因为 query 和 gallery 如果来自同一个摄像头那基本是同一段视频的相邻帧外观几乎一样算进去等于送分指标会虚高。标准做法是构建一个junk_index把这些样本标记为忽略。import numpy as np def eval_reid(distmat, q_pids, g_pids, q_camids, g_camids, max_rank50): num_q, num_g distmat.shape indices np.argsort(distmat, axis1) # 按距离升序 matches (g_pids[indices] q_pids[:, np.newaxis]).astype(np.int32) all_cmc, all_AP [], [] for q_idx in range(num_q): q_pid, q_cam q_pids[q_idx], q_camids[q_idx] order indices[q_idx] # 排除同摄像头同 ID 的 gallery remove (g_pids[order] q_pid) (g_camids[order] q_cam) keep np.invert(remove) raw_cmc matches[q_idx][keep] if not np.any(raw_cmc): continue # 该 query 在 gallery 无同 ID跳过 cmc raw_cmc.cumsum() cmc[cmc 1] 1 all_cmc.append(cmc[:max_rank]) num_rel raw_cmc.sum() tmp_cmc raw_cmc.cumsum() tmp_cmc [x / (i 1.) for i, x in enumerate(tmp_cmc)] tmp_cmc np.asarray(tmp_cmc) * raw_cmc AP tmp_cmc.sum() / num_rel all_AP.append(AP) all_cmc np.asarray(all_cmc).astype(np.float32) cmc all_cmc.sum(0) / len(all_cmc) mAP np.mean(all_AP) return cmc, mAP这段代码里tmp_cmc [x / (i 1.) ...]是 PrecisionK 的计算* raw_cmc是只保留命中位置的精度最后除以num_rel得到 AP。最常见的错误是忘了cmc[cmc 1] 1导致 CMC 曲线出现大于 1 的值。另一个错误是if not np.any(raw_cmc): continue这行漏写当某个 query 在 gallery 里没有同 ID 时num_rel为 0除零直接报 nan。在 Market-1501 上一个训练充分的 ResNet50 BNNeck Triplet 基线Rank-1 能到 94% 左右mAP 85% 左右。如果你跑出来 Rank-1 只有 60% 多大概率是评测代码写错了而不是模型没训好——先查评测再查训练。5. 避坑与排查那些让指标原地踏步的细节5.1 训练 loss 正常但 mAP 上不去现象训练时 ID Loss 稳定下降Triplet Loss 也在降但每个 epoch 评测 mAP 卡在 40%~50% 不动。原因九成是评测阶段的问题不是训练问题。最常见的是特征提取时没切model.eval()BN 层还在用当前 batch 的均值和方差导致同一张图两次提取的特征不一致。其次是忘了 L2 归一化距离矩阵尺度不对排序全乱。解决在extract_features函数开头加model.eval()并用torch.no_grad()装饰器。提取完特征后立刻做F.normalize。验证方法很简单对同一张图连续提取两次特征算余弦相似度如果结果不是 1.0说明 eval 模式或归一化有问题。5.2 显存溢出OOM在 batch size 不大时也发生现象batch size 设成 64输入 256x1288G 显存直接 OOM但理论上 ResNet50 不该这么吃显存。原因Triplet Loss 里的torch.cdist会生成[B, B]的距离矩阵B64 时是 64x64看着不大但如果你的实现里对每个样本单独算距离再拼接中间变量会膨胀几十倍。另一个原因是 DataLoader 的num_workers设太大每个 worker 都复制一份数据到内存。解决用torch.cdist一次性算完整个 batch 的距离矩阵不要写循环。num_workers设成 4 就够设 8 或 16 在 Windows 上还容易出共享内存错误。如果还是 OOM把 batch size 降到 32同时把学习率按比例降到 0.0002。5.3 换数据集后指标断崖式下跌现象在 Market-1501 上 mAP 85%换到 DukeMTMC 或 MSMT17 上直接掉到 30% 多。原因不同数据集的图像分辨率、行人框比例、光照条件差异很大。Market-1501 的图普遍是 128x64 的小图MSMT17 分辨率更高、场景更复杂。直接用 Market 上训好的模型去测 MSMT属于跨域测试掉点是正常的。但如果你是在 MSMT 上重新训练还掉那就是输入尺寸没适配。解决换数据集时重新统计图像尺寸分布把Resize的目标尺寸调到该数据集的中位数附近。MSMT17 建议用 384x128。另外跨域场景下 BNNeck 的 BN 层统计量需要重新估计训练前几个 epoch 可以先冻结 backbone 只训 BN 和分类头。5.4 数据增强开太猛反而掉点现象为了提升泛化加了 RandomRotation、ColorJitter、RandomErasing 一堆增强结果 mAP 比不加还低 3 个点。原因ReID 依赖两个先验——行人的竖直结构和颜色一致性。RandomRotation 破坏前者ColorJitter 破坏后者。RandomErasing 本身是有效的但概率设太高比如 0.5会让模型学不到完整外观。解决增强只保留RandomHorizontalFlip概率 0.5、RandomCroppadding10、RandomErasing概率 0.5但面积比控制在 0.02~0.4。ColorJitter 如果要加brightness 和 contrast 的幅度控制在 0.2 以内别动 hue。5.5 训练到后期 loss 突然变 nan现象前 30 个 epoch 一切正常第 40 个 epoch 左右 loss 突然变成 nan之后再也降不下来。原因Triplet Loss 里的hardest_pos.max()在极端情况下会选到距离为 0 的样本自己和自己的距离如果此时 margin 又比较大梯度会爆炸。另一个诱因是学习率在余弦衰减后期没有下限数值下溢。解决在距离矩阵的对角线上加一个极小值避免选到自己dist.fill_diagonal_(1e-12)。学习率加一个最小下限比如max(lr, 1e-6)。如果已经出现 nan从上一个正常 epoch 的 checkpoint 恢复把学习率砍半再训。6. 把毕设做成能讲清楚的作品从跑通到答辩的进阶技巧跑通基线只是及格线答辩时老师真正会问的是「你为什么这么设计」「换个场景还行不行」。这里分享几个我踩过坑之后总结的实用技巧。第一用 TensorBoard 把训练过程可视化。把 ID Loss、Triplet Loss、Rank-1、mAP 四条曲线画在一张图上答辩时直接展示。老师看到你能解释「为什么第 20 个 epoch mAP 有一个小回落」比你说十句「模型收敛良好」都有说服力。回落的常见原因是学习率在那个点做了 step 衰减特征空间在重新调整。第二做一个可视化检索 demo。用 Gradio 或 Streamlit 搭一个界面上传一张行人图展示 Top-10 检索结果命中的用绿框、未命中的用红框。这个 demo 的代码量不超过 100 行但答辩效果拉满。核心逻辑就是复用第 4 章的extract_features和compute_distmat把 gallery 特征提前算好存成.npy推理时只算 query 特征。第三准备一个跨数据集的对比实验。在 Market-1501 上训练直接测 DukeMTMC展示跨域掉点现象然后说明你尝试的改进方向比如加 Instance Normalization、用聚类伪标签做无监督域适应。哪怕改进效果有限能讲清楚「为什么掉点」「我试了什么」「结果如何」就已经超过大部分毕设的水平了。第四把参数量和推理速度测出来。用thop或torchsummary算 FLOPs用time.time()测单张图推理耗时。答辩时老师问「这个系统能不能实时」你能答出「ResNet50 在 1080Ti 上单张 8msOSNet 单张 3ms满足 25fps 视频流处理」这就是工程素养的体现。进阶方向实现难度预期 mAP 提升适合场景BNNeck Warmup低2~3%所有基线RandomErasing低1~2%遮挡场景OSNet 主干中2~4%显存受限跨域域适应高视场景实际部署重排序Re-ranking中3~5%离线检索最后说个我自己的习惯每次改完代码先跑 5 个 epoch 的小实验看 loss 曲线和 mAP 趋势对不对再开完整训练。ReID 训练一次动辄几小时盲目开长训练是最浪费时间的做法。另外所有实验的超参和结果都记在一个 markdown 表格里答辩前翻一遍哪个参数改了什么效果一目了然。这套流程我用了三年帮我省下的显卡时间够训几十个模型了。希望帮到你。本文还有配套的精品资源点击获取
返回列表