Files
hair/static/test_interface52.html
T
xsl 5b2b73f5d5 feat: 新增52号发际线接口并限制GPU worker来源IP
保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。

worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。

验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
2026-11-04 10:20:29 +08:00

419 lines
25 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>接口52 — 发际线PNG生成 测试页</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; }
.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; }
.form-group { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.form-group label { font-size: 13px; font-weight: 600; color: #374151; }
.form-group select { padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; font-size: 14px; }
.hair-checkboxes { display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: center; }
.hair-checkboxes label { font-size: 13px; font-weight: 500; color: #374151; cursor: pointer; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
.hair-checkboxes label input[type=checkbox] { cursor: pointer; }
.checkbox-actions { display: flex; gap: 6px; margin-left: 4px; }
.checkbox-actions button { font-size: 11px; padding: 2px 8px; border-radius: 4px; border: 1px solid #d1d5db; background: #f9fafb; cursor: pointer; }
.checkbox-actions button:hover { background: #e5e7eb; }
.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.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 每个发型区块 */
.hair-block { padding: 16px 0; border-bottom: 1px solid #f0f0f0; }
.hair-block:first-child { padding-top: 4px; }
.hair-block:last-child { border-bottom: none; padding-bottom: 4px; }
.hair-block-title { font-size: 15px; font-weight: 700; color: #111827; margin-bottom: 10px; }
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
.level-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; align-items: start; }
.level-cell { background: #222; border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
.level-cell .level-label { font-size: 12px; font-weight: 600; color: #fff; background: rgba(0,0,0,.55); padding: 4px 8px; min-height: 38px; display: flex; align-items: center; }
.level-cell img { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .level-none { color: #9ca3af; font-size: 13px; padding: 28px 0; text-align: center; }
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
.level-cell .img-stack { position: relative; line-height: 0; }
.level-cell .img-stack .layer-base { width: 100%; max-height: 380px; object-fit: contain; display: block; }
.level-cell .img-stack .layer-anno { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: contain; }
.hairline-crop-border { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; }
.hairline-crop-border polygon { fill: none; stroke: #e33399; stroke-width: 2; stroke-dasharray: 6 4; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
@media (max-width: 700px) { .level-row { grid-template-columns: repeat(2, 1fr); } }
/* 坐标高亮 */
.coord-box { background: #f9fafb; border-radius: 8px; padding: 12px 16px; margin-top: 10px; font-size: 14px; }
.coord-box .label { font-size: 11px; color: #9ca3af; margin-bottom: 4px; }
.coord-box .value { font-weight: 700; font-size: 18px; color: #111827; }
/* 四庭七眼测量(face_measure) */
.fm-section { margin-bottom: 18px; }
.fm-section:last-child { margin-bottom: 0; }
.fm-title { font-size: 13px; font-weight: 700; color: #374151; margin-bottom: 8px; }
.fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
.fm-cell { background: #f9fafb; border-radius: 6px; padding: 8px 10px; border: 1px solid #f0f0f0; }
.fm-cell .k { font-size: 11px; color: #9ca3af; margin-bottom: 2px; }
.fm-cell .v { font-size: 15px; font-weight: 700; color: #111827; }
.fm-cell .v .sub { font-size: 11px; font-weight: 500; color: #6b7280; margin-left: 4px; }
.fm-cell.muted .v { color: #9ca3af; font-weight: 600; }
.fm-tag { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 10px; margin-left: 6px; font-weight: 600; }
.fm-tag.ok { background: #d1fae5; color: #065f46; }
.fm-tag.warn { background: #fef3c7; color: #92400e; }
.json-panel { max-height: 550px; 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; }
.rule-banner { background:#ecfdf5; border:1px solid #a7f3d0; border-radius:12px; padding:18px; margin-bottom:20px; line-height:1.8; }
.rule-banner strong { color:#065f46; font-size:19px; }
.rule-banner a { color:#065f46; margin-left:18px; font-weight:600; }
.placement { font-size:12px; color:#64748b; font-weight:400; margin:6px 0 10px; }
@media (max-width: 800px) { .results-layout { flex-direction: column; } }
</style>
<script src="/static/img_downscale.js?v=2"></script>
<script src="/static/hairline_reference_box.js?v=2"></script>
</head>
<body>
<div class="container">
<h1>💈 接口52 — 发际线 PNG 生成 测试</h1>
<p class="subtitle">上传正面照,选择性别与发型,查看按中下庭均值定位的设计曲线和生发效果。</p>
<div class="rule-banner"><strong>目标上庭高度 =(中庭高度 + 下庭高度)÷ 2</strong><a href="/static/test_interface52_compare.html">查看旧 / 新规则对比 →</a><br>上传图先扶正9→152,再裁切为高约 3.3333×L、宽 2.5×L,人物比原3L规则缩小约10%;L 是9号点到152号点的像素距离,眉心位置沿用参考图比例。长图会在保留人脸的前提下裁为3:4,其余空间不足时仅扶正。从眉心向上定位,各发型中点对齐同一目标。high、low 已停用,旧字段返回 null。</div>
<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>
<div class="form-group">
<label>性别</label>
<select id="gender" onchange="onGenderChange()"><option value="female" selected>👩 Female(7种)</option><option value="male">👨 Male(7种)</option></select>
</div>
<div class="form-group" style="align-items:flex-start">
<label style="padding-top:3px">发型 *</label>
<div>
<div class="hair-checkboxes" id="hairStyleGroup"></div>
<div class="checkbox-actions" style="margin-top:4px">
<button type="button" onclick="selectAllHair(true)">全选</button>
<button type="button" onclick="selectAllHair(false)">全不选</button>
</div>
</div>
</div>
<div class="form-group">
<label>生发效果图</label>
<label class="checkbox-inline" style="font-weight:normal;display:flex;align-items:center;gap:6px">
<input type="checkbox" id="genGrowImg" checked> 同时生成生发效果(关闭后只查看设计曲线,速度更快)
</label>
</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; Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 &nbsp;|&nbsp; Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 &nbsp;|&nbsp; 每个发型返回一张设计曲线,生发遮罩同步到新位置</div>
<div id="statusBar" class="status hidden"></div>
</div>
</div>
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card">
<div class="card-header"><span>📐 处理后图像的四庭七眼测量</span><span style="font-weight:400;font-size:12px;color:#9ca3af">与当前显示底图使用同一坐标系</span></div>
<div class="card-body"><div id="faceMeasureArea"><span style="color:#9ca3af">—</span></div></div>
</div>
<!-- 所有发型平铺 -->
<div class="card">
<div class="card-header"><span>🎯 未裁切原图 + middle + 裁切发际线叠加 + 生发图</span><span style="font-weight:400;font-size:12px;color:#9ca3af" id="resultCount">共 0 个发型</span></div>
<div class="card-body">
<div class="coord-box" style="margin-top:0;margin-bottom:16px">
<div class="label">📍 设计中心点 — 图片像素坐标</div>
<div class="value">
<span id="centerPoint">—</span>
</div>
</div>
<div id="ruleDetails" style="font-size:13px;color:#475569;margin-bottom:16px;line-height:1.8"></div>
<div id="resultsGrid"></div>
</div>
</div>
</div>
<div class="col-side">
<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;
let _images = [], _uploadUrl = '', _originalSrc = '', _processedSrc = '', _center = null, _guideLandmarks = null, _renderVersion = 0;
function $(id) { return document.getElementById(id); }
function setStatus(t, type) { const b=$('statusBar'); b.textContent=t; b.className='status '+type; }
// 发型列表(同接口2)
const HAIR_STYLES = {
female: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. flower 花瓣' },
{ value:'3', label:'3. heart 心形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. wave 波浪' },
{ value:'6', label:'6. bigflower 大花瓣型' },
{ value:'7', label:'7. clasicalflower 古典花瓣型' },
],
male: [
{ value:'1', label:'1. ellipse 椭圆' },
{ value:'2', label:'2. inverse_arc 倒弧' },
{ value:'3', label:'3. m M形' },
{ value:'4', label:'4. straight 直线' },
{ value:'5', label:'5. heart 桃心形' },
{ value:'6', label:'6. Softpetal 柔和花瓣形' },
{ value:'7', label:'7. wave 波浪形' },
]
};
function onGenderChange() {
const g = $('gender').value;
const div = $('hairStyleGroup');
div.innerHTML = '';
HAIR_STYLES[g].forEach(s => {
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.value = s.value; cb.id = 'hs_' + s.value;
const lb = document.createElement('label');
lb.htmlFor = cb.id; lb.appendChild(cb);
lb.appendChild(document.createTextNode(' ' + s.label));
div.appendChild(lb);
});
}
function selectAllHair(select) {
document.querySelectorAll('#hairStyleGroup input[type=checkbox]').forEach(cb => { cb.checked = select; });
}
async function submitTest() {
let f = $('imageFile').files[0];
if (!f) { setStatus('请选择图片', 'error'); return; }
const checked = [...document.querySelectorAll('#hairStyleGroup input:checked')].map(cb => cb.value);
if (!checked.length) { setStatus('请至少选择一个发型', 'error'); return; }
// Preserve native pixels: the worker frames and straightens the image first.
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_uploadUrl = URL.createObjectURL(f);
_originalSrc = _uploadUrl; _processedSrc = ''; _images = []; _guideLandmarks = null; _renderVersion++;
$('resultsGrid').innerHTML = '';
$('submitBtn').disabled = true; $('submitBtn').textContent = '⏳ ...';
setStatus('请求中...', 'info'); $('resultsArea').classList.add('hidden');
const fd = new FormData(); fd.append('image_file', f); fd.append('gender', $('gender').value); fd.append('hair_style', checked.join(',')); fd.append('generate_grow_image', $('genGrowImg').checked ? 'true' : 'false'); fd.append('normalize_image', 'true');
const _reqStart = performance.now();
try {
const r = await fetch(API_BASE + '/api/v1/hairline/generate-52', { method:'POST', headers: { 'X-Internal-Token': 'dev-shared-secret-2026' }, body:fd });
const json = await r.json();
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
$('jsonContent').textContent = JSON.stringify(json, null, 2);
$('resultsArea').classList.remove('hidden');
if (json.code === 0) {
_images = json.data.hairline_images || [];
_originalSrc = resolveImgSrc(json.data.original_image_url, json.data.original_image_base64, 'image/jpeg') || _uploadUrl;
_processedSrc = resolveImgSrc(json.data.input_image_url, json.data.input_image_base64, 'image/jpeg');
_guideLandmarks = json.data.guide_landmarks;
if (_originalSrc !== _uploadUrl) { URL.revokeObjectURL(_uploadUrl); _uploadUrl = ''; }
_center = json.data.best_hairline_center_point;
const _fmtPt = p => p ? '(' + p.x + ', ' + p.y + ')' : '—';
$('centerPoint').textContent = _fmtPt(json.data.best_hairline_center_point);
const rule = json.data.height_rule;
$('ruleDetails').textContent = rule
? '中庭 ' + rule.middle_height_px.toFixed(2) + ' px + 下庭 ' + rule.lower_height_px.toFixed(2) + ' px → 目标上庭 ' + rule.target_upper_height_px.toFixed(2) + ' px' + (rule.target_upper_height_cm == null ? '' : '(约 ' + rule.target_upper_height_cm.toFixed(2) + ' cm)') + ';目标 y = ' + rule.target_center.y.toFixed(2) + '。测量来源:' + (rule.measurement_source === 'face_measure' ? '当前底图测量' : '发际线关键点')
: '—';
if (json.data.framing) { const f = json.data.framing; $('ruleDetails').textContent += ';' + f.message + ',旋转 ' + f.rotation_degrees.toFixed(3) + '°,底图 ' + f.output_size.width + '×' + f.output_size.height + '。'; }
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型已按新规则定位 (' + _elapsed + 's)', 'success');
await renderGrid();
renderFaceMeasure(json.data.face_measure);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
} catch(e) {
const _elapsed = ((performance.now() - _reqStart) / 1000).toFixed(2);
setStatus('❌ (' + _elapsed + 's) ' + e.message, 'error'); $('resultsArea').classList.remove('hidden'); }
finally { $('submitBtn').disabled = false; $('submitBtn').textContent = '🚀 提交'; }
}
async function buildClippedOverlay(src, points) {
const image = await new Promise((resolve, reject) => {
const img = new Image(); img.crossOrigin = 'anonymous';
img.onload = () => resolve(img); img.onerror = () => reject(new Error('无法读取发际线图片'));
img.src = src;
});
const canvas = window.clipHairlineToReferenceBox(image, points);
const corners = window.hairlineReferenceBox(points).corners.map(p => p.join(',')).join(' ');
const result = {
src: canvas.toDataURL('image/png'),
border: '<svg class="hairline-crop-border" viewBox="0 0 '+canvas.width+' '+canvas.height+'" role="img" aria-label="粉色虚线:发际线裁切边界"><polygon points="'+corners+'"></polygon></svg>'
};
canvas.width = 0; canvas.height = 0;
return result;
}
async function renderGrid() {
const version = ++_renderVersion;
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
const images = _images, originalSrc = _originalSrc, processedSrc = _processedSrc, guideLandmarks = _guideLandmarks;
const escapeText = text => String(text).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// The overlay uses the server's processed image, with matching coordinates.
const overlayCell = (label, src, className, emptyText = '暂无处理后图片', border = '') => (src && processedSrc)
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="img-stack">'+
'<img class="layer-base" src="'+processedSrc+'" alt="处理后底图">'+
'<img class="layer-anno" src="'+src+'" alt="'+label+'">'+border+'</div></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">'+escapeText(emptyText)+'</div></div>';
const photoCell = (label, src, className) => src
? '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><img src="'+src+'" alt="'+label+'"></div>'
: '<div class="level-cell '+className+'"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
const blocks = await Promise.all(images.map(async function(it) {
// 兼容网关 *_url 与 worker *_base64
const mid = resolveImgSrc(it.image_middle_url, it.image_middle_base64, 'image/png');
const grown = resolveImgSrc(it.grown_image_url, it.grown_image_base64, 'image/jpeg');
let clipped = '', clipBorder = '', clipError = '缺少发际线图片或参考点';
if (mid && processedSrc && guideLandmarks) {
try { const preview = await buildClippedOverlay(mid, guideLandmarks); clipped = preview.src; clipBorder = preview.border; }
catch (error) { clipError = error.message; }
}
return '<div class="hair-block">' +
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(grown ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
(it.height_placement ? '<div class="placement">中心 y:' + it.height_placement.new_center.y.toFixed(2) + ' · 相对旧规则 ' + (it.height_placement.shift_y_px >= 0 ? '下移 ' : '上移 ') + Math.abs(it.height_placement.shift_y_px).toFixed(2) + ' px · 目标误差 ' + it.height_placement.target_error_px.toFixed(2) + ' px</div>' : '') +
'<div class="level-row">' +
photoCell('未裁切原图', originalSrc, 'original-cell') +
overlayCell('middle · 发际线叠加', mid, 'middle-cell') +
overlayCell('裁切发际线叠加', clipped, 'clipped-cell', clipError, clipBorder) +
photoCell('生发图', grown, 'grown-cell') +
'</div>' +
'</div>';
}));
if (version === _renderVersion) $('resultsGrid').innerHTML = blocks.join('');
}
function clearResults() {
if (_uploadUrl) URL.revokeObjectURL(_uploadUrl);
_images=[]; _uploadUrl=''; _originalSrc=''; _processedSrc=''; _center=null; _guideLandmarks=null; _renderVersion++;
$('ruleDetails').textContent = '';
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('centerPoint').textContent='—';
$('resultCount').textContent = '共 0 个发型';
$('faceMeasureArea').innerHTML = '<span style="color:#9ca3af">—</span>';
}
// 四庭七眼测量渲染(face_measure,接口1数值)
const _PCT = r => (r == null ? '' : '(' + (r * 100).toFixed(1) + '%)');
const _FMT = v => (v == null ? '—' : Number(v).toFixed(2));
function renderFaceMeasure(fm) {
const box = $('faceMeasureArea');
if (!fm) {
box.innerHTML = '<div style="padding:24px;text-align:center;color:#9ca3af;font-size:14px">⚠️ face_measure = null(测量失败或未启用)</div>';
return;
}
// 发际线来源 + 头部姿态
const src = fm.hairline_source;
const srcTag = src === 'segmentation'
? '<span class="fm-tag ok">segmentation 真实分割</span>'
: (src === 'estimated' ? '<span class="fm-tag warn">estimated 比例估算</span>' : '');
let poseHtml = '—';
if (fm.head_pose) {
const p = fm.head_pose;
poseHtml = 'yaw ' + _FMT(p.yaw) + '° &nbsp; pitch ' + _FMT(p.pitch) + '° &nbsp; roll ' + _FMT(p.roll) + '°';
}
let h = '<div class="coord-box" style="margin-top:0;margin-bottom:16px;display:flex;gap:32px;flex-wrap:wrap;align-items:center">'
+ '<div><div class="label">脸总高</div><div class="value">' + _FMT(fm.face_total_height_cm) + ' cm</div></div>'
+ '<div><div class="label">发际线来源</div><div class="value" style="font-size:15px">' + (src || '—') + srcTag + '</div></div>'
+ '<div><div class="label">头部姿态</div><div class="value" style="font-size:14px">' + poseHtml + '</div></div>'
+ '</div>';
// 四庭
const fc = fm.four_courts || {};
const fr = (fc.ratios || {});
const courts = [
['顶庭', fc.top_court_cm, fr.top_court],
['上庭', fc.upper_court_cm, fr.upper_court],
['中庭', fc.middle_court_cm, fr.middle_court],
['下庭', fc.lower_court_cm, fr.lower_court],
];
h += '<div class="fm-section"><div class="fm-title">📏 四庭(纵向,自上而下)</div><div class="fm-grid">';
courts.forEach(c => {
h += '<div class="fm-cell"><div class="k">' + c[0] + '</div><div class="v">' + _FMT(c[1]) + ' cm<span class="sub">' + _PCT(c[2]).replace(/[()]/g, '') + '</span></div></div>';
});
h += '</div></div>';
// 七眼
const se = fm.seven_eyes || {};
const ser = (se.ratios || {});
h += '<div class="fm-section"><div class="fm-title">👁 七眼(横向汇总)</div><div class="fm-grid">';
[['单眼宽', se.eye_width_cm, ser.eye_width], ['脸宽', se.face_width_cm, null], ['两眼间距', se.inter_eye_distance_cm, ser.inter_eye_distance]].forEach(e => {
h += '<div class="fm-cell"><div class="k">' + e[0] + '</div><div class="v">' + _FMT(e[1]) + ' cm' + (e[2] != null ? '<span class="sub">' + (e[2] * 100).toFixed(1) + '%</span>' : '') + '</div></div>';
});
h += '</div></div>';
// 七眼 7 段(从左到右)
const segLabels = ['eye1 左耳外段', 'eye2 左脸颊段', 'eye3 左眼', 'eye4 两眼间距', 'eye5 右眼', 'eye6 右脸颊段', 'eye7 右耳外段'];
h += '<div class="fm-section"><div class="fm-title">↔️ 七眼 7 段宽度(从左到右,cm)</div><div class="fm-grid" style="grid-template-columns:repeat(7,1fr)">';
segLabels.forEach((lab, i) => {
const v = se['eye' + (i + 1)];
const muted = v == null ? ' muted' : '';
h += '<div class="fm-cell' + muted + '"><div class="k">' + lab + '</div><div class="v">' + (v == null ? '—' : _FMT(v)) + '</div></div>';
});
h += '</div></div>';
// 关键点坐标
const lm = fm.landmarks || {};
const lmLabels = { hair_top: '头顶', hairline: '发际线', brow_center: '眉心', nose_bottom: '鼻翼下缘', chin_tip: '下巴尖' };
h += '<div class="fm-section"><div class="fm-title">📍 关键分界点(当前底图像素坐标)</div><div class="fm-grid">';
Object.keys(lmLabels).forEach(k => {
const p = lm[k];
h += '<div class="fm-cell"><div class="k">' + lmLabels[k] + '</div><div class="v" style="font-size:14px">' + (p ? '(' + p.x + ', ' + p.y + ')' : '—') + '</div></div>';
});
h += '</div></div>';
box.innerHTML = h;
}
function copyJson() {
navigator.clipboard.writeText($('jsonContent').textContent).then(() => {
const b = event.target, o = b.textContent; b.textContent = '✅ 已复制'; setTimeout(() => b.textContent = o, 1500);
});
}
document.addEventListener('DOMContentLoaded', () => {
onGenderChange(); // 初始填充发型列表
const dz = $('imageFile');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.style.borderColor='#2563eb'; });
dz.addEventListener('dragleave', () => dz.style.borderColor='#ddd');
dz.addEventListener('drop', e => { e.preventDefault(); dz.style.borderColor='#ddd'; if(e.dataTransfer.files.length){ dz.files=e.dataTransfer.files; setStatus('已选择: '+e.dataTransfer.files[0].name, 'info'); } });
dz.addEventListener('change', () => { if(dz.files.length) setStatus('已选择: '+dz.files[0].name, 'info'); });
});
</script>
</body>
</html>