"""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()