Files
change_hair/hair_grow_service/static/manual.html
xsl 215663177a feat: 新增mask区域重绘功能 + 羽化贴回优化 + 发丝增强
新增功能:
- inpaint_mask.py: mask区域重绘服务(enhance/pure_inpaint两种模式)
  严格只在mask区做SD inpainting,发丝自然化
- /inpaint 页面 + /api/inpaint 接口:画板手绘mask + 提示词编辑 + denoising可调
- enhance_hair.webui_img2img: 新增denoising_strength参数(原硬编码0.35)

羽化贴回优化(hair_swap_manual.py 步骤⑥):
- feather_px(羽化范围)+ feather_alpha(羽化强度)拆分为两个独立参数
- 改为只羽化边缘(mask内部保持硬切,仅边缘带渐变)
- 新增黑底羽化对比图(纯黑背景凸显边缘过渡)

其他改动:
- OSS/COS密钥脱敏:改为可选import,未设环境变量时不崩溃
- manual页面:发型列表改为本次训练的8个(含hair_flow)
- enhance二次增强:重绘区改为「原图头发∪手绘mask」并外扩10%+减发际线带3cm
- 发型清单文档更新:179→180个可用发型(+hair_flow)

涉及文件:
- 新增: inpaint_mask.py, inpaint.html
- 修改: app.py, manual.html, hair_swap_manual.py, enhance_hair.py
- 修改: oss_module.py, upload_oss.py, cos_module.py(密钥脱敏)
- 文档: HAIRSTYLES_AVAILABLE.md, hairstyles_available.csv
2026-07-08 20:38:31 +08:00

