Files
change_hair/hair_grow_service/static/inpaint.html
T
xsl 215663177a feat: 新增mask区域重绘功能 + 羽化贴回优化 + 发丝增强
新增功能:
- 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
2026-07-08 20:38:31 +08:00

324 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>