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.
591 lines
24 KiB
Python
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)
|