简介:本资源面向计算机视觉研究者、算法工程师及具备深度学习基础的学生,提供一套完整的行人重识别(ReID)与图像检索实战项目。项目以深度神经网络提取行人特征为核心,覆盖行人检测、特征提取、特征匹配与排序等关键环节,并针对跨摄像头视角、光照变化及遮挡等难点给出可复现的实现方案,适用于安防监控、智能交通与零售分析等场景。压缩包共94个文件,约632.5MB,以69个Python源码为主体,辅以yml配置文件、pth预训练权重、sh训练脚本及txt说明文档,涵盖数据预处理、模型构建、训练验证、测试部署全流程,并包含Market1501数据集接入与重排序等模块。目前已有462人学习下载。读者可借助完整源码与预训练权重快速搭建实验环境,理解ReID算法细节与图像检索流程,并在此基础上开展算法对比、调优与二次开发。
1. 行人重识别 ReID 到底在解决什么问题:从一张抓拍图到跨镜头的同一人
监控室里最常见的场景:一台摄像机拍到某个穿黑外套的人,三小时后另一台摄像机又拍到类似身影,但角度、光线、遮挡全变了。人眼要花十几分钟比对,而行人重识别(ReID)要做的,就是让算法在几秒内告诉你「这两张图是不是同一个人」。它本质是一个图像检索问题——给定一张查询图(Query),在候选图库(Gallery)里按相似度排序,把最像的排前面。ReID 和普通人脸识别最大的区别在于:人脸识别依赖清晰正脸,而 ReID 用的是整个人体的外观、衣着、姿态、步态等特征,所以它能在人脸拍不清、背对镜头、戴口罩的场景下继续工作。这套方案适合做安防检索、智慧园区轨迹还原、零售客流分析的工程师,也适合想拿一个完整项目练手图像检索 pipeline 的同学。标题里提到的「项目源码」,落地形态通常就是一个「特征提取 + 向量检索 + 评估」的闭环,下面我按这个闭环把每一步拆开讲。
2. ReID 的技术骨架:特征提取、度量学习与检索排序怎么串起来
2.1 为什么 ReID 不是简单的图像分类
很多人第一反应是「训个分类模型,把每个人当一个类别不就行了」。这条路在 ReID 里会翻车,原因是:训练集里的人(ID)和测试集里的人完全不重叠,你分类分得再好,测试时遇到没见过的人照样抓瞎。所以 ReID 的核心不是「认出是谁」,而是「判断两张图是不是同一人」,这是一个度量学习(Metric Learning)问题。常见做法是训练一个 CNN 或 Transformer 主干网络,把每张行人图映射成一个固定维度的特征向量(比如 2048 维),然后让同一个人的向量在特征空间里靠得近,不同人的向量离得远。损失函数一般用三元组损失(Triplet Loss)配合 ID 分类损失联合训练。三元组损失的关键是「难样本挖掘」:从一批数据里挑出最难区分的正样本对和负样本对,逼着网络去学那些容易混淆的细节,比如两个人都穿黑裤子但鞋子不同。
2.2 主干网络与特征维度的选型逻辑
主干网络的选择直接决定精度和速度的平衡。常见做法是:追求精度用 ResNet50-IBN 或更强的 Transformer 结构(如 TransReID),追求部署速度用 ResNet18 或 MobileNet。IBN 模块(Instance-Batch Normalization)对 ReID 特别有用,因为它能缓解不同摄像头之间的风格差异——同一个人的衣服颜色在不同相机白平衡下会偏色,IBN 让网络对颜色偏移更鲁棒。特征维度一般取 2048 或 1024,维度越高表达能力越强但检索越慢。我一般会先把特征做 L2 归一化,这样后续用余弦相似度检索时,内积就等于余弦值,省一次计算。下面是一个最小可跑的特征提取代码骨架,用 PyTorch 写,主干换成你本地已有的权重即可:
import torch import torch.nn as nn import torchvision.models as models class ReIDBackbone(nn.Module): def __init__(self, num_classes=751, feat_dim=2048): super().__init__() # 用 ResNet50 做主干,去掉最后的全连接层 resnet = models.resnet50(pretrained=True) self.backbone = nn.Sequential(*list(resnet.children())[:-1]) # 输出 (B,2048,1,1) self.feat_dim = feat_dim # BNNeck:在特征后加一个 BN 层,缓解 ID 损失和三元组损失的冲突 self.bnneck = nn.BatchNorm1d(feat_dim) self.bnneck.bias.requires_grad_(False) # BN 的 bias 不参与更新 self.classifier = nn.Linear(feat_dim, num_classes, bias=False) def forward(self, x): feat = self.backbone(x).flatten(1) # (B,2048) feat_bn = self.bnneck(feat) # 用于分类的归一化特征 if not self.training: return nn.functional.normalize(feat_bn, dim=1) # 推理时输出归一化特征 cls_score = self.classifier(feat_bn) return feat, cls_score # 训练时返回原始特征和分类分数这段代码里几个参数值得说清楚:num_classes要设成训练集的实际 ID 数,比如 Market1501 是 751;feat_dim跟主干输出对齐,ResNet50 是 2048;BNNeck 的作用是让分类损失和三元组损失不在同一个特征空间里打架,这是很多 ReID 论文验证过的稳定技巧。推理阶段只返回归一化后的特征向量,检索时直接算余弦相似度即可。
2.3 检索排序:从特征向量到 Rank 列表
拿到 Query 和 Gallery 的特征后,检索就是算相似度矩阵并排序。最朴素的是暴力枚举——把 Query 和每张 Gallery 图算一次余弦相似度,复杂度 O(N)。Gallery 上万张时还能接受,上百万张就得换近似最近邻(ANN)方案,比如 Faiss 的 IVF 索引。但 ReID 里有个比暴力枚举更值钱的技巧叫「重排序(Re-ranking)」,核心思想是:如果一张 Gallery 图同时和 Query 以及 Query 的 Top-K 邻居都很像,那它更可能是正确匹配。k 互近邻重排序能把 mAP 提升好几个点,代价是计算量增加。下面是最小检索代码:
import numpy as np import torch def compute_dist(query_feat, gallery_feat): # 特征已 L2 归一化,内积即余弦相似度 sim = torch.mm(query_feat, gallery_feat.t()) # (Q, G) return sim def rank_list(sim, topk=10): # 按相似度从高到低排序,返回索引 values, indices = torch.sort(sim, dim=1, descending=True) return indices[:, :topk] # 假设 query_feat 和 gallery_feat 都是 (N,2048) 的归一化张量 sim = compute_dist(query_feat, gallery_feat) topk_idx = rank_list(sim, topk=10) print("Top-10 候选索引:", topk_idx[0].tolist())compute_dist用矩阵乘法一次性算完所有 Query 和 Gallery 的相似度,比循环快几个数量级。rank_list返回每张 Query 的 Top-K 候选索引,实际评估时拿这个索引去和真实标签比对,算 Rank-1 和 mAP。注意特征一定要先归一化,否则相似度会被特征模长带偏。
3. 用 Market1501 跑通训练与评估:数据组织、损失配置与指标解读
3.1 数据集目录结构与 DataLoader 的坑
ReID 最常用的公开数据集是 Market1501,它包含 6 个摄像头、1501 个行人 ID、三万多张图。目录结构一般是Market-1501-v15.09.15/bounding_box_train、bounding_box_test、query三个文件夹,文件名格式是0002_c1s1_000451_03.jpg,其中0002是行人 ID,c1是摄像头编号。写 DataLoader 时有两个坑:第一,训练集要按 ID 采样,保证一个 batch 里每个 ID 至少出现 4 张图,否则三元组损失找不到正样本对;第二,测试集的bounding_box_test里包含-1开头的干扰图(junk images),评估时要过滤掉。常见做法是用一个自定义 Sampler:
import os from torch.utils.data import Sampler import random 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 # 按 ID 分组 self.index_dic = {} for idx, (img_path, pid, camid) in enumerate(data_source): self.index_dic.setdefault(pid, []).append(idx) self.pids = list(self.index_dic.keys()) self.num_pids_per_batch = batch_size // num_instances def __iter__(self): batch_idxs_dict = {} for pid in self.pids: idxs = self.index_dic[pid].copy() if len(idxs) < self.num_instances: idxs = random.choices(idxs, k=self.num_instances) random.shuffle(idxs) batch_idxs_dict[pid] = [idxs[i::self.num_instances] for i in range(self.num_instances)] # 每个 epoch 重新打乱 avai_pids = self.pids.copy() random.shuffle(avai_pids) final_idxs = [] for pid in avai_pids: final_idxs.extend(batch_idxs_dict[pid].pop(0)) return iter(final_idxs) def __len__(self): return len(self.data_source)num_instances=4表示每个 ID 在一个 batch 里抽 4 张图,batch_size一般设 64 或 128,这样每个 batch 覆盖 16 或 32 个 ID。这个 Sampler 是 ReID 训练能收敛的前提,少了它三元组损失基本学不动。
3.2 损失函数配置:ID Loss 与 Triplet Loss 的权重
训练时一般用两个损失相加:ID 分类损失(交叉熵)和三元组损失。权重上,ID Loss 权重设 1.0,Triplet Loss 权重设 1.0 是常见起点。三元组的 margin 参数一般设 0.3,太小了学不到区分性,太大了训练不稳定。下面是一个简化的训练循环:
import torch.nn.functional as F def train_one_epoch(model, dataloader, optimizer, criterion_id, criterion_tri, device): model.train() for imgs, pids, camids in dataloader: imgs, pids = imgs.to(device), pids.to(device) feat, cls_score = model(imgs) loss_id = criterion_id(cls_score, pids) loss_tri = criterion_tri(feat, pids) # 三元组损失,内部做难样本挖掘 loss = loss_id + loss_tri optimizer.zero_grad() loss.backward() optimizer.step()criterion_tri可以用torchreid里的TripletLoss,它内部实现了 batch-hard 挖掘。注意feat是 BNNeck 之前的原始特征,cls_score是 BNNeck 之后的分类分数,这样两个损失各取所需。
3.3 Rank-1 与 mAP 怎么算才不虚高
评估指标里 Rank-1 是「Top-1 命中率」,mAP 是「平均精度均值」。算 mAP 时要对每张 Query 算 AP,再对所有 Query 取平均。这里有个容易虚高的点:如果 Gallery 里同一个 ID 有多张图,命中一张就算对,但 AP 会考虑所有正确匹配的排序位置。常见做法是过滤掉同摄像头且同 ID 的图(因为同摄像头同 ID 可能是同一张图的副本),只保留跨摄像头匹配。评估代码骨架:
def evaluate(sim, query_pids, query_camids, gallery_pids, gallery_camids): indices = torch.sort(sim, dim=1, descending=True)[1] all_AP, all_R1 = [], [] for i in range(sim.shape[0]): order = indices[i] # 过滤同摄像头同 ID 的干扰项 keep = ~((gallery_pids[order] == query_pids[i]) & (gallery_camids[order] == query_camids[i])) order = order[keep] matches = (gallery_pids[order] == query_pids[i]).float() if matches.sum() == 0: continue # Rank-1 all_R1.append(matches[0].item()) # AP cum = torch.cumsum(matches, 0) precision = cum / torch.arange(1, len(matches)+1, device=matches.device) AP = (precision * matches).sum() / matches.sum() all_AP.append(AP.item()) return sum(all_R1)/len(all_R1), sum(all_AP)/len(all_AP)keep那行是关键,它把同摄像头同 ID 的 Gallery 图排除掉,避免「自己匹配自己」导致指标虚高。跑完一轮训练后,Rank-1 能到 90% 以上、mAP 到 75% 以上,说明 pipeline 基本正常。
4. 避坑与排查:ReID 项目里最容易翻车的 5 个地方
4.1 现象:训练 loss 不降,Rank-1 一直在 1% 左右
原因:最常见的是 DataLoader 没按 ID 采样,一个 batch 里全是不同的人,三元组损失找不到正样本对,梯度几乎为零。解决:换成RandomIdentitySampler,确保每个 batch 至少覆盖 4 个 ID、每个 ID 至少 4 张图。如果换了还不降,检查特征是否做了 L2 归一化,没归一化时余弦相似度会被模长主导。
4.2 现象:训练集精度很高,测试集 Rank-1 很低
原因:过拟合到训练集的 ID 和摄像头风格。解决:加数据增强(随机擦除 Random Erasing、颜色抖动),加标签平滑(Label Smoothing),或者换 IBN 主干。另外检查测试时是否误用了训练集的 BN 统计量,推理时要用model.eval()固定 BN。
4.3 现象:检索结果里同一张图反复出现
原因:Gallery 里有重复图或高度相似的连续帧。解决:评估前对 Gallery 做去重,或者用 k 互近邻重排序把「和 Query 邻居也像」的图排前面,抑制孤立的高相似度噪声。
4.4 现象:显存爆了,batch_size 上不去
原因:ReID 输入分辨率一般是 256x128,比分类的 224x224 宽高比不同,显存占用更高。解决:用混合精度训练(AMP),或者把主干换成 ResNet18,或者用梯度累积模拟大 batch。注意 batch_size 小于 16 时 BN 统计量会不准,这时候可以冻结 BN 或换 Instance Norm。
4.5 现象:换了自己的数据集后,ID 数对不上,分类层报错
原因:num_classes写死了 Market1501 的 751,自己的数据集 ID 数不同。解决:训练前先扫一遍训练集目录,统计唯一 ID 数,动态设置num_classes。另外自己的数据集如果每个 ID 只有两三张图,三元组损失很难训,建议先做数据清洗或改用对比学习方案。
5. 把 ReID 推到可用:重排序调参、特征融合与部署前验证
重排序是 ReID 里性价比最高的提点手段,但参数调不好反而掉点。k 互近邻重排序有两个关键参数:k1 控制「Query 的邻居范围」,k2 控制「Gallery 的邻居范围」,常见起点是 k1=20、k2=6,lambda 设 0.3。调参时先固定 k2 和 lambda,把 k1 从 10 扫到 40,看 mAP 变化;再固定 k1 扫 k2。注意重排序对 Gallery 规模敏感,Gallery 小于 1000 张时提升不明显,上万张时能提 3 到 5 个点 mAP。
特征融合是另一个实用技巧:把全局特征和局部特征拼起来。全局特征用整张图提,局部特征把图水平切成 6 条,每条单独提特征再拼。这样对遮挡和姿态变化更鲁棒,代价是特征维度翻几倍,检索变慢。我一般只在精度不够时上局部特征,日常用全局特征加 BNNeck 就够了。
部署前验证有个血泪经验:一定要用真实摄像头抓的图跑一遍,别只看 Market1501 的指标。公开数据集是裁剪好的行人框,真实场景里检测框会抖、会截断、会包含背景。我一般会拿 50 张真实抓拍图做 Query,人工标 200 张 Gallery 的匹配关系,跑一遍看 Rank-5 能不能到 80% 以上。如果掉得厉害,优先检查检测框质量,而不是急着换模型。
最后说个我自己的习惯:每次改完代码,先跑一个 10 个 ID 的小子集,确认 loss 能降到接近 0、检索能命中,再上全量数据。这个「小数据冒烟测试」帮我省过无数次通宵排查。ReID 这个方向,模型结构固然重要,但数据组织、采样策略和评估细节往往才是决定成败的地方。希望帮到你。
本文还有配套的精品资源,点击获取