实时目标追踪:ByteTrack算法步骤详解和代码逐行解析

文章目录

  • ByteTrack算法简介
  • 算法步骤分析:
  • 算法框架流程:
  • 一、 对追踪轨迹和边界框进行分类
  • 二、对轨迹进行第一次追踪(仅针对激活状态的轨迹的高分匹配)
  • 三、对轨迹进行第二次追踪(仅针对激活状态的轨迹的低分匹配)
  • 四、对未激活状态的轨迹进行追踪
  • 五、新建轨迹
  • 六、返回结果
  • 算法实现流程
  • 算法源码(逐行注释)
  • ByteTrack算法简介

    ByteTrack算法是一种基于目标检测的追踪算法,和其他非ReID的算法一样,仅仅使用目标追踪所得到的bbox进行追踪。追踪算法使用了卡尔曼滤波预测边界框,然后使用匈牙利算法进行目标和轨迹间的匹配。
    ByteTrack算法的最大创新点就是对低分框的使用,作者认为低分框可能是对物体遮挡时产生的框,直接对低分框抛弃会影响性能,所以作者使用低分框对追踪算法进行了二次匹配,有效优化了追踪过程中因为遮挡造成换id的问题。

  • 没有使用ReID特征计算外观相似度
  • 非深度方法,不需要训练
  • 利用高分框和低分框之间的区别和匹配,有效解决遮挡问题
  • 算法步骤分析:

    算法步骤分析会分为两块来写,第一块会按照比较习惯的思路为大家整理出算法的主要框架流程,读者可以很清楚的了解算法的原理和步骤。第二块会按照作者代码实现的步骤和思路来讲解,方便大家更容易读懂作者的代码,以及跟上作者的代码设计思路

    算法框架流程:

    算法的主要思路就是创建追踪的轨迹,然后利用追踪的轨迹对每一帧的目标进行匹配,逐帧来匹配目标,从而形成完整的轨迹。

    首先,我们需要先理清关键的两个单元,第一个是追踪的轨迹,是代码从第一帧开始创建,包含了持续追踪和中断追踪的一切轨迹;第二个是当前帧的边界框,当前帧的边界框是仅从当前帧中获取到的边界框,不包含任何以往帧的信息。

    追踪轨迹状态解析:

  • 激活状态:激活追踪了两帧以上的目标框(包括第一帧时目标框新建的轨迹)

  • 未激活状态:在视频中间出现的新轨迹,并且暂未匹配到轨迹的第二点

  • 新轨迹:新生成的轨迹

  • 已追踪轨迹:在前一帧成功追踪的轨迹

  • 失追轨迹:在前n帧失去追踪的轨迹(n<=30)

  • 已删除轨迹:在前n帧失去追踪的轨迹(n>30)

  • 当开始扫描第一帧时,此时还没有任何的轨迹出现:算法会将所有的目标框都创建轨迹对象,并储存起来。
    注意:此时所有创建的轨迹都会被标注为已追踪轨迹

    从第二帧开始,算法就会逐步构建轨迹,步骤如下:

    一、 对追踪轨迹和边界框进行分类

  • 对所有的追踪轨迹分为激活和未激活两类(激活追踪了两帧以上的目标框(包括第一帧时目标框新建的轨迹))

  • 对所有的当前帧边界框分为高分和低分两类(按照边界框的得分阈值进行分类(官方是0.5))

  • 二、对轨迹进行第一次追踪(仅针对激活状态的轨迹的高分匹配)

    1. 将所有的已追踪轨迹失追轨迹合并,称为初步追踪轨迹
    2. 预测初步追踪轨迹的下一帧边界框可能的位置和大小(使用卡尔曼滤波预测边界框,网上有大量详细介绍,篇幅有限,就不赘述)
    3. 计算初步追踪轨迹预测的下一帧边界框当前帧高分边界框之间的IoU(交并比)值,获取一个两两之间IoU的关系损失矩阵(IoU越小表示关联程度越大,IoU最大值为1,代表边界框间没有交集)
    4. 根据IoU损失矩阵,使用匈牙利算法初步追踪轨迹当前帧高分边界框进行匹配,获得三个结果:已匹配的轨迹与边界框,未成功匹配的轨迹,未成功匹配的当前帧边界框。(匈牙利算法可以根据损失矩阵对两两之间进行一对一的匹配,返回匹配成功和不成功的结果,篇幅有限不赘述)
    5. 使用已成功匹配的当前帧边界框更新初步追踪轨迹(把初步追踪轨迹中的框改为当前帧边界框,id还是原来的id)

    三、对轨迹进行第二次追踪(仅针对激活状态的轨迹的低分匹配)

    1. 找出第一次匹配中没匹配到的轨迹,筛选出其中的已追踪轨迹(因为低分匹配不匹配那些已经失追的轨迹,可能作者认为这样比较合理)
    2. 因为这些轨迹之前也已经预测过下一帧的边界框了,所以这里不用预测
    3. 计算上述轨迹和当前帧的低分边界框之间的IoU
    4. 使用匈牙利算法上述追踪轨迹当前帧低分边界框进行匹配
    5. 使用已成功匹配的当前帧边界框更新上述追踪轨迹
    6. 将此时还未成功追踪的轨迹标记为失追轨迹(扔给下一帧去追踪了)

    四、对未激活状态的轨迹进行追踪

    个人理解:我觉得这一步存在的意义可能是作者认为在视频中间突然出现的目标可信度可能不太高(可能出现重复轨迹什么的),所以对中间才新出现的轨迹比较谨慎处理

    1. 找出第一步中未成功匹配的当前帧边界框(没有成功匹配的高分边界框),并且找出未激活的轨迹
    2. 计算上述轨迹和当前帧的边界框之间的IoU
    3. 使用匈牙利算法上述追踪轨迹边界框进行匹配
    4. 使用已成功匹配的当前帧边界框更新上述追踪轨迹
    5. 此时将未成功追踪的未激活轨迹直接标记为已删除轨迹(作者新好狠,直接给人删了)

    五、新建轨迹

    如果到现在还没有成功匹配的高分边界框,就能认为是新出现的东西了,会给它分配一个新的轨迹以及新的id(低分框就之间当成误判扔掉了,不会生成新轨迹)。

    注意:此时不是第一帧了,新增的轨迹都是未激活状态,如果下一帧不能成功匹配的话就会被无情删除了

    六、返回结果

    此时就可以放回所有已追踪的轨迹了(当然不包含失追轨迹和删除轨迹),所有轨迹都有一个唯一的id,这个结果就可以拿去作为每一帧追踪的结果了!

    算法实现流程

    代码实现和上面步骤基本一致,除了一些封装成类的细节和中间变量被我省略了。大家直接读我逐行注释的源码就行了

    算法源码(逐行注释)

    这是byte_tracker.py 文件,不包含目标检测部分,是追踪的完整代码

    import numpy as np
    from collections import deque
    import os
    import os.path as osp
    import copy
    import torch
    import torch.nn.functional as F
    
    from .kalman_filter import KalmanFilter
    from tracker import matching
    from .basetrack import BaseTrack, TrackState
    
    # 这个类是用来存放轨迹的,每个轨迹都有一些自己的属性,例如id、边界框、预测框、状态等等
    class STrack(BaseTrack):
        shared_kalman = KalmanFilter()
        def __init__(self, tlwh, score):
    
            # wait activate
            self._tlwh = np.asarray(tlwh, dtype=np.float)
            self.kalman_filter = None
            self.mean, self.covariance = None, None
            self.is_activated = False
    
            self.score = score
            self.tracklet_len = 0
    
        def predict(self):
            mean_state = self.mean.copy()
            if self.state != TrackState.Tracked:
                mean_state[7] = 0
            self.mean, self.covariance = self.kalman_filter.predict(mean_state, self.covariance)
    
        @staticmethod
        def multi_predict(stracks):
            if len(stracks) > 0:
                multi_mean = np.asarray([st.mean.copy() for st in stracks])
                multi_covariance = np.asarray([st.covariance for st in stracks])
                for i, st in enumerate(stracks):
                    if st.state != TrackState.Tracked:
                        multi_mean[i][7] = 0
                multi_mean, multi_covariance = STrack.shared_kalman.multi_predict(multi_mean, multi_covariance)
                for i, (mean, cov) in enumerate(zip(multi_mean, multi_covariance)):
                    stracks[i].mean = mean
                    stracks[i].covariance = cov
    
        def activate(self, kalman_filter, frame_id):
            """Start a new tracklet"""
            self.kalman_filter = kalman_filter
            self.track_id = self.next_id()
            self.mean, self.covariance = self.kalman_filter.initiate(self.tlwh_to_xyah(self._tlwh))
    
            self.tracklet_len = 0
            self.state = TrackState.Tracked
            if frame_id == 1:
                self.is_activated = True
            # self.is_activated = True
            self.frame_id = frame_id
            self.start_frame = frame_id
    
        def re_activate(self, new_track, frame_id, new_id=False):
            self.mean, self.covariance = self.kalman_filter.update(
                self.mean, self.covariance, self.tlwh_to_xyah(new_track.tlwh)
            )
            self.tracklet_len = 0
            self.state = TrackState.Tracked
            self.is_activated = True
            self.frame_id = frame_id
            if new_id:
                self.track_id = self.next_id()
            self.score = new_track.score
    
        def update(self, new_track, frame_id):
            """
            Update a matched track
            :type new_track: STrack
            :type frame_id: int
            :type update_feature: bool
            :return:
            """
            self.frame_id = frame_id
            self.tracklet_len += 1
    
            new_tlwh = new_track.tlwh
            self.mean, self.covariance = self.kalman_filter.update(
                self.mean, self.covariance, self.tlwh_to_xyah(new_tlwh))
            self.state = TrackState.Tracked
            self.is_activated = True
    
            self.score = new_track.score
    
        @property
        # @jit(nopython=True)
        def tlwh(self):
            """Get current position in bounding box format `(top left x, top left y,
                    width, height)`.
            """
            if self.mean is None:
                return self._tlwh.copy()
            ret = self.mean[:4].copy()
            ret[2] *= ret[3]
            ret[:2] -= ret[2:] / 2
            return ret
    
        @property
        # @jit(nopython=True)
        def tlbr(self):
            """Convert bounding box to format `(min x, min y, max x, max y)`, i.e.,
            `(top left, bottom right)`.
            """
            ret = self.tlwh.copy()
            ret[2:] += ret[:2]
            return ret
    
        @staticmethod
        # @jit(nopython=True)
        def tlwh_to_xyah(tlwh):
            """Convert bounding box to format `(center x, center y, aspect ratio,
            height)`, where the aspect ratio is `width / height`.
            """
            ret = np.asarray(tlwh).copy()
            ret[:2] += ret[2:] / 2
            ret[2] /= ret[3]
            return ret
    
        def to_xyah(self):
            return self.tlwh_to_xyah(self.tlwh)
    
        @staticmethod
        # @jit(nopython=True)
        def tlbr_to_tlwh(tlbr):
            ret = np.asarray(tlbr).copy()
            ret[2:] -= ret[:2]
            return ret
    
        @staticmethod
        # @jit(nopython=True)
        def tlwh_to_tlbr(tlwh):
            ret = np.asarray(tlwh).copy()
            ret[2:] += ret[:2]
            return ret
    
        def __repr__(self):
            return 'OT_{}_({}-{})'.format(self.track_id, self.start_frame, self.end_frame)
    
    # 正片开始
    class BYTETracker(object):
        def __init__(self, args, frame_rate=30):
            self.tracked_stracks = []  # type: list[STrack]
            self.lost_stracks = []  # type: list[STrack]
            self.removed_stracks = []  # type: list[STrack]
    
            self.frame_id = 0
            self.args = args
            #self.det_thresh = args.track_thresh
            self.det_thresh = args.track_thresh + 0.1
            self.buffer_size = int(frame_rate / 30.0 * args.track_buffer)
            self.max_time_lost = self.buffer_size
            self.kalman_filter = KalmanFilter()
    
        def update(self, output_results):
            self.frame_id += 1
            activated_starcks = [] #保存当前帧匹配到持续追踪的轨迹
            refind_stracks = [] #保存当前帧匹配到之前目标丢失的轨迹
            lost_stracks = [] #保存当前帧没有匹配到目标的轨迹
            removed_stracks = [] #保存当前帧
            # 第一步:将objects转换为x1,y1,x2,y2,score的格式,并构建strack
            if output_results.shape[1] == 5:
                scores = output_results[:, 4]
                bboxes = output_results[:, :4]
            else:
                output_results = output_results.cpu().numpy()
                scores = output_results[:, 4] * output_results[:, 5]
                bboxes = output_results[:, :4]  # x1y1x2y2
    
            #第二步:根据scroe和track_thresh将strack分为detetions(dets)(>=)和detections_low(dets_second)
            remain_inds = scores > self.args.track_thresh
            inds_low = scores > 0.1
            inds_high = scores < self.args.track_thresh
    
            inds_second = np.logical_and(inds_low, inds_high)   #  筛选分数处于0.1<分数<阈值的
            dets_second = bboxes[inds_second]
            dets = bboxes[remain_inds]
            scores_keep = scores[remain_inds]
            scores_second = scores[inds_second]
    
            if len(dets) > 0:
                '''Detections'''
                detections = [STrack(STrack.tlbr_to_tlwh(tlbr), s) for
                              (tlbr, s) in zip(dets, scores_keep)]
            else:
                detections = []
    
            ''' Add newly detected tracklets to tracked_stracks'''
            # 遍历tracked_stracks(所有的轨迹),如果track还activated,加入tracked_stracks(继续匹配该帧),否则加入unconfirmed
            unconfirmed = []
            tracked_stracks = []  # type: list[STrack]
            # is_activated表示除了第一帧外中途只出现一次的目标轨迹(新轨迹,没有匹配过或从未匹配到其他轨迹)
            for track in self.tracked_stracks:
                if not track.is_activated:
                    unconfirmed.append(track)
                else:
                    tracked_stracks.append(track)
    
            ''' Step 2: First association, with high score detection boxes'''
            # 第一次匹配
            # 将track_stracks和lost_stracks合并得到track_pool
            strack_pool = joint_stracks(tracked_stracks, self.lost_stracks)
            # Predict the current location with KF
            # 将strack_pool送入muti_predict进行预测(卡尔曼滤波)
            STrack.multi_predict(strack_pool)
            # 计算strack_pool(当前帧的预测框和之前未匹配到轨迹的bbox)和detections的iou_distance(代价矩阵)
            # detections是当前帧的bbox
            # 如果矩阵没有交集则为1
            dists = matching.iou_distance(strack_pool, detections)
            if not self.args.mot20:
                dists = matching.fuse_score(dists, detections)
            # 用match_thresh = 0.8(越大说明iou越小)过滤较小的iou,利用匈牙利算法进行匹配,得到matches, u_track, u_detection
            matches, u_track, u_detection = matching.linear_assignment(dists, thresh=self.args.match_thresh)
            # 遍历matches,如果state为Tracked,调用update方法,并加入到activated_stracks,否则调用re_activate,并加入refind_stracks
            # matches = [itracked, idet] itracked指的是轨迹的索引,idet 指的是当前目标框的索引,意思是第几个轨迹匹配第几个目标框
            for itracked, idet in matches:
                track = strack_pool[itracked]
                det = detections[idet]
                if track.state == TrackState.Tracked:
                    # 更新轨迹的bbox为当前匹配到的bbox
                    track.update(detections[idet], self.frame_id)
                    # activated_starcks是目前能持续追踪到的轨迹
                    activated_starcks.append(track)
                else:
                    track.re_activate(det, self.frame_id, new_id=False)
                    # refind_stracks是重新追踪到的轨迹
                    refind_stracks.append(track)
            # 第二次匹配:和低分的矩阵进行匹配
            ''' Step 3: Second association, with low score detection boxes'''
            # association the untrack to the low score detections
            if len(dets_second) > 0:
                '''Detections'''
                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]
            # 计算r_tracked_stracks和detections_second的iou_distance(代价矩阵)
            dists = matching.iou_distance(r_tracked_stracks, detections_second)
            # 用match_thresh = 0.8过滤较小的iou,利用匈牙利算法进行匹配,得到matches, u_track, u_detection
            matches, u_track, u_detection_second = matching.linear_assignment(dists, thresh=0.5) #分数比较低的目标框没有匹配到轨迹就会直接被扔掉,不会创建新的轨迹
            # 遍历matches,如果state为Tracked,调用update方法,并加入到activated_stracks,否则调用re_activate,并加入refind_stracks
            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(第二次匹配也没匹配到的轨迹),将state不是Lost的轨迹,调用mark_losk方法,并加入lost_stracks,等待下一帧匹配
            # lost_stracks加入上一帧还在持续追踪但是这一帧两次匹配不到的轨迹
            for it in u_track:
                track = r_tracked_stracks[it]
                if not track.state == TrackState.Lost:
                    track.mark_lost()
                    lost_stracks.append(track)
    
            '''Deal with unconfirmed tracks, which tracks with only one beginning frame'''
            # 尝试匹配中途第一次出现的轨迹
            # 当前帧的目标框会优先和长期存在的轨迹(包括持续追踪的和断追的轨迹)匹配,再和只出现过一次的目标框匹配
            detections = [detections[i] for i in u_detection]
            # 计算unconfirmed和detections的iou_distance(代价矩阵)
            # unconfirmed是不活跃的轨迹(过了30帧)
            dists = matching.iou_distance(unconfirmed, detections)
            if not self.args.mot20:
                dists = matching.fuse_score(dists, detections)
            # 用match_thresh = 0.8过滤较小的iou,利用匈牙利算法进行匹配,得到matches, u_track, u_detection
            matches, u_unconfirmed, u_detection = matching.linear_assignment(dists, thresh=0.8)
            # 遍历matches,如果state为Tracked,调用update方法,并加入到activated_stracks,否则调用re_activate,并加入refind_stracks
            for itracked, idet in matches:
                unconfirmed[itracked].update(detections[idet], self.frame_id)
                activated_starcks.append(unconfirmed[itracked])
            # 遍历u_unconfirmed,调用mark_removd方法,并加入removed_stracks
            for it in u_unconfirmed:
                # 中途出现一次的轨迹和当前目标框匹配失败,删除该轨迹(认为是检测器误判)
                # 真的需要直接删除吗??
                track = unconfirmed[it]
                track.mark_removed()
                removed_stracks.append(track)
    
            """ Step 4: Init new stracks"""
            # 遍历u_detection(前两步都没匹配成功的目标框),对于score大于high_thresh,调用activate方法,并加入activated_stracks
            # 此时还没匹配的u_detection将赋予新的id
            for inew in u_detection:
                track = detections[inew]
                if track.score < self.det_thresh:
                    continue
                # 只有第一帧新建的轨迹会被标记为is_activate=True,其他帧不会
                track.activate(self.kalman_filter, self.frame_id)
                #把新的轨迹加入到当前活跃轨迹中
                activated_starcks.append(track)
            # 遍历lost_stracks,对于丢失超过max_time_lost(30)的轨迹,调用mark_removed方法,并加入removed_stracks
            """ Step 5: Update state"""
            for track in self.lost_stracks:
                if self.frame_id - track.end_frame > self.max_time_lost:
                    track.mark_removed()
                    removed_stracks.append(track)
    
            # print('Ramained match {} s'.format(t4-t3))
            # 遍历tracked_stracks,筛选出state为Tracked的轨迹,保存到tracked_stracks
            self.tracked_stracks = [t for t in self.tracked_stracks if t.state == TrackState.Tracked]
            # 将activated_stracks,refind_stracks合并到track_stracks
            self.tracked_stracks = joint_stracks(self.tracked_stracks, activated_starcks)
            self.tracked_stracks = joint_stracks(self.tracked_stracks, refind_stracks)
            # 遍历lost_stracks,去除tracked_stracks和removed_stracks中存在的轨迹
            self.lost_stracks = sub_stracks(self.lost_stracks, self.tracked_stracks)
            self.lost_stracks.extend(lost_stracks)
            self.lost_stracks = sub_stracks(self.lost_stracks, self.removed_stracks)
            self.removed_stracks.extend(removed_stracks)
            # 调用remove_duplicate_stracks函数,计算tracked_stracks,lost_stracks的iou_distance,对于iou_distance<0.15的认为是同一个轨迹,
            # 对比该轨迹在track_stracks和lost_stracks的跟踪帧数和长短,仅保留长的那个
            self.tracked_stracks, self.lost_stracks = remove_duplicate_stracks(self.tracked_stracks, self.lost_stracks)
            # get scores of lost tracks
            # 遍历tracked_stracks,将所有的is_activated为true的轨迹输出
            output_stracks = [track for track in self.tracked_stracks if track.is_activated]
    
            return output_stracks
    
    
    def joint_stracks(tlista, tlistb):
        exists = {}
        res = []
        for t in tlista:
            exists[t.track_id] = 1
            res.append(t)
        for t in tlistb:
            tid = t.track_id
            if not exists.get(tid, 0):
                exists[tid] = 1
                res.append(t)
        return res
    
    
    def sub_stracks(tlista, tlistb):
        stracks = {}
        for t in tlista:
            stracks[t.track_id] = t
        for t in tlistb:
            tid = t.track_id
            if stracks.get(tid, 0):
                del stracks[tid]
        return list(stracks.values())
    
    
    def remove_duplicate_stracks(stracksa, stracksb):
        pdist = matching.iou_distance(stracksa, stracksb)
        pairs = np.where(pdist < 0.15)
        dupa, dupb = list(), list()
        for p, q in zip(*pairs):
            timep = stracksa[p].frame_id - stracksa[p].start_frame
            timeq = stracksb[q].frame_id - stracksb[q].start_frame
            if timep > timeq:
                dupb.append(q)
            else:
                dupa.append(p)
        resa = [t for i, t in enumerate(stracksa) if not i in dupa]
        resb = [t for i, t in enumerate(stracksb) if not i in dupb]
        return resa, resb
    
    

    来源:Ticuby

    物联沃分享整理
    物联沃-IOTWORD物联网 » 实时目标追踪:ByteTrack算法步骤详解和代码逐行解析

    发表回复