File size: 15,784 Bytes
9b75405
 
 
 
f775aa8
 
 
 
9b75405
f775aa8
9b75405
 
 
 
 
 
 
9c0dcb2
9b75405
 
 
 
 
9c0dcb2
9b75405
 
 
9c0dcb2
65744f1
9c0dcb2
 
 
9b75405
f775aa8
 
 
 
 
 
 
65744f1
9b75405
9c0dcb2
4d5d7cd
9c0dcb2
4d5d7cd
 
 
9c0dcb2
f775aa8
 
 
 
 
 
67c350b
4d5d7cd
9c0dcb2
 
67c350b
9c0dcb2
 
4d5d7cd
9c0dcb2
 
 
 
 
 
 
 
 
 
 
 
4d5d7cd
9c0dcb2
4d5d7cd
 
35fcc34
9c0dcb2
67c350b
4d5d7cd
67c350b
 
35fcc34
4d5d7cd
67c350b
1ab031c
4d5d7cd
9b75405
 
9c0dcb2
4d5d7cd
 
 
9c0dcb2
35fcc34
 
 
1ab031c
35fcc34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c0dcb2
67c350b
9c0dcb2
 
9b75405
9c0dcb2
 
4d5d7cd
9b75405
9c0dcb2
 
67c350b
4d5d7cd
 
 
 
9c0dcb2
 
 
4d5d7cd
9c0dcb2
67c350b
4d5d7cd
 
67c350b
 
 
 
 
4d5d7cd
 
 
 
 
 
 
 
67c350b
9c0dcb2
f775aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c0dcb2
9b75405
f775aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c0dcb2
f775aa8
 
9c0dcb2
 
4d5d7cd
9c0dcb2
4d5d7cd
9c0dcb2
67c350b
 
9c0dcb2
 
 
 
 
1ab031c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f775aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c0dcb2
9b75405
 
 
 
65744f1
9c0dcb2
f775aa8
 
9c0dcb2
 
f775aa8
 
9c0dcb2
 
f775aa8
 
9c0dcb2
65744f1
9b75405
9c0dcb2
9b75405
9c0dcb2
 
9b75405
9c0dcb2
9b75405
f775aa8
9c0dcb2
9b75405
 
 
4d5d7cd
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import os
import cv2
import torch
import shutil
import uuid
import yt_dlp
from typing import Optional
from fastapi import FastAPI, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from transformers import VideoMAEImageProcessor, VideoMAEForVideoClassification, pipeline
from facenet_pytorch import MTCNN
from PIL import Image

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ── Load models once at startup ───────────────────────────────────────────────
print("Loading MTCNN Face Detector...")
mtcnn = MTCNN(keep_all=False, select_largest=True, post_process=False)

print("Loading VideoMAE Temporal Deepfake Detector...")
model_name = "Ammar2k/videomae-base-finetuned-deepfake-subset"
processor  = VideoMAEImageProcessor.from_pretrained(model_name)
model      = VideoMAEForVideoClassification.from_pretrained(model_name)
model.eval()

print("Loading AI Image Detector (for fully synthetic AI-generated videos)...")
ai_image_detector = pipeline(
    "image-classification",
    model="Smogy/SMOGY-Ai-images-detector",
    device=-1  # CPU
)

os.makedirs("temp", exist_ok=True)

# ── Configuration ─────────────────────────────────────────────────────────────
SEQUENCE_LENGTH = 16   # VideoMAE requires exactly 16 frames

# IMPORTANT: This model is biased toward fake. Calibrated threshold after testing:
# Real videos score ~60-75%, so we raise the bar significantly.
FAKE_THRESHOLD  = 0.80  # Only call FAKE if model is 80%+ confident

# Threshold for the AI image detector (probability that a frame is AI-generated)
# Using a combo: flag if MAX single frame >= 0.55 OR average >= 0.30
AI_IMAGE_AVG_THRESHOLD = 0.30   # Flag if avg across all frames is >= 30%
AI_IMAGE_MAX_THRESHOLD = 0.55   # Flag if ANY single frame hits >= 55%
AI_FRAME_SAMPLES       = 8      # Number of frames to sample from the video

