接口6 增加字段

This commit is contained in:
xsl
2026-07-16 12:38:10 +08:00
parent 1cd4115b26
commit 632e75317b
10 changed files with 98 additions and 30 deletions
+2 -1
View File
@@ -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 有。
+12 -2
View File
@@ -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 = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
return;
}
const checked = $('showAnno').checked ? '' : 'display:none';
const opacity = ($('annoOpacity').value / 100).toFixed(2);
+1 -1
View File
@@ -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);
+66 -19
View File
@@ -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 @@
<div class="card">
<h2 style="margin-top:0">🎯 最终结果</h2>
<div class="steps" style="grid-template-columns: repeat(auto-fill, minmax(320px, 1fr))">
<div class="step big"><div class="cap">输入原图</div><img id="finalInput"></div>
<div class="step big"><div class="cap">最终结果</div><img id="finalOut"></div>
<div class="step big"><div class="cap"><span class="badge" style="background:#9ca3af">输入</span><span class="ttext">输入原图</span></div><img id="finalInput"></div>
<div class="step big"><div class="cap"><span class="badge"></span><span class="ttext">最终结果<small>接口11 最终输出 = ⑩接缝融合结果</small></span></div><img id="finalOut"></div>
</div>
</div>
@@ -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),传入原图 + 发际线类型 IDhairline_id)。'
+ '服务用对应发型的 LoRA 模型(webui img2imgdenoising_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 ? '<img src="' + src + '" onclick="zoom(this.src)">'
: '<div class="noimg">无图(后端返回空)</div>';
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></div>' + img;
const badge = st.no ? '<span class="badge">' + st.no + '</span>' : '';
const ttext = '<span class="ttext">' + (st.title||'') + '<small>' + (st.sub||'') + (hasImg ? ' ('+src.length+'字符)' : '') + '</small></span>';
const desc = st.desc ? '<div class="desc">' + st.desc + '</div>' : '';
div.innerHTML = '<div class="cap">' + badge + ttext + '</div>' + 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'); });
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+12 -2
View File
@@ -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 = '<span class="placeholder">后端未返回标注图(annotated_image_base64 为空)</span>';
return;
}
const checked = $('showAnno').checked ? '' : 'display:none';
const opacity = ($('annoOpacity').value / 100).toFixed(2);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);