Files
hair/static/test_interface11.html
T
2026-07-11 23:14:07 +08:00

463 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>接口11 — 发际线生发 测试页</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #f5f5f5; color: #333; }
.container { max-width: 1240px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; line-height: 1.6; }
h2 { font-size: 16px; margin: 24px 0 12px; }
.card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.upload-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
input[type=file] { flex: 1; min-width: 200px; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.btn { padding: 10px 24px; 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; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
.params { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 14px; margin-top: 16px; }
.pf { display: flex; flex-direction: column; gap: 4px; }
.pf label { font-size: 13px; font-weight: 600; }
.pf .desc { font-size: 11px; color: #9ca3af; font-weight: 400; }
.pf select, .pf input[type=number], .pf input[type=text] { padding: 8px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; }
.pf .row { display: flex; gap: 8px; align-items: center; }
.pf .row input[type=number] { width: 80px; }
.pf .row input[type=range] { flex: 1; }
.chk { display: flex; align-items: center; gap: 8px; }
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-bottom: 16px; 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; }
.metrics { display: flex; gap: 16px; flex-wrap: wrap; }
.metric { background: #f9fafb; border: 1px solid #eee; border-radius: 8px; padding: 10px 16px; min-width: 110px; }
.metric .label { font-size: 12px; color: #888; }
.metric .value { font-size: 17px; font-weight: 700; color: #111; }
.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 small { color: #999; font-weight: 400; display:block; margin-top:2px; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.big img { max-height: 520px; object-fit: contain; }
.json-panel { max-height: 320px; overflow: auto; background: #1e1e1e; color: #d4d4d4; padding: 14px; border-radius: 8px; font: 12px/1.5 Consolas, Monaco, monospace; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none; }
.lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.85); display: none; align-items: center; justify-content: center; z-index: 50; cursor: zoom-out; }
.lightbox img { max-width: 95%; max-height: 95%; }
</style>
</head>
<body>
<div class="container">
<h1>接口11 — 发际线生发</h1>
<div class="subtitle">
接口9 头发遮罩 → change_hair 换发型(发际线类型 LoRA)→ 严格按遮罩贴回原图 → 接缝羽化融合。<br>
输入:发际线较高 / 头发稀少的正脸图 + 发际线类型 ID。输出:同一个人同一发型、压低发际线后的图。每一步可视化,所有可调参数都在下面。
</div>
<div class="card">
<div class="upload-row">
<input type="file" id="imageFile" accept="image/*">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline" onclick="clearResults()">清空</button>
</div>
<div class="upload-row" style="margin-top:10px">
<label style="font-size:13px; font-weight:600; white-space:nowrap">X-Internal-Token</label>
<input type="text" id="token" placeholder="直连 worker 需填;走网关可留空" value="dev-shared-secret-2026"
style="flex:1; min-width:200px; padding:8px; border:1px solid #ddd; border-radius:8px">
</div>
<div class="params">
<div class="pf">
<label>gen_backend <span class="desc">生成后端</span></label>
<select id="genBackend">
<option value="swaphair">swaphair(换发型LoRA,应用发际线类型)</option>
<option value="hairgrow">hairgrow(区域生发inpaint,压低发际线)</option>
</select>
</div>
<div class="pf" data-show="gen:swaphair">
<label>发际线类型 ID <span class="desc">= change_hair hair_id(仅swaphair</span></label>
<select id="hairlineId">
<option value="chang_zhixian">chang_zhixian(直线)</option>
<option value="chang_tuoyuan">chang_tuoyuan(椭圆)</option>
<option value="chang_bolang">chang_bolang(波浪)</option>
<option value="chang_xinxing">chang_xinxing(心形)</option>
<option value="chang_huaban">chang_huaban(花瓣)</option>
</select>
</div>
<div class="pf" data-show="gen:swaphair">
<label>swap_mode <span class="desc">换发型取图模式</span></label>
<select id="swapMode">
<option value="ext_mask">ext_mask(改造换发型,用接口9遮罩重绘)</option>
<option value="as_is">as_is(不改换发型,贴回再裁)</option>
</select>
</div>
<div class="pf">
<label>seg_model <span class="desc">头发分割模型</span></label>
<select id="segModel">
<option value="segformer">segformer(默认)</option>
<option value="bisenet">bisenet</option>
</select>
</div>
<div class="pf">
<label>mask_type <span class="desc">遮罩类型</span></label>
<select id="maskType">
<option value="eroded">eroded(外缘内缩,默认)</option>
<option value="closed">closed(闭合区域,未内缩)</option>
<option value="pushed">pushed(发际线外推)</option>
</select>
</div>
<div class="pf">
<label>erode_cm <span class="desc">外缘内缩距离(cm)</span></label>
<div class="row">
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2">
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="1.2">
</div>
</div>
<div class="pf" data-show="mask:pushed">
<label>hairline_push_cm <span class="desc">发际线外推距离(仅pushed)</span></label>
<div class="row">
<input type="number" id="hairlinePushCm" min="0" max="3" step="0.1" value="1.0">
<input type="range" id="hairlinePushRange" min="0" max="3" step="0.1" value="1.0">
</div>
</div>
<div class="pf" data-show="mask:pushed">
<label>hairline_edge <span class="desc">发际线提取方式(仅pushed)</span></label>
<select id="hairlineEdge">
<option value="column">column(逐列最低点,稳定)</option>
<option value="contour">contour(形态学轮廓,精确)</option>
</select>
</div>
<div class="pf">
<label>blend_method <span class="desc">接缝融合算法</span></label>
<select id="blendMethod">
<option value="feather">feather(高斯羽化,默认)</option>
<option value="alpha_gradient">alpha_gradient(距离变换内渐变)</option>
<option value="seamless">seamless(泊松无缝克隆)</option>
<option value="multiband">multiband(多频段金字塔融合)</option>
</select>
</div>
<div class="pf" data-show="blend:feather,alpha_gradient,multiband">
<label>color_match <span class="desc">颜色校正前置</span></label>
<div class="chk"><input type="checkbox" id="colorMatch"><span style="font-size:13px">融合前对生成图做 Reinhard 颜色校正(消除整体色差,seamless 自动跳过)</span></div>
</div>
<div class="pf" data-show="blend:feather,alpha_gradient">
<label>feather_px <span class="desc">羽化/渐变过渡宽度(px)</span></label>
<div class="row">
<input type="number" id="featherPx" min="1" max="80" step="1" value="15">
<input type="range" id="featherRange" min="1" max="60" step="1" value="15">
</div>
</div>
<div class="pf" data-show="blend:multiband">
<label>mb_levels <span class="desc">多频段金字塔层数(仅multiband)</span></label>
<div class="row">
<input type="number" id="mbLevels" min="2" max="6" step="1" value="5">
<input type="range" id="mbLevelsRange" min="2" max="6" step="1" value="5">
</div>
</div>
<div class="pf">
<label>edge_erode_px <span class="desc">贴图前遮罩内缩(px)</span></label>
<div class="row">
<input type="number" id="edgeErodePx" min="0" max="40" step="1" value="3">
<input type="range" id="edgeErodeRange" min="0" max="30" step="1" value="3">
</div>
</div>
<div class="pf" data-show="gen:swaphair">
<label>denoising_strength <span class="desc">换发型重绘强度(仅swaphair)</span></label>
<div class="row">
<input type="number" id="denoise" min="0.2" max="1.0" step="0.05" value="0.6">
<input type="range" id="denoiseRange" min="0.2" max="1.0" step="0.05" value="0.6">
</div>
</div>
<div class="pf" data-show="gen:hairgrow">
<label>hairgrow_strength <span class="desc">区域生发强度(仅hairgrow)</span></label>
<div class="row">
<input type="number" id="hgStrength" min="0.2" max="1.0" step="0.05" value="0.75">
<input type="range" id="hgStrengthRange" min="0.2" max="1.0" step="0.05" value="0.75">
</div>
</div>
<div class="pf" data-show="gen:swaphair">
<label>is_hr <span class="desc">高清模式</span></label>
<div class="chk"><input type="checkbox" id="isHr"><span style="font-size:13px">1152×1536(否则 576×768</span></div>
</div>
</div>
<div class="hint">
发际线类型 ID 必须是 change_hair 已训练的 hair_id(下拉为已有的 5 个)。参数改动后点「提交测试」重新计算,设置自动记住。<br>
换发型走 GPU + SD img2img,单次约 10~15sis_hr 更慢。
</div>
</div>
<div class="status hidden" id="statusBar"></div>
<div id="resultsArea" class="hidden">
<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">最终结果 <small>按遮罩贴回 + 接缝融合</small></div><img id="finalOut"></div>
</div>
</div>
<div class="card">
<h2 style="margin-top:0">📏 参数与指标</h2>
<div class="metrics" id="metricsBar"></div>
</div>
<div class="card">
<h2 style="margin-top:0">🪜 分步可视化</h2>
<div class="steps" id="stepsGrid"></div>
</div>
<div class="card">
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
<span>原始 JSON</span>
<button class="btn btn-outline" style="padding:6px 14px; font-size:13px" onclick="copyJson()">复制</button>
</h2>
<div class="json-panel" id="jsonContent"></div>
</div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/hairline/grow';
// 分步展示的图(按算法文档 4 步;key 前缀、标题、副标题)
const STEPS = [
// 遮罩计算全过程(接口9 子步骤,蓝→红)
{ key: 'baseline_overlay', title: '①-a 发际线分割线', sub: '黄线=baseline151中心点标红、其余标绿)' },
{ key: 'upper_overlay', title: '①-b 分割线上半区', sub: '青=baseline 以上区域(裁剪范围)' },
{ key: 'hair_seg_overlay', title: '①-c 头发分割', sub: '绿=分割模型原始头发像素(segformer/bisenet' },
{ key: 'top_fill_overlay', title: '①-d 填充到基线', sub: '蓝=头发向下填充含额头(延伸到图底,未裁剪)' },
{ key: 'closed_overlay', title: '①-e 闭合区域', sub: '紫=top_fill ∩ 上半区(头发+额头,底=基线)' },
// pushed 模式专有(非 pushed 模式无图)
{ 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: 'change_hair 生成,已对齐原图' },
{ key: 'hard_paste', title: '③ 严格按遮罩贴回', sub: '遮罩内=生成,遮罩外=原图,无融合' },
{ key: 'alpha', title: '④ 融合权重 alpha', sub: '羽化/渐变,白=用生成图' },
{ key: 'final', title: '④ 接缝融合(最终)', sub: '遮罩边缘自然过渡' },
];
function $(id) { return document.getElementById(id); }
function setStatus(text, type) { const b = $('statusBar'); b.textContent = text; b.className = 'status ' + type; }
// 网关把 *_base64 改写为 *_url;直连 worker 保留 *_base64。两者都兼容。
function pick(obj, name) { if (!obj) return null; return obj[name + '_url'] || obj[name + '_base64'] || null; }
function stepCard(title, sub, src) {
const div = document.createElement('div');
div.className = 'step';
const img = src ? '<img src="' + src + '" alt="' + title + '" onclick="zoom(this.src)">'
: '<div style="padding:30px;text-align:center;color:#bbb;font-size:13px">无图</div>';
div.innerHTML = '<div class="cap">' + title + '<small>' + (sub || '') + '</small></div>' + img;
return div;
}
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function renderMetrics(d) {
const sz = d.image_size || {}, t = d.timings_ms || {};
const items = [
{ label: 'gen_backend', value: d.gen_backend },
{ label: 'hairline_id', value: d.hairline_id },
{ label: 'swap_mode', value: d.swap_mode },
{ label: 'seg_model', value: d.seg_model },
{ label: 'mask_type', value: d.mask_type },
{ label: 'blend', value: d.blend_method },
{ label: 'color_match', value: d.color_match },
{ label: 'mb_levels', value: d.mb_levels },
{ label: 'hairline_push', value: d.hairline_push_cm + 'cm' },
{ label: 'hairline_edge', value: d.hairline_edge },
{ label: 'denoise', value: d.denoising_strength },
{ label: 'px_per_cm', value: d.px_per_cm },
{ label: '内缩', value: d.erode_cm + 'cm/' + d.erode_px + 'px' },
{ label: '遮罩像素', value: d.mask_pixels },
{ label: '尺寸', value: (sz.width||'?') + '×' + (sz.height||'?') },
{ label: '耗时(遮罩/换发/融合)', value: (t.mask||0) + '/' + (t.swap||0) + '/' + (t.blend||0) + 'ms' },
];
$('metricsBar').innerHTML = items.map(m =>
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
).join('');
}
function renderResult(d) {
const s = d.steps || {};
$('finalInput').src = pick(s, 'input') || '';
$('finalInput').onclick = function(){ zoom(this.src); };
$('finalOut').src = pick(s, 'final') || '';
$('finalOut').onclick = function(){ zoom(this.src); };
renderMetrics(d);
const grid = $('stepsGrid'); grid.innerHTML = '';
// 按 mask_type 过滤步骤:pushed 只显示 baseline/upper/头发分割/发际线/外推;
// eroded/closed 只显示 baseline/upper/头发分割/填充/闭合,不显示发际线/外推。
const mt = d.mask_type;
const showKeys = new Set(['baseline_overlay','upper_overlay','hair_seg_overlay','mask_overlay','mask','swap_raw','hard_paste','alpha','final']);
if (mt === 'pushed') { showKeys.add('hairline_overlay'); showKeys.add('pushed_overlay'); }
else { showKeys.add('top_fill_overlay'); showKeys.add('closed_overlay'); }
STEPS.filter(st => showKeys.has(st.key)).forEach(st =>
grid.appendChild(stepCard(st.title, st.sub, pick(s, st.key))));
}
async function submitTest() {
const file = $('imageFile').files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
const t0 = performance.now();
const btn = $('submitBtn');
btn.disabled = true; btn.textContent = '⏳ 请求中...';
setStatus('正在请求(换发型走 GPU,约 10~15s...', 'info');
$('resultsArea').classList.add('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('hairline_id', $('hairlineId').value.trim());
form.append('gen_backend', $('genBackend').value);
form.append('hairgrow_strength', $('hgStrength').value || '0.75');
form.append('is_hr', $('isHr').checked ? 'true' : 'false');
form.append('seg_model', $('segModel').value);
form.append('mask_type', $('maskType').value);
form.append('erode_cm', $('erodeCm').value || '1.2');
setStatus('正在请求 mask_type=' + $('maskType').value
+ ' hairline_push_cm=' + ($('hairlinePushCm').value||'1.0')
+ ' hairline_edge=' + $('hairlineEdge').value
+ '(换发型走 GPU,约 10~15s...', 'info');
form.append('swap_mode', $('swapMode').value);
form.append('blend_method', $('blendMethod').value);
form.append('feather_px', $('featherPx').value || '15');
form.append('edge_erode_px', $('edgeErodePx').value || '3');
form.append('denoising_strength', $('denoise').value || '0.6');
form.append('color_match', $('colorMatch').checked ? 'true' : 'false');
form.append('mb_levels', $('mbLevels').value || '5');
form.append('hairline_push_cm', $('hairlinePushCm').value || '1.0');
form.append('hairline_edge', $('hairlineEdge').value);
try {
const headers = {};
const tok = $('token').value.trim();
if (tok) headers['X-Internal-Token'] = tok;
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', headers, body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, (k, v) =>
(typeof v === 'string' && v.length > 120) ? v.slice(0, 60) + '…(' + v.length + ')' : v, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 成功 (' + dt + 's)', 'success');
renderResult(json.data);
} else {
setStatus('❌ 业务错误 (' + dt + 's) — code ' + json.code + '' + json.message, 'error');
}
} catch (err) {
setStatus('❌ 网络错误: ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
$('resultsArea').classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = '🚀 提交测试';
}
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
// ---- 参数持久化 + 联动 ----
const FIELDS = ['token','genBackend','hairlineId','swapMode','segModel','maskType','erodeCm','blendMethod','featherPx','edgeErodePx','denoise','hgStrength','mbLevels','hairlinePushCm','hairlineEdge'];
function saveField(id) { try { localStorage.setItem('if11_' + id, $(id).type === 'checkbox' ? ($(id).checked?'1':'0') : $(id).value); } catch(e){} }
function linkNumRange(numId, rngId) {
const num = $(numId), rng = $(rngId);
function sync(from) {
let v = parseFloat(from.value); if (isNaN(v)) v = 0;
num.value = v; rng.value = Math.min(parseFloat(rng.max), Math.max(parseFloat(rng.min), v));
saveField(numId);
}
num.addEventListener('input', () => sync(num));
rng.addEventListener('input', () => sync(rng));
}
document.addEventListener('DOMContentLoaded', () => {
const dz = $('imageFile');
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
FIELDS.forEach(id => {
try { const s = localStorage.getItem('if11_' + id); if (s !== null) $(id).value = s; } catch(e){}
$(id).addEventListener('change', () => saveField(id));
});
try { $('isHr').checked = localStorage.getItem('if11_isHr') === '1'; } catch(e){}
$('isHr').addEventListener('change', () => saveField('isHr'));
try { $('colorMatch').checked = localStorage.getItem('if11_colorMatch') === '1'; } catch(e){}
$('colorMatch').addEventListener('change', () => saveField('colorMatch'));
linkNumRange('erodeCm','erodeRange');
linkNumRange('featherPx','featherRange');
linkNumRange('edgeErodePx','edgeErodeRange');
linkNumRange('denoise','denoiseRange');
linkNumRange('hgStrength','hgStrengthRange');
linkNumRange('mbLevels','mbLevelsRange');
linkNumRange('hairlinePushCm','hairlinePushRange');
// 初始化滑块值
$('erodeRange').value = Math.min(3, parseFloat($('erodeCm').value)||1.2);
$('featherRange').value = Math.min(60, parseFloat($('featherPx').value)||15);
$('edgeErodeRange').value = Math.min(30, parseFloat($('edgeErodePx').value)||3);
$('denoiseRange').value = Math.min(1.0, parseFloat($('denoise').value)||0.6);
$('hgStrengthRange').value = Math.min(1.0, parseFloat($('hgStrength').value)||0.75);
$('mbLevelsRange').value = Math.min(6, Math.max(2, parseFloat($('mbLevels').value)||5));
$('hairlinePushRange').value = Math.min(3, parseFloat($('hairlinePushCm').value)||1.0);
// 参数联动:按 gen_backend / blend_method 显隐无用参数(data-show 声明)
function applyVisibility() {
const gen = $('genBackend').value, bm = $('blendMethod').value, mt = $('maskType').value;
document.querySelectorAll('.pf[data-show]').forEach(el => {
const conds = el.dataset.show.split(',').map(s => s.trim());
let vis = true;
conds.forEach(c => {
const [k, v] = c.split(':');
if (k === 'gen' && v !== gen) vis = false;
if (k === 'blend' && v !== bm) vis = false;
if (k === 'mask' && v !== mt) vis = false;
});
el.style.display = vis ? '' : 'none';
});
}
$('genBackend').addEventListener('change', applyVisibility);
$('blendMethod').addEventListener('change', applyVisibility);
$('maskType').addEventListener('change', applyVisibility);
applyVisibility();
});
</script>
</body>
</html>