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 = 768 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 = 80 #60 PREVIEW_TARGET_W = 1280 #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.55 # бэк из твоего примера ждёт массив cameras[], так что лучше БЕЗ {mac} ALARM_HTTP_ENABLE = True ALARM_HTTP_MIRROR = True ALARM_HTTP_BASE = "http://192.168.54.100:3000" 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:3000/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 = 5 #1.2 LONG_LOSS_SEC = 4.0 ABS_MIN_FALLBACK = 25.0 ABS_MAX_FALLBACK = 40.0 FALLBACK_PRESET_CAP: float | None = None # Loss/Return TRACK_END_LOSS_SEC = 0.6 LOSS_TO_PRESET_SEC = 6.0 EDGE_LOSS_MARGIN = 0.015 VERT_LOSS_MARGIN = 0.050 PRESET_AFTER_LOSS = True EDGE_LOSS_GRACE_SEC = 1.0 # Возврат к пресету, если камера ушла вручную/дрейфует без детекции 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.012 SMOOTHING_FACTOR = 0.35 MOVE_GAIN = 1.85 KI = 0.06 INTEGRAL_CLAMP = 0.20 KD = 0.14 VEL_SMOOTH = 0.75 BASE_LEAD = 0.14 LEAD_GAIN = 0.38 LEAD_MAX = 0.36 CENTER_TOL = 0.040 DECAY_FACTOR = 0.85 MIN_FRAMES_TO_TRACK = 3 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.45 #-0.65 Z_MIN_CMD = 0.05 Z_RAMP_TIME = 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.45 Z_OUT_EDGE = 0.07 Z_SAFETY_EDGE = 0.05 Z_SAFETY_TOP_BONUS = 0.50 Z_MIN_SWITCH_INTERVAL = 0.80 #0.50 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 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.35 PTZ_EPS = 0.008 PTZ_QUANT = 0.005 # Faster aim / zoom AIM_SMOOTH = 0.40 AIM_P = 2.30 AIM_I = 0.06 AIM_D = 0.22 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__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) ~= 85–86°. # Явно прописывай в 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