From 632e75317bc5966f298b4741833a63a05e0979e8 Mon Sep 17 00:00:00 2001 From: xsl Date: Thu, 16 Jul 2026 12:38:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A36=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 3 +- static/test_interface1.html | 14 ++++- static/test_interface10.html | 2 +- static/test_interface11_debug.html | 85 +++++++++++++++++++++++------- static/test_interface3.html | 2 +- static/test_interface4.html | 2 +- static/test_interface5.html | 2 +- static/test_interface6.html | 14 ++++- static/test_interface7.html | 2 +- static/test_interface9.html | 2 +- 10 files changed, 98 insertions(+), 30 deletions(-) diff --git a/app.py b/app.py index dd40853..f15b5c3 100644 --- a/app.py +++ b/app.py @@ -374,7 +374,8 @@ def _run_face_measure_data(image, variant="v1"): data["four_courts"].pop("top_court_cm", None) data["face_total_height_cm"] = round( result.upper_cm + result.middle_cm + result.lower_cm, 2) - data["landmarks"].pop("hair_top", None) + # 注:landmarks.hair_top 保留返回(供前端/下游定位头顶),但顶庭数值、 + # 占比、标注图仍按三庭处理,显示效果不变。 # 七眼段宽度(cm)。eye1=左耳外段 eye2=左脸颊 eye3=左眼 eye4=两眼间距 eye5=右眼 eye6=右脸颊 eye7=右耳外段。 # eye2~eye6(5段)只用内部分点,接口1/6 共用;eye1/eye7 需耳朵分割端线,仅接口1 有。 diff --git a/static/test_interface1.html b/static/test_interface1.html index e5a6fec..edfa976 100644 --- a/static/test_interface1.html +++ b/static/test_interface1.html @@ -148,7 +148,7 @@ async function submitTest() { form.append('image_file', file); try { - const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', body: form }); + const resp = await fetch(API_BASE + '/api/v1/face/measure', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form }); const json = await resp.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); @@ -157,7 +157,13 @@ async function submitTest() { if (json.code === 0) { setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success'); - showOverlay(json.data.annotated_image_url); + // 兼容本地直连 worker(返回 *_base64)与网关(改写为 *_url)两种情况 + const _d = json.data || {}; + const annoUrl = _d.annotated_image_url + || (_d.annotated_image_base64 + ? ('data:image/png;base64,' + _d.annotated_image_base64.replace(/^data:image\/\w+;base64,/, '')) + : ''); + showOverlay(annoUrl); renderMetrics(json.data); $('metricsBar').classList.remove('hidden'); } else { @@ -182,6 +188,10 @@ function showOverlay(annoUrl) { setTimeout(() => showOverlay(annoUrl), 200); return; } + if (!annoUrl) { + $('imgPanel').innerHTML = '后端未返回标注图(annotated_image_base64 为空)'; + return; + } const checked = $('showAnno').checked ? '' : 'display:none'; const opacity = ($('annoOpacity').value / 100).toFixed(2); diff --git a/static/test_interface10.html b/static/test_interface10.html index 4f8ba8a..72f088a 100644 --- a/static/test_interface10.html +++ b/static/test_interface10.html @@ -212,7 +212,7 @@ async function submitTest() { form.append('dilate_cm', $('dilateCm').value || '2.0'); try { - const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form }); + const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form }); const json = await resp.json(); const dt = ((performance.now() - t0) / 1000).toFixed(2); diff --git a/static/test_interface11_debug.html b/static/test_interface11_debug.html index c23020e..2eda948 100644 --- a/static/test_interface11_debug.html +++ b/static/test_interface11_debug.html @@ -34,8 +34,12 @@ .status.success { background: #d1fae5; color: #065f46; display: block; } .steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; } .step { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); } - .step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; } + .step .cap { font-size: 13px; font-weight: 600; padding: 8px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display:flex; align-items:center; gap:8px; } + .step .cap .badge { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; min-width:24px; height:24px; padding:0 6px; border-radius:6px; background:#2563eb; color:#fff; font-size:13px; font-weight:700; } + .step .cap .ttext { flex:1; } .step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; } + .step .desc { font-size: 12px; line-height: 1.7; color: #4b5563; padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #f0f0f0; } + .step .desc b { color:#1f2937; } .step img { width: 100%; display: block; background: #eee; cursor: zoom-in; } .step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; } .big img { max-height: 520px; object-fit: contain; } @@ -147,8 +151,8 @@

