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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user