feat: 接口5 新增 face_measure(复用接口1测量数值)+ 接口1 七眼 eye1~eye7

接口5(/api/v1/hairline/generate)在发际线结果基础上新增 data.face_measure,
复用接口1的四庭七眼测量数值(四庭/七眼 eye1~eye7/landmarks/姿态),
不含标注图;独立流程容错,测量失败时为 null 不影响发际线主结果。

重构:提取 _run_face_measure_data() 共用函数,接口1/6 改调它再补标注图,
行为不变(43 测试全过,错误码 1001/1003/1008 回归正常)。

接口1/5 七眼新增 eye1~eye7 从左到右 7 段宽度(cm),eye1/eye7 耳朵不可见
时为 null(保留键)。

测试页 test_interface5.html:移除点击切换卡片网格改为所有发型平铺,
新增四庭七眼测量卡片。前端接入页 integration.html / 接口文档同步更新。
This commit is contained in:
xsl
2026-07-13 22:40:55 +08:00
parent b163a3f34a
commit 28255ef7c2
5 changed files with 337 additions and 92 deletions
+119 -41
View File
@@ -45,15 +45,11 @@
.col-main { flex: 1.5; min-width: 0; }
.col-side { flex: 1; min-width: 0; }
/* 方案网格 */
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 14px; margin-top: 12px; }
.result-card { background: #fff; border-radius: 10px; box-shadow: 0 1px 4px rgba(0,0,0,.06); overflow: hidden; cursor: pointer; transition: .15s; border: 3px solid transparent; }
.result-card:hover { box-shadow: 0 2px 12px rgba(0,0,0,.12); }
.result-card.selected { border-color: #2563eb; }
.result-card .img-wrap { background: #222; min-height: 150px; max-height: 200px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.result-card .img-wrap img { max-width: 100%; max-height: 200px; object-fit: contain; }
.result-card .info { padding: 10px 12px; font-size: 12px; display: flex; justify-content: space-between; align-items: center; }
.result-card .info .badge { background: #2563eb; color: #fff; padding: 1px 8px; border-radius: 10px; font-size: 11px; }
/* 每个发型区块 */
.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; }
/* 三档 + 生发图 横向排列 */
.level-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
@@ -68,6 +64,20 @@
.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; }
@@ -108,22 +118,22 @@
<div class="results-layout hidden" id="resultsArea">
<div class="col-main">
<!-- 预览 -->
<!-- 四庭七眼测量(face_measure,复用接口1数值) -->
<div class="card">
<div class="card-header"><span>📐 三档发际线叠图 + 生发图</span></div>
<div class="card-body" style="text-align:center">
<div id="previewArea"><span style="color:#9ca3af">← 点击下方发型卡片</span></div>
<div class="coord-box">
<div class="label">📍 最佳发际线中心点(best_hairline_center_point,首个选中发型 middle 档)— 原图像素坐标</div>
<div class="card-header"><span>📐 四庭七眼测量(face_measure</span><span style="font-weight:400;font-size:12px;color:#9ca3af">复用接口1测量数值</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>🎯 所有发型结果(三档 + 生发图)</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">📍 最佳发际线中心点(best_hairline_center_point,首个发型 middle 档)— 原图像素坐标</div>
<div class="value" id="centerPoint"></div>
</div>
<div id="resultsGrid"></div>
</div>
</div>
<!-- 发型列表 -->
<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 class="results-grid" id="resultsGrid"></div></div>
</div>
</div>
<div class="col-side">
<div class="card">
@@ -198,9 +208,10 @@ async function submitTest() {
_images = json.data.hairline_images || [];
_center = json.data.best_hairline_center_point;
$('centerPoint').textContent = _center ? '(' + _center.x + ', ' + _center.y + ')' : '—';
$('resultCount').textContent = '共 ' + _images.length + ' 个发型';
setStatus('✅ ' + _images.length + ' 个发型 × 三档 (' + _elapsed + 's)', 'success');
renderGrid();
if (_images.length) selectCard(0);
renderFaceMeasure(json.data.face_measure);
} else {
setStatus('❌ (' + _elapsed + 's) code=' + json.code + ' ' + json.message, 'error');
}
@@ -211,38 +222,105 @@ async function submitTest() {
}
function renderGrid() {
if (!_images.length) { $('resultsGrid').innerHTML = '<span style="color:#9ca3af">无数据</span>'; return; }
let h = '';
_images.forEach((img, i) => {
h += '<div class="result-card'+(i===0?' selected':'')+'" onclick="selectCard('+i+',this)">'+
'<div class="img-wrap"><img src="'+img.image_middle_url+'" alt="#'+img.order+'" loading="lazy"></div>'+
'<div class="info"><span class="badge">#'+(img.order||'—')+' '+(img.hairline_type||'')+'</span>'+
(img.grown_image_url?'<span style="font-size:11px;color:#7c3aed">🌱 生发</span>':'')+'</div></div>';
});
$('resultsGrid').innerHTML = h;
}
function selectCard(idx, el) {
document.querySelectorAll('.result-card').forEach(c => c.classList.remove('selected'));
if (el) el.classList.add('selected');
const it = _images[idx];
const cell = (label, url) => url
? '<div class="level-cell"><div class="level-label">'+label+'</div><img src="'+url+'" alt="'+label+'"></div>'
: '<div class="level-cell"><div class="level-label">'+label+'</div><div class="level-none">无</div></div>';
$('previewArea').innerHTML =
'<div class="level-row">'+
cell('middle', it.image_middle_url)+
cell('high', it.image_high_url)+
cell('low', it.image_low_url)+
cell('🌱 生发图', it.grown_image_url)+
_images.forEach(function(it) {
h += '<div class="hair-block">' +
'<div class="hair-block-title">#' + (it.order||'—') + ' ' + (it.hairline_type||'') +
(it.grown_image_url ? ' &nbsp;<span style="font-size:11px;color:#7c3aed">含生发图</span>' : '') + '</div>' +
'<div class="level-row">' +
cell('middle', it.image_middle_url) +
cell('high', it.image_high_url) +
cell('low', it.image_low_url) +
cell('生发图', it.grown_image_url) +
'</div>' +
'</div>';
});
$('resultsGrid').innerHTML = h;
}
function clearResults() {
_images=[]; _origUrl=''; _center=null;
$('resultsArea').classList.add('hidden'); $('statusBar').className='status hidden';
$('imageFile').value=''; $('jsonContent').textContent=''; $('resultsGrid').innerHTML='';
$('previewArea').innerHTML='<span style="color:#9ca3af">← 点击下方发型卡片</span>';
$('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() {