🎯 最终结果

-
输入原图
-
最终结果
+
输入输入原图
+
最终结果接口11 最终输出 = ⑩接缝融合结果
@@ -192,19 +196,55 @@ function flog(msg, level) { function clearLog() { FE_LOGS.length = 0; document.getElementById('logPanel').innerHTML = ''; } const STEPS = [ - { key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline' }, - { key: 'upper_overlay', title: '①-b 上半区', sub: '青=baseline以上' }, - { key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=头发像素' }, - { key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=top_fill(仅eroded/closed)' }, - { key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=closed(仅eroded/closed)' }, - { key: 'hairline_overlay', title: '①-f 头发内轮廓线', sub: '绿=头发内轮廓(额头弧+两侧到下颌),黄=baseline折线(仅pushed)' }, - { key: 'pushed_overlay', title: '①-g 外推发际线', sub: '青=外推线(进头发push_cm),红=遮罩,绿=内轮廓(仅pushed)' }, - { key: 'mask_overlay', title: '① 最终遮罩(叠加)', sub: '红=最终遮罩区' }, - { key: 'mask', title: '① 纯遮罩', sub: '白=贴回区' }, - { key: 'swap_raw', title: '② 生成全帧', sub: '换发型结果' }, - { key: 'hard_paste', title: '③ 严格贴回', sub: '遮罩内=生成,外=原图' }, - { key: 'alpha', title: '④ 融合权重', sub: '白=用生成图' }, - { key: 'final', title: '④ 接缝融合(最终)', sub: '最终输出' }, + { no: '①', key: 'baseline_overlay', title: '发际线分割线 baseline', sub: '黄线=baseline(眉峰水平连线)', + desc: '用 468 点人脸关键点定位两侧眉峰,连成一条水平线作为「上半区」的底界。' + + '这条线把画面分成上下两半:以上是额头+头发(要处理),以下是五官(保持不动)。' + + '它是后续所有遮罩、外推、贴回的坐标基准线。图中的黄线就是 baseline,红点为 151 号中心点(径向外推的圆心)。' }, + { no: '②', key: 'upper_overlay', title: '上半区 upper', sub: '青色=baseline 以上区域', + desc: '把 baseline 以上到画面顶部的整片区域标为「上半区」。后续的头发分割、发际线外推、' + + '最终遮罩都只会在这个上半区内计算,确保下半张脸(眉、眼、鼻、嘴)永远不被改动。' + + '青色覆盖的就是上半区范围。' }, + { no: '③', key: 'hair_seg_overlay', title: '头发分割', sub: '绿色=头发像素', + desc: '用语义分割模型(默认 SegFormer,可选 BiSeNet)逐像素判断哪些是「头发」。' + + '绿色覆盖的就是被识别为头发的像素。这一步的目的是找到现有头发的边界,' + + '为下一步「发际线内轮廓」提供输入——发际线就长在头发区域的内边缘上。' }, + { no: '④', key: 'hairline_overlay', title: '头发内轮廓线', sub: '绿=内轮廓(额头弧+两侧),黄=baseline', + desc: '从头发分割结果里提取出「头发的内轮廓」:即头发与皮肤交界的那条线。' + + '它包含额头弧线(发际线本体)和两侧向下的鬓角轮廓。' + + '提取方式 hairline_edge=column 时按逐列(竖向)找头发最低点连成线;=contour 时用形态学轮廓。' + + '绿色折线就是提取出的内轮廓,黄色折线是 ① 的 baseline。这条内轮廓是外推发际线的起点。' }, + { no: '⑤', key: 'pushed_overlay', title: '外推发际线 + 遮罩', sub: '青=外推线,红=遮罩,绿=内轮廓,红点=圆心', + desc: '把 ④ 的发际线内轮廓「往头发方向(向头顶)推进 hairline_push_cm 厘米」得到一条新的外推线(青色)。' + + '外推方式:以眉心(151 点)为圆心做径向外推,推过的这段就是「要新长出头发的区域」。' + + '然后用【外推线(上界)到 baseline(下界)】之间的闭合区域作为最终遮罩(红色半透明)。' + + 'push_cm 越大,新发际线越靠上、生发区越大(默认 0.8cm)。' }, + { no: '⑥', key: 'mask_overlay', title: '最终遮罩(叠加图)', sub: '红色=要重绘/贴回的区域', + desc: '把 ⑤ 算出的遮罩叠回原图看效果。红色区域 = 需要被新生成的头发覆盖的位置(遮罩内),' + + '红色以外 = 保持原样不动(遮罩外)。这张图用来直观确认遮罩范围是否合理——' + + '理想情况是红色正好覆盖额头该生发的区域,不越界到眉毛或脸颊。' }, + { no: '⑦', key: 'mask', title: '纯遮罩', sub: '白色=贴回区,黑色=保留区', + desc: '同一张遮罩的纯黑白版本(无原图背景)。白色=贴回区,黑色=保留区。' + + '这张纯遮罩会作为 ext_mask 传给换发型服务,让 webui 精确地只在这个区域内重绘画头发。' + + '它的好处是不受背景图干扰,便于检查遮罩形状是否干净(无噪点、无破洞)。' }, + { no: '⑧', key: 'swap_raw', title: '换发型生成', sub: 'change_hair 换该发际线类型后的整帧图', + desc: '调用 change_hair 换发型服务(POST :8801/api/swapHair/v1),传入原图 + 发际线类型 ID(hairline_id)。' + + '服务用对应发型的 LoRA 模型(webui img2img,denoising_strength 控制生发强度)生成一张' + + '「同一个人、换成该发际线类型发型」的完整图。注意:这张图是整帧都变了,' + + '下一步会严格按遮罩只取额头那块,其余丢掉,保证五官不动。inpainting_fill/mask_blur 等参数控制这里的重绘方式。' }, + { no: '⑨', key: 'hard_paste', title: '严格贴回(无融合)', sub: '遮罩内=生成图,遮罩外=原图', + desc: '把 ⑧ 的生成图按 ⑥/⑦ 的遮罩「硬贴」回原图:遮罩内用生成图,遮罩外完全保留原图。' + + '这是没有做任何接缝处理的版本,因此遮罩边缘通常能看到明显的接缝/色差。' + + '它的作用是让你对比看出「融合前后的差别」——边缘接缝要靠下一步的融合来消除。' }, + { no: '⑩', key: 'alpha', title: '融合权重 alpha', sub: '白=用生成图,黑=保留原图,灰=过渡', + desc: '决定每个像素最终取多少比例的生成图。纯白(=1)完全用生成图,纯黑(=0)完全保留原图,' + + '灰色是两者按比例过渡。blend_method=multiband/two_stage 时是多层金字塔权重(过渡带较宽、自然);' + + '=feather/alpha_gradient 时是单层羽化(硬边缘软过渡)。这张图用来理解融合是怎么"渐变"地把新头发融进去的。' }, + { no: '⑪', key: 'final', title: '接缝融合(最终输出)', sub: '接口11 最终结果', + desc: '按 ⑩ 的权重,把生成图和原图加权融合,得到无接缝的最终图。这就是接口11 的最终返回结果。' + + 'blend_method 选择融合算法:multiband=多频段金字塔(分频段融合,大色差场景用 two_stage 先泊松调色再多频段);' + + 'seamless=泊松无缝克隆(梯度域自动调色);feather/alpha_gradient=简单羽化。' + + 'color_match=true 时融合前还做一次 Reinhard 颜色迁移消除整体色差(seamless/two_stage 自带调色故跳过)。' + + '本接口不含重绘,需要重绘(美颜/补发丝)见接口12。' }, ]; function $(id) { return document.getElementById(id); } @@ -212,13 +252,16 @@ function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; } function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; } -function stepCard(title, sub, src) { +function stepCard(st, src) { const div = document.createElement('div'); div.className = 'step'; const hasImg = src && src.length > 50; const img = hasImg ? '' : '
无图(后端返回空)
'; - div.innerHTML = '
' + title + '' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '
' + img; + const badge = st.no ? '' + st.no + '' : ''; + const ttext = '' + (st.title||'') + '' + (st.sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + ''; + const desc = st.desc ? '
' + st.desc + '
' : ''; + div.innerHTML = '
' + badge + ttext + '
' + desc + img; return div; } @@ -238,7 +281,7 @@ function renderResult(d) { const src = pick(s, st.key); const hasImg = src && src.length > 50; flog(' 渲染 ' + st.key + ': ' + (hasImg ? '有图(' + src.length + '字符)' : '无图'), hasImg ? 'info' : 'warn'); - grid.appendChild(stepCard(st.title, st.sub, src)); + grid.appendChild(stepCard(st, src)); }); flog('renderResult 完成', 'info'); } @@ -364,6 +407,10 @@ async function downloadBackendLog() { } } +// 步骤序号 → 对应英文 key 的映射,供按序号定位步骤 +const STEP_BY_NO = {}; +STEPS.forEach(s => { if (s.no) STEP_BY_NO[s.no] = s.key; }); + // 参数联动(遮罩/融合已固定,无下拉联动) $('imageFile').addEventListener('change', function(){ if(this.files.length) flog('选择图片: ' + this.files[0].name, 'info'); }); diff --git a/static/test_interface3.html b/static/test_interface3.html index e65d6f5..7cfc6e4 100644 --- a/static/test_interface3.html +++ b/static/test_interface3.html @@ -161,7 +161,7 @@ async function submitTest() { const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/hair/grow-b', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/static/test_interface4.html b/static/test_interface4.html index b272528..a5d395d 100644 --- a/static/test_interface4.html +++ b/static/test_interface4.html @@ -131,7 +131,7 @@ async function submitTest() { const fd = new FormData(); fd.append('image_file', f); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/face/features', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/static/test_interface5.html b/static/test_interface5.html index 317a80d..6151c0d 100644 --- a/static/test_interface5.html +++ b/static/test_interface5.html @@ -207,7 +207,7 @@ async function submitTest() { const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/hairline/generate', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/static/test_interface6.html b/static/test_interface6.html index 6882c44..2d3370b 100644 --- a/static/test_interface6.html +++ b/static/test_interface6.html @@ -150,7 +150,7 @@ async function submitTest() { form.append('image_file', file); try { - const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', body: form }); + const resp = await fetch(API_BASE + '/api/v1/face/measure-v2', { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form }); const json = await resp.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); @@ -159,7 +159,13 @@ async function submitTest() { if (json.code === 0) { setStatus('✅ 请求成功 (' + _elapsed + 's) — request_id: ' + json.request_id, 'success'); - showOverlay(json.data.annotated_image_url); + // 兼容本地直连 worker(返回 *_base64)与网关(改写为 *_url)两种情况 + const _d = json.data || {}; + const annoUrl = _d.annotated_image_url + || (_d.annotated_image_base64 + ? ('data:image/png;base64,' + _d.annotated_image_base64.replace(/^data:image\/\w+;base64,/, '')) + : ''); + showOverlay(annoUrl); renderMetrics(json.data); $('metricsBar').classList.remove('hidden'); } else { @@ -183,6 +189,10 @@ function showOverlay(annoUrl) { setTimeout(() => showOverlay(annoUrl), 200); return; } + if (!annoUrl) { + $('imgPanel').innerHTML = '后端未返回标注图(annotated_image_base64 为空)'; + return; + } const checked = $('showAnno').checked ? '' : 'display:none'; const opacity = ($('annoOpacity').value / 100).toFixed(2); diff --git a/static/test_interface7.html b/static/test_interface7.html index e54fa17..f916a59 100644 --- a/static/test_interface7.html +++ b/static/test_interface7.html @@ -153,7 +153,7 @@ async function submitTest() { fd.append('prompt', $('promptInput').value); const _reqStart = performance.now(); try { - const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', body:fd }); + const r = await fetch(API_BASE + '/api/v1/hair/grow-v2', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd }); const json = await r.json(); const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2); $('jsonContent').textContent = JSON.stringify(json, null, 2); diff --git a/static/test_interface9.html b/static/test_interface9.html index c054b76..d14fcb0 100644 --- a/static/test_interface9.html +++ b/static/test_interface9.html @@ -208,7 +208,7 @@ async function submitTest() { form.append('erode_cm', $('erodeCm').value || '1.2'); try { - const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form }); + const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body: form }); const json = await resp.json(); const dt = ((performance.now() - t0) / 1000).toFixed(2);