Spaces:
Running
fix: sharp output, source skin tone preservation, 4K upscaling pipeline
Browse files- swapper.py: sharpen ORIGINAL crop (not bilateral-blurred) β unsharp mask
strength 2.3x on raw crop; mild bilateral d=5/30 only removes compression
artefacts without killing texture detail
- skin_tone.py: match_skin_tone now accepts face_mask; LAB transfer confined
to face region via feathered alpha β hair/neck/background no longer shifted
- core/super_res.py (NEW): GFPGAN face restoration + RealESRGAN x4 upscale;
Lanczos fallback when models absent β always delivers 4K download
- web_app.py: complete pipeline β add match_skin_tone (source tone, face mask),
laplacian_blend + poisson_blend, 4K hi-res PNG saved for download, preview
JPEG at 92 quality; input cap raised 1024β2048 for more InsightFace detail
- quality_checker.py: _alignment_score handles None/empty arrays β returns
50.0 instead of propagating nan (was showing 0.0/100 in UI)
- full_pipeline.py: enable_super_res=True by default; pass face_mask to
match_skin_tone; Lanczos fallback on super_res failure; remove unused imports
- scripts/download_models.py: add RealESRGAN_x4plus.pth + GFPGANv1.4.pth
download entries so `python scripts/download_models.py` fetches all models
- core/quality_checker.py +11 -8
- core/skin_tone.py +34 -13
- core/super_res.py +111 -0
- core/swapper.py +16 -11
- pipeline/full_pipeline.py +15 -10
- scripts/download_models.py +11 -4
- web_app.py +41 -16
|
@@ -5,8 +5,8 @@ import numpy as np
|
|
| 5 |
def compute_quality_score(
|
| 6 |
swapped: np.ndarray,
|
| 7 |
target: np.ndarray,
|
| 8 |
-
src_landmarks: np.ndarray
|
| 9 |
-
tgt_landmarks: np.ndarray
|
| 10 |
) -> dict:
|
| 11 |
"""
|
| 12 |
Compute quality metrics for the swap result.
|
|
@@ -25,16 +25,19 @@ def compute_quality_score(
|
|
| 25 |
}
|
| 26 |
|
| 27 |
|
| 28 |
-
def _alignment_score(src_lm
|
| 29 |
"""Mean pixel deviation between landmark sets, converted to 0-100 score."""
|
| 30 |
if src_lm is None or tgt_lm is None:
|
| 31 |
return 50.0
|
| 32 |
n = min(len(src_lm), len(tgt_lm))
|
|
|
|
|
|
|
| 33 |
diff = np.linalg.norm(src_lm[:n] - tgt_lm[:n], axis=1)
|
| 34 |
-
mean_err = diff.mean()
|
| 35 |
-
|
|
|
|
| 36 |
score = max(0.0, 100.0 - mean_err * 10.0)
|
| 37 |
-
return round(
|
| 38 |
|
| 39 |
|
| 40 |
def _blend_quality(swapped: np.ndarray, target: np.ndarray) -> float:
|
|
@@ -50,7 +53,7 @@ def _blend_quality(swapped: np.ndarray, target: np.ndarray) -> float:
|
|
| 50 |
discontinuity = np.abs(edges).mean()
|
| 51 |
# Map: 0 -> 100, 30 -> 0
|
| 52 |
score = max(0.0, 100.0 - discontinuity * (100.0 / 30.0))
|
| 53 |
-
return round(
|
| 54 |
|
| 55 |
|
| 56 |
def _compute_delta_e(swapped: np.ndarray, target: np.ndarray) -> float:
|
|
@@ -94,4 +97,4 @@ def _naturalness_score(swapped: np.ndarray) -> float:
|
|
| 94 |
noise_penalty = min(noise / 1000.0, 20.0)
|
| 95 |
|
| 96 |
score = max(0.0, 100.0 - clip_penalty - sat_penalty - noise_penalty)
|
| 97 |
-
return round(
|
|
|
|
| 5 |
def compute_quality_score(
|
| 6 |
swapped: np.ndarray,
|
| 7 |
target: np.ndarray,
|
| 8 |
+
src_landmarks, # type: np.ndarray | None
|
| 9 |
+
tgt_landmarks, # type: np.ndarray | None
|
| 10 |
) -> dict:
|
| 11 |
"""
|
| 12 |
Compute quality metrics for the swap result.
|
|
|
|
| 25 |
}
|
| 26 |
|
| 27 |
|
| 28 |
+
def _alignment_score(src_lm, tgt_lm) -> float:
|
| 29 |
"""Mean pixel deviation between landmark sets, converted to 0-100 score."""
|
| 30 |
if src_lm is None or tgt_lm is None:
|
| 31 |
return 50.0
|
| 32 |
n = min(len(src_lm), len(tgt_lm))
|
| 33 |
+
if n == 0:
|
| 34 |
+
return 50.0
|
| 35 |
diff = np.linalg.norm(src_lm[:n] - tgt_lm[:n], axis=1)
|
| 36 |
+
mean_err = float(diff.mean())
|
| 37 |
+
if not np.isfinite(mean_err):
|
| 38 |
+
return 50.0
|
| 39 |
score = max(0.0, 100.0 - mean_err * 10.0)
|
| 40 |
+
return round(score, 1)
|
| 41 |
|
| 42 |
|
| 43 |
def _blend_quality(swapped: np.ndarray, target: np.ndarray) -> float:
|
|
|
|
| 53 |
discontinuity = np.abs(edges).mean()
|
| 54 |
# Map: 0 -> 100, 30 -> 0
|
| 55 |
score = max(0.0, 100.0 - discontinuity * (100.0 / 30.0))
|
| 56 |
+
return round(score, 1)
|
| 57 |
|
| 58 |
|
| 59 |
def _compute_delta_e(swapped: np.ndarray, target: np.ndarray) -> float:
|
|
|
|
| 97 |
noise_penalty = min(noise / 1000.0, 20.0)
|
| 98 |
|
| 99 |
score = max(0.0, 100.0 - clip_penalty - sat_penalty - noise_penalty)
|
| 100 |
+
return round(score, 1)
|
|
@@ -75,29 +75,50 @@ def analyze_skin_tone(image: np.ndarray, face_bbox) -> dict:
|
|
| 75 |
def match_skin_tone(
|
| 76 |
swapped_img: np.ndarray,
|
| 77 |
target_img: np.ndarray,
|
| 78 |
-
src_tone: dict,
|
| 79 |
-
tgt_tone: dict,
|
| 80 |
-
strength: float = 0.9
|
|
|
|
| 81 |
) -> np.ndarray:
|
| 82 |
"""
|
| 83 |
-
Adjust swapped
|
| 84 |
-
|
|
|
|
| 85 |
"""
|
| 86 |
src_lab = cv2.cvtColor(swapped_img, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 87 |
tgt_lab = cv2.cvtColor(target_img, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 88 |
|
| 89 |
-
#
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
| 93 |
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
continue
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
result = cv2.cvtColor(
|
| 100 |
-
np.clip(
|
| 101 |
)
|
| 102 |
return result
|
| 103 |
|
|
|
|
| 75 |
def match_skin_tone(
|
| 76 |
swapped_img: np.ndarray,
|
| 77 |
target_img: np.ndarray,
|
| 78 |
+
src_tone: dict, # noqa: ARG001 β kept for API compatibility
|
| 79 |
+
tgt_tone: dict, # noqa: ARG001 β kept for API compatibility
|
| 80 |
+
strength: float = 0.9,
|
| 81 |
+
face_mask=None, # type: np.ndarray | None
|
| 82 |
) -> np.ndarray:
|
| 83 |
"""
|
| 84 |
+
Adjust swapped face skin tone to match target, confined to face_mask region.
|
| 85 |
+
Passing face_mask prevents hair/neck/background from being colour-shifted.
|
| 86 |
+
When face_mask is None the transfer falls back to the whole image (legacy).
|
| 87 |
"""
|
| 88 |
src_lab = cv2.cvtColor(swapped_img, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 89 |
tgt_lab = cv2.cvtColor(target_img, cv2.COLOR_BGR2LAB).astype(np.float32)
|
| 90 |
|
| 91 |
+
# Determine which pixels to compute statistics from
|
| 92 |
+
if face_mask is not None and face_mask.size > 0:
|
| 93 |
+
stat_mask = face_mask > 128
|
| 94 |
+
else:
|
| 95 |
+
stat_mask = np.ones(src_lab.shape[:2], dtype=bool)
|
| 96 |
|
| 97 |
+
corrected_lab = src_lab.copy()
|
| 98 |
+
for ch in range(3):
|
| 99 |
+
src_vals = src_lab[:, :, ch][stat_mask]
|
| 100 |
+
tgt_vals = tgt_lab[:, :, ch][stat_mask]
|
| 101 |
+
if src_vals.std() < 1e-6 or tgt_vals.std() < 1e-6:
|
| 102 |
continue
|
| 103 |
+
corrected_ch = (
|
| 104 |
+
(src_lab[:, :, ch] - src_vals.mean()) *
|
| 105 |
+
(tgt_vals.std() / src_vals.std()) +
|
| 106 |
+
tgt_vals.mean()
|
| 107 |
+
)
|
| 108 |
+
corrected_lab[:, :, ch] = (
|
| 109 |
+
src_lab[:, :, ch] * (1 - strength) + corrected_ch * strength
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Apply correction only inside the face mask (feathered at boundary)
|
| 113 |
+
if face_mask is not None and face_mask.size > 0:
|
| 114 |
+
alpha = cv2.GaussianBlur(face_mask.astype(np.float32) / 255.0, (21, 21), 7)
|
| 115 |
+
alpha = np.stack([alpha] * 3, axis=-1)
|
| 116 |
+
result_lab = src_lab * (1.0 - alpha) + corrected_lab * alpha
|
| 117 |
+
else:
|
| 118 |
+
result_lab = corrected_lab
|
| 119 |
|
| 120 |
result = cv2.cvtColor(
|
| 121 |
+
np.clip(result_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR
|
| 122 |
)
|
| 123 |
return result
|
| 124 |
|
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
_realesrgan_instance = None
|
| 6 |
+
_gfpgan_instance = None
|
| 7 |
+
|
| 8 |
+
MODELS_DIR = "models"
|
| 9 |
+
REALESRGAN_MODEL = os.path.join(MODELS_DIR, "RealESRGAN_x4plus.pth")
|
| 10 |
+
GFPGAN_MODEL = os.path.join(MODELS_DIR, "GFPGANv1.4.pth")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _load_realesrgan():
|
| 14 |
+
global _realesrgan_instance
|
| 15 |
+
if _realesrgan_instance is not None:
|
| 16 |
+
return _realesrgan_instance
|
| 17 |
+
if not os.path.exists(REALESRGAN_MODEL):
|
| 18 |
+
print(f"[super_res] RealESRGAN model not found: {REALESRGAN_MODEL}")
|
| 19 |
+
return None
|
| 20 |
+
try:
|
| 21 |
+
from basicsr.archs.rrdbnet_arch import RRDBNet
|
| 22 |
+
from realesrgan import RealESRGANer
|
| 23 |
+
|
| 24 |
+
model = RRDBNet(
|
| 25 |
+
num_in_ch=3, num_out_ch=3, num_feat=64,
|
| 26 |
+
num_block=23, num_grow_ch=32, scale=4
|
| 27 |
+
)
|
| 28 |
+
_realesrgan_instance = RealESRGANer(
|
| 29 |
+
scale=4,
|
| 30 |
+
model_path=REALESRGAN_MODEL,
|
| 31 |
+
model=model,
|
| 32 |
+
tile=512,
|
| 33 |
+
tile_pad=10,
|
| 34 |
+
pre_pad=0,
|
| 35 |
+
half=False, # CPU-safe β no float16
|
| 36 |
+
)
|
| 37 |
+
print("[super_res] RealESRGAN x4 loaded OK")
|
| 38 |
+
return _realesrgan_instance
|
| 39 |
+
except Exception as e:
|
| 40 |
+
print(f"[super_res] RealESRGAN load failed: {e}")
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _load_gfpgan():
|
| 45 |
+
global _gfpgan_instance
|
| 46 |
+
if _gfpgan_instance is not None:
|
| 47 |
+
return _gfpgan_instance
|
| 48 |
+
if not os.path.exists(GFPGAN_MODEL):
|
| 49 |
+
print(f"[super_res] GFPGAN model not found: {GFPGAN_MODEL}")
|
| 50 |
+
return None
|
| 51 |
+
try:
|
| 52 |
+
from gfpgan import GFPGANer
|
| 53 |
+
|
| 54 |
+
_gfpgan_instance = GFPGANer(
|
| 55 |
+
model_path=GFPGAN_MODEL,
|
| 56 |
+
upscale=1, # restore only β upscaling done by RealESRGAN
|
| 57 |
+
arch="clean",
|
| 58 |
+
channel_multiplier=2,
|
| 59 |
+
)
|
| 60 |
+
print("[super_res] GFPGAN loaded OK")
|
| 61 |
+
return _gfpgan_instance
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"[super_res] GFPGAN load failed: {e}")
|
| 64 |
+
return None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def enhance_resolution(image: np.ndarray, scale: int = 4) -> np.ndarray:
|
| 68 |
+
"""
|
| 69 |
+
Two-stage quality enhancement:
|
| 70 |
+
1. GFPGAN β restores face detail lost in InsightFace's 128x128 resize
|
| 71 |
+
(no resolution change, just sharpness/texture restoration on faces).
|
| 72 |
+
2. RealESRGAN x4 β upscales the whole image to ~4K.
|
| 73 |
+
Falls back to Lanczos resize when models are not downloaded.
|
| 74 |
+
|
| 75 |
+
scale: 2 or 4 (4 = ~4K from a 1024px input)
|
| 76 |
+
"""
|
| 77 |
+
result = image.copy()
|
| 78 |
+
|
| 79 |
+
# --- Stage 1: face restoration ----------------------------------------
|
| 80 |
+
gfpgan = _load_gfpgan()
|
| 81 |
+
if gfpgan is not None:
|
| 82 |
+
try:
|
| 83 |
+
_, _, restored = gfpgan.enhance(
|
| 84 |
+
result,
|
| 85 |
+
has_aligned=False,
|
| 86 |
+
only_center_face=False,
|
| 87 |
+
paste_back=True,
|
| 88 |
+
)
|
| 89 |
+
if restored is not None and restored.shape == result.shape:
|
| 90 |
+
result = restored
|
| 91 |
+
print("[super_res] GFPGAN face restoration applied")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
print(f"[super_res] GFPGAN enhance failed: {e}")
|
| 94 |
+
|
| 95 |
+
# --- Stage 2: full-image upscaling ------------------------------------
|
| 96 |
+
upsampler = _load_realesrgan()
|
| 97 |
+
if upsampler is not None:
|
| 98 |
+
try:
|
| 99 |
+
output, _ = upsampler.enhance(result, outscale=scale)
|
| 100 |
+
if output is not None:
|
| 101 |
+
h, w = output.shape[:2]
|
| 102 |
+
print(f"[super_res] RealESRGAN {scale}x done β {w}Γ{h}")
|
| 103 |
+
return output
|
| 104 |
+
except Exception as e:
|
| 105 |
+
print(f"[super_res] RealESRGAN enhance failed: {e}")
|
| 106 |
+
|
| 107 |
+
# --- Fallback: Lanczos resize -----------------------------------------
|
| 108 |
+
h, w = result.shape[:2]
|
| 109 |
+
tw, th = w * scale, h * scale
|
| 110 |
+
print(f"[super_res] Lanczos fallback {scale}x: {w}Γ{h} β {tw}Γ{th}")
|
| 111 |
+
return cv2.resize(result, (tw, th), interpolation=cv2.INTER_LANCZOS4)
|
|
@@ -114,10 +114,12 @@ def _restore_glasses_region(swapped: np.ndarray, original_target: np.ndarray, fa
|
|
| 114 |
|
| 115 |
def _sharpen_face_region(image: np.ndarray, tgt_faces: list) -> np.ndarray:
|
| 116 |
"""
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
| 121 |
"""
|
| 122 |
result = image.copy()
|
| 123 |
h, w = image.shape[:2]
|
|
@@ -132,14 +134,17 @@ def _sharpen_face_region(image: np.ndarray, tgt_faces: list) -> np.ndarray:
|
|
| 132 |
if crop.size == 0:
|
| 133 |
continue
|
| 134 |
|
| 135 |
-
# 1.
|
| 136 |
-
smooth = cv2.bilateralFilter(crop, d=
|
| 137 |
|
| 138 |
-
# 2.
|
| 139 |
-
blur
|
| 140 |
-
sharp
|
| 141 |
|
| 142 |
-
# 3.
|
|
|
|
|
|
|
|
|
|
| 143 |
fh, fw = crop.shape[:2]
|
| 144 |
feather = np.ones((fh, fw), dtype=np.float32)
|
| 145 |
border = max(4, pad // 2)
|
|
@@ -152,7 +157,7 @@ def _sharpen_face_region(image: np.ndarray, tgt_faces: list) -> np.ndarray:
|
|
| 152 |
feather = np.stack([feather] * 3, axis=-1)
|
| 153 |
|
| 154 |
result[y1p:y2p, x1p:x2p] = (
|
| 155 |
-
|
| 156 |
result[y1p:y2p, x1p:x2p].astype(np.float32) * (1 - feather)
|
| 157 |
).astype(np.uint8)
|
| 158 |
|
|
|
|
| 114 |
|
| 115 |
def _sharpen_face_region(image: np.ndarray, tgt_faces: list) -> np.ndarray:
|
| 116 |
"""
|
| 117 |
+
Recover detail lost in InsightFace's 128x128 internal resize.
|
| 118 |
+
Strategy:
|
| 119 |
+
- Mild bilateral filter to remove compression artefacts (not texture).
|
| 120 |
+
- Unsharp mask on the ORIGINAL crop (not the blurred version) for true
|
| 121 |
+
high-frequency recovery; 2.3x strength gives clean edges without halos.
|
| 122 |
+
- Blend sharp + smooth so skin stays natural while edges are crisp.
|
| 123 |
"""
|
| 124 |
result = image.copy()
|
| 125 |
h, w = image.shape[:2]
|
|
|
|
| 134 |
if crop.size == 0:
|
| 135 |
continue
|
| 136 |
|
| 137 |
+
# 1. Mild bilateral β removes compression blotches while keeping edges
|
| 138 |
+
smooth = cv2.bilateralFilter(crop, d=5, sigmaColor=30, sigmaSpace=30)
|
| 139 |
|
| 140 |
+
# 2. Unsharp mask on ORIGINAL crop β true detail recovery
|
| 141 |
+
blur = cv2.GaussianBlur(crop, (0, 0), sigmaX=1.5)
|
| 142 |
+
sharp = cv2.addWeighted(crop, 2.3, blur, -1.3, 0)
|
| 143 |
|
| 144 |
+
# 3. Composite: 55% sharp detail + 45% smooth skin base
|
| 145 |
+
enhanced = cv2.addWeighted(sharp, 0.55, smooth, 0.45, 0)
|
| 146 |
+
|
| 147 |
+
# 4. Feathered paste β no hard border at crop edges
|
| 148 |
fh, fw = crop.shape[:2]
|
| 149 |
feather = np.ones((fh, fw), dtype=np.float32)
|
| 150 |
border = max(4, pad // 2)
|
|
|
|
| 157 |
feather = np.stack([feather] * 3, axis=-1)
|
| 158 |
|
| 159 |
result[y1p:y2p, x1p:x2p] = (
|
| 160 |
+
enhanced.astype(np.float32) * feather +
|
| 161 |
result[y1p:y2p, x1p:x2p].astype(np.float32) * (1 - feather)
|
| 162 |
).astype(np.uint8)
|
| 163 |
|
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
import cv2
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
from core.detector import detect_faces
|
|
@@ -10,17 +9,16 @@ from core.skin_tone import analyze_skin_tone, match_skin_tone
|
|
| 10 |
from core.neck_integrator import seamless_hair_to_neck_blend
|
| 11 |
from core.color_corrector import harmonize_colors
|
| 12 |
from core.quality_checker import compute_quality_score
|
| 13 |
-
from utils.image_io import resize_keep_aspect
|
| 14 |
|
| 15 |
|
| 16 |
def run_full_pipeline(
|
| 17 |
source: np.ndarray,
|
| 18 |
target: np.ndarray,
|
| 19 |
blend_strength: float = 0.85,
|
| 20 |
-
tone_match_strength: float = 0.
|
| 21 |
hair_preserve: float = 0.8,
|
| 22 |
neck_blend_strength: float = 0.75,
|
| 23 |
-
enable_super_res: bool =
|
| 24 |
progress_callback=None,
|
| 25 |
) -> dict:
|
| 26 |
"""
|
|
@@ -58,8 +56,11 @@ def run_full_pipeline(
|
|
| 58 |
swapped = swap_face_insightface(source, target)
|
| 59 |
|
| 60 |
_progress(55, "Matching skin tones...")
|
| 61 |
-
swapped = match_skin_tone(
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
_progress(65, "Blending hair, face, neck seamlessly...")
|
| 65 |
swapped = seamless_hair_to_neck_blend(
|
|
@@ -83,12 +84,16 @@ def run_full_pipeline(
|
|
| 83 |
swapped = harmonize_colors(swapped, target, tgt_masks)
|
| 84 |
|
| 85 |
if enable_super_res:
|
| 86 |
-
_progress(92, "Enhancing resolution...")
|
| 87 |
try:
|
| 88 |
from core.super_res import enhance_resolution
|
| 89 |
-
swapped = enhance_resolution(swapped)
|
| 90 |
-
except Exception:
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
_progress(97, "Computing quality metrics...")
|
| 94 |
quality = compute_quality_score(swapped, target, src_lm, tgt_lm)
|
|
|
|
|
|
|
| 1 |
import numpy as np
|
| 2 |
|
| 3 |
from core.detector import detect_faces
|
|
|
|
| 9 |
from core.neck_integrator import seamless_hair_to_neck_blend
|
| 10 |
from core.color_corrector import harmonize_colors
|
| 11 |
from core.quality_checker import compute_quality_score
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def run_full_pipeline(
|
| 15 |
source: np.ndarray,
|
| 16 |
target: np.ndarray,
|
| 17 |
blend_strength: float = 0.85,
|
| 18 |
+
tone_match_strength: float = 0.6,
|
| 19 |
hair_preserve: float = 0.8,
|
| 20 |
neck_blend_strength: float = 0.75,
|
| 21 |
+
enable_super_res: bool = True,
|
| 22 |
progress_callback=None,
|
| 23 |
) -> dict:
|
| 24 |
"""
|
|
|
|
| 56 |
swapped = swap_face_insightface(source, target)
|
| 57 |
|
| 58 |
_progress(55, "Matching skin tones...")
|
| 59 |
+
swapped = match_skin_tone(
|
| 60 |
+
swapped, source, src_tone, src_tone,
|
| 61 |
+
strength=tone_match_strength,
|
| 62 |
+
face_mask=tgt_masks.get("face_mask"),
|
| 63 |
+
)
|
| 64 |
|
| 65 |
_progress(65, "Blending hair, face, neck seamlessly...")
|
| 66 |
swapped = seamless_hair_to_neck_blend(
|
|
|
|
| 84 |
swapped = harmonize_colors(swapped, target, tgt_masks)
|
| 85 |
|
| 86 |
if enable_super_res:
|
| 87 |
+
_progress(92, "Enhancing resolution to 4K...")
|
| 88 |
try:
|
| 89 |
from core.super_res import enhance_resolution
|
| 90 |
+
swapped = enhance_resolution(swapped, scale=4)
|
| 91 |
+
except Exception as e:
|
| 92 |
+
import cv2
|
| 93 |
+
print(f"[pipeline] super_res skipped: {e}")
|
| 94 |
+
h, w = swapped.shape[:2]
|
| 95 |
+
swapped = cv2.resize(swapped, (w * 4, h * 4),
|
| 96 |
+
interpolation=cv2.INTER_LANCZOS4)
|
| 97 |
|
| 98 |
_progress(97, "Computing quality metrics...")
|
| 99 |
quality = compute_quality_score(swapped, target, src_lm, tgt_lm)
|
|
@@ -2,7 +2,6 @@
|
|
| 2 |
Download pretrained model weights required by the pipeline.
|
| 3 |
Run: python scripts/download_models.py
|
| 4 |
"""
|
| 5 |
-
import os
|
| 6 |
import sys
|
| 7 |
import urllib.request
|
| 8 |
from pathlib import Path
|
|
@@ -14,13 +13,21 @@ MODELS_DIR.mkdir(exist_ok=True)
|
|
| 14 |
MODELS = {
|
| 15 |
"inswapper_128.onnx": (
|
| 16 |
"https://huggingface.co/deepinsight/inswapper/resolve/main/inswapper_128.onnx",
|
| 17 |
-
"InsightFace face-swap model (required)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
),
|
| 19 |
}
|
| 20 |
|
| 21 |
# Note: BiSeNet and RetinaFace weights are downloaded automatically by
|
| 22 |
-
# their respective Python packages on first use. Only the
|
| 23 |
-
#
|
| 24 |
|
| 25 |
|
| 26 |
def _progress_hook(block_num, block_size, total_size):
|
|
|
|
| 2 |
Download pretrained model weights required by the pipeline.
|
| 3 |
Run: python scripts/download_models.py
|
| 4 |
"""
|
|
|
|
| 5 |
import sys
|
| 6 |
import urllib.request
|
| 7 |
from pathlib import Path
|
|
|
|
| 13 |
MODELS = {
|
| 14 |
"inswapper_128.onnx": (
|
| 15 |
"https://huggingface.co/deepinsight/inswapper/resolve/main/inswapper_128.onnx",
|
| 16 |
+
"InsightFace face-swap model (required)",
|
| 17 |
+
),
|
| 18 |
+
"RealESRGAN_x4plus.pth": (
|
| 19 |
+
"https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth",
|
| 20 |
+
"RealESRGAN x4 upscaler β enables 4K output (optional but recommended)",
|
| 21 |
+
),
|
| 22 |
+
"GFPGANv1.4.pth": (
|
| 23 |
+
"https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth",
|
| 24 |
+
"GFPGAN face restoration β fixes InsightFace blur (optional but recommended)",
|
| 25 |
),
|
| 26 |
}
|
| 27 |
|
| 28 |
# Note: BiSeNet and RetinaFace weights are downloaded automatically by
|
| 29 |
+
# their respective Python packages on first use. Only the models listed
|
| 30 |
+
# above require a manual download.
|
| 31 |
|
| 32 |
|
| 33 |
def _progress_hook(block_num, block_size, total_size):
|
|
@@ -30,8 +30,9 @@ from PIL import Image
|
|
| 30 |
from core.detector import detect_faces
|
| 31 |
from core.swapper import swap_face_insightface
|
| 32 |
from core.segmentor import segment_hair_neck_skin
|
| 33 |
-
from core.skin_tone import analyze_skin_tone
|
| 34 |
from core.neck_integrator import seamless_hair_to_neck_blend
|
|
|
|
| 35 |
from core.quality_checker import compute_quality_score
|
| 36 |
from utils.image_io import save_image, resize_keep_aspect
|
| 37 |
|
|
@@ -178,9 +179,9 @@ def api_swap():
|
|
| 178 |
neck_blend = int(request.form.get("neck_blend", 75)) / 100.0
|
| 179 |
blend_strength = int(request.form.get("blend_strength",85)) / 100.0
|
| 180 |
|
| 181 |
-
# -- resize --------
|
| 182 |
-
source = resize_keep_aspect(source,
|
| 183 |
-
target = resize_keep_aspect(target,
|
| 184 |
|
| 185 |
# -- face detection ---------------------------------------------------
|
| 186 |
faces_src = _safe_detect(source)
|
|
@@ -190,20 +191,31 @@ def api_swap():
|
|
| 190 |
if not faces_tgt:
|
| 191 |
return jsonify({"ok": False, "error": "No face detected in target image"}), 400
|
| 192 |
|
| 193 |
-
# --
|
| 194 |
-
src_tone
|
| 195 |
-
tgt_tone
|
| 196 |
-
delta_e
|
| 197 |
(src_tone["L"] - tgt_tone["L"]) ** 2 +
|
| 198 |
(src_tone["a"] - tgt_tone["a"]) ** 2 +
|
| 199 |
(src_tone["b"] - tgt_tone["b"]) ** 2
|
| 200 |
) ** 0.5
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
# 1. Core face swap (InsightFace inswapper_128)
|
| 203 |
swapped = swap_face_insightface(source, target)
|
| 204 |
|
| 205 |
-
# 2.
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
swapped = seamless_hair_to_neck_blend(
|
| 208 |
source_img=swapped, target_img=target,
|
| 209 |
src_masks=tgt_masks, tgt_masks=tgt_masks,
|
|
@@ -213,22 +225,35 @@ def api_swap():
|
|
| 213 |
blend_strength=blend_strength,
|
| 214 |
)
|
| 215 |
|
| 216 |
-
#
|
|
|
|
|
|
|
|
|
|
| 217 |
|
|
|
|
| 218 |
quality = compute_quality_score(
|
| 219 |
swapped, target,
|
| 220 |
-
|
| 221 |
-
|
| 222 |
)
|
| 223 |
|
| 224 |
-
# --
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
out_name = f"swap_{uuid.uuid4().hex[:8]}.png"
|
| 226 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 227 |
-
save_image(
|
| 228 |
|
| 229 |
return jsonify({
|
| 230 |
"ok": True,
|
| 231 |
-
"result_image": _encode_image(swapped, fmt="JPEG", quality=
|
| 232 |
"quality": quality,
|
| 233 |
"delta_e": round(delta_e, 2),
|
| 234 |
"src_tone": src_tone,
|
|
|
|
| 30 |
from core.detector import detect_faces
|
| 31 |
from core.swapper import swap_face_insightface
|
| 32 |
from core.segmentor import segment_hair_neck_skin
|
| 33 |
+
from core.skin_tone import analyze_skin_tone, match_skin_tone
|
| 34 |
from core.neck_integrator import seamless_hair_to_neck_blend
|
| 35 |
+
from core.blender import laplacian_blend, poisson_blend
|
| 36 |
from core.quality_checker import compute_quality_score
|
| 37 |
from utils.image_io import save_image, resize_keep_aspect
|
| 38 |
|
|
|
|
| 179 |
neck_blend = int(request.form.get("neck_blend", 75)) / 100.0
|
| 180 |
blend_strength = int(request.form.get("blend_strength",85)) / 100.0
|
| 181 |
|
| 182 |
+
# -- resize (2048 gives InsightFace more texture to work with) --------
|
| 183 |
+
source = resize_keep_aspect(source, 2048)
|
| 184 |
+
target = resize_keep_aspect(target, 2048)
|
| 185 |
|
| 186 |
# -- face detection ---------------------------------------------------
|
| 187 |
faces_src = _safe_detect(source)
|
|
|
|
| 191 |
if not faces_tgt:
|
| 192 |
return jsonify({"ok": False, "error": "No face detected in target image"}), 400
|
| 193 |
|
| 194 |
+
# -- skin tone analysis -----------------------------------------------
|
| 195 |
+
src_tone = analyze_skin_tone(source, faces_src[0])
|
| 196 |
+
tgt_tone = analyze_skin_tone(target, faces_tgt[0])
|
| 197 |
+
delta_e = (
|
| 198 |
(src_tone["L"] - tgt_tone["L"]) ** 2 +
|
| 199 |
(src_tone["a"] - tgt_tone["a"]) ** 2 +
|
| 200 |
(src_tone["b"] - tgt_tone["b"]) ** 2
|
| 201 |
) ** 0.5
|
| 202 |
|
| 203 |
+
# -- segmentation (needed for masking) --------------------------------
|
| 204 |
+
tgt_masks = segment_hair_neck_skin(target)
|
| 205 |
+
face_mask = tgt_masks.get("face_mask")
|
| 206 |
+
|
| 207 |
# 1. Core face swap (InsightFace inswapper_128)
|
| 208 |
swapped = swap_face_insightface(source, target)
|
| 209 |
|
| 210 |
+
# 2. Match face skin tone to source β only inside face region so hair
|
| 211 |
+
# and neck are not colour-shifted.
|
| 212 |
+
swapped = match_skin_tone(
|
| 213 |
+
swapped, source, src_tone, src_tone,
|
| 214 |
+
strength=0.6,
|
| 215 |
+
face_mask=face_mask,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
# 3. Seamless hair β face β neck blend
|
| 219 |
swapped = seamless_hair_to_neck_blend(
|
| 220 |
source_img=swapped, target_img=target,
|
| 221 |
src_masks=tgt_masks, tgt_masks=tgt_masks,
|
|
|
|
| 225 |
blend_strength=blend_strength,
|
| 226 |
)
|
| 227 |
|
| 228 |
+
# 4. Laplacian pyramid + Poisson seamless-clone to remove paste edges
|
| 229 |
+
if face_mask is not None:
|
| 230 |
+
swapped = laplacian_blend(target, swapped, face_mask, levels=4)
|
| 231 |
+
swapped = poisson_blend(swapped, target, face_mask)
|
| 232 |
|
| 233 |
+
# -- quality metrics --------------------------------------------------
|
| 234 |
quality = compute_quality_score(
|
| 235 |
swapped, target,
|
| 236 |
+
None, # landmarks not extracted in web pipeline β returns 50.0
|
| 237 |
+
None,
|
| 238 |
)
|
| 239 |
|
| 240 |
+
# -- 4K upscale for download (Lanczos fallback when SR models absent) -
|
| 241 |
+
try:
|
| 242 |
+
from core.super_res import enhance_resolution
|
| 243 |
+
hi_res = enhance_resolution(swapped, scale=4)
|
| 244 |
+
except Exception:
|
| 245 |
+
h, w = swapped.shape[:2]
|
| 246 |
+
hi_res = cv2.resize(swapped, (w * 4, h * 4),
|
| 247 |
+
interpolation=cv2.INTER_LANCZOS4)
|
| 248 |
+
|
| 249 |
+
# -- save 4K PNG output -----------------------------------------------
|
| 250 |
out_name = f"swap_{uuid.uuid4().hex[:8]}.png"
|
| 251 |
out_path = os.path.join(OUTPUT_DIR, out_name)
|
| 252 |
+
save_image(hi_res, out_path)
|
| 253 |
|
| 254 |
return jsonify({
|
| 255 |
"ok": True,
|
| 256 |
+
"result_image": _encode_image(swapped, fmt="JPEG", quality=92),
|
| 257 |
"quality": quality,
|
| 258 |
"delta_e": round(delta_e, 2),
|
| 259 |
"src_tone": src_tone,
|