aditya-rAj19 commited on
Commit
f1b722f
Β·
1 Parent(s): 00273de

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 CHANGED
@@ -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: np.ndarray, tgt_lm: np.ndarray) -> 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
  diff = np.linalg.norm(src_lm[:n] - tgt_lm[:n], axis=1)
34
- mean_err = diff.mean()
35
- # Map: 0px -> 100, 10px -> 0
 
36
  score = max(0.0, 100.0 - mean_err * 10.0)
37
- return round(float(score), 1)
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(float(score), 1)
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(float(score), 1)
 
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)
core/skin_tone.py CHANGED
@@ -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 image skin tone to match target.
84
- Uses LAB color space transfer with strength control.
 
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
- # Per-channel mean/std transfer
90
- for ch in range(3):
91
- src_mean, src_std = src_lab[:, :, ch].mean(), src_lab[:, :, ch].std()
92
- tgt_mean, tgt_std = tgt_lab[:, :, ch].mean(), tgt_lab[:, :, ch].std()
 
93
 
94
- if src_std < 1e-6:
 
 
 
 
95
  continue
96
- corrected = (src_lab[:, :, ch] - src_mean) * (tgt_std / src_std) + tgt_mean
97
- src_lab[:, :, ch] = src_lab[:, :, ch] * (1 - strength) + corrected * strength
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
  result = cv2.cvtColor(
100
- np.clip(src_lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR
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
 
core/super_res.py ADDED
@@ -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)
core/swapper.py CHANGED
@@ -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
- Sharpen + even skin tone in each swapped face region.
118
- - Unsharp mask restores detail lost in InsightFace's 128x128 internal resize.
119
- - Bilateral filter smooths out skin blotchiness while preserving edges.
120
- - Colour correction at the face boundary blends the two skin tones.
 
 
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. Even skin tone β€” bilateral filter (smooth blotches, keep edges)
136
- smooth = cv2.bilateralFilter(crop, d=9, sigmaColor=60, sigmaSpace=60)
137
 
138
- # 2. Sharpen β€” unsharp mask on top of smoothed
139
- blur = cv2.GaussianBlur(smooth, (0, 0), sigmaX=2.5)
140
- sharp = cv2.addWeighted(smooth, 1.6, blur, -0.6, 0)
141
 
142
- # 3. Feathered paste β€” fade near the crop edges so no hard border
 
 
 
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
- sharp.astype(np.float32) * feather +
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
 
pipeline/full_pipeline.py CHANGED
@@ -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.9,
21
  hair_preserve: float = 0.8,
22
  neck_blend_strength: float = 0.75,
23
- enable_super_res: bool = False,
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(swapped, target, src_tone, tgt_tone,
62
- strength=tone_match_strength)
 
 
 
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
- pass
 
 
 
 
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)
scripts/download_models.py CHANGED
@@ -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 InsightFace
23
- # inswapper model requires a manual download.
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):
web_app.py CHANGED
@@ -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, 1024)
183
- target = resize_keep_aspect(target, 1024)
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
- # -- pipeline ---------------------------------------------------------
194
- src_tone = analyze_skin_tone(source, faces_src[0])
195
- tgt_tone = analyze_skin_tone(target, faces_tgt[0])
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. Extend blend from face β†’ hair boundary β†’ neck
206
- tgt_masks = segment_hair_neck_skin(target)
 
 
 
 
 
 
 
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
- # Skin tone is preserved naturally by InsightFace β€” no override needed
 
 
 
217
 
 
218
  quality = compute_quality_score(
219
  swapped, target,
220
- np.zeros((0, 2), dtype=np.float32),
221
- np.zeros((0, 2), dtype=np.float32),
222
  )
223
 
224
- # -- save output -------------------------------------------------------
 
 
 
 
 
 
 
 
 
225
  out_name = f"swap_{uuid.uuid4().hex[:8]}.png"
226
  out_path = os.path.join(OUTPUT_DIR, out_name)
227
- save_image(swapped, out_path)
228
 
229
  return jsonify({
230
  "ok": True,
231
- "result_image": _encode_image(swapped, fmt="JPEG", quality=90),
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,