"""20-camera mosaic + framed tiles with name placeholders for the KMS wall.""" from __future__ import annotations from typing import Iterable, Optional import cv2 import numpy as np ROLES = ("grid", "speaker", "screenshare") # BGRA CANVAS_BG = (10, 10, 10, 255) CELL_BG = (18, 18, 18, 255) EMPTY_FILL = (36, 36, 36, 255) FRAME_COLOR = (196, 196, 196, 255) FRAME_LIVE = (168, 168, 168, 255) LABEL_BG = (12, 12, 12, 255) LABEL_FG = (245, 245, 245, 255) NAME_FG = (220, 220, 220, 255) def camera_ids(n: int = 20, prefix: str = "cam") -> list[str]: return [f"{prefix}-{i:02d}" for i in range(1, n + 1)] def is_camera_identity(identity: str, prefix: str = "cam") -> bool: ident = (identity or "").strip() p = prefix.rstrip("-") return ident.startswith(p + "-") or ident.startswith(p) def display_name(identity: str) -> str: """Human label for a camera identity (cam-07 -> CAM-07).""" return (identity or "").strip().upper() or "CAMERA" def letterbox_bgra(src: np.ndarray, tw: int, th: int) -> np.ndarray: """Scale BGRA `src` into tw x th, preserving aspect (black bars).""" if tw < 1 or th < 1: raise ValueError("tile size must be positive") if src.ndim != 3 or src.shape[2] not in (3, 4): raise ValueError(f"expected HxWx3/4, got {src.shape}") h, w = src.shape[:2] if src.shape[2] == 3: bgra = cv2.cvtColor(src, cv2.COLOR_BGR2BGRA) else: bgra = src scale = min(tw / w, th / h) nw = max(1, int(round(w * scale))) nh = max(1, int(round(h * scale))) resized = cv2.resize(bgra, (nw, nh), interpolation=cv2.INTER_AREA) out = np.zeros((th, tw, 4), dtype=np.uint8) y = (th - nh) // 2 x = (tw - nw) // 2 out[y : y + nh, x : x + nw] = resized return out def bgra_from_livekit(data: memoryview | bytes, width: int, height: int) -> np.ndarray: arr = np.frombuffer(data, dtype=np.uint8) return arr.reshape((height, width, 4)).copy() def placeholder( width: int, height: int, lines: list[str], *, color: tuple[int, int, int, int] = (24, 24, 24, 255), ) -> np.ndarray: img = np.zeros((height, width, 4), dtype=np.uint8) img[:] = color if not lines: return img font = cv2.FONT_HERSHEY_SIMPLEX scale = max(0.5, min(width / 960.0, height / 540.0) * 1.2) thick = 2 if scale >= 0.8 else 1 sizes = [cv2.getTextSize(t, font, scale, thick)[0] for t in lines] total_h = sum(s[1] for s in sizes) + 12 * (len(lines) - 1) y = (height - total_h) // 2 + sizes[0][1] for text, (tw, th) in zip(lines, sizes): x = max(8, (width - tw) // 2) cv2.putText(img, text, (x, y), font, scale, (240, 240, 240, 255), thick, cv2.LINE_AA) y += th + 12 return img def _fit_text(text: str, max_w: int, max_h: int) -> tuple[float, int, tuple[int, int]]: font = cv2.FONT_HERSHEY_SIMPLEX scale = max(0.35, min(max_w / 220.0, max_h / 36.0)) thick = 2 if scale >= 0.7 else 1 for _ in range(8): (tw, th), _ = cv2.getTextSize(text, font, scale, thick) if tw <= max_w and th <= max_h: return scale, thick, (tw, th) scale *= 0.85 if scale < 0.3: break (tw, th), _ = cv2.getTextSize(text, font, scale, thick) return scale, thick, (tw, th) def _draw_centered(img: np.ndarray, text: str, box: tuple[int, int, int, int], color=NAME_FG) -> None: x0, y0, x1, y1 = box w, h = max(1, x1 - x0), max(1, y1 - y0) scale, thick, (tw, th) = _fit_text(text, w - 8, h - 6) x = x0 + max(2, (w - tw) // 2) y = y0 + max(th + 2, (h + th) // 2) cv2.putText(img, text, (x, y), cv2.FONT_HERSHEY_SIMPLEX, scale, color, thick, cv2.LINE_AA) class CameraGrid: """5x4 (default) mosaic of framed camera tiles with name placeholders.""" def __init__( self, width: int = 1920, height: int = 1080, cols: int = 5, rows: int = 4, identities: Optional[Iterable[str]] = None, gap: int = 8, border: int = 3, label_h: int = 28, ) -> None: if cols < 1 or rows < 1: raise ValueError("cols/rows must be >= 1") self.width = int(width) self.height = int(height) self.cols = int(cols) self.rows = int(rows) self.gap = max(0, int(gap)) self.border = max(1, int(border)) self.label_h = max(16, int(label_h)) self.identities = list(identities) if identities is not None else camera_ids(cols * rows) self.tile_w = max(32, (self.width - self.gap * (self.cols + 1)) // self.cols) self.tile_h = max(32, (self.height - self.gap * (self.rows + 1)) // self.rows) self.inner_w = max(1, self.tile_w - 2 * self.border) self.inner_h = max(1, self.tile_h - 2 * self.border - self.label_h) self._live: set[str] = set() self._tiles: dict[str, np.ndarray] = {} for ident in self.identities: self._tiles[ident] = self._compose_cell(ident, None) self._canvas = np.zeros((self.height, self.width, 4), dtype=np.uint8) def _compose_cell(self, identity: str, inner: Optional[np.ndarray]) -> np.ndarray: tw, th = self.tile_w, self.tile_h b = self.border cell = np.empty((th, tw, 4), dtype=np.uint8) cell[:] = CELL_BG frame = FRAME_LIVE if inner is not None else FRAME_COLOR cv2.rectangle(cell, (0, 0), (tw - 1, th - 1), frame, b) # video / placeholder region iy0, ix0 = b, b iy1, ix1 = b + self.inner_h, b + self.inner_w if inner is not None: fitted = letterbox_bgra(inner, self.inner_w, self.inner_h) cell[iy0:iy1, ix0:ix1] = fitted else: cell[iy0:iy1, ix0:ix1] = EMPTY_FILL _draw_centered(cell, display_name(identity), (ix0, iy0, ix1, iy1)) # name bar under the feed, inside the frame ly0 = th - b - self.label_h cell[ly0 : th - b, b : tw - b] = LABEL_BG _draw_centered( cell, display_name(identity), (b + 2, ly0, tw - b - 2, th - b), color=LABEL_FG, ) return cell def set_frame(self, identity: str, bgra: np.ndarray) -> None: if identity not in self._tiles: return self._live.add(identity) self._tiles[identity] = self._compose_cell(identity, bgra) def clear(self, identity: str) -> None: if identity not in self._tiles: return self._live.discard(identity) self._tiles[identity] = self._compose_cell(identity, None) def render(self) -> bytes: canvas = self._canvas canvas[:] = CANVAS_BG g = self.gap for i, ident in enumerate(self.identities): r, c = divmod(i, self.cols) if r >= self.rows: break y = g + r * (self.tile_h + g) x = g + c * (self.tile_w + g) tile = self._tiles[ident] canvas[y : y + self.tile_h, x : x + self.tile_w] = tile return canvas.tobytes()