ARTICLE DETAIL

资讯详情

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

YOLOv3行人检测与ReID重识别:从框选到跨摄像头追踪的完整实践

YOLOv3行人检测与ReID重识别:从框选到跨摄像头追踪的完整实践 简介面向图像识别与行人检索方向的开发者该项目整合了YOLOv3目标检测与行人重识别ReID模型提供完整的行人检测搜索实现方案。资源包含person_search_demo-master项目压缩包共49个文件以27个Python脚本为核心覆盖模型定义、检测与检索推理、数据配置等环节另有19张示例图片用于测试效果以及cfg、names、data格式的配置与类别文件包体约5.51MB轻量易部署。目前已有1358人学习下载。通过阅读源码与运行demo可掌握YOLOv3候选框生成、ReID特征提取与相似度匹配的完整流程并理解如何将检测与重识别串联实现跨镜头特定行人查找。适合具备一定深度学习基础、希望实战目标检测与ReID结合的算法工程师及研究者。1. YOLOv3结合行人重识别从“检测到人”到“认出是谁”的完整链路在监控视频里查找特定行人单靠 YOLOv3 做行人检测只能回答哪里有人要回答“这是不是我要找的人”还得靠行人重识别模型。人脸在远距离、逆光或低分辨率下基本不可用而衣服颜色、体型、背包、发型这些整体外观反而更稳定。这个题目把两件事串起来先用 YOLOv3 把画面里的行人框出来再用行人重识别模型ReID把同一个人的不同抓拍映射成同一段特征向量。检测负责“哪儿有人”重识别负责“这是谁”。两者一前一后就是一条完整的图像识别链路。这套方案适合做人工智能课程设计、算法岗笔试项目也适合在智慧安防场景里做跨摄像头行人检索的研发人员。2. 先立理论YOLOv3检测与行人重识别模型的职责边界2.1 为什么把检测和重识别拆成两段而不是塞进一个模型端到端模型当然存在比如把检测和身份识别放进同一个输出头里但工程上很少这么做。原因在于检测框本身是结构化输出身份特征又需要很强的判别性两个任务的数据分布并不一致。检测框稍微偏一点身份特征就会被背景污染反过来为了学身份而去修改检测特征又会让锚框回归变差。两段式的好处是检测模型可以针对遮挡、密集场景单独调优ReID 模型可以针对跨摄像头光照变化单独训练两者互不拖累。另一个实际理由是缓存。视频检索场景里大量帧是重复的两段式允许只对关键帧做 YOLOv3 检测检测到的裁剪图统一离线提取特征入库后查询阶段不再碰原图。如果做成一个端到端模型每次查询都要重新过一遍检测和分类计算成本翻倍。所以常见做法是把检测和重识别当成两个独立服务中间用“行人裁剪图”作为数据接口。2.2 YOLOv3的锚框、特征金字塔与三个尺度输出YOLOv3 把输入图划分成网格每个网格负责预测落在它范围内的目标。它用特征金字塔结构输出三个尺度的特征图13×13、26×26、52×52。每个网格预设 3 个锚框所以一张 416×416 的输入图总共产生(13×1326×2652×52)×310647个候选框。不同尺度对应不同感受野大尺度特征图负责小目标小尺度负责大目标。做行人检测时下面这张表能说明三者的关系输出层特征图尺寸适合目标行人场景里的作用yolo_8213×13大目标近距离行人全身占画面 1/3 以上yolo_9426×26中等目标常规监控距离下的行人yolo_10652×52小目标远距离行人或密集人群中的单个人这里最容易被忽略的是锚框尺寸。YOLOv3 默认锚框是在 COCO 数据集上聚类出来的COCO 里包含大量猫狗车辆行人是高瘦目标长宽比通常在 1:2 到 1:3 之间。如果你直接拿默认锚框跑行人检测远距离小目标的召回率会偏低。我一般会在行人数据集上用 k-means 重新聚类锚框然后把 cfg 文件里的anchors替换掉。这个改动对 mAP 的提升通常在 2 到 4 个点比换骨干网络更划算。2.3 行人重识别如何用三元组损失训练“这个人和那个人不同”ReID 模型本质上做的是度量学习。训练时每组样本包含一个锚点Anchor、一个正样本同一人和一个负样本不同的人三元组损失强制要求锚点与正样本的距离小于与负样本的距离并留出一个间隔。PyTorch 里的实现非常短但参数含义要理解透彻import torch import torch.nn as nn class TripletLoss(nn.Module): def __init__(self, margin0.3): super().__init__() self.margin margin def forward(self, anchor, positive, negative): # 三个输入形状都是 [batch, dim] pos_dist torch.sum((anchor - positive) ** 2, dim1) neg_dist torch.sum((anchor - negative) ** 2, dim1) # 当 pos_dist 比 neg_dist 小 margin 时不产生损失否则取正值 loss torch.relu(pos_dist - neg_dist self.margin) return loss.mean()margin是控制正负样本对间距的阈值一般取 0.2 到 0.5。取太大模型会过度拉大不同人之间的差距导致特征空间被撑开同类样本被压缩成一团取太小同类和异类的边界不明显检索时容易混入大量误检。实际训练时不会只依赖三元组损失还会加上交叉熵分类损失称为 ID loss triplet loss 联合训练。交叉熵让模型具备分类能力三元组让特征在实例级别有区分度。推理阶段全连接层输出的特征向量先做 L2 归一化再算余弦相似度这样距离计算就和向量模长无关不同摄像头下的亮度差异不会直接放大成特征的模长变化。3. 本地跑通的实现路径用 OpenCV DNN 加载 YOLOv3再用 PyTorch 提取 ReID 特征3.1 加载YOLOv3权重并输出检测框的最小代码没有任何项目背景时最稳的组合是OpenCV DNN 加载 YOLOv3 的 cfg 和 weightsPyTorch 加载 ReID 模型。OpenCV DNN 不用装 Darknet 运行时也不需要考虑 CUDA 版本CPU 就能跑通。下面这个函数输出所有属于 person 类的检测框import cv2 import numpy as np # net 是全局对象cfg 是模型结构文件weights 是训练好的权重 net cv2.dnn.readNet(yolov3.cfg, yolov3.weights) layer_names net.getLayerNames() # getUnconnectedOutLayers() 返回三个 yolo 输出层的索引这里转成名字 out_layers [layer_names[i - 1] for i in net.getUnconnectedOutLayers()] def detect_person(frame, conf_threshold0.5, nms_threshold0.4): h, w frame.shape[:2] # 416x416 是 YOLOv3 常见的输入尺寸1/255 做像素归一化 blob cv2.dnn.blobFromImage( frame, 1/255.0, (416, 416), (0, 0, 0), swapRBTrue, cropFalse ) net.setInput(blob) outputs net.forward(out_layers) boxes, confidences, _ [], [], [] for output in outputs: for detection in output: scores detection[5:] class_id int(np.argmax(scores)) confidence scores[class_id] # COCO 数据集里 person 类的 id 是 0这里只保留行人 if confidence conf_threshold or class_id ! 0: continue center_x, center_y, w_box, h_box detection[:4] left int((center_x - w_box / 2) * w) top int((center_y - h_box / 2) * h) width int(w_box * w) height int(h_box * h) boxes.append([left, top, width, height]) confidences.append(float(confidence)) indices cv2.dnn.NMSBoxes(boxes, confidences, conf_threshold, nms_threshold) person_boxes [] for i in indices.flatten(): x, y, width, height boxes[i] person_boxes.append((x, y, x width, y height)) return person_boxesconf_threshold控制召回率调低到 0.2 会留下更多误检调高到 0.7 会漏掉远距离小行人。NMS 阈值控制重叠框合并力度0.4 是监控场景里比较平衡的值。swapRBTrue是因为 OpenCV 读图片是 BGR 顺序而 Darknet 训练时用的是 RGB。如果你发现检测框位置偏移优先检查这一项。3.2 裁剪行人区域并做ReID预处理的尺寸与归一化ReID 模型输入尺寸常见的是256×128高×宽也有384×192。裁剪行人区域时不能直接把检测框压缩成目标尺寸这样会把人压扁破坏体型特征。正确做法是保留长宽比在检测框基础上向外扩展一点上下文再 resize。我一般扩展 10% 到 15%这个比例能减少检测框把鞋子或帽子切掉的风险。from PIL import Image import torchvision.transforms as T # box 是 (x1, y1, x2, y2)expand_ratio 控制外扩比例 def crop_person(frame, box, expand_ratio0.1): x1, y1, x2, y2 box w x2 - x1 h y2 - y1 ex int(w * expand_ratio) ey int(h * expand_ratio) x1 max(0, x1 - ex) y1 max(0, y1 - ey) x2 min(frame.shape[1], x2 ex) y2 min(frame.shape[0], y2 ey) return frame[y1:y2, x1:x2] transform T.Compose([ T.Resize((256, 128)), T.ToTensor(), T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])expand_ratio太大会把背景和旁边的人也带进来导致特征污染太小又会丢掉拉杆箱、裤脚这些有判别性的细节。我一般从 0.15 起步看到返回的 Top-K 结果里连续出现同一背景的人就说明外扩太多了。归一化的均值方差来自 ImageNetReID 模型迁移时默认沿用这一套不要随便改成 0.5。3.3 余弦相似度计算与阈值选择的依据ReID 模型输出特征后先做 L2 归一化再算余弦相似度。归一化后余弦相似度和欧氏距离在排序上是等价的但余弦值有固定范围更容易解释。import torch import torch.nn.functional as F def extract_feature(model, image_tensor, device): # 跳过分类型预测头只拿特征向量 model.eval() with torch.no_grad(): feat model(image_tensor.to(device)) feat F.normalize(feat, p2, dim1) return feat.cpu().numpy() def cosine_similarity(a, b): # 输入已经是 L2 归一化的行向量点积就是余弦相似度 return float(np.dot(a, b))阈值怎么选同一行人在不同摄像头下的相似度通常在 0.6 到 0.8不同行人经常落在 0.2 到 0.5。但这不是通用规则必须基于你自己的图库统计。我会从每个行人挑出一对已知同类和一对已知异类分别算相似度画出分布直方图取两个分布波峰之间的谷底作为阈值。直接拍脑袋选 0.7后面一定会在某个光照差的摄像头下翻车。4. 实战为特定行人建库、预计算特征与跨摄像头查找4.1 图库采集每个行人至少需要几种视角特定行人如果只有一张正面照跨摄像头检索会非常吃力。因为摄像头从侧面或背面拍时正面照里学到的颜色分布已经对不上了。图库采集至少要覆盖以下视角视角类型建议帧数作用正面照5~10 帧提供最完整的颜色和体态信息背面照5~10 帧监控摄像头最常见的是背对画面左右侧面各 5 帧侧面能体现背包、步态特征带/不带物品各若干帧背包、拉杆箱是强判别特征不同光照各若干帧避免颜色分布被一张图锁死我通常的做法是从每个行人在不同摄像头下的 5 到 15 秒跟踪片段里按帧间隔 3 到 5 帧抽帧再人工剔除模糊和遮挡严重的图最后每个行人保留 10 到 20 张。图库样本太少时先不要急着调模型先把样本量补起来ReID 对数据多样性的敏感度远高于对模型结构的敏感度。4.2 预计算特征并保存为检索索引查询阶段如果每次都重新跑检测和特征提取内存和耗时都不可接受。正确做法是把所有图库图像离线跑一遍提取特征后保存成 npy 文件查询时只读向量。import os import numpy as np from PIL import Image def build_gallery(model, gallery_dir, transform, device): gallery_feats [] gallery_ids [] for img_name in sorted(os.listdir(gallery_dir)): # 文件名格式 person_id_序号.jpg例如 p01_001.jpg person_id img_name.split(_)[0] img Image.open(os.path.join(gallery_dir, img_name)).convert(RGB) img_tensor transform(img).unsqueeze(0) with torch.no_grad(): feat extract_feature(model, img_tensor, device) gallery_feats.append(feat) gallery_ids.append(person_id) # 按第一个维度拼接成 [num_gallery, feature_dim] 的矩阵 np.save(gallery_feats.npy, np.concatenate(gallery_feats, axis0)) with open(gallery_ids.txt, w) as f: f.write(\n.join(gallery_ids)) print(gallery size:, len(gallery_ids), feature dim:, gallery_feats[0].shape[1])extract_feature返回的是[1, dim]的向量np.concatenate沿着第一维拼接最后矩阵的行号对应gallery_ids里的顺序。注意这里不需要对每个图做 YOLOv3 检测因为建库素材已经是手工裁剪好的行人图。如果从视频抽帧建库才需要先跑一遍detect_person再裁剪。4.3 查询过程的完整脚本与结果排序查询流程可以串成一条函数输入一帧画面检测出所有行人对每个行人框提取 ReID 特征再和图库矩阵做矩阵乘法找到最相似的 Top-K。代码比循环调用更高效def query_person(model, frame, detect_fn, transform, gallery_feats, gallery_ids, device, top_k10): boxes detect_fn(frame) results [] # gallery_feats 形状 [N, dim]已经做过 L2 归一化 gallery_feats torch.from_numpy(gallery_feats).to(device) for box in boxes: crop crop_person(frame, box, expand_ratio0.15) crop_rgb cv2.cvtColor(crop, cv2.COLOR_BGR2RGB) img_tensor transform(Image.fromarray(crop_rgb)).unsqueeze(0) with torch.no_grad(): q_feat extract_feature(model, img_tensor, device) # q_feat 是 [1, dim]转置后和 gallery 矩阵做点积 sims gallery_feats q_feat.T # 得到 [N, 1] sims sims.cpu().numpy().flatten() ranked_idx np.argsort(sims)[::-1][:top_k] results.append([(gallery_ids[i], float(sims[i])) for i in ranked_idx]) return results把gallery_feats一次性搬到 GPU用矩阵乘法一次算出所有相似度避免 for 循环逐次点积。argsort默认升序所以用[::-1]反转成降序取前top_k。返回的每个元素包含图库 ID 和相似度分数人工复核时能看到分数排序。4.4 实际项目里的三个必调参数Top-K、距离阈值、多尺度拼接落地时真正影响体验的参数只有三个Top-K、相似度阈值、多尺度特征拼接。我一般按下面的初始值开始调参数初始值调整方向Top-K10单摄像头取 5跨摄像头取 20相似度阈值0.65根据 P-R 曲线平衡点修正多尺度拼接不用离线建库用 0.8/1.0/1.2 倍拼接在线查询不用Top-K 不是越大越好。跨摄像头检索时每个目标行人可能只出现几分钟gallery 里真正相关的样本不超过几十个Top-K 取 20 基本够用。建议先取 10 让标注者复核再根据召回情况放宽到 20。多尺度拼接是把同一张裁剪图缩放成 0.8、1.0、1.2 倍分别过模型取特征平均。它能让模型对不同像素密度都稳定通常提升 1 到 3 个点 mAP但推理时间变为原来的三倍。所以只有离线建库才建议这样做实时查询时宁可用更大的图像尺寸也别做多尺度。5. 把Demo变成能用的工程跟踪、打分与副作用处理5.1 用IoU跟踪去重避免同一人抓拍占满Top-K实时视频流里同一个行人会连续出现几十帧。如果不做去重查询结果会被同一个时间点的相似抓拍刷屏真正的其他摄像头画面被挤到底部。我一般维护一个简单的 IoU 跟踪器当前帧检测框和上一帧所有跟踪框计算交并比大于 0.5 就认为属于同一个 ID。每产生一个新的跟踪 ID才提取一次 ReID 特征并写入待查询队列。这个逻辑很简单但能过滤掉约 70% 的重复特征。5.2 跨摄像头亮度分布差异的处理不同摄像头的曝光补偿差异很大同一个人的红色上衣在室内摄像头下偏暗在室外阳光下偏亮。ReID 模型对亮度有部分鲁棒性但极端情况下会把颜色特征拉偏。常见做法是每路摄像头单独估计灰度均值查询前把查询图像的灰度分布线性拉伸到图库的平均分布。代码上就是cv2.normalize加一个 alpha 系数不需要训练。如果数据充足也可以在训练阶段加入 RandomErasing 和颜色抖动让模型见过更极端的光照扰动。5.3 最后一步用硬负样本迭代你的ReID模型如果检索结果里总有几个“长得像”但不是目标的人把这些错误样本收集起来作为 hard negatives 加入训练集。比如目标穿红衣服图库里另一个穿红白条纹衫的人也经常排进前三那么这个条纹衫就是硬负样本。把它们和正确目标组成新的三元组用很小的学习率例如 1e-5微调 ReID 模型 10 到 15 个 epoch。这个迭代过程比换一个更大的 backbone 更直接因为错误的产生往往源于训练数据里缺少同类干扰样本。把这一轮的 hard negative 加进训练集下一轮再观察错误是否还在前三这比盲目调相似度阈值更值得投入时间。本文还有配套的精品资源点击获取
返回列表