ByteTrack目标跟踪算法论文代码解析(Python深度解析)

ByteTrack: Multi-Object Tracking by Associating Every Detection Box

论文:https://arxiv.org/pdf/2110.06864.pdf
github:https://github.com/ifzhang/ByteTrack

目录

  • 一. 背景
  • 二. 解决方法
  • 三. 总结
  • 1. 两种框
  • 2. 两个阈值
  • 3. 两次匹配
  • 4. 一个指标
  • 四. 伪代码
  • 五. 代码详解(Python)
  • 一. 背景

    多目标跟踪(MOT)的目标是估计视频中目标的边界框和ID。大多数方法通过关联得分高于阈值的检测框来获得ID。对于检测分数较低的目标(如被遮挡)会直接丢弃,造成不可忽略的真目标缺失和轨迹碎片化。

    二. 解决方法

    为了解决这一问题,本文提出了一种简单、有效、通用的关联方法,通过关联几乎所有的检测框来跟踪,而不是仅仅关联得分高的检测框。

    三. 总结

    1. 两种框
    1. 检测框
    2. 跟踪框(航迹)
    2. 两个阈值
    1. 是否为高分检测:track_thresh
    2. 是否可新启航迹:high_thresh
      high_thresh一般大于track_thresh
    3. 两次匹配
    1. 所有航迹与高分检测框匹配:
      匹配失败的高分检测框新启航迹
    2. 第一次匹配失败的航迹与低分检测框再匹配
      匹配失败的低分检测框记为背景
      匹配失败的航迹记为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

    物联沃分享整理
    物联沃-IOTWORD物联网 » ByteTrack目标跟踪算法论文代码解析(Python深度解析)

    发表回复