|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""OpenCV screen-balance / layout metrics for a graph-canvas screenshot. |
| 3 | +
|
| 4 | +Given an image (ideally already cropped to the graph drawing area), it isolates |
| 5 | +the rendered graph content from the calm gradient background via edge density |
| 6 | +(robust to a smooth background), then reports OBJECTIVE numeric measurements of |
| 7 | +how the content is placed and how much of the canvas it uses: |
| 8 | +
|
| 9 | + centroid offset from center, bounding-box coverage, content pixel usage, |
| 10 | + left/right & top/bottom margin balance, quadrant mass distribution, and a |
| 11 | + single informational balanceScore (0-100, higher = better-centered/balanced). |
| 12 | +
|
| 13 | +These are measurements, not verdicts — thresholds/pass-fail live in the caller |
| 14 | +(Playwright / the live audit), so the same numbers can drive reports first and |
| 15 | +gates later. |
| 16 | +
|
| 17 | +Usage: |
| 18 | + python3 balance_metrics.py <image> [--annotate <out.jpg>] |
| 19 | +Prints a JSON object to stdout. |
| 20 | +""" |
| 21 | +import json |
| 22 | +import sys |
| 23 | + |
| 24 | +import cv2 |
| 25 | +import numpy as np |
| 26 | + |
| 27 | +MAX_QUAD_STD = 0.4330 # std of [1,0,0,0] — all mass in one quadrant (worst case) |
| 28 | + |
| 29 | + |
| 30 | +def measure(path, annotate=None, force="auto"): |
| 31 | + img = cv2.imread(path) |
| 32 | + if img is None: |
| 33 | + return {"error": f"could not read {path}"} |
| 34 | + h, w = img.shape[:2] |
| 35 | + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) |
| 36 | + |
| 37 | + # Background sampled from a thin border ring. If it's near-uniform (the flat |
| 38 | + # high-contrast "audit" theme), detect content by COLOUR DISTANCE from it — |
| 39 | + # accurate for solid node fills, not just edges. Otherwise fall back to edge |
| 40 | + # density, which is robust against a gradient/textured background. |
| 41 | + ring = np.concatenate([ |
| 42 | + img[:6].reshape(-1, 3), img[-6:].reshape(-1, 3), |
| 43 | + img[:, :6].reshape(-1, 3), img[:, -6:].reshape(-1, 3), |
| 44 | + ]).astype(np.int16) |
| 45 | + bg = np.median(ring, axis=0) |
| 46 | + flat = float(ring.std(axis=0).mean()) < 12.0 |
| 47 | + if force == "edge": |
| 48 | + flat = False |
| 49 | + elif force == "flat": |
| 50 | + flat = True |
| 51 | + if flat: |
| 52 | + diff = np.abs(img.astype(np.int16) - bg).max(axis=2).astype(np.uint8) |
| 53 | + mask = ((diff > 28) * 255).astype(np.uint8) |
| 54 | + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) |
| 55 | + else: |
| 56 | + edges = cv2.Canny(gray, 50, 140) |
| 57 | + mask = cv2.dilate(edges, np.ones((9, 9), np.uint8), iterations=2) |
| 58 | + ys, xs = np.where(mask > 0) |
| 59 | + |
| 60 | + out = {"image": path, "w": w, "h": h, "method": "flat" if flat else "edge", "contentPixels": int(len(xs))} |
| 61 | + if len(xs) < 200: # effectively empty canvas |
| 62 | + out.update({"empty": True, "balanceScore": None}) |
| 63 | + return out |
| 64 | + |
| 65 | + cx, cy = float(xs.mean()), float(ys.mean()) |
| 66 | + off_x = (cx - w / 2) / (w / 2) |
| 67 | + off_y = (cy - h / 2) / (h / 2) |
| 68 | + off_mag = float((off_x ** 2 + off_y ** 2) ** 0.5) |
| 69 | + |
| 70 | + x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max()) |
| 71 | + bw, bh = x1 - x0, y1 - y0 |
| 72 | + coverage = (bw * bh) / (w * h) # bbox area / canvas area |
| 73 | + usage = len(xs) / (w * h) # actual content pixels / canvas |
| 74 | + fill = len(xs) / max(1, bw * bh) # content density within its bbox |
| 75 | + |
| 76 | + left, right, top, bottom = x0, w - 1 - x1, y0, h - 1 - y1 |
| 77 | + bal_x = abs(left - right) / max(1, left + right) |
| 78 | + bal_y = abs(top - bottom) / max(1, top + bottom) |
| 79 | + |
| 80 | + q = [ |
| 81 | + int(((xs < w / 2) & (ys < h / 2)).sum()), # tl |
| 82 | + int(((xs >= w / 2) & (ys < h / 2)).sum()), # tr |
| 83 | + int(((xs < w / 2) & (ys >= h / 2)).sum()), # bl |
| 84 | + int(((xs >= w / 2) & (ys >= h / 2)).sum()), # br |
| 85 | + ] |
| 86 | + qf = [v / len(xs) for v in q] |
| 87 | + quad_imbalance = float(np.std(qf)) |
| 88 | + |
| 89 | + center_pen = min(1.0, off_mag) |
| 90 | + margin_pen = (bal_x + bal_y) / 2 |
| 91 | + quad_pen = min(1.0, quad_imbalance / MAX_QUAD_STD) |
| 92 | + score = round(100 * max(0.0, 1 - 0.45 * center_pen - 0.30 * margin_pen - 0.25 * quad_pen)) |
| 93 | + |
| 94 | + out.update({ |
| 95 | + "contentFrac": round(usage, 4), |
| 96 | + "centroid": {"x": round(cx, 1), "y": round(cy, 1), |
| 97 | + "offX": round(off_x, 3), "offY": round(off_y, 3), "offMag": round(off_mag, 3)}, |
| 98 | + "bbox": {"x": x0, "y": y0, "w": bw, "h": bh, |
| 99 | + "coverage": round(coverage, 4), "fill": round(fill, 3)}, |
| 100 | + "margins": {"left": left, "right": right, "top": top, "bottom": bottom, |
| 101 | + "balanceX": round(bal_x, 3), "balanceY": round(bal_y, 3)}, |
| 102 | + "quadrants": {"tl": round(qf[0], 3), "tr": round(qf[1], 3), |
| 103 | + "bl": round(qf[2], 3), "br": round(qf[3], 3), |
| 104 | + "imbalance": round(quad_imbalance, 3)}, |
| 105 | + "balanceScore": score, |
| 106 | + }) |
| 107 | + |
| 108 | + if annotate: |
| 109 | + vis = img.copy() |
| 110 | + cv2.rectangle(vis, (x0, y0), (x1, y1), (80, 200, 80), 2) # content bbox (green) |
| 111 | + cv2.drawMarker(vis, (int(cx), int(cy)), (80, 80, 240), cv2.MARKER_CROSS, 26, 3) # centroid (red) |
| 112 | + cv2.drawMarker(vis, (w // 2, h // 2), (240, 200, 80), cv2.MARKER_TILTED_CROSS, 22, 2) # frame center (blue) |
| 113 | + cv2.line(vis, (w // 2, 0), (w // 2, h), (90, 90, 90), 1) |
| 114 | + cv2.line(vis, (0, h // 2), (w, h // 2), (90, 90, 90), 1) |
| 115 | + label = f"score {score} | off {off_mag:.2f} | use {usage*100:.0f}% | quad {quad_imbalance:.2f}" |
| 116 | + cv2.rectangle(vis, (0, 0), (max(360, 9 * len(label)), 26), (20, 20, 20), -1) |
| 117 | + cv2.putText(vis, label, (8, 18), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (240, 240, 240), 1, cv2.LINE_AA) |
| 118 | + cv2.imwrite(annotate, vis, [cv2.IMWRITE_JPEG_QUALITY, 80]) |
| 119 | + out["annotated"] = annotate |
| 120 | + |
| 121 | + return out |
| 122 | + |
| 123 | + |
| 124 | +if __name__ == "__main__": |
| 125 | + if len(sys.argv) < 2: |
| 126 | + print(json.dumps({"error": "usage: balance_metrics.py <image> [--annotate <out.jpg>]"})) |
| 127 | + sys.exit(2) |
| 128 | + ann = None |
| 129 | + if "--annotate" in sys.argv: |
| 130 | + ann = sys.argv[sys.argv.index("--annotate") + 1] |
| 131 | + force = "flat" if "--flat" in sys.argv else "edge" if "--edge" in sys.argv else "auto" |
| 132 | + print(json.dumps(measure(sys.argv[1], ann, force))) |
0 commit comments