基于PicoDet和KalmanFilter的多目标跟踪

0 项目引言

当前基于深度学习的多目标跟踪思路主要有两种,分别是JDE(Jointly learns the Detector and Embedding model)和SDE(Separate Detection and Embedding model),其中代表性的有SORT 1 ^1 1 和DeepSORT 2 ^2 2,遵循Tracking-by-Detection思路,卡尔曼滤波算法 3 ^3 3(Kalman Filter)是这些方法的基础,所以本项目的主要目的也是通过对卡尔曼滤波的通俗讲解,以探寻前人优秀工作轨迹和自我启发。

本项目的检测器部分使用PaddlePaddle—PaddleX 4 ^4 4全流程开发套件接口,跟踪器部分主要描述卡尔曼滤波的算法思想和步骤,并且设计简易的卡尔曼模型,实现基于检测器预测的多目标跟踪。

与本项目类似的还有《基于PP-YOLO Tiny和DSST算法的多目标跟踪》,该方案将生成式模型修改为了相关式模型。

1 目标检测器

以行人跟踪为例,准备好的行人视频位置为 work/origin.mp4,它用来测试后面的跟踪任务。

1.1 模型导出

说明:本章的代码不一定要运行,权重已经保存在 models/

飞桨最近发布了新模型 PP-PicoDet 5 ^{5} 5 ,便采用该模型作为检测器。但为了编写方便不采用yaml形式配置项目,恰好PaddleX存在该模型的接口,所以可以直接调用该模型无需重新编写。

本节主要是将PaddleX模型装载预训练的权重,这里选择下载这个PicoDet-L预训练。

获取 COCO 数据集预训练权重。

!mkdir work/pretrained/
!wget https://paddledet.bj.bcebos.com/models/picodet_l_640_coco.pdparams -P work/pretrained/

安装最新版 PaddleX,它支持 PicoDet 模型。

!pip install paddlex==2.1.0
!pip install scikit-image
import paddle
import paddlex as pdx
from paddlex import transforms as T

import shutil
import glob
import os

import numpy as np
import pandas as pd

import cv2
from PIL import Image
import skimage

import matplotlib.pyplot as plt
%matplotlib inline

考虑到笔者不想重新训练行人跟踪模型,所以将要手动灌入权重等信息,导出为部署模型,但是 PaddleX 不提供接口支持,所以就自己实现。

首先是模型框架搭建,注意 “backbone” 要和预训练 “ESNet_l” 相对应,然后将参数灌入。

def set_model_params(model, params_path):
    param_state_dict = paddle.load(params_path)
    model_state_dict = model.net.state_dict()

    for k in param_state_dict.keys():
        if 'backbone.res5' in k:
            new_k = k.replace('backbone', 'bbox_head.head')
            if new_k in model_state_dict:
                value = param_state_dict.pop(k)
                param_state_dict[new_k] = value

    num_params_loaded = 0
    for k in model_state_dict:
        if k not in param_state_dict:
            print("{} is not in pretrained model".format(k))
        elif list(param_state_dict[k].shape) != list(model_state_dict[k].shape):
            print("[SKIP] Shape of pretrained params {} doesn't match.(Pretrained: {}, Actual: {})"
                .format(k, param_state_dict[k].shape, model_state_dict[k].shape))
        else:
            model_state_dict[k] = param_state_dict[k]
            num_params_loaded += 1
    model.net.set_state_dict(model_state_dict)
    print("There are {}/{} variables loaded into PicoDet.".format(num_params_loaded, len(model_state_dict)))

    return model
model = pdx.det.PicoDet(backbone='ESNet_l')
model = set_model_params(model, params_path='work/pretrained/picodet_l_640_coco.pdparams')
There are 660/660 variables loaded into PicoDet.

因为没有训练集,所以也就没有标签和评估变换,这也是需要手动赋予的。

model.labels = [
    'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus', 'train', 'truck',
    'boat', 'traffic light', 'fire hydrant', 'stop sign', 'parking meter', 'bench',
    'bird', 'cat', 'dog', 'horse', 'sheep', 'cow', 'elephant', 'bear', 'zebra',
    'giraffe', 'backpack', 'umbrella', 'handbag', 'tie', 'suitcase', 'frisbee',
    'skis', 'snowboard', 'sports ball', 'kite', 'baseball bat', 'baseball glove',
    'skateboard', 'surfboard', 'tennis racket', 'bottle', 'wine glass', 'cup', 'fork',
    'knife', 'spoon', 'bowl', 'banana', 'apple', 'sandwich', 'orange', 'broccoli',
    'carrot', 'hot dog', 'pizza', 'donut', 'cake', 'chair', 'couch', 'potted plant',
    'bed', 'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote', 'keyboard',
    'cell phone', 'microwave', 'oven', 'toaster', 'sink', 'refrigerator', 'book',
    'clock', 'vase', 'scissors', 'teddy bear', 'hair drier', 'toothbrush']

