包含: - 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密钥已脱敏为环境变量,原文件备份在本地
243 lines
16 KiB
HTML
243 lines
16 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: 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: 280px; 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-tool { background: #0f3460; color: #eee; }
|
|
.btn-tool:hover { background: #1a4a7a; }
|
|
.btn-tool.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
|
.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-danger { background: #c0392b; color: #fff; font-size: 12px; }
|
|
.btn-danger:hover { background: #e74c3c; }
|
|
.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; }
|
|
#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; }
|
|
.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; }
|
|
#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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🟢 区域生发(换发型工作流)</h1>
|
|
<p>选发型 → 上传人像 → 在缺发区涂抹遮罩 → 生成(走 LoRA + 换发型链路)</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="tool-group">
|
|
<button class="btn btn-tool active" id="btn-brush">🖌️ 画笔</button>
|
|
<button class="btn btn-tool" id="btn-eraser">🧽 橡皮</button>
|
|
</div>
|
|
<div class="tool-group">
|
|
<label>画笔粗细 <span style="float:right;color:#4ecca3" id="size-val">25px</span></label>
|
|
<input type="range" id="brush-size" min="5" max="60" value="25" style="width:100%">
|
|
</div>
|
|
<button class="btn btn-danger" id="btn-clear">🗑️ 清空遮罩</button>
|
|
<h3 style="margin-top:20px">③ 生成</h3>
|
|
<div class="tool-group">
|
|
<label><input type="checkbox" id="is-hr" checked> 高清模式 (较慢)</label>
|
|
</div>
|
|
<button class="btn btn-primary" id="btn-generate" disabled>✨ 生成生发</button>
|
|
<div class="hint">
|
|
<b>使用说明</b><br>
|
|
1. 左侧选择一个发型<br>
|
|
2. 上传一张人头像照片<br>
|
|
3. 用画笔在头发稀少/发际线区域涂抹(红色=生发区)<br>
|
|
4. 点"生成",等待30-90秒<br>
|
|
工作流:换发型粗推理 + LoRA + mask并集(原头发∪新发型∪手绘)
|
|
</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">
|
|
<div id="canvas-container" style="display:none">
|
|
<canvas id="img-canvas"></canvas>
|
|
<canvas id="mask-canvas"></canvas>
|
|
</div>
|
|
</div>
|
|
<div id="status"><span class="spinner"></span><span id="status-text">正在生成,请稍候...</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" alt="原图"></div>
|
|
<div class="result-card"><h4>生发结果</h4><img id="result-new" alt="结果"><a class="download-btn" id="download-link" download="hairgrow_result.jpg">⬇️ 下载结果</a></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
const $=id=>document.getElementById(id);
|
|
const uploadZone=$("upload-zone"),fileInput=$("file-input"),canvasContainer=$("canvas-container");
|
|
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;
|
|
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,60).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>60){const m=document.createElement("div");m.className="loading-hairstyles";m.style.gridColumn="1/-1";m.textContent="还有 "+(f.length-60)+" 个,请搜索";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;updateGenerateBtn();
|
|
}
|
|
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();
|
|
|
|
$("brush-size").addEventListener("input",e=>{brushSize=+e.target.value;$("size-val").textContent=brushSize+"px";});
|
|
$("btn-brush").addEventListener("click",()=>{mode="brush";$("btn-brush").classList.add("active");$("btn-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");});
|
|
$("btn-eraser").addEventListener("click",()=>{mode="eraser";$("btn-eraser").classList.add("active");$("btn-brush").classList.remove("active");maskCanvas.classList.add("eraser");});
|
|
$("btn-clear").addEventListener("click",()=>{maskCtx.globalCompositeOperation="source-over";maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);});
|
|
|
|
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=>{
|
|
const im=new Image();
|
|
im.onload=()=>{
|
|
let w=im.width,h=im.height;const MAX=1024;
|
|
if(w>MAX||h>MAX){const r=Math.min(MAX/w,MAX/h);w=Math.round(w*r);h=Math.round(h*r);}
|
|
w=w-(w%8);h=h-(h%8);
|
|
imgCanvas.width=maskCanvas.width=w;imgCanvas.height=maskCanvas.height=h;
|
|
imgCtx.drawImage(im,0,0,w,h);imgB64Orig=imgCanvas.toDataURL("image/jpeg",0.95);
|
|
maskCtx.clearRect(0,0,w,h);
|
|
uploadZone.style.display="none";canvasContainer.style.display="inline-block";
|
|
updateGenerateBtn();$("result-section").classList.remove("show");$("error-msg").style.display="none";
|
|
};im.src=e.target.result;
|
|
};rd.readAsDataURL(file);
|
|
}
|
|
function updateGenerateBtn(){$("btn-generate").disabled=!(selectedHairId&&imgB64Orig);}
|
|
|
|
function getPos(e){const r=maskCanvas.getBoundingClientRect();const sx=maskCanvas.width/r.width,sy=maskCanvas.height/r.height;const p=e.touches?e.touches[0]:e;return{x:(p.clientX-r.left)*sx,y:(p.clientY-r.top)*sy};}
|
|
function startDraw(e){e.preventDefault();drawing=true;const p=getPos(e);lastX=p.x;lastY=p.y;drawDot(p.x,p.y);}
|
|
function draw(e){if(!drawing)return;e.preventDefault();const p=getPos(e);drawLine(lastX,lastY,p.x,p.y);lastX=p.x;lastY=p.y;}
|
|
function endDraw(){drawing=false;}
|
|
function drawDot(x,y){if(mode==="brush"){maskCtx.globalCompositeOperation="source-over";maskCtx.fillStyle="rgba(255,50,50,0.45)";}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,50,50,0.45)";}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",draw);
|
|
maskCanvas.addEventListener("mouseup",endDraw);maskCanvas.addEventListener("mouseleave",endDraw);
|
|
maskCanvas.addEventListener("touchstart",startDraw,{passive:false});maskCanvas.addEventListener("touchmove",draw,{passive:false});
|
|
maskCanvas.addEventListener("touchend",endDraw);
|
|
|
|
$("btn-generate").addEventListener("click",async()=>{
|
|
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 isHr=$("is-hr").checked;
|
|
const tmp=document.createElement("canvas");tmp.width=maskCanvas.width;tmp.height=maskCanvas.height;
|
|
const tmpCtx=tmp.getContext("2d");
|
|
const md2=maskCtx.getImageData(0,0,maskCanvas.width,maskCanvas.height);
|
|
const out=tmpCtx.createImageData(tmp.width,tmp.height);
|
|
for(let i=0;i<md2.data.length;i+=4){const v=md2.data[i+3]>30?255:0;out.data[i]=v;out.data[i+1]=v;out.data[i+2]=v;out.data[i+3]=255;}
|
|
tmpCtx.putImageData(out,0,0);
|
|
const maskB64=tmp.toDataURL("image/png");
|
|
$("btn-generate").disabled=true;$("status").style.display="block";
|
|
$("status-text").textContent="正在生成(换发型工作流+LoRA),约30-90秒...";
|
|
$("error-msg").style.display="none";$("result-section").classList.remove("show");
|
|
try{
|
|
const resp=await fetch("/api/grow",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({img:imgB64Orig,mask:maskB64,hair_id:selectedHairId,is_hr:String(isHr)})});
|
|
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");$("result-section").scrollIntoView({behavior:"smooth"});
|
|
maskCtx.clearRect(0,0,maskCanvas.width,maskCanvas.height);
|
|
}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";}
|
|
updateGenerateBtn();
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|