@@ -0,0 +1,451 @@
<!doctype html>
< html lang = "zh-CN" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > 接口 22 · 生发测试< / title >
< style >
* { box-sizing : border-box ; }
body { margin : 0 ; background : #f3f5f8 ; color : #172334 ; font : 15 px / 1.5 system-ui , sans-serif ; }
main { max-width : 1100 px ; margin : 32 px auto ; padding : 0 16 px ; }
h1 { margin : 0 0 4 px ; font-size : 28 px ; }
h2 { margin : 0 0 12 px ; font-size : 18 px ; }
. muted , figcaption { color : #526075 ; }
. card { background : white ; border : 1 px solid #dce3eb ; border-radius : 12 px ; padding : 18 px ; margin : 16 px 0 ; }
. fields { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 210 px , 1 fr ) ) ; gap : 14 px ; }
. field { display : grid ; gap : 5 px ; align-content : start ; }
input , select , button { font : inherit ; }
. field input , select { min-width : 0 ; width : 100 % ; padding : 6 px 8 px ; }
fieldset { margin : 16 px 0 ; border : 1 px solid #dce3eb ; border-radius : 8 px ; }
# sourceFields { margin-top : 0 ; padding : 0 ; border : 0 ; min-width : 0 ; }
. choices { display : flex ; flex-wrap : wrap ; gap : 8 px 18 px ; }
. choices label { white-space : nowrap ; }
button { background : #1757ab ; color : white ; border : 0 ; border-radius : 8 px ; padding : 8 px 16 px ; cursor : pointer ; }
button : disabled { opacity : .6 ; cursor : wait ; }
. status { margin-bottom : 0 ; }
. status [ data-error = "true" ] { color : #a02020 ; }
. images { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( min ( 100 % , 220 px ) , 1 fr ) ) ; gap : 12 px ; }
. images figure { max-width : 380 px ; }
. result-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( min ( 100 % , 122 px ) , 1 fr ) ) ; gap : 10 px ; }
. result-tile { min-width : 0 ; overflow : hidden ; background : white ; border : 1 px solid #dce3eb ; border-radius : 8 px ; }
. result-tile img { display : block ; width : 100 % ; height : auto ; }
. result-tile figcaption { display : grid ; gap : 2 px ; margin : 0 ; padding : 6 px 8 px ; font-size : 12 px ; }
. result-tile strong { color : #172334 ; }
. preview-editor { display : grid ; grid-template-columns : minmax ( 0 , 1 fr ) auto ; align-items : start ; gap : 12 px ; margin : 16 px 0 ; }
. preview-editor : has ( # previews > figure : only-child ) { grid-template-columns : minmax ( 0 , 380 px ) auto ; justify-content : start ; }
. height-controls { display : grid ; gap : 6 px ; padding-top : 27 px ; }
. height-scale { display : flex ; gap : 6 px ; height : clamp ( 170 px , 42 vw , 300 px ) ; }
# height { writing-mode : vertical - lr ; direction : rtl ; width : 24 px ; height : 100 % ; margin : 0 ; }
. height-presets { display : flex ; flex-direction : column ; justify-content : space-between ; }
. height-presets label { display : flex ; align-items : center ; gap : 3 px ; cursor : pointer ; white-space : nowrap ; }
figure { margin : 0 ; min-width : 0 ; }
figcaption { margin-bottom : 6 px ; }
. stack { position : relative ; overflow : hidden ; border-radius : 6 px ; }
. stack img , . stack canvas { width : 100 % ; height : auto ; display : block ; border-radius : 6 px ; }
. preview-layer , . preview-clip , . stack . overlay , . crop-border { position : absolute ; inset : 0 ; width : 100 % ; height : 100 % ; }
. preview-layer { transform : translateY ( calc ( -1 * var ( - - height - adjustment ) * var ( - - max - shift ) ) ) ; }
. crop-border polygon { fill : none ; stroke : #e33399 ; stroke-width : 2 ; stroke-dasharray : 6 4 ; vector-effect : non-scaling-stroke ; }
pre { white-space : pre-wrap ; overflow-wrap : anywhere ; }
@ media ( max-width : 560px ) { . images { grid-template-columns : 1 fr ; } }
< / style >
< script src = "/static/img_downscale.js?v=2" defer > < / script >
< script src = "/static/hairline_reference_box.js?v=2" defer > < / script >
< / head >
< body >
< main >
< h1 > 接口 22 生发测试< / h1 >
< div class = "muted" > < code > POST /api/v1/hair/grow-22< / code > < / div >
< form id = "generateRequest" class = "card" >
< fieldset id = "sourceFields" aria-label = "照片与性别" >
< div class = "fields" >
< label class = "field" > 照片(JPG / PNG)
< input type = "file" name = "image_file" accept = "image/jpeg,image/png,.jpg,.jpeg,.png" required >
< / label >
< label class = "field" > 性别
< select name = "gender" > < option value = "female" > 女性< / option > < option value = "male" > 男性< / option > < / select >
< / label >
< / div >
< / fieldset >
< fieldset id = "styleFields" >
< legend > 发际线形状(可多选)< / legend >
< div id = "styles" class = "choices" > < / div >
< p id = "generationPreviewStatus" class = "status" role = "status" > < / p >
< / fieldset >
< p id = "firstRunHint" class = "muted" > 首次按默认高度 0 生图,出图后可预览和调整发际线高度。< / p >
< div id = "heightEditor" hidden >
< p class = "muted" > 勾选右侧档位后按所选高度生图。未勾选时,使用滑块当前高度。< / p >
< div class = "preview-editor" >
< div id = "previews" class = "images" aria-label = "52 号裁切发际线预览" > < / div >
< div class = "height-controls" >
< label for = "height" > 高度 < output id = "heightValue" for = "height" > 0< / output > < / label >
< div class = "height-scale" >
< input type = "range" id = "height" name = "hairline_height" min = "-1" max = "1" step = "0.1" value = "0" >
< div class = "height-presets" role = "group" aria-label = "预设高度(可多选)" >
< label > < input type = "checkbox" name = "height_preset" value = "1" > +1< / label >
< label > < input type = "checkbox" name = "height_preset" value = "0.5" > +0.5< / label >
< label > < input type = "checkbox" name = "height_preset" value = "0" > 0< / label >
< label > < input type = "checkbox" name = "height_preset" value = "-0.5" > − 0.5< / label >
< label > < input type = "checkbox" name = "height_preset" value = "-1" > − 1< / label >
< / div >
< / div >
< / div >
< / div >
< / div >
< fieldset >
< legend > 生发密度(至少选择一档)< / legend >
< div class = "choices" >
< label > < input type = "checkbox" name = "hair_density" value = "sparse" > 稀疏< / label >
< label > < input type = "checkbox" name = "hair_density" value = "normal" checked > 正常< / label >
< label > < input type = "checkbox" name = "hair_density" value = "dense" > 浓密< / label >
< / div >
< / fieldset >
< button id = "submit" type = "submit" > 生成生发效果< / button >
< p id = "status" class = "status" role = "status" > < / p >
< / form >
< section id = "results" class = "result-grid" aria-label = "生发结果" > < / section >
< details class = "card" id = "response" hidden >
< summary > 响应 JSON(省略图片 Base64) < / summary >
< pre id = "json" > < / pre >
< / details >
< / main >
< script >
const generateForm = document . getElementById ( 'generateRequest' ) ;
const sourceFields = document . getElementById ( 'sourceFields' ) ;
const firstRunHint = document . getElementById ( 'firstRunHint' ) ;
const heightEditor = document . getElementById ( 'heightEditor' ) ;
const previews = document . getElementById ( 'previews' ) ;
const height = document . getElementById ( 'height' ) ;
const heightPresets = generateForm . querySelectorAll ( '[name="height_preset"]' ) ;
const styles = document . getElementById ( 'styles' ) ;
const styleFields = document . getElementById ( 'styleFields' ) ;
const generationPreviewStatus = document . getElementById ( 'generationPreviewStatus' ) ;
const statusBar = document . getElementById ( 'status' ) ;
const results = document . getElementById ( 'results' ) ;
const response = document . getElementById ( 'response' ) ;
const submit = document . getElementById ( 'submit' ) ;
const densityLabels = { sparse : '稀疏' , normal : '正常' , dense : '浓密' } ;
const styleNames = {
female : [ '椭圆' , '花瓣' , '心形' , '直线' , '波浪' , '大花瓣' , '古典花瓣' ] ,
male : [ '椭圆' , '倒弧' , 'M 形' , '直线' , '心形' , '柔和花瓣' , '波浪' ] ,
} ;
let prepared ;
let previewRevision = 0 ;
function renderStyleChoices ( container , gender , selected ) {
container . replaceChildren ( ... styleNames [ gender ] . map ( ( name , index ) => {
const label = document . createElement ( 'label' ) ;
const checkbox = document . createElement ( 'input' ) ;
checkbox . type = 'checkbox' ;
checkbox . name = 'hair_style' ;
checkbox . value = index + 1 ;
checkbox . checked = selected . includes ( checkbox . value ) ;
label . append ( checkbox , ' ' + ( index + 1 ) + '. ' + name ) ;
return label ;
} ) ) ;
}
function imageSource ( url , base64 , mime ) {
if ( url ) return url ;
if ( ! base64 ) return '' ;
return base64 . startsWith ( 'data:' ) ? base64 : 'data:' + mime + ';base64,' + base64 ;
}
function imagePanel ( title , src , preview ) {
const figure = document . createElement ( 'figure' ) ;
const caption = document . createElement ( 'figcaption' ) ;
caption . textContent = title ;
figure . append ( caption ) ;
if ( ! src ) {
figure . append ( preview ? . error || '未返回图片' ) ;
return figure ;
}
const stack = document . createElement ( 'div' ) ;
stack . className = 'stack' ;
const image = new Image ( ) ;
image . src = src ;
image . alt = title ;
stack . append ( image ) ;
if ( preview ) {
const movingLayer = document . createElement ( 'div' ) ;
movingLayer . className = 'preview-layer' ;
movingLayer . style . setProperty ( '--max-shift' , preview . maxShift + '%' ) ;
const clip = document . createElement ( 'div' ) ;
clip . className = 'preview-clip' ;
const polygonPoints = preview . corners . map ( ( [ x , y ] ) =>
( 100 * x / preview . width ) + '% ' + ( 100 * y / preview . height ) + '%' ) ;
clip . style . clipPath = 'polygon(' + polygonPoints . join ( ', ' ) + ')' ;
const layer = new Image ( ) ;
layer . src = preview . overlay ;
layer . alt = '发际线' ;
layer . className = 'overlay' ;
clip . append ( layer ) ;
movingLayer . append ( clip ) ;
const ns = 'http://www.w3.org/2000/svg' ;
const border = document . createElementNS ( ns , 'svg' ) ;
border . classList . add ( 'crop-border' ) ;
border . setAttribute ( 'viewBox' , '0 0 ' + preview . width + ' ' + preview . height ) ;
border . setAttribute ( 'aria-label' , '发际线裁切边界' ) ;
const polygon = document . createElementNS ( ns , 'polygon' ) ;
polygon . setAttribute ( 'points' , preview . corners . map ( point => point . join ( ',' ) ) . join ( ' ' ) ) ;
border . append ( polygon ) ;
movingLayer . append ( border ) ;
stack . append ( movingLayer ) ;
}
figure . append ( stack ) ;
return figure ;
}
async function loadCroppedPreviews ( data , selected ) {
const formData = new FormData ( ) ;
for ( const [ key , value ] of data ) formData . set ( key , value ) ;
formData . set ( 'hair_style' , selected . join ( ',' ) ) ;
formData . set ( 'normalize_image' , 'true' ) ;
formData . set ( 'generate_grow_image' , 'false' ) ;
const reply = await fetch ( '/api/v1/hairline/generate-52' , { method : 'POST' , body : formData } ) ;
const body = await reply . json ( ) ;
if ( ! reply . ok || body . code !== 0 ) {
throw new Error ( body . message || 'HTTP ' + reply . status ) ;
}
const preview = body . data ;
const base = imageSource ( preview . input _image _url , preview . input _image _base64 , 'image/jpeg' ) ;
const { width , height } = preview ;
const { corners , landmarks } = window . hairlineReferenceBox ( preview . guide _landmarks ) ;
// Convert the interface 22 scale (10% of brow-to-chin distance) to a percentage of the image height.
const maxShift = 100 * 0.10 * Math . abs ( landmarks [ 152 ] [ 1 ] - landmarks [ 9 ] [ 1 ] ) / height ;
const items = preview . hairline _images . map ( item => {
const overlay = imageSource ( item . image _middle _url , item . image _middle _base64 , 'image/png' ) ;
return { order : item . order , ... ( ( base && overlay )
? { base , corners , width , height , maxShift , overlay }
: { error : '接口 52 未返回完整预览图片' } ) } ;
} ) ;
return { items , framing : preview . framing } ;
}
function makePreviewPanels ( items , gender ) {
const names = styleNames [ gender ] ;
return new Map ( items . filter ( item => item . base ) . map ( item => [
String ( item . order ) , imagePanel ( '#' + item . order + ' · ' + names [ item . order - 1 ] , item . base , item ) ,
] ) ) ;
}
async function replacePreviewBases ( images , panels , framing , fallback = false ) {
const byOrder = new Map ( images . map ( item => [ String ( item . order ) , item ] ) ) ;
const targets = [ ... panels ] . flatMap ( ( [ order , panel ] ) => {
const image = byOrder . get ( order ) || ( fallback ? images [ 0 ] : undefined ) ;
return image ? [ [ image , panel ] ] : [ ] ;
} ) ;
if ( ! targets . length ) return 0 ;
const { source _to _processed : matrix , source _size : source , output _size : output } = framing || { } ;
if ( ! matrix || ! source || ! output ) return targets . length ;
const [ [ a , c , e ] , [ b , d , f ] ] = matrix ;
const updated = await Promise . all ( targets . map ( async ( [ item , panel ] ) => {
const base = panel . querySelector ( '.stack > img' ) ;
if ( ! base ) return false ;
try {
const generated = new Image ( ) ;
generated . src = imageSource ( item . grown _image _url , item . grown _image _base64 , 'image/jpeg' ) ;
await generated . decode ( ) ;
const canvas = document . createElement ( 'canvas' ) ;
canvas . width = output . width ;
canvas . height = output . height ;
canvas . setAttribute ( 'role' , 'img' ) ;
canvas . setAttribute ( 'aria-label' , base . alt + ' · 默认高度生发底图' ) ;
const ctx = canvas . getContext ( '2d' , { alpha : false } ) ;
ctx . setTransform ( a , b , c , d , e , f ) ;
ctx . drawImage ( generated , 0 , 0 , source . width , source . height ) ;
base . replaceWith ( canvas ) ;
return true ;
} catch ( error ) {
return false ;
}
} ) ) ;
return updated . filter ( ok => ! ok ) . length ;
}
function renderResult ( item , submittedHeight , gender ) {
const tile = document . createElement ( 'figure' ) ;
tile . className = 'result-tile' ;
const image = new Image ( ) ;
image . loading = 'lazy' ;
image . src = imageSource ( item . grown _image _url , item . grown _image _base64 , 'image/jpeg' ) ;
const name = styleNames [ gender ] [ item . order - 1 ] || item . hairline _type ;
const style = '#' + item . order + ' ' + name + ' · ' + ( gender === 'male' ? '男性' : '女性' ) ;
const details = ( densityLabels [ item . hair _density ] || item . hair _density ) + ' · 高度 ' + submittedHeight ;
image . alt = style + ', ' + details ;
const caption = document . createElement ( 'figcaption' ) ;
const title = document . createElement ( 'strong' ) ;
title . textContent = style ;
const parameters = document . createElement ( 'span' ) ;
parameters . textContent = details ;
caption . append ( title , parameters ) ;
tile . append ( image , caption ) ;
return tile ;
}
async function requestGrowth ( data ) {
const reply = await fetch ( '/api/v1/hair/grow-22' , { method : 'POST' , body : data } ) ;
const body = await reply . json ( ) ;
if ( ! reply . ok || body . code !== 0 ) {
throw new Error ( body . message || 'HTTP ' + reply . status ) ;
}
return body ;
}
function updatePreviewHeight ( ) {
document . getElementById ( 'heightValue' ) . value = height . value ;
previews . style . setProperty ( '--height-adjustment' , height . value ) ;
}
height . addEventListener ( 'input' , updatePreviewHeight ) ;
heightPresets . forEach ( preset => preset . addEventListener ( 'change' , ( ) => {
if ( preset . checked ) {
height . value = preset . value ;
updatePreviewHeight ( ) ;
}
} ) ) ;
updatePreviewHeight ( ) ;
function renderSourceStyles ( ) {
renderStyleChoices ( styles , generateForm . elements . gender . value , [ '1' ] ) ;
}
generateForm . elements . gender . addEventListener ( 'change' , renderSourceStyles ) ;
renderSourceStyles ( ) ;
sourceFields . addEventListener ( 'change' , ( ) => {
prepared = undefined ;
heightEditor . hidden = true ;
firstRunHint . hidden = false ;
previews . replaceChildren ( ) ;
results . replaceChildren ( ) ;
response . hidden = true ;
statusBar . textContent = '' ;
generationPreviewStatus . textContent = '' ;
submit . disabled = false ;
height . value = '0' ;
heightPresets . forEach ( preset => { preset . checked = false ; } ) ;
updatePreviewHeight ( ) ;
} ) ;
async function refreshStylePreviews ( selected ) {
const current = prepared ;
if ( ! current ) return ;
const revision = ++ previewRevision ;
const showSelected = ( ) => previews . replaceChildren ( ... selected . flatMap ( order =>
current . panels . has ( order ) ? [ current . panels . get ( order ) ] : [ ] ) ) ;
showSelected ( ) ;
generationPreviewStatus . dataset . error = 'false' ;
generationPreviewStatus . textContent = selected . length ? '' : '请至少选择一种发际线形状。' ;
const missing = selected . filter ( order => ! current . panels . has ( order ) ) ;
submit . disabled = ! selected . length || ! ! missing . length ;
if ( ! missing . length ) return ;
generationPreviewStatus . textContent = '正在更新发际线预览…' ;
try {
const { items , framing } = await loadCroppedPreviews ( current . data , missing ) ;
const panels = makePreviewPanels ( items , current . data . get ( 'gender' ) ) ;
const unmatched = await replacePreviewBases ( current . defaultImages , panels , framing , true ) ;
if ( current !== prepared || revision !== previewRevision ) return ;
for ( const [ order , panel ] of panels ) current . panels . set ( order , panel ) ;
showSelected ( ) ;
if ( missing . some ( order => ! panels . has ( order ) ) ) {
throw new Error ( '接口 52 未返回全部所选形状。' ) ;
}
generationPreviewStatus . textContent = unmatched ? '部分生成图无法对齐,已显示对应的裁切原照。' : '' ;
} catch ( error ) {
if ( current !== prepared || revision !== previewRevision ) return ;
generationPreviewStatus . dataset . error = 'true' ;
generationPreviewStatus . textContent = '更新发际线预览失败:' + error . message ;
} finally {
if ( current === prepared && revision === previewRevision ) submit . disabled = false ;
}
}
styles . addEventListener ( 'change' , ( ) => {
refreshStylePreviews ( new FormData ( generateForm ) . getAll ( 'hair_style' ) ) ;
} ) ;
generateForm . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( ) ;
if ( submit . disabled ) return ;
const data = new FormData ( generateForm ) ;
const selectedStyles = data . getAll ( 'hair_style' ) ;
if ( ! selectedStyles . length ) {
statusBar . dataset . error = 'true' ;
statusBar . textContent = '请至少选择一种发际线形状。' ;
return ;
}
const densities = data . getAll ( 'hair_density' ) ;
if ( ! densities . length ) {
statusBar . dataset . error = 'true' ;
statusBar . textContent = '请至少选择一档生发密度。' ;
return ;
}
const selectedHeights = data . getAll ( 'height_preset' ) ;
const firstRun = ! prepared ;
const submittedHeights = firstRun ? [ '0' ] : ( selectedHeights . length ? selectedHeights : [ data . get ( 'hairline_height' ) ] ) ;
data . delete ( 'height_preset' ) ;
data . set ( 'hair_style' , selectedStyles . join ( ',' ) ) ;
sourceFields . disabled = styleFields . disabled = submit . disabled = true ;
statusBar . dataset . error = 'false' ;
results . replaceChildren ( ) ;
response . hidden = true ;
const reports = [ ] ;
const failedHeights = [ ] ;
const defaultImages = new Map ( ) ;
const planned = submittedHeights . length * densities . length * selectedStyles . length ;
let generated = 0 ;
const start = performance . now ( ) ;
try {
statusBar . textContent = '正在准备照片…' ;
const file = firstRun
? await window . downscaleImageFile ( data . get ( 'image_file' ) )
: prepared . data . get ( 'image_file' ) ;
data . set ( 'image_file' , file ) ;
for ( const [ index , submittedHeight ] of submittedHeights . entries ( ) ) {
statusBar . textContent = '正在生成 ' + ( index + 1 ) + '/' + submittedHeights . length + ' 档 · 高度 ' + submittedHeight + '…' ;
// The slider is positive up, while the API uses positive image Y (down).
data . set ( 'hairline_height' , String ( - Number ( submittedHeight ) ) ) ;
try {
const body = await requestGrowth ( data ) ;
const images = body . data . results . filter ( item => item . grown _image _url || item . grown _image _base64 ) ;
results . append ( ... images . map ( item => renderResult ( item , submittedHeight , data . get ( 'gender' ) ) ) ) ;
generated += images . length ;
if ( firstRun ) {
for ( const item of images ) {
if ( ! defaultImages . has ( item . order ) || item . hair _density === 'normal' ) {
const { order , grown _image _url , grown _image _base64 } = item ;
defaultImages . set ( order , { order , grown _image _url , grown _image _base64 } ) ;
}
}
}
reports . push ( JSON . stringify ( { height : Number ( submittedHeight ) , response : body } , ( key , value ) =>
key . endsWith ( '_base64' ) && typeof value === 'string' ? '[图片已省略]' : value , 2 ) ) ;
} catch ( error ) {
failedHeights . push ( submittedHeight ) ;
reports . push ( JSON . stringify ( { height : Number ( submittedHeight ) , error : error . message } , null , 2 ) ) ;
}
}
document . getElementById ( 'json' ) . textContent = '[\n' + reports . join ( ',\n' ) + '\n]' ;
response . hidden = false ;
const seconds = ( ( performance . now ( ) - start ) / 1000 ) . toFixed ( 1 ) ;
statusBar . dataset . error = String ( generated !== planned ) ;
statusBar . textContent = '生发成功 ' + generated + '/' + planned + ' 张 · ' + seconds + ' 秒'
+ ( failedHeights . length ? ' · 请求失败的高度 ' + failedHeights . join ( '、' ) : '' ) ;
if ( firstRun && generated ) {
const source = new FormData ( ) ;
source . set ( 'image_file' , file ) ;
source . set ( 'gender' , data . get ( 'gender' ) ) ;
prepared = { data : source , panels : new Map ( ) , defaultImages : [ ... defaultImages . values ( ) ] } ;
heightEditor . hidden = false ;
firstRunHint . hidden = true ;
await refreshStylePreviews ( selectedStyles ) ;
}
} catch ( error ) {
statusBar . dataset . error = 'true' ;
statusBar . textContent = '生发失败:' + error . message ;
} finally {
sourceFields . disabled = styleFields . disabled = submit . disabled = false ;
}
} ) ;
< / script >
< / body >
< / html >