from paddlex import transforms as T
model.test_transforms = T.Compose([
    T.Resize(target_size=640, interp='CUBIC'),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
])

以上,一个 PaddleX 模型就算“训练”好了,已经有了权重,但还需要一个 .yml 文件才能导出为推理模型。

save_dir = 'normal_model'
if not os.path.exists(save_dir):
    os.makedirs(save_dir)

paddle.save(model.net.state_dict(), os.path.join(save_dir, 'model.pdparams'))
import yaml

model_info = model.get_model_info()
model_info['status'] = model.status
with open(os.path.join(save_dir, 'model.yml'), mode='w', encoding='utf-8') as f:
    yaml.dump(model_info, f)

以上,得到 normal_model/ 文件夹,接着导出为部署模型,得到 inference_model/ 文件夹。

!paddlex --export_inference\
    --model_dir=./normal_model\
    --save_dir=./inference_model

整理本章节的资源文件,将常规模型和部署模型放在一起。

!mkdir models/
!mv normal_model models/normal_model
!mv inference_model/inference_model models/inference_model
!rm -r inference_model/
!tree models/
models/
├── inference_model
│   ├── model.pdiparams
│   ├── model.pdiparams.info
│   ├── model.pdmodel
│   ├── model.yml
│   └── pipeline.yml
└── normal_model
    ├── model.pdparams
    └── model.yml

2 directories, 7 files

1.2 部署测试

测试一下非显式可用的操作得到的部署模型能不能用。

infer_model = pdx.deploy.Predictor(model_dir='models/inference_model', use_gpu=True)
image = skimage.io.imread('https://i-blog.csdnimg.cn/blog_migrate/4ce3580738248b8492f80114d387374c.jpeg')

result = infer_model.predict(img_file=image, warmup_iters=0, repeats=1)
pdx.visualize_det(image[:, :, ::-1], result, threshold=0.5, save_dir='./output')

观察结果可以发现,模型能够正常预测,所以本章节的工作就顺利完成,得到了PicoDet(models/inference_model) 的部署模型。

重启内核,开始进入正题——卡尔曼滤波。

2 卡尔曼滤波

本章讲述的是如何利用卡尔曼滤波算法构建动态模型,结合目标检测模型进行(多)目标跟踪,其中原理等内容主要是在当前应用领域下的通俗讲解,目的是大致理解卡尔曼滤波算法的目标和思路,自己设计、搭建和更新动态估计模型,算法详情具体可以参考网络资源,例如 《卡尔曼滤波器介绍》 6 ^6 6

2.1 算法思路概述

首先得大致清楚卡尔曼滤波是用来干什么的,有什么内容。

卡尔曼滤波(Kalman Filter)是一种算法,它利用 线性系统状态方程,通过系统输入输出 观测数据,对系统状态进行 最优估计 的算法。由于观测数据中包括系统中的 噪声和干扰 的影响,所以最优估计也可看作是滤波过程。

对于以上描述的通俗解释:

  1. 观测数据:在每个离散时间过程中,手动或自动得到的数据。例如,每一帧中,我们从目标检测器可能会得到该帧图像中的目标边框,这个就是观测数据 z k z_k zk
  2. 线性系统状态方程:构建状态转移增益矩阵 A A A 及其控制矩阵 B B B,通过离散随机差分方程,得到基于前一时刻状态的当前状态。例如,在物理上,得知当前时刻汽车的状态 x k − 1 x_{k-1} xk1(如位移、速度、加速度等),预测得到下一时刻汽车的状态 x k x_k xk
  3. 噪声和干扰:前面的观测数据和预测数据,在实际上由于各种因素,存在着偏差,类似于物理计量中需要保留一定误差位数;此处的观测误差为 Q Q Q 而预测误差为 R R R,后者会随着递推而更新,前者固定不变。
  4. 最优估计:在这里,卡尔曼滤波算法旨基于均带有误差的观测数据和预测数据,计算出理论最优状态(估计)。这个步骤的关键是计算卡尔曼增益 K K K,可以将其理解为一个系数矩阵,它决定着两个数据对于最优估计状态的权重:假如它是个数值的话就类似于这种形式, x b e s t = K x p r e d i c t + ( 1 − K ) x m e a s u r e , K ∈ [ 0 , 1 ] x^{best} = Kx^{predict} + (1-K)x^{measure}, K∈[0, 1] xbest=Kxpredict+(1K)xmeasure,K[0,1]

