包含: - 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密钥已脱敏为环境变量,原文件备份在本地
299 lines
17 KiB
HTML
299 lines
17 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; }
|
|
.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: 320px; 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; }
|
|
.face-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
|
.face-tab { padding: 5px 12px; background: #0f3460; border: none; border-radius: 4px; color: #aaa; cursor: pointer; font-size: 12px; }
|
|
.face-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
|
.status-row { font-size: 12px; color: #888; margin-bottom: 10px; padding: 6px 10px; background: #0d1b3e; border-radius: 4px; }
|
|
.status-row b { color: #4ecca3; }
|
|
.hairstyle-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; max-height: 500px; overflow-y: auto; }
|
|
.hairstyle-item { position: relative; cursor: pointer; border-radius: 6px; 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: 2px; right: 4px; color: #4ecca3; font-weight: bold; text-shadow: 0 0 3px #000; }
|
|
.hairstyle-item .name { position: absolute; bottom: 0; left: 0; right: 0; background: linear-gradient(transparent, rgba(0,0,0,.85)); color: #fff; font-size: 11px; padding: 12px 4px 3px; text-align: center; }
|
|
.hairstyle-item .badge { position: absolute; top: 3px; left: 3px; background: #4ecca3; color: #16213e; font-size: 9px; padding: 1px 5px; border-radius: 3px; font-weight: 600; }
|
|
.hairstyle-item .badge.training { background: #e9b949; }
|
|
.hairstyle-item .badge.pending { background: #555; color: #aaa; }
|
|
.hairstyle-item.pending { opacity: 0.45; cursor: not-allowed; }
|
|
.selected-hairstyle-info { font-size: 12px; color: #4ecca3; margin-top: 10px; text-align: center; padding: 6px; background: #0d1b3e; border-radius: 4px; }
|
|
.tool-group { margin-bottom: 16px; }
|
|
.tool-group label { display: block; font-size: 13px; color: #aaa; margin-bottom: 6px; cursor: pointer; }
|
|
.tool-group label input { margin-right: 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; }
|
|
.btn-ghost { background: #0f3460; color: #eee; }
|
|
.btn-ghost:hover { background: #1a4a80; }
|
|
.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: 320px; 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>💇 新发型效果测试(30款 · 按脸型适配)</h1>
|
|
<p>选择发型 → 上传人像 → 生成对比。绿色徽章=可测试,黄色=训练中,灰色=待训练</p>
|
|
</div>
|
|
<div class="container">
|
|
<div class="panel">
|
|
<h3>① 选择发型</h3>
|
|
<div class="status-row" id="status-row">加载中...</div>
|
|
<div class="face-tabs" id="face-tabs"></div>
|
|
<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>
|
|
<button class="btn btn-ghost" id="btn-refresh">↻ 刷新训练状态</button>
|
|
<div class="hint">
|
|
<b>说明</b><br>
|
|
• 只能选择「可测试」状态的发型<br>
|
|
• 换发型约需 30-60 秒<br>
|
|
• 会展示换发型的中间步骤便于评估效果
|
|
</div>
|
|
<div class="link-row"><a href="/swap">→ 去全部发型测试页</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);
|
|
|
|
// 30 个新发型 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 uploadZone=$("upload-zone"),fileInput=$("file-input"),previewImg=$("preview-img");
|
|
let selectedHairId=null, readySet=new Set(), imgB64Orig=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){
|
|
$("status-row").innerHTML='<span style="color:#e74c3c">加载可用发型失败: '+e.message+'</span>';
|
|
}
|
|
renderStatus();
|
|
renderTabs();
|
|
renderGrid("全部");
|
|
}
|
|
function renderStatus(){
|
|
const ready=HAIRSTYLES.filter(h=>readySet.has(h.name)).length;
|
|
$("status-row").innerHTML='共 30 款发型|<b style="color:#4ecca3">可测试 '+ready+'</b>|训练中/待训练 '+(30-ready);
|
|
}
|
|
function renderTabs(){
|
|
const faces=["全部",...new Set(HAIRSTYLES.map(h=>h.face))];
|
|
$("face-tabs").innerHTML=faces.map((f,i)=>
|
|
`<button class="face-tab${i===0?' active':''}" data-face="${f}">${f}</button>`).join('');
|
|
$("face-tabs").querySelectorAll(".face-tab").forEach(t=>{
|
|
t.onclick=()=>{
|
|
$("face-tabs").querySelectorAll(".face-tab").forEach(x=>x.classList.remove("active"));
|
|
t.classList.add("active");
|
|
renderGrid(t.dataset.face);
|
|
};
|
|
});
|
|
}
|
|
function renderGrid(faceFilter){
|
|
let list=HAIRSTYLES;
|
|
if(faceFilter!=="全部") list=list.filter(h=>h.face===faceFilter);
|
|
const g=$("hairstyle-grid");
|
|
if(list.length===0){ g.innerHTML='<div class="loading-hairstyles">无发型</div>'; return; }
|
|
g.innerHTML="";
|
|
list.forEach(h=>{
|
|
const ready=readySet.has(h.name);
|
|
const it=document.createElement("div");
|
|
it.className="hairstyle-item"+(ready?" ready":" pending");
|
|
if(h.name===selectedHairId) it.classList.add("selected");
|
|
const badge=ready
|
|
? '<span class="badge">可测试</span>'
|
|
: '<span class="badge training">训练中</span>';
|
|
it.innerHTML=badge+
|
|
`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.background='#0d1b3e';this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22120%22 height=%22120%22><text x=%2260%22 y=%2265%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2212%22>训练中</text></svg>'">`+
|
|
`<div class="name">${h.name}</div>`;
|
|
if(ready){
|
|
it.onclick=()=>{
|
|
selectedHairId=h.name;
|
|
$("hairstyle-grid").querySelectorAll(".hairstyle-item").forEach(el=>el.classList.remove("selected"));
|
|
it.classList.add("selected");
|
|
$("selected-info").textContent="已选: "+h.name;
|
|
updateBtn();
|
|
};
|
|
}
|
|
g.appendChild(it);
|
|
});
|
|
}
|
|
|
|
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&&readySet.has(selectedHairId)); }
|
|
|
|
function showLightbox(src){ $("lightbox-img").src=src; $("lightbox").classList.add("show"); }
|
|
|
|
$("btn-refresh").onclick=()=>{ loadStatus(); };
|
|
|
|
$("btn-generate").addEventListener("click",async()=>{
|
|
if(!selectedHairId||!imgB64Orig) return;
|
|
const isHr=$("is-hr").checked;
|
|
$("btn-generate").disabled=true;$("status").style.display="block";
|
|
$("status-text").textContent="正在换发型(含过程可视化),约30-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();
|
|
});
|
|
|
|
loadStatus();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|