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
This commit is contained in:
xsl
2026-07-08 20:38:31 +08:00
parent aa38085867
commit 215663177a
10 changed files with 751 additions and 229 deletions
+78 -7
View File
@@ -87,6 +87,12 @@ def manual_page():
return send_from_directory(os.path.join(BASE_DIR, "static"), "manual.html")
@app.route("/inpaint")
def inpaint_page():
"""mask区域重绘测试页(纯发丝增强/自然重绘)"""
return send_from_directory(os.path.join(BASE_DIR, "static"), "inpaint.html")
@app.route("/api/swap", methods=["POST"])
def api_swap_proxy():
"""代理换发型请求到 8801(避免前端跨域问题)"""
@@ -340,6 +346,7 @@ def api_swap_manual():
blend_dilate=(int(bd[0]), int(bd[1])),
seamless_dilate=(int(sd[0]), int(sd[1])),
feather_px=int(g("feather_px", 0)),
feather_alpha=float(g("feather_alpha", 1.0)),
enhance=bool(g("enhance", False)),
enhance_denoising=float(g("enhance_denoising", 0.35)),
)
@@ -402,14 +409,25 @@ def preview_img(hair_id):
@app.route("/train_src/<hair_id>")
def train_src_img(hair_id):
"""返回发型的训练原图hair_type_images/<hair_id>.jpg/.png
用于测试页展示发型真实样子,而非套在标准脸上的效果图
"""返回发型的训练原图,用于测试页展示发型真实样子
依次查找:hair_type_images/ → images/ → train_material/<id>/images 输入 → 预览图(兜底)
"""
src_dir = "/home/xsl/change_hair/hair_type_images"
for ext in (".jpg", ".jpeg", ".png"):
path = os.path.join(src_dir, hair_id + ext)
if os.path.exists(path):
return send_from_directory(src_dir, hair_id + ext)
# 候选源目录(按优先级)
candidate_dirs = [
"/home/xsl/change_hair/hair_type_images",
"/home/xsl/change_hair/images",
"/home/xsl/change_hair/data/batch_train_inputs/" + hair_id,
]
for src_dir in candidate_dirs:
for ext in (".jpg", ".jpeg", ".png"):
path = os.path.join(src_dir, hair_id + ext)
if os.path.exists(path):
return send_from_directory(src_dir, hair_id + ext)
# 兜底:返回套脸预览图(previews/<hair_id>.jpg
preview_dir = os.path.join(BASE_DIR, "static", "previews")
preview_path = os.path.join(preview_dir, hair_id + ".jpg")
if os.path.exists(preview_path):
return send_from_directory(preview_dir, hair_id + ".jpg")
return ("", 404)
@@ -507,6 +525,59 @@ def api_grow():
return jsonify({"state": -1, "msg": f"生发失败: {e}"}), 500
@app.route("/api/inpaint", methods=["POST"])
def api_inpaint():
"""mask区域重绘接口(严格只在mask区做SD inpainting)。
入参 JSON:
img: 原图 base64
mask: 手绘mask base64(白色=重绘区)
prompt: 提示词(空则用模式默认值)
denoising_strength: 重绘强度 0~1(默认0.5
mode: "enhance""pure_inpaint"(默认enhance
返回: {state, result, mask_used, info}
"""
try:
d = request.json
img_b64 = d.get("img", "")
mask_b64 = d.get("mask", "")
if not img_b64 or not mask_b64:
return jsonify({"state": -1, "msg": "img 和 mask 不能为空(请先手绘重绘区域)"}), 400
img = _b64_to_ndarray(img_b64, color=True)
mask = _b64_to_ndarray(mask_b64, color=False)
if img is None or mask is None:
return jsonify({"state": -1, "msg": "img 或 mask 解析失败"}), 400
prompt = d.get("prompt", "")
denoising = float(d.get("denoising_strength", 0.5))
mode = d.get("mode", "enhance")
if mode not in ("enhance", "pure_inpaint"):
mode = "enhance"
print(f"[inpaint] mode={mode}, denoising={denoising}, prompt={prompt[:40]}, img={img.shape}")
import sys
sys.path.insert(0, "/home/xsl/change_hair/project/hair_service_sd")
from inpaint_mask import inpaint_mask as do_inpaint
result, mask_used, info = do_inpaint(
img, mask, prompt=prompt,
denoising_strength=denoising, mode=mode
)
_, buf = cv2.imencode(".jpg", result, [cv2.IMWRITE_JPEG_QUALITY, 95])
result_b64 = base64.b64encode(buf).decode("utf-8")
_, mbuf = cv2.imencode(".png", mask_used)
mask_used_b64 = "data:image/png;base64," + base64.b64encode(mbuf).decode("utf-8")
return jsonify({"state": 0, "result": result_b64,
"mask_used": mask_used_b64, "info": info})
except Exception as e:
print(f"[inpaint] 失败: {e}")
traceback.print_exc()
return jsonify({"state": -1, "msg": f"mask重绘失败: {e}"}), 500
if __name__ == "__main__":
print(f"生发服务启动,端口 {PORT}")
print(f"测试页面: http://0.0.0.0:{PORT}")
+323
View File
@@ -0,0 +1,323 @@
<!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>
+13 -11
View File
@@ -159,9 +159,13 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
</div>
</div>
<div class="field">
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="严格mask贴回的边缘羽化像素。0=硬边缘,>0=高斯模糊边缘平滑过渡(消除贴回接缝)"></span></div>
<div class="field-label">feather_px <span class="val" id="v-fp">0</span><span class="tip" title="羽化范围(像素),边缘带宽度。0=无羽化,越大羽化范围越宽"></span></div>
<input type="range" id="fp" min="0" max="60" step="1" value="0" oninput="syncInt('fp')">
</div>
<div class="field">
<div class="field-label">feather_alpha <span class="val" id="v-fa">1.00</span><span class="tip" title="羽化强度(0~1)。0=硬切不羽化,1=最大羽化。控制边缘过渡柔和程度"></span></div>
<input type="range" id="fa" min="0" max="1.0" step="0.05" value="1.0" oninput="syncFloat('fa')">
</div>
<div class="readonly-info">
<b style="color:#888">webui端固定:</b><br>
<code>cfg=7</code> <code>steps=20</code> <code>sampler=DPM++ 2M Karras</code>
@@ -182,7 +186,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
<!-- 中间发型选择 -->
<div class="mid-panel">
<h3>③ 选发型</h3>
<h3>③ 选发型 <span id="count-info" style="font-size:10px;color:#888;font-weight:normal"></span></h3>
<div class="hairstyle-grid" id="hairstyle-grid"><div style="color:#666;font-size:11px">加载中...</div></div>
<div style="font-size:11px;color:#4ecca3;margin-top:8px;text-align:center" id="sel-info">未选择</div>
<button class="btn-tool" style="margin-top:8px;width:100%" onclick="loadStatus()">↻ 刷新</button>
@@ -225,14 +229,6 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
<script>
(function(){
const $=id=>document.getElementById(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:"长 -花瓣"}
];
let readySet=new Set(), selectedHair=null, imgB64Orig=null;
// 画板状态
@@ -240,6 +236,11 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
const imgCtx=imgCanvas.getContext("2d"),maskCtx=maskCanvas.getContext("2d",{willReadFrequently:true});
let mode="brush",brushSize=25,drawing=false,lastX=0,lastY=0;
// 本次训练的发型(昨天6个 + 今天hair_flow
const HAIRSTYLES = [
{name:"hair_flow"},{name:"圆-心形"},{name:"圆-椭圆"},{name:"圆-波浪"},
{name:"圆-直线"},{name:"圆-花瓣"},{name:"心形-心形"},{name:"心形-椭圆"}
];
async function loadStatus(){
try{
const r=await fetch("/api/hairstyles");const d=await r.json();
@@ -254,7 +255,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
const ready=readySet.has(h.name);
const it=document.createElement("div");
it.className="hairstyle-item"+(ready?" ready":" pending")+(h.name===selectedHair?" selected":"");
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>训练中</text></svg>'"><div class="name">${h.name}</div>`;
it.innerHTML=`<img loading="lazy" src="/train_src/${encodeURIComponent(h.name)}" onerror="this.style.objectFit='contain';this.src='data:image/svg+xml;utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22100%22 height=%22100%22><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23666%22 font-size=%2210%22>无图</text></svg>'"><div class="name">${h.name}</div>`;
if(ready) it.onclick=()=>{
selectedHair=h.name;
$("sel-info").textContent="已选: "+h.name;
@@ -366,6 +367,7 @@ body { font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; ba
is_hr:$("sw-is_hr").classList.contains("on"),
dilate_kernel:[parseInt($("dk0").value),parseInt($("dk1").value)],
feather_px:parseInt($("fp").value),
feather_alpha:parseFloat($("fa").value),
denoising_strength:parseFloat($("den").value),
enhance:$("sw-enhance").classList.contains("on"),
enhance_denoising:parseFloat($("ed").value),