Files
change_hair/hair_grow_service/static/debug.html
T
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

380 lines
20 KiB
HTML

<!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>调参数 → 看每一步中间产物如何变化。绿色框=可编辑参数,灰色=webui端固定</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 on" id="sw-cut_bang" onclick="toggleSwitch('cut_bang', this)">
<span>减刘海圆 <span class="tip" title="从原头发mask中扣除眼睛周围的圆形区域,避免重绘眼睛"></span></span>
<div class="toggle"></div>
</div>
<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>③ SD 推理参数</h3>
<div class="field">
<div class="field-label">denoising_strength <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="syncRange('den',2)">
</div>
<div class="readonly-info">
<b style="color:#888">webui端固定(不可调):</b><br>
<code>cfg_scale=7</code> <code>steps=20</code><br>
<code>sampler=DPM++ 2M Karras</code><br>
<code>mask_blur=11</code> <code>seed=123456789</code>
</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>
<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 = {cut_bang:true,strict_mask:false,seamless_blend:true,is_hr:true,
dk0:6,dk1:18,den:0.6,bd0:5,bd1:5,sd0:9,sd1:9};
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");
updateBtn();
};
// range 滑块绑定显示
["den"].forEach(id=>{
$(id).addEventListener("input",e=>$("v-"+id).textContent=parseFloat(e.target.value).toFixed(2));
});
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==="den"){ $("den").value=defaults[k]; $("v-den").textContent=defaults[k].toFixed(2);
}else{ $(k).value=defaults[k]; $("v-"+k).textContent=defaults[k]; }
});
};
function collectParams(){
return {
cut_bang:$("sw-cut_bang").classList.contains("on"),
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)],
};
}
// 上传
$("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="执行中(粗推理→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_debug",{
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");}
loadStatus();
})();
</script>
</body>
</html>