"""多帧累积分析器 (MVP-3 / D26-D27) 针对同一目标在时间窗口内的连续帧候选事件进行累积, 为 ``LLMTrigger`` 提供"是否值得调用 LLM 二次判断"的决策依据。 设计要点: 1. 以 ``(source_id, event_type, target_identity)`` 作为唯一累积键, ``target_identity`` 优先采用 ``track_id``,缺失时回退到 bbox 网格哈希 (与 ``EventAggregator`` 一致,确保两个模块对"同一目标"的认定口径相同)。 2. 累积条目记录连续命中帧数、累积平均置信度、首末时间戳、最大严重性。 3. 按时间窗口自动淘汰过期条目,并使用 OrderedDict 实现 LRU 容量保护, 避免多路摄像头长时间运行导致内存无限增长。 4. 当某次新候选事件与已有键的 ``last_seen`` 间隔超出窗口时, 认为序列中断,自动重置 ``consecutive_hits`` 计数。 Thread-safety: 当前实现为非线程安全,调用方应在单事件循环中按序使用。 """ from __future__ import annotations import logging import time from collections import OrderedDict from dataclasses import dataclass, field from typing import Dict, List, Optional, Tuple, TypeAlias from models.event_schemas import ( BBox, CandidateEvent, EventType, SeverityLevel, UnifiedDetection, ) logger = logging.getLogger(__name__) _AccKey: TypeAlias = Tuple[Optional[str], str, str] _SEVERITY_ORDER: Tuple[str, ...] = ( SeverityLevel.INFO.value, SeverityLevel.LOW.value, SeverityLevel.MEDIUM.value, SeverityLevel.HIGH.value, SeverityLevel.CRITICAL.value, ) # --------------------------------------------------------------------------- # 累积条目 # --------------------------------------------------------------------------- @dataclass class AccumulationEntry: """单个目标在窗口内的累积统计。 Attributes: key: (source_id, event_type, target_identity) event_type: 事件类型 source_id: 摄像头/视频流标识 first_seen: 首次出现时间戳 last_seen: 最近一次出现时间戳 total_hits: 窗口内累计命中次数 (含被时间窗口截断重置前的不计) consecutive_hits: 当前连续命中帧数 confidence_sum: 置信度累积和 (用于计算平均值) max_confidence: 窗口内最高置信度 max_severity: 窗口内最高严重性 last_event: 最近一次的候选事件 (用于 LLM 抽帧) last_bbox: 最近一次目标 bbox """ key: _AccKey event_type: EventType source_id: Optional[str] first_seen: float last_seen: float total_hits: int = 1 consecutive_hits: int = 1 confidence_sum: float = 0.0 max_confidence: float = 0.0 max_severity: SeverityLevel = SeverityLevel.INFO last_event: Optional[CandidateEvent] = None last_bbox: Optional[BBox] = None metadata: Dict[str, float] = field(default_factory=dict) @property def avg_confidence(self) -> float: """窗口内平均置信度 (按累积命中数平均)。""" if self.total_hits <= 0: return 0.0 return self.confidence_sum / self.total_hits @property def duration(self) -> float: """累积持续时间 (秒)。""" return max(0.0, self.last_seen - self.first_seen) def to_dict(self) -> Dict[str, object]: return { "source_id": self.source_id, "event_type": self.event_type.value, "target_identity": self.key[2], "total_hits": self.total_hits, "consecutive_hits": self.consecutive_hits, "avg_confidence": round(self.avg_confidence, 4), "max_confidence": round(self.max_confidence, 4), "max_severity": self.max_severity.value, "first_seen": self.first_seen, "last_seen": self.last_seen, "duration": round(self.duration, 3), } # --------------------------------------------------------------------------- # MultiFrameAccumulator # --------------------------------------------------------------------------- class MultiFrameAccumulator: """多帧候选事件累积器。 Args: window_seconds: 累积时间窗口,超过此值的条目会被淘汰; 同一 key 两次命中间隔超出窗口时,连续帧计数会被重置 max_capacity: 最大跟踪条目数,超出时按 LRU 淘汰 grid_size: 缺失 track_id 时用于构造目标 hash 的网格尺寸 (像素) """ def __init__( self, window_seconds: float = 3.0, max_capacity: int = 2000, grid_size: int = 50, ) -> None: if window_seconds <= 0: raise ValueError("window_seconds 必须 > 0") if max_capacity < 1: raise ValueError("max_capacity 必须 >= 1") if grid_size < 1: raise ValueError("grid_size 必须 >= 1") self.window_seconds = window_seconds self.max_capacity = max_capacity self.grid_size = grid_size self._entries: "OrderedDict[_AccKey, AccumulationEntry]" = OrderedDict() # ------------------------------------------------------------------ # 主入口 # ------------------------------------------------------------------ def accumulate( self, events: List[CandidateEvent], now: Optional[float] = None, ) -> List[AccumulationEntry]: """累积一批候选事件,返回受影响的累积条目快照列表。 Args: events: 当前帧的候选事件 now: 当前时间戳 (供测试注入),默认 ``time.time()`` """ if now is None: now = time.time() self._evict_expired(now) affected: List[AccumulationEntry] = [] for event in events: entry = self._update_one(event, now) if entry is not None: affected.append(entry) # LRU 容量保护 while len(self._entries) > self.max_capacity: dropped_key, _ = self._entries.popitem(last=False) logger.debug("MultiFrameAccumulator LRU 淘汰: %s", dropped_key) return affected # ------------------------------------------------------------------ # 单事件累积 # ------------------------------------------------------------------ def _update_one( self, event: CandidateEvent, now: float, ) -> Optional[AccumulationEntry]: key = self._make_key(event) existing = self._entries.get(key) if existing is None: entry = AccumulationEntry( key=key, event_type=event.event_type, source_id=event.source_id, first_seen=now, last_seen=now, total_hits=1, consecutive_hits=1, confidence_sum=event.confidence, max_confidence=event.confidence, max_severity=event.severity, last_event=event, last_bbox=event.detection.bbox, ) self._entries[key] = entry return entry # 序列中断检测:超出窗口则重置连续计数与平均累积 if now - existing.last_seen > self.window_seconds: existing.first_seen = now existing.total_hits = 0 existing.consecutive_hits = 0 existing.confidence_sum = 0.0 existing.last_seen = now existing.total_hits += 1 existing.consecutive_hits += 1 existing.confidence_sum += event.confidence existing.max_confidence = max(existing.max_confidence, event.confidence) existing.max_severity = self._max_severity( existing.max_severity, event.severity ) existing.last_event = event existing.last_bbox = event.detection.bbox # 移到队尾保持 LRU self._entries.move_to_end(key) return existing # ------------------------------------------------------------------ # 工具 # ------------------------------------------------------------------ def _make_key(self, event: CandidateEvent) -> _AccKey: return ( event.source_id, event.event_type.value, self._target_identity(event.detection), ) def _target_identity(self, det: UnifiedDetection) -> str: """构造目标稳定标识:优先 track_id,否则 bbox 网格哈希。""" if det.track_id is not None: return f"t{det.track_id}" cx, cy = det.bbox.center gx = int(cx) // self.grid_size gy = int(cy) // self.grid_size return f"g{gx}_{gy}_{det.class_name}" @staticmethod def _max_severity(a: SeverityLevel, b: SeverityLevel) -> SeverityLevel: try: ai = _SEVERITY_ORDER.index(a.value) bi = _SEVERITY_ORDER.index(b.value) except ValueError: return a return a if ai >= bi else b # ------------------------------------------------------------------ # 淘汰 / 自省 # ------------------------------------------------------------------ def _evict_expired(self, now: float) -> None: expired = [ key for key, entry in self._entries.items() if now - entry.last_seen > self.window_seconds ] for key in expired: self._entries.pop(key, None) def get(self, event: CandidateEvent) -> Optional[AccumulationEntry]: return self._entries.get(self._make_key(event)) def clear(self) -> None: self._entries.clear() @property def active_count(self) -> int: return len(self._entries) def snapshot(self) -> List[Dict[str, object]]: return [entry.to_dict() for entry in self._entries.values()] __all__ = ["MultiFrameAccumulator", "AccumulationEntry"]