feat(接口2): 测试页暴露重绘分辨率选项 + 分辨率对比测试套件

- test_interface2.html: 新增「重绘分辨率」下拉(默认896/1024/768/640/不缩图0),
  仅 female 生效,append redraw_max_side 字段;male 时自动隐藏
- 新增分辨率对比测试工具 (image/compare_test, image/res_test):
  - batch_test.py: 串行批量测试脚本,支持断点续跑
  - gen_report.py: 生成自包含 HTML 对比报告(速度色阶+缩略图+点击放大)
- 两轮测试结果:
  - v1: 4图×5发型×2档=40次 (compare_test)
  - v2: 5图×3发型×5档=75次 (res_test)
  - 结论: 大图(长边>1600)原图直送比896档慢~3倍; 中小图各档差异小
- .gitignore: 忽略测试结果图/原图副本/运行日志(out/, *.log, progress.json)
This commit is contained in:
xsl
2026-07-26 22:06:27 +08:00
parent ff4019c570
commit 5101bb5f6b
12 changed files with 1583 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""把 report.json + 结果图生成为自包含 HTML 报告(支持 896 / 1024 / 原图0 三档)。"""
import json, os, html
OUT = "/home/ubuntu/hair/image/compare_test/out"
REPORT_HTML = os.path.join(OUT, "report.html")
results = json.load(open(os.path.join(OUT, "report.json")))
IMAGES = ["girl2", "girl5", "qwer", "asdf"]
STYLES = ["ellipse", "flower", "heart", "straight", "wave"]
SIDES = [
("default896", "默认 896", "#2563eb", "d"),
("side1024", "1024", "#10b981", "m"),
("origin0", "原图直送 0", "#f59e0b", "o"),
]
IMG_LONGSIDE = {"girl2": 1082, "girl5": 767, "qwer": 1678, "asdf": 1666}
STYLE_CN = {"ellipse": "椭圆", "flower": "花瓣", "heart": "心形", "straight": "直线", "wave": "波浪"}
def get(img, style, side):
for r in results:
if r["img"] == img and r["style"] == style and r["side"] == side:
return r
return None
# 总览统计(>60s 视为冷启动/异常,不进均值)
total = len(results)
ok = sum(1 for r in results if r["ok"])
COLD_S = 60.0
def avg(seq):
seq = [x for x in seq if x is not None]
return sum(seq) / len(seq) if seq else 0
def times(side, img=None):
out = []
for r in results:
if not r["ok"] or r["side"] != side:
continue
if img is not None and r["img"] != img:
continue
if r["elapsed"] >= COLD_S:
continue
out.append(r["elapsed"])
return out
cold_n = sum(1 for r in results if r["ok"] and r["elapsed"] >= COLD_S)
side_avgs = {sid: avg(times(sid)) for sid, *_ in SIDES}
all_times = [t for sid, *_ in SIDES for t in times(sid)]
max_bar = max(all_times + [1])
# 构造速度对比图数据:每图 × 三档
chart_rows = []
for img in IMAGES:
chart_rows.append((img, IMG_LONGSIDE[img], [avg(times(sid, img)) for sid, *_ in SIDES]))
def bar_svg():
bar_h = 22
gap = 12
label_w = 78
chart_w = 760
n_bars = len(SIDES)
rows = len(chart_rows)
h = rows * (bar_h * n_bars + gap) + 40
parts = [f'<svg viewBox="0 0 {label_w + chart_w + 80} {h}" class="chart">']
y = 10
for img, longside, avgs in chart_rows:
base = avgs[0] if avgs and avgs[0] else 1
for i, ((sid, label, color, _), a) in enumerate(zip(SIDES, avgs)):
yi = y + i * bar_h
w = int(a / max_bar * chart_w) if a else 0
warn = "" if (i > 0 and a > base * 1.8) else ""
parts.append(f'<rect x="{label_w}" y="{yi}" width="{w}" height="{bar_h-4}" rx="3" fill="{color}"/>')
parts.append(f'<text x="{label_w + w + 6}" y="{yi + bar_h - 10}" class="barlabel">{a:.1f}s {warn}</text>')
parts.append(f'<text x="{label_w-8}" y="{yi + bar_h - 10}" class="rowlabel" text-anchor="end">{html.escape(label)}</text>')
parts.append(f'<text x="0" y="{y + bar_h - 2}" class="imglabel">{img}<tspan class="imgside">长边{longside}</tspan></text>')
y += bar_h * n_bars + gap
parts.append("</svg>")
return "".join(parts)
def img_cell(r, cls):
if not r:
return f'<td class="{cls} fail">缺失</td>'
fname = r["key"] + ".jpg"
status = "" if r["ok"] else ""
t = f'{r["elapsed"]}s'
dim = f'{r["out_w"]}×{r["out_h"]}'
err = f'<div class="err">{html.escape(r["err"])}</div>' if r["err"] else ""
img_tag = (f'<img loading="lazy" src="{fname}" onclick="openImg(this.src)" alt="{html.escape(r["key"])}">'
if r["ok"] else '<div class="noimg">无图</div>')
return (f'<td class="{cls}"><div class="thumb">{img_tag}</div>'
f'<div class="meta">{status} {t} · {dim}</div>{err}</td>')
def compare_cards():
out = []
for img in IMAGES:
out.append(f'<div class="card"><div class="card-h">📷 {html.escape(img)} <span class="tag">原图长边 {IMG_LONGSIDE[img]}px</span></div><div class="card-b">')
heads = "".join(f'<th class="{cls}">{html.escape(label)}</th>' for _, label, _, cls in SIDES)
out.append(f'<table class="cmp"><thead><tr><th>发型</th>{heads}</tr></thead><tbody>')
for style in STYLES:
sc = STYLE_CN[style]
cells = "".join(img_cell(get(img, style, sid), cls) for sid, _, _, cls in SIDES)
out.append(f'<tr><td class="sname">{html.escape(style)}<span>{sc}</span></td>{cells}</tr>')
out.append("</tbody></table></div></div>")
return "".join(out)
# 结论
small = [img for img in IMAGES if IMG_LONGSIDE[img] <= 1100]
large = [img for img in IMAGES if IMG_LONGSIDE[img] > 1100]
def ratio_range(num_side, den_side="default896"):
ratios = []
for img in large:
den = avg(times(den_side, img))
num = avg(times(num_side, img))
if den:
ratios.append(num / den)
if not ratios:
return 0, 0
return min(ratios), max(ratios)
r1024_lo, r1024_hi = ratio_range("side1024")
r0_lo, r0_hi = ratio_range("origin0")
cold_note = f"冷启动 {cold_n} 次(≥{COLD_S:.0f}s)已从均值剔除。" if cold_n else ""
conclusion = (
f"原图长边 ≤ 1100{'/'.join(small)})时三档耗时接近;"
f"长边 > 1600{'/'.join(large)})时相对默认896"
f"<b>1024 约 {r1024_lo:.1f}~{r1024_hi:.1f}×</b>"
f"<b>原图直送约 {r0_lo:.1f}~{r0_hi:.1f}×</b>。"
f"{cold_note}画质对比见下方三列并排,点击可放大。"
)
avg896 = side_avgs.get("default896", 0)
avg1024 = side_avgs.get("side1024", 0)
avg0 = side_avgs.get("origin0", 0)
ratio1024 = avg1024 / avg896 if avg896 else 0
ratio0 = avg0 / avg896 if avg896 else 0
legend = "".join(
f'<span><i style="background:{color}"></i>{html.escape(label)}</span>'
for _, label, color, _ in SIDES
)
html_doc = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口2 分辨率对比测试报告</title>
<style>
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; background: #f3f4f6; color: #1f2937; line-height: 1.5; padding: 20px; }}
.wrap {{ max-width: 1400px; margin: 0 auto; }}
h1 {{ font-size: 24px; margin-bottom: 4px; }}
.sub {{ color: #6b7280; font-size: 13px; margin-bottom: 20px; }}
.summary {{ display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 24px; }}
.stat {{ background: #fff; border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.06); }}
.stat .num {{ font-size: 26px; font-weight: 700; }}
.stat .lbl {{ font-size: 12px; color: #6b7280; margin-top: 2px; }}
.stat.d .num {{ color: #2563eb; }}
.stat.m .num {{ color: #10b981; }}
.stat.o .num {{ color: #f59e0b; }}
.card {{ background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.07); margin-bottom: 20px; overflow: hidden; }}
.card-h {{ padding: 12px 18px; background: #fafafa; border-bottom: 1px solid #f0f0f0; font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 8px; }}
.card-h .tag {{ font-size: 11px; font-weight: 500; color: #6b7280; background: #f3f4f6; padding: 2px 8px; border-radius: 10px; }}
.card-b {{ padding: 16px; }}
.chart {{ width: 100%; height: auto; max-width: 920px; }}
.barlabel {{ font-size: 12px; fill: #374151; font-weight: 600; }}
.rowlabel {{ font-size: 11px; fill: #6b7280; }}
.imglabel {{ font-size: 14px; fill: #111; font-weight: 700; }}
.imgside {{ font-size: 10px; fill: #9ca3af; font-weight: 400; }}
table.cmp {{ width: 100%; border-collapse: collapse; table-layout: fixed; }}
table.cmp th {{ font-size: 12px; color: #6b7280; font-weight: 600; padding: 8px; text-align: center; border-bottom: 2px solid #f0f0f0; }}
table.cmp td {{ padding: 8px; border-bottom: 1px solid #f6f6f6; vertical-align: top; text-align: center; }}
table.cmp th.d, table.cmp td.d {{ background: #eff6ff; }}
table.cmp th.m, table.cmp td.m {{ background: #ecfdf5; }}
table.cmp th.o, table.cmp td.o {{ background: #fffbeb; }}
td.sname {{ font-weight: 600; text-align: left; width: 90px; }}
td.sname span {{ display: block; font-size: 11px; color: #9ca3af; font-weight: 400; }}
.thumb {{ background: #222; border-radius: 6px; overflow: hidden; margin-bottom: 4px; cursor: zoom-in; }}
.thumb img {{ width: 100%; height: 200px; object-fit: contain; display: block; }}
.noimg {{ color: #d1d5db; font-size: 12px; padding: 40px 0; }}
.meta {{ font-size: 11px; color: #6b7280; }}
.err {{ font-size: 10px; color: #dc2626; margin-top: 2px; }}
.note {{ background: #fef3c7; border-left: 3px solid #f59e0b; padding: 12px 16px; border-radius: 6px; font-size: 13px; margin-bottom: 20px; }}
.legend {{ display: flex; gap: 20px; font-size: 12px; color: #6b7280; margin-bottom: 12px; flex-wrap: wrap; }}
.legend span {{ display: inline-flex; align-items: center; gap: 5px; }}
.legend i {{ width: 12px; height: 12px; border-radius: 2px; display: inline-block; }}
.overlay {{ display: none; position: fixed; inset: 0; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; padding: 30px; }}
.overlay.active {{ display: flex; }}
.overlay img {{ max-width: 95%; max-height: 95%; object-fit: contain; border-radius: 4px; }}
@media (max-width: 900px) {{
.summary {{ grid-template-columns: repeat(2, 1fr); }}
.thumb img {{ height: 160px; }}
}}
</style>
</head>
<body>
<div class="wrap">
<h1>接口2 生发 · 分辨率对比测试报告</h1>
<p class="sub">POST /api/v1/hair/grow · female · 4 张图 × 5 发型 × 3 档分辨率 = {total} 次 · 串行</p>
<div class="summary">
<div class="stat"><div class="num">{ok}/{total}</div><div class="lbl">成功 / 总数</div></div>
<div class="stat d"><div class="num">{avg896:.1f}s</div><div class="lbl">默认896 平均</div></div>
<div class="stat m"><div class="num">{avg1024:.1f}s</div><div class="lbl">1024 平均 · ×{ratio1024:.2f}</div></div>
<div class="stat o"><div class="num">{avg0:.1f}s</div><div class="lbl">原图直送 平均 · ×{ratio0:.2f}</div></div>
<div class="stat"><div class="num" style="color:{'#dc2626' if ratio0>1.3 else '#16a34a'}">×{ratio0:.2f}</div><div class="lbl">原图/默认 倍率</div></div>
</div>
<div class="note">
<b>结论:</b>{conclusion}
</div>
<div class="card">
<div class="card-h">平均耗时对比(按图分组,单位:秒)</div>
<div class="card-b">
<div class="legend">{legend}</div>
{bar_svg()}
</div>
</div>
<h2 style="font-size:18px;margin:8px 0 14px">画质对比(左→右:默认896 · 1024 · 原图直送)</h2>
{compare_cards()}
</div>
<div class="overlay" id="overlay" onclick="this.classList.remove('active')">
<img id="overlayImg" src="">
</div>
<script>
function openImg(src) {{
document.getElementById('overlayImg').src = src;
document.getElementById('overlay').classList.add('active');
}}
</script>
</body>
</html>
"""
with open(REPORT_HTML, "w") as f:
f.write(html_doc)
# 同步首页
with open(os.path.join(OUT, "index.html"), "w") as f:
f.write(html_doc)
print(f"已生成: {REPORT_HTML}")
print(f"图片目录: {OUT}")