unkor-backend / video_model.py
wachekk's picture
feat: lazy loading Naman712
492b161
Raw
History Blame Contribute Delete
10.5 kB
"""
video_model.py — Spécialiste deepfake VIDÉO du Système Schrödinger (CPU).
Architecture : ResNext50_32x4d (backbone, tronqué avant avgpool/fc) -> features
2048 par frame -> LSTM(2048 -> 2048, 1 couche) -> Linear(2048 -> 2).
Checkpoint : Naman712/Deep-fake-detection / model_87_acc_20_frames_final_data.pt
(~87 % de précision, entraîné sur des séquences de 20 crops de visage 112×112).
Convention des classes : **classe 1 = FAKE** — constaté empiriquement sur ce
checkpoint (probs=[0.001, 0.999] sur une vidéo deepfake avérée) ; le projet
GitHub d'origine documentait l'inverse. Surchargeable via VIDEO_FAKE_INDEX.
TÉLÉCHARGEMENT + CHARGEMENT PARESSEUX, thread-safe : le checkpoint est récupéré
depuis HuggingFace ET chargé au tout premier appel à predict() (première analyse
vidéo), pas au démarrage du serveur. Un verrou (self._lock) sérialise l'opération
-> pas de téléchargements simultanés si plusieurs vidéos arrivent en même temps.
Tant que le modèle est indisponible (pas de HF_TOKEN, repo gated…), predict()
renvoie (0.5, False) et la fusion vidéo se rabat sur le pipeline forensique image.
CONTRAT D'ENTRÉE (fidélité au régime d'entraînement du checkpoint) : le projet
d'origine lisait les frames avec OpenCV et les passait à ToPILImage SANS
conversion BGR->RGB — le modèle a donc appris en ordre de canaux **BGR**, sur
des frames **consécutives** et des crops de visage **serrés**. predict() attend
donc des frames BGR (telles que décodées par OpenCV). Si un autre checkpoint
entraîné en RGB est branché, définir VIDEO_MODEL_CHANNELS=rgb.
Variables d'environnement :
VIDEO_MODEL_PATH chemin .pt local explicite (court-circuite models/)
VIDEO_FAKE_INDEX index de la classe « fake » (défaut 1 — checkpoint Naman712)
VIDEO_MODEL_CHANNELS ordre de canaux attendu par le checkpoint : bgr (défaut) | rgb
TORCH_THREADS threads intra-op PyTorch (défaut 2 — HF Spaces 2 vCPU)
"""
import os
import threading
import numpy as np
import cv2
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_MODELS_DIR = os.path.join(_BASE_DIR, "models")
_DEBUG = os.getenv("UNKOR_DEBUG", "1").lower() not in ("0", "false", "no")
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
_IM_SIZE = 112 # taille d'entrée du projet d'origine
def _dbg(msg):
if _DEBUG:
try:
print(f"[video-model] {msg}", flush=True)
except UnicodeEncodeError:
print(f"[video-model] {msg}".encode("ascii", "replace").decode("ascii"), flush=True)
def _build_model(num_classes):
"""Reconstruit l'architecture exacte du checkpoint (projet ResNext+LSTM)."""
import torch.nn as nn
from torchvision import models as tv_models
class ResNextLSTM(nn.Module):
def __init__(self, num_classes, latent_dim=2048, lstm_layers=1,
hidden_dim=2048, bidirectional=False):
super().__init__()
backbone = tv_models.resnext50_32x4d(weights=None) # poids fournis par le checkpoint
self.model = nn.Sequential(*list(backbone.children())[:-2])
self.lstm = nn.LSTM(latent_dim, hidden_dim, lstm_layers, bidirectional)
self.relu = nn.LeakyReLU()
self.dp = nn.Dropout(0.4)
self.linear1 = nn.Linear(2048, num_classes)
self.avgpool = nn.AdaptiveAvgPool2d(1)
def forward(self, x):
b, t, c, h, w = x.shape
x = x.view(b * t, c, h, w)
fmap = self.model(x)
x = self.avgpool(fmap)
x = x.view(b, t, 2048)
x_lstm, _ = self.lstm(x, None)
return fmap, self.dp(self.linear1(x_lstm[:, -1, :]))
return ResNextLSTM(num_classes)
class VideoDeepfakeAnalyzer:
"""ResNext50+LSTM sur une séquence de crops de visage — (proba_fake, ok)."""
_MODEL = os.path.join(_MODELS_DIR, "video_resnext_lstm.pt")
def __init__(self):
self._model = None
self._tried = False # tentative (téléchargement + chargement) déjà faite ?
self._lock = threading.Lock()
self._fake_idx = int(os.getenv("VIDEO_FAKE_INDEX", "1"))
def _resolve_or_download(self):
"""Chemin du .pt : explicite -> local -> téléchargement paresseux HF.
Renvoie None si indisponible (l'analyse vidéo se rabat alors sur le
pipeline image). Appelé UNIQUEMENT sous self._lock (voir _ensure)."""
p = os.getenv("VIDEO_MODEL_PATH")
if p and os.path.exists(p):
return p
if os.path.exists(self._MODEL):
return self._MODEL
# Téléchargement paresseux (1er predict) — repo gated : HF_TOKEN requis.
token = os.getenv("HF_TOKEN") or os.getenv("HUGGINGFACE_TOKEN") or None
if not token:
print("[VIDEO] HF_TOKEN absent — modèle vidéo non téléchargé (repo gated). "
"Analyse vidéo = pipeline image seul.", flush=True)
return None
repo = os.getenv("VIDEO_MODEL_REPO", "Naman712/Deep-fake-detection")
fname = os.getenv("VIDEO_MODEL_FILE", "model_87_acc_20_frames_final_data.pt")
try:
import shutil
from huggingface_hub import hf_hub_download
os.makedirs(_MODELS_DIR, exist_ok=True)
print(f"[VIDEO] téléchargement paresseux {repo}/{fname} "
"(~220 Mo, 1re analyse vidéo)…", flush=True)
f = hf_hub_download(repo_id=repo, filename=fname, token=token)
if os.path.getsize(f) < 10_000_000: # pointeur LFS/HTML = quelques Ko
print("[VIDEO] téléchargement invalide (fichier trop petit).", flush=True)
return None
shutil.copy(f, self._MODEL)
print(f"[VIDEO] modèle téléchargé -> {os.path.basename(self._MODEL)} "
f"({os.path.getsize(self._MODEL) // (1024 * 1024)} Mo)", flush=True)
return self._MODEL
except Exception as e:
print(f"[VIDEO] téléchargement échoué : {e}", flush=True)
return None
def available(self):
self._ensure()
return self._model is not None
def _ensure(self):
if self._model is not None:
return
# Verrou : sérialise téléchargement + chargement -> pas de DL simultanés.
with self._lock:
if self._model is not None or self._tried:
return
self._tried = True # une seule tentative (DL inclus) par process
path = self._resolve_or_download()
if not path:
return
try:
import torch
torch.set_num_threads(max(1, int(os.getenv("TORCH_THREADS", "2"))))
# weights_only=True : refuse tout pickle arbitraire (state_dict pur).
sd = torch.load(path, map_location="cpu", weights_only=True)
if isinstance(sd, dict) and "state_dict" in sd and isinstance(sd["state_dict"], dict):
sd = sd["state_dict"]
sd = {k[7:] if k.startswith("module.") else k: v for k, v in sd.items()}
if "linear1.weight" not in sd:
raise RuntimeError("clé linear1.weight absente — architecture inattendue")
num_classes = int(sd["linear1.weight"].shape[0])
model = _build_model(num_classes)
missing, unexpected = model.load_state_dict(sd, strict=False)
critical = [k for k in missing if k.split(".")[0] in ("model", "lstm", "linear1")]
if critical:
raise RuntimeError(f"poids manquants: {critical[:5]} (+{max(0, len(critical)-5)})")
model.eval()
self._model = model
print(f"[VIDEO] ResNext50+LSTM chargé : {os.path.basename(path)} "
f"(classes={num_classes}, fake_idx={self._fake_idx}, "
f"inattendues={len(unexpected)})", flush=True)
except Exception as e:
print(f"[VIDEO] chargement échoué : {e}", flush=True)
self._model = None
def _preprocess(self, frames):
# L'appelant fournit du BGR (décodage OpenCV) ; on ne convertit en RGB
# QUE si le checkpoint branché a été entraîné en RGB.
to_rgb = os.getenv("VIDEO_MODEL_CHANNELS", "bgr").lower() == "rgb"
seq = []
for f in frames:
img = f
if img is None or not isinstance(img, np.ndarray) or img.ndim < 2:
continue
if img.ndim == 2:
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
elif img.shape[2] == 4:
img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
if img.shape[0] < 8 or img.shape[1] < 8:
continue
if to_rgb:
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (_IM_SIZE, _IM_SIZE), interpolation=cv2.INTER_AREA)
img = img.astype(np.float32) / 255.0
img = (img - _MEAN) / _STD
seq.append(np.transpose(img, (2, 0, 1)))
return seq
def predict(self, frames):
"""frames : liste de np.ndarray BGR (crops visage serrés, consécutifs de
préférence — voir le contrat d'entrée en tête de module).
-> (proba_fake dans [0.02, 0.98], ok)."""
self._ensure()
if self._model is None:
return 0.5, False
try:
seq = self._preprocess(frames)
if len(seq) < 4:
_dbg(f"séquence trop courte ({len(seq)} frames exploitables) — modèle ignoré")
return 0.5, False
import torch
x = torch.from_numpy(np.ascontiguousarray(
np.stack(seq)[None, ...].astype(np.float32))) # (1, T, 3, 112, 112)
with torch.no_grad():
_, logits = self._model(x)
probs = torch.softmax(logits, dim=1)[0].tolist()
p = float(probs[min(self._fake_idx, len(probs) - 1)])
_dbg(f"T={len(seq)} probs={[round(v, 3) for v in probs]} "
f"fake_idx={self._fake_idx} -> p_fake={p:.3f}")
return float(np.clip(p, 0.02, 0.98)), True
except Exception as e:
print(f"[VIDEO] inférence échouée : {e}", flush=True)
return 0.5, False