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.

129 lines
4.5 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 cv2
import numpy as np
from typing import Any, Tuple, Optional
from . import state, config
CV2_JPEG_PARAMS = [
int(cv2.IMWRITE_JPEG_QUALITY), int(config.PREVIEW_JPEG_QUALITY),
int(cv2.IMWRITE_JPEG_PROGRESSIVE), 0,
int(cv2.IMWRITE_JPEG_OPTIMIZE), 0
]
def encode_jpeg_bgr(img_bgr: np.ndarray) -> Optional[bytes]:
ok, jpeg = cv2.imencode(".jpg", img_bgr, CV2_JPEG_PARAMS)
return jpeg.tobytes() if ok else None
def maybe_downscale(img: np.ndarray, target_w: int = config.PREVIEW_TARGET_W) -> Tuple[np.ndarray, float, float]:
h, w = img.shape[:2]
if w <= target_w:
return img, 1.0, 1.0
scale = target_w / float(w)
return cv2.resize(img, (target_w, int(h * scale)), interpolation=cv2.INTER_AREA), scale, scale
# ----------------------------- HUD (новое) -----------------------------
def _fmt(v: Optional[float], suffix: str = "°", nd: int = 1) -> str:
if v is None:
return ""
try:
return f"{float(v):.{nd}f}{suffix}"
except Exception:
return ""
def _bearing_to_compass(b: Optional[float]) -> str:
if b is None:
return ""
names = ["N","NNE","NE","ENE","E","ESE","SE","SSE",
"S","SSW","SW","WSW","W","WNW","NW","NNW"]
i = int(((float(b) % 360.0) + 11.25) // 22.5) % 16
return names[i]
def _draw_text_block(img: np.ndarray, lines: list[str], org=(10, 24)) -> None:
"""
Рисует компактный полупрозрачный блок с текстом в левом верхнем углу.
"""
x0, y0 = org
pad_x, pad_y = 8, 6
line_h = 22
# вычислим ширину по самой длинной строке
max_w = 0
for t in lines:
(tw, th), _ = cv2.getTextSize(t, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
max_w = max(max_w, tw)
box_w = max_w + pad_x * 2
box_h = line_h * len(lines) + pad_y * 2
x1, y1 = x0, y0 - 18
x2, y2 = x1 + box_w, y1 + box_h
x2 = min(x2, img.shape[1] - 1)
y2 = min(y2, img.shape[0] - 1)
# полупрозрачный фон
overlay = img.copy()
cv2.rectangle(overlay, (x1, y1), (x2, y2), (0, 0, 0), -1)
cv2.addWeighted(overlay, 0.35, img, 0.65, 0, img)
# текст
y = y1 + pad_y + 16
for t in lines:
cv2.putText(img, t, (x1 + pad_x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 2, cv2.LINE_AA)
y += line_h
def draw_status_hud(cam_id: int, img_bgr: np.ndarray) -> None:
"""
Отрисовывает HUD поверх кадра:
Bearing (гео), Δ от preset1, Attack (курс+румб), raw pan/tilt.
Ничего не делает, если данных нет.
"""
st = state.ptz_states.get(cam_id, {})
bearing = st.get("bearing_curr_geo")
delta = st.get("delta_from_preset1_geo")
attack = st.get("attack_bearing_geo")
attack_compass = st.get("attack_compass")
az_deg = st.get("az_deg")
tilt_deg = st.get("tilt_deg")
lines = []
if bearing is not None:
lines.append(f"Bearing: {_fmt(bearing)} ({_bearing_to_compass(bearing)})")
if delta is not None:
sign = "+" if float(delta) >= 0 else ""
lines.append(f"ΔPreset1: {sign}{_fmt(delta)}")
if attack is not None or attack_compass:
comp = attack_compass if isinstance(attack_compass, str) else _bearing_to_compass(attack)
lines.append(f"Attack: {_fmt(attack)} ({comp})")
if az_deg is not None or tilt_deg is not None:
lines.append(f"raw pan/tilt: {_fmt(az_deg)} / {_fmt(tilt_deg)}")
if lines:
_draw_text_block(img_bgr, lines, org=(10, 28))
# --------------------------- публикация ----------------------------
def publish_preview(cam_id: int, img: np.ndarray) -> None:
"""
Публикатор JPEG-превью. При наличии флага config.PREVIEW_DRAW_HUD
поверх кадра будет нарисован компактный HUD с курсом/дельтой/атакой.
"""
if getattr(config, "PREVIEW_DRAW_HUD", False):
try:
draw_status_hud(cam_id, img)
except Exception:
# в превью важнее стабильность, ошибки HUD не должны мешать стриму
pass
if config.PUBLISH_ONLY_IF_CLIENTS and not state.video_clients:
return
jpeg = encode_jpeg_bgr(img)
if not jpeg:
return
fid = state.frame_id_by_cam.get(cam_id, 0) + 1
state.frame_id_by_cam[cam_id] = fid
state.latest_jpeg_by_cam[cam_id] = (fid, jpeg)