问题:接口2 与接口3/5 乱序调用时耗时抖动(最差 15~22s)。两个根因: 1. GPU 24G 常驻 21.4G,Flux-2(3.9G) 无法完全驻留显存,每次采样动态换页, 速度随空闲显存波动(2s~8s); 2. ComfyUI 单队列 FIFO,接口2 排在接口3/5 批量任务后面。 改动: - hairline/comfyui.py: run() 新增 front 参数,/prompt 带 "front": true 插队到队列最前; redraw.py 透传;service.py 接口2 三处调用(女重绘 + 男有/无遮罩)传 front=True, 接口3/5 仍走普通队列。 - add_hair.json / 0716add-hair-api.json: 节点61 CLIPLoader device default→cpu。 qwen CLIP(4G) 不再占显存(文本条件缓存常年命中),ComfyUI 显存 8.8G→4.5G, Flux-2 完全驻留,采样稳定 ~3-5s。代价:换 prompt 后首次请求 CPU 编码 ~11s(一次性)。 - 提示词全局统一为「填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜」: app.py 4处默认值、service.py _REDRAW_PROMPT、redraw.py _DEFAULT_PROMPT、 4个工作流节点60内置文案、测试页(test_interface2/3/7/12/12_final)、local_test。 任何两个不同 prompt 交替提交都会打爆 CLIP 编码缓存(--cache-classic 只存最近一次), 之前测试页旧文案与服务端不一致导致交替测试每次 +11s。 - app.py: 接口7 /api/v1/hair/grow-v2 下线(业务弃用;add_hair2.json 的 Klein-9b 会把常驻 Klein-4b 挤出显存)。保留 stub 返回 1007 明确报错,避免裸 404。 实测(1024 档):接口2女 8.5~10s、接口2男 ~5s、接口3 ~7-10s,交替混跑无尖刺。 Co-authored-by: Cursor <cursoragent@cursor.com> (cherry-picked from ubuntu3090 e7b62f2;已适配 main 分支代码结构:main 无 _REDRAW_PROMPT/_REDRAW_MAX_SIDE 缩图逻辑,front=True 直接加在 _call_local_redraw / generate_grow_results 的调用点;另把 main 独有的 benchmark_*.py 里的 prompt 一并统一) Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
8.8 KiB
Python
202 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
"""接口2女性5种发型对比测试:Plan B vs Plan B+。
|
||
|
||
测试 hair_style=1..5(椭圆/花瓣/心形/直线/波浪),每种发型一次,记录耗时+显存+生成图。
|
||
"""
|
||
import json, os, time, base64, subprocess, urllib.request, urllib.error
|
||
from datetime import datetime
|
||
|
||
API_BASE = "http://127.0.0.1:8187"
|
||
TOKEN = "dev-shared-secret-2026"
|
||
GIRL_IMG = "/home/ubuntu/hair/image/girl_img/girl13.jpg"
|
||
PLAN_TAG = os.getenv("PLAN_TAG", "Bplus") # Bplus / B
|
||
OUT_DIR = f"/home/ubuntu/hair/benchmark_out/iface2_female_{PLAN_TAG}"
|
||
RESULT_JSON = os.path.join(OUT_DIR, "results.json")
|
||
REPORT_HTML = os.path.join(OUT_DIR, "report.html")
|
||
|
||
HAIR_STYLE_NAMES = {
|
||
"1": "椭圆", "2": "花瓣", "3": "心形", "4": "直线", "5": "波浪",
|
||
}
|
||
|
||
os.makedirs(OUT_DIR, exist_ok=True)
|
||
|
||
|
||
def get_vram():
|
||
try:
|
||
out = subprocess.check_output(
|
||
["nvidia-smi", "--query-gpu=memory.used,memory.free",
|
||
"--format=csv,noheader,nounits"], text=True, timeout=5).strip()
|
||
used, free = out.split(",")
|
||
return int(used.strip()), int(free.strip())
|
||
except Exception:
|
||
return -1, -1
|
||
|
||
|
||
def _multipart(fields, files=None):
|
||
boundary = "----If2Test" + str(int(time.time() * 1000))
|
||
parts = []
|
||
for k, v in fields.items():
|
||
parts.append(f"--{boundary}\r\n".encode())
|
||
parts.append(f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode())
|
||
parts.append(str(v).encode())
|
||
parts.append(b"\r\n")
|
||
if files:
|
||
for field_name, (filename, data, mime) in files.items():
|
||
parts.append(f"--{boundary}\r\n".encode())
|
||
parts.append(f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'.encode())
|
||
parts.append(f"Content-Type: {mime}\r\n\r\n".encode())
|
||
parts.append(data)
|
||
parts.append(b"\r\n")
|
||
parts.append(f"--{boundary}--\r\n".encode())
|
||
return b"".join(parts), boundary
|
||
|
||
|
||
def call_iface2_female(image_path, hair_style):
|
||
"""接口2 女性 + 指定发型(走 swapHair 路径)"""
|
||
with open(image_path, "rb") as f:
|
||
img_data = f.read()
|
||
fields = {"gender": "female", "hair_style": str(hair_style),
|
||
"prompt": "填充遮罩区域的头发,皮肤加一点磨皮,再加一点美颜"}
|
||
body, boundary = _multipart(
|
||
fields, {"image_file": (os.path.basename(image_path), img_data, "image/jpeg")})
|
||
req = urllib.request.Request(f"{API_BASE}/api/v1/hair/grow", data=body, method="POST")
|
||
req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||
req.add_header("X-Internal-Token", TOKEN)
|
||
t0 = time.time()
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=600) as resp:
|
||
data = json.loads(resp.read())
|
||
return data, time.time() - t0, None
|
||
except Exception as e:
|
||
return {}, time.time() - t0, str(e)
|
||
|
||
|
||
def run_test():
|
||
results = []
|
||
print(f"接口2女性5种发型测试 — Plan={PLAN_TAG}")
|
||
print(f"图片:{GIRL_IMG}\n")
|
||
|
||
for hs in range(1, 6):
|
||
name = HAIR_STYLE_NAMES[str(hs)]
|
||
print(f"\n[{hs}/5] hair_style={hs} ({name}) ...")
|
||
|
||
vram_before, vram_free_before = get_vram()
|
||
data, elapsed, error = call_iface2_female(GIRL_IMG, hs)
|
||
vram_after, vram_free_after = get_vram()
|
||
|
||
code = data.get("code", -1) if data else -1
|
||
ok = (code == 0)
|
||
|
||
# 保存生成图(如果有)
|
||
img_path = None
|
||
if ok and data.get("data"):
|
||
try:
|
||
items = data["data"].get("results") or []
|
||
if items and isinstance(items, list):
|
||
first = items[0]
|
||
b64 = first.get("grown_image_base64") or ""
|
||
if b64.startswith("data:"):
|
||
b64 = b64.split(",", 1)[1]
|
||
if b64:
|
||
img_path = os.path.join(OUT_DIR, f"hair_style_{hs}_{name}.jpg")
|
||
with open(img_path, "wb") as f:
|
||
f.write(base64.b64decode(b64))
|
||
except Exception as e:
|
||
print(f" 保存图片失败: {e}")
|
||
|
||
record = {
|
||
"plan": PLAN_TAG,
|
||
"hair_style": hs,
|
||
"hair_style_name": name,
|
||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
||
"elapsed_s": round(elapsed, 2),
|
||
"success": ok,
|
||
"code": code,
|
||
"error": error,
|
||
"vram_before_mb": vram_before,
|
||
"vram_after_mb": vram_after,
|
||
"vram_delta_mb": vram_after - vram_before,
|
||
"image_path": img_path,
|
||
}
|
||
results.append(record)
|
||
|
||
status = "✓" if ok else "✗"
|
||
print(f" → {status} code={code} time={elapsed:.1f}s "
|
||
f"vram={vram_before}→{vram_after}MB (Δ{vram_after-vram_before:+d}) "
|
||
f"img={'saved' if img_path else 'none'}")
|
||
|
||
with open(RESULT_JSON, "w") as f:
|
||
json.dump(results, f, indent=2, ensure_ascii=False)
|
||
generate_html(results)
|
||
|
||
generate_html(results)
|
||
print(f"\n✅ 完成!{RESULT_JSON}")
|
||
print(f"📄 报告:{REPORT_HTML}")
|
||
|
||
|
||
def generate_html(results):
|
||
total = len(results)
|
||
success = sum(1 for r in results if r["success"])
|
||
times = [r["elapsed_s"] for r in results if r["success"]]
|
||
avg_time = sum(times) / len(times) if times else 0
|
||
|
||
html = f"""<!DOCTYPE html>
|
||
<html lang="zh-CN"><head><meta charset="UTF-8"><title>接口2女5种发型 - Plan {PLAN_TAG}</title>
|
||
<style>
|
||
*{{margin:0;padding:0;box-sizing:border-box}}
|
||
body{{font-family:sans-serif;background:#1a1a2e;color:#e0e0e0;padding:20px}}
|
||
h1{{text-align:center;color:#00d4ff;margin-bottom:10px}}
|
||
.subtitle{{text-align:center;color:#888;margin-bottom:30px;font-size:14px}}
|
||
.summary{{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:15px;margin-bottom:30px}}
|
||
.card{{background:#16213e;border-radius:12px;padding:20px;text-align:center;border:1px solid #333}}
|
||
.card .num{{font-size:28px;font-weight:700}}
|
||
.card .label{{font-size:12px;color:#888;margin-top:5px}}
|
||
.card.ok .num{{color:#0f0}} .card.err .num{{color:#f44}} .card.time .num{{color:#00d4ff}}
|
||
table{{width:100%;border-collapse:collapse;margin-bottom:30px;background:#16213e;border-radius:12px;overflow:hidden}}
|
||
th{{background:#0f3460;padding:10px 8px;text-align:center;font-size:13px;color:#fff}}
|
||
td{{padding:8px;text-align:center;border-bottom:1px solid #222;font-size:13px}}
|
||
tr:hover{{background:#1a1a3e}}
|
||
.section-title{{font-size:18px;font-weight:600;margin:30px 0 15px;color:#00d4ff;border-bottom:1px solid #333;padding-bottom:10px}}
|
||
.ok{{color:#0f0}} .fail{{color:#f44}}
|
||
.time-bar{{display:inline-block;height:18px;background:linear-gradient(90deg,#0f3460,#00d4ff);border-radius:3px;vertical-align:middle;min-width:2px}}
|
||
.gallery{{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:15px}}
|
||
.gallery img{{width:100%;border-radius:8px;border:1px solid #333}}
|
||
.gallery .item{{text-align:center}}
|
||
.gallery .cap{{margin-top:5px;font-size:12px;color:#888}}
|
||
</style></head><body>
|
||
<h1>📊 接口2女性5种发型测试报告</h1>
|
||
<div class="subtitle">Plan {PLAN_TAG} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {total} 次调用</div>
|
||
<div class="summary">
|
||
<div class="card ok"><div class="num">{success}</div><div class="label">成功</div></div>
|
||
<div class="card err"><div class="num">{total-success}</div><div class="label">失败</div></div>
|
||
<div class="card time"><div class="num">{avg_time:.1f}s</div><div class="label">平均耗时</div></div>
|
||
</div>
|
||
<div class="section-title">详细结果</div>
|
||
<table><thead><tr><th>发型</th><th>名称</th><th>耗时</th><th>显存前</th><th>显存后</th><th>Δ</th><th>状态</th></tr></thead><tbody>
|
||
"""
|
||
for r in results:
|
||
status = '<span class="ok">✓</span>' if r["success"] else f'<span class="fail">✗ {str(r.get("error",""))[:30]}</span>'
|
||
html += f'<tr><td>style {r["hair_style"]}</td><td>{r["hair_style_name"]}</td>' \
|
||
f'<td><div class="time-bar" style="width:{min(r["elapsed_s"]*15,200)}px"></div> {r["elapsed_s"]:.1f}s</td>' \
|
||
f'<td>{r["vram_before_mb"]}MB</td><td>{r["vram_after_mb"]}MB</td>' \
|
||
f'<td>{r["vram_delta_mb"]:+d}</td><td>{status}</td></tr>'
|
||
|
||
html += '</tbody></table>'
|
||
|
||
# 图片画廊
|
||
saved = [r for r in results if r.get("image_path") and os.path.isfile(r["image_path"])]
|
||
if saved:
|
||
html += '<div class="section-title">生成图片</div><div class="gallery">'
|
||
for r in saved:
|
||
rel = os.path.relpath(r["image_path"], OUT_DIR)
|
||
html += f'<div class="item"><img src="{rel}"><div class="cap">{r["hair_style_name"]} ({r["elapsed_s"]:.1f}s)</div></div>'
|
||
html += '</div>'
|
||
|
||
html += '</body></html>'
|
||
with open(REPORT_HTML, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
run_test()
|