Files
hair/static/test_interface9.html
T
xslandClaude Opus 4.8 9774997035 接口9:头发遮罩生成 + 分步可视化
新增 POST /api/v1/head/mask(worker + 网关代理)与测试页 test_interface9.html:
- MediaPipe 关键点连成额头分割线(21,68,104,69,108,151,337,299,333,298,251,
  左端21/右端251 水平延伸到图片边缘),分割线以上为上半区。
- 头发分割 BiSeNet 与 SegFormer 两套并排对比;每列从最顶端头发向下填充到分割线,
  得到含额头的闭合区域(不从发际线割断)。
- 外缘朝中心点151内缩 erode_cm(默认1.2cm,页面可调,虹膜标定换算像素)、底线不动。
- 复用现有 detector/hair_segmenter/SegFormer 单例(只读推理),无新依赖;纯新增,
  不改动既有接口。

顺带修复接口2 遗留测试 test_grow_female_returns_5:hair_style 自 cb1989c 起必填,
补上 hair_style=1,2,3,4,5。全套 42 passed。

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

279 lines
12 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>接口9 — 头发遮罩生成 测试页</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: 1200px; margin: 0 auto; padding: 24px; }
h1 { font-size: 22px; margin-bottom: 8px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 20px; }
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; }
.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: 120px; }
.metric .label { font-size: 12px; color: #888; }
.metric .value { font-size: 18px; font-weight: 700; color: #111; }
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 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; }
.step img { width: 100%; display: block; background: #eee; cursor: zoom-in; }
.cols { display: flex; gap: 20px; }
.cols > div { flex: 1; min-width: 0; }
.col-title { font-size: 15px; font-weight: 700; padding: 8px 12px; border-radius: 8px; margin-bottom: 12px; color: #fff; }
.col-title.bisenet { background: #7c3aed; }
.col-title.segformer { background: #0891b2; }
.model-err { background: #fee2e2; color: #991b1b; padding: 12px; border-radius: 8px; font-size: 13px; }
.json-panel { max-height: 360px; 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>接口9 — 头发遮罩生成</h1>
<div class="subtitle">MediaPipe 关键点 → 额头分割线 → 上半区 → 头发分割(BiSeNet / SegFormer) → 闭合区域(含额头) → 外缘内缩(可调) → 遮罩。每一步可视化,两套分割并排对比。</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:14px">
<label style="font-size:14px; font-weight:600; white-space:nowrap">外缘内缩 (cm)</label>
<input type="number" id="erodeCm" min="0" max="5" step="0.1" value="1.2"
style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px">
<input type="range" id="erodeRange" min="0" max="3" step="0.1" value="1.2" style="flex:1; min-width:140px">
</div>
<div class="hint">默认 1.2cm。数值越大,遮罩外缘朝中心点 151 收得越多(底线不动)。改动后点「提交测试」重新计算;设置会自动记住。</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="metrics" id="metricsBar"></div>
</div>
<div class="card">
<h2 style="margin-top:0">① 公共步骤(与分割模型无关)</h2>
<div class="steps" id="commonSteps"></div>
</div>
<div class="card">
<h2 style="margin-top:0">② 两套分割对比</h2>
<div class="cols">
<div>
<div class="col-title bisenet">BiSeNet(接口1同款)</div>
<div id="bisenetPanel"></div>
</div>
<div>
<div class="col-title segformer">SegFormer(接口2/3同款)</div>
<div id="segformerPanel"></div>
</div>
</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/head/mask';
// 每个分割模型要展示的步骤(key 前缀,标题,副标题)
const MODEL_STEPS = [
{ key: 'hair_mask', title: '③ 头发分割 hair_mask', sub: '整张头发掩膜' },
{ key: 'closed_region', title: '④ 闭合区域', sub: '头发+额头(含额头,底=画线)' },
{ key: 'final_overlay', title: '⑥ 最终遮罩叠加', sub: '外缘内缩,底线不动' },
{ key: 'mask', title: '⑦ 纯遮罩', sub: '白=遮罩' },
];
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
// 网关会把 *_base64 改写为 *_url;直连 worker 则保留 *_base64data URI)。两者都兼容。
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 renderCommon(data) {
const c = $('commonSteps');
c.innerHTML = '';
c.appendChild(stepCard('① 关键点 + 分割线', '21..251 连线,21/251 延伸到边缘', pick(data.steps_common, 'landmarks_baseline')));
c.appendChild(stepCard('② 上半区', '分割线以上(绿)', pick(data.steps_common, 'upper_region')));
}
function renderModel(panelId, model) {
const panel = $(panelId);
panel.innerHTML = '';
if (!model) { panel.innerHTML = '<div class="model-err">无数据</div>'; return; }
if (model.error) { panel.innerHTML = '<div class="model-err">分割失败:' + model.error + '</div>'; return; }
const px = document.createElement('div');
px.className = 'metrics';
px.style.marginBottom = '12px';
px.innerHTML =
'<div class="metric"><div class="label">头发像素</div><div class="value">' + (model.hair_pixels ?? '—') + '</div></div>' +
'<div class="metric"><div class="label">遮罩像素</div><div class="value">' + (model.mask_pixels ?? '—') + '</div></div>';
panel.appendChild(px);
const steps = document.createElement('div');
steps.className = 'steps';
steps.style.gridTemplateColumns = 'repeat(auto-fill, minmax(180px, 1fr))';
MODEL_STEPS.forEach(s => steps.appendChild(stepCard(s.title, s.sub, pick(model, s.key))));
panel.appendChild(steps);
}
function renderMetrics(data) {
const sz = data.image_size || {};
const items = [
{ label: 'px_per_cm', value: data.px_per_cm },
{ label: '内缩距离', value: data.erode_cm + ' cm' },
{ label: '内缩像素', value: data.erode_px + ' px' },
{ label: '图片尺寸', value: (sz.width || '?') + '×' + (sz.height || '?') },
];
$('metricsBar').innerHTML = items.map(m =>
'<div class="metric"><div class="label">' + m.label + '</div><div class="value">' + (m.value ?? '—') + '</div></div>'
).join('');
}
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('正在请求 ' + API_BASE + ENDPOINT + ' ...', 'info');
$('resultsArea').classList.add('hidden');
const form = new FormData();
form.append('image_file', file);
form.append('erode_cm', $('erodeCm').value || '1.2');
try {
const resp = await fetch(API_BASE + ENDPOINT, { method: 'POST', body: form });
const json = await resp.json();
const dt = ((performance.now() - t0) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('✅ 请求成功 (' + dt + 's) — request_id: ' + json.request_id, 'success');
const data = json.data;
renderMetrics(data);
renderCommon(data);
renderModel('bisenetPanel', data.bisenet);
renderModel('segformerPanel', data.segformer);
} else {
setStatus('❌ 业务错误 (' + dt + 's) — code: ' + json.code + ' message: ' + 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; const o = b.textContent;
b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
const ERODE_KEY = 'if9_erode_cm';
function syncErode(from) {
const num = $('erodeCm'), rng = $('erodeRange');
let v = parseFloat(from.value);
if (isNaN(v) || v < 0) v = 0;
num.value = v;
rng.value = Math.min(parseFloat(rng.max), v); // 滑块封顶但数值框可超范围
try { localStorage.setItem(ERODE_KEY, String(v)); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', () => {
const dz = $('imageFile');
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
// 恢复上次的内缩值
try {
const saved = localStorage.getItem(ERODE_KEY);
if (saved !== null) { $('erodeCm').value = saved; }
} catch (e) {}
syncErode($('erodeCm'));
$('erodeCm').addEventListener('input', e => syncErode(e.target));
$('erodeRange').addEventListener('input', e => syncErode(e.target));
});
</script>
</body>
</html>