Files
hair/static/test_interface10.html
T
xslandClaude Opus 4.8 3eb60bddc5 接口10:头部外缘膨胀带遮罩 + 分步可视化
新增 POST /api/v1/head/band(worker + 网关代理)与测试页 test_interface10.html:
- 先和接口9 一样得到内缩后的基准遮罩(含额头的闭合区域外缘朝151内缩 erode_cm、
  底线不动,默认1.2cm),在此基础上取外轮廓线,去掉贴着底部分割线的那一段。
- 把外轮廓线膨胀成带子(总宽 dilate_cm,默认2cm;半径=总宽/2,虹膜标定换算)。
- 裁到分割线以上(不越过底线)。BiSeNet/SegFormer 两套并排对比。
- 两个可调参数 erode_cm + dilate_cm(页面数字框+滑块+localStorage)。
- 复用 head_mask 的构件,无新依赖;纯新增,不改动既有接口。

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

287 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>接口10 — 头部外缘膨胀带遮罩 测试页</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>接口10 — 头部外缘膨胀带遮罩</h1>
<div class="subtitle">先同接口9 内缩(可调) 得基准遮罩 → 取外轮廓线(去底部分界线) → 膨胀成带子(总宽可调) → 裁到分界线以上 → 带子遮罩。两套分割并排对比。</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:80px; 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:120px">
<label style="font-size:14px; font-weight:600; white-space:nowrap; margin-left:8px">带子总宽 (cm)</label>
<input type="number" id="dilateCm" min="0" max="8" step="0.1" value="2.0"
style="width:80px; padding:8px; border:1px solid #ddd; border-radius:8px">
<input type="range" id="dilateRange" min="0" max="5" step="0.1" value="2.0" style="flex:1; min-width:120px">
</div>
<div class="hint">内缩默认 1.2cm(同接口9,得基准遮罩再取轮廓);带子总宽默认 2cm(半径=总宽/2)。改动后点「提交测试」重新计算;设置会自动记住。</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/band';
const MODEL_STEPS = [
{ key: 'base_mask', title: '③ 内缩后基准遮罩', sub: '同接口9,含额头、内缩后' },
{ key: 'contour', title: '④ 外轮廓线', sub: '去掉底部分界线的那段(绿)' },
{ key: 'band_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.base_pixels ?? '—') + '</div></div>' +
'<div class="metric"><div class="label">带子像素</div><div class="value">' + (model.band_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 (' + data.erode_px + 'px)' },
{ label: '带子总宽', value: data.dilate_cm + ' cm' },
{ label: '膨胀半径', value: data.dilate_radius_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');
form.append('dilate_cm', $('dilateCm').value || '2.0');
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);
});
}
// 每个参数一组:数字框 id / 滑块 id / localStorage key
const PARAMS = [
{ num: 'erodeCm', rng: 'erodeRange', key: 'if10_erode_cm' },
{ num: 'dilateCm', rng: 'dilateRange', key: 'if10_dilate_cm' },
];
function syncParam(p, fromEl) {
const num = $(p.num), rng = $(p.rng);
let v = parseFloat(fromEl.value);
if (isNaN(v) || v < 0) v = 0;
num.value = v;
rng.value = Math.min(parseFloat(rng.max), v);
try { localStorage.setItem(p.key, String(v)); } catch (e) {}
}
document.addEventListener('DOMContentLoaded', () => {
const dz = $('imageFile');
dz.addEventListener('change', () => { if (dz.files.length) setStatus('已选择: ' + dz.files[0].name, 'info'); });
PARAMS.forEach(p => {
try {
const saved = localStorage.getItem(p.key);
if (saved !== null) { $(p.num).value = saved; }
} catch (e) {}
syncParam(p, $(p.num));
$(p.num).addEventListener('input', () => syncParam(p, $(p.num)));
$(p.rng).addEventListener('input', () => syncParam(p, $(p.rng)));
});
});
</script>
</body>
</html>