""" Layout quality metrics for the RL reward signal. Every metric operates on a list of element dicts in normalised [0,1] space: {"cx": float, "cy": float, "w": float, "h": float, "type": str, "font_size": float} Penalties (lower is better, target 0): overlap, boundary, occlusion. Rewards (higher is better): alignment, spacing, plausibility. Time Complexities (maybe I should fix them later): - overlap_score: O(n^2) - boundary_score: O(n) - alignment_score: O(n^2) - spacing_score: O(nlogn) - plausibility_score: O(n) - occlusion_score: O(n*h*w) - compute_all_metrics: O(n) - quality_score: O(1) """ from itertools import combinations from typing import Dict, List, Optional, Set import numpy as np # ============================================================================= # Helpers # ============================================================================= def _precompute_boxes(elements: List[Dict]) -> np.ndarray: """Convert list of element dicts to (n, 4) float32 array of [l, t, r, b].""" n = len(elements) if n == 0: return np.zeros((0, 4), dtype=np.float32) boxes = np.zeros((n, 4), dtype=np.float32) for i, e in enumerate(elements): hw = e["w"] * 0.5 hh = e["h"] * 0.5 boxes[i] = [e["cx"] - hw, e["cy"] - hh, e["cx"] + hw, e["cy"] + hh] return boxes def _to_ltrb(e: Dict) -> tuple[float, float, float, float]: """cxywh to ltrb""" hw, hh = e["w"] / 2, e["h"] / 2 return (e["cx"] - hw, e["cy"] - hh, e["cx"] + hw, e["cy"] + hh) def _area(e: Dict) -> float: return max(e["w"], 0) * max(e["h"], 0) def _axis_value(e: Dict, axis: str) -> float: l, t, r, b = _to_ltrb(e) return {"left": l, "right": r, "cx": e["cx"], "top": t, "bottom": b, "cy": e["cy"]}[axis] # ============================================================================= # Individual Metrics # ============================================================================= def overlap_score(elements: List[Dict]) -> float: """Sum of pairwise intersection / min-area. 0 = no overlap.""" if len(elements) < 2: return 0.0 total = 0.0 for a, b in combinations(elements, 2): la, ta, ra, ba_ = _to_ltrb(a) lb, tb, rb, bb_ = _to_ltrb(b) ix = max(0.0, min(ra, rb) - max(la, lb)) iy = max(0.0, min(ba_, bb_) - max(ta, tb)) inter = ix * iy if inter > 0: min_area = min(_area(a), _area(b)) total += inter / (min_area + 1e-8) n_pairs = len(elements) * (len(elements) - 1) / 2 return total / n_pairs def boundary_score(elements: List[Dict]) -> float: """Fraction of area outside [0,1]^2 per element, averaged. 0 = all inside.""" if not elements: return 0.0 boxes = _precompute_boxes(elements) areas = np.array([e["w"] * e["h"] for e in elements], dtype=np.float32) l = np.clip(boxes[:, 0], 0.0, 1.0) t = np.clip(boxes[:, 1], 0.0, 1.0) r = np.clip(boxes[:, 2], 0.0, 1.0) b = np.clip(boxes[:, 3], 0.0, 1.0) clipped_areas = np.maximum(r - l, 0.0) * np.maximum(b - t, 0.0) valid = areas > 0 ratios = np.zeros_like(areas) ratios[valid] = 1.0 - (clipped_areas[valid] / areas[valid]) return float(np.mean(ratios)) def alignment_score(elements: List[Dict], eps: float = 0.02) -> float: """Fraction of element-pairs that share an aligned edge/centre. 1 = perfect.""" if len(elements) < 2: return 1.0 axes = ["left", "right", "cx", "top", "bottom", "cy"] aligned = 0 total_pairs = 0 for axis in axes: values = [_axis_value(e, axis) for e in elements] for i, j in combinations(range(len(values)), 2): total_pairs += 1 if abs(values[i] - values[j]) < eps: aligned += 1 return aligned / total_pairs if total_pairs > 0 else 0.0 def spacing_score(elements: List[Dict]) -> float: """Consistency of vertical and horizontal gaps. 1 = perfectly uniform.""" if len(elements) < 2: return 1.0 boxes = _precompute_boxes(elements) cy = np.array([e["cy"] for e in elements]) sort_idx = np.argsort(cy) sorted_boxes = boxes[sort_idx] v_gaps = sorted_boxes[1:, 1] - sorted_boxes[:-1, 3] # top[i+1] - bottom[i] cx = np.array([e["cx"] for e in elements]) sort_idx = np.argsort(cx) sorted_boxes = boxes[sort_idx] h_gaps = sorted_boxes[1:, 0] - sorted_boxes[:-1, 2] # left[i+1] - right[i] def _consistency(gaps: np.ndarray) -> float: if len(gaps) < 2: return 1.0 mean = float(np.mean(gaps)) if abs(mean) < 1e-8: return 1.0 cv = float(np.std(gaps)) / (abs(mean) + 1e-8) return float(np.clip(1.0 - cv, 0.0, 1.0)) return (_consistency(v_gaps) + _consistency(h_gaps)) * 0.5 def plausibility_score( elements: List[Dict], stats: Optional[Dict] = None, ) -> float: """Batched Gaussian plausibility per element type. 1 = perfect match to data distribution.""" if not elements or stats is None: return 0.0 features = np.zeros((len(elements), 5), dtype=np.float32) for i, e in enumerate(elements): features[i] = [e["cx"], e["cy"], e["w"], e["h"], e.get("font_size", 0.0)] features = np.clip(features, 0.0, 1.0) type_groups: Dict[str, List[int]] = {} for i, e in enumerate(elements): etype = e.get("type") if etype in stats: type_groups.setdefault(etype, []).append(i) total_score = 0.0 total_counted = 0 for etype, indices in type_groups.items(): mu = stats[etype]["mu"] cov_inv = stats[etype]["cov_inv"] diff = features[indices] - mu # (k, 5) left = diff @ cov_inv # (k, 5) mahal = np.sqrt(np.clip(np.einsum('ij,ij->i', left, diff), 0.0, None)) total_score += float(np.sum(np.exp(-0.5 * mahal))) total_counted += len(indices) return total_score / total_counted if total_counted > 0 else 0.0 def occlusion_score( elements: List[Dict], saliency_map: Optional[np.ndarray], ) -> Optional[float]: """ Saliency-covered ratio by layout elements. 0 = no salient area covered. Returns None when saliency is unavailable/invalid so callers can apply a neutral fallback policy without biasing the reward. """ if saliency_map is None: return None sal = np.asarray(saliency_map, dtype=np.float32) if sal.ndim != 2 or sal.size == 0: return None sal = np.nan_to_num(sal, nan=0.0, posinf=0.0, neginf=0.0) sal = np.clip(sal, 0.0, None) total_saliency = float(np.sum(sal)) if total_saliency <= 1e-8: return 0.0 h, w = sal.shape mask = np.zeros((h, w), dtype=bool) boxes = _precompute_boxes(elements) for l, t, r, b in boxes: l = max(0.0, min(1.0, float(l))) t = max(0.0, min(1.0, float(t))) r = max(0.0, min(1.0, float(r))) b = max(0.0, min(1.0, float(b))) if r <= l or b <= t: continue x1 = max(0, min(w, int(np.floor(l * w)))) x2 = max(0, min(w, int(np.ceil(r * w)))) y1 = max(0, min(h, int(np.floor(t * h)))) y2 = max(0, min(h, int(np.ceil(b * h)))) if x2 > x1 and y2 > y1: mask[y1:y2, x1:x2] = True if not np.any(mask): return 0.0 return float(np.clip(np.sum(sal[mask]) / total_saliency, 0.0, 1.0)) # ============================================================================= # Composite Quality Function # ============================================================================= DEFAULT_WEIGHTS = { "overlap": 2.0, "boundary": 3.0, "occlusion": 1.0, "alignment": 1.0, "spacing": 0.5, "plausibility": 1.0, } def compute_all_metrics( elements: List[Dict], stats: Optional[Dict] = None, saliency_map: Optional[np.ndarray] = None, content_metric_names: Optional[Set[str]] = None, ) -> Dict[str, float]: """Return a dict of all individual metric scores.""" content_metric_names = content_metric_names or set() occ: Optional[float] if "occlusion" in content_metric_names: occ = occlusion_score(elements, saliency_map) else: occ = None occ_out = 0.0 if occ is None else occ return { "overlap": round(overlap_score(elements), 4), "boundary": round(boundary_score(elements), 4), "occlusion": round(occ_out, 4), "alignment": round(alignment_score(elements), 4), "spacing": round(spacing_score(elements), 4), "plausibility": round(plausibility_score(elements, stats), 4), } def quality_score( metrics: Dict[str, float], weights: Optional[Dict[str, float]] = None, ) -> float: """Composite Q(state). Higher is better.""" w = weights or DEFAULT_WEIGHTS q = ( -w["overlap"] * metrics["overlap"] - w["boundary"] * metrics["boundary"] - w["occlusion"] * metrics["occlusion"] + w["alignment"] * metrics["alignment"] + w["spacing"] * metrics["spacing"] + w["plausibility"] * metrics["plausibility"] ) return round(q, 4)