- worker /api/v1/face/features 不再返回假成功数据,直接告知仅网关实现, 避免本机误打 :8187 被 Mock 结果误导。 - 网关 ark_api_key 加载优先级改为 gateway/config.json 优先(原先误读 worker_config.json 里的失效 key)。 - 接口4 face_shape 不再采信豆包结果,改用本机 face/face_shape_classifier.py (MediaPipe 7 类)计算覆盖;其余 5 项特征仍走豆包。 - 修复 face_shape_classifier 共享 FaceMesh 实例的线程安全问题(加锁), 避免网关侧接口4 并发请求时崩溃/结果错乱。 - 新增 /api/v1/debug/face-shape 调试接口 + static/test_face_shape.html 单图调试页(worker 侧)。 - 更新文档:网关机现在也需要 mediapipe/opencv-python/numpy<2。 ⚠️ 部署前提醒:网关机需先安装 mediapipe==0.10.14 / opencv-python==4.10.0.84 / numpy==1.26.4,否则接口4 会返回 1007「分析服务异常」。 Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
12 KiB
HTML
268 lines
12 KiB
HTML
<!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>
|
||
| 本地 <code>face/face_shape_classifier.py</code>(7 类)
|
||
| 非接口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 正面照 | 走本机 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 TOKEN = 'dev-shared-secret-2026';
|
||
|
||
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',
|
||
headers: { 'X-Internal-Token': TOKEN },
|
||
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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||
});
|
||
}
|
||
|
||
function copyJson() {
|
||
const t = $('jsonContent').textContent;
|
||
if (!t) return;
|
||
navigator.clipboard.writeText(t).then(function () {
|
||
setStatus('已复制 JSON', 'success');
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|