Files
hair/static/test_face_shape.html
T
xsl b031ce5bbe security: 清除全部公开测试页硬编码的 worker 内部密码
- 16 个测试页移除 X-Internal-Token 硬编码(网关不校验该头,删除零影响,已线上验证)
- 11/12 系列保留 token 输入框仅清空默认值,直连 worker 调试改为手输
- 接入文档 integration.html:补请求体 2MB 上限与 413 说明、HTTP 层错误提示、接口4 7 字段、补全 9/10/99 测试页入口
2026-09-07 00:35:29 +08:00

266 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>脸型分类调试 — MediaPipe</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: 6px; }
.subtitle { color: #888; font-size: 13px; margin-bottom: 24px; }
.subtitle code { background: #eef2ff; color: #3730a3; padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.card { background: #fff; border-radius: 12px; box-shadow: 0 1px 4px rgba(0,0,0,.06); margin-bottom: 20px; }
.card-header { font-weight: 700; font-size: 14px; padding: 14px 18px; border-bottom: 1px solid #f0f0f0; background: #fafafa; display: flex; justify-content: space-between; align-items: center; }
.card-body { padding: 18px; }
.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; }
.btn { padding: 10px 28px; 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; }
.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; }
.results-layout { display: flex; gap: 24px; }
.col-main { flex: 1.4; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
.verdict { display: flex; gap: 16px; flex-wrap: wrap; align-items: stretch; }
.verdict-item { flex: 1; min-width: 140px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 16px; }
.verdict-item .label { font-size: 11px; color: #64748b; text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
.verdict-item .value { font-size: 20px; font-weight: 700; color: #0f172a; }
.verdict-item .value.accent { color: #2563eb; }
.badge-mixed { display: inline-block; margin-left: 8px; background: #fef3c7; color: #92400e; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 700; vertical-align: middle; }
.img-preview { text-align: center; background: #222; border-radius: 8px; overflow: hidden; min-height: 200px; display: flex; align-items: center; justify-content: center; }
.img-preview img { max-width: 100%; max-height: 560px; object-fit: contain; display: block; }
.img-preview .placeholder { color: #9ca3af; padding: 40px; font-size: 14px; }
.bar-list { display: flex; flex-direction: column; gap: 10px; }
.bar-row { display: grid; grid-template-columns: 72px 1fr 52px; gap: 10px; align-items: center; font-size: 13px; }
.bar-row .name { font-weight: 600; color: #334155; }
.bar-row .track { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
.bar-row .fill { height: 100%; background: #94a3b8; border-radius: 999px; }
.bar-row.top .fill { background: #2563eb; }
.bar-row.second .fill { background: #60a5fa; }
.bar-row .score { text-align: right; font-variant-numeric: tabular-nums; color: #475569; }
.feat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.feat-table th, .feat-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #f1f5f9; }
.feat-table th { background: #f8fafc; font-weight: 700; color: #475569; font-size: 11px; text-transform: uppercase; letter-spacing: .3px; }
.feat-table td:first-child { font-weight: 600; color: #1e293b; width: 180px; }
.feat-table tr:hover td { background: #f8fafc; }
.json-panel { max-height: 520px; overflow: auto; }
.json-content { padding: 14px 16px; font-family: "SF Mono", "Fira Code", monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
.hidden { display: none !important; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js"></script>
</head>
<body>
<div class="container">
<h1>脸型分类调试(MediaPipe)</h1>
<p class="subtitle">
POST <code>/api/v1/debug/face-shape</code>
&nbsp;|&nbsp; 本地 <code>face/face_shape_classifier.py</code>(7 类)
&nbsp;|&nbsp; 非接口4 豆包分析
</p>
<div class="card">
<div class="card-body">
<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="hint">JPG/PNG 正面照 &nbsp;|&nbsp; 走本机 worker(MediaPipe),约 1s 内</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<div class="card">
<div class="card-header"><span>判定结果</span></div>
<div class="card-body">
<div class="verdict" id="verdictBox"></div>
</div>
</div>
<div class="card">
<div class="card-header"><span>特征标注图</span></div>
<div class="card-body">
<div class="img-preview" id="imgPreview"><span class="placeholder">—</span></div>
</div>
</div>
<div class="card">
<div class="card-header"><span>各脸型得分</span></div>
<div class="card-body">
<div class="bar-list" id="scoreBars"></div>
</div>
</div>
</div>
<div class="col-side">
<div class="card">
<div class="card-header"><span>几何特征</span></div>
<div class="card-body" style="padding:0;max-height:360px;overflow:auto">
<table class="feat-table" id="featTable"></table>
</div>
</div>
<div class="card">
<div class="card-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>
</div>
</div>
<script>
const API_BASE = window.location.origin;
const FEAT_LABELS = {
face_height: '脸高 (px)',
face_width: '脸宽 (px)',
forehead_width: '额宽 (px)',
cheekbone_width: '颧宽 (px)',
jaw_width: '下颌宽 (px)',
chin_width: '下巴宽 (px)',
aspect_ratio: '长宽比 (宽/高)',
forehead_ratio: '额宽/面宽',
cheekbone_ratio: '颧宽/面宽',
jaw_ratio: '下颌宽/面宽',
chin_ratio: '下巴宽/面宽',
chin_sharpness: '下巴尖锐度',
taper_ratio: '额头→下巴收窄',
jaw_angle: '下颌角 (°)',
width_uniformity: '宽度均匀度',
face_curve_score: '面部曲线分',
};
function $(id) { return document.getElementById(id); }
function setStatus(t, type) {
const b = $('statusBar');
b.textContent = t;
b.className = 'status ' + type;
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status hidden';
$('imageFile').value = '';
$('jsonContent').textContent = '';
$('imgPreview').innerHTML = '<span class="placeholder">—</span>';
}
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
if (window.downscaleImageFile) f = await window.downscaleImageFile(f);
$('submitBtn').disabled = true;
$('submitBtn').textContent = '分析中...';
setStatus('调用 MediaPipe 脸型分类...', 'info');
$('resultsArea').classList.add('hidden');
const fd = new FormData();
fd.append('image_file', f);
const t0 = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/debug/face-shape', {
method: 'POST',
body: fd,
});
const json = await r.json();
const elapsed = ((performance.now() - t0) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
setStatus('完成 (' + elapsed + 's)', 'success');
renderResult(json.data);
} else {
setStatus('(' + elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch (e) {
setStatus('请求失败: ' + e.message, 'error');
} finally {
$('submitBtn').disabled = false;
$('submitBtn').textContent = '分析脸型';
}
}
function renderResult(data) {
const mixed = data.is_mixed
? '<span class="badge-mixed">混合 · 次选 ' + (data.second_shape || '—') + '</span>'
: '';
$('verdictBox').innerHTML =
'<div class="verdict-item"><div class="label">脸型</div><div class="value accent">' +
esc(data.display || data.face_shape) + mixed + '</div></div>' +
'<div class="verdict-item"><div class="label">置信度</div><div class="value">' +
(data.confidence * 100).toFixed(1) + '%</div></div>' +
'<div class="verdict-item"><div class="label">分差 score_gap</div><div class="value">' +
(data.score_gap == null ? '—' : data.score_gap) + '</div></div>' +
'<div class="verdict-item"><div class="label">尺寸</div><div class="value" style="font-size:16px">' +
(data.image_size ? data.image_size.width + '×' + data.image_size.height : '—') + '</div></div>';
if (data.annotated_image_base64) {
$('imgPreview').innerHTML =
'<img src="data:image/jpeg;base64,' + data.annotated_image_base64 + '" alt="annotated">';
} else {
$('imgPreview').innerHTML = '<span class="placeholder">无标注图</span>';
}
const ranked = data.ranked || [];
const maxScore = ranked.length ? Math.max.apply(null, ranked.map(function (x) { return x.score; })) : 100;
$('scoreBars').innerHTML = ranked.map(function (row, i) {
const cls = i === 0 ? ' top' : (i === 1 ? ' second' : '');
const pct = maxScore > 0 ? (100 * row.score / maxScore) : 0;
return '<div class="bar-row' + cls + '">' +
'<div class="name">' + esc(row.shape) + '</div>' +
'<div class="track"><div class="fill" style="width:' + pct.toFixed(1) + '%"></div></div>' +
'<div class="score">' + Number(row.score).toFixed(1) + '</div>' +
'</div>';
}).join('');
const feats = data.features || {};
const keys = Object.keys(feats);
let html = '<tr><th>特征</th><th>值</th></tr>';
keys.forEach(function (k) {
const label = FEAT_LABELS[k] || k;
const v = feats[k];
const text = typeof v === 'number' ? (Number.isInteger(v) ? v : v.toFixed(4)) : String(v);
html += '<tr><td>' + esc(label) + '</td><td>' + esc(String(text)) + '</td></tr>';
});
$('featTable').innerHTML = html;
}
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c];
});
}
function copyJson() {
const t = $('jsonContent').textContent;
if (!t) return;
navigator.clipboard.writeText(t).then(function () {
setStatus('已复制 JSON', 'success');
});
}
</script>
</body>
</html>