|
|
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_<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) ~= 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
|