feat: 拆分接口11/12,新增接口12 final / final v2 精简重绘端点
- 接口11 移除重绘,仅生成 final;接口12 (grow_v2) 负责发际线带 Flux-2 重绘 - 接口12 重绘带改为发际线外推 band_lo_mult~band_hi_mult 倍 push(默认 0.5~1.5),页面可调 - 接口12 同时产出 A 整帧重绘 与 B 局部加发+全脸美颜(beauty_alpha 可调) - 新增 grow_v2_final(整帧重绘)/ grow_v2_final_v2(B 局部+美颜)端点:仅需图片+发型 ID,其余用固化默认值(color_match 关闭) - 配套精简测试页 test_interface12_final.html / test_interface12_final_v2.html / test_interface12.html - 恢复接口11 调试页多频段与换发型可调参数、color_match 默认不勾选 - 删除旧脚本 batch_grow_v2.py / gen_report_hairline_v2.py / test_simple.html Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,156 +0,0 @@
|
||||
"""批量调用接口12(/api/v1/hairline/grow_v2)生成对比素材。
|
||||
|
||||
20 张女生照片 × 5 种发际线发型(仅非高清)= 100 张输出。
|
||||
并发 2,失败的跳过并记录原因。结果图落盘到 static/report_hairline_v2/img/,
|
||||
元数据落盘 static/report_hairline_v2/results.json,供生成报告用。
|
||||
|
||||
用法: python scripts/batch_grow_v2.py
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import httpx
|
||||
|
||||
API = "http://127.0.0.1:8187/api/v1/hairline/grow_v2"
|
||||
TOKEN = "dev-shared-secret-2026"
|
||||
CONCURRENCY = 2
|
||||
|
||||
INPUT_DIR = "/home/xsl/hair/image/test"
|
||||
OUT_DIR = "/home/xsl/hair/static/report_hairline_v2"
|
||||
IMG_DIR = os.path.join(OUT_DIR, "img")
|
||||
ORIG_DIR = os.path.join(OUT_DIR, "orig")
|
||||
|
||||
# 5 种发际线发型(= change_hair hair_id)
|
||||
HAIRSTYLES = [
|
||||
("chang_zhixian", "直线"),
|
||||
("chang_tuoyuan", "椭圆"),
|
||||
("chang_bolang", "波浪"),
|
||||
("chang_xinxing", "心形"),
|
||||
("chang_huaban", "花瓣"),
|
||||
]
|
||||
HR_OPTIONS = [(False, "nohr")] # 仅非高清
|
||||
|
||||
|
||||
def list_inputs():
|
||||
files = sorted(f for f in os.listdir(INPUT_DIR) if f.lower().endswith((".jpg", ".png")))
|
||||
return files
|
||||
|
||||
|
||||
def one_call(stem, face_file, hair_id, hair_cn, is_hr, hr_tag):
|
||||
"""调用一次接口,落盘结果图。返回结果 dict。"""
|
||||
src = os.path.join(INPUT_DIR, face_file)
|
||||
out_name = f"{stem}__{hair_id}__{hr_tag}.jpg"
|
||||
out_path = os.path.join(IMG_DIR, out_name)
|
||||
# 断点续跑:已存在的图直接跳过,不重复调用
|
||||
if os.path.exists(out_path) and os.path.getsize(out_path) > 1024:
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
||||
"out": f"img/{out_name}", "size": None,
|
||||
"ms": 0, "error": None, "skipped": True,
|
||||
}
|
||||
t0 = time.time()
|
||||
try:
|
||||
with open(src, "rb") as fh:
|
||||
files = {"image_file": (face_file, fh.read(), "image/jpeg")}
|
||||
data = {"hairline_id": hair_id, "is_hr": str(is_hr).lower()}
|
||||
with httpx.Client(timeout=180.0) as c:
|
||||
resp = c.post(API, headers={"X-Internal-Token": TOKEN}, files=files, data=data)
|
||||
j = resp.json()
|
||||
if j.get("code") != 0 or not j.get("data"):
|
||||
raise RuntimeError(f"code={j.get('code')} msg={j.get('message')}")
|
||||
b64 = j["data"]["final_base64"].split(",", 1)[1]
|
||||
raw = base64.b64decode(b64)
|
||||
with open(out_path, "wb") as fh:
|
||||
fh.write(raw)
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": True,
|
||||
"out": f"img/{out_name}", "size": j["data"].get("image_size"),
|
||||
"ms": int((time.time() - t0) * 1000), "error": None,
|
||||
}
|
||||
except Exception as ex: # noqa: BLE001
|
||||
return {
|
||||
"stem": stem, "face_file": face_file, "hair_id": hair_id, "hair_cn": hair_cn,
|
||||
"is_hr": is_hr, "hr_tag": hr_tag, "ok": False,
|
||||
"out": None, "size": None, "ms": int((time.time() - t0) * 1000),
|
||||
"error": str(ex)[:200],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(IMG_DIR, exist_ok=True)
|
||||
os.makedirs(ORIG_DIR, exist_ok=True)
|
||||
faces = list_inputs()
|
||||
print(f"输入 {len(faces)} 张脸 × {len(HAIRSTYLES)} 发型 × {len(HR_OPTIONS)} = "
|
||||
f"{len(faces)*len(HAIRSTYLES)*len(HR_OPTIONS)} 次调用,并发 {CONCURRENCY}")
|
||||
|
||||
# 1. 先把原图拷一份到 orig/(报告要用)
|
||||
import shutil
|
||||
for f in faces:
|
||||
stem = os.path.splitext(f)[0]
|
||||
dst = os.path.join(ORIG_DIR, f"{stem}.jpg")
|
||||
if not os.path.exists(dst):
|
||||
shutil.copy2(os.path.join(INPUT_DIR, f), dst)
|
||||
|
||||
# 2. 构造全部任务
|
||||
tasks = []
|
||||
for f in faces:
|
||||
stem = os.path.splitext(f)[0]
|
||||
for hair_id, hair_cn in HAIRSTYLES:
|
||||
for is_hr, hr_tag in HR_OPTIONS:
|
||||
tasks.append((stem, f, hair_id, hair_cn, is_hr, hr_tag))
|
||||
|
||||
results = []
|
||||
done = 0
|
||||
total = len(tasks)
|
||||
t_start = time.time()
|
||||
with ThreadPoolExecutor(max_workers=CONCURRENCY) as ex:
|
||||
futs = {ex.submit(one_call, *t): t for t in tasks}
|
||||
for fut in as_completed(futs):
|
||||
r = fut.result()
|
||||
results.append(r)
|
||||
done += 1
|
||||
status = "OK " if r["ok"] else "FAIL"
|
||||
if r.get("skipped"):
|
||||
print(f"[{done}/{total}] SKIP {r['stem']} {r['hair_cn']} {r['hr_tag']}")
|
||||
elif r["ok"]:
|
||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
||||
f"({r['ms']}ms)", flush=True)
|
||||
else:
|
||||
print(f"[{done}/{total}] {status} {r['stem']} {r['hair_cn']} {r['hr_tag']} "
|
||||
f"-> {r['error']}")
|
||||
|
||||
elapsed = time.time() - t_start
|
||||
ok = sum(1 for r in results if r["ok"])
|
||||
fail = len(results) - ok
|
||||
# 按稳定顺序排序,报告好看
|
||||
order = {s: i for i, s in enumerate(HAIRSTYLES)}
|
||||
hr_order = {True: 0, False: 1}
|
||||
face_order = {os.path.splitext(f)[0]: i for i, f in enumerate(faces)}
|
||||
results.sort(key=lambda r: (face_order.get(r["stem"], 0),
|
||||
order.get((r["hair_id"], r["hair_cn"]), 0),
|
||||
hr_order.get(r["is_hr"], 0)))
|
||||
|
||||
meta = {
|
||||
"total": total, "ok": ok, "fail": fail,
|
||||
"elapsed_sec": round(elapsed, 1), "concurrency": CONCURRENCY,
|
||||
"hairstyles": [{"id": h, "cn": c} for h, c in HAIRSTYLES],
|
||||
"hr_options": [{"is_hr": is_hr, "tag": tag} for is_hr, tag in HR_OPTIONS],
|
||||
"faces": [os.path.splitext(f)[0] for f in faces],
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
out = {"meta": meta, "results": results}
|
||||
with open(os.path.join(OUT_DIR, "results.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump(out, fh, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"\n完成:{ok}/{total} 成功,{fail} 失败,耗时 {elapsed:.1f}s")
|
||||
print(f"结果图 -> {IMG_DIR}")
|
||||
print(f"元数据 -> {OUT_DIR}/results.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,220 +0,0 @@
|
||||
"""根据 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:repeat({len(hairstyles)+1}, 1fr);
|
||||
gap:10px; align-items:start; }}
|
||||
.col-hdr {{ font-size:12px; color:var(--muted); text-align:center; padding:6px 4px;
|
||||
border-bottom:1px solid var(--border); }}
|
||||
.col-hdr.orig {{ color:var(--accent); }}
|
||||
.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">固定:pushed 遮罩 + multiband 融合 · mb_levels=5 · erode_cm=0.6 · 非高清</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 列为发际线生发结果(非高清)。点击任意图片可放大。</div>')
|
||||
|
||||
# 每张脸一个区块:原图 + 5 发型横向并排
|
||||
for stem in faces:
|
||||
orig_rel = f"orig/{stem}.jpg"
|
||||
parts.append('<div class="face-block">')
|
||||
parts.append(f' <div class="face-head"><h2>{html.escape(stem)}</h2></div>')
|
||||
# 网格:第一列原图,其余 5 列发型结果
|
||||
parts.append('<div class="grid">')
|
||||
# 表头
|
||||
parts.append('<div class="col-hdr orig">原图</div>')
|
||||
for h in hairstyles:
|
||||
parts.append(f'<div class="col-hdr">{html.escape(h["cn"])}<br><span style="opacity:.6">{html.escape(h["id"])}</span></div>')
|
||||
# 第一列:原图大图(可点击放大)
|
||||
parts.append(
|
||||
f'<div class="cell"><img class="zoomable" src="{url(orig_rel)}" '
|
||||
f'data-full="{url(orig_rel)}" loading="lazy" alt="原图 {html.escape(stem)}">'
|
||||
f'<div class="cap">原图</div></div>')
|
||||
# 5 发型结果(hr_opts 现在只有 nohr 一档,直接取)
|
||||
hr_tag = hr_opts[0]["tag"] if hr_opts else "nohr"
|
||||
for h in hairstyles:
|
||||
r = idx.get((stem, h["id"], hr_tag))
|
||||
if r and r["ok"]:
|
||||
ms = r["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"])}">'
|
||||
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>耗时(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["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> · 固定 pushed 遮罩 + multiband 融合 ·
|
||||
数据源 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()
|
||||
Reference in New Issue
Block a user