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
+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)