# ============================================================ # stationary_killer.py # Детектор прилипших треков на статичных объектах сцены. # Защита от track hijacking на: # - снежных фичах / следах / кустах # - линии горизонта # - ЛЭП / деревьях / ветках # - отражениях от воды # # Работает как независимый "полицейский": следит за # движением центра bbox'а confirmed трека и motion saliency # score в его зоне. Если трек не движется И не даёт motion # response — форсирует сброс target. # ============================================================ from collections import deque import numpy as np # ─── Параметры детектора ───────────────────────────────────── STAT_WINDOW_FRAMES = 45 # окно истории (~0.75s при 60fps) STAT_MAX_CENTER_SHIFT = 12.0 # макс сдвиг центра bbox в пикселях STAT_MAX_MOTION_SCORE = 0.12 # порог motion saliency STAT_MIN_CONFIRMED_F = 20 # не трогать трек моложе этого STAT_GRACE_PERIOD = 15 # после kill — не убивать N кадров # Для нижней половины кадра (parallax даёт false motion) # пороги мягче — там движение фона более заметно STAT_BOTTOM_FRAC = 0.55 STAT_LOW_MAX_CENTER_SHIFT = 18.0 STAT_LOW_MAX_MOTION = 0.20 class StationaryKiller: """ Следит за движением confirmed трека и сигнализирует когда трек "прилип" к статичной фиче (снег, горизонт, дерево). Работает независимо от ByteTrack / KLT / Kalman — просто смотрит на историю locked_box_eff и motion_sal score. """ def __init__(self): self.target_id = None self.history = deque(maxlen=STAT_WINDOW_FRAMES) # history items: (frame_id, cx, cy, motion_score, frame_h) self.confirmed_since = -1 self.grace_until = -1 self.kills_total = 0 self.last_kill_reason = "" def reset(self): self.target_id = None self.history.clear() self.confirmed_since = -1 def notify_kill_done(self, frame_id): """Вызывается main.py после force reset.""" self.kills_total += 1 self.grace_until = int(frame_id) + int(STAT_GRACE_PERIOD) self.reset() def update(self, frame_id, target_id, locked_box_eff, motion_sal, confirmed, frame_h): """Обновить историю трека. Вызывать каждый кадр.""" if (not confirmed) or (target_id is None) or (locked_box_eff is None): if self.target_id is not None: self.reset() return if target_id != self.target_id: self.reset() self.target_id = target_id self.confirmed_since = int(frame_id) cx = 0.5 * (float(locked_box_eff[0]) + float(locked_box_eff[2])) cy = 0.5 * (float(locked_box_eff[1]) + float(locked_box_eff[3])) if motion_sal is not None and motion_sal.saliency is not None: ms_score = float(motion_sal.score_box(locked_box_eff)) else: ms_score = 0.5 # нет saliency — не штрафуем self.history.append((int(frame_id), cx, cy, ms_score, float(frame_h))) def should_kill(self, frame_id): """Проверить нужно ли сбросить текущий target.""" if int(frame_id) < int(self.grace_until): return False if self.target_id is None: return False if len(self.history) < int(STAT_WINDOW_FRAMES): return False if (int(frame_id) - int(self.confirmed_since)) < int(STAT_MIN_CONFIRMED_F): return False cx0 = self.history[0][1] cy0 = self.history[0][2] max_shift = 0.0 motion_scores = [] frame_h = self.history[-1][4] for _, cx, cy, ms, _fh in self.history: s = float(np.hypot(cx - cx0, cy - cy0)) if s > max_shift: max_shift = s motion_scores.append(ms) avg_motion = float(np.mean(motion_scores)) if motion_scores else 1.0 last_cy = self.history[-1][2] is_low_zone = last_cy >= float(frame_h) * float(STAT_BOTTOM_FRAC) if is_low_zone: shift_lim = float(STAT_LOW_MAX_CENTER_SHIFT) motion_lim = float(STAT_LOW_MAX_MOTION) else: shift_lim = float(STAT_MAX_CENTER_SHIFT) motion_lim = float(STAT_MAX_MOTION_SCORE) if max_shift <= shift_lim and avg_motion <= motion_lim: zone = "low" if is_low_zone else "normal" self.last_kill_reason = ( f"stationary:{zone} shift={max_shift:.1f}/{shift_lim:.0f} " f"motion={avg_motion:.3f}/{motion_lim:.2f}" ) return True return False def status_line(self): return ( f"StationaryKiller: monitoring tid={self.target_id} " f"history={len(self.history)}/{STAT_WINDOW_FRAMES} " f"kills={self.kills_total}" )