初始化:换发型/换发色/训练发型服务

包含:
- 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密钥已脱敏为环境变量,原文件备份在本地
This commit is contained in:
xsl
2026-07-07 13:53:52 +08:00
commit 443cfa298f
312 changed files with 67065 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
<!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; }
.header { background: #16213e; padding: 18px 28px; border-bottom: 1px solid #0f3460; }
.header h1 { font-size: 20px; font-weight: 600; }
.header p { font-size: 13px; color: #888; margin-top: 4px; }
.container { display: flex; gap: 20px; padding: 20px; max-width: 1700px; margin: 0 auto; }
.panel { width: 280px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 18px; height: fit-content; max-height: 90vh; overflow-y: auto; }
.panel h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 1px; }
.panel::-webkit-scrollbar { width: 6px; }
.panel::-webkit-scrollbar-thumb { background: #0f3460; border-radius: 3px; }
.hairstyle-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
.hairstyle-tab { flex: 1; padding: 6px; text-align: center; background: #0f3460; border: none; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; }
.hairstyle-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
.search-box { width: 100%; padding: 6px 10px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; margin-bottom: 10px; }
.hairstyle-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; max-height: 420px; overflow-y: auto; }
.hairstyle-item { position: relative; cursor: pointer; border-radius: 4px; 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.selected { border-color: #4ecca3; }
.hairstyle-item.selected::after { content: '✓'; position: absolute; top: 2px; right: 4px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; }
.selected-hairstyle-info { font-size: 11px; color: #888; margin-top: 8px; text-align: center; word-break: break-all; }
.tool-group { margin-bottom: 16px; }
.tool-group label { display: block; font-size: 13px; color: #aaa; margin-bottom: 6px; }
.btn { display: block; width: 100%; padding: 9px; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; margin-bottom: 8px; transition: .15s; }
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; font-size: 15px; padding: 12px; }
.btn-primary:hover { background: #6ee0bd; }
.btn-primary:disabled { background: #555; color: #999; cursor: not-allowed; }
.hint { font-size: 12px; color: #666; line-height: 1.6; margin-top: 14px; padding: 10px; background: #0d1b3e; border-radius: 6px; }
.workspace { flex: 1; min-width: 0; }
.canvas-wrap { position: relative; background: #0d0d1a; border-radius: 10px; padding: 20px; text-align: center; min-height: 400px; display: flex; align-items: center; justify-content: center; }
.upload-zone { width: 100%; max-width: 500px; border: 2px dashed #0f3460; border-radius: 10px; padding: 50px 20px; text-align: center; cursor: pointer; transition: .2s; }
.upload-zone:hover, .upload-zone.dragover { border-color: #4ecca3; background: rgba(78,204,163,.05); }
.upload-zone svg { width: 48px; height: 48px; fill: #4ecca3; margin-bottom: 12px; }
.upload-zone p { color: #888; font-size: 14px; }
.upload-zone p.highlight { color: #4ecca3; margin-bottom: 4px; }
#preview-img { display: none; max-width: 100%; max-height: 500px; border-radius: 6px; }
.result-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
.result-section.show { display: block; }
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 14px; }
.result-grid { display: flex; gap: 16px; flex-wrap: wrap; }
.result-card { flex: 1; min-width: 250px; }
.result-card h4 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; }
.result-card img { width: 100%; border-radius: 6px; display: block; }
.download-btn { display: inline-block; margin-top: 8px; padding: 6px 14px; background: #0f3460; color: #eee; border-radius: 4px; font-size: 12px; text-decoration: none; }
/* 步骤画廊 */
.steps-section { margin-top: 20px; background: #16213e; border-radius: 10px; padding: 18px; display: none; }
.steps-section.show { display: block; }
.steps-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 6px; }
.steps-section .sub { font-size: 12px; color: #666; margin-bottom: 16px; }
.step-item { margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid #0f3460; }
.step-item:last-child { border-bottom: none; }
.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.5; }
.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; }
.step-img-card img:hover { border-color: #4ecca3; }
#status { text-align: center; padding: 30px; color: #4ecca3; font-size: 15px; display: none; }
.spinner { display: inline-block; width: 20px; height: 20px; 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; text-align: center; padding: 20px; display: none; font-size: 14px; }
.loading-hairstyles { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.link-row { margin-top: 12px; text-align: center; }
.link-row a { color: #4ecca3; font-size: 12px; text-decoration: none; }
/* 图片放大查看 */
#lightbox { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.9); z-index: 999; justify-content: center; align-items: center; cursor: zoom-out; }
#lightbox img { max-width: 90%; max-height: 90%; border-radius: 8px; }
#lightbox.show { display: flex; }
</style>
</head>
<body>
<div class="header">
<h1>💇 换发型测试(过程可视化)</h1>
<p>选发型 → 上传人像 → 生成(展示换发型每个步骤的中间产物)</p>
</div>
<div class="container">
<div class="panel">
<h3>① 选择发型</h3>
<div class="hairstyle-tabs">
<button class="hairstyle-tab active" data-gender="all">全部</button>
<button class="hairstyle-tab" data-gender="girl">女款</button>
<button class="hairstyle-tab" data-gender="boy">男款</button>
</div>
<input type="text" class="search-box" id="search" placeholder="搜索发型ID...">
<div class="hairstyle-grid" id="hairstyle-grid"><div class="loading-hairstyles">加载发型中...</div></div>
<div class="selected-hairstyle-info" id="selected-info">未选择发型</div>
<h3 style="margin-top:20px">② 上传人像</h3>
<div class="hint" style="margin-top:0">点击右侧上传区选择人头像照片</div>
<h3 style="margin-top:20px">③ 生成</h3>
<div class="tool-group">
<label><input type="checkbox" id="is-hr"> 高清模式 (较慢)</label>
<label><input type="checkbox" id="strict-mask"> 严格按mask贴回 (mask外不变)</label>
</div>
<button class="btn btn-primary" id="btn-generate" disabled>✨ 换发型</button>
<div class="hint">
<b>说明</b><br>
换发型后会展示6个步骤:粗推理→生成mask→warpAffine→SD推理→贴回原图
</div>
<div class="link-row"><a href="/test_new">→ 去新发型测试页(30款)</a></div>
<div class="link-row"><a href="/">→ 去生发测试页</a></div>
</div>
<div class="workspace">
<div class="canvas-wrap" id="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">正在换发型(含可视化),约60秒...</span></div>
<div id="error-msg"></div>
<div class="result-section" id="result-section">
<h3>对比结果</h3>
<div class="result-grid">
<div class="result-card"><h4>原图</h4><img id="result-orig"></div>
<div class="result-card"><h4>换发型结果</h4><img id="result-new"><a class="download-btn" id="download-link" download="swaphair_result.jpg">⬇️ 下载结果</a></div>
</div>
</div>
<div class="steps-section" id="steps-section">
<h3>🔍 换发型过程(逐步可视化)</h3>
<div class="sub">每个步骤用到的图片和说明,点击图片可放大查看</div>
<div id="steps-container"></div>
</div>
</div>
</div>
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
<script>
(function(){
const $=id=>document.getElementById(id);
const uploadZone=$("upload-zone"),fileInput=$("file-input"),previewImg=$("preview-img");
let selectedHairId=null,allHairstyles=[],imgB64Orig=null;
async function loadHairstyles(){
try{const r=await fetch("/api/hairstyles");const d=await r.json();
if(d.state!==0)throw new Error(d.msg);allHairstyles=d.data;renderHairstyles("all");
}catch(e){$("hairstyle-grid").innerHTML='<div class="loading-hairstyles">加载失败: '+e.message+'</div>';}
}
function renderHairstyles(gf){
const s=$("search").value.trim().toLowerCase();
const f=allHairstyles.filter(x=>{if(gf!=="all"&&x.gender!==gf)return false;if(s&&!x.hair_id.toLowerCase().includes(s))return false;return true;});
const g=$("hairstyle-grid");if(f.length===0){g.innerHTML='<div class="loading-hairstyles">无匹配发型</div>';return;}
g.innerHTML="";f.slice(0,90).forEach(x=>{
const it=document.createElement("div");it.className="hairstyle-item"+(x.hair_id===selectedHairId?" selected":"");it.dataset.hairId=x.hair_id;
const im=document.createElement("img");im.loading="lazy";im.src="/preview/"+x.hair_id;im.onerror=()=>{im.style.visibility="hidden";};
it.appendChild(im);it.onclick=()=>selectHairstyle(x.hair_id);g.appendChild(it);
});
if(f.length>90){const m=document.createElement("div");m.className="loading-hairstyles";m.style.gridColumn="1/-1";m.textContent="还有 "+(f.length-90)+" 个,请搜索";g.appendChild(m);}
}
function selectHairstyle(id){selectedHairId=id;document.querySelectorAll(".hairstyle-item").forEach(el=>el.classList.toggle("selected",el.dataset.hairId===id));$("selected-info").textContent="已选: "+id;updateBtn();}
document.querySelectorAll(".hairstyle-tab").forEach(t=>{t.onclick=()=>{document.querySelectorAll(".hairstyle-tab").forEach(x=>x.classList.remove("active"));t.classList.add("active");renderHairstyles(t.dataset.gender);};});
$("search").addEventListener("input",()=>{const a=document.querySelector(".hairstyle-tab.active");renderHairstyles(a?a.dataset.gender:"all");});
loadHairstyles();
uploadZone.addEventListener("click",()=>fileInput.click());
uploadZone.addEventListener("dragover",e=>{e.preventDefault();uploadZone.classList.add("dragover");});
uploadZone.addEventListener("dragleave",()=>uploadZone.classList.remove("dragover"));
uploadZone.addEventListener("drop",e=>{e.preventDefault();uploadZone.classList.remove("dragover");if(e.dataTransfer.files[0])loadImage(e.dataTransfer.files[0]);});
fileInput.addEventListener("change",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=>{imgB64Orig=e.target.result;previewImg.src=imgB64Orig;previewImg.style.display="block";uploadZone.style.display="none";updateBtn();$("result-section").classList.remove("show");$("steps-section").classList.remove("show");$("error-msg").style.display="none";};rd.readAsDataURL(file);}
function updateBtn(){$("btn-generate").disabled=!(selectedHairId&&imgB64Orig);}
function showLightbox(src){$("lightbox-img").src=src;$("lightbox").classList.add("show");}
$("btn-generate").addEventListener("click",async()=>{
const isHr=$("is-hr").checked;
$("btn-generate").disabled=true;$("status").style.display="block";
$("status-text").textContent="正在换发型(含过程可视化),约60秒...";
$("error-msg").style.display="none";$("result-section").classList.remove("show");$("steps-section").classList.remove("show");
try{
const resp=await fetch("/api/swap_viz",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({img:imgB64Orig,hair_id:selectedHairId,is_hr:String(isHr),strict_mask:$("strict-mask").checked})});
const data=await resp.json();$("status").style.display="none";
if(data.state===0){
const url="data:image/jpeg;base64,"+data.result;
$("result-orig").src=imgB64Orig;$("result-new").src=url;$("download-link").href=url;
$("result-section").classList.add("show");
// 渲染步骤画廊
if(data.steps&&data.steps.length){
const c=$("steps-container");c.innerHTML="";
data.steps.forEach(step=>{
const div=document.createElement("div");div.className="step-item";
let imgsHtml='<div class="step-images">';
step.images.forEach(im=>{imgsHtml+='<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>';});
imgsHtml+='</div>';
div.innerHTML='<div class="step-title">'+step.title+'</div><div class="step-desc">'+step.desc+'</div>'+imgsHtml;
c.appendChild(div);
});
// 点击放大
c.querySelectorAll("img").forEach(img=>img.onclick=()=>showLightbox(img.dataset.full));
$("steps-section").classList.add("show");
}
$("result-section").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();
});
})();
</script>
</body>
</html>