aditya-rAj19 commited on
Commit
732cb77
·
1 Parent(s): e61f9c7

feat: match swapped face complexion to the source

Browse files

Adds match_face_to_source_tone: samples mean CIE-LAB from the central face oval
of both the source and the swapped result, then adds the per-channel difference
back over the target face box through a feathered ellipse. Only the face is
affected (neck/body untouched), so the swapped face takes on the source's
complexion as requested. Wired into the web pipeline after GFPGAN restoration.

Files changed (2) hide show
  1. core/skin_tone.py +64 -0
  2. web_app.py +7 -1
core/skin_tone.py CHANGED
@@ -125,6 +125,70 @@ def match_skin_tone(
125
  return corrected_bgr
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def _default_tone() -> dict:
129
  return dict(L=55.0, a=5.0, b=10.0, hue=25.0, saturation=35.0,
130
  undertone="Neutral", category="Medium")
 
125
  return corrected_bgr
126
 
127
 
128
+ def _central_mean_lab(img: np.ndarray, bbox):
129
+ """Mean CIE-LAB of the central face oval inside bbox (avoids hair/beard/bg)."""
130
+ x1, y1, x2, y2 = [int(v) for v in bbox]
131
+ x1, y1 = max(0, x1), max(0, y1)
132
+ x2, y2 = min(img.shape[1], x2), min(img.shape[0], y2)
133
+ crop = img[y1:y2, x1:x2]
134
+ if crop.size == 0:
135
+ return None
136
+ h, w = crop.shape[:2]
137
+ mask = np.zeros((h, w), np.uint8)
138
+ cv2.ellipse(mask, (w // 2, h // 2), (int(w * 0.32), int(h * 0.42)),
139
+ 0, 0, 360, 255, -1)
140
+ lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB)
141
+ return np.array(cv2.mean(lab, mask=mask)[:3], np.float32)
142
+
143
+
144
+ def match_face_to_source_tone(
145
+ swapped: np.ndarray,
146
+ source: np.ndarray,
147
+ src_bbox,
148
+ tgt_bbox,
149
+ strength: float = 0.7,
150
+ ) -> np.ndarray:
151
+ """
152
+ Shift the swapped face's complexion toward the SOURCE skin tone.
153
+
154
+ Mean LAB is sampled from the central face oval of both the source and the
155
+ swapped result, and the per-channel difference is added back over the target
156
+ face box through a feathered ellipse — so the neck/body are left alone and
157
+ only the face takes on the source's complexion. strength scales how fully it
158
+ matches (1.0 = exact source mean).
159
+ """
160
+ src_mean = _central_mean_lab(source, src_bbox)
161
+ swp_mean = _central_mean_lab(swapped, tgt_bbox)
162
+ if src_mean is None or swp_mean is None:
163
+ return swapped
164
+
165
+ delta = (src_mean - swp_mean) * strength
166
+
167
+ x1, y1, x2, y2 = [int(v) for v in tgt_bbox]
168
+ x1, y1 = max(0, x1), max(0, y1)
169
+ x2, y2 = min(swapped.shape[1], x2), min(swapped.shape[0], y2)
170
+ if x2 <= x1 or y2 <= y1:
171
+ return swapped
172
+
173
+ crop = swapped[y1:y2, x1:x2]
174
+ h, w = crop.shape[:2]
175
+ lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB).astype(np.float32)
176
+
177
+ # Feathered ellipse so the shift fades out before the jaw/hairline.
178
+ feather = np.zeros((h, w), np.float32)
179
+ cv2.ellipse(feather, (w // 2, h // 2), (int(w * 0.48), int(h * 0.58)),
180
+ 0, 0, 360, 1.0, -1)
181
+ feather = cv2.GaussianBlur(feather, (0, 0), sigmaX=max(2.0, w * 0.08))
182
+
183
+ for c in range(3):
184
+ lab[:, :, c] += delta[c] * feather
185
+
186
+ out = cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)
187
+ result = swapped.copy()
188
+ result[y1:y2, x1:x2] = out
189
+ return result
190
+
191
+
192
  def _default_tone() -> dict:
193
  return dict(L=55.0, a=5.0, b=10.0, hue=25.0, saturation=35.0,
194
  undertone="Neutral", category="Medium")
web_app.py CHANGED
@@ -29,7 +29,7 @@ from PIL import Image
29
 
30
  from core.detector import detect_faces
31
  from core.swapper import swap_face_insightface
32
- from core.skin_tone import analyze_skin_tone
33
  from core.super_res import restore_faces, upscale_image
34
  from core.quality_checker import compute_quality_score
35
  from utils.image_io import save_image, resize_keep_aspect
@@ -207,6 +207,12 @@ def api_swap():
207
  # encoded so the on-screen result is sharp, not just the download.
208
  swapped = restore_faces(swapped)
209
 
 
 
 
 
 
 
210
  # -- quality metrics --------------------------------------------------
211
  quality = compute_quality_score(swapped, target, None, None)
212
 
 
29
 
30
  from core.detector import detect_faces
31
  from core.swapper import swap_face_insightface
32
+ from core.skin_tone import analyze_skin_tone, match_face_to_source_tone
33
  from core.super_res import restore_faces, upscale_image
34
  from core.quality_checker import compute_quality_score
35
  from utils.image_io import save_image, resize_keep_aspect
 
207
  # encoded so the on-screen result is sharp, not just the download.
208
  swapped = restore_faces(swapped)
209
 
210
+ # 3. Match the swapped face's complexion to the SOURCE (only the face
211
+ # box, feathered — neck/body untouched).
212
+ swapped = match_face_to_source_tone(
213
+ swapped, source, faces_src[0], faces_tgt[0], strength=0.7
214
+ )
215
+
216
  # -- quality metrics --------------------------------------------------
217
  quality = compute_quality_score(swapped, target, None, None)
218