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