Skip to content
Open
4 changes: 2 additions & 2 deletions libraries/getitrack/configs/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ score_threshold: 0.1
class_filter: null
# Detection-to-track matching.
match_threshold: 0.8
high_score_threshold: 0.6
high_score_threshold: 0.5
match_class_only: true
lifecycle:
max_age: 30
Expand All @@ -15,7 +15,7 @@ lifecycle:
motion:
process_noise: 1.0
measurement_noise: 1.0
velocity_decay: 0.99
velocity_decay: 1.0
interpolation:
enabled: true
method: linear
Expand Down
7 changes: 4 additions & 3 deletions libraries/getitrack/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"loguru>=0.7",
"numpy>=2.0",
"pydantic>=2.7",
"loguru~=0.7",
"numpy~=2.0",
"pydantic~=2.7",
"pyyaml==6.0.3",
"scipy~=1.13",
]

[dependency-groups]
Expand Down
9 changes: 5 additions & 4 deletions libraries/getitrack/src/getitrack/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# SPDX-License-Identifier: Apache-2.0
"""getitrack: Multi-object tracking toolkit."""

from loguru import logger
from loguru import logger as _logger

# Stay silent until the application opts in via `logger.enable("getitrack")`
# or a tracker's `verbose` flag.
logger.disable("getitrack")
import getitrack.algorithms # noqa: F401 -> registers the bundled algorithms on import

# Silent by default; the application opts in with ``logger.enable("getitrack")``.
_logger.disable("getitrack")

__version__ = "0.1.0"
12 changes: 12 additions & 0 deletions libraries/getitrack/src/getitrack/algorithms/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
"""Concrete tracker algorithms.

Importing this package registers every algorithm into
`getitrack.core.registry.ALGORITHM_REGISTRY` as a side effect, so that
`BaseTracker.from_config` can dispatch by name without explicit wiring.
"""

from getitrack.algorithms.bytetrack import ByteTrackTracker

__all__ = ["ByteTrackTracker"]
289 changes: 289 additions & 0 deletions libraries/getitrack/src/getitrack/algorithms/bytetrack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
"""ByteTrack: two-stage detection-to-track association.

Tracks are held in one `_tracks` dict keyed by id, with lifecycle state on each
`Track` and Kalman state on the tracker in `_kalman_states`. Duplicate
suppression is class-aware when `match_class_only` is set.

Reference: Zhang et al., "ByteTrack: Multi-Object Tracking by Associating
Every Detection Box" (ECCV 2022).
"""

from __future__ import annotations

from dataclasses import replace
from typing import TYPE_CHECKING, ClassVar

import numpy as np

from getitrack.algorithms.configs.bytetrack import ByteTrackConfig
from getitrack.core.base import BaseTracker
from getitrack.core.detection import Detections, TrackedDetections
from getitrack.core.registry import register_algorithm
from getitrack.core.track import Track, TrackState
from getitrack.matching import fuse_score, iou_distance, linear_assignment
from getitrack.motion import KalmanFilter
from getitrack.utils import xyah_to_xyxy, xyxy_to_xyah

if TYPE_CHECKING:
from getitrack.config import LifecycleConfig


def _subset(dets: Detections, indices: list[int] | np.ndarray) -> Detections:
idx = np.asarray(indices, dtype=np.int64)
return Detections(
bboxes=dets.bboxes[idx],
scores=dets.scores[idx],
class_ids=dets.class_ids[idx],
frame_id=dets.frame_id,
embeddings=None if dets.embeddings is None else dets.embeddings[idx],
)


@register_algorithm("bytetrack", config=ByteTrackConfig)
class ByteTrackTracker(BaseTracker[ByteTrackConfig]):
"""ByteTrack multi-object tracker.

Two-stage association: confirmed tracks (ACTIVE + LOST) are matched
against high-score detections with score fusion, then unmatched ACTIVE
tracks are matched against low-score detections to recover from brief
mis-detections. TENTATIVE tracks compete for leftover high-score
detections; a remaining high-score detection spawns a new track once its
score clears the high/low split by 0.1. ``update`` returns the ACTIVE
tracks for the frame.
"""

algorithm_name: ClassVar[str] = "bytetrack"

_UNMATCHABLE_COST: ClassVar[np.float32] = np.nextafter(np.float32(1.0), np.float32(2.0))
# Cost limits for the second-stage and tentative association passes.
_SECOND_STAGE_COST_LIMIT: ClassVar[float] = 0.5
_TENTATIVE_COST_LIMIT: ClassVar[float] = 0.7
# IoU distance below which an ACTIVE and a LOST track are treated as duplicates.
_DUPLICATE_IOU_DIST: ClassVar[float] = 0.15

