Files
hair/static/hairline_height_compare.js
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

255 lines
20 KiB
JavaScript
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.
"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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[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=`<div class="case-header"><div><span class="case-number original-reference-label">未裁切原图</span><h3>${esc(caseName)} · 上传原图</h3><p>处理前完整图片</p></div></div><div class="metric-line">${sizeLabel}</div><div class="image-pair overlay"><figure><figcaption>未扶正、未裁切</figcaption><a href="${esc(src)}" target="_blank" rel="noopener"><img src="${esc(src)}" ${dimensions} alt="${esc(caseName)}未裁切原图" loading="lazy" decoding="async"></a></figure></div><div class="case-bottom"><span>保留完整画面</span><a href="${esc(src)}" target="_blank" rel="noopener">查看原图 ↗</a></div>`;
return panel;
}
function failureCard(errors){
const panel=document.createElement("article");panel.className="failed-reference-card";
panel.innerHTML='<h3>定位失败,未生成对比图</h3><p>左侧保留完整上传原图。</p>';
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=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · 定位失败</h2></div><div class="person-body"><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的原图与定位结果"></div></div>`;
row.querySelector(".person-styles").append(originalCard(caseErrors[0],caseName),failureCard(caseErrors));
$("results").append(row);continue;
}
row.innerHTML=`<div class="person-row-header"><h2 class="case-group-heading">${esc(caseName)} · ${cards.length} 种发际线样式</h2><div class="row-controls"><span>点击样式,左侧旧图同步切换</span><button type="button" aria-label="查看前面的样式">←</button><button type="button" aria-label="查看后面的样式">→</button></div></div><div class="person-body"><article class="old-reference-card"><div class="case-header"><div><span class="case-number old-reference-label"><span class="dot old"></span>旧规则 · 头发边界定位</span><h3 class="old-style-name">正在加载…</h3><p>与右侧选中的形状对应</p></div></div><div class="metric-line old-style-metrics"></div><div class="image-pair overlay"><figure><figcaption>当前选中样式的原位置</figcaption><canvas aria-label="旧规则参考图"></canvas></figure></div><div class="case-bottom">本行仅显示一次 · 点击右侧样式切换</div></article><div class="person-styles" role="region" tabindex="0" aria-label="${esc(caseName)}的全部新规则样式"></div></div>`;
$("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 <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"original","image/jpeg")||groupForRow.source_url)}">上传原图 ↗</a> <a target="_blank" rel="noopener" href="${esc(imageSource(groupForRow,"input","image/jpeg"))}">处理后底图 ↗</a>`;
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=`<div class="case-header"><div><span class="case-number">${group.gender==="female"?"女性":"男性"}发际线</span><h3>${esc(label)} <span style="font-size:11px;color:#7c8c88;font-weight:400">${esc(result.hairline_type)}</span></h3><p>目标上庭 (${fixed(rule.middle_height_px)} + ${fixed(rule.lower_height_px)}) ÷ 2 = ${fixed(rule.target_upper_height_px)} px</p></div></div><div class="metric-line"><span>新 y <b>${fixed(p.new_center.y)}</b></span><span><b>${p.shift_y_px<0?"上移":"下移"} ${fixed(Math.abs(p.shift_y_px))} px</b></span></div><div class="image-pair overlay"><figure><figcaption class="new"><span class="dot new"></span>${mode==="overlay"?"橙色旧线 / 青色新线":"新规则 · 中下庭均值"}</figcaption><canvas aria-label="新规则发际线"></canvas></figure></div><div class="case-bottom"><span>中心误差 ${fixed(p.target_error_px)} px</span><button class="select-style" type="button">对照此样式</button><a href="${esc(group.source_url||imageSource(group,"input","image/jpeg"))}" target="_blank" rel="noopener">原图 ↗</a></div>`;
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='<div class="empty">暂无对比结果。可上传图片或点击“重跑全部30个案例”。</div>';
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=`<img src="${esc(p.reference_image_url)}" alt="9号与152号点已竖直对齐的参考图" loading="lazy"><div><strong>已扶正参考图:${p.reference_size.width}×${p.reference_size.height}(3:4)</strong><p>参考图9号点 (${fixed(p.brow_9.x)}, ${fixed(p.brow_9.y)})<br>参考图152号点 (${fixed(p.chin_152.x)}, ${fixed(p.chin_152.y)})<br>两点X相同;旋转 ${fixed(p.rotation_degrees,3)}°</p><p><strong>当前裁切:高约 ${Number(fixed(p.height_per_face_length,4))}×L,宽 ${Number(fixed(p.width_per_face_length,4))}×L</strong>L 为9号点到152号点的像素距离。<br>眉心位置:横向 ${fixed(p.brow_x_ratio*100,2)}%,纵向 ${fixed(p.brow_y_ratio*100,2)}%</p><p>用户图先扶正,有空间时按上述尺寸裁切;长图保留人脸裁为3:4,其余空间不足时仅扶正。保留实际像素,不放大图片。</p></div>`;
}).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);