基于卡尔曼滤波实现行人目标跟踪
目录
- 1. 作者介绍
- 2. 目标跟踪算法介绍
- 2.1 目标跟踪背景
- 2.2 目标跟踪任务分类
- 2.3 目标跟踪遇到的问题
- 2.4 目标跟踪方法
- 3. 卡尔曼滤波的目标跟踪算法介绍
- 3.1 所用数据视频说明
- 3.2 卡尔曼滤波
- 3.3 单目标跟踪算法
- 3.3.1 IOU匹配算法
- 3.3.2 卡尔曼滤波的使用方法
- 3.4 多目标跟踪算法
- 4. 单目标跟踪算法实现
- 4.1 环境搭建
- 4.2 数据集使用方法
- 4.3 准备工作
- 4.4 main.py完整代码
- 4.5 结果分析
- 5. 多目标跟踪算法实现
- 5.1 程序文件架构
- 5.2 const.py
- 5.3 kalman.py
- 5.4 matcher.py
- 5.5 measure.py
- 5.6 utils.py
- 5.7 main.py
- 5.8 结果分析
- 6. 鸣谢
1. 作者介绍
黄浩磊,男,西安工程大学电子信息学院,2023级硕士研究生,张宏伟人工智能课题组
研究方向:智能视觉检测与工业自动化技术
电子邮件:hhl57303@163.com
2. 目标跟踪算法介绍
2.1 目标跟踪背景
目标跟踪是计算机视觉的关键任务之一,在军工领域中警戒伺服、制导控制等功能实现中颇有建树,也是工领程域中自动驾驶、智能机器人等项目的重要组成部分,展现出了广泛的研究价值。目标跟踪就是在连续的视频序列中,建立所要跟踪物体的位置关系,从而得到物体完整的运动轨迹。其具体原理为给定图像第一帧的目标坐标位置,计算在下一帧图像中目标的确切位置。在运动的过程中,目标可能会呈现一些图像上的变化,比如姿态或形状的变化、尺度的变化、背景遮挡或光线亮度的变化等。目标跟踪算法的研究也围绕着解决这些变化和具体的应用展开。
2.2 目标跟踪任务分类
对于目标跟踪的具体研究领域,其可以分为以下几种任务:
- 单目标跟踪——给定单个目标,对该目标位置进行追踪;
- 多目标跟踪——一次性追踪多个目标位置;
- 行人重识别(Person Re-ID)——利用计算机视觉技术判断图像或者视频序列中是否存在特定行人的技术,广泛被认为是一个图像检索的子问题;
- 多目标多摄像头跟踪(MTMCT)——在多个摄像头分别位于完全不同的位置进行拍摄的情况下,对视频流中的多个目标进行同时跟踪和识别的技术;
- 姿态跟踪——通过对连续图像或视频数据进行处理,实现对目标姿态的跟踪和重建。
在本篇中,我们将主要实现对于一段视频中的行人进行单目标跟踪和多目标跟踪任务。
2.3 目标跟踪遇到的问题
虽然目标追踪的应用前景非常广泛,但还是有一些问题限制了它的应用,一般来说,跟踪过程中的技术难点主要包括以下几个方面:
- 遮挡(Occlusion)——遮挡是目标跟踪中最常见的挑战因素之一。遮挡又分为部分遮挡(Partial Occlusion)以及完全遮挡(Full Occlusion)。遮挡问题增加了目标跟踪的复杂性,因为跟踪器需要处理目标的消失和重新出现,同时确保在目标不可见期间不会错误地跟踪其他对象。该问题需要跟踪算法具备处理不同类型遮挡的能力,并结合外观建模、运动模型和深度学习等方法来解决。
- 形变(Deformation)——也是目标跟踪中的一大难题。目标表观的不断变化,通常导致跟踪发生漂移(Drift),这些变化使得目标跟踪变得复杂和困难。形变问题是目标跟踪中的一个重要挑战,要求跟踪算法具备适应性强、鲁棒性高以及对不同形变类型有针对性的处理策略,以实现准确、稳定的目标跟踪。
- 背景杂乱(Background Clutter)——指在复杂的场景中,背景中存在大量的杂乱、复杂纹理、动态变化等,从而影响了跟踪算法对目标对象的准确追踪。这种问题使得跟踪算法很容易将背景中的干扰与目标对象混淆,导致误判和失效。跟踪算法需要具备识别和过滤背景干扰的能力,以实现在复杂背景环境中准确、稳定的目标跟踪。
- 尺度变换(Scale Variation)——是指目标对象在视频或图像序列中可能发生尺寸变化,导致跟踪算法难以准确追踪目标的大小、位置和运动。这种问题通常涉及到目标的缩放或拉伸,可能由于目标靠近或远离摄像机、目标的内部或外部运动引起。该问题要求跟踪算法具备对目标尺度变换的识别和适应能力,以实现准确、稳定的目标跟踪。解决这个问题需要综合运用尺度估计、模型预测和多尺度处理等策略。
图1和图2所展示为上述问题的一些实例:
2.4 目标跟踪方法
目标跟踪是计算机视觉领域的一个重要任务,有多种不同的方法和技术,用于实现在视频序列或图像中跟踪目标对象。以下是目前目标跟踪中常用的一些方法:
- 基于特征的跟踪:
- 光流方法:通过分析像素在连续帧之间的运动来跟踪目标,常用于低级别的目标跟踪。
- 稀疏特征跟踪:例如,使用角点或边缘特征来跟踪目标对象。
- 稠密特征跟踪:使用像素级别的特征来跟踪目标,如基于深度学习的方法。
- 基于外观的跟踪:
- 模板匹配:使用目标的初始外观模板,并在后续帧中寻找最匹配的区域来跟踪目标。
- 相关滤波器:使用滤波器来估计目标的位置,常与目标的外观模型相关联。
- 基于运动的跟踪:
- 卡尔曼滤波器:用于估计目标的状态(位置、速度等)和外观模型,通常用于跟踪稳定运动的目标。
- 粒子滤波器:通过采样一组粒子来估计目标的状态,适用于非线性和非高斯分布的问题。
- 基于深度学习的跟踪:
- 卷积神经网络(CNN):使用卷积神经网络来学习目标的特征表示,以改善目标识别和跟踪性能。
- 循环神经网络(RNN):可以用于建模目标的运动模式,提高跟踪的稳定性。
- Siamese网络:通过训练网络来度量目标与候选区域之间的相似性,从而实现目标跟踪。
- 在线学习方法:
- 增量式学习:随着时间的推移,自适应地更新目标模型,以适应目标外观和运动的变化。
- 强化学习:使用强化学习技术来优化跟踪决策,以最大程度地提高跟踪性能。
- 多目标跟踪:
- 多目标跟踪:同时跟踪多个目标,考虑它们之间的相互关系和遮挡等复杂情况。
- 混合方法:
- 许多跟踪器将不同的方法组合在一起,以提高跟踪性能,例如,结合外观特征和运动信息。
这些方法在不同情境下都具有优势和劣势,选择合适的方法取决于应用需求、场景复杂性以及计算资源的可用性。近年来,深度学习技术在目标跟踪领域取得了显著进展,但传统的计算机视觉方法仍然在特定情景下具有价值。因此,通常会根据具体任务的需求来选择最适合的跟踪方法。
3. 卡尔曼滤波的目标跟踪算法介绍
3.1 所用数据视频说明
首先我们先说明一下程序所用到的数据视频。如图3所示为数据视频截图,该视频来源于opencv官方数据库,视频中包含了多位具有不同特征、不同行动方式的行人,是一段非常经典的行人目标检测视频。本次所用的演示视频是从该视频中截取了一部分使用。
同时,本次设计程序主要以基于卡尔曼滤波实现行人目标跟踪为主,因此所使用数据视频已提前使用YOLO算法对视频中每一帧所有行人进行检测并输出边界框位置,并将每帧的全部边界框位置输出成一个.txt文件,如图4所示。其具体原理和方法可参考以下链接:
目标检测—教你利用yolov5训练自己的目标检测模型(一个很详细的讲解,大家可以尝试照猫画虎)
基于YOLOv5实现安全帽检测识别(人类识别和安全帽识别没啥区别,只是训练时数据集不一样)
YOLO理解及边界框计算(用来理解何为边界框以及它的标注原理)
YOLOv5输出检测到的边界框的坐标、类别以及置信度(边界框标注代码实现)
3.2 卡尔曼滤波
卡尔曼滤波是一种用于估计系统状态的数学方法,它被广泛应用于控制系统、导航、机器人技术、金融等领域。卡尔曼滤波的核心原理是通过融合来自不同来源的信息来估计系统的状态,同时考虑了测量的不确定性和系统的动态特性。
以下是卡尔曼滤波的主要原理:
- 系统模型:卡尔曼滤波假设系统可以用线性动态方程来描述。这些方程通常表示为状态方程和观测方程。
- 状态方程:描述系统状态如何随时间演变。通常表示为 x(k) = F × x(k-1) + B u(k) + w(k),其中 x(k) 是系统状态向量,F 是状态转移矩阵,B 是控制输入矩阵,u(k) 是控制输入,w(k) 是过程噪声。
- 观测方程:描述测量如何与系统状态相关联。通常表示为 z(k) = H * x(k) + v(k),其中 z(k) 是测量向量,H 是观测矩阵,v(k) 是测量噪声。
- 预测阶段:在这个阶段,卡尔曼滤波器使用状态方程来预测系统的下一个状态和状态协方差。这一步通常包括以下几个步骤:
- 预测状态:根据状态方程和先前的状态估计,估计系统的下一个状态。
- 预测状态协方差:根据状态方程和先前的状态协方差,估计系统状态的不确定性。
- 更新阶段:在这个阶段,卡尔曼滤波器使用观测方程来融合测量值和预测的状态,从而改进状态估计。这一步通常包括以下几个步骤:
- 计算卡尔曼增益:卡尔曼增益(Kalman Gain)表示了预测状态与测量之间的权衡,以考虑测量的不确定性和预测的不确定性。
- 更新状态估计:使用卡尔曼增益来结合预测状态和测量,得到更准确的状态估计。
- 更新状态协方差:使用卡尔曼增益来更新状态协方差,以反映新的估计的不确定性。
- 循环迭代:卡尔曼滤波是一个迭代过程,系统的状态估计会随着时间的推移不断改进。每一次新的测量都会触发一次预测和更新过程,以提高状态估计的准确性。
卡尔曼滤波的关键在于优雅地融合了动态系统的预测信息和测量信息,同时考虑了这些信息的不确定性,从而实现了对系统状态的有效估计。这种方法对于需要实时状态估计的应用非常有用,例如导航系统、目标跟踪、自动驾驶汽车等领域。但要注意,卡尔曼滤波的有效性在很大程度上取决于系统模型的准确性和噪声的统计特性。
3.3 单目标跟踪算法
3.3.1 IOU匹配算法
在学习如何将卡尔曼滤波应用到目标跟踪中之前,我们首先需要了解目标跟踪中常用的一种方法——基于IOU的匹配算法。
IOU(Intersection over Union)匹配是目标检测和目标跟踪领域常用的一种方法,用于衡量两个边界框(通常是真实标注框和预测框)之间的重叠程度。IOU匹配在评估模型性能、计算准确度以及进行多目标跟踪等任务中都有广泛应用。
IOU是通过计算两个边界框的交集面积与并集面积之间的比率来度量它们的重叠程度。IOU的计算公式如下:
在目标检测和跟踪的场景中,通常会有一组预测框和一组真实标注框。IOU匹配的主要目标是将预测框与对应的真实标注框进行匹配,以评估预测框的准确度或者跟踪目标的连续性。具体的匹配过程如下:
- 对于每个预测框,计算其与所有真实标注框的IOU值。
- 将每个预测框与具有最高IOU值的真实标注框进行匹配。这意味着一个预测框只会与一个真实标注框进行匹配,而一个真实标注框也只会与一个预测框进行匹配。
- 通常会设定一个IOU阈值,例如0.5。如果某个预测框与最高IOU值的真实标注框的IOU大于等于阈值,就认为这个预测框是正确匹配的;否则,认为这个预测框是误检测。
- 对于未匹配到的真实标注框和预测框,可以根据具体的任务和需求进行后续处理,例如标记为漏检或者虚警。
我们以所用的数据视频举个例子:在T0帧时,我们指定其中一位行人为我们的跟踪目标,如图5左侧红框所示,我们假设称之为跟踪框T0;
而在接下来的T+1帧中,我们可以看到目标的位置发生变化,而跟踪框T0的位置却不会变化,如图6右侧所示;
此时我们需要使得跟踪框T0能发生更新,重新定位到跟踪目标身上变成新的跟踪框T+1,那么就需要计算跟踪框T0位置与T+1帧中所有检测到的行人边界框位置的IOU值,通过最大IOU匹配来确定跟踪目标在T+1帧时的位置,从而更新跟踪框变为跟踪框T+1。按此原理,在T+2帧时,我们可以计算T+2帧中所有行人位置与跟踪框T+1位置的IOU来推断出跟踪框T+2的位置;不断迭代,我们便可以得到每一帧中被跟踪目标的位置,从而实现视频中单个目标的持续跟踪。
3.3.2 卡尔曼滤波的使用方法
卡尔曼滤波(Kalman Filter)和最大IOU匹配法(Intersection over Union Matching)可以结合使用,以改善目标跟踪的性能。卡尔曼滤波可以用于预测目标的状态,而最大IOU匹配法可以用于关联检测和跟踪的目标。以下是将这两种方法结合使用的一般步骤:
-
目标状态表示:首先,每个目标需要用状态向量来表示,通常包括目标的位置(如中心坐标或边界框的坐标)、速度、加速度等信息。这是卡尔曼滤波需要的输入。
-
初始化卡尔曼滤波器:在第一帧中,对于每个检测到的目标,初始化一个对应的卡尔曼滤波器。卡尔曼滤波器将用于对目标状态进行预测和更新。
-
预测阶段:在每一帧中,对于每个跟踪目标,使用卡尔曼滤波器来进行状态的预测。这一步会提供关于目标在下一帧中可能位置的估计。
-
最大IOU匹配:对于当前帧中的每个检测,计算其与已跟踪目标的IOU值。最大IOU匹配法用于将检测与已有的跟踪目标关联起来。通常,与某个检测具有最大IOU值的跟踪目标会被认为是最可能与之对应的目标。
-
更新阶段:根据最大IOU匹配的结果,将检测信息用于更新卡尔曼滤波器中的目标状态。这包括使用检测的位置信息来修正状态估计。
-
添加新目标:对于未匹配到的检测,可以将其视为新目标并初始化新的卡尔曼滤波器。
-
删除失踪目标:如果某个目标在多帧中都未能与检测匹配成功,可以将其标记为失踪目标并停止跟踪。
-
迭代处理:重复上述步骤,以连续迭代地进行目标跟踪。
通过结合卡尔曼滤波和最大IOU匹配法,可以实现对目标的平滑跟踪和关联,因为卡尔曼滤波会考虑目标状态的动态变化,而最大IOU匹配法可以确保检测与目标之间的一致性。这种结合方法在目标跟踪中非常常见,特别是在处理视频序列等连续数据时,它可以提供更稳定的跟踪性能。但要注意,卡尔曼滤波的性能取决于模型的质量和噪声的统计特性,因此需要仔细调整参数和模型来适应不同的应用场景。
3.4 多目标跟踪算法
多目标跟踪是一个重要的计算机视觉和机器学习任务,其目标是在连续帧中跟踪多个目标,并将它们在不同帧之间进行关联。在多目标跟踪中,加入最大权重匹配是一种常见的方法,通常基于图论的思想,用于解决多目标关联问题。
最大权重匹配是图论中的一个基本问题,通常用于寻找一个带权重的二分图中的最佳匹配,以使权重总和最大化。这个问题的经典算法是匈牙利算法(Hungarian algorithm),其原理如下:
假设有一个带权重的二分图,其中包括两组节点,分别是左侧节点和右侧节点。我们的目标是找到一种匹配方式,使得连接这些节点的边上的权重之和最大。
下面是将最大权重匹配引入多目标跟踪的一般实现步骤:
-
目标表示:首先,每个目标需要在每一帧中进行表示。这通常包括目标的位置(如边界框或轨迹),以及与目标相关的特征或描述子,如颜色、速度、外观特征等。这些信息将构成跟踪问题的输入数据。
-
构建关联图:在每一帧中,构建一个图,其中包括两类节点:目标节点和检测节点。目标节点表示已知的跟踪目标,检测节点表示在当前帧中检测到的新目标候选。边表示目标节点和检测节点之间的关联关系,通常使用目标与检测之间的相似性(如距离、外观相似性等)来确定边的权重。
-
最大权重匹配:使用图论算法来解决关联问题。其中,最大权重匹配算法,如匈牙利算法(Hungarian algorithm)或者线性分配问题(Linear Assignment Problem, LAP),可以用于找到使得总权重最大的节点匹配。这将确定哪些检测节点与已知目标节点关联,以及哪些目标节点需要新建。
-
更新目标状态:根据最大权重匹配的结果,更新目标的状态(位置、速度等)以及任何与目标相关的信息。通常,使用目标与检测之间的关联关系来更新目标的状态,例如使用卡尔曼滤波或其他状态估计方法来融合检测信息和先前的目标状态。
-
处理未关联的目标:在最大权重匹配后,可能仍然存在未关联的目标或者未匹配的检测。你可以根据任务需求来处理这些未关联的目标,例如保持它们的跟踪状态、删除它们或者将它们标记为新的目标。
-
迭代处理:多目标跟踪是一个逐帧迭代的过程,需要在每一帧中执行上述步骤。同时,根据应用场景,你可能需要考虑在多帧中维护目标的轨迹,处理遮挡、目标消失和新目标出现等复杂情况。
最大权重匹配方法在多目标跟踪中通常表现出良好的性能,但需要仔细选择相似性度量和匹配算法,以适应具体的应用场景。此外,考虑到实时性等要求,还可以使用一些启发式方法来加速匹配过程。多目标跟踪是一个广泛研究的领域,具体的实现会依赖于任务和数据的特点。
4. 单目标跟踪算法实现
4.1 环境搭建
在学习代码之前,我们要确保自己的python环境中拥有numpy、matplotlib、opencv-python和networkx四个软件包,软件包版本并无要求,但尽量不要安装最新的版本(防止更新或者删除了某些函数,这下代码可不好改)对于软件包的安装可以参考如何在VSCode中添加Python解释器并安装Python库。当然,我们需要创建一个新的python项目工程。
4.2 数据集使用方法
这里给出程序所用的视频数据和已经使用YOLO检测输出的边界框位置.txt文件。
百度网盘链接:
链接:https://pan.baidu.com/s/1e6dwULkIaV_M969XlV53vg
提取码:fm45
这里给出了所用的视频数据和标注文件夹,其中“labels”文件夹中为每一帧的标注文件,“testvideo1”为数据视频。我们需要在新建的python工程目录下创建新的文件夹并命名为“data”,如图8所示:
4.3 准备工作
接着,我们需要在新创建的工程中新建一个.py文件并命名为“utils.py”,其中会编写一些将会使用到的公式,代码如下:
import cv2
# 将左上右下表示的框转换为中心点坐标和宽高表示的框
def xyxy_to_xywh(xyxy):
center_x = (xyxy[0] + xyxy[2]) / 2
center_y = (xyxy[1] + xyxy[3]) / 2
w = xyxy[2] - xyxy[0]
h = xyxy[3] - xyxy[1]
return (center_x, center_y, w, h)
# 在图像上绘制一个框
def plot_one_box(xyxy, img, color=(0, 200, 0), target=False):
xy1 = (int(xyxy[0]), int(xyxy[1]))
xy2 = (int(xyxy[2]), int(xyxy[3]))
if target:
color = (0, 0, 255)
cv2.rectangle(img, xy1, xy2, color, 1, cv2.LINE_AA) # filled
# 更新轨迹列表
def updata_trace_list(box_center, trace_list, max_list_len=50):
if len(trace_list) <= max_list_len:
trace_list.append(box_center)
else:
trace_list.pop(0)
trace_list.append(box_center)
return trace_list
# 绘制轨迹
def draw_trace(img, trace_list):
"""
更新trace_list,绘制trace
:param trace_list:
:param max_list_len:
:return:
"""
for i, item in enumerate(trace_list):
if i < 1:
continue
cv2.line(img,
(trace_list[i][0], trace_list[i][1]), (trace_list[i - 1][0], trace_list[i - 1][1]),
(255, 255, 0), 3)
# 计算IOU(交并比)
def cal_iou(box1, box2):
"""
:param box1: xyxy 左上右下
:param box2: xyxy
:return:
"""
x1min, y1min, x1max, y1max = box1[0], box1[1], box1[2], box1[3]
x2min, y2min, x2max, y2max = box2[0], box2[1], box2[2], box2[3]
# 计算两个框的面积
s1 = (y1max - y1min + 1.) * (x1max - x1min + 1.)
s2 = (y2max - y2min + 1.) * (x2max - x2min + 1.)
# 计算相交部分的坐标
xmin = max(x1min, x2min)
ymin = max(y1min, y2min)
xmax = min(x1max, x2max)
ymax = min(y1max, y2max)
inter_h = max(ymax - ymin + 1, 0)
inter_w = max(xmax - xmin + 1, 0)
intersection = inter_h * inter_w
union = s1 + s2 - intersection
# 计算iou
iou = intersection / union
return iou
# 计算两个中心点之间的距离
def cal_distance(box1, box2):
"""
计算两个box中心点的距离
:param box1: xyxy 左上右下
:param box2: xyxy
:return:
"""
center1 = ((box1[0] + box1[2]) // 2, (box1[1] + box1[3]) // 2)
center2 = ((box2[0] + box2[2]) // 2, (box2[1] + box2[3]) // 2)
dis = ((center1[0] - center2[0]) ** 2 + (center1[1] - center2[1]) ** 2) ** 0.5
return dis
# 将中心点坐标和宽高表示的框转换为左上右下表示的框
def xywh_to_xyxy(xywh):
x1 = xywh[0] - xywh[2] // 2
y1 = xywh[1] - xywh[3] // 2
x2 = xywh[0] + xywh[2] // 2
y2 = xywh[1] + xywh[3] // 2
return [x1, y1, x2, y2]
# 计算框1和框2的IOU
if __name__ == "__main__":
box1 = [100, 100, 200, 200]
box2 = [100, 100, 200, 300]
iou = cal_iou(box1, box2)
print(iou)
# 修改框1的值
box1.pop(0)
box1.append(555)
print(box1)
4.4 main.py完整代码
以下为单目标跟踪算法主代码:
import os
import cv2
import numpy as np
from utils import plot_one_box, cal_iou, xyxy_to_xywh, xywh_to_xyxy, updata_trace_list, draw_trace
# 单目标跟踪
# 检测器获得检测框,全程只赋予1个ID,有两个相同的东西进来时,不会丢失唯一跟踪目标
# 检测器的检测框为测量值
# 目标的状态X = [x,y,h,w,delta_x,delta_y],中心坐标,宽高,中心坐标速度
# 观测值
# 如何寻找目标的观测值
# 观测到的是N个框
# 怎么找到目标的观测值
# t时刻的框与t-1后验估计时刻IOU最大的框的那个作为观测值(存在误差,交叉情况下观测值会有误差)
# 所以需要使用先验估计值进行融合
# 状态初始化
initial_target_box = [729, 238, 764, 339] # 目标初始bouding box
initial_box_state = xyxy_to_xywh(initial_target_box)
initial_state = np.array([[initial_box_state[0], initial_box_state[1], initial_box_state[2], initial_box_state[3],
0, 0]]).T # [中心x,中心y,宽w,高h,dx,dy]
IOU_Threshold = 0.3 # 匹配时的阈值
# 状态转移矩阵,上一时刻的状态转移到当前时刻
A = np.array([[1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1]])
# 状态观测矩阵
H = np.eye(6)
# 过程噪声协方差矩阵Q,p(w)~N(0,Q),噪声来自真实世界中的不确定性,
# 在跟踪任务当中,过程噪声来自目标移动的不确定性(突然加速、减速、转弯等)
Q = np.eye(6) * 0.1
# 观测噪声协方差矩阵R,p(v)~N(0,R)
# 观测噪声来自于检测框丢失、重叠等
R = np.eye(6) * 1
# 控制输入矩阵B
B = None
# 状态估计协方差矩阵P初始化
P = np.eye(6)
if __name__ == "__main__":
# 定义视频路径、标签路径和文件名
video_path = "./data/testvideo1.mp4"
label_path = "./data/labels"
file_name = "testvideo1"
# 打开视频文件
cap = cv2.VideoCapture(video_path)
# cv2.namedWindow("track", cv2.WINDOW_NORMAL)
# 是否保存输出视频
SAVE_VIDEO = False
# 如果需要保存输出视频,设置视频编码器和帧率
if SAVE_VIDEO:
fourcc = cv2.VideoWriter_fourcc(*'XVID')
out = cv2.VideoWriter('kalman_output.avi', fourcc, 20, (768, 576))
# ---------状态初始化----------------------------------------
# 初始化状态变量
frame_counter = 1
X_posterior = np.array(initial_state)
P_posterior = np.array(P)
Z = np.array(initial_state)
trace_list = [] # 用于保存目标box的轨迹
while (True):
# 逐帧捕获视频
ret, frame = cap.read()
# 获取上一帧的后验估计,将其用白色框表示
last_box_posterior = xywh_to_xyxy(X_posterior[0:4])
plot_one_box(last_box_posterior, frame, color=(255, 255, 255), target=False)
# 如果视频结束,跳出循环
if not ret:
break
# print(frame_counter)
# 读取当前帧对应的标签文件
label_file_path = os.path.join(label_path, file_name + "_" + str(frame_counter) + ".txt")
with open(label_file_path, "r") as f:
content = f.readlines()
max_iou = IOU_Threshold
max_iou_matched = False
# ---------使用最大IOU来寻找观测值------------
for j, data_ in enumerate(content):
data = data_.replace('\n', "").split(" ")
xyxy = np.array(data[1:5], dtype="float")
plot_one_box(xyxy, frame)
iou = cal_iou(xyxy, xywh_to_xyxy(X_posterior[0:4]))
# 如果IOU大于阈值,认为找到了观测值
if iou > max_iou:
target_box = xyxy
max_iou = iou
max_iou_matched = True
if max_iou_matched == True:
# 如果找到了最大IOU BOX,则认为该框为观测值
plot_one_box(target_box, frame, target=True)
xywh = xyxy_to_xywh(target_box)
box_center = (int((target_box[0] + target_box[2]) // 2), int((target_box[1] + target_box[3]) // 2))
trace_list = updata_trace_list(box_center, trace_list, 100)
cv2.putText(frame, "Tracking", (int(target_box[0]), int(target_box[1] - 5)), cv2.FONT_HERSHEY_SIMPLEX,
0.7,
(255, 0, 0), 2)
# 计算dx,dy
dx = xywh[0] - X_posterior[0]
dy = xywh[1] - X_posterior[1]
Z[0:4] = np.array([xywh]).T
Z[4::] = np.array([dx, dy])
if max_iou_matched:
# -----进行先验估计-----------------
X_prior = np.dot(A, X_posterior)
box_prior = xywh_to_xyxy(X_prior[0:4])
# plot_one_box(box_prior, frame, color=(0, 0, 0), target=False)
# -----计算状态估计协方差矩阵P--------
P_prior_1 = np.dot(A, P_posterior)
P_prior = np.dot(P_prior_1, A.T) + Q
# ------计算卡尔曼增益---------------------
k1 = np.dot(P_prior, H.T)
k2 = np.dot(np.dot(H, P_prior), H.T) + R
K = np.dot(k1, np.linalg.inv(k2))
# --------------后验估计------------
X_posterior_1 = Z - np.dot(H, X_prior)
X_posterior = X_prior + np.dot(K, X_posterior_1)
box_posterior = xywh_to_xyxy(X_posterior[0:4])
# plot_one_box(box_posterior, frame, color=(255, 255, 255), target=False)
# ---------更新状态估计协方差矩阵P-----
P_posterior_1 = np.eye(6) - np.dot(K, H)
P_posterior = np.dot(P_posterior_1, P_prior)
else:
# 如果IOU匹配失败,此时失去观测值,那么直接使用上一次的最优估计作为先验估计
# 此时直接迭代,不使用卡尔曼滤波
X_posterior = np.dot(A, X_posterior)
# X_posterior = np.dot(A_, X_posterior)
box_posterior = xywh_to_xyxy(X_posterior[0:4])
# plot_one_box(box_posterior, frame, color=(255, 255, 255), target=False)
box_center = (
(int(box_posterior[0] + box_posterior[2]) // 2), int((box_posterior[1] + box_posterior[3]) // 2))
trace_list = updata_trace_list(box_center, trace_list, 20)
cv2.putText(frame, "Lost", (box_center[0], box_center[1] - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.7,
(255, 0, 0), 2)
draw_trace(frame, trace_list)
# 在图像上添加文本信息
cv2.putText(frame, "ALL BOXES(Green)", (25, 50), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 200, 0), 2)
cv2.putText(frame, "TRACKED BOX(Red)", (25, 75), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
cv2.putText(frame, "Last frame best estimation(White)", (25, 100), cv2.FONT_HERSHEY_SIMPLEX, 0.7,
(255, 255, 255), 2)
# 显示图像
cv2.imshow('track', frame)
# 如果需要保存视频,将帧写入输出视频文件
if SAVE_VIDEO:
out.write(frame)
frame_counter = frame_counter + 1
# 检测是否按下 'q' 键,如果按下则退出循环。同时我们可以在cv2.waitKey(200)中改变数值大小来控制视频播放速度
if cv2.waitKey(200) & 0xFF == ord('q'):
break
# 当所有操作完成后,释放视频捕获和关闭所有窗口
cap.release()
cv2.destroyAllWindows()
4.5 结果分析
运行main.py后,会弹出一个新的窗口,窗口中会按帧播放我们的目标跟踪结果,这里我们放出两种方法的跟踪结果展示,分别是基于最大IOU法的目标跟踪以及基于卡尔曼滤波改良的目标跟踪。
由上述两种方法结果我们可以明显看出,被跟踪目标在走到路灯处时被遮挡了,被跟踪目标在这一帧中未被检测到,也就是发生了漏检,这也是上述目标跟踪常见的问题之一——遮挡。因此在这一帧计算IOU更新跟踪框时,由于漏检使得并没有该帧中被跟踪目标的边界框位置,从而导致没有超过设定阈值的最佳IOU值,丢失跟踪目标。
而通过引入卡尔曼滤波之后可以让上一帧的检测框具有运动属性(例如,输入白框的dx,dy,使用卡尔曼不断预测下一帧白框的位置),从而使得白色框不再和之前一样来自于上一帧,而是根据运动属性预测下一帧,保持和当前检测框处于同一时间,从而解决IOU匹配的问题。
5. 多目标跟踪算法实现
5.1 程序文件架构
我们需要新建一个python项目,并将单目标跟踪项目的数据拷贝一份放入该项目目录下。
多目标跟踪由于加入了多特征方法,因此相较于单目标跟踪较为复杂,因此将该算法分为多个模块去实现,程序文件架构如图10所示,接下来我们将分步进行程序的编写与实现。
5.2 const.py
# 路径
FILE_DIR = "./data/labels"
VIDEO_PATH = "./data/testvideo1.mp4"
VIDEO_OUTPUT_PATH = "./data/multi-object.mp4"
FPS = 30 # 帧率
Size = (720, 360)
GREEN = (0, 250, 0)
RED = (0, 0, 250)
COLOR_MEA = GREEN
COLOR_STA = RED
COLOR_MATCH = (255, 255, 0)
5.3 kalman.py
import random
import numpy as np
import utils
from matcher import Matcher
GENERATE_SET = 1 # 设置航迹起始帧数
TERMINATE_SET = 7 # 设置航迹终止帧数
class Kalman:
def __init__(self, A, B, H, Q, R, X, P):
# 固定参数
self.A = A # 状态转移矩阵
self.B = B # 控制矩阵
self.H = H # 观测矩阵
self.Q = Q # 过程噪声
self.R = R # 量测噪声
# 迭代参数
self.X_posterior = X # 后验状态, 定义为 [中心x,中心y,宽w,高h,dx,dy]
self.P_posterior = P # 后验误差矩阵
self.X_prior = None # 先验状态
self.P_prior = None # 先验误差矩阵
self.K = None # kalman gain
self.Z = None # 观测, 定义为 [中心x,中心y,宽w,高h]
# 起始和终止策略
self.terminate_count = TERMINATE_SET
# 缓存航迹
self.track = [] # 记录当前航迹[(p1_x,p1_y),()]
self.track_color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
self.__record_track()
def predict(self):
"""
预测外推
:return:
"""
self.X_prior = np.dot(self.A, self.X_posterior)
self.P_prior = np.dot(np.dot(self.A, self.P_posterior), self.A.T) + self.Q
return self.X_prior, self.P_prior
@staticmethod
def association(kalman_list, mea_list):
"""
多目标关联,使用最大权重匹配
:param kalman_list: 状态列表,存着每个kalman对象,已经完成预测外推
:param mea_list: 量测列表,存着矩阵形式的目标量测 ndarray [c_x, c_y, w, h].T
:return:
"""
# 记录需要匹配的状态和量测
state_rec = {i for i in range(len(kalman_list))}
mea_rec = {i for i in range(len(mea_list))}
# 将状态类进行转换便于统一匹配类型
state_list = list() # [c_x, c_y, w, h].T
for kalman in kalman_list:
state = kalman.X_prior
state_list.append(state[0:4])
# 进行匹配得到一个匹配字典
match_dict = Matcher.match(state_list, mea_list)
# 根据匹配字典,将匹配上的直接进行更新,没有匹配上的返回
state_used = set()
mea_used = set()
match_list = list()
for state, mea in match_dict.items():
state_index = int(state.split('_')[1])
mea_index = int(mea.split('_')[1])
match_list.append([utils.state2box(state_list[state_index]), utils.mea2box(mea_list[mea_index])])
kalman_list[state_index].update(mea_list[mea_index])
state_used.add(state_index)
mea_used.add(mea_index)
# 求出未匹配状态和量测,返回
return list(state_rec - state_used), list(mea_rec - mea_used), match_list
def update(self, mea=None):
"""
完成一次kalman滤波
:param mea:
:return:
"""
status = True
if mea is not None: # 有关联量测匹配上
self.Z = mea
self.K = np.dot(np.dot(self.P_prior, self.H.T),
np.linalg.inv(np.dot(np.dot(self.H, self.P_prior), self.H.T) + self.R)) # 计算卡尔曼增益
self.X_posterior = self.X_prior + np.dot(self.K, self.Z - np.dot(self.H, self.X_prior)) # 更新后验估计
self.P_posterior = np.dot(np.eye(6) - np.dot(self.K, self.H), self.P_prior) # 更新后验误差矩阵
status = True
else: # 无关联量测匹配上
if self.terminate_count == 1:
status = False
else:
self.terminate_count -= 1
self.X_posterior = self.X_prior
self.P_posterior = self.P_prior
status = True
if status:
self.__record_track()
return status, self.X_posterior, self.P_posterior
def __record_track(self):
self.track.append([int(self.X_posterior[0]), int(self.X_posterior[1])])
if __name__ == '__main__':
# 状态转移矩阵,上一时刻的状态转移到当前时刻
A = np.array([[1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1]])
# 控制输入矩阵B
B = None
# 过程噪声协方差矩阵Q,p(w)~N(0,Q),噪声来自真实世界中的不确定性,
# 在跟踪任务当中,过程噪声来自于目标移动的不确定性(突然加速、减速、转弯等)
Q = np.eye(A.shape[0]) * 0.1
# 状态观测矩阵
H = np.array([[1, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0]])
# 观测噪声协方差矩阵R,p(v)~N(0,R)
# 观测噪声来自于检测框丢失、重叠等
R = np.eye(H.shape[0]) * 1
# 状态估计协方差矩阵P初始化
P = np.eye(A.shape[0])
box = [729, 238, 764, 339]
X = utils.box2state(box)
k1 = Kalman(A, B, H, Q, R, X, P)
print(k1.predict())
mea = [730, 240, 766, 340]
mea = utils.box2meas(mea)
print(k1.update(mea))
5.4 matcher.py
import networkx as nx
import numpy as np
import utils
class Matcher:
def __init__(self):
pass
@classmethod
def match(cls, state_list, measure_list): # 两个list中单个元素数据结构都是[c_x, c_y, w, h].T
"""
最大权值匹配
:param state_list: 先验状态list
:param measure_list: 量测list
:return: dict 匹配结果, eg:{'state_1': 'mea_1', 'state_0': 'mea_0'}
"""
graph = nx.Graph()
for idx_sta, state in enumerate(state_list):
state_node = 'state_%d' % idx_sta
graph.add_node(state_node, bipartite=0)
for idx_mea, measure in enumerate(measure_list):
mea_node = 'mea_%d' % idx_mea
graph.add_node(mea_node, bipartite=1)
score = cls.cal_iou(state, measure)
if score is not None:
graph.add_edge(state_node, mea_node, weight=score)
match_set = nx.max_weight_matching(graph)
res = dict()
for (node_1, node_2) in match_set:
if node_1.split('_')[0] == 'mea':
node_1, node_2 = node_2, node_1
res[node_1] = node_2
return res
@classmethod
def cal_iou(cls, state, measure):
"""
计算状态和观测之间的IOU
:param state:ndarray [c_x, c_y, w, h].T
:param measure:ndarray [c_x, c_y, w, h].T
:return:
"""
state = utils.mea2box(state) # [lt_x, lt_y, rb_x, rb_y].T
measure = utils.mea2box(measure) # [lt_x, lt_y, rb_x, rb_y].T
s_tl_x, s_tl_y, s_br_x, s_br_y = state[0], state[1], state[2], state[3]
m_tl_x, m_tl_y, m_br_x, m_br_y = measure[0], measure[1], measure[2], measure[3]
# 计算相交部分的坐标
x_min = max(s_tl_x, m_tl_x)
x_max = min(s_br_x, m_br_x)
y_min = max(s_tl_y, m_tl_y)
y_max = min(s_br_y, m_br_y)
inter_h = max(y_max - y_min + 1, 0)
inter_w = max(x_max - x_min + 1, 0)
inter = inter_h * inter_w
if inter == 0:
return 0
else:
return inter / ((s_br_x - s_tl_x) * (s_br_y - s_tl_y) + (m_br_x - m_tl_x) * (m_br_y - m_tl_y) - inter)
if __name__ == '__main__':
# state_list = [np.array([10, 10, 5, 5]).T, np.array([30, 30, 5, 5]).T]
state_list = []
measure_list = [np.array([12, 12, 5, 5]).T, np.array([28, 28, 5, 5]).T]
match_dict = Matcher.match(state_list, measure_list)
print(match_dict)
for state, mea in match_dict.items():
print(state, mea)
5.5 measure.py
import re
import pathlib
import numpy as np
import cv2
import const
def load_measurement(file_dir):
"""
根据file目录,产生观测list
:param file_dir: 每帧观测分别对应一个txt文件,每个文件中多个目标观测逐行写入
:return: 所有观测list,[[帧1所有观测],[帧2所有观测]]
"""
files = pathlib.Path(file_dir).rglob("testvideo1_*.txt")
files = sorted(files, key=lambda x: int(re.findall("testvideo1_(.*?).txt", str(x))[0]))
return [list(np.loadtxt(str(file), int, usecols=[1, 2, 3, 4])) for index, file in enumerate(files)]
if __name__ == "__main__":
cap = cv2.VideoCapture(const.VIDEO_PATH)
mea_list = load_measurement(const.FILE_DIR)
for mea_frame_list in mea_list:
ret, frame = cap.read()
for mea in mea_frame_list:
# print(mea)
cv2.rectangle(frame, tuple(mea[:2]), tuple(mea[2:]), const.COLOR_MEA, thickness=1)
cv2.imshow('Demo', frame)
cv2.waitKey(100) # 显示 1000 ms 即 1s 后消失
cap.release()
cv2.destroyAllWindows()
5.6 utils.py
import cv2
import numpy as np
def box2state(box):
center_x = (box[0] + box[2]) / 2
center_y = (box[1] + box[3]) / 2
w = box[2] - box[0]
h = box[3] - box[1]
return np.array([[center_x, center_y, w, h, 0, 0]]).T # 定义为 [中心x,中心y,宽w,高h,dx,dy]
def state2box(state):
center_x = state[0]
center_y = state[1]
w = state[2]
h = state[3]
return [int(i) for i in [center_x - w/2, center_y - h/2, center_x + w/2, center_y + h/2]]
def box2meas(box):
center_x = (box[0] + box[2]) / 2
center_y = (box[1] + box[3]) / 2
w = box[2] - box[0]
h = box[3] - box[1]
return np.array([[center_x, center_y, w, h]]).T # 定义为 [中心x,中心y,宽w,高h]
def mea2box(mea):
center_x = mea[0]
center_y = mea[1]
w = mea[2]
h = mea[3]
return [int(i) for i in [center_x - w/2, center_y - h/2, center_x + w/2, center_y + h/2]]
def mea2state(mea):
return np.row_stack((mea, np.zeros((2, 1))))
def state2mea(state):
return state.X_prior[0:4]
if __name__ == "__main__":
# box1 = [100, 100, 200, 200]
# box2 = [100, 100, 200, 300]
# iou = cal_iou(box1, box2)
# print(iou)
# box1.pop(0)
# box1.append(555)
# print(box1)
print(mea2state(np.array([[1,2,3,4]]).T))
5.7 main.py
# -*- coding: utf-8 -*-
import cv2
import numpy as np
import const
import utils
import measure
from kalman import Kalman
# --------------------------------Kalman参数---------------------------------------
# 状态转移矩阵,上一时刻的状态转移到当前时刻
A = np.array([[1, 0, 0, 0, 1, 0],
[0, 1, 0, 0, 0, 1],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 1]])
# 控制输入矩阵B
B = None
# 过程噪声协方差矩阵Q,p(w)~N(0,Q),噪声来自真实世界中的不确定性,
# 在跟踪任务当中,过程噪声来自于目标移动的不确定性(突然加速、减速、转弯等)
Q = np.eye(A.shape[0]) * 0.1
# 状态观测矩阵
H = np.array([[1, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0],
[0, 0, 0, 1, 0, 0]])
# 观测噪声协方差矩阵R,p(v)~N(0,R)
# 观测噪声来自于检测框丢失、重叠等
R = np.eye(H.shape[0]) * 1
# 状态估计协方差矩阵P初始化
P = np.eye(A.shape[0])
# -------------------------------------------------------------------------------
def main():
# 1.载入视频和目标位置信息
cap = cv2.VideoCapture(const.VIDEO_PATH) # 穿插着视频是为了方便展示
meas_list_all = measure.load_measurement(const.FILE_DIR)
sz = (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
fourcc = cv2.VideoWriter_fourcc('m', 'p', '4', 'v') # opencv3.0
video_writer = cv2.VideoWriter(const.VIDEO_OUTPUT_PATH, fourcc, const.FPS, sz, True)
# 2. 逐帧滤波
state_list = [] # 单帧目标状态信息,存kalman对象
frame_cnt = 1
for meas_list_frame in meas_list_all:
# --------------------------------------------加载当帧图像------------------------------------
ret, frame = cap.read()
if not ret:
break
# ---------------------------------------Kalman Filter for multi-objects-------------------
# 预测
for target in state_list:
target.predict()
# 关联
mea_list = [utils.box2meas(mea) for mea in meas_list_frame]
state_rem_list, mea_rem_list, match_list = Kalman.association(state_list, mea_list)
# 状态没匹配上的,更新一下,如果触发终止就删除
state_del = list()
for idx in state_rem_list:
status, _, _ = state_list[idx].update()
if not status:
state_del.append(idx)
state_list = [state_list[i] for i in range(len(state_list)) if i not in state_del]
# 量测没匹配上的,作为新生目标进行航迹起始
for idx in mea_rem_list:
state_list.append(Kalman(A, B, H, Q, R, utils.mea2state(mea_list[idx]), P))
# -----------------------------------------------可视化-----------------------------------
# 显示所有mea到图像上
for mea in meas_list_frame:
cv2.rectangle(frame, tuple(mea[:2]), tuple(mea[2:]), const.COLOR_MEA, thickness=1)
# 显示所有的state到图像上
for kalman in state_list:
pos = utils.state2box(kalman.X_posterior)
cv2.rectangle(frame, tuple(pos[:2]), tuple(pos[2:]), const.COLOR_STA, thickness=2)
# 将匹配关系画出来
for item in match_list:
cv2.line(frame, tuple(item[0][:2]), tuple(item[1][:2]), const.COLOR_MATCH, 3)
# 绘制轨迹
for kalman in state_list:
tracks_list = kalman.track
for idx in range(len(tracks_list) - 1):
last_frame = tracks_list[idx]
cur_frame = tracks_list[idx + 1]
# print(last_frame, cur_frame)
cv2.line(frame, last_frame, cur_frame, kalman.track_color, 2)
cv2.putText(frame, str(frame_cnt), (0, 50), color=const.RED, fontFace=cv2.FONT_HERSHEY_SIMPLEX, fontScale=1.5)
cv2.imshow('Demo', frame)
cv2.imwrite("./image/{}.jpg".format(frame_cnt), frame)
video_writer.write(frame)
cv2.waitKey(100) # 显示 1000 ms 即 1s 后消失
frame_cnt += 1
cap.release()
cv2.destroyAllWindows()
video_writer.release()
if __name__ == '__main__':
main()
5.8 结果分析
运行main.py,结果同样会以视频的形式播放出来,结果如下所示。可以看到通过特征取得使得出现在画面中的每一位行人都会被跟踪,直到在画面中失去目标。
6. 鸣谢
在此要特别感谢B站大佬oginwe所提供的思路与实现方法,原方法视频链接:
用卡尔曼滤波器打造一个简易单目标跟踪器
同时还有另一位大佬所修改的多目标跟踪方法,大佬GitHub链接:
基于卡尔曼滤波的多目标跟踪