You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
MAI/stationary_killer.py

134 lines
5.4 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# ============================================================
# 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}"
)