controller: placeholder_renderer — dynamic PNG generation для per-cell labels

При startup controller iterate'т FrigateBridge mappings и рендерит:
  - offline_<cell>.png с camera label из placeholder_label (или frigate_camera
    как fallback) — для каждого pad
  - offline.png generic — для cells без mapping

Filter cuda_grid looks up "offline_<pad>.png" first per-cell, fallback к
"offline.png". User меняет yaml placeholder_label → restart controller →
PNGs регенерируются. Никаких ручных PNG manipulations.

Russian text supported (PIL + freetype, vs single-byte path в filter).

Pattern:
  frigate.mappings:
    - frigate_camera: parking_overview
      placeholder_label: Парковка
      cell: 0
    ...

При stale input на cell 0 → filter blits offline_0.png с "Парковка — НЕТ
СИГНАЛА" (текст желтый, transparent bg — filter сам fill'ит black за иконкой).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
gx
2026-05-25 12:01:06 +01:00
parent d29f3f96e5
commit f5ea2e3005
3 changed files with 97 additions and 0 deletions
@@ -16,6 +16,7 @@ from .dispatch import CommandDispatcher
from .browser_overlays import BrowserRenderer, DashboardCfg
from .dynamic_overlays import ChartCfg, ChatCfg, DynamicRenderer
from .pipeline_monitor import PipelineMonitor
from .placeholder_renderer import generate_placeholders
from .frigate_bridge import FrigateBridge, FrigateBridgeCfg
from .http_api import create_app
from .mqtt_loop import MqttLoop
@@ -55,6 +56,19 @@ async def _run(cfg: Config) -> None:
fcfg = FrigateBridgeCfg.model_validate(cfg.frigate)
if fcfg.enabled:
frigate_bridge = FrigateBridge(fcfg, dispatcher=dispatcher)
# Generate per-cell placeholder PNGs based на mappings — filter
# blits offline_<cell>.png когда input pad stale. Empty
# placeholder_label → fallback к frigate_camera name.
labels = {
m.cell: (m.placeholder_label or m.frigate_camera)
for m in fcfg.mappings if m.enabled
}
if labels:
try:
generate_placeholders(Path(cfg.icon_dir), labels)
except Exception as e:
structlog.get_logger().warning(
"placeholders.gen_fail", error=str(e))
except Exception as e:
structlog.get_logger().warning(
"frigate_bridge.config_invalid", error=str(e)
@@ -37,6 +37,10 @@ class FrigateCameraMapping(BaseModel):
frigate_camera: str
target_instance: str
cell: int = Field(default=0, description="Cell index в layout куда рисовать bbox")
placeholder_label: str = Field(default="",
description="Текст label на placeholder при stale ('Парковка', 'Ворота'). "
"Empty → используется frigate_camera как label. Controller "
"рендерит PNG offline_<cell>.png при startup.")
enabled: bool = True
camera_width: int = Field(default=1920, gt=0, description="Native camera resolution для нормализации bbox")
camera_height: int = Field(default=1080, gt=0)
@@ -0,0 +1,79 @@
"""Placeholder PNG generator — рендерит per-cell offline placeholder
("CAMERA LABEL — НЕТ СИГНАЛА") при controller startup.
Filter cuda_grid looks up "<placeholder_icon>_<pad>.png" first (per-cell),
fallback к "<placeholder_icon>.png" generic. Этот module генерирует
per-cell PNG с camera label из FrigateBridge config — user меняет label
в yaml, controller restarts, PNGs регенерируются.
Поддерживает UTF-8 (Russian text) через PIL freetype — недоступно в filter's
basic FT_Get_Char_Index path (single-byte only).
"""
from __future__ import annotations
from pathlib import Path
import structlog
from PIL import Image, ImageDraw, ImageFont
log = structlog.get_logger()
_FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
try:
return ImageFont.truetype(_FONT_PATH, size=size)
except OSError:
return ImageFont.load_default()
def render_placeholder(
label: str,
width: int = 480,
height: int = 120,
) -> Image.Image:
"""2-line transparent PNG: camera label (top, yellow) + 'НЕТ СИГНАЛА' (bottom, gray).
Transparent bg — filter заполняет cell black, потом blit'ит этот PNG поверх."""
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
label_font = _font(36)
sub_font = _font(28)
bbox_l = draw.textbbox((0, 0), label, font=label_font)
lw, lh = bbox_l[2] - bbox_l[0], bbox_l[3] - bbox_l[1]
label_y = height // 2 - lh - 4
draw.text(((width - lw) // 2, label_y),
label, fill=(255, 230, 100, 255), font=label_font)
sub_text = "НЕТ СИГНАЛА"
bbox_s = draw.textbbox((0, 0), sub_text, font=sub_font)
sw, sh = bbox_s[2] - bbox_s[0], bbox_s[3] - bbox_s[1]
sub_y = height // 2 + 4
draw.text(((width - sw) // 2, sub_y),
sub_text, fill=(220, 220, 220, 230), font=sub_font)
return img
def generate_placeholders(icon_dir: Path, labels: dict[int, str],
base_name: str = "offline") -> None:
"""Generates <base>_<pad>.png для each (pad → label) entry.
Also generic <base>.png (fallback когда pad index без custom label)."""
icon_dir.mkdir(parents=True, exist_ok=True)
for pad, label in labels.items():
img = render_placeholder(label)
path = icon_dir / f"{base_name}_{pad}.png"
tmp = path.with_suffix(".png.tmp")
img.save(tmp, "PNG")
tmp.replace(path)
log.info("placeholder.rendered", pad=pad, label=label, path=str(path))
img = render_placeholder("СИГНАЛ ПОТЕРЯН")
path = icon_dir / f"{base_name}.png"
tmp = path.with_suffix(".png.tmp")
img.save(tmp, "PNG")
tmp.replace(path)
log.info("placeholder.rendered_generic", path=str(path))