controller: Phase 4b end-to-end working — wire format fix + FrigateBridge auto-overlay

Fixed pipeline (live verified с ffmpeg-vf-cuda-grid:phase4b-icon):

zmq_client.send_command — wrap args в single-quotes
  FFmpeg's zmq filter parses через av_get_token (libavfilter/f_zmq.c) — берёт
  ОДИН token как arg. Без quotes filter получает только id, parse fail.

dispatch._serialize_overlay_to_zmq — translation pydantic → filter wire:
  color "#RRGGBB"     → r=N g=N b=N
  opacity 0..1.0      → opacity 0..255
  border_only + width → thickness (0=filled, иначе width)
  dim_factor 0..1.0   → amount 0..255
  text/icon_name      → URL-encoded (%20 для space)

frigate_bridge — Phase 4b auto-rendering:
  motion ON       → add RectOverlay (orange border весь cell)
  motion OFF      → remove
  event new/update → add RectOverlay (bbox) + TextOverlay (label + score%)
  event end       → remove оба
  Bbox px → normalized через configurable camera_width/height (default 1920x1080).
  Опциональные flags motion_indicator/bbox_overlay в mapping.
  Constructor принимает dispatcher для dispatch.handle("overlay.add"/"overlay.remove").

mqtt_loop._handle_message — await frigate_bridge.handle_message (теперь async).
__main__.py — передаёт dispatcher в FrigateBridge constructor.

Verified end-to-end через test pipeline:
  pydantic Overlay → _serialize_overlay_to_zmq → send_command quoted →
  ZMQ → filter parse_overlay_args → CUDA kernel render
4× add_overlay (rect border, text, dim, rect filled) — все 4 visible в output frame.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-20 19:19:47 +01:00
parent c396a47f4a
commit 96e6048b64
5 changed files with 229 additions and 67 deletions
+2 -2
View File
@@ -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)
+65 -20
View File
@@ -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)
Формат: `<id> <type> <key>=<val> <key>=<val> ...`
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: `<id> <type> <key>=<val>...`
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)
+152 -42
View File
@@ -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/<cam>/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/<cam>/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/<cam>/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)
+1 -1
View File
@@ -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)
@@ -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: