| """ |
| 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 |
|
|
|
|
| |
| |
| |
| 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] |
|
|
|
|
| |
| |
| |
| 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] |
|
|
| 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] |
|
|
| 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 |
| left = diff @ cov_inv |
| 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)) |
|
|
|
|
| |
| |
| |
| 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) |
|
|