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,42 @@
# Python artifacts
__pycache__/
*.py[cod]
*$py.class
*.so
*.egg-info/
.Python
.pytest_cache/
.mypy_cache/
.ruff_cache/
# Virtual envs
.venv/
venv/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Git
.git/
.gitignore
# Runtime data (mounted as volumes)
data/
# Docker meta (avoid recursive copy)
Dockerfile
docker-compose*.yml
.dockerignore
.env*
# Documentation (kept on host, not needed in image)
docs/
README.md
# Logs
*.log

View file

@ -0,0 +1,10 @@
# Copy to .env and adjust. Read automatically by docker compose.
# Port pe care expune API-ul pe host
API_PORT=8080
# Opțional: activează HF AI detector în m27 (Organika/sdxl-detector).
# Default 0 — testat empiric, regresie pe video out-of-distribution.
# Setează 1 doar dacă vrei să experimentezi (necesită + transformers + torch
# în requirements.txt).
# M27_USE_HF=0

View file

@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1.7
# ──────────────────────────────────────────────────────────────────────────
# Forensic Features API — Lean Container
#
# Imagine finală estimată: ~1.5 GB
# (vs ~8 GB cu transformers/torch/autogluon — neincluse aici)
#
# Build: docker build -t forensic-features:latest .
# Run: docker run -p 8080:8080 forensic-features:latest
# ──────────────────────────────────────────────────────────────────────────
# ═══ Stage 1: Builder ═══════════════════════════════════════════════════
FROM python:3.12-slim-bookworm AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
gcc \
g++ \
&& rm -rf /var/lib/apt/lists/*
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv $VIRTUAL_ENV
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip setuptools wheel
COPY requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
# ═══ Stage 2: Runtime ═══════════════════════════════════════════════════
FROM python:3.12-slim-bookworm AS runtime
# Sistem deps minime:
# ffmpeg — frame + audio extraction
# libgl1 — OpenCV runtime
# libglib2.0-0 — OpenCV glib runtime
# libgles2 — MediaPipe OpenGL ES (libGLESv2.so.2)
# libegl1 — MediaPipe EGL (libEGL.so.1)
# libgomp1 — OpenMP
# curl — pentru healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
libgl1 \
libglib2.0-0 \
libgomp1 \
libgles2 \
libegl1 \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copiem venv-ul pre-construit
COPY --from=builder /opt/venv /opt/venv
ENV VIRTUAL_ENV=/opt/venv
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# Non-root user pentru securitate
RUN groupadd --system --gid 1001 app \
&& useradd --system --uid 1001 --gid app --create-home --home-dir /home/app app
WORKDIR /app
# ── Copiere cod (ordine: stabil → modificat des, pentru cache optim) ────
# 1. MediaPipe model (rar modificat, ~3.6 MB)
COPY --chown=app:app face_landmarker.task ./
# 2. Tools forensice (5 module)
COPY --chown=app:app tools/ ./tools/
# 3. Glue layer
COPY --chown=app:app forensic/ ./forensic/
# 4. Core API + preprocessing (cele mai des modificate)
COPY --chown=app:app api.py preprocessing.py ./
COPY --chown=app:app requirements.txt ./
# Directoare runtime
RUN mkdir -p /app/data/inference /app/data/results \
&& chown -R app:app /app
USER app
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
API_HOST=0.0.0.0 \
API_PORT=8080
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl --fail --silent --max-time 5 http://localhost:8080/health || exit 1
CMD ["python", "api.py"]

View file

@ -0,0 +1,134 @@
# Forensic Features API
**Microserviciu REST care extrage măsurători forensice obiective din
video/imagini și le împachetează într-un format consumabil de un LLM extern
multimodal (Qwen Vision, GPT-4V, Claude Sonnet, etc.).**
> Acest serviciu **NU** face clasificare end-to-end. Nu spune "FAKE" sau "REAL".
> Produce **numere și hărți vizuale** pe care LLM-ul tău (cel care face deja
> vision/OCR/tipologii) le folosește ca semnal suplimentar peste analiza lui
> vizuală.
## Ce face concret
Pentru fiecare video/imagine primit la `POST /api/forensic-evidence`, rulează
**5 detectoare forensice** care produc semnale **pe care un LLM nu le poate
calcula din imagine**:
| Modul | Ce extrage | De ce e util pentru LLM |
|-------|------------|--------------------------|
| **m25 Physiology** | Puls cardiac (rPPG POS), rata clipitului, asimetrie ochi L/R | LLM nu poate „număra" pulsul din variația subtilă de culoare facială |
| **m26 Audio Forensics** | Drift lip-sync (ms), F0 std, varianță spectrală centroidă, voice/silence ratio | LLM nu poate cuantifica sincronicitatea audio-video sau caracteristici TTS |
| **m27 AI Detector** | NPR cross-scale + JPEG-recon error (opțional HF model) | LLM nu poate face FFT / inferință neurală pe textura imaginii |
| **m28 Forgery Heatmap** | Hartă vizuală localizare blending boundary (Face X-ray-like) | LLM primește o **imagine PNG** care arată EXACT unde să se uite |
| **m29 Lighting 3D** | Direcție lumină față vs scenă (azimuth+elevation), catchlight consistency | LLM nu poate fitta un model de iluminare 3D |
Output: text formatat + 12-15 imagini PNG base64 + scoruri raw structurate.
## Arhitectură (5 secunde)
```
┌────────────────┐ ┌─────────────────────┐
│ Aplicația ta │ POST video.mp4 │ Forensic Features │
│ (existentă) │ ───────────────────────────► │ API (acest serviciu)│
│ │ │ │
│ + Qwen/GPT-4V │ ◄────────────────────────── │ - extrage features │
│ + tipologii │ evidence_text + PNG-uri │ - generează PNG │
│ + OCR │ + raw scores │ - formatează prompt│
└────────────────┘ └─────────────────────┘
│ inserează evidence_text în prompt LLM
│ atașează imagini PNG la apel multimodal
┌────────────────┐
│ LLM extern │ vede:
│ (Qwen/GPT-4V) │ - imaginile originale uploadate
│ │ - tipologiile tale
│ │ + evidence_text de la noi
│ │ + heatmap-uri PNG de la noi
│ │ → produce verdict cu signal mult mai bogat
└────────────────┘
```
## Quick start
```bash
# Build și pornește container
docker compose up -d
# Verifică
curl http://localhost:8080/health
curl http://localhost:8080/api/forensic-modules
# Test end-to-end
curl -X POST http://localhost:8080/api/forensic-evidence \
-F "video=@test_video.mp4" \
-F "encode_images=1"
```
Răspunsul conține `evidence_text` (de inserat în promptul LLM-ului tău) plus
o listă de imagini PNG base64 (de atașat la apelul multimodal).
## Cerințe sistem
| Resursă | Minim | Recomandat |
|---------|-------|------------|
| RAM | 2 GB | 4 GB |
| CPU | 2 cores | 4 cores |
| Disk | 2 GB liberi | 5 GB (date temporare) |
| OS | Linux (Docker), Windows/Mac (Docker Desktop) | Linux |
Per request: ~2-3 GB RAM peak, 5-90 secunde procesare (depinde de durata
video — videoclipuri >30s pot lua minute pe CPU).
## Structura proiectului
```
forensic_features/
├── README.md # Acest fișier
├── docs/ # Documentație tehnică în adâncime
│ ├── ARCHITECTURE.md # Cum funcționează fiecare strat
│ ├── API.md # Reference complet endpoint-uri
│ ├── CONTRACT.md # Schema completă de output
│ ├── MODULES.md # Deep dive m25-m29 cu algoritmi
│ └── INTEGRATION.md # Cum integrezi în aplicația ta LLM
├── Dockerfile # Multi-stage, ~1.5 GB final
├── docker-compose.yml # Healthcheck + volumes + limits
├── .dockerignore
├── .env.example
├── requirements.txt # Lean: numpy, scipy, opencv, mediapipe, aiohttp
├── api.py # REST API (270 linii, doar 5 endpoints)
├── preprocessing.py # Frame extraction prin ffmpeg
├── face_landmarker.task # MediaPipe model (~3.6 MB)
├── forensic/ # Glue layer (orchestrare + fuziune + formatare)
│ ├── __init__.py
│ ├── orchestrator.py # Rulează modulele cu input corect, captează erori
│ ├── scoring.py # Fuziune Dempster-Shafer-inspired a scorurilor
│ └── prompt_builder.py # Formatare evidence_text + base64 imagini
└── tools/ # Cele 5 detectoare forensice
├── _contract.py # Helper make_response() pentru schema unificată
├── m25_physiology/ # rPPG POS + blink dynamics
├── m26_audio/ # Lip-sync + voice clone heuristic
├── m27_ai_detector/ # NPR cross-scale + JPEG-recon
├── m28_forgery_heatmap/ # Face X-ray-inspired blending detection
└── m29_lighting/ # Lambertian SfS + catchlight consistency
```
## Documentație suplimentară
Pentru detalii tehnice profunde, vezi [docs/](docs/):
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** — cum se leagă straturile
- **[docs/API.md](docs/API.md)** — reference complet endpoint-uri
- **[docs/CONTRACT.md](docs/CONTRACT.md)** — schema returnată
- **[docs/MODULES.md](docs/MODULES.md)** — algoritmii fiecărui modul, line by line
- **[docs/INTEGRATION.md](docs/INTEGRATION.md)** — cum apelezi din aplicația ta
## License & contact
Cod custom pentru pipeline de augmentare LLM cu signal forensic.
Toate dependențele third-party sunt MIT/BSD/Apache 2.0 compatible.

View file

@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""
api.py Forensic Features API
Singurul scop al acestui serviciu: extrage măsurători forensice obiective
din video/imagini și le returnează într-un format consumabil de un LLM
extern (Qwen Vision, GPT-4V, Claude, etc.) care face deja vision/OCR pe
imaginile originale.
Endpoint-uri:
POST /api/forensic-evidence upload video/imagine, returnează
evidence_text + base64 PNG-uri + raw scores
GET /api/forensic-modules listează modulele disponibile (m25-m29)
GET /api/status/{job_id} polling pentru cereri async
GET /api/result/{job_id} preluare rezultat job async
GET /health healthcheck
Vezi docs/API.md pentru detalii complete.
"""
from __future__ import annotations
import asyncio
import os
import shutil
import sys
import uuid
from pathlib import Path
import aiohttp
from aiohttp import web
import aiohttp_cors
BASE_DIR = Path(__file__).parent
sys.path.insert(0, str(BASE_DIR))
INFER_DIR = BASE_DIR / "data" / "inference"
RESULTS_DIR = BASE_DIR / "data" / "results"
from forensic.orchestrator import (
run_forensic_pipeline, AVAILABLE_MODULES,
)
from forensic.prompt_builder import build_evidence_block
# ── Job store in-memory pentru cereri async ──────────────────────────────
_jobs: dict[str, dict] = {}
# ───────────────────────────────────────────────────────────────────────────
# Core pipeline runner — apelat sync sau via executor în handler async
# ───────────────────────────────────────────────────────────────────────────
def _run_forensic_and_format(
video_path: str, results_dir: str,
modules: list[str] | None, every_n_frames: int | None,
encode_images: bool,
) -> dict:
"""
Rulează orchestratorul + prompt builder. Sync apelat în executor thread.
every_n_frames=None orchestrator calculează adaptive bazat pe durata video.
"""
orch = run_forensic_pipeline(
video_path=video_path,
results_dir=results_dir,
modules=modules,
every_n_frames=every_n_frames,
use_parallel=False,
)
images_dir = os.path.join(results_dir, "images")
evidence = build_evidence_block(
module_results=orch["modules"],
fusion=orch["fusion"],
images_dir=images_dir,
encode_images=encode_images,
include_instruction=True,
)
return {
"video_path": os.path.basename(video_path),
"n_frames_extracted": orch["n_frames_extracted"],
"every_n_frames_used": orch.get("every_n_frames_used"),
"auto_skipped": orch.get("auto_skipped", []),
"modules_run": list(orch["modules"].keys()),
"execution_time_ms": orch["execution_time_ms"],
"fusion": orch["fusion"],
"explanation": orch["explanation"],
"modules": orch["modules"],
"evidence_text": evidence["evidence_text"],
"images": evidence["images"],
"summary": evidence["summary"],
"instruction_for_llm": evidence["instruction_for_llm"],
"errors": orch["errors"],
}
def _async_pipeline_wrapper(
job_id: str, video_path: str, results_dir: str,
modules: list[str], every_n_frames: int | None, encode_images: bool,
) -> None:
"""Wrapper executor pentru cereri async — update-uri în _jobs."""
try:
_jobs[job_id].update(
status="running",
progress="Running forensic modules m25-m29...",
)
result = _run_forensic_and_format(
video_path, results_dir, modules, every_n_frames, encode_images
)
_jobs[job_id].update(status="done", result=result, progress="Complete.")
except Exception as e:
_jobs[job_id].update(status="error", error=str(e), progress=f"Error: {e}")
finally:
try:
up = Path(video_path)
if up.exists():
up.unlink()
if up.parent.exists() and not any(up.parent.iterdir()):
up.parent.rmdir()
except Exception:
pass
# ───────────────────────────────────────────────────────────────────────────
# HTTP handlers
# ───────────────────────────────────────────────────────────────────────────
async def handle_forensic_evidence(request: web.Request) -> web.Response:
"""
POST /api/forensic-evidence multipart/form-data.
Form fields:
video (file, required) Video sau imagine de analizat
(mp4, mov, avi, mkv, webm, jpg, png).
modules (str, optional) CSV de module IDs ("m25,m27,m28").
Default: toate cele 5 module.
encode_images (str, optional) "1" (default) = atașează data URLs
base64 pentru PNG-uri.
"0" = doar paths.
every_n_frames (int, optional) Pas extracție cadre. Default: adaptiv
după durata video.
async_mode (str, optional) "0" (default) = sync, returnează rezultat.
"1" = creează job + returnează 202.
Returns:
Sync (200) JSON cu:
evidence_text, images, summary, modules, fusion,
instruction_for_llm vezi docs/CONTRACT.md.
Async (202) {"job_id": "...", "status": "queued"}.
"""
reader = await request.multipart()
video_field = None
modules_csv = None
encode_images = True
every_n_frames: int | None = None
async_mode = False
while True:
field = await reader.next()
if field is None:
break
if field.name == "video":
video_field = field
break
elif field.name == "modules":
modules_csv = (await field.text()).strip()
elif field.name == "encode_images":
encode_images = (await field.text()).strip() not in ("0", "false", "no", "")
elif field.name == "every_n_frames":
try:
every_n_frames = int((await field.text()).strip())
except ValueError:
pass
elif field.name == "async_mode":
async_mode = (await field.text()).strip() in ("1", "true", "yes")
if video_field is None:
raise web.HTTPBadRequest(text="Field 'video' is required in multipart upload.")
filename = video_field.filename or ""
allowed = (".mp4", ".mov", ".avi", ".mkv", ".webm", ".jpg", ".jpeg", ".png")
if not filename.lower().endswith(allowed):
raise web.HTTPBadRequest(
text=f"Unsupported file type: '{filename}'. Allowed: {', '.join(allowed)}."
)
# Validate module list
if modules_csv:
modules_requested = [m.strip() for m in modules_csv.split(",") if m.strip()]
invalid = [m for m in modules_requested if m not in AVAILABLE_MODULES]
if invalid:
raise web.HTTPBadRequest(
text=f"Unknown modules: {invalid}. Available: {list(AVAILABLE_MODULES.keys())}"
)
else:
modules_requested = list(AVAILABLE_MODULES.keys())
# Save upload to disk
job_id = uuid.uuid4().hex[:16]
work_dir = INFER_DIR / job_id
work_dir.mkdir(parents=True, exist_ok=True)
upload_path = work_dir / f"{job_id}_input{Path(filename).suffix.lower()}"
with open(upload_path, "wb") as f:
while True:
chunk = await video_field.read_chunk(65536)
if not chunk:
break
f.write(chunk)
results_dir = RESULTS_DIR / job_id
results_dir.mkdir(parents=True, exist_ok=True)
# ── Async mode: queue job and return immediately ──
if async_mode:
_jobs[job_id] = {
"status": "queued",
"progress": "Forensic evidence pipeline queued",
"result": None,
"error": None,
}
loop = asyncio.get_event_loop()
loop.run_in_executor(
None, _async_pipeline_wrapper,
job_id, str(upload_path), str(results_dir),
modules_requested, every_n_frames, encode_images,
)
return web.json_response(
{"job_id": job_id, "status": "queued", "modules": modules_requested},
status=202,
)
# ── Sync mode: run inline and return result ──
try:
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, _run_forensic_and_format,
str(upload_path), str(results_dir),
modules_requested, every_n_frames, encode_images,
)
return web.json_response(result)
except Exception as e:
return web.json_response(
{"error": str(e), "modules_requested": modules_requested},
status=500,
)
finally:
try:
if upload_path.exists():
upload_path.unlink()
if work_dir.exists() and not any(work_dir.iterdir()):
work_dir.rmdir()
except Exception:
pass
async def handle_forensic_modules(request: web.Request) -> web.Response:
"""GET /api/forensic-modules — listează modulele disponibile."""
catalog = []
for mid, (import_path, fn_name, input_type) in AVAILABLE_MODULES.items():
catalog.append({
"id": mid,
"input_type": input_type,
"import_path": import_path,
"function": fn_name,
})
return web.json_response({
"available_modules": catalog,
"default": list(AVAILABLE_MODULES.keys()),
})
async def handle_status(request: web.Request) -> web.Response:
"""GET /api/status/{job_id} — polling pentru cereri async."""
job_id = request.match_info["job_id"]
if job_id not in _jobs:
raise web.HTTPNotFound(text=f"Job '{job_id}' not found.")
job = _jobs[job_id]
return web.json_response({
"job_id": job_id,
"status": job["status"],
"progress": job["progress"],
})
async def handle_result(request: web.Request) -> web.Response:
"""GET /api/result/{job_id} — preluare rezultat job async."""
job_id = request.match_info["job_id"]
if job_id not in _jobs:
raise web.HTTPNotFound(text=f"Job '{job_id}' not found.")
job = _jobs[job_id]
if job["status"] == "error":
return web.json_response({"job_id": job_id, "error": job["error"]}, status=500)
if job["status"] != "done":
return web.json_response(
{"job_id": job_id, "status": job["status"], "progress": job["progress"]},
status=202,
)
return web.json_response(job["result"])
async def handle_health(request: web.Request) -> web.Response:
"""GET /health — healthcheck pentru Docker / load balancer."""
return web.json_response({
"status": "ok",
"service": "forensic-features",
"modules": list(AVAILABLE_MODULES.keys()),
})
# ───────────────────────────────────────────────────────────────────────────
# App setup
# ───────────────────────────────────────────────────────────────────────────
async def handle_metrics(_request: web.Request) -> web.Response:
"""Prometheus /metrics endpoint."""
try:
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST # type: ignore
return web.Response(body=generate_latest(), content_type=CONTENT_TYPE_LATEST.split(';')[0])
except ImportError:
return web.Response(text="prometheus_client not installed\n", status=503)
def build_app() -> web.Application:
app = web.Application(client_max_size=2 * 1024**3) # 2GB upload limit
app.router.add_post("/api/forensic-evidence", handle_forensic_evidence)
app.router.add_get( "/api/forensic-modules", handle_forensic_modules)
app.router.add_get( "/api/status/{job_id}", handle_status)
app.router.add_get( "/api/result/{job_id}", handle_result)
app.router.add_get( "/health", handle_health)
app.router.add_get( "/metrics", handle_metrics)
cors = aiohttp_cors.setup(app, defaults={
"*": aiohttp_cors.ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
)
})
for route in list(app.router.routes()):
cors.add(route)
# HTTP middleware: count requests + duration per route
try:
from prometheus_client import Counter, Histogram # type: ignore
REQ_COUNTER = Counter("http_requests_total", "Total HTTP requests",
labelnames=["method", "path", "status"])
REQ_LATENCY = Histogram("http_request_duration_seconds", "HTTP request duration",
labelnames=["method", "path"],
buckets=[0.005, 0.025, 0.1, 0.5, 1, 5, 30, 90, 300])
import time as _time
@web.middleware
async def metrics_middleware(request: web.Request, handler):
start = _time.perf_counter()
try:
response = await handler(request)
status = response.status
return response
except web.HTTPException as exc:
status = exc.status
raise
except Exception:
status = 500
raise
finally:
# Use route.resource canonical path so /api/status/{job_id} groups together
path = request.match_info.route.resource.canonical if request.match_info.route.resource else request.path
REQ_COUNTER.labels(method=request.method, path=path, status=str(status)).inc()
REQ_LATENCY.labels(method=request.method, path=path).observe(_time.perf_counter() - start)
app.middlewares.append(metrics_middleware)
except ImportError:
pass
# OTel tracing — aiohttp server + client auto-instrumentation
import os as _os
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if _otel_ep:
try:
from opentelemetry import trace as _trace # type: ignore
from opentelemetry.sdk.resources import Resource as _R # type: ignore
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
from opentelemetry.instrumentation.aiohttp_server import AioHttpServerInstrumentor as _AInst # type: ignore
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "forensic-features-api")}))
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
_trace.set_tracer_provider(_provider)
_AInst().instrument()
print(f"[otel] forensic-features-api instrumented -> {_otel_ep}")
except ImportError as _e:
print(f"[otel] skip: {_e}")
return app
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Forensic Features API")
parser.add_argument("--host", default=os.environ.get("API_HOST", "0.0.0.0"))
parser.add_argument("--port", type=int,
default=int(os.environ.get("API_PORT", "8080")))
args = parser.parse_args()
os.chdir(BASE_DIR)
INFER_DIR.mkdir(parents=True, exist_ok=True)
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
print(f"Forensic Features API on http://{args.host}:{args.port}")
print(f"Available modules: {list(AVAILABLE_MODULES.keys())}")
web.run_app(build_app(), host=args.host, port=args.port)

View file

@ -0,0 +1,70 @@
# Forensic Features API — Docker Compose
#
# Build: docker compose build
# Up: docker compose up -d
# Logs: docker compose logs -f api
# Down: docker compose down
name: forensic-features
services:
api:
build:
context: .
dockerfile: Dockerfile
image: forensic-features:latest
container_name: forensic-features-api
restart: unless-stopped
ports:
# Localhost only — DiDi agent-v3 ajunge via didi-network DNS
- "127.0.0.1:${API_PORT:-8085}:8080"
environment:
API_HOST: "0.0.0.0"
API_PORT: "8080"
PYTHONUNBUFFERED: "1"
PYTHONDONTWRITEBYTECODE: "1"
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
OTEL_SERVICE_NAME: forensic-features-api
volumes:
# Persistent dir for debug/artifacts (heatmaps, evidence JSON)
- api_data:/app/data
networks:
- didi-network
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--max-time", "5",
"http://localhost:8080/health"]
interval: 30s
timeout: 10s
start_period: 30s
retries: 3
# Each request consumes ~2-3 GB peak (frame extraction + MediaPipe + ffmpeg).
# Adjust based on expected concurrency.
deploy:
resources:
limits:
memory: 4G
cpus: "4.0"
reservations:
memory: 1G
cpus: "1.0"
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
volumes:
api_data:
driver: local
networks:
didi-network:
external: true

View file

@ -0,0 +1,260 @@
# API Reference
Toate endpoint-urile, parametrii, status codes, exemple curl.
## Base URL
```
http://localhost:8080
```
## Endpoints
### `POST /api/forensic-evidence`
**Singurul endpoint care contează în practică.** Upload un video/imagine,
primește înapoi evidence formatat pentru LLM.
#### Request
Content-Type: `multipart/form-data`
| Field | Type | Required | Default | Description |
|-----------------|----------|----------|---------|-------------|
| `video` | file | DA | — | Fișier video sau imagine (mp4, mov, avi, mkv, webm, jpg, jpeg, png). Max 2 GB. |
| `modules` | string | NU | toate | CSV de module IDs ("m25,m27,m28"). Default rulează toate cele 5. |
| `encode_images` | string | NU | "1" | "1" → atașează data URLs base64; "0" → doar paths absolute pe disk |
| `every_n_frames`| int | NU | adaptiv | Pas extracție cadre. None → orchestrator alege bazat pe durata video |
| `async_mode` | string | NU | "0" | "1" → returnează 202 cu job_id; "0" → blochează până la rezultat |
#### Response
**Sync mode (default) — 200 OK** — JSON cu schema completă (vezi [CONTRACT.md](CONTRACT.md)):
```jsonc
{
"video_path": "video.mp4",
"n_frames_extracted": 64,
"every_n_frames_used": 3,
"auto_skipped": ["m26 (no audio track)"],
"modules_run": ["m25", "m27", "m28", "m29"],
"execution_time_ms": 48432.0,
"fusion": {
"score": 0.67,
"label": "FAKE",
"confidence": 0.78,
"n_contributing": 4,
"n_no_signal": 0,
"disagreement": 0.005,
"contributions": {"m25": 0.09, "m27": 0.23, "m28": 0.24, "m29": 0.11}
},
"explanation": [
"Verdict forensic: FAKE (score=0.67, confidence=0.78, din 4 detectoare active)",
"Top contributors: m28 INCERT, m27 FAKE, m25 INCERT"
],
"modules": {
"m25": { ... schema completă CONTRACT.md ... },
"m27": { ... },
"m28": { ... },
"m29": { ... }
},
"evidence_text": "FORENSIC EVIDENCE (objective measurements...)\n\n[m25 ...] score=...\n...",
"images": [
{
"name": "m25_pulse_signal.png",
"tool_id": "m25",
"abs_path": "/app/data/results/{job_id}/images/m25_pulse_signal.png",
"size_bytes": 69384,
"data_url": "data:image/png;base64,iVBORw0KG..." // dacă encode_images=1
},
// ... 8-15 imagini total
],
"summary": {
"overall_score": 0.67,
"overall_label": "FAKE",
"overall_confidence": 0.78,
"n_modules_run": 4,
"n_modules_signal": 4,
"disagreement": 0.005
},
"instruction_for_llm": "HOW TO USE FORENSIC EVIDENCE ABOVE:\n...",
"errors": []
}
```
**Async mode (`async_mode=1`) — 202 Accepted**:
```json
{
"job_id": "abc123def456789a",
"status": "queued",
"modules": ["m25", "m26", "m27", "m28", "m29"]
}
```
Apoi polling pe `/api/status/{job_id}` și preluare cu `/api/result/{job_id}`.
#### Error codes
| Code | Cauza |
|------|-------|
| 400 | Câmp `video` lipsește, tip fișier nesuportat, sau modul necunoscut |
| 500 | Eroare în pipeline (vezi mesaj eroare în body) |
#### Examples
**Sync, toate modulele**:
```bash
curl -X POST http://localhost:8080/api/forensic-evidence \
-F "video=@suspicious_video.mp4"
```
**Sync, doar 2 module + fără base64 (mai rapid)**:
```bash
curl -X POST http://localhost:8080/api/forensic-evidence \
-F "video=@image.jpg" \
-F "modules=m27,m28" \
-F "encode_images=0"
```
**Async, polling**:
```bash
# Submit
JOB=$(curl -s -X POST http://localhost:8080/api/forensic-evidence \
-F "video=@long_video.mp4" -F "async_mode=1" \
| python -c "import sys,json; print(json.load(sys.stdin)['job_id'])")
# Wait
while [ "$(curl -s http://localhost:8080/api/status/$JOB \
| python -c "import sys,json; print(json.load(sys.stdin)['status'])")" != "done" ]; do
sleep 5
done
# Get result
curl http://localhost:8080/api/result/$JOB | jq .summary
```
---
### `GET /api/forensic-modules`
Listează modulele disponibile, pentru introspection.
```bash
curl http://localhost:8080/api/forensic-modules
```
```json
{
"available_modules": [
{"id": "m25", "input_type": "overview_frames",
"import_path": "tools.m25_physiology.physiology", "function": "run"},
{"id": "m26", "input_type": "video_path",
"import_path": "tools.m26_audio.audio", "function": "run"},
{"id": "m27", "input_type": "overview_frames",
"import_path": "tools.m27_ai_detector.ai_detector", "function": "run"},
{"id": "m28", "input_type": "overview_frames",
"import_path": "tools.m28_forgery_heatmap.forgery_heatmap", "function": "run"},
{"id": "m29", "input_type": "overview_frames",
"import_path": "tools.m29_lighting.lighting", "function": "run"}
],
"default": ["m25", "m26", "m27", "m28", "m29"]
}
```
---
### `GET /api/status/{job_id}`
Polling pentru cereri async. Returns 200 cu status string.
```bash
curl http://localhost:8080/api/status/abc123def456789a
```
```json
{
"job_id": "abc123def456789a",
"status": "running",
"progress": "Running forensic modules m25-m29..."
}
```
Status values: `queued`, `running`, `done`, `error`.
Error code 404 dacă job_id nu există.
---
### `GET /api/result/{job_id}`
Preluare rezultat job async. Comportament:
| Job status | Răspuns |
|------------|--------------------------------------------|
| `queued` sau `running` | 202 cu status + progress |
| `done` | 200 cu rezultatul complet (același ca sync)|
| `error` | 500 cu mesajul de eroare |
```bash
curl http://localhost:8080/api/result/abc123def456789a
```
Error code 404 dacă job_id nu există.
---
### `GET /health`
Healthcheck pentru Docker / load balancer / monitoring.
```bash
curl http://localhost:8080/health
```
```json
{
"status": "ok",
"service": "forensic-features",
"modules": ["m25", "m26", "m27", "m28", "m29"]
}
```
## Notes
### Limita upload
`client_max_size=2 * 1024**3` = **2 GB** per request. Pe video >2GB,
preprocesezi cu ffmpeg înainte de upload.
### Concurența
Job store este **in-memory** (Python dict). Asta înseamnă:
- Joburile se pierd la restart container
- Multi-replica fără sticky sessions = nu funcționează
Pentru producție serioasă, înlocuiește `_jobs` din `api.py:30` cu un store
persistent (Redis recomandat).
### CORS
Activat default cu `*` pentru orice origine. Pentru producție public,
restrictionează în `api.py:build_app()`.
### Rate limiting
**NU există**. Pentru producție public, pune un reverse proxy
(nginx, Traefik) în față cu rate limiting.
### Auth
**NU există**. Endpoint-urile sunt deschise. Pentru producție, integrează cu
sistemul tău de auth via reverse proxy sau adaugă middleware.

View file

@ -0,0 +1,235 @@
# Arhitectură
Document care explică **cum funcționează fiecare strat** și **de ce e separat
așa**. Citire necesară înainte de a modifica codul.
## Filozofia: extracție de features, NU clasificare
Sistemul are **un singur scop**: să producă pentru LLM-ul tău extern un set
de **măsurători numerice + hărți vizuale** pe care LLM-ul **nu le poate
calcula din imagine**.
LLM-ul tău face deja:
- Vision generală pe imaginea originală
- OCR
- Recunoaștere de tipologii custom
- Raționament semantic
Acest serviciu adaugă:
- Puls cardiac din variația de culoare facială (rPPG)
- Drift lip-sync în milisecunde
- Direcția luminii estimată via shape-from-shading
- Hartă PNG cu zonele suspect de blending
- Scoruri statistice care diferențiază imagini AI vs naturale
LLM-ul integrează totul și decide singur. **Noi nu decidem nimic.**
## Cele 4 straturi
```
┌─────────────────────────────────────────────────────────────┐
│ STRAT 4: API HTTP (api.py) │
│ - Multipart upload │
│ - Routing: /api/forensic-evidence, /forensic-modules etc. │
│ - Sync sau async mode (job store in-memory) │
└────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ STRAT 3: Glue Layer (forensic/) │
│ │
│ orchestrator.py — rulează modulele cu input corect │
│ - adaptive every_n_frames │
│ - auto-skip m26 dacă lipsește audio │
│ - captură excepții per modul │
│ │
│ scoring.py — fuziune ponderată Dempster-Shafer │
│ - score per modul × confidence × weight │
│ - penalizare disagreement │
│ │
│ prompt_builder.py — formatare evidence_text │
│ + encoding base64 PNG pentru LLM │
└────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ STRAT 2: Detectoare Forensice (tools/m25-m29) │
│ │
│ Fiecare modul: │
│ - Primește frame_paths (sau video_path pentru m26) │
│ - Aplică algoritmul propriu (POS rPPG, NPR, etc.) │
│ - Salvează PNG-uri pentru consum vizual LLM │
│ - Returnează schema CONTRACT.md uniformă │
│ │
│ _contract.py — helper make_response() / empty_response()│
└────────────────────────────┬────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ STRAT 1: Infrastructure (preprocessing.py + libraries) │
│ - ffmpeg pentru extracție frame-uri și audio │
│ - MediaPipe FaceLandmarker (478 landmarks) │
│ - OpenCV pentru imread/imwrite + image ops │
│ - scipy/numpy pentru DSP │
└─────────────────────────────────────────────────────────────┘
```
## Fluxul unui request
Pentru `POST /api/forensic-evidence` cu un video:
### 1. Upload + validare (api.py:106-180)
- Multipart streaming în chunks 64KB (nu buffer tot fișierul)
- Validare extensie: mp4/mov/avi/mkv/webm/jpg/png
- Asignare `job_id` UUID hex 16 chars
- Salvare temporară la `/app/data/inference/{job_id}/`
### 2. Orchestrator setup (forensic/orchestrator.py:75-110)
- **Adaptive `every_n_frames`** bazat pe durata video:
- < 5s every_n=1 (toate cadrele)
- < 30s every_n=3 (~10 fps efectiv)
- < 120s every_n=10 (~3 fps)
- else → every_n=30 (~1 fps)
- **Auto-skip m26** dacă videoul nu are pistă audio
- Extracție cadre cu ffmpeg via `preprocessing.extract_frames()`
- Scriere `_meta.json` cu fps efectiv (m25 îl folosește pentru rPPG)
### 3. Rulare module (forensic/orchestrator.py:120-145)
Fiecare modul rulează **secvențial** (default), izolat în try/except:
- Crash într-un modul NU oprește restul
- Modulul eșuat returnează `empty_response()` cu primary_score=None
- Toate cele 5 module returnează aceeași schemă (CONTRACT.md)
### 4. Fuziune (forensic/scoring.py)
Dempster-Shafer-inspired weighted average:
```
weighted_score = Σ(score_i × confidence_i × weight_i) / Σ(confidence_i × weight_i)
```
- Module cu `confidence=0` (NO_SIGNAL) sunt ignorate
- Module cu confidence mic contribuie mai puțin
- Disagreement (varianța scorurilor) penalizează confidence final
- Weight default per modul: m28=1.3, m25=1.2, m27=1.0, m26=0.9, m29=0.8
Rezultatul fuziunii e **opțional** — îl includem pentru context, dar LLM-ul
poate să-l ignore.
### 5. Format pentru LLM (forensic/prompt_builder.py)
Construiește:
- **evidence_text** — bloc text formatat, ~2-3 KB tipic
- **images** — lista cu metadata + opțional data URLs base64
- **summary** — obiect cu scoruri agregate pentru parsing programatic
- **instruction_for_llm** — text fix care explică LLM-ului cum să folosească datele
### 6. Răspuns (api.py:225-265)
- Sync (default): JSON imediat
- Async (`async_mode=1`): 202 cu job_id, polling pe `/api/status/{id}`
## Schema unificată
Toate cele 5 module returnează **exact aceleași chei top-level**, garantat de
`tools/_contract.py:make_response()`:
```jsonc
{
"tool": {"id": "m25", "name": "...", "version": "1.0", "input_type": "..."},
"summary": {
"primary_score": 0..1 | None,
"primary_label": "FAKE"|"REAL"|"INCERT"|"NO_SIGNAL",
"confidence": 0..1,
"evidence": [...],
"frames_analyzed": int,
"frames_with_signal": int,
// câmpuri specifice tool — vezi MODULES.md
},
"per_frame": [...],
"metrics": {...},
"artifacts": {"images": [str]},
"errors": [],
"warnings": [],
"execution_time_ms": float
}
```
Asta înseamnă că:
- Un client nou care vrea să consume un singur modul îl poate parsa cu același cod
- Orchestrator-ul nu trebuie să cunoască câmpurile specifice fiecărui modul
- Modulele noi pot fi adăugate fără să schimbi nimic în glue layer
Vezi [CONTRACT.md](CONTRACT.md) pentru schema completă.
## Module dependențe
| Modul | Cere MediaPipe? | Cere audio? | Cere ≥N cadre |
|-------|-----------------|-------------|---------------|
| m25 Physiology | DA (FaceLandmarker, 478 lm) | NU | ≥5 cadre, fps≥4 pt rPPG |
| m26 Audio | DA (pentru mouth aperture) | DA | ≥10 cadre + audio |
| m27 AI Detector | NU | NU | ≥1 cadru |
| m28 Forgery Heatmap | DA (face mask + landmarks) | NU | ≥1 cadru cu față |
| m29 Lighting | DA (face mask + landmarks) | NU | ≥1 cadru cu față + scene highlights |
`orchestrator.py` auto-detectează aceste condiții:
- Dacă nu există audio → m26 e omis (auto_skipped)
- Dacă MediaPipe ratează față → modul returnează `empty_response("face not detected")`
- Dacă cadrele sunt prea puține pentru rPPG → m25 returnează NO_SIGNAL pe pulse
## Decizii arhitecturale importante
### De ce orchestrator secvențial, nu paralel?
Default `use_parallel=False`. Motiv: MediaPipe **nu e thread-safe la load**
(crearea instanței FaceLandmarker face I/O + alocare GPU buffers). Doi
workers care încarcă concomitent pot avea race condition.
Pe viitor, paralelizare prin **proces dedicate per modul**, nu thread.
### De ce job store in-memory?
Simplitate pentru baseline. Pentru producție serioasă, înlocuiește dict-ul
`_jobs` din `api.py` cu Redis sau SQLite (vezi nota din docs/API.md
secțiunea Async mode).
### De ce nu un model neural unic?
Trei motive:
1. **Interpretabilitate**: LLM-ul vede 5 semnale separate, poate combina cu vision
2. **Robustețe**: când un modul eșuează (ex. fără audio), restul funcționează
3. **Cost**: rulare CPU, fără GPU obligatoriu
Pentru clasificare end-to-end SOTA pe GPU, folosește un model dedicat ca
**Face X-ray** sau **UnivFD** — nu acest sistem.
### De ce HF detector e opt-in dezactivat?
Testat empiric pe 30 samples reale:
- Cu HF agresiv (weight 0.75): regresie de la 27% strict → 20%
- Cu HF defensiv (doar la confidence extremă): 13% strict
- Fără HF: 27% strict, 77% lenient (cel mai bun)
Cauza: `Organika/sdxl-detector` e antrenat pe imagini SD vs photos. Pe video
TikTok/news (distribuția ta), produce fals-pozitive masive. Cod prezent în
m27 pentru când se găsește un model potrivit; activează cu `M27_USE_HF=1`.
## Performanță
| Tip input | Procesare tipică |
|-----------|------------------|
| Imagine statică (1-3 cadre) | 1-3 s |
| Video scurt (<5s) cu față clară | 5-15 s |
| Video mediu (30s) | 30-60 s |
| Video lung (>2 min) | 1-5 min |
Bottleneck-uri:
1. **MediaPipe FaceLandmarker** pe CPU: ~80-150 ms/frame
2. **m28 forgery heatmap**: 3 component maps × Gaussian blur — heavy
3. **rPPG POS**: rapid (~50ms/clip) pe semnal mic
## Următorii pași dacă vrei extindere
Vezi [MODULES.md](MODULES.md) pentru cum să adaugi un modul nou.
Pe scurt:
1. Creează `tools/mXX_nume/nume.py` + `nume.json` config
2. Funcția `run()` returnează schema CONTRACT prin `make_response()`
3. Adaugă `(import_path, fn_name, input_type)` în `AVAILABLE_MODULES` din
`forensic/orchestrator.py`
4. Adaugă weight default în `DEFAULT_WEIGHTS` din `forensic/scoring.py`
5. Rebuild + test cu `/api/forensic-modules` — apare în catalog
Nu trebuie modificat `api.py` — orchestrator-ul descoperă modulele automat.

View file

@ -0,0 +1,246 @@
# Output Schema Contract
Schema completă returnată de `POST /api/forensic-evidence`. Acest contract
este **stabil** între versiuni — clienții se pot baza pe câmpurile listate aici.
## Top-level structure
```jsonc
{
// ── Metadata request ─────────────────────────────────────────────────
"video_path": "video.mp4", // string — numele fișierului uploadat
"n_frames_extracted": 64, // int — câte cadre s-au extras cu ffmpeg
"every_n_frames_used": 3, // int — pasul efectiv folosit
"auto_skipped": ["m26 (no audio)"], // array<string> — module sărite + motiv
"modules_run": ["m25", "m27", "m28", "m29"], // array<string> — IDs efectiv rulate
"execution_time_ms": 48432.0, // float — timp total în ms
// ── Verdict fuzionat (info, NU obligatoriu) ──────────────────────────
"fusion": {
"score": 0.67, // float 0..1 | null
"label": "FAKE", // "FAKE" | "REAL" | "INCERT" | "NO_SIGNAL"
"confidence": 0.78, // float 0..1
"n_contributing": 4, // int — module care au contribuit
"n_no_signal": 0, // int — module fără semnal
"disagreement": 0.005, // float — varianța scorurilor
"contributions": { // dict<module_id, float> — pondere efectivă
"m25": 0.09,
"m27": 0.23,
"m28": 0.24,
"m29": 0.11
}
},
// ── Explicație human-readable (1-3 propoziții) ───────────────────────
"explanation": [
"Verdict forensic: FAKE (score=0.67, confidence=0.78, din 4 detectoare active)",
"Top contributors: m28 INCERT, m27 FAKE, m25 INCERT"
],
// ── Output per modul (cel mai important pentru parsing detaliat) ─────
"modules": {
"m25": { ... vezi „Per-module schema" mai jos ... },
"m27": { ... },
"m28": { ... },
"m29": { ... }
},
// ── Text formatat pentru injectare directă în prompt LLM ─────────────
"evidence_text": "FORENSIC EVIDENCE (objective measurements)...", // string ~1500-3000 chars
// ── Imagini pentru atașare la apel multimodal LLM ────────────────────
"images": [
{
"name": "m25_pulse_signal.png", // numele fișierului
"tool_id": "m25", // care modul l-a generat
"abs_path": "/app/data/results/{job_id}/images/m25_pulse_signal.png",
"size_bytes": 69384, // dimensiune pe disk
"data_url": "data:image/png;base64,..." // OPTIONAL, doar dacă encode_images=1
}
// ... 8-15 imagini total tipic
],
// ── Summary structurat pentru parsing programatic ────────────────────
"summary": {
"overall_score": 0.67, // = fusion.score (duplicat pentru convenience)
"overall_label": "FAKE",
"overall_confidence": 0.78,
"n_modules_run": 4,
"n_modules_signal": 4,
"disagreement": 0.005
},
// ── Instrucțiune fixă pentru LLM (cum să folosească evidence) ────────
"instruction_for_llm": "HOW TO USE FORENSIC EVIDENCE ABOVE:\n- These are objective...",
// ── Erori globale (NU per modul) ─────────────────────────────────────
"errors": []
}
```
## Per-module schema
Fiecare modul în `modules.{module_id}` are **EXACT aceeași structură**:
```jsonc
{
// ── Identificare ─────────────────────────────────────────────────────
"tool": {
"id": "m25", // string — module ID stabil
"name": "Physiology", // human-readable
"version": "1.0", // versiune algoritm (semantic)
"input_type": "overview_frames" // "overview_frames" | "video_path"
},
// ── Sumar — primary contract LLM ─────────────────────────────────────
"summary": {
// Câmpuri OBLIGATORII (toate modulele le au):
"frames_analyzed": 20, // int — cadre procesate efectiv
"frames_with_signal": 18, // int — cadre cu semnal valid
"primary_score": 0.63, // float 0..1 | null
// 1.0 = maxim suspect FAKE
// 0.0 = maxim natural REAL
// null = NU s-a putut calcula
"primary_label": "INCERT", // derivat din primary_score:
// null → "NO_SIGNAL"
// >=0.65 → "FAKE"
// <=0.35 → "REAL"
// else → "INCERT"
"confidence": 0.41, // float 0..1 — încredere în primary_score
"evidence": [ // array<string>, max 5 — pentru LLM
"Pulse: 82 BPM, SNR=1.1 dB (no plausible cardiac signal)",
"Blink count: 1 over 5.1s (natural)"
],
// Câmpuri SPECIFICE modulului (vezi docs/MODULES.md pentru lista completă):
"pulse_bpm": 82.35, // (m25)
"pulse_snr_db": 1.1, // (m25)
"blink_count_total": 1, // (m25)
"blink_asymmetry_ms": null, // (m25)
"lip_sync_offset_ms": 187.0, // (m26, doar dacă audio prezent)
"f0_std_hz": 12.3, // (m26)
"voice_ratio": 0.95, // (m26)
"npr_score_mean": 0.48, // (m27)
"jpeg_recon_score_mean": 0.78, // (m27)
"hf_score_mean": null, // (m27, doar dacă M27_USE_HF=1)
"peak_suspicion_max": 0.90, // (m28)
"boundary_mean_suspicion": 0.137, // (m28)
"lighting_mismatch_deg_mean": 95.0, // (m29)
"catchlight_consistency_mean": null // (m29)
// ... (vezi MODULES.md pentru lista exhaustivă per modul)
},
// ── Per-frame breakdown (opțional, pentru debug) ─────────────────────
"per_frame": [
{
"frame_index": 0,
"signal_present": true,
// câmpuri specifice modulului
"ear_left": 0.31,
"ear_right": 0.29
}
// ...
],
// ── Metrici la nivel de clip (date raw care nu sunt în summary) ──────
"metrics": {
"rppg_samples": 20,
"blinks_left": [{...}], // detalii per blink (m25)
"blinks_right": [{...}],
// ...
},
// ── PNG-uri generate de acest modul ──────────────────────────────────
"artifacts": {
"images": [
"m25_pulse_signal.png",
"m25_blink_timeline.png"
]
},
"errors": [], // array<string> — erori non-fatale
"warnings": ["MediaPipe lipsă; fallback la Haar"],
"execution_time_ms": 1247.3 // float — timp execuție modul
}
```
## Reguli stricte (garantate de `tools/_contract.py`)
1. **`primary_score`** este **întotdeauna** în [0, 1] sau `null`.
2. **`primary_label`** este derivat strict din `primary_score`:
| Score range | Label |
|-------------|-------|
| `null` | `"NO_SIGNAL"` |
| `>= 0.65` | `"FAKE"` |
| `<= 0.35` | `"REAL"` |
| `(0.35, 0.65)` | `"INCERT"` |
3. **`confidence`** reflectă cât material valid a avut tool-ul. Video scurte
sau cu puține cadre valide → confidence scăzut.
4. **`evidence`** conține **max 5** propoziții human-readable, fără jargon
tehnic excesiv. Gata de inserat în prompt LLM.
5. **`artifacts.images`** conține **doar nume de fișier** (nu paths absolute).
Paths se construiesc cu `results_dir/images/{name}`.
6. **`errors`** se umple **doar cu erori non-fatale** (modul produce un
rezultat parțial). Crash → excepție → orchestrator capturează în `errors`
global, nu per modul.
7. **NU există NaN/Inf** în output JSON. Toate float-urile sunt finite sau `null`.
8. **NO_SIGNAL este normal**, NU eroare. Modul care nu poate calcula nimic
(ex. m26 fără audio, m25 cu prea puține cadre) returnează:
```json
{
"summary": {
"primary_score": null,
"primary_label": "NO_SIGNAL",
"confidence": 0.0,
"evidence": ["Reason: no audio track in video"]
},
...
}
```
## Versionare
Versiunea schemei e implicită în structură. Schimbări breaking sunt anunțate
prin **versiune nouă a modulului** (`tool.version`). Câmpurile noi pot fi
adăugate la `summary` fără să rupă clienții existenți care le ignoră.
Câmpurile **OBLIGATORII** listate mai sus sunt stabile între versiuni.
## Validare schema în client
```python
def validate_response(data: dict) -> bool:
required_top = {"video_path", "modules_run", "fusion", "modules",
"evidence_text", "images", "summary"}
if not required_top.issubset(data.keys()):
return False
for mid, mod in data["modules"].items():
if "summary" not in mod or "tool" not in mod:
return False
s = mod["summary"]
required_summary = {"primary_score", "primary_label",
"confidence", "evidence"}
if not required_summary.issubset(s.keys()):
return False
return True
```
## Câmpuri specifice complete per modul
Pentru lista exhaustivă a câmpurilor `summary.tool_specific_field_*` pentru
fiecare modul m25-m29, vezi:
- [MODULES.md](MODULES.md) — secțiunea "Output specific" pentru fiecare modul
Acestea sunt **opționale** pentru clienți — toți ar trebui să se bazeze pe
câmpurile obligatorii (primary_score, primary_label, confidence, evidence)
plus `evidence_text` la nivel top-level care e gata pentru LLM.

View file

@ -0,0 +1,366 @@
# Integrare în aplicația ta LLM
Document care arată **EXACT** cum apelezi acest serviciu din aplicația ta
care folosește deja un LLM multimodal (Qwen Vision, GPT-4V, Claude Sonnet).
## Pattern de bază
```
┌─────────────────────────┐
│ User uploadează video │
│ în aplicația ta │
└───────────┬─────────────┘
├─ Pas 1: Trimite videoul la Forensic Features API
│ → primești evidence_text + base64 imagini
├─ Pas 2: Construiește prompt-ul TĂU existent
│ (cu tipologiile tale, instrucțiunile tale)
│ + APPEND evidence_text
├─ Pas 3: Atașează la apelul LLM:
│ - imaginile originale ale userului
│ - imaginile noastre (heatmap-uri)
└─ Pas 4: LLM-ul tău returnează verdictul
cu signal îmbogățit de la noi
```
## Exemple complete
### Python — Qwen Vision (compatible cu OpenAI ChatCompletions API)
```python
import requests
import base64
from pathlib import Path
FORENSIC_API = "http://localhost:8080"
QWEN_API = "http://your-qwen-host:14011/v1/chat/completions"
def analyze_video(video_path: str, your_typologies: list[str]) -> dict:
"""
Apelează Forensic Features API → construiește prompt → apelează Qwen.
"""
# ── Pas 1: Forensic Features API ──────────────────────────────────
with open(video_path, "rb") as f:
resp = requests.post(
f"{FORENSIC_API}/api/forensic-evidence",
files={"video": f},
data={
"encode_images": "1", # cu base64 pentru atașare directă
# Optional: "modules": "m25,m27,m28" pt doar 3 module
},
timeout=300,
)
resp.raise_for_status()
forensic = resp.json()
# ── Pas 2: Construiește mesajul multimodal pentru LLM ─────────────
# PROMPT-UL TĂU EXISTENT — așa cum îl ai acum
your_prompt = f"""
Ești un analist video forensic. Analizează acest video și verifică:
Tipologii de elemente vizuale de căutat:
{chr(10).join(f"- {t}" for t in your_typologies)}
Răspunde structurat...
"""
# Append evidence-ul nostru
augmented_prompt = your_prompt + "\n\n" + forensic["evidence_text"]
# ── Pas 3: Construiește content multimodal ───────────────────────
# Lista de imagini originale ale userului (din aplicația ta) +
# imaginile noastre forensice (heatmap-uri, plot-uri)
content = [
{"type": "text", "text": augmented_prompt},
]
# Imaginile TALE existente — pe care le aveai deja în pipeline
your_keyframes = extract_keyframes_yourself(video_path) # funcția ta existentă
for img_path in your_keyframes:
img_b64 = base64.b64encode(open(img_path, "rb").read()).decode()
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
})
# Imaginile NOASTRE forensice — heatmap-uri zone suspect, lighting arrows
for img in forensic["images"]:
if "data_url" in img:
content.append({
"type": "image_url",
"image_url": {"url": img["data_url"]},
})
# ── Pas 4: Apelează LLM-ul tău ───────────────────────────────────
qwen_payload = {
"model": "Qwen3.5-397B-A17B",
"messages": [
{"role": "system", "content": "Ești analist forensic. Răspunzi în JSON valid."},
{"role": "user", "content": content},
],
"max_tokens": 1500,
"temperature": 0.1,
}
qwen_resp = requests.post(QWEN_API, json=qwen_payload, timeout=120)
qwen_resp.raise_for_status()
# Parse JSON din răspuns
import json
llm_text = qwen_resp.json()["choices"][0]["message"]["content"]
verdict = json.loads(llm_text)
return {
"verdict": verdict, # ce decide LLM-ul tău
"forensic_evidence": forensic, # pentru debug / audit
"augmented_prompt": augmented_prompt, # pentru replicare
}
# Usage
result = analyze_video(
"user_uploaded.mp4",
your_typologies=["face_swap_visible", "background_anomaly", "logo_overlay"],
)
print(result["verdict"])
```
### JavaScript / Node — direct fetch
```javascript
async function analyzeWithForensic(videoFile) {
// Pas 1: Forensic Features
const fd = new FormData();
fd.append("video", videoFile);
fd.append("encode_images", "1");
const forensicResp = await fetch("http://localhost:8080/api/forensic-evidence", {
method: "POST",
body: fd,
});
const forensic = await forensicResp.json();
// Pas 2: Construiește content multimodal pentru LLM-ul tău
const content = [
{ type: "text", text: yourExistingPrompt + "\n\n" + forensic.evidence_text }
];
// Imaginile tale + ale noastre
for (const img of yourKeyframes) {
content.push({ type: "image_url", image_url: { url: img.dataUrl } });
}
for (const img of forensic.images) {
if (img.data_url) {
content.push({ type: "image_url", image_url: { url: img.data_url } });
}
}
// Pas 3: Apelează LLM-ul tău
const llmResp = await callYourLLM({ content });
return llmResp;
}
```
## Pattern async pentru video lung
Pe video >30 secunde, procesarea poate dura 1-5 minute. Folosește **async mode**:
```python
import time
def analyze_long_video_async(video_path):
# Submit
with open(video_path, "rb") as f:
resp = requests.post(
f"{FORENSIC_API}/api/forensic-evidence",
files={"video": f},
data={"async_mode": "1"},
)
job_id = resp.json()["job_id"]
print(f"Job submitted: {job_id}")
# Poll
while True:
status_resp = requests.get(f"{FORENSIC_API}/api/status/{job_id}")
status = status_resp.json()
print(f"Status: {status['status']} — {status['progress']}")
if status["status"] == "done":
break
if status["status"] == "error":
raise Exception(f"Forensic pipeline failed: {status}")
time.sleep(5)
# Retrieve
result_resp = requests.get(f"{FORENSIC_API}/api/result/{job_id}")
return result_resp.json()
```
## Cum interpretează LLM-ul tău evidence-ul
Când inserezi `evidence_text` în prompt, LLM-ul vede ceva de genul:
```
FORENSIC EVIDENCE (objective measurements you cannot recompute)
======================================================================
OVERALL VERDICT: FAKE (score=0.67, confidence=0.78)
Detectors active: 4, no signal: 0, disagreement: 0.005
Individual detectors:
----------------------------------------------------------------------
[m25 Physiology] score=0.63 (INCERT) confidence=0.41 contrib=+0.091
- Pulse: 82 BPM, SNR=1.1 dB (no plausible cardiac signal)
- Blink count: 1 over 5.1s (natural)
Visuals: m25_pulse_signal.png, m25_blink_timeline.png
[m27 AI-Generated Image Detector] score=0.78 (FAKE) confidence=1.00 contrib=+0.231
- JPEG-recon: 0.78 (above 0.65 = AI suspect)
- NPR: 0.48
Visuals: m27_score_timeline.png
[m28 Forgery Localization Heatmap] score=0.62 (INCERT) confidence=1.00 contrib=+0.237
- Forgery score: 0.62 (peak=0.90, boundary_mean=0.137) — intermediate
- Frames with face: 5/5
Visuals: m28_heatmap_0000.png, ..., m28_heatmap_0040.png
[m29 Lighting 3D Consistency] score=0.61 (INCERT) confidence=0.76
- Face vs scene lighting: 95° (mismatch >90°, suspect compus)
- Catchlights: nedetectabile (ochi închiși/ochelari/rezoluție mică)
Visuals: m29_lighting_0000.png, ..., m29_lighting_0030.png
======================================================================
HOW TO USE FORENSIC EVIDENCE ABOVE:
- These are objective numerical measurements that you CANNOT recompute from
images alone. They are produced by classical signal-processing detectors...
- For each detector that flags FAKE, search the keyframes for the visual
artifact that explains the score...
======================================================================
```
LLM-ul are toate aceste informații + imaginile reale + tipologiile tale.
Combinat cu reasoning-ul lui semantic, decide singur cu signal MULT mai bogat
decât doar din imagine.
## Best practices
### 1. Cache rezultatul forensic per video
Forensic features sunt **deterministe** pe același video. Cache prin
hash SHA256 al fișierului — economie de zeci de secunde per request repetat.
```python
import hashlib
def video_hash(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()[:16]
# Cache in Redis sau SQLite cu key = video_hash
```
### 2. Selectează module relevante per tip conținut
Nu rula toate cele 5 mereu:
- **Imagine statică** (jpg/png) → m27 + m28 (rest sunt no-op temporale)
- **Talking head video** → m25 + m26 + m28 + m29
- **AI-generated landscape** (fără față) → m27 doar
- **Screen recording** → m24 (din pipeline vechi v3, NU în această versiune)
Setezi cu `-F "modules=m27,m28"`.
### 3. Truncare evidence_text pe LLM cu context mic
Dacă LLM-ul tău are context window mic (<8K), poți cere doar `summary`:
```python
# În prompt, în loc de evidence_text complet (1500-3000 chars), folosește:
short_evidence = f"""
Forensic signals on this video:
- Overall: {forensic['fusion']['label']} (score={forensic['fusion']['score']:.2f})
- m25 Physiology: {forensic['modules']['m25']['summary']['primary_label']}
- m27 AI Detector: {forensic['modules']['m27']['summary']['primary_label']}
- m28 Blending: {forensic['modules']['m28']['summary']['primary_label']}
"""
```
### 4. Atașează DOAR cele mai relevante PNG-uri
Pe LLM cu limite multimodal (4-6 imagini per call), nu trimite toate 12-15
din output. Filtrează:
```python
# Doar PNG-uri din module flagged FAKE
relevant_images = [
img for img in forensic["images"]
if forensic["modules"][img["tool_id"]]["summary"]["primary_label"] == "FAKE"
]
```
### 5. Loghează verdictul + evidence pentru audit
```python
# Salvează atât verdictul LLM cât și evidence-ul nostru
# pentru audit ulterior și calibrare
audit_log.write({
"video_id": video_hash(video_path),
"forensic_fusion": forensic["fusion"],
"llm_verdict": verdict,
"timestamp": datetime.utcnow().isoformat(),
})
```
## Health check și monitoring
```python
# Verifică serviciul e disponibil înainte de a procesa
def is_forensic_healthy():
try:
r = requests.get(f"{FORENSIC_API}/health", timeout=5)
return r.status_code == 200 and r.json().get("status") == "ok"
except Exception:
return False
# Fallback graceful dacă serviciul e down
if not is_forensic_healthy():
logger.warning("Forensic features API down, proceeding without augmentation")
# Doar apel LLM cu prompt-ul tău original, fără evidence
else:
# Apel complet cu augmentation
```
## Limitări la integrare
1. **Cost de timp**: +5-90s pe request (depinde de durata video). Pentru
UX, folosește async mode cu indicator de progres.
2. **Cost de tokens LLM**: evidence_text adaugă ~500-1000 tokens.
Imaginile noastre ~12-15 imagini × cost per image LLM.
3. **Determinism**: forensic features sunt deterministe, dar LLM-ul nu.
Pe același video, evidence e mereu același, dar verdict LLM poate varia.
4. **Limită upload**: 2 GB max. Video peste asta — split sau downscale înainte.
## Test live
```bash
# Verifică serviciu
curl http://localhost:8080/health
# Test cu un video real
curl -X POST http://localhost:8080/api/forensic-evidence \
-F "video=@your_test_video.mp4" \
-o response.json
# Extrage doar partea text pentru LLM
python -c "import json; print(json.load(open('response.json'))['evidence_text'])"
# Numără imaginile generate
python -c "import json; print(f'{len(json.load(open(\"response.json\"))[\"images\"])} PNG-uri pentru LLM')"
```

View file

@ -0,0 +1,496 @@
# Module Deep Dive
Detaliu tehnic pentru fiecare din cele 5 module. Algoritm exact, parametri,
câmpuri de output specifice, limitări cunoscute.
---
## m25 — Physiology (rPPG + Blink Dynamics)
**Scop**: detectează semnale fiziologice care nu pot fi falsificate de AI:
puls cardiac prin remote photoplethysmography (rPPG) din variația de culoare
facială, plus dinamica clipitului ochilor.
**Fișier**: [tools/m25_physiology/physiology.py](../tools/m25_physiology/physiology.py)
### Algoritm
#### Partea 1: rPPG via POS (Plane Orthogonal to Skin, Wang et al. 2017)
1. **Per cadru**: detectează fața cu MediaPipe FaceMesh (478 landmarks).
Extrage ROI obraz stânga + dreapta din landmarks-uri specifice
(`LEFT_CHEEK_LM=[101,207,187]`, `RIGHT_CHEEK_LM=[330,427,411]`).
Mediază RGB pe fiecare ROI.
2. **Construiește seria temporală** RGB(t) — câte un sample per cadru.
3. **POS algorithm**:
```
C_n(t) = C(t) / mean(C) # normalizare temporală
X = 3*R_n - 2*G_n
Y = 1.5*R_n + G_n - 1.5*B_n
α = std(X) / std(Y)
P(t) = X(t) + α * Y(t)
```
4. **Detrending polynomial ordin 3** pe P(t) — elimină drift slow din
variația de iluminare (cloud cover, AGC cameră). Critical pentru rPPG
fiabil pe video real.
5. **FFT + bandpass** 0.7-4 Hz (40-240 BPM range fiziologic):
- Identifică peak în banda 50-110 BPM
- BPM = peak_freq × 60
- SNR = power(peak ± 0.2 Hz) / power(restul benzii)
#### Partea 2: Blink Dynamics
1. **EAR per ochi** (Eye Aspect Ratio, Soukupová & Čech 2016):
```
EAR = (||p2-p6|| + ||p3-p5||) / (2 × ||p1-p4||)
```
Calculat separat pe ochi stâng (landmarks `[33, 160, 158, 133, 153, 144]`)
și drept (`[362, 385, 387, 263, 373, 380]`).
2. **Detectare blink event**: tranziție EAR < 0.20 EAR > 0.25.
Pentru fiecare blink: start_idx, min_idx, end_idx, min_ear.
3. **Asimetrie L-R temporală**: pentru fiecare blink stâng, găsește perechea
cea mai apropiată în timp pe dreapta. Calculează diferența în ms.
Real human: 30-80 ms asimetrie (asimetrie neurologică naturală).
AI face: simetric perfect sau jitter aleator.
### Output specific (summary)
```jsonc
{
"pulse_bpm": 82.4, // BPM detectat (null dacă fără semnal)
"pulse_snr_db": 1.1, // SNR în dB (>3 = semnal credibil)
"blink_count_left": 1,
"blink_count_right": 1,
"blink_count_total": 1,
"blink_asymmetry_ms": 42.0, // ms diferență temporală L-R
"fps_used": 8.0, // fps efectiv (din _meta.json)
"rppg_method": "POS",
"mediapipe_used": true
}
```
### Limitări
- **Sparse extraction**: dacă fps efectiv < 4, rPPG e imposibil (sub Nyquist
pentru banda 0.7-4 Hz). Modulul returnează NO_SIGNAL clar.
- **Iluminare variabilă**: face mai dificil detrending-ul. Pe video cu
flickering puternic, semnal degradat.
- **Față mică** (< 150 px lățime): ROI obraz prea mic pentru sample RGB
stabil.
- **MediaPipe rateaza fața**: pe ochi închiși, profil oblic >45°, low-light.
### PNG-uri generate
- `m25_pulse_signal.png` — grafic P(t) detrendat + spectrul FFT cu peak marcat
- `m25_blink_timeline.png` — EAR L și R în timp + zonele de blink evidențiate
---
## m26 — Audio Forensics (Lip-Sync + Voice Clone Heuristic)
**Scop**: analizează coloana sonoră pentru drift între mișcarea buzelor și
audio (lip-sync offset) + caracteristici statistice ale vocii sintetice
(F0 prea stabil, centroidă spectrală prea constantă, lipsa pauzelor
respiratorii).
**Fișier**: [tools/m26_audio/audio.py](../tools/m26_audio/audio.py)
### Algoritm
#### Audio extraction (in-memory, fără disk I/O)
`ffmpeg -i video.mp4 -vn -ac 1 -ar 16000 -f f32le pipe:1`
Output: PCM float32 mono 16 kHz în memorie.
#### VAD (Voice Activity Detection) inline
Combină energie locală + zero-crossing rate per fereastră 30ms:
- Threshold energie adaptiv = 30th percentile × 1.5
- Voce = energie > threshold AND ZCR în [0.02, 0.30]
- Returnează `voiced` mask + `voice_ratio` global
Folosit ca **gate** pe restul analizei — analizăm F0/centroid DOAR pe
ferestrele unde VAD spune că e voce.
#### F0 (pitch fundamental) via autocorelație
Per fereastră 25ms (hop 10ms):
- Autocorelație normalizată a semnalului
- Caută peak în banda 75-400 Hz (range voce umană)
- F0 = sample_rate / peak_lag dacă ac[peak] > 0.3, altfel 0
#### Spectral centroid
Per fereastră 25ms cu Hann window:
- FFT magnitude spectrum
- Centroid = Σ(freq × magnitude) / Σ(magnitude)
#### Lip-sync offset (cross-correlation)
1. Pe video, extrage MediaPipe FaceMesh per cadru, calculează:
```
mouth_aperture = ||lm_13 - lm_14|| / ||lm_61 - lm_291||
```
(deschidere verticală / lățime gură).
2. Resample mouth_aperture la 100 Hz (același rate ca audio envelope).
3. Cross-correlation FFT-based între:
- audio RMS envelope (per fereastră 10ms)
- mouth_aperture differential (|d_aperture/dt|)
4. Lag care maximizează corelația = lip_sync_offset_ms.
- |offset| < 60 ms real video
- |offset| > 150 ms → deepfake lip-sync (Wav2Lip-class drift)
### Output specific (summary)
```jsonc
{
"lip_sync_offset_ms": 187.0, // ms (null dacă fără față)
"lip_sync_correlation": 0.42,
"f0_std_hz": 12.3, // <25 Hz = TTS-like
"spectral_centroid_std_hz": 145.0, // <250 Hz = TTS-like
"silence_ratio": 0.05,
"voice_ratio": 0.95,
"n_voiced_frames": 480,
"audio_duration_s": 20.4,
"audio_sample_rate": 16000,
"lip_sync_component": 0.9,
"voice_clone_component": 0.7
}
```
### Limitări
- **Fără audio**: m26 returnează NO_SIGNAL. Orchestrator-ul auto-skip cu
ffprobe verificare înainte de execuție.
- **Audio cu zgomot puternic**: VAD raporteaza voce când e doar zgomot;
F0 e brittle pe semnale noise-y.
- **Profil oblic**: mouth_aperture devine instabilă când unghiul depășește
30° (raport vertical/horiz inflate).
- **Voice clone heuristic e PROXY**: pentru detector real, integrează
AASIST sau RawNet2.
### PNG-uri generate
- `m26_audio_visual_sync.png` — overlay audio envelope normalizat + mouth
aperture, cu lag marcat. Util pentru LLM să vadă vizual decalajul.
---
## m27 — AI-Generated Image Detector (Black-Box)
**Scop**: distinge imagini fotografice naturale de imagini generate AI
(GAN, diffusion models).
**Fișier**: [tools/m27_ai_detector/ai_detector.py](../tools/m27_ai_detector/ai_detector.py)
### Algoritm
Trei semnale combinate:
#### NPR (Neighboring Pixel Relationships, Tan et al. 2024 — adapted)
1. Construiește **piramida Gaussian** 4 niveluri din imaginea grayscale.
2. Resize toate nivelurile la dimensiunea originală.
3. Pentru fiecare pereche de niveluri consecutive: calculează **reziduul**
și **varianța locală 3×3** a reziduului².
4. Medianul varianțelor → `mean_residual_var`.
5. **Sigmoid centrat pe 3.5** → score 0..1 (mai mic mean → mai suspect AI).
Real video natural: mean_residual_var ~5-30 (textură fluctuantă).
AI generated: mean_residual_var ~0.5-4 (smooth predict).
#### JPEG Reconstruction Error (in-memory)
1. `cv2.imencode(".jpg", frame, [JPEG_QUALITY, 65])`
2. `cv2.imdecode(buf)` — recompressed
3. `error_norm = mean(|orig - recomp|) / std(orig)`
4. **Sigmoid centrat pe 0.04** (steep) → score 0..1.
Imagini cu detalii naturale → error_norm mare (~0.10-0.15).
AI imagery → error_norm mic (~0.02-0.06).
#### HuggingFace Detector (OPT-IN, dezactivat default)
Dacă `M27_USE_HF=1`:
- Încarcă `Organika/sdxl-detector` (ViT base ~330MB) prin transformers
- Inferință per cadru, output: score 0..1 = probabilitate AI
**Defensive fusion**: HF folosit DOAR când e confident extrem (>0.85 sau <0.15).
În rest cade pe NPR + JPEG.
**Status empiric**: testat pe 30 samples reale (TikTok video + AI images).
Out-of-distribution masiv → regresie de la 27% strict (fără HF) la 13-20%
(cu HF). **NU recomandat pe video data**. Cod prezent pentru când se găsește
un model mai bine adaptat distribuției tale.
### Fusion internă
Dacă există HF score și e confident:
```
primary = 0.50 × hf_mean + 0.30 × npr_mean + 0.20 × jpeg_mean
```
Altfel (default):
```
primary = max(npr_mean, jpeg_mean)
```
### Output specific (summary)
```jsonc
{
"hf_score_mean": null, // sau float 0..1 dacă activat
"hf_model_loaded": false,
"hf_model_status": "HF detector disabled (set M27_USE_HF=1)",
"npr_score_mean": 0.48,
"npr_score_std": 0.10,
"jpeg_recon_score_mean": 0.78,
"jpeg_recon_score_std": 0.05,
"torch_score_mean": null,
"torch_model_loaded": false,
"n_frames_sampled": 16
}
```
### Limitări
- **Statistical-only fără HF**: discriminative power limitat. Pe video real
cu compresie puternică, JPEG-recon dă fals-pozitive (vezi tabel din
experimente: pe REAL video tipic 0.70-0.85 FAKE).
- **NPR sensibil la rezoluție**: imagini sub 64×64 → fallback la 0.5 neutru.
- **Pe video re-encodat**: codec deja a smoothed detalii fine → ambele semnale
se confundă cu AI-generated.
### PNG generat
- `m27_score_timeline.png` — line plot scoruri NPR + JPEG per cadru sample.
---
## m28 — Forgery Localization Heatmap
**Scop**: produce o **hartă 2D** unde fiecare pixel are probabilitate de
manipulare locală. **Cel mai util output pentru LLM** — îi dai imagine PNG
cu zona suspect colorată, LLM-ul confirmă vizual.
**Fișier**: [tools/m28_forgery_heatmap/forgery_heatmap.py](../tools/m28_forgery_heatmap/forgery_heatmap.py)
### Algoritm (Face X-ray-inspired, simplificat fără rețea)
1. **Detectează fața** cu MediaPipe FaceMesh, extrage contour din
`FACE_OVAL` landmarks. Construiește mască poligonală binară.
2. **Boundary band adaptiv** la mărimea feței: `max(8, face_width * 0.05)`.
3. **3 hărți de discontinuitate**:
**(a) Multi-scale Laplacian discrepancy**:
- Aplică Laplacian la scale [3, 7, 15] (Gaussian smoothed)
- Stivuiește; pentru fiecare pixel: std cross-scale
- Limitează la boundary band
**(b) Frequency-domain split inconsistency**:
- FFT global → high-pass (cutoff 15% rază)
- Gradient Sobel pe high-freq map
- Limitează la boundary band
**(c) Chrominance step in LAB**:
- Convert LAB; gradient pe canalele a și b
- Localizat strict pe boundary band
4. **Compunere ponderată** (NU max, ca să nu satureze la outlier):
```
combined = 0.5*map_chroma + 0.3*map_freq + 0.2*map_laplacian
heatmap = sigmoid(6 * (combined - 0.5))
```
5. **Peak detection**: bounding box al regiunii cu max suspicion.
`boundary_mean_suspicion` = media heatmap pe banda boundary.
### Output specific (summary)
```jsonc
{
"peak_suspicion_max": 0.90,
"peak_suspicion_mean": 0.88,
"boundary_mean_suspicion": 0.14, // metric principal — mai stabil
"frames_with_face": 5,
"mediapipe_used": true
}
```
### Scoring final
```
score = 0.4 * peak_max + 0.6 * min(1.0, (boundary_mean - 0.05) / 0.20)
```
Peak ridicat + boundary_mean mic = noise (un pixel anomalous).
Peak ridicat + boundary_mean ridicat = blending real detectat.
### Limitări
- **Pe full-AI generated** (fără boundary face-swap), m28 nu vede mare diferență.
Pentru asta folosește m27.
- **MediaPipe rateaza fața** → fallback la **Haar eliptic** (mai puțin precis).
Pe video oblice/low-light, fallback dă fals-pozitive frecvente.
- **Boundary band fix vs. mărime față**: adaptiv 5% lățime — funcționează
bine, dar pe fețe foarte mici (<80 px) banda devine prea îngustă.
### PNG generat (cel mai util pentru LLM)
- `m28_heatmap_{frame_idx}.png` — overlay original + heatmap fierbinte cu
zona suspect colorată. Câte unul per 5 frame samples = 5 PNG-uri.
**LLM-ul vede aceste imagini** și poate spune: "da, văd zona aceea
evidențiată — și văd EFECTIV un edge artificial la jawline". Confirmare
vizuală + numerică = signal puternic.
---
## m29 — Lighting 3D Consistency
**Scop**: verifică dacă fața și scena sunt iluminate de aceleași surse de
lumină. Mismatch unghi = subiect compus.
**Fișier**: [tools/m29_lighting/lighting.py](../tools/m29_lighting/lighting.py)
### Algoritm
#### Estimare direcție lumină față (Lambertian SfS simplificat)
1. **Aproximare normale 3D ca sferă**: centrul = centroid landmark-uri,
rază = jumătate lățime față.
```
N(x, y) = ((x-cx)/r, (y-cy)/r, sqrt(1 - dx² - dy²))
```
2. **Lambertian model**:
```
I(x,y) ≈ ρ * max(N · L, 0) + ambient
```
3. **Least squares fit** pe pixeli mască:
```
[N | 1] @ [L_x, L_y, L_z, ambient]^T = I
```
Rezolvă cu `np.linalg.lstsq`.
4. **Normalizare** și conversie sferică:
- azimuth = atan2(L_x, L_z)
- elevation = asin(-L_y)
#### Estimare direcție lumină scenă
1. **Highlight detection** în zona NON-face:
- `V > percentila 95` în HSV
- `S < 80` (saturație scăzută — highlights sunt aproape albe)
2. **Connected components** pe highlight mask.
3. **Weighted centroid** (greutate = area cluster):
- dx = mediu_x_clusters - W/2
- dy = mediu_y_clusters - H/2
4. Convertit în (azimuth_scene, elevation_scene).
#### Angular discrepancy
```
angle = acos(L_face · L_scene)
```
- < 60° consistent
- 60-90° → marginal
- > 90° → mismatch suspect
#### Catchlight consistency
1. ROI ochi stâng + drept (din landmarks).
2. Top 1% intensitate per ochi → centroid + intensitate.
3. Comparare: poziții relative trebuie să fie aproximativ **oglindite**
(ochiul drept e oglindit pe x).
4. Score = 1 - normalize(|dx_diff_mirror| + |dy_diff| + |intensity_diff|).
### Output specific (summary)
```jsonc
{
"lighting_mismatch_deg_mean": 95.0,
"lighting_mismatch_deg_max": 105.4,
"catchlight_consistency_mean": null, // sau float 0..1
"frames_with_lighting_signal": 5,
"frames_with_catchlights": 0,
"lighting_component": 0.76,
"catchlight_component": 0.40
}
```
### Limitări
- **Spherical approximation** pentru normale 3D e grosier — pentru SOTA
folosește 3DMM (FLAME, BFM2009) fittat real cu eos-py.
- **Scene fără highlights**: nu putem estima direcția scenă → NO_SIGNAL.
Lumină ambientă uniformă (interior office, cer înnorat) e cazul tipic.
- **Catchlight pe ochi mici** (<30×30 px): primul pixel câștigă, semnal zgomot.
### PNG generat
- `m29_lighting_{frame_idx}.png` — overlay imagine + 2 săgeți care arată
direcția estimată: **roșie** pentru lumină față, **albastră** pentru scenă.
Mismatch vizibil instant.
---
## Cum să adaugi un modul nou (mXX)
1. Creează folder `tools/mXX_nume/`:
```
tools/mXX_nume/
├── __init__.py
├── nume.json # config: id, input_type, parameters
└── nume.py # cod algoritm
```
2. În `nume.py`:
```python
from tools._contract import make_response, empty_response
TOOL_ID = "mXX"
TOOL_NAME = "Numele tău"
VERSION = "1.0"
INPUT_TYPE = "overview_frames" # sau "video_path"
def run(frame_paths, results_dir=None):
# ... algoritmul tău ...
return make_response(
tool_id=TOOL_ID, tool_name=TOOL_NAME,
version=VERSION, input_type=INPUT_TYPE,
primary_score=0.5, confidence=0.7,
evidence=["...", "..."],
frames_analyzed=N, frames_with_signal=M,
summary_extras={"custom_field": value},
artifacts_images=["mXX_visualization.png"],
)
```
3. În `forensic/orchestrator.py:AVAILABLE_MODULES`, adaugă:
```python
"mXX": ("tools.mXX_nume.nume", "run", "overview_frames"),
```
4. În `forensic/scoring.py:DEFAULT_WEIGHTS`, adaugă weight (default 1.0):
```python
"mXX": 1.0,
```
5. Rebuild container. Modulul apare automat în `/api/forensic-modules`.

View file

@ -0,0 +1,33 @@
"""
forensic/ Glue layer pentru consumul modulelor m25-m29 într-un context LLM.
Componente:
- scoring.py Fuziunea celor N scoruri individuale într-un verdict
unificat. Pure Python, FĂRĂ dependențe pesante (cv2,
mediapipe). Importabil safe peste tot.
- prompt_builder.py Format text + imagini base64 pentru prompt LLM.
Doar stdlib + os/base64. Importabil safe.
- orchestrator.py Apelează modulele forensice. Importă preprocessing.py
care depinde de cv2 IMPORT LAZY. Nu e exportat din
__init__ ca nu propage cv2 dependency la utilizatorii
care vor doar scoring/prompt_builder.
Convenție import recomandată:
from forensic.scoring import fuse_scores # pur, fără cv2
from forensic.prompt_builder import build_evidence_block # pur, fără cv2
from forensic.orchestrator import run_forensic_pipeline # cere cv2 + mediapipe
"""
# Exportăm DOAR componente fără dependențe pesante. Orchestrator importat
# explicit de cine-l folosește.
from .scoring import fuse_scores, fusion_label, explain_fusion, DEFAULT_WEIGHTS
from .prompt_builder import build_evidence_block, encode_image_b64, format_for_chat_completion
__all__ = [
"fuse_scores", "fusion_label", "explain_fusion", "DEFAULT_WEIGHTS",
"build_evidence_block", "encode_image_b64", "format_for_chat_completion",
]

View file

@ -0,0 +1,333 @@
"""
forensic/orchestrator.py Apel uniform pentru modulele m25-m29.
OBIECTIV:
Modulele forensice noi au input_type diferit: m25, m27, m28, m29 cer
o listă de frame_paths; m26 cere video_path direct (audio extraction).
Orchestratorul rezolvă această diferență prin extragerea frame-urilor
o singură dată și trimiterea la fiecare modul în formatul corect.
EXECUȚIE:
Modulele rulează SECVENȚIAL (default) pentru predictibilitate, dar pot
rula în parallel via ProcessPoolExecutor (use_parallel=True). Atenție:
MediaPipe e thread-safe la inferență dar nu și la load (FaceLandmarker
se inițializează per process), iar m26 ține audio buffer mare în RAM.
Pe mașini cu <8GB RAM, paralelizarea poate cauza OOM.
EROARE HANDLING:
Dacă un modul aruncă excepție, NU oprește orchestratorul. Înregistrează
eroarea în câmpul "errors" al rezultatului acelui modul și continuă.
Asta ține contractul: toate modulele întotdeauna întorc o intrare
în results, chiar și când eșuează.
CALL EXEMPLU (sync):
>>> from forensic.orchestrator import run_forensic_pipeline
>>> results = run_forensic_pipeline(
... video_path="/tmp/clip.mp4",
... results_dir="/tmp/forensic_out",
... modules=["m25", "m26", "m27", "m28", "m29"],
... )
>>> results["modules"]["m25"]["summary"]["primary_label"]
'FAKE'
>>> results["fusion"]["score"]
0.78
"""
from __future__ import annotations
import importlib
import os
import sys
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
import preprocessing # noqa: E402
from tools._contract import empty_response # noqa: E402
from forensic.scoring import fuse_scores, explain_fusion # noqa: E402
# ── Catalog module disponibile ──────────────────────────────────────────────
# Map: module_id → (import_path, function_name, input_type)
# input_type: "video_path" sau "overview_frames" (lista frame paths)
AVAILABLE_MODULES: dict[str, tuple[str, str, str]] = {
"m25": ("tools.m25_physiology.physiology", "run", "overview_frames"),
"m26": ("tools.m26_audio.audio", "run", "video_path"),
"m27": ("tools.m27_ai_detector.ai_detector", "run", "overview_frames"),
"m28": ("tools.m28_forgery_heatmap.forgery_heatmap", "run", "overview_frames"),
"m29": ("tools.m29_lighting.lighting", "run", "overview_frames"),
}
DEFAULT_MODULES = ["m25", "m26", "m27", "m28", "m29"]
def _load_module_function(module_id: str):
"""Importă lazy modulul și întoarce funcția run."""
if module_id not in AVAILABLE_MODULES:
raise ValueError(f"Unknown module: {module_id}")
import_path, fn_name, input_type = AVAILABLE_MODULES[module_id]
mod = importlib.import_module(import_path)
return getattr(mod, fn_name), input_type, getattr(mod, "TOOL_NAME", module_id), getattr(mod, "VERSION", "?")
def _run_single_module(
module_id: str,
video_path: str,
frame_paths: list[str] | None,
module_results_dir: str,
) -> tuple[str, dict[str, Any]]:
"""
Rulează un singur modul, capturează excepții, întoarce (id, result).
Garantează result e un dict cu schema CONTRACT.md, chiar și pe eroare.
"""
t_start = time.perf_counter()
try:
fn, input_type, tool_name, version = _load_module_function(module_id)
except Exception as e:
return module_id, empty_response(
tool_id=module_id, tool_name=module_id, version="?",
input_type="overview_frames",
reason=f"Module load failed: {e}",
execution_time_ms=(time.perf_counter() - t_start) * 1000,
)
try:
if input_type == "video_path":
result = fn(video_path, results_dir=module_results_dir)
else:
if frame_paths is None or not frame_paths:
return module_id, empty_response(
tool_id=module_id, tool_name=tool_name, version=version,
input_type=input_type,
reason="Frame extraction failed sau frame paths empty",
execution_time_ms=(time.perf_counter() - t_start) * 1000,
)
result = fn(frame_paths, results_dir=module_results_dir)
except Exception as e:
tb = traceback.format_exc(limit=5)
return module_id, empty_response(
tool_id=module_id, tool_name=tool_name, version=version,
input_type=input_type,
reason=f"Module execution failed: {e}\n{tb}",
execution_time_ms=(time.perf_counter() - t_start) * 1000,
)
# Verifică schema minimă: orice modul TREBUIE să întoarcă schema CONTRACT.md.
# Dacă cineva a uitat să folosească make_response, hotfix la rulare.
if not isinstance(result, dict) or "summary" not in result or "tool" not in result:
return module_id, empty_response(
tool_id=module_id, tool_name=tool_name, version=version,
input_type=input_type,
reason="Schema invalidă — modulul nu folosește make_response",
execution_time_ms=(time.perf_counter() - t_start) * 1000,
)
return module_id, result
def _adaptive_every_n_frames(video_path: str) -> int:
"""
Calculează pasul de extracție frame-uri în funcție de durata video.
Pe video scurt vrem TOATE cadrele (ca m25 rPPG poată funcționa).
Pe video lung sample sparse ca nu producem 1000+ frame-uri.
Heuristic empiric:
< 5s every_n=1 (toate cadrele)
< 30s every_n=3 (~10 fps efectiv)
< 120s every_n=10 (~3 fps efectiv)
else every_n=30 (~1 fps efectiv)
"""
try:
import cv2 # local import (preprocessing îl are deja)
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
n = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
cap.release()
duration = float(n) / fps if fps > 0 else 0.0
if duration <= 0:
return 30
if duration < 5:
return 1
if duration < 30:
return 3
if duration < 120:
return 10
return 30
except Exception:
return 30
def _probe_has_audio(video_path: str) -> bool:
"""Verifică rapid cu ffprobe dacă video-ul are pistă audio."""
try:
import subprocess
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "a:0",
"-show_entries", "stream=codec_type",
"-of", "default=nw=1:nk=1", video_path],
capture_output=True, text=True, timeout=15,
)
return "audio" in result.stdout.lower()
except Exception:
return False
def run_forensic_pipeline(
video_path: str,
results_dir: str,
modules: list[str] | None = None,
every_n_frames: int | None = None,
use_parallel: bool = False,
max_workers: int = 3,
) -> dict[str, Any]:
"""
Rulează modulele forensice m25-m29 (sau alt set) pe un video.
Args:
video_path: cale către fișierul video (.mp4, .mov, etc.)
results_dir: director unde se salvează frame-urile extrase și
artefactele PNG (un sub-folder per modul). Se creează dacă lipsește.
modules: lista module_ids de rulat. Default: DEFAULT_MODULES (toate cele 5).
every_n_frames: pas extracție frame-uri (1 = toate; 30 = ~1/sec @ 30fps).
Pentru analiza forensică e suficient 1 frame/sec economie disk.
use_parallel: dacă True, rulează modulele concurent (ThreadPoolExecutor).
Notă: MediaPipe nu e thread-safe la load module care folosesc
MediaPipe cer un proces per modul (vezi limitări în docstring top).
Default False = sequential, mai lent dar safe.
max_workers: număr thread-uri pentru paralelizare (ignorat dacă
use_parallel=False).
Returns:
dict cu:
"video_path": str
"frame_paths": list[str] (pentru debug)
"n_frames_extracted": int
"modules": dict {module_id: result_CONTRACT_schema}
"fusion": dict (rezultatul fuse_scores)
"explanation": list[str] (1-3 propoziții human-readable)
"execution_time_ms": float (durată totală orchestrator)
"errors": list[str] (erori globale, NU per-modul)
"""
t_start = time.perf_counter()
if modules is None:
modules = list(DEFAULT_MODULES)
# Adaptive every_n_frames dacă nu e specificat — important pentru m25/m26
# care au nevoie de extracție densă pe video scurt (rPPG, audio analysis).
if every_n_frames is None:
every_n_frames = _adaptive_every_n_frames(video_path)
os.makedirs(results_dir, exist_ok=True)
frames_dir = os.path.join(results_dir, "_frames")
errors: list[str] = []
auto_skipped: list[str] = []
# ── Auto-skip module unde input lipsește ──
# m26 (audio) are nevoie de pistă audio. Dacă lipsește, skip ca să nu
# consumăm 30s+ pe ffmpeg degeaba.
if "m26" in modules and not _probe_has_audio(video_path):
modules = [m for m in modules if m != "m26"]
auto_skipped.append("m26 (no audio track)")
# ── Extragere frame-uri o singură dată ──
frame_paths: list[str] = []
if any(AVAILABLE_MODULES[m][2] == "overview_frames" for m in modules
if m in AVAILABLE_MODULES):
try:
frame_paths = preprocessing.extract_frames(
video_path, frames_dir, every_n=every_n_frames
)
except Exception as e:
errors.append(f"Frame extraction failed: {e}")
frame_paths = []
# ── Scriem metadata care modulele pot citi (fps real, every_n) ──
# Asta evită ghicirea hațardă în m25 a fps-ului real.
try:
import cv2 as _cv2
cap = _cv2.VideoCapture(video_path)
original_fps = float(cap.get(_cv2.CAP_PROP_FPS) or 24.0)
cap.release()
except Exception:
original_fps = 24.0
effective_fps = original_fps / max(1, every_n_frames)
meta = {
"video_path": os.path.abspath(video_path),
"original_fps": original_fps,
"every_n_frames": every_n_frames,
"effective_fps": effective_fps,
"n_frames_extracted": len(frame_paths),
}
try:
import json as _json
with open(os.path.join(results_dir, "_meta.json"), "w") as f:
_json.dump(meta, f, indent=2)
except Exception:
pass
# ── Rulare module ──
module_results: dict[str, dict[str, Any]] = {}
# Toate modulele scriu PNG-uri în results_dir/images/ — numele sunt
# prefixate unic (m25_*, m26_*, ...) deci nu există coliziuni.
# Folosim același results_dir pentru toate.
if use_parallel and len(modules) > 1:
with ThreadPoolExecutor(max_workers=max_workers) as ex:
futures = {
ex.submit(_run_single_module, mid, video_path,
frame_paths, results_dir): mid
for mid in modules
}
for fut in as_completed(futures):
mid, result = fut.result()
module_results[mid] = result
else:
for mid in modules:
mid_returned, result = _run_single_module(
mid, video_path, frame_paths, results_dir
)
module_results[mid_returned] = result
# ── Fuziunea scorurilor ──
fusion = fuse_scores(module_results)
explanation = explain_fusion(fusion, module_results)
return {
"video_path": video_path,
"frame_paths": frame_paths,
"n_frames_extracted": len(frame_paths),
"every_n_frames_used": every_n_frames,
"auto_skipped": auto_skipped,
"modules": module_results,
"fusion": fusion,
"explanation": explanation,
"execution_time_ms": round((time.perf_counter() - t_start) * 1000, 2),
"errors": errors,
}
def list_module_artifacts(orchestrator_result: dict[str, Any],
results_dir: str) -> dict[str, list[str]]:
"""
Helper: returnează dict {module_id: [absolute_paths]} cu toate PNG-urile
salvate de fiecare modul. Util pentru a le mâna la prompt_builder.
Toate PNG-urile sunt în results_dir/images/ (nume prefixate unic).
"""
artifacts: dict[str, list[str]] = {}
images_dir = os.path.join(results_dir, "images")
for mid, result in orchestrator_result.get("modules", {}).items():
if not isinstance(result, dict):
continue
names = (result.get("artifacts") or {}).get("images", []) or []
artifacts[mid] = [
os.path.join(images_dir, n)
for n in names
if os.path.exists(os.path.join(images_dir, n))
]
return artifacts

View file

@ -0,0 +1,295 @@
"""
forensic/prompt_builder.py Construcție evidence block pregătit pentru LLM.
OBIECTIV:
Aplicația din spate are deja un LLM care face vision/OCR/typology pe
imagini. Acest builder produce un BLOC TEXT + LISTA DE IMAGINI base64
care se inserează în prompt-ul existent FĂRĂ modifice promptul curent
al userului. LLM-ul primește astfel:
(1) imaginile lui obișnuite + tipologii
(2) PLUS un bloc cu măsurători forensice obiective pe care nu le
poate calcula singur
REGULI DE FORMATARE:
- Tot textul în engleză (compat cu prompt-uri existente bilingve)
- Numerele cu unități clare ("BPM", "Hz", "ms", "deg")
- Comparații implicite cu range-uri reale ("real: 60-100 BPM")
- Bullet-uri scurte (max ~80 chars per linie)
- Imaginile referite cu nume scurt în text, apoi atașate ca data URLs
SCHEMA DE OUTPUT:
{
"evidence_text": str, # bloc text de inserat în prompt
"images": [ # listă de imagini formatate
{
"name": "m25_pulse_signal.png",
"tool_id": "m25",
"data_url": "data:image/png;base64,...", # opțional
"abs_path": "/abs/path/file.png",
"size_bytes": 12345,
}
],
"summary": { # info structurat pentru reasoning
"overall_score": 0.78,
"overall_label": "FAKE",
"overall_confidence": 0.65,
"n_modules_run": 5,
"n_modules_signal": 4,
},
"instruction_for_llm": str, # text fix care explică LLM-ului
# cum să folosească evidence-ul
}
"""
from __future__ import annotations
import base64
import os
from typing import Any
# ── Instrucțiunea fixă pe care o injectăm la final pentru a ghida LLM-ul ──
LLM_INSTRUCTION = """\
HOW TO USE FORENSIC EVIDENCE ABOVE:
- These are objective numerical measurements that you CANNOT recompute from
images alone. They are produced by classical signal-processing detectors
(PRNU, FFT, optical flow, gradient analysis, etc.) plus pre-trained
black-box scorers.
- For each detector that flags FAKE, search the keyframes for the visual
artifact that explains the score. If you can confirm visually, the verdict
is strong; if you see nothing, treat it as a numerical anomaly that may
not transfer (codec artifact, lighting noise, etc.).
- A single detector at FAKE does NOT mean fake it means "investigate this
channel". Multiple detectors agreeing across independent channels (audio,
video, frequency, lighting) is the strong signal.
- The OVERALL score is a weighted fusion that already accounts for confidence
and disagreement between detectors. Use it as a baseline; your visual
analysis can override it if you have a concrete reason.
- When forensic says NO_SIGNAL or low confidence on a detector, IGNORE that
detector and rely on others + your visual analysis.\
"""
def encode_image_b64(path: str) -> str | None:
"""Citește imaginea de pe disk și returnează data URL base64 (sau None)."""
if not path or not os.path.exists(path):
return None
try:
with open(path, "rb") as f:
raw = f.read()
ext = os.path.splitext(path)[1].lower().lstrip(".")
mime = "image/png" if ext == "png" else f"image/{ext or 'jpeg'}"
return f"data:{mime};base64,{base64.b64encode(raw).decode('ascii')}"
except Exception:
return None
def _format_module_block(
module_id: str,
result: dict[str, Any],
fusion_contribution: float | None = None,
) -> str:
"""
Format compact pentru un singur modul. Exemple de ieșire:
[m25 Physiology] score=0.83 (FAKE) confidence=0.71 contrib=0.27
- Pulse: 0 BPM (real: 60-100 BPM)
- Eye blink count over 5s: 0
- Blink L/R asymmetry: 0ms (typical: 30-80ms)
Visuals: m25_pulse_signal.png, m25_blink_timeline.png
"""
tool = result.get("tool", {})
summary = result.get("summary", {})
name = tool.get("name", module_id)
score = summary.get("primary_score")
label = summary.get("primary_label", "?")
conf = summary.get("confidence", 0.0)
evidence = summary.get("evidence", []) or []
artifacts = (result.get("artifacts") or {}).get("images", []) or []
if score is None:
header = f"[{module_id} {name}] NO_SIGNAL — {(evidence or ['no signal'])[0]}"
return header
contrib_str = ""
if fusion_contribution is not None:
contrib_str = f" contrib={fusion_contribution:+.3f}"
header = (
f"[{module_id} {name}] score={score:.2f} ({label}) "
f"confidence={conf:.2f}{contrib_str}"
)
bullets = []
for ev in evidence[:5]:
bullets.append(f" - {ev}")
visuals = ""
if artifacts:
visuals = f" Visuals: {', '.join(artifacts)}"
parts = [header] + bullets
if visuals:
parts.append(visuals)
return "\n".join(parts)
def build_evidence_block(
module_results: dict[str, dict[str, Any]],
fusion: dict[str, Any] | None = None,
images_dir: str | None = None,
encode_images: bool = True,
include_instruction: bool = True,
max_images: int = 12,
) -> dict[str, Any]:
"""
Construiește evidence block pentru LLM.
Args:
module_results: dict {module_id: result_dict CONTRACT.md}
fusion: rezultatul fuse_scores() (opțional; dacă None, se omite
secțiunea OVERALL VERDICT)
images_dir: director unde sunt salvate PNG-urile artifact (necesar
pentru base64 encoding). Dacă None, încercăm orchestrator.results_dir.
encode_images: dacă True, atașează data URL base64 pentru fiecare
imagine. Dacă False, doar paths absolute.
include_instruction: dacă True, append LLM_INSTRUCTION la text.
max_images: limită cap maxim imagini (LLM-urile au limite de tokeni
pentru imagini multimodale).
Returns:
dict cu evidence_text, images, summary, instruction_for_llm.
"""
lines: list[str] = []
# ── Header secțiune forensică ──
lines.append("=" * 70)
lines.append("FORENSIC EVIDENCE (objective measurements you cannot recompute)")
lines.append("=" * 70)
lines.append("")
# ── OVERALL VERDICT (dacă fusion e furnizat) ──
summary_obj: dict[str, Any] = {}
if fusion is not None:
if fusion.get("score") is None:
lines.append("OVERALL: NO_SIGNAL — niciun detector n-a returnat semnal valid.")
lines.append(
f" Modules with no signal: {fusion.get('n_no_signal', 0)} "
f"of {fusion.get('n_no_signal', 0) + fusion.get('n_contributing', 0)}"
)
summary_obj = {
"overall_score": None,
"overall_label": "NO_SIGNAL",
"overall_confidence": 0.0,
"n_modules_run": len(module_results),
"n_modules_signal": 0,
"disagreement": 0.0,
}
else:
lines.append(
f"OVERALL VERDICT: {fusion['label']} "
f"(score={fusion['score']:.2f}, confidence={fusion['confidence']:.2f})"
)
lines.append(
f" Detectors active: {fusion['n_contributing']}, "
f"no signal: {fusion['n_no_signal']}, "
f"disagreement: {fusion['disagreement']:.3f}"
)
summary_obj = {
"overall_score": fusion["score"],
"overall_label": fusion["label"],
"overall_confidence": fusion["confidence"],
"n_modules_run": len(module_results),
"n_modules_signal": fusion["n_contributing"],
"disagreement": fusion["disagreement"],
}
lines.append("")
# ── PER-MODULE BLOCKS ──
lines.append("Individual detectors:")
lines.append("-" * 70)
contributions = (fusion or {}).get("contributions", {}) or {}
for module_id in sorted(module_results.keys()):
result = module_results[module_id]
block = _format_module_block(
module_id, result, fusion_contribution=contributions.get(module_id)
)
lines.append(block)
lines.append("")
# ── INSTRUCȚIUNEA PENTRU LLM ──
if include_instruction:
lines.append("=" * 70)
lines.append(LLM_INSTRUCTION)
lines.append("=" * 70)
evidence_text = "\n".join(lines)
# ── COLECTARE ȘI ENCODE IMAGINI ──
images_list: list[dict[str, Any]] = []
for module_id, result in module_results.items():
if not isinstance(result, dict):
continue
artifacts = (result.get("artifacts") or {}).get("images", []) or []
for img_name in artifacts:
if len(images_list) >= max_images:
break
abs_path = None
if images_dir:
abs_path = os.path.join(images_dir, img_name)
if not os.path.exists(abs_path):
abs_path = None
entry: dict[str, Any] = {
"name": img_name,
"tool_id": module_id,
"abs_path": abs_path,
"size_bytes": (os.path.getsize(abs_path)
if abs_path and os.path.exists(abs_path) else 0),
}
if encode_images and abs_path:
data_url = encode_image_b64(abs_path)
if data_url:
entry["data_url"] = data_url
images_list.append(entry)
if len(images_list) >= max_images:
break
return {
"evidence_text": evidence_text,
"images": images_list,
"summary": summary_obj,
"instruction_for_llm": LLM_INSTRUCTION if include_instruction else "",
}
def format_for_chat_completion(
evidence: dict[str, Any],
user_prompt_prefix: str = "",
) -> list[dict[str, Any]]:
"""
Helper: împachetează evidence block ca message content pentru un
LLM ChatCompletions multimodal (OpenAI/Anthropic/Qwen format).
Returnează lista pentru content (text + image_url-uri), ready de pus
direct în messages: [{"role": "user", "content": <ăsta>}].
Pentru pipeline-ul tău existent: combină acest content cu textul tău
de prompt dinainte (typologii, instrucțiuni custom), inserează imaginile
tale + imaginile noastre, trimite ca un singur mesaj user.
"""
content: list[dict[str, Any]] = []
# Text user prefix (existing prompt) + evidence block
full_text = (user_prompt_prefix + "\n\n" if user_prompt_prefix else "") + \
evidence["evidence_text"]
content.append({"type": "text", "text": full_text})
# Adaugă imaginile noastre forensice
for img in evidence.get("images", []):
if "data_url" in img:
content.append({
"type": "image_url",
"image_url": {"url": img["data_url"]},
})
return content

View file

@ -0,0 +1,235 @@
"""
forensic/scoring.py Fuziunea scorurilor individuale în verdict unificat.
PROBLEMA:
Fiecare modul m25-m29 returnează un primary_score în [0,1] cu propria
confidence. Cum combinăm 5 scoruri independente (cu surse de zgomot
diferite) într-un verdict unic? Mediere naivă pierde semnal când un
detector strigă tare pe canal specific.
ABORDARE:
Folosim fuziunea Dempster-Shafer-inspired: fiecare modul produce o
"credință" videoul e fake. Credința unui modul e
primary_score * confidence (modulul cu confidence 0.1 contribuie
aproape nimic, modulul cu confidence 0.9 contribuie aproape integral).
Score final:
weighted_score = sum(score_i * confidence_i * weight_i) / sum(confidence_i * weight_i)
Weight-urile sunt empiric: m25 (physiology) și m28 (forgery heatmap)
sunt cele mai discriminante; m27 (AI detector) e generalist; m26
(audio) e specific pe talking-head; m29 (lighting) e niche pe scene
cu lumină distinctă.
CALIBRARE:
Pragurile (0.35, 0.65) sunt aceleași ca în CONTRACT.md per-modul,
pentru consistență. Dacă vrei threshold-uri agresive sau conservatoare,
suprascrie via parametru.
NU FACE:
- Nu antrenează un meta-classifier (ar trebui ground truth);
- Nu filtrează detectoare cu NO_SIGNAL (le elimină din pondere);
- Nu impune "majority vote" un singur detector cu confidence mare
poate dicta verdictul, ceea ce e corect statistic.
"""
from __future__ import annotations
from typing import Any
# Ponderi empirice per modul. Suma nu trebuie 1.0; e renormalizată.
# Bazate pe discriminative power observat în literatură + auditul nostru:
# - m28 (forgery heatmap) — directly localizes blending = signal puternic
# - m25 (physiology) — pulse + blink absent = signal puternic specific
# - m27 (AI detector) — generalist, util pe orice imagine
# - m26 (audio) — specific talking-head, niche dar discriminant când prinde
# - m29 (lighting) — niche pe scene cu lumină distinctă, dar greu de
# falsificat când există semnal
DEFAULT_WEIGHTS = {
"m25": 1.2,
"m26": 0.9,
"m27": 1.0,
"m28": 1.3,
"m29": 0.8,
}
# Pragurile pentru label final, identice cu CONTRACT.md
THRESHOLD_FAKE = 0.65
THRESHOLD_REAL = 0.35
def fusion_label(score: float | None) -> str:
"""Mapează scor fuzionat în label final."""
if score is None:
return "NO_SIGNAL"
if score >= THRESHOLD_FAKE:
return "FAKE"
if score <= THRESHOLD_REAL:
return "REAL"
return "INCERT"
def fuse_scores(
module_results: dict[str, dict[str, Any]],
weights: dict[str, float] | None = None,
) -> dict[str, Any]:
"""
Fuzionează rezultatele a N module într-un verdict unificat.
Args:
module_results: dict {module_id: result_dict} unde result_dict e
schema CONTRACT.md (cu summary.primary_score, summary.confidence).
Doar modulele cu primary_score != None contribuie la fuzionare.
weights: dict {module_id: float} ponderi opționale. Default:
DEFAULT_WEIGHTS. Module nelistate primesc weight 1.0.
Returns:
dict cu:
score: float | None (None dacă niciun modul n-a contribuit)
label: str ("FAKE" | "REAL" | "INCERT" | "NO_SIGNAL")
confidence: float agregarea confidence individuale
n_contributing: int câte module au contribuit
n_no_signal: int câte module au returnat NO_SIGNAL
contributions: dict {module_id: weighted_contribution}
(pentru debug contribuția ponderată per modul)
disagreement: float varianța scorurilor (>0.15 = detectoare
contradictorii, redu confidence final)
"""
if weights is None:
weights = DEFAULT_WEIGHTS
contributing: list[tuple[str, float, float, float]] = [] # (id, score, conf, weight)
no_signal_count = 0
for mid, result in module_results.items():
if not isinstance(result, dict) or "summary" not in result:
continue
summary = result["summary"]
score = summary.get("primary_score")
if score is None:
no_signal_count += 1
continue
conf = float(summary.get("confidence", 0.5) or 0.5)
w = float(weights.get(mid, 1.0))
contributing.append((mid, float(score), conf, w))
if not contributing:
return {
"score": None,
"label": "NO_SIGNAL",
"confidence": 0.0,
"n_contributing": 0,
"n_no_signal": no_signal_count,
"contributions": {},
"disagreement": 0.0,
}
# Weight efectiv per modul = confidence * weight
total_eff_weight = sum(c * w for _, _, c, w in contributing)
if total_eff_weight < 1e-9:
# Toate confidence-urile sunt zero — fallback la mediană simplă
scores_only = [s for _, s, _, _ in contributing]
score_final = float(sum(scores_only) / len(scores_only))
confidence_final = 0.1
contributions = {mid: 1.0 / len(contributing) for mid, _, _, _ in contributing}
else:
weighted_sum = sum(s * c * w for _, s, c, w in contributing)
score_final = float(weighted_sum / total_eff_weight)
# Confidence finală: media ponderată a confidence-urilor, dar
# diminuată dacă detectoarele nu sunt de acord.
scores_only = [s for _, s, _, _ in contributing]
if len(scores_only) > 1:
mean_s = sum(scores_only) / len(scores_only)
disagreement = sum((s - mean_s) ** 2 for s in scores_only) / len(scores_only)
else:
disagreement = 0.0
# Disagreement penalty: 0 → factor 1.0; 0.25 → factor 0.5
disagreement_factor = max(0.3, 1.0 - 2.0 * disagreement)
avg_conf = sum(c for _, _, c, _ in contributing) / len(contributing)
confidence_final = float(avg_conf * disagreement_factor)
# Contribuții normalizate per modul (cât a influențat scorul final)
contributions = {
mid: round((s * c * w) / total_eff_weight, 4)
for mid, s, c, w in contributing
}
# Penalizare confidence dacă au contribuit puține module
if len(contributing) <= 1:
confidence_final *= 0.5
elif len(contributing) == 2:
confidence_final *= 0.8
# Penalizare suplimentară când multe module au returnat NO_SIGNAL
if no_signal_count >= 3:
confidence_final *= 0.7
confidence_final = max(0.0, min(1.0, confidence_final))
# Disagreement raw (pre-factor) — util pentru debug
if len(contributing) > 1:
scores_only = [s for _, s, _, _ in contributing]
mean_s = sum(scores_only) / len(scores_only)
disagreement_raw = sum((s - mean_s) ** 2 for s in scores_only) / len(scores_only)
else:
disagreement_raw = 0.0
return {
"score": round(score_final, 4),
"label": fusion_label(score_final),
"confidence": round(confidence_final, 4),
"n_contributing": len(contributing),
"n_no_signal": no_signal_count,
"contributions": contributions,
"disagreement": round(disagreement_raw, 4),
}
def explain_fusion(fusion: dict[str, Any],
module_results: dict[str, dict[str, Any]]) -> list[str]:
"""
Generează 1-3 propoziții human-readable care explică verdictul fuzionat.
Util pentru a injecta în prompt LLM ca "executive summary".
"""
lines: list[str] = []
if fusion["score"] is None:
lines.append("Niciun detector forensic n-a putut produce semnal valid.")
return lines
label = fusion["label"]
score = fusion["score"]
n = fusion["n_contributing"]
n_zero = fusion["n_no_signal"]
# Linia principală
lines.append(
f"Verdict forensic: {label} (score={score:.2f}, confidence={fusion['confidence']:.2f}, "
f"din {n} detectoare active, {n_zero} fără semnal)"
)
# Contributors top-3
contribs = fusion.get("contributions", {})
if contribs:
sorted_c = sorted(contribs.items(), key=lambda x: abs(x[1]), reverse=True)[:3]
names = []
for mid, c in sorted_c:
mname = ""
if mid in module_results:
mname = module_results[mid].get("tool", {}).get("name", mid)
label_per_module = (
module_results.get(mid, {}).get("summary", {}).get("primary_label", "")
)
names.append(f"{mid} {label_per_module}".strip())
if names:
lines.append(f"Top contributors: {', '.join(names)}")
# Disagreement warning
disagreement = fusion.get("disagreement", 0.0)
if disagreement > 0.15:
lines.append(
f"Atenție: detectoarele NU sunt de acord (disagreement={disagreement:.2f}); "
f"verdictul are confidence redusă."
)
return lines

View file

@ -0,0 +1,172 @@
"""
preprocessing.py Shared infrastructure for all forensic tools.
Handles:
1. Frame extraction from video via ffmpeg
2. Face detection on extracted frames
3. Loading frames as numpy arrays for analysis
Every tool imports from this. No tool extracts frames on its own.
"""
import subprocess
import os
import cv2
import numpy as np
def extract_frames(video_path, output_dir, every_n=30):
"""
Extract every Nth frame from a video as PNG files.
Uses ffmpeg under the hood:
ffmpeg -i <video> -vf "select=not(mod(n,N))" -vsync vfr <output>
Why PNG? Lossless we don't want to add compression artifacts
on top of whatever the video already has. Our forensic methods
need to analyze the pixels as they were encoded, not re-compressed.
Args:
video_path: path to the .mp4 file
output_dir: where to save the frame PNGs
every_n: extract 1 frame per N (default 30 = ~1fps for 30fps video)
Returns:
list of file paths to the extracted frames, sorted by frame number
"""
os.makedirs(output_dir, exist_ok=True)
cmd = [
"ffmpeg", "-i", video_path,
"-vf", f"select=not(mod(n\\,{every_n}))",
"-vsync", "vfr",
os.path.join(output_dir, "frame_%04d.png"),
"-y"
]
subprocess.run(cmd, capture_output=True, text=True)
frames = sorted([
os.path.join(output_dir, f)
for f in os.listdir(output_dir)
if f.startswith("frame_") and f.endswith(".png")
])
return frames
def extract_consecutive_frames(video_path, output_dir, start_frame, count=30):
"""
Extract a block of consecutive frames starting at a specific frame number.
Used by temporal methods (optical flow, temporal variance, SSIM)
that need frame-to-frame comparison without gaps.
Uses ffmpeg:
ffmpeg -i <video> -vf "select=between(n,start,start+count)" -vsync vfr <output>
Args:
video_path: path to the .mp4 file
output_dir: where to save
start_frame: which frame number to start from
count: how many consecutive frames to extract
Returns:
list of file paths to the extracted frames
"""
os.makedirs(output_dir, exist_ok=True)
end_frame = start_frame + count - 1
cmd = [
"ffmpeg", "-i", video_path,
"-vf", f"select=between(n\\,{start_frame}\\,{end_frame})",
"-vsync", "vfr",
os.path.join(output_dir, "consec_%04d.png"),
"-y"
]
subprocess.run(cmd, capture_output=True, text=True)
frames = sorted([
os.path.join(output_dir, f)
for f in os.listdir(output_dir)
if f.startswith("consec_") and f.endswith(".png")
])
return frames
def load_frame(frame_path):
"""
Load a single frame as a BGR numpy array (OpenCV default).
Args:
frame_path: path to a PNG file
Returns:
numpy array of shape (H, W, 3) in BGR color order
"""
return cv2.imread(frame_path)
def load_frame_gray(frame_path):
"""
Load a single frame as grayscale float64.
Most forensic methods work on grayscale.
Returns:
numpy array of shape (H, W) as float64
"""
return cv2.imread(frame_path, cv2.IMREAD_GRAYSCALE).astype(np.float64)
def detect_faces(frame, min_size=50):
"""
Detect faces in a frame using Haar cascade.
This is the same detector I used in all our analyses.
It's fast and good enough for ROI extraction.
For landmark-based methods (m33-m40), we'll use MediaPipe instead.
Args:
frame: BGR numpy array
min_size: minimum face size in pixels
Returns:
list of (x, y, w, h) tuples, sorted largest first
"""
face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(
gray, scaleFactor=1.1, minNeighbors=4, minSize=(min_size, min_size)
)
if len(faces) == 0:
return []
# Sort by area, largest first
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)
return [tuple(f) for f in faces]
def get_face_roi(frame, face, margin=0.3):
"""
Extract a face ROI with margin around it.
The margin is important blending boundaries in face swaps
sit OUTSIDE the face detection box, so we need to include
some surrounding area.
Args:
frame: BGR or grayscale numpy array
face: (x, y, w, h) tuple from detect_faces
margin: how much to expand (0.3 = 30% on each side)
Returns:
cropped numpy array of the face region
"""
x, y, w, h = face
m = int(w * margin)
H_img = frame.shape[0]
W_img = frame.shape[1]
y1 = max(0, y - m)
y2 = min(H_img, y + h + m)
x1 = max(0, x - m)
x2 = min(W_img, x + w + m)
return frame[y1:y2, x1:x2]

View file

@ -0,0 +1,43 @@
# ──────────────────────────────────────────────────────────────────────────
# Forensic Features API — Lean Dependencies
# ──────────────────────────────────────────────────────────────────────────
# Scopul acestui set: extracție de features forensice pentru consum LLM.
# NU include torch / autogluon / transformers (acelea sunt pentru a face
# clasificare end-to-end — irrelevant aici).
#
# Imagine Docker rezultată: ~1.5 GB (vs ~8 GB cu transformers + autogluon).
# ──────────────────────────────────────────────────────────────────────────
# ── Observability ────────────────────────────────────────────────────────
prometheus-client==0.21.1
opentelemetry-api==1.30.0
opentelemetry-sdk==1.30.0
opentelemetry-exporter-otlp-proto-grpc==1.30.0
opentelemetry-instrumentation-aiohttp-server==0.51b0
# ── Core scientific stack ────────────────────────────────────────────────
numpy==2.1.3
scipy==1.16.3
pillow==11.3.0
matplotlib==3.10.8
# ── Computer vision ──────────────────────────────────────────────────────
# headless = fără GUI Qt → economie ~100MB
opencv-contrib-python-headless==4.13.0.92
mediapipe==0.10.33
# ── Web framework ────────────────────────────────────────────────────────
aiohttp==3.13.5
aiohttp-cors==0.8.1
# ── Note ─────────────────────────────────────────────────────────────────
# Sistem necesar (instalat în Dockerfile prin apt):
# ffmpeg — extracție frame-uri și audio
# libgl1 + libglib2.0-0 — OpenCV runtime
# libgles2 + libegl1 — MediaPipe FaceLandmarker OpenGL ES
# libgomp1 — OpenMP
#
# Opțional (NU în această listă, opt-in via env M27_USE_HF=1):
# transformers + torch — pentru HF AI detector în m27. Adaugă ~2GB.
# Testat empiric: NU îmbunătățește per ansamblu
# pe date out-of-distribution. Vezi docs/MODULES.md.

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