#!/usr/bin/env python3 """Programmatic visual review of screenshots: OCR geometry, contrast, palette, composition.""" import sys, subprocess, csv, io, math from PIL import Image import numpy as np SHOTS = sys.argv[1:] LANG = "eng+chi_sim" def rel_lum(arr): a = arr.astype(np.float64) / 255.0 def f(c): return np.where(c <= 0.03928, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) return 0.2126 * f(a[..., 0]) + 0.7152 * f(a[..., 1]) + 0.0722 * f(a[..., 2]) def cr(l1, l2): hi, lo = max(l1, l2), min(l1, l2) return (hi + 0.05) / (lo + 0.05) def hexc(rgb): return "#%02x%02x%02x" % tuple(int(x) for x in rgb) def ocr_lines(img_path): """Return list of (text, x,y,w,h, conf) grouped per line.""" p = subprocess.run( ["tesseract", img_path, "stdout", "-l", LANG, "--psm", "11", "tsv"], capture_output=True, text=True) rows = list(csv.DictReader(io.StringIO(p.stdout), delimiter="\t")) lines = {} for r in rows: try: conf = float(r["conf"]) except Exception: continue if conf < 35 or not r["text"].strip(): continue key = (r["block_num"], r["par_num"], r["line_num"]) e = lines.setdefault(key, {"words": [], "x0": 10**9, "y0": 10**9, "x1": -1, "y1": -1, "confs": []}) x, y, w, h = int(r["left"]), int(r["top"]), int(r["width"]), int(r["height"]) e["words"].append((r["text"], conf)) e["confs"].append(conf) e["x0"] = min(e["x0"], x); e["y0"] = min(e["y0"], y) e["x1"] = max(e["x1"], x + w); e["y1"] = max(e["y1"], y + h) out = [] for k in sorted(lines, key=lambda k: (lines[k]["y0"], lines[k]["x0"])): e = lines[k] out.append({"text": " ".join(w for w, _ in e["words"]), "bbox": (e["x0"], e["y0"], e["x1"] - e["x0"], e["y1"] - e["y0"]), "conf": sum(e["confs"]) / len(e["confs"])}) return out def analyze(path): im = Image.open(path).convert("RGB") W, H = im.size arr = np.asarray(im) L = rel_lum(arr) gray = np.asarray(im.convert("L")) print(f"\n{'='*78}\n{path} ({W}x{H})\n{'='*78}") # Global tone print(f"luminance mean={L.mean():.3f} p5={np.percentile(L,5):.3f} p50={np.percentile(L,50):.3f} " f"p95={np.percentile(L,95):.3f} | dark(<0.15)={100*(L<0.15).mean():.0f}% bright(>0.85)={100*(L>0.85).mean():.0f}%") # Palette q = im.quantize(colors=8, method=Image.Quantize.MEDIANCUT) pal = q.getpalette() counts = sorted(q.getcolors(W * H), reverse=True) tot = W * H tops = [] for cnt, idx in counts[:8]: rgb = pal[idx*3:idx*3+3] tops.append(f"{hexc(rgb)} {100*cnt/tot:.0f}%") print("palette: " + " ".join(tops)) # Composition grid: 16 cols x 10 rows -> mean luminance (0-9) and dominant hue class letter GX, GY = 16, 10 print("composition grid (mean lum 0-9; hue: R=red/warm Y=yellow/tan G=green C=cyan B=blue M=magenta K=neutral-dark N=neutral-mid W=white-ish):") hsv = np.asarray(im.convert("HSV")).astype(int) hh, ss, vv = hsv[..., 0], hsv[..., 1], hsv[..., 2] for gy in range(GY): row = "" for gx in range(GX): cell = L[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX] m = cell.mean() # dominant hue of saturated pixels ch = hh[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX] cs = ss[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX] sat = cs > 60 if m > 0.85: c = "W" elif sat.mean() > 0.12: ang = np.median(ch[sat]) * 360 / 255 c = "R" if ang < 25 or ang >= 330 else "Y" if ang < 70 else "G" if ang < 160 else "C" if ang < 200 else "B" if ang < 270 else "M" elif m > 0.45: c = "N" else: c = "K" row += str(min(9, int(m * 10))) + c print(" " + row) # Low-variance (flat) regions — candidate dead space flat = [] for gy in range(GY): for gx in range(GX): cell = gray[gy*H//GY:(gy+1)*H//GY, gx*W//GX:(gx+1)*W//GX] if cell.std() < 6: flat.append((gx, gy, round(float(cell.mean())))) print(f"flat cells (std<6): {len(flat)}/{GX*GY}" + (f" at {flat[:12]}" if flat else "")) # OCR line analysis lines = ocr_lines(path) print(f"OCR: {len(lines)} lines (conf>=35)") low_contrast, overlaps, tiny = [], [], [] boxes = [] for ln in lines: x, y, w, h = ln["bbox"] if w <= 1 or h <= 1 or x < 0 or y < 0 or x + w > W or y + h > H: continue pad = 2 x0, y0 = max(0, x - pad), max(0, y - pad) x1, y1 = min(W, x + w + pad), min(H, y + h + pad) reg = L[y0:y1, x0:x1] lo, hi = np.percentile(reg, 5), np.percentile(reg, 95) ratio = cr(hi, lo) boxes.append((x, y, w, h, ln["text"])) if h < 9 and ln["conf"] > 50: tiny.append(ln) if ratio < 4.0 and len(ln["text"].strip()) > 1: low_contrast.append((ln["text"][:40], (x, y, w, h), round(ratio, 2))) # overlap between different lines (>25% of smaller area) for i in range(len(boxes)): for j in range(i + 1, len(boxes)): ax, ay, aw, ah, at = boxes[i]; bx, by, bw, bh, bt = boxes[j] ox = max(0, min(ax+aw, bx+bw) - max(ax, bx)); oy = max(0, min(ay+ah, by+bh) - max(ay, by)) inter = ox * oy small = min(aw*ah, bw*bh) if small and inter / small > 0.30 and at != bt: overlaps.append((at[:28], bt[:28], round(100*inter/small))) if low_contrast: print("LOW CONTRAST (<4:1):") for t, b, r in low_contrast[:14]: print(f" {r:>5}:1 @({b[0]},{b[1]},{b[2]}x{b[3]}) '{t}'") if len(low_contrast) > 14: print(f" ... +{len(low_contrast)-14} more") else: print("LOW CONTRAST: none flagged") if overlaps: print("OVERLAPPING TEXT BOXES:") for a, b, pct in overlaps[:10]: print(f" {pct}% '{a}' vs '{b}'") if tiny: print(f"VERY SMALL TEXT (<9px tall): {len(tiny)} e.g. " + "; ".join(t['text'][:18] for t in tiny[:5])) # Print all OCR lines with geometry so reviewer can reconstruct layout print("--- OCR transcript (x,y w h | conf | text) ---") for ln in lines: x, y, w, h = ln["bbox"] print(f" ({x:>4},{y:>4} {w:>4}x{h:<3}|{ln['conf']:.0f}) {ln['text'][:90]}") for p in SHOTS: try: analyze(p) except Exception as e: print(f"\nFAILED {p}: {type(e).__name__}: {e}")