ByteTrack目标跟踪算法论文代码解析(Python深度解析)
ByteTrack: Multi-Object Tracking by Associating Every Detection Box
论文:https://arxiv.org/pdf/2110.06864.pdf
github:https://github.com/ifzhang/ByteTrack
目录
一. 背景
多目标跟踪(MOT)的目标是估计视频中目标的边界框和ID。大多数方法通过关联得分高于阈值的检测框来获得ID。对于检测分数较低的目标(如被遮挡)会直接丢弃,造成不可忽略的真目标缺失和轨迹碎片化。
二. 解决方法
为了解决这一问题,本文提出了一种简单、有效、通用的关联方法,通过关联几乎所有的检测框来跟踪,而不是仅仅关联得分高的检测框。
三. 总结
1. 两种框
- 检测框
- 跟踪框(航迹)
2. 两个阈值
- 是否为高分检测:track_thresh
- 是否可新启航迹:high_thresh
high_thresh一般大于track_thresh
3. 两次匹配
- 所有航迹与高分检测框匹配:
匹配失败的高分检测框新启航迹 - 第一次匹配失败的航迹与低分检测框再匹配
匹配失败的低分检测框记为背景
匹配失败的航迹记为lost等待重生或消亡(连续30帧都失败)
4. 一个指标
两次匹配均使用IoU(交并比),因为低分数检测框通常包含严重的遮挡或运动模糊,并且外观特征不可靠,因此不适用外观相似度
四. 伪代码
五. 代码详解(Python)
此跟踪算法的核心逻辑在yolox.tracker.byte_tracker
的类class BYTETracker(object)
以下详解对最核心的代码逐步解释,源码请到原作者的github仓库
这里用到了一个自定义的类型STrack,表示航迹
strack有几个标志Flag:
# 可查询四种状态
class TrackState(object):
New = 0
Tracked = 1
Lost = 2
Removed = 3
# 表示是否激活
is_activated = Flase
首先是几个重要的列表的定义,用来存放各种状态的stracks
self.tracked_stracks = [] # 此列表存放成功追踪到的航迹
self.lost_stracks = [] # 此列表暂存丢失航迹(可能由于完全遮挡或者出视野)
self.removed_stracks = [] # 此列表存放彻底删除的航迹(丢失过长时间)
activated_starcks = [] # 临时列表放已激活的航迹
refind_stracks = [] # 临时列表放原本丢失又重生的航迹
lost_stracks = [] # 临时列表放丢失的航迹
removed_stracks = [] # 临时列表放删除的航迹
还有三个阈值要进行说明:
args.track_thresh # detect的分数高于此值才被认为是高分检测框
args.match_thresh # 计算IoU距离时高于此值才被认为匹配
# detect的分数高于det_thresh才会激活一个新追踪目标
# 这个值比一般要求的高分检测框还要大0.1,激活新目标的条件更严格
self.det_thresh = args.track_thresh + 0.1
类BYTETracker
的主要方法def update(self, output_results, img_info, img_size)
:
先从检测器处获得检测结果output_results
,包含detect box和score
scores = output_results[:, 4]
bboxes = output_results[:, :4]
按检测器给出的score分为高分检测框和低分检测框
remain_inds = scores > self.args.track_thresh
inds_low = scores > 0.1
inds_high = scores < self.args.track_thresh
# 下面用了 _second 表示是第二步匹配时会用到的
inds_second = np.logical_and(inds_low, inds_high) # 低于阈值但高于0.1
dets_second = bboxes[inds_second] # 分数低于阈值但高于0.1的那些检测框
dets = bboxes[remain_inds] # 分数高的那些检测框
把高分检测器的检测框转为STrack类型便于后续的匹配
if len(dets) > 0:
detections = [STrack(STrack.tlbr_to_tlwh(tlbr), s) for
(tlbr, s) in zip(dets, scores_keep)]
新识别到的航迹处于非激活状态(除了第一帧),先放入unconfirmed
列表,后面再处理
在上一帧已经是追踪状态、激活状态的的就放入tracked_stracks
列表
unconfirmed = [] # 临时列表放上一帧新激活的还没有开始追踪的
tracked_stracks = [] # 临时列表放上一帧就已经在追踪的
for track in self.tracked_stracks:
if not track.is_activated:
unconfirmed.append(track)
else:
tracked_stracks.append(track)
接下来就开始了第一次匹配,将所有航迹与高分检测框匹配
# 所有航迹包括了新激活的、追踪中的、丢失的
strack_pool = joint_stracks(tracked_stracks, self.lost_stracks)
# 使用Kalman滤波器预来预测航迹的新位置
STrack.multi_predict(strack_pool)
# 使用IoU距离匹配
# strack_pool里面有所有航迹,detections里面有所有高分检测框
dists = matching.iou_distance(strack_pool, detections)
if not self.args.mot20:
dists = matching.fuse_score(dists, detections)
# 返回值
# matches 匹配成功的航迹与检测框
# u_track 匹配失败剩余的航迹(既没有与之匹配的检测框)
# u_detection 匹配失败剩余的检测框(既没有与之匹配的跟踪航迹)
# 参数match_thresh是设定多大的距离认为是匹配
matches, u_track, u_detection = matching.linear_assignment(dists,
thresh=self.args.match_thresh)
# 把匹配成功的航迹进行状态更新
# 那些处于跟踪状态的的航迹则更新位置,放入激活航迹的列表
# 那些处于丢失状态的航迹则再次激活,放入重生航迹的列表
for itracked, idet in matches:
track = strack_pool[itracked]
det = detections[idet]
if track.state == TrackState.Tracked:
track.update(detections[idet], self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
refind_stracks.append(track)
第一次匹配失败的航迹并没有丢掉,还再进行第二次的匹配,与低分检测框匹配。这就是这篇论文的创新,关联了低分检测框,解决了严重遮挡导致轨迹丢失的问题。
# dets_second里面有分数高于0.1但是低于阈值的低分检测框
if len(dets_second) > 0:
# 同样地,把检测器的检测框转为STrack类型便于后续的匹配
detections_second = [STrack(STrack.tlbr_to_tlwh(tlbr), s) for
(tlbr, s) in zip(dets_second, scores_second)]
else:
detections_second = []
# 取出第一次匹配失败剩余的航迹,只要跟踪中的,丢失的不做处理
r_tracked_stracks = [strack_pool[i] for i in u_track if strack_pool[i].state == TrackState.Tracked]
# 第二次匹配也用IoU距离,不能用外观形似度
dists = matching.iou_distance(r_tracked_stracks, detections_second)
# 这里的匹配阈值比第一次的小了,放宽条件
matches, u_track, u_detection_second = matching.linear_assignment(dists, thresh=0.5)
# 第二次匹配成功的话那就是正规军啦,与第一次的同等地位,放入“编制”!
for itracked, idet in matches:
track = r_tracked_stracks[itracked]
det = detections_second[idet]
if track.state == TrackState.Tracked:
track.update(det, self.frame_id)
activated_starcks.append(track)
else:
track.re_activate(det, self.frame_id, new_id=False)
refind_stracks.append(track)
# u_track里面是第二次匹配还是失败的剩余航迹,也不删除还有得用,列为丢失等待重生
for it in u_track:
track = r_tracked_stracks[it]
if not track.state == TrackState.Lost:
track.mark_lost()
lost_stracks.append(track)
第一次匹配中失败的高分检测框很可能是新进入的目标,没有与之对应的航迹,因此新启航迹
# u_detection 第一次匹配中失败的高分检测框
for inew in u_detection:
track = detections[inew]
# 新启的条件严格一点,看开头说明的三个阈值
if track.score < self.det_thresh:
continue
# 激活一个kalman滤波器,下一帧开始预测航迹新位置
track.activate(self.kalman_filter, self.frame_id)
activated_starcks.append(track)
把那些在lost_stracks
列表里存在过长时间(一直没得到重生)的就认为轨迹消亡了,可以删除了
for track in self.lost_stracks:
if self.frame_id - track.end_frame > self.max_time_lost:
track.mark_removed()
removed_stracks.append(track)
把所有临时列表里的轨迹放入最终的列表,作为这一帧确定的结果
# 上一帧本来就跟踪状态的就一样地放进来
self.tracked_stracks = [t for t in self.tracked_stracks if t.state == TrackState.Tracked]
# 上面代码成功匹配的轨迹都加进activated_starcks了,把他们添加进来
self.tracked_stracks = joint_stracks(self.tracked_stracks, activated_starcks)
# 这帧重生的也添加进来
self.tracked_stracks = joint_stracks(self.tracked_stracks, refind_stracks)
# 轨迹重生了,那就要从self.lost_stracks中移除
self.lost_stracks = sub_stracks(self.lost_stracks, self.tracked_stracks)
# 这帧被列为丢失的,放到self.lost_stracks
self.lost_stracks.extend(lost_stracks)
# 被删除的肯定原来是在self.lost_stracks里面,要把它们从中移除
self.lost_stracks = sub_stracks(self.lost_stracks, self.removed_stracks)
# 这帧刚被列为删除的,放入self.removed_stracks,等待下一帧才会被移除
self.removed_stracks.extend(removed_stracks)
# 前面把各种各样杂七杂八的都放进来了,可能会有重复的,最后筛一遍。:) 细,真细啊
self.tracked_stracks, self.lost_stracks = remove_duplicate_stracks(self.tracked_stracks, self.lost_stracks)
作者:Voluntino