Instructions to use deepsafe/deepsafe-services with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use deepsafe/deepsafe-services with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("deepsafe/deepsafe-services", dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
Download video/recce/app.py from deepsafe/deepsafe-services: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/deepsafe/deepsafe-services/resolve/main/video/recce/app.py
- Command line
-
hf download hf://deepsafe/deepsafe-services/video/recce/app.py
-
curl -L -o app.py https://huggingface.co/deepsafe/deepsafe-services/resolve/main/video/recce/app.py
15.9 kB
| """RECCE deepfake video detection service. | |
| Wraps the RECCE (CVPR 2022) reconstruction-classification face forgery | |
| detection model with a FastAPI endpoint. Uses an Xception encoder with | |
| guided attention and graph reasoning, trained via DeepfakeBench on | |
| FaceForensics++ (c40). | |
| The checkpoint originates from DeepfakeBench v1.0.1 which wraps the | |
| original RECCE architecture under a ``model.`` prefix and trains with | |
| 2-class (real/fake) output instead of the original repo's 1-class | |
| sigmoid. Weights are loaded by stripping the ``model.`` prefix and | |
| instantiating ``Recce(num_classes=2)``. | |
| Reference: Cao et al., "End-to-End Reconstruction-Classification | |
| Learning for Face Forgery Detection", CVPR 2022. | |
| """ | |
| import base64 | |
| import gc | |
| import logging | |
| import os | |
| import platform | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| from typing import Any, Dict, List, Optional, Tuple | |
| import cv2 | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| import uvicorn | |
| from facenet_pytorch import MTCNN | |
| from fastapi import FastAPI, HTTPException | |
| from PIL import Image | |
| from pydantic import BaseModel, ConfigDict, Field | |
| # ββ Model code import ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # The original RECCE model code is vendored under model_code/. We add it | |
| # to sys.path so that ``from model.network import Recce`` resolves. | |
| _MODEL_CODE_DIR = os.path.join(os.path.dirname(__file__), "model_code") | |
| if _MODEL_CODE_DIR not in sys.path: | |
| sys.path.insert(0, _MODEL_CODE_DIR) | |
| # Patch timm's xception to skip pretrained-weight download. We load our | |
| # own checkpoint, so downloading ImageNet weights wastes bandwidth and | |
| # fails in air-gapped Docker containers. | |
| import timm.models # noqa: E402 | |
| _original_xception = timm.models.xception | |
| def _xception_no_pretrained(**kwargs): | |
| """Force pretrained=False to avoid downloading default weights.""" | |
| kwargs["pretrained"] = False | |
| return _original_xception(**kwargs) | |
| timm.models.xception = _xception_no_pretrained | |
| from model.network import Recce # noqa: E402 | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| MODEL_PORT = int(os.environ.get("MODEL_PORT", 7007)) | |
| PRELOAD_MODEL = os.environ.get("PRELOAD_MODEL", "false").lower() == "true" | |
| MODEL_TIMEOUT = int(os.environ.get("MODEL_TIMEOUT", 1800)) | |
| WEIGHTS_PATH = os.environ.get("WEIGHTS_PATH", "/app/weights/recce_checkpoint.pth") | |
| # RECCE uses 299x299 face crops (from config/Recce.yml and inference.py) | |
| IMAGE_SIZE = (299, 299) | |
| # Number of frames to sample from each video | |
| NUM_FRAMES = 32 | |
| # Face crop margin factor | |
| MARGIN_FACTOR = 0.5 | |
| def _get_device() -> torch.device: | |
| """Select optimal device: CUDA > MPS (Apple) > CPU.""" | |
| override = os.environ.get("DEEPSAFE_DEVICE", "").strip().lower() | |
| if override == "cpu": | |
| return torch.device("cpu") | |
| if override == "cuda" and torch.cuda.is_available(): | |
| return torch.device("cuda") | |
| if ( | |
| override == "mps" | |
| and hasattr(torch.backends, "mps") | |
| and torch.backends.mps.is_available() | |
| ): | |
| return torch.device("mps") | |
| if torch.cuda.is_available(): | |
| return torch.device("cuda") | |
| if ( | |
| platform.system() == "Darwin" | |
| and hasattr(torch.backends, "mps") | |
| and torch.backends.mps.is_available() | |
| ): | |
| return torch.device("mps") | |
| return torch.device("cpu") | |
| # ββ Global state βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _model: Optional[Recce] = None | |
| _face_detector: Optional[MTCNN] = None | |
| _device: Optional[torch.device] = None | |
| _load_lock = threading.Lock() | |
| def _load_models() -> None: | |
| """Load RECCE detector and MTCNN face detector (thread-safe).""" | |
| global _model, _face_detector, _device | |
| if _model is not None: | |
| return | |
| with _load_lock: | |
| # Double-check after acquiring lock | |
| if _model is not None: | |
| return | |
| _device = _get_device() | |
| if _device.type == "cuda": | |
| torch.backends.cudnn.benchmark = True | |
| torch.set_float32_matmul_precision("high") | |
| if _device.type == "cuda": | |
| logger.info( | |
| "Device: cuda (%s, %.1f GB VRAM)", | |
| torch.cuda.get_device_name(0), | |
| torch.cuda.get_device_properties(0).total_memory / 1024**3, | |
| ) | |
| else: | |
| logger.warning( | |
| "Device: %s (no CUDA available" " -- check nvidia-container-toolkit)", | |
| _device, | |
| ) | |
| logger.info("Loading RECCE model on %s ...", _device) | |
| # ββ Face detector (MTCNN) ββββββββββββββββββββββββββββββββββββββ | |
| _face_detector = MTCNN( | |
| keep_all=True, | |
| device=_device, | |
| post_process=False, | |
| ) | |
| # ββ RECCE classifier βββββββββββββββββββββββββββββββββββββββββββ | |
| # DeepfakeBench trains RECCE with 2-class output (real, fake) | |
| net = Recce(num_classes=2) | |
| if not os.path.exists(WEIGHTS_PATH): | |
| raise FileNotFoundError(f"RECCE weights not found at {WEIGHTS_PATH}") | |
| checkpoint = torch.load(WEIGHTS_PATH, map_location="cpu", weights_only=False) | |
| # DeepfakeBench wraps the RECCE model under a 'model.' prefix. | |
| # Strip it so the keys match the original Recce class. | |
| if any(k.startswith("model.") for k in checkpoint.keys()): | |
| state_dict = { | |
| k[len("model.") :]: v | |
| for k, v in checkpoint.items() | |
| if k.startswith("model.") | |
| } | |
| else: | |
| state_dict = checkpoint | |
| net.load_state_dict(state_dict) | |
| net = net.to(_device) | |
| net.eval() | |
| _model = net | |
| logger.info("RECCE model loaded successfully.") | |
| def _is_model_loaded() -> bool: | |
| """Return True if both the classifier and face detector are loaded.""" | |
| return _model is not None and _face_detector is not None | |
| # ββ FastAPI app ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI( | |
| title="RECCE Detection Service", | |
| description=( | |
| "End-to-End Reconstruction-Classification Learning " | |
| "for Face Forgery Detection (Xception backbone, CVPR 2022)" | |
| ), | |
| version="1.0.0", | |
| ) | |
| class PredictRequest(BaseModel): | |
| """Incoming prediction request.""" | |
| video_data: str # Base64-encoded video bytes | |
| threshold: float = 0.5 | |
| class PredictResponse(BaseModel): | |
| """Outgoing prediction result.""" | |
| model_config = ConfigDict(populate_by_name=True) | |
| model: str = "recce_detection" | |
| probability: float | |
| prediction: int | |
| class_name: str = Field(..., alias="class") | |
| inference_time: float | |
| metadata: Dict[str, Any] | |
| async def startup_event(): | |
| """Optionally preload model at startup.""" | |
| if PRELOAD_MODEL: | |
| _load_models() | |
| def root(): | |
| """Service info endpoint.""" | |
| return { | |
| "service": "recce_detection", | |
| "port": MODEL_PORT, | |
| "model_loaded": _is_model_loaded(), | |
| "device": str(_device) if _device else "unknown", | |
| } | |
| def _gpu_health_info() -> dict: | |
| """Return GPU metrics for the health endpoint.""" | |
| if torch.cuda.is_available() and _device is not None and _device.type == "cuda": | |
| return { | |
| "gpu_name": torch.cuda.get_device_name(0), | |
| "vram_used_mb": round(torch.cuda.memory_allocated(0) / 1024**2), | |
| "vram_total_mb": round( | |
| torch.cuda.get_device_properties(0).total_memory / 1024**2 | |
| ), | |
| } | |
| return {} | |
| def health(): | |
| """Health check endpoint.""" | |
| return { | |
| "status": "healthy", | |
| "model": "recce_detection", | |
| "device": str(_device) if _device else "cpu", | |
| "model_loaded": _is_model_loaded(), | |
| "weights_exist": os.path.exists(WEIGHTS_PATH), | |
| **_gpu_health_info(), | |
| } | |
| # ββ Video / face utilities βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _extract_frames(video_path: str, num_frames: int = NUM_FRAMES) -> List[np.ndarray]: | |
| """Uniformly sample *num_frames* RGB frames from a video file. | |
| Args: | |
| video_path: Path to the video on disk. | |
| num_frames: Number of frames to extract. | |
| Returns: | |
| List of RGB uint8 numpy arrays (H, W, 3). | |
| """ | |
| cap = cv2.VideoCapture(video_path) | |
| total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) | |
| if total <= 0: | |
| cap.release() | |
| return [] | |
| indices = np.linspace(0, total - 1, num_frames, endpoint=True, dtype=int) | |
| frames: List[np.ndarray] = [] | |
| for idx in indices: | |
| cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx)) | |
| ret, frame = cap.read() | |
| if ret: | |
| frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) | |
| cap.release() | |
| return frames | |
| def _crop_face( | |
| img: np.ndarray, | |
| bbox: Tuple[float, float, float, float], | |
| margin: float = MARGIN_FACTOR, | |
| ) -> np.ndarray: | |
| """Crop a face region from an image with a relative margin. | |
| Args: | |
| img: RGB image array (H, W, 3). | |
| bbox: (x0, y0, x1, y1) face bounding box. | |
| margin: Fraction of bbox dimension to add as padding. | |
| Returns: | |
| Cropped face region as a numpy array. | |
| """ | |
| h_img, w_img = img.shape[:2] | |
| x0, y0, x1, y1 = bbox | |
| w = x1 - x0 | |
| h = y1 - y0 | |
| x0_new = max(0, int(x0 - w * margin / 2)) | |
| x1_new = min(w_img, int(x1 + w * margin / 2) + 1) | |
| y0_new = max(0, int(y0 - h * margin / 2)) | |
| y1_new = min(h_img, int(y1 + h * margin / 2) + 1) | |
| return img[y0_new:y1_new, x0_new:x1_new] | |
| def _detect_and_crop_faces(frame: np.ndarray) -> List[np.ndarray]: | |
| """Detect faces in a single frame and return cropped+resized chips. | |
| Uses MTCNN for face detection, then crops with margin and resizes | |
| to IMAGE_SIZE (299x299). | |
| Args: | |
| frame: RGB image array (H, W, 3). | |
| Returns: | |
| List of face crops resized to IMAGE_SIZE, as uint8 arrays. | |
| """ | |
| assert _face_detector is not None | |
| pil_img = Image.fromarray(frame) | |
| boxes, _ = _face_detector.detect(pil_img) | |
| if boxes is None or len(boxes) == 0: | |
| return [] | |
| crops: List[np.ndarray] = [] | |
| for box in boxes: | |
| x0, y0, x1, y1 = box.tolist() | |
| face = _crop_face(frame, (x0, y0, x1, y1)) | |
| if face.size == 0: | |
| continue | |
| resized = cv2.resize(face, IMAGE_SIZE) | |
| crops.append(resized) | |
| return crops | |
| def _preprocess_face(face_crop: np.ndarray) -> torch.Tensor: | |
| """Apply RECCE-specific preprocessing to a face crop. | |
| RECCE uses Normalize(mean=[0.5]*3, std=[0.5]*3) which maps | |
| [0, 255] uint8 to [-1, 1] float32. This matches the | |
| albumentations pipeline in the original inference.py. | |
| Args: | |
| face_crop: RGB uint8 array of shape (299, 299, 3). | |
| Returns: | |
| Tensor of shape (3, 299, 299) in range [-1, 1]. | |
| """ | |
| # uint8 [0, 255] -> float32 [0, 1] -> normalized [-1, 1] | |
| tensor = torch.tensor(face_crop).permute(2, 0, 1).float().div(255.0) | |
| tensor = (tensor - 0.5) / 0.5 | |
| return tensor | |
| # ββ Prediction endpoint ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def predict(request: PredictRequest): | |
| """Run RECCE face-forgery detection on a base64-encoded video. | |
| Pipeline: | |
| 1. Decode video and write to temp file. | |
| 2. Extract uniformly-sampled frames. | |
| 3. Detect and crop faces per frame (MTCNN). | |
| 4. Preprocess each face crop (resize 299x299, normalize to [-1,1]). | |
| 5. Classify each face crop with RECCE (2-class softmax). | |
| 6. For each frame, take the max fake probability across faces. | |
| 7. Average the per-frame max probabilities. | |
| If no faces are detected in any frame the service returns | |
| probability=0.5 (undetermined) rather than raising an error. | |
| """ | |
| if not _is_model_loaded(): | |
| _load_models() | |
| start_time = time.time() | |
| # ββ Decode video βββββββββββββββββββββββββββββββββββββββββββββββ | |
| with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp: | |
| try: | |
| video_bytes = base64.b64decode(request.video_data) | |
| tmp.write(video_bytes) | |
| tmp_path = tmp.name | |
| except Exception as e: | |
| raise HTTPException(status_code=400, detail=f"Failed to decode video: {e}") | |
| try: | |
| # ββ Extract frames βββββββββββββββββββββββββββββββββββββββββ | |
| frames = _extract_frames(tmp_path, NUM_FRAMES) | |
| if not frames: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Could not extract frames from video.", | |
| ) | |
| # ββ Detect faces and classify ββββββββββββββββββββββββββββββ | |
| per_frame_max: List[float] = [] | |
| total_faces = 0 | |
| for frame in frames: | |
| crops = _detect_and_crop_faces(frame) | |
| if not crops: | |
| continue | |
| # Preprocess and build batch tensor | |
| tensors = [_preprocess_face(c) for c in crops] | |
| batch_tensor = torch.stack(tensors).to(_device) | |
| with torch.no_grad(): | |
| logits = _model(batch_tensor) | |
| # RECCE forward() calls squeeze() which drops the | |
| # batch dim when batch=1, producing shape (2,) | |
| # instead of (1, 2). Always ensure 2D. | |
| if logits.dim() == 1: | |
| logits = logits.unsqueeze(0) | |
| probs = F.softmax(logits, dim=1)[:, 1] # fake prob | |
| frame_max = probs.max().cpu().item() | |
| per_frame_max.append(frame_max) | |
| total_faces += len(crops) | |
| # ββ Aggregate ββββββββββββββββββββββββββββββββββββββββββββββ | |
| if per_frame_max: | |
| probability = float(np.mean(per_frame_max)) | |
| else: | |
| # No faces detected in any frame -- undetermined | |
| probability = 0.5 | |
| prediction = 1 if probability >= request.threshold else 0 | |
| class_name = "fake" if prediction == 1 else "real" | |
| return PredictResponse( | |
| probability=probability, | |
| prediction=prediction, | |
| class_name=class_name, | |
| inference_time=time.time() - start_time, | |
| metadata={ | |
| "frames_sampled": len(frames), | |
| "frames_with_faces": len(per_frame_max), | |
| "total_faces_detected": total_faces, | |
| "device": str(_device), | |
| }, | |
| ) | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.exception("Error during RECCE prediction") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| finally: | |
| if os.path.exists(tmp_path): | |
| os.remove(tmp_path) | |
| gc.collect() | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=MODEL_PORT) | |