Files
Timo 2567554475 Soft-Blend aus: Swap war technisch da, aber zurueckgemischt
Gemeldet wurde "meldet OK, aber man erkennt nichts getauscht". Ursache
sind zwei Fehler, die sich verstärken:

1) apply_soft_face_blend() mischte das Originalbild ueber den Swap-Patch.
   Die Gesichtsmaske deckte nur ~30 % der BBox ab, 74 % der von
   inswapper veraenderten Pixel lagen ausserhalb und wurden zurueckgegeben
   - genau Wangen, Schlaefen und Kiefer, also die identitaetsrelevanten
   Partien. Der Blend erzeugte zudem die Naht, die er beseitigen soll
   (Naht/Innen 1.38 mit gegen 1.18 ohne).

2) build_face_mask() war mit Faktor 0.80 zu schmal.

Gemessen ueber den vollen Batch-Pfad (tests/probe_batch_identity.py),
InsightFace-Embeddings als Identitaetsmetrik, vor/nach dem Fix am
gleichen Testmaterial:

  vorher   +0.34 bis +0.53 Identitaetsgewinn zur Quellperson
  nachher  +0.81 bis +0.97

Aenderungen:
- soft_blend standardmaessig aus (core/face_engine.py, core/config.py,
  face_swap.py, config.json) - auch die persistierte Config, sonst
  wuerde der alte true-Wert weiter den Default ueberschreiben
- Maskenfaktor 0.80 -> 1.25, damit die Filter Wangen und Kiefer
  einschliessen

R16 war an den alten, blend-aktiven Zustand kalibriert: der Wert 2,1 %
war ein Messartefakt, weil der Blend alles zurueckschrieb - auch die
Stellen, die die Filter im Hintergrund veraendert hatten. R16 misst
jetzt den Filterbeitrag zum rohen Swap statt die Differenz zum Original
und prueft die eigentliche Aussage (Filter bleiben auf der Gesichtsform).
17/17 Regressionstests bestanden.
2026-09-29 20:59:24 +02:00

591 lines
24 KiB
Python

