Files
hair/scripts/gen_report_hairline_v2.py
T
xsl 41bb164a52 接口11/12:新增发际线外推遮罩(pushed)模式 + multiband融合修复 + 调试日志 + 对比报告脚本
发际线生发遮罩算法(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 对比报告生成
2026-07-11 22:23:57 +08:00

230 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""根据 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()