# -*- coding: utf-8 -*- """ merge_fixed.py — 原图 + AI修改图 无缝融合工具 ============================================ 背景:ComfyUI inpainting 移除合影中的其他人物时,即使指定"非修改区域不得像素漂移", 全图清晰度仍不可避免损失。本工具把: - 原图的"未修改区域"(清晰度完好) - 修改图的"修改区域"(人物被干净移除) 自动合并,过渡自然无痕迹。 用法: python merge_fixed.py <原图目录> <修改图目录> <输出目录> [--feather 25] [--threshold 20] 匹配规则: - 原图 IMG_0463.jpg(任意后缀 jpg/png/webp/jpeg) - 修改图 IMG_0463*.png(以原图文件名开头、.png 结尾、中间任意字符) - 一张原图可有多张修改图,每张都独立合并 - 输出:{原图stem}_{修改图中间部分}.png 算法: 1. 修改图 resize 到原图尺寸 2. Lab 色彩空间差异图 → 高斯模糊去噪 → 阈值 → 闭运算+膨胀 = 修改区 mask 3. 羽化混合:result = 原图*(1-alpha) + 修改图*alpha(alpha 由 mask 高斯模糊得到) """ import argparse import os import sys from pathlib import Path import cv2 import numpy as np sys.stdout.reconfigure(encoding="utf-8", errors="replace") # 复用 face_checker 的人脸检测(目标人物面部保护) sys.path.insert(0, str(Path(__file__).resolve().parent)) import face_checker as fc IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"} _fc_det = None def get_face_detector(): global _fc_det if _fc_det is None: _fc_det = fc.FaceDetector() return _fc_det def protect_faces(alpha, mod_rgb, expand=1.35, margin=40): """ 面部强制保护(保险):检测修改图中残留的人脸(=目标人物,被移除者已被 inpaint 掉), 把这些人脸区域的 alpha 置 0(100% 用原图像素,零改变)。 通用规则(有限修改→原图)已覆盖大部分,此项兜底确保面部绝对不变。 """ det = get_face_detector() faces = fc._detect_faces_fast(mod_rgb, det) if not faces: return alpha for f in faces: x, y, w, h = f[0], f[1], f[2], f[3] cw, ch = w * expand, h * expand x0 = max(0, int(x - (cw - w) / 2) - margin) y0 = max(0, int(y - (ch - h) / 2) - margin) x1 = min(alpha.shape[1], int(x + w + (cw - w) / 2) + margin) y1 = min(alpha.shape[0], int(y + h + (ch - h) / 2) + margin) alpha[y0:y1, x0:x1] = 0.0 return alpha def build_alpha_from_mask(mask_rgb, feather=20): """ 从 ComfyUI inpaint 蒙版生成 alpha:白色区域 = 被修改区(100% 修改图),黑色 = 原图。 蒙版是最准确的"修改区"标记(用户在 ComfyUI 画的就是要移除的人物), 比 diff 检测可靠 100 倍——紧贴人物的残影完美解决。 feather: 蒙版边缘羽化像素 """ gray = cv2.cvtColor(mask_rgb, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0 # 蒙版白色→1(修改图),黑色→0(原图) alpha = gray if feather > 0: k = feather * 2 + 1 alpha = cv2.GaussianBlur(alpha, (k, k), 0) return alpha def find_mask(orig_path, mask_dir): """按文件名匹配蒙版:{stem}*mask*.png 或 {stem}*.png(取 mask 关键字优先)""" stem = Path(orig_path).stem cands = sorted(p for p in mask_dir.iterdir() if p.suffix.lower() == ".png" and p.name.startswith(stem)) # 优先带 mask 关键字的 for p in cands: if "mask" in p.name.lower(): return p return cands[0] if cands else None def build_alpha(orig_rgb, mod_rgb, blur_kernel=15, threshold=4.0, min_area_ratio=0.005, softness=1.5, dilate=60, body_extend=2.2): """ 融合权重 alpha(0=原图像素, 1=修改图像素)——双图人脸差集定位被移除人物: 1. 原图人脸 - 修改图人脸 = 被移除人物(合影中被清掉的人) 2. 被移除人物区域(脸框 + 向下 body_extend 倍覆盖身体)= 修改区 3. 目标人物(修改图残留的最大人脸)紧贴框 = 保留区(强制原图) 4. diff 高值连通块(排除保留区)兜底其他修改 5. 膨胀覆盖边缘残影 紧贴合影场景:被移除人物与目标紧贴时,靠"人脸差集"精确定位,不靠 diff 幅度。 """ det = get_face_detector() orig_faces = fc._detect_faces_fast(orig_rgb, det) mod_faces = fc._detect_faces_fast(mod_rgb, det) h, w = orig_rgb.shape[:2] def matched(of, mfaces): ox, oy = of[0] + of[2] / 2, of[1] + of[3] / 2 return any(abs(ox - (mf[0] + mf[2] / 2)) < of[2] * 0.8 and abs(oy - (mf[1] + mf[3] / 2)) < of[3] * 0.8 for mf in mfaces) removed_faces = [f for f in orig_faces if not matched(f, mod_faces)] # 过滤误检小脸(背景物体/花纹被当脸):只保留足够大的人脸 # 真实人物脸 ≥ 图面积 0.05% 或短边 ≥ 40px min_face_area = max(1, int(h * w * 0.0005)) removed_faces = [f for f in removed_faces if f[2] * f[3] >= min_face_area and min(f[2], f[3]) >= 40] # 被移除人物区(脸 + 身体向下扩展) mod_mask = np.zeros((h, w), np.uint8) for f in removed_faces: x, y, fw, fh = [int(v) for v in f[:4]] x0 = max(0, int(x - fw * 0.4)); x1 = min(w, int(x + fw * 1.4)) y0 = max(0, int(y - fh * 0.3)); y1 = min(h, int(y + fh * body_extend)) mod_mask[y0:y1, x0:x1] = 1 # 保留区:目标人物最大脸,紧贴框(左右不扩,防侵入紧贴的被移除人物) keep_mask = np.zeros((h, w), np.uint8) if mod_faces: big = max(mod_faces, key=lambda f: f[2] * f[3]) x, y, fw, fh = [int(v) for v in big[:4]] x0, x1 = max(0, x), min(w, x + fw) y0, y1 = max(0, int(y - fh * 0.35)), min(h, int(y + fh * 1.2)) keep_mask[y0:y1, x0:x1] = 1 # diff 兜底连通块(排除保留区)。无被移除人物时用高阈值(保守,防降质误判) o_lab = cv2.cvtColor(orig_rgb, cv2.COLOR_RGB2LAB).astype(np.float32) m_lab = cv2.cvtColor(mod_rgb, cv2.COLOR_RGB2LAB).astype(np.float32) diff_s = cv2.GaussianBlur(np.abs(o_lab - m_lab).mean(axis=2), (blur_kernel, blur_kernel), 0) eff_threshold = threshold if removed_faces else max(threshold, 15.0) base = ((diff_s > eff_threshold) & ~keep_mask.astype(bool)).astype(np.uint8) num, labels, stats, _ = cv2.connectedComponentsWithStats(base, 8) min_area = max(1, int(min_area_ratio * h * w)) filtered = np.zeros_like(base) for i in range(1, num): if stats[i, cv2.CC_STAT_AREA] >= min_area: filtered[labels == i] = 1 core = np.maximum(filtered, mod_mask) if dilate > 0: core = cv2.dilate(core, np.ones((dilate, dilate), np.uint8)) alpha = core.astype(np.float32) if softness > 0: k = int(softness * 6) * 2 + 1 alpha = cv2.GaussianBlur(alpha, (k, k), 0) return alpha """ 融合权重 alpha(0=原图像素, 1=修改图像素): - 固定阈值判定明显修改区(人物移除 diff 高,有限降质 diff 低,区分度 ~20x) - diff > 阈值 → alpha≈1(100% 修改图,修改区内部零残影) - diff < 阈值 → alpha≈0(100% 原图,目标人物/有限修改区零改变) - 修改区膨胀 dilate 像素(默认 75):覆盖人物边缘 10-40px 的浅残影带 (残影带 diff 接近未修改区,无法用阈值检测,必须靠膨胀) - 陡 sigmoid 边缘过渡防硬边 """ o = cv2.cvtColor(orig_rgb, cv2.COLOR_RGB2LAB).astype(np.float32) m = cv2.cvtColor(mod_rgb, cv2.COLOR_RGB2LAB).astype(np.float32) diff = np.abs(o - m).mean(axis=2) diff_s = cv2.GaussianBlur(diff, (blur_kernel, blur_kernel), 0) alpha = 1.0 / (1.0 + np.exp(-(diff_s - threshold) / softness)) # 修改区膨胀:覆盖人物边缘残影带 if dilate > 0: kernel = np.ones((dilate, dilate), np.uint8) core = (alpha > 0.5).astype(np.uint8) core = cv2.dilate(core, kernel) alpha = np.maximum(alpha, core.astype(np.float32)) return alpha def seamless_merge(orig_rgb, mod_rgb, alpha): """ 连续 alpha 混合:result = orig*(1-alpha) + mod*alpha。 alpha 已是平滑的浮点图(sigmoid 过渡),无需额外羽化。 """ a = alpha[..., None].astype(np.float32) result = orig_rgb.astype(np.float32) * (1.0 - a) + mod_rgb.astype(np.float32) * a return np.clip(result, 0, 255).astype(np.uint8) def find_modified(orig_path, mod_dir): """按文件名匹配修改图:{stem}*.png""" stem = Path(orig_path).stem return sorted(p for p in mod_dir.iterdir() if p.suffix.lower() == ".png" and p.name.startswith(stem)) def process(orig_dir, mod_dir, out_dir, alpha_threshold=8.0, softness=1.5, protect=True, mask_dir=None, feather=20, verbose=True): """ alpha_threshold: 无蒙版时的 diff 阈值(默认 8;误判时调) softness: 过渡带宽度 protect: 面部强制保护(无蒙版 fallback 时的保险) mask_dir: ComfyUI inpaint 蒙版目录(可选,有则优先用,蒙版白色=被移除区,最准确) feather: 蒙版边缘羽化 """ orig_dir, mod_dir, out_dir = Path(orig_dir), Path(mod_dir), Path(out_dir) if mask_dir: mask_dir = Path(mask_dir) out_dir.mkdir(parents=True, exist_ok=True) originals = sorted(p for p in orig_dir.iterdir() if p.suffix.lower() in IMG_EXTS and not p.name.startswith(".")) if not originals: print(f"[WARN] 原图目录没有图片: {orig_dir}") return total = 0 for op in originals: mods = find_modified(op, mod_dir) if not mods: if verbose: print(f"[跳过] {op.name}: 无匹配修改图") continue from PIL import Image orig_img = Image.open(op).convert("RGB") ow, oh = orig_img.size orig_rgb = np.array(orig_img) # 尝试匹配蒙版 mask_path = find_mask(op, mask_dir) if mask_dir else None for mp in mods: try: mod_img = Image.open(mp).convert("RGB") if mod_img.size != (ow, oh): mod_img = mod_img.resize((ow, oh), Image.LANCZOS) mod_rgb = np.array(mod_img) if mask_path and mask_path.exists(): # 蒙版模式:最准确 mask_img = Image.open(mask_path).convert("RGB") if mask_img.size != (ow, oh): mask_img = mask_img.resize((ow, oh), Image.LANCZOS) mask_rgb = np.array(mask_img) alpha = build_alpha_from_mask(mask_rgb, feather=feather) mode = f"蒙版模式({mask_path.name})" else: # 双图人脸差集模式(自动定位被移除人物 + 保留目标脸) alpha = build_alpha(orig_rgb, mod_rgb, threshold=alpha_threshold, softness=softness) mode = "差集模式" area_ratio = float((alpha > 0.5).mean()) if area_ratio < 0.0005: if verbose: print(f"[警告] {op.name} <- {mp.name}: 明显修改区仅 {area_ratio*100:.2f}%%,可能未检测到修改") mask_area_note = f"明显修改区 {area_ratio*100:.2f}% (偏小?)" else: mask_area_note = f"明显修改区 {area_ratio*100:.1f}%" result = seamless_merge(orig_rgb, mod_rgb, alpha) mid = mp.stem[len(Path(op).stem):] or "_mod" out_name = f"{Path(op).stem}{mid}.png" Image.fromarray(result).save(out_dir / out_name) total += 1 if verbose: print(f"[OK] {out_name} | {mode} | {mask_area_note}") except Exception as e: if verbose: print(f"[失败] {op.name} <- {mp.name}: {e}") print(f"\n完成:共输出 {total} 张融合图 -> {out_dir}") def main(): ap = argparse.ArgumentParser(description="原图 + AI修改图 无缝融合工具(支持 ComfyUI 蒙版)") ap.add_argument("orig_dir", help="原图目录") ap.add_argument("mod_dir", help="修改图目录(文件名以原图名开头、.png 结尾)") ap.add_argument("out_dir", help="输出目录") ap.add_argument("--mask-dir", default=None, help="ComfyUI inpaint 蒙版目录(可选,有则优先用;蒙版白色=被移除区,最准确解决紧贴残影)") ap.add_argument("--alpha-threshold", type=float, default=8.0, help="无蒙版时的 diff 阈值(默认 8)") ap.add_argument("--softness", type=float, default=1.5, help="过渡带宽度(默认 1.5)") ap.add_argument("--feather", type=int, default=20, help="蒙版边缘羽化(默认 20)") ap.add_argument("--no-protect-face", action="store_true", help="关闭面部强制保护(无蒙版时的保险)") args = ap.parse_args() process(args.orig_dir, args.mod_dir, args.out_dir, alpha_threshold=args.alpha_threshold, softness=args.softness, protect=not args.no_protect_face, mask_dir=args.mask_dir, feather=args.feather) if __name__ == "__main__": main()