Files

306 lines
13 KiB
Python
Raw Permalink 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.
# -*- 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) + 修改图*alphaalpha 由 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()