def smooth_box(current_box, last_box, alpha=0.5):
    """EMA smoothing to stabilise the face bounding box across frames."""
    if last_box is None:
        return current_box
    return [alpha * c + (1 - alpha) * p for c, p in zip(current_box, last_box)]

def crop_face(pil_img, box, padding=0.35):
    """Crop the face region with proportional padding."""
    w = box[2] - box[0]
    h = box[3] - box[1]
    pad_w = int(w * padding)
    pad_h = int(h * padding)
    x1 = max(0, int(box[0]) - pad_w)
    y1 = max(0, int(box[1]) - pad_h)
    x2 = min(pil_img.width,  int(box[2]) + pad_w)
    y2 = min(pil_img.height, int(box[3]) + pad_h)
    if x2 > x1 and y2 > y1:
        return pil_img.crop((x1, y1, x2, y2))
    return None

def extract_clip(video_path, start_frame):
    """
    Open a fresh cap, seek ONCE to start_frame, then read frames
    SEQUENTIALLY (no cap.set inside loop). This is reliable for all codecs.
    Collects SEQUENCE_LENGTH face crops using downscaled frame-by-frame tracking.
    """
    cap = cv2.VideoCapture(video_path)
    cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)  # Seek exactly ONCE

    faces    = []
    last_box = None
    attempts = 0

    while len(faces) < SEQUENCE_LENGTH and attempts < 60:
        ret, frame = cap.read()  # Sequential β€” no random seeking
        if not ret:
            break

        attempts += 1
        frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        pil_img   = Image.fromarray(frame_rgb)

        # Downscale for faster MTCNN detection on every frame
        detect_img = pil_img.copy()
        current_box = None
        
        if detect_img.width > 640:
            ratio = 640.0 / detect_img.width
            detect_img = detect_img.resize((640, int(detect_img.height * ratio)))
            boxes, _ = mtcnn.detect(detect_img)
            if boxes is not None and len(boxes) > 0:
                current_box = [b / ratio for b in boxes[0].tolist()]
        else:
            boxes, _ = mtcnn.detect(pil_img)
            if boxes is not None and len(boxes) > 0:
                current_box = boxes[0].tolist()

        if current_box is not None:
            smoothed = smooth_box(current_box, last_box)
            last_box = smoothed
        elif last_box is not None:
            smoothed = last_box   # Hold last known position
        else:
            continue              # No face yet β€” keep reading

        # Use standard padding for centered faces
        crop = crop_face(pil_img, smoothed, padding=0.35)
        if crop is not None:
            faces.append(crop)

    cap.release()

    if not faces:
        return []

    while len(faces) < SEQUENCE_LENGTH:
        faces.append(faces[-1])  # Pad with last frame if clip was short

    return faces[:SEQUENCE_LENGTH]

def run_inference(faces):
    """
    Run VideoMAE on 16 face-crop frames.
    Returns the raw probability for the 'fake' class.
    """
    inputs = processor(list(faces), return_tensors="pt")
    with torch.no_grad():
        outputs = model(**inputs)

    probs = torch.nn.functional.softmax(outputs.logits[0], dim=-1)

    # Explicitly find the "fake" label index
    fake_prob = None
    for idx, label in model.config.id2label.items():
        if "fake" in label.lower():
            fake_prob = probs[idx].item()
            break

    # Fallback if label map is unexpected
    if fake_prob is None:
        predicted_idx = probs.argmax(-1).item()
        label = model.config.id2label[predicted_idx].lower()
        fake_prob = probs[predicted_idx].item() if "fake" in label else 1.0 - probs[predicted_idx].item()

    print(f"  id2label: {model.config.id2label}")
    print(f"  raw fake_prob: {fake_prob:.4f}")
    return fake_prob


