- 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部署步骤)
267 lines
11 KiB
HTML
267 lines
11 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>接口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>
|