以上,大致可以理解它干了件什么事情:当前时刻有一个观测值 z k z_k zk,通过线性状态增益矩阵 A A A 得到前一时刻状态最优估计 x k − 1 x_{k-1} xk1 预测的当前时刻先验状态 x k − x^-_{k} xk,然后计算一个“系数”卡尔曼增益 K K K,将 z k z_k zk x k − x^-_{k} xk 加权融合,得到当前时刻的最优估计 x k x_{k} xk x k x_{k} xk 作为下一时刻先验值 x k + 1 − x^-_{k+1} xk+1 的输入条件。

2.2 算法计算流程

知道了卡尔曼滤波大致是什么,要做什么,接着就是具体怎么计算了。

首先给出卡尔曼滤波公式定义, _ − \_^- _ 代表先验, _ ^ \hat{\_} _^ 代表估计,例如先验估计 x ^ − \hat{x}^- x^、先验误差 P − P^- P

离散卡尔曼滤波器时间更新方程:

(1)计算先验估计: x ^ k − = A x ^ k − 1 + B u k − 1 \hat{x}^-_k = A\hat{x}_{k−1} + Bu_{k−1} x^k=Ax^k1+Buk1

(2)计算先验估计误差的协方差: P k − = A P k − 1 A T + Q P^−_k = AP_{k−1}A^T + Q Pk=APk1AT+Q

离散卡尔曼滤波器状态更新方程:

(3)计算卡尔曼增益: K k = P k − H T ( H P k − H T + R ) − 1 K_k = P^−_k H^T(H P^−_k H^T + R)^{−1} Kk=PkHT(HPkHT+R)1

(4)计算最优估计: x ^ k = x ^ k − + K k ( z k − H x ^ k − ) \hat{x}_k = \hat{x}^−_k + K_k(z_k − H \hat{x}^−_k) x^k=x^k+Kk(zkHx^k)

(5)更新先验估计误差的协方差: P k = ( I − K k H ) P k − P_k = (I − K_k H)P^−_k Pk=(IKkH)Pk

以上公式的解释:

  1. 在进行一次卡尔曼模型最优估计的流程中,首先通过前一次最优估计 x ^ k − 1 \hat{x}_{k−1} x^k1 得到预测估计 x ^ k − \hat{x}^-_k x^k(公式 1)。
  2. 然后将前一次状态预测估计的误差(协方差矩阵)也线性更新,得到当前时刻的预测估计的误差 P k − P^−_k Pk(公式 2)。

↑ 到这一步,就得到了通过历史状态预测的当前时刻状态 x ^ k − \hat{x}^-_k x^k 和误差信息 P k − P^−_k Pk

  1. 因为现在已经有了观测状态 z k z_k zk 及其误差 R R R,则可以计算当前时刻的卡尔曼增益 K k K_k Kk 以得到两个状态的权重分配情况(公式 3)。
  2. 计算得到卡尔曼增益 K K K 之后,就可以进行“加权求和”了,得到理论上的最优估计值 x ^ k \hat{x}_k x^k (公式4)。
  3. 当然这里还没有结束,假如后续时刻 k + 1 , K + 2 k+1,K+2 k+1,K+2 也需要进行最优估计的话,需要用到当前时刻 K K K 的先验估计误差协方差矩阵 P k P_k Pk,所以再更新一下这个动态变换的先验误差(公式5)。

↑ 至此,通过计算当前时刻下的卡尔曼增益 K k K_k Kk,对先验状态 x ^ k − \hat{x}^-_k x^k和后验状态 z k z_k zk 进行“加权求和”,得到最优估计值 x k x_k xk,这个值也将被作为下一时刻先验估计的输入。


2.3 部分说明

例如,对二维平面中的点位移进行正交分解,可以设计如下状态 x ( n × 1 ) x(n×1) x(n×1)

x = ( x y Δ x Δ y ) x = \left( \begin{array}{ccc} x\\ y\\ Δx\\ Δy \end{array} \right) x=xyΔxΔy

