diff --git a/controller/cuda_grid_controller/__main__.py b/controller/cuda_grid_controller/__main__.py index 5f813d6..c13e271 100644 --- a/controller/cuda_grid_controller/__main__.py +++ b/controller/cuda_grid_controller/__main__.py @@ -43,13 +43,13 @@ async def _run(cfg: Config) -> None: dispatcher = CommandDispatcher(cfg, state) - # Frigate bridge (опционально) + # Frigate bridge (опционально) — передаём dispatcher для auto-overlay generation frigate_bridge: FrigateBridge | None = None if cfg.frigate: try: fcfg = FrigateBridgeCfg.model_validate(cfg.frigate) if fcfg.enabled: - frigate_bridge = FrigateBridge(fcfg) + frigate_bridge = FrigateBridge(fcfg, dispatcher=dispatcher) except Exception as e: structlog.get_logger().warning( "frigate_bridge.config_invalid", error=str(e) diff --git a/controller/cuda_grid_controller/dispatch.py b/controller/cuda_grid_controller/dispatch.py index fe9a663..e68f9a7 100644 --- a/controller/cuda_grid_controller/dispatch.py +++ b/controller/cuda_grid_controller/dispatch.py @@ -22,30 +22,75 @@ from .zmq_client import FFmpegZmqClient log = structlog.get_logger() -def _serialize_overlay_to_zmq(overlay: Overlay) -> str: - """Сериализовать overlay в одну строку для FFmpeg process_command. +def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: + """'#FF8800' → (255, 136, 0). Fallback (255, 255, 255) при невалидном input.""" + s = hex_color.lstrip("#") + if len(s) != 6: + return (255, 255, 255) + try: + return (int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)) + except ValueError: + return (255, 255, 255) - Формат: ` = = ...` - String values URL-encoded (spaces → %20), filter-side decode'ит inline - в parse_overlay_args. Nested values (style и т.п.) skip'аются — Phase 4b - их не поддерживает. + +def _serialize_overlay_to_zmq(overlay: Overlay) -> str: + """Сериализовать overlay в строку для FFmpeg process_command. + + Wire format: ` =...` + Filter parser (libavfilter/vf_cuda_grid.c parse_overlay_args) ожидает + plain fields — поэтому translate: + color "#RRGGBB" → r=N g=N b=N + opacity 0..1.0 → opacity 0..255 + border_only+width → thickness (0=filled, иначе border_width) + dim_factor 0..1 → amount 0..255 + String values URL-encoded (%20 для space) — filter inline decode'ит. """ from urllib.parse import quote - parts = [overlay.id, overlay.type] - data = overlay.model_dump() - for key, value in data.items(): - if key in {"id", "type"}: - continue - if value is None: - continue - if isinstance(value, (dict, list)): - continue # Phase 4b skipped — Phase 5 для nested style - if isinstance(value, bool): - value = 1 if value else 0 - if isinstance(value, str): - value = quote(value, safe="") # encode spaces + всё кроме alnum - parts.append(f"{key}={value}") + r, g, b = 255, 255, 255 # default + parts: list[str] = [overlay.id, overlay.type] + + # Common base fields + if overlay.cell is not None: + parts.append(f"cell={overlay.cell}") + parts.append(f"z_order={overlay.z_order}") + parts.append(f"opacity={int(round(overlay.opacity * 255))}") + parts.append(f"visible={1 if overlay.visible else 0}") + + t = overlay.type + + if t == "rect": + r, g, b = _hex_to_rgb(overlay.color) + thickness = overlay.border_width if overlay.border_only else 0 + parts += [ + f"x={overlay.x}", f"y={overlay.y}", f"w={overlay.w}", f"h={overlay.h}", + f"r={r}", f"g={g}", f"b={b}", f"thickness={thickness}", + ] + elif t == "text": + r, g, b = _hex_to_rgb(overlay.color) + parts += [ + f"x={overlay.x}", f"y={overlay.y}", + f"text={quote(overlay.text, safe='')}", + f"font_size={overlay.font_size}", + f"r={r}", f"g={g}", f"b={b}", + ] + elif t == "icon": + parts += [ + f"x={overlay.x}", f"y={overlay.y}", + f"icon_name={quote(overlay.name, safe='')}", + ] + elif t == "dim": + # filter: amount = насколько затемнить (0..255). Маппится из dim_factor. + amount = int(round(overlay.dim_factor * 255)) + parts += [ + f"x={overlay.x}", f"y={overlay.y}", f"w={overlay.w}", f"h={overlay.h}", + f"amount={amount}", + ] + elif t in ("image", "graph", "chat"): + # Phase 5+ — filter пока не support'ит. Просто отправляем что есть, filter + # skip-логирует (parse_overlay_args знает только rect/text/icon/dim). + pass + return " ".join(parts) diff --git a/controller/cuda_grid_controller/frigate_bridge.py b/controller/cuda_grid_controller/frigate_bridge.py index c2255db..4ec43b3 100644 --- a/controller/cuda_grid_controller/frigate_bridge.py +++ b/controller/cuda_grid_controller/frigate_bridge.py @@ -1,17 +1,31 @@ -"""Frigate MQTT bridge — subscribe `frigate/+/motion` + `frigate/events`, -маппит к overlay add/remove на configured instance. +"""Frigate MQTT bridge — auto-overlay generation для motion + object events. -Phase 4a: skeleton — receives + logs events, не делает auto-overlay yet. -Phase 4b: actual auto-bbox overlays для detect events. +Phase 4b auto-rendering: subscribed на frigate//motion + frigate/events, +конвертирует в add/remove overlay commands к configured target instance + cell. + +Logic: + - motion ON → add RectOverlay (orange border) на весь cell + - motion OFF → remove тот overlay + - event new → add RectOverlay (bbox) + TextOverlay (label + score) + - event end → remove оба overlays + +Bbox coords из Frigate event = absolute pixel в camera resolution. +Если в mapping указано camera_width/height — используем для normalize; иначе assume 1920×1080. """ from __future__ import annotations import json +from typing import TYPE_CHECKING import structlog from pydantic import BaseModel, Field +from .overlays import RectOverlay, TextOverlay + +if TYPE_CHECKING: + from .dispatch import CommandDispatcher + log = structlog.get_logger() @@ -22,6 +36,10 @@ class FrigateCameraMapping(BaseModel): target_instance: str cell: int = Field(default=0, description="Cell index в layout куда рисовать bbox") enabled: bool = True + camera_width: int = Field(default=1920, gt=0, description="Native camera resolution для нормализации bbox") + camera_height: int = Field(default=1080, gt=0) + motion_indicator: bool = Field(default=True, description="Подсвечивать рамкой при motion ON") + bbox_overlay: bool = Field(default=True, description="Рисовать bbox для object detection events") class FrigateBridgeCfg(BaseModel): @@ -31,18 +49,19 @@ class FrigateBridgeCfg(BaseModel): class FrigateBridge: - """Подписывается на frigate/* topics; transforms к overlay commands. + """Frigate MQTT subscriber → auto-overlay commands. - Phase 4a: just log events. Phase 4b will: - - на frigate//motion ON → add dim overlay на cell (highlight motion) - - на frigate/events object_detected → add rect+text overlay с label+confidence - - на event ended → remove overlay + Состояние: + _motion_overlays: cam → overlay_id (motion indicator rect) + _event_overlays: event_id → (rect_id, text_id) для bbox + label """ - def __init__(self, cfg: FrigateBridgeCfg) -> None: + def __init__(self, cfg: FrigateBridgeCfg, dispatcher: "CommandDispatcher | None" = None) -> None: self.cfg = cfg - # cam_name → mapping (для быстрого lookup) + self.dispatcher = dispatcher self._by_camera = {m.frigate_camera: m for m in cfg.mappings if m.enabled} + self._motion_overlays: dict[str, str] = {} + self._event_overlays: dict[str, tuple[str, str]] = {} def topics_to_subscribe(self) -> list[str]: if not self.cfg.enabled: @@ -50,8 +69,7 @@ class FrigateBridge: base = self.cfg.base_topic.rstrip("/") return [f"{base}/+/motion", f"{base}/events"] - def handle_message(self, topic: str, payload: str) -> None: - """Logging + future overlay generation.""" + async def handle_message(self, topic: str, payload: str) -> None: if not self.cfg.enabled: return @@ -60,39 +78,131 @@ class FrigateBridge: # frigate//motion if topic.startswith(f"{base}/") and topic.endswith("/motion"): cam = topic[len(base) + 1 : -len("/motion")] - mapping = self._by_camera.get(cam) - if mapping is None: - return - state = payload.strip().upper() - log.info( - "frigate.motion", - camera=cam, - state=state, - target=mapping.target_instance, - cell=mapping.cell, - ) - # Phase 4b: добавлять/удалять dim overlay в зависимости от state + await self._handle_motion(cam, payload.strip().upper()) return - # frigate/events — JSON с details + # frigate/events — JSON if topic == f"{base}/events": try: event = json.loads(payload) - cam = event.get("after", {}).get("camera") or event.get("before", {}).get("camera") - event_type = event.get("type", "?") - label = event.get("after", {}).get("label", "?") - mapping = self._by_camera.get(cam) if cam else None - if mapping is None: - return - log.info( - "frigate.event", - camera=cam, - type=event_type, - label=label, - target=mapping.target_instance, - cell=mapping.cell, - ) - # Phase 4b: для event_type=new → add rect+text overlay; - # для event_type=end → remove overlay except json.JSONDecodeError as e: log.warning("frigate.event_parse_fail", error=str(e)) + return + await self._handle_event(event) + + async def _handle_motion(self, cam: str, state: str) -> None: + mapping = self._by_camera.get(cam) + if mapping is None or not mapping.motion_indicator: + return + + log.info("frigate.motion", camera=cam, state=state, target=mapping.target_instance, cell=mapping.cell) + + if not self.dispatcher: + return + + ov_id = f"motion_{cam}" + + if state == "ON": + if cam in self._motion_overlays: + return # уже активен + ov = RectOverlay( + id=ov_id, + cell=mapping.cell, + x=0.0, y=0.0, w=1.0, h=1.0, + color="#FF8800", + border_only=True, + border_width=6, + z_order=10, + opacity=0.9, + ) + self._motion_overlays[cam] = ov_id + await self.dispatcher.handle(mapping.target_instance, "overlay.add", ov.model_dump_json()) + elif state == "OFF": + stored = self._motion_overlays.pop(cam, None) + if stored: + await self.dispatcher.handle(mapping.target_instance, "overlay.remove", stored) + + async def _handle_event(self, event: dict) -> None: + event_type = event.get("type", "?") + after = event.get("after") or {} + before = event.get("before") or {} + cam = after.get("camera") or before.get("camera") + if not cam: + return + + mapping = self._by_camera.get(cam) + if mapping is None or not mapping.bbox_overlay: + return + if not self.dispatcher: + return + + event_id = after.get("id") or before.get("id") + if not event_id: + return + label = after.get("label", "?") + score = after.get("score") or after.get("top_score") or 0.0 + + log.info( + "frigate.event", + camera=cam, type=event_type, label=label, score=round(score, 2), + target=mapping.target_instance, cell=mapping.cell, event_id=event_id, + ) + + if event_type in ("new", "update"): + await self._upsert_event_overlay(mapping, event_id, after, label, score) + elif event_type == "end": + await self._remove_event_overlay(mapping, event_id) + + async def _upsert_event_overlay(self, mapping, event_id: str, after: dict, label: str, score: float) -> None: + box = after.get("box") # [x1, y1, x2, y2] в абс пикселях камеры + if not box or len(box) != 4: + return + + x1, y1, x2, y2 = box + nx = x1 / mapping.camera_width + ny = y1 / mapping.camera_height + nw = (x2 - x1) / mapping.camera_width + nh = (y2 - y1) / mapping.camera_height + # Clamp на [0, 1] + nx = max(0.0, min(0.99, nx)) + ny = max(0.0, min(0.99, ny)) + nw = max(0.01, min(1.0 - nx, nw)) + nh = max(0.01, min(1.0 - ny, nh)) + + # Short id'ы — кутаем event_id чтобы влезть в 32 chars + eid_short = event_id.replace("-", "")[:8] + rect_id = f"e{eid_short}r" + text_id = f"e{eid_short}t" + + rect = RectOverlay( + id=rect_id, + cell=mapping.cell, + x=nx, y=ny, w=nw, h=nh, + color="#00FF00", + border_only=True, + border_width=3, + z_order=20, + opacity=1.0, + ) + text = TextOverlay( + id=text_id, + cell=mapping.cell, + x=nx, y=max(0.0, ny - 0.04), # над bbox + text=f"{label} {int(score * 100)}%", + font_size=20, + color="#00FF00", + z_order=21, + opacity=1.0, + ) + + self._event_overlays[event_id] = (rect_id, text_id) + await self.dispatcher.handle(mapping.target_instance, "overlay.add", rect.model_dump_json()) + await self.dispatcher.handle(mapping.target_instance, "overlay.add", text.model_dump_json()) + + async def _remove_event_overlay(self, mapping, event_id: str) -> None: + stored = self._event_overlays.pop(event_id, None) + if not stored: + return + rect_id, text_id = stored + await self.dispatcher.handle(mapping.target_instance, "overlay.remove", rect_id) + await self.dispatcher.handle(mapping.target_instance, "overlay.remove", text_id) diff --git a/controller/cuda_grid_controller/mqtt_loop.py b/controller/cuda_grid_controller/mqtt_loop.py index f898a26..b2e0d4e 100644 --- a/controller/cuda_grid_controller/mqtt_loop.py +++ b/controller/cuda_grid_controller/mqtt_loop.py @@ -123,7 +123,7 @@ class MqttLoop: # Frigate bridge if self.frigate_bridge and topic.startswith(self.frigate_bridge.cfg.base_topic + "/"): - self.frigate_bridge.handle_message(topic, payload) + await self.frigate_bridge.handle_message(topic, payload) return log.warning("mqtt.unknown_topic", topic=topic, payload=payload) diff --git a/controller/cuda_grid_controller/zmq_client.py b/controller/cuda_grid_controller/zmq_client.py index 4fe2c92..8b51455 100644 --- a/controller/cuda_grid_controller/zmq_client.py +++ b/controller/cuda_grid_controller/zmq_client.py @@ -38,14 +38,21 @@ class FFmpegZmqClient: log.info("zmq.connected", endpoint=self.endpoint) async def send_command(self, target: str, command: str, args: str | None = None) -> str: - """Отправить команду filter'у. Возвращает ответ от ffmpeg ('0 Success' / error string).""" + """Отправить команду filter'у. Возвращает ответ от ffmpeg ('0 Success' / error string). + + FFmpeg's zmq filter parses через `av_get_token` (libavfilter/f_zmq.c) — берёт + ОДИН token как arg. Если arg содержит пробелы (наш case для add_overlay), + нужно обернуть в single-quotes. av_get_token honours quoting + escape '\\''. + """ if self._sock is None: await self.connect() assert self._sock is not None cmd_str = f"{target} {command}" if args: - cmd_str = f"{cmd_str} {args}" + # Escape embedded single-quotes (rare для наших args, но safe). + escaped = args.replace("'", r"\'") + cmd_str = f"{cmd_str} '{escaped}'" log.debug("zmq.send", endpoint=self.endpoint, cmd=cmd_str) try: