chore: import upstream snapshot with attribution
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
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
This commit is contained in:
Executable
+193
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Quantitative quality metrics for Jcode Desktop inline widgets.
|
||||
|
||||
Captures widget states headlessly and scores each card from its PNG plus the
|
||||
geometry in the capture manifest:
|
||||
|
||||
bottom_clip ink fraction in the last few px above the card's bottom
|
||||
border. Sliced glyphs leave ink touching the boundary;
|
||||
clean cards end with padding. Lower is better.
|
||||
right_clip same for the right border (text running off the card).
|
||||
row_fit |content_height mod line_height| in px, normalized to
|
||||
[0, 0.5*lh]. Non-zero means the card cannot end on a whole
|
||||
text row.
|
||||
edge_margin min distance in px from any ink to the card border
|
||||
(should be >= ~4px padding).
|
||||
|
||||
Usage:
|
||||
scripts/widget_quality.py [--size WxH] [--states a,b,c] [--json]
|
||||
|
||||
Exit code 1 if any state fails a budget.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("error: pillow required", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_BIN = ROOT / "target" / "debug" / "jcode-desktop"
|
||||
WIDGET_STATES = [
|
||||
"hotkey-help",
|
||||
"model-picker",
|
||||
"session-info",
|
||||
"session-switcher",
|
||||
"slash-suggestions",
|
||||
]
|
||||
|
||||
# Budgets: a card fails when any metric exceeds these.
|
||||
BOTTOM_CLIP_BUDGET = 0.02 # <=2% ink density in the clip band
|
||||
RIGHT_CLIP_BUDGET = 0.02
|
||||
EDGE_MARGIN_BUDGET = 3.0 # ink should keep >=3px from the border
|
||||
ROW_CUT_BUDGET = 0.15 # clip window must end within 0.15 rows of a whole row
|
||||
CLIP_BAND_PX = 4 # band width inspected inside each border
|
||||
|
||||
|
||||
def luminance_ink(img: Image.Image, background_estimate: int = 16) -> Image.Image:
|
||||
"""Binary ink mask: pixels notably darker than their card background."""
|
||||
gray = img.convert("L")
|
||||
# Card backgrounds are near-white (>=230); text is dark (<=120).
|
||||
return gray.point(lambda v: 255 if v < 180 else 0)
|
||||
|
||||
|
||||
def analyze(image_path: Path, geometry: dict) -> dict:
|
||||
img = Image.open(image_path)
|
||||
card = geometry["card"]
|
||||
line_height = geometry["line_height"]
|
||||
text_top = geometry["text_top"]
|
||||
clip_bottom = geometry["visible_text_bottom"]
|
||||
clip_right = geometry.get("visible_text_right")
|
||||
x0 = max(int(card["x"]), 0)
|
||||
y0 = max(int(card["y"]), 0)
|
||||
x1 = min(int(card["x"] + card["width"]), img.width)
|
||||
y1 = min(int(card["y"] + card["height"]), img.height)
|
||||
if x1 - x0 < 8 or y1 - y0 < 8:
|
||||
return {"error": "card rect out of bounds"}
|
||||
|
||||
ink = luminance_ink(img.crop((x0, y0, x1, y1)))
|
||||
width, height = ink.size
|
||||
pixels = ink.load()
|
||||
|
||||
def band_density(band) -> float:
|
||||
bx0, by0, bx1, by1 = band
|
||||
bx0 = max(bx0, 0); by0 = max(by0, 0)
|
||||
bx1 = min(bx1, width); by1 = min(by1, height)
|
||||
total = max((bx1 - bx0) * (by1 - by0), 1)
|
||||
hits = sum(
|
||||
1
|
||||
for yy in range(by0, by1)
|
||||
for xx in range(bx0, bx1)
|
||||
if pixels[xx, yy]
|
||||
)
|
||||
return hits / total
|
||||
|
||||
# Glyphs sliced by the text clip line leave ink right at the boundary.
|
||||
clip_y = int(clip_bottom - card["y"])
|
||||
bottom_clip = band_density((0, clip_y - CLIP_BAND_PX, width, clip_y))
|
||||
if clip_right is not None:
|
||||
clip_x = int(clip_right - card["x"])
|
||||
right_clip = band_density((clip_x - CLIP_BAND_PX, 0, clip_x, height))
|
||||
else:
|
||||
right_clip = band_density((width - 4 - CLIP_BAND_PX, 0, width - 4, height))
|
||||
|
||||
# Fraction of a text row that the visible clip window cuts through.
|
||||
visible_rows = (clip_bottom - text_top) / line_height
|
||||
row_cut = visible_rows - int(visible_rows)
|
||||
row_cut = min(row_cut, 1.0 - row_cut) # distance to nearest whole row
|
||||
|
||||
# Min ink margin to the card border itself.
|
||||
bbox = ink.getbbox()
|
||||
if bbox:
|
||||
_, _, ink_right, ink_bottom = bbox
|
||||
edge_margin = min(height - ink_bottom, width - ink_right)
|
||||
else:
|
||||
edge_margin = min(width, height)
|
||||
|
||||
failures = []
|
||||
if bottom_clip > BOTTOM_CLIP_BUDGET:
|
||||
failures.append(f"bottom_clip {bottom_clip:.3f} > {BOTTOM_CLIP_BUDGET} (glyphs sliced at clip line)")
|
||||
if right_clip > RIGHT_CLIP_BUDGET:
|
||||
failures.append(f"right_clip {right_clip:.3f} > {RIGHT_CLIP_BUDGET} (text cut at right edge)")
|
||||
if row_cut > ROW_CUT_BUDGET:
|
||||
failures.append(f"row_cut {row_cut:.2f} rows > {ROW_CUT_BUDGET} (clip not row-aligned)")
|
||||
if edge_margin < EDGE_MARGIN_BUDGET:
|
||||
failures.append(f"edge_margin {edge_margin}px < {EDGE_MARGIN_BUDGET}px")
|
||||
|
||||
return {
|
||||
"bottom_clip": round(bottom_clip, 4),
|
||||
"right_clip": round(right_clip, 4),
|
||||
"row_cut": round(row_cut, 3),
|
||||
"edge_margin_px": edge_margin,
|
||||
"failures": failures,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--binary", type=Path, default=DEFAULT_BIN)
|
||||
parser.add_argument("--size", default=None)
|
||||
parser.add_argument("--states", default=",".join(WIDGET_STATES))
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
states = [s for s in args.states.split(",") if s]
|
||||
results = {}
|
||||
failed = False
|
||||
with tempfile.TemporaryDirectory(prefix="jcode-widget-quality-") as tmp:
|
||||
out = Path(tmp)
|
||||
for state in states:
|
||||
cmd = [
|
||||
str(args.binary),
|
||||
"--capture-gallery-screens", str(out),
|
||||
"--gallery-state", state,
|
||||
]
|
||||
if args.size:
|
||||
cmd += ["--capture-size", args.size]
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
if proc.returncode != 0:
|
||||
results[state] = {"error": proc.stderr.strip()[:200]}
|
||||
failed = True
|
||||
continue
|
||||
manifest = json.loads(proc.stdout)
|
||||
screen = manifest["screens"][0]
|
||||
geometry = screen.get("inline_widget")
|
||||
if not geometry:
|
||||
results[state] = {"error": "no inline widget geometry"}
|
||||
failed = True
|
||||
continue
|
||||
metrics = analyze(out / screen["file"], geometry)
|
||||
results[state] = metrics
|
||||
if metrics.get("failures") or metrics.get("error"):
|
||||
failed = True
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2))
|
||||
else:
|
||||
for state, metrics in results.items():
|
||||
if "error" in metrics:
|
||||
print(f"FAIL {state:20s} {metrics['error']}")
|
||||
continue
|
||||
status = "FAIL" if metrics["failures"] else "ok "
|
||||
print(
|
||||
f"{status} {state:20s} bottom_clip={metrics['bottom_clip']:.3f} "
|
||||
f"right_clip={metrics['right_clip']:.3f} "
|
||||
f"row_cut={metrics['row_cut']:.2f} "
|
||||
f"edge_margin={metrics['edge_margin_px']}px"
|
||||
)
|
||||
for failure in metrics["failures"]:
|
||||
print(f" - {failure}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user