ARTICLE DETAIL

资讯详情

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

ReID行人重识别实战:从图像检索到重排序的完整指南

ReID行人重识别实战:从图像检索到重排序的完整指南 简介行人重识别ReID与图像检索实战项目面向计算机视觉研究者与中级以上开发者解决监控场景下跨摄像头行人的识别、匹配与检索难题。资源包共94个文件大小632.5MB以69个Python源码为主干从数据预处理、网络模型构建到训练、验证、测试及部署均有对应实现另有YAML配置、预训练权重、Shell训练脚本与说明文档便于快速复现与二次开发。项目基于深度学习与度量学习提供多套ReID训练策略涵盖ResNet50、SE-ResNeXt50等主干网络及中心损失、三元组损失等要点并集成图像检索与重排序模块。已有462人学习下载。通过学习可获得完整可运行的工程理解行人检测、特征提取、特征匹配与排序全链路同时掌握跨视角、光照变化下的调参思路为学术实验或安防、智慧零售等产业落地提供扎实范本。1. 行人重识别到底在解决什么问题从“认脸”到“认衣橱”如果你接过任何监控场景的项目大概率碰到过这种窘境目标背对摄像头正脸只有十几像素人脸识别直接罢工但你要找的人穿着红色外套、背着双肩包从A摄像头走到B摄像头。这种事就是行人重识别-ReID行人重识别算法图像检索实现最典型的用武之地。ReID不认脸它认的是全身外观——衣服、背包、体型、步态这些视觉线索把一张查询图放进过去几小时甚至几天的图库里按相似度把同一行人的所有出现捞出来。你再回头看这类源码包会发现它本质上不是目标检测项目而是图像检索项目训练一个特征提取器把图库压成全库特征向量查询时做一次最近邻搜索。适合谁准备做安防、智慧零售、无人店巡场或者想在校招简历上放一个完整ReID实战的人。这篇文章不给你讲PPT直接讲把它跑起来、调出效果要过的坎。2. 把ReID当成图像检索来做特征提取、度量学习与重排序2.1 行人重识别为什么不是“人脸识别的亲戚”ReID和face recognition在任务定义上都是同一个身份匹配但技术路线差得很远。人脸识别做的是人脸关键点对齐后提特征ReID面对的是全身图像姿态变化、遮挡、分辨率低、跨摄像头光照不一致没有稳定的关键点可以做。另一个差别是识别粒度人脸通常要求闭合集分类而ReID是开放集检索——图库里可能根本没有query这个人模型必须学会描述一个人的外观而不是记住一个ID。所以ReID模型最后输出的不是一个softmax概率而是一个固定维度的embedding向量比如2048维降成512维再归一化到单位球面上。这个embedding就是整条链路的语言。训练时用三元组损失或身份损失让同一个ID的特征向量尽量靠近不同ID尽量远离。检索时query向量和gallery向量做内积分数越高越相似。整个过程更像搜索引擎而不是分类器这也是为什么很多ReID工程包里的核心代码不叫detect.py而叫train.py、test.py、feature_extraction.py。公开数据集方面reid数据集最常用的是Market1501、DukeMTMC-reID和MSMT17。Market1501规模小、标注干净适合用来验证你手里的源码能不能跑通MSMT17规模大、场景复杂适合用来压榨模型上限。评价指标主要看两个Rank-1和mAP。Rank-1只看第一张对不对mAP则把整张排序列表都算进去更严格。如果你调完一个模型发现Rank-1很高、mAP上不去说明模型能认对人但对难样本的排序不稳定后面重排序章节会专门讲这个。2.2 训练一个ReID模型需要什么triplet、ID loss和P×K采样ReID模型的骨干网大多沿用ImageNet分类网络ResNet50至今还是最稳的baseline轻量的用MobileNetV3追求精度可以换OSNet、ViT-Base。关键不在骨干而在训练策略。常见训练目标由两部分组成交叉熵ID loss加三元组损失。ID loss把每个行人当作一个类别让模型学会区分不同ID收敛快triplet loss拉近正样本、推远负样本让特征对同一个行人不同摄像头下的变化有鲁棒性。两个损失一般叠加使用权重各取1.0。这里有个几乎每个ReID训练配置都会有的参数P×K采样。每次iteration随机挑P个行人每个行人再挑K张图batch_size等于P乘K。这样能保证一个batch里同时出现正负样本供triplet计算。常见取值P16、K4batch_size64。不要直接改一个batch_size128因为随机抽样很可能让同一个人的图都挤在一个batch里triplet没得算loss会乱跳。输入尺寸默认256×128因为行人图像通常是竖长的强行缩成正方形会拉伸体型。训练时的另一个关键设计是BNNeck。做法是特征向量先过一层BatchNorm再送进分类器训练时ID loss在BN之前取特征triplet loss在BN之后取特征推理时统一使用BN之后、L2归一化之前的输出。这样做的好处是避免分类损失过拟合triplet又能在单位球面上起作用。你拿到源码后如果发现训练Loss正常但检索效果差大概率是在推理阶段取了错误的特征分支这个坑后面避坑章节还会提。2.3 用预训练reid模型抽取特征最小可运行的Python代码不管你是要验证手里的源码包还是想自己搭一个demo第一步永远是先跑通“图片到特征向量”这条路。下面这段代码演示了加载一个ReID模型并对单张图片提取512维特征。实际项目中你只需要把权重路径换成zip包里的weights路径即可。import torch import torch.nn as nn from torchvision import models, transforms from PIL import Image class ReIDNet(nn.Module): def __init__(self, feat_dim512): super().__init__() # 只用ResNet50卷积部分丢掉全局池化和分类头 backbone models.resnet50(weightsNone) self.base nn.Sequential(*list(backbone.children())[:-2]) self.global_pool nn.AdaptiveAvgPool2d((1, 1)) self.fc nn.Linear(2048, feat_dim) self.bn nn.BatchNorm1d(feat_dim) def forward(self, x): x self.base(x) x self.global_pool(x).flatten(1) feat self.fc(x) # 推理阶段用BN后的特征 feat self.bn(feat) return torch.nn.functional.normalize(feat, p2, dim1) model ReIDNet() state_dict torch.load(./weights/reid_model.pth, map_locationcpu) # 很多权重带DataParallel前缀需要剥掉 state_dict {k.replace(module., ): v for k, v in state_dict.items()} model.load_state_dict(state_dict) model.eval() 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]) ]) img Image.open(./data/Market1501/query/0001_c1s1_001051_00.jpg).convert(RGB) x transform(img).unsqueeze(0) with torch.no_grad(): feat model(x).cpu().numpy().flatten() print(feat.shape) # (512,)权重路径和图片路径改成你电脑上真实存在的路径别照抄。代码里有一个非常值得注意的地方我加了一层BatchNorm1d而不是直接归一化。ReID模型在推理时必须处在model.eval()状态否则BatchNorm会继续用当前batch的统计量同一个query每次提取的特征都不一样eval()之后用的是训练阶段累积的running mean和running variance。另一个细节是DataParallel前缀因为很多训练脚本是多卡跑的保存的state_dict里key都带着module.加载前必须先做strip。如果你的项目包用的是OSNet或者ViTReIDNet结构会不同但调用方式一样加载state_dict、置eval、过预处理、得到归一化向量。建议先跑通这一段再去折腾训练。因为后面所有检索逻辑都建立在能稳定拿到向量这个前提上。3. 搭一套可用图像检索系统从特征库到Top-K返回3.1 特征库怎么存先别急着上向量数据库拿到一个训练好的reid模型第一件事就是把图库里所有图片过一遍模型把特征向量存下来。很多初学者第一步就想着接Milvus、FAISS其实没必要。ReID项目图库规模通常是几千到几万张一张图一个512维float32向量一万张才20MB左右numpy矩阵直接能放下检索一次矩阵乘法几毫秒。内存够的情况下我一般用两个文件gallery_feats.npy保存全部向量gallery_paths.txt按相同顺序保存图片路径。检索时用np.load读进来顺序一致就不会错乱。什么时候需要FAISS图库超过10万张、检索延迟要求几十毫秒、或者数据要持续增量这时用numpy全量暴力计算也能跑但为了后续扩容可以切换到FAISS的IndexFlatIP。它的检索结果和numpy点积完全一致是精确索引不是近似索引不会损失召回。先别上向量数据库的原因还有一个小型ReID项目最常见的是单机跑Python脚本引入数据库之后要多维护一个服务排查问题也多一层。等gallery真正到了百万级再考虑Milvus也来得及。下面是方案选型按图库规模做参考。图库规模方案单次检索耗时维护成本1万以下numpy矩阵argsort毫秒级零1万到10万FAISS IndexFlatIP毫秒级低10万以上FAISS或Milvus毫秒到几十毫秒需要部署这里要强调的是不要一上来就用IVF系列近似索引nlist和nprobe调不好召回掉的比性能省的还多。3.2 检索链路query预处理、特征比对、排序与可视化特征库建好剩下的检索逻辑其实很朴素query图片提特征和gallery全部向量算相似度排序后取Top-K。下面这段代码就是一个最小可用的检索脚本gallery规模不大直接用矩阵乘法。import numpy as np # 图库特征与路径路径顺序必须和npy的行顺序一致 gallery_feats np.load(./gallery_feats.npy) # shape: (N, 512) gallery_paths [p.strip() for p in open(./gallery_paths.txt)] # 假设extract_feature就是2.3节写好的函数 q_feat extract_feature(model, ./query/0001_c1s1_001051_00.jpg) # 特征已经L2归一化内积越大越相似转成距离升序排序 sims np.dot(gallery_feats, q_feat) topk 10 inds np.argsort(-sims)[:topk] for rank, idx in enumerate(inds, 1): print(rank, gallery_paths[idx], float(sims[idx]))sims是gallery的每一行和query做点积得到N维向量。argsort(-sims)表示按相似度从高到低取前10。这里的query和图库特征必须来自同一个模型、同一套预处理否则相似度分数没有可比性。模型更新或者换了图库之后一定要重新生成gallery_feats.npy这是最容易被忽视的脏数据来源。可视化不是可选项。把query图和Top-K结果画在一张图上能直观看到错误是什么类型是同一摄像头下的目标没召回还是跨摄像头下衣服颜色相近的干扰项排在前面。推荐用matplotlib画一个2行5列的网格第一格放query其余放检索结果每张图下方标距离分数和排名。不要只打印文件名文件名里很少有足够信息。3.3 参数怎么调距离度量、阈值与候选集大小特征归一化之后余弦距离、欧氏距离、内积三者排序结果在数学上是等价的但工程上有一个差别如果你在预处理里没有做归一化内积会受特征模长影响模长的信息对ReID有时是有用的但多数项目里模长不稳定所以标准做法还是先归一化再算内积。欧氏距离在这种场景下也可以只是分数越小越相似阅读代码时容易搞混建议统一用内积或余弦相似度。如果你除了排序之外还要做“这个人在不在图库里”的判定就需要阈值。怎么定不要拍脑袋。常见做法是拿一部分验证集对每个query算它和图库的相似度标出正确匹配和错误匹配的分数分布画一条ROC曲线取误报率可接受的平衡点。有的项目里还会做“如果Top1分数低于0.5就判为未命中”这个0.5需要你在自己的数据上重算不同场景差异很大。一个阈值在Market1501上能用换到自己监控数据大概率不管用。候选集大小K这个参数在纯检索阶段似乎不重要但如果你后面要做重排序K就会直接影响效果。K太大会引入大量噪声太小会让重排序视野不足常见取50到100。我见过一个翻车现场有人把K从50改成500重排序之后mAP反而降了4个点原因是Top500里大部分是无关样本重排序算法把那些样本也纳入了计算。后面第6章会解释为什么K不是越大越好。4. 把项目源码跑起来的完整流程解压、环境配置与踩坑4.1 拿到zip之后先别急着解压检查完整性与伪加密网上下载的ReID项目包大概率是一个zip压缩包很多人在第一步就翻车。最常见的问题是解压到一半报bad CRC或者明明没有设置密码却弹窗要求输入密码。后者很多属于zip伪加密不是真的加密而是文件头里一个标志位被改成了1。常见于某些打包工具或者从网盘转存后比特错位。判断方法很简单用Python读一下zip文件头里的加密标志位import zipfile with zipfile.ZipFile(ReID_project.zip, r) as zf: for info in zf.infolist(): print(info.filename, hex(info.flag_bits), encrypted:, bool(info.flag_bits 0x1))如果输出里flag_bits包含0x1但文件内容又看不出加密痕迹多半是伪加密。解决起来也直接找一个能正确识别伪加密的工具重写文件头或者再找一份重新打包的原始zip。这里不推荐用任何暴力破解工具原因有两个一是大多数伪加密本来就不用破解二是对真实加密干这种事有版权和安全风险。我自己的习惯是确认zip从下载到本地中间没有断点再决定要不要修。完整性检查在解压之前做一次能帮你区分是项目本身有问题还是文件没下全。Linux和macOS上可以用unzip -t ReID_project.zip测所有文件的CRCWindows上WinRAR和7-Zip也有测试功能。如果不先做这一步后面训练跑一半说某个配置文件不存在你会浪费大量时间在排查源码是不是缺文件。4.2 环境配置PyTorch版本、依赖冲突与CUDA匹配ReID项目的依赖通常不复杂最核心是PyTorch和torchvision。拿到源码后先看根目录有没有requirements.txt有就逐个装同时注意两点第一opencv-python和opencv-contrib-python不能同时装ReID代码里一般只用基础的imread、resize装opencv-python就够了第二scipy版本会影响某些重排序实现如果你用的是开源re-ranking代码老版本依赖scipy 1.x新版本不一定兼容。遇到属性不存在这类报错优先查依赖版本而不是改源码。更麻烦的是CUDA和PyTorch的匹配。别贪新先跑nvidia-smi看驱动支持的最高CUDA版本再到PyTorch官网选择对应版本安装。装完之后用下面三行验证python -c import torch; print(torch.__version__) python -c import torch; print(torch.cuda.is_available()) python -c import torch; print(torch.cuda.get_device_name(0))只要第二个输出True第三个能打印显卡型号基本就通了。我建议用conda环境隔离不要直接pip install到base环境因为ReID项目经常和目标检测、跟踪项目混在一台机器上依赖互相覆盖是血泪教训。创建环境conda create -n reid python3.8 -y conda activate reid pip install -r requirements.txt如果requirements.txt不存在按这个最小集合装torch、torchvision、numpy、scipy、opencv-python、tqdm、pyyaml。装完先跑一个最小推理不要急着跑训练。4.3 跑通训练与评估的最小命令绝大多数ReID项目包的结构都是configs目录存YAML配置train.py和test.py在根目录data目录放数据集。你需要的reid数据集最常见的是Market1501下载解压之后目录要整理成代码能认出的结构。Market1501的标准布局是这样的data/Market1501/ ├── bounding_box_train/ ├── bounding_box_test/ ├── query/ └── 其他标签文件很多配置里还会要求meta.json或者train_path、query_path字段你只要把YAML里指向数据集的路径改对就行。训练命令在项目包首页一般有写没有的话按这个套路试python train.py --config configs/market1501.yml常见config里需要关心的参数是这几个。批量大小batch_size建议保留P×K的结构不能只改一个数字学习率在3e-4到6e-4之间比较常见训练轮数epochs从60到120不等输入尺寸height和width一般是256和128如果你的显卡显存不够先缩到224×112或者192×96但要注意这会同时降低精度。YAML里大概长这样data_root: ./data/Market1501 batch_size: 64 p_size: 16 k_size: 4 height: 256 width: 128 lr: 0.0003 epochs: 80如果项目脚本里没有p_size和k_size通常就只调batch_size但要确认训练代码的采样器是正常的。评估命令一般是python test.py --config configs/market1501.yml --resume ./checkpoints/model_best.pth这里的model_best.pth是训练过程中隔多少epoch保存的权重别用最后一次epoch的权重。很多时候最后一次epoch已经过拟合保存条件里应该选择在验证集上mAP最高的那次而不是训练Loss最低的那次。训练完如果test.py只输出Rank-1和mAP自己再确认一下数值是不是和项目README里宣称的接近。如果差很多先别骂代码回看2.3节提到的推理分支是否取错以及预处理是否一致。5. ReID训练与检索调优避坑五个实战翻车现场5.1 现象1Loss降到0.2但mAP只有0.3训练日志里总损失一路降到0.2看着很漂亮但test.py输出mAP只有0.3Rank-1勉强0.6。原因大多是这类模型同时有ID Loss和Triplet LossID loss收敛快、数值低会掩盖triplet还在挣扎。更深层的原因是训练和推理用的特征分支不一致训练时用了BNNeck的BN后特征做triplet推理时却拿了BN前或分类层的logits。解决方法是找到训练脚本里提取特征做测试的那一行确认它用的是同一个branch。一般代码里会写feat self.bn_neck(embedding)推理时也要完整走这一层不能直接拿backbone输出。如果确认分支没问题就调大triplet loss的权重或margin从0.3调到0.5试试。5.2 现象2换一台机器检索结果面目全非同一个模型文件、同一个query和gallery在自己电脑上mAP 0.85拷贝到同事机器上变成0.6甚至更差。第一嫌疑是图片预处理不一致OpenCV读图是BGRPIL读图是RGB如果代码里没统一特征就会发生通道错乱第二嫌疑是PyTorch版本差异带来BatchNorm算法变化另外数据增强在推理时如果没关也会随机扰动输入。解决方法是写死预处理顺序凡是涉及颜色通道的都用PIL或cv2.cvtColor统一成RGB在推理脚本开头加两句model.eval()和torch.no_grad()不要依赖外部调用。如果想彻底避免这类差异用torch.jit.trace把模型和预处理一起固化成TorchScript或者导出ONNX。这是一份后悔药值得准备。5.3 现象3Top10里同一个人的多个目标却排不到前面检查检索结果时发现某摄像头拍到了query同一个人的背影但它排在20名之后排在Top10的是好几个衣服颜色相近的干扰项。原因是对单张全局特征丢了细节尤其是跨摄像头视差大、遮挡多的图。全局特征只有一维很难同时兼顾衣服颜色和背包形状等多个线索。常见做法是在提取特征时做多尺度推理和水平翻转融合。把query图片缩放成3个尺寸分别过模型特征相加再归一化再配合翻转特征通常能将Rank-1提升1到3个点。这个trick在训练时没有体现但在推理时是白捡的收益。5.4 现象4显存不够batch_size调到8还是OOM一个batch 16都跑不起来改成8还是爆显存。ReID训练中P×K采样决定了batch里身份数量如果P8、K4batch_size是32但backbone是ResNet50256×128输入在8G显卡上32确实紧张。更隐蔽的是混合精度没开还有PyTorch的显存缓存碎片。解决方法是先打开torch.backends.cudnn.benchmark True再把学习率降到原来的0.5倍以下仍不行就把P和K同时减半P8、K4比P4、K8要稳定因为triplet需要足够多的负样本ID。如果项目支持AMP混合精度直接开--fp16显存占用通常能降到原来的60%。不要单纯把batch_size减到很小那会导致BN统计量不稳定。5.5 现象5zip解压报CRC错误压缩包提示输入密码你下载的项目zip在Windows资源管理器里双击弹出要密码用命令行unzip则提示bad CRC根本解不开。原因可能是伪加密也可能是下载不完整。伪加密的flag bit设置为1但文件本体没有加密典型表现是压缩软件弹密码框但同一文件用zipfile读取时不需要password。解决方法是先用4.1节的小脚本打印所有文件的flag_bits如果只有flag_bits的第0位为1而文件内容没有加密头说明是伪加密此时换一个支持修复文件头的解压工具重写zip或者直接重新下载。遇到坏CRC则直接重新下载不要修。把CRC错误当伪加密去折腾是浪费时间。这种zip伪加密问题经常成为项目包跑起来的第一道门槛值得记进笔记。6. 用重排序把检索结果再拉高几个点6.1 重排序原理前面说mAP和Rank-1卡住时重排序是最后一道工序。核心思想是候选集里如果两个图同时出现在彼此的Top-K列表中它们更大概率属于同一个人。k-reciprocal编码就是取“我能在你的Top-K里、你也能在我的Top-K里”的双向邻居再用Jaccard距离代替原始特征距离。好处是它能利用gallery内部的近邻结构弥补query单张图信息不足。6.2 一个可抄作业的重排序实现下面是最简的k-reciprocal重排序原版来自ReID社区经典的re-ranking方法我做了剪枝只保留最核心的几步。import numpy as np from scipy.spatial.distance import cdist def k_reciprocal_rerank(q_feat, g_feats, k20): # 余弦距离越小越相似 dist cdist([q_feat], g_feats, metriccosine)[0] init_rank np.argsort(dist) # query到gallery的top-k集合 q_g_k set(init_rank[:k].tolist()) # 为了简化只对排序前100的候选做gallery内部邻居 candidate init_rank[:100] sub_g g_feats[candidate] sub_dist cdist(sub_g, sub_g, metriccosine) sub_rank np.argsort(sub_dist, axis1) scores np.zeros(len(candidate)) for i, idx in enumerate(candidate): gi_topk sub_rank[i][:k].tolist() # 双向都在对方topk才保留 if idx in q_g_k: scores[i] 1.0 - sub_dist[0][i] return candidate[np.argsort(-scores)]这个实现压缩得很狠实际项目里还会对距离做Jaccard融合和local query expansion但意思到了。参数k是最敏感的常见范围是10到30不要超过50。跑完重排序之后和原始排序做对比重排序应该能提高mAP几个点同时Rank-1可能变化不大。如果重排序后效果变差检查k是否太大以及是否对gallery内部特征做了正确归一化。6.3 验证重排序效果的技巧看效果别只看mAP。把重排序前和重排序后的失败案例打印出来重点观察那些原本Top10里混入颜色相近干扰项的query重排序是否把它清掉了。重排序处理不了两种问题一是gallery中完全没有query这个人二是gallery里同一个人只有一张图且拍摄视角差异巨大。所以它只能作为最后一步不能替代训练。我的习惯是每次改完重排序固定抽20个query手动看一遍前后对比再决定要不要上线。这个习惯救了我不止一次因为有一次我调出一个mAP上涨2个点的参数实际排查发现是把所有结果压到同一类导致的假涨。做ReID项目最大的心得是重型网络和先进loss只决定上限预处理、推理分支、重排序这三个环节才是把上限兑现成可交付结果的关键。希望帮到你。本文还有配套的精品资源点击获取
返回列表