其对应的增益矩阵 A ( n × n ) A(n×n) A(n×n)进行如下设计,实现各个分量对应位置更新,运算过程简洁,得到当前时刻的先验状态估计 x − x^- x

A = ( 1 0 1 0 0 1 0 1 0 0 1 0 0 0 0 1 ) x − = A x = ( x + Δ x y + Δ y Δ x Δ y ) A = \left( \begin{array}{ccc} 1 & 0 & 1 & 0\\ 0 & 1 & 0 & 1\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\\ \end{array} \right) \\ x^- = Ax = \left( \begin{array}{ccc} x + Δx\\ y + Δy\\ Δx\\ Δy \end{array} \right) A=1000010010100101x=Ax=x+Δxy+ΔyΔxΔy

默认情况下,先验估计和后验估计是一一对应的,但有可能我们的观测值只有 x x x y y y,缺少部分状态因素,这种情况下通过量测方程 z k = H x k + v k z_k = Hx_k + v_k zk=Hxk+vk 控制,当然也可以自己补全,则此时的观测噪声协方差矩阵的形状则为 m × m m×m m×m,这个对应之前的(公式4)。

$$
z_k = \left(
\begin{array}{ccc}
x’\
y’\
\end{array}
\right), H = \left(
\begin{array}{ccc}
1 & 0 & 0 & 0\
0 & 1 & 0 & 0\
\end{array}
\right)
\
z_k - Hx^-_k =
\left(
\begin{array}{ccc}
x’\
y’\
\end{array}
\right)

\left(
\begin{array}{ccc}
1 & 0 & 0 & 0\
0 & 1 & 0 & 0\
\end{array}
\right)
\left(
\begin{array}{ccc}
x + Δx\
y + Δy\
Δx\
Δy
\end{array}
\right) =
\left(
\begin{array}{ccc}
x’-x\
y’-y\
\end{array}
\right)
$$

例如,在这种思路下,可以实现鼠标的跟踪:work/mouse_track.py,有兴趣的话可以拷贝到本地运行。

2.4 基于边框建模

为了后续的编程实现,这里遵循卡尔曼滤波算法,基于边框信息以设计和建立一个动态模型。

首先需要定义状态 x x x,常见的目标检测边框格式有两种: ( x m i n , y m i n , x m a x , y m a x ) (x_{min}, y_{min}, x_{max},y_{max}) (xmin,ymin,xmax,ymax) ( x c e n t e r , y c e n t e r , w , h ) (x_{center}, y_{center}, w, h) (xcenter,ycenter,w,h),根据边框信息可以定义如下状态 x x x

x = ( x c e n t e r y c e n t e r w / h h Δ x c e n t e r Δ y c e n t e r Δ w / h Δ h ) x = \left( \begin{array}{ccc} x_{center}\\ y_{center}\\ w/h\\ h\\ Δx_{center}\\ Δy_{center}\\ Δw/h\\ Δh\\ \end{array} \right) x=xcenterycenterw/hhΔxcenterΔycenterΔw/hΔh

这里给边框的高度变化提供了相对于宽度更大的影响权重,是考虑到行人目标检测中,边框高的变化幅度可能会比边框宽的变化幅度更大,这样的状态估计也更稳定。


当然也可以采用原版 SORT 的状态 x S O R T x^{SORT} xSORT。其实,一种比较好的状态设计方法是,对训练数据进行统计分析,构造基于边框信息的合适强度特征作为状态的一部分。

x S O R T = ( 边 框 中 心 宽 边 框 中 心 高 面 积 边 框 宽 高 比 ( 作 者 认 为 它 是 常 量 , 无 速 度 ) Δ 边 框 中 心 宽 Δ 边 框 中 心 高 Δ 面 积 ) = ( x c e n t e r y c e n t e r s w / h Δ x c e n t e r Δ y c e n t e r Δ s ) x^{SORT} = \left( \begin{array}{ccc} 边框中心宽\\ 边框中心高\\ 面积\\ 边框宽高比(作者认为它是常量,无速度)\\ Δ边框中心宽\\ Δ边框中心高\\ Δ面积\\ \end{array} \right) = \left( \begin{array}{ccc} x_{center}\\ y_{center}\\ s\\ w/h\\ Δx_{center}\\ Δy_{center}\\ Δs\\ \end{array} \right) xSORT=(,)ΔΔΔ=xcenterycentersw/hΔxcenterΔycenterΔs


如上定义的状态 x x x 具有 8 个影响因子,相应的状态增益矩阵 A A A

