|
|
from __future__ import annotations
|
|
|
|
|
|
import time
|
|
|
from typing import Optional
|
|
|
|
|
|
from . import state, config
|
|
|
from .events import _send_record_event
|
|
|
from .ptz_io import stop_ptz # <-- важно для "abort goto"
|
|
|
|
|
|
|
|
|
def _ema_update(prev: Optional[float], value: float, alpha: float = 0.2) -> float:
|
|
|
return value if prev is None else (1.0 - alpha) * prev + alpha * value
|
|
|
|
|
|
|
|
|
def _stat_on_detection(cam_idx: int, ts: float) -> None:
|
|
|
g = state.detect_stats_global
|
|
|
if g["last_ts"] is not None:
|
|
|
dt = ts - g["last_ts"]
|
|
|
if dt > 0.05:
|
|
|
g["ema_interval"] = _ema_update(g["ema_interval"], dt)
|
|
|
g["times"].append(ts)
|
|
|
g["last_ts"] = ts
|
|
|
|
|
|
c = state.detect_stats_cam[cam_idx]
|
|
|
if c["last_ts"] is not None:
|
|
|
dtc = ts - c["last_ts"]
|
|
|
if dtc > 0.05:
|
|
|
c["ema_interval"] = _ema_update(c["ema_interval"], dtc)
|
|
|
c["times"].append(ts)
|
|
|
c["last_ts"] = ts
|
|
|
|
|
|
|
|
|
def notify_detected(cam_idx: int, detected: bool, force: bool = False) -> None:
|
|
|
"""
|
|
|
detected=True/False — «сырое» состояние текущего кадра.
|
|
|
Формирует START/STOP записи (с гистерезисом) ТОЛЬКО для PTZ (или force).
|
|
|
Также ведёт режимы TRACK/SEARCH с hold на потерю, чтобы не срывать трекинг
|
|
|
при прерывистой детекции.
|
|
|
"""
|
|
|
|
|
|
# Панорамы не создают событий записи, если не включено явно
|
|
|
if cam_idx not in config.PTZ_CAM_IDS and not config.ALLOW_PANORAMA_RECORD and not force:
|
|
|
return
|
|
|
|
|
|
st = state.ptz_states[cam_idx]
|
|
|
now = time.time()
|
|
|
|
|
|
# -------------------- Параметры поведения трекинга --------------------
|
|
|
TRACK_LOSS_HOLD_SEC = float(getattr(config, "TRACK_LOSS_HOLD_SEC", 2.0))
|
|
|
TRACK_HOLD_WHILE_REC_ACTIVE_SEC = float(
|
|
|
getattr(config, "TRACK_HOLD_WHILE_REC_ACTIVE_SEC", TRACK_LOSS_HOLD_SEC)
|
|
|
)
|
|
|
|
|
|
# -------------------- Внутренняя логика трекинга --------------------
|
|
|
last_raw = st.get("last_detected_state")
|
|
|
|
|
|
if detected:
|
|
|
# статистика по фронту False->True
|
|
|
if last_raw is not True:
|
|
|
_stat_on_detection(cam_idx, now)
|
|
|
|
|
|
# фиксируем "видели цель"
|
|
|
st["last_seen"] = now
|
|
|
|
|
|
# Если в этот момент камера ехала на пресет (goto) — прерываем,
|
|
|
# иначе goto/патруль будет мешать трекингу первые секунды
|
|
|
if now < float(st.get("goto_in_progress_until", 0.0) or 0.0):
|
|
|
try:
|
|
|
stop_ptz(cam_idx)
|
|
|
except Exception:
|
|
|
pass
|
|
|
st["goto_in_progress_until"] = 0.0
|
|
|
|
|
|
# сброс кандидата на потерю
|
|
|
st.pop("track_loss_since", None)
|
|
|
|
|
|
# ВАЖНО: сообщаем патрулю, что идёт трекинг
|
|
|
st["tracking_active"] = True
|
|
|
|
|
|
# трекинг включается сразу
|
|
|
st["fallback_done"] = False
|
|
|
st["mode"] = "TRACK"
|
|
|
|
|
|
# опционально: если используешь где-то пост-трекинг флаги
|
|
|
st.pop("post_track_since", None)
|
|
|
st["post_track_park_done"] = False
|
|
|
|
|
|
else:
|
|
|
# detected == False
|
|
|
if st.get("mode") == "TRACK":
|
|
|
if "track_loss_since" not in st:
|
|
|
st["track_loss_since"] = now
|
|
|
else:
|
|
|
hold = TRACK_HOLD_WHILE_REC_ACTIVE_SEC if bool(st.get("rec_active", False)) else TRACK_LOSS_HOLD_SEC
|
|
|
if (now - float(st["track_loss_since"])) >= hold:
|
|
|
st.pop("track_loss_since", None)
|
|
|
st["mode"] = "SEARCH"
|
|
|
st["tracking_active"] = False
|
|
|
st["post_track_since"] = now
|
|
|
else:
|
|
|
# не трекаем — на всякий случай
|
|
|
st["tracking_active"] = False
|
|
|
|
|
|
st["last_detected_state"] = detected
|
|
|
|
|
|
# -------------------- форс-STOP --------------------
|
|
|
if force:
|
|
|
if st.get("rec_active", False) or st.get("rec_last_sent") is not False:
|
|
|
_send_record_event(cam_idx, False)
|
|
|
|
|
|
st["rec_active"] = False
|
|
|
st["rec_first_seen"] = None
|
|
|
st["rec_started_at"] = 0.0
|
|
|
st["rec_last_off_at"] = now
|
|
|
st["rec_last_sent"] = False
|
|
|
|
|
|
# сброс трекинга
|
|
|
st.pop("track_loss_since", None)
|
|
|
st["tracking_active"] = False
|
|
|
if st.get("mode") == "TRACK":
|
|
|
st["mode"] = "SEARCH"
|
|
|
return
|
|
|
|
|
|
# -------------------- гистерезис записи --------------------
|
|
|
if detected:
|
|
|
st["rec_last_seen"] = now
|
|
|
|
|
|
if not st.get("rec_active", False):
|
|
|
if now - float(st.get("rec_last_off_at", 0.0) or 0.0) < float(getattr(config, "REC_MIN_OFF_SEC", 0.0)):
|
|
|
return
|
|
|
|
|
|
if st.get("rec_first_seen") is None:
|
|
|
st["rec_first_seen"] = now
|
|
|
|
|
|
if (now - float(st["rec_first_seen"])) >= float(getattr(config, "REC_ON_CONFIRM_SEC", 0.35)):
|
|
|
st["rec_active"] = True
|
|
|
st["rec_started_at"] = now
|
|
|
if st.get("rec_last_sent") is not True:
|
|
|
_send_record_event(cam_idx, True) # START → C++
|
|
|
st["rec_last_sent"] = True
|
|
|
return
|
|
|
|
|
|
# detected == False
|
|
|
st["rec_first_seen"] = None
|
|
|
|
|
|
if st.get("rec_active", False):
|
|
|
rec_last_seen = float(st.get("rec_last_seen", 0.0) or 0.0)
|
|
|
rec_started_at = float(st.get("rec_started_at", 0.0) or 0.0)
|
|
|
|
|
|
long_no_see = (now - rec_last_seen) >= float(getattr(config, "REC_OFF_GRACE_SEC", 1.2))
|
|
|
long_enough = (now - rec_started_at) >= float(getattr(config, "REC_MIN_ON_SEC", 0.0))
|
|
|
too_long = (now - rec_started_at) >= float(getattr(config, "REC_MAX_ON_SEC", 1e9))
|
|
|
|
|
|
if (long_no_see and long_enough) or too_long:
|
|
|
st["rec_active"] = False
|
|
|
st["rec_last_off_at"] = now
|
|
|
if st.get("rec_last_sent") is not False:
|
|
|
_send_record_event(cam_idx, False) # STOP → C++
|
|
|
st["rec_last_sent"] = False
|