"use strict"; const $ = id => document.getElementById(id); const styleNames = { female: ["椭圆形", "花瓣形", "心形", "直线形", "波浪形", "大花瓣形", "古典花瓣形"], male: ["椭圆形", "倒弧形", "M 型", "直线形", "心形", "柔和花瓣形", "波浪形"] }; let dataset = {groups: [], errors: []}, mode = "side", drawVersion = 0, busy = false, cardObserver = null; const inputImages = new Map(); const rowSelections = new Map(); const esc = value => String(value).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); const fixed = (value, n = 2) => Number(value).toFixed(n); const imageSource = (object, field, mime = "image/png") => object[field + "_url"] || window.resolveImgSrc(null, object[field + "_base64"], mime); function status(text, error = false) { $("status").textContent = text; $("status").classList.toggle("error", error); } function loadImage(src) { return new Promise((resolve, reject) => { const im = new Image(); im.onload = () => resolve(im); im.onerror = () => reject(new Error("无法读取对比图片,请重新运行案例")); im.src = src; }); } function inputImage(group) { const src = imageSource(group, "input", "image/jpeg"); if (!inputImages.has(src)) inputImages.set(src, loadImage(src)); return inputImages.get(src); } function originalCard(group,caseName){ const src=imageSource(group,"original","image/jpeg")||group.source_url||imageSource(group,"input","image/jpeg"); const size=group.framing?.source_size||{width:group.width,height:group.height}; const dimensions=size.width&&size.height?`width="${size.width}" height="${size.height}"`:""; const sizeLabel=size.width&&size.height?`原尺寸 ${size.width}×${size.height}`:"完整上传图片"; const panel=document.createElement("article");panel.className="original-reference-card"; panel.innerHTML=`
未裁切原图

${esc(caseName)} · 上传原图

处理前完整图片

${sizeLabel}
未扶正、未裁切
${esc(caseName)}未裁切原图
保留完整画面查看原图 ↗
`; return panel; } function failureCard(errors){ const panel=document.createElement("article");panel.className="failed-reference-card"; panel.innerHTML='

定位失败,未生成对比图

左侧保留完整上传原图。

'; for(const error of errors){ const message=document.createElement("p"); message.textContent=`${error.gender==="female"?"女性":"男性"}7型:${error.message}`; panel.append(message); } return panel; } function refreshStyleOptions() { const old = $("styleFilter").value || "all", gender = $("genderFilter").value; $("styleFilter").replaceChildren(); for (let i = 0; i < 7; i++) { const label = gender === "all" ? `${i+1}. 女:${styleNames.female[i]} / 男:${styleNames.male[i]}` : `${i+1}. ${styleNames[gender][i]}`; $("styleFilter").add(new Option(label, String(i+1))); } $("styleFilter").add(new Option("全部 7 种形状", "all")); $("styleFilter").value = old; } function refreshCases() { const selected = $("caseFilter").value; const cases = [...new Set([...dataset.groups,...dataset.errors].map(g => String(g.case_number)))].sort((a,b)=>a.localeCompare(b,"en",{numeric:true})); $("caseFilter").replaceChildren(new Option(`全部 ${cases.length} 个案例`, "all")); for (const key of cases) $("caseFilter").add(new Option(key.startsWith("custom") ? "自定义图片" : `案例 ${key}`, key)); if (cases.includes(selected)) $("caseFilter").value = selected; } function refreshStats() { const results = dataset.groups.flatMap(g => g.results); const errors = results.map(r => r.height_placement.target_error_px); $("statCases").textContent = new Set([...dataset.groups,...dataset.errors].map(g => g.case_number)).size; $("statComparisons").textContent = results.length; $("statError").textContent = errors.length ? `${fixed(Math.max(...errors))} px` : "—"; $("statStatus").textContent = busy ? "运行中" : (dataset.errors.length ? "有异常" : (results.length ? "已完成" : "待运行")); $("runTime").textContent = dataset.created_at ? `更新于 ${new Date(dataset.created_at).toLocaleString("zh-CN", {timeZone:"Asia/Shanghai",hour12:false})}` : "尚无保存结果"; } function cropBox(group) { if (!$("zoomFace").checked) return [0, 0, group.width, group.height]; if (group.crop_box) return group.crop_box; const rule = group.height_rule, upper = rule.target_upper_height_px, center = rule.target_center; const oldY = group.results.map(r => r.height_placement.old_center.y); const left = Math.max(0, center.x - 1.7 * upper), top = Math.max(0, Math.min(center.y, ...oldY) - .55 * upper); const right = Math.min(group.width, center.x + 1.7 * upper), bottom = Math.min(group.height, rule.brow_center.y + .75 * upper); return [left, top, right, bottom]; } function paint(canvas, photo, oldLine, newLine, group, kind) { const [x0,y0,x1,y1] = cropBox(group), cw = x1-x0, ch = y1-y0; canvas.width = kind === "overlay" ? 1000 : 650; canvas.height = Math.round(canvas.width * ch / cw); const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height; ctx.drawImage(photo, x0,y0,cw,ch,0,0,W,H); const tint = (line, color) => { const layer = document.createElement("canvas"); layer.width=W; layer.height=H; const ink=layer.getContext("2d"); ink.drawImage(line,x0,y0,cw,ch,0,0,W,H); ink.globalCompositeOperation="source-in";ink.fillStyle=color;ink.fillRect(0,0,W,H);ctx.drawImage(layer,0,0); }; if (kind !== "new") tint(oldLine, "#f3a338"); if (kind !== "old") tint(newLine, "#10c6b6"); if ($("showTarget").checked) { const target = group.height_rule.target_center, tx=(target.x-x0)/cw*W, ty=(target.y-y0)/ch*H; ctx.save();ctx.strokeStyle="rgba(255,255,255,.85)";ctx.lineWidth=1.7;ctx.setLineDash([7,6]); ctx.beginPath();ctx.moveTo(0,ty);ctx.lineTo(W,ty);ctx.stroke();ctx.setLineDash([]); ctx.fillStyle="#fff";ctx.beginPath();ctx.arc(tx,ty,4,0,2*Math.PI);ctx.fill();ctx.restore(); } if(kind!=="old"&&$("showBox").checked&&group.guide_landmarks){ window.drawHairlineReferenceBox(ctx,group.guide_landmarks,[x0,y0,x1,y1],W,H); } } async function render() { if(cardObserver)cardObserver.disconnect(); const version=++drawVersion,gender=$("genderFilter").value,number=$("caseFilter").value,style=$("styleFilter").value; const cases=new Map(); for(const group of dataset.groups){ if(gender!=="all"&&group.gender!==gender)continue; if(number!=="all"&&String(group.case_number)!==number)continue; for(const result of group.results){ if(style!=="all"&&result.order!==Number(style))continue; if(!cases.has(group.case_number))cases.set(group.case_number,[]); cases.get(group.case_number).push({group,result}); } } const visibleErrors=dataset.errors.filter(error=>(gender==="all"||error.gender===gender)&&(number==="all"||String(error.case_number)===number)); for(const error of visibleErrors)if(!cases.has(error.case_number))cases.set(error.case_number,[]); $("results").replaceChildren(); let drawErrors=0,total=0; const rowUpdates=[],drawJobs=new WeakMap(); cardObserver=new IntersectionObserver(entries=>{ for(const entry of entries)if(entry.isIntersecting){ cardObserver.unobserve(entry.target);drawJobs.get(entry.target)?.(); } },{rootMargin:"600px 0px"}); for(const [caseNumber,cards] of [...cases].sort((a,b)=>String(a[0]).localeCompare(String(b[0]),"en",{numeric:true}))){ total+=cards.length; const row=document.createElement("section");row.className="person-row"; const caseName=String(caseNumber).startsWith("custom")?"自定义图片":`案例 ${caseNumber}`; const caseErrors=visibleErrors.filter(error=>error.case_number===caseNumber); if(!cards.length){ row.classList.add("failed-case"); row.innerHTML=`

${esc(caseName)} · 定位失败

`; row.querySelector(".person-styles").append(originalCard(caseErrors[0],caseName),failureCard(caseErrors)); $("results").append(row);continue; } row.innerHTML=`

${esc(caseName)} · ${cards.length} 种发际线样式

点击样式,左侧旧图同步切换
旧规则 · 头发边界定位

正在加载…

与右侧选中的形状对应

当前选中样式的原位置
本行仅显示一次 · 点击右侧样式切换
`; $("results").append(row); const groupForRow=cards[0].group,framing=groupForRow.framing; if(framing){ const info=document.createElement("div");info.className="framing-info"; info.innerHTML=`${esc(framing.message)} · 旋转 ${fixed(framing.rotation_degrees,3)}° · ${framing.source_size.width}×${framing.source_size.height} → ${framing.output_size.width}×${framing.output_size.height} · 9/152 横向差 ${fixed(framing.axis_x_error_px,3)} px 上传原图 ↗ 处理后底图 ↗`; row.insertBefore(info,row.querySelector(".person-body")); } const strip=row.querySelector(".person-styles"),oldPanel=row.querySelector(".old-reference-card"),oldCanvas=oldPanel.querySelector("canvas"); // One horizontal row keeps the unprocessed photo first on narrow screens // too, without squeezing the new styles into a zero-width column. strip.append(originalCard(groupForRow,caseName),oldPanel); if(caseErrors.length)strip.append(failureCard(caseErrors)); const [previous,next]=row.querySelectorAll(".row-controls button"); previous.addEventListener("click",()=>strip.scrollBy({left:-strip.clientWidth*.85,behavior:"smooth"})); next.addEventListener("click",()=>strip.scrollBy({left:strip.clientWidth*.85,behavior:"smooth"})); const updateArrows=()=>{previous.disabled=strip.scrollLeft<=2;next.disabled=strip.scrollWidth-strip.clientWidth-strip.scrollLeft<=2;}; strip.addEventListener("scroll",updateArrows,{passive:true});rowUpdates.push(updateArrows); let selectionVersion=0; const selectOld=async(group,result,drawNow=true)=>{ const selected=++selectionVersion,key=`${group.gender}:${result.order}`; oldPanel.dataset.drawn="false"; rowSelections.set(caseNumber,key);row.dataset.selectedStyle=key; for(const card of strip.querySelectorAll(".case-card")){ const active=card.dataset.styleKey===key;card.classList.toggle("selected",active); card.setAttribute("aria-current",String(active));card.querySelector(".select-style").textContent=active?"已对照":"对照此样式"; } const label=styleNames[group.gender][result.order-1]; oldPanel.querySelector(".old-style-name").textContent=`${group.gender==="female"?"女性":"男性"} · ${label}`; oldPanel.querySelector(".old-style-metrics").textContent=`原中心 y ${fixed(result.height_placement.old_center.y)} · 目标 y ${fixed(group.height_rule.target_center.y)}`; const box=cropBox(group);oldCanvas.width=650;oldCanvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0])); const draw=async()=>{ try{ const [photo,line]=await Promise.all([inputImage(group),loadImage(imageSource(result,"old_overlay"))]); if(version!==drawVersion||selected!==selectionVersion)return; paint(oldCanvas,photo,line,null,group,"old");oldPanel.dataset.drawn="true"; }catch(error){if(version===drawVersion)status(error.message,true);} }; if(drawNow)await draw(); else{drawJobs.set(oldPanel,draw);cardObserver.observe(oldPanel);} }; for(const {group,result} of cards){ const rule=group.height_rule,p=result.height_placement,label=styleNames[group.gender][result.order-1]; const card=document.createElement("article");card.className="case-card";card.tabIndex=0; card.dataset.styleKey=`${group.gender}:${result.order}`; card.innerHTML=`
${group.gender==="female"?"女性":"男性"}发际线

${esc(label)} ${esc(result.hairline_type)}

目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px

新 y ${fixed(p.new_center.y)}${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px
${mode==="overlay"?"橙色旧线 / 青色新线":"新规则 · 中下庭均值"}
中心误差 ${fixed(p.target_error_px)} px原图 ↗
`; strip.append(card); card.addEventListener("click",event=>{if(!event.target.closest("a"))selectOld(group,result);}); card.addEventListener("keydown",event=>{if(event.target===card&&(event.key==="Enter"||event.key===" ")){event.preventDefault();selectOld(group,result);}}); const canvas=card.querySelector("canvas"),box=cropBox(group);canvas.width=650;canvas.height=Math.round(650*(box[3]-box[1])/(box[2]-box[0])); drawJobs.set(card,async()=>{ try{ const [photo,line,oldLine]=await Promise.all([inputImage(group),loadImage(imageSource(result,"new_overlay")),mode==="overlay"?loadImage(imageSource(result,"old_overlay")):Promise.resolve(null)]); if(version!==drawVersion)return; paint(canvas,photo,oldLine,line,group,mode==="overlay"?"overlay":"new");card.dataset.drawn="true"; }catch(error){drawErrors++;const message=document.createElement("p");message.className="error-card";message.textContent=error.message;card.append(message);status(`有 ${drawErrors} 组图片加载失败,可重跑案例。`,true);} }); cardObserver.observe(card); } const active=cards.find(c=>`${c.group.gender}:${c.result.order}`===rowSelections.get(caseNumber))||cards[0]; selectOld(active.group,active.result,false); } if(!cases.size&&!dataset.errors.length)$("results").innerHTML='
暂无对比结果。可上传图片或点击“重跑全部30个案例”。
'; requestAnimationFrame(()=>rowUpdates.forEach(update=>update())); if(version===drawVersion&&!busy){ const failures=new Set(visibleErrors.map(error=>error.case_number)).size; status(`显示 ${cases.size} 个案例、${total} 种新规则样式${failures?`,${failures} 个案例存在定位失败`:""}。每人一行,最前面为未裁切原图,旧图仅一次;左右滑动查看全部样式。`,failures>0); } } function setBusy(value) {busy=value;$("rerunBatch").disabled=value;$("customRun").disabled=value;refreshStats();} async function compare(form) { const response=await fetch("/api/v1/debug/hairline-height-compare",{method:"POST",body:form}); if(!response.ok)throw new Error(`请求失败:HTTP ${response.status}`); const json=await response.json();if(json.code!==0)throw new Error(json.message);return json.data; } async function rerun() { if(busy)return;setBusy(true);let failure=null; try { const response=await fetch("/api/v1/debug/hairline-height-cases");const json=await response.json(); if(json.code!==0)throw new Error(json.message); dataset={groups:[],errors:[],created_at:new Date().toISOString()};inputImages.clear(); const cases=json.data.cases,total=cases.length*2;let done=0; for(const test of cases)for(const gender of ["female","male"]){ status(`正在运行案例 ${test.image_number} / ${gender==="female"?"女性":"男性"} 7 型(${done}/${total})…`); const form=new FormData();form.append("image_url",test.image_url);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7"); try{const data=await compare(form);dataset.groups.push({...data,case_number:test.image_number,source_url:test.image_url});} catch(error){dataset.errors.push({case_number:test.image_number,gender,message:error.message,source_url:test.image_url,original_url:test.image_url});} done++;refreshCases();refreshStats();await render(); } }catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);} } async function custom(event) { event.preventDefault();if(busy)return;const file=$("customFile").files[0];if(!file)return;setBusy(true);status("正在计算自定义图片的旧 / 新位置…");let failure=null; try { const photo=file,form=new FormData(),gender=$("customGender").value; form.append("image_file",photo);form.append("gender",gender);form.append("hair_style","1,2,3,4,5,6,7"); const data=await compare(form),number=`custom-${Date.now()}`; dataset.groups.push({...data,case_number:number});dataset.created_at=new Date().toISOString(); $("genderFilter").value=gender;refreshStyleOptions();refreshCases();$("caseFilter").value=number; }catch(error){failure=error.message;}finally{setBusy(false);await render();if(failure)status(failure,true);} } async function init() { fetch("/api/v1/debug/hairline-framing-reference").then(r=>r.json()).then(json=>{ if(json.code!==0)throw new Error(json.message); const p=json.data; $("framingStandard").innerHTML=`9号与152号点已竖直对齐的参考图
已扶正参考图:${p.reference_size.width}×${p.reference_size.height}(3:4)

参考图9号点 (${fixed(p.brow_9.x)}, ${fixed(p.brow_9.y)})
参考图152号点 (${fixed(p.chin_152.x)}, ${fixed(p.chin_152.y)})
两点X相同;旋转 ${fixed(p.rotation_degrees,3)}°

当前裁切:高约 ${Number(fixed(p.height_per_face_length,4))}×L,宽 ${Number(fixed(p.width_per_face_length,4))}×LL 为9号点到152号点的像素距离。
眉心位置:横向 ${fixed(p.brow_x_ratio*100,2)}%,纵向 ${fixed(p.brow_y_ratio*100,2)}%

用户图先扶正,有空间时按上述尺寸裁切;长图保留人脸裁为3:4,其余空间不足时仅扶正。保留实际像素,不放大图片。

`; }).catch(error=>{$("framingStandard").textContent=error.message;}); refreshStyleOptions(); $("genderFilter").addEventListener("change",()=>{refreshStyleOptions();render();}); for(const id of ["styleFilter","caseFilter","zoomFace","showTarget","showBox"])$(id).addEventListener("change",render); for(const [id,value] of [["sideMode","side"],["overlayMode","overlay"]])$(id).addEventListener("click",()=>{mode=value;$("sideMode").classList.toggle("active",value==="side");$("overlayMode").classList.toggle("active",value==="overlay");render();}); $("customForm").addEventListener("submit",custom);$("rerunBatch").addEventListener("click",rerun); try { const response=await fetch("/static/annotations/hairline_height_compare/latest.json",{cache:"no-store"}); if(!response.ok)throw new Error("尚无保存的批量结果,可上传图片或重跑全部30个案例。"); dataset=await response.json();dataset.errors ||= [];refreshCases();refreshStats();await render(); }catch(error){refreshStats();status(error.message,true);} } document.addEventListener("DOMContentLoaded",init);