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