Ray1ee01's picture
Upload folder using huggingface_hub
dc30f9a verified
Raw
History Blame Contribute Delete
13.1 kB
"""
为 title_styler 回归测试结果生成一个零依赖前端 (viewer.html)。
工作流:
PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline
PYTHONPATH=. python tests/title_styler_regression/run.py --run after_fix
PYTHONPATH=. python tests/title_styler_regression/viewer.py
# 然后浏览器打开 tests/title_styler_regression/viewer.html
特性:
- 扫描 results/<run>/summary.json 自动发现所有 run
- 顶部两个 dropdown 选 左/右 run, 一键切换
- 每个 case 一行: 左 PNG | 右 PNG | meta (尺寸、字号、dh)
- 支持按 |dh| 排序、按模板名 / test_id 过滤
- 仅生成一个 HTML 文件 (相对路径引用 PNG), file:// 协议可直接打开
"""
import base64
import json
import os
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
HERE = os.path.abspath(os.path.dirname(__file__))
RESULTS_ROOT = os.path.join(HERE, "results")
OUT_HTML = os.path.join(HERE, "viewer.html")
def discover_runs():
runs = {}
if not os.path.isdir(RESULTS_ROOT):
return runs
for entry in sorted(os.listdir(RESULTS_ROOT)):
run_dir = os.path.join(RESULTS_ROOT, entry)
summary_path = os.path.join(run_dir, "summary.json")
if not os.path.isfile(summary_path):
continue
with open(summary_path, encoding="utf-8") as f:
summary = json.load(f)
cases = {}
for c in summary.get("cases", []):
cid = c.get("case_id")
if not cid:
continue
png_rel = os.path.relpath(
os.path.join(run_dir, cid, "title.png"), start=HERE
)
svg_rel = os.path.relpath(
os.path.join(run_dir, cid, "title.svg"), start=HERE
)
png_exists = os.path.isfile(os.path.join(HERE, png_rel))
cases[cid] = {
"ok": c.get("ok", False),
"reason": c.get("reason"),
"png": png_rel if png_exists else None,
"svg": svg_rel,
"w": c.get("svg_width"),
"h": c.get("svg_height"),
"fonts": c.get("font_size_hist", {}),
"test_id": c.get("test_id"),
"template": c.get("template_name"),
"title": c.get("title_segments"),
"description": c.get("description"),
}
runs[entry] = {
"n_ok": summary.get("n_ok", 0),
"n_fail": summary.get("n_fail", 0),
"cases": cases,
}
return runs
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>title_styler regression viewer</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; background: #f6f7f9; color: #222; }
header { position: sticky; top: 0; background: #1f2937; color: #fff; padding: 10px 16px; z-index: 10;
box-shadow: 0 1px 4px rgba(0,0,0,.15); }
header h1 { font-size: 14px; margin: 0 0 6px; font-weight: 600; letter-spacing: .3px; }
.controls { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; font-size: 12px; }
.controls label { color: #cbd5e1; }
select, input[type=text] { background: #374151; color: #fff; border: 1px solid #4b5563;
border-radius: 4px; padding: 4px 8px; font: inherit; }
select:focus, input:focus { outline: 2px solid #60a5fa; }
.badge { background: #374151; padding: 2px 8px; border-radius: 10px; color: #e5e7eb; }
.badge.up { background: #14532d; color: #bbf7d0; }
.badge.down { background: #7f1d1d; color: #fecaca; }
.badge.zero { background: #374151; color: #9ca3af; }
main { padding: 14px; }
.summary { background: #fff; padding: 10px 14px; border-radius: 6px; margin-bottom: 12px;
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-size: 12px; color: #4b5563;
display: flex; gap: 18px; flex-wrap: wrap; }
.row { background: #fff; border-radius: 6px; margin-bottom: 12px; padding: 10px;
box-shadow: 0 1px 2px rgba(0,0,0,.06); }
.row.changed { box-shadow: 0 0 0 2px #fbbf24, 0 1px 2px rgba(0,0,0,.06); }
.row-head { display: flex; gap: 10px; align-items: center; font-weight: 600; margin-bottom: 8px;
padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; flex-wrap: wrap; }
.row-head .case-id { color: #1f2937; font-size: 13px; }
.row-head .pill { font-size: 11px; padding: 1px 6px; border-radius: 8px;
background: #eef2ff; color: #3730a3; font-weight: 500; }
.row-head .pill.tpl { background: #ecfeff; color: #155e75; }
.row-meta { font-size: 11px; color: #6b7280; font-weight: 400; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.panel { background: #fafbfc; border: 1px solid #e5e7eb; border-radius: 4px; padding: 8px; }
.panel .head { font-size: 11px; color: #6b7280; margin-bottom: 6px; display: flex; justify-content: space-between; }
.panel img { display: block; max-width: 100%; height: auto;
background-image: repeating-conic-gradient(#f3f4f6 0 25%, #fff 0 50%);
background-size: 16px 16px; }
.panel.missing { background: #fef2f2; border-color: #fecaca; color: #991b1b; padding: 18px; text-align: center; }
.fonts { font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 11px; color: #1f2937; }
details { margin-top: 6px; }
details > summary { cursor: pointer; color: #4b5563; font-size: 11px; }
details pre { background: #f3f4f6; padding: 6px 8px; border-radius: 4px; overflow: auto;
font: 11px ui-monospace, monospace; margin: 6px 0 0; }
.empty { text-align: center; color: #6b7280; padding: 60px; }
</style>
</head>
<body>
<header>
<h1>title_styler regression viewer</h1>
<div class="controls">
<label>left run <select id="runA"></select></label>
<label>right run <select id="runB"></select></label>
<label>filter <input type="text" id="filter" placeholder="template / test_id"></label>
<label>sort <select id="sort">
<option value="abs_dh_desc">|dh| desc (changed first)</option>
<option value="dh_desc">dh desc</option>
<option value="dh_asc">dh asc</option>
<option value="case_id">case_id</option>
</select></label>
<label><input type="checkbox" id="onlyChanged"> only changed (|dh|≥1)</label>
<span class="badge" id="counts"></span>
</div>
</header>
<main>
<div class="summary" id="summary"></div>
<div id="grid"></div>
</main>
<script>
const RUNS = __RUNS_JSON__;
function caseIds(run) {
return run ? Object.keys(run.cases) : [];
}
function pickDefaults() {
const names = Object.keys(RUNS);
if (names.length === 0) return [null, null];
if (names.length === 1) return [names[0], names[0]];
const afterAIdx = names.indexOf('after_fix');
const preferredB = [
'after_fix_linegap_tune',
'after_fix_bg_width',
'after_fix_desc_gap',
'after_fix_AB_anchor',
'after_fix_AB',
].find(n => names.includes(n));
const preferredBIdx = preferredB ? names.indexOf(preferredB) : -1;
const a = afterAIdx >= 0 ? names[afterAIdx] : names[0];
const b = preferredBIdx >= 0 ? names[preferredBIdx] : names[1];
return [a, b];
}
function fontsHistToString(h) {
if (!h) return '';
return Object.keys(h).sort((a,b)=>parseFloat(a)-parseFloat(b)).map(k => `${k}×${h[k]}`).join(' ');
}
function classifyDh(dh) {
if (dh > 0) return 'up';
if (dh < 0) return 'down';
return 'zero';
}
function render() {
const aName = document.getElementById('runA').value;
const bName = document.getElementById('runB').value;
const runA = RUNS[aName] || { cases: {} };
const runB = RUNS[bName] || { cases: {} };
const filterStr = document.getElementById('filter').value.trim().toLowerCase();
const sortBy = document.getElementById('sort').value;
const onlyChanged = document.getElementById('onlyChanged').checked;
const allIds = Array.from(new Set([...caseIds(runA), ...caseIds(runB)]));
let rows = allIds.map(cid => {
const a = runA.cases[cid];
const b = runB.cases[cid];
const dh = (a && b && a.ok && b.ok) ? (b.h - a.h) : 0;
return { cid, a, b, dh };
});
if (filterStr) {
rows = rows.filter(r => r.cid.toLowerCase().includes(filterStr));
}
if (onlyChanged) {
rows = rows.filter(r => Math.abs(r.dh) >= 1);
}
switch (sortBy) {
case 'abs_dh_desc': rows.sort((x,y) => Math.abs(y.dh) - Math.abs(x.dh) || x.cid.localeCompare(y.cid)); break;
case 'dh_desc': rows.sort((x,y) => y.dh - x.dh); break;
case 'dh_asc': rows.sort((x,y) => x.dh - y.dh); break;
case 'case_id': rows.sort((x,y) => x.cid.localeCompare(y.cid)); break;
}
const summaryEl = document.getElementById('summary');
const ups = rows.filter(r => r.dh > 0).length;
const downs = rows.filter(r => r.dh < 0).length;
const zeros = rows.filter(r => r.dh === 0).length;
const max = Math.max(0, ...rows.map(r => Math.abs(r.dh)));
summaryEl.innerHTML =
`<span><b>${rows.length}</b> cases shown</span>` +
`<span class="badge up">↑ taller: ${ups}</span>` +
`<span class="badge down">↓ shorter: ${downs}</span>` +
`<span class="badge zero">– unchanged: ${zeros}</span>` +
`<span>max |Δh| = <b>${max}</b>px</span>` +
`<span>left = <b>${aName}</b> · right = <b>${bName}</b></span>`;
const grid = document.getElementById('grid');
if (rows.length === 0) {
grid.innerHTML = '<div class="empty">no cases match</div>';
return;
}
grid.innerHTML = rows.map(r => renderRow(r, aName, bName)).join('');
}
function renderRow({ cid, a, b, dh }, aName, bName) {
const klass = Math.abs(dh) >= 4 ? 'row changed' : 'row';
const dhKlass = classifyDh(dh);
const dhText = dh === 0 ? '±0' : (dh > 0 ? `+${dh}` : `${dh}`);
const aFonts = a && a.fonts ? fontsHistToString(a.fonts) : '—';
const bFonts = b && b.fonts ? fontsHistToString(b.fonts) : '—';
const tpl = (a && a.template) || (b && b.template) || '';
const tid = (a && a.test_id) || (b && b.test_id) || '';
return `
<div class="${klass}">
<div class="row-head">
<span class="case-id">${cid}</span>
<span class="pill tpl">${tpl}</span>
<span class="pill">${tid}</span>
<span class="badge ${dhKlass}">Δh ${dhText}px</span>
<span class="row-meta">${a ? `left ${a.w}×${a.h}` : 'left missing'} · ${b ? `right ${b.w}×${b.h}` : 'right missing'}</span>
</div>
<div class="pair">
${renderPanel(a, aName, aFonts)}
${renderPanel(b, bName, bFonts)}
</div>
${a && a.title ? `<details><summary>show title text / description</summary>
<pre>title: ${escapeHtml(a.title)}\\ndescription: ${escapeHtml(a.description||'')}</pre></details>` : ''}
</div>`;
}
function renderPanel(rec, runName, fontsStr) {
if (!rec) return `<div class="panel missing">missing in ${runName}</div>`;
if (!rec.ok) return `<div class="panel missing">failed: ${escapeHtml(rec.reason||'')}</div>`;
if (!rec.png) return `<div class="panel missing">no png for ${runName}</div>`;
return `<div class="panel">
<div class="head"><span>${runName}</span><span class="fonts">${fontsStr}</span></div>
<img src="${rec.png}" alt="${rec.png}">
</div>`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, ch => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
})[ch]);
}
function populateSelects() {
const names = Object.keys(RUNS);
const [defA, defB] = pickDefaults();
for (const id of ['runA', 'runB']) {
const sel = document.getElementById(id);
sel.innerHTML = names.map(n => `<option value="${n}">${n}</option>`).join('');
sel.value = id === 'runA' ? defA : defB;
sel.addEventListener('change', render);
}
document.getElementById('filter').addEventListener('input', render);
document.getElementById('sort').addEventListener('change', render);
document.getElementById('onlyChanged').addEventListener('change', render);
}
if (Object.keys(RUNS).length === 0) {
document.getElementById('grid').innerHTML =
'<div class="empty">no runs found. run <code>tests/title_styler_regression/run.py --run baseline</code> first.</div>';
} else {
populateSelects();
render();
}
</script>
</body>
</html>
"""
def main():
runs = discover_runs()
if not runs:
print("no run found under tests/title_styler_regression/results/", file=sys.stderr)
print("first run:", file=sys.stderr)
print(" PYTHONPATH=. python tests/title_styler_regression/run.py --run baseline",
file=sys.stderr)
payload = json.dumps(runs, ensure_ascii=False)
html = HTML_TEMPLATE.replace("__RUNS_JSON__", payload)
with open(OUT_HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"wrote {OUT_HTML}")
if runs:
print("available runs:")
for name, r in runs.items():
print(f" - {name} ok={r['n_ok']} fail={r['n_fail']}")
print(f"\nopen in browser: file://{OUT_HTML}")
if __name__ == "__main__":
main()