保留5号历史三档实现,新增中下庭高度定位、扶正裁切、矩形叠加与30案例对比页。 worker仅允许本机和配置白名单访问;网关不增加IP限制。空图片URL字段改写限定为52号,保持旧接口兼容。同步接口、裁切与部署文档。 验证:113项测试通过;1项历史数值基线失败,在未修改的a3fddbb版本复现。浏览器验证5/52及30案例对比页通过。
255 lines
20 KiB
JavaScript
255 lines
20 KiB
JavaScript
"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=`<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);
|