发际线生发遮罩算法(mask_type=pushed): - _extract_hairline:提取头发/皮肤交界线(逐列头发下沿),用 baseline 水平 y 线截断(无竖线) - _pushed_mask:以眉心(151点)为圆心逐点径向外推 push_cm,与 baseline 组闭合区域 - 径向归并锯齿用插值填补,避免遮罩碎裂 - pushed 模式过程可视化(①-f 交界线 / ①-g 外推+遮罩),eroded/closed 不展示无关步骤 multiband 金字塔融合修复(hairline_grow.py): - mb_levels 按层数膨胀外缘 keep 区,让过渡带随层数变宽(旧硬二值钳回导致 mb_levels 形同虚设) 接口12 grow_v2(固定参数精简版): - 固定 multiband/mb_levels=5/erode_cm=0.6,仅返回 final_base64 - 支持 mask_type=pushed + hairline_push_cm/hairline_edge 调试支持: - 调试页 test_interface11_debug.html(前后端日志面板 + 下载日志按钮) - hairline_grow.log 全链路日志(按 rid 关联),/api/v1/debug/hairline_log 下载接口 - 遮罩计算过程可视化(baseline/upper/头发分割/交界线/外推/最终遮罩) 文档与脚本: - docs/发际线生发遮罩算法_pushed模式.md 算法说明 - scripts/batch_grow_v2.py 批量调用、gen_report_hairline_v2.py 对比报告生成
230 lines
10 KiB
Python
230 lines
10 KiB
Python
"""根据 results.json 生成发际线生发对比报告 HTML。
|
||
|
||
报告布局(原图 vs 结果对比):
|
||
- 顶部:总览统计(成功/失败数、耗时、参数)
|
||
- 按脸分组,每张脸一个区块:
|
||
- 左:原图
|
||
- 右:5 发型 × {高清, 非高清} 网格(共 10 张),失败的格子标注原因
|
||
- 失败用例汇总表
|
||
|
||
输出:static/report_hairline_v2/index.html
|
||
"""
|
||
import html
|
||
import json
|
||
import os
|
||
from urllib.parse import quote
|
||
|
||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
||
RESULTS = os.path.join(OUT_DIR, "results.json")
|
||
TARGET = os.path.join(OUT_DIR, "index.html")
|
||
|
||
|
||
def url(path):
|
||
"""把相对路径里的中文做 URL 编码,分隔符 '/' 保留。
|
||
Starlette StaticFiles 对未编码中文路径返回 400,编码后所有浏览器稳定可加载。
|
||
"""
|
||
return "/".join(quote(seg) for seg in path.split("/"))
|
||
|
||
|
||
def main():
|
||
with open(RESULTS, encoding="utf-8") as fh:
|
||
data = json.load(fh)
|
||
meta = data["meta"]
|
||
results = data["results"]
|
||
|
||
hairstyles = meta["hairstyles"] # [{id, cn}]
|
||
faces = meta["faces"] # [stem, ...]
|
||
hr_opts = meta["hr_options"] # [{is_hr, tag}]
|
||
|
||
# 索引:(stem, hair_id, hr_tag) -> result
|
||
idx = {}
|
||
for r in results:
|
||
idx[(r["stem"], r["hair_id"], r["hr_tag"])] = r
|
||
|
||
# 统计
|
||
ok = sum(1 for r in results if r["ok"])
|
||
fail = len(results) - ok
|
||
|
||
parts = []
|
||
parts.append(f"""<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>发际线生发对比报告 · 接口12 grow_v2</title>
|
||
<style>
|
||
:root {{
|
||
--bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --txt:#e6e6e6;
|
||
--muted:#8a93a3; --accent:#6ea8fe; --ok:#4ade80; --fail:#f87171;
|
||
}}
|
||
* {{ box-sizing:border-box; }}
|
||
body {{ margin:0; background:var(--bg); color:var(--txt);
|
||
font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
||
line-height:1.5; }}
|
||
header {{ padding:24px 32px; border-bottom:1px solid var(--border); }}
|
||
header h1 {{ margin:0 0 8px; font-size:22px; }}
|
||
header .sub {{ color:var(--muted); font-size:14px; }}
|
||
.stats {{ display:flex; gap:16px; flex-wrap:wrap; margin-top:16px; }}
|
||
.stat {{ background:var(--card); border:1px solid var(--border); border-radius:8px;
|
||
padding:12px 16px; min-width:120px; }}
|
||
.stat .n {{ font-size:24px; font-weight:600; }}
|
||
.stat .l {{ font-size:12px; color:var(--muted); }}
|
||
.stat.ok .n {{ color:var(--ok); }}
|
||
.stat.fail .n {{ color:var(--fail); }}
|
||
main {{ padding:24px 32px; }}
|
||
.face-block {{ background:var(--card); border:1px solid var(--border);
|
||
border-radius:12px; padding:20px; margin-bottom:24px; }}
|
||
.face-head {{ display:flex; align-items:center; gap:12px; margin-bottom:16px; }}
|
||
.face-head h2 {{ margin:0; font-size:18px; }}
|
||
.face-head .orig-thumb {{ width:64px; height:64px; object-fit:cover;
|
||
border-radius:8px; border:1px solid var(--border); }}
|
||
.grid {{ display:grid; grid-template-columns:140px repeat({len(hairstyles)*2}, 1fr);
|
||
gap:8px; align-items:start; }}
|
||
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
|
||
border-bottom:1px solid var(--border); }}
|
||
.row-lbl {{ font-size:13px; color:var(--accent); padding:8px 4px;
|
||
border-right:1px solid var(--border); }}
|
||
.cell {{ position:relative; }}
|
||
.cell img {{ width:100%; border-radius:6px; display:block;
|
||
border:1px solid var(--border); }}
|
||
.cell .cap {{ font-size:11px; color:var(--muted); margin-top:4px; text-align:center; }}
|
||
.cell.fail .failbox {{ aspect-ratio:3/4; background:#2a1518; border:1px solid #5c2a30;
|
||
border-radius:6px; display:flex; align-items:center;
|
||
justify-content:center; padding:8px; text-align:center;
|
||
font-size:11px; color:var(--fail); }}
|
||
.tag {{ display:inline-block; font-size:11px; padding:1px 6px; border-radius:4px;
|
||
background:#243044; color:var(--accent); margin-left:6px; }}
|
||
.tag.no {{ background:#3a3526; color:#e0c97a; }}
|
||
.legend {{ font-size:13px; color:var(--muted); margin-bottom:16px; }}
|
||
.fail-table {{ width:100%; border-collapse:collapse; font-size:13px; margin-top:8px; }}
|
||
.fail-table th, .fail-table td {{ border:1px solid var(--border); padding:6px 10px; text-align:left; }}
|
||
.fail-table th {{ background:#1f232c; color:var(--muted); }}
|
||
.anchor {{ display:block; height:0; overflow:hidden; }}
|
||
footer {{ padding:24px 32px; color:var(--muted); font-size:12px; border-top:1px solid var(--border); }}
|
||
/* 点击放大 */
|
||
img.zoomable {{ cursor:zoom-in; transition:opacity .12s; }}
|
||
img.zoomable:hover {{ opacity:.85; }}
|
||
#lightbox {{ position:fixed; inset:0; background:rgba(0,0,0,.92); display:none;
|
||
align-items:center; justify-content:center; z-index:9999; padding:24px;
|
||
cursor:zoom-out; }}
|
||
#lightbox.open {{ display:flex; }}
|
||
#lightbox img {{ max-width:100%; max-height:100%; object-fit:contain;
|
||
border-radius:8px; box-shadow:0 8px 40px rgba(0,0,0,.6); }}
|
||
#lightbox .lb-cap {{ position:absolute; bottom:16px; left:0; right:0; text-align:center;
|
||
color:var(--muted); font-size:13px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>发际线生发对比报告 <span class="tag">接口12 · grow_v2</span></h1>
|
||
<div class="sub">固定参数:blend_method=multiband · mb_levels=5 · erode_cm=0.6 · 生成后端=swaphair</div>
|
||
<div class="sub">生成时间:{html.escape(meta['generated_at'])} · 并发 {meta['concurrency']} · 耗时 {meta['elapsed_sec']}s</div>
|
||
<div class="stats">
|
||
<div class="stat"><div class="n">{meta['total']}</div><div class="l">总调用</div></div>
|
||
<div class="stat ok"><div class="n">{ok}</div><div class="l">成功</div></div>
|
||
<div class="stat fail"><div class="n">{fail}</div><div class="l">失败</div></div>
|
||
<div class="stat"><div class="n">{len(faces)}</div><div class="l">人脸数</div></div>
|
||
<div class="stat"><div class="n">{len(hairstyles)}</div><div class="l">发型数</div></div>
|
||
</div>
|
||
</header>
|
||
<main>
|
||
""")
|
||
|
||
# 图例
|
||
parts.append('<div class="legend">每张脸横向为 5 种发际线发型,每种发型分 '
|
||
'<span class="tag">高清 is_hr=true</span>'
|
||
'<span class="tag no">非高清 is_hr=false</span> 两列。</div>')
|
||
|
||
# 每张脸一个区块
|
||
for stem in faces:
|
||
orig_rel = f"orig/{stem}.jpg"
|
||
parts.append('<div class="face-block">')
|
||
parts.append(f' <div class="face-head">'
|
||
f'<img class="orig-thumb zoomable" src="{url(orig_rel)}" '
|
||
f'data-full="{url(orig_rel)}" alt="原图 {html.escape(stem)}">'
|
||
f'<h2>{html.escape(stem)}</h2>'
|
||
f'</div>')
|
||
# 网格表头
|
||
parts.append('<div class="grid">')
|
||
parts.append('<div class="col-hdr"></div>')
|
||
for h in hairstyles:
|
||
parts.append(f'<div class="col-hdr" style="grid-column:span 2;">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
|
||
# 行:原图占满左侧第一列的高度由第一行承载;每行 = 一个 hr 选项
|
||
for hro in hr_opts:
|
||
hr_tag = hro["tag"]
|
||
is_hr = hro["is_hr"]
|
||
tag_cls = "" if is_hr else " no"
|
||
tag_txt = "高清" if is_hr else "非高清"
|
||
parts.append(f'<div class="row-lbl">{tag_txt}<span class="tag{tag_cls}">{hr_tag}</span></div>')
|
||
for h in hairstyles:
|
||
r = idx.get((stem, h["id"], hr_tag))
|
||
if r and r["ok"]:
|
||
ms = r["ms"]
|
||
cap = f"{html.escape(h['cn'])} · {tag_txt} · {ms}ms"
|
||
parts.append(
|
||
f'<div class="cell"><img class="zoomable" src="{url(r["out"])}" '
|
||
f'data-full="{url(r["out"])}" loading="lazy" '
|
||
f'alt="{html.escape(stem)} {html.escape(h["cn"])} {hr_tag}">'
|
||
f'<div class="cap">{ms}ms</div></div>')
|
||
else:
|
||
err = (r or {}).get("error", "未执行")
|
||
parts.append(
|
||
f'<div class="cell fail"><div class="failbox">{html.escape(err)}</div></div>')
|
||
parts.append('</div>') # grid
|
||
parts.append('</div>') # face-block
|
||
|
||
# 失败汇总
|
||
fails = [r for r in results if not r["ok"]]
|
||
if fails:
|
||
parts.append('<div class="face-block">')
|
||
parts.append(f'<h2>失败用例({len(fails)})</h2>')
|
||
parts.append('<table class="fail-table"><thead><tr>'
|
||
'<th>人脸</th><th>发型</th><th>高清</th><th>耗时(ms)</th><th>原因</th>'
|
||
'</tr></thead><tbody>')
|
||
for r in fails:
|
||
parts.append(
|
||
f'<tr><td>{html.escape(r["stem"])}</td>'
|
||
f'<td>{html.escape(r["hair_cn"])}</td>'
|
||
f'<td>{r["hr_tag"]}</td>'
|
||
f'<td>{r["ms"]}</td>'
|
||
f'<td>{html.escape(r["error"] or "")}</td></tr>')
|
||
parts.append('</tbody></table></div>')
|
||
|
||
parts.append(f"""
|
||
<footer>
|
||
接口:<code>POST /api/v1/hairline/grow_v2</code> ·
|
||
固定 multiband / mb_levels=5 / erode_cm=0.6 ·
|
||
数据源 results.json · 点击任意图片可放大查看
|
||
</footer>
|
||
</main>
|
||
<div id="lightbox"><img><div class="lb-cap"></div></div>
|
||
<script>
|
||
(function(){{
|
||
var lb=document.getElementById('lightbox'),lbImg=lb.querySelector('img'),
|
||
lbCap=lb.querySelector('.lb-cap');
|
||
function open(src,cap){{
|
||
lbImg.src=src; lbCap.textContent=cap||''; lb.classList.add('open');
|
||
}}
|
||
function close(){{ lb.classList.remove('open'); lbImg.src=''; }}
|
||
document.addEventListener('click',function(e){{
|
||
var t=e.target.closest('img.zoomable');
|
||
if(t){{ open(t.dataset.full||t.src, t.alt||''); }}
|
||
else if(e.target===lb||e.target===lbImg){{ close(); }}
|
||
}});
|
||
document.addEventListener('keydown',function(e){{
|
||
if(e.key==='Escape') close();
|
||
}});
|
||
}})();
|
||
</script>
|
||
</body>
|
||
</html>""")
|
||
|
||
with open(TARGET, "w", encoding="utf-8") as fh:
|
||
fh.write("".join(parts))
|
||
print(f"报告已生成:{TARGET}")
|
||
print(f"成功 {ok}/{meta['total']},失败 {fail}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|