spideyhead commited on
Commit
67c350b
Β·
verified Β·
1 Parent(s): 9c0dcb2

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +101 -101
main.py CHANGED
@@ -32,26 +32,22 @@ 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 = 8 # Sample the video at this framerate (higher = finer detail)
37
- NUM_WINDOWS = 4 # Number of independent clips to analyze across the video
38
- WINDOW_SECONDS = 2 # Duration (seconds) of each clip
39
 
40
  def get_video_fps(cap):
41
  fps = cap.get(cv2.CAP_PROP_FPS)
42
  return fps if fps and fps > 0 else 25.0
43
 
44
- def smooth_box(current_box, last_box, alpha=0.6):
45
- """Exponential moving-average smoothing on bounding boxes to reduce jitter."""
46
  if last_box is None:
47
  return current_box
48
- return [
49
- alpha * c + (1 - alpha) * p
50
- for c, p in zip(current_box, last_box)
51
- ]
52
 
53
  def crop_face(pil_img, box, padding=0.35):
54
- """Crop the face from a PIL image with proportional padding."""
55
  w = box[2] - box[0]
56
  h = box[3] - box[1]
57
  pad_w = int(w * padding)
@@ -64,70 +60,80 @@ def crop_face(pil_img, box, padding=0.35):
64
  return pil_img.crop((x1, y1, x2, y2))
65
  return None
66
 
67
- def extract_window(cap, start_frame, video_fps):
68
  """
69
- Extract one 16-frame clip from [start_frame] sampled at TARGET_FPS.
70
- Returns a list of exactly SEQUENCE_LENGTH PIL face crops.
 
71
  """
72
- # How many raw video frames to skip between each sample
73
- frame_step = max(1, int(video_fps / TARGET_FPS))
74
- faces = []
75
- last_box = None
76
-
77
- frame_idx = start_frame
78
- attempts = 0
79
- max_attempts = SEQUENCE_LENGTH * frame_step * 3 # safety cap
80
-
81
- while len(faces) < SEQUENCE_LENGTH and attempts < max_attempts:
82
- cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
83
- ret, frame = cap.read()
84
  if not ret:
85
  break
86
 
87
- frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
88
- pil_img = Image.fromarray(frame_rgb)
 
 
89
 
90
- boxes, _ = mtcnn.detect(pil_img)
91
 
92
- if boxes is not None and len(boxes) > 0:
93
- raw_box = boxes[0].tolist()
94
- smoothed = smooth_box(raw_box, last_box)
95
- last_box = smoothed
96
- elif last_box is not None:
97
- smoothed = last_box # hold last known position
98
- else:
99
- frame_idx += frame_step
100
- attempts += 1
101
- continue
 
 
 
 
 
102
 
103
- crop = crop_face(pil_img, smoothed)
104
- if crop is not None:
105
- faces.append(crop)
106
 
107
- frame_idx += frame_step
108
- attempts += 1
109
 
110
  if not faces:
111
  return []
112
 
113
- # Pad to exactly SEQUENCE_LENGTH by repeating the last frame
114
  while len(faces) < SEQUENCE_LENGTH:
115
  faces.append(faces[-1])
116
 
117
  return faces[:SEQUENCE_LENGTH]
118
 
119
- def run_inference_on_clip(faces):
120
- """Run VideoMAE on a single 16-frame clip. Returns (is_fake, fake_probability)."""
121
  inputs = processor(list(faces), return_tensors="pt")
122
  with torch.no_grad():
123
  outputs = model(**inputs)
124
  probs = torch.nn.functional.softmax(outputs.logits[0], dim=-1)
125
- class_idx = probs.argmax(-1).item()
126
- label = model.config.id2label[class_idx].lower()
127
- is_fake = "fake" in label
128
- # Normalize: always return the probability assigned to "fake"
129
- fake_prob = probs[class_idx].item() if is_fake else 1.0 - probs[class_idx].item()
130
- return is_fake, fake_prob
 
 
 
131
 
132
  # ── API endpoint ──────────────────────────────────────────────────────────────
133
  @app.post("/api/analyze")
@@ -139,77 +145,71 @@ async def analyze_video(file: UploadFile = File(...)):
139
  shutil.copyfileobj(file.file, buf)
140
 
141
  try:
 
142
  cap = cv2.VideoCapture(temp_path)
143
  video_fps = get_video_fps(cap)
144
  total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
145
- duration_s = total_frames / video_fps
146
 
147
- print(f"Video: {duration_s:.1f}s @ {video_fps:.1f}fps ({total_frames} frames)")
 
148
 