430 lines
23 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>手绘mask换发型</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: 290px; 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; }
.hint { font-size: 11px; color: #888; line-height: 1.6; margin: 6px 0 10px; }
/* 画笔工具 */
.tool-row { display: flex; gap: 6px; margin-bottom: 10px; }
.btn-tool { flex: 1; padding: 8px; background: #0f3460; color: #ccc; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; }
.btn-tool.active { background: #4ecca3; color: #16213e; font-weight: 600; }
.btn-tool:hover { background: #1a4a7a; }
.btn-tool.active:hover { background: #6ee0bd; }
.btn-danger { width: 100%; padding: 7px; background: #c0392b; color: #fff; border: none; border-radius: 5px; cursor: pointer; font-size: 12px; margin-bottom: 10px; }
.btn-danger:hover { background: #e74c3c; }
.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 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; }
.switch { display: flex; align-items: center; justify-content: space-between; padding: 6px 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; }
.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; }
/* 中间发型选择 */
.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: 600px; 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; }
#canvas-container { position: relative; display: inline-block; max-width: 100%; }
#img-canvas, #mask-canvas { display: block; max-width: 100%; border-radius: 6px; }
#mask-canvas { position: absolute; top: 0; left: 0; cursor: crosshair; touch-action: none; }
#mask-canvas.eraser { cursor: cell; }
.canvas-tip { font-size: 11px; color: #666; margin-top: 8px; }
#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-title { font-size: 15px; font-weight: 600; color: #4ecca3; margin-bottom: 4px; }
.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>🖌️ 手绘mask换发型(重绘区由你画)</h1>
<p>上传人像 → 用画笔涂出要重绘的区域 → 选发型 → 生成。SD 只在你涂的区域重绘</p>
</div>
<div class="links">
<a href="/debug">→ 全自动调试台</a>
<a href="/hairline">→ 发际线带实验</a>
<a href="/test_new">→ 简易测试</a>
</div>
</div>
<div class="container">
<!-- 左侧:画笔工具 + 参数 -->
<div class="panel">
<h3>① 画笔工具</h3>
<div class="hint">在右侧图片上涂抹需要重绘的区域(白色=重绘,黑色=保留原图)</div>
<div class="tool-row">
<button class="btn-tool active" id="tool-brush">🖌️ 画笔</button>
<button class="btn-tool" id="tool-eraser">🧹 橡皮</button>
</div>
<div class="field">
<div class="field-label">画笔大小 <span class="val" id="size-val">25px</span></div>
<input type="range" id="brush-size" min="5" max="80" value="25">
</div>
<button class="btn-danger" id="btn-clear">✕ 清除全部涂抹</button>
<h3>② 参数</h3>
<div class="switch on" id="sw-is_hr" onclick="toggleSwitch('is_hr', this)">
<span>高清模式 (1152x1536)</span>
<div class="toggle"></div>
</div>
<div class="switch" id="sw-strict_mask" onclick="toggleSwitch('strict_mask', this)">
<span>严格mask贴回 <span style="color:#666;font-size:10px">(mask外保留原图)</span></span>
<div class="toggle"></div>
</div>
<div class="switch on" id="sw-seamless_blend" onclick="toggleSwitch('seamless_blend', this)">
<span>泊松融合消接缝 <span style="color:#e9b949;font-size:10px">⚠需同时开「严格mask」</span></span>
<div class="toggle"></div>
</div>
<div class="field">
<div class="field-label">denoising_strength <span class="val" id="v-den">0.60</span></div>
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.6" oninput="syncFloat('den')">
</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="$('v-dk0').textContent=this.value">
</div>
<div class="field">
<div class="field-label">(膨胀) <span class="val" id="v-dk1">18</span></div>
<input type="number" id="dk1" value="18" min="1" max="40" oninput="$('v-dk1').textContent=this.value">
</div>
</div>
<div class="field">
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="羽化范围(像素),边缘带宽度。0=无羽化,越大羽化范围越宽"></span></div>
<input type="range" id="fp" min="0" max="60" step="1" value="0" oninput="syncInt('fp')">
</div>
<div class="field">
<div class="field-label">feather_alpha <span class="val" id="v-fa">1.00</span><span class="tip" title="羽化强度(0~1)。0=硬切不羽化,1=最大羽化。控制边缘过渡柔和程度"></span></div>
<input type="range" id="fa" min="0" max="1.0" step="0.05" value="1.0" oninput="syncFloat('fa')">
</div>
<div class="readonly-info">
<b style="color:#888">webui端固定:</b><br>
<code>cfg=7</code> <code>steps=20</code> <code>sampler=DPM++ 2M Karras</code>
</div>
<h3 style="color:#4ecca3;margin-top:14px">③ enhance 二次增强(可选)</h3>
<div class="switch" id="sw-enhance" onclick="toggleSwitch('enhance', this)">
<span>开启enhance <span class="tip" title="换发型后对结果再做一次低强度SD重绘,让发丝更清晰(+15-20秒)"></span></span>
<div class="toggle"></div>
</div>
<div class="field">
<div class="field-label">enhance_denoising <span class="val" id="v-ed">0.35</span><span class="tip" title="enhance重绘强度。越低越保留原图,越高发丝变化越大"></span></div>
<input type="range" id="ed" min="0.1" max="0.8" step="0.05" value="0.35" oninput="syncFloat('ed')">
</div>
<button class="btn btn-primary" id="btn-run" disabled>▶ 生成(手绘区换发型)</button>
</div>
<!-- 中间发型选择 -->
<div class="mid-panel">
<h3>③ 选发型 <span id="count-info" style="font-size:10px;color:#888;font-weight:normal"></span></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-tool" style="margin-top:8px;width:100%" 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>
<div id="canvas-container" style="display:none">
<canvas id="img-canvas"></canvas>
<canvas id="mask-canvas"></canvas>
</div>
<input type="file" id="file-input" accept="image/*" style="display:none">
</div>
<div class="canvas-tip" id="canvas-tip" style="display:none;text-align:center">用左侧画笔在图片上涂抹要重绘的区域</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);
let readySet=new Set(), selectedHair=null, imgB64Orig=null;
// 画板状态
const imgCanvas=$("img-canvas"),maskCanvas=$("mask-canvas");
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
let mode="brush",brushSize=25,drawing=false,lastX=0,lastY=0;
// 本次训练的发型(昨天6个 + 今天hair_flow
const HAIRSTYLES = [
{name:"hair_flow"},{name:"圆-心形"},{name:"圆-椭圆"},{name:"圆-波浪"},
{name:"圆-直线"},{name:"圆-花瓣"},{name:"心形-心形"},{name:"心形-椭圆"}
];
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"); };
// 浮点滑块联动显示
window.syncFloat=(id)=>{ $("v-"+id).textContent=parseFloat($(id).value).toFixed(2); };
// 整数滑块联动显示
window.syncInt=(id)=>{ $("v-"+id).textContent=$(id).value; };
// 上传图片
$("upload-zone").onclick=()=>$("file-input").click();
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImage(e.target.files[0]); };
function loadImage(file){
if(!file.type.startsWith("image/")){alert("请上传图片");return;}
const rd=new FileReader();
rd.onload=e=>{
const im=new Image();
im.onload=()=>{
// 限制最大尺寸,避免太大
let w=im.width,h=im.height;
const MAX=1024;
if(Math.max(w,h)>MAX){const sc=MAX/Math.max(w,h);w=Math.round(w*sc);h=Math.round(h*sc);}
imgCanvas.width=w;imgCanvas.height=h;
maskCanvas.width=w;maskCanvas.height=h;
imgCtx.drawImage(im,0,0,w,h);
maskCtx.clearRect(0,0,w,h);
imgB64Orig=imgCanvas.toDataURL("image/jpeg",0.95);
$("canvas-container").style.display="inline-block";
$("upload-zone").style.display="none";
$("canvas-tip").style.display="block";
updateBtn();
};
im.src=e.target.result;
};
rd.readAsDataURL(file);
}
// 画笔工具切换
$("tool-brush").onclick=()=>{mode="brush";$("tool-brush").classList.add("active");$("tool-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");};
$("tool-eraser").onclick=()=>{mode="eraser";$("tool-eraser").classList.add("active");$("tool-brush").classList.remove("active");maskCanvas.classList.add("eraser");};
$("brush-size").oninput=e=>{brushSize=+e.target.value;$("size-val").textContent=brushSize+"px";};
$("btn-clear").onclick=()=>{maskCtx.globalCompositeOperation="source-over";maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);};
// 绘制
function getPos(e){
const r=maskCanvas.getBoundingClientRect();
const sc=maskCanvas.width/r.width;
const cx=(e.touches?e.touches[0].clientX:e.clientX)-r.left;
const cy=(e.touches?e.touches[0].clientY:e.clientY)-r.top;
return [cx*sc,cy*sc];
}
function startDraw(e){e.preventDefault();drawing=true;const[x,y]=getPos(e);lastX=x;lastY=y;drawDot(x,y);}
function moveDraw(e){if(!drawing)return;e.preventDefault();const[x,y]=getPos(e);drawLine(lastX,lastY,x,y);lastX=x;lastY=y;}
function endDraw(){drawing=false;}
function drawDot(x,y){
if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.fillStyle="rgba(255,60,80,0.5)";}
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.fillStyle="rgba(0,0,0,1)";}
maskCtx.beginPath();maskCtx.arc(x,y,brushSize/2,0,Math.PI*2);maskCtx.fill();
}
function drawLine(x1,y1,x2,y2){
if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.strokeStyle="rgba(255,60,80,0.5)";}
else{maskCtx.globalCompositeOperation="destination-out";maskCtx.strokeStyle="rgba(0,0,0,1)";}
maskCtx.beginPath();maskCtx.moveTo(x1,y1);maskCtx.lineTo(x2,y2);
maskCtx.lineWidth=brushSize;maskCtx.lineCap="round";maskCtx.lineJoin="round";maskCtx.stroke();
}
maskCanvas.addEventListener("mousedown",startDraw);
maskCanvas.addEventListener("mousemove",moveDraw);
window.addEventListener("mouseup",endDraw);
maskCanvas.addEventListener("touchstart",startDraw,{passive:false});
maskCanvas.addEventListener("touchmove",moveDraw,{passive:false});
maskCanvas.addEventListener("touchend",endDraw);
function updateBtn(){ $("btn-run").disabled=!(selectedHair&&imgB64Orig); }
// 提取mask为黑白二值PNG base64
function extractMaskB64(){
const tmp=document.createElement("canvas");
tmp.width=maskCanvas.width;tmp.height=maskCanvas.height;
const tc=tmp.getContext("2d");
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
const out=tc.createImageData(tmp.width,tmp.height);
for(let i=0;i<md.data.length;i+=4){
const v=md.data[i+3]>30?255:0; // 用alpha判断是否涂抹过
out.data[i]=v;out.data[i+1]=v;out.data[i+2]=v;out.data[i+3]=255;
}
tc.putImageData(out,0,0);
return tmp.toDataURL("image/png");
}
// 执行
$("btn-run").onclick=async()=>{
if(!selectedHair||!imgB64Orig) return;
// 检查是否涂抹了mask
const md=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
let hasMask=false;
for(let i=3;i<md.data.length;i+=4){if(md.data[i]>30){hasMask=true;break;}}
if(!hasMask){alert("请先用画笔涂抹需要重绘的区域");return;}
const maskB64=extractMaskB64();
const params={
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)],
feather_px:parseInt($("fp").value),
feather_alpha:parseFloat($("fa").value),
denoising_strength:parseFloat($("den").value),
enhance:$("sw-enhance").classList.contains("on"),
enhance_denoising:parseFloat($("ed").value),
};
$("btn-run").disabled=true;
$("status").style.display="block";
$("status-text").textContent = params.enhance
? "生成中(换发型 + enhance增强),约60-110秒..."
: "生成中(粗推理→手绘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_manual",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify(Object.assign({img:imgB64Orig,mask:maskB64,hair_id:selectedHair},params))
});
const data=await resp.json();
$("status").style.display="none";
if(data.state===0){
const url="data:image/jpeg;base64,"+data.result;
$("r-orig").src=imgB64Orig;$("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-title">'+step.title+'</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");}
loadStatus();
})();
</script>
</body>
</html>