简介这是一份面向监控视频分析场景的机器学习应用工程核心功能是根据输入的目标图像自动在海量视频中搜索并标记包含该行人的片段。项目基于Python编写运行环境为Python 3.6.2、TensorFlow-GPU 1.6.0依赖Keras、OpenCV、scikit-learn等库适合具备一定深度学习基础、希望快速落地行人检索功能的开发者和研究者。资源包共365个文件压缩后约30.72MB构成上以25个py源码、36个pyc编译文件、283个json配置为主体同时附带了caffemodel、pb、cfg等目标检测模型文件以及readme、md说明文档py文件承载核心算法json保存运行参数模型文件可直接用于推理。当前已有144人学习下载。通过研读源码与模型配置可理解行人检测、特征匹配、轨迹关联的完整技术链路并利用自带模型快速搭建可运行的行人轨迹搜索系统节省模型训练和环境调试时间。1. 行人轨迹搜索不是追踪先把问题边界说清楚某天深夜运维打电话说园区监控里有一位老人走失家属拿着一张照片要帮忙找人三个人盯着回放看了两个小时只确认了三个出现点。那之后我把「基于机器学习的图像行人轨迹搜索」梳理成一条离线检索管线给一张行人照片在一段视频或一批图像里找出同一个人所有的出现位置再按时间拼成一条轨迹。一句话概括这个方向让模型记住人长什么样而不是让算法跟着人跑。这和「追踪」完全不同。追踪要求逐帧锁定运动目标目标一遮挡就跟丢轨迹搜索把过程拆成检测、特征提取、检索三段每一段独立可控反而更适合落地。这篇文章适合正在做视觉应用的工程师、想把深度学习模型搬到真实数据上的算法岗也适合想做完整小项目练手的研究生。文中所有脚本按单卡 GPU 环境写你不需要复现别人的完整仓库照着搭一条能用的最小链路就行。2. 技术选型为什么行人轨迹搜索要先检测、再提特征、最后检索很多人拿到「图像行人轨迹搜索」这个任务第一反应是上多目标跟踪框架比如 DeepSORT 或 ByteTrack。这个思路看起来顺理成章先检测再跟踪最后把跟踪结果保存成轨迹。但实际做下来你会发现MOT 的在线关联逻辑非常依赖帧间运动连续性一旦目标遮挡、出画再进画、或者跨镜头切换ID 就会重置轨迹跟着断裂。而「轨迹搜索」本质是检索问题不是关联问题。检索意味着可以离线把历史图像全部过一遍提取每个人的特征存入向量库用户给出目标照片后直接在库里查询完全不需要在线跟踪。于是这类任务我通常固定为三段式检测Detection、表征Embedding、检索Retrieval。检测负责找出画面中的行人位置表征负责把每个行人框变成一条可比较的特征向量检索负责在历史向量库里找出最相似的那一批。这三段可以分别换实现比如把检测从 YOLOv5 换成 YOLOv8把表征从 ResNet50 换成 ViT检索从暴力搜索换成 faiss IVF互不牵连。2.1 轨迹搜索不等于目标跟踪场景、成本、评价指标都不一样MOT 的目标是「持续锁定并区分场景中的所有目标」每一帧都要做关联这导致两个问题一是计算量随帧率线性上升二是遮挡发生时 ID Switch 几乎不可避免。轨迹搜索的目标是「给定一个查询目标在历史图像里找到它出现过哪些帧」本质是实例检索不是帧间关联。因此在评价上两者完全分叉MOT 看 IDF1 和 MOTA前者衡量 ID 身份保持能力后者衡量整体匹配准确率轨迹搜索看 mAP 和 Rank-1关注排序质量而非逐帧关联质量。选型因此有了明确依据。轨迹搜索的输入是一段可能需要回放数小时的录像推理需要跑完所有帧速度就是第一约束而要处理跨镜头、跨时段的数据特征又必须有足够的判别力。如果你用 MOT 链路硬做不仅要解决跟踪器自身的参数调优还要额外处理轨迹断裂后的拼接等于把检索任务复杂化了。反过来离线检索的流程可以随时重跑检测漏了人就调检测阈值特征不好就换模型重训检索不对就换索引每一层都有后悔药。2.2 检测层、特征层、索引层怎么搭一张表说清选型理由模块常见选型选择理由行人检测YOLOv5s / YOLOv8s单帧推理速度快在单张 1080Ti 上接近实时检测框质量直接影响后续特征质量特征提取ResNet50 BNNeckReID 领域验证最充分的骨架输出 512 维向量性价比高边缘设备换 MobileNetV3向量检索faiss IndexFlatIP十万级向量库用暴力内积即可毫秒级返回百万级以上再换 IndexIVFFlat轨迹拼接时间戳排序 速度过滤不依赖位置关联把 ReID 匹配结果按时间排序即可表格里的选型不是唯一答案。检测层不用 Faster R-CNN 是因为这类两阶段方法精度虽高推理速度在长录像回放场景里不划算特征层不用特别大的模型是因为 ResNet50 在 ReID 数据集上的表现已经足够好更大的模型对 mAP 的提升有限却会让推理时间翻倍索引层则要看数据规模十万条以内 IndexFlatIP 毫秒级返回强行换 IVF 反而要多维护一个训练步骤得不偿失。先按这个组合跑通再根据实际数据量调整。2.3 传统机器学习方案为什么会被替换一个对比在「机器学习」这个大前提下其实存在两条路线。传统机器学习模型的典型做法是 HOG 特征加 SVM 分类器做行人检测再用颜色直方图或 LBP 做特征匹配。在固定机位、固定光照的单场景监控里这套东西能跑通而且 CPU 就能跑。但一旦遇到跨镜头、跨时段光照和白平衡一变颜色直方图的分布就会整体偏移匹配精度掉得非常快行人姿态变化一大HOG 也基本失灵。这也是为什么机器学习算法的主流注意力都集中到了深度学习模型上——不是传统方法完全不能用而是它的假设太脆。但传统方法并非全无价值。如果你手上只有几百张标注图像、又没有 GPU先用 HOGSVM 跑一个能动的版本作为基线用它的结果去暴露流程问题再决定要不要换深度模型是一个性价比很高的起步方式。我在实际项目里就遇到过先用传统方案验证产品需求的场景——需求验证阶段跑得快不重要跑得动才重要。深度学习模型把手工特征工程变成从数据里学特征鲁棒性有了质的提升但它对数据量和算力的要求也是实打实的门槛。2.4 抽帧与时间戳轨迹粒度由采样率决定轨迹搜索的对象往往是一段长视频抽帧率直接决定轨迹的粒度。每秒抽 1 帧意味着一个人 3 秒内出现 3 次但你在结果里只看到 1 个点抽得太密一帧内同一个人的重复框会淹没真正的新出现点。我的默认配置是回放录像用 2fps实时图像流用 1fps。按 2fps 抽帧一小时录像会得到 7200 帧每帧平均 3 个行人向量库规模约 2 万条IndexFlatIP 查询耗时毫秒级这个成本完全可控。时间戳是轨迹拼接的第一排序键这个字段在抽帧时必须保留。很多人会用文件名编码时间但批量处理时这种方案容易出错。我一般把时间戳作为单独一列存在 JSON 里文件名只存帧序号。每一帧检测到的所有行人框都带上帧时间戳后续对齐轨迹时才不用回头翻视频。文件清单里至少要有抽帧结果目录、检测结果 JSON、向量库目录、时间戳索引文件。提示抽帧时把时间戳单独存 JSON别只靠文件名。多个摄像头时每个镜头单独维护一个 JSON避免轨迹拼接时数据混在一起。2.5 三层阈值联动检测置信度、匹配相似度、拼接窗口各调什么整个系统有三层参数很多人只盯着 ReID 相似度调忽略了另外两层效果自然好不了。第一层是检测置信度控制「能不能看到人」它决定召回的上限第二层是匹配相似度控制「是不是同一个人」它决定检索结果的精确率第三层是轨迹拼接的时间窗口控制「轨迹连不连得起来」它决定最终展示的连续性。这三层是联动关系检测阈值调高进入特征库的图变少但更干净匹配阈值就可以适当放宽拼接窗口调大轨迹更连续但会把不同人的短暂交错也连进去。改任何一层都要回头检查另外两层的表现。3. 数据准备与特征抽取从原始视频到可检索向量库这一步的目标是把原始录像变成可检索的向量库。整个过程涉及四个环节划分数据集、抽帧检测、统一图像尺寸、提取特征。每一步都有对应的实操脚本和参数下面按顺序讲。3.1 ReID 数据集三件套train / query / gallery 怎么划分在行人重识别任务里数据被拆成 train、query、gallery 三份很多人一开始分不清。train 用于训练 ReID 模型里面的人和 query/gallery 完全不相交否则就是数据泄漏query 是你要搜索的目标图每个行人挑一张到几张gallery 是候选图库算法需要从中找出和 query 属于同一个人的图片。这里最关键的约束是「身份不相交」。如果是自己标的数据train 里的行人 ID 不能出现在 query 和 gallery 里。这个约束很容易被忽略导致训练时模型已经见过 query 里的人评估时 mAP 虚高到 0.95上线后直接暴露真实水平。我建议所有划分在标注阶段就完成而不是训练完成后临时改。如果从公开数据集起步可以参照 Market1501 的划分逻辑训练集和测试集身份互斥query 每 ID 出若干张查询图gallery 是待检索候选集。自建单摄像头回放库时不需要跨摄像头评价但身份互斥这条依然要守住。3.2 从视频抽帧到行人裁剪一段直接能跑的 Pythonimport cv2 import json from pathlib import Path from ultralytics import YOLO video_path camera_01.mp4 frame_dir Path(frames) det_dir Path(detections) frame_dir.mkdir(exist_okTrue) det_dir.mkdir(exist_okTrue) model YOLO(yolov8s.pt) # 检测模型, 在公开权重上直接推理 fps 2 # 抽帧率, 回放录像建议 2, 实时流建议 1 conf_thres 0.5 # 置信度阈值, 误检多时往上抬到 0.6 class_id 0 # COCO 里 0 是 person 类别 cap cv2.VideoCapture(str(video_path)) video_fps cap.get(cv2.CAP_PROP_FPS) frame_interval int(video_fps / fps) # 按原始帧间隔抽帧 records [] frame_idx 0 while cap.isOpened(): ok, frame cap.read() if not ok: break if frame_idx % frame_interval ! 0: frame_idx 1 continue results model(frame, classes[class_id], confconf_thres, verboseFalse) save_path frame_dir / fframe_{frame_idx:06d}.jpg cv2.imwrite(str(save_path), frame) for box in results[0].boxes: x1, y1, x2, y2 [int(v) for v in box.xyxy[0].tolist()] if y2 - y1 40: # 过滤太小的框, 这类框在特征提取阶段全是噪声 continue cropped frame[y1:y2, x1:x2] crop_path det_dir / fframe_{frame_idx:06d}_id{len(records):04d}.jpg cv2.imwrite(str(crop_path), cropped) records.append({ crop_path: str(crop_path), frame_id: frame_idx, timestamp: frame_idx / video_fps, bbox: [x1, y1, x2, y2], }) frame_idx 1 cap.release() with open(det_dir / records.json, w) as f: json.dump(records, f, indent2)这段脚本把视频抽帧、行人检测、裁剪、产出 JSON 四件事一次做完。抽帧率 2fps 的依据是一个人在画面里停留 10 秒就有 20 个候选框足够 ReID 后续匹配抽 25fps 会让特征库里 90% 都是高度相似的冗余框。置信度阈值和最小高度两个参数要联动调阈值太低会把椅子和海报上的人影当行人最小高度过滤太狠会漏掉远处的小目标40 像素是起步值要根据镜头分辨率和安装高度调整。3.3 图像缩放与统一尺寸直接拉伸是特征劣化的第一来源ReID 模型的输入一般固定为 256×128 或 224×224而检测裁剪出的 bbox 宽高比往往不固定。如果直接把 bbox 拉伸到输入尺寸宽高比 2:3 的行人会变成 1:2身体比例全部变形特征向量的质量会明显劣化。这就是图像缩放原理在 ReID 里最常见的坑——resize 不是随意拉伸要保持内容比例。我用的处理方式是先按目标宽高比计算等比缩放再在两侧或上下 padding 到统一尺寸padding 区域像素值填 0。这样模型看到的身体比例和训练分布一致。还有一种做法是保留原图不 resize用 ROI Align 直接提取特征但那样需要把检测和 ReID 放进同一个网络工程约束更多起步阶段不推荐。def letterbox_crop(img, target_w128, target_h256): h, w img.shape[:2] scale min(target_w / w, target_h / h) # 等比缩放, 取较小比例 nw, nh int(w * scale), int(h * scale) resized cv2.resize(img, (nw, nh), interpolationcv2.INTER_LINEAR) canvas np.zeros((target_h, target_w, 3), dtypenp.uint8) x0 (target_w - nw) // 2 y0 (target_h - nh) // 2 canvas[y0:y0nh, x0:x0nw] resized return canvas3.4 特征提取与向量库一条可抄的建档管线import numpy as np import faiss import torch import torchvision.transforms as T from PIL import Image # 假设 model 是训练好的 ReID 模型, 输出 512 维 L2 归一化特征 model.load_state_dict(torch.load(reid_best.pt, map_locationcpu)) model.eval() transform T.Compose([ T.Resize((256, 128)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) features, paths [], [] for rec in records: crop Image.open(rec[crop_path]).convert(RGB) crop letterbox_crop(np.array(crop), 128, 256) # 等比缩放 padding tensor transform(crop).unsqueeze(0) with torch.no_grad(): feat model(tensor).numpy().reshape(-1) feat feat / np.linalg.norm(feat) # L2 归一化, 让内积等价于余弦相似度 features.append(feat) paths.append(rec[crop_path]) X np.vstack(features).astype(float32) index faiss.IndexFlatIP(512) index.add(X) np.save(feat.npy, X)两个细节值得注意。IndexFlatIP 是内积索引配合 L2 归一化特征后返回的相似度分数就是余弦相似度如果跳过归一化特征向量的模长会干扰排序结果。另外 faiss 索引不负责持久化建议把原始 npy 也存一份索引损坏或维度调整时可以重新构建。查询时 faiss 返回相似度分数和索引下标下标对应用户记录 JSON 里的图片路径和时间戳回溯就靠这个映射关系。4. 训练一个可用的 ReID 模型最少代码与关键参数特征提取是这套管线的核心而特征的质量取决于 ReID 模型怎么训练。很多做检测的工程师在这里翻车因为 ReID 的训练逻辑和图像分类有本质区别。这一章直接讲训练一个可用模型的最小配置。4.1 为什么不能用预训练分类模型的输出直接当特征有人会想用 torchvision 里在 ImageNet 上训好的 ResNet50去掉最后全连接层直接拿 2048 维向量当特征不也能检索吗能但效果很差。原因在于 ImageNet 预训练模型学到的是「类别级」特征它关注的是「这是一只猫还是一只狗」而 ReID 要学的是「这是张三还是李四」。同样是穿红衣服的行人分类模型会把它们聚成一团ReID 则要强行拉开哪怕两个人长得非常像。这是 ReID 训练目标和普通图像分类本质不同的地方。它不能只做 ID 分类还需要在特征层面拉近同一行人的距离、推开不同行人的距离。如果只用分类损失训练模型学到的是把每个人变成 one-hot 标签泛化到训练集里没出现过的新行人的效果会差如果只用三元组损失训练收敛慢且容易陷入早停。常见做法是把两个损失加权组合分类损失提供强监督信号三元组损失负责修正特征分布的形状。4.2 PK 采样一个 Batch 里有多少行人、每 ID 抽几张ReID 训练的特殊之处在于数据采样。普通分类任务每个 Batch 随机抽图片即可ReID 里如果用随机采样一个 Batch 内的样本可能 90% 都是不同行人三元组根本组不起来。所以要用 PK 采样Batch 里有 P 个行人 ID每个 ID 抽 K 张图Batch size P x K。P 和 K 怎么设P8、K4 是很多人跑出来的默认配置Batch size 32显存够用。想追求更高精度可以上 P16、K4Batch size 64但对难样本挖掘的要求更高。K 小于 4 会导致一个 Batch 内同一行人的样本对太少三元组的难度覆盖不足训练出来的特征区分度会变差。class PKSampler: def __init__(self, dataset, p8, k4): self.p p self.k k # dataset.ids 是每个样本对应的行人ID, dataset.indices 是样本下标 self.id2idx {} for i, pid in enumerate(dataset.ids): self.id2idx.setdefault(pid, []).append(i) def __iter__(self): import random pids random.sample(list(self.id2idx.keys()), self.p) batch [] for pid in pids: idxs random.sample(self.id2idx[pid], self.k) batch.extend(idxs) random.shuffle(batch) return iter(batch) def __len__(self): return (len(self.id2idx) // self.p) * self.p4.3 损失函数与训练循环分类损失 三元组损失的最小实现import torch import torch.nn as nn class ReIDModel(nn.Module): def __init__(self, num_classes, feat_dim512, backboneresnet50): super().__init__() import torchvision # 如果 torchvision 版本较旧, 换成 pretrainedTrue base torchvision.models.resnet50(weightstorchvision.models.ResNet50_Weights.IMAGENET1K_V1) self.base nn.Sequential(*list(base.children())[:-2]) # 去掉最后两层 self.gap nn.AdaptiveAvgPool2d((1, 1)) self.embed nn.Linear(2048, feat_dim) # 2048 - 512 self.bnneck nn.BatchNorm1d(feat_dim) # BNNeck, 训练时用分类头, 检索时用 bnneck 前特征 self.classifier nn.Linear(feat_dim, num_classes, biasFalse) def forward(self, x): x self.base(x) x self.gap(x).flatten(1) feat_before_bn self.embed(x) # 这是用于检索的特征 feat self.bnneck(feat_before_bn) logits self.classifier(feat) return logits, feat_before_bn def batch_hard_triplet(feat, pids, margin0.3): # 对每个 anchor 取最难正样本和最难负样本, 组成三元组损失 dist torch.cdist(feat, feat) pos_mask (pids[:, None] pids[None, :]) ~torch.eye(len(pids), dtypetorch.bool) neg_mask pids[:, None] ! pids[None, :] hardest_pos torch.where(pos_mask, dist, torch.full_like(dist, float(inf))).max(dim1).values hardest_neg torch.where(neg_mask, dist, torch.full_like(dist, float(-inf))).min(dim1).values loss torch.clamp(hardest_pos - hardest_neg margin, min0).mean() return loss criterion_cls nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr0.00035) for epoch in range(40): for images, pids in train_loader: logits, feat model(images) loss_cls criterion_cls(logits, pids) loss_tri batch_hard_triplet(feat, pids) # 欧氏距离版难样本三元组 loss loss_cls loss_tri optimizer.zero_grad() loss.backward() optimizer.step()几个参数直接影响训练结果。学习率 0.00035 建议配合 warmup 使用前 3 个 epoch 从 0 线性升到 0.00035随后在第 40 和第 70 epoch 处乘以 0.1。三元组 margin 常用 0.3太大会让训练在困难样本上震荡太小又提供不了有效梯度。自建小数据集 20 到 30 个 epoch 足够公开大数据集一般跑到 120 个 epoch。4.4 导出特征时用 BNNeck 前还是后用前BNNeck 是 ReID 里一个精巧的设计但也容易让人用错。训练时分类头要求特征按类别可分BatchNorm 会把特征分布强制对齐到标准分布这有利于分类损失收敛但检索时特征分布的「原始形状」里包含的信息反而更有区分度。所以在推理阶段取 bnneck 之前的 512 维向量L2 归一化后建库这是 BNNeck 论文里的标准做法。如果你用的模型实现里没有 BNNeck直接取分类层前一层的特征也可以但要注意维度分类输入是 512 维取出来的就要是 512 维别和其他版本的 2048 维混在一起。这个细节就是第 5.5 节要展开的坑。4.5 保存与恢复权重、维度、预处理全部锁进配置文件训练完成后把模型权重、特征维度、输入尺寸、归一化参数、索引维度全部固化到一个配置里训练和部署共用同一份。不要靠记忆在推理脚本里重新写一遍时间一长必然不一致。config { backbone: resnet50, feat_dim: 512, input_size: [256, 128], norm: [[0.485, 0.456, 0.406], [0.229, 0.224, 0.225]], index_dim: 512, loss_weights: {cls: 1.0, triplet: 1.0}, ckpt_path: reid_best.pt, }推理脚本加载 config 里的 feat_dim 来初始化索引加载 input_size 来预处理图像。索引维度与模型输出维度不一致的问题80% 以上是配置漂移导致的。5. 常见问题与排查特征没区分度、轨迹断链、检索返回一堆错图5.1 检索结果全是背景图问题根源在检测不在 ReID现象query 是一张行人照片gallery 返回的前 10 个结果里有 6 个是墙面、柱子和水渍纹理压根没有行人。初看像是 ReID 模型没训练好其实问题大多出在检测阶段。原因检测置信度阈值设得太低把图像上的背景纹理、远处的树影都当成了行人裁剪出来后直接进特征库。特征模型对「这是一张纯背景图」没有拒绝能力它只能在行人图之间排序。解决把检测置信度从 0.3 提到 0.5 以上过滤高宽比异常的框行人宽高比一般在 1.5 到 4.5 之间小于 1 或大于 6 的基本都是误检同时检查裁剪图上行人占据的面积比例过小的框不进入特征库。做完这三步背景图占比会明显下降。5.2 同一个人的轨迹被拆成两段跨镜头颜色偏移现象同一个行人在 A 镜头里穿蓝色外套走到 B 镜头里因为白平衡变成偏紫色检索时 A 的特征和 B 的特征距离超过阈值轨迹断成两段。原因ReID 模型的「颜色」信息权重很大不同镜头的色域映射不一致会让特征偏移。这个现象在室内走廊灯光下尤其明显日间户外相对好一些。解决从两个层面处理。数据增强层面训练时对裁剪图施加随机亮度、对比度和饱和度扰动让模型不再依赖绝对颜色值推理层面对 gallery 特征做标准化减均值或者把 RGB 空间做一次白化。更省事的做法是在应用层允许更松的阈值再配合时间连续性去补断链如果一个人在 A 镜头消失后 10 秒在相邻镜头出现且外观相似就先接上交给后续的速度过滤去纠错。5.3 轨迹上同一秒出现 20 个点缺少时间去重现象检索结果里同一个行人在同一秒内反复出现一条轨迹看着像一堆点挤在同一个位置。原因2fps 抽帧下一个人站在原地 5 秒会产生 10 个高度相似的特征和图全部进入轨迹展示人眼看到的就是一团点。解决在轨迹拼接时做时间窗口去重把同一个行人 ID 在 5 秒内的出现记录合并成一个点取窗口内置信度最高的那条记录。按行人 ID 和时间排序后用滑动窗口合并即可。注意这里的行人 ID 不是检测器的跟踪 ID而是 ReID 检索匹配后回填的 ID逻辑上要对上。5.4 相似度阈值永远调不对用得分分布说话现象把检索阈值从 0.6 改到 0.7误检少了一些但漏检变多改回 0.65总感觉差的那零点零几是玄学只能一个个试。原因相似度阈值的本质是一个分类边界它应该由正负样本得分分布的交点决定。ReID 特征空间里同一行人的余弦相似度可能在 0.5 到 0.9 之间大幅波动不同行人也可能在 0.4 到 0.7 之间重叠固定阈值天然不够可靠。解决训练完成后用一小部分验证集计算正样本对和负样本对的相似度分布画出直方图在两个分布交界处选阈值。更进一步的做法是给每个行人动态阈值gallery 里同一个人出现次数少就放宽出现次数多就收紧。第一步先画分布你会发现很多自己之前「感觉对了」的阈值其实离交界点很远。这一步用 numpy 就能算不需要额外工具。5.5 特征维度不一致导致 faiss 报错或结果全乱现象建索引时维度设定为 2048但推理时模型输出的是 512 维向量faiss 直接报 dimension mismatch或者训练和推理用了不同的预处理流程导致同一个人的特征在库内部分布完全对不上检索结果全是乱序的。原因常见于换模型、换代码版本后没有同步更新索引的维度以及推理管线和训练管线的预处理不一致比如训练用了随机裁剪推理直接缩放。解决把特征维度、归一化方式、预处理流程固化在一个配置文件里训练和部署共用就是第 4.5 节说的那件事。同时 faiss 索引每次重新构建后跑一次自检随机抽 20 个 query人工确认前 5 个结果是否合理确认后才对外提供服务。这个自检我都是直接在命令行跑一个小脚本加载几个人工标注的查询样本打印 top-5 的图片路径和相似度分数。6. 让轨迹搜索真正能落地验证指标与两招提升召回6.1 用 mAP 和 Rank-1 给整套链路做一次体检ReID 系统看两个指标Rank-1 是第一个结果是否命中mAP 是正确结果在排序里是否靠前。放在轨迹搜索里Rank-1 对应「第一张图是不是这个人」mAP 对应「整条轨迹漏了多少」。def evaluate(query_feats, gallery_feats, query_ids, gallery_ids, topk10): sim query_feats gallery_feats.T # 已L2归一化, 内积即余弦 rank np.argsort(-sim, axis1)[:, :topk] ap_list, hit_list [], [] for i in range(len(query_ids)): g np.array(gallery_ids)[rank[i]] query_ids[i] if g.sum() 0: continue hit_list.append(bool(g[0])) hit_pos np.where(g)[0] ap sum((j 1) / (hit_pos[j] 1) for j in range(len(hit_pos))) ap_list.append(ap / len(hit_pos)) return float(np.mean(hit_list)), float(np.mean(ap_list))评价时最怕数据泄漏同一个行人在相邻帧里连续出现query 和 gallery 各分到几张mAP 会虚高。自建库时应保证同一个行人同一段时间出现的帧只落在 query 或 gallery 其中一侧。6.2 轨迹后处理的两招时间邻域去重和运动速度过滤第一招去重。同一行人 ID 的命中记录按时间排序后如果两条记录时间差小于 5 秒直接合并成一个点取置信度更高的那条。这能消除 2fps 抽帧下同一个人在连续帧里重复出现造成的「同一点一堆点」现象。第二招速度过滤。两条命中记录之间的距离除以时间差得到一个速度超出合理范围就丢弃或标记为可疑。室内步行场景 5 km/h 封顶户外可以放到 15 km/h。不同场景不要复用同一个阈值这是容易踩的地方。6.3 多尺度推理与水平翻转低成本提升特征区分度的最后一招推理时对同一张裁剪图分别做 0.9、1.0、1.1 倍缩放提取三个特征并取平均水平翻转后的特征也一起平均。这两招不重训模型通常能带来 1 到 3 个点的 Rank-1 提升。如果 query 图年代久远且分辨率极低先用图像超分辨率重建拉大再提特征比多尺度平均更有效。落地一个行人轨迹搜索服务真正花时间的不是跑通流程而是让每一段在几十小时录像上都不出错。我的习惯是每次改完检测阈值、ReID 权重或去重参数都保留一条包含 3 个镜头、累计 2 小时录像的回归集跑一遍 Rank-1 和 mAP 记录在表格里。这条回归集救过我很多次——有一次我以为阈值优化让分数涨了 2 个点换了个摄像头做回归才发现 mAP 掉了 4 个点问题出在没评估跨镜头鲁棒性而不是算法本身。希望你能把手上的录像跑通、把阈值调到你的场景里让数据告诉你下一步该改哪里。希望这些经验能帮到你。本文还有配套的精品资源点击获取
