Files
hair/static/test_interface1_debug.html
xsl 48c9875381 feat: 接口1/5/6 发际线弃用逻辑(顶庭<0.7cm)+接口1调试页分步可视化
- measure.py: MeasureResult 增 hairline_discarded 判定(顶庭<0.7cm),弃用时
  顶/上庭字段置null、face_total只算中下庭、hairline_source=discarded
- annotation.py: 弃用时保留头顶线、去掉发际线、只标中/下庭
- app.py: 接口1/6 弃用分支 + eye1/7竖向范围改用眉心 + 接口1调试页(measure-debug)
- 新增 static/test_interface1_debug.html 分步可视化页(9步原理)
2026-07-27 23:07:54 +08:00

367 lines
19 KiB
HTML
Raw Permalink 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>接口1 调试页(分步可视化)— 四庭七眼测量</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; }
h2 { font-size: 17px; margin: 24px 0 12px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 16px; line-height: 1.7; }
.subtitle code { background:#f0f0f0; padding:1px 6px; border-radius:4px; font-size:12px; }
/* 上传区 */
.upload-card { background: #fff; border-radius: 12px; padding: 20px 24px; 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; }
.file-input { flex: 1; min-width: 200px; }
.file-input input[type=file] { width: 100%; padding: 8px; border: 2px dashed #ddd; border-radius: 8px; cursor: pointer; }
.file-input input[type=file].dragover { border-color: #2563eb; background: #eff6ff; }
.btn { padding: 10px 24px; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; transition: .2s; }
.btn-primary { background: #2563eb; color: #fff; }
.btn-primary:hover { background: #1d4ed8; }
.btn-primary:disabled { background: #93c5fd; cursor: not-allowed; }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-outline { background: #fff; border: 1px solid #d1d5db; color: #374151; }
.btn-outline:hover { background: #f9fafb; }
.upload-hint { font-size: 12px; color: #9ca3af; margin-top: 8px; line-height: 1.6; }
/* 状态 */
.status { padding: 10px 16px; border-radius: 8px; font-size: 14px; margin-top: 12px; 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: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.metric { background: #f9fafb; border-radius: 8px; padding: 12px 14px; }
.metric .label { font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: .5px; }
.metric .value { font-size: 18px; font-weight: 700; color: #111827; margin-top: 2px; }
.metric .unit { font-size: 12px; color: #6b7280; font-weight: 400; }
/* debug 数值面板 */
.debug-info { background: #fff; border-radius: 12px; padding: 16px 20px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-top: 16px; }
.debug-info .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 24px; font-size: 13px; }
.debug-info .grid div { color: #4b5563; }
.debug-info .grid b { color: #111827; }
/* 分步卡片 */
.steps { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 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: 10px 12px; background: #fafafa; border-bottom: 1px solid #f0f0f0; display:flex; align-items:center; gap:8px; }
.step .cap .badge { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; min-width:26px; height:26px; padding:0 6px; border-radius:6px; background:#2563eb; color:#fff; font-size:14px; font-weight:700; }
.step .cap .ttext { flex:1; }
.step .cap small { color: #999; font-weight: 400; display:block; margin-top:2px; font-size:11px; }
.step .desc { font-size: 12.5px; line-height: 1.7; color: #4b5563; padding: 10px 12px; background: #f9fafb; border-bottom: 1px solid #f0f0f0; }
.step .desc b { color:#1f2937; }
.step img { width: 100%; display: block; background: #222; cursor: zoom-in; }
.step .noimg { padding: 30px; text-align: center; color: #ccc; font-size: 13px; }
/* JSON */
.panel { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; margin-top:16px; }
.panel-header { font-weight: 700; font-size: 14px; padding: 12px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.json-panel { max-height: 500px; overflow: auto; }
.json-content { padding: 14px 18px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
/* lightbox */
#lightbox { position: fixed; inset: 0; background: rgba(0,0,0,.9); display: none; align-items: center; justify-content: center; z-index: 999; cursor: zoom-out; padding: 30px; }
#lightbox img { max-width: 95%; max-height: 95%; }
.hidden { display: none !important; }
</style>
<script src="/static/img_downscale.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>📐 接口1 调试页 <span style="font-size:13px;color:#888">(分步可视化 + 原理)</span></h1>
<div class="subtitle">
独立调试页,把「四庭七眼测量」算法的每一步中间产物都可视化出来,并标注原理。<br>
调试接口:<code>POST /api/v1/face/measure-debug</code> &nbsp;|&nbsp; 与正式接口 <code>/api/v1/face/measure</code> 同算法链路,额外返回 9 张分步图。<br>
算法链路:取图 → 关键点检测 → 姿态校验 → 头发/耳朵分割 → 发际线定位 → 四庭纵向 → 七眼横向 → 尺度校准 → 最终标注。
</div>
<!-- 上传区 -->
<div class="upload-card">
<div class="upload-row">
<div class="file-input">
<input type="file" id="imageFile" accept="image/jpeg,image/png,.jpg,.jpeg,.png">
</div>
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline btn-sm" onclick="clearResults()">清除结果</button>
</div>
<div class="upload-hint">支持 JPG / PNG 正面照 &nbsp;|&nbsp; 也可拖拽图片到文件选择框</div>
<div id="statusBar" class="status hidden"></div>
</div>
<!-- 指标速览 -->
<h2 class="hidden" id="metricsTitle">📊 指标速览</h2>
<div id="metricsBar" class="metrics hidden"></div>
<!-- debug 数值 -->
<div id="debugPanel" class="debug-info hidden">
<div style="font-weight:700;font-size:13px;margin-bottom:8px">🔍 中间数值(data.debug</div>
<div class="grid" id="debugGrid"></div>
</div>
<!-- 分步可视化 -->
<h2 class="hidden" id="stepsTitle">🪜 分步可视化(每一步中间产物 + 原理)</h2>
<div class="steps" id="stepsGrid"></div>
<!-- JSON -->
<div class="panel hidden" id="jsonPanel">
<div class="panel-header">
<span>📋 JSON 响应</span>
<button class="btn btn-outline btn-sm" onclick="copyJson()">📋 复制</button>
</div>
<div class="json-panel"><pre class="json-content" id="jsonContent"></pre></div>
</div>
</div>
<!-- lightbox -->
<div id="lightbox" onclick="this.style.display='none'"><img id="lightboxImg" alt=""></div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/face/measure-debug';
// 9 个步骤的配置:no=序号, key=后端 steps 字段名, title=标题, sub=子标题, desc=原理说明
const STEPS = [
{ no: '①', key: 'input', title: '输入原图', sub: '用户上传的正面照',
desc: '算法入口:接收 JPG/PNG 图片,解码为 OpenCV BGR 数组。后续所有关键点、分割、测量都基于这张图,坐标原点在图片左上角,x 向右、y 向下。'
},
{ no: '②', key: 'landmarks', title: '人脸关键点检测', sub: '白=全部478点,青=虹膜点,红+标号=关键测量点',
desc: '用 <b>MediaPipe Face Mesh</b>468 点拓扑,开启 refine_landmarks 再加 10 个虹膜点,共 478 点)定位人脸。'
+ '中/下庭(眉心→鼻翼下缘→下巴尖)能直接从关键点读出;上/顶庭因发际线被头发遮挡、关键点不稳定,需后续分割补齐。'
+ '虹膜点(468~477)是尺度校准的天然标尺。图中红点为七眼 6 点 + 鼻翼/下巴/眉间等纵向基准点。'
},
{ no: '③', key: 'pose', title: '头部姿态校验', sub: '黄=PnP 6点,红/绿/青=三轴,文字=yaw/pitch/roll',
desc: '用 6 个关键点(鼻尖/下巴/双眼外角/双嘴角)配通用 3D 头模,<b>cv2.solvePnP</b> 解出欧拉角。'
+ 'yaw=左右扭头、pitch=上下点头、roll=面内倾斜。三轴绝对值均 ≤ 30° 才算正面照,否则报「角度问题」(1003)。'
+ 'ITERATIVE 解偶尔收敛到相机后方(翻转解),检测到 tz&lt;0 时用 SQPNP 重解正深度解。'
},
{ no: '④', key: 'segmentation', title: '头发/耳朵分割', sub: '绿=头发(hair=17),蓝=耳朵(ear=7/8)',
desc: '<b>BiSeNet</b>CelebAMask-HQ 19 类语义分割,hair=17/l_ear=7/r_ear=8)单次推理同时出头发和耳朵 mask。'
+ '整张大场景图(脸只占一小块)会欠分割丢耳朵,故先按人脸框放大裁剪再分割,让脸接近训练分布。'
+ '头发 mask 用于下一步定位发际线;耳朵 mask 用于七眼取「人头最左/最右」端线。'
},
{ no: '⑤', key: 'hairline', title: '发际线/头顶定位', sub: '黄列带=中轴线扫描,红横线=hairline,黄横线=hair_top',
desc: '<b>方案 B(分割,优先)</b>:在面部中轴线 ±3px 列带上扫头发 mask,最靠下的头发行 = 发际线 hairline_y'
+ '全图头发 mask 最高点 = 头顶 hair_top_y;合理性校验(头顶&lt;发际线&lt;眉心、各庭为正)不过则回退。'
+ '<b>方案 A(兜底)</b>:实测中/下庭,按比例常量(顶:上:中:下=0.22:0.25:0.28:0.25)推算上/顶庭。'
+ 'source=segmentation 表示用了真实分割,=estimated 表示回退估算。'
},
{ no: '⑥', key: 'vertical', title: '四庭纵向点', sub: '红点=5个纵向基准,绿竖线=各庭段高(cm)',
desc: '把 5 个纵向点(头顶/发际线/眉心/鼻翼下缘/下巴尖)连成 4 段:'
+ '<b>顶庭</b>(头顶→发际线)、<b>上庭</b>(发际线→眉心)、<b>中庭</b>(眉心→鼻翼下缘)、<b>下庭</b>(鼻翼下缘→下巴尖)。'
+ '各段像素高 ÷ px_per_cm = 厘米值。这是「四庭」比例分析的物理依据。'
},
{ no: '⑦', key: 'seven_eyes', title: '七眼横向点', sub: '红点=6个横向基准,紫线=人头最左/最右端线',
desc: '取左右脸颊/眼外角/眼内角 6 个关键点的横坐标,把头宽切成 5 段(左脸颊/左眼/两眼间距/右眼/右脸颊)。'
+ '再用耳朵分割外缘补「人头最左/最右」两条端线(紫色),共 7 段 = <b>七眼</b>。'
+ '耳朵被头发遮挡时该侧端线省略(看不到耳朵就不画),对应 eye1/eye7 返回 null。'
},
{ no: '⑧', key: 'scale', title: '尺度校准', sub: '青=虹膜直径线,黄=眼宽线(降级),文字=px_per_cm',
desc: '把像素换算成厘米。<b>虹膜直径法(优先)</b>:成人虹膜直径高度稳定(平均 11.7mm=1.17cm),'
+ '左右虹膜直径像素均值 ÷ 1.17 = px_per_cm。虹膜点缺失时<b>降级用眼宽法</b>(外→内眼角均值 2.85cm)。'
+ '这一步决定了所有 cm 数值的准确度——标尺错了,后面全错。'
},
{ no: '⑨', key: 'final', title: '最终标注图', sub: '原图 + 标注层叠加 = 接口1 正式输出',
desc: '把前面算出的四庭七眼数据,用 <b>create_annotated_image()</b> 渲染成透明底 RGBA 标注层'
+ '(白线/白字、渐变消失横竖线、虚线双箭头、各段 cm+百分比),再叠加回原图。'
+ '这就是正式接口 <code>/api/v1/face/measure</code> 返回的 annotated_image_base64。'
},
];
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
function pick(obj, name) {
if (!obj) return null;
return obj[name + '_url'] || obj[name + '_base64'] || null;
}
function zoom(src) { $('lightboxImg').src = src; $('lightbox').style.display = 'flex'; }
function stepCard(st, src) {
const div = document.createElement('div');
div.className = 'step';
const hasImg = src && src.length > 50;
const img = hasImg ? '<img src="' + src + '" onclick="zoom(this.src)">'
: '<div class="noimg">无图(后端未返回此步)</div>';
const badge = '<span class="badge">' + st.no + '</span>';
const lenTxt = hasImg ? ' (' + src.length + '字符)' : '';
const ttext = '<span class="ttext">' + st.title + '<small>' + st.sub + lenTxt + '</small></span>';
const desc = '<div class="desc">' + st.desc + '</div>';
div.innerHTML = '<div class="cap">' + badge + ttext + '</div>' + desc + img;
return div;
}
function renderResult(d) {
const s = d.steps || {};
const grid = $('stepsGrid');
grid.innerHTML = '';
STEPS.forEach(st => {
grid.appendChild(stepCard(st, pick(s, st.key)));
});
}
function renderMetrics(data) {
const fc = data.four_courts || {};
const se = data.seven_eyes || {};
const items = [
{ label: '全脸总高', value: data.face_total_height_cm, unit: 'cm' },
{ label: '顶庭', value: fc.top_court_cm, unit: 'cm' },
{ label: '上庭', value: fc.upper_court_cm, unit: 'cm' },
{ label: '中庭', value: fc.middle_court_cm, unit: 'cm' },
{ label: '下庭', value: fc.lower_court_cm, unit: 'cm' },
{ label: '单眼宽度', value: se.eye_width_cm, unit: 'cm' },
{ label: '脸宽', value: se.face_width_cm, unit: 'cm' },
{ label: '两眼间距', value: se.inter_eye_distance_cm, unit: 'cm' },
];
let html = '';
items.forEach(m => {
// null 值(发际线弃用时顶/上庭)显示「已弃用」,灰色弱化
if (m.value === null || m.value === undefined) {
html += '<div class="metric" style="opacity:.5"><div class="label">' + m.label + '</div>'
+ '<div class="value" style="font-size:13px;color:#9ca3af">已弃用</div></div>';
} else {
html += '<div class="metric"><div class="label">' + m.label + '</div><div class="value">'
+ m.value + ' <span class="unit">' + m.unit + '</span></div></div>';
}
});
$('metricsBar').innerHTML = html;
}
function renderDebug(dbg) {
if (!dbg) { $('debugPanel').classList.add('hidden'); return; }
const rows = [];
const push = (k, v) => rows.push('<div>' + k + ' <b>' + v + '</b></div>');
push('图片尺寸', (dbg.image_width||'?') + ' × ' + (dbg.image_height||'?') + ' px');
push('关键点数', dbg.num_landmarks || 0);
if (dbg.head_pose) {
push('姿态 yaw/pitch/roll',
dbg.head_pose.yaw + '° / ' + dbg.head_pose.pitch + '° / ' + dbg.head_pose.roll + '°');
push('是否正面', dbg.head_pose.frontal ? 'YES' : 'NO');
}
push('头发像素', dbg.hair_pixels ?? '—');
push('耳朵像素', dbg.ear_pixels ?? '—');
const srcMap = { segmentation: '分割(方案B)', estimated: '估算(方案A兜底)',
discarded: '⚠️ 已弃用(离头顶<0.7cm)' };
push('发际线来源', srcMap[dbg.hairline_source] || dbg.hairline_source || '—');
push('尺度方法', dbg.scale_method === 'iris' ? '虹膜直径法' :
(dbg.scale_method === 'eye_width' ? '眼宽法(降级)' : dbg.scale_method || '—'));
push('px_per_cm', dbg.px_per_cm ?? '—');
if (dbg.head_left_x != null || dbg.head_right_x != null) {
push('人头最左/最右 x', (dbg.head_left_x ?? 'null') + ' / ' + (dbg.head_right_x ?? 'null'));
}
$('debugGrid').innerHTML = rows.join('');
$('debugPanel').classList.remove('hidden');
}
async function submitTest() {
const fileInput = $('imageFile');
let file = fileInput.files[0];
if (!file) { setStatus('请先选择一张图片', 'error'); return; }
file = await window.downscaleImageFile(file);
const _reqStart = performance.now();
const btn = $('submitBtn');
btn.disabled = true;
btn.textContent = '⏳ 请求中...';
setStatus('正在请求 ' + ENDPOINT + ' ...', 'info');
// 隐藏旧结果
['metricsTitle','metricsBar','stepsTitle','jsonPanel'].forEach(id => $(id).classList.add('hidden'));
$('debugPanel').classList.add('hidden');
$('stepsGrid').innerHTML = '';
const form = new FormData();
form.append('image_file', file);
try {
const resp = await fetch(API_BASE + ENDPOINT, {
method: 'POST',
headers: { 'X-Internal-Token': 'dev-shared-secret-2026' },
body: form
});
const json = await resp.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('jsonPanel').classList.remove('hidden');
$('stepsTitle').classList.remove('hidden');
const d = json.data || {};
if (json.code === 0) {
setStatus('✅ 请求成功 (' + _elapsed + 's) — 完整 9 步可视化', 'success');
renderResult(d);
renderMetrics(d);
renderDebug(d.debug);
$('metricsTitle').classList.remove('hidden');
$('metricsBar').classList.remove('hidden');
} else {
// 业务错误(如 1001 未检出人脸 / 1003 非正面):仍展示已完成的步骤图
setStatus('⚠️ 业务错误 (' + _elapsed + 's) — code: ' + json.code + ' ' + json.message
+ '(下方展示算法走到哪一步)', 'error');
if (d.steps) renderResult(d);
if (d.debug) renderDebug(d.debug);
}
} catch (err) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ 网络错误 (' + _elapsed + 's): ' + err.message, 'error');
$('jsonContent').textContent = 'Error: ' + err.message;
} finally {
btn.disabled = false;
btn.textContent = '🚀 提交测试';
}
}
function clearResults() {
$('metricsTitle').classList.add('hidden');
$('metricsBar').classList.add('hidden');
$('debugPanel').classList.add('hidden');
$('stepsTitle').classList.add('hidden');
$('stepsGrid').innerHTML = '';
$('jsonPanel').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
}
function copyJson() {
const text = $('jsonContent').textContent;
navigator.clipboard.writeText(text).then(() => {
const btn = event.target;
const orig = btn.textContent;
btn.textContent = '✅ 已复制';
setTimeout(() => btn.textContent = orig, 1500);
});
}
// 拖拽上传
document.addEventListener('DOMContentLoaded', () => {
const dropZone = $('imageFile');
dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); });
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
dropZone.addEventListener('drop', e => {
e.preventDefault();
dropZone.classList.remove('dragover');
if (e.dataTransfer.files.length) {
dropZone.files = e.dataTransfer.files;
setStatus('已选择: ' + e.dataTransfer.files[0].name, 'info');
}
});
dropZone.addEventListener('change', () => {
if (dropZone.files.length) setStatus('已选择: ' + dropZone.files[0].name, 'info');
});
});
</script>
</body>
</html>