Files
xsl 443cfa298f 初始化:换发型/换发色/训练发型服务
包含:
- hair_service_sd: 主服务(换发型/换发色/生发,端口8801)
- photo_service: LoRA调度+训练(端口32678)
- hair_grow_service: 调试测试页(端口8888,含4个测试页)
- 批量训练脚本(batch_train_hairstyles.py)
- 发际线mask自动识别(hairline_mask.py,4种方案)
- 手绘mask换发型(hair_swap_manual.py)
- 文档:README.md + LARGE_FILES.md + docs/

大文件(模型权重200G、训练数据123G)已排除,见 LARGE_FILES.md
OSS/COS密钥已脱敏为环境变量,原文件备份在本地
2026-07-07 13:53:52 +08:00

435 lines
24 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>发际线带重绘实验</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: #1a1a2e; color: #eee; min-height: 100vh; font-size: 14px; }
.header { background: #16213e; padding: 16px 28px; border-bottom: 1px solid #0f3460; display: flex; justify-content: space-between; align-items: center; }
.header h1 { font-size: 19px; font-weight: 600; }
.header p { font-size: 12px; color: #888; margin-top: 3px; }
.header .links a { color: #4ecca3; font-size: 12px; text-decoration: none; margin-left: 12px; }
.container { display: flex; gap: 18px; padding: 18px; max-width: 1800px; margin: 0 auto; }
/* 左侧参数面板 */
.panel { width: 340px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; max-height: 92vh; overflow-y: auto; }
.panel::-webkit-scrollbar { width: 6px; }
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
.panel h3 { font-size: 13px; color: #4ecca3; margin: 14px 0 8px; text-transform: uppercase; letter-spacing: 1px; padding-bottom: 5px; border-bottom: 1px solid #0f3460; }
.panel h3:first-child { margin-top: 0; }
.field { margin-bottom: 12px; }
.field-label { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: #bbb; margin-bottom: 5px; }
.field-label .val { color: #4ecca3; font-family: monospace; font-size: 11px; }
.field-label .tip { color: #666; font-size: 10px; margin-left: 4px; cursor: help; }
.field input[type="range"] { width: 100%; height: 4px; -webkit-appearance: none; background: #0f3460; border-radius: 2px; outline: none; }
.field input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #4ecca3; cursor: pointer; }
.field input[type="number"] { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
.field select { width: 100%; padding: 5px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; }
.switch { display: flex; align-items: center; justify-content: space-between; padding: 7px 0; font-size: 13px; color: #ccc; cursor: pointer; }
.switch:hover { color: #eee; }
.toggle { position: relative; width: 36px; height: 20px; background: #0f3460; border-radius: 10px; transition: .2s; flex-shrink: 0; }
.toggle::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; background: #888; border-radius: 50%; transition: .2s; }
.switch.on .toggle { background: #4ecca3; }
.switch.on .toggle::after { left: 18px; background: #16213e; }
.pair { display: flex; gap: 8px; }
.pair .field { flex: 1; }
.hint { font-size: 11px; color: #666; line-height: 1.5; margin-top: 8px; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; }
.hint b { color: #aaa; }
.readonly-info { font-size: 11px; color: #777; padding: 8px 10px; background: #0d1b3e; border-radius: 5px; line-height: 1.6; }
.readonly-info code { color: #aaa; background: #16213e; padding: 1px 5px; border-radius: 3px; }
.btn { display: block; width: 100%; padding: 10px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: .15s; }
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; margin-top: 8px; }
.btn-primary:hover { background: #6ee0bd; }
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
.btn-ghost { background: #0f3460; color: #eee; margin-top: 6px; }
.btn-ghost:hover { background: #1a4a80; }
/* 中间发型选择 */
.mid-panel { width: 200px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 14px; height: fit-content; max-height: 92vh; overflow-y: auto; }
.mid-panel h3 { font-size: 12px; color: #4ecca3; margin-bottom: 10px; }
.hairstyle-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.hairstyle-item { position: relative; cursor: pointer; border-radius: 5px; overflow: hidden; border: 2px solid transparent; aspect-ratio: 1; background: #0d1b3e; }
.hairstyle-item img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hairstyle-item.ready { border-color: #2a5a4a; }
.hairstyle-item.selected { border-color: #4ecca3; }
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 1px; right: 3px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; font-size: 11px; }
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 10px; padding: 8px 2px 2px; text-align: center; }
.hairstyle-item.pending { opacity: 0.4; cursor: not-allowed; }
/* 右侧工作区 */
.workspace { flex: 1; min-width: 0; }
.canvas-wrap { background: #0d0d1a; border-radius: 10px; padding: 18px; text-align: center; min-height: 280px; display: flex; align-items: center; justify-content: center; }
.upload-zone { width: 100%; max-width: 460px; border: 2px dashed #0f3460; border-radius: 10px; padding: 40px 20px; text-align: center; cursor: pointer; transition: .2s; }
.upload-zone:hover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
.upload-zone svg { width: 42px; height: 42px; fill: #4ecca3; margin-bottom: 10px; }
.upload-zone p { color: #888; font-size: 13px; }
.upload-zone p.highlight { color: #4ecca3; }
#preview-img { display: none; max-width: 100%; max-height: 460px; border-radius: 6px; }
#status { text-align: center; padding: 24px; color: #4ecca3; display: none; }
.spinner { display: inline-block; width: 18px; height: 18px; border: 3px solid #0f3460; border-top-color: #4ecca3; border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
#error-msg { color: #e74c3c; padding: 16px; display: none; font-size: 13px; background: rgba(231,76,60,.1); border-radius: 6px; margin-top: 12px; }
/* 步骤画廊 */
.result-top { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
.result-top.show { display: block; }
.result-top h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
.result-pair { display: flex; gap: 16px; flex-wrap: wrap; }
.result-card { flex: 1; min-width: 240px; }
.result-card h4 { font-size: 12px; color: #aaa; margin-bottom: 6px; text-align: center; }
.result-card img { width: 100%; border-radius: 6px; display: block; }
.params-used { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; font-family: monospace; }
.steps-section { margin-top: 16px; }
.step-item { background: #16213e; border-radius: 10px; padding: 16px; margin-bottom: 14px; }
.step-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 4px; }
.step-title { font-size: 15px; font-weight: 600; color: #4ecca3; }
.step-time { font-size: 11px; color: #666; font-family: monospace; }
.step-desc { font-size: 12px; color: #999; margin-bottom: 12px; line-height: 1.6; }
.step-images { display: flex; gap: 12px; flex-wrap: wrap; }
.step-img-card { flex: 1; min-width: 180px; max-width: 280px; }
.step-img-card .lbl { font-size: 11px; color: #888; margin-bottom: 4px; text-align: center; }
.step-img-card img { width: 100%; border-radius: 6px; display: block; border: 1px solid #0f3460; cursor: zoom-in; transition: .15s; }
.step-img-card img:hover { border-color: #4ecca3; }
#lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.92); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
#lightbox img { max-width: 92%; max-height: 92%; border-radius: 8px; }
#lightbox.show { display: flex; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>🔬 发际线带重绘实验(只重绘发际线,主体保留原图)</h1>
<p>实验性:重绘区域只取发际线边界带,观察"只改发际线、保留原发型主体"的效果</p>
</div>
<div class="links">
<a href="/test_new">→ 简易测试页</a>
<a href="/swap">→ 全部发型页</a>
</div>
</div>
<div class="container">
<!-- 左侧:参数面板 -->
<div class="panel">
<h3>① 流程开关</h3>
<div class="switch" id="sw-strict_mask" onclick="toggleSwitch('strict_mask', this)">
<span>严格mask贴回 <span class="tip" title="只在mask区域覆盖,mask外保留原图(否则整个裁剪框覆盖)"></span></span>
<div class="toggle"></div>
</div>
<div class="switch on" id="sw-seamless_blend" onclick="toggleSwitch('seamless_blend', this)">
<span>泊松融合消接缝 <span class="tip" title="严格mask模式下用seamlessClone消除边缘色差(仅strict_mask开启时生效)"></span></span>
<div class="toggle"></div>
</div>
<h3>② 尺寸 / 对齐</h3>
<div class="switch on" id="sw-is_hr" onclick="toggleSwitch('is_hr', this)">
<span>高清模式 <span class="tip" title="1152x1536 vs 576x768,影响显存和速度"></span></span>
<div class="toggle"></div>
</div>
<div class="pair">
<div class="field">
<div class="field-label">dilate_kernel <span class="val" id="v-dk0">6</span></div>
<input type="number" id="dk0" value="6" min="1" max="40" oninput="syncNum('dk0')">
</div>
<div class="field">
<div class="field-label">mask膨胀核(x,y) <span class="val" id="v-dk1">18</span></div>
<input type="number" id="dk1" value="18" min="1" max="40" oninput="syncNum('dk1')">
</div>
</div>
<h3>③ 贴回 / 融合</h3>
<div class="pair">
<div class="field">
<div class="field-label">blend_dilate <span class="val" id="v-bd0">5</span></div>
<input type="number" id="bd0" value="5" min="1" max="40" oninput="syncNum('bd0')">
</div>
<div class="field">
<div class="field-label">(strict贴回) <span class="val" id="v-bd1">5</span></div>
<input type="number" id="bd1" value="5" min="1" max="40" oninput="syncNum('bd1')">
</div>
</div>
<div class="pair">
<div class="field">
<div class="field-label">seamless_dilate <span class="val" id="v-sd0">9</span></div>
<input type="number" id="sd0" value="9" min="1" max="40" oninput="syncNum('sd0')">
</div>
<div class="field">
<div class="field-label">(泊松融合) <span class="val" id="v-sd1">9</span></div>
<input type="number" id="sd1" value="9" min="1" max="40" oninput="syncNum('sd1')">
</div>
</div>
<h3 style="color:#e9b949">★ 发际线mask参数(landmark_1k 圆角矩形)</h3>
<div class="field">
<div class="field-label">band_width <span class="val" id="v-bw">15</span><span class="tip" title="膨胀核宽度(像素)。越大重绘区域越宽"></span></div>
<input type="range" id="bw" min="3" max="50" step="1" value="15" oninput="syncRangeInt('bw')">
</div>
<div class="hint" style="font-size:10px;color:#e9b949;margin:6px 0 4px">▼ 圆角矩形形状</div>
<div class="field">
<div class="field-label">height_ratio <span class="val" id="v-hr">0.43</span><span class="tip" title="高度缩放比(相对原额头高度)。越小上下越窄"></span></div>
<input type="range" id="hr" min="0.1" max="1.0" step="0.02" value="0.43" oninput="syncRangeFloat('hr',2)">
</div>
<div class="field">
<div class="field-label">width_ratio <span class="val" id="v-wr">0.14</span><span class="tip" title="左右各扩展比(相对眉宽)。越大左右越宽"></span></div>
<input type="range" id="wr" min="0" max="0.6" step="0.02" value="0.14" oninput="syncRangeFloat('wr',2)">
</div>
<div class="field">
<div class="field-label">corner_ratio <span class="val" id="v-cr">0.25</span><span class="tip" title="圆角半径比(相对短边)。越大圆角越大"></span></div>
<input type="range" id="cr" min="0" max="0.5" step="0.05" value="0.25" oninput="syncRangeFloat('cr',2)">
</div>
<div class="field">
<div class="field-label">vertical_offset <span class="val" id="v-vo">0.00</span><span class="tip" title="上下平移比(相对额头高度)。正值下移,负值上移"></span></div>
<input type="range" id="vo" min="-0.5" max="0.5" step="0.02" value="0" oninput="syncRangeFloat('vo',2)">
</div>
<div class="hint" style="font-size:10px;color:#4ecca3;margin:6px 0 4px">▼ SD 推理(仅执行换发型时生效)</div>
<div class="field">
<div class="field-label">denoising <span class="val" id="v-den">0.60</span><span class="tip" title="重绘强度。越高越偏离原图"></span></div>
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.6" oninput="syncRangeFloat('den',2)">
</div>
<div class="field">
<div class="field-label">refiner_switch_at <span class="val" id="v-rsa">0.50</span><span class="tip" title="refiner切换点(仅高清模式)。0=不用refiner0.5=中途切换,1=全程refiner"></span></div>
<input type="range" id="rsa" min="0" max="1.0" step="0.05" value="0.5" oninput="syncRangeFloat('rsa',2)">
</div>
<div class="switch on" id="sw-preview_only" onclick="toggleSwitch('preview_only', this)">
<span>仅验证mask <span class="tip" title="开=只生成发际线mask(几秒),不做换发型重绘。关=执行完整换发型(40-90秒)"></span></span>
<div class="toggle"></div>
</div>
<div class="hint" style="border-left:3px solid #e9b949">
<b style="color:#e9b949">说明:</b><br>
步骤③用 <b>landmark_1k 关键点</b>定位额头,生成<b>圆角矩形</b>重绘区。头发主体保留原图,LoRA 仅在发际线区生效。<br>
看「★ 叠加用户原图」直观判断重绘区位置。
</div>
<button class="btn btn-primary" id="btn-run" disabled>▶ 执行换发型</button>
<button class="btn btn-ghost" id="btn-reset" onclick="resetParams()">↺ 恢复默认参数</button>
<div class="hint">
<b>使用说明</b><br>
1. 选发型(中间)→ 上传人像(右侧)<br>
2. 调整左侧参数<br>
3. 点"执行",看7步中间产物<br>
4. 改参数重跑,对比效果差异
</div>
</div>
<!-- 中间:发型选择 -->
<div class="mid-panel">
<h3>选择发型</h3>
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
<button class="btn btn-ghost" style="margin-top:8px;font-size:12px" onclick="loadStatus()">↻ 刷新</button>
</div>
<!-- 右侧:工作区 -->
<div class="workspace">
<div class="canvas-wrap">
<div class="upload-zone" id="upload-zone">
<svg viewBox="0 0 24 24"><path d="M19 13v6H5v-6H3v8h18v-8zM6 9l1.41 1.41L11 6.83V18h2V6.83l3.59 3.58L18 9l-6-6z"/></svg>
<p class="highlight">点击上传人像</p>
<p>正面清晰照效果最佳</p>
</div>
<input type="file" id="file-input" accept="image/*" style="display:none">
<img id="preview-img">
</div>
<div id="status"><span class="spinner"></span><span id="status-text">执行中,约40-90秒...</span></div>
<div id="error-msg"></div>
<div class="result-top" id="result-top">
<h3>最终对比</h3>
<div class="result-pair">
<div class="result-card"><h4>原图</h4><img id="r-orig"></div>
<div class="result-card"><h4>换发型结果</h4><img id="r-new"></div>
</div>
<div class="params-used" id="params-used"></div>
</div>
<div class="steps-section" id="steps-section"></div>
</div>
</div>
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
<script>
(function(){
const $=id=>document.getElementById(id);
const HAIRSTYLES = [
{face:"圆",name:"圆-心形"},{face:"圆",name:"圆-椭圆"},{face:"圆",name:"圆-波浪"},{face:"圆",name:"圆-直线"},{face:"圆",name:"圆-花瓣"},
{face:"心形",name:"心形-心形"},{face:"心形",name:"心形-椭圆"},{face:"心形",name:"心形-波浪"},{face:"心形",name:"心形-直线"},{face:"心形",name:"心形-花瓣"},
{face:"方脸",name:"方脸-心形"},{face:"方脸",name:"方脸-椭圆"},{face:"方脸",name:"方脸-波浪"},{face:"方脸",name:"方脸-直线"},{face:"方脸",name:"方脸-花瓣"},
{face:"椭圆",name:"椭圆-心形"},{face:"椭圆",name:"椭圆-椭圆"},{face:"椭圆",name:"椭圆-波浪"},{face:"椭圆",name:"椭圆-直线"},{face:"椭圆",name:"椭圆-花瓣"},
{face:"菱形",name:"菱形-心"},{face:"菱形",name:"菱形-椭圆"},{face:"菱形",name:"菱形-波浪"},{face:"菱形",name:"菱形-直线"},{face:"菱形",name:"菱形-花瓣"},
{face:"长",name:"长-心形"},{face:"长",name:"长 -椭圆"},{face:"长",name:"长 -波浪"},{face:"长",name:"长 -直线"},{face:"长",name:"长 -花瓣"}
];
const defaults = {method:"landmark_1k",strict_mask:false,seamless_blend:true,is_hr:true,
dk0:6,dk1:18,bd0:5,bd1:5,sd0:9,sd1:9,bw:15,preview_only:true,
hr:0.43,wr:0.14,cr:0.25,vo:0,den:0.6,rsa:0.5};
let readySet=new Set(), selectedHair=null, imgB64=null;
async function loadStatus(){
try{
const r=await fetch("/api/hairstyles");const d=await r.json();
if(d.state!==0)throw new Error(d.msg);
readySet=new Set(d.data.map(x=>x.hair_id));
}catch(e){readySet=new Set();}
renderGrid();
}
function renderGrid(){
const g=$("hairstyle-grid");g.innerHTML="";
HAIRSTYLES.forEach(h=>{
const ready=readySet.has(h.name);
const it=document.createElement("div");
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
if(ready) it.onclick=()=>{
selectedHair=h.name;
$("sel-info").textContent="已选: "+h.name;
renderGrid(); updateBtn();
};
g.appendChild(it);
});
}
// 参数控件
window.toggleSwitch=(key,el)=>{
el.classList.toggle("on");
if(key==="preview_only") updateRunBtn();
updateBtn();
};
function updateRunBtn(){
const po=$("sw-preview_only").classList.contains("on");
$("btn-run").innerHTML = po ? "▶ 仅生成mask(快速预览)" : "▶ 执行完整换发型(40-90秒)";
}
// range 滑块绑定显示
["den"].forEach(id=>{
$(id).addEventListener("input",e=>$("v-"+id).textContent=parseFloat(e.target.value).toFixed(2));
});
// band_width 滑块(整数,HTML 里 oninput 直接调用全局函数)
window.syncRangeInt=(id)=>{ $("v-"+id).textContent=$(id).value; };
// 浮点滑块(HTML oninput 调用,联动显示)
window.syncRangeFloat=(id,fix)=>{ $("v-"+id).textContent=parseFloat($(id).value).toFixed(fix); };
window.syncNum=(id)=>{ $("v-"+id).textContent=$(id).value; };
window.resetParams=()=>{
Object.keys(defaults).forEach(k=>{
if(typeof defaults[k]==="boolean"){
const el=$("sw-"+k); el.classList.toggle("on",defaults[k]);
}else if(k==="method"){
// method 固定 landmark_1k,无 UI
}else{
const v=defaults[k];
$(k).value=v;
const lbl=$("v-"+k);
if(lbl){
// 浮点显示2位小数,整数原样
lbl.textContent = (typeof v==="number" && !Number.isInteger(v)) ? v.toFixed(2) : v;
}
}
});
updateRunBtn();
};
function collectParams(){
return {
method:"landmark_1k",
strict_mask:$("sw-strict_mask").classList.contains("on"),
seamless_blend:$("sw-seamless_blend").classList.contains("on"),
is_hr:$("sw-is_hr").classList.contains("on"),
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
denoising_strength:parseFloat($("den").value),
blend_dilate:[parseInt($("bd0").value),parseInt($("bd1").value)],
seamless_dilate:[parseInt($("sd0").value),parseInt($("sd1").value)],
band_width:parseInt($("bw").value),
preview_only:$("sw-preview_only").classList.contains("on"),
height_ratio:parseFloat($("hr").value),
width_ratio:parseFloat($("wr").value),
corner_ratio:parseFloat($("cr").value),
vertical_offset:parseFloat($("vo").value),
refiner_switch_at:parseFloat($("rsa").value),
};
}
// 上传
$("upload-zone").onclick=()=>$("file-input").click();
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImg(e.target.files[0]); };
$("upload-zone").ondragover=e=>{e.preventDefault();$("upload-zone").style.borderColor="#4ecca3";};
$("upload-zone").ondragleave=()=>$("upload-zone").style.borderColor="#0f3460";
$("upload-zone").ondrop=e=>{e.preventDefault();if(e.dataTransfer.files[0])loadImg(e.dataTransfer.files[0]);};
function loadImg(file){
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
const rd=new FileReader();
rd.onload=e=>{
imgB64=e.target.result;
$("preview-img").src=imgB64;
$("preview-img").style.display="block";
$("upload-zone").style.display="none";
updateBtn();
};
rd.readAsDataURL(file);
}
function updateBtn(){ $("btn-run").disabled=!(selectedHair&&imgB64); }
// 执行
$("btn-run").onclick=async()=>{
if(!selectedHair||!imgB64) return;
const p=collectParams();
$("btn-run").disabled=true;
$("status").style.display="block";
$("status-text").textContent = p.preview_only
? "生成发际线mask中(粗推理+方案),约5-15秒..."
: "执行完整换发型中(粗推理→mask→warpAffine→SD→贴回),约40-90秒...";
$("error-msg").style.display="none";
$("result-top").classList.remove("show");
$("steps-section").innerHTML="";
try{
const resp=await fetch("/api/swap_hairline",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify(Object.assign({img:imgB64,hair_id:selectedHair},p))
});
const data=await resp.json();
$("status").style.display="none";
if(data.state===0){
const url="data:image/jpeg;base64,"+data.result;
$("r-orig").src=imgB64;$("r-new").src=url;
// 参数回显
let ph=""; Object.keys(data.params).forEach(k=>{
let v=data.params[k]; if(Array.isArray(v))v="["+v.join(",")+"]";
if(typeof v==="boolean")v=v?"开":"关";
ph+=k+" = "+v+" ";
});
$("params-used").textContent="本次参数: "+ph;
$("result-top").classList.add("show");
// 步骤画廊
const sc=$("steps-section");
data.steps.forEach(step=>{
const div=document.createElement("div");div.className="step-item";
let imgs='<div class="step-images">';
step.images.forEach(im=>{imgs+='<div class="step-img-card"><div class="lbl">'+im.label+'</div><img src="data:image/jpeg;base64,'+im.b64+'" data-full="data:image/jpeg;base64,'+im.b64+'"></div>';});
imgs+='</div>';
div.innerHTML='<div class="step-head"><div class="step-title">'+step.title+'</div></div><div class="step-desc">'+step.desc+'</div>'+imgs;
sc.appendChild(div);
});
sc.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
$("result-top").scrollIntoView({behavior:"smooth"});
}else{
$("error-msg").textContent="❌ "+(data.msg||"失败");
$("error-msg").style.display="block";
}
}catch(err){
$("status").style.display="none";
$("error-msg").textContent="❌ 请求失败: "+err.message;
$("error-msg").style.display="block";
}
updateBtn();
};
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
updateRunBtn();
loadStatus();
})();
</script>
</body>
</html>