Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

323 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""UI efficiency analyzer for jcode iOS screenshots.
Turns "this looks ugly" into hill-climbable numbers. Given a PNG screenshot
(from `xcrun simctl io ... screenshot`), it scores the rendered UI on
objective axes and prints a scorecard plus an overall 0-100 efficiency score.
Axes (each 0-100, higher is better):
space use of the available canvas: fill ratio, vertical balance,
and the size of the largest empty "dead zone".
consistency visual discipline: how few distinct dominant colors, and how
well content aligns to a small set of left margins.
legibility text/background contrast (WCAG-style) for the brightest content.
rhythm whether vertical gaps between content rows snap to an 8pt grid.
It is deliberately renderer-agnostic: it reads pixels, not the view tree, so
the same tool grades any screenshot and regressions are caught without trust
in the code. Pair with ui_lint.py (source discipline) for full coverage.
Usage:
python3 ui_metrics.py SHOT.png [--scale 3] [--json] [--annotate OUT.png]
python3 ui_metrics.py --baseline a.png --candidate b.png # compare two
"""
import argparse
import json
import sys
from dataclasses import dataclass, asdict
import numpy as np
from PIL import Image, ImageDraw
# --- Design tokens (must mirror Sources/JCodeMobile/Theme.swift) -------------
TOKENS = {
"background": 0x0F0F14,
"surface": 0x1A1A1F,
"surfaceElevated": 0x242429,
"mint": 0x4DD9A6,
"warning": 0xF59E0B,
"error": 0xD94D59,
}
# iPhone status bar / home indicator are OS chrome, not our UI. Trim them so we
# grade the app's content area, not Apple's clock.
STATUS_BAR_FRAC = 0.055
HOME_INDICATOR_FRAC = 0.025
def hex_to_rgb(h):
return np.array([(h >> 16) & 0xFF, (h >> 8) & 0xFF, h & 0xFF], dtype=np.float64)
def relative_luminance(rgb):
srgb = rgb / 255.0
lin = np.where(srgb <= 0.03928, srgb / 12.92, ((srgb + 0.055) / 1.055) ** 2.4)
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]
def contrast_ratio(a, b):
la, lb = relative_luminance(a), relative_luminance(b)
hi, lo = max(la, lb), min(la, lb)
return (hi + 0.05) / (lo + 0.05)
@dataclass
class Scorecard:
space: float
consistency: float
legibility: float
rhythm: float
overall: float
# raw measurements for debugging / regression diffing
fill_ratio: float
vertical_balance: float
dead_zone_frac: float
dominant_colors: int
margin_groups: int
text_contrast: float
rhythm_snap: float
def render(self):
lines = [
"UI efficiency scorecard",
"=" * 40,
f" space {bar(self.space)} {self.space:5.1f}",
f" consistency {bar(self.consistency)} {self.consistency:5.1f}",
f" legibility {bar(self.legibility)} {self.legibility:5.1f}",
f" rhythm {bar(self.rhythm)} {self.rhythm:5.1f}",
"-" * 40,
f" OVERALL {bar(self.overall)} {self.overall:5.1f}",
"",
"raw:",
f" fill_ratio {self.fill_ratio:.3f} (content / canvas)",
f" vertical_balance {self.vertical_balance:.3f} (1=centered)",
f" dead_zone_frac {self.dead_zone_frac:.3f} (largest empty band)",
f" dominant_colors {self.dominant_colors}",
f" margin_groups {self.margin_groups} (distinct left edges)",
f" text_contrast {self.text_contrast:.2f}:1 (WCAG)",
f" rhythm_snap {self.rhythm_snap:.3f} (gap->8pt grid)",
]
return "\n".join(lines)
def bar(v, width=20):
filled = int(round(v / 100 * width))
return "[" + "#" * filled + "." * (width - filled) + "]"
def clamp(v, lo=0.0, hi=100.0):
return max(lo, min(hi, v))
def analyze(path, scale=3, annotate=None):
img = Image.open(path).convert("RGB")
arr = np.asarray(img, dtype=np.float64)
H, W, _ = arr.shape
# Trim OS chrome.
top = int(H * STATUS_BAR_FRAC)
bot = int(H * (1 - HOME_INDICATOR_FRAC))
content = arr[top:bot]
ch, cw, _ = content.shape
bg = hex_to_rgb(TOKENS["background"])
# Per-pixel distance from the app background.
dist = np.linalg.norm(content - bg, axis=2)
is_content = dist > 18.0 # tolerance for AA + jpeg-ish noise
# --- SPACE -------------------------------------------------------------
fill_ratio = float(is_content.mean())
row_occ = is_content.mean(axis=1) # fraction of content per row
ys = np.arange(ch)
occ_sum = row_occ.sum()
if occ_sum > 0:
com = float((ys * row_occ).sum() / occ_sum) / ch # 0..1 center of mass
else:
com = 0.5
vertical_balance = 1.0 - abs(com - 0.5) * 2.0
# Largest contiguous "empty" band (rows with <1% content).
empty = row_occ < 0.01
dead = longest_run(empty) / ch
# Space score: reward filling, centering, and penalize a huge dead zone.
# An ideal chat fills ~35-65% with content reasonably spread.
fill_score = 100 * (1 - abs(fill_ratio - 0.45) / 0.45)
space = clamp(0.45 * clamp(fill_score) + 0.35 * (vertical_balance * 100)
+ 0.20 * (100 * (1 - dead)))
# --- CONSISTENCY -------------------------------------------------------
# Distinct dominant colors: quantize to 5 bits/channel, count buckets that
# cover >0.4% of content pixels. Clean designs use few.
cont_px = content[is_content]
if len(cont_px):
q = (cont_px // 8).astype(np.int64)
keys = q[:, 0] * 1024 + q[:, 1] * 32 + q[:, 2]
_, counts = np.unique(keys, return_counts=True)
dominant = int((counts > 0.004 * len(cont_px)).sum())
else:
dominant = 0
# 4-9 dominant colors is healthy; more = noisy, fewer = empty.
color_score = 100 * (1 - clamp(abs(dominant - 7) / 12, 0, 1))
# Left-margin alignment: leftmost content x per row, clustered.
margins = leftmost_edges(is_content)
margin_groups = cluster_count(margins, tol=int(8 * scale))
# A disciplined layout uses 1-3 left margins.
margin_score = 100 * (1 - clamp((margin_groups - 2) / 6, 0, 1))
consistency = clamp(0.5 * color_score + 0.5 * margin_score)
# --- LEGIBILITY --------------------------------------------------------
# Brightest content (text-ish) contrast vs background.
if len(cont_px):
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
lum = cont_px @ np.array([0.299, 0.587, 0.114], dtype=np.float64)
bright = cont_px[lum > np.percentile(lum, 90)]
sample = bright.mean(axis=0) if len(bright) else cont_px.mean(axis=0)
text_contrast = contrast_ratio(sample, bg)
else:
text_contrast = 1.0
# WCAG AA body text wants >= 4.5:1; AAA 7:1. Map 1->0, 7->100.
legibility = clamp((text_contrast - 1.0) / (7.0 - 1.0) * 100)
# --- RHYTHM ------------------------------------------------------------
# Gaps between content bands; reward snapping to an 8pt grid.
bands = content_bands(row_occ, thresh=0.01)
gaps = [bands[i + 1][0] - bands[i][1] for i in range(len(bands) - 1)]
grid = 8 * scale
if gaps:
snap = np.mean([1 - min(abs((g % grid)), grid - (g % grid)) / (grid / 2)
for g in gaps if g > 0]) if any(g > 0 for g in gaps) else 0.0
snap = float(max(0.0, snap))
else:
snap = 0.0
rhythm = clamp(snap * 100)
overall = clamp(0.40 * space + 0.30 * consistency
+ 0.20 * legibility + 0.10 * rhythm)
card = Scorecard(
space=round(space, 1), consistency=round(consistency, 1),
legibility=round(legibility, 1), rhythm=round(rhythm, 1),
overall=round(overall, 1),
fill_ratio=round(fill_ratio, 4), vertical_balance=round(vertical_balance, 4),
dead_zone_frac=round(dead, 4), dominant_colors=dominant,
margin_groups=margin_groups, text_contrast=round(text_contrast, 2),
rhythm_snap=round(snap, 4),
)
if annotate:
draw_overlay(img.copy(), top, bot, is_content, bands, annotate, scale)
return card
def longest_run(boolean_arr):
best = run = 0
for v in boolean_arr:
run = run + 1 if v else 0
best = max(best, run)
return best
def leftmost_edges(mask):
edges = []
for row in mask:
idx = np.argmax(row)
if row[idx]:
edges.append(int(idx))
return edges
def cluster_count(values, tol):
if not values:
return 0
vals = sorted(values)
groups = 1
anchor = vals[0]
# weight by frequency: only count a cluster if it has enough rows
from collections import Counter
c = Counter(values)
centers = []
for v in sorted(c):
if not centers or v - centers[-1] > tol:
centers.append(v)
# keep clusters covering >2% of rows
total = len(values)
significant = 0
bucket = {}
for v in values:
placed = False
for center in centers:
if abs(v - center) <= tol:
bucket[center] = bucket.get(center, 0) + 1
placed = True
break
return sum(1 for center, n in bucket.items() if n > 0.02 * total)
def content_bands(row_occ, thresh):
bands = []
start = None
for i, v in enumerate(row_occ):
if v >= thresh and start is None:
start = i
elif v < thresh and start is not None:
bands.append((start, i))
start = None
if start is not None:
bands.append((start, len(row_occ)))
# merge tiny gaps
return [b for b in bands if b[1] - b[0] > 3]
def draw_overlay(img, top, bot, mask, bands, out, scale):
d = ImageDraw.Draw(img)
d.rectangle([0, top, img.width - 1, bot], outline=(77, 217, 166), width=2)
for (s, e) in bands:
d.rectangle([0, top + s, img.width - 1, top + e],
outline=(245, 158, 11), width=1)
img.save(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("path", nargs="?")
ap.add_argument("--scale", type=int, default=3, help="device px per point")
ap.add_argument("--json", action="store_true")
ap.add_argument("--annotate", help="write an annotated PNG to this path")
ap.add_argument("--baseline")
ap.add_argument("--candidate")
args = ap.parse_args()
if args.baseline and args.candidate:
a = analyze(args.baseline, args.scale)
b = analyze(args.candidate, args.scale)
print(f"baseline overall {a.overall:5.1f}")
print(f"candidate overall {b.overall:5.1f}")
delta = b.overall - a.overall
sign = "+" if delta >= 0 else ""
print(f"delta {sign}{delta:.1f}")
for k in ["space", "consistency", "legibility", "rhythm"]:
av, bv = getattr(a, k), getattr(b, k)
dd = bv - av
s = "+" if dd >= 0 else ""
print(f" {k:12} {av:5.1f} -> {bv:5.1f} ({s}{dd:.1f})")
sys.exit(0 if delta >= -0.5 else 1)
if not args.path:
ap.error("provide a screenshot path, or --baseline/--candidate")
card = analyze(args.path, args.scale, args.annotate)
if args.json:
print(json.dumps(asdict(card), indent=2))
else:
print(card.render())
if __name__ == "__main__":
main()