Phase 11
This commit is contained in:
commit
c5bcb95d74
172 files changed
+9487
No files matched your search
@@ -0,0 +1,209 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user