Files
hair/static/test_interface99.html
T
xsl 0b02ad1693 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 23:32:24 +08:00

267 lines
11 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>接口99 — 人脸特殊点检测 测试页</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; }
.hidden { display: none; }
.canvas-wrap { position: relative; display: inline-block; max-width: 100%; border-radius: 8px; overflow: hidden; }
canvas { max-width: 100%; display: block; }
.legend { display: flex; gap: 14px; flex-wrap: wrap; margin-top: 12px; font-size: 13px; }
.legend .item { display: flex; align-items: center; gap: 6px; }
.legend .dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
.json-panel { background: #1e293b; color: #e2e8f0; font-family: "SF Mono", Consolas, monospace; font-size: 12px; line-height: 1.6; padding: 16px; border-radius: 8px; max-height: 480px; overflow: auto; white-space: pre; }
</style>
</head>
<body>
<div class="container">
<h1>接口99 — 人脸特殊点检测</h1>
<div class="subtitle">POST /api/v1/face/special-points · 固定编号 MediaPipe 关键点 + 5 个发际线特殊点(射线与头发分割 mask 交点),返回原图像素坐标</div>
<div class="card">
<div class="upload-row">
<input type="file" id="imageFile" accept="image/jpeg,image/png">
<button class="btn btn-primary" id="submitBtn" onclick="submitTest()">🚀 提交测试</button>
<button class="btn btn-outline" onclick="clearResults()">清空</button>
</div>
<div class="hint">支持 JPG / PNG;单人正面照效果最佳。发际线点依赖头发分割,秃顶/遮挡时对应字段为 null。</div>
<div class="status" id="statusBar"></div>
</div>
<div id="resultsArea" class="hidden">
<div class="card">
<h2 style="margin-top:0; display:flex; justify-content:space-between; align-items:center">
<span>可视化结果(点击图片可放大)</span>
<label style="font-size:13px; font-weight:400; display:flex; align-items:center; gap:6px; cursor:pointer">
<input type="checkbox" id="showIds" checked onchange="redraw()"> 显示点ID
</label>
</h2>
<div class="canvas-wrap"><canvas id="resultCanvas"></canvas></div>
<div class="legend" id="legendBar"></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'"
style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.85); z-index:99; cursor:zoom-out; align-items:center; justify-content:center">
<img id="lightboxImg" style="max-width:96vw; max-height:96vh" alt="">
</div>
<script>
const API_BASE = window.location.origin;
const ENDPOINT = '/api/v1/face/special-points';
// 组 → 颜色(与下方 legend 一致)
const GROUP_COLORS = {
eyebrow: '#ef4444', // 红
eye: '#3b82f6', // 蓝
mouth: '#22c55e', // 绿
nose_midline: '#a855f7', // 紫
cheek: '#f97316', // 橙
face_edge: '#06b6d4', // 青
hairline: '#e11d48', // 玫红(发际线特殊点,大圆+标签)
};
const GROUP_LABELS = {
eyebrow: '眉毛', eye: '眼睛', mouth: '嘴', nose_midline: '中线鼻',
cheek: '面颊', face_edge: '边线', hairline: '发际线特殊点',
};
const HAIRLINE_KEYS = ['hair_line_center', 'hair_line_right', 'hair_line_right_right',
'hair_line_left', 'hair_line_left_left'];
let lastImage = null; // 提交的原图(用于画布底图)
let lastJson = null;
let lastData = null; // 最近一次成功的 data(供「显示点ID」切换时重画)
function $(id) { return document.getElementById(id); }
function setStatus(text, type) {
const bar = $('statusBar');
bar.textContent = text;
bar.className = 'status ' + type;
}
async function submitTest() {
let 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');
// 本地预览原图(原图直送,不缩放,坐标即原图像素)
lastImage = await loadImage(await file.arrayBuffer());
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 dt = ((performance.now() - t0) / 1000).toFixed(2);
lastJson = json;
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
const n = Object.values(json.data.points || {}).filter(Boolean).length;
setStatus('✅ 请求成功 (' + dt + 's) — request_id: ' + json.request_id
+ ',有效点 ' + n + '/' + Object.keys(json.data.points || {}).length
+ ',hairline_source=' + json.data.hairline_source, 'success');
lastData = json.data;
renderCanvas(json.data);
renderLegend();
} 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 loadImage(buf) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(new Blob([buf]));
});
}
function renderCanvas(data) {
const canvas = $('resultCanvas');
const ctx = canvas.getContext('2d');
const img = lastImage;
const scale = Math.min(1, 900 / img.naturalWidth); // 画布显示尺寸(坐标按比例缩放)
canvas.width = Math.round(img.naturalWidth * scale);
canvas.height = Math.round(img.naturalHeight * scale);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const r = Math.max(1.5, 1.6 * scale * 2); // 点半径随图缩小适度收缩
const font = Math.max(9, Math.round(9 * scale * 2)); // 发际线标签字号
const idFont = Math.max(8, Math.round(6.5 * scale * 2)); // 关键点ID字号
const showIds = $('showIds').checked;
// 1) 固定关键点:小实心圆 + 白描边 + 点ID
for (const [key, pt] of Object.entries(data.points || {})) {
if (!pt || HAIRLINE_KEYS.includes(key)) continue;
const group = key.split('_')[0];
const color = GROUP_COLORS[group] || '#888';
const x = pt.x * scale, y = pt.y * scale;
drawDot(ctx, x, y, r, color);
if (showIds) {
const id = key.split('_').pop(); // 如 eyebrow_107 → 107
ctx.font = idFont + 'px sans-serif';
ctx.textBaseline = 'alphabetic';
ctx.lineWidth = 2.5;
ctx.strokeStyle = 'rgba(0,0,0,.75)'; // 黑描边保证任意底色可读
ctx.strokeText(id, x + r + 1, y + 3);
ctx.fillStyle = '#fff';
ctx.fillText(id, x + r + 1, y + 3);
}
}
// 2) 发际线特殊点:大空心圆 + 字段名标签
for (const key of HAIRLINE_KEYS) {
const pt = (data.points || {})[key];
if (!pt) continue;
const x = pt.x * scale, y = pt.y * scale;
ctx.beginPath();
ctx.arc(x, y, r * 2, 0, Math.PI * 2);
ctx.strokeStyle = GROUP_COLORS.hairline;
ctx.lineWidth = Math.max(1.5, r * 0.55);
ctx.stroke();
drawDot(ctx, x, y, r, GROUP_COLORS.hairline);
ctx.font = '600 ' + font + 'px sans-serif';
ctx.textBaseline = 'bottom';
const label = key.replace('hair_line_', 'HL ');
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(x + r * 2.4, y - font - 4, tw + 8, font + 6);
ctx.fillStyle = '#fecdd3';
ctx.fillText(label, x + r * 2.6 + 4, y - 2);
}
}
function drawDot(ctx, x, y, r, color) {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = Math.max(1, r * 0.35);
ctx.strokeStyle = 'rgba(255,255,255,.9)';
ctx.stroke();
}
function renderLegend() {
const bar = $('legendBar');
bar.innerHTML = Object.entries(GROUP_LABELS)
.map(([g, label]) => `<span class="item"><span class="dot" style="background:${GROUP_COLORS[g]}"></span>${label}</span>`)
.join('');
}
function clearResults() {
$('resultsArea').classList.add('hidden');
$('statusBar').className = 'status';
$('imageFile').value = '';
lastImage = null; lastJson = null; lastData = null;
}
function redraw() {
if (lastData && lastImage) renderCanvas(lastData);
}
function copyJson() {
if (!lastJson) return;
navigator.clipboard.writeText(JSON.stringify(lastJson, null, 2))
.then(() => setStatus('✅ JSON 已复制到剪贴板', 'success'));
}
// 点击画布放大
$('resultCanvas').addEventListener('click', () => {
const lb = $('lightbox');
$('lightboxImg').src = $('resultCanvas').toDataURL('image/png');
lb.style.display = 'flex';
});
</script>
</body>
</html>