Livrare LOT 1 - Didi

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

View file

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

View file

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

View file

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

View file

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

View file

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