def __init__(self, config: ByteTrackConfig) -> None:
super().__init__(config)
self._kalman = KalmanFilter.from_config(config.motion)
self._tracks: dict[int, Track] = {}
self._kalman_states: dict[int, tuple[np.ndarray, np.ndarray]] = {}
self._first_frame_id: int | None = None
# track_id -> row index into this frame's input Detections.
self._frame_det_index: dict[int, int] = {}

def reset(self) -> None: # noqa: D102
super().reset()
self._tracks.clear()
self._kalman_states.clear()
self._first_frame_id = None
self._frame_det_index.clear()

def _update_impl(self, detections: Detections) -> TrackedDetections:
Comment thread
kprokofi marked this conversation as resolved.
"""Run one ByteTrack iteration and return the active set."""
self._frame_id = detections.frame_id
if self._first_frame_id is None:
self._first_frame_id = detections.frame_id
self._frame_det_index.clear()
cfg = self.config
lifecycle = cfg.lifecycle

# Partition detections by score: high (score > high_score_threshold) and
# low (score_threshold < score < high_score_threshold). Bounds are strict,
# so scores equal to a threshold fall outside both bands and are dropped.
scores = detections.scores
high_src = np.flatnonzero(scores > cfg.high_score_threshold)
low_src = np.flatnonzero((scores > cfg.score_threshold) & (scores < cfg.high_score_threshold))
high_dets = _subset(detections, high_src)
low_dets = _subset(detections, low_src)

self._predict_all()

tentative_ids = [tid for tid, t in self._tracks.items() if t.state == TrackState.TENTATIVE]
confirmed_ids = [tid for tid, t in self._tracks.items() if t.state in {TrackState.ACTIVE, TrackState.LOST}]

# First association: confirmed tracks (ACTIVE + LOST) vs high-score detections.
matches_a, unmatched_track_a, unmatched_det_a = self._associate(
confirmed_ids,
high_dets,
cfg.match_threshold,
apply_fuse_score=True,
)
matched_high_dets: set[int] = set()
for ti, di in matches_a:
self._apply_hit(confirmed_ids[ti], high_dets, di, lifecycle, src_index=int(high_src[di]))
matched_high_dets.add(di)

# Second association: unmatched ACTIVE tracks vs low-score detections (no fuse_score).
remaining_confirmed_ids = [
confirmed_ids[i] for i in unmatched_track_a if self._tracks[confirmed_ids[i]].state == TrackState.ACTIVE
]
matches_b, unmatched_track_b, _ = self._associate(
remaining_confirmed_ids,
low_dets,
cost_limit=self._SECOND_STAGE_COST_LIMIT,
apply_fuse_score=False,
)
for ti, di in matches_b:
self._apply_hit(remaining_confirmed_ids[ti], low_dets, di, lifecycle, src_index=int(low_src[di]))

missed_after_b = {remaining_confirmed_ids[i] for i in unmatched_track_b}
confirmed_ids_set_b = set(remaining_confirmed_ids)
for i in unmatched_track_a:
tid = confirmed_ids[i]
if tid not in confirmed_ids_set_b or tid in missed_after_b:
self._tracks[tid].mark_miss(lifecycle)

# TENTATIVE tracks compete for leftover high-score detections.
leftover_high_idx = [i for i in unmatched_det_a if i not in matched_high_dets]
matches_c, unmatched_track_c, unmatched_det_c = self._associate(
tentative_ids,
_subset(high_dets, leftover_high_idx),
self._TENTATIVE_COST_LIMIT,
apply_fuse_score=True,
)
for ti, di in matches_c:
real_di = leftover_high_idx[di]
self._apply_hit(tentative_ids[ti], high_dets, real_di, lifecycle, src_index=int(high_src[real_di]))
for i in unmatched_track_c:
tid = tentative_ids[i]
self._tracks[tid].mark_miss(lifecycle)

# A new track needs a score clear of the high/low split by the margin.
new_track_floor = cfg.new_track_floor
for di in unmatched_det_c:
real_di = leftover_high_idx[di]
if float(high_dets.scores[real_di]) >= new_track_floor:
self._spawn(high_dets, real_di, src_index=int(high_src[real_di]))

for tid in list(self._tracks):
if self._tracks[tid].should_remove:
del self._tracks[tid]
self._kalman_states.pop(tid, None)

self._remove_duplicate_tracks()
return self._compose_output(detections.frame_id)

def _predict_all(self) -> None:
if not self._kalman_states:
return
tids = list(self._kalman_states)
means = np.stack([self._kalman_states[tid][0] for tid in tids], axis=0)
covs = np.stack([self._kalman_states[tid][1] for tid in tids], axis=0)
# Lost tracks: zero out the height velocity so missing observations do not blow up the box.
for i, tid in enumerate(tids):
if self._tracks[tid].state != TrackState.ACTIVE:
means[i, 7] = 0.0
means, covs = self._kalman.multi_predict(means, covs)
for i, tid in enumerate(tids):
self._kalman_states[tid] = (means[i], covs[i])
self._tracks[tid].bbox = xyah_to_xyxy(means[i, :4][None, :])[0].astype(np.float32)

