@@ -0,0 +1,418 @@
<!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 : 1200 px ; margin : 0 auto ; padding : 24 px ; }
h1 { font-size : 22 px ; margin-bottom : 6 px ; }
. subtitle { color : #888 ; font-size : 13 px ; margin-bottom : 24 px ; }
. card { background : #fff ; border-radius : 12 px ; box-shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , .06 ) ; margin-bottom : 20 px ; }
. card-header { font-weight : 700 ; font-size : 14 px ; padding : 14 px 18 px ; border-bottom : 1 px solid #f0f0f0 ; background : #fafafa ; display : flex ; justify-content : space-between ; align-items : center ; }
. card-body { padding : 18 px ; }
. upload-row { display : flex ; gap : 12 px ; align-items : center ; flex-wrap : wrap ; }
. file-input { flex : 1 ; min-width : 200 px ; }
. file-input input [ type = file ] { width : 100 % ; padding : 8 px ; border : 2 px dashed #ddd ; border-radius : 8 px ; cursor : pointer ; }
. form-group { display : flex ; align-items : center ; gap : 6 px ; white-space : nowrap ; }
. form-group label { font-size : 13 px ; font-weight : 600 ; color : #374151 ; }
. form-group select { padding : 8 px 12 px ; border : 1 px solid #d1d5db ; border-radius : 6 px ; font-size : 14 px ; }
. hair-checkboxes { display : flex ; flex-wrap : wrap ; gap : 4 px 14 px ; align-items : center ; }
. hair-checkboxes label { font-size : 13 px ; font-weight : 500 ; color : #374151 ; cursor : pointer ; display : inline-flex ; align-items : center ; gap : 3 px ; white-space : nowrap ; }
. hair-checkboxes label input [ type = checkbox ] { cursor : pointer ; }
. checkbox-actions { display : flex ; gap : 6 px ; margin-left : 4 px ; }
. checkbox-actions button { font-size : 11 px ; padding : 2 px 8 px ; border-radius : 4 px ; border : 1 px solid #d1d5db ; background : #f9fafb ; cursor : pointer ; }
. checkbox-actions button : hover { background : #e5e7eb ; }
. btn { padding : 10 px 28 px ; border : none ; border-radius : 8 px ; font-size : 15 px ; cursor : pointer ; font-weight : 600 ; transition : .2 s ; }
. btn-primary { background : #2563eb ; color : #fff ; }
. btn-primary : hover { background : #1d4ed8 ; }
. btn-primary : disabled { background : #93c5fd ; cursor : not-allowed ; }
. btn-sm { padding : 6 px 14 px ; font-size : 13 px ; }
. btn-outline { background : #fff ; border : 1 px solid #d1d5db ; color : #374151 ; }
. btn-outline : hover { background : #f9fafb ; }
. hint { font-size : 12 px ; color : #9ca3af ; margin-top : 8 px ; }
. status { padding : 10 px 16 px ; border-radius : 8 px ; font-size : 14 px ; margin-bottom : 16 px ; 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 : 24 px ; }
. col-main { flex : 1.5 ; min-width : 0 ; }
. col-side { flex : 1 ; min-width : 0 ; }
/* 每个发型区块 */
. hair-block { padding : 16 px 0 ; border-bottom : 1 px solid #f0f0f0 ; }
. hair-block : first-child { padding-top : 4 px ; }
. hair-block : last-child { border-bottom : none ; padding-bottom : 4 px ; }
. hair-block-title { font-size : 15 px ; font-weight : 700 ; color : #111827 ; margin-bottom : 10 px ; }
/* 未裁切原图 + middle + 矩形裁切发际线叠加 + 生发图 */
. level-row { display : grid ; grid-template-columns : repeat ( 4 , minmax ( 0 , 1 fr ) ) ; gap : 10 px ; align-items : start ; }
. level-cell { background : #222 ; border-radius : 8 px ; overflow : hidden ; display : flex ; flex-direction : column ; }
. level-cell . level-label { font-size : 12 px ; font-weight : 600 ; color : #fff ; background : rgba ( 0 , 0 , 0 , .55 ) ; padding : 4 px 8 px ; min-height : 38 px ; display : flex ; align-items : center ; }
. level-cell img { width : 100 % ; max-height : 380 px ; object-fit : contain ; display : block ; }
. level-cell . level-none { color : #9ca3af ; font-size : 13 px ; padding : 28 px 0 ; text-align : center ; }
/* 处理后底图 + 同尺寸透明设计曲线叠加 */
. level-cell . img-stack { position : relative ; line-height : 0 ; }
. level-cell . img-stack . layer-base { width : 100 % ; max-height : 380 px ; 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 , 1 fr ) ; } }
/* 坐标高亮 */
. coord-box { background : #f9fafb ; border-radius : 8 px ; padding : 12 px 16 px ; margin-top : 10 px ; font-size : 14 px ; }
. coord-box . label { font-size : 11 px ; color : #9ca3af ; margin-bottom : 4 px ; }
. coord-box . value { font-weight : 700 ; font-size : 18 px ; color : #111827 ; }
/* 四庭七眼测量(face_measure) */
. fm-section { margin-bottom : 18 px ; }
. fm-section : last-child { margin-bottom : 0 ; }
. fm-title { font-size : 13 px ; font-weight : 700 ; color : #374151 ; margin-bottom : 8 px ; }
. fm-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 120 px , 1 fr ) ) ; gap : 8 px ; }
. fm-cell { background : #f9fafb ; border-radius : 6 px ; padding : 8 px 10 px ; border : 1 px solid #f0f0f0 ; }
. fm-cell . k { font-size : 11 px ; color : #9ca3af ; margin-bottom : 2 px ; }
. fm-cell . v { font-size : 15 px ; font-weight : 700 ; color : #111827 ; }
. fm-cell . v . sub { font-size : 11 px ; font-weight : 500 ; color : #6b7280 ; margin-left : 4 px ; }
. fm-cell . muted . v { color : #9ca3af ; font-weight : 600 ; }
. fm-tag { display : inline-block ; font-size : 11 px ; padding : 2 px 8 px ; border-radius : 10 px ; margin-left : 6 px ; font-weight : 600 ; }
. fm-tag . ok { background : #d1fae5 ; color : #065f46 ; }
. fm-tag . warn { background : #fef3c7 ; color : #92400e ; }
. json-panel { max-height : 550 px ; overflow : auto ; }
. json-content { padding : 14 px 16 px ; font-family : "SF Mono" , "Fira Code" , monospace ; font-size : 12 px ; line-height : 1.6 ; white-space : pre-wrap ; word-break : break-all ; }
. hidden { display : none !important ; }
. rule-banner { background : #ecfdf5 ; border : 1 px solid #a7f3d0 ; border-radius : 12 px ; padding : 18 px ; margin-bottom : 20 px ; line-height : 1.8 ; }
. rule-banner strong { color : #065f46 ; font-size : 19 px ; }
. rule-banner a { color : #065f46 ; margin-left : 18 px ; font-weight : 600 ; }
. placement { font-size : 12 px ; color : #64748b ; font-weight : 400 ; margin : 6 px 0 10 px ; }
@ media ( max-width : 800px ) { . results-layout { flex-direction : column ; } }
< / style >
< script src = "/static/img_downscale.js?v=52" > < / 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 > < 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 正面照 | Female: 椭圆/花瓣/心形/直线/波浪/大花瓣/古典花瓣 | Male: 椭圆/倒弧/M形/直线/桃心/柔和花瓣/波浪 | 每个发型返回一张设计曲线,生发遮罩同步到新位置< / 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' , 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 => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ 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 ? ' <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 ) + '° pitch ' + _FMT ( p . pitch ) + '° 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 >