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);