"""Placement: find low-variance regions and resolve a position to a pixel box. Low-variance regions (sky, walls, uniform backgrounds) hide faint text best and also give the VLM a clean patch to read. We rank a small grid of candidate boxes by local variance and a position prior. """ from __future__ import annotations import numpy as np from PIL import Image from veil_pgd.types import Position REFERENCE = 1024 # canonical size specs are defined against def _position_anchor(position: Position, w: int, h: int, bw: int, bh: int) -> tuple[int, int]: """Top-left pixel for a text box of size (bw, bh) at a named position.""" pad = int(0.03 * max(w, h)) xmid = (w - bw) // 2 ymid = (h - bh) // 2 table = { "center": (xmid, ymid), "top_middle": (xmid, pad), "bottom_middle": (xmid, h - bh - pad), "top_left": (pad, pad), "top_right": (w - bw - pad, pad), "bottom_left": (pad, h - bh - pad), "bottom_right": (w - bw - pad, h - bh - pad), } return table.get(position, table["bottom_middle"]) def resolve_box( image: Image.Image, position: Position, box_w: int, box_h: int ) -> tuple[int, int, int, int]: """Return (x0, y0, x1, y1) for the overlay text box, clamped to the image.""" w, h = image.size x0, y0 = _position_anchor(position, w, h, box_w, box_h) x0 = max(0, min(x0, w - box_w)) y0 = max(0, min(y0, h - box_h)) return x0, y0, x0 + box_w, y0 + box_h def region_variance(image: Image.Image, box: tuple[int, int, int, int]) -> float: """Mean per-channel variance inside a box (lower = flatter = stealthier).""" crop = np.asarray(image.convert("RGB").crop(box), dtype=np.float32) if crop.size == 0: return float("inf") return float(crop.reshape(-1, 3).var(axis=0).mean()) def rank_positions( image: Image.Image, positions: list[Position], box_w: int, box_h: int ) -> list[tuple[Position, float]]: """Rank candidate positions by ascending region variance.""" scored = [] for p in positions: box = resolve_box(image, p, box_w, box_h) scored.append((p, region_variance(image, box))) return sorted(scored, key=lambda kv: kv[1]) def scale_px(font_px: int, image: Image.Image) -> int: """Scale a reference (1024px) font size to the true image's short side.""" short = min(image.size) return max(6, round(font_px * short / REFERENCE))