
简介这是一套基于深度学习的行人重识别ReID项目源码面向具备一定Python基础的计算机视觉学习者或算法工程师旨在解决跨摄像头场景下行人身份匹配与检索问题。压缩包共11个py文件总体量仅18KB涵盖数据加载、预处理、模型结构、损失函数、优化器配置、距离度量、评估指标等完整环节。已有150人学习使用适合作为入门或二次开发的参考模板。项目代码结构清晰从训练类到评估工具一应俱全便于快速理解ReID中特征提取、度量学习与检索验证的典型流程也可在此基础上替换骨干网络或损失函数进行改进实验。1. 行人重识别项目先读懂 Person-ReID 的十个文件再动手行人重识别Person ReID这个方向外行看着是「跨摄像头找同一个人」内行知道核心就一句话让模型对同一个人的不同照片给出足够近的特征向量。这套基于深度学习的行人重识别项目把数据管理、特征提取、损失计算、评测验证全部拆成独立文件data_manager、dataset_loader、ResNet、losses、train_class、eval_metrics 这些模块属于那种能直接跑通、也能逐块替换的工程型代码。它适合正在系统学深度学习的开发者也适合需要在短时间内把 ReID 基线跑起来、后续还要换主干换数据集的一线从业者。下面我会按数据流、模型、损失与训练、踩坑、推理验证的顺序把每个文件讲透最后给一个可以校验效果的查询脚本。2. 数据流水线Market-1501 怎么进 DatasetLoader增强参数怎么定2.1 文件分工data_manager 管流程dataset_loader 管解析打开 Person-ReID-main 目录第一个建议是从 data_manager.py 和 dataset_loader.py 开始读。这两个文件决定了后续模型和损失函数拿到的是什么数据数据错了模型再强也是白搭。项目里的文件命名其实已经把分工写在脸上了文件职责关键输出data_manager.py数据总调度对外暴露统一接口训练 / query / gallery 数据集对象dataset_loader.py解析 Market-1501 目录与文件名图片张量、行人 ID、摄像头 IDtransforms.py数据增强与归一化统一尺寸的输入张量data_manager 接收数据集根目录内部判断当前处于哪个阶段把「原始图片路径 行人 ID 摄像头 ID」打包成三元组返回dataset_loader 则干脏活从文件名解析出结构化标签。Market-1501 的命名规则是训练集图片形如 0002_c1s1_000451_03.jpg含义是「行人 ID0002摄像头 1第 000451 帧第 03 个检测框」query 和 gallery 文件名里还会带上额外的摄像头与序列信息。这里最容易犯的错是把「帧号」当成「行人 ID」一旦 ID 错位triplet loss 会把两个不同的人当成同一个人拉近训练结果直接报废。所以 dataset_loader 里的文件名解析函数是整个项目第一个不该省校验的地方。常见解析写法是先把文件名按下划线切分# dataset_loader.py 中解析 Market-1501 文件名的核心逻辑 def parse_market_filename(filename): # 输入示例0002_c1s1_000451_03.jpg name filename.split(.)[0] # 去掉扩展名得到 0002_c1s1_000451_03 parts name.split(_) pid int(parts[0]) # 前 4 位是行人 ID cam int(parts[1][1]) # c1s1 中的 1 表示摄像头编号 return pid, cam这段逻辑本身不难但有两个细节要注意。第一同一个行人 ID 会出现在多个摄像头下摄像头编号必须单独存因为 Market-1501 的评测协议默认 query 和 gallery 来自不同摄像头cam 字段在评测阶段做「同摄像头排除」时要用。第二训练集里存在某些 ID 只有单张图的情况如果不对这些样本做过滤triplet 采样时会找不到正样本对训练直接中断或 loss 抖动。2.2 transforms.py增强参数按 ReID 的尺寸习惯来定transforms.py 负责数据增强。行人重识别和图像分类的增强策略有明显差异分类任务关心「物体是什么」ReID 关心「这个人是谁」增强不能破坏身份特征。水平翻转、随机擦除、轻微裁剪是主流组合颜色抖动要谨慎过强的色彩扰动会让同一个人的特征漂移尤其当测试集光照和训练集差别不大时颜色增强带来的收益是负的。我一般会这样组织训练集增强测试集则用另一套不含随机操作的流程# transforms.py训练集与测试集使用不同增强策略 from torchvision import transforms import random class RandomErasing: # 模拟行人被遮挡的常见场景按概率擦除一块矩形区域 def __init__(self, p0.5, sl0.02, sh0.4): self.p p self.sl sl self.sh sh def __call__(self, img): if random.uniform(0, 1) self.p: return img # 实际实现需要随机选矩形区域并用随机值填充 return img train_transform transforms.Compose([ transforms.Resize((256, 128)), # ReID 约定高 256、宽 128 transforms.RandomHorizontalFlip(p0.5), # 模拟行人左右朝向变化 transforms.Pad(10), # 四周补 10 像素再随机裁剪 transforms.RandomCrop((256, 128)), # 等效随机平移增强位置鲁棒性 RandomErasing(p0.5, sl0.02, sh0.4), # 随机擦除应对遮挡 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) test_transform transforms.Compose([ transforms.Resize((256, 128)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])这里几个参数值得单独说明。Resize 用 256×128 而不是正方形是因为行人检测框天然「高大于宽」强行缩成 224×224 会把入拉宽特征分布直接变形。RandomErasing 的 sl 和 sh 分别是最小和最大擦除面积占比按 0.02 到 0.4 设置擦太小没作用擦太大会把整张图盖住模型反而学会「看见噪声就认为是遮挡」的假规律。测试集不做随机翻转和擦除目的是保证评测时特征稳定可复现。注意Normalize 的均值和标准差对应 ImageNet 预训练统计值换数据集时可以重算统计量但换完必须重新训练直接加载预训练权重会失效。2.3 组装训练集、query 和 gallery验证数据加载数据管理器的最终产出是三组可迭代对象训练集更新权重query 是待检索的行人图片gallery 是候选底库。很多人第一次跑 Market-1501 以为「把图片都读进来就行」实际上评测协议要求 query 和 gallery 来自不同摄像头且 gallery 不能包含 query 里同一帧的图片。验证加载是否正常的脚本很简单关键看两个数# 快速验证 DataManager 输出的数据规模 from dataset_loader import DatasetLoader from data_manager import DataManager dm DataManager(rootdata/market1501) train_set dm.get_dataset(train) query_set dm.get_dataset(query) gallery_set dm.get_dataset(gallery) print(train 图片数:, len(train_set.images)) # Market-1501 约 12936 print(train ID 数:, train_set.num_pids) # 标准协议下是 751 print(query 图片数:, len(query_set.images)) # 3368 print(gallery 图片数:, len(gallery_set.images)) # 15913打印出来的 ID 数在 751 附近说明解析和过滤逻辑正确明显偏少基本就是 dataset_loader 的多摄像头去重逻辑把不该滤的 ID 滤掉了。这一步务必在训练前跑通训练途中再发现数据错了返工成本不是改几行代码能补回来的。我自己的习惯是顺手统计每个 ID 的图片数分布如果某个 ID 的图片数异常多要确认它是不是多个摄像头下的同名 ID 被合并了。注意训练前务必先跑数据校验脚本确认图片数和 ID 数符合 Market-1501 标准协议再进入训练阶段。3. 模型与度量ResNet 主干、优化器和 distance.py 的协同3.1 ResNet.py把分类网络改成 ReID 特征提取器ResNet.py 是整个项目的特征提取核心。行人重识别基本不从零搭网络主流做法是用 ImageNet 预训练的 ResNet-50 做主干把最后用于分类的全连接层删掉换成适合 ReID 的特征输出结构。这里有一个长久以来的讨论直接用分类网络的倒数第二层特征行不行答案是可以但不理想。ImageNet 分类任务学到的特征偏向「物体类别」ReID 需要的是「区分同一个类内的不同个体」所以必须经过 ReID 数据的微调而且要配合特定的损失函数。这也是为什么 ResNet.py 里必定同时存在特征分支和分类分支。常见改造方式保留 ResNet 的 conv1 到 conv5 层用全局平均池化把特征图压成 2048 维向量再接一个 BatchNorm 得到用于 triplet 的特征最后接一个去掉 bias 的全连接层用于 softmax 分类。# ResNet.py把 torchvision 的 ResNet-50 改造成 ReID 特征提取器 import torch.nn as nn from torchvision import models class ResNetReID(nn.Module): def __init__(self, num_classes, feat_dim2048): super().__init__() base models.resnet50(pretrainedTrue) # 去掉 avgpool 和 fc只保留到 conv5_x self.backbone nn.Sequential(*list(base.children())[:-2]) self.global_pool nn.AdaptiveAvgPool2d((1, 1)) self.bnneck nn.BatchNorm1d(feat_dim) # 特征归一化层 self.classifier nn.Linear(feat_dim, num_classes, biasFalse) def forward(self, x): feat self.backbone(x) feat self.global_pool(feat).flatten(1) bn_feat self.bnneck(feat) # 用于 triplet loss 的特征 logits self.classifier(bn_feat) # 用于 softmax 的输出 return bn_feat, logits两个容易忽略的细节。第一classifier 设置 biasFalse配合 BNNeck 的归一化特征分类面更稳定这是很多 ReID 论文的默认配置第二forward 同时返回 bn_feat 和 logitstrain_class.py 会把它们分别喂给不同损失同一网络同时服务「拉近同类」和「分清理类别」两个目标。如果你的数据集类数很大比如 MSMT17 有 1041 个训练 ID可以不用预训练权重随机初始化慢慢训但训练轮数基本要翻倍。更轻量的主干如 OSNet 在算力受限时也可以替换不过那属于另一套改造逻辑OSNet 通道数小、多尺度融合结构不同直接换 backbone 文件里的类名往往不兼容得连 forward 一起改。这个项目既然默认 ResNet先在 ResNet 上跑通流程再考虑轻量化是更稳的路径。3.2 optimizers.pySGD 和 Adam 在这个任务里的取舍optimizers.py 很短学习率策略却是复现时最容易翻车的一环。行人重识别一般用 SGD Momentum初始学习率 0.01 到 0.05配合 warmup 和余弦退火Adam 不是不能用但在中小数据集上收敛偏快容易停在局部最优换成轻量主干时可以试试ResNet-50 上我不建议一上来就用 Adam。# optimizers.pySGD 的典型配置 optimizer torch.optim.SGD( model.parameters(), lr0.01, # 初始学习率配合 warmup 使用 momentum0.9, weight_decay5e-4, # 正则项防止过拟合 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max60, eta_min0.0001 )参数说明momentum 0.9 是图像任务默认值基本不用动weight_decay 取 5e-4但要注意这个值如果作用于 BN 层参数有的实现会把 BN 的 weight_decay 单独设成 0避免过强正则把 BatchNorm 的缩放能力压掉。学习率用余弦退火是因为 ReID 数据量不大训练后期需要更小的步长精调特征。warmup 阶段通常跑 5 到 10 个 epoch把学习率从很小的值线性升到 0.01这么做是防止预训练权重在第一步大更新时被破坏。还有一个进阶做法是分层设置学习率主干用较小的 0.001新增的 bnneck 和 classifier 用 0.01。因为预训练权重不需要大幅更新随机初始化的层需要更快收敛。这个做法在训练集和预训练分布差异很大时尤其管用。3.3 distance.py 与 eval_metrics.py距离计算和评测口径distance.py 是三段式流程里的「检索执行器」。训练完成后gallery 全部图片过一遍模型得到特征矩阵再对每个 query 特征计算它与全部 gallery 特征的相似度按相似度排序输出前 k 个结果。distance.py 里通常封装两种度量欧氏距离和余弦距离。两者的选择有个基本规则度量方式适用场景注意点欧氏距离特征未归一化对特征尺度敏感需保证训练测试口径一致余弦距离特征做 L2 归一化后等价于归一化后的内积尺度无关eval_metrics.py 负责把排序结果换算成行业标准指标。行人重识别不看单一 Accuracy而是看 mAP 和 Rank-1。mAP 衡量所有 query 检索排序的整体质量Rank-1 只看第一张返回结果是否命中同一个人二者计算口径和通用图像检索一致但有一个必须警惕的坑同一个行人在多个摄像头下的图片互为正样本同一摄像头下的图片即便属于同一 ID也要排除在正样本之外这就是 Market-1501 的「同摄像头排除」规则。# eval_metrics.pymAP 与 Rank-k 的核心计算思路 def evaluate_rank(query_feats, gallery_feats, query_ids, gallery_ids, query_cams, gallery_cams): dist compute_distance(query_feats, gallery_feats) # 距离矩阵 [Q, G] for q_idx in range(dist.shape[0]): # 排除同摄像头下的同 ID 图片避免「作弊命中」 mask (gallery_ids query_ids[q_idx]) (gallery_cams query_cams[q_idx]) dist[q_idx, mask] 1e10 sorted_idx dist[q_idx].argsort() # 按距离从小到大排序 # 根据 sorted_idx 计算 AP 与 Rank-1mask 就是协议的关键。遗漏了它mAP 会虚高好几个点看起来模型很强换一个评测工具立刻露馅。我在复现时会把这份评测脚本单独拎出来跑一遍和项目自带的数据结果做交叉验证确保口径一致后再拿去和论文数字对比。提示评测脚本建议固定下来每次对比模型都用同一份代码否则 mAP 口径不一致数字没有任何可比性。4. 损失与训练triplet loss、center loss 在 train_class 里怎么配合4.1 losses.py三种损失的代码逻辑与适用边界losses.py 是这个项目里技术含量最高的文件。行人重识别的损失函数有一条清晰的演进路线早期只用 softmax 交叉熵把 ReID 当分类问题做后来发现分类损失学到的特征区分度不够引入 triplet loss 做度量学习让同类特征距离变小、异类变大再后来加上 center loss给每个类别维护一个中心向量惩罚特征偏离中心的距离让类内更紧凑。三种损失的代码各有要点。softmax 交叉熵直接调用 torch.nn.CrossEntropyLosstriplet loss 需要自己组织样本标准做法是每个 batch 采样 P 个行人、每个行人 K 张图形成 P×K 结构再为每个 anchor 挑最难的 positive 和最难的 negativecenter loss 要额外维护一个可学习的中心矩阵每个 step 用指数移动平均更新中心。# losses.pyBatch Hard triplet loss 的实现思路 def batch_hard_triplet_loss(anchor_feats, pos_feats, neg_feats, margin0.3): # 输入形状均为 [num_samples, feat_dim] pos_dist torch.cdist(anchor_feats, pos_feats, p2) # anchor 与正样本距离 neg_dist torch.cdist(anchor_feats, neg_feats, p2) # anchor 与负样本距离 hardest_pos pos_dist.max(dim1).values # 最远的正样本 hardest_neg neg_dist.min(dim1).values # 最近的负样本 loss torch.nn.functional.relu(hardest_pos - hardest_neg margin).mean() return lossmargin0.3 是常见起点但这个值的玄学成分不小设太大模型被迫把所有类别距离都拉开训练波动大设太小区分度不足。按我的经验0.2 到 0.4 之间对 ResNet-50 主干都算稳定可以先固定 0.3 跑一个 epoch 看 loss 曲线震荡剧烈就往小调。triplet loss 的采样方式也很关键Batch Hard取最难正样本和最難负样本比 Batch All取所有组合的平均收敛快但更难训练如果起步阶段 loss 不降可以先切到 Batch All 验证数据组织是否正确。center loss 的加入通常能带来 1 到 2 个点的 mAP 提升代价是训练时维护中心的额外开销# losses.pycenter loss 的核心逻辑 class CenterLoss(nn.Module): def __init__(self, num_classes, feat_dim): super().__init__() self.centers nn.Parameter(torch.randn(num_classes, feat_dim)) def forward(self, feat, labels): # 为每个样本取对应类中心计算特征偏离中心的平方距离 centers_batch self.centers[labels] return ((feat - centers_batch) ** 2).sum(dim1).mean()center loss 不能单独用必须和 softmax 或 triplet 联合否则模型会把特征都压到中心点附近丧失区分度。实际项目中更常见的是「softmax triplet」双损失center loss 作为可选项。4.2 train_class.py一个 epoch 里发生了什么train_class.py 是训练主循环把数据加载、模型前向、损失计算、反向传播串起来同时处理 checkpoint 保存和日志记录。这部分工程细节不复杂但有一个直接影响效果的设计数据怎么被组织成 P×K 结构。常见做法是自定义 Sampler先随机挑 P 个行人再为每个行人随机挑 K 张图组成一个 mini-batch。# train_class.py训练循环的核心结构 for epoch in range(start_epoch, max_epoch): model.train() for batch in train_loader: # batch 形状 [P*K, C, H, W] images, pids batch bn_feat, logits model(images) softmax_loss CrossEntropyLoss()(logits, pids) triplet_loss batch_hard_triplet_loss( bn_feat, bn_feat, bn_feat, margin0.3) # 实际实现需要根据 pids 组织正负样本对这里仅示意 loss softmax_loss triplet_loss optimizer.zero_grad() loss.backward() optimizer.step() if (epoch 1) % 10 0: torch.save(model.state_dict(), checkpoint_%d.pth % (epoch 1))两个地方值得展开。第一loss 叠加方式softmax_loss 和 triplet_loss 直接相加权重 1:1如果发现特征区分度不够可以把 triplet 权重提到 1.5 再跑一轮对比。第二torch.save 保存的是 state_dict 而不是整个模型加载时要用上章的 ResNetReID 类先构造再 load_state_dict很多人卡在这一步以为是模型坏了其实是保存方式不匹配。utils.py 在这里通常承担 logger 和 checkpoint 管理保证训练中断后能从最近的 epoch 恢复而不是从头再来。P 和 K 的取值直接决定训练稳定性P8、K4 是常用配置P16、K4 在显存充足时能提升效果。P 太小导致每个 batch 里类别太少triplet 的负样本多样性不足K 太小导致正样本太少很难挖掘难正样本。4.3 超参数总表我复现时用的配置把调试稳定的配置列成一张表方便直接照抄参数取值说明输入尺寸256×128行人框矩形比例P×K8×4batch 内 8 个行人每人 4 张图主干ResNet-50ImageNet 预训练初始学习率0.01配合 warmup学习率调度余弦退火T_max60eta_min0.0001margin0.3triplet loss 间隔损失权重softmax:triplet1:1可上调 triplet 至 1.5训练轮数60-80超过后收益明显下降这组配置在 Market-1501 上通常能跑到 mAP 70 以上、Rank-1 85 以上的基线水平。如果你的结果差很多优先检查 P×K 和学习率这两个参数它们对结果的影响比重最大。还有一个容易被忽略的细节BN 层在 train 和 eval 模式下的行为不同训练时用 batch 统计量评测时用 running mean如果你的测试代码忘记调 model.eval()评测结果会莫名其妙低几个点。5. 避坑手册行人重识别复现中最容易翻车的五个点5.1 数据口径相关的坑尺寸不一致、加载 0 张图、mAP 虚高坑一训练集和 query 图像尺寸不一致mAP 异常低。现象训练 loss 正常下降评测 mAP 始终只有 60 出头怎么调都上不去。原因训练集用了带随机裁剪的增强测试集却忘了做相同尺寸的 Resize特征分布不一致更隐蔽的是把 Resize 写到了 RandomCrop 之后实际送入模型的尺寸根本没对齐。解决保证训练和测试的最终输入尺寸一致严格按「Resize → 增强 → ToTensor → Normalize」顺序执行。评测前用两种 transform 各跑一次同一张图打印张量形状确认相同再继续往下走。坑二Market-1501 加载出 0 张图。现象DataManager 初始化不报错但 len(train_set.images) 是 0训练循环直接跳过。原因数据集目录名大小写不一致dataset_loader 硬编码了 Market-1501 或 market1501路径对不上也可能是 int(parts[0]) 解析失败被 try-except 静默吞掉了。解决先打印数据集目录树确认根目录名再把解析函数单独拿出来对 3 个文件跑一遍看哪些名字解析失败。改路径比改代码快但根因如果是文件名格式不标准那必须修解析函数。坑三评测时把训练集混进 gallerymAP 虚高。现象自己评测的 mAP 比论文高 10 个点换成别人的评测脚本就暴跌。原因query 中的行人 ID 在 gallery 里重复出现且没有按协议排除同一摄像头下的同 ID 样本等于开卷考试。解决严格按 3.3 节的 mask 逻辑排除同摄像头同 ID 样本再用一份公开的评测脚本交叉验证。mAP 虚高是评测口径问题不是模型变强了这一点务必养成先验证评测脚本再信数字的习惯。5.2 训练稳定性相关的坑NaN 和显存溢出坑四triplet loss 训练发散loss 变成 NaN。现象训练几百步后 loss 突变 NaN重跑出现在不同位置像是随机的。原因距离矩阵里出现了 inf 或极大值根源是特征里进了 NaN。通常由学习率过大、batch 太小导致 BN 不稳定、或数据里存在全黑图片引起。解决先看日志确认 NaN 出现时对应的学习率把初始学习率降到 0.005 左右检查 batch 是否小于 16最后在特征输入损失函数前加一个 clamp 兜底把异常值截断到有限区间。注意 clamp 只是兜底根因不解决换个 epoch 还会复发。坑五GPU 显存不足 OOM。现象batch size 设 32 直接 OOM降到 16 才能跑P×K 采样下想加大每个行人的图数也加不了。原因256×128 输入配合 ResNet-50中间特征图占用大P×K 结构下 32 张图比同等 batch 的分类任务吃显存得多。解决先保持 P×K8×4 不变把单张图分辨率降到 224×112 验证显存余量实在不够就换 ResNet-34 主干mAP 通常只掉 2 个点左右显存占用少接近一半。还有一种取巧做法是在主干里冻结前几层只训练后段特征显存占用和带宽都会降。6. 从权重到查询写一个 gallery 检索脚本并核验 mAP6.1 query 推理脚本训练完成后最大的需求是把模型用起来给定一张 query 图从 gallery 里找出同一行人。这个脚本只需要加载模型、提特征、算距离、排序四步。# query.py加载 checkpoint 并对 gallery 做检索 import torch from ResNet import ResNetReID from transforms import test_transform model ResNetReID(num_classes751) model.load_state_dict(torch.load(checkpoint_60.pth)) model.eval() # 必须切 evalBN 行为不同 query_img test_transform(load_image(query.jpg)).unsqueeze(0) with torch.no_grad(): q_feat, _ model(query_img) q_feat torch.nn.functional.normalize(q_feat, dim1) distances ((q_feat - gallery_feats) ** 2).sum(dim1).sqrt() topk distances.argsort()[:10]gallery_feats 需要提前一次性算好并缓存1.5 万张图在单张 V100 上只要几分钟之后每次查询只有一次向量减法的计算量能做到毫秒级返回。这里 model.eval() 不是可选项上一章提过 BN 在 train 模式下用 batch 统计量忘了调 eval查询结果会出现偏差。6.2 验证标准模型有没有达标我习惯看三个数mAP、Rank-1、以及 gallery 规模翻倍时 mAP 的衰减幅度。Market-1501 基线上ResNet-50 softmax triplet 的组合至少应该达到 mAP 70、Rank-1 85。如果用的是自己切的私人数据没有公开标准答案那就回到 3.3 节的评测脚本先确认 mAP 口径没有被「同摄像头排除」规则影响再谈数字。Rank-1 高但 mAP 低的模型说明检索排序不稳定只有前几名靠谱实际部署时体验很差。从那以后我每次把模型集成进新项目都强制走一遍完整流程先跑数据校验看 ID 数和图片数再跑 1 个 epoch 看 loss 是否下降最后用同一份评测脚本核验 mAP三者全过才敢说模型是稳的。这套习惯就是从这个项目练出来的希望帮到你。本文还有配套的精品资源点击获取