Spaces:
Sleeping
Sleeping
| """Traffic analytics engine. | |
| Responsibilities: | |
| - Vehicle counting when tracks cross named counting lines | |
| - Speed estimation from pixel displacement Γ calibration factor | |
| - Per-class breakdown | |
| - Anomaly detection (count spikes, stopped vehicles) | |
| - Confidence drift monitoring | |
| """ | |
| from __future__ import annotations | |
| import time | |
| from collections import defaultdict, deque | |
| from dataclasses import dataclass, field | |
| from typing import Any | |
| import numpy as np | |
| from core.tracker import Track | |
| # ββ Data structures βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CountingLineSpec: | |
| """Specification for a named counting line.""" | |
| name: str | |
| p1: tuple[int, int] | |
| p2: tuple[int, int] | |
| class SpeedSample: | |
| track_id: int | |
| speed_kmh: float | |
| class_id: int | |
| timestamp: float | |
| class FrameMetrics: | |
| """Analytics snapshot for one frame β sent over WebSocket.""" | |
| timestamp: float | |
| frame_index: int | |
| # Counts | |
| total_count: int # vehicles counted crossing all lines (summed) | |
| count_per_class: dict[str, int] # cumulative per class (from first line) | |
| count_per_line: dict[str, int] # cumulative count per named line | |
| vehicles_in_frame: int # currently visible tracks | |
| # Speed | |
| avg_speed_kmh: float # rolling average over last N samples | |
| speed_samples: list[float] # recent speed readings (last 10) | |
| # Alerts | |
| alerts: list[str] | |
| # Raw tracks for frontend overlay | |
| tracks: list[dict[str, Any]] | |
| class Alert: | |
| message: str | |
| severity: str # "info" | "warning" | "critical" | |
| timestamp: float = field(default_factory=time.time) | |
| # ββ Counting line βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CountingLine: | |
| """A line segment that counts vehicles crossing it. | |
| The line is defined by two points (x1,y1)β(x2,y2) in pixel coords. | |
| A crossing is detected when a track's centre moves from one side to the other. | |
| """ | |
| def __init__(self, p1: tuple[int, int], p2: tuple[int, int]) -> None: | |
| self.p1 = np.array(p1, dtype=float) | |
| self.p2 = np.array(p2, dtype=float) | |
| self._prev_sides: dict[int, float] = {} | |
| self.total_count: int = 0 | |
| self.count_per_class: dict[str, int] = defaultdict(int) | |
| def update(self, tracks: list[Track]) -> list[Track]: | |
| """Check each track for a crossing. Returns newly-crossed tracks.""" | |
| crossed = [] | |
| current_ids = {t.track_id for t in tracks} | |
| for track in tracks: | |
| cx, cy = track.center | |
| side = self._side(cx, cy) | |
| prev = self._prev_sides.get(track.track_id) | |
| if prev is not None and prev != 0 and side != 0 and prev != side: | |
| self.total_count += 1 | |
| self.count_per_class[track.class_name] += 1 | |
| crossed.append(track) | |
| if side != 0: | |
| self._prev_sides[track.track_id] = side | |
| # Clean up lost tracks | |
| for tid in list(self._prev_sides): | |
| if tid not in current_ids: | |
| del self._prev_sides[tid] | |
| return crossed | |
| def _side(self, px: float, py: float) -> float: | |
| dx = self.p2[0] - self.p1[0] | |
| dy = self.p2[1] - self.p1[1] | |
| return float(np.sign(dx * (py - self.p1[1]) - dy * (px - self.p1[0]))) | |
| # ββ Speed estimator ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class SpeedEstimator: | |
| """Estimates speed from pixel displacement between frames.""" | |
| def __init__( | |
| self, | |
| pixels_per_meter: float = 10.0, | |
| fps: float = 25.0, | |
| smoothing_window: int = 5, | |
| ) -> None: | |
| self.pixels_per_meter = pixels_per_meter | |
| self.fps = fps | |
| self.smoothing_window = smoothing_window | |
| self._history: dict[int, deque[tuple[float, float]]] = defaultdict( | |
| lambda: deque(maxlen=smoothing_window) | |
| ) | |
| def update(self, tracks: list[Track]) -> list[SpeedSample]: | |
| samples = [] | |
| current_ids = {t.track_id for t in tracks} | |
| for track in tracks: | |
| hist = self._history[track.track_id] | |
| hist.append(track.center) | |
| if len(hist) >= 2: | |
| positions = list(hist) | |
| total_px = sum( | |
| np.linalg.norm(np.array(positions[i + 1]) - np.array(positions[i])) | |
| for i in range(len(positions) - 1) | |
| ) | |
| px_per_frame = total_px / (len(positions) - 1) | |
| speed_kmh = (px_per_frame / self.pixels_per_meter) * self.fps * 3.6 | |
| samples.append( | |
| SpeedSample( | |
| track_id=track.track_id, | |
| speed_kmh=round(speed_kmh, 1), | |
| class_id=track.class_id, | |
| timestamp=time.time(), | |
| ) | |
| ) | |
| for tid in list(self._history): | |
| if tid not in current_ids: | |
| del self._history[tid] | |
| return samples | |
| # ββ Anomaly detector ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AnomalyDetector: | |
| """Threshold-based anomaly detection. | |
| - Count spike: current vehicle count > mean + 2Ο of rolling window | |
| - Stopped vehicle: track barely moves for N consecutive frames | |
| """ | |
| SPIKE_WINDOW = 60 | |
| SPIKE_Z_THRESHOLD = 2.0 | |
| STOPPED_MIN_FRAMES = 30 | |
| STOPPED_PX_THRESHOLD = 5.0 | |
| def __init__(self) -> None: | |
| self._count_history: deque[int] = deque(maxlen=self.SPIKE_WINDOW) | |
| self._stationary_frames: dict[int, int] = defaultdict(int) | |
| self._last_centers: dict[int, tuple[float, float]] = {} | |
| def update(self, tracks: list[Track]) -> list[Alert]: | |
| alerts: list[Alert] = [] | |
| current_count = len(tracks) | |
| current_ids = {t.track_id for t in tracks} | |
| # Count spike | |
| self._count_history.append(current_count) | |
| if len(self._count_history) == self.SPIKE_WINDOW: | |
| arr = np.array(self._count_history) | |
| mean, std = arr.mean(), arr.std() | |
| if std > 0 and current_count > mean + self.SPIKE_Z_THRESHOLD * std: | |
| alerts.append(Alert( | |
| message=( | |
| f"Traffic surge: {current_count} vehicles " | |
| f"(mean={mean:.1f}, +{self.SPIKE_Z_THRESHOLD}Ο)" | |
| ), | |
| severity="warning", | |
| )) | |
| # Stopped vehicles | |
| for track in tracks: | |
| cx, cy = track.center | |
| last = self._last_centers.get(track.track_id) | |
| if last is not None: | |
| dist = np.linalg.norm(np.array([cx, cy]) - np.array(last)) | |
| if dist < self.STOPPED_PX_THRESHOLD: | |
| self._stationary_frames[track.track_id] += 1 | |
| else: | |
| self._stationary_frames[track.track_id] = 0 | |
| self._last_centers[track.track_id] = (cx, cy) | |
| if self._stationary_frames[track.track_id] == self.STOPPED_MIN_FRAMES: | |
| alerts.append(Alert( | |
| message=f"Stopped vehicle: ID {track.track_id} ({track.class_name})", | |
| severity="info", | |
| )) | |
| for tid in list(self._stationary_frames): | |
| if tid not in current_ids: | |
| del self._stationary_frames[tid] | |
| self._last_centers.pop(tid, None) | |
| return alerts | |
| # ββ Drift monitor βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class DriftMonitor: | |
| """Monitors detection confidence for model drift. | |
| Establishes a baseline mean confidence over the first BASELINE_FRAMES frames, | |
| then alerts when the rolling average drops more than THRESHOLD below baseline. | |
| Useful for catching model degradation in production. | |
| """ | |
| BASELINE_FRAMES = 100 # frames to collect before locking in baseline | |
| WINDOW = 30 # rolling window for current mean | |
| THRESHOLD = 0.15 # relative drop fraction that triggers an alert | |
| ALERT_COOLDOWN = 150 # minimum frames between repeated drift alerts | |
| def __init__(self) -> None: | |
| self._baseline_samples: list[float] = [] | |
| self._baseline: float | None = None | |
| self._window: deque[float] = deque(maxlen=self.WINDOW) | |
| self._frames_since_alert: int = self.ALERT_COOLDOWN | |
| def update(self, tracks: list[Track]) -> list[Alert]: | |
| if not tracks: | |
| return [] | |
| avg_conf = sum(t.confidence for t in tracks) / len(tracks) | |
| if self._baseline is None: | |
| self._baseline_samples.append(avg_conf) | |
| if len(self._baseline_samples) >= self.BASELINE_FRAMES: | |
| self._baseline = sum(self._baseline_samples) / len(self._baseline_samples) | |
| print(f"[DriftMonitor] Baseline confidence locked: {self._baseline:.3f}") | |
| return [] | |
| self._window.append(avg_conf) | |
| self._frames_since_alert += 1 | |
| if len(self._window) < self.WINDOW: | |
| return [] | |
| rolling_mean = sum(self._window) / len(self._window) | |
| threshold = self._baseline * (1.0 - self.THRESHOLD) | |
| if rolling_mean < threshold and self._frames_since_alert >= self.ALERT_COOLDOWN: | |
| drop_pct = (self._baseline - rolling_mean) / self._baseline * 100 | |
| self._frames_since_alert = 0 | |
| return [Alert( | |
| message=( | |
| f"Confidence drift: rolling avg {rolling_mean:.2f} " | |
| f"vs baseline {self._baseline:.2f} ({drop_pct:.0f}% drop)" | |
| ), | |
| severity="warning", | |
| )] | |
| return [] | |
| # ββ Main analytics engine ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class AnalyticsEngine: | |
| """Orchestrates counting, speed, anomaly detection, and drift monitoring. | |
| Usage:: | |
| engine = AnalyticsEngine( | |
| counting_lines=[ | |
| CountingLineSpec("north", (640, 0), (640, 360)), | |
| CountingLineSpec("south", (640, 360), (640, 720)), | |
| ] | |
| ) | |
| metrics = engine.update(tracks) | |
| """ | |
| SPEED_HISTORY_LEN = 50 | |
| def __init__( | |
| self, | |
| counting_lines: list[CountingLineSpec] | None = None, | |
| pixels_per_meter: float = 10.0, | |
| fps: float = 25.0, | |
| ) -> None: | |
| # Build named counting lines | |
| self._lines: list[tuple[str, CountingLine]] = [] | |
| for spec in (counting_lines or []): | |
| self._lines.append((spec.name, CountingLine(spec.p1, spec.p2))) | |
| self.speed_estimator = SpeedEstimator(pixels_per_meter=pixels_per_meter, fps=fps) | |
| self.anomaly_detector = AnomalyDetector() | |
| self.drift_monitor = DriftMonitor() | |
| self._speed_history: deque[float] = deque(maxlen=self.SPEED_HISTORY_LEN) | |
| self._recent_alerts: deque[Alert] = deque(maxlen=20) | |
| self._frame_index: int = 0 | |
| def update(self, tracks: list[Track]) -> FrameMetrics: | |
| """Process one frame's tracks and return an analytics snapshot.""" | |
| # ββ Multi-line counting βββββββββββββββββββββββββββββββββββββββββββββββ | |
| count_per_line: dict[str, int] = {} | |
| total_count = 0 | |
| count_per_class: dict[str, int] = {} | |
| for name, line in self._lines: | |
| line.update(tracks) | |
| count_per_line[name] = line.total_count | |
| total_count += line.total_count | |
| # Use the first line's per-class breakdown for backward compat | |
| if self._lines: | |
| count_per_class = dict(self._lines[0][1].count_per_class) | |
| # ββ Speed βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| speed_samples = self.speed_estimator.update(tracks) | |
| for s in speed_samples: | |
| if s.speed_kmh < 200: | |
| self._speed_history.append(s.speed_kmh) | |
| avg_speed = float(np.mean(list(self._speed_history))) if self._speed_history else 0.0 | |
| recent_speeds = [s.speed_kmh for s in speed_samples[-10:]] | |
| # ββ Anomaly + drift βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| new_alerts: list[Alert] = [] | |
| new_alerts.extend(self.anomaly_detector.update(tracks)) | |
| new_alerts.extend(self.drift_monitor.update(tracks)) | |
| self._recent_alerts.extend(new_alerts) | |
| alert_strings = [ | |
| f"[{a.severity.upper()}] {a.message}" | |
| for a in list(self._recent_alerts)[-5:] | |
| ] | |
| # ββ Serialise tracks ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| speed_map = {s.track_id: s.speed_kmh for s in speed_samples} | |
| tracks_data = [ | |
| { | |
| "id": t.track_id, | |
| "bbox": list(t.bbox), | |
| "class_id": t.class_id, | |
| "class_name": t.class_name, | |
| "confidence": round(t.confidence, 3), | |
| "speed_kmh": speed_map.get(t.track_id), | |
| } | |
| for t in tracks | |
| ] | |
| self._frame_index += 1 | |
| return FrameMetrics( | |
| timestamp=time.time(), | |
| frame_index=self._frame_index, | |
| total_count=total_count, | |
| count_per_class=count_per_class, | |
| count_per_line=count_per_line, | |
| vehicles_in_frame=len(tracks), | |
| avg_speed_kmh=round(avg_speed, 1), | |
| speed_samples=recent_speeds, | |
| alerts=alert_strings, | |
| tracks=tracks_data, | |
| ) | |
| def cumulative_counts(self) -> dict[str, int]: | |
| if self._lines: | |
| return dict(self._lines[0][1].count_per_class) | |
| return {} | |
| def line_specs(self) -> list[CountingLineSpec]: | |
| """Return the counting line specs for serialisation.""" | |
| out = [] | |
| for name, line in self._lines: | |
| out.append(CountingLineSpec( | |
| name=name, | |
| p1=(int(line.p1[0]), int(line.p1[1])), | |
| p2=(int(line.p2[0]), int(line.p2[1])), | |
| )) | |
| return out | |