210 lines
10 KiB
Python
210 lines
10 KiB
Python
"""Research-oriented lifecycle management for tracked people."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import deque
|
|
|
|
from .config_loader import CrossingSettings, TrackManagementSettings
|
|
from .crossing_detector import CrossingDetector
|
|
from .models import FrameInfo, PixelRegion, PixelTriggerLine, TrackEvent, TrackPoint, TrackedPerson, TrackState
|
|
|
|
|
|
class TrackManager:
|
|
"""Accumulate trajectories, events, counters, and finalized summaries."""
|
|
|
|
def __init__(
|
|
self, config: TrackManagementSettings, evaluation_region: PixelRegion | None,
|
|
trigger_line: PixelTriggerLine | None, crossing_config: CrossingSettings, max_missed_frames: int = 30,
|
|
) -> None:
|
|
self.config = config
|
|
self.evaluation_region = evaluation_region
|
|
self.crossing = CrossingDetector(crossing_config, trigger_line)
|
|
self.max_missed_frames = max_missed_frames
|
|
self._active: dict[int, TrackState] = {}
|
|
self._finalized: dict[int, TrackState] = {}
|
|
self._unique_confirmed_ids: set[int] = set()
|
|
|
|
def update(self, tracked_persons: list[TrackedPerson], frame_info: FrameInfo) -> list[TrackEvent]:
|
|
events: list[TrackEvent] = []
|
|
visible_ids = {person.track_id for person in tracked_persons}
|
|
for state in self._active.values():
|
|
if state.track_id not in visible_ids:
|
|
state.total_missed_frames += 1
|
|
missed = frame_info.frame_number - state.last_seen_frame
|
|
state.max_missed_frames = max(state.max_missed_frames, missed)
|
|
for person in tracked_persons:
|
|
events.extend(self._update_person(person, frame_info))
|
|
return events
|
|
|
|
def _update_person(self, person: TrackedPerson, info: FrameInfo) -> list[TrackEvent]:
|
|
events: list[TrackEvent] = []
|
|
inside = self.evaluation_region.contains(round(person.foot_x), round(person.foot_y)) if self.evaluation_region else False
|
|
point = TrackPoint(
|
|
timestamp_monotonic=info.elapsed_time_sec, timestamp_iso=info.timestamp_iso, frame_number=info.frame_number,
|
|
center_x=person.center_x, center_y=person.center_y, foot_x=person.foot_x, foot_y=person.foot_y, bbox=person.bbox,
|
|
)
|
|
state = self._active.get(person.track_id)
|
|
if state is None:
|
|
state = TrackState(
|
|
track_id=person.track_id, first_seen_time=info.timestamp_iso, last_seen_time=info.timestamp_iso,
|
|
first_seen_frame=info.frame_number, last_seen_frame=info.frame_number,
|
|
trajectory=deque(maxlen=self.config.trajectory_max_points), first_seen_monotonic=info.elapsed_time_sec,
|
|
last_seen_monotonic=info.elapsed_time_sec, current_inside_region=inside,
|
|
)
|
|
self._active[person.track_id] = state
|
|
events.append(self._event("track_created", state, info, person.foot_x, person.foot_y))
|
|
if inside:
|
|
self._mark_entry(state, info)
|
|
events.append(self._event("region_entered", state, info, person.foot_x, person.foot_y))
|
|
state.last_seen_time = info.timestamp_iso
|
|
state.last_seen_frame = info.frame_number
|
|
state.last_seen_monotonic = info.elapsed_time_sec
|
|
state.total_visible_frames += 1
|
|
state.is_confirmed = state.is_confirmed or person.is_confirmed
|
|
if state.is_confirmed:
|
|
self._unique_confirmed_ids.add(state.track_id)
|
|
state.trajectory.append(point)
|
|
if len(state.trajectory) > 1:
|
|
events.extend(self._update_region(state, inside, info, person))
|
|
state.movement_direction = self._resolved_direction(state)
|
|
crossing_direction = self.crossing.detect(state.track_id, list(state.trajectory), state.is_confirmed)
|
|
if crossing_direction is not None and not state.trigger_crossed:
|
|
state.trigger_crossed = True
|
|
state.trigger_crossing_time = info.timestamp_iso
|
|
state.trigger_crossing_frame = info.frame_number
|
|
state.trigger_crossing_direction = crossing_direction
|
|
state.movement_direction = crossing_direction
|
|
events.append(self._event("trigger_crossed", state, info, person.foot_x, person.foot_y, crossing_direction))
|
|
return events
|
|
|
|
def _update_region(self, state: TrackState, inside: bool, info: FrameInfo, person: TrackedPerson) -> list[TrackEvent]:
|
|
events: list[TrackEvent] = []
|
|
if inside:
|
|
state.pending_exit_frames = 0
|
|
if not state.current_inside_region:
|
|
state.pending_entry_frames += 1
|
|
if state.pending_entry_frames >= self.config.region_entry_confirm_frames:
|
|
state.current_inside_region = True
|
|
state.pending_entry_frames = 0
|
|
if not state.entered_evaluation_region:
|
|
self._mark_entry(state, info)
|
|
events.append(self._event("region_entered", state, info, person.foot_x, person.foot_y))
|
|
else:
|
|
state.pending_entry_frames = 0
|
|
if state.current_inside_region:
|
|
state.pending_exit_frames += 1
|
|
if state.pending_exit_frames >= self.config.region_exit_confirm_frames:
|
|
state.current_inside_region = False
|
|
state.pending_exit_frames = 0
|
|
if not state.exited_evaluation_region:
|
|
state.exited_evaluation_region = True
|
|
state.exit_time = info.timestamp_iso
|
|
state.exit_frame = info.frame_number
|
|
state.exit_monotonic = info.elapsed_time_sec
|
|
events.append(self._event("region_exited", state, info, person.foot_x, person.foot_y))
|
|
return events
|
|
|
|
@staticmethod
|
|
def _mark_entry(state: TrackState, info: FrameInfo) -> None:
|
|
state.entered_evaluation_region = True
|
|
state.entry_time = info.timestamp_iso
|
|
state.entry_frame = info.frame_number
|
|
state.entry_monotonic = info.elapsed_time_sec
|
|
|
|
def _movement_direction(self, state: TrackState) -> str:
|
|
points = list(state.trajectory)
|
|
if len(points) < 2:
|
|
return "unknown"
|
|
start = points[max(0, len(points) - self.config.direction_window_points)]
|
|
end = points[-1]
|
|
dx, dy = end.foot_x - start.foot_x, end.foot_y - start.foot_y
|
|
if max(abs(dx), abs(dy)) < self.config.minimum_direction_displacement_pixels:
|
|
return "stationary"
|
|
if abs(dx) >= abs(dy):
|
|
return "left_to_right" if dx > 0 else "right_to_left"
|
|
return "top_to_bottom" if dy > 0 else "bottom_to_top"
|
|
|
|
def _resolved_direction(self, state: TrackState) -> str:
|
|
"""Prefer an observed line-crossing direction over trajectory estimation."""
|
|
if state.trigger_crossed and state.trigger_crossing_direction:
|
|
return state.trigger_crossing_direction
|
|
return self._movement_direction(state)
|
|
|
|
def finalize_stale_tracks(self, frame_info: FrameInfo) -> list[TrackEvent]:
|
|
stale = [state for state in self._active.values() if frame_info.frame_number - state.last_seen_frame > self.max_missed_frames]
|
|
events: list[TrackEvent] = []
|
|
for state in stale:
|
|
point = state.trajectory[-1]
|
|
events.append(self._event("track_lost", state, frame_info, point.foot_x, point.foot_y))
|
|
events.append(self._finalize(state, frame_info))
|
|
return events
|
|
|
|
def finalize_all(self, frame_info: FrameInfo) -> list[TrackEvent]:
|
|
return [self._finalize(state, frame_info) for state in list(self._active.values())]
|
|
|
|
def _finalize(self, state: TrackState, info: FrameInfo) -> TrackEvent:
|
|
state.finalized = True
|
|
state.movement_direction = self._resolved_direction(state)
|
|
state.valid_for_counting, state.invalid_reason = self._counting_validity(state)
|
|
self._active.pop(state.track_id, None)
|
|
self._finalized[state.track_id] = state
|
|
point = state.trajectory[-1]
|
|
return self._event("track_finalized", state, info, point.foot_x, point.foot_y, state.movement_direction,
|
|
{"valid_for_counting": state.valid_for_counting, "invalid_reason": state.invalid_reason})
|
|
|
|
def _counting_validity(self, state: TrackState) -> tuple[bool, str]:
|
|
if not state.is_confirmed:
|
|
return False, "unconfirmed"
|
|
if state.total_visible_frames < self.config.minimum_visible_frames_for_count:
|
|
return False, "insufficient_visible_frames"
|
|
if state.duration_sec < self.config.minimum_duration_sec_for_count:
|
|
return False, "insufficient_duration"
|
|
if self.config.require_region_entry_for_count and not state.entered_evaluation_region:
|
|
return False, "no_region_entry"
|
|
if self.config.require_trigger_crossing_for_count and not state.trigger_crossed:
|
|
return False, "no_trigger_crossing"
|
|
return True, ""
|
|
|
|
@staticmethod
|
|
def _event(
|
|
event_type: str, state: TrackState, info: FrameInfo, x: float, y: float,
|
|
direction: str | None = None, metadata: dict[str, object] | None = None,
|
|
) -> TrackEvent:
|
|
return TrackEvent(event_type, state.track_id, info.frame_number, info.timestamp_iso, info.elapsed_time_sec,
|
|
x, y, direction, dict(metadata or {}))
|
|
|
|
def get_active_states(self) -> list[TrackState]:
|
|
return list(self._active.values())
|
|
|
|
def get_finalized_states(self) -> list[TrackState]:
|
|
return list(self._finalized.values())
|
|
|
|
def get_state(self, track_id: int) -> TrackState | None:
|
|
return self._active.get(track_id) or self._finalized.get(track_id)
|
|
|
|
@property
|
|
def active_track_count(self) -> int:
|
|
return sum(state.is_confirmed for state in self._active.values())
|
|
|
|
@property
|
|
def total_unique_tracks(self) -> int:
|
|
return len(self._unique_confirmed_ids)
|
|
|
|
@property
|
|
def region_visitors(self) -> int:
|
|
return sum(state.is_confirmed and state.entered_evaluation_region for state in (*self._active.values(), *self._finalized.values()))
|
|
|
|
@property
|
|
def trigger_crossings(self) -> int:
|
|
return sum(state.is_confirmed and state.trigger_crossed for state in (*self._active.values(), *self._finalized.values()))
|
|
|
|
@property
|
|
def completed_passers(self) -> int:
|
|
return sum(state.valid_for_counting for state in self._finalized.values())
|
|
|
|
def reset(self) -> None:
|
|
self._active.clear()
|
|
self._finalized.clear()
|
|
self._unique_confirmed_ids.clear()
|
|
self.crossing.reset()
|