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:
@@ -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}")
|
||||
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user