Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
0
ai_platform/modules/forensic_features/tools/__init__.py
Normal file
0
ai_platform/modules/forensic_features/tools/__init__.py
Normal file
123
ai_platform/modules/forensic_features/tools/_contract.py
Normal file
123
ai_platform/modules/forensic_features/tools/_contract.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
tools/_contract.py — Helper comun pentru construcția răspunsurilor uniforme
|
||||
returnate de modulele forensice m25-m29.
|
||||
|
||||
Vezi tools/CONTRACT.md pentru schema completă.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def label_from_score(score: float | None) -> str:
|
||||
"""
|
||||
Mapează scorul forensic în etichetă human-readable.
|
||||
|
||||
Reguli (din CONTRACT.md):
|
||||
score is None → "NO_SIGNAL"
|
||||
score >= 0.65 → "FAKE"
|
||||
score <= 0.35 → "REAL"
|
||||
otherwise → "INCERT"
|
||||
"""
|
||||
if score is None:
|
||||
return "NO_SIGNAL"
|
||||
if score >= 0.65:
|
||||
return "FAKE"
|
||||
if score <= 0.35:
|
||||
return "REAL"
|
||||
return "INCERT"
|
||||
|
||||
|
||||
def clamp01(v: float | None) -> float | None:
|
||||
"""Forțează valoarea în [0, 1] sau None."""
|
||||
if v is None:
|
||||
return None
|
||||
if v != v: # NaN check
|
||||
return None
|
||||
return float(max(0.0, min(1.0, v)))
|
||||
|
||||
|
||||
def make_response(
|
||||
*,
|
||||
tool_id: str,
|
||||
tool_name: str,
|
||||
version: str,
|
||||
input_type: str,
|
||||
primary_score: float | None,
|
||||
confidence: float,
|
||||
evidence: list[str],
|
||||
frames_analyzed: int,
|
||||
frames_with_signal: int,
|
||||
summary_extras: dict[str, Any] | None = None,
|
||||
per_frame: list[dict[str, Any]] | None = None,
|
||||
metrics: dict[str, Any] | None = None,
|
||||
artifacts_images: list[str] | None = None,
|
||||
errors: list[str] | None = None,
|
||||
warnings: list[str] | None = None,
|
||||
execution_time_ms: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construiește răspuns conform CONTRACT.md.
|
||||
|
||||
Folosit de toate modulele m25-m29 ca să garanteze schema identică.
|
||||
"""
|
||||
score = clamp01(primary_score)
|
||||
conf = clamp01(confidence) or 0.0
|
||||
|
||||
summary: dict[str, Any] = {
|
||||
"frames_analyzed": int(frames_analyzed),
|
||||
"frames_with_signal": int(frames_with_signal),
|
||||
"primary_score": score,
|
||||
"primary_label": label_from_score(score),
|
||||
"confidence": round(conf, 4),
|
||||
"evidence": list(evidence)[:5],
|
||||
}
|
||||
if summary_extras:
|
||||
summary.update(summary_extras)
|
||||
|
||||
return {
|
||||
"tool": {
|
||||
"id": tool_id,
|
||||
"name": tool_name,
|
||||
"version": version,
|
||||
"input_type": input_type,
|
||||
},
|
||||
"summary": summary,
|
||||
"per_frame": per_frame or [],
|
||||
"metrics": metrics or {},
|
||||
"artifacts": {
|
||||
"images": list(artifacts_images or []),
|
||||
},
|
||||
"errors": list(errors or []),
|
||||
"warnings": list(warnings or []),
|
||||
"execution_time_ms": round(float(execution_time_ms), 2),
|
||||
}
|
||||
|
||||
|
||||
def empty_response(
|
||||
*,
|
||||
tool_id: str,
|
||||
tool_name: str,
|
||||
version: str,
|
||||
input_type: str,
|
||||
reason: str,
|
||||
execution_time_ms: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construiește răspuns NO_SIGNAL când tool-ul nu poate calcula nimic.
|
||||
Folosit când față nu e detectată, audio lipsă, etc.
|
||||
"""
|
||||
return make_response(
|
||||
tool_id=tool_id,
|
||||
tool_name=tool_name,
|
||||
version=version,
|
||||
input_type=input_type,
|
||||
primary_score=None,
|
||||
confidence=0.0,
|
||||
evidence=[reason],
|
||||
frames_analyzed=0,
|
||||
frames_with_signal=0,
|
||||
warnings=[reason],
|
||||
execution_time_ms=execution_time_ms,
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"id": "m25",
|
||||
"name": "Physiology (rPPG + Blink Dynamics)",
|
||||
"description": "Detectează semnal cardiovascular (puls) prin remote photoplethysmography și analizează dinamica clipitului (durată închidere/deschidere, asimetrie L-R). Fețele AI nu pulsează și clipesc simetric, neuman.",
|
||||
"category": "biometric_temporal",
|
||||
"input_type": "overview_frames",
|
||||
"module": "physiology",
|
||||
"function": "run",
|
||||
"run_order": 25,
|
||||
"enabled": true,
|
||||
"always_run": true,
|
||||
"parameters": {
|
||||
"min_frames_for_pulse": 60,
|
||||
"pulse_band_hz": [0.7, 4.0],
|
||||
"ear_blink_threshold": 0.20,
|
||||
"ear_open_threshold": 0.25,
|
||||
"rppg_method": "POS"
|
||||
},
|
||||
"thresholds": {
|
||||
"pulse_snr_real_min": 3.0,
|
||||
"pulse_bpm_real_range": [50, 110],
|
||||
"blink_asymmetry_real_min_ms": 20.0,
|
||||
"description": "Real video at >2s should yield pulse SNR > 3 dB in 50-110 BPM range. Real blink left-right asymmetry typically 30-80ms; AI generates symmetric blinks (<10ms diff)."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
"""
|
||||
m25 — Physiology (rPPG + Blink Dynamics)
|
||||
|
||||
WHAT IT DOES:
|
||||
Detectează semnale fiziologice care nu pot fi falsificate de un generator AI:
|
||||
pulsul cardiac extras prin remote photoplethysmography (rPPG) din variația
|
||||
de culoare facială, și dinamica clipitului (durată închidere vs deschidere,
|
||||
asimetrie ochi stâng vs drept). Fețele generate AI sunt static din punct de
|
||||
vedere cardiovascular și produc clipiri perfect simetrice, ne-naturale.
|
||||
|
||||
HOW IT WORKS:
|
||||
1. Pentru fiecare cadru, detectează fața cu MediaPipe FaceMesh (478 landmarks)
|
||||
sau cu Haar cascade (fallback). Extrage 478 landmarks pentru analiză EAR
|
||||
per ochi, plus o ROI patch pe pomet pentru rPPG.
|
||||
|
||||
2. rPPG via metoda POS (Plane Orthogonal to Skin, Wang et al. 2017):
|
||||
a. Pentru fiecare cadru, mediază RGB pe ROI obraz → semnal RGB(t).
|
||||
b. Normalizează: C_n(t) = C(t) / mean(C)
|
||||
c. Combinație ortogonală pe planul pielii:
|
||||
X = 3*R_n - 2*G_n
|
||||
Y = 1.5*R_n + G_n - 1.5*B_n
|
||||
P = X + (std(X)/std(Y)) * Y
|
||||
d. Filtru bandpass 0.7-4 Hz (40-240 BPM).
|
||||
e. FFT → identifică peak-ul în banda fiziologică.
|
||||
f. SNR = power(peak ± 0.1 Hz) / power(restul benzii fiziologice).
|
||||
|
||||
3. Blink dynamics:
|
||||
a. Per cadru, calculează EAR stânga și EAR dreapta din landmark-urile
|
||||
MediaPipe (perechi standardizate ochi: 33-133 stânga, 263-362 dreapta).
|
||||
b. Detectează evenimente de clipit ca tranziție EAR < 0.20 → > 0.25.
|
||||
c. Pentru fiecare clipit: măsoară durata închiderii (de la primul EAR<0.20
|
||||
la EAR minim) și deschiderii (de la minim la primul EAR>0.25).
|
||||
d. Calculează asimetria stânga-dreapta: |EAR_L_min - EAR_R_min| și
|
||||
diferența temporală între clipirea ochiului stâng și a celui drept.
|
||||
|
||||
WHY DETECTS DEEPFAKES:
|
||||
- Pulse: orice persoană vie are puls 50-100 BPM detectabil rPPG cu SNR>3dB
|
||||
pe 5+ secunde de video. Față AI = puls 0 sau zgomot incoherent.
|
||||
- Blink count: adult mediu clipește 12-20/minut → 1-2 clipiri pe 5 secunde.
|
||||
Multe deepfake-uri faciale au blink_count=0 (Li et al. 2018, "In Ictu Oculi").
|
||||
- Blink asymmetry: ochiul dominant clipește cu 30-80ms înaintea celuilalt
|
||||
la oameni reali (asimetrie neurologică). AI generează simetric perfect
|
||||
sau cu jitter aleator (nu asimetrie sistematică).
|
||||
|
||||
WHAT THE OUTPUT MEANS:
|
||||
primary_score = 0.0–0.35 → REAL (puls detectat clar, clipire naturală)
|
||||
primary_score = 0.35–0.65 → INCERT (semnal slab pe partea video scurt)
|
||||
primary_score = 0.65–1.0 → FAKE (puls absent + clipire absentă/simetrică)
|
||||
|
||||
primary_score se calculează ca:
|
||||
0.5 * (1 - sigmoid(pulse_snr - 3))
|
||||
+ 0.3 * (1 if blink_count == 0 else 0)
|
||||
+ 0.2 * (1 - normalized(blink_asymmetry_ms))
|
||||
|
||||
Răspunsul respectă schema unificată din tools/CONTRACT.md.
|
||||
|
||||
LIMITATIONS:
|
||||
- Necesită minim ~3 secunde de video cu față stabilă pentru pulse SNR fiabil.
|
||||
- Iluminare variabilă în clip degradează rPPG.
|
||||
- Pe fețe foarte mici (<150 px lățime), ROI-ul obraz nu are destulă suprafață.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
import preprocessing # noqa: E402
|
||||
from tools._contract import make_response, empty_response # noqa: E402
|
||||
|
||||
# Versiune algoritm
|
||||
TOOL_ID = "m25"
|
||||
TOOL_NAME = "Physiology"
|
||||
VERSION = "1.0"
|
||||
INPUT_TYPE = "overview_frames"
|
||||
|
||||
# Indici landmark MediaPipe FaceMesh pentru EAR
|
||||
# Stânga (din perspectiva camerei → ochiul drept al subiectului)
|
||||
LEFT_EYE_IDX = [33, 160, 158, 133, 153, 144] # p1, p2, p3, p4, p5, p6
|
||||
RIGHT_EYE_IDX = [362, 385, 387, 263, 373, 380]
|
||||
# ROI obraz pentru rPPG (sub ochi, pe os zigomatic)
|
||||
LEFT_CHEEK_LM = [101, 207, 187]
|
||||
RIGHT_CHEEK_LM = [330, 427, 411]
|
||||
|
||||
|
||||
def _estimate_effective_fps(frame_paths: list[str],
|
||||
results_dir: str | None = None) -> float:
|
||||
"""
|
||||
Returnează fps efectiv al frame_paths primit.
|
||||
|
||||
Sursa autoritativă: orchestrator-ul scrie `_meta.json` în results_dir
|
||||
cu `effective_fps`. Citim de acolo când e disponibil.
|
||||
|
||||
Fallback (când e apelat direct, nu prin orchestrator): heuristic
|
||||
bazat pe numărul de cadre — dar nu este precis și NU ghicește.
|
||||
Pe sparse extraction (count mic), m25 va decide oricum NO_SIGNAL.
|
||||
"""
|
||||
# 1. Caută _meta.json din orchestrator (sursa adevărată)
|
||||
if results_dir:
|
||||
meta_path = os.path.join(results_dir, "_meta.json")
|
||||
if os.path.exists(meta_path):
|
||||
try:
|
||||
import json
|
||||
with open(meta_path) as f:
|
||||
meta = json.load(f)
|
||||
fps_eff = meta.get("effective_fps")
|
||||
if fps_eff and fps_eff > 0:
|
||||
return float(fps_eff)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Fallback heuristic (apel standalone fără orchestrator)
|
||||
if len(frame_paths) < 2:
|
||||
return 1.0
|
||||
# Pe sparse extraction (every_n_frames mare în orchestrator), avem
|
||||
# tipic 1-30 cadre. Returnăm 1.0 ca să trigger NO_SIGNAL în compute_score.
|
||||
return 1.0 if len(frame_paths) < 60 else 4.0
|
||||
|
||||
|
||||
def _try_import_mediapipe():
|
||||
"""Returnează FaceLandmarker dacă MediaPipe e disponibil, altfel None."""
|
||||
try:
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision as mp_vision
|
||||
|
||||
# Caută fișierul model în rădăcina proiectului
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
model_path = os.path.join(root, "face_landmarker.task")
|
||||
if not os.path.exists(model_path):
|
||||
return None
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=model_path)
|
||||
options = mp_vision.FaceLandmarkerOptions(
|
||||
base_options=base_options,
|
||||
num_faces=1,
|
||||
min_face_detection_confidence=0.5,
|
||||
min_face_presence_confidence=0.5,
|
||||
running_mode=mp_vision.RunningMode.IMAGE,
|
||||
)
|
||||
return mp_vision.FaceLandmarker.create_from_options(options), mp
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _ear(landmarks: np.ndarray, idx: list[int]) -> float | None:
|
||||
"""Eye Aspect Ratio Soukupová & Čech 2016."""
|
||||
try:
|
||||
p1, p2, p3, p4, p5, p6 = [landmarks[i] for i in idx]
|
||||
v1 = np.linalg.norm(p2 - p6)
|
||||
v2 = np.linalg.norm(p3 - p5)
|
||||
h = np.linalg.norm(p1 - p4)
|
||||
if h < 1e-6:
|
||||
return None
|
||||
return float((v1 + v2) / (2.0 * h))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _cheek_roi_mean_rgb(frame_bgr: np.ndarray, landmarks_xy: np.ndarray,
|
||||
cheek_idx: list[int]) -> tuple[float, float, float] | None:
|
||||
"""Mediază BGR pe ROI patch trasat în jurul punctelor cheek."""
|
||||
try:
|
||||
pts = np.array([landmarks_xy[i] for i in cheek_idx], dtype=np.int32)
|
||||
if pts.shape[0] < 3:
|
||||
return None
|
||||
h, w = frame_bgr.shape[:2]
|
||||
# Bounding box + clamp
|
||||
x1, y1 = max(0, pts[:, 0].min()), max(0, pts[:, 1].min())
|
||||
x2, y2 = min(w, pts[:, 0].max()), min(h, pts[:, 1].max())
|
||||
if x2 - x1 < 8 or y2 - y1 < 8:
|
||||
return None
|
||||
roi = frame_bgr[y1:y2, x1:x2]
|
||||
if roi.size == 0:
|
||||
return None
|
||||
mean_bgr = roi.reshape(-1, 3).mean(axis=0)
|
||||
return float(mean_bgr[2]), float(mean_bgr[1]), float(mean_bgr[0]) # R, G, B
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _pos_rppg(rgb_signal: np.ndarray, fps: float,
|
||||
pulse_band: tuple[float, float] = (0.7, 4.0)
|
||||
) -> tuple[float | None, float | None, np.ndarray | None]:
|
||||
"""
|
||||
POS algorithm (Wang et al. 2017): RGB time series → estimated pulse signal P(t).
|
||||
Returnează (bpm, snr_db, P_signal) sau (None, None, None) dacă semnalul e prea scurt.
|
||||
"""
|
||||
n = len(rgb_signal)
|
||||
if n < int(fps * 2): # minim 2 secunde
|
||||
return None, None, None
|
||||
|
||||
rgb = rgb_signal.astype(np.float64)
|
||||
# Normalizare temporală (mean centering pe fereastra completă)
|
||||
means = rgb.mean(axis=0)
|
||||
if np.any(means < 1e-6):
|
||||
return None, None, None
|
||||
rgb_n = rgb / means
|
||||
|
||||
R, G, B = rgb_n[:, 0], rgb_n[:, 1], rgb_n[:, 2]
|
||||
X = 3 * R - 2 * G
|
||||
Y = 1.5 * R + G - 1.5 * B
|
||||
|
||||
sX, sY = X.std(), Y.std()
|
||||
if sY < 1e-9:
|
||||
return None, None, None
|
||||
alpha = sX / sY
|
||||
P = X + alpha * Y
|
||||
|
||||
# Detrending polinomial de ordin 3 — elimină drift slow din variația
|
||||
# iluminării (cloud cover, AGC cameră, mișcare lent a feței).
|
||||
# Fără asta, FFT-ul e dominat de componenta DC + pante lente, NU de
|
||||
# pulsul cardiac. Critical pentru rPPG fiabil.
|
||||
t = np.arange(n, dtype=np.float64)
|
||||
try:
|
||||
poly = np.polyfit(t, P, deg=3)
|
||||
P = P - np.polyval(poly, t)
|
||||
except (np.linalg.LinAlgError, ValueError):
|
||||
# Fallback la median subtraction simplu dacă polyfit eșuează
|
||||
P = P - np.median(P)
|
||||
|
||||
# Bandpass FIR via FFT (zero-phase)
|
||||
freqs = np.fft.rfftfreq(n, d=1.0 / fps)
|
||||
Pf = np.fft.rfft(P)
|
||||
band_mask = (freqs >= pulse_band[0]) & (freqs <= pulse_band[1])
|
||||
Pf_filt = np.where(band_mask, Pf, 0)
|
||||
P_band = np.fft.irfft(Pf_filt, n=n)
|
||||
|
||||
# Power spectrum în bandă
|
||||
power = np.abs(Pf_filt) ** 2
|
||||
if not band_mask.any() or power[band_mask].sum() < 1e-12:
|
||||
return None, None, P_band
|
||||
|
||||
# Peak în bandă fiziologică
|
||||
band_freqs = freqs[band_mask]
|
||||
band_power = power[band_mask]
|
||||
peak_idx = int(np.argmax(band_power))
|
||||
peak_freq = float(band_freqs[peak_idx])
|
||||
bpm = peak_freq * 60.0
|
||||
|
||||
# SNR: power în ±0.2 Hz în jurul peak / restul benzii
|
||||
near = (band_freqs >= peak_freq - 0.2) & (band_freqs <= peak_freq + 0.2)
|
||||
p_peak = band_power[near].sum()
|
||||
p_noise = band_power[~near].sum()
|
||||
if p_noise < 1e-12:
|
||||
return float(bpm), 50.0, P_band # peak izolat
|
||||
snr_db = 10.0 * math.log10(p_peak / p_noise)
|
||||
|
||||
return float(bpm), float(snr_db), P_band
|
||||
|
||||
|
||||
def _detect_blinks(ear_series: list[float | None],
|
||||
close_thr: float = 0.20,
|
||||
open_thr: float = 0.25,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Detectează evenimente de clipit dintr-o serie EAR.
|
||||
Returnează lista cu dict-uri: {start_idx, min_idx, end_idx, min_ear}.
|
||||
"""
|
||||
blinks = []
|
||||
n = len(ear_series)
|
||||
i = 0
|
||||
while i < n:
|
||||
if ear_series[i] is None or ear_series[i] >= close_thr:
|
||||
i += 1
|
||||
continue
|
||||
# Începe închidere
|
||||
start = i
|
||||
min_idx = i
|
||||
min_ear = ear_series[i]
|
||||
while i < n and ear_series[i] is not None and ear_series[i] < open_thr:
|
||||
if ear_series[i] is not None and ear_series[i] < min_ear:
|
||||
min_idx = i
|
||||
min_ear = ear_series[i]
|
||||
i += 1
|
||||
end = i - 1 if i > start else start
|
||||
if end > start: # tranziție validă
|
||||
blinks.append({
|
||||
"start_idx": start,
|
||||
"min_idx": min_idx,
|
||||
"end_idx": end,
|
||||
"min_ear": float(min_ear),
|
||||
})
|
||||
return blinks
|
||||
|
||||
|
||||
def _save_pulse_plot(P_signal: np.ndarray, fps: float, bpm: float | None,
|
||||
snr_db: float | None, out_path: str) -> None:
|
||||
"""Salvează plot semnal puls + spectru FFT."""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, axes = plt.subplots(2, 1, figsize=(10, 5))
|
||||
t = np.arange(len(P_signal)) / fps
|
||||
axes[0].plot(t, P_signal, color="#c0392b")
|
||||
axes[0].set_title(
|
||||
f"rPPG (POS) — BPM={bpm:.1f}, SNR={snr_db:.1f} dB"
|
||||
if bpm and snr_db else "rPPG (POS) — semnal slab"
|
||||
)
|
||||
axes[0].set_xlabel("Timp (s)")
|
||||
axes[0].set_ylabel("Amplitudine")
|
||||
axes[0].grid(alpha=0.3)
|
||||
|
||||
freqs = np.fft.rfftfreq(len(P_signal), d=1.0 / fps)
|
||||
spec = np.abs(np.fft.rfft(P_signal))
|
||||
mask = (freqs >= 0.5) & (freqs <= 5.0)
|
||||
axes[1].plot(freqs[mask] * 60, spec[mask], color="#2980b9")
|
||||
if bpm:
|
||||
axes[1].axvline(bpm, color="red", linestyle="--", label=f"Peak {bpm:.0f} BPM")
|
||||
axes[1].set_xlabel("BPM")
|
||||
axes[1].set_ylabel("Power")
|
||||
axes[1].set_title("Spectru pulse")
|
||||
axes[1].legend()
|
||||
axes[1].grid(alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _save_blink_timeline(ear_l: list[float | None], ear_r: list[float | None],
|
||||
blinks_l: list, blinks_r: list, fps: float,
|
||||
out_path: str) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
t = np.arange(len(ear_l)) / fps
|
||||
ear_l_arr = np.array([v if v is not None else np.nan for v in ear_l])
|
||||
ear_r_arr = np.array([v if v is not None else np.nan for v in ear_r])
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.plot(t, ear_l_arr, label="EAR stânga", color="#27ae60")
|
||||
ax.plot(t, ear_r_arr, label="EAR dreapta", color="#8e44ad")
|
||||
ax.axhline(0.20, color="red", linestyle="--", alpha=0.5, label="Prag clipit")
|
||||
for b in blinks_l:
|
||||
ax.axvspan(b["start_idx"] / fps, b["end_idx"] / fps,
|
||||
alpha=0.15, color="#27ae60")
|
||||
for b in blinks_r:
|
||||
ax.axvspan(b["start_idx"] / fps, b["end_idx"] / fps,
|
||||
alpha=0.15, color="#8e44ad")
|
||||
ax.set_xlabel("Timp (s)")
|
||||
ax.set_ylabel("EAR")
|
||||
ax.set_title(f"Blink timeline — L: {len(blinks_l)} clipiri, R: {len(blinks_r)} clipiri")
|
||||
ax.legend()
|
||||
ax.grid(alpha=0.3)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _compute_score_and_evidence(
|
||||
bpm: float | None, snr_db: float | None,
|
||||
blink_count: int, blink_asymmetry_ms: float | None,
|
||||
n_frames: int, fps: float,
|
||||
) -> tuple[float | None, float, list[str]]:
|
||||
"""Compune primary_score, confidence, evidence din metricile fiziologice."""
|
||||
duration_s = n_frames / max(fps, 1e-6)
|
||||
evidence: list[str] = []
|
||||
|
||||
# ── Detectează sparse extraction (sub Nyquist limit pentru pulse) ──
|
||||
sparse_extraction = fps < 4.0
|
||||
if sparse_extraction:
|
||||
evidence.append(
|
||||
f"Sparse frame extraction ({fps:.1f} fps effective) — rPPG impossible "
|
||||
f"(needs >=4 fps for pulse band detection)."
|
||||
)
|
||||
# Pe sparse extraction, returnăm NO_SIGNAL — nu pretindem că putem
|
||||
# face physiology forensics pe 1 frame/sec.
|
||||
return None, 0.0, evidence[:5]
|
||||
|
||||
# Componenta puls
|
||||
if snr_db is None or bpm is None:
|
||||
pulse_component = 0.7 # nu am putut calcula → suspect, dar nu cert
|
||||
pulse_conf = 0.2
|
||||
evidence.append("Pulse: not detected (signal too short or unstable)")
|
||||
else:
|
||||
in_band = 50 <= bpm <= 110
|
||||
if snr_db >= 3.0 and in_band:
|
||||
pulse_component = 0.0 # puls clar = REAL
|
||||
evidence.append(f"Pulse: {bpm:.0f} BPM, SNR={snr_db:.1f} dB (real range)")
|
||||
elif snr_db >= 1.5 and in_band:
|
||||
pulse_component = 0.3
|
||||
evidence.append(f"Pulse: {bpm:.0f} BPM, SNR={snr_db:.1f} dB (weak signal)")
|
||||
else:
|
||||
pulse_component = 0.85
|
||||
evidence.append(
|
||||
f"Pulse: {bpm:.0f} BPM, SNR={snr_db:.1f} dB (no plausible cardiac signal)"
|
||||
)
|
||||
pulse_conf = min(1.0, snr_db / 6.0) if snr_db > 0 else 0.1
|
||||
|
||||
# Componenta clipit count
|
||||
expected_blinks = max(0.5, duration_s * (15 / 60)) # 15 BPM normă
|
||||
# IMPORTANT: blink count = 0 e signal SLAB single-handedly. O persoană
|
||||
# tăcută într-o conversație scurtă poate să nu clipească 4-8s. Doar pe
|
||||
# video LUNG (>15s) și fără puls valid, blink_count=0 e cu adevărat
|
||||
# suspect. Sub 15s, downgradăm la INCERT.
|
||||
if blink_count == 0 and duration_s >= 15.0:
|
||||
blink_count_component = 0.85
|
||||
evidence.append(f"Blink count: 0 over {duration_s:.1f}s — natural blinking 1-2/5s")
|
||||
elif blink_count == 0 and duration_s >= 4.0:
|
||||
# Sub 15s, blink absent NU e cu adevărat suspect — multe persoane reale
|
||||
# nu clipesc în 5-10s consecutive (mai ales tăcute, cu privire fixă).
|
||||
blink_count_component = 0.5
|
||||
evidence.append(f"Blink count: 0 over {duration_s:.1f}s (inconclusive — short clip)")
|
||||
elif blink_count >= expected_blinks * 0.5:
|
||||
blink_count_component = 0.0
|
||||
evidence.append(f"Blink count: {blink_count} over {duration_s:.1f}s (natural)")
|
||||
else:
|
||||
blink_count_component = 0.3
|
||||
evidence.append(f"Blink count: {blink_count} over {duration_s:.1f}s (low)")
|
||||
|
||||
# Componenta asimetrie
|
||||
if blink_asymmetry_ms is None:
|
||||
asym_component = 0.5
|
||||
elif blink_asymmetry_ms < 10:
|
||||
asym_component = 0.7
|
||||
evidence.append(
|
||||
f"Blink L/R asymmetry: {blink_asymmetry_ms:.0f}ms (typical real: 30-80ms)"
|
||||
)
|
||||
elif blink_asymmetry_ms > 100:
|
||||
asym_component = 0.4 # extrem — fie real cu jitter, fie AI cu zgomot
|
||||
else:
|
||||
asym_component = 0.0
|
||||
evidence.append(f"Blink L/R asymmetry: {blink_asymmetry_ms:.0f}ms (natural range)")
|
||||
|
||||
# Combinație ponderată: puls e signal-ul cel mai discriminant; blink_count
|
||||
# e proxy mai slab (oameni reali pot avea 0 blink-uri pe clip scurt).
|
||||
score = 0.65 * pulse_component + 0.20 * blink_count_component + 0.15 * asym_component
|
||||
confidence = (
|
||||
0.6 * pulse_conf
|
||||
+ 0.3 * (1.0 if blink_count > 0 or duration_s >= 4.0 else 0.3)
|
||||
+ 0.1 * (1.0 if blink_asymmetry_ms is not None else 0.0)
|
||||
)
|
||||
return score, confidence, evidence[:5]
|
||||
|
||||
|
||||
def run(frame_paths: list[str], results_dir: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Entry point — vezi tools/CONTRACT.md pentru schema returnată.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
images_dir = None
|
||||
if results_dir:
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if not frame_paths or len(frame_paths) < 5:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Sub 5 cadre disponibile pentru analiză fiziologică",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# FPS efectiv: citim din _meta.json scris de orchestrator (sursa
|
||||
# adevărată). Fallback heuristic dacă rulăm standalone.
|
||||
fps = _estimate_effective_fps(frame_paths, results_dir=results_dir)
|
||||
|
||||
mp_result = _try_import_mediapipe()
|
||||
has_mp = mp_result is not None
|
||||
landmarker = mp_result[0] if has_mp else None
|
||||
mp_module = mp_result[1] if has_mp else None
|
||||
|
||||
rgb_left: list[tuple[float, float, float]] = []
|
||||
rgb_right: list[tuple[float, float, float]] = []
|
||||
ear_left_series: list[float | None] = []
|
||||
ear_right_series: list[float | None] = []
|
||||
per_frame: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for i, fpath in enumerate(frame_paths):
|
||||
frame_bgr = cv2.imread(fpath)
|
||||
if frame_bgr is None:
|
||||
per_frame.append({"frame_index": i, "signal_present": False})
|
||||
ear_left_series.append(None)
|
||||
ear_right_series.append(None)
|
||||
continue
|
||||
|
||||
h, w = frame_bgr.shape[:2]
|
||||
rec: dict[str, Any] = {"frame_index": i, "signal_present": False}
|
||||
|
||||
if has_mp:
|
||||
try:
|
||||
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||||
mp_image = mp_module.Image(
|
||||
image_format=mp_module.ImageFormat.SRGB, data=rgb
|
||||
)
|
||||
result = landmarker.detect(mp_image)
|
||||
if not result.face_landmarks:
|
||||
ear_left_series.append(None)
|
||||
ear_right_series.append(None)
|
||||
per_frame.append(rec)
|
||||
continue
|
||||
|
||||
lms = result.face_landmarks[0]
|
||||
landmarks_xy = np.array(
|
||||
[[lm.x * w, lm.y * h] for lm in lms], dtype=np.float64
|
||||
)
|
||||
|
||||
ear_l = _ear(landmarks_xy, LEFT_EYE_IDX)
|
||||
ear_r = _ear(landmarks_xy, RIGHT_EYE_IDX)
|
||||
ear_left_series.append(ear_l)
|
||||
ear_right_series.append(ear_r)
|
||||
|
||||
roi_l = _cheek_roi_mean_rgb(frame_bgr, landmarks_xy, LEFT_CHEEK_LM)
|
||||
roi_r = _cheek_roi_mean_rgb(frame_bgr, landmarks_xy, RIGHT_CHEEK_LM)
|
||||
if roi_l:
|
||||
rgb_left.append(roi_l)
|
||||
if roi_r:
|
||||
rgb_right.append(roi_r)
|
||||
|
||||
rec["signal_present"] = True
|
||||
rec["ear_left"] = ear_l
|
||||
rec["ear_right"] = ear_r
|
||||
per_frame.append(rec)
|
||||
except Exception as e:
|
||||
errors.append(f"frame {i} mediapipe error: {e}")
|
||||
ear_left_series.append(None)
|
||||
ear_right_series.append(None)
|
||||
per_frame.append(rec)
|
||||
else:
|
||||
# Fallback fără MediaPipe: folosim Haar pentru ROI și nu putem calcula EAR
|
||||
warnings.append("MediaPipe lipsă; fallback la Haar (fără EAR per ochi)")
|
||||
faces = preprocessing.detect_faces(frame_bgr)
|
||||
if not faces:
|
||||
ear_left_series.append(None)
|
||||
ear_right_series.append(None)
|
||||
per_frame.append(rec)
|
||||
continue
|
||||
x, y, fw, fh = faces[0]
|
||||
cheek_y = y + int(fh * 0.55)
|
||||
cheek_h = max(8, int(fh * 0.20))
|
||||
left_cheek = frame_bgr[cheek_y:cheek_y + cheek_h,
|
||||
x:x + fw // 2]
|
||||
right_cheek = frame_bgr[cheek_y:cheek_y + cheek_h,
|
||||
x + fw // 2:x + fw]
|
||||
if left_cheek.size > 0:
|
||||
m = left_cheek.reshape(-1, 3).mean(axis=0)
|
||||
rgb_left.append((float(m[2]), float(m[1]), float(m[0])))
|
||||
if right_cheek.size > 0:
|
||||
m = right_cheek.reshape(-1, 3).mean(axis=0)
|
||||
rgb_right.append((float(m[2]), float(m[1]), float(m[0])))
|
||||
ear_left_series.append(None)
|
||||
ear_right_series.append(None)
|
||||
rec["signal_present"] = True
|
||||
per_frame.append(rec)
|
||||
|
||||
if landmarker is not None:
|
||||
try:
|
||||
landmarker.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
frames_with_signal = sum(1 for r in per_frame if r.get("signal_present"))
|
||||
|
||||
# ── rPPG: combină ROI stânga + dreapta dacă ambele au date ──
|
||||
rppg_input = []
|
||||
if rgb_left and rgb_right and len(rgb_left) == len(rgb_right):
|
||||
for l, r in zip(rgb_left, rgb_right):
|
||||
rppg_input.append([
|
||||
(l[0] + r[0]) / 2.0,
|
||||
(l[1] + r[1]) / 2.0,
|
||||
(l[2] + r[2]) / 2.0,
|
||||
])
|
||||
elif rgb_left:
|
||||
rppg_input = [list(x) for x in rgb_left]
|
||||
elif rgb_right:
|
||||
rppg_input = [list(x) for x in rgb_right]
|
||||
|
||||
bpm, snr_db, P_signal = (None, None, None)
|
||||
if len(rppg_input) >= int(fps * 2):
|
||||
bpm, snr_db, P_signal = _pos_rppg(np.array(rppg_input), fps)
|
||||
|
||||
# ── Blink dynamics ──
|
||||
blinks_l = _detect_blinks(ear_left_series)
|
||||
blinks_r = _detect_blinks(ear_right_series)
|
||||
blink_count_total = max(len(blinks_l), len(blinks_r))
|
||||
|
||||
# Asimetrie temporală: pereche-cea-mai-apropiată între blink stâng și drept
|
||||
asymmetry_ms = None
|
||||
if blinks_l and blinks_r:
|
||||
diffs = []
|
||||
for bl in blinks_l:
|
||||
best = min(blinks_r, key=lambda b: abs(b["min_idx"] - bl["min_idx"]))
|
||||
diff_frames = abs(best["min_idx"] - bl["min_idx"])
|
||||
if diff_frames < int(fps * 0.5): # max 500ms toleranță
|
||||
diffs.append(diff_frames)
|
||||
if diffs:
|
||||
asymmetry_ms = float(np.mean(diffs)) / fps * 1000.0
|
||||
|
||||
# ── Score + evidence ──
|
||||
score, confidence, evidence = _compute_score_and_evidence(
|
||||
bpm=bpm, snr_db=snr_db,
|
||||
blink_count=blink_count_total,
|
||||
blink_asymmetry_ms=asymmetry_ms,
|
||||
n_frames=len(frame_paths), fps=fps,
|
||||
)
|
||||
|
||||
# ── Save artefacte ──
|
||||
artifacts: list[str] = []
|
||||
if images_dir:
|
||||
if P_signal is not None:
|
||||
name = "m25_pulse_signal.png"
|
||||
_save_pulse_plot(P_signal, fps, bpm, snr_db,
|
||||
os.path.join(images_dir, name))
|
||||
artifacts.append(name)
|
||||
if any(v is not None for v in ear_left_series):
|
||||
name = "m25_blink_timeline.png"
|
||||
_save_blink_timeline(ear_left_series, ear_right_series,
|
||||
blinks_l, blinks_r, fps,
|
||||
os.path.join(images_dir, name))
|
||||
artifacts.append(name)
|
||||
|
||||
summary_extras = {
|
||||
"pulse_bpm": round(bpm, 2) if bpm is not None else None,
|
||||
"pulse_snr_db": round(snr_db, 2) if snr_db is not None else None,
|
||||
"blink_count_left": len(blinks_l),
|
||||
"blink_count_right": len(blinks_r),
|
||||
"blink_count_total": blink_count_total,
|
||||
"blink_asymmetry_ms": round(asymmetry_ms, 1) if asymmetry_ms is not None else None,
|
||||
"fps_used": fps,
|
||||
"rppg_method": "POS",
|
||||
"mediapipe_used": has_mp,
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"blinks_left": blinks_l,
|
||||
"blinks_right": blinks_r,
|
||||
"rppg_samples": len(rppg_input),
|
||||
}
|
||||
|
||||
return make_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
primary_score=score, confidence=confidence, evidence=evidence,
|
||||
frames_analyzed=len(frame_paths),
|
||||
frames_with_signal=frames_with_signal,
|
||||
summary_extras=summary_extras,
|
||||
per_frame=per_frame,
|
||||
metrics=metrics,
|
||||
artifacts_images=artifacts,
|
||||
errors=errors, warnings=warnings,
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"id": "m26",
|
||||
"name": "Audio Forensics (Sync + Voice Clone)",
|
||||
"description": "Detectează drift între mișcarea buzelor și amplitudine audio (lip-sync offset) și caracteristici statistice ale vocii sintetice (centroidă spectrală prea stabilă, F0 prea constant, lipsă breathiness).",
|
||||
"category": "audio_visual",
|
||||
"input_type": "video_path",
|
||||
"module": "audio",
|
||||
"function": "run",
|
||||
"run_order": 26,
|
||||
"enabled": true,
|
||||
"always_run": true,
|
||||
"parameters": {
|
||||
"audio_sample_rate": 16000,
|
||||
"max_offset_search_ms": 500,
|
||||
"voiced_energy_threshold_db": -40
|
||||
},
|
||||
"thresholds": {
|
||||
"lip_sync_offset_real_max_ms": 60,
|
||||
"lip_sync_offset_suspicious_ms": 150,
|
||||
"centroid_std_real_min_hz": 250,
|
||||
"f0_std_real_min_hz": 25,
|
||||
"description": "Real recording: |lip_sync_offset| < 60ms, centroid std > 250 Hz, F0 std > 25 Hz. TTS modern: centroid foarte stabil (<150 Hz std), F0 quantized."
|
||||
}
|
||||
}
|
||||
577
ai_platform/modules/forensic_features/tools/m26_audio/audio.py
Normal file
577
ai_platform/modules/forensic_features/tools/m26_audio/audio.py
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
"""
|
||||
m26 — Audio Forensics (Lip-Sync Offset + Voice Clone Heuristic)
|
||||
|
||||
WHAT IT DOES:
|
||||
Analizează coloana sonoră a videoului — domeniu complet ignorat de modulele
|
||||
m00-m25 — pentru două tipuri de semnal forensic:
|
||||
|
||||
(1) Lip-sync offset: dacă deschiderea gurii se corelează în timp cu energia
|
||||
audio rostită. Deepfake lip-sync (Wav2Lip, SadTalker, generative
|
||||
avatars) produce drift de 50-200ms măsurabil prin cross-correlation.
|
||||
|
||||
(2) Voice clone heuristic: voce sintetică modernă (ElevenLabs, OpenAI TTS,
|
||||
Tortoise) produce semnal cu varianță spectrală sub-naturală — F0 prea
|
||||
constant, centroidă spectrală prea stabilă, lipsa breath noise între
|
||||
cuvinte. Acestea sunt proxy-uri statistice; pentru detector de
|
||||
producție folosește AASIST sau RawNet2 pre-trained.
|
||||
|
||||
HOW IT WORKS:
|
||||
1. Extracție audio cu ffmpeg → wav mono 16kHz în memorie.
|
||||
Dacă videoul nu are pistă audio → return NO_SIGNAL.
|
||||
|
||||
2. Pe pista audio:
|
||||
a. RMS envelope per fereastră 10ms → energy_curve(t).
|
||||
b. F0 (pitch fundamental) prin autocorelație per fereastră 25ms.
|
||||
c. Spectral centroid per fereastră 25ms.
|
||||
d. Statistici de stabilitate: std al F0, std al centroidei,
|
||||
procent ferestre cu energie sub prag (silence/breath).
|
||||
|
||||
3. Pentru lip-sync (necesită video accesibil):
|
||||
a. Pe fiecare cadru, extrage 478 landmarks MediaPipe FaceMesh.
|
||||
b. Calculează mouth_aperture = ||lm_13 - lm_14|| / ||lm_61 - lm_291||
|
||||
(deschidere verticală / lățime, normalizat).
|
||||
c. Resample mouth_aperture la 100 Hz (același rate ca audio envelope).
|
||||
d. Cross-correlation între mouth_aperture și energy_envelope cu lag
|
||||
în ±500ms.
|
||||
e. lip_sync_offset_ms = lag care maximizează corelația.
|
||||
|
||||
WHY DETECTS DEEPFAKES:
|
||||
- Lip-sync: orice video real are offset < 50ms (encoding/playback drift).
|
||||
Wav2Lip-class generators produc 100-200ms drift sistematic.
|
||||
- Voice clone: F0 std real ~30-80 Hz pe propoziții cu inflexiune emoțională.
|
||||
TTS produce 5-20 Hz std (pitch quantizat). Centroidă spectrală
|
||||
similar: real 300-600 Hz std, TTS 100-200 Hz std.
|
||||
- Lipsa silence/breath: oameni respiră la 0.3-0.8s pauze între frază.
|
||||
TTS continuu fără pauze respiratorii naturale.
|
||||
|
||||
WHAT THE OUTPUT MEANS:
|
||||
primary_score = 0.5 * lip_sync_component + 0.5 * voice_synth_component
|
||||
unde fiecare componentă e 0 (real) → 1 (suspect).
|
||||
|
||||
primary_score = 0.0–0.35 → REAL
|
||||
primary_score = 0.65–1.0 → FAKE
|
||||
primary_label = NO_SIGNAL dacă videoul nu are audio.
|
||||
|
||||
Răspunsul respectă schema unificată din tools/CONTRACT.md.
|
||||
|
||||
LIMITATIONS:
|
||||
- Heuristica de voice clone NU înlocuiește un detector pre-trained
|
||||
(AASIST, RawNet2). E rezonabilă ca pre-screening, nu ca verdict final.
|
||||
- Lip-sync necesită fețe vizibile cu gură detectabilă în majoritatea
|
||||
cadrelor; pe video unde subiectul nu vorbește sau e ne-frontal → INCERT.
|
||||
- Audio cu zgomot de fundal puternic degradează F0 și centroid std.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import wave
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from tools._contract import make_response, empty_response # noqa: E402
|
||||
|
||||
TOOL_ID = "m26"
|
||||
TOOL_NAME = "Audio Forensics"
|
||||
VERSION = "1.0"
|
||||
INPUT_TYPE = "video_path"
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
FRAME_MS = 25
|
||||
HOP_MS = 10
|
||||
|
||||
|
||||
def _extract_audio_pcm(video_path: str, sr: int = SAMPLE_RATE) -> np.ndarray | None:
|
||||
"""
|
||||
Extrage audio mono 16kHz PCM cu ffmpeg, in-memory (raw stream f32le, fără
|
||||
parsing WAV header). Mai rapid și mai puțin RAM decât pipe WAV+wave.
|
||||
"""
|
||||
try:
|
||||
cmd = [
|
||||
"ffmpeg", "-i", video_path,
|
||||
"-vn", "-ac", "1", "-ar", str(sr),
|
||||
"-f", "f32le", # raw 32-bit float little-endian, no header
|
||||
"-loglevel", "error",
|
||||
"pipe:1",
|
||||
]
|
||||
proc = subprocess.run(cmd, capture_output=True, timeout=120)
|
||||
if proc.returncode != 0 or len(proc.stdout) < sr: # min 0.25s
|
||||
return None
|
||||
pcm = np.frombuffer(proc.stdout, dtype=np.float32)
|
||||
if pcm.size < sr // 2:
|
||||
return None
|
||||
# Clamp [-1, 1] în caz de overflow ffmpeg
|
||||
return np.clip(pcm, -1.0, 1.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _simple_vad(pcm: np.ndarray, sr: int, frame_ms: int = 30
|
||||
) -> tuple[np.ndarray, float]:
|
||||
"""
|
||||
Voice Activity Detection inline: combină energie locală + zero-crossing
|
||||
rate. Voice are ZCR ~0.05-0.20, energie peste threshold adaptiv.
|
||||
Returnează (mask boolean voiced per frame, voice_ratio 0..1).
|
||||
"""
|
||||
frame = max(1, int(sr * frame_ms / 1000))
|
||||
n = len(pcm)
|
||||
n_frames = n // frame
|
||||
if n_frames < 2:
|
||||
return np.array([], dtype=bool), 0.0
|
||||
|
||||
energies = np.zeros(n_frames, dtype=np.float32)
|
||||
zcrs = np.zeros(n_frames, dtype=np.float32)
|
||||
for i in range(n_frames):
|
||||
seg = pcm[i * frame:(i + 1) * frame]
|
||||
if seg.size == 0:
|
||||
continue
|
||||
energies[i] = float(np.sqrt(np.mean(seg ** 2)))
|
||||
# Zero-crossing rate (proxy spectral flatness)
|
||||
signs = np.sign(seg)
|
||||
zcrs[i] = float(np.sum(np.abs(np.diff(signs)))) / (2.0 * len(seg))
|
||||
|
||||
# Threshold adaptiv: 30th percentile al energiilor + 1.5x
|
||||
energy_thresh = float(np.percentile(energies, 30) * 1.5)
|
||||
energy_thresh = max(energy_thresh, 0.005) # floor pe semnal foarte slab
|
||||
|
||||
# Voice = energie suficientă + ZCR moderată (nu silence, nu doar noise)
|
||||
voiced = (energies > energy_thresh) & (zcrs > 0.02) & (zcrs < 0.30)
|
||||
voice_ratio = float(voiced.mean()) if voiced.size else 0.0
|
||||
return voiced, voice_ratio
|
||||
|
||||
|
||||
def _rms_envelope(pcm: np.ndarray, sr: int, hop_ms: int = HOP_MS,
|
||||
frame_ms: int = FRAME_MS) -> tuple[np.ndarray, float]:
|
||||
"""Returnează (envelope, hop_rate_hz)."""
|
||||
hop = max(1, int(sr * hop_ms / 1000))
|
||||
frame = max(2, int(sr * frame_ms / 1000))
|
||||
n = len(pcm)
|
||||
n_frames = (n - frame) // hop + 1
|
||||
if n_frames <= 0:
|
||||
return np.array([]), 1000.0 / hop_ms
|
||||
|
||||
env = np.zeros(n_frames, dtype=np.float32)
|
||||
for i in range(n_frames):
|
||||
s = i * hop
|
||||
seg = pcm[s:s + frame]
|
||||
env[i] = float(np.sqrt(np.mean(seg ** 2)))
|
||||
return env, 1000.0 / hop_ms
|
||||
|
||||
|
||||
def _spectral_centroid_series(pcm: np.ndarray, sr: int,
|
||||
hop_ms: int = HOP_MS,
|
||||
frame_ms: int = FRAME_MS) -> np.ndarray:
|
||||
"""Centroidă spectrală per fereastră."""
|
||||
hop = max(1, int(sr * hop_ms / 1000))
|
||||
frame = max(2, int(sr * frame_ms / 1000))
|
||||
n = len(pcm)
|
||||
n_frames = (n - frame) // hop + 1
|
||||
if n_frames <= 0:
|
||||
return np.array([])
|
||||
|
||||
window = np.hanning(frame).astype(np.float32)
|
||||
freqs = np.fft.rfftfreq(frame, d=1.0 / sr)
|
||||
centroids = np.zeros(n_frames, dtype=np.float32)
|
||||
for i in range(n_frames):
|
||||
s = i * hop
|
||||
seg = pcm[s:s + frame] * window
|
||||
spec = np.abs(np.fft.rfft(seg))
|
||||
if spec.sum() < 1e-9:
|
||||
centroids[i] = 0.0
|
||||
else:
|
||||
centroids[i] = float((freqs * spec).sum() / spec.sum())
|
||||
return centroids
|
||||
|
||||
|
||||
def _f0_autocorr(pcm: np.ndarray, sr: int,
|
||||
hop_ms: int = HOP_MS, frame_ms: int = FRAME_MS,
|
||||
fmin: float = 75.0, fmax: float = 400.0) -> np.ndarray:
|
||||
"""F0 prin autocorelație normalizată. Returnează 0 pe ferestre nevorbite."""
|
||||
hop = max(1, int(sr * hop_ms / 1000))
|
||||
frame = max(2, int(sr * frame_ms / 1000))
|
||||
n = len(pcm)
|
||||
n_frames = (n - frame) // hop + 1
|
||||
if n_frames <= 0:
|
||||
return np.array([])
|
||||
|
||||
min_lag = int(sr / fmax)
|
||||
max_lag = int(sr / fmin)
|
||||
f0 = np.zeros(n_frames, dtype=np.float32)
|
||||
energy_thresh = 0.01
|
||||
|
||||
for i in range(n_frames):
|
||||
s = i * hop
|
||||
seg = pcm[s:s + frame].astype(np.float64)
|
||||
if np.sqrt(np.mean(seg ** 2)) < energy_thresh:
|
||||
continue
|
||||
seg = seg - seg.mean()
|
||||
# Autocorelație normalizată
|
||||
ac = np.correlate(seg, seg, mode="full")[len(seg) - 1:]
|
||||
if ac[0] < 1e-9:
|
||||
continue
|
||||
ac = ac / ac[0]
|
||||
if max_lag >= len(ac):
|
||||
continue
|
||||
ac_band = ac[min_lag:max_lag]
|
||||
if ac_band.size == 0:
|
||||
continue
|
||||
peak = int(np.argmax(ac_band)) + min_lag
|
||||
if ac[peak] < 0.3: # nu există periodicitate clară
|
||||
continue
|
||||
f0[i] = float(sr / peak)
|
||||
return f0
|
||||
|
||||
|
||||
def _silence_ratio(env: np.ndarray, threshold_db: float = -40.0) -> float:
|
||||
"""Procent ferestre sub prag dB raportat la max envelope."""
|
||||
if env.size == 0 or env.max() < 1e-9:
|
||||
return 1.0
|
||||
db = 20.0 * np.log10(np.maximum(env, 1e-9) / env.max())
|
||||
return float((db < threshold_db).mean())
|
||||
|
||||
|
||||
def _video_mouth_aperture(video_path: str) -> tuple[np.ndarray | None, float]:
|
||||
"""
|
||||
Citește videoul, calculează mouth_aperture per cadru cu MediaPipe.
|
||||
Returnează (serie_aperture, fps) sau (None, fps) dacă MediaPipe lipsește.
|
||||
"""
|
||||
try:
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision as mp_vision
|
||||
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
model_path = os.path.join(root, "face_landmarker.task")
|
||||
if not os.path.exists(model_path):
|
||||
return None, 24.0
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=model_path)
|
||||
options = mp_vision.FaceLandmarkerOptions(
|
||||
base_options=base_options,
|
||||
num_faces=1,
|
||||
running_mode=mp_vision.RunningMode.IMAGE,
|
||||
)
|
||||
landmarker = mp_vision.FaceLandmarker.create_from_options(options)
|
||||
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
|
||||
apertures = []
|
||||
idx = 0
|
||||
max_frames = 600 # limită ~25s la 24fps; ajunge pentru analiză
|
||||
while idx < max_frames:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
try:
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
||||
result = landmarker.detect(mp_image)
|
||||
if result.face_landmarks:
|
||||
h, w = frame.shape[:2]
|
||||
lms = result.face_landmarks[0]
|
||||
# 13/14 = lip top/bottom inner; 61/291 = mouth corners
|
||||
p13 = np.array([lms[13].x * w, lms[13].y * h])
|
||||
p14 = np.array([lms[14].x * w, lms[14].y * h])
|
||||
p61 = np.array([lms[61].x * w, lms[61].y * h])
|
||||
p291 = np.array([lms[291].x * w, lms[291].y * h])
|
||||
vert = float(np.linalg.norm(p13 - p14))
|
||||
horiz = float(np.linalg.norm(p61 - p291))
|
||||
if horiz > 1e-3:
|
||||
apertures.append(vert / horiz)
|
||||
else:
|
||||
apertures.append(np.nan)
|
||||
else:
|
||||
apertures.append(np.nan)
|
||||
except Exception:
|
||||
apertures.append(np.nan)
|
||||
idx += 1
|
||||
cap.release()
|
||||
try:
|
||||
landmarker.close()
|
||||
except Exception:
|
||||
pass
|
||||
if not apertures:
|
||||
return None, fps
|
||||
return np.array(apertures, dtype=np.float32), float(fps)
|
||||
except Exception:
|
||||
return None, 24.0
|
||||
|
||||
|
||||
def _resample_to(series: np.ndarray, src_rate: float,
|
||||
target_rate: float) -> np.ndarray:
|
||||
"""Resampling liniar simplu."""
|
||||
if series.size == 0:
|
||||
return series
|
||||
duration = (series.size - 1) / src_rate
|
||||
n_target = int(duration * target_rate) + 1
|
||||
if n_target <= 1:
|
||||
return series
|
||||
src_t = np.arange(series.size) / src_rate
|
||||
tgt_t = np.arange(n_target) / target_rate
|
||||
valid = ~np.isnan(series)
|
||||
if valid.sum() < 2:
|
||||
return np.zeros(n_target, dtype=series.dtype)
|
||||
return np.interp(tgt_t, src_t[valid], series[valid])
|
||||
|
||||
|
||||
def _cross_corr_lag(a: np.ndarray, b: np.ndarray,
|
||||
max_lag_samples: int) -> tuple[int, float]:
|
||||
"""
|
||||
Lag care maximizează corelația normalizată între a și b.
|
||||
Lag pozitiv = b în urmă față de a.
|
||||
Returnează (lag_samples, max_corr).
|
||||
"""
|
||||
if a.size < max_lag_samples * 2 or b.size < max_lag_samples * 2:
|
||||
return 0, 0.0
|
||||
# Aliniază lungimi
|
||||
n = min(a.size, b.size)
|
||||
a = a[:n]
|
||||
b = b[:n]
|
||||
a = (a - a.mean())
|
||||
b = (b - b.mean())
|
||||
if a.std() < 1e-9 or b.std() < 1e-9:
|
||||
return 0, 0.0
|
||||
a = a / (a.std() * np.sqrt(n))
|
||||
b = b / (b.std() * np.sqrt(n))
|
||||
|
||||
full = np.correlate(a, b, mode="full")
|
||||
center = full.size // 2
|
||||
lo = max(0, center - max_lag_samples)
|
||||
hi = min(full.size, center + max_lag_samples + 1)
|
||||
band = full[lo:hi]
|
||||
if band.size == 0:
|
||||
return 0, 0.0
|
||||
peak = int(np.argmax(np.abs(band))) + lo
|
||||
return int(peak - center), float(full[peak])
|
||||
|
||||
|
||||
def _save_audio_plot(env: np.ndarray, mouth: np.ndarray, lag_ms: float,
|
||||
hop_rate: float, out_path: str) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
n = min(env.size, mouth.size)
|
||||
env_n = env[:n] / (env.max() + 1e-9)
|
||||
mouth_n = mouth[:n] / (mouth.max() + 1e-9) if mouth.max() > 0 else mouth[:n]
|
||||
t = np.arange(n) / hop_rate
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.plot(t, env_n, label="Audio energy (normalized)", color="#2980b9")
|
||||
ax.plot(t, mouth_n, label="Mouth aperture (normalized)", color="#c0392b")
|
||||
ax.set_xlabel("Timp (s)")
|
||||
ax.set_title(f"Audio-visual sync — lag={lag_ms:+.0f} ms")
|
||||
ax.legend()
|
||||
ax.grid(alpha=0.3)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _compute_score_and_evidence(
|
||||
lip_sync_ms: float | None,
|
||||
lip_sync_corr: float,
|
||||
f0_std: float,
|
||||
centroid_std: float,
|
||||
silence_ratio: float,
|
||||
n_voiced_frames: int,
|
||||
) -> tuple[float | None, float, list[str], dict[str, Any]]:
|
||||
evidence: list[str] = []
|
||||
|
||||
# Lip-sync component
|
||||
if lip_sync_ms is None or lip_sync_corr < 0.05:
|
||||
ls_component = 0.5
|
||||
ls_conf = 0.1
|
||||
evidence.append("Lip-sync: nu s-a putut calcula (face/mouth indisponibil)")
|
||||
elif abs(lip_sync_ms) < 60:
|
||||
ls_component = 0.0
|
||||
evidence.append(f"Lip-sync offset: {lip_sync_ms:+.0f}ms (în limita normală <60ms)")
|
||||
ls_conf = min(1.0, lip_sync_corr / 0.3)
|
||||
elif abs(lip_sync_ms) < 150:
|
||||
ls_component = 0.5
|
||||
evidence.append(f"Lip-sync offset: {lip_sync_ms:+.0f}ms (drift moderat)")
|
||||
ls_conf = min(1.0, lip_sync_corr / 0.3)
|
||||
else:
|
||||
ls_component = 0.9
|
||||
evidence.append(f"Lip-sync offset: {lip_sync_ms:+.0f}ms (drift mare, suspect deepfake lip-sync)")
|
||||
ls_conf = min(1.0, lip_sync_corr / 0.3)
|
||||
|
||||
# Voice clone heuristic component
|
||||
if n_voiced_frames < 50:
|
||||
vc_component = 0.5
|
||||
vc_conf = 0.1
|
||||
evidence.append("Voice analysis: prea puțin material vocal pentru analiză statistică")
|
||||
else:
|
||||
flags = 0
|
||||
if f0_std < 25.0:
|
||||
flags += 1
|
||||
evidence.append(f"F0 std: {f0_std:.1f} Hz (TTS-like, real prosody >25 Hz)")
|
||||
if centroid_std < 250.0:
|
||||
flags += 1
|
||||
evidence.append(f"Spectral centroid std: {centroid_std:.0f} Hz (low variation, real >250 Hz)")
|
||||
if silence_ratio < 0.05:
|
||||
flags += 1
|
||||
evidence.append(f"Silence ratio: {silence_ratio:.2f} (TTS continuu, real >0.10 cu pauze respiratorii)")
|
||||
|
||||
if flags >= 2:
|
||||
vc_component = 0.85
|
||||
elif flags == 1:
|
||||
vc_component = 0.5
|
||||
else:
|
||||
vc_component = 0.05
|
||||
evidence.append("Voice statistics: variabilitate naturală (F0, centroid, silence)")
|
||||
vc_conf = min(1.0, n_voiced_frames / 200.0)
|
||||
|
||||
score = 0.5 * ls_component + 0.5 * vc_component
|
||||
confidence = 0.5 * ls_conf + 0.5 * vc_conf
|
||||
|
||||
diag = {
|
||||
"lip_sync_component": round(ls_component, 3),
|
||||
"voice_clone_component": round(vc_component, 3),
|
||||
}
|
||||
return score, confidence, evidence[:5], diag
|
||||
|
||||
|
||||
def run(video_path: str, results_dir: str | None = None) -> dict[str, Any]:
|
||||
t_start = time.perf_counter()
|
||||
images_dir = None
|
||||
if results_dir:
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if not video_path or not os.path.exists(video_path):
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason=f"Video inexistent: {video_path}",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
pcm = _extract_audio_pcm(video_path)
|
||||
if pcm is None:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Videoul nu conține pistă audio sau extracția ffmpeg a eșuat",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# ── Audio features ──
|
||||
env, hop_rate = _rms_envelope(pcm, SAMPLE_RATE)
|
||||
centroid = _spectral_centroid_series(pcm, SAMPLE_RATE)
|
||||
f0 = _f0_autocorr(pcm, SAMPLE_RATE)
|
||||
|
||||
# VAD primary — separă voce de silence/noise.
|
||||
# Folosim VAD-ul nostru să restrângem analiza F0/centroid DOAR pe ferestre
|
||||
# cu voce reală. Fără asta, F0 din silence e zgomot care strică statistica.
|
||||
vad_mask, voice_ratio = _simple_vad(pcm, SAMPLE_RATE, frame_ms=HOP_MS)
|
||||
|
||||
# F0 valid doar pe ferestrele unde VAD detectează voce
|
||||
if vad_mask.size > 0 and f0.size > 0:
|
||||
# Aliniem dimensiunile (f0 și vad pot diferi cu 1-2 frame-uri)
|
||||
m = min(f0.size, vad_mask.size)
|
||||
f0_trim = f0[:m]
|
||||
vad_trim = vad_mask[:m]
|
||||
voiced_mask = (f0_trim > 0) & vad_trim
|
||||
else:
|
||||
voiced_mask = f0 > 0
|
||||
|
||||
n_voiced = int(voiced_mask.sum())
|
||||
if n_voiced > 1:
|
||||
f0_voiced = f0[:voiced_mask.size][voiced_mask]
|
||||
f0_std = float(f0_voiced.std())
|
||||
else:
|
||||
f0_std = 0.0
|
||||
|
||||
# Centroid pe ferestre voiced
|
||||
if vad_mask.size > 0 and centroid.size > 0:
|
||||
m = min(centroid.size, vad_mask.size)
|
||||
centroid_voiced = centroid[:m][vad_mask[:m] & (centroid[:m] > 0)]
|
||||
centroid_std = float(centroid_voiced.std()) if centroid_voiced.size > 1 else 0.0
|
||||
else:
|
||||
centroid_std = float(centroid[centroid > 0].std()) if (centroid > 0).any() else 0.0
|
||||
|
||||
# Silence ratio = 1 - voice_ratio (mai precis decât doar energie)
|
||||
sil_ratio = 1.0 - voice_ratio
|
||||
|
||||
# ── Lip-sync ──
|
||||
mouth_series, video_fps = _video_mouth_aperture(video_path)
|
||||
lip_sync_ms = None
|
||||
lip_sync_corr = 0.0
|
||||
mouth_resampled = np.array([], dtype=np.float32)
|
||||
if mouth_series is not None and mouth_series.size > 5:
|
||||
mouth_resampled = _resample_to(mouth_series, video_fps, hop_rate)
|
||||
max_lag = int(0.5 * hop_rate) # ±500ms
|
||||
# Diferenți (mouth_aperture e poziție; energy e amplitude → derivăm mouth)
|
||||
mouth_diff = np.abs(np.diff(mouth_resampled, prepend=mouth_resampled[0]))
|
||||
lag_samples, corr = _cross_corr_lag(env, mouth_diff, max_lag)
|
||||
if corr > 0:
|
||||
lip_sync_ms = float(lag_samples) / hop_rate * 1000.0
|
||||
lip_sync_corr = float(corr)
|
||||
else:
|
||||
warnings.append("Lip-sync indisponibil: MediaPipe lipsă sau față nedetectată în video")
|
||||
|
||||
# ── Score + evidence ──
|
||||
score, confidence, evidence, diag = _compute_score_and_evidence(
|
||||
lip_sync_ms=lip_sync_ms,
|
||||
lip_sync_corr=lip_sync_corr,
|
||||
f0_std=f0_std,
|
||||
centroid_std=centroid_std,
|
||||
silence_ratio=sil_ratio,
|
||||
n_voiced_frames=n_voiced,
|
||||
)
|
||||
|
||||
# ── Artefacte ──
|
||||
artifacts: list[str] = []
|
||||
if images_dir and lip_sync_ms is not None and mouth_resampled.size > 0:
|
||||
name = "m26_audio_visual_sync.png"
|
||||
_save_audio_plot(env, mouth_resampled, lip_sync_ms, hop_rate,
|
||||
os.path.join(images_dir, name))
|
||||
artifacts.append(name)
|
||||
|
||||
summary_extras = {
|
||||
"lip_sync_offset_ms": round(lip_sync_ms, 1) if lip_sync_ms is not None else None,
|
||||
"lip_sync_correlation": round(lip_sync_corr, 4),
|
||||
"f0_std_hz": round(f0_std, 2),
|
||||
"spectral_centroid_std_hz": round(centroid_std, 2),
|
||||
"silence_ratio": round(sil_ratio, 4),
|
||||
"voice_ratio": round(voice_ratio, 4),
|
||||
"n_voiced_frames": n_voiced,
|
||||
"audio_duration_s": round(len(pcm) / SAMPLE_RATE, 3),
|
||||
"audio_sample_rate": SAMPLE_RATE,
|
||||
**diag,
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"video_fps": round(video_fps, 3),
|
||||
"audio_hop_rate_hz": round(hop_rate, 1),
|
||||
"n_audio_frames": int(env.size),
|
||||
}
|
||||
|
||||
frames_with_signal = int(env.size)
|
||||
|
||||
return make_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
primary_score=score, confidence=confidence, evidence=evidence,
|
||||
frames_analyzed=int(env.size),
|
||||
frames_with_signal=frames_with_signal,
|
||||
summary_extras=summary_extras,
|
||||
per_frame=[], # audio nu e per-frame video
|
||||
metrics=metrics,
|
||||
artifacts_images=artifacts,
|
||||
errors=errors, warnings=warnings,
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "m27",
|
||||
"name": "AI-Generated Image Detector",
|
||||
"description": "Detector black-box pentru imagini generate AI (GAN, diffusion). Combină NPR (Neighboring Pixel Relationships, Tan et al. 2024) statistical, DIRE-like JPEG reconstruction error, și un loader opțional pentru modele torch pre-trained (UnivFD, DM Image Detection).",
|
||||
"category": "ai_generation",
|
||||
"input_type": "overview_frames",
|
||||
"module": "ai_detector",
|
||||
"function": "run",
|
||||
"run_order": 27,
|
||||
"enabled": true,
|
||||
"always_run": true,
|
||||
"parameters": {
|
||||
"npr_scales": [3, 5, 7],
|
||||
"jpeg_recon_quality": 65,
|
||||
"torch_model_path": null,
|
||||
"torch_model_threshold": 0.5
|
||||
},
|
||||
"thresholds": {
|
||||
"npr_score_real_max": 0.3,
|
||||
"jpeg_recon_score_real_max": 0.4,
|
||||
"description": "NPR score < 0.3 = real (Neighboring Pixel relations naturale). JPEG recon error < 0.4 = real (recompresia produce diferență mare = imagine cu detalii naturale)."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
"""
|
||||
m27 — AI-Generated Image Detector (Black-Box)
|
||||
|
||||
WHAT IT DOES:
|
||||
Detector dedicat pentru a distinge imagini fotografice naturale de imagini
|
||||
generate de AI (GAN, diffusion models). Spre deosebire de modulele m01-m23
|
||||
care caută artefacte specifice de manipulare locală (face-swap, splice),
|
||||
m27 evaluează imaginea ÎNTREAGĂ ca fiind sintetică sau nu.
|
||||
|
||||
Combină trei semnale plug-and-play:
|
||||
|
||||
(1) NPR — Neighboring Pixel Relationships (Tan et al. 2024)
|
||||
Statistici de corelație între pixeli vecini la scale multiple.
|
||||
AI rupe pattern-ul natural de corelații locale.
|
||||
|
||||
(2) JPEG Reconstruction Error
|
||||
Re-codare JPEG la calitate joasă, măsoară reconstruction error.
|
||||
Imaginile naturale au detalii fine care produc eroare mare la
|
||||
re-encodare; AI-generated tinde să fie mai "compresibilă".
|
||||
|
||||
(3) Torch Model Loader (opțional)
|
||||
Dacă există un fișier model pre-trained la path-ul configurat,
|
||||
îl încarcă și produce un scor 0-1 direct. Compatibil cu modele
|
||||
torch.hub: UnivFD, NPR official, DIRE.
|
||||
|
||||
HOW IT WORKS:
|
||||
|
||||
NPR (Neighboring Pixel Relationships):
|
||||
Pentru fiecare scală s din {3, 5, 7}:
|
||||
Convoluție Laplacian/Sobel pe imagine grayscale
|
||||
Calculează entropia distribuției valorilor
|
||||
AI-generated: entropia e mai mică (distribuție mai concentrată)
|
||||
npr_score = 1 - normalize(entropy_mean / natural_baseline)
|
||||
|
||||
JPEG Reconstruction Error:
|
||||
Encodează imaginea la JPEG Q=65
|
||||
Decodează și calculează |original - recompressed| / original_std
|
||||
Imagine naturală: error >0.4 (detalii fine pierdute)
|
||||
Imagine AI: error <0.3 (deja "smooth", compresia nu pierde mult)
|
||||
jpeg_score = 1 - normalize(error)
|
||||
|
||||
Torch model:
|
||||
Dacă parameters.torch_model_path e setat și fișierul există:
|
||||
model = torch.load(path)
|
||||
score = model(preprocess(image))
|
||||
Compatibilitatea modelelor: trebuie să accepte tensor (1,3,H,W)
|
||||
și să returneze logit sau probabilitate într-o singură valoare.
|
||||
|
||||
WHY DETECTS DEEPFAKES:
|
||||
Aceste teste funcționează pe IMAGINI GENERATE, nu pe face-swap pe video
|
||||
real. Detectează:
|
||||
- StyleGAN, BigGAN, ProGAN faces
|
||||
- Stable Diffusion, DALL-E, Midjourney imagery
|
||||
- Sora keyframes, Runway Gen-2/3 keyframes
|
||||
- DeepFloyd IF, Flux outputs
|
||||
|
||||
Pentru face-swap pe video real, m27 va da scor scăzut (imaginea ÎN MARE
|
||||
e reală, doar fața e modificată) — folosește m05, m12, m22 pentru asta.
|
||||
|
||||
WHAT THE OUTPUT MEANS:
|
||||
primary_score = max(npr_score, jpeg_score, torch_score)
|
||||
Adoptăm "max" pentru că oricare dintre cele 3 declanșate e suficient.
|
||||
|
||||
primary_score = 0.0–0.35 → REAL
|
||||
primary_score = 0.65–1.0 → FAKE
|
||||
|
||||
Răspunsul respectă schema unificată din tools/CONTRACT.md.
|
||||
|
||||
LIMITATIONS:
|
||||
- NPR și JPEG reconstruction sunt PROXY-uri; pentru SOTA folosește
|
||||
torch_model_path cu UnivFD checkpoint sau DIRE.
|
||||
- Imaginile foarte mici (<256×256) sau cu zgomot puternic dau false alarms.
|
||||
- Pe video re-encodate puternic, JPEG reconstruction error e neutralizat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from tools._contract import make_response, empty_response # noqa: E402
|
||||
|
||||
TOOL_ID = "m27"
|
||||
TOOL_NAME = "AI-Generated Image Detector"
|
||||
VERSION = "1.0"
|
||||
INPUT_TYPE = "overview_frames"
|
||||
|
||||
# Baselines empirice pentru NPR cross-scale variance:
|
||||
# Real natural: residual variance locală ~5-30 (fluctuație texturală)
|
||||
# AI generated: residual variance locală ~0.5-4 (smooth)
|
||||
# Sigmoid centrat pe 3.5 → real_score < 0.3, AI_score > 0.7
|
||||
#
|
||||
# JPEG-recon error pe video re-encodat:
|
||||
# Real: error_norm ~0.05-0.15
|
||||
# AI: error_norm ~0.02-0.06
|
||||
NPR_DECISION_BOUNDARY = 3.5 # cross-scale residual variance < 3.5 = suspect AI
|
||||
JPEG_DECISION_BOUNDARY = 0.04 # error_norm sub asta → suspect AI
|
||||
SIGMOID_STEEPNESS = 1.5
|
||||
|
||||
|
||||
def _sigmoid_inverted(x: float, boundary: float, steepness: float = SIGMOID_STEEPNESS) -> float:
|
||||
"""
|
||||
Sigmoid descrescător centrat pe boundary.
|
||||
Returnează ~1.0 când x << boundary, ~0.0 când x >> boundary.
|
||||
Tranziția e graduală — niciodată nu satureazză cert la 0 sau 1.
|
||||
"""
|
||||
import math
|
||||
try:
|
||||
return float(1.0 / (1.0 + math.exp(steepness * (x - boundary))))
|
||||
except OverflowError:
|
||||
return 1.0 if x < boundary else 0.0
|
||||
|
||||
|
||||
def _npr_score(gray: np.ndarray, scales: list[int]) -> float:
|
||||
"""
|
||||
Real NPR per Tan et al. 2024 — cross-scale neighbor pixel correlations.
|
||||
|
||||
Construim o piramidă Gaussian (4 niveluri), upscalăm fiecare la dim
|
||||
originală, calculăm reziduul între nivele consecutive. Pe fiecare reziduu
|
||||
calculăm varianța locală 3×3. Imaginile naturale au varianță cross-scale
|
||||
MARE (textură naturală e variabilă între scale). AI imagery are varianță
|
||||
cross-scale MICĂ (smooth predict de la rețea generativă).
|
||||
|
||||
Scoring: mean residual variance scăzută → suspect AI.
|
||||
"""
|
||||
if gray.size == 0:
|
||||
return 0.5
|
||||
H, W = gray.shape
|
||||
if H < 64 or W < 64:
|
||||
return 0.5 # imagini prea mici pentru piramidă fiabilă
|
||||
|
||||
# Construim piramidă Gaussian
|
||||
g = gray.astype(np.uint8)
|
||||
pyramid = [g]
|
||||
for _ in range(3):
|
||||
if pyramid[-1].shape[0] < 32 or pyramid[-1].shape[1] < 32:
|
||||
break
|
||||
pyramid.append(cv2.pyrDown(pyramid[-1]))
|
||||
if len(pyramid) < 2:
|
||||
return 0.5
|
||||
|
||||
# Resize toate la dimensiunea primului nivel pentru comparație
|
||||
target_h, target_w = pyramid[0].shape
|
||||
full = [pyramid[0].astype(np.float64)]
|
||||
for p in pyramid[1:]:
|
||||
resized = cv2.resize(p, (target_w, target_h),
|
||||
interpolation=cv2.INTER_LINEAR).astype(np.float64)
|
||||
full.append(resized)
|
||||
|
||||
# Reziduuri cross-scale + varianța locală 3×3
|
||||
residuals_var = []
|
||||
for i in range(len(full) - 1):
|
||||
r = full[i] - full[i + 1]
|
||||
local_var = cv2.blur(r ** 2, (3, 3))
|
||||
# Eliminăm coada — folosim mediana ca estimator robust
|
||||
med = float(np.median(local_var))
|
||||
residuals_var.append(med)
|
||||
|
||||
mean_var = float(np.mean(residuals_var))
|
||||
# Empiric (după testare):
|
||||
# Real natural: mean_var ~5-30 (fluctuație texturală inter-scale)
|
||||
# AI generated: mean_var ~0.5-4 (predicții smooth)
|
||||
# Sigmoid centrat pe NPR_DECISION_BOUNDARY=3.5 — sub asta = AI suspect
|
||||
return _sigmoid_inverted(mean_var, NPR_DECISION_BOUNDARY,
|
||||
steepness=0.4)
|
||||
|
||||
|
||||
def _jpeg_recon_score(bgr: np.ndarray, quality: int = 65) -> float:
|
||||
"""
|
||||
Re-encoding JPEG (in-memory, fără disk I/O). Returnează scor 0..1
|
||||
unde 1 = AI suspect. Imagini cu detalii naturale produc eroare mai
|
||||
mare la recompresie.
|
||||
"""
|
||||
if bgr.size == 0:
|
||||
return 0.5
|
||||
# Encode + decode in-memory (cv2.imencode/imdecode pe buffer)
|
||||
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
if not ok:
|
||||
return 0.5
|
||||
recompressed = cv2.imdecode(buf, cv2.IMREAD_COLOR)
|
||||
if recompressed is None:
|
||||
return 0.5
|
||||
diff = cv2.absdiff(bgr, recompressed).astype(np.float64)
|
||||
std_orig = float(bgr.astype(np.float64).std()) + 1e-9
|
||||
error_norm = float(diff.mean()) / std_orig
|
||||
return _sigmoid_inverted(error_norm, JPEG_DECISION_BOUNDARY,
|
||||
steepness=80.0)
|
||||
|
||||
|
||||
# Singleton HF pipeline — cache între apeluri ale lui run() ca să nu
|
||||
# reîncarcăm ViT 330MB de fiecare dată.
|
||||
_hf_pipeline_cache = {"pipe": None, "model_id": None, "load_error": None}
|
||||
|
||||
# DEZACTIVAT DEFAULT: HF detector (Organika/sdxl-detector) testat live pe
|
||||
# 30 samples — regresie semnificativă pe video real (false positive masiv).
|
||||
# Modelul e out-of-distribution pe video screenshots (antrenat pe SD images
|
||||
# vs photos). Cod păstrat pentru când se găsește un model mai bun calibrat
|
||||
# pe video. Activează cu ENV: M27_USE_HF=1
|
||||
HF_DETECTOR_ENABLED = os.environ.get("M27_USE_HF", "0") in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _try_hf_detector(
|
||||
bgr_frames: list[np.ndarray],
|
||||
model_id: str = "Organika/sdxl-detector",
|
||||
) -> tuple[list[float] | None, str]:
|
||||
if not HF_DETECTOR_ENABLED:
|
||||
return None, "HF detector disabled (set M27_USE_HF=1 pentru a activa)"
|
||||
|
||||
"""
|
||||
Încearcă să încarce un model HuggingFace AI detector (ViT) și să producă
|
||||
scor 0..1 per frame unde 1 = AI generated.
|
||||
|
||||
Default: Organika/sdxl-detector — ViT-base finetuned pe Stable Diffusion
|
||||
XL outputs vs real images. ~330MB checkpoint, robust pe diffusion models.
|
||||
|
||||
Returnează (lista scoruri 0..1, status_message). None dacă nu reușește.
|
||||
"""
|
||||
# Singleton — încărcăm o singură dată per proces
|
||||
if _hf_pipeline_cache["pipe"] is None and _hf_pipeline_cache["load_error"] is None:
|
||||
try:
|
||||
from transformers import pipeline # type: ignore
|
||||
except ImportError:
|
||||
_hf_pipeline_cache["load_error"] = "transformers nu e instalat"
|
||||
return None, _hf_pipeline_cache["load_error"]
|
||||
try:
|
||||
_hf_pipeline_cache["pipe"] = pipeline(
|
||||
"image-classification",
|
||||
model=model_id,
|
||||
device=-1, # CPU
|
||||
)
|
||||
_hf_pipeline_cache["model_id"] = model_id
|
||||
except Exception as e:
|
||||
_hf_pipeline_cache["load_error"] = f"HF model load failed: {e}"
|
||||
return None, _hf_pipeline_cache["load_error"]
|
||||
|
||||
if _hf_pipeline_cache["load_error"]:
|
||||
return None, _hf_pipeline_cache["load_error"]
|
||||
|
||||
pipe = _hf_pipeline_cache["pipe"]
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return None, "PIL nu e instalat"
|
||||
|
||||
scores = []
|
||||
try:
|
||||
for bgr in bgr_frames:
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
pil = Image.fromarray(rgb)
|
||||
result = pipe(pil)
|
||||
# Result: lista de {"label": str, "score": float}
|
||||
# Căutăm clasa AI/fake/artificial — etichetele variază pe modele.
|
||||
fake_score = 0.5
|
||||
for r in result:
|
||||
lbl = r.get("label", "").lower()
|
||||
if any(k in lbl for k in ("ai", "fake", "artificial", "generated", "synthetic")):
|
||||
fake_score = float(r.get("score", 0.5))
|
||||
break
|
||||
# Pe modele cu "real" / "human" / "natural" ca clasă pozitivă,
|
||||
# inversăm: 1 - score_real
|
||||
if any(k in lbl for k in ("real", "human", "natural")):
|
||||
fake_score = 1.0 - float(r.get("score", 0.5))
|
||||
break
|
||||
scores.append(fake_score)
|
||||
return scores, f"HF model loaded: {_hf_pipeline_cache['model_id']}"
|
||||
except Exception as e:
|
||||
return None, f"HF inference eșec: {e}"
|
||||
|
||||
|
||||
def _try_torch_model(model_path: str | None, bgr_frames: list[np.ndarray]
|
||||
) -> tuple[list[float] | None, str]:
|
||||
"""
|
||||
Încearcă să încarce un model torch pre-trained și să producă scor per frame.
|
||||
Returnează (lista scoruri 0..1, status_message). None dacă nu reușește.
|
||||
"""
|
||||
if not model_path:
|
||||
return None, "torch_model_path nu e setat"
|
||||
if not os.path.exists(model_path):
|
||||
return None, f"model path nu există: {model_path}"
|
||||
|
||||
try:
|
||||
import torch # type: ignore
|
||||
except ImportError:
|
||||
return None, "torch nu e instalat — folosește numai NPR + JPEG"
|
||||
|
||||
try:
|
||||
model = torch.load(model_path, map_location="cpu", weights_only=False)
|
||||
if hasattr(model, "eval"):
|
||||
model.eval()
|
||||
except Exception as e:
|
||||
return None, f"nu am putut încărca modelul: {e}"
|
||||
|
||||
scores = []
|
||||
try:
|
||||
with torch.no_grad():
|
||||
for bgr in bgr_frames:
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
# Resize la 224×224 (standard) și normalize ImageNet stats
|
||||
img = cv2.resize(rgb, (224, 224)).astype(np.float32) / 255.0
|
||||
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
||||
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
||||
img = (img - mean) / std
|
||||
tensor = torch.from_numpy(img.transpose(2, 0, 1)).unsqueeze(0).float()
|
||||
out = model(tensor)
|
||||
if hasattr(out, "logits"):
|
||||
out = out.logits
|
||||
if isinstance(out, (tuple, list)):
|
||||
out = out[0]
|
||||
# Sigmoid pe single output
|
||||
if out.numel() == 1:
|
||||
score = float(torch.sigmoid(out).item())
|
||||
elif out.numel() == 2:
|
||||
# binary classifier cu 2 clase: idx 1 = fake
|
||||
score = float(torch.softmax(out, dim=-1)[0, 1].item())
|
||||
else:
|
||||
return None, f"output model neașteptat: shape={out.shape}"
|
||||
scores.append(score)
|
||||
return scores, f"model loaded from {os.path.basename(model_path)}"
|
||||
except Exception as e:
|
||||
return None, f"inference eșec: {e}"
|
||||
|
||||
|
||||
def _save_score_timeline(scores_per_frame: list[dict[str, float]],
|
||||
out_path: str) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
if not scores_per_frame:
|
||||
return
|
||||
n = len(scores_per_frame)
|
||||
x = np.arange(n)
|
||||
npr = [d.get("npr", 0.0) for d in scores_per_frame]
|
||||
jpg = [d.get("jpeg", 0.0) for d in scores_per_frame]
|
||||
torch_scr = [d.get("torch") for d in scores_per_frame]
|
||||
has_torch = any(s is not None for s in torch_scr)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 4))
|
||||
ax.plot(x, npr, label="NPR score", color="#c0392b", marker="o", markersize=3)
|
||||
ax.plot(x, jpg, label="JPEG recon score", color="#2980b9", marker="s", markersize=3)
|
||||
if has_torch:
|
||||
torch_clean = [s if s is not None else np.nan for s in torch_scr]
|
||||
ax.plot(x, torch_clean, label="Torch model score", color="#27ae60",
|
||||
marker="^", markersize=3)
|
||||
ax.axhline(0.5, color="gray", linestyle="--", alpha=0.5)
|
||||
ax.axhline(0.65, color="red", linestyle=":", alpha=0.4, label="Suspect threshold")
|
||||
ax.set_xlabel("Cadru")
|
||||
ax.set_ylabel("Score (0=real, 1=AI)")
|
||||
ax.set_title("AI detector scores per frame")
|
||||
ax.set_ylim(-0.05, 1.05)
|
||||
ax.legend()
|
||||
ax.grid(alpha=0.3)
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _compute_score_and_evidence(
|
||||
npr_mean: float, jpeg_mean: float,
|
||||
torch_mean: float | None, hf_mean: float | None,
|
||||
n_frames: int,
|
||||
) -> tuple[float, float, list[str]]:
|
||||
"""
|
||||
Strategie:
|
||||
1. Dacă există HF detector (Organika/sdxl-detector ViT) → folosim
|
||||
ca primary, NPR/JPEG ca corroborare opțională.
|
||||
2. Dacă există torch custom → folosim ca primary.
|
||||
3. Dacă doar NPR/JPEG → folosim max (vechi behavior).
|
||||
"""
|
||||
evidence: list[str] = []
|
||||
|
||||
if hf_mean is not None:
|
||||
# ATENȚIE: HF model (Organika/sdxl-detector) e antrenat pe imagini SD
|
||||
# vs photos, NU pe video screenshots. Pe video real produce frecvent
|
||||
# false positive (HF=0.95 pe TV news real). Folosim defensiv:
|
||||
# - HF foarte încrezător în AI (>=0.85) → contează 50% (semnal puternic)
|
||||
# - HF foarte încrezător în REAL (<=0.15) → contează 50%
|
||||
# - HF în mijloc (0.15-0.85) → DEZACTIVAT, doar context vizual
|
||||
# NPR/JPEG rămân majoritatea ponderii ca să compensăm out-of-distribution.
|
||||
hf_confident = hf_mean >= 0.85 or hf_mean <= 0.15
|
||||
if hf_confident:
|
||||
primary = 0.50 * hf_mean + 0.30 * npr_mean + 0.20 * jpeg_mean
|
||||
confidence_factor = 0.4
|
||||
else:
|
||||
# HF unsure → cad înapoi pe NPR/JPEG majoritar, HF doar context
|
||||
primary = 0.20 * hf_mean + 0.45 * npr_mean + 0.35 * jpeg_mean
|
||||
confidence_factor = 0.15
|
||||
|
||||
if hf_mean >= 0.85:
|
||||
evidence.append(f"HF AI detector: {hf_mean:.2f} (high confidence AI — used)")
|
||||
elif hf_mean <= 0.15:
|
||||
evidence.append(f"HF AI detector: {hf_mean:.2f} (high confidence REAL — used)")
|
||||
else:
|
||||
evidence.append(f"HF AI detector: {hf_mean:.2f} (uncertain — downweighted)")
|
||||
evidence.append(f"NPR statistical: {npr_mean:.2f}, JPEG-recon: {jpeg_mean:.2f}")
|
||||
confidence = min(1.0, n_frames / 10.0 + confidence_factor)
|
||||
elif torch_mean is not None:
|
||||
primary = 0.7 * torch_mean + 0.2 * npr_mean + 0.1 * jpeg_mean
|
||||
evidence.append(f"Torch model: {torch_mean:.2f}")
|
||||
evidence.append(f"NPR statistical: {npr_mean:.2f}, JPEG-recon: {jpeg_mean:.2f}")
|
||||
confidence = min(1.0, n_frames / 10.0 + 0.3)
|
||||
else:
|
||||
# Fallback statistical-only — comportament v6
|
||||
components = [("NPR", npr_mean), ("JPEG-recon", jpeg_mean)]
|
||||
scored = sorted(components, key=lambda x: x[1], reverse=True)
|
||||
primary = scored[0][1]
|
||||
primary_name = scored[0][0]
|
||||
if primary >= 0.65:
|
||||
evidence.append(f"{primary_name}: {primary:.2f} (above 0.65 = AI suspect)")
|
||||
elif primary >= 0.4:
|
||||
evidence.append(f"{primary_name}: {primary:.2f} (intermediate)")
|
||||
else:
|
||||
evidence.append(f"{primary_name}: {primary:.2f} (below 0.40 = natural)")
|
||||
for name, val in scored[1:]:
|
||||
evidence.append(f"{name}: {val:.2f}")
|
||||
evidence.append("HF detector + torch model indisponibile — folosit doar NPR + JPEG")
|
||||
confidence = min(1.0, n_frames / 10.0)
|
||||
|
||||
return float(primary), float(confidence), evidence[:5]
|
||||
|
||||
|
||||
def run(frame_paths: list[str], results_dir: str | None = None) -> dict[str, Any]:
|
||||
t_start = time.perf_counter()
|
||||
images_dir = None
|
||||
if results_dir:
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if not frame_paths:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Nu s-au primit frame paths",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# Citește parametri din JSON config dacă există (relativ la modulul curent)
|
||||
params = {
|
||||
"npr_scales": [3, 5, 7],
|
||||
"jpeg_recon_quality": 65,
|
||||
"torch_model_path": None,
|
||||
}
|
||||
|
||||
npr_scores: list[float] = []
|
||||
jpeg_scores: list[float] = []
|
||||
bgr_frames: list[np.ndarray] = []
|
||||
per_frame: list[dict[str, Any]] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# Sample max 16 frame-uri uniform pentru cost rezonabil
|
||||
n_total = len(frame_paths)
|
||||
if n_total > 16:
|
||||
step = n_total / 16
|
||||
sample_indices = [int(i * step) for i in range(16)]
|
||||
else:
|
||||
sample_indices = list(range(n_total))
|
||||
|
||||
for idx in sample_indices:
|
||||
fpath = frame_paths[idx]
|
||||
bgr = cv2.imread(fpath)
|
||||
if bgr is None:
|
||||
per_frame.append({"frame_index": idx, "signal_present": False})
|
||||
continue
|
||||
h, w = bgr.shape[:2]
|
||||
if h < 64 or w < 64:
|
||||
warnings.append(f"frame {idx} prea mic ({w}×{h})")
|
||||
continue
|
||||
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
try:
|
||||
npr = _npr_score(gray, params["npr_scales"])
|
||||
jpg = _jpeg_recon_score(bgr, params["jpeg_recon_quality"])
|
||||
except Exception as e:
|
||||
errors.append(f"frame {idx}: {e}")
|
||||
continue
|
||||
|
||||
npr_scores.append(npr)
|
||||
jpeg_scores.append(jpg)
|
||||
bgr_frames.append(bgr)
|
||||
per_frame.append({
|
||||
"frame_index": idx,
|
||||
"signal_present": True,
|
||||
"npr_score": round(npr, 4),
|
||||
"jpeg_recon_score": round(jpg, 4),
|
||||
})
|
||||
|
||||
if not npr_scores:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Niciun cadru valid pentru analiză",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# 1) HF pretrained detector — sursa primară când e disponibil
|
||||
hf_scores, hf_status = _try_hf_detector(bgr_frames)
|
||||
if hf_scores is not None:
|
||||
valid_indices = [j for j, p in enumerate(per_frame) if p.get("signal_present")]
|
||||
for j, hs in zip(valid_indices, hf_scores):
|
||||
per_frame[j]["hf_score"] = round(hs, 4)
|
||||
else:
|
||||
warnings.append(f"HF detector: {hf_status}")
|
||||
|
||||
# 2) Torch checkpoint custom (dacă e setat în config)
|
||||
torch_scores, torch_status = _try_torch_model(
|
||||
params["torch_model_path"], bgr_frames
|
||||
)
|
||||
if torch_scores is None:
|
||||
warnings.append(torch_status)
|
||||
else:
|
||||
valid_indices = [j for j, p in enumerate(per_frame) if p.get("signal_present")]
|
||||
for j, ts in zip(valid_indices, torch_scores):
|
||||
per_frame[j]["torch_score"] = round(ts, 4)
|
||||
|
||||
npr_mean = float(np.mean(npr_scores))
|
||||
jpeg_mean = float(np.mean(jpeg_scores))
|
||||
torch_mean = float(np.mean(torch_scores)) if torch_scores else None
|
||||
hf_mean = float(np.mean(hf_scores)) if hf_scores else None
|
||||
|
||||
score, confidence, evidence = _compute_score_and_evidence(
|
||||
npr_mean=npr_mean, jpeg_mean=jpeg_mean,
|
||||
torch_mean=torch_mean, hf_mean=hf_mean,
|
||||
n_frames=len(npr_scores),
|
||||
)
|
||||
|
||||
artifacts: list[str] = []
|
||||
if images_dir:
|
||||
scores_for_plot = [
|
||||
{
|
||||
"npr": p.get("npr_score", 0.0),
|
||||
"jpeg": p.get("jpeg_recon_score", 0.0),
|
||||
"torch": p.get("torch_score"),
|
||||
}
|
||||
for p in per_frame if p.get("signal_present")
|
||||
]
|
||||
if scores_for_plot:
|
||||
name = "m27_score_timeline.png"
|
||||
_save_score_timeline(scores_for_plot, os.path.join(images_dir, name))
|
||||
artifacts.append(name)
|
||||
|
||||
summary_extras = {
|
||||
"hf_score_mean": round(hf_mean, 4) if hf_mean is not None else None,
|
||||
"hf_score_std": round(float(np.std(hf_scores)), 4) if hf_scores else None,
|
||||
"hf_model_loaded": hf_scores is not None,
|
||||
"hf_model_status": hf_status,
|
||||
"npr_score_mean": round(npr_mean, 4),
|
||||
"npr_score_std": round(float(np.std(npr_scores)), 4),
|
||||
"jpeg_recon_score_mean": round(jpeg_mean, 4),
|
||||
"jpeg_recon_score_std": round(float(np.std(jpeg_scores)), 4),
|
||||
"torch_score_mean": round(torch_mean, 4) if torch_mean is not None else None,
|
||||
"torch_model_loaded": torch_scores is not None,
|
||||
"torch_model_status": torch_status,
|
||||
"n_frames_sampled": len(npr_scores),
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"npr_scales_used": params["npr_scales"],
|
||||
"jpeg_recon_quality": params["jpeg_recon_quality"],
|
||||
"npr_decision_boundary": NPR_DECISION_BOUNDARY,
|
||||
"jpeg_decision_boundary": JPEG_DECISION_BOUNDARY,
|
||||
}
|
||||
|
||||
return make_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
primary_score=score, confidence=confidence, evidence=evidence,
|
||||
frames_analyzed=len(sample_indices),
|
||||
frames_with_signal=len(npr_scores),
|
||||
summary_extras=summary_extras,
|
||||
per_frame=per_frame,
|
||||
metrics=metrics,
|
||||
artifacts_images=artifacts,
|
||||
errors=errors, warnings=warnings,
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"id": "m28",
|
||||
"name": "Forgery Localization Heatmap",
|
||||
"description": "Detectează blending boundaries (Face X-ray-inspired): produce o hartă 2D unde fiecare pixel are probabilitate de manipulare locală. Detectează discontinuități multi-scală pe perimetru față, frecvențiale între interior și exterior, plus inconsistențe pe gradient.",
|
||||
"category": "localization",
|
||||
"input_type": "overview_frames",
|
||||
"module": "forgery_heatmap",
|
||||
"function": "run",
|
||||
"run_order": 28,
|
||||
"enabled": true,
|
||||
"always_run": true,
|
||||
"parameters": {
|
||||
"boundary_band_px": 12,
|
||||
"scales": [3, 7, 15],
|
||||
"freq_split_ratio": 0.15
|
||||
},
|
||||
"thresholds": {
|
||||
"peak_suspicion_real_max": 0.45,
|
||||
"peak_suspicion_suspicious": 0.65,
|
||||
"description": "Peak suspicion < 0.45 = nicio zonă suspectă semnificativă. Peak > 0.65 = zonă cu boundary blending detectat."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,501 @@
|
|||
"""
|
||||
m28 — Forgery Localization Heatmap (Face X-ray-Inspired)
|
||||
|
||||
WHAT IT DOES:
|
||||
Produce o hartă 2D (heatmap H×W) unde fiecare pixel are probabilitate de
|
||||
manipulare locală. Spre deosebire de modulele care întorc un singur scor
|
||||
pentru întregul cadru, m28 spune UNDE anume e tampered. Asta e util în
|
||||
primul rând pentru LLM: îi dai imaginea + heatmap-ul și-i ceri să
|
||||
confirme vizual zona de pe care heatmap-ul indică.
|
||||
|
||||
Inspirat din Face X-ray (Li et al. 2020), simplificat ca să ruleze pe CPU
|
||||
fără rețea pre-trained: detectează blending boundaries prin discontinuități
|
||||
multi-scală pe gradient, plus inconsistențe spectrale între interior față
|
||||
și ring-ul peripheric.
|
||||
|
||||
HOW IT WORKS:
|
||||
1. Detectează fața cu MediaPipe FaceMesh (478 landmarks) sau Haar fallback.
|
||||
Construiește mască poligonală conturul exterior al feței.
|
||||
|
||||
2. Calculează 3 hărți de "discontinuitate":
|
||||
|
||||
(a) Multi-scale Laplacian discrepancy:
|
||||
Pentru fiecare scală s din {3, 7, 15} aplicăm Laplacian la scală.
|
||||
Pentru fiecare pixel de pe boundary band (12 px de la conturul mască),
|
||||
comparăm valoarea Laplacian în interiorul și exteriorul măștii la
|
||||
distanță s. Discontinuitatea normală pentru piele→fundal e
|
||||
similară pe scale; o mască de blending generează scale-dependence
|
||||
care diferă. Discrepanță = 1 - corelația răspunsurilor multi-scală.
|
||||
|
||||
(b) Frequency-domain split inconsistency:
|
||||
Pe ROI lărgit cu boundary band, FFT 2D, split în low/high frequency
|
||||
(cutoff 15% rază). Reconstruim DOAR din high-freq → vedem zonele
|
||||
cu detalii. Pe blending boundary, raportul high/low fluctuează
|
||||
anormal față de zone naturale.
|
||||
|
||||
(c) Color / chrominance step:
|
||||
În spațiu LAB, gradient pe canalele a și b (chrominanță). Pe
|
||||
blending boundary, există adesea step de chrominanță fără
|
||||
tranziție de luminanță corespunzătoare.
|
||||
|
||||
3. Compunem heatmap = max(a, b, c) după normalizare. Aplicăm Gaussian blur
|
||||
să netezim, apoi reportăm:
|
||||
- peak_suspicion: max(heatmap)
|
||||
- peak_zone: bounding box jurul vârfului
|
||||
- mean_boundary_suspicion: media pe boundary band
|
||||
|
||||
4. Salvăm heatmap suprapus peste imaginea originală ca PNG.
|
||||
|
||||
WHY DETECTS DEEPFAKES:
|
||||
Face-swap clasic (DeepFaceLab, Roop, FaceFusion, InsightFace swappers) face:
|
||||
- Generează fața nouă într-o cutie
|
||||
- Aplică o mască Gaussian / poligonală pentru blending
|
||||
- Combină cu imaginea originală
|
||||
Boundary-ul măștii are inevitabil discontinuități de:
|
||||
- Frecvență (interiorul mai smooth ca exteriorul)
|
||||
- Chrominanță (color matching imperfect)
|
||||
- Detalii multi-scală (mască Gaussian = scale-dependent)
|
||||
|
||||
WHAT THE OUTPUT MEANS:
|
||||
primary_score = peak_suspicion
|
||||
0.0–0.35 → REAL (nicio zonă suspectă)
|
||||
0.65–1.0 → FAKE (boundary blending detectat)
|
||||
|
||||
artifacts.images conține heatmap suprapus pe imagine pentru consum vizual
|
||||
de către LLM. LLM-ul poate confirma vizual ce indică harta.
|
||||
|
||||
Răspunsul respectă schema unificată din tools/CONTRACT.md.
|
||||
|
||||
LIMITATIONS:
|
||||
- Nu funcționează pe full-AI generated (nu există boundary). Pentru asta
|
||||
folosește m27.
|
||||
- Detectorul de față eșuează → heatmap NULL, primary_label=NO_SIGNAL.
|
||||
- Pe video puternic re-encodate, semnalul boundary slăbește.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
import preprocessing # noqa: E402
|
||||
from tools._contract import make_response, empty_response # noqa: E402
|
||||
|
||||
TOOL_ID = "m28"
|
||||
TOOL_NAME = "Forgery Localization Heatmap"
|
||||
VERSION = "1.0"
|
||||
INPUT_TYPE = "overview_frames"
|
||||
|
||||
# FaceMesh outline indices (MediaPipe canonical face oval contour)
|
||||
FACE_OVAL = [
|
||||
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365,
|
||||
379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93,
|
||||
234, 127, 162, 21, 54, 103, 67, 109,
|
||||
]
|
||||
|
||||
|
||||
def _try_face_mask_mediapipe(frame_bgr: np.ndarray
|
||||
) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
"""Returnează (mask uint8 H×W, contour np.array) folosind MediaPipe."""
|
||||
try:
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision as mp_vision
|
||||
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
model_path = os.path.join(root, "face_landmarker.task")
|
||||
if not os.path.exists(model_path):
|
||||
return None, None
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=model_path)
|
||||
options = mp_vision.FaceLandmarkerOptions(
|
||||
base_options=base_options,
|
||||
num_faces=1,
|
||||
running_mode=mp_vision.RunningMode.IMAGE,
|
||||
)
|
||||
landmarker = mp_vision.FaceLandmarker.create_from_options(options)
|
||||
|
||||
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
||||
result = landmarker.detect(mp_image)
|
||||
try:
|
||||
landmarker.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result.face_landmarks:
|
||||
return None, None
|
||||
|
||||
h, w = frame_bgr.shape[:2]
|
||||
lms = result.face_landmarks[0]
|
||||
contour = np.array(
|
||||
[[int(lms[i].x * w), int(lms[i].y * h)] for i in FACE_OVAL],
|
||||
dtype=np.int32,
|
||||
)
|
||||
mask = np.zeros((h, w), dtype=np.uint8)
|
||||
cv2.fillPoly(mask, [contour], 255)
|
||||
return mask, contour
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
|
||||
def _fallback_face_mask_haar(frame_bgr: np.ndarray
|
||||
) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
"""Fallback Haar: mask = elipsă în bbox detectat."""
|
||||
faces = preprocessing.detect_faces(frame_bgr)
|
||||
if not faces:
|
||||
return None, None
|
||||
x, y, w, h = faces[0]
|
||||
H, W = frame_bgr.shape[:2]
|
||||
mask = np.zeros((H, W), dtype=np.uint8)
|
||||
center = (x + w // 2, y + h // 2)
|
||||
axes = (max(8, w // 2), max(8, int(h * 0.6)))
|
||||
cv2.ellipse(mask, center, axes, 0, 0, 360, 255, -1)
|
||||
# Pseudo-contour pentru consistență
|
||||
contour = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)[0]
|
||||
contour = contour[0].reshape(-1, 2) if contour else None
|
||||
return mask, contour
|
||||
|
||||
|
||||
def _multiscale_laplacian_discrepancy(gray: np.ndarray, mask: np.ndarray,
|
||||
scales: list[int],
|
||||
boundary_band_px: int = 12) -> np.ndarray:
|
||||
"""
|
||||
Heatmap: discrepanță între răspunsurile Laplacian la scale diferite,
|
||||
măsurată cross-mask (interior vs exterior).
|
||||
"""
|
||||
H, W = gray.shape
|
||||
g = gray.astype(np.float64)
|
||||
|
||||
laps = []
|
||||
for s in scales:
|
||||
ks = max(3, s | 1) # impar
|
||||
blurred = cv2.GaussianBlur(g, (ks, ks), 0)
|
||||
lap = cv2.Laplacian(blurred, cv2.CV_64F, ksize=3)
|
||||
laps.append(lap)
|
||||
|
||||
# Heatmap = std cross-scale (per pixel) — zone unde Laplacian răspunde
|
||||
# diferit la scale diferite = blending boundary candidate
|
||||
stack = np.stack(laps, axis=0)
|
||||
cross_scale_std = stack.std(axis=0)
|
||||
|
||||
# Normalizare 0..1
|
||||
p99 = np.percentile(cross_scale_std, 99) + 1e-9
|
||||
norm = np.clip(cross_scale_std / p99, 0, 1)
|
||||
|
||||
# Limităm la boundary band (relativ la mărimea feței, nu fix 12 px)
|
||||
inv_mask = 255 - mask
|
||||
boundary_dist_in = cv2.distanceTransform(mask, cv2.DIST_L2, 3)
|
||||
boundary_dist_out = cv2.distanceTransform(inv_mask, cv2.DIST_L2, 3)
|
||||
band = ((boundary_dist_in <= boundary_band_px)
|
||||
| (boundary_dist_out <= boundary_band_px)).astype(np.float64)
|
||||
|
||||
return (norm * band).astype(np.float64)
|
||||
|
||||
|
||||
def _frequency_split_inconsistency(gray: np.ndarray, mask: np.ndarray,
|
||||
cutoff_ratio: float = 0.15,
|
||||
boundary_band_px: int = 12) -> np.ndarray:
|
||||
"""
|
||||
High-frequency map. Zone unde înaltele frecvențe sunt locale anormal
|
||||
(interior smooth + exterior bogat în textură = blending suspect).
|
||||
"""
|
||||
g = gray.astype(np.float64)
|
||||
H, W = g.shape
|
||||
|
||||
# FFT global
|
||||
f = np.fft.fft2(g)
|
||||
f_shift = np.fft.fftshift(f)
|
||||
cy, cx = H // 2, W // 2
|
||||
r = int(min(H, W) * cutoff_ratio)
|
||||
y, x = np.ogrid[-cy:H - cy, -cx:W - cx]
|
||||
mask_low = (x ** 2 + y ** 2 <= r ** 2)
|
||||
|
||||
# High-pass
|
||||
f_high = np.where(mask_low, 0, f_shift)
|
||||
high_img = np.abs(np.fft.ifft2(np.fft.ifftshift(f_high)))
|
||||
|
||||
# Locală: smoothing pe high-freq density → unde se schimbă
|
||||
high_local = cv2.GaussianBlur(high_img, (15, 15), 0)
|
||||
p99 = np.percentile(high_local, 99) + 1e-9
|
||||
high_norm = np.clip(high_local / p99, 0, 1)
|
||||
|
||||
# Step pe boundary: gradient pe high_norm
|
||||
grad_x = cv2.Sobel(high_norm, cv2.CV_64F, 1, 0, ksize=3)
|
||||
grad_y = cv2.Sobel(high_norm, cv2.CV_64F, 0, 1, ksize=3)
|
||||
grad_mag = np.sqrt(grad_x ** 2 + grad_y ** 2)
|
||||
p99g = np.percentile(grad_mag, 99) + 1e-9
|
||||
grad_norm = np.clip(grad_mag / p99g, 0, 1)
|
||||
|
||||
# Limităm la boundary band
|
||||
inv_mask = 255 - mask
|
||||
boundary_dist_in = cv2.distanceTransform(mask, cv2.DIST_L2, 3)
|
||||
boundary_dist_out = cv2.distanceTransform(inv_mask, cv2.DIST_L2, 3)
|
||||
band = ((boundary_dist_in <= boundary_band_px)
|
||||
| (boundary_dist_out <= boundary_band_px)).astype(np.float64)
|
||||
|
||||
return (grad_norm * band).astype(np.float64)
|
||||
|
||||
|
||||
def _chroma_step_map(bgr: np.ndarray, mask: np.ndarray,
|
||||
boundary_band_px: int = 12) -> np.ndarray:
|
||||
"""Gradient pe canalele a, b din LAB. Zone cu step chromatic = blending."""
|
||||
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB).astype(np.float64)
|
||||
a = lab[:, :, 1]
|
||||
b = lab[:, :, 2]
|
||||
grad_a = np.sqrt(cv2.Sobel(a, cv2.CV_64F, 1, 0, ksize=3) ** 2
|
||||
+ cv2.Sobel(a, cv2.CV_64F, 0, 1, ksize=3) ** 2)
|
||||
grad_b = np.sqrt(cv2.Sobel(b, cv2.CV_64F, 1, 0, ksize=3) ** 2
|
||||
+ cv2.Sobel(b, cv2.CV_64F, 0, 1, ksize=3) ** 2)
|
||||
chroma_grad = (grad_a + grad_b) / 2.0
|
||||
p99 = np.percentile(chroma_grad, 99) + 1e-9
|
||||
norm = np.clip(chroma_grad / p99, 0, 1)
|
||||
|
||||
inv_mask = 255 - mask
|
||||
boundary_dist_in = cv2.distanceTransform(mask, cv2.DIST_L2, 3)
|
||||
boundary_dist_out = cv2.distanceTransform(inv_mask, cv2.DIST_L2, 3)
|
||||
band = ((boundary_dist_in <= boundary_band_px)
|
||||
| (boundary_dist_out <= boundary_band_px)).astype(np.float64)
|
||||
|
||||
return (norm * band).astype(np.float64)
|
||||
|
||||
|
||||
def _save_heatmap_overlay(bgr: np.ndarray, heatmap: np.ndarray,
|
||||
out_path: str, title: str = "") -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
|
||||
axes[0].imshow(rgb)
|
||||
axes[0].set_title("Original")
|
||||
axes[0].axis("off")
|
||||
axes[1].imshow(rgb)
|
||||
axes[1].imshow(heatmap, cmap="hot", alpha=0.55, vmin=0, vmax=1)
|
||||
axes[1].set_title(f"Forgery heatmap {title}")
|
||||
axes[1].axis("off")
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _heatmap_peak_zone(heatmap: np.ndarray) -> dict[str, Any]:
|
||||
"""Identifică bounding box-ul zonei cu maximă suspiciune."""
|
||||
if heatmap.max() < 1e-6:
|
||||
return {"peak_value": 0.0, "peak_x": None, "peak_y": None,
|
||||
"peak_bbox": None}
|
||||
threshold = max(0.6, heatmap.max() * 0.7)
|
||||
binary = (heatmap >= threshold).astype(np.uint8)
|
||||
if binary.sum() == 0:
|
||||
peak_y, peak_x = np.unravel_index(int(np.argmax(heatmap)), heatmap.shape)
|
||||
return {"peak_value": float(heatmap.max()),
|
||||
"peak_x": int(peak_x), "peak_y": int(peak_y),
|
||||
"peak_bbox": None}
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
largest = max(contours, key=cv2.contourArea)
|
||||
x, y, w, h = cv2.boundingRect(largest)
|
||||
peak_y, peak_x = np.unravel_index(int(np.argmax(heatmap)), heatmap.shape)
|
||||
return {
|
||||
"peak_value": float(heatmap.max()),
|
||||
"peak_x": int(peak_x), "peak_y": int(peak_y),
|
||||
"peak_bbox": [int(x), int(y), int(w), int(h)],
|
||||
}
|
||||
|
||||
|
||||
def run(frame_paths: list[str], results_dir: str | None = None) -> dict[str, Any]:
|
||||
t_start = time.perf_counter()
|
||||
images_dir = None
|
||||
if results_dir:
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if not frame_paths:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE, reason="Nu s-au primit frame paths",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
# Sample max 5 cadre uniform (heatmap-ul e expensive)
|
||||
n_total = len(frame_paths)
|
||||
if n_total > 5:
|
||||
step = n_total / 5
|
||||
sample_indices = [int(i * step) for i in range(5)]
|
||||
else:
|
||||
sample_indices = list(range(n_total))
|
||||
|
||||
per_frame: list[dict[str, Any]] = []
|
||||
artifacts: list[str] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
peak_values: list[float] = []
|
||||
boundary_means: list[float] = []
|
||||
used_mediapipe = False
|
||||
|
||||
for idx in sample_indices:
|
||||
fpath = frame_paths[idx]
|
||||
bgr = cv2.imread(fpath)
|
||||
if bgr is None:
|
||||
per_frame.append({"frame_index": idx, "signal_present": False})
|
||||
continue
|
||||
H, W = bgr.shape[:2]
|
||||
|
||||
mask, contour = _try_face_mask_mediapipe(bgr)
|
||||
if mask is None:
|
||||
mask, contour = _fallback_face_mask_haar(bgr)
|
||||
else:
|
||||
used_mediapipe = True
|
||||
|
||||
rec: dict[str, Any] = {"frame_index": idx, "signal_present": False}
|
||||
if mask is None:
|
||||
warnings.append(f"frame {idx}: face nedetectată")
|
||||
per_frame.append(rec)
|
||||
continue
|
||||
|
||||
try:
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
# Boundary band adaptiv la mărimea feței: 5% din face_width.
|
||||
# Față mare (close-up) → band mai gros; față mică → band mai subțire.
|
||||
ys_m, xs_m = np.where(mask > 0)
|
||||
if len(xs_m) > 0:
|
||||
fw = int(xs_m.max() - xs_m.min())
|
||||
bb = max(8, min(40, int(fw * 0.05)))
|
||||
else:
|
||||
bb = 12
|
||||
|
||||
map_lap = _multiscale_laplacian_discrepancy(gray, mask, [3, 7, 15], bb)
|
||||
map_freq = _frequency_split_inconsistency(gray, mask, 0.15, bb)
|
||||
map_chroma = _chroma_step_map(bgr, mask, bb)
|
||||
|
||||
# Compunere PONDERATĂ (nu max):
|
||||
# chroma e cel mai puternic semnal real pe blending boundary
|
||||
# freq e secundar
|
||||
# laplacian e ultim — texture noise oricum
|
||||
# max() saturează ușor pe outlier-i; medie ponderată e stabilă.
|
||||
combined = (
|
||||
0.5 * map_chroma +
|
||||
0.3 * map_freq +
|
||||
0.2 * map_lap
|
||||
)
|
||||
combined = cv2.GaussianBlur(combined, (9, 9), 0)
|
||||
# Sigmoid pentru tranziție smooth, nu clip brutal:
|
||||
# combined < 0.4 → ~0 (ne-suspect)
|
||||
# combined > 0.6 → ~1 (suspect)
|
||||
heatmap = 1.0 / (1.0 + np.exp(-6.0 * (combined - 0.5)))
|
||||
except Exception as e:
|
||||
errors.append(f"frame {idx}: heatmap compute error: {e}")
|
||||
per_frame.append(rec)
|
||||
continue
|
||||
|
||||
zone = _heatmap_peak_zone(heatmap)
|
||||
peak_values.append(zone["peak_value"])
|
||||
|
||||
# Mean pe boundary band
|
||||
inv = 255 - mask
|
||||
b_in = cv2.distanceTransform(mask, cv2.DIST_L2, 3)
|
||||
b_out = cv2.distanceTransform(inv, cv2.DIST_L2, 3)
|
||||
band = (b_in <= 12) | (b_out <= 12)
|
||||
boundary_mean = float(heatmap[band].mean()) if band.any() else 0.0
|
||||
boundary_means.append(boundary_mean)
|
||||
|
||||
rec.update({
|
||||
"signal_present": True,
|
||||
"peak_suspicion": round(zone["peak_value"], 4),
|
||||
"peak_x": zone["peak_x"],
|
||||
"peak_y": zone["peak_y"],
|
||||
"peak_bbox": zone["peak_bbox"],
|
||||
"boundary_mean_suspicion": round(boundary_mean, 4),
|
||||
})
|
||||
per_frame.append(rec)
|
||||
|
||||
if images_dir:
|
||||
name = f"m28_heatmap_{idx:04d}.png"
|
||||
_save_heatmap_overlay(
|
||||
bgr, heatmap, os.path.join(images_dir, name),
|
||||
title=f"(peak={zone['peak_value']:.2f})",
|
||||
)
|
||||
artifacts.append(name)
|
||||
|
||||
if not peak_values:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Față nedetectată în niciun cadru",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
peak_max = float(np.max(peak_values))
|
||||
peak_mean = float(np.mean(peak_values))
|
||||
boundary_mean_overall = float(np.mean(boundary_means))
|
||||
|
||||
# Score: combinăm peak și boundary_mean ca să avem signal pe imagini
|
||||
# statice (unde peak e relevant) ȘI pe video (unde boundary_mean e
|
||||
# mai stabil cross-frame).
|
||||
#
|
||||
# Calibrare empirică post-test:
|
||||
# Real natural video: boundary_mean ~0.05-0.15, peak ~0.4-0.8
|
||||
# Face-swap video: boundary_mean ~0.20-0.40, peak ~0.7-1.0
|
||||
# AI generated image: peak depinde foarte mult de boundary
|
||||
#
|
||||
# Combinăm: peak (40%) + boundary normalized (60%)
|
||||
bm_norm = float(min(1.0, max(0.0, (boundary_mean_overall - 0.05) / 0.20)))
|
||||
score = float(0.4 * peak_max + 0.6 * bm_norm)
|
||||
confidence = min(1.0, len(peak_values) / 5.0)
|
||||
if used_mediapipe:
|
||||
confidence = min(1.0, confidence + 0.2)
|
||||
|
||||
evidence: list[str] = []
|
||||
if score >= 0.65:
|
||||
evidence.append(
|
||||
f"Forgery score: {score:.2f} (peak={peak_max:.2f}, boundary_mean={boundary_mean_overall:.3f}) — blending boundary detected"
|
||||
)
|
||||
elif score >= 0.45:
|
||||
evidence.append(
|
||||
f"Forgery score: {score:.2f} (peak={peak_max:.2f}, boundary_mean={boundary_mean_overall:.3f}) — intermediate"
|
||||
)
|
||||
else:
|
||||
evidence.append(
|
||||
f"Forgery score: {score:.2f} (peak={peak_max:.2f}, boundary_mean={boundary_mean_overall:.3f}) — no significant blending"
|
||||
)
|
||||
|
||||
evidence.append(f"Frames with face: {len(peak_values)}/{len(sample_indices)}")
|
||||
if not used_mediapipe:
|
||||
evidence.append("MediaPipe lipsă, folosit Haar elliptical fallback (mask mai imprecisă)")
|
||||
|
||||
summary_extras = {
|
||||
"peak_suspicion_max": round(peak_max, 4),
|
||||
"peak_suspicion_mean": round(peak_mean, 4),
|
||||
"boundary_mean_suspicion": round(boundary_mean_overall, 4),
|
||||
"frames_with_face": len(peak_values),
|
||||
"mediapipe_used": used_mediapipe,
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"scales_used": [3, 7, 15],
|
||||
"boundary_band_px": 12,
|
||||
"freq_cutoff_ratio": 0.15,
|
||||
}
|
||||
|
||||
return make_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
primary_score=score, confidence=confidence, evidence=evidence,
|
||||
frames_analyzed=len(sample_indices),
|
||||
frames_with_signal=len(peak_values),
|
||||
summary_extras=summary_extras,
|
||||
per_frame=per_frame,
|
||||
metrics=metrics,
|
||||
artifacts_images=artifacts,
|
||||
errors=errors, warnings=warnings,
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "m29",
|
||||
"name": "Lighting 3D Consistency",
|
||||
"description": "Estimează direcția dominantă de iluminare pe față (din shading model Lambertian + normale 3DMM aproximate din FaceMesh) versus direcția dominantă de iluminare a scenei (din specular highlights detectate în fundal). Plus consistența catchlight-urilor între ochii stâng și drept.",
|
||||
"category": "lighting_3d",
|
||||
"input_type": "overview_frames",
|
||||
"module": "lighting",
|
||||
"function": "run",
|
||||
"run_order": 29,
|
||||
"enabled": true,
|
||||
"always_run": true,
|
||||
"parameters": {
|
||||
"highlight_percentile": 95,
|
||||
"min_highlight_clusters": 3,
|
||||
"catchlight_search_padding": 6
|
||||
},
|
||||
"thresholds": {
|
||||
"lighting_mismatch_real_max_deg": 60,
|
||||
"lighting_mismatch_suspicious_deg": 90,
|
||||
"catchlight_consistency_real_min": 0.6,
|
||||
"description": "Real video: face-vs-scene lighting mismatch < 60° azimuth, catchlight consistency > 0.6 (poziții similare în ambii ochi). Mismatch > 90° = subiect compus."
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,560 @@
|
|||
"""
|
||||
m29 — Lighting 3D Consistency
|
||||
|
||||
WHAT IT DOES:
|
||||
Verifică dacă fața și scena sunt iluminate de aceleași surse de lumină.
|
||||
Subiectul filmat real e iluminat de aceleași surse vizibile în fundal
|
||||
(ferestre, lămpi, soare). Un face-swap sau un compus pune o față
|
||||
iluminată dintr-o direcție într-o scenă cu lumină din altă direcție —
|
||||
inconsistență detectabilă matematic, dar dificil pentru ochi liber.
|
||||
|
||||
Plus catchlight consistency: sclipirile (specular highlights) din ochiul
|
||||
stâng și drept trebuie să provină din aceleași surse de lumină. AI
|
||||
generators eșuează adesea aici (poziții asimetrice, intensități diferite,
|
||||
număr de catchlights diferit).
|
||||
|
||||
HOW IT WORKS:
|
||||
1. Detectează 478 landmarks FaceMesh. Construiește mască poligonală.
|
||||
|
||||
2. Estimare direcție lumină pe față (Lambertian shape-from-shading
|
||||
simplificat):
|
||||
a. Se aproximează normalele 3D ale feței asumând o sferă cu
|
||||
centrul la centroidul feței și rază = jumătate din lățimea feței.
|
||||
N(x, y) = (x - cx, y - cy, sqrt(r² - (x-cx)² - (y-cy)²)) / norm
|
||||
b. Pe regiunea măștii fețe, intensitatea I(x,y) ≈ ρ * max(N · L, 0) + ambient
|
||||
c. Stivuim ecuațiile pentru toți pixelii (eșantion uniform):
|
||||
I = N · L + b
|
||||
unde I e intensitatea normalizată, N e normalele 3D, L e
|
||||
direcția dominantă de lumină (3D), b = ambient.
|
||||
d. Rezolvăm prin least squares: L = pinv(N) @ I
|
||||
e. L_normalized = L / ||L||
|
||||
f. Convertim în (azimuth, elevation) sferice.
|
||||
|
||||
3. Estimare direcție lumină scenă:
|
||||
a. Detectează specular highlights în zona NON-față (V > percentila 95
|
||||
în HSV, plus saturație scăzută).
|
||||
b. Aglomerează pixelii bright în clustere (DBSCAN simplu via connected
|
||||
components). Centroidul fiecărui cluster = sursă de lumină candidate.
|
||||
c. Direcția dominantă scenă: media ponderată a vectorilor de la
|
||||
centrul scenei către cluster-uri, cu greutate = intensitatea bright.
|
||||
d. Convertim în (azimuth, elevation), folosind elevation
|
||||
aproximată din poziția verticală relativă.
|
||||
|
||||
4. Catchlight consistency:
|
||||
a. Pe ROI ochi stâng și drept (din landmarks FaceMesh), detectăm
|
||||
punctele cele mai luminoase (top 1%) — catchlights.
|
||||
b. Comparăm: poziție relativă în ochi (centrul iris ca origin),
|
||||
intensitate, count.
|
||||
c. consistency = 1 - normalize(diff_position + diff_intensity * 0.3)
|
||||
|
||||
5. Compunere:
|
||||
primary_score = 0.6 * lighting_mismatch_score + 0.4 * (1 - catchlight_consistency)
|
||||
lighting_mismatch_score = clamp((angle_deg - 60) / 60, 0, 1)
|
||||
|
||||
WHY DETECTS DEEPFAKES:
|
||||
- Un face-swap mută o față dintr-un context de lumină în altul. AI
|
||||
"best-matchers" încearcă să compenseze prin shading retouch, dar
|
||||
consistența 3D a normalelor cu sursa de lumină reală e greu de
|
||||
produs sintetic.
|
||||
- Catchlight: pupila reflectă fix poziția surselor de lumină. Doi ochi
|
||||
din aceeași față trebuie să aibă reflexele aproape simetric oglindite
|
||||
(mici diferențe din unghi). AI generators uneori produc catchlights
|
||||
complet diferiți (StyleGAN, Stable Diffusion făceau asta în versiuni
|
||||
timpurii; modele moderne mai bune dar nu perfecte).
|
||||
|
||||
WHAT THE OUTPUT MEANS:
|
||||
primary_score = 0.0–0.35 → REAL (lumină consistentă față-scenă, catchlights OK)
|
||||
primary_score = 0.65–1.0 → FAKE (mismatch >90° sau catchlights inconsistenți)
|
||||
|
||||
artifacts.images conține o vizualizare cu săgeți: direcția estimată de
|
||||
lumină pentru față (roșu) și scenă (albastru), plus crop ochi cu
|
||||
catchlights marcați.
|
||||
|
||||
Răspunsul respectă schema unificată din tools/CONTRACT.md.
|
||||
|
||||
LIMITATIONS:
|
||||
- Shape-from-shading prin sferă aproximată e GROSIER. Pentru SOTA folosește
|
||||
un 3DMM real fittat (BFM2009, FLAME) — necesită eos-py sau similar.
|
||||
- Pe scene cu lumină ambientă uniformă (interior office, cer înnorat),
|
||||
direcția dominantă nu e bine definită → confidence scăzut.
|
||||
- Catchlight detection eșuează pe ochi închiși, ochelari, rezoluție mică.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from tools._contract import make_response, empty_response # noqa: E402
|
||||
|
||||
TOOL_ID = "m29"
|
||||
TOOL_NAME = "Lighting 3D Consistency"
|
||||
VERSION = "1.0"
|
||||
INPUT_TYPE = "overview_frames"
|
||||
|
||||
# Indices FaceMesh
|
||||
FACE_OVAL = [
|
||||
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365,
|
||||
379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93,
|
||||
234, 127, 162, 21, 54, 103, 67, 109,
|
||||
]
|
||||
LEFT_EYE_OUTLINE = [33, 160, 158, 133, 153, 144]
|
||||
RIGHT_EYE_OUTLINE = [362, 385, 387, 263, 373, 380]
|
||||
|
||||
|
||||
def _try_face_landmarks(frame_bgr: np.ndarray) -> np.ndarray | None:
|
||||
"""Returnează landmarks_xy (478, 2) din MediaPipe sau None."""
|
||||
try:
|
||||
import mediapipe as mp
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision as mp_vision
|
||||
|
||||
root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
model_path = os.path.join(root, "face_landmarker.task")
|
||||
if not os.path.exists(model_path):
|
||||
return None
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=model_path)
|
||||
options = mp_vision.FaceLandmarkerOptions(
|
||||
base_options=base_options,
|
||||
num_faces=1,
|
||||
running_mode=mp_vision.RunningMode.IMAGE,
|
||||
)
|
||||
landmarker = mp_vision.FaceLandmarker.create_from_options(options)
|
||||
|
||||
rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
|
||||
result = landmarker.detect(mp_image)
|
||||
try:
|
||||
landmarker.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result.face_landmarks:
|
||||
return None
|
||||
h, w = frame_bgr.shape[:2]
|
||||
lms = result.face_landmarks[0]
|
||||
return np.array([[lm.x * w, lm.y * h] for lm in lms], dtype=np.float64)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _estimate_face_light_direction(gray: np.ndarray, face_mask: np.ndarray,
|
||||
landmarks_xy: np.ndarray
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""
|
||||
Lambertian SfS simplificat. Returnează (azimuth_deg, elevation_deg, lux_norm)
|
||||
sau None dacă nu se poate fitta.
|
||||
"""
|
||||
H, W = gray.shape
|
||||
ys, xs = np.where(face_mask > 0)
|
||||
if len(xs) < 200:
|
||||
return None
|
||||
|
||||
# Eșantion uniform de pixeli (max 1500)
|
||||
if len(xs) > 1500:
|
||||
sel = np.random.choice(len(xs), size=1500, replace=False)
|
||||
xs, ys = xs[sel], ys[sel]
|
||||
|
||||
cx = float(landmarks_xy[:, 0].mean())
|
||||
cy = float(landmarks_xy[:, 1].mean())
|
||||
rx = (landmarks_xy[:, 0].max() - landmarks_xy[:, 0].min()) / 2.0
|
||||
ry = (landmarks_xy[:, 1].max() - landmarks_xy[:, 1].min()) / 2.0
|
||||
r = max(1.0, (rx + ry) / 2.0)
|
||||
|
||||
# Aproximare normale 3D (sferă)
|
||||
dx = (xs - cx) / r
|
||||
dy = (ys - cy) / r
|
||||
dz_sq = 1.0 - dx ** 2 - dy ** 2
|
||||
valid = dz_sq > 0.05
|
||||
dx, dy = dx[valid], dy[valid]
|
||||
dz = np.sqrt(dz_sq[valid])
|
||||
xs2, ys2 = xs[valid], ys[valid]
|
||||
|
||||
if len(dx) < 100:
|
||||
return None
|
||||
|
||||
N = np.stack([dx, dy, dz], axis=1) # (n, 3)
|
||||
I = gray[ys2, xs2].astype(np.float64) / 255.0 # (n,)
|
||||
|
||||
# Augmentăm cu coloană constantă pentru ambient
|
||||
A = np.concatenate([N, np.ones((len(N), 1))], axis=1) # (n, 4)
|
||||
try:
|
||||
coeffs, *_ = np.linalg.lstsq(A, I, rcond=None)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
L = coeffs[:3]
|
||||
norm = float(np.linalg.norm(L))
|
||||
if norm < 1e-6:
|
||||
return None
|
||||
L = L / norm
|
||||
|
||||
# Conversie sferică (azimuth în plan xz, elevation în plan y)
|
||||
# Convenție: x = dreapta, y = jos (ecran), z = afară din ecran
|
||||
# → "lumina vine din direcția -L" (vectorul L pointează SPRE sursa de lumină)
|
||||
az = math.degrees(math.atan2(L[0], L[2])) # x vs z
|
||||
el = math.degrees(math.asin(max(-1.0, min(1.0, -L[1])))) # y inversat
|
||||
return float(az), float(el), float(norm)
|
||||
|
||||
|
||||
def _estimate_scene_light_direction(bgr: np.ndarray, face_mask: np.ndarray
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""
|
||||
Detectează specular highlights în zona NON-face și estimează direcția
|
||||
dominantă bazată pe poziția lor relativă față de centrul scenei.
|
||||
"""
|
||||
H, W = bgr.shape[:2]
|
||||
inv_mask = (face_mask == 0).astype(np.uint8) * 255
|
||||
# Exclude o zonă în jurul feței (boundary effects)
|
||||
kernel = np.ones((25, 25), np.uint8)
|
||||
inv_mask = cv2.erode(inv_mask, kernel)
|
||||
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||
V = hsv[:, :, 2]
|
||||
S = hsv[:, :, 1]
|
||||
|
||||
bg_pixels_v = V[inv_mask > 0]
|
||||
if bg_pixels_v.size < 100:
|
||||
return None
|
||||
p95 = float(np.percentile(bg_pixels_v, 95))
|
||||
if p95 < 100: # scenă întunecată
|
||||
return None
|
||||
|
||||
highlight_mask = ((V >= p95) & (S < 80) & (inv_mask > 0)).astype(np.uint8) * 255
|
||||
n_components, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
||||
highlight_mask, connectivity=8
|
||||
)
|
||||
if n_components < 2:
|
||||
return None # nicio zonă bright distinctă
|
||||
|
||||
cx_scene = W / 2.0
|
||||
cy_scene = H / 2.0
|
||||
weighted_dx, weighted_dy, total_w = 0.0, 0.0, 0.0
|
||||
for i in range(1, n_components): # skip background
|
||||
area = stats[i, cv2.CC_STAT_AREA]
|
||||
if area < 4:
|
||||
continue
|
||||
cx, cy = centroids[i]
|
||||
weight = float(area)
|
||||
weighted_dx += (cx - cx_scene) * weight
|
||||
weighted_dy += (cy - cy_scene) * weight
|
||||
total_w += weight
|
||||
|
||||
if total_w < 5.0:
|
||||
return None
|
||||
dx = weighted_dx / total_w
|
||||
dy = weighted_dy / total_w
|
||||
|
||||
# Convertim în azimuth/elevation
|
||||
# Asumăm z = 1 (lumina e undeva în față, default)
|
||||
norm_xy = math.sqrt(dx ** 2 + dy ** 2) + 1e-6
|
||||
L_x = dx / max(norm_xy, W)
|
||||
L_y = dy / max(norm_xy, H)
|
||||
L_z = 1.0
|
||||
norm = math.sqrt(L_x ** 2 + L_y ** 2 + L_z ** 2)
|
||||
L = (L_x / norm, L_y / norm, L_z / norm)
|
||||
az = math.degrees(math.atan2(L[0], L[2]))
|
||||
el = math.degrees(math.asin(max(-1.0, min(1.0, -L[1]))))
|
||||
return float(az), float(el), float(min(1.0, total_w / (H * W * 0.001)))
|
||||
|
||||
|
||||
def _angle_between(az1: float, el1: float, az2: float, el2: float) -> float:
|
||||
"""Unghi în grade între două direcții sferice."""
|
||||
az1r, el1r = math.radians(az1), math.radians(el1)
|
||||
az2r, el2r = math.radians(az2), math.radians(el2)
|
||||
v1 = (math.sin(az1r) * math.cos(el1r),
|
||||
-math.sin(el1r),
|
||||
math.cos(az1r) * math.cos(el1r))
|
||||
v2 = (math.sin(az2r) * math.cos(el2r),
|
||||
-math.sin(el2r),
|
||||
math.cos(az2r) * math.cos(el2r))
|
||||
dot = max(-1.0, min(1.0, v1[0] * v2[0] + v1[1] * v2[1] + v1[2] * v2[2]))
|
||||
return float(math.degrees(math.acos(dot)))
|
||||
|
||||
|
||||
def _detect_catchlight(eye_roi_bgr: np.ndarray, padding: int = 6
|
||||
) -> tuple[float, float, float] | None:
|
||||
"""
|
||||
Detectează catchlight în ROI ochi. Returnează (cx_rel, cy_rel, intensity)
|
||||
relativ la centrul ROI, sau None dacă nu se găsește.
|
||||
"""
|
||||
if eye_roi_bgr.size == 0:
|
||||
return None
|
||||
h, w = eye_roi_bgr.shape[:2]
|
||||
if h < 6 or w < 6:
|
||||
return None
|
||||
gray = cv2.cvtColor(eye_roi_bgr, cv2.COLOR_BGR2GRAY)
|
||||
if gray.max() < 100:
|
||||
return None
|
||||
# Top 1% intensitate
|
||||
p99 = np.percentile(gray, 99)
|
||||
if p99 < 200:
|
||||
return None
|
||||
bright_mask = (gray >= p99).astype(np.uint8) * 255
|
||||
n_components, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
||||
bright_mask, connectivity=8
|
||||
)
|
||||
if n_components < 2:
|
||||
return None
|
||||
largest_area = 0
|
||||
largest_idx = 0
|
||||
for i in range(1, n_components):
|
||||
if stats[i, cv2.CC_STAT_AREA] > largest_area:
|
||||
largest_area = int(stats[i, cv2.CC_STAT_AREA])
|
||||
largest_idx = i
|
||||
if largest_area < 1:
|
||||
return None
|
||||
cx, cy = centroids[largest_idx]
|
||||
cx_rel = (cx - w / 2.0) / (w / 2.0)
|
||||
cy_rel = (cy - h / 2.0) / (h / 2.0)
|
||||
return float(cx_rel), float(cy_rel), float(p99 / 255.0)
|
||||
|
||||
|
||||
def _eye_roi(frame_bgr: np.ndarray, landmarks_xy: np.ndarray,
|
||||
eye_idx: list[int], padding: int = 6) -> np.ndarray | None:
|
||||
pts = np.array([landmarks_xy[i] for i in eye_idx], dtype=np.int32)
|
||||
h, w = frame_bgr.shape[:2]
|
||||
x1 = max(0, int(pts[:, 0].min()) - padding)
|
||||
y1 = max(0, int(pts[:, 1].min()) - padding)
|
||||
x2 = min(w, int(pts[:, 0].max()) + padding)
|
||||
y2 = min(h, int(pts[:, 1].max()) + padding)
|
||||
if x2 - x1 < 6 or y2 - y1 < 6:
|
||||
return None
|
||||
return frame_bgr[y1:y2, x1:x2].copy()
|
||||
|
||||
|
||||
def _save_lighting_viz(bgr: np.ndarray, face_dir: tuple | None,
|
||||
scene_dir: tuple | None, face_center: tuple,
|
||||
out_path: str) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
H, W = bgr.shape[:2]
|
||||
fig, ax = plt.subplots(figsize=(8, 8))
|
||||
ax.imshow(rgb)
|
||||
cx, cy = face_center
|
||||
arrow_len = min(W, H) * 0.2
|
||||
|
||||
if face_dir is not None:
|
||||
az, el = face_dir[0], face_dir[1]
|
||||
dx = math.sin(math.radians(az)) * math.cos(math.radians(el))
|
||||
dy = -math.sin(math.radians(el))
|
||||
ax.arrow(cx, cy, -dx * arrow_len, -dy * arrow_len,
|
||||
color="red", width=4, head_width=20,
|
||||
label=f"Face light az={az:.0f}° el={el:.0f}°")
|
||||
|
||||
if scene_dir is not None:
|
||||
az, el = scene_dir[0], scene_dir[1]
|
||||
dx = math.sin(math.radians(az)) * math.cos(math.radians(el))
|
||||
dy = -math.sin(math.radians(el))
|
||||
scene_cx, scene_cy = W / 2, H / 2
|
||||
ax.arrow(scene_cx, scene_cy, -dx * arrow_len, -dy * arrow_len,
|
||||
color="blue", width=4, head_width=20,
|
||||
label=f"Scene light az={az:.0f}° el={el:.0f}°")
|
||||
|
||||
ax.legend(loc="upper right")
|
||||
ax.set_title("Estimated lighting directions")
|
||||
ax.axis("off")
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=110)
|
||||
plt.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run(frame_paths: list[str], results_dir: str | None = None) -> dict[str, Any]:
|
||||
t_start = time.perf_counter()
|
||||
images_dir = None
|
||||
if results_dir:
|
||||
images_dir = os.path.join(results_dir, "images")
|
||||
os.makedirs(images_dir, exist_ok=True)
|
||||
|
||||
if not frame_paths:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE, reason="Nu s-au primit frame paths",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
n_total = len(frame_paths)
|
||||
if n_total > 5:
|
||||
step = n_total / 5
|
||||
sample_indices = [int(i * step) for i in range(5)]
|
||||
else:
|
||||
sample_indices = list(range(n_total))
|
||||
|
||||
per_frame: list[dict[str, Any]] = []
|
||||
artifacts: list[str] = []
|
||||
warnings: list[str] = []
|
||||
errors: list[str] = []
|
||||
angles_diff: list[float] = []
|
||||
catchlight_consistencies: list[float] = []
|
||||
|
||||
for idx in sample_indices:
|
||||
fpath = frame_paths[idx]
|
||||
bgr = cv2.imread(fpath)
|
||||
if bgr is None:
|
||||
per_frame.append({"frame_index": idx, "signal_present": False})
|
||||
continue
|
||||
|
||||
H, W = bgr.shape[:2]
|
||||
landmarks = _try_face_landmarks(bgr)
|
||||
rec: dict[str, Any] = {"frame_index": idx, "signal_present": False}
|
||||
if landmarks is None:
|
||||
warnings.append(f"frame {idx}: face nedetectată")
|
||||
per_frame.append(rec)
|
||||
continue
|
||||
|
||||
# Mască față
|
||||
h, w = bgr.shape[:2]
|
||||
contour = np.array([landmarks[i] for i in FACE_OVAL], dtype=np.int32)
|
||||
face_mask = np.zeros((h, w), dtype=np.uint8)
|
||||
cv2.fillPoly(face_mask, [contour], 255)
|
||||
|
||||
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
face_dir = _estimate_face_light_direction(gray, face_mask, landmarks)
|
||||
scene_dir = _estimate_scene_light_direction(bgr, face_mask)
|
||||
|
||||
# Catchlight
|
||||
left_roi = _eye_roi(bgr, landmarks, LEFT_EYE_OUTLINE, padding=6)
|
||||
right_roi = _eye_roi(bgr, landmarks, RIGHT_EYE_OUTLINE, padding=6)
|
||||
cl_left = _detect_catchlight(left_roi) if left_roi is not None else None
|
||||
cl_right = _detect_catchlight(right_roi) if right_roi is not None else None
|
||||
|
||||
catch_consistency = None
|
||||
if cl_left and cl_right:
|
||||
# Pozițiile relative trebuie să fie aproximativ oglindite
|
||||
# Diferență absolută în coord (ținând cont că ochi-ul drept e oglindă)
|
||||
dx_diff = abs(cl_left[0] - (-cl_right[0])) # oglindire pe x
|
||||
dy_diff = abs(cl_left[1] - cl_right[1])
|
||||
int_diff = abs(cl_left[2] - cl_right[2])
|
||||
score = 1.0 - min(1.0, dx_diff * 0.7 + dy_diff * 0.5 + int_diff * 0.3)
|
||||
catch_consistency = float(max(0.0, score))
|
||||
catchlight_consistencies.append(catch_consistency)
|
||||
|
||||
# Mismatch de iluminare
|
||||
angle_deg = None
|
||||
if face_dir is not None and scene_dir is not None:
|
||||
angle_deg = _angle_between(face_dir[0], face_dir[1],
|
||||
scene_dir[0], scene_dir[1])
|
||||
angles_diff.append(angle_deg)
|
||||
|
||||
rec.update({
|
||||
"signal_present": face_dir is not None or scene_dir is not None,
|
||||
"face_light_azimuth": round(face_dir[0], 2) if face_dir else None,
|
||||
"face_light_elevation": round(face_dir[1], 2) if face_dir else None,
|
||||
"scene_light_azimuth": round(scene_dir[0], 2) if scene_dir else None,
|
||||
"scene_light_elevation": round(scene_dir[1], 2) if scene_dir else None,
|
||||
"lighting_mismatch_deg": round(angle_deg, 2) if angle_deg is not None else None,
|
||||
"catchlight_left": cl_left,
|
||||
"catchlight_right": cl_right,
|
||||
"catchlight_consistency": round(catch_consistency, 4) if catch_consistency is not None else None,
|
||||
})
|
||||
per_frame.append(rec)
|
||||
|
||||
if images_dir and (face_dir or scene_dir):
|
||||
cx = float(landmarks[:, 0].mean())
|
||||
cy = float(landmarks[:, 1].mean())
|
||||
name = f"m29_lighting_{idx:04d}.png"
|
||||
_save_lighting_viz(bgr, face_dir, scene_dir, (cx, cy),
|
||||
os.path.join(images_dir, name))
|
||||
artifacts.append(name)
|
||||
|
||||
if not angles_diff and not catchlight_consistencies:
|
||||
return empty_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
reason="Nu s-a putut estima direcția de iluminare în niciun cadru",
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
|
||||
angle_mean = float(np.mean(angles_diff)) if angles_diff else None
|
||||
angle_max = float(np.max(angles_diff)) if angles_diff else None
|
||||
cl_mean = float(np.mean(catchlight_consistencies)) if catchlight_consistencies else None
|
||||
|
||||
# Score components
|
||||
if angle_mean is None:
|
||||
lighting_component = 0.5
|
||||
elif angle_mean < 60:
|
||||
lighting_component = 0.0
|
||||
elif angle_mean < 90:
|
||||
lighting_component = (angle_mean - 60) / 30.0 # 0..1 între 60° și 90°
|
||||
else:
|
||||
lighting_component = min(1.0, 0.7 + (angle_mean - 90) / 90.0)
|
||||
|
||||
if cl_mean is None:
|
||||
catch_component = 0.4
|
||||
elif cl_mean >= 0.6:
|
||||
catch_component = 0.0
|
||||
else:
|
||||
catch_component = 1.0 - cl_mean
|
||||
|
||||
score = 0.6 * lighting_component + 0.4 * catch_component
|
||||
confidence = (
|
||||
0.4 * (1.0 if angle_mean is not None else 0.2)
|
||||
+ 0.3 * (1.0 if cl_mean is not None else 0.2)
|
||||
+ 0.3 * min(1.0, len(angles_diff) / 3.0)
|
||||
)
|
||||
|
||||
evidence: list[str] = []
|
||||
if angle_mean is not None:
|
||||
if angle_mean < 60:
|
||||
evidence.append(f"Face vs scene lighting: {angle_mean:.0f}° (consistent, real range)")
|
||||
elif angle_mean < 90:
|
||||
evidence.append(f"Face vs scene lighting: {angle_mean:.0f}° (intermediate, marginal)")
|
||||
else:
|
||||
evidence.append(f"Face vs scene lighting: {angle_mean:.0f}° (mismatch >90°, suspect compus)")
|
||||
else:
|
||||
evidence.append("Lighting direction: not estimable (uniform ambient or no scene highlights)")
|
||||
|
||||
if cl_mean is not None:
|
||||
if cl_mean >= 0.6:
|
||||
evidence.append(f"Catchlight L/R consistency: {cl_mean:.2f} (natural)")
|
||||
else:
|
||||
evidence.append(f"Catchlight L/R consistency: {cl_mean:.2f} (asymmetric, suspect)")
|
||||
else:
|
||||
evidence.append("Catchlights: nedetectabile (ochi închiși/ochelari/rezoluție mică)")
|
||||
|
||||
if angle_max is not None and angle_max > 90 and angle_mean and angle_mean < 90:
|
||||
evidence.append(f"Single frame outlier: peak mismatch {angle_max:.0f}°")
|
||||
|
||||
summary_extras = {
|
||||
"lighting_mismatch_deg_mean": round(angle_mean, 2) if angle_mean is not None else None,
|
||||
"lighting_mismatch_deg_max": round(angle_max, 2) if angle_max is not None else None,
|
||||
"catchlight_consistency_mean": round(cl_mean, 4) if cl_mean is not None else None,
|
||||
"frames_with_lighting_signal": len(angles_diff),
|
||||
"frames_with_catchlights": len(catchlight_consistencies),
|
||||
"lighting_component": round(lighting_component, 3),
|
||||
"catchlight_component": round(catch_component, 3),
|
||||
}
|
||||
|
||||
metrics = {
|
||||
"highlight_percentile": 95,
|
||||
"shape_from_shading_method": "spherical_approx_lambertian",
|
||||
}
|
||||
|
||||
return make_response(
|
||||
tool_id=TOOL_ID, tool_name=TOOL_NAME, version=VERSION,
|
||||
input_type=INPUT_TYPE,
|
||||
primary_score=score, confidence=confidence, evidence=evidence,
|
||||
frames_analyzed=len(sample_indices),
|
||||
frames_with_signal=max(len(angles_diff), len(catchlight_consistencies)),
|
||||
summary_extras=summary_extras,
|
||||
per_frame=per_frame,
|
||||
metrics=metrics,
|
||||
artifacts_images=artifacts,
|
||||
errors=errors, warnings=warnings,
|
||||
execution_time_ms=(time.perf_counter() - t_start) * 1000,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue