Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View 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 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,
)

View file

@ -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)."
}
}

View file

@ -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.00.35 REAL (puls detectat clar, clipire naturală)
primary_score = 0.350.65 INCERT (semnal slab pe partea video scurt)
primary_score = 0.651.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,
)

View file

@ -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."
}
}

View 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.00.35 REAL
primary_score = 0.651.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,
)

View file

@ -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)."
}
}

View file

@ -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 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 accepte tensor (1,3,H,W)
și 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 oricare dintre cele 3 declanșate e suficient.
primary_score = 0.00.35 REAL
primary_score = 0.651.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ă încarce un model HuggingFace AI detector (ViT) și 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ă încarce un model torch pre-trained și 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,
)

View file

@ -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."
}
}

View file

@ -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
confirme vizual zona de pe care heatmap-ul indică.
Inspirat din Face X-ray (Li et al. 2020), simplificat ca 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 pielefundal 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
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.00.35 REAL (nicio zonă suspectă)
0.651.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,
)

View file

@ -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."
}
}

View file

@ -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 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( - (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ă 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 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.00.35 REAL (lumină consistentă față-scenă, catchlights OK)
primary_score = 0.651.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,
)