///|
priv enum Lifecycle {
Active
Lost
}
///|
priv struct TrackedState {
track : Track
motion : MotionState
lifecycle : Lifecycle
}
///|
/// Stateful online association for one ordered frame stream.
struct Tracker {
config : TrackerConfig
mut states : Array[TrackedState]
mut next_id : Int
mut last_frame : Int?
}
///|
/// Creates an empty tracker with a validated configuration.
pub fn Tracker::new(config : TrackerConfig) -> Tracker {
{ config, states: [], next_id: 1, last_frame: None, }
}
///|
fn TrackedState::active(self : TrackedState) -> Bool {
self.lifecycle is Active
}
///|
fn TrackedState::confirmed(self : TrackedState, config : TrackerConfig) -> Bool {
self.track.hits >= config.min_hits
}
///|
fn TrackedState::predict(self : TrackedState) -> TrackedState {
{
track: self.track,
motion: self.motion.predict(self.lifecycle is Lost),
lifecycle: self.lifecycle,
}
}
///|
fn TrackedState::mark_lost(self : TrackedState) -> TrackedState {
{ track: self.track, motion: self.motion, lifecycle: Lost, }
}
///|
fn TrackedState::observe(
self : TrackedState,
detection : Detection,
frame_id : Int,
) -> TrackedState {
{
track: {
track_id: self.track.track_id,
bbox: detection.bbox,
class_id: self.track.class_id,
score: detection.score,
first_frame: self.track.first_frame,
last_frame: frame_id,
hits: self.track.hits + 1,
},
motion: self.motion.correct(detection.bbox),
lifecycle: Active,
}
}
///|
fn TrackedState::expired(
self : TrackedState,
frame_id : Int64,
config : TrackerConfig,
) -> Bool {
frame_id - self.track.last_frame.to_int64() >
config.max_lost_frames.to_int64()
}
///|
fn association_cost(
state : TrackedState,
detection : Detection,
fuse_score : Bool,
) -> Double {
if state.track.class_id != detection.class_id {
return 1.0e300
}
guard state.motion.bbox() is Some(predicted_bbox) else { return 1.0e300 }
let overlap = predicted_bbox.iou(detection.bbox)
if fuse_score {
1.0 - overlap * detection.score
} else {
1.0 - overlap
}
}
///|
fn validate_detections(
detections : Array[Detection],
) -> Unit raise TrackerError {
for index, detection in detections {
if !detection.valid() {
raise InvalidDetection(index~)
}
}
}
///|
fn advance_silent_frame(
states : Array[TrackedState],
frame_id : Int64,
config : TrackerConfig,
) -> Array[TrackedState] {
let retained : Array[TrackedState] = []
for state in states {
let predicted = state.predict()
let transitioned = if predicted.active() {
if predicted.confirmed(config) {
Some(predicted.mark_lost())
} else {
None
}
} else {
Some(predicted)
}
match transitioned {
Some(candidate) if !candidate.expired(frame_id, config) =>
retained.push(candidate)
_ => ()
}
}
retained
}
///|
fn advance_skipped_frames(
states : Array[TrackedState],
previous_frame : Int,
frame_id : Int,
config : TrackerConfig,
) -> Array[TrackedState] {
let previous = previous_frame.to_int64()
let current = frame_id.to_int64()
let silent_count = current - previous - 1L
if silent_count <= 0L {
return states
}
if silent_count > config.max_lost_frames.to_int64() {
return []
}
let mut advanced = states
let mut skipped = previous + 1L
while skipped < current {
advanced = advance_silent_frame(advanced, skipped, config)
skipped += 1L
}
advanced
}
///|
fn new_state(
track_id : Int,
detection : Detection,
frame_id : Int,
) -> TrackedState {
{
track: {
track_id,
bbox: detection.bbox,
class_id: detection.class_id,
score: detection.score,
first_frame: frame_id,
last_frame: frame_id,
hits: 1,
},
motion: MotionState::init(detection.bbox),
lifecycle: Active,
}
}
///|
/// Associates one strictly newer frame and returns its visible identities.
///
/// The entire frame is validated before tracker state changes.
pub fn Tracker::update(
self : Tracker,
frame_id : Int,
detections : Array[Detection],
) -> FrameTracks raise TrackerError {
match self.last_frame {
Some(previous) if frame_id <= previous =>
raise NonIncreasingFrame(previous~, received=frame_id)
_ => ()
}
validate_detections(detections)
let working = match self.last_frame {
Some(previous) =>
advance_skipped_frames(self.states, previous, frame_id, self.config)
None => self.states
}
let predicted = working.map(state => state.predict())
predicted.sort_by((left, right) => {
left.track.track_id.compare(right.track.track_id)
})
let high : Array[Detection] = []
let low : Array[Detection] = []
for detection in detections {
if detection.score >= self.config.high_score_threshold {
high.push(detection)
} else if detection.score >= self.config.low_score_threshold {
low.push(detection)
}
}
let first_costs = predicted.map(state => {
high.map(detection => {
association_cost(state, detection, self.config.fuse_score)
})
})
let first_matches = optimal_matches(
first_costs,
self.config.first_match_max_cost,
)
let state_matched = Array::make(predicted.length(), false)
let high_matched = Array::make(high.length(), false)
let active_states : Array[TrackedState] = []
for pair in first_matches {
let updated = predicted[pair.track_index].observe(
high[pair.detection_index],
frame_id,
)
state_matched[pair.track_index] = true
high_matched[pair.detection_index] = true
active_states.push(updated)
}
let low_pool : Array[TrackedState] = []
let pending_lost : Array[TrackedState] = []
let removed : Array[Int] = []
for index, state in predicted {
if !state_matched[index] {
if state.active() {
if state.confirmed(self.config) {
low_pool.push(state)
} else {
removed.push(state.track.track_id)
}
} else {
pending_lost.push(state)
}
}
}
let second_costs = low_pool.map(state => {
low.map(detection => association_cost(state, detection, false))
})
let second_matches = optimal_matches(
second_costs,
self.config.second_match_max_cost,
)
let low_state_matched = Array::make(low_pool.length(), false)
for pair in second_matches {
let updated = low_pool[pair.track_index].observe(
low[pair.detection_index],
frame_id,
)
low_state_matched[pair.track_index] = true
active_states.push(updated)
}
let newly_lost : Array[TrackedState] = []
for index, state in low_pool {
if !low_state_matched[index] {
let lost_state = state.mark_lost()
pending_lost.push(lost_state)
newly_lost.push(lost_state)
}
}
let mut next_id = self.next_id
for index, detection in high {
if !high_matched[index] &&
detection.score >= self.config.new_track_threshold {
active_states.push(new_state(next_id, detection, frame_id))
next_id += 1
}
}
let retained_lost : Array[TrackedState] = []
let lost : Array[Int] = []
for state in pending_lost {
if state.expired(frame_id.to_int64(), self.config) {
removed.push(state.track.track_id)
} else {
retained_lost.push(state)
}
}
for state in newly_lost {
if !state.expired(frame_id.to_int64(), self.config) {
lost.push(state.track.track_id)
}
}
let next_states = active_states.copy()
next_states.push_iter(retained_lost.iter())
next_states.sort_by((left, right) => {
left.track.track_id.compare(right.track.track_id)
})
let visible : Array[Track] = []
for state in active_states {
if state.confirmed(self.config) {
visible.push(state.track)
}
}
visible.sort_by((left, right) => left.track_id.compare(right.track_id))
lost.sort()
removed.sort()
self.states = next_states
self.next_id = next_id
self.last_frame = Some(frame_id)
{ tracks: visible, lost, removed, }
}
///|
/// Clears every identity and restarts numbering from 1.
pub fn Tracker::reset(self : Tracker) -> Unit {
self.states = []
self.next_id = 1
self.last_frame = None
}
///|
/// Summarizes retained identity state without exposing motion internals.
pub fn Tracker::status(self : Tracker) -> TrackerStatus {
let mut active_count = 0
let mut tentative_count = 0
let mut lost_count = 0
for state in self.states {
if state.active() {
if state.confirmed(self.config) {
active_count += 1
} else {
tentative_count += 1
}
} else {
lost_count += 1
}
}
{ last_frame: self.last_frame, active_count, tentative_count, lost_count, }
}