接口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>
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
<!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 则保留 *_base64(data 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>
|
||||
Reference in New Issue
Block a user