149
  if total_frames == 0:
150
  return {"isFake": False, "confidence": 0,
151
  "explanation": "Could not read the video file.", "details": []}
152
 
153
- # ── Determine start frames for each analysis window ───────────────────
154
- window_frames = int(WINDOW_SECONDS * video_fps) # raw frames per window
155
- usable_end = max(0, total_frames - window_frames)
 
 
156
 
157
- if usable_end == 0:
158
- # Very short video: use a single window at the start
159
- start_frames = [0]
160
  else:
161
- # Spread NUM_WINDOWS evenly across the usable range
162
  start_frames = [
163
- int(i * usable_end / (NUM_WINDOWS - 1)) if NUM_WINDOWS > 1 else usable_end // 2
164
  for i in range(NUM_WINDOWS)
165
  ]
166
- # Remove duplicate starts (can happen with short videos)
167
- start_frames = sorted(set(start_frames))
168
 
169
- # ── Run inference on each window ──────────────────────────────────────
170
- all_fake_probs = []
 
 
171
  windows_analyzed = 0
172
 
173
  for i, start in enumerate(start_frames):
174
- print(f" Window {i+1}/{len(start_frames)} β€” starting at frame {start}")
175
- faces = extract_window(cap, start, video_fps)
176
 
177
  if not faces:
178
- print(" No face detected β€” skipping window.")
179
  continue
180
 
181
- _, fake_prob = run_inference_on_clip(faces)
182
- all_fake_probs.append(fake_prob)
183
  windows_analyzed += 1
184
- print(f" Fake probability: {fake_prob:.3f}")
185
-
186
- cap.release()
187
 
188
- if not all_fake_probs:
189
  return {"isFake": False, "confidence": 0,
190
  "explanation": "Could not detect a clear face in the video.",
191
  "details": []}
192
 
193
- # ── Aggregate results (weighted toward the most suspicious window) ────
194
- arr = np.array(all_fake_probs)
195
- avg_fake_prob = float(np.mean(arr))
196
- max_fake_prob = float(np.max(arr))
197
- # Blend: 70 % mean + 30 % max β†’ catches a single damning window
198
- blended_prob = 0.70 * avg_fake_prob + 0.30 * max_fake_prob
199
-
200
- is_fake = blended_prob > 0.50
201
- confidence = round((blended_prob if is_fake else 1.0 - blended_prob) * 100, 2)
202
 
203
- print(f"FINAL β€” isFake={is_fake}, blended_prob={blended_prob:.3f}, confidence={confidence}%")
204
 
205
  explanation = (
206
- "Our 3D Temporal AI analyzed multiple video segments and detected "
207
- "unnatural facial motion, micro-expression inconsistencies, or "
208
- "spatial blending artifacts characteristic of deepfakes."
209
  if is_fake else
210
- "Our 3D Temporal AI analyzed multiple video segments and found "
211
- "natural micro-expressions, consistent temporal flow, and no "
212
- "manipulation artifacts."
213
  )
214
 
215
  return {
@@ -218,24 +218,24 @@ async def analyze_video(file: UploadFile = File(...)):
218
  "explanation": explanation,
219
  "details": [
220
  {
221
- "title": "Multi-Window Temporal Analysis",
222
  "desc": (
223
- f"Analyzed {windows_analyzed} independent {WINDOW_SECONDS}-second "
224
- f"clip(s) spread across the video using a VideoMAE 3D Transformer."
225
  )
226
  },
227
  {
228
- "title": "High-FPS Face Tracking",
229
  "desc": (
230
- f"Sampled at {TARGET_FPS} FPS with smoothed bounding-box tracking "
231
- "to capture jitter, blinking anomalies, and lip-sync mismatches."
232
  )
233
  },
234
  {
235
  "title": "Ensemble Scoring",
236
  "desc": (
237
- f"Final score = 70% average + 30% peak across all windows "
238
- f"(avg {avg_fake_prob*100:.1f}%, peak {max_fake_prob*100:.1f}%)."
239
  )
240
  }
241
  ]
@@ -252,4 +252,4 @@ async def analyze_video(file: UploadFile = File(...)):
252
 
253
  @app.get("/")
254
  def health_check():
255
- return {"status": "Multi-Window Temporal Backend is running!"}
 
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
  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 ──────────────────────────────────────────────────────────────
139
  @app.post("/api/analyze")
 
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)")
156
 
157
  if total_frames == 0:
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
  "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
  ]
 
252
 
253
  @app.get("/")
254
  def health_check():
255
+ return {"status": "Multi-Clip Sequential Backend is running!"}