feat(调试): 接口2女性生发分步耗时分析页面

新增 /api/v1/debug/grow-timing 调试接口 + debug_grow_timing.html 测试页:
- 拆分接口2女性生发的每一步并独立计时(extract_context/mask/swap/blend/comfyui)
- 测试页用条形图展示各步耗时占比,附步骤说明
- 用于定位性能瓶颈,优化方向判断

实测花瓣发型: 总11.9s = 预处理0.44s + 换发型4.4s + ComfyUI重绘6.8s
This commit is contained in:
xsl
2026-07-26 16:12:32 +08:00
parent ff4019c570
commit d318efcff0
2 changed files with 340 additions and 0 deletions
+137
View File
@@ -813,6 +813,143 @@ async def hair_grow(
return err(1007, f"处理失败:{ex}") return err(1007, f"处理失败:{ex}")
# ---------------------------------------------------------------------------
# 调试接口:接口2 女性生发 分步计时
# ---------------------------------------------------------------------------
@app.post(
"/api/v1/debug/grow-timing",
summary="调试-接口2女性生发分步计时",
tags=["调试"],
include_in_schema=False,
)
async def debug_grow_timing(
image_file: Optional[UploadFile] = File(default=None),
image_url: Optional[str] = Form(default=None),
image_base64: Optional[str] = Form(default=None),
hair_style: str = Form(default="2", description="发型序号(花瓣=2),逗号分隔多选"),
):
"""单图跑接口2女性生发,返回每个步骤的耗时 + 结果图,用于定位性能瓶颈。
步骤拆分:
1. extract_context:人脸关键点检测 + 头发分割 + 发际线几何
2. [每个发型] generate_hairline_redraw
2a. compute_mask:发际线遮罩计算
2b. _call_swap:调 change_hair 换发型(内含 webui SD1.5 推理,远程或本机)
2c. _composite:接缝融合(多频段/羽化)
3. [每个发型] _call_local_redraw:调本机 ComfyUI 用 Flux.2 重绘
"""
import time as _time
from fastapi.concurrency import run_in_threadpool
raw, e = await resolve_image_bytes(image_file, image_url, image_base64)
if e is not None:
return e
image = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持(仅 JPG / PNG)")
try:
max_styles = 5
hair_styles = _parse_hair_styles(hair_style, max_styles)
if hair_styles is None:
return err(1007, f"hair_style 必须为 1..{max_styles}")
t_total0 = _time.perf_counter()
timings = {"total_ms": 0, "extract_context_ms": 0, "per_hairstyle": []}
# 步骤1: extract_context
t0 = _time.perf_counter()
from hairline.service import extract_context as _ec, _call_local_redraw, _REDRAW_MAX_SIDE # noqa
from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError # noqa
from face_analysis.head_mask import SEGFORMER_HAIR # noqa
ctx = await run_in_threadpool(_ec, image)
timings["extract_context_ms"] = int((_time.perf_counter() - t0) * 1000)
if ctx is None:
return err(1001, "无法识别人像")
hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)
h, w = image.shape[:2]
eff_side = _REDRAW_MAX_SIDE
redraw_img, hair_mask_redraw = image, hair_mask_reuse
downscale_info = None
if eff_side > 0 and max(h, w) > eff_side:
from hairline.service import _downscale_max_side
redraw_img, _rs = _downscale_max_side(image, eff_side)
_nh, _nw = redraw_img.shape[:2]
if hair_mask_redraw is not None:
hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
interpolation=cv2.INTER_NEAREST).astype(bool)
downscale_info = {"from": f"{w}x{h}", "to": f"{_nw}x{_nh}", "max_side": eff_side}
textures_map = None
from hairline.service import get_texture_map, _FEMALE_KEY_TO_CHANG, load_texture_rgba, build_overlay_layer, load_ext_mesh
textures = get_texture_map()["female"]
items = [(s, textures[s - 1]) for s in hair_styles]
for order, (key, white_path) in items:
hs_t0 = _time.perf_counter()
entry = {"hairline_type": key, "order": order}
chang_id = _FEMALE_KEY_TO_CHANG.get(key)
entry["chang_id"] = chang_id
entry["ok"] = False
entry["error"] = None
entry["grown_b64"] = None
if chang_id is None:
entry["error"] = f"无对应 chang_id"
timings["per_hairstyle"].append(entry)
continue
try:
# 2a/2b/2c: generate_hairline_redraw (内部含 mask+swap+blend)
t0 = _time.perf_counter()
data = await run_in_threadpool(
generate_hairline_redraw, redraw_img, chang_id,
hair_mask=hair_mask_redraw, **_V2_FINAL_DEFAULTS)
t_redraw_pipeline = _time.perf_counter() - t0
_tm = data.get("timings_ms") or {}
entry["mask_ms"] = _tm.get("mask", 0)
entry["swap_ms"] = _tm.get("swap", 0)
entry["blend_ms"] = _tm.get("blend", 0)
entry["redraw_pipeline_ms"] = int(t_redraw_pipeline * 1000)
steps = data.get("steps") or {}
final_b64 = steps.get("final_base64") or ""
mask_b64 = steps.get("redraw_band_mask_base64") or ""
if not final_b64 or not mask_b64:
entry["error"] = f"final/遮罩缺失(final={len(final_b64)} mask={len(mask_b64)})"
timings["per_hairstyle"].append(entry)
continue
if final_b64.startswith("data:"):
final_b64 = final_b64.split(",", 1)[1]
if mask_b64.startswith("data:"):
mask_b64 = mask_b64.split(",", 1)[1]
# 3: ComfyUI 重绘
t0 = _time.perf_counter()
grown_png = await run_in_threadpool(
_call_local_redraw,
base64.b64decode(final_b64), base64.b64decode(mask_b64))
entry["comfyui_redraw_ms"] = int((_time.perf_counter() - t0) * 1000)
if grown_png:
entry["grown_b64"] = "data:image/jpeg;base64," + _png_to_jpg_b64(grown_png)
entry["ok"] = True
else:
entry["error"] = "ComfyUI 重绘返回空"
except NoFaceError:
entry["error"] = "未检出人脸"
except Exception as ex: # noqa: BLE001
entry["error"] = str(ex)[:150]
entry["hairstyle_total_ms"] = int((_time.perf_counter() - hs_t0) * 1000)
timings["per_hairstyle"].append(entry)
timings["total_ms"] = int((_time.perf_counter() - t_total0) * 1000)
timings["downscale"] = downscale_info
timings["image_size"] = f"{w}x{h}"
return ok(timings)
except Exception as ex: # noqa: BLE001
logger.exception("debug/grow-timing 异常")
return err(1007, f"处理失败:{ex}")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的 # 接口 7:C 端生发 v2 —— 已弃用(add_hair2.json 用 Klein-9b 大模型,会把常驻的
# Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。 # Klein-4b/Flux 挤出显存,导致接口2/3/5 耗时抖动;且业务已不再调用)。
+203
View File
@@ -0,0 +1,203 @@
<!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, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; padding: 20px; }
h1 { font-size: 20px; margin-bottom: 4px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; }
.card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 16px; }
.card-header { font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; border-radius: 10px 10px 0 0; }
.card-body { padding: 18px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.file-input input[type=file] { padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; width: 280px; }
.form-group { display: flex; align-items: center; gap: 6px; }
.form-group label { font-size: 13px; font-weight: 600; white-space: nowrap; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.btn { padding: 10px 28px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-top: 12px; display: none; }
.status.info { background: #dbeafe; color: #1e40af; display: block; }
.status.error { background: #fee2e2; color: #991b1b; display: block; }
.status.success { background: #d1fae5; color: #065f46; display: block; }
.total-bar { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.total-bar .big { font-size: 32px; font-weight: 800; color: #2563eb; }
.total-bar .lbl { font-size: 14px; color: #6b7280; }
.hs-block { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; }
.hs-title { font-weight: 700; font-size: 14px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
.hs-title .badge { background: #2563eb; color: #fff; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.hs-title .tag { font-size: 11px; color: #9ca3af; }
/* 耗时条形图 */
.step-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; font-size: 13px; }
.step-name { width: 240px; flex-shrink: 0; color: #374151; }
.step-name .desc { display: block; font-size: 11px; color: #9ca3af; margin-top: 1px; }
.step-bar-wrap { flex: 1; background: #f3f4f6; border-radius: 4px; height: 22px; position: relative; min-width: 200px; }
.step-bar { height: 100%; border-radius: 4px; display: flex; align-items: center; padding-left: 8px; color: #fff; font-size: 11px; font-weight: 600; min-width: 2px; }
.step-time { width: 70px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; flex-shrink: 0; }
/* 颜色 */
.c-ctx { background: #6366f1; } /* extract_context 紫 */
.c-mask { background: #10b981; } /* mask 绿 */
.c-swap { background: #f59e0b; } /* swap 橙(最大头) */
.c-blend { background: #06b6d4; } /* blend 青 */
.c-comfy { background: #ef4444; } /* comfyui 红(大头) */
.c-other { background: #9ca3af; } /* 其他 灰 */
.result-imgs { display: flex; gap: 12px; margin-top: 10px; }
.result-imgs .slot { text-align: center; }
.result-imgs .slot .lbl { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.result-imgs .slot img { max-height: 180px; border-radius: 6px; border: 1px solid #e5e7eb; }
.legend { font-size: 12px; color: #6b7280; margin-top: 8px; }
.json-panel { max-height: 300px; overflow: auto; background: #1e293b; border-radius: 8px; }
.json-panel pre { padding: 14px; color: #e2e8f0; font-family: "SF Mono", monospace; font-size: 12px; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<h1>⏱️ 接口2女性生发 — 分步耗时分析</h1>
<p class="subtitle">上传图片 → 跑一次女性生发 → 展示每步耗时,定位性能瓶颈</p>
<div class="card">
<div class="card-body">
<div class="upload-row">
<div class="file-input"><input type="file" id="imageFile" accept="image/jpeg,image/png"></div>
<div class="form-group">
<label>发型</label>
<select id="hairStyle">
<option value="1">1 椭圆</option>
<option value="2" selected>2 花瓣</option>
<option value="3">3 心形</option>
<option value="4">4 直线</option>
<option value="5">5 波浪</option>
</select>
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 开始分析</button>
</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div id="resultsArea" class="hidden">
<div class="card">
<div class="card-header">📊 耗时分析</div>
<div class="card-body" id="timingBody"></div>
</div>
<div class="card">
<div class="card-header">📋 原始 JSON</div>
<div class="json-panel"><pre id="jsonContent"></pre></div>
</div>
</div>
<script>
const API_BASE = window.location.origin;
const HAIR_NAMES = {1:'椭圆 ellipse',2:'花瓣 flower',3:'心形 heart',4:'直线 straight',5:'波浪 wave'};
function $(id){ return document.getElementById(id); }
function setStatus(t,type){ const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
async function submitTest(){
let f = $('imageFile').files[0];
if(!f){ setStatus('请选择图片','error'); return; }
f = await window.downscaleImageFile(f);
$('submitBtn').disabled=true; $('submitBtn').textContent='⏳ 分析中...';
setStatus('⏳ 正在跑完整生发流程(约10-30秒)...','info');
$('resultsArea').classList.add('hidden');
const fd = new FormData();
fd.append('image_file', f);
fd.append('hair_style', $('hairStyle').value);
try{
const r = await fetch(API_BASE+'/api/v1/debug/grow-timing',{
method:'POST', headers:{'X-Internal-Token':'dev-shared-secret-2026'}, body:fd});
const j = await r.json();
$('jsonContent').textContent = JSON.stringify(j, null, 2);
$('resultsArea').classList.remove('hidden');
if(j.code===0 && j.data){
renderTiming(j.data);
setStatus('✅ 分析完成,总耗时 '+(j.data.total_ms/1000).toFixed(1)+'s','success');
} else {
setStatus('❌ code='+j.code+' '+j.message,'error');
}
}catch(e){
setStatus('❌ '+e.message,'error');
}finally{
$('submitBtn').disabled=false; $('submitBtn').textContent='🚀 开始分析';
}
}
function renderTiming(data){
// 找所有发型里最大的单步耗时,作为条形图基准
let maxMs = data.extract_context_ms || 1;
(data.per_hairstyle||[]).forEach(hs=>{
['mask_ms','swap_ms','blend_ms','comfyui_redraw_ms','redraw_pipeline_ms','hairstyle_total_ms'].forEach(k=>{
if(hs[k] && hs[k]>maxMs) maxMs = hs[k];
});
});
let html = '';
// 总耗时
html += '<div class="total-bar"><span class="big">'+(data.total_ms/1000).toFixed(2)+'s</span><span class="lbl">总耗时 · 图片 '+data.image_size+(data.downscale?' · 降分辨率 '+data.downscale.from+'→'+data.downscale.to:'')+'</span></div>';
// 步骤1: extract_context
html += renderStep('extract_context', data.extract_context_ms, maxMs, 'c-ctx',
'人脸关键点检测(MediaPipe) + 头发分割(SegFormer) + 发际线几何计算', '① 共享预处理(仅1次)');
// 每个发型的步骤
(data.per_hairstyle||[]).forEach(hs=>{
html += '<div class="hs-block">';
html += '<div class="hs-title"><span class="badge">#'+hs.order+'</span> '+HAIR_NAMES[hs.order]+
' <span class="tag">chang_id='+hs.chang_id+(hs.ok?' ✓':(hs.error?' ❌ '+hs.error:''))+'</span></div>';
if(hs.ok || hs.mask_ms){
// 换发型管线子步骤
html += renderStep('遮罩计算 compute_mask', hs.mask_ms, maxMs, 'c-mask',
'根据发际线类型计算要重绘的区域(发际线外推+头部分割)', '②a');
html += renderStep('换发型 _call_swap (change_hair/webui SD推理)', hs.swap_ms, maxMs, 'c-swap',
'调 change_hair 服务换发型,内部走 webui SD1.5 img2img 推理(~4-5s) — 通常是最慢的', '②b');
html += renderStep('接缝融合 _composite', hs.blend_ms, maxMs, 'c-blend',
'把换发型结果按遮罩贴回原图,多频段金字塔融合消除接缝', '②c');
html += renderStep('ComfyUI Flux.2 重绘', hs.comfyui_redraw_ms, maxMs, 'c-comfy',
'调本机 ComfyUI 用 Flux.2-klein 对发际线带做整帧重绘(美颜+生发) — 主要耗时', '③');
const other = (hs.redraw_pipeline_ms||0) - (hs.mask_ms||0) - (hs.swap_ms||0) - (hs.blend_ms||0);
if(other > 5){
html += renderStep('管线开销(编码/解码/IO)', other, maxMs, 'c-other', 'base64编解码、图片读写、网络传输等', '②overhead');
}
html += '<div class="step-row" style="margin-top:6px;border-top:1px dashed #e5e7eb;padding-top:8px">'+
'<div class="step-name" style="font-weight:700">小计(单发型)</div>'+
'<div class="step-bar-wrap"></div>'+
'<div class="step-time" style="color:#2563eb">'+hs.hairstyle_total_ms+'ms</div></div>';
}
// 结果图
if(hs.grown_b64){
html += '<div class="result-imgs"><div class="slot"><div class="lbl">生发结果</div><img src="'+hs.grown_b64+'"></div></div>';
}
html += '</div>';
});
// 图例
html += '<div class="legend">颜色: 🟪预处理 🟩遮罩 🟧换发型(SD推理) 🟦融合 🟥ComfyUI(Flux重绘) ⬜开销 &nbsp;|&nbsp; 条形宽度按相对耗时缩放</div>';
$('timingBody').innerHTML = html;
}
function renderStep(name, ms, maxMs, colorClass, desc, tag){
const pct = Math.max(1, (ms / maxMs) * 100);
const safeMs = ms || 0;
return '<div class="step-row">'+
'<div class="step-name">'+name+'<span class="desc">'+desc+'</span></div>'+
'<div class="step-bar-wrap"><div class="step-bar '+colorClass+'" style="width:'+pct+'%">'+(pct>15?safeMs+'ms':'')+'</div></div>'+
'<div class="step-time">'+safeMs+'ms</div>'+
'</div>';
}
</script>
</body>
</html>