A = ( 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 ) A = \left( \begin{array}{ccc} 1 & 0 & 0 & 0 & 1 & 0 & 0 & 0\\ 0 & 1 & 0 & 0 & 0 & 1 & 0 & 0\\ 0 & 0 & 1 & 0 & 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1 & 0 & 0 & 0 & 1\\ 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0\\ 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0\\ 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1\\ \end{array} \right) A=1000000001000000001000000001000010001000010001000010001000010001

接着设定观测状态 z z z,考虑到检测器的输出只有前4个,而我们也不想手动计算速度,所以就选择前 4 个作为观测状态:

z = ( x m i n y m i n x m a x y m a x ) z = \left( \begin{array}{ccc} x_{min}\\ y_{min}\\ x_{max}\\ y_{max}\\ \end{array} \right) z=xminyminxmaxymax

因为观测状态 z z z 和先验预测状态 x − x^- x 的形状不同,需要将 <状态变量 x x x 对测量变量 z z z 的增益矩阵 H H H> 设计为如下形式,这样 z . s h a p e = ( H x − ) . s h a p e z.shape= (Hx^-).shape z.shape=(Hx).shape

H = ( 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 ) H = \left( \begin{array}{ccc} 1& 0& 0& 0& 0& 0& 0& 0\\ 0& 1& 0& 0& 0& 0& 0& 0\\ 0& 0& 1& 0& 0& 0& 0& 0\\ 0& 0& 0& 1& 0& 0& 0& 0\\ \end{array} \right) H=10000100001000010000000000000000

最后就是噪声的设定:过程误差 Q Q Q (8×8)、观测误差 R R R (4×4)、先验估计误差 P P P (8×8)都采用单位矩阵乘上一个系数,以控制噪音大小。


以上均定义好了,就可以开始编程实现,其中状态控制一般不设立,所以公式(1)的 B u k − 1 Bu_{k−1} Buk1 就不用参与计算了。

离散卡尔曼滤波器时间更新方程:

(1)计算先验估计: x ^ k − = A x ^ k − 1 \hat{x}^-_k = A\hat{x}_{k−1} x^k=Ax^k1

(2)计算先验估计误差的协方差: P k − = A P k − 1 A T + Q P^−_k = AP_{k−1}A^T + Q Pk=APk1AT+Q

离散卡尔曼滤波器状态更新方程:

(3)计算卡尔曼增益: K k = P k − H T ( H P k − H T + R ) − 1 K_k = P^−_k H^T(H P^−_k H^T + R)^{−1} Kk=PkHT(HPkHT+R)1

(4)计算最优估计: x ^ k = x ^ k − + K k ( z k − H x ^ k − ) \hat{x}_k = \hat{x}^−_k + K_k(z_k − H \hat{x}^−_k) x^k=x^k+Kk(zkHx^k)

(5)更新先验估计误差的协方差: P k = ( I − K k H ) P k − P_k = (I − K_k H)P^−_k Pk=(IKkH)Pk

3 目标跟踪

在 notebook 环境中可能无法实时打开窗口,观察测试效果,所以这里还是使用将每帧结果另存为视频,以观察结果。

3.1 卡尔曼滤波器

以下BBoxKalmanFilter类构建了边框的最优估计模型,其中 BBoxKalmanFilter.update(z) 方法输入观测状态 z z z,迭代更新最优估计状态 x x x 和 先验预测误差 P P P,我们要用的话就更新每次的观测值,然后取最优估计状态的前 4 个数值(即边框)就可以了。

需要注意的是: 在常规应用中,观测数据一般在每个时刻稳定提供,例如 [2.3] 中的鼠标跟踪,但我们知道,在目标检测中常有“抖动”、“遮挡”等情况,导致某时刻没有观测状态( z = N o n e z=None z=None),这时候的最有估计值 x x x 是固定不变的,在这种影响下可能的结果是:真正的目标在出现观测值时,可能已经远离了该边框而导致交并比过低匹配不上。对于这种问题,我们可以使用 <先验估计状态 x − x^- x> 来作为某个时刻的没有观测框时候的最优估计值 x x x,这样在没有观测状态时,也能基于历史状态更新跟踪边框的位置,一定程度上降低了抖动的影响。

以下是笔者本地测试时候的效果对比。

!pip install paddlex==2.1.0
import paddle
import paddlex as pdx

import shutil
import glob
import os

import numpy as np
import pandas as pd

import cv2
from PIL import Image

