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.

498 lines
16 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.

from __future__ import annotations
import os
import json
from pathlib import Path
from typing import Any, Dict, List, Tuple
# ================== CONSTANTS / SETTINGS ==================
# Inference / batching
INFER_IMGSZ = 960
DRAW_ALL_ON_PANO = True
ADAPTIVE_BATCH_ENABLE = False
BATCH_MIN = 1 #4
BATCH_MAX = 4 #16
BATCH_TARGET_MS = 8.0
BATCH_UP_SCALE = 1.05
BATCH_DOWN_SCALE = 1.15
# ==== Logging toggles ====
LOG_GEO_CHANGES = True # писать в лог при заметном изменении bearing/attack
LOG_GEO_DEG_STEP = 2.0 # шаг (°) для логирования bearing
LOG_ATTACK_LOG_STEP = 5.0 # шаг (°) для логирования attack_bearing
LOG_STATUS_BATCH = False # если True — broadcaster пишет сводку каждые CONTROL_STATUS_INTERVAL
# Preview / video publishing
DROP_DECODE_WHEN_BUSY = True
PUBLISH_ONLY_IF_CLIENTS = True
PREVIEW_JPEG_QUALITY = 60 #60
PREVIEW_TARGET_W = 896 #896
DEFAULT_CLIENT_FPS = 25
# HUD на превью (азимут/дельта/атака)
PREVIEW_DRAW_HUD = False
VIDEO_MIN_INTERVAL_SLOW = 1.0 / 20.0
VIDEO_MIN_INTERVAL_FAST = max(1.0 / max(1, DEFAULT_CLIENT_FPS), 1.0 / 30.0)
# Detection thresholds
MODEL_CONF = 0.65
DETECT_CONF = 0.55
ALARM_CONF = 0.65
# бэк из твоего примера ждёт массив cameras[], так что лучше БЕЗ {mac}
ALARM_HTTP_ENABLE = True
ALARM_HTTP_MIRROR = True
ALARM_HTTP_BASE = "http://192.168.54.100/api/core"
ALARM_HTTP_PATH = "/videodata/10:ff:e0:4f:09:4b" #mac 00%3A11%3A22%3A33%3A44%3A51
ALARM_HTTP_PATH_TMPL = "/videodata"
ALARM_MAC_ONLY = False
ALARM_WS_FORMAT = "freq"
ALARM_COUNT_ON = 20
ALARM_COUNT_OFF = 80
ALARM_WS_SEND_LEGACY = True
# Компас (направления в логах/OSD)
COMPASS_LANG = "ru" # пока не используется, но оставим на будущее
COMPASS_POINTS = 8 # 8 румбов: С, СВ, В, ...
# Авто-вычисление ref-азимута preset1 от границ панорамы
AUTO_NORTH_FROM_PANORAMA = True
# Debounce for recording
REC_ON_CONFIRM_SEC = 0.1
REC_OFF_GRACE_SEC = 1.2
REC_MIN_ON_SEC = 4.0
REC_MIN_OFF_SEC = 1.0
REC_MAX_ON_SEC = 45.0
# Record gate
REC_DET_CONF = 0.45
REC_MIN_MARGIN = 0.08
REC_CENTER_MAX = 0.20
REC_W_MIN, REC_W_MAX = 0.04, 0.55
# Recording permissions
RECORD_FROM_PANORAMAS = True
ALLOW_PANORAMA_RECORD = True
# WebSocket / events
CPP_WS_URI = "ws://192.168.54.122:8767" # incoming video (C++)
CPP_CTRL_WS_URI = "ws://192.168.54.122:8767" # detection events → C++
VUE_CONTROL_WS_PORT = 8765
VUE_VIDEO_WS_PORT = 8766
ALARM_WS_URL = "ws://192.168.54.100/api/core/ws"
CONTROL_STATUS_INTERVAL = 0.25 # сек
# Heartbeat
HEARTBEAT_HOST = "127.0.0.1"
HEARTBEAT_PORT = 55555
HEARTBEAT_INTERVAL = 0.5
# Patrol
PATROL_LEG_SEC = 45.0 #25
HANDOFF_NOTIFY = True
PATROL_ENABLE_ON_START = True
PUBLISH_RAW_BEFORE_INFER = False
# Sweep (smooth pass)
SMOOTH_SWEEP_ENABLE = True
SWEEP_PAN_SPEED = 0.04 #0.08
SWEEP_SETTLE_SEC = 0.20 #0.20
SWEEP_RAMP_SEC = 0.80 #0.60
PATROL_SNAP_TO_END = False
END_DWELL_SEC = 0
SHOT_PAUSE_ENABLE = False
SHOT_INTERVAL_SEC = 0
SHOT_PAUSE_SEC = 0
# Bounded patrol dynamics
PATROL_PAN_GAIN_DEG = 120.0 #30
PATROL_SPEED_SCALE = 0.70 #0.90
PATROL_OFFSECTOR_GRACE_SEC = 2.0
# Starting direction per camera (PTZ only)
PATROL_START_DIR: Dict[int, int] = {0: +1, 2: +1, 4: +1, 6: -1, 8: -1, 10: -1}
# Sector
SECTOR_PATROL_ENABLE = False
USE_PRESET_EDGES_FOR_SECTOR = True
SECTOR_MARGIN_DEG = 2.0 # было 0.0 — маленький запас вокруг сектора
EDGE_EPS_HARD = 0.35 # было 0.0 — жёсткий край
EDGE_EPS_SOFT = 1 # было 0.0 — мягкая зона замедления у края
# Classes
CLASS_NAMES = {0: "Fighter", 1: "drone"}
TARGET_CLASSES = [0, 1]
# Auto strategy
AUTO_STRATEGY = True
SHORT_LOSS_SEC = 0.25 #1.2
LONG_LOSS_SEC = 1.20 #4/0
ABS_MIN_FALLBACK = 25.0
ABS_MAX_FALLBACK = 40.0
FALLBACK_PRESET_CAP: float | None = None
# Loss/Return
TRACK_END_LOSS_SEC = 2.5
LOSS_TO_PRESET_SEC = 8.0
EDGE_LOSS_MARGIN = 0.015
VERT_LOSS_MARGIN = 0.050
PRESET_AFTER_LOSS = True
EDGE_LOSS_GRACE_SEC = 1.2
# Возврат к пресету, если камера ушла вручную/дрейфует без детекции
IDLE_DRIFT_RETURN_SEC = 4.0 # сколько секунд можно быть вне пресета по панораме
IDLE_TILT_RETURN_SEC = 2.0 # сколько секунд можно быть вне допустимого диапазона по наклону
IDLE_NEAR_TOL_PAN_DEG = 1.2 # допуск «рядом с пресетом» по pan
IDLE_NEAR_TOL_TILT_DEG = 2.5 # допуск «рядом с пресетом» по tilt
# PID / Zoom / Track
DEAD_ZONE = 0.010
SMOOTHING_FACTOR = 0.35
MOVE_GAIN = 1.85
KI = 0.06
INTEGRAL_CLAMP = 0.20
KD = 0.14
VEL_SMOOTH = 0.60
BASE_LEAD = 0.14
LEAD_GAIN = 0.55
LEAD_MAX = 0.55
CENTER_TOL = 0.040
DECAY_FACTOR = 0.85
MIN_FRAMES_TO_TRACK = 3
LOSS_DECAY_TAU = 0.35
#НОВЫЕ
LEAD_OFF_ERR = 0.18
Z_ERR_GATE = 0.18
PRED_CLAMP_MIN = 0.05
PRED_CLAMP_MAX = 0.95
PRED_DX_CLAMP = 0.35
W_GROWTH_CLAMP = 2.0
TRACK_P = 1.25
TRACK_I = 0.03
TRACK_D = 0.08
TRACK_SCALE = 0.75
TRACK_DEAD_ZONE = 0.022
TRACK_EDGE_SLOWDOWN_DEG = 8.0
TRACK_EDGE_MIN_SCALE = 0.18
TRACK_OFFSECTOR_GRACE_SEC = 4.0
IDLE_OFFPRESET_GRACE_SEC = 7.0
Z_TARGET_SLOW = 0.16
Z_TARGET_FAST = 0.12
Z_VEL_REF = 0.45
Z_DELTA_IN = 0.008
Z_DELTA_OUT = 0.060
Z_DEADBAND = 0.012 #0.010
Z_P = 2.4
Z_I = 0.16
Z_INT_CLAMP = 0.15
Z_FF_GAIN = 0.55
Z_SPEED_LIMIT_IN = 0.45
Z_SPEED_LIMIT_OUT = -0.65 #-0.45
Z_MIN_CMD = 0.05
Z_RAMP_TIME = 0.25 #0.45
Z_PAN_BOOST = 1.18
Z_CENTER_GATE_BASE = 0.06
Z_CENTER_GATE_SCALE = 0.20
Z_CENTER_STRICT = 0.030
Z_VELOCITY_GATE = 0.30
Z_OUT_EDGE = 0.07
Z_SAFETY_EDGE = 0.05
Z_SAFETY_TOP_BONUS = 0.50
Z_MIN_SWITCH_INTERVAL = 0.50 #0.80
Z_MOTION_THR = 0.015
Z_HOVER_THR = 0.030
Z_FREEZE_GROWTH_THR = 0.005 #0.004
Z_LOCK_ENABLE = True
Z_LOCK_ARM_SECS = 1.2 #0.8
Z_UNLOCK_VEL_THR = 0.070 #0.060
Z_UNLOCK_GROWTH_THR = 0.018 #0.015
W_GROWTH_CLAMP = 2.0 # clamp for w_growth derivative (stability)
Z_UNLOCK_CENTER_THR = 0.12 #0.10
Z_UNLOCK_EDGE = 0.050
SCAN_AMPL = 0.12
SCAN_STEP = 0.08
RECOVER_ZOOM_OUT = -0.55 #-0.35
PTZ_EPS = 0.008
PTZ_QUANT = 0.005
# Faster aim / zoom
AIM_SMOOTH = 0.38
AIM_P = 2.40
AIM_I = 0.06
AIM_D = 0.30
RECOVER_ZOOM_OUT_SHORT = 0.0
W_EMA_ALPHA = 0.30
CENTER_GATE_IN = 0.06
Z_PAN_BOOST_WHEN_IN = 1.35
Z_LEAD_DURING_IN = 0.65
# Video broadcast
SEND_DUPLICATES_WHEN_IDLE = False
ANN_MIN_INTERVAL = 0.0
# Sync patrol
SYNC_PATROL_ENABLE = False
SYNC_COHORTS = {"A": {"plus": [0, 2, 4], "minus": [6, 8, 10]}}
SYNC_USE_BOUNDED = False
SYNC_BARRIER_SEC = 0.8
SYNC_DWELL_SEC = END_DWELL_SEC
#====================#
PTZ_MIN_INTERVAL_SEC = 0.04
PTZ_MAX_PAN_I = 8
PTZ_MAX_TILT_I = 10
PTZ_MAX_ZOOM_I = 8
GOTO_SETTLE_SEC = 2.5
PATROL_MAX_SPEED = 0.14
# ================== CAMERA LOADER (TOML/JSON) ==================
DEFAULT_SECOND_PRESET = "2"
def _load_cameras_from_file() -> Dict[int, Dict[str, Any]]:
"""
Загружает конфиг камер из TOML/JSON.
- Путь можно задать переменной окружения CAMERAS_FILE.
- По умолчанию ищется 'cameras.toml' рядом с этим модулем.
Ожидается массив объектов с полем `id`.
"""
here = Path(__file__).resolve().parent
path = Path(os.getenv("CAMERAS_FILE", here / "cameras.toml"))
if not path.exists():
# fallback: попробуем cameras.json
json_path = here / "cameras.json"
if json_path.exists():
path = json_path
else:
return {}
data: Any
if path.suffix.lower() == ".toml":
try:
import tomllib # py311+
except Exception as e: # pragma: no cover
raise RuntimeError("Для TOML нужен Python 3.11+ (модуль tomllib)") from e
with open(path, "rb") as f:
data = tomllib.load(f)
arr = data.get("camera") or data.get("cameras") or []
elif path.suffix.lower() == ".json":
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
arr = data.get("camera") or data.get("cameras") or data
if isinstance(arr, dict):
arr = [{"id": int(k), **v} for k, v in arr.items()]
else:
raise RuntimeError(f"Неподдерживаемое расширение файла камер: {path.suffix}")
if not isinstance(arr, list):
raise RuntimeError("Файл камер должен содержать список объектов")
cfg_by_id: Dict[int, Dict[str, Any]] = {}
for item in arr:
if not isinstance(item, dict):
continue
if "id" not in item:
raise RuntimeError("Каждая камера должна иметь поле 'id'")
cid = int(item["id"])
cfg = dict(item) # copy
# Back-compat: если указали единый preset — дополнить вторым
if "preset" in cfg and cfg.get("preset") not in (None, "None"):
cfg.setdefault("preset1", cfg["preset"])
cfg.setdefault("preset2", DEFAULT_SECOND_PRESET)
# Значения по умолчанию
cfg.setdefault("ptz_channel", 1)
cfg.setdefault("preset1", None)
cfg.setdefault("preset2", None)
cfg_by_id[cid] = cfg
return cfg_by_id
# === Загрузка и пост-обработка ===
CAMERA_CONFIG: Dict[int, Dict[str, Any]] = _load_cameras_from_file()
# Перекрытие паролей через переменные окружения CAM_<ID>_PASSWORD
for cid, cfg in CAMERA_CONFIG.items():
env_pw = os.getenv(f"CAM_{cid}_PASSWORD")
if env_pw:
cfg["password"] = env_pw
# Список PTZ-камер (есть preset1 и preset2)
PTZ_CAM_IDS: List[int] = [cid for cid, cfg in CAMERA_CONFIG.items() if cfg.get("preset1") and cfg.get("preset2")]
PTZ_CAM_IDS.sort()
# Значения по умолчанию для PTZ
for cam_id in PTZ_CAM_IDS:
cfg = CAMERA_CONFIG[cam_id]
cfg.setdefault("preset1_deg", None)
cfg.setdefault("preset2_deg", None)
cfg.setdefault("preset1_tilt_deg", None)
cfg.setdefault("preset2_tilt_deg", None)
cfg.setdefault("sector_min_deg", None)
cfg.setdefault("sector_max_deg", None)
cfg.setdefault("pan_sign", cfg.get("sweep_sign", 1))
cfg.setdefault("tilt_sign", -1)
cfg.setdefault("pan_offset_deg", 0.0)
cfg.setdefault("tilt_offset_deg", 0.0)
# Гео-поля для привязки к северу и UI
cfg.setdefault("north_offset_deg", None)
cfg.setdefault("preset1_deg_geo", None)
cfg.setdefault("preset2_deg_geo", None)
cfg.setdefault("sector_min_deg_geo", None)
cfg.setdefault("sector_max_deg_geo", None)
# Значения по умолчанию для «пуль»/панорам (без PTZ-пресетов)
for cid, cfg in CAMERA_CONFIG.items():
if not (cfg.get("preset1") and cfg.get("preset2")):
# Для DS-2SF8C442MXS-DL(14F1)(P3) панорама (канал 2) ~= 8586°.
# Явно прописывай в cameras.toml: bullet_hfov_deg = 85.5
cfg.setdefault("bullet_hfov_deg", 90.0)
cfg.setdefault("bullet_vfov_deg", 65.0)
# Panorama ↔ PTZ pairing по IP и каналам
PAN_TO_PTZ: Dict[int, int] = {}
PTZ_TO_PAN: Dict[int, int] = {}
_by_ip: Dict[str, Dict[int, int]] = {}
for cam_id, cfg in CAMERA_CONFIG.items():
ip = cfg["ip"]; ch = int(cfg.get("ptz_channel", 1))
_by_ip.setdefault(ip, {})[ch] = cam_id
for ip, chmap in _by_ip.items():
ptz_id = chmap.get(1)
pano_id = chmap.get(2)
if ptz_id is not None and pano_id is not None:
PAN_TO_PTZ[pano_id] = ptz_id
PTZ_TO_PAN[ptz_id] = pano_id
# PTZ order (circular)
PTZ_ORDER: List[int] = PTZ_CAM_IDS[:]
NEXT_FWD: Dict[int, int] = {}
NEXT_REV: Dict[int, int] = {}
if PTZ_ORDER:
n = len(PTZ_ORDER)
for i, cam in enumerate(PTZ_ORDER):
NEXT_FWD[cam] = PTZ_ORDER[(i + 1) % n]
NEXT_REV[cam] = PTZ_ORDER[(i - 1) % n]
# Cohorts config → static maps (runtime sync_state lives in state.py)
sync_group_by_cam: Dict[int, str] = {}
cohort_sign_by_cam: Dict[int, int] = {}
for gname, coh in (SYNC_COHORTS or {}).items():
for cid in coh.get("plus", []):
sync_group_by_cam[cid] = gname
cohort_sign_by_cam[cid] = +1
for cid in coh.get("minus", []):
sync_group_by_cam[cid] = gname
cohort_sign_by_cam[cid] = -1
# ========= Утилита для hot-reload =========
def reload_cameras() -> None:
"""Перечитать cameras.toml/JSON и обновить производные структуры."""
global CAMERA_CONFIG, PTZ_CAM_IDS, PAN_TO_PTZ, PTZ_TO_PAN, PTZ_ORDER, NEXT_FWD, NEXT_REV
global sync_group_by_cam, cohort_sign_by_cam
cfg = _load_cameras_from_file()
# env override паролей
for cid, c in cfg.items():
env_pw = os.getenv(f"CAM_{cid}_PASSWORD")
if env_pw:
c["password"] = env_pw
CAMERA_CONFIG = cfg
PTZ_CAM_IDS = [cid for cid, c in CAMERA_CONFIG.items() if c.get("preset1") and c.get("preset2")]
PTZ_CAM_IDS.sort()
for cam_id in PTZ_CAM_IDS:
c = CAMERA_CONFIG[cam_id]
c.setdefault("preset1_deg", None)
c.setdefault("preset2_deg", None)
c.setdefault("preset1_tilt_deg", None)
c.setdefault("preset2_tilt_deg", None)
c.setdefault("sector_min_deg", None)
c.setdefault("sector_max_deg", None)
c.setdefault("pan_sign", c.get("sweep_sign", 1))
c.setdefault("tilt_sign", 1)
c.setdefault("pan_offset_deg", 0.0)
c.setdefault("tilt_offset_deg", 0.0)
c.setdefault("north_offset_deg", None)
c.setdefault("preset1_deg_geo", None)
c.setdefault("preset2_deg_geo", None)
c.setdefault("sector_min_deg_geo", None)
c.setdefault("sector_max_deg_geo", None)
for cid, c in CAMERA_CONFIG.items():
if not (c.get("preset1") and c.get("preset2")):
c.setdefault("bullet_hfov_deg", 90.0)
c.setdefault("bullet_vfov_deg", 65.0)
PAN_TO_PTZ = {}
PTZ_TO_PAN = {}
by_ip: Dict[str, Dict[int, int]] = {}
for cam_id, c in CAMERA_CONFIG.items():
ip = c["ip"]; ch = int(c.get("ptz_channel", 1))
by_ip.setdefault(ip, {})[ch] = cam_id
for ip, chmap in by_ip.items():
ptz_id = chmap.get(1)
pano_id = chmap.get(2)
if ptz_id is not None and pano_id is not None:
PAN_TO_PTZ[pano_id] = ptz_id
PTZ_TO_PAN[ptz_id] = pano_id
PTZ_ORDER = PTZ_CAM_IDS[:]
NEXT_FWD = {}
NEXT_REV = {}
if PTZ_ORDER:
n = len(PTZ_ORDER)
for i, cam in enumerate(PTZ_ORDER):
NEXT_FWD[cam] = PTZ_ORDER[(i + 1) % n]
NEXT_REV[cam] = PTZ_ORDER[(i - 1) % n]
# ===== Helpers for compass-bearing (used by postprocess.py) =====
def get_cam_base_azimuth_deg(cam_id: int) -> float:
"""
Базовый азимут камеры (0°=С, 90°=В). Это направление центрального пикселя
(для PTZ — при текущем pan=az_deg, для панорамы — просто «куда смотрит» середина кадра).
Берётся из cameras.toml: north_offset_deg.
"""
cfg = CAMERA_CONFIG.get(cam_id, {})
north = cfg.get("north_offset_deg")
if not isinstance(north, (int, float)):
return 0.0
is_ptz = bool(cfg.get("preset1")) and cfg.get("preset2")
if not is_ptz:
return float(north)
try:
from . import state
st = state.ptz_states.get(cam_id, {})
pan = st.get("az_deg")
if isinstance(pan, (int, float)):
return (float(north) + float(pan)) % 360.0
except Exception:
pass
return float(north)
def get_cam_hfov_deg(cam_id: int) -> float:
"""
Горизонтальный FOV камеры. Для панорамы/«пули» — bullet_hfov_deg (или hfov_deg).
Для PTZ тоже можно прописать hfov_deg, если хотим считать bearing по x.
"""
cfg = CAMERA_CONFIG.get(cam_id, {})
for key in ("bullet_hfov_deg", "hfov_deg"):
v = cfg.get(key)
if isinstance(v, (int, float)) and v > 0:
return float(v)
return 90.0