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部署步骤)
This commit is contained in:
xsl
2026-09-06 23:37:01 +08:00
parent 989f4d0d18
commit 63bd265386
8 changed files with 906 additions and 93 deletions
+266
View File
@@ -0,0 +1,266 @@
<!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>