from scipy.optimize import linear_sum_assignment
class BBoxKalmanFilter:
    def __init__(self, x):
        self.A = np.array(
            [[1, 0, 0, 0, 1, 0, 0, 0],
             [0, 1, 0, 0, 0, 1, 0, 0],
             [0, 0, 1, 0, 0, 0, 1, 0],
             [0, 0, 0, 1, 0, 0, 0, 1],
             [0, 0, 0, 0, 1, 0, 0, 0],
             [0, 0, 0, 0, 0, 1, 0, 0],
             [0, 0, 0, 0, 0, 0, 1, 0],
             [0, 0, 0, 0, 0, 0, 0, 1]])
        self.H = np.array(
            [[1, 0, 0, 0, 0, 0, 0, 0],
             [0, 1, 0, 0, 0, 0, 0, 0],
             [0, 0, 1, 0, 0, 0, 0, 0],
             [0, 0, 0, 1, 0, 0, 0, 0]])
        assert self.A.shape[0] == self.A.shape[1]
        assert x.shape == (self.A.shape[0], 1)

        self.x = x
        self.Q = np.eye(self.A.shape[0]) * 0.01
        self.P = np.eye(self.A.shape[0]) * 1.0
        self.R = np.eye(self.H.shape[0]) * 1.0

        self._I = np.eye(self.A.shape[0])
        self.inv = np.linalg.inv

    def update(self, z):
        if z is None:
            self.x = self.A @ self.x
        else:
            x_ = self.A @ self.x
            P_ = self.A @ self.P @ self.A.T + self.Q
            K = (P_ @ self.H.T) @ self.inv(self.H @ P_ @ self.H.T + self.R)
            self.x = x_ + K @ (z - self.H @ x_)
            self.P = (self._I - K @ self.H) @ P_

3.2 多目标跟踪逻辑

首先编写一个测试主逻辑,其中有两个待实现函数:

  1. get_person_bboxes():返回行人边框列表。
  2. update_trackers():更新跟踪器列表。
def predict_video(detector_dir, src_path, dst_dir='output/'):
    # 初始化PicoDet检测器
    detector = pdx.deploy.Predictor(detector_dir, use_gpu=True)

    # 打开测试视频
    capture = cv2.VideoCapture(src_path)
    video_name = os.path.split(src_path)[-1]

    # 初始化输出视频
    if not os.path.exists(dst_dir):
        os.makedirs(dst_dir)
    dst_path = os.path.join(dst_dir, video_name)
    W = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
    H = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
    writer = cv2.VideoWriter(dst_path,
                             fourcc=cv2.VideoWriter_fourcc(*'mp4v'),
                             fps=30,
                             frameSize=(W, H))

    # 存储跟踪器的列表,跟踪器的ID编号
    tracker_list = []
    tracker_count = 0
    while True:
        # 得到每一帧图像
        ret, frame = capture.read()
        if not ret:
            capture.release()
            writer.release()
            break

        # 获取当前帧中的所有行人的边框,即观测框 [(x_min, y_min, x_max, y_max), (...)]
        m_bboxes = get_person_bboxes(detector, frame, _threshold=0.5)
        
        # 将跟踪器最优估计框和检测器的行人框进行级联匹配,更新跟踪器列表和跟踪器计数
        tracker_list, tracker_count = update_trackers(tracker_list,
                                                      m_bboxes,
                                                      tracker_count)

        # 绘制当前帧中的目标检测框和最优估计框
        for tracker in tracker_list:
            frame = tracker.plot(frame)
        for m_bbox in m_bboxes:
            x_min, y_min, x_max, y_max = m_bbox
            cv2.rectangle(img=frame,
                          pt1=(int(x_min), int(y_min)),
                          pt2=(int(x_max), int(y_max)),
                          color=(155, 175, 131),
                          thickness=2,
                          lineType=cv2.LINE_AA)
        writer.write(frame)

(一)首先编写第一个函数,过滤检测器的结果,返回行人的边框列表。

def get_person_bboxes(_model, _image, _threshold=0.25):
    results = _model.predict(_image)
    person_bbox = []
    for item in results:
        if item['category_id'] != 0 or item['score'] < _threshold:
            continue
        xmin, ymin, w, h = item['bbox']  # 这是PaddleX目标检测模型默认的边框输出格式
        person_bbox.append([xmin, ymin, xmin + w, ymin + h])
    return person_bbox

(二)接着完成第二个函数,以交并比为度量,利用匈牙利算法进行级联匹配,更新跟踪器列表。

最后还需要实现单目标跟踪器SingleTracker类。

