spideyhead commited on
Commit
4d5d7cd
Β·
verified Β·
1 Parent(s): 67c350b

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +75 -111
main.py CHANGED
@@ -2,7 +2,6 @@ import os
2
  import cv2
3
  import torch
4
  import shutil
5
- import numpy as np
6
  from fastapi import FastAPI, UploadFile, File
7
  from fastapi.middleware.cors import CORSMiddleware
8
  from transformers import VideoMAEImageProcessor, VideoMAEForVideoClassification
@@ -32,22 +31,20 @@ model.eval()
32
  os.makedirs("temp", exist_ok=True)
33
 
34
  # ── Configuration ─────────────────────────────────────────────────────────────
35
- SEQUENCE_LENGTH = 16 # VideoMAE requires exactly 16 frames per clip
36
- TARGET_FPS = 6 # How many frames per second to sample (covers more motion)
37
- NUM_WINDOWS = 3 # Analyze 3 clips from: start, middle, end
38
 
39
- def get_video_fps(cap):
40
- fps = cap.get(cv2.CAP_PROP_FPS)
41
- return fps if fps and fps > 0 else 25.0
42
 
43
  def smooth_box(current_box, last_box, alpha=0.5):
44
- """EMA smoothing to prevent the face crop from jumping between frames."""
45
  if last_box is None:
46
  return current_box
47
  return [alpha * c + (1 - alpha) * p for c, p in zip(current_box, last_box)]
48
 
49
  def crop_face(pil_img, box, padding=0.35):
50
- """Crop and pad the face region from a PIL image."""
51
  w = box[2] - box[0]
52
  h = box[3] - box[1]
53
  pad_w = int(w * padding)
@@ -60,79 +57,78 @@ def crop_face(pil_img, box, padding=0.35):
60
  return pil_img.crop((x1, y1, x2, y2))
61
  return None
62
 
63
- def extract_clip(video_path, start_frame, video_fps):
64
  """
65
- KEY FIX: Open a fresh VideoCapture, seek ONCE to start_frame,
66
- then read frames sequentially. No cap.set() inside the loop.
67
- This is reliable across all codecs.
68
  """
69
  cap = cv2.VideoCapture(video_path)
70
- cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) # Seek ONCE
71
-
72
- # How many raw frames to skip to achieve TARGET_FPS
73
- frame_step = max(1, int(round(video_fps / TARGET_FPS)))
74
 
75
  faces = []
76
  last_box = None
77
- read_idx = 0 # frames read since seek
78
 
79
- while len(faces) < SEQUENCE_LENGTH:
80
- ret, frame = cap.read() # Sequential read β€” no seeking inside loop
81
  if not ret:
82
  break
83
 
84
- # Only process every Nth frame to hit TARGET_FPS
85
- if read_idx % frame_step == 0:
86
- frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
87
- pil_img = Image.fromarray(frame_rgb)
88
-
89
- boxes, _ = mtcnn.detect(pil_img)
90
 
91
- if boxes is not None and len(boxes) > 0:
92
- raw_box = boxes[0].tolist()
93
- smoothed = smooth_box(raw_box, last_box)
94
- last_box = smoothed
95
- elif last_box is not None:
96
- smoothed = last_box # Hold last known box
97
- else:
98
- read_idx += 1
99
- continue # No face yet β€” keep reading
100
 
101
- crop = crop_face(pil_img, smoothed)
102
- if crop is not None:
103
- faces.append(crop)
 
 
 
 
104
 
105
- read_idx += 1
106
-
107
- # Safety: don't read more than 10 seconds worth of frames
108
- if read_idx > int(video_fps * 10):
109
- break
110
 
111
  cap.release()
112
 
113
  if not faces:
114
  return []
115
 
116
- # Pad to exactly SEQUENCE_LENGTH if the clip was short
117
  while len(faces) < SEQUENCE_LENGTH:
118
- faces.append(faces[-1])
119
 
120
  return faces[:SEQUENCE_LENGTH]
121
 
122
  def run_inference(faces):
123
- """Run VideoMAE on exactly 16 face-crop frames. Returns raw fake probability."""
 
 
 
124
  inputs = processor(list(faces), return_tensors="pt")
125
  with torch.no_grad():
126
  outputs = model(**inputs)
 
127
  probs = torch.nn.functional.softmax(outputs.logits[0], dim=-1)
128
 
129
- # Identify which label index is "fake"
130
- fake_prob = 0.0
131
  for idx, label in model.config.id2label.items():
132
  if "fake" in label.lower():
133
  fake_prob = probs[idx].item()
134
  break
135
 
 
 
 
 
 
 
 
 
136
  return fake_prob
137
 
138
  # ── API endpoint ──────────────────────────────────────────────────────────────
@@ -145,11 +141,10 @@ async def analyze_video(file: UploadFile = File(...)):
145
  shutil.copyfileobj(file.file, buf)
146
 
147
  try:
148
- # Read basic video metadata
149
  cap = cv2.VideoCapture(temp_path)
150
- video_fps = get_video_fps(cap)
151
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
152
- cap.release() # Release immediately β€” each clip opens its own cap
153
 
154
  duration_s = total_frames / video_fps
155
  print(f"Video: {duration_s:.1f}s @ {video_fps:.1f}fps ({total_frames} frames)")
@@ -158,58 +153,36 @@ async def analyze_video(file: UploadFile = File(...)):
158
  return {"isFake": False, "confidence": 0,
159
  "explanation": "Could not read the video file.", "details": []}
160
 
161
- # ── Choose start frames: beginning, middle, end ───────────────────────
162
- # We need SEQUENCE_LENGTH frames Γ— frame_step room before the end
163
- frame_step = max(1, int(round(video_fps / TARGET_FPS)))
164
- needed_frames = SEQUENCE_LENGTH * frame_step
165
- usable_end = max(0, total_frames - needed_frames)
166
-
167
- if NUM_WINDOWS == 1 or usable_end == 0:
168
- start_frames = [max(0, usable_end // 2)]
169
- else:
170
- start_frames = [
171
- int(i * usable_end / (NUM_WINDOWS - 1))
172
- for i in range(NUM_WINDOWS)
173
- ]
174
-
175
- print(f"Analyzing {len(start_frames)} clips at frames: {start_frames}")
176
-
177
- # ── Run inference on each clip ────────────────────────────────────────
178
- fake_probs = []
179
- windows_analyzed = 0
180
 
181
- for i, start in enumerate(start_frames):
182
- print(f" Clip {i+1}: starting frame {start}")
183
- faces = extract_clip(temp_path, start, video_fps)
184
 
185
- if not faces:
186
- print(" No face found β€” skipping.")
187
- continue
 
 
 
188
 
189
- fp = run_inference(faces)
190
- fake_probs.append(fp)
191
- windows_analyzed += 1
192
- print(f" Fake prob: {fp:.3f}")
193
-
194
- if not fake_probs:
195
- return {"isFake": False, "confidence": 0,
196
- "explanation": "Could not detect a clear face in the video.",
197
- "details": []}
198
 
199
- # ── Simple mean ensemble β€” no max bias ────────────────────────────────
200
- avg_fake_prob = float(np.mean(fake_probs))
201
- is_fake = avg_fake_prob > 0.50
202
- confidence = round((avg_fake_prob if is_fake else 1.0 - avg_fake_prob) * 100, 2)
203
 
204
- print(f"FINAL β€” isFake={is_fake}, avg={avg_fake_prob:.3f}, confidence={confidence}%")
205
 
206
  explanation = (
207
- "Our AI analyzed multiple video segments and detected unnatural facial motion, "
208
- "micro-expression inconsistencies, or spatial blending artifacts characteristic "
209
- "of deepfake synthesis."
210
  if is_fake else
211
- "Our AI analyzed multiple video segments and found natural micro-expressions, "
212
- "consistent temporal flow, and no manipulation artifacts."
213
  )
214
 
215
  return {
@@ -218,25 +191,16 @@ async def analyze_video(file: UploadFile = File(...)):
218
  "explanation": explanation,
219
  "details": [
220
  {
221
- "title": "Multi-Clip Temporal Analysis",
222
- "desc": (
223
- f"Analyzed {windows_analyzed} clip(s) from the beginning, middle, "
224
- f"and end of the video using a VideoMAE 3D Transformer."
225
- )
226
  },
227
  {
228
- "title": "Sequential Frame Tracking",
229
- "desc": (
230
- f"Frames sampled at {TARGET_FPS} FPS with EMA bounding-box smoothing "
231
- "to ensure stable face tracking across the clip."
232
- )
233
  },
234
  {
235
- "title": "Ensemble Scoring",
236
- "desc": (
237
- f"Final verdict = mean probability across {windows_analyzed} clip(s). "
238
- f"Per-clip scores: {[f'{p*100:.1f}%' for p in fake_probs]}."
239
- )
240
  }
241
  ]
242
  }
@@ -252,4 +216,4 @@ async def analyze_video(file: UploadFile = File(...)):
252
 
253
  @app.get("/")
254
  def health_check():
255
- return {"status": "Multi-Clip Sequential Backend is running!"}
 
2
  import cv2
3
  import torch
4
  import shutil
 
5
  from fastapi import FastAPI, UploadFile, File
6
  from fastapi.middleware.cors import CORSMiddleware
7
  from transformers import VideoMAEImageProcessor, VideoMAEForVideoClassification
 
31
  os.makedirs("temp", exist_ok=True)
32
 
33
  # ── Configuration ─────────────────────────────────────────────────────────────
34
+ SEQUENCE_LENGTH = 16 # VideoMAE requires exactly 16 frames
 
 
35
 
36
+ # IMPORTANT: This model is biased toward fake. Calibrated threshold after testing:
37
+ # Real videos score ~60-75%, so we raise the bar significantly.
38
+ FAKE_THRESHOLD = 0.80 # Only call FAKE if model is 80%+ confident
39
 
40
  def smooth_box(current_box, last_box, alpha=0.5):
41
+ """EMA smoothing to stabilise the face bounding box across frames."""
42
  if last_box is None:
43
  return current_box
44
  return [alpha * c + (1 - alpha) * p for c, p in zip(current_box, last_box)]
45
 
46
  def crop_face(pil_img, box, padding=0.35):
47
+ """Crop the face region with proportional padding."""
48
  w = box[2] - box[0]
49
  h = box[3] - box[1]
50
  pad_w = int(w * padding)
 
57
  return pil_img.crop((x1, y1, x2, y2))
58
  return None
59
 
60
+ def extract_clip(video_path, start_frame):
61
  """
62
+ Open a fresh cap, seek ONCE to start_frame, then read frames
63
+ SEQUENTIALLY (no cap.set inside loop). This is reliable for all codecs.
64
+ Collects SEQUENCE_LENGTH face crops.
65
  """
66
  cap = cv2.VideoCapture(video_path)
67
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) # Seek exactly ONCE
 
 
 
68
 
69
  faces = []
70
  last_box = None
71
+ attempts = 0
72
 
73
+ while len(faces) < SEQUENCE_LENGTH and attempts < 300:
74
+ ret, frame = cap.read() # Sequential β€” no random seeking
75
  if not ret:
76
  break
77
 
78
+ attempts += 1
79
+ frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
80
+ pil_img = Image.fromarray(frame_rgb)
 
 
 
81
 
82
+ boxes, _ = mtcnn.detect(pil_img)
 
 
 
 
 
 
 
 
83
 
84
+ if boxes is not None and len(boxes) > 0:
85
+ smoothed = smooth_box(boxes[0].tolist(), last_box)
86
+ last_box = smoothed
87
+ elif last_box is not None:
88
+ smoothed = last_box # Hold last known position
89
+ else:
90
+ continue # No face yet β€” keep reading
91
 
92
+ crop = crop_face(pil_img, smoothed)
93
+ if crop is not None:
94
+ faces.append(crop)
 
 
95
 
96
  cap.release()
97
 
98
  if not faces:
99
  return []
100
 
 
101
  while len(faces) < SEQUENCE_LENGTH:
102
+ faces.append(faces[-1]) # Pad with last frame if clip was short
103
 
104
  return faces[:SEQUENCE_LENGTH]
105
 
106
  def run_inference(faces):
107
+ """
108
+ Run VideoMAE on 16 face-crop frames.
109
+ Returns the raw probability for the 'fake' class.
110
+ """
111
  inputs = processor(list(faces), return_tensors="pt")
112
  with torch.no_grad():
113
  outputs = model(**inputs)
114
+
115
  probs = torch.nn.functional.softmax(outputs.logits[0], dim=-1)
116
 
117
+ # Explicitly find the "fake" label index
118
+ fake_prob = None
119
  for idx, label in model.config.id2label.items():
120
  if "fake" in label.lower():
121
  fake_prob = probs[idx].item()
122
  break
123
 
124
+ # Fallback if label map is unexpected
125
+ if fake_prob is None:
126
+ predicted_idx = probs.argmax(-1).item()
127
+ label = model.config.id2label[predicted_idx].lower()
128
+ fake_prob = probs[predicted_idx].item() if "fake" in label else 1.0 - probs[predicted_idx].item()
129
+
130
+ print(f" id2label: {model.config.id2label}")
131
+ print(f" raw fake_prob: {fake_prob:.4f}")
132
  return fake_prob
133
 
134
  # ── API endpoint ──────────────────────────────────────────────────────────────
 
141
  shutil.copyfileobj(file.file, buf)
142
 
143
  try:
 
144
  cap = cv2.VideoCapture(temp_path)
145
+ video_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
146
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
147
+ cap.release()
148
 
149
  duration_s = total_frames / video_fps
150
  print(f"Video: {duration_s:.1f}s @ {video_fps:.1f}fps ({total_frames} frames)")
 
153
  return {"isFake": False, "confidence": 0,
154
  "explanation": "Could not read the video file.", "details": []}
155
 
156
+ # ── Start from the middle of the video (most likely to have a clear face) ─
157
+ start_frame = max(0, (total_frames // 2) - (SEQUENCE_LENGTH // 2))
158
+ print(f"Extracting clip from frame {start_frame}...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
+ faces = extract_clip(temp_path, start_frame)
 
 
161
 
162
+ if not faces:
163
+ return {
164
+ "isFake": False, "confidence": 0,
165
+ "explanation": "Could not detect a clear face in the video. Ensure the subject's face is visible.",
166
+ "details": []
167
+ }
168
 
169
+ print(f"Extracted {len(faces)} face frames. Running inference...")
170
+ fake_prob = run_inference(faces)
 
 
 
 
 
 
 
171
 
172
+ # ── Apply calibrated threshold ─────────────────────────────────────────
173
+ # This model scores ~60-75% for real videos, so we require 80%+ to call FAKE.
174
+ is_fake = fake_prob >= FAKE_THRESHOLD
175
+ confidence = round((fake_prob if is_fake else 1.0 - fake_prob) * 100, 2)
176
 
177
+ print(f"FINAL β€” fake_prob={fake_prob:.3f}, threshold={FAKE_THRESHOLD}, isFake={is_fake}, confidence={confidence}%")
178
 
179
  explanation = (
180
+ "Our Temporal AI detected strong evidence of facial manipulation β€” "
181
+ "unnatural micro-expressions, blending artifacts, or temporal inconsistencies "
182
+ "characteristic of deepfake synthesis."
183
  if is_fake else
184
+ "Our Temporal AI found no significant manipulation artifacts. "
185
+ "Facial motion, micro-expressions, and temporal consistency appear natural."
186
  )
187
 
188
  return {
 
191
  "explanation": explanation,
192
  "details": [
193
  {
194
+ "title": "Temporal Analysis",
195
+ "desc": f"Analyzed a 16-frame consecutive clip from the middle of the video using a VideoMAE 3D Transformer."
 
 
 
196
  },
197
  {
198
+ "title": "Sequential Face Tracking",
199
+ "desc": "Frames read sequentially with EMA bounding-box smoothing for stable face tracking."
 
 
 
200
  },
201
  {
202
+ "title": "Calibrated Threshold",
203
+ "desc": f"Raw model score: {fake_prob*100:.1f}%. Detection threshold: {FAKE_THRESHOLD*100:.0f}% (tuned to reduce false positives)."
 
 
 
204
  }
205
  ]
206
  }
 
216
 
217
  @app.get("/")
218
  def health_check():
219
+ return {"status": "Calibrated VideoMAE Backend is running!"}