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