def _associate(
self,
track_ids: list[int],
dets: Detections,
cost_limit: float,
*,
apply_fuse_score: bool,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
if not track_ids or len(dets) == 0:
return (
np.empty((0, 2), dtype=np.int64),
np.arange(len(track_ids), dtype=np.int64),
np.arange(len(dets), dtype=np.int64),
)
track_boxes = np.stack([self._tracks[tid].bbox for tid in track_ids], axis=0)
cost = iou_distance(track_boxes, dets.bboxes)
cls_mismatch: np.ndarray | None = None
if self.config.match_class_only:
track_classes = np.array([self._tracks[tid].class_id for tid in track_ids])
cls_mismatch = track_classes[:, None] != dets.class_ids[None, :]
if apply_fuse_score:
cost = fuse_score(cost, dets.scores)
if cls_mismatch is not None:
cost[cls_mismatch] = self._UNMATCHABLE_COST
return linear_assignment(cost, cost_limit)

def _apply_hit(
self,
track_id: int,
dets: Detections,
det_idx: int,
lifecycle: LifecycleConfig,
*,
src_index: int,
) -> None:
track = self._tracks[track_id]
bbox = dets.bboxes[det_idx]
score = float(dets.scores[det_idx])
track.mark_hit(bbox, score, lifecycle)
self._frame_det_index[track_id] = src_index
measurement = xyxy_to_xyah(bbox[None, :])[0]
mean, covariance = self._kalman_states[track_id]
self._kalman_states[track_id] = self._kalman.update(mean, covariance, measurement)

def _spawn(self, dets: Detections, det_idx: int, *, src_index: int) -> None:
track_id = self._allocate_id()
bbox = dets.bboxes[det_idx].astype(np.float32)
class_id = int(dets.class_ids[det_idx])
score = float(dets.scores[det_idx])
# Tracks activate immediately when min_hits <= 1 or on the first frame.
skip_tentative = self.config.lifecycle.min_hits <= 1 or dets.frame_id == self._first_frame_id
initial_state = TrackState.ACTIVE if skip_tentative else TrackState.TENTATIVE
track = Track(
track_id=track_id,
class_id=class_id,
bbox=bbox,
score=score,
state=initial_state,
_start_frame=dets.frame_id,
)
self._tracks[track_id] = track
self._frame_det_index[track_id] = src_index
measurement = xyxy_to_xyah(bbox[None, :])[0]
self._kalman_states[track_id] = self._kalman.initiate(measurement)

def _remove_duplicate_tracks(self) -> None:
"""Drop the younger of any ACTIVE/LOST track pair with near-identical boxes.

The longer-lived track is kept. Cross-class pairs are skipped when
``match_class_only`` is set, since they are distinct objects.
"""
active = [tid for tid, t in self._tracks.items() if t.state == TrackState.ACTIVE]
lost = [tid for tid, t in self._tracks.items() if t.state == TrackState.LOST]
if not active or not lost:
return
dist = iou_distance(
np.stack([self._tracks[tid].bbox for tid in active], axis=0),
np.stack([self._tracks[tid].bbox for tid in lost], axis=0),
)
# Collect duplicates first, then remove, so a track shared across
# several pairs is not popped mid-iteration.
drop_ids: set[int] = set()
for ai, li in np.argwhere(dist < self._DUPLICATE_IOU_DIST):
a_track, l_track = self._tracks[active[ai]], self._tracks[lost[li]]
if self.config.match_class_only and a_track.class_id != l_track.class_id:
continue
# Reference tie-break: keep the strictly longer-lived track, so an
# equal-age pair drops the active-side track.
drop_ids.add(lost[li] if a_track.age > l_track.age else active[ai])
for tid in drop_ids:
self._tracks.pop(tid, None)
self._kalman_states.pop(tid, None)

def _compose_output(self, frame_id: int) -> TrackedDetections:
# Output is ACTIVE tracks only; LOST tracks coast internally.
active = [t for t in self._tracks.values() if t.state == TrackState.ACTIVE]
if not active:
empty = TrackedDetections.create_empty(frame_id=frame_id)
return replace(empty, det_indices=np.empty((0,), dtype=np.int64))
return TrackedDetections(
bboxes=np.stack([t.bbox for t in active], axis=0).astype(np.float32),
scores=np.array([t.score for t in active], dtype=np.float32),
class_ids=np.array([t.class_id for t in active], dtype=np.int64),
track_ids=np.array([t.track_id for t in active], dtype=np.int64),
track_states=np.array([int(t.state) for t in active], dtype=np.int8),
frame_id=frame_id,
det_indices=np.array([self._frame_det_index.get(t.track_id, -1) for t in active], dtype=np.int64),
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
"""Per-tracker configuration models."""
Loading
Loading