def run_ai_image_check(video_path, total_frames):
    """
    Sample AI_FRAME_SAMPLES evenly-spaced frames from the video and run them
    through the AI image detector. Returns (is_ai_generated, avg_ai_score, triggered_frames).
    This catches fully synthetic videos (Gemini Veo, Sora, Runway, etc.) that
    VideoMAE misses because they have no face-swap artifacts.
    """
    step = max(1, total_frames // AI_FRAME_SAMPLES)
    frame_indices = [min(i * step, total_frames - 1) for i in range(AI_FRAME_SAMPLES)]

    ai_scores = []
    cap = cv2.VideoCapture(video_path)

    for idx in frame_indices:
        cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
        ret, frame = cap.read()
        if not ret:
            continue
        frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
        pil_img = Image.fromarray(frame_rgb)

        results = ai_image_detector(pil_img)
        # Model labels vary; find the AI/Fake label score
        ai_score = 0.0
        for res in results:
            if any(kw in res['label'].lower() for kw in ['ai', 'fake', 'artificial', 'generated', 'synthetic']):
                ai_score = res['score']
                break
        ai_scores.append(ai_score)
        print(f"  Frame {idx}: AI image score = {ai_score:.4f}")

    cap.release()

    if not ai_scores:
        return False, 0.0, 0.0, 0

    avg_score = sum(ai_scores) / len(ai_scores)
    max_score = max(ai_scores)
    triggered = sum(1 for s in ai_scores if s >= AI_IMAGE_AVG_THRESHOLD)
    # Flag as AI-generated if avg is high OR any single frame was very strongly AI-detected
    is_ai = avg_score >= AI_IMAGE_AVG_THRESHOLD or max_score >= AI_IMAGE_MAX_THRESHOLD
    print(f"  AI Image Check β€” avg={avg_score:.4f}, max={max_score:.4f}, triggered={triggered}/{len(ai_scores)}, is_ai={is_ai}")
    return is_ai, avg_score, max_score, triggered

# ── API endpoint ──────────────────────────────────────────────────────────────
@app.post("/api/analyze")
async def analyze_video(
    file: Optional[UploadFile] = File(None),
    url: Optional[str] = Form(None)
):
    temp_path = None
    try:
        if file is not None and file.filename:
            print(f"Received file: {file.filename}")
            temp_path = os.path.join("temp", f"{uuid.uuid4()}_{file.filename}")
            with open(temp_path, "wb") as buf:
                shutil.copyfileobj(file.file, buf)
                
        elif url is not None and url.strip():
            print(f"Received URL: {url}")
            temp_id = str(uuid.uuid4())
            temp_path_template = os.path.join("temp", f"{temp_id}.%(ext)s")
            
            ydl_opts = {
                'format': 'best', # Simply download the best single file, avoiding ffmpeg merge requirements
                'outtmpl': temp_path_template,
                'noplaylist': True,
                'quiet': True,
                'max_filesize': 100 * 1024 * 1024  # Limit to 100MB
            }
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([url])
                
            # Find the actual downloaded file since extension might vary
            for f in os.listdir("temp"):
                if temp_id in f:
                    temp_path = os.path.join("temp", f)
                    break
                    
            if not temp_path or not os.path.exists(temp_path):
                return {"error": "Failed to download the video from the provided URL."}

        else:
            return {"error": "Please provide either a video file or a valid URL."}

        cap          = cv2.VideoCapture(temp_path)
        video_fps    = cap.get(cv2.CAP_PROP_FPS) or 25.0
        total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        cap.release()

        duration_s = total_frames / video_fps
        print(f"Video: {duration_s:.1f}s @ {video_fps:.1f}fps ({total_frames} frames)")

        if total_frames == 0:
            return {"isFake": False, "confidence": 0,
                    "explanation": "Could not read the video file.", "details": []}

        # ── Extract multiple clips from different parts of the video ───────────────
        segments = [
            max(0, int(total_frames * 0.2) - (SEQUENCE_LENGTH // 2)),
            max(0, int(total_frames * 0.5) - (SEQUENCE_LENGTH // 2)),
            max(0, int(total_frames * 0.8) - (SEQUENCE_LENGTH // 2))
        ]
        
        # Ensure unique starting frames if the video is very short
        segments = sorted(list(set(segments)))
        
        highest_fake_prob = 0.0
        successful_clips = 0

        for start_frame in segments:
            print(f"Extracting clip from frame {start_frame}...")
            faces = extract_clip(temp_path, start_frame)
            
            if faces:
                prob = run_inference(faces)
                highest_fake_prob = max(highest_fake_prob, prob)
                successful_clips += 1
                
                # If we already found very strong evidence of a fake, we can short-circuit to save time
                if highest_fake_prob > 0.95:
                    break

        # ── Run AI Image Detector on sampled frames ────────────────────────────
        # This catches fully synthetic AI-generated videos (Gemini, Sora, Runway, etc.)
        # that VideoMAE misses because they have no face-swap artifacts.
        print("Running AI Image Detector on sampled frames...")
        is_ai_generated, ai_avg_score, ai_max_score, ai_triggered = run_ai_image_check(temp_path, total_frames)

        # ── Combine both signals ───────────────────────────────────────────────
        # VideoMAE: catches face-swaps and traditional deepfakes
        # AI Image Detector: catches fully synthetic AI-generated content
        videomae_flagged = successful_clips > 0 and highest_fake_prob >= FAKE_THRESHOLD
        is_fake = videomae_flagged or is_ai_generated

        # Determine which method triggered and compute confidence
        if videomae_flagged and is_ai_generated:
            detection_method = "Dual-Model (Temporal + AI Image)"
            confidence = round(max(highest_fake_prob, ai_avg_score) * 100, 2)
            explanation = (
                "Both our Temporal VideoMAE and AI Image Detector flagged this video. "
                "It shows face-swap artifacts AND frame-level characteristics of AI-generated content."
            )
        elif is_ai_generated:
            detection_method = "AI Image Detector"
            confidence = round(max(ai_avg_score, ai_max_score) * 100, 2)
            explanation = (
                "Our AI Image Detector identified this video as fully synthetic β€” "
                f"frame-level analysis found strong AI-generation signatures "
                f"(peak score: {ai_max_score*100:.1f}%, avg: {ai_avg_score*100:.1f}%) "
                "consistent with tools like Gemini Veo, Sora, Runway, or similar generative AI systems."
            )
        elif videomae_flagged:
            detection_method = "VideoMAE Temporal Analysis"
            confidence = round(highest_fake_prob * 100, 2)
            explanation = (
                "Our Temporal AI detected strong evidence of facial manipulation β€” "
                "unnatural micro-expressions, blending artifacts, or temporal inconsistencies "
                "characteristic of deepfake face-swap synthesis."
            )
        else:
            # Neither triggered β€” real video
            detection_method = "Dual-Model"
            # Show highest confidence-of-real from both signals
            real_conf = max(
                (1.0 - highest_fake_prob) if successful_clips > 0 else 0.0,
                (1.0 - ai_avg_score)
            )
            confidence = round(real_conf * 100, 2)
            explanation = (
                "Our dual-model analysis found no significant manipulation. "
                "VideoMAE detected no temporal face-swap artifacts, and the AI Image Detector "
                "found no frame-level synthetic generation signatures."
            )

        print(f"FINAL β€” videomae={highest_fake_prob:.3f}, ai_avg={ai_avg_score:.3f}, ai_max={ai_max_score:.3f}, "
              f"method={detection_method}, isFake={is_fake}, confidence={confidence}%")

        return {
            "isFake": is_fake,
            "confidence": confidence,
            "explanation": explanation,
            "details": [
                {
                    "title": "VideoMAE Temporal Analysis",
                    "desc": f"Analyzed {successful_clips} clip(s) with a 3D VideoMAE Transformer. Peak score: {highest_fake_prob*100:.1f}%."
                },
                {
                    "title": "AI Image Frame Analysis",
                    "desc": f"Sampled {AI_FRAME_SAMPLES} frames for AI-generation signatures. Peak frame score: {ai_max_score*100:.1f}%, avg: {ai_avg_score*100:.1f}%. Detects Gemini Veo, Sora, Runway, etc."
                },
                {
                    "title": "Detection Method",
                    "desc": f"Result by: {detection_method}. Thresholds: VideoMAE β‰₯{FAKE_THRESHOLD*100:.0f}% | AI avg β‰₯{AI_IMAGE_AVG_THRESHOLD*100:.0f}% or max β‰₯{AI_IMAGE_MAX_THRESHOLD*100:.0f}%."
                }
            ]
        }

    except Exception as e:
        print(f"Error: {e}")
        import traceback; traceback.print_exc()
        return {"error": str(e)}

    finally:
        if temp_path and os.path.exists(temp_path):
            os.remove(temp_path)

@app.get("/")
def health_check():
    return {"status": "Calibrated VideoMAE Backend is running!"}