新增功能: - 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
324 lines
17 KiB
HTML
324 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>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: 300px; flex-shrink: 0; background: #16213e; border-radius: 10px; padding: 16px; height: fit-content; }
|
||
.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; }
|
||
.field input[type="text"], .field textarea { width: 100%; padding: 6px 8px; background: #0d1b3e; border: 1px solid #0f3460; border-radius: 4px; color: #eee; font-size: 12px; font-family: monospace; }
|
||
.field textarea { min-height: 60px; resize: vertical; }
|
||
|
||
.mode-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
|
||
.mode-tab { flex: 1; padding: 8px; background: #0f3460; border: none; border-radius: 5px; color: #aaa; cursor: pointer; font-size: 12px; text-align: center; }
|
||
.mode-tab.active { background: #4ecca3; color: #16213e; font-weight: 600; }
|
||
|
||
.btn { display: block; width: 100%; padding: 12px; border: none; border-radius: 6px; font-size: 15px; cursor: pointer; transition: .15s; }
|
||
.btn-primary { background: #4ecca3; color: #16213e; font-weight: 600; margin-top: 8px; }
|
||
.btn-primary:hover { background: #6ee0bd; }
|
||
.btn-primary:disabled { background: #555; color: #999; 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-section { margin-top: 16px; background: #16213e; border-radius: 10px; padding: 16px; display: none; }
|
||
.result-section.show { display: block; }
|
||
.result-section h3 { font-size: 14px; color: #4ecca3; margin-bottom: 12px; }
|
||
.result-grid { 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; cursor: zoom-in; }
|
||
.info-box { margin-top: 12px; padding: 10px 12px; background: #0d1b3e; border-radius: 6px; font-size: 11px; color: #888; line-height: 1.7; }
|
||
#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; padding: 20px; }
|
||
#lightbox img { max-width: 95%; max-height: 95%; border-radius: 8px; }
|
||
#lightbox.show { display: flex; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<div>
|
||
<h1>🖌️ mask区域重绘(发丝自然化)</h1>
|
||
<p>上传图片 → 涂抹要重绘的区域 → 调提示词和强度 → 生成。严格只在涂抹区重绘,其余保留原图</p>
|
||
</div>
|
||
<div class="links">
|
||
<a href="/manual">→ 手绘mask换发型</a>
|
||
<a href="/debug">→ 换发型调试</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="mode-tabs">
|
||
<button class="mode-tab active" id="mode-enhance" onclick="setMode('enhance')">发丝增强<br><span style="font-size:10px;opacity:.7">enhance</span></button>
|
||
<button class="mode-tab" id="mode-pure" onclick="setMode('pure_inpaint')">自然重绘<br><span style="font-size:10px;opacity:.7">pure_inpaint</span></button>
|
||
</div>
|
||
|
||
<h3>③ 提示词</h3>
|
||
<div class="field">
|
||
<textarea id="prompt" placeholder="留空用模式默认提示词">high quality, detailed natural hair strands, realistic hair texture, sharp focus, photorealistic</textarea>
|
||
</div>
|
||
|
||
<h3>④ 重绘强度</h3>
|
||
<div class="field">
|
||
<div class="field-label">denoising_strength <span class="val" id="v-den">0.50</span><span style="color:#666;font-size:10px;margin-left:6px">越低越保留原图</span></div>
|
||
<input type="range" id="den" min="0.1" max="1.0" step="0.05" value="0.5" oninput="$('v-den').textContent=parseFloat(this.value).toFixed(2)">
|
||
</div>
|
||
|
||
<button class="btn btn-primary" id="btn-run" disabled>▶ 生成(mask区域重绘)</button>
|
||
<div class="hint" style="margin-top:10px">
|
||
<b>说明:</b><br>
|
||
• 严格只在涂抹区域重绘,mask外完全保留原图<br>
|
||
• enhance模式:发丝细节增强(默认0.35强度)<br>
|
||
• pure_inpaint模式:自然重绘(默认0.5强度)<br>
|
||
• 改提示词可控制重绘方向(如"detailed curly hair")
|
||
</div>
|
||
</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>支持 jpg/png,建议人脸清晰照</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">重绘中,约15-30秒...</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="r-orig"></div>
|
||
<div class="result-card"><h4>使用的mask</h4><img id="r-mask"></div>
|
||
<div class="result-card"><h4>重绘结果</h4><img id="r-new"></div>
|
||
</div>
|
||
<div class="info-box" id="info-box"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="lightbox" onclick="this.classList.remove('show')"><img id="lightbox-img"></div>
|
||
|
||
<script>
|
||
(function(){
|
||
const $=id=>document.getElementById(id);
|
||
let mode="enhance", imgB64Orig=null;
|
||
const imgCanvas=$("img-canvas"),maskCanvas=$("mask-canvas");
|
||
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
|
||
let brushSize=25,drawing=false,lastX=0,lastY=0;
|
||
|
||
// 模式切换
|
||
window.setMode=(m)=>{
|
||
mode=m;
|
||
$("mode-enhance").classList.toggle("active",m==="enhance");
|
||
$("mode-pure").classList.toggle("active",m==="pure_inpaint");
|
||
// 切换模式时更新默认提示词(如果当前是空的或等于另一模式的默认值)
|
||
const defaults={
|
||
enhance:"high quality, detailed natural hair strands, realistic hair texture, sharp focus, photorealistic",
|
||
pure_inpaint:"natural realistic hair, soft hair strands, photorealistic, high quality, detailed"
|
||
};
|
||
const cur=$("prompt").value.trim();
|
||
if(!cur || Object.values(defaults).includes(cur)){
|
||
$("prompt").value=defaults[m];
|
||
}
|
||
// 切换模式时调整默认denoising
|
||
if(m==="enhance" && parseFloat($("den").value)===0.5){ $("den").value=0.35; $("v-den").textContent="0.35"; }
|
||
if(m==="pure_inpaint" && parseFloat($("den").value)===0.35){ $("den").value=0.5; $("v-den").textContent="0.50"; }
|
||
};
|
||
|
||
// 上传
|
||
$("upload-zone").onclick=()=>$("file-input").click();
|
||
$("file-input").onchange=e=>{ if(e.target.files[0]) loadImage(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])loadImage(e.dataTransfer.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";
|
||
$("btn-run").disabled=false;
|
||
};
|
||
im.src=e.target.result;
|
||
};
|
||
rd.readAsDataURL(file);
|
||
}
|
||
|
||
// 画笔
|
||
let drawMode="brush";
|
||
$("tool-brush").onclick=()=>{drawMode="brush";$("tool-brush").classList.add("active");$("tool-eraser").classList.remove("active");maskCanvas.classList.remove("eraser");};
|
||
$("tool-eraser").onclick=()=>{drawMode="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(drawMode==="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(drawMode==="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);
|
||
|
||
// 提取mask为黑白二值PNG
|
||
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;
|
||
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(!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();
|
||
$("btn-run").disabled=true;
|
||
$("status").style.display="block";
|
||
$("status-text").textContent="mask区域重绘中,约15-30秒...";
|
||
$("error-msg").style.display="none";
|
||
$("result-section").classList.remove("show");
|
||
try{
|
||
const resp=await fetch("/api/inpaint",{
|
||
method:"POST",headers:{"Content-Type":"application/json"},
|
||
body:JSON.stringify({
|
||
img:imgB64Orig, mask:maskB64,
|
||
prompt:$("prompt").value.trim(),
|
||
denoising_strength:parseFloat($("den").value),
|
||
mode:mode
|
||
})
|
||
});
|
||
const data=await resp.json();
|
||
$("status").style.display="none";
|
||
if(data.state===0){
|
||
$("r-orig").src=imgB64Orig;
|
||
$("r-mask").src=data.mask_used;
|
||
$("r-new").src="data:image/jpeg;base64,"+data.result;
|
||
$("info-box").textContent=data.info;
|
||
// 绑定点击放大
|
||
["r-orig","r-mask","r-new"].forEach(id=>{
|
||
$(id).onclick=(e)=>{ $("lightbox-img").src=e.target.src; $("lightbox").classList.add("show"); };
|
||
});
|
||
$("result-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";
|
||
}
|
||
$("btn-run").disabled=false;
|
||
};
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|