def iou_between(bbox1, bbox2):
    x1_min, y1_min, x1_max, y1_max = bbox1
    x2_min, y2_min, x2_max, y2_max = bbox2
    inter_w = max(min(y1_max, y2_max) - max(y1_min, y2_min) + 1., 0.)
    inter_h = max(min(x1_max, x2_max) - max(x1_min, x2_min) + 1., 0.)
    inter_s = inter_h * inter_w
    s1 = (y1_max - y1_min + 1.) * (x1_max - x1_min + 1.)
    s2 = (y2_max - y2_min + 1.) * (x2_max - x2_min + 1.)
    return inter_s / (s1 + s2 - inter_s)


def update_trackers(tracker_list, m_bboxes, tracker_count, iou_threshold=0.3, max_lost_count=4):
    # 获得每个跟踪器中的边框
    e_bboxes = [tracker.get_bbox() for tracker in tracker_list]

    # 计算交并比矩阵,最优估计框(行)×目标检测框(列)
    iou_matrix = np.zeros(shape=(len(e_bboxes), len(m_bboxes)), dtype=np.float32)
    for i in range(len(e_bboxes)):
        for j in range(len(m_bboxes)):
            iou_matrix[i, j] = iou_between(e_bboxes[i], m_bboxes[j])

    # 得到一个整体交并比最大的匹配结果
    row, col = linear_sum_assignment(iou_matrix, maximize=True)

    # 1. 成功匹配并且交并比高于阈值
    for r, c in zip(row, col):
        if iou_matrix[r, c] > iou_threshold:
            tracker_list[r].update(m_bboxes[c])

    # 2. 未匹配的最优估计框:被认为是暂时丢失,跟踪器的观测值以None进行更新
    unmatched_e_idx = [i for i in range(len(e_bboxes)) if i not in row]
    for i in unmatched_e_idx:
        tracker_list[i].update(None)

    # 3. 存在匹配关系,但是交并比过低,这也被认为是丢失,以None作为观测值更新
    for r, c in zip(row, col):
        if iou_matrix[r, c] < iou_threshold:
            tracker_list[r].update(None)

    # 以上(2)(3)步骤更新之后,删除长期丢失的跟踪器
    lost_idx = []
    for i in range(len(tracker_list)):
        if tracker_list[i].lost_count > max_lost_count:
            lost_idx.append(i)
    tracker_list = [tracker_list[i] for i in range(len(tracker_list)) if i not in lost_idx]

    # 4. 未匹配的目标检测框:可能是新目标,实例化单目标跟踪器添加进列表中
    unmatched_m_idx = [i for i in range(len(m_bboxes)) if i not in col]
    for i in unmatched_m_idx:
        tracker_count += 1
        tracker_list.append(SingleTracker(tracker_id=tracker_count,
                                          bbox=m_bboxes[i],
                                          plot_color=(0, 0, 250)))

    return tracker_list, tracker_count

通过对BBoxKalmanFilter进行逻辑上的封装,得到如下单目标跟踪器SingleTracker,它用来被跟踪器列表tracker_list所存储。

