From ca8e39e3bb5956fe0b6f728e3c37c9cb536954da Mon Sep 17 00:00:00 2001 From: Kenneth Estanislao Date: Mon, 18 May 2026 02:11:04 +0800 Subject: [PATCH] Fix mouth mask --- modules/face_analyser.py | 30 +++++++++++++++++++ modules/processors/frame/face_swapper.py | 37 ++++++++++++++++-------- modules/ui.py | 17 ++++++++--- 3 files changed, 68 insertions(+), 16 deletions(-) diff --git a/modules/face_analyser.py b/modules/face_analyser.py index 754cd9c..ba3f0e5 100644 --- a/modules/face_analyser.py +++ b/modules/face_analyser.py @@ -187,6 +187,36 @@ def detect_many_faces_fast(frame: Frame) -> Any: for i in range(bboxes.shape[0])] +def ensure_landmarks(frame: Frame, faces: Any) -> None: + """Run the 2d106 landmark model in-place on faces that lack it. + + The fast webcam path (detect_one_face_fast / detect_many_faces_fast) + produces detection-only Face objects with no ``landmark_2d_106``. + Mouth masking needs those landmarks, so add them on demand only when + the feature is active — keeping the fast path fast otherwise. + """ + if faces is None: + return + if not isinstance(faces, (list, tuple)): + faces = [faces] + + fa = get_face_analyser() + lmk_model = fa.models.get("landmark_2d_106") + if lmk_model is None: + return + + for face in faces: + if face is None: + continue + # insightface Face is a dict; missing keys raise AttributeError, + # so getattr(..., None) is the safe presence check. + if getattr(face, "landmark_2d_106", None) is None: + try: + lmk_model.get(frame, face) + except Exception as e: # pragma: no cover - never break the swap + print(f"Error computing 2d106 landmarks: {e}") + + def has_valid_map() -> bool: for map in modules.globals.source_target_map: if "source" in map and "target" in map: diff --git a/modules/processors/frame/face_swapper.py b/modules/processors/frame/face_swapper.py index 2d09e17..74997f4 100644 --- a/modules/processors/frame/face_swapper.py +++ b/modules/processors/frame/face_swapper.py @@ -1111,13 +1111,20 @@ def create_lower_mouth_mask( return mask, mouth_cutout, mouth_box, lower_lip_polygon try: # Wrap main logic in try-except - # Use outer mouth landmarks (52-71) to capture the full mouth area - # This covers both upper and lower lips for proper mouth preservation - lower_lip_order = list(range(52, 72)) + # Outer mouth/lip landmarks (52-63) — the lip outline only. In this + # repo's insightface 2d106 convention these 12 points, taken in index + # order, form a SIMPLE (non-self-intersecting) closed polygon that + # cv2.fillPoly fills as one solid region directly over the mouth. + # This is the last shipped, known-good landmark set; range(52,72) + # (the regression) added the inner-lip points and made the path + # self-intersect, and the ancient [65,66,62,...,0,8,7...] indices + # belong to a different/older landmark convention (they land on the + # inner lip + random jaw points, so the mask never covers the mouth). + lower_lip_order = list(range(52, 64)) - # Check if all indices are valid for the loaded landmarks (already partially done by < 106 check) + # All indices must be valid for the loaded landmark set if max(lower_lip_order) >= landmarks.shape[0]: - # print(f"Warning: Landmark index {max(lower_lip_order)} out of bounds for shape {landmarks.shape[0]}.") + # print(f"Warning: Landmark index out of bounds for shape {landmarks.shape[0]}.") return mask, mouth_cutout, mouth_box, lower_lip_polygon lower_lip_landmarks = landmarks[lower_lip_order].astype(np.float32) @@ -1132,16 +1139,22 @@ def create_lower_mouth_mask( # print("Warning: Could not calculate valid center for mouth mask.") return mask, mouth_cutout, mouth_box, lower_lip_polygon - + # Drive expansion from the Mouth Mask slider so it actually responds. + # The known-good version expanded by the now-unused mask_down_size + # constant, which is why the slider had no effect. + # s: 0.0 (slider ~0, tight lip outline) -> 1.0 (slider 100, mouth->chin). mouth_mask_size = getattr(modules.globals, "mouth_mask_size", 0.0) # 0-100 slider - # 0=tight lip outline, 50=covers mouth area, 100=mouth to chin - expansion_factor = 1 + (mouth_mask_size / 100.0) * 2.5 + s = max(0.0, min(1.0, mouth_mask_size / 100.0)) - # Expand landmarks from center, with extra downward bias toward chin + # Uniformly scaling a simple polygon about its centroid keeps it simple + # (no self-intersection). x grows with expansion_factor; points below + # centre (toward the chin) also get an extra downward stretch so high + # slider values reach from the mouth down to the chin. + expansion_factor = 1.0 + s * 2.0 # 1.0x -> 3.0x + chin_bias = 1.0 + s * 2.0 # extra downward stretch offsets = lower_lip_landmarks - center - # Add extra downward expansion for points below center (toward chin) - chin_bias = 1 + (mouth_mask_size / 100.0) * 1.5 # extra vertical stretch downward - scale_y = np.where(offsets[:, 1] > 0, expansion_factor * chin_bias, expansion_factor) + scale_y = np.where(offsets[:, 1] > 0, + expansion_factor * chin_bias, expansion_factor) expanded_landmarks = lower_lip_landmarks.copy() expanded_landmarks[:, 0] = center[0] + offsets[:, 0] * expansion_factor expanded_landmarks[:, 1] = center[1] + offsets[:, 1] * scale_y diff --git a/modules/ui.py b/modules/ui.py index 35b0c13..cade5f5 100644 --- a/modules/ui.py +++ b/modules/ui.py @@ -58,6 +58,7 @@ from modules.face_analyser import ( add_blank_map, detect_many_faces_fast, detect_one_face_fast, + ensure_landmarks, get_one_face, get_unique_faces_from_target_image, get_unique_faces_from_target_video, @@ -336,8 +337,10 @@ def load_switch_states(): modules.globals.live_resizable = state.get("live_resizable", False) modules.globals.fp_ui = state.get("fp_ui", {"face_enhancer": False}) modules.globals.show_fps = state.get("show_fps", False) - modules.globals.mouth_mask_size = state.get("mouth_mask_size", 0.0) - modules.globals.mouth_mask = modules.globals.mouth_mask_size > 0 + # Mouth mask always starts disabled (slider at 0) on launch, + # regardless of the persisted value — enable it explicitly each session. + modules.globals.mouth_mask_size = 0.0 + modules.globals.mouth_mask = False modules.globals.show_mouth_mask_box = False except FileNotFoundError: pass @@ -655,9 +658,9 @@ class MainWindow(QMainWindow): self.s_sharpness.setToolTip(_("Sharpen the enhanced face output")) grid.addWidget(self.s_sharpness, 1, 1) - # Mouth mask + # Mouth mask — always starts at 0 (disabled) on launch grid.addWidget(QLabel(_("Mouth Mask")), 2, 0) - self.s_mouth = slider(0.0, 100.0, modules.globals.mouth_mask_size, 1, + self.s_mouth = slider(0.0, 100.0, 0.0, 1, self._on_mouth_mask_change) self.s_mouth.sliderPressed.connect(self._on_mouth_mask_pressed) self.s_mouth.sliderReleased.connect(self._on_mouth_mask_released) @@ -1085,6 +1088,12 @@ class _ProcessingWorker(QThread): elif cached_target_face is not None: cached_faces = [cached_target_face] + # Fast detection skips the 2d106 landmark model, but the mouth + # mask needs it. Attach landmarks on demand (computed once per + # detection cycle — the helper no-ops if already present). + if modules.globals.mouth_mask and cached_faces: + ensure_landmarks(temp_frame, cached_faces) + for fp in frame_processors: if fp.NAME == "DLC.FACE-ENHANCER": if modules.globals.fp_ui["face_enhancer"]: