This commit is contained in:
agomizi1cm committed 2026-08-09 21:41:44 +09:00
commit c5bcb95d74
172 files changed
+9487

No files matched your search

+209
View File
@@ -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()