前言
这段时间一直在研究MOT,前段时间在看botsort论文的时候,发现了一个在MOT20数据集下的追踪算法排名,deepocsort榜上有名,于是拿出来研究一下。
但是在读论文提供的代码后,没找到deepocsort有关代码模块,有小伙伴知道也可以在评论区讨论一下。
于是我在GitHub上找到了另一个代码,是一个代码的集合,包括现在主流的代码,如下。
通过此代码我找到了deepocsort对应的模块,在本站当中也没有找到比较完整的解析,于是我花了点时间把deepocsort对应模块的代码做了一一解析。
本质上我的目的是想把deepocsort用在以后自己写的程序上,所以每行代码,除了重复的我都做了详细的标注,有什么不对的评论区见。
代码解释
"""
This script is adopted from the SORT script by Alex Bewley alex@bewley.ai
"""
import numpy as np
import torch
from boxmot.appearance.reid_multibackend import ReIDDetectMultiBackend
from boxmot.motion.cmc import get_cmc_method
from boxmot.motion.kalman_filters.adapters import OCSortKalmanFilterAdapter
from boxmot.utils import PerClassDecorator
from boxmot.utils.association import (associate, associate_kitti,
linear_assignment)
from boxmot.utils.iou import get_asso_func
def k_previous_obs(observations, cur_age, k): # 该函数是在给定的观察列表里,根据所给的cur_age和k,找到最接近k的观察值
if len(observations) == 0:
return [-1, -1, -1, -1, -1] # 如果所给的观察列表为0,则返回[-1, -1, -1, -1, -1]
for i in range(k): # 从0到k-1进行迭代
dt = k - i # dt的值是k-i,其中k是指定的要查找的先前观察值的数量,i是当前迭代的变量。通过从0到k-1迭代,依次计算出时间差,然后检查当前年龄减去时间差是否在观察列表中,如果存在匹配的观察值,就返回该观察值。
if cur_age - dt in observations: # 如果cur_age - dt在观察列表里,就返回对应的观察值
return observations[cur_age - dt]
max_age = max(observations.keys())
return observations[max_age] # 若没有找到对应的观察值,则返回观察列表里面的最大年龄
def convert_bbox_to_z(bbox):
"""
Takes a bounding box in the form [x1,y1,x2,y2] and returns z in the form
[x,y,s,r] where x,y is the centre of the box and s is the scale/area and r is
the aspect ratio
以 [x1,y1,x2,y2] 的形式获取边界框,并以 [x,y,s,r] 的形式返回 z,其中 x,y 是边界框的中心点,s 是比例/面积,r 是长宽比
"""
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
x = bbox[0] + w / 2.0
y = bbox[1] + h / 2.0
s = w * h # scale is just area
r = w / float(h + 1e-6)
return np.array([x, y, s, r]).reshape((4, 1))
def convert_bbox_to_z_new(bbox):
w = bbox[2] - bbox[0] # 计算出边界框的宽度w
h = bbox[3] - bbox[1] # 计算出边界框的高度h
x = bbox[0] + w / 2.0 # 计算出边界框的中心点横坐标x
y = bbox[1] + h / 2.0 # 计算出边界框的中心点纵坐标y
return np.array([x, y, w, h]).reshape((4, 1)) # 最后计算出的whxy组成一个长度为4的数组,并重塑为形状为(4,1)的二维数组
"""
这个函数的目的是将边界框的坐标转换为零均值归一化的形式,可以将不同尺寸的边界框映射到相同的坐标系中,方便后续的计算和处理。
"""
def convert_x_to_bbox_new(x):
x, y, w, h = x.reshape(-1)[:4]
return np.array([x - w / 2, y - h / 2, x + w / 2, y + h / 2]).reshape(1, 4)
"""
这个函数的作用是将零均值归一化的坐标形式转换回边界框(bbox)。
首先通过 x.reshape(-1)[:4] 将输入的数组 x 进行重塑,提取出其中的前四个元素,分别赋值给变量 x、y、w、h。
然后,通过 x - w / 2 计算出边界框的左上角横坐标,通过 y - h / 2 计算出边界框的左上角纵坐标,
通过 x + w / 2 计算出边界框的右下角横坐标,通过 y + h / 2 计算出边界框的右下角纵坐标。
"""
def convert_x_to_bbox(x, score=None):
"""
Takes a bounding box in the centre form [x,y,s,r] and returns it in the form
[x1,y1,x2,y2] where x1,y1 is the top left and x2,y2 is the bottom right
以中心点 [x,y,s,r] 的形式获取边界框,并以 [x1,y1,x2,y2] 的形式返回,其中 x1,y1 表示左上方,x2,y2 表示右下方
"""
w = np.sqrt(x[2] * x[3])
h = x[2] / w
if score is None:
return np.array([x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0]).reshape((1, 4))
else:
return np.array([x[0] - w / 2.0, x[1] - h / 2.0, x[0] + w / 2.0, x[1] + h / 2.0, score]).reshape((1, 5))
def speed_direction(bbox1, bbox2): # 这个函数的作用是计算两个边界框(bbox1和bbox2)的相对速度方向
cx1, cy1 = (bbox1[0] + bbox1[2]) / 2.0, (bbox1[1] + bbox1[3]) / 2.0
# 通过 (bbox1[0] + bbox1[2]) / 2.0 和 (bbox1[1] + bbox1[3]) / 2.0 计算出边界框 bbox1 的中心点坐标 (cx1, cy1)
cx2, cy2 = (bbox2[0] + bbox2[2]) / 2.0, (bbox2[1] + bbox2[3]) / 2.0
# 通过 (bbox2[0] + bbox2[2]) / 2.0 和 (bbox2[1] + bbox2[3]) / 2.0 计算出边界框 bbox2 的中心点坐标 (cx2, cy2)
speed = np.array([cy2 - cy1, cx2 - cx1])
# 通过 np.array([cy2 - cy1, cx2 - cx1]) 计算出两个中心点之间的速度向量 speed,
# 其中 cy2 - cy1 表示垂直方向上的速度差,cx2 - cx1 表示水平方向上的速度差
norm = np.sqrt((cy2 - cy1) ** 2 + (cx2 - cx1) ** 2) + 1e-6
# 通过 np.sqrt((cy2 - cy1) ** 2 + (cx2 - cx1) ** 2) 计算出速度向量的长度(即相对速度的大小),
# 并加上一个很小的数值 1e-6,以避免出现除零错误,sqrt为平方根
return speed / norm
# 将相对速度向量除以速度向量的长度,得到单位速度向量,即相对速度方向。函数返回单位速度向量
def new_kf_process_noise(w, h, p=1 / 20, v=1 / 160): # w 和 h 是边界框的宽度和高度,而 p 和 v 是两个缩放因子,用于计算过程噪声的方差
"""
这个函数定义了一个卡尔曼滤波器的过程噪声矩阵Q,过程噪音矩阵用来描述系统的动态性能,又叫状态转移矩阵,
这个矩阵可以用来计算卡尔曼增益,从而在更新滤波器的状态来估计时减少噪声的影响。
"""
Q = np.diag(
((p * w) ** 2, (p * h) ** 2, (p * w) ** 2, (p * h) ** 2, (v * w) ** 2, (v * h) ** 2, (v * w) ** 2, (v * h) ** 2)
)
# NumPy 的 diag() 函数作用为:创建一个对角矩阵,并将根据给定的参数计算得到的值作为对角线元素填充该矩阵。最后,函数返回这个对角矩阵,即过程噪声矩阵 Q
return Q
def new_kf_measurement_noise(w, h, m=1 / 20): # w 和 h 是边界框的宽度和高度,而 m 是用于计算测量噪声方差的缩放因子。
"""
这个矩阵与上面的矩阵效果类似,作用为:创建了一个卡尔曼滤波器的测量噪声矩阵 R
测量噪声矩阵用于描述传感器误差的方差。
这个矩阵可以用来计算卡尔曼增益,从而在更新滤波器的状态估计时减少测量噪声的影响
"""
w_var = (m * w) ** 2
h_var = (m * h) ** 2
R = np.diag((w_var, h_var, w_var, h_var))
return R
class KalmanBoxTracker(object):
"""
This class represents the internal state of individual tracked objects observed as bbox.
该类表示作为 bbox 观察到的单个跟踪对象的内部状态。
"""
count = 0
def __init__(self, bbox, cls, delta_t=3, emb=None, alpha=0, new_kf=False):
"""
Initialises a tracker using initial bounding box.
使用初始化的bounding box来初始追踪器
"""
# define constant velocity model 定义恒速模型
self.cls = cls
self.conf = bbox[-1] # 将边界框最后一个值(高度)赋值给conf
self.new_kf = new_kf
if new_kf:
self.kf = OCSortKalmanFilterAdapter(dim_x=8, dim_z=4)
self.kf.F = np.array(
[
# x y w h x' y' w' h'
[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.kf.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],
]
)
_, _, w, h = convert_bbox_to_z_new(bbox).reshape(-1)
self.kf.P = new_kf_process_noise(w, h)
self.kf.P[:4, :4] *= 4
self.kf.P[4:, 4:] *= 100
# Process and measurement uncertainty happen in functions
self.bbox_to_z_func = convert_bbox_to_z_new
self.x_to_bbox_func = convert_x_to_bbox_new
else:
self.kf = OCSortKalmanFilterAdapter(dim_x=7, dim_z=4)
self.kf.F = np.array(
[
# x y s r x' y' s'
[1, 0, 0, 0, 1, 0, 0],
[0, 1, 0, 0, 0, 1, 0],
[0, 0, 1, 0, 0, 0, 1],
[0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 1, 0, 0],
[0, 0, 0, 0, 0, 1, 0],
[0, 0, 0, 0, 0, 0, 1],
]
)
self.kf.H = np.array(
[
[1, 0, 0, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 1, 0, 0, 0],
]
)
self.kf.R[2:, 2:] *= 10.0
self.kf.P[4:, 4:] *= 1000.0 # give high uncertainty to the unobservable initial velocities
self.kf.P *= 10.0
self.kf.Q[-1, -1] *= 0.01
self.kf.Q[4:, 4:] *= 0.01
self.bbox_to_z_func = convert_bbox_to_z
self.x_to_bbox_func = convert_x_to_bbox
self.kf.x[:4] = self.bbox_to_z_func(bbox)
self.time_since_update = 0
self.id = KalmanBoxTracker.count
KalmanBoxTracker.count += 1
self.history = []
self.hits = 0
self.hit_streak = 0
self.age = 0
"""
NOTE: [-1,-1,-1,-1,-1] is a compromising placeholder for non-observation status, the same for the return of
function k_previous_obs. It is ugly and I do not like it. But to support generate observation array in a
fast and unified way, which you would see below k_observations = np.array([k_previous_obs(...]]),
let's bear it for now.
[-1,-1,-1,-1,-1,-1]是非观察状态的折衷占位符,函数 k_previous_obs 的返回值也是如此。这很难看,我不喜欢。
不过,为了支持以快速、统一的方式生成观察数组(如下所示 k_observations = np.array([k_previous_obs(...]]) ),我们还是暂时忍耐一下吧。
"""
# Used for OCR以观察为中心恢复
self.last_observation = np.array([-1, -1, -1, -1, -1]) # placeholder
# Used to output track after min_hits reached 用于在达到 min_hits 后输出轨迹
self.history_observations = []
# Used for velocity
self.observations = dict()
self.velocity = None
self.delta_t = delta_t
self.emb = emb
self.frozen = False
def update(self, bbox, cls):
"""
Updates the state vector with observed bbox.
用观测到的 bbox 更新状态向量
这段代码实现了根据观测到的边界框信息更新目标的状态向量和观测记录,并维护了一些相关的属性,如命中次数、连续命中次数等。
"""
if bbox is not None: # 如果传入的边界框(bbox)不为None
self.frozen = False # 目标的冻结状态(frozen)设置为False,表示目标不再被冻结
self.cls = cls # 将目标所属的类别(cls)设置为传入的类别。
self.conf = bbox[-1] # 将目标的置信度(conf)设置为传入的边界框的最后一个元素,即边界框的置信度。
if self.last_observation.sum() >= 0: # no previous observation
previous_box = None
for dt in range(self.delta_t, 0, -1):
if self.age - dt in self.observations:
previous_box = self.observations[self.age - dt]
break
if previous_box is None:
previous_box = self.last_observation
"""
如果上次观测的信息(last_observation)的总和大于等于0,说明之前有观测记录,
则从之前的观测记录中寻找与当前时间步Delta_t步之前的观测记录,并将其作为前一个边界框(previous_box)。
如果找不到合适的记录,则将previous_box设置为last_observation。
Estimate the track speed direction with observations \Delta t steps away
"""
self.velocity = speed_direction(previous_box, bbox)
"""
使用之前的边界框和当前的边界框估计目标的运动速度方向(velocity)。
Insert new observations. This is a ugly way to maintain both self.observations
and self.history_observations. Bear it for the moment.
插入新的观察结果。这种维护 self.observations 和 self.history_observations 的方式很难看。暂时忍忍吧。
"""
self.last_observation = bbox
self.observations[self.age] = bbox
"""
这段代码将传入的边界框(bbox)赋值给类成员变量self.last_observation,并将该边界框存储在字典self.observations中,以当前时间步(age)作为键。
通常,self.last_observation用于存储上次观测到的边界框信息,而self.observations字典用于存储所有观测到的边界框信息,以便进行目标跟踪或状态估计等任务。
通过将新的边界框存储在self.observations中,可以逐步积累目标的观测历史记录,从而更准确地估计目标的状态。
"""
self.history_observations.append(bbox)
self.time_since_update = 0 # 更新时间自上次更新以来经过的步数(time_since_update)
self.history = [] # 清空历史的观测记录(history)
self.hits += 1 # 增加目标的命中次数(hits)
self.hit_streak += 1 # 增加当前连续命中目标的次数(hit_streak)
if self.new_kf:
R = new_kf_measurement_noise(self.kf.x[2, 0], self.kf.x[3, 0])
self.kf.update(self.bbox_to_z_func(bbox), R=R)
else:
self.kf.update(self.bbox_to_z_func(bbox))
"""
如果之前没有进行过卡尔曼滤波器(kf)的更新,则使用新的卡尔曼滤波器进行更新,同时根据当前状态向量和观测信息计算卡尔曼增益(kf.update()中的R参数)
"""
else: # 如果传入的边界框为None,则仅对卡尔曼滤波器进行更新,并将目标的状态向量设置为传入的边界框
self.kf.update(bbox)
self.frozen = True
def update_emb(self, emb, alpha=0.9):
self.emb = alpha * self.emb + (1 - alpha) * emb
self.emb /= np.linalg.norm(self.emb)
"""
这段代码是一个类的成员方法update_emb,用于更新目标对象的嵌入向量(embedding)。
代码中的参数emb表示要更新的嵌入向量,alpha是一个控制更新步长的的大小,默认值为0.9。
具体更新过程如下:
1.将当前嵌入向量self.emb乘以alpha,得到一个平滑的嵌入向量。
2.将输入的嵌入向量emb乘以1 - alpha,得到一个要添加到当前嵌入向量的增量。
3.将平滑的嵌入向量和增量相加,得到新的嵌入向量。
4.将新的嵌入向量除以其范数(使用np.linalg.norm函数计算),以确保其大小在归一化范围内。
通过这个更新过程,输入的嵌入向量将逐步融入目标对象的的历史信息中,以实现嵌入向量的更新
"""
def get_emb(self): # 获取目标对象的嵌入向量(embedding)
return self.emb.cpu()
def apply_affine_correction(self, affine): # 应用仿射校正(affine correction)来修正目标框(bounding box)的位置。以适应目标物体的旋转、缩放、平移等变换。
m = affine[:, :2]
t = affine[:, 2].reshape(2, 1) # 从传入的仿射矩阵affine中提取出变换矩阵m和平移向量t
# For OCR
if self.last_observation.sum() > 0:
ps = self.last_observation[:4].reshape(2, 2).T
ps = m @ ps + t
self.last_observation[:4] = ps.T.reshape(-1)
"""
如果上次观测到的边界框信息self.last_observation不为空(即有上次观测到的边界框信息可用),则计算出上一次观测到的边界框的质心位置ps,
并将当前仿射矩阵m和平移向量t应用于质心位置ps,得到新的质心位置ps。将新的质心位置重新构造成一维数组,并更新上次观测到的边界框信息中的前四个元素。
"""
# Apply to each box in the range of velocity computation应用于速度计算范围内的每个方框
for dt in range(self.delta_t, -1, -1):
if self.age - dt in self.observations:
ps = self.observations[self.age - dt][:4].reshape(2, 2).T
ps = m @ ps + t
self.observations[self.age - dt][:4] = ps.T.reshape(-1)
"""
对于每个时间步长dt(从当前时间步向前遍历到时间差self.delta_t为止),如果观测记录self.observations中存在对应时间步长的下的边界框信息,
则计算出该边界框的质心位置ps,并将当前仿射矩阵m和平移向量t应用于质心位置ps,得到新的质心位置ps。将新的质心位置重新构造成一维数组,
并更新观测记录self.observations中对应时间步长下的边界框信息的前四个元素。
"""
# Also need to change kf state, but might be frozen
self.kf.apply_affine_correction(m, t, self.new_kf)
# 根据当前状态和是否为新卡尔曼滤波器(new Kalman Filter),调用卡尔曼滤波器的apply_affine_correction方法,
# 将仿射矩阵m和平移向量t应用于卡尔曼滤波器的状态,以更新卡尔曼滤波器的状态。
def predict(self):
"""
Advances the state vector and returns the predicted bounding box estimate.
增加状态向量并且返回预测边界框的估计值
"""
# Don't allow negative bounding boxes
if self.new_kf: # 检查是否为新卡尔曼滤波器(new Kalman Filter)
if self.kf.x[2] + self.kf.x[6] <= 0: # 检查边界框的宽度和高度是否小于等于0,如果是,则将对应的宽度和高度设置为0。
self.kf.x[6] = 0
if self.kf.x[3] + self.kf.x[7] <= 0:
self.kf.x[7] = 0
# Stop velocity, will update in kf during OOS 停止速度,在oos的期间更新kf
if self.frozen: # 如果目标被冻结(frozen),则将速度项(x[6]和x[7])设置为0
self.kf.x[6] = self.kf.x[7] = 0
Q = new_kf_process_noise(self.kf.x[2, 0], self.kf.x[3, 0]) # 生成过程噪声Q
else:
if (self.kf.x[6] + self.kf.x[2]) <= 0:
self.kf.x[6] *= 0.0 # 如果不是新卡尔曼滤波器,检查边界框的宽度和速度之和是否小于等于0,如果是,则将速度项(x[6])设置为0
Q = None # 生成的过程噪声Q设置为None
self.kf.predict(Q=Q) # 调用卡尔曼滤波器的predict方法,进行状态预测,并更新状态向量
self.age += 1 # 增加目标的年龄(age)计数器
if self.time_since_update > 0:
self.hit_streak = 0 # 如果上次更新时间(time_since_update)大于0,说明已经有一段时间没有更新目标了,将连续命中次数(hit_streak)清零
self.time_since_update += 1 # 将当前时间与上次更新时间的差值加1,即增加上次更新时间计数器
self.history.append(self.x_to_bbox_func(self.kf.x)) # 将当前状态向量通过x_to_bbox_func函数转换为边界框信息,并将其添加到历史记录(history)列表中
return self.history[-1] # 返回历史记录中的最后一个元素,即预测的边界框信息
def get_state(self): # 返回当前的目标边界框估计值
"""
Returns the current bounding box estimate.
"""
return self.x_to_bbox_func(self.kf.x) # 将当前卡尔曼滤波器的状态向量self.kf.x通过x_to_bbox_func函数转换为边界框信息,并将其作为返回值返回
def mahalanobis(self, bbox): # 计算马氏距离(Mahalanobis distance)
"""Should be run after a predict() call for accuracy.需要注意的是,该方法应该在predict方法调用之后调用,以获取更准确的马氏距离"""
return self.kf.md_for_measurement(self.bbox_to_z_func(bbox))
"""
该方法接受一个边界框(bbox)作为输入,并将其通过bbox_to_z_func函数转换为状态向量。然后,调用卡尔曼滤波器的md_for_measurement方法,
将转换后的状态向量作为输入,计算马氏距离。最后,返回计算得到的马氏距离。
"""
class DeepOCSort(object):
def __init__(
self,
model_weights, # 用于ReID检测的模型的权重
device,
fp16, # 是否使用半精度(float16)进行计算
per_class=True, # 是否为每个类别进行跟踪
det_thresh=0.3, # 检测阈值,用于确定哪些对象应该进行跟踪
max_age=30, # 指定一个对象在多少帧后仍被视为跟踪中
min_hits=3, # 指定一个对象被跟踪的最小次数
iou_threshold=0.3, # IoU阈值,用于判断两个对象的重叠度
delta_t=3, # 时间差值,指定两个帧之间的时间差
asso_func="iou", # 关联函数,用于计算两个对象之间的关联度
inertia=0.2, # 惯性因子,用于调整对象的移动速度
w_association_emb=0.75, # 关联嵌入权重
alpha_fixed_emb=0.95, # 固定嵌入权重
aw_param=0.5, # 权值参数
embedding_off=False, # 是否关闭嵌入计算
cmc_off=False, # 是否关闭CMC计算
aw_off=False, # 是否关闭 Association Weighting计算
new_kf_off=False, # 是否关闭新卡尔曼滤波计算
**kwargs
):
"""
Sets key parameters for SORT
"""
self.max_age = max_age
self.min_hits = min_hits
self.iou_threshold = iou_threshold
self.trackers = [] # 跟踪器列表,用于存储跟踪的对象
self.frame_count = 0 # 帧计数,记录已经处理的帧数
self.det_thresh = det_thresh # 检测阈值,用于确定哪些对象应该进行跟踪
self.delta_t = delta_t
self.asso_func = get_asso_func(asso_func)
self.inertia = inertia
self.w_association_emb = w_association_emb
self.alpha_fixed_emb = alpha_fixed_emb
self.aw_param = aw_param
self.per_class = per_class
KalmanBoxTracker.count = 0 # 用于跟踪器计数的静态变量
self.embedder = ReIDDetectMultiBackend(weights=model_weights, device=device, fp16=fp16)
# ReID检测器的实例,用于提取特征并进行识别匹配
# "similarity transforms using feature point extraction, optical flow, and RANSAC"
self.cmc = get_cmc_method('sof')()
self.embedding_off = embedding_off
self.cmc_off = cmc_off
self.aw_off = aw_off
self.new_kf_off = new_kf_off
@PerClassDecorator
def update(self, dets, img):
"""
Params:
dets - a numpy array of detections in the format [[x1,y1,x2,y2,score],[x1,y1,x2,y2,score],...]
Requires: this method must be called once for each frame even with empty detections
(use np.empty((0, 5)) for frames without detections).
Returns the a similar array, where the last column is the object ID.
NOTE: The number of objects returned may differ from the number of detections provided.
"""
assert isinstance(dets, np.ndarray), f"Unsupported 'dets' input type '{type(dets)}', valid format is np.ndarray"
assert isinstance(img, np.ndarray), f"Unsupported 'img' input type '{type(img)}', valid format is np.ndarray"
assert len(dets.shape) == 2, "Unsupported 'dets' dimensions, valid number of dimensions is two"
assert dets.shape[1] == 6, "Unsupported 'dets' 2nd dimension lenght, valid lenghts is 6"
"""
这几行代码是进行输入类型和形状的检查,确保输入的参数符合预期
第一行代码检查dets是否为np.ndarray类型,如果不是,则抛出一个异常,异常消息中会指出支持的输入类型为np.ndarray。
第二行代码检查img是否为np.ndarray类型,如果不是,则抛出一个异常,异常消息中会指出支持的输入类型为np.ndarray
第三行代码检查dets的维度是否为2,如果不是,则抛出一个异常,异常消息中会指出支持的维度数为2
第四行代码检查dets的第二维大小是否为6,如果不是,则抛出一个异常,异常消息中会指出第二维的大小必须为6
"""
self.frame_count += 1 # 表示已经处理了一帧数据。
scores = dets[:, 4] # 从dets数组中提取第4列(即索引为3),将其赋值给scores变量
dets = dets[:, 0:6] # 从dets数组中提取前6列(即索引范围为0到5),将其赋值给dets变量
remain_inds = scores > self.det_thresh # 如果scores中的某个元素大于self.det_thresh,则对应位置的remain_inds元素为True,否则为False。
dets = dets[remain_inds] # 从dets中选取remain_inds为True的行,将其赋值给dets变量。这个步骤是为了过滤掉得分低于阈值的的目标
self.height, self.width = img.shape[:2]
# 将img数组的形状中的前两个元素(即高度和宽度)分别赋值给self.height和self.width属性。这个步骤是为了获取图像的高度和宽度,以便后续处理使用。
# Embedding
if self.embedding_off or dets.shape[0] == 0:
dets_embs = np.ones((dets.shape[0], 1))
# 嵌入被关闭或者没有检测到任何目标,那么代码将创建一个形状与dets相同的大小为1的NumPy数组dets_embs,并将其初始化为全1
else:
# (Ndets x X) [512, 1024, 2048]
# dets_embs = self.embedder.compute_embedding(img, dets[:, :4], tag)
dets_embs = self._get_features(dets[:, :4], img)
# 嵌入没有被关闭且检测到了目标,那么代码将调用self._get_features方法,该方法接受dets[:, :4]和img作为输入,并返回一个与dets形状相同的数组dets_embs
# CMC
if not self.cmc_off:
transform = self.cmc.apply(img, dets[:, :4])
for trk in self.trackers: # 每个追踪器,调用trk.apply_affine_correction(transform)方法应用仿射校正
trk.apply_affine_correction(transform)
trust = (dets[:, 4] - self.det_thresh) / (1 - self.det_thresh) # 计算信任度指标trust
af = self.alpha_fixed_emb
# From [self.alpha_fixed_emb, 1], goes to 1 as detector is less confident
dets_alpha = af + (1 - af) * (1 - trust) # 计算每个目标的alpha固定嵌入表示dets_alpha,其中af为固定嵌入权重。
# get predicted locations from existing trackers.
trks = np.zeros((len(self.trackers), 5)) # 初始化一个长度为len(self.trackers)的零数组trks,用于存储预测的位置信息
trk_embs = [] # 初始化一个空列表trk_embs,用于存储追踪器的嵌入表示
to_del = [] # 初始化一个空列表to_del,用于存储需要删除的追踪器索引
ret = [] # 初始化一个空列表ret,用于存储结果
for t, trk in enumerate(trks):
pos = self.trackers[t].predict()[0]
trk[:] = [pos[0], pos[1], pos[2], pos[3], 0]
if np.any(np.isnan(pos)):
to_del.append(t)
else:
trk_embs.append(self.trackers[t].get_emb())
"""
遍历每个追踪器,执行以下操作:
使用追踪器的预测方法获取预测位置信息。
将预测位置信息更新到trks数组中的相应位置。
如果位置信息中存在NaN,则将当前追踪器的索引添加到to_del列表中。
否则,将当前追踪器的嵌入表示添加到trk_embs列表中。
"""
trks = np.ma.compress_rows(np.ma.masked_invalid(trks))
# 使用NumPy的compress_rows函数和masked_invalid掩码,对trks数组进行行压缩,去除包含NaN的行。
if len(trk_embs) > 0:
trk_embs = np.vstack(trk_embs) # 如果trk_embs列表中的元素数量大于0,则使用vstack函数将其垂直堆叠为一个数组。
else:
trk_embs = np.array(trk_embs) # 将trk_embs列表转换为NumPy数组。
for t in reversed(to_del):
self.trackers.pop(t)
velocities = np.array([trk.velocity if trk.velocity is not None else np.array((0, 0)) for trk in self.trackers])
last_boxes = np.array([trk.last_observation for trk in self.trackers])
k_observations = np.array([k_previous_obs(trk.observations, trk.age, self.delta_t) for trk in self.trackers])
"""
使用列表解析式,计算所有追踪器的速度信息,并将其存储在velocities数组中
计算所有追踪器的最后观测信息,并将其存储在last_boxes数组中
计算所有追踪器的年龄和时间步长之前的观测信息,并将其存储在k_observations数组中。
主要用于数据更新
"""
"""
First round of association第一轮关联
"""
# (M detections X N tracks, final score)
if self.embedding_off or dets.shape[0] == 0 or trk_embs.shape[0] == 0:
stage1_emb_cost = None
# 首先,检查一些条件,包括嵌入是否关闭(self.embedding_off为True或dets的形状为0)或跟踪器的嵌入表示数量为0(trk_embs.shape[0] == 0)。
# 如果满足其中任何一个条件,将stage1_emb_cost设置为None
else: # 否则,计算dets_embs @ trk_embs.T,即检测结果和跟踪器之间的嵌入相似度矩阵。
stage1_emb_cost = dets_embs @ trk_embs.T
matched, unmatched_dets, unmatched_trks = associate( # 调用associate函数进行目标跟踪和关联
dets, # 检测结果数组
trks, # 跟踪器数组
self.iou_threshold, # IoU阈值
velocities, # 速度数组
k_observations, # 年龄和时间步长之前的观测数组
self.inertia, # 惯性
stage1_emb_cost, # 嵌入相似度矩阵(如果存在)
self.w_association_emb, # 嵌入关联权重
self.aw_off, # 是否关闭自适应权重
self.aw_param, # 自适应权重参数
)
"""
associate函数返回三个值:matched、unmatched_dets和unmatched_trks。
matched: 匹配的结果,是一个嵌套数组,每个元素是一个包含检测结果和跟踪器索引的元组(例如[(0, 1), (2, 3), ...])。
unmatched_dets: 未匹配的检测结果索引。
unmatched_trks: 未匹配的跟踪器索引。
"""
for m in matched: # 使用for循环遍历matched数组。对于每个匹配的结果m,更新对应跟踪器的位置和速度信息,使用检测结果的位置和速度信息。
self.trackers[m[1]].update(dets[m[0], :5], dets[m[0], 5])
self.trackers[m[1]].update_emb(dets_embs[m[0]], alpha=dets_alpha[m[0]]) # 更新跟踪器的嵌入表示,使用检测结果的嵌入表示和alpha权重。
"""
Second round of associaton by OCR
"""
if unmatched_dets.shape[0] > 0 and unmatched_trks.shape[0] > 0:
left_dets = dets[unmatched_dets]
left_dets_embs = dets_embs[unmatched_dets]
left_trks = last_boxes[unmatched_trks]
left_trks_embs = trk_embs[unmatched_trks]
"""
这段代码检查是否存在未匹配的检测结果和跟踪器。如果存在,将它们分别存储在left_dets、left_dets_embs、left_trks和left_trks_embs中。
具体来说,unmatched_dets是一个包含未匹配的检测结果索引的数组,dets[unmatched_dets]会返回对应索引的检测结果组成的数组,即未匹配的检测结果。
同样地,unmatched_trks是包含未匹配的跟踪器索引的数组,last_boxes[unmatched_trks]会返回对应索引的跟踪器位置信息组成的数组,即未匹配的跟踪器位置信息。
通过将未匹配的检测结果和跟踪器的嵌入表示分别存储在left_dets_embs和left_trks_embs中,可以在后续的代码中使用这些信息,例如计算相似度矩阵等。
"""
iou_left = self.asso_func(left_dets, left_trks) # 使用self.asso_func函数计算未匹配的检测结果和跟踪器之间的重叠度,并将结果存储在iou_left中
# TODO: is better without this
emb_cost_left = left_dets_embs @ left_trks_embs.T # 计算未匹配的检测结果和跟踪器的嵌入相似度矩阵,即emb_cost_left
if self.embedding_off:
emb_cost_left = np.zeros_like(emb_cost_left) # 如果嵌入关闭(self.embedding_off为True),将emb_cost_left设置为零矩阵
iou_left = np.array(iou_left) # 将iou_left转换为NumPy数组
if iou_left.max() > self.iou_threshold:
# 如果iou_left中的最大值大于给定的阈值self.iou_threshold,则执行以下操作
"""
NOTE: by using a lower threshold, e.g., self.iou_threshold - 0.1, you may
get a higher performance especially on MOT17/MOT20 datasets. But we keep it
uniform here for simplicity
"""
rematched_indices = linear_assignment(-iou_left) # 使用线性分配算法(-iou_left)获取检测结果和跟踪器的匹配索引
to_remove_det_indices = []
to_remove_trk_indices = []
for m in rematched_indices: # 遍历匹配索引rematched_indices,对于每个匹配结果,获取对应的检测结果和跟踪器索引。
det_ind, trk_ind = unmatched_dets[m[0]], unmatched_trks[m[1]]
if iou_left[m[0], m[1]] < self.iou_threshold: # 如果重叠度iou_left[m[0], m[1]]小于给定的阈值,则跳过该匹配
continue
self.trackers[trk_ind].update(dets[det_ind, :5], dets[det_ind, 5]) # 否则,更新对应跟踪器的位置和速度信息,并更新跟踪器的嵌入表示
self.trackers[trk_ind].update_emb(dets_embs[det_ind], alpha=dets_alpha[det_ind])
to_remove_det_indices.append(det_ind) # 将检测结果和跟踪器的索引添加到待删除的索引列表中
to_remove_trk_indices.append(trk_ind)
unmatched_dets = np.setdiff1d(unmatched_dets, np.array(to_remove_det_indices))
unmatched_trks = np.setdiff1d(unmatched_trks, np.array(to_remove_trk_indices))
# 使用np.setdiff1d函数从unmatched_dets和unmatched_trks中删除待删除的索引列表。
for m in unmatched_trks:
self.trackers[m].update(None, None)
# 对于未匹配的跟踪器,代码通过循环遍历unmatched_trks数组,并使用self.trackers[m].update(None, None)方法更新跟踪器的位置和速度信息,表示跟踪器没有得到有效的更新。
# create and initialise new trackers for unmatched detections 为不匹配的检测创建和初始化新的跟踪器
for i in unmatched_dets:
trk = KalmanBoxTracker(
dets[i, :5],
dets[i, 5],
delta_t=self.delta_t,
emb=dets_embs[i],
alpha=dets_alpha[i],
new_kf=not self.new_kf_off
)
self.trackers.append(trk)
"""
第一个循环遍历未匹配的检测结果(unmatched_dets),对于每个检测结果,创建一个KalmanBoxTracker对象(一个跟踪器)。
跟踪器使用检测结果的特征和类别信息进行初始化,并将其添加到跟踪器列表(self.trackers)中
"""
i = len(self.trackers)
for trk in reversed(self.trackers):
if trk.last_observation.sum() < 0:
d = trk.get_state()[0]
else:
"""
this is optional to use the recent observation or the kalman filter prediction,
we didn't notice significant difference here
"""
d = trk.last_observation[:4]
"""
第二个循环遍历跟踪器列表(self.trackers),从后向前遍历。对于每个跟踪器,如果最后一次观察结果的元素和小于0,
则使用跟踪器的状态信息(d = trk.get_state()[0]),否则使用最近的观察结果的前4个元素(d = trk.last_observation[:4])
"""
if (trk.time_since_update < 1) and (trk.hit_streak >= self.min_hits or self.frame_count <= self.min_hits):
# +1 as MOT benchmark requires positive
ret.append(np.concatenate((d, [trk.id + 1], [trk.conf], [trk.cls])).reshape(1, -1))
i -= 1
# remove dead tracklet
if trk.time_since_update > self.max_age:
self.trackers.pop(i)
if len(ret) > 0:
return np.concatenate(ret) # 在循环结束后,如果ret列表的长度大于0,则将ret中的所有元素连接成一个二维数组,并返回该数组
return np.empty((0, 5)) # 如果ret列表为空,则返回一个空的一维数组,形状为(0, 5)
"""
在跟踪器满足以下两个条件之一时,将跟踪器的状态信息添加到返回结果列表(ret)中:
跟踪器自上一次更新以来时间不超过1帧 和 连续命中次数(hit_streak)大于等于最小命中次数(self.min_hits)或者或者帧数小于最小命中次数(self.frame_count <= self.min_hits)
"""
def _xywh_to_xyxy(self, bbox_xywh):
x, y, w, h = bbox_xywh
x1 = max(int(x - w / 2), 0)
x2 = min(int(x + w / 2), self.width - 1)
y1 = max(int(y - h / 2), 0)
y2 = min(int(y + h / 2), self.height - 1)
return x1, y1, x2, y2
"""
这段代码是一个函数 _xywh_to_xyxy,它接受一个包含坐标、宽度和高度信息的四元组 bbox_xywh,并返回一个新的四元组 (x1, y1, x2, y2)
"""
@torch.no_grad()
def _get_features(self, bbox_xyxy, ori_img):
# 接受两个输入参数:包含边界框坐标信息的四元组列表 bbox_xyxy 和原始图像 ori_img。函数返回一个特征向量 features
im_crops = [] # 用于存储从每个边界框中提取的图像片段
for box in bbox_xyxy:
x1, y1, x2, y2 = box.astype(int)
im = ori_img[y1:y2, x1:x2]
im_crops.append(im)
if im_crops:
features = self.embedder(im_crops).cpu()
# 如果 im_crops 列表不为空,函数使用预训练的嵌入器模型(通过调用 self.embedder(im_crops).cpu())对图像片段进行特征提取,并将提取的特征存储在变量 features 中
else:
features = np.array([])
# 如果 im_crops 列表为空,函数将创建一个空的 NumPy 数组 np.array([]),并将其赋值给 features
return features
def update_public(self, dets, cates, scores): # 该函数接受三个输入参数:dets(检测结果)、cates(类别)和scores(分数)。
self.frame_count += 1 # 将当前帧数加1
det_scores = np.ones((dets.shape[0], 1)) # 创建一个与dets形状相同的的一维NumPy数组det_scores,并将其设置为1
dets = np.concatenate((dets, det_scores), axis=1) # 将det_scores与dets在轴=1上进行连接,将新的特征向量的维度添加到dets中
remain_inds = scores > self.det_thresh # 根据阈值(self.det_thresh)对检测结果进行筛选,将满足阈值的检测结果保留下来
cates = cates[remain_inds]
dets = dets[remain_inds] # 根据筛选结果,更新类别和检测结果的索引。
trks = np.zeros((len(self.trackers), 5)) # 创建一个形状为(len(self.trackers, 5)的零数组trks
to_del = []
ret = []
for t, trk in enumerate(trks):
pos = self.trackers[t].predict()[0]
cat = self.trackers[t].cate
trk[:] = [pos[0], pos[1], pos[2], pos[3], cat]
if np.any(np.isnan(pos)): # 如果位置信息中存在NaN(非数字),则将该跟踪器的索引添加到to_del列表中
to_del.append(t)
trks = np.ma.compress_rows(np.ma.masked_invalid(trks)) # 使用掩码和压缩行操作,将trks中的无效值进行屏蔽和压缩
for t in reversed(to_del): # 逆序遍历to_del列表,并从跟踪器列表中删除对应索引的跟踪器
self.trackers.pop(t)
velocities = np.array([trk.velocity if trk.velocity is not None else np.array((0, 0)) for trk in self.trackers])
last_boxes = np.array([trk.last_observation for trk in self.trackers])
k_observations = np.array([k_previous_obs(trk.observations, trk.age, self.delta_t) for trk in self.trackers])
matched, unmatched_dets, unmatched_trks = associate_kitti(
dets,
trks,
cates,
self.iou_threshold,
velocities,
k_observations,
self.inertia,
)
for m in matched:
self.trackers[m[1]].update(dets[m[0], :])
# 通过索引m[1]访问self.trackers中的跟踪器,并使用检测结果dets[m[0], :]更新跟踪器的状态
if unmatched_dets.shape[0] > 0 and unmatched_trks.shape[0] > 0:
"""
The re-association stage by OCR.
NOTE: at this stage, adding other strategy might be able to continue improve
the performance, such as BYTE association by ByteTrack.
如果未匹配的检测结果(unmatched_dets.shape[0] > 0)和未匹配的跟踪器(unmatched_trks.shape[0] > 0)都存在
进入重新关联阶段(OCR重关联)
在这个阶段,可以添加其他策略来进一步提高性能,例如通过ByteTrack进行字节关联。
"""
left_dets = dets[unmatched_dets]
left_trks = last_boxes[unmatched_trks] # 将未匹配的检测结果和跟踪器的数据分别存储在left_dets、left_trks中
left_dets_c = left_dets.copy()
left_trks_c = left_trks.copy() # 创建副本left_dets_c和left_trks_c
iou_left = self.asso_func(left_dets_c, left_trks_c)
# 使用自定义的关联函数(self.asso_func)计算left_dets_c和left_trks_c之间的IOU值,并将结果存储在iou_left中
iou_left = np.array(iou_left)
det_cates_left = cates[unmatched_dets] # 将未匹配的检测结果(存储在unmatched_dets中)对应的类别存储在det_cates_left中。
trk_cates_left = trks[unmatched_trks][:, 4] # 将未匹配的跟踪器(存储在unmatched_trks中)对应的类别存储在trk_cates_left中
num_dets = unmatched_dets.shape[0]
num_trks = unmatched_trks.shape[0] # 分别存储未匹配的检测结果和跟踪器的数量。
cate_matrix = np.zeros((num_dets, num_trks)) # 创建一个二维矩阵,用于存储检测结果和跟踪器之间的类别匹配关系。该矩阵的行数等于未匹配的检测结果数量,列数等于未匹配的跟踪器数量。
for i in range(num_dets):
for j in range(num_trks): # 两个嵌套的循环遍历每个未匹配的检测结果和跟踪器,以考虑所有的组合。
if det_cates_left[i] != trk_cates_left[j]:
"""
For some datasets, such as KITTI, there are different categories,
we have to avoid associate them together.
"""
cate_matrix[i][j] = -1e6 # 如果检测结果类别和跟踪器类别不相等,将它们之间的匹配值设为负无穷大,以避免将它们关联在一起
iou_left = iou_left + cate_matrix # 将类别矩阵(cate_matrix)的对应元素添加到IOU值中
if iou_left.max() > self.iou_threshold - 0.1: # 如果最大IOU值超过阈值(self.iou_threshold - 0.1),则进行线性分配,将检测结果和跟踪器进行重新匹配
rematched_indices = linear_assignment(-iou_left) # 创建列表rematched_indices,存储重新匹配的索引
to_remove_det_indices = [] # 创建空列表to_remove_det_indices和to_remove_trk_indices,用于存储需要删除的检测结果和跟踪器索引。
to_remove_trk_indices = []
for m in rematched_indices: # 对于每个重新匹配的索引
det_ind, trk_ind = unmatched_dets[m[0]], unmatched_trks[m[1]] # 获取检测结果索引det_ind和跟踪器索引trk_ind
if iou_left[m[0], m[1]] < self.iou_threshold - 0.1: # 如果重新匹配的IOU值小于阈值(self.iou_threshold - 0.1),则跳过该匹配
continue
self.trackers[trk_ind].update(dets[det_ind, :]) # 否则,使用检测结果dets[det_ind, :]更新跟踪器状态,并将检测结果和跟踪器的索引添加到相应的删除列表中。
to_remove_det_indices.append(det_ind)
to_remove_trk_indices.append(trk_ind)
unmatched_dets = np.setdiff1d(unmatched_dets, np.array(to_remove_det_indices)) # 使用np.setdiff1d函数从未匹配的检测结果和跟踪器中删除已删除的索引
unmatched_trks = np.setdiff1d(unmatched_trks, np.array(to_remove_trk_indices))
for i in unmatched_dets: # 对于每个未匹配的检测结果
trk = KalmanBoxTracker(dets[i, :]) # 创建一个新的卡尔曼盒子跟踪器(KalmanBoxTracker),并使用检测结果初始化该跟踪器。
trk.cate = cates[i] # 将跟踪器的类别存储为对应的检测结果的类别。
self.trackers.append(trk) # 将跟踪器添加到跟踪器列表(self.trackers)中
i = len(self.trackers) # 初始化变量i为跟踪器列表的长度
for trk in reversed(self.trackers): # 逆序遍历跟踪器列表
if trk.last_observation.sum() > 0: # 如果跟踪器的最后观测值的(trk.last_observation)的 sum 大于 0
d = trk.last_observation[:4] # 将跟踪器的最后观测值的前四个元素存储在变量 d 中
else:
d = trk.get_state()[0] # 使用跟踪器的状态(trk.get_state())的第一个元素作为变量 d
if trk.time_since_update < 1: # 如果跟踪器的时间自上次更新以来小于 1
if (self.frame_count <= self.min_hits) or (trk.hit_streak >= self.min_hits):
# 如果当前帧数(self.frame_count)小于最小命中次数(self.min_hits)或者跟踪器的命中连击数(trk.hit_streak)大于等于最小命中次数:
# id+1 as MOT benchmark requires positive
ret.append(np.concatenate((d, [trk.id + 1], [trk.conf], [trk.cls])).reshape(1, -1))
# 将变量 d、跟踪器的 ID(加 1,因为 MOT 基准要求正数)、置信度和类别合并成一个数组,并将该数组reshape为形状为 (1, -1) 的数组,并将该数组添加到结果列表(ret)中
if trk.hit_streak == self.min_hits:
# 如果跟踪器的命中连击数等于最小命中次数
# Head Padding (HP): recover the lost steps during initializing the track
for prev_i in range(self.min_hits - 1): # 使用头填充(Head Padding,HP)来恢复在初始化跟踪器时丢失的步骤
prev_observation = trk.history_observations[-(prev_i + 2)]
ret.append(
(
np.concatenate(
(
prev_observation[:4],
[trk.id + 1],
[trk.conf],
[trk.cls],
)
)
).reshape(1, -1)
)
# 对于之前的索引prev_i,从跟踪器的历史观测值(trk.history_observations)中获取前两个观测值,
# 将其前四个元素、跟踪器的 ID、置信度和类别合并成一个数组,并将该数组reshape为形状为 (1, -1) 的数组,并将该数组添加到结果列表中
i -= 1
if trk.time_since_update > self.max_age: # 如果跟踪器的时间自上次更新以来大于最大年龄(self.max_age)
self.trackers.pop(i) # 从跟踪器列表(self.trackers)中移除该跟踪器。
if len(ret) > 0:
return np.concatenate(ret) # 如果结果列表(ret)非空,则将结果列表合并为一个数组并返回
return np.empty((0, 7)) # 如果结果列表为空,则返回一个形状为 (0, 7) 的空数组
def dump_cache(self): # 用于清除缓存
self.cmc.dump_cache()
self.embedder.dump_cache()
总结
之后,我发现了一个新的追踪方式SMILEtrack,下一段时间我将对它研究一下,后续再见。
有研究目标追踪的也可以一起交流,共同学习!