图像行人轨迹搜索:从目标检测到卡尔曼滤波的完整技术解析
简介:这是一份面向计算机视觉与监控视频分析场景的机器学习工程包,输入一张目标图像即可从大量视频中筛选出包含该对象的片段并自动标记,实现行人轨迹搜索,适合有一定深度学习基础、希望直接运行或二次开发的读者。资源基于 Python、TensorFlow-GPU 与 Keras 构建,整个压缩包共 365 个文件,大小约 30.72MB。文件组成以 JSON 配置、Python 源码及 pyc 编译文件为主:大量 JSON 用于保存模型元数据与运行参数,py 脚本为可直接修改的源码,pyc 为预编译模块;同时提供模型权重、图片示例、说明文档与 sqlite 数据持久化文件,目录划分清晰,便于按用途取用。已有 145 人学习下载。压缩包内含已训练好的检测模型配置与权重、完整可调用脚本及运行说明,可帮助使用者快速跑通从目标检测到轨迹关联的全流程,并在此基础上调整模型参数、替换自有数据,适合作为行人重识别与视频检索项目的参考。
1. 基于机器学习的图像行人轨迹搜索:把“找相似图”变成“还原路径”
接到一个标题叫“基于机器学习的图像行人轨迹搜索”的项目压缩包时,很容易被名字误导,以为核心工作是图像检索:框住一个人,去库里找相似画面。实际上,监控视频抽帧出来的连续图像里,单纯做相似度匹配根本得不出轨迹。真正要做的是三段式任务:先靠检测模型在每一帧里定位行人,再靠跟踪关联把帧间的同一个行人连成路径,最后把路径组织成按时间、区域、轨迹形状可查询的索引。轨迹一旦还原出来,才能回答“这个人在什么时间段走过哪条路线”。适合做这件事的人,通常是做安防监控画面分析、商场人流动线统计,或者拿连续图像批量做行人运动数据集的研究者。这个方向值得投入,因为它把几个机器学习子任务串成了一个能落地的闭环。
2. 轨迹搜索到底在搜什么:和行人重识别的区别与机器学习应用流程
2.1 先分清两件事:搜索运动记录和搜索身份
轨迹搜索这个名词最容易和行人重识别(Person ReID)混在一起。行人重识别做的是“跨画面身份匹配”,给出一张目标图,在另外一路摄像机里找出同一个人的照片;而行人轨迹搜索做的是“路线还原”,输入连续图像序列,输出同一个行人在时间轴上的坐标序列。两者并不冲突,反而是上下游关系:重识别特征可以作为轨迹关联的辅助信号,轨迹连成之后,跨镜头的身份匹配才有更完整的上下文。
我从实际需求出发,一般会先问对方一个问题:你拿到的输入是连续抽帧,还是几张不连续照片?只有不连续照片时,能做的是“找同一个人”,做不到“搜轨迹”;只有连续图像才能支撑轨迹关联。这个项目标题里同时出现“图像”和“行人轨迹搜索”,侧重点显然是后者:输入是图像序列,输出是轨迹结果。如果一开始就把目标定成图像检索,后面做出来的系统只会在“找相似图”的层面打转,根本没法回答“这个人在某个时间段去了哪里”这样的业务问题。
2.2 机器学习应用流程里常见的三种实现架构
在这个方向的项目里,最常见实现方案有三条,可以先按自己手里的算力和场景选一条开始:
第一种是检测器加运动关联。用YOLO这类轻量检测器输出候选框,再靠框与框的重叠关系(IOU)和卡尔曼滤波做帧间关联。优点:速度快、依赖少,单镜头人少时效果够好。缺点:密集或遮挡场景里,ID容易互相错配。
第二种是检测加重识别再加关联。检测器照常输出框,另加一个重识别模型,给每个框提取外观特征向量;匹配时同时看两个信号:运动预测与检测框的距离、外观特征之间的余弦距离。这就是DeepSORT系跟踪器的思路,也是我在复现这类项目时最常采用的基线。原因很实际:它把“视觉相似”和“运动连续”拆开,出问题时能明确知道是哪一个环节出错。
第三种是端到端统一输出,用可学习的跟踪模块把检测和关联放在同一个网络里输出。这种方案精度理论上最高,但训练数据要求高、部署成本大,在中小型项目里性价比不高。我通常的做法是先按第二种搭一个能跑通闭环的版本,再决定要不要向下换成轻量方案。
不管选哪种,机器学习应用流程本身都固定是三段式:特征提取(检测、重识别)→ 帧间关联(运动推断与匹配)→ 结果输出(轨迹存储、搜索)。把这三段打通之后,调参才有意义。这里有一个选型判断表可以参考:画面里同时出现的人数少于20人且机位固定,用第一种;画面密度高、遮挡频繁,直接上第二种;有离线训练资源和大量同一场景数据,才考虑第三种。
2.3 最小命令:把压缩包里的测试画面跑出一条轨迹
假设已经拿到了一个包含模型权重、依赖文件和测试抽帧的压缩包,常见做法是按这套顺序快速跑通:
unzip trajectory_search.zip -d trajectory_search_proj cd trajectory_search_proj python -m venv venv source venv/bin/activate pip install -r requirements.txt python run_search.py --input_dir data/sample_frames --output_dir output/tracks --conf 0.5run_search.py一般由四段组成:读帧、检测、关联、写轨迹,输出目录里会生成一个按track_id组织的 CSV 或 JSON。--input_dir只接受图像帧目录,如果拿到的数据还是原始视频,就先要有一个抽帧脚本把视频切成按序号命名的帧;--conf是检测置信度,只在关联前过滤低分框,不能指望把它当作轨迹的开关;--output_dir指向输出目录,程序会按track_id分文件保存。
第一次跑不通时,九成问题出在路径而不是模型:权重路径写死、数据目录里放的是视频而非图片、pip 依赖版本冲突。一个习惯是先让主程序把每个输入输出路径打印出来,确认“它到底在读哪一帧”。先把一条轨迹画出来,哪怕它是断的、错的,也好过盯着黑匣子猜。这个“端到端先出图,再回头调参”的次序,是我在机器学习应用落地里比较笃定的习惯。
3. 检测器选型与卡尔曼滤波算法参数:轨迹能否连起来,关键看这一环
3.1 实时图像场景里,检测器应该选 YOLO 系还是两阶段网络
图像行人轨迹搜索对检测部分的实时性要求比普通图像分类高得多。监控抽帧往往要求帧率不低于 10 FPS,不然轨迹很容易断。两阶段检测器在小目标、密集人群场景下精度更好,但单帧耗时普遍偏高;YOLO 系检测器在视频流的实时图像场景里部署更方便,对行人这类中大型目标效果足够。选型判断我一般按三个条件卡:
第一,检测帧率要明显高于抽帧帧率,留出至少 30% 到 50% 的余量,防止排队积压。第二,行人类别在验证集上的 AP 要高于 0.8;如果模型库里的“person”类 AP 很差,说明训练数据里的行人形态与你的画面差异过大,换模型不如先做图像预处理。第三,部署机的显存要提前量好。一个 YOLOv8n 大约占 1GB 左右显存,但加上 ReID 分支后显存会明显上涨,一定要在接完整链路之前测一次峰值占用。
3.2 检测框质量:置信度、NMS 和低对比度画面的预处理
检测输出并不直接等于跟踪器的输入。连续两帧之间,同一个人的框中心应该只移动很小距离,如果框来回跳动,跟踪器会被迫频繁开关轨迹。因此,我会在检测后做一个轻量处理流程:先滤掉低于conf的框,再对重叠的高分框做 NMS,最后用上一帧的卡尔曼预测框对这个检测框做一点平滑。这里的平滑系数如果偏向当前检测,框更贴合但不稳定;偏向历史轨迹则更平滑,但快速走动的人会拖出一条“尾巴”。
监控画面里常见的低对比度问题是另一大坑。我在处理偏暗画面时,第一件事不是换检测权重,而是先做一次图像增强,比如 CLAHE。这个操作很短:
import cv2 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) frame_enhanced = clahe.apply(frame_gray) frame = cv2.cvtColor(frame_enhanced, cv2.COLOR_GRAY2BGR)clipLimit控制对比度拉伸强度,设得太大容易出现噪声块;tileGridSize决定局部块大小,8×8 适合 1280×720 的监控帧,画面更大时可以换到 16×16。把增强后的帧再送检测器,往往比换一个更深的模型权重来得划算。如果画面来源是多路监控并且想做统一检测,图像拼接也是一个路子,但拼接缝附近的行人框容易被切断;先做全景拼接再检测时,要在拼接矩阵上保留原始坐标映射,不然输出轨迹坐标无法映射回原镜头。
3.3 卡尔曼滤波在图像行人轨迹搜索里的状态设计与调参方向
帧间关联的“运动预测”部分,最常见做法是卡尔曼滤波。按 DeepSORT 的表达习惯,状态向量常写成(x, y, w, h, vx, vy):x、y是框中心点坐标,w、h是宽高,vx、vy是中心点速度。每一帧做三步:用上一帧的状态预测当前位置,把预测位置和此刻检测框做匹配,匹配成功就更新状态。没有检测框命中时,预测值可以当作“外推”的临时轨迹点,但外推次数不能太多,否则轨迹会越飘越远。
调参上,直接改配置里两个值:过程噪声 Q 和测量噪声 R。Q 越大,表示模型越不相信匀速运动假设,会更快地倒向实时测量结果;R 越大,表示越不相信检测框精度,轨迹更平滑。实际调参时,Q 和 R 的比值比绝对值重要。经验做法是先按默认值跑一遍,观察轨迹是贴合还是滞后:如果贴合得太紧导致框抖动明显,就把 Q 调小或把 R 调大;如果检测框移动时轨迹迟迟不跟,就反向调整。这套调参在噪声数据比较严重的监控画面上尤其玄学,同一个参数组换一段光线不同的视频,结果可能完全不一样,所以每次只动一个变量。
一个简化的状态更新代码可以帮助理解这个直觉:
class KalmanBox: def __init__(self, x, y, w, h): self.x, self.y, self.w, self.h = x, y, w, h self.vx = self.vy = 0.0 self.gain = 0.6 def predict(self, dt): self.x += self.vx * dt self.y += self.vy * dt def update(self, zx, zy, zw, zh): self.x += self.gain * (zx - self.x) self.y += self.gain * (zy - self.y) self.w += self.gain * (zw - self.w) self.h += self.gain * (zh - self.h) self.vx = (zx - self.x) * 0.5 self.vy = (zy - self.y) * 0.5这个类只是教学级简化版本,完整实现还要维护协方差矩阵。gain表示更新步长,小则平滑、大则跟手。predict里的dt如果恒等 1,就等于默认等间隔抽帧;如果帧率波动,要把真实时间戳差异传进来。在完整工程里,手感相同的配置项就是前面说的 Q 和 R,调整路径也是先看轨迹漂移量再决定方向。
另外,卡尔曼滤波不是唯一解。如果检测器稳定性足够好,纯 IOU 匹配也可以,而且能避免一部分错配。像 ByteTrack 这类跟踪器会把低置信度框也保留下来参与匹配,减少漏检导致的断裂,在轨迹搜索项目里值得试一下,不要一上来就把卡尔曼当成必选组件。
4. 把帧间候选框变成轨迹:关联、存储与轨迹检索的实现
4.1 运动距离与外观特征加权,匹配时到底在看什么
在图像行人轨迹搜索中,单独用 IOU 匹配在密集场景里几乎一定会出错。IOU 只看两个框的重叠面积,两个相邻行人的框重合度高的时候,会被错误连成同一轨迹。更常见的做法是使用两种互补的度量:运动模型距离(比如马氏距离)和外观特征距离(ReID 向量的余弦距离)。
马氏距离会把“预测位置与观测位置的偏差”按协方差归一化,避免大框和小框用欧式距离时天然不公平;余弦距离则适合比较特征向量方向。最终匹配得分是两者的加权和。我通常先让运动距离权重占大头,跑完看结果,再在出现错跟的场景里提高外观权重。怎么判断错跟?看可视化轨迹是否在交叉点处交换颜色,这是最直观的信号。
这里要留意级联匹配的排序逻辑。连续匹配越久、已经稳定存在的轨迹优先匹配,刚出现的轨迹放到后面,避免“老轨迹被新检测抢走”。这个顺序改不得,一旦反过来,短暂遮挡后重新出现的老人会被当成新人,轨迹一片一片地碎。
4.2 轨迹的存储结构、时间戳和区域检索
轨迹在运行期以track_id为主键,每一条轨迹是一个有序列表,元素是(frame_id, x, y, w, h, ts)。其中frame_id用抽帧顺序,ts用真实时间戳。如果后续要按时间段和区域搜索,我一般会把轨迹落成一张 SQLite 表,字段包含track_id、ts、x、y、w、h、region_id。这样能用数据库索引完成查询:找出上午十点到十一点之间经过某个区域的人。这比在内存里人工过滤可靠得多,也方便前端按track_id回放整段轨迹。
一个按时间和区域检索的示例如下:
SELECT track_id, COUNT(*) AS point_cnt, MIN(ts) AS first_ts, MAX(ts) AS last_ts FROM track_points WHERE ts BETWEEN ? AND ? AND region_id = ? GROUP BY track_id HAVING point_cnt >= 5 ORDER BY first_ts;GROUP BY track_id把每段轨迹的行数、首末时间统计出来;HAVING point_cnt >= 5是为了滤掉点太少、无法构成路线的碎片轨迹。region_id可以预先用检测框中心点落到哪个区域来打标。数据量再大一些时,SQLite 的 RTree 索引可以用来做空间范围查询,但前期用字段过滤足够支撑原型验证。注意时间戳统一存成毫秒整数,避免字符串比较时出现排序错乱。
4.3 关联主循环里先调好的五个跟踪参数
主循环里的关联参数直接影响轨迹质量,我一般把它们放在一个配置块里集中管理。下面的起点值以 1080p 监控、约每 500ms 抽一帧为参照:
| 参数 | 含义 | 起点值 | 调节方向 |
|---|---|---|---|
| conf_thres | 检测置信度下限 | 0.5 - 0.6 | 漏检多就调低,误检多就调高 |
| max_dist | 预测框与检测框匹配的最大像素距离 | 60 - 100 | 画面宽或人移动快就调大 |
| track_max_age | 轨迹在丢失后最多保持的帧数 | 30 | 人流量大时调小,防止 ID 堆积 |
| n_init | 新轨迹需要连续命中几帧才确认 | 3 - 5 | 想更快出轨迹就调小,但碎片会变多 |
| max_cosine_dist | 外观特征余弦距离阈值 | 0.3 - 0.5 | 多镜头或外观多变场景适当放宽 |
这些参数没有绝对正确值。以画面宽度为参照:1920 宽的帧,max_dist取 60 到 100 比较稳;1280 宽的帧取 40 到 60。人走得快,距离阈值就要放大,否则下一帧检测框已经在匹配半径之外,轨迹必然断。
排查顺序比参数本身更重要。轨迹一断就去调track_max_age,是很多人都踩过的坑。真正原因往往是conf_thres太高,导致跟踪器连续好几帧没有观测可用。建议先看检测日志里每帧的行人框数量:如果框数量本身剧烈波动,先修检测,再动跟踪。
4.4 轨迹相似度搜索:当输入是“一段路径”时怎么搜
有些场景不按时间区域搜,而是按轨迹形状搜,比如找“从 A 区绕圈走到 B 区的人”。此时不能直接把两条不同长度的轨迹逐点相减,常见做法是用动态时间规划(DTW)计算距离。两条轨迹长度可以不同,慢走和快走只要路线相近,距离就会小。坐标归一化也重要:将x、y分别除以画面宽高,避免不同分辨率下距离数值失真。
一个最简实现如下:
def dtw_dist(a, b): import numpy as np n, m = len(a), len(b) d = np.zeros((n + 1, m + 1)) d[:, 0] = np.inf d[0, :] = np.inf d[0, 0] = 0 for i in range(1, n + 1): for j in range(1, m + 1): cost = np.linalg.norm(np.array(a[i - 1]) - np.array(b[j - 1])) d[i, j] = cost + min(d[i - 1, j], d[i, j - 1], d[i - 1, j - 1]) return d[n, m]a和b是两条轨迹的坐标点列表,进入函数前已经各自归一化。这个算法的时间复杂度是 O(n*m),对离线搜索足够;实时在线搜轨迹时,要先降采样或改用近似距离。轨迹搜索项目里,这个函数是处理“按形状找路径”需求时最实用的一招。
5. 真实监控画面里容易翻车的四个坑:现象、原因、处理
我在复现类似项目时,发现真正让结果崩掉的往往不是模型本身,而是几个具体的工程细节。下面四条都来自监控画面的实际场景,按“现象、原因、处理”的次序写出来,可以当排障清单用。
5.1 多镜头或云台切换后,同一个人被重开了一条轨迹
现象:画面切到另一路摄像机或云台转动后,刚才的track_id不见了,镜头转回来后,同一个人又多了一个新 ID。
原因:相邻两帧的画面内容发生突变,ReID 特征距离瞬间超过阈值。固定机位里人的特征变化是渐进的,镜头切换会把它变成跳变;跟踪器没有“世界坐标”概念,自然认为这是一个新目标。
处理:在做抽帧序列时记录镜头切换帧。检测到两帧图像差异过大时,主动把现存轨迹标记为“待匹配”状态,切换后尝试用外观特征重新接上旧 ID,接不上的再开新 ID。像海康威视监控录像导出后的序列,建议先按录像分段,不要跨段匹配,能显著减少 ID 跳变。
5.2 两个人穿同色衣服,并行行走时轨迹互串
现象:两个人并排从画面中走过来,中途交叉,之后他们的轨迹发生 ID 互换,可视化图上颜色在交叉点交换。
原因:外观特征区分度不够。身高、衣服颜色接近时,余弦距离很容易低于内部阈值,匹配算法在交叉处“看走眼”。这类错误属于比较典型的噪声数据问题:ReID 训练集里很难覆盖所有穿同色衣服的成对样本。
处理:交叉瞬间,历史速度方向比外观颜色更可信,把运动距离权重调高即可。再加一个保护逻辑:两条轨迹在连续若干帧内距离过近、相对速度又接近时,禁止交叉后的 ID 跳变。实现上,用各自当前速度外推,判断交叉后哪个框更可能属于原轨迹,再执行唯一匹配。
5.3 抽帧帧率波动导致卡尔曼滤波外推偏差
现象:服务器负载高时抽帧延迟变大,某两帧间隔接近两秒,预测框位置和实际框差出大半个画面,匹配失败,轨迹中断。
原因:大多数跟踪实现把状态转移里的dt当成常量 1,抽帧节奏变了以后,状态转移矩阵没有按真实时间变化,等于用“上一秒的速度”去预测“两秒后的位置”。
处理:把抽帧的原始时间戳传入predict。当连续两帧间隔超过阈值(比如抽帧间隔 500ms 时,阈值设为 800ms),先不让这条轨迹参与新帧匹配,而是等待,避免把另一个行人误配进来。这条在输入是图像序列而非原始视频时尤其重要,因为图像序列的“帧间时间”完全取决于抽帧脚本。
5.4 低对比度、低亮度画面漏检,轨迹断裂
现象:光线不足的监控画面上,行人轮廓几乎与背景融为一体,检测器连续几帧没有输出框,跟踪器只能保持一段预测轨迹,最终因为超过track_max_age而作废。
原因:检测器训练数据大多来自光照较好的公开数据集,弱光环境下直接喂原始帧,相当于做了一个分布偏移。
处理:送检测器之前先做 CLAHE 或低光增强,效果不明显再叠加去雾、直方图拉伸。检测阈值可以适当降到 0.4 附近,并结合“断帧插值”:某track_id缺失一到两帧时,用前两帧位置线性补点,等检测回来再对齐。这类预处理应该做成插入式模块,不要让轨迹搜索主流程变复杂。
6. 用可视化验证轨迹搜索,并把 MOTA 当成最终接受标准
6.1 先画轨迹图,再谈参数
跑完主流程以后,我第一件事不是看指标,而是把所有轨迹画在同一张图上,按track_id着色。这一步能直接暴露断头和错跟位置:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("output/tracks.csv") for tid, g in df.groupby("track_id"): plt.plot(g["x"], g["y"], lw=2, label=f"ID-{tid}") plt.gca().invert_yaxis() plt.legend(fontsize=7) plt.savefig("tracks_preview.png")这段代码假设 CSV 里有track_id、x、y三列。invert_yaxis是因为图像坐标的 y 轴向下,不反转看起来会上下颠倒。看见交叉点上两条曲线互换颜色,就说明发生了 ID 切换;看见轨迹在某个区域密集终止,去查那几帧的检测输出,通常是漏检而不是跟踪器问题。
6.2 MOTA 指标的计算口径
可视化说清了“长什么样”,数值指标则是最终接受标准。MOTA 把三件事折算成一个分数:漏检(FN)、误检(FP)、ID 切换(IDSW),公式是:
MOTA = 1 - (FN + FP + IDSW) / GT
其中 GT 是人工标注的真值框总数。MOTA 越高,整体轨迹越靠谱。不要只看检测 AP,轨迹搜索项目里,一次 ID 切换带来的业务损失往往比单帧框偏几个像素大得多。改参数前记录一次 MOTA 基线,改完后再跑同一批抽帧数据比较,才能区分“检测器改进”和“跟踪器改进”各自贡献了多少。
以前我总把时间花在提升检测精度上,后来发现,在监控画面里,一次 ID 切换造成的轨迹污染远比单帧误检要贵。真正值得投入的是跟踪参数和预处理,这个顺序希望在轨迹搜索项目里能帮你少走些弯路,希望帮到你。
本文还有配套的精品资源,点击获取