简介:这份资源是理工大学本科毕业设计项目——基于深度学习的行人重识别系统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是行人 ID(person ID)c1是摄像头编号(camera 1)s1是序列号(sequence 1)000451是帧号03是检测框编号
训练集里每个 ID 会出现在多个摄像头下,测试时 query 里的每张图,要在 gallery(即bounding_box_test)里找出同 ID 的所有图。同一个人在不同摄像头下的外观差异,就是模型要克服的核心矛盾。这也是为什么 ReID 不能简单套用分类网络——分类只要求区分不同 ID,ReID 还要求同一个 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, transform=None): 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))+RandomHorizontalFlip+RandomCrop+Pad,最后归一化。不要用 RandomRotation 和 ColorJitter 开太大,前者会破坏行人的竖直结构先验,后者会让颜色特征失真——而颜色恰恰是 ReID 里区分度很高的线索,过度扰动反而掉点。
2.2 主干网络选 ResNet50 还是轻量网络
主干网络(backbone)的选择直接决定你的显存占用和最终 mAP。本科毕设的算力条件通常是单张 8G 或 12G 显卡,我建议分两档:
| Backbone | 输入尺寸 | 参数量 | 单卡 batch | Market-1501 mAP 参考 |
|---|---|---|---|---|
| ResNet50 | 256x128 | 25.6M | 64 | 85% 左右 |
| ResNet50-IBN-a | 256x128 | 25.6M | 64 | 88% 左右 |
| OSNet-x1.0 | 256x128 | 2.2M | 128 | 87% 左右 |
| MobileNetV3 | 256x128 | 5.4M | 128 | 80% 左右 |
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, pretrained=True): super().__init__() weights = ResNet50_Weights.IMAGENET1K_V1 if pretrained else None base = resnet50(weights=weights) # 去掉 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, bias=False) 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 但距离最近的负样本,用这两个最难样本算损失。这样梯度信号最强,模型学得最快。实现上通常用BatchHardTripletLoss:
import torch import torch.nn as nn class BatchHardTripletLoss(nn.Module): def __init__(self, margin=0.3): super().__init__() self.margin = margin self.ranking_loss = nn.MarginRankingLoss(margin=margin) def forward(self, features, labels): # features: [B, D] 已 L2 归一化 # labels: [B] n = features.size(0) dist = torch.cdist(features, features, p=2) # 成对欧氏距离 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(dim=1) hardest_neg, _ = dist_neg.min(dim=1) y = torch.ones(n, device=features.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 里可能全是不同 ID,Triplet 直接失效。
解决办法是自定义RandomIdentitySampler,每个 batch 采样 P 个 ID,每个 ID 采 K 张图,通常 P=16、K=4,batch 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_instances=4): 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, replace=False) for pid in chosen_pids: idxs = np.random.choice( self.index_dic[pid], self.num_instances, replace=True) batch_idxs.extend(idxs) return iter(batch_idxs) def __len__(self): return len(self.data_source) // self.batch_size注意replace=True这一行。如果某个 ID 在训练集里只有 2 张图,而 K=4,不设 replace 会直接报错。设成 True 意味着允许重复采样,虽然会引入少量重复样本,但保证了训练不中断。这是工程上的妥协,实测对最终指标影响很小。
训练超参方面,我一般用 SGD 优化器,初始学习率 0.00035(对,就是这么小),weight decay 5e-4,momentum 0.9,配合 CosineAnnealingLR 或 WarmupMultiStepLR。学习率千万别照搬分类任务的 0.1,ReID 的 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, p=2, dim=1) # 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_rank=50): num_q, num_g = distmat.shape indices = np.argsort(distmat, axis=1) # 按距离升序 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.) ...]是 Precision@K 的计算,* 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,输入 256x128,8G 显存直接 OOM,但理论上 ResNet50 不该这么吃显存。
原因:Triplet Loss 里的torch.cdist会生成[B, B]的距离矩阵,B=64 时是 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)、RandomCrop(padding=10)、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 上单张 8ms,OSNet 单张 3ms,满足 25fps 视频流处理」,这就是工程素养的体现。
| 进阶方向 | 实现难度 | 预期 mAP 提升 | 适合场景 |
|---|---|---|---|
| BNNeck + Warmup | 低 | +2~3% | 所有基线 |
| RandomErasing | 低 | +1~2% | 遮挡场景 |
| OSNet 主干 | 中 | +2~4% | 显存受限 |
| 跨域域适应 | 高 | 视场景 | 实际部署 |
| 重排序(Re-ranking) | 中 | +3~5% | 离线检索 |
最后说个我自己的习惯:每次改完代码,先跑 5 个 epoch 的小实验,看 loss 曲线和 mAP 趋势对不对,再开完整训练。ReID 训练一次动辄几小时,盲目开长训练是最浪费时间的做法。另外,所有实验的超参和结果都记在一个 markdown 表格里,答辩前翻一遍,哪个参数改了什么效果一目了然。这套流程我用了三年,帮我省下的显卡时间够训几十个模型了。希望帮到你。
本文还有配套的精品资源,点击获取