class SingleTracker:
    def __init__(self, tracker_id, bbox, plot_color=None):
        self.id = tracker_id  # 跟踪器ID
        self.kf = BBoxKalmanFilter(self.bbox2state(bbox))  # 卡尔曼滤波器
        self.lost_count = 0  # 观测丢失次数
        if plot_color is not None:  # 默认的绘制颜色
            self.plot_color = plot_color
        else:
            self.plot_color = (np.random.randint(50, 255),
                               np.random.randint(50, 255),
                               np.random.randint(50, 255))
        self.history = []  # 历史最优估计框的中心点坐标
        self.MAX_HISTORY_NUM = 20  # 最多保存多少个历史坐标

    def update(self, bbox):
        """
        更新跟踪器的状态和属性。
        :param bbox: 观测框
        :return: None
        """
        if bbox is None:
            self.lost_count += 1
            z = None
        else:
            self.lost_count = 0
            z = self.bbox2state(bbox, is_z=True)
        self.kf.update(z)

        x_min, y_min, x_max, y_max = self.get_bbox()
        if len(self.history) > self.MAX_HISTORY_NUM:
            self.history.pop(0)
        self.history.append(
            (int(x_min + (x_max - x_min) / 2.0), int(y_min + (y_max - y_min) / 2.0))
        )

    @staticmethod
    def bbox2state(bbox, is_z=False):
        """
        边框格式转换为状态格式。
        :param bbox: 目标检测框
        :param is_z: 是否是观测状态
        :return: 状态矩阵
        """
        x_min, y_min, x_max, y_max = bbox
        state = [x_min + (x_max - x_min) / 2.0,
                 y_min + (y_max - y_min) / 2.0,
                 (x_max - x_min) / (y_max - y_min),
                 (y_max - y_min)]
        if not is_z:
            for _ in range(4):
                state.append(0)
        return np.array(state).reshape(-1, 1)

    def get_bbox(self):
        """
        :return: 最优估计状态计算得到的最优估计框
        """
        x, y, w_div_h, h = self.kf.x.squeeze()[:4]
        w = w_div_h * h
        return [x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0]

    def plot(self, _frame):
        """
        跟踪器ID、边框和历史坐标的绘制。
        :param _frame: 帧图像
        :return: 绘制后的帧图像
        """
        if self.lost_count > 0:
            return _frame

        x_min, y_min, x_max, y_max = self.get_bbox()
        cv2.putText(img=_frame,
                    text=f'{self.id}',
                    org=(int(x_min), int(y_min) - 5),
                    fontFace=0,
                    fontScale=0.6,
                    color=self.plot_color,
                    thickness=2)
        cv2.rectangle(img=_frame,
                      pt1=(int(x_min), int(y_min)),
                      pt2=(int(x_max), int(y_max)),
                      color=self.plot_color,
                      thickness=2,
                      lineType=cv2.LINE_AA)
        if len(self.history) > 1:
            pre_x, pre_y = self.history[0]
            for i in range(1, len(self.history)):
                cv2.line(img=_frame,
                         pt1=(pre_x, pre_y),
                         pt2=(self.history[i][0], self.history[i][1]),
                         color=self.plot_color,
                         thickness=2)
                pre_x, pre_y = self.history[i]

        return _frame

3.3 多目标跟踪

章节(3.2)实现了多目标跟踪的主要组件,这一步就可以调用主逻辑函数进行测试。

predict_video(
    detector_dir='models/inference_model',
    src_path='work/origin.mp4',
    dst_dir='output')

以上,就得到了 output/ 文件夹下同名视频的推理结果文件 origin.mp4

笔者利用 ffmpeg 将它转换为 GIF,得到如下情况展示,其中绿色框为目标检测框,而红色框是卡尔曼滤波的最优估计框,可以看出最优估计框的变化情况更加的平滑,这也就是所谓的“滤波”。

以上,也可以直观的了解SORT跟踪算法的一些不足,例如在目标重叠的时候效果不好,ID切换情况很常见,而两种边框的度量指标交并比IOU是这种情况的原因之一,跟踪器和度量指标均忽视了跟踪目标的外观等可以有效区分的特征,这也是DeepSORT对此进行优化的一个方向。

4 项目总结

本项目遵循SORT(Simple online and realtime tracking)算法的多目标跟踪思路,使用PaddleX的PicoDet作为行人目标检测器,设计与搭建了基于卡尔曼滤波的边框估计模型,以传统的边框交并比为距离度量指标,利用Jonker-Volgenant算法进行级联距离最小匹配,实现了简单的行人多目标跟踪任务。

针对 SORT 算法的主要缺点,之后考虑沿着 DeepSORT(Simple online and realtime tracking with a deep association metric)的思路,训练度量模块,修改级联匹配的度量指标,以提高跟踪性能。


5 参考文献

[1] Bewley A, Ge Z, Ott L, et al. Simple online and realtime tracking[C]//2016 IEEE international conference on image processing (ICIP). IEEE, 2016: 3464-3468.

[2] Wojke N, Bewley A, Paulus D. Simple online and realtime tracking with a deep association metric[C]//2017 IEEE international conference on image processing (ICIP). IEEE, 2017: 3645-3649.

[3] Kalman R E.A New Approach to Linear Filtering and Prediction Problems[J].Journal of Basic Engineering,1960, 82 (1): 35-45.

[4] PaddlePaddle (2021) PaddleX [Source code].https://github.com/PaddlePaddle/PaddleX.

[5] Yu G, Chang Q, Lv W, et al. PP-PicoDet: A Better Real-Time Object Detector on Mobile Devices[J]. arXiv preprint arXiv:2111.00902, 2021.

[6] Bishop G W G. 卡尔曼滤波器介绍[EB/OL]. https://www.cs.unc.edu/~welch/kalman/media/pdf/kalman_intro_chinese.pdf. 2007.1.8.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值