import cv2
import numpy as np
from pathlib import Path
from core.config import MODELS_DIR, get_providers
from core.models import FaceRestorer
def normalize_bbox(bbox, img_shape):
"""
Macht eine beliebige/kaputte BBox benutzbar.
Motivation: Gesmoothte oder falsch skalierte BBoxen koennen NaN, Inf,
negative Koordinaten oder x2<x1 enthalten. Ohne diese Normalisierung
bricht int(v) mit 'ValueError: cannot convert float NaN to integer' ab
und reisst die komplette Bild- bzw. Frame-Verarbeitung mit.
Rueckgabe: (x1, y1, x2, y2) als int, auf die Bildgrenzen geklemmt und
garantiert mit x2>x1 und y2>y1 - oder None, wenn nichts Brauchbares
uebrig bleibt (dann ruft der Aufrufer auf).
"""
if bbox is None:
return None
try:
vals = [float(v) for v in bbox]
except (TypeError, ValueError):
return None
if len(vals) < 4 or not all(np.isfinite(vals[:4])):
return None
h, w = int(img_shape[0]), int(img_shape[1])
x1, y1, x2, y2 = vals[:4]
if x2 < x1:
x1, x2 = x2, x1
if y2 < y1:
y1, y2 = y2, y1
x1 = int(max(0, min(w, np.floor(x1))))
y1 = int(max(0, min(h, np.floor(y1))))
x2 = int(max(0, min(w, np.ceil(x2))))
y2 = int(max(0, min(h, np.ceil(y2))))
if x2 - x1 < 1 or y2 - y1 < 1:
return None
return x1, y1, x2, y2
def crop_shape_ok(bbox, img_shape, min_side=1):
"""True, wenn die (bereits normalisierte) BBox einen sinnvollen Patch ergibt."""
if bbox is None:
return False
x1, y1, x2, y2 = bbox
return (x2 - x1) >= min_side and (y2 - y1) >= min_side
def swap_effect_ratio(after, before, bbox):
"""
Messbarer Anteilveraenderungsgrad im BBox-Patch, auf 0..1 normiert.
Der alte Code pruefte `np.abs(...).mean() < 0.01` auf einer 0..255-Skala.
Das ist praktisch toter Code: bereits JPE-/Rauschen liegt bei ~0.5, also
muss ein Patch fast exakt unveraendert bleiben, damit er als 'nicht
getauscht' verworfen wird. Die neue Schwelle liegt auf einer
normalisierten Skala und ist damit kalibrierbar.
"""
x1, y1, x2, y2 = bbox
a = after[y1:y2, x1:x2].astype(np.float32)
b = before[y1:y2, x1:x2].astype(np.float32)
if a.size == 0 or a.shape != b.shape:
return 0.0
return float(np.abs(a - b).mean() / 255.0)
def build_face_mask(img_shape, kps, bbox=None, feather=0.28):
"""
Gesichtsfoermige Maske aus den 5 InsightFace-Landmarken.
Motivation: die Filter in diesem Modul arbeiteten auf einem GEPOLSTERTEN
RECHTECK um die BBox. Dadurch griffen LAB-Farbtransfer und Schaerfen auch
auf Haare, Hintergrund und Kleidung - messbar 27 % der Rechteck-Pixel
ausserhalb der Gesichtsform wurden mitveraendert. Das erzeugt genau die
typischen "Kasten"-Artefakte an Stirn und Kinn.
Die Ellipse wird aus der Augenlinie und dem Kinn abgeleitet:
Breite = Augenabstand * 0.78 (Seiten, Wangen)
Hoehe = Augenmitte bis Kinn, plus Stirnkuppel und Kieferansatz
Rueckgabe: float32-Maske in 0..1, weich auslaufend.
"""
h, w = int(img_shape[0]), int(img_shape[1])
if kps is None:
# Auf die BBox zurueckfallen (mit Ellipse statt Rechteck)
if bbox is None:
return None
nb = normalize_bbox(bbox, (h, w, 3))
if nb is None:
return None
x1, y1, x2, y2 = nb
mask = np.zeros((h, w), np.uint8)
cv2.ellipse(mask, ((x1 + x2) // 2, (y1 + y2) // 2),
(max(2, int((x2 - x1) * 0.49)), max(2, int((y2 - y1) * 0.49))),
0, 0, 360, 255, -1)
return cv2.GaussianBlur(mask, (0, 0), max(1.0, min(x2 - x1, y2 - y1) * 0.09)).astype(np.float32) / 255.0
k = np.asarray(kps, dtype=np.float32)
if k.shape[0] < 5:
return None
eye_l, eye_r, nose, mouth_l, mouth_r = k[0], k[1], k[2], k[3], k[4]
eye_dx = float(np.linalg.norm(eye_r - eye_l))
if not np.isfinite(eye_dx) or eye_dx < 4.0:
return build_face_mask(img_shape, None, bbox)
eye_mid = (eye_l + eye_r) / 2.0
chin = (mouth_l + mouth_r) / 2.0
# Asymmetrie ausgleichen: nehme den mittleren X-Wert
cx = float((eye_mid[0] + chin[0] + nose[0]) / 3.0)
# Breite: der Faktor 0.80 war zu schmal. Gemessen (tests/probe_mask_width.py):
# die Ellipse deckte nur 30 % der BBox ab, 74 % der von inswapper
# veraenderten Pixel lagen ausserhalb und wurden von den Filtern
# zurueckgemischt. Bei 1.25 sind es 45 % Flaechendeckung und nur noch
# 59 % Swap-Pixel ausserhalb - und der Identitaetsgewinn steigt von
# +0,135 auf +0,414. Die Ellipse muss Wangen und Kiefer einschliessen,
# sonst liegen genau die identitaetsrelevanten Partien draussen.
half_w = eye_dx * 1.25
top = float(min(eye_l[1], eye_r[1])) - eye_dx * 0.92 # Stirnkuppel
bottom = float(chin[1]) + eye_dx * 0.30 # Kinn
if bottom - top < eye_dx * 0.8:
bottom = top + eye_dx * 1.3
mask = np.zeros((h, w), np.uint8)
cv2.ellipse(
mask,
(int(cx), int((top + bottom) / 2.0)),
(int(max(3, half_w)), int(max(3, (bottom - top) / 2.0))),
0, 0, 360, 255, -1,
)
sigma = max(1.0, eye_dx * feather)
return cv2.GaussianBlur(mask, (0, 0), sigma).astype(np.float32) / 255.0
def _masked_region(img, mask, pad):
"""Gepufferte RO-Region plus die zugehoerige Maske, auf die Region zugeschnitten."""
h, w = img.shape[:2]
x1 = max(0, int(pad[0])); y1 = max(0, int(pad[1]))
x2 = min(w, int(pad[2])); y2 = min(h, int(pad[3]))
if x2 - x1 < 1 or y2 - y1 < 1:
return None, None
return img[y1:y2, x1:x2], mask[y1:y2, x1:x2]
def match_face_color_lab(swapped_img, original_img, bbox, factor=0.55, face_mask=None):
"""
Reinhard color transfer in perceptual CIE-LAB space with soft edge feathering.
Matches luminance (L) and chrominance (a, b) of the original skin tone,
avoiding harsh saturation clipping, color casts, or rectangular boundaries.
face_mask: optionale 0..1-Gesichtsmaske. Ohne sie wird ein Rechteck
verwendet (Rueckwaertskompatibilitaet), was aber Haare und Hintergrund
mitfaescht - siehe build_face_mask().
"""
_nb = normalize_bbox(bbox, swapped_img.shape)
if _nb is None:
return swapped_img
x1, y1, x2, y2 = _nb
pad = 8
roi = (x1 - pad, y1 - pad, x2 + pad, y2 + pad)
_dst, m = _masked_region(swapped_img, face_mask, roi) if face_mask is not None else (None, None)
if _dst is None:
return _rect_color_transfer(swapped_img, original_img, _nb, factor)
_src, _ = _masked_region(original_img, face_mask, roi)
if _src is None:
return swapped_img
return _lab_transfer_masked(swapped_img, _dst, _src, m, roi, factor)
def _lab_transfer_masked(swapped_img, dst, src, mask, roi, factor=0.55):
"""LAB-Reinhard-Transfer, ausschliesslich innerhalb der Gesichtsmaske."""
if dst.size == 0 or src.size == 0 or dst.shape != src.shape:
return swapped_img
src_lab = cv2.cvtColor(src, cv2.COLOR_BGR2LAB).astype(np.float32)
dst_lab = cv2.cvtColor(dst, cv2.COLOR_BGR2LAB).astype(np.float32)
wsum = float(mask.sum())
if wsum < 1e-6:
return swapped_img
for c in range(3):
# Gewichtete Mittel/Varianz: nur die Fläche MITHELFEN zur Anpassung,
# sonst würde der Hintergrund den Hautton verwässern.
src_m = float((src_lab[:, :, c] * mask).sum() / wsum)
src_s = float(np.sqrt((((src_lab[:, :, c] - src_m) ** 2) * mask).sum() / wsum)) + 1e-6
dst_m = float((dst_lab[:, :, c] * mask).sum() / wsum)
dst_s = float(np.sqrt((((dst_lab[:, :, c] - dst_m) ** 2) * mask).sum() / wsum)) + 1e-6
adjusted = (dst_lab[:, :, c] - dst_m) * (src_s / dst_s) + src_m
dst_lab[:, :, c] = adjusted * factor + dst_lab[:, :, c] * (1.0 - factor)
dst_lab = np.clip(dst_lab, 0, 255).astype(np.uint8)
transferred = cv2.cvtColor(dst_lab, cv2.COLOR_LAB2BGR)
m3 = np.clip(mask, 0.0, 1.0)[:, :, np.newaxis]
blended = (transferred.astype(np.float32) * m3
+ dst.astype(np.float32) * (1.0 - m3))
result = swapped_img.copy()
x1, y1, x2, y2 = [int(v) for v in roi]
result[y1:y2, x1:x2] = np.clip(blended, 0, 255).astype(np.uint8)
return result
def _rect_color_transfer(swapped_img, original_img, bbox, factor=0.55):
"""Rechteck-Variante (Rueckwaertskompatibilitaet, wenn keine Maske da ist)."""
x1, y1, x2, y2 = bbox
pad = 8
x1c = max(0, x1 - pad); y1c = max(0, y1 - pad)
x2c = min(swapped_img.shape[1], x2 + pad); y2c = min(swapped_img.shape[0], y2 + pad)
h, w = y2c - y1c, x2c - x1c
if h <= 0 or w <= 0:
return swapped_img
src_region = original_img[y1c:y2c, x1c:x2c]
dst_region = swapped_img[y1c:y2c, x1c:x2c]
if src_region.size == 0 or dst_region.size == 0:
return swapped_img
# Convert to CIE-LAB
src_lab = cv2.cvtColor(src_region, cv2.COLOR_BGR2LAB).astype(np.float32)
dst_lab = cv2.cvtColor(dst_region, cv2.COLOR_BGR2LAB).astype(np.float32)
for c in range(3):
src_m, src_s = src_lab[:, :, c].mean(), src_lab[:, :, c].std() + 1e-6
dst_m, dst_s = dst_lab[:, :, c].mean(), dst_lab[:, :, c].std() + 1e-6
adjusted = (dst_lab[:, :, c] - dst_m) * (src_s / dst_s) + src_m
dst_lab[:, :, c] = adjusted * factor + dst_lab[:, :, c] * (1.0 - factor)
dst_lab = np.clip(dst_lab, 0, 255).astype(np.uint8)
transferred = cv2.cvtColor(dst_lab, cv2.COLOR_LAB2BGR)
# Soft feathering mask
mask = np.zeros((h, w), dtype=np.float32)
border = max(4, min(h, w) // 8)
mask[border:-border, border:-border] = 1.0
mask = cv2.GaussianBlur(mask, (0, 0), border * 0.75)
mask = np.clip(mask, 0.0, 1.0)[:, :, np.newaxis]
blended = transferred.astype(np.float32) * mask + dst_region.astype(np.float32) * (1.0 - mask)
result = swapped_img.copy()
result[y1c:y2c, x1c:x2c] = np.clip(blended, 0, 255).astype(np.uint8)
return result
def enhance_face_region(img, bbox, sharpen=True, face_mask=None):
"""Sharpen the facial region using local unsharp masking with soft feathered blending.
face_mask: optionale 0..1-Gesichtsmaske. Ohne sie wird ein Rechteck
verwendet, was Kanten von Haaren und Hintergrund mitschaerft.
"""
_nb = normalize_bbox(bbox, img.shape)
if _nb is None:
return img
x1, y1, x2, y2 = _nb
pad = 8
roi = (x1 - pad, y1 - pad, x2 + pad, y2 + pad)
if face_mask is not None:
region, m = _masked_region(img, face_mask, roi)
if region is None:
return img
sigma = max(0.8, min(x2 - x1, y2 - y1) * 0.02)
blur = cv2.GaussianBlur(region, (0, 0), sigma)
amount = 0.32 # schwächer als im Rechteck-Fall: weniger Rauschen
else:
region, m = None, None
pad_use = 8
x1c = max(0, x1 - pad_use); y1c = max(0, y1 - pad_use)
x2c = min(img.shape[1], x2 + pad_use); y2c = min(img.shape[0], y2 + pad_use)
region = img[y1c:y2c, x1c:x2c]
if region.size == 0:
return img
blur = cv2.GaussianBlur(region, (0, 0), 2.0)
amount = 0.35
if region.size == 0:
return img
enhanced = cv2.addWeighted(region, 1.0 + amount, blur, -amount, 0)
if m is not None:
m3 = np.clip(m, 0.0, 1.0)[:, :, np.newaxis]
blended = (enhanced.astype(np.float32) * m3
+ region.astype(np.float32) * (1.0 - m3))
out = img.copy()
rx1, ry1, rx2, ry2 = [int(v) for v in roi]
out[ry1:ry2, rx1:rx2] = np.clip(blended, 0, 255).astype(np.uint8)
return out
# Rechteck-Variante mit weicher Randmaske (Rueckwaertskompatibilitaet)
h, w = region.shape[:2]
border = max(6, min(h, w) // 6)
bmask = np.zeros((h, w), dtype=np.float32)
bmask[border:-border, border:-border] = 1.0
bmask = cv2.GaussianBlur(bmask, (0, 0), border * 0.75)
bmask = np.clip(bmask, 0.0, 1.0)[:, :, np.newaxis]
blended = (enhanced.astype(np.float32) * bmask
+ region.astype(np.float32) * (1.0 - bmask))
img[y1c:y2c, x1c:x2c] = np.clip(blended, 0, 255).astype(np.uint8)
return img
def apply_soft_face_blend(swapped_img, original_img, bbox, face_mask=None):
"""
Feather only the outer border of the swapped region, softening the
transition into the original skin and hair.
face_mask: optionale 0..1-Gesichtsmaske. Mit Maske wird die weiche
Ellipse entlang der Gesichtskontur angewendet, sodass die Ueberblendung
dem Gesicht folgt statt dem Rechteck.
"""
_nb = normalize_bbox(bbox, swapped_img.shape)
if _nb is None:
return swapped_img
x1, y1, x2, y2 = _nb
pad = 4
roi = (x1 - pad, y1 - pad, x2 + pad, y2 + pad)
if face_mask is not None:
dst_p, m = _masked_region(swapped_img, face_mask, roi)
src_p, _ = _masked_region(original_img, face_mask, roi)
if dst_p is None or src_p is None or dst_p.shape != src_p.shape:
return swapped_img
# An der Maskenkante weich auslaufen lassen
soft = cv2.GaussianBlur(m, (0, 0), max(1.0, (x2 - x1) * 0.02))
soft = np.clip(soft, 0.0, 1.0)[:, :, np.newaxis]
blended = dst_p.astype(np.float32) * soft + src_p.astype(np.float32) * (1.0 - soft)
out = swapped_img.copy()
rx1, ry1, rx2, ry2 = [int(v) for v in roi]
out[ry1:ry2, rx1:rx2] = np.clip(blended, 0, 255).astype(np.uint8)
return out
x1c = max(0, x1 - pad); y1c = max(0, y1 - pad)
x2c = min(swapped_img.shape[1], x2 + pad); y2c = min(swapped_img.shape[0], y2 + pad)
h, w = y2c - y1c, x2c - x1c
if h <= 10 or w <= 10:
return swapped_img
mask = np.zeros((h, w), dtype=np.float32)
center = (w // 2, h // 2)
axes = (int(w * 0.49), int(h * 0.49))
cv2.ellipse(mask, center, axes, 0, 0, 360, 1.0, -1)
k_size = max(5, int(min(w, h) * 0.05) | 1)
mask = cv2.GaussianBlur(mask, (k_size, k_size), 0)
mask = np.clip(mask, 0.0, 1.0)[:, :, np.newaxis]
dst_patch = swapped_img[y1c:y2c, x1c:x2c].astype(np.float32)
orig_patch = original_img[y1c:y2c, x1c:x2c].astype(np.float32)
blended = dst_patch * mask + orig_patch * (1.0 - mask)
result = swapped_img.copy()
result[y1c:y2c, x1c:x2c] = np.clip(blended, 0, 255).astype(np.uint8)
return result
class FaceSwapper:
"""Core Face Swapper engine coordinating InsightFace detection and inswapper_128."""
def __init__(self, log_fn=print):
self.log = log_fn
self.app = None
self.swapper = None
self.restorer = FaceRestorer(log_fn=self.log)
self.use_restoration = False
self.enhance = True
self.color = True
# Soft-Blend ist standardmaessig AUS.
#
# Messung (tests/probe_blend_necessity.py, echte Fotos, InsightFace-
# Embeddings als Identitaetsmetrik): der Blend loescht 85 % des Swaps
# zurueck. Identitaetsgewinn zur Quellperson +0,131 mit Blend gegen
# +0,867 ohne. Er erzeugt zudem genau die Kante, die er beseitigen soll
# (Nahtenergie/innen 1.38 mit gegen 1.18 ohne).
#
# Ursache: build_face_mask() deckt nur ~30 % der BBox ab, 74 % der von
# inswapper veraenderten Pixel liegen ausserhalb der Maske und werden
# mit dem Originalbild zurueckgemischt - genau die Wangen-, Schlaefen-
# und Kieferpartien, die die Identitaet tragen. Auch im Rechteck-Modus
# ohne Maske bleibt der Schaden (+0,811 gegen +0,868).
#
# inswappers eigener Paste-Back blendet die Kanten bereits weich aus,
# der zusaetzliche Blend ist also entbehrlich.
self.soft_blend = False
self.high_sensitivity = True
self.det_size = (640, 640)
self.det_thresh = 0.25
# Mindest-Anteilveraenderung im Patch, damit ein Swap als wirksam gilt.
# Normalisierte Skala 0..1. Kalibriert in tests/calibrate_effect.py.
# Kalibriert (tests/calibrate_effect.py): kleinster echter Swap 0.0105
# (unterbelichtetes Bild), Rauschen-Scheinwerte bis 0.0126,
# Median echter Swaps 0.045.
# Die Schwelle muss UNTER dem kleinsten echten Swap liegen, sonst
# geht ein legitimer Swap verloren. Bewusst konservativ gewaehlt:
# ein durchgelassener Schein-Effekt ist harmlos (unveraenderter
# Patch), ein verworfener echter Swap ist ein sichtbarer Fehler.
self.min_swap_effect = 0.008
# Filter auf die Gesichtsform beschraenken statt auf das BBox-Rechteck.
self.face_mask_quality = True
self.last_swapped_bboxes = []
self.last_swap_delta = 0.0
self.last_output_delta = 0.0
def init_models(self):
"""Initialize buffalo_l face analysis and inswapper_128 ONNX model."""
if self.app is not None and self.swapper is not None:
return
providers = get_providers(self.log)
gpu = "CUDAExecutionProvider" in providers
self.log(f" Modus: {'GPU (CUDA)' if gpu else 'CPU'}")
self.log(" Lade buffalo_l Gesichtsdetektion ...")
from insightface.app import FaceAnalysis
import insightface.model_zoo as mz
preferred_det_size = (1024, 1024) if gpu else (640, 640)
self.app = FaceAnalysis(name="buffalo_l", root=str(MODELS_DIR.parent), providers=providers)
try:
self.app.prepare(ctx_id=0 if gpu else -1, det_size=preferred_det_size, det_thresh=self.det_thresh)
self.det_size = preferred_det_size
except Exception as e:
fallback_size = (640, 640)
self.log(f" Detektions-Aufloesung Fallback auf {fallback_size[0]}x{fallback_size[1]} ({e})")
self.app = FaceAnalysis(name="buffalo_l", root=str(MODELS_DIR.parent), providers=providers)
self.app.prepare(ctx_id=0 if gpu else -1, det_size=fallback_size, det_thresh=self.det_thresh)
self.det_size = fallback_size
swap_path = MODELS_DIR / "inswapper_128.onnx"
if not swap_path.exists():
raise RuntimeError(f"inswapper_128.onnx fehlt in {MODELS_DIR}")
self.log(" Lade inswapper_128.onnx ...")
self.swapper = mz.get_model(str(swap_path), providers=providers)
self.log(" Modelle erfolgreich bereitgestellt.")
def detect_faces(self, frame):
"""Detect faces with multi-scale fallback and low-threshold fallback for distant or occluded faces."""
faces = self.app.get(frame)
if faces or not self.high_sensitivity:
return faces, frame, 1.0
h, w = frame.shape[:2]
short_edge = min(h, w)
if short_edge < 720:
scale = 2.0
elif short_edge < 1080:
scale = 1.5
else:
scale = 1.25
up_w = max(2, int(round(w * scale)))
up_h = max(2, int(round(h * scale)))
max_side = max(up_w, up_h)
if max_side > 1920:
clamp = 1920.0 / max_side
up_w = max(2, int(round(up_w * clamp)))
up_h = max(2, int(round(up_h * clamp)))
scale *= clamp
upscaled = None
if scale > 1.01:
upscaled = cv2.resize(frame, (up_w, up_h), interpolation=cv2.INTER_CUBIC)
faces = self.app.get(upscaled)
if faces:
return faces, upscaled, scale
# Low-threshold fallback: faces partially covered or with challenging lighting
det_model = getattr(self.app, 'det_model', None)
if det_model is None:
for _m in getattr(self.app, 'models', {}).values():
if hasattr(_m, 'det_thresh'):
det_model = _m
break
if det_model is not None and hasattr(det_model, 'det_thresh'):
orig_thresh = det_model.det_thresh
try:
det_model.det_thresh = 0.10
faces = self.app.get(frame)
if not faces and upscaled is not None:
faces_up = self.app.get(upscaled)
if faces_up:
return faces_up, upscaled, scale
finally:
det_model.det_thresh = orig_thresh
if faces:
return faces, frame, 1.0
if upscaled is not None:
return [], upscaled, scale
return [], frame, 1.0
@staticmethod
def face_area(face):
x1, y1, x2, y2 = [float(v) for v in face.bbox]
return max(0.0, x2 - x1) * max(0.0, y2 - y1)
def pick_primary_face(self, faces):
"""Pick the largest and highest confidence face."""
if not faces:
return None
return max(faces, key=lambda f: (self.face_area(f), float(getattr(f, "det_score", 0.0))))
def get_first_face(self, img):
"""Detect the primary face in an image (used for reference face selection)."""
faces, _, _ = self.detect_faces(img)
return self.pick_primary_face(faces)
def swap_single_frame(self, frame, src_face, swap_all_faces=True):
"""
Perform a high-quality face swap on a single frame or image.
Applies:
1. InsightFace inswapper
2. LAB Reinhard color matching
3. Soft border feathering
4. Detail sharpening
5. GFPGAN restoration pass
"""
if frame is None or getattr(frame, "size", 0) == 0:
return frame, 0
if src_face is None:
return frame, 0
faces, work_img, scale = self.detect_faces(frame)
if not faces:
return frame, 0
target_faces = faces if swap_all_faces else [self.pick_primary_face(faces)]
original = work_img.copy()
result = work_img.copy()
swapped_bboxes = []
for face in target_faces:
if face is None:
continue
# BBox validieren, BEVOR sie an inswapper oder die Filter geht.
# inswapper nutzt face.kps; die Filter brauchen eine geklemmte Box.
nb = normalize_bbox(getattr(face, "bbox", None), work_img.shape)
if nb is None:
continue
face.bbox = np.asarray(nb, dtype=np.float32)
before_swap = result.copy()
try:
result = self.swapper.get(result, face, src_face, paste_back=True)
except Exception as e:
self.log(f" Swap fuer ein Gesicht fehlgeschlagen ({type(e).__name__}: {e})")
result = before_swap
continue
# Messbarer Effekt? (normalisierte Skala 0..1, kalibrierbare Schwelle)
effect = swap_effect_ratio(result, before_swap, nb)
if effect < self.min_swap_effect:
result = before_swap
continue
swapped_bboxes.append(nb)
# Gesichtsmaske aus den Landmarken: begrenzt alle Filter auf die
# tatsaechliche Gesichtsform statt auf das BBox-Rechteck. Ohne das
# werden Haare, Hintergrund und Kleidung mitveraendert.
fmask = (build_face_mask(work_img.shape, getattr(face, "kps", None), nb)
if self.face_mask_quality else None)
# Quality enhancements
if self.color:
result = match_face_color_lab(result, original, nb, face_mask=fmask)
if self.soft_blend:
result = apply_soft_face_blend(result, original, nb, face_mask=fmask)
if self.enhance:
result = enhance_face_region(result, nb, sharpen=True, face_mask=fmask)
# Optional GFPGAN restoration pass
if self.use_restoration and self.restorer is not None and swapped_bboxes:
result = self.restorer.restore_faces(result, swapped_bboxes)
# Scale back to native resolution if upscaling was used during detection
if scale != 1.0:
result = cv2.resize(result, (frame.shape[1], frame.shape[0]), interpolation=cv2.INTER_AREA)
self.last_swapped_bboxes = [
(b[0] / scale, b[1] / scale, b[2] / scale, b[3] / scale) for b in swapped_bboxes
]
else:
self.last_swapped_bboxes = list(swapped_bboxes)
if frame is not None and getattr(frame, "shape", None) == result.shape:
self.last_output_delta = float(
np.abs(result.astype(np.float32) - frame.astype(np.float32)).mean() / 255.0
)
return result, len(swapped_bboxes)