diff --git a/controller/cuda_grid_controller/__main__.py b/controller/cuda_grid_controller/__main__.py index 6c2f319..a80867e 100644 --- a/controller/cuda_grid_controller/__main__.py +++ b/controller/cuda_grid_controller/__main__.py @@ -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_.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) diff --git a/controller/cuda_grid_controller/frigate_bridge.py b/controller/cuda_grid_controller/frigate_bridge.py index 3dec61a..55121b4 100644 --- a/controller/cuda_grid_controller/frigate_bridge.py +++ b/controller/cuda_grid_controller/frigate_bridge.py @@ -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_.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) diff --git a/controller/cuda_grid_controller/placeholder_renderer.py b/controller/cuda_grid_controller/placeholder_renderer.py new file mode 100644 index 0000000..3eb5f74 --- /dev/null +++ b/controller/cuda_grid_controller/placeholder_renderer.py @@ -0,0 +1,79 @@ +"""Placeholder PNG generator — рендерит per-cell offline placeholder +("CAMERA LABEL — НЕТ СИГНАЛА") при controller startup. + +Filter cuda_grid looks up "_.png" first (per-cell), +fallback к ".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 _.png для each (pad → label) entry. + Also generic .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))