ARTICLE DETAIL

资讯详情

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

图像行人轨迹搜索实战:从检测跟踪到ReID向量检索

图像行人轨迹搜索实战:从检测跟踪到ReID向量检索 简介面向监控视频行人检索场景的机器学习应用包聚焦“以图搜人”任务输入一张目标图像即可从批量视频中自动定位包含目标的片段并标记目标可用于监控录像事后追查、特定人员轨迹分析等场景适合计算机视觉学习者、算法工程师及安防领域研究者。包体共365个文件以JSON配置、Python源码、pyc编译文件为主其中JSON保存网络结构与超参数、pyc为预编译版本另含模型权重如caffemodel、pb、cfg与说明文档整体约30.72MB目录结构清晰。基于Python 3.6与TensorFlow-GPU 1.6环境TensorFlow与Keras负责行人特征提取与匹配OpenCV处理视频帧覆盖模型加载、检测、检索与标注全流程便于替换自有数据集二次实验。已有145人学习下载可快速搭建行人搜索原型并对照不同检测或跟踪模型的实际效果。1. 图像行人轨迹搜索是什么把跨镜头的行人轨迹变成可检索索引在监控回放里找一个人过去最常用的办法是开 16 倍速盯屏幕看到疑似目标再手动回退反复确认。图像行人轨迹搜索要解决的是完全不同的一件事输入一张目标行人的截图系统把他在不同相机、不同时间段里出现过的轨迹点重新捞出来按时间排成一条连续链路。它本质上是一套基于机器学习的查询系统把检测、跟踪、ReID 和向量检索串在一起而不是传统意义上的在线多目标跟踪。这类系统适合做园区访客分析、零售动线统计、安防平台检索的工程师真正难啃的往往不是跟踪算法本身而是 ReID 特征不稳定导致的轨迹碎片化。2. 技术选型先立住检测、跟踪、ReID 在轨迹搜索里各管一段2.1 为什么检测和跟踪解决的是“这一段”ReID 解决的是“这个人”图像行人轨迹搜索的第一直觉是“找一个跟踪算法就行”这个直觉最容易翻车。跟踪算法解决的是“同一目标在连续几帧里对应哪个框”它的生命周期通常只有几秒到几十秒一旦目标被遮挡、走出画面、或者相机切换跟踪器给出的轨迹就断掉了。场景越接近真实监控这个断点越多走廊转角、柱子遮挡、人流交错都会让目标从跟踪视野里暂时消失。跟踪器可以在短暂遮挡后靠运动轨迹和外观特征把目标接回来但超过几秒基本就放弃了。轨迹搜索真正需要回答的问题是“这段轨迹和另一段轨迹是不是同一个人”这才轮得到 ReID 出场。ReID 做的事是对行人的裁剪图做特征提取输出一个固定长度的向量在向量空间里用余弦距离衡量两个人是不是同一个身份。它不关心目标从哪里来、到哪里去只比较外观特征。所以一套完整的轨迹搜索系统等于三个模块叠起来检测器给出候选框跟踪器把连续帧连成 trackletReID 把分散的 tracklet 按身份聚类。这里还要强调一个容易忽略的顺序问题检测是入口入口质量决定后面所有环节。如果检测器把行人的置信度压得很低、或者框的位置不稳定跟踪器拿到的输入就先天不足ReID 聚类也会被脏框干扰。我见过不少项目一上来就换 ReID 模型、调跟踪参数最后发现瓶颈其实是检测器在远距离小目标上的召回不足。先做检测链路再做跟踪最后才轮到 ReID这个顺序不要乱。2.2 DeepSORT 与 ByteTrack轨迹长尾和碎片率的取舍在轨迹搜索这条赛道上选跟踪器不能只比 MOTA 这类在线指标更要看它能不能产出足够长的 tracklet。tracklet 越长后续 ReID 聚类的输入越干净搜索结果的链路也越完整。实际落地多以两类方法为主DeepSORT 这类把 ReID 嵌进匹配过程的方案和 ByteTrack 这类纯运动匹配的方案。DeepSORT 的思路是卡尔曼滤波预测下一帧框位置再用 ReID 特征算外观距离最后用匈牙利算法做数据关联。它看起来天生适合轨迹搜索因为匹配时已经带上了外观信息。但代价是每个检测框都要走一遍 ReID 特征提取计算量比 ByteTrack 高一个量级而且在外观相似度高的人群场景里ReID 距离并不一定比运动预测更可靠。另一个隐性问题是DeepSORT 对低置信度检测框的处理很保守监控画面里大量远距离小目标被直接丢弃轨迹碎片率反而更高。ByteTrack 走的是另一条路它把检测框按置信度分成两组先用高置信度框做运动匹配剩下的低置信度框再用 IoU 兜底全程不依赖 ReID。这带来一个在轨迹搜索场景里很实用的特性目标哪怕被遮挡到置信度掉到 0.1 以下只要检测器还没彻底丢框ByteTrack 就有机会继续把轨迹接上而不是把原来的 track_id 直接终结。维度DeepSORTByteTrack是否依赖 ReID是每帧提取否仅用 IoU低置信度检测利用低高遮挡断轨恢复需要 ReID 重识别需要 track_buffer计算开销高低轨迹搜索场景定位适合实时在线应用更适合产出长 tracklet下面是我在工程里常用的 ByteTrack 配置片段两处参数会直接影响轨迹碎片率# bytetrack.yaml track_buffer: 90 # 最大允许丢失的帧数单位是帧不是秒 match_thresh: 0.55 # 匹配阈值调太大会把正常遮挡误判成新轨迹 low_thresh: 0.1 # 低置信度框参与匹配的下限配合 conf0.1 使用参数说明track_buffer 决定目标消失多久之后才放弃追踪。如果是 25 帧/秒的视频90 帧等于 3.6 秒如果现场只有 5 帧/秒90 帧只等于 18 秒差距很大必须按帧率换算。match_thresh 控制匹配严格程度阈值越高要求的位置重合越严目标稍微被挡就容易断轨。low_thresh 则是 ByteTrack 区分高低置信度的分界线在模糊监控里我会把检测阈值和 low_thresh 同时下调避免低置信度框根本进不了匹配流程。2.3 ReID 特征维度和距离度量先想清楚怎么搜再选模型ReID 模型选型要回答的第一个问题不是“哪个模型最准”而是“特征要拿来做什么”。轨迹搜索场景里特征向量既要比较同一相机前后出现的 tracklet又要比较不同相机之间的候选轨迹还会被直接用作查询图的匹配依据。这意味着特征需要具备跨视角、跨光照的稳定性而不是只看单帧识别精度那种指标。常见做法是用 OSNet、BoT 这类基于 ImageNet 预训练的 ReID 模型输出特征维度在 512 维左右。选模型时我会优先看两个点输入尺寸能不能稳定吃下 256×128 的行人裁剪图输出特征是否自带归一化。有的模型输出的是未归一化的向量直接拿去做内积检索分数会被向量模长干扰这在后面建索引时会变成一个非常隐蔽的坑。特征是 512 维还是 1024 维并不是越大越好越高的维度存储开销越大在 CPU 上做向量计算也越慢实测中小规模轨迹库把 512 维主成分压到 256 维检索精度损失在几个百分点以内速度却能快一倍以上。距离度量上我统一用 L2 归一化后的余弦距离。写入索引库之前先做一次归一化查询向量进来也做同样处理这样 faiss 里用内积就等于余弦相似度阈值设置才有意义。ReID 最终给出的是一个相似度分数不要把分数当分类概率超过 0.6 就认定是同一人这种硬规则要慎用留 top-k 给后续时空校验去判断比在向量层就卡死更稳。3. 跑通最小轨迹入库 pipeline检测、跟踪、写库3.1 数据集和最小环境先用公开图像序列把链路调顺做这类系统最容易翻车的做法是第一天就拿现场监控录像调参。现场视频有两个问题没有标注出了错分不清是检测问题还是跟踪问题帧率、视角不稳定参数稍一动结果就玄学。我的习惯是先选 MOT17 数据集里的公开视频序列它视角接近真实监控行人的框有完整标注能拿来做定量验证。先把链路跑通再换现场数据排查问题时才有参照物。环境上用一套最小 Python 环境就够了不需要一上来就搭 GPU 集群。下面这条命令创建一个独立虚拟环境并安装本次要用到的依赖mkdir -p traj_search cd traj_search python -m venv .venv source .venv/bin/activate pip install ultralytics opencv-python numpy pandas依赖说明ultralytics 同时封装了 YOLO 检测和 ByteTrack 跟踪我不需要再单独写跟踪逻辑opencv-python 负责读视频、画框和最终可视化numpy 用于后面做特征聚合。模型权重用 yolov8n 起步跑通后我会换成 s 或 m 级别远距离小目标的召回会明显变好代价是推理变慢。不要把这个步骤理解成选型定案它只是保证你本机能先把结果跑出来。3.2 用 YOLO ByteTrack 逐帧拿到 track_id最小可复现脚本这个脚本只做一件事把每一帧里的行人框和 track_id 同时取出来。后面的轨迹搜索全部建立在它的输出之上。所谓 track_id是跟踪器给同一段连续轨迹分配的临时编号它不等同于真实身份只代表“这一帧的这个框和上一帧的哪个框连上了”。from ultralytics import YOLO import cv2 model YOLO(yolov8n.pt) cap cv2.VideoCapture(MOT17-02.mp4) frame_id 0 while cap.isOpened(): ret, frame cap.read() if not ret: break result model.track( frame, persistTrue, # 关键不写这个track_id 每帧都会重新分配 conf0.10, # 监控场景调低保住默认 0.25 下被漏掉的小目标 iou0.5, classes[0], # 只保留 person 类别 trackerbytetrack.yaml, verboseFalse, ) if result[0].boxes is None or result[0].boxes.id is None: frame_id 1 continue for box in result[0].boxes: tid int(box.id.item()) x1, y1, x2, y2 [round(float(v), 1) for v in box.xyxy[0].tolist()] conf float(box.conf[0]) # 这一行是轨迹入库入口后面章节会把点写进数据库 print(frame_id, tid, x1, y1, x2, y2, conf) frame_id 1逻辑说明persistTrue 表示用上一帧的跟踪状态去关联当前帧它是 track_id 能跨帧连续的前提。如果漏掉这个参数每一帧都被当成新视频开头处理track_id 全部重排轨迹全断。conf0.10 是我在监控视角下的起步值默认的 0.25 会丢掉大量远端小目标与其让跟踪器无米下锅不如先放进低置信度框后面交给 ByteTrack 的 low_thresh 去做二次取舍。classes[0] 把检测类别锁死在行人避免把车、动物混进轨迹库。第一次运行先看输出里的 tid 是不是稳定递增。如果 tid 大量是 -1说明跟踪器没有生效检查 tracker 参数和 persist如果 tid 每隔几帧就出个新号说明检测置信度阈值还是太高低置信度框没进来逐步把 conf 往 0.05 方向降。这一步的判断比任何指标都直接。3.3 把轨迹点写进 SQLite为什么主键是 track_id frame_id拿到逐帧输出后就要落库。SQLite 对原型验证已经够用不用一上来就上 PostgreSQL。轨迹点表的核心设计在于主键每一条原始轨迹点由 track_id 和 frame_id 两个字段一起确定因为同一个跟踪 id 在同一帧最多只可能对应一个框。把两者合并成主键既避免重复写入也方便后续按 tracklet 聚合统计。import sqlite3 conn sqlite3.connect(trajectory.db) cur conn.cursor() cur.execute( CREATE TABLE IF NOT EXISTS track_points ( track_id INTEGER NOT NULL, frame_id INTEGER NOT NULL, ts REAL, x1 REAL, y1 REAL, x2 REAL, y2 REAL, conf REAL, PRIMARY KEY (track_id, frame_id) ); ) def insert_points(points): cur conn.cursor() cur.executemany( INSERT OR IGNORE INTO track_points VALUES (?,?,?,?,?,?,?,?), points, ) conn.commit()参数说明ts 是浮点时间戳建议直接用 frame_id / fps 换算比记录系统当前时间可靠如果有多路相机还需要在后面加一个 cam_id 字段查询时按相机和时间联合过滤。executemany 一次吃一个批次比逐条 execute 快一个数量级监控视频一跑就是几万帧逐条写库会让处理速度慢到没法用。INSERT OR IGNORE 遇到主键冲突时直接跳过能容忍跟踪器偶尔在同一帧重复输出框的极端情况。写完原始点之后要跑一次 tracklet 聚合观察轨迹碎片化程度SELECT track_id, MIN(frame_id) AS start_frame, MAX(frame_id) AS end_frame, COUNT(*) AS point_count FROM track_points GROUP BY track_id ORDER BY point_count DESC LIMIT 20;这段 SQL 是判断跟踪质量的第一把尺子。如果点最多的一批 tracklet 只有几十帧说明跟踪器断得很碎后面做轨迹搜索时会把同一个人拆成很多条查询结果自然乱。如果 top tracklet 能覆盖几百帧说明检测和跟踪链路基本稳了才值得投入时间做 ReID 和检索。我在实际项目里会把这个查询结果直接截图作为调参前后对比的证据比口头说“好多了”有效得多。4. 让搜索真正成立为轨迹建立可查询的行人特征索引4.1 一条轨迹只留一个特征用高置信度帧特征做聚合从 track_points 表里能得到很多段轨迹但它们还没有身份属性。现在要做的是把每条 tracklet 变成一个可供检索的向量。实现里我不会把每一帧的特征都塞进索引那样检索结果会按帧返回而不是按轨迹返回同一个人有几百帧特征top-k 结果会被同一个人霸占其他人根本露不出来。所以先把 tracklet 折叠成一条 embedding。折叠要避免一个常见错误不能把所有帧的特征直接平均。侧身、遮挡、运动模糊都会污染最终向量。常见做法是取 32 帧置信度最高的特征做 L2 归一化之后平均再把平均向量归一化一次import numpy as np def build_tracklet_embedding(frames): # frames: 同一 track_id 的所有帧特征每项含 feat 和 conf frames sorted(frames, keylambda f: f[conf], reverseTrue)[:32] feats np.stack([f[feat] for f in frames], dtypenp.float32) feats feats / (np.linalg.norm(feats, axis1, keepdimsTrue) 1e-9) emb feats.mean(axis0) emb / (np.linalg.norm(emb) 1e-9) return emb参数说明排序取 top32 的目的是去脏不是取多。如果一段轨迹只有 20 帧就用 20 帧不必强凑。平均操作能抵消单帧姿态差异带来的特征波动但不能抵消长时间跨相机的光照差异所以后面做跨相机拼接时不能只看这一个向量还是要回到时空约束里做二次验证。这里需要先把每一帧的 ReID 特征提取出来常见做法是单独加载一个 ReID 模型对 bbox 裁剪图做一次前向。特征提取可以和检测并行也可以离线按 tracklet 再补跑后一种更省 GPU 时间。4.2 用 FAISS 先粗召回再用时空约束精排与拼接有了每条 tracklet 的向量再要对用户上传的查询图做搜索就要建向量索引。十万条以内我一般不引入专门的向量数据库直接上 faiss 做内存索引就够了。搜索过程分两段先用向量索引按相似度召回 top-k 条候选 tracklet再回到关系型数据里用时间和空间约束做精排把真正属于同一次出行的轨迹段拼接出来。这两步的顺序不能颠倒因为 faiss 只懂向量不懂相机和时间import faiss import numpy as np emb_dim 512 index faiss.IndexFlatIP(emb_dim) # tracklets_emb 是 N 条轨迹的向量矩阵必须是 float32 且已 L2 归一化 index.add(tracklets_emb) # extract_reid_feature 用你加载的 ReID 模型实现 # query_crop → 预处理到 256x128 → model(query_tensor) → L2 归一化 query_emb extract_reid_feature(query_crop) D, I index.search(query_emb[None, :], k20)参数说明IndexFlatIP 是暴力精确检索十万条以内查询毫秒级返回当轨迹量到百万、千万级别才需要换 IndexIVFFlat 或 HNSW。建索引前必须检查一遍向量是否归一化否则内积分数没有统一尺度。k20 是粗召回数宁多勿少因为后面时空精排会砍掉一多半候选k 如果只看 5 条真正跨相机的长链路很容易被挤掉这个参数直接决定了召回率的顶。4.3 检索参数怎么给k、阈值和先过滤后计算精排阶段我会用一条覆盖核心约束的查询按候选 tracklet 的时间和相机关系过滤。假设用户想看目标在上午 10:00 到 10:30 的轨迹并且限定出现在 A、B 两台相机下SELECT track_id, cam_id, MIN(frame_id) AS frame_start, MAX(frame_id) AS frame_end, COUNT(*) AS segment_len FROM track_points WHERE cam_id IN (A, B) AND frame_id BETWEEN :f0 AND :f1 GROUP BY track_id HAVING segment_len 5 ORDER BY segment_len DESC;参数说明f0、f1 由查询时间段换算成帧号匹配的是前面入库的 ts 字段换算关系。HAVING segment_len 5 过滤掉长度不足 5 帧的噪声碎片这类碎片通常是误检或瞬时目标参与拼接只会增加误报。查询结果里看 frame_start 和 frame_end 能判断候选段之间是否有衔接如果几条 tracklet 的首尾时间差在 2 至 3 秒内、且空间位置相互靠近就用 ReID 特征做最后确认否则视为两条独立的行人轨迹。这里有一个需要反复试的环节不同相机之间的 ReID 分数通常比同一相机内部低 0.1 到 0.2因为光照、视角差异很大。我一般把阈值分两档同一相机内 0.65跨相机 0.55。先窄后松可以明显减少把同色系的人串成同一个身份的问题。如果查准率还不够就回到 2.3 节重新审视特征质量而不是继续调低阈值。5. 基于机器学习的轨迹搜索避坑现象、原因、解法5.1 轨迹被切成几十段现象检索结果里最长的轨迹只有十几秒目标明明走了两分钟却被拆成二十多条碎片搜索出来的链路根本连不成线。原因监控图像里行人偏小默认检测置信度阈值 0.25 直接丢掉大部分远端目标检测一旦短暂漏框跟踪器就会判定轨迹终结下一次检测到就分配一个全新 track_id。解决排查时先把 conf 降到 0.08 到 0.12同时在 ByteTrack 配置里把 track_buffer 拉长到按秒算。以现场 10 帧/秒举例要让轨迹容忍 3 秒遮挡就把 track_buffer 设为 30 帧再把 low_thresh 放在 conf 附近让低置信度框进入数据关联而不是被直接丢弃。调完后再跑一次 3.3 节里的聚合查询tracklet 平均长度至少要到几十帧才算及格。5.2 ReID 把两个相似行人当成一个人现象查询一个穿红色上衣的人返回结果里出现好几个明显不同的人点击预览一眼就能看出串人。原因ReID 特征是全局外观向量两个人穿衣颜色、体态相近时特征区分度快速下降如果查询图还是监控里截出来的模糊小图特征又被进一步拉偏。解决先对查询图做预处理模糊小图可以用图像超分辨率重建提一轮清晰度再提特征特征聚合时只取置信度最高的帧避免用运动模糊帧参与平均。最后加一层时间约束兜底同一个人在同一时刻不可能出现在两台物理距离很远的相机如果候选轨迹在时间上重叠直接判定不是同一条链路的可能性极高不需要和 ReID 分数硬拼。5.3 想按时间段搜索却全库扫描现象“查这个人上午 10:00 到 10:15 的轨迹”这种很普通的查询在库里跑了半天SQLite 直接卡死。原因track_points 表没有针对时间和相机建索引查询里只要有 frame_id 范围过滤就变成全表扫描数据量一大立刻暴露。解决给轨迹表加复合索引索引列顺序按 cam_id 在前、frame_id 在后CREATE INDEX idx_cam_frame ON track_points (cam_id, frame_id);索引顺序不能反过来。先按相机收缩到单路数据再按帧号范围过滤才能把扫描规模压下来如果反过来按 frame_id 在前跨相机的全时段扫描依然存在。加了索引后几十万条记录也能在几十毫秒内返回这是投入产出比最高的一个改动。5.4 faiss 返回的相似度分数没法看现象用 faiss 检索出来的分数有的高达 9.8有的只有 2.3完全没法设一个统一的阈值来判断“像不像”。原因建库时没有对向量做 L2 归一化IndexFlatIP 的内积同时受方向和模长影响模长大的向量天然得分高和相似度无关。解决build 和 query 双端都做归一化归一化后内积等于余弦相似度分数范围落在 -1 到 1 之间阈值设置才有物理意义。查出来的分数分布如果还是挤在 0.95 附近那是候选集本身太像不代表检索出错需要回头考虑是否要换更强的 ReID 模型。5.5 多路视频一接进来推理就卡死现象接到 8 路以上视频流GPU 显存飙升检测丢帧track_id 频繁断裂整个链路开始连锁反应。原因直接把原始 1080p 帧送给检测器多路采集线程同时打推理没有统一批处理和显存规划算力被打满。解决进入检测前先统一 letterbox 到 640 或 768 分辨率目标太小再放大到 960多路视频用生产者消费者模型把多路帧攒成一个大 batch 再送 GPU而不是每路各开一个进程写推理循环。如果机器确实扛不住把采集帧率降到 5fps 是一步后悔药轨迹还能接上但对快速动作会丢细节属于最后再用的手段。6. 低成本的收敛技巧先把搜索结果画回时间轴6.1 画轨迹用的 30 行脚本先看 ID 跳变再谈指标调参阶段我几乎不看指标先把轨迹画回视频上看。人眼对“轨迹是不是同一个人”的判断比 MOTA 这类数字敏感得多。写一个简单的可视化脚本把 track_id 渲染成不同颜色的框逐帧看目标走过遮挡区域时 id 有没有跳变import cv2 def visualize_track(video_path, hits): cap cv2.VideoCapture(video_path) frame_id 0 # hits: {frame_id: [(tid, x1, y1, x2, y2)]} while cap.isOpened(): ret, frame cap.read() if not ret: break for tid, x1, y1, x2, y2 in hits.get(frame_id, []): color (tid * 37 % 255, tid * 61 % 255, tid * 97 % 255) cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), color, 2) cv2.putText(frame, str(tid), (int(x1), int(y1) - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 1) cv2.imshow(track, frame) if cv2.waitKey(30) 0xFF ord(q): break frame_id 1 cap.release()参数说明waitKey(30) 表示每帧等待 30 毫秒25 帧/秒的视频基本按原速播放。这个脚本配合前面的 SQLite 查询结果使用先把某个查询时间段内所有轨迹点取出来按 frame_id 组织成 dict 再传入。看到 ID 跳变后再上指标做回归重点看 IDF1 而不是 MOTA轨迹搜索拼的就是 ID 别换人IDF1 比 MOTA 更能反映这个能力。6.2 高频调参习惯先检测、再跟踪、最后动 ReID把这段时间的踩坑经验压缩成一句调参顺序我会坚持先检测、再跟踪、最后动 ReID。新拿到一段现场录像我第一轮只调 conf 阈值先保证行人框不漏第二轮调 track_buffer 和 low_thresh让 tracklet 平均长度拉到上百帧第三轮才做查询测试根据反馈微调 ReID 相似度阈值。每一轮都用同一个 5 分钟片段回归避免在长视频上反复试错。期望不用多高反正 90% 的轨迹翻车都不是 ReID 的锅而是检测置信度参数没跟着场景走。先修最靠近入口的环节比换模型、堆 GPU 都有效。希望帮到你。本文还有配套的精品资源点击获取
返回列表