Files
hair/static/test_interface11.html
T
xslandClaude Opus 4.8 0ddfa83743 接口11:发际线生发(接口9遮罩 + change_hair换发型/区域生发 + 按遮罩羽化贴回)+ 分步可视化测试页
- 端点 POST /api/v1/hairline/grow(app.py,纯新增,不影响接口1-5)
- 编排模块 face_analysis/hairline_grow.py:复用接口9 遮罩 → HTTP 调 change_hair(8801) → 按遮罩贴回
- 双生成后端 gen_backend:swaphair(换发型LoRA) / hairgrow(区域生发inpaint)
- swap_mode:ext_mask(接口9遮罩作换发型遮罩) / as_is;融合 feather/alpha_gradient/seamless
- 参数全在测试页可调;可视化按算法文档4步:最终遮罩→生成全帧→严格贴回→接缝融合
- 附启停脚本 scripts/restart_if11_backends.sh、算法文档、测试图

注:change_hair 侧 swapHair 的 ext_mask/denoising_strength 改造在 change_hair 仓库,向后兼容。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:06:17 +08:00

377 lines
18 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">
<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">
<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>
</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">
<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>
</select>
</div>
<div class="pf">
<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">
<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">
<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">
<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">
<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 = [
{ key: 'mask_overlay', title: '① 接口9 最终遮罩(叠加)', 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: '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 = '';
STEPS.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');
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');
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'];
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'));
linkNumRange('erodeCm','erodeRange');
linkNumRange('featherPx','featherRange');
linkNumRange('edgeErodePx','edgeErodeRange');
linkNumRange('denoise','denoiseRange');
linkNumRange('hgStrength','hgStrengthRange');
// 初始化滑块值
$('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);
});
</script>
</body>
</html>