Venkatkalyan21 commited on
Commit
1970a5b
Β·
0 Parent(s):

Deploy clean backend to Hugging Face

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ backend/models/*.pth filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ο»Ώbackend/venv/
2
+ backend/uploads/
3
+ backend/results/
4
+ __pycache__/
5
+ *.pyc
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Install system dependencies (including ffmpeg for audio extraction and git-lfs for model weights)
4
+ RUN apt-get update && apt-get install -y \
5
+ ffmpeg \
6
+ git \
7
+ git-lfs \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ WORKDIR /app
11
+
12
+ # Copy requirements and install
13
+ COPY backend/requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Copy the backend code
17
+ COPY backend/ .
18
+
19
+ # Expose port (Hugging Face Spaces listens on port 7860 by default)
20
+ EXPOSE 7860
21
+
22
+ # Run Flask using gunicorn
23
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "--timeout", "120", "app:app"]
backend/.env ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ MONGO_URI=mongodb://localhost:27017/deepshield
2
+ IMAGE_MODEL_PATH=models/image_model_best.pth
3
+ AUDIO_MODEL_PATH=models/audio_model_best.pth
4
+ VIDEO_MODEL_PATH=models/deepfake_model.pth
5
+ THRESHOLD=0.5
6
+ FLASK_ENV=development
7
+ PORT=5000
backend/.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ MONGO_URI=mongodb://localhost:27017/deepshield
2
+ IMAGE_MODEL_PATH=models/image_model_best.pth
3
+ AUDIO_MODEL_PATH=models/audio_model_best.pth
4
+ VIDEO_MODEL_PATH=models/deepfake_model.pth
5
+ THRESHOLD=0.5
6
+ FLASK_ENV=development
7
+ PORT=5000
backend/Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: gunicorn app:app
backend/app.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepShield β€” Multi-Modal Deepfake Detection API (Flask)
3
+ Flask server handling Video, Image, and Audio deepfake detection with MongoDB integration.
4
+ """
5
+ import os
6
+ import shutil
7
+ import uuid
8
+ from pathlib import Path
9
+ from dotenv import load_dotenv
10
+
11
+ from flask import Flask, request, jsonify, send_from_directory
12
+ from flask_cors import CORS
13
+ from werkzeug.utils import secure_filename
14
+
15
+ from multimodal_detector import MultiModalDetector
16
+ from db import save_detection, get_recent_detections
17
+
18
+ load_dotenv()
19
+
20
+ # ── Setup directories ─────────────────────────────────────────────
21
+ for d in ["uploads", "results", "models"]:
22
+ Path(d).mkdir(exist_ok=True)
23
+
24
+ # ── App ───────────────────────────────────────────────────────────
25
+ app = Flask(__name__)
26
+ CORS(app) # Enable CORS for all routes
27
+
28
+ app.config['UPLOAD_FOLDER'] = 'uploads'
29
+ app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500 MB max limit
30
+
31
+ # ── Load detector once at startup ─────────────────────────────────
32
+ VIDEO_MODEL = os.getenv("VIDEO_MODEL_PATH", "models/deepfake_model.pth")
33
+ IMAGE_MODEL = os.getenv("IMAGE_MODEL_PATH", "models/image_model_best.pth")
34
+ AUDIO_MODEL = os.getenv("AUDIO_MODEL_PATH", "models/audio_model_best.pth")
35
+ THRESHOLD = float(os.getenv("THRESHOLD", "0.5"))
36
+
37
+ detector = MultiModalDetector(
38
+ video_model_path = VIDEO_MODEL if Path(VIDEO_MODEL).exists() else None,
39
+ image_model_path = IMAGE_MODEL if Path(IMAGE_MODEL).exists() else None,
40
+ audio_model_path = AUDIO_MODEL if Path(AUDIO_MODEL).exists() else None,
41
+ threshold = THRESHOLD,
42
+ )
43
+
44
+ # ── Allowed file types ────────────────────────────────────────────
45
+ VIDEO_EXTS = {".mp4", ".avi", ".mov", ".mkv", ".webm"}
46
+ IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
47
+ AUDIO_EXTS = {".wav", ".mp3", ".flac", ".ogg", ".m4a"}
48
+ MAX_MB = {"video": 500, "image": 20, "audio": 50}
49
+
50
+ def save_upload(file, session_id, ext):
51
+ upload_dir = Path("uploads") / session_id
52
+ upload_dir.mkdir(parents=True, exist_ok=True)
53
+ filename = secure_filename(file.filename)
54
+ dest = upload_dir / f"input{ext}"
55
+ file.save(str(dest))
56
+ return dest, os.path.getsize(str(dest)), filename
57
+
58
+ # ═══════════════════════════════════════════════════════════════════
59
+ # ENDPOINTS
60
+ # ═══════════════════════════════════════════════════════════════════
61
+
62
+ @app.route("/health", methods=["GET"])
63
+ def health():
64
+ return jsonify({
65
+ "status": "ok",
66
+ "system": "DeepShield v2.0 (Flask)",
67
+ "device": detector.device,
68
+ "video_model": "Loaded" if Path(VIDEO_MODEL).exists() else "Demo mode",
69
+ "image_model": "Loaded" if Path(IMAGE_MODEL).exists() else "Demo mode",
70
+ "audio_available": detector.audio_available,
71
+ "modalities": ["video", "image", "audio"],
72
+ })
73
+
74
+ @app.route("/history", methods=["GET"])
75
+ def get_history():
76
+ limit = request.args.get('limit', default=50, type=int)
77
+ history = get_recent_detections(limit)
78
+ return jsonify({
79
+ "total": len(history),
80
+ "history": history
81
+ })
82
+
83
+ # ── VIDEO Detection ───────────────────────────────────────────────
84
+ @app.route("/detect/video", methods=["POST"])
85
+ def detect_video():
86
+ if 'file' not in request.files:
87
+ return jsonify({"detail": "No file part"}), 400
88
+
89
+ file = request.files['file']
90
+ if file.filename == '':
91
+ return jsonify({"detail": "No selected file"}), 400
92
+
93
+ suffix = Path(file.filename).suffix.lower()
94
+ if suffix not in VIDEO_EXTS:
95
+ return jsonify({"detail": f"Unsupported video type: {suffix}. Allowed: {VIDEO_EXTS}"}), 400
96
+
97
+ session_id = str(uuid.uuid4())
98
+ dest, size, orig_filename = save_upload(file, session_id, suffix)
99
+ size_mb = size / (1024 * 1024)
100
+ if size_mb > MAX_MB["video"]:
101
+ shutil.rmtree(dest.parent, ignore_errors=True)
102
+ return jsonify({"detail": f"File too large ({size_mb:.1f} MB). Max {MAX_MB['video']} MB."}), 413
103
+
104
+ try:
105
+ result = detector.analyze_video(str(dest), session_id=session_id)
106
+ # Save to MongoDB
107
+ save_detection(
108
+ session_id=session_id,
109
+ filename=orig_filename,
110
+ modality="video",
111
+ verdict=result.get("verdict"),
112
+ confidence=result.get("confidence"),
113
+ details={"frames_analyzed": result.get("frames_analyzed")}
114
+ )
115
+ except Exception as e:
116
+ shutil.rmtree(dest.parent, ignore_errors=True)
117
+ return jsonify({"detail": f"Detection error: {e}"}), 500
118
+
119
+ return jsonify(result)
120
+
121
+ # ── IMAGE Detection ───────────────────────────────────────────────
122
+ @app.route("/detect/image", methods=["POST"])
123
+ def detect_image():
124
+ if 'file' not in request.files:
125
+ return jsonify({"detail": "No file part"}), 400
126
+
127
+ file = request.files['file']
128
+ if file.filename == '':
129
+ return jsonify({"detail": "No selected file"}), 400
130
+
131
+ suffix = Path(file.filename).suffix.lower()
132
+ if suffix not in IMAGE_EXTS:
133
+ return jsonify({"detail": f"Unsupported image type: {suffix}. Allowed: {IMAGE_EXTS}"}), 400
134
+
135
+ session_id = str(uuid.uuid4())
136
+ dest, size, orig_filename = save_upload(file, session_id, suffix)
137
+ size_mb = size / (1024 * 1024)
138
+ if size_mb > MAX_MB["image"]:
139
+ shutil.rmtree(dest.parent, ignore_errors=True)
140
+ return jsonify({"detail": f"File too large ({size_mb:.1f} MB). Max {MAX_MB['image']} MB."}), 413
141
+
142
+ try:
143
+ result = detector.analyze_image(str(dest))
144
+ result["session_id"] = session_id
145
+ # Save to MongoDB
146
+ save_detection(
147
+ session_id=session_id,
148
+ filename=orig_filename,
149
+ modality="image",
150
+ verdict=result.get("verdict"),
151
+ confidence=result.get("confidence"),
152
+ details={"faces_detected": result.get("faces_detected", 1)}
153
+ )
154
+ except Exception as e:
155
+ shutil.rmtree(dest.parent, ignore_errors=True)
156
+ return jsonify({"detail": f"Detection error: {e}"}), 500
157
+
158
+ return jsonify(result)
159
+
160
+ # ── AUDIO Detection ───────────────────────────────────────────────
161
+ @app.route("/detect/audio", methods=["POST"])
162
+ def detect_audio():
163
+ if 'file' not in request.files:
164
+ return jsonify({"detail": "No file part"}), 400
165
+
166
+ file = request.files['file']
167
+ if file.filename == '':
168
+ return jsonify({"detail": "No selected file"}), 400
169
+
170
+ suffix = Path(file.filename).suffix.lower()
171
+ if suffix not in AUDIO_EXTS:
172
+ return jsonify({"detail": f"Unsupported audio type: {suffix}. Allowed: {AUDIO_EXTS}"}), 400
173
+
174
+ session_id = str(uuid.uuid4())
175
+ dest, size, orig_filename = save_upload(file, session_id, suffix)
176
+ size_mb = size / (1024 * 1024)
177
+ if size_mb > MAX_MB["audio"]:
178
+ shutil.rmtree(dest.parent, ignore_errors=True)
179
+ return jsonify({"detail": f"File too large ({size_mb:.1f} MB). Max {MAX_MB['audio']} MB."}), 413
180
+
181
+ try:
182
+ result = detector.analyze_audio(str(dest))
183
+ result["session_id"] = session_id
184
+ # Save to MongoDB
185
+ save_detection(
186
+ session_id=session_id,
187
+ filename=orig_filename,
188
+ modality="audio",
189
+ verdict=result.get("verdict"),
190
+ confidence=result.get("confidence")
191
+ )
192
+ except Exception as e:
193
+ shutil.rmtree(dest.parent, ignore_errors=True)
194
+ return jsonify({"detail": f"Detection error: {e}"}), 500
195
+
196
+ return jsonify(result)
197
+
198
+ # ── Results & cleanup ─────────────────────────────────────────────
199
+ @app.route("/results/<session_id>/<filename>", methods=["GET"])
200
+ def get_result_file(session_id, filename):
201
+ result_dir = Path("results") / session_id
202
+ return send_from_directory(str(result_dir), filename)
203
+
204
+ @app.route("/results/<session_id>", methods=["GET"])
205
+ def get_results(session_id):
206
+ result_dir = Path("results") / session_id
207
+ if not result_dir.exists():
208
+ return jsonify({"detail": "Session not found."}), 404
209
+ files = [f.name for f in result_dir.glob("*.jpg")]
210
+ return jsonify({"session_id": session_id, "heatmaps": files})
211
+
212
+ @app.route("/session/<session_id>", methods=["DELETE"])
213
+ def delete_session(session_id):
214
+ for d in ["uploads", "results"]:
215
+ shutil.rmtree(Path(d) / session_id, ignore_errors=True)
216
+ return jsonify({"deleted": session_id})
217
+
218
+ if __name__ == "__main__":
219
+ port = int(os.environ.get("PORT", 5000))
220
+ app.run(host="0.0.0.0", port=port, debug=True)
backend/audio_detector.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AudioDeepfakeDetector β€” Wav2Vec2-based audio deepfake detection
3
+ Uses Facebook's Wav2Vec2-base for feature extraction + lightweight classification head.
4
+ Detects AI-generated or voice-cloned audio (TTS, VC spoofing).
5
+ """
6
+ import torch
7
+ import torch.nn as nn
8
+ import numpy as np
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ try:
13
+ import librosa
14
+ LIBROSA_AVAILABLE = True
15
+ except ImportError:
16
+ LIBROSA_AVAILABLE = False
17
+
18
+ try:
19
+ from transformers import Wav2Vec2Model, Wav2Vec2FeatureExtractor
20
+ WAV2VEC_AVAILABLE = True
21
+ except ImportError:
22
+ WAV2VEC_AVAILABLE = False
23
+
24
+
25
+ # ─────────────────────────────────────────────────────────────────
26
+ # Classifier Head (on top of Wav2Vec2 hidden states)
27
+ # ─────────────────────────────────────────────────────────────────
28
+ class AudioClassifierHead(nn.Module):
29
+ """
30
+ Pools temporal hidden states from Wav2Vec2 and classifies Real/Fake.
31
+ Input: (B, T, D) β€” Wav2Vec2 last_hidden_state
32
+ Output: (B, 1) β€” raw logits (apply sigmoid for probability)
33
+ """
34
+ def __init__(self, input_dim: int = 768):
35
+ super().__init__()
36
+ self.attention_pool = nn.Sequential(
37
+ nn.Linear(input_dim, 128),
38
+ nn.Tanh(),
39
+ nn.Linear(128, 1),
40
+ )
41
+ self.classifier = nn.Sequential(
42
+ nn.Linear(input_dim, 256),
43
+ nn.LayerNorm(256),
44
+ nn.ReLU(inplace=True),
45
+ nn.Dropout(0.4),
46
+ nn.Linear(256, 64),
47
+ nn.ReLU(inplace=True),
48
+ nn.Dropout(0.2),
49
+ nn.Linear(64, 1),
50
+ )
51
+
52
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
53
+ # Attention-weighted pooling
54
+ weights = torch.softmax(self.attention_pool(hidden_states), dim=1) # (B, T, 1)
55
+ pooled = (hidden_states * weights).sum(dim=1) # (B, D)
56
+ return self.classifier(pooled) # (B, 1)
57
+
58
+
59
+ # ─────────────────────────────────────────────────────────────────
60
+ # Main Audio Deepfake Detector
61
+ # ─────────────────────────────────────────────────────────────────
62
+ class AudioDeepfakeDetector(nn.Module):
63
+ """
64
+ Wav2Vec2-base + Attention-Pooling Classifier for audio deepfake detection.
65
+
66
+ Pipeline:
67
+ Raw waveform (16 kHz mono)
68
+ β†’ Wav2Vec2 feature extractor (normalisation)
69
+ β†’ Wav2Vec2 transformer encoder β†’ hidden states (T Γ— 768)
70
+ β†’ Attention-weighted pooling β†’ 768-dim vector
71
+ β†’ FC classifier head β†’ fake probability
72
+ """
73
+ SAMPLE_RATE = 16_000
74
+ MAX_DURATION_SEC = 10.0 # Clip to 10 s for speed
75
+
76
+ def __init__(self, pretrained: bool = True, freeze_base: bool = True):
77
+ super().__init__()
78
+
79
+ if not WAV2VEC_AVAILABLE:
80
+ raise RuntimeError(
81
+ "transformers library not installed.\n"
82
+ "Run: pip install transformers>=4.30.0"
83
+ )
84
+
85
+ model_name = "facebook/wav2vec2-base"
86
+ self.feature_extractor = Wav2Vec2FeatureExtractor.from_pretrained(model_name)
87
+ self.wav2vec2 = Wav2Vec2Model.from_pretrained(model_name)
88
+
89
+ # Optionally freeze Wav2Vec2 backbone for efficient fine-tuning
90
+ if freeze_base:
91
+ for param in self.wav2vec2.parameters():
92
+ param.requires_grad = False
93
+
94
+ hidden_dim = self.wav2vec2.config.hidden_size # 768
95
+ self.head = AudioClassifierHead(hidden_dim)
96
+
97
+ # ── Forward ─────────────────────────────────────────────────
98
+ def forward(
99
+ self,
100
+ input_values: torch.Tensor,
101
+ attention_mask: Optional[torch.Tensor] = None,
102
+ ) -> torch.Tensor:
103
+ outputs = self.wav2vec2(input_values, attention_mask=attention_mask)
104
+ return self.head(outputs.last_hidden_state) # (B, 1) logits
105
+
106
+ # ── Inference helpers ────────────────────────────────────────
107
+ def predict_proba(self, waveform: np.ndarray, device: str = "cpu") -> float:
108
+ """
109
+ Args:
110
+ waveform: 1-D numpy float32 array at 16 kHz
111
+ device: 'cpu' or 'cuda'
112
+ Returns:
113
+ Fake probability in [0, 1]
114
+ """
115
+ max_samples = int(self.MAX_DURATION_SEC * self.SAMPLE_RATE)
116
+ if len(waveform) > max_samples:
117
+ waveform = waveform[:max_samples]
118
+
119
+ inputs = self.feature_extractor(
120
+ waveform,
121
+ sampling_rate=self.SAMPLE_RATE,
122
+ return_tensors="pt",
123
+ padding=True,
124
+ )
125
+ input_values = inputs.input_values.to(device)
126
+
127
+ self.eval()
128
+ with torch.no_grad():
129
+ logits = self.forward(input_values)
130
+ prob = torch.sigmoid(logits).squeeze().item()
131
+ return float(prob)
132
+
133
+ # ── Static helpers ───────────────────────────────────────────
134
+ @staticmethod
135
+ def load_audio(path: str, target_sr: int = 16_000) -> np.ndarray:
136
+ """Load any audio/video file and resample to 16 kHz mono."""
137
+ if not LIBROSA_AVAILABLE:
138
+ raise RuntimeError("librosa not installed. Run: pip install librosa>=0.10.0")
139
+ waveform, _ = librosa.load(path, sr=target_sr, mono=True)
140
+ return waveform.astype(np.float32)
141
+
142
+ @staticmethod
143
+ def load(path: str, device: str = "cpu") -> "AudioDeepfakeDetector":
144
+ """Load a trained detector from a .pth file."""
145
+ model = AudioDeepfakeDetector(pretrained=False, freeze_base=False)
146
+ state = torch.load(path, map_location=device, weights_only=True)
147
+ model.load_state_dict(state)
148
+ model.eval()
149
+ return model
150
+
151
+
152
+ # ── Quick sanity check ────────────────────────────────────────────
153
+ if __name__ == "__main__":
154
+ if WAV2VEC_AVAILABLE:
155
+ model = AudioDeepfakeDetector(pretrained=False, freeze_base=False)
156
+ dummy = torch.randn(2, 16_000) # 1-second batch
157
+ logits = model(dummy)
158
+ total = sum(p.numel() for p in model.parameters())
159
+ print(f"Output shape : {logits.shape}")
160
+ print(f"Total params : {total:,}")
161
+ print("AudioDeepfakeDetector OK βœ“")
162
+ else:
163
+ print("transformers not installed β€” skipping sanity check.")
backend/db.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import datetime
3
+ from pymongo import MongoClient
4
+ from pymongo.server_api import ServerApi
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/")
10
+ DB_NAME = "deepshield"
11
+ COLLECTION_NAME = "detections"
12
+
13
+ _db_client = None
14
+
15
+ def get_db():
16
+ global _db_client
17
+ if _db_client is None:
18
+ try:
19
+ # Connect to MongoDB Atlas or local MongoDB
20
+ _db_client = MongoClient(MONGO_URI, server_api=ServerApi('1') if "mongodb.net" in MONGO_URI else None)
21
+ # Send a ping to confirm a successful connection
22
+ _db_client.admin.command('ping')
23
+ print("Successfully connected to MongoDB!")
24
+ except Exception as e:
25
+ print(f"MongoDB connection error: {e}")
26
+ return None
27
+
28
+ return _db_client[DB_NAME]
29
+
30
+ def save_detection(session_id, filename, modality, verdict, confidence, details=None):
31
+ db = get_db()
32
+ if db is None:
33
+ return False
34
+
35
+ try:
36
+ record = {
37
+ "session_id": session_id,
38
+ "filename": filename,
39
+ "modality": modality,
40
+ "verdict": verdict,
41
+ "confidence": confidence,
42
+ "details": details or {},
43
+ "timestamp": datetime.datetime.utcnow()
44
+ }
45
+ db[COLLECTION_NAME].insert_one(record)
46
+ return True
47
+ except Exception as e:
48
+ print(f"Error saving to MongoDB: {e}")
49
+ return False
50
+
51
+ def get_recent_detections(limit=50):
52
+ db = get_db()
53
+ if db is None:
54
+ return []
55
+
56
+ try:
57
+ cursor = db[COLLECTION_NAME].find({}, {"_id": 0}).sort("timestamp", -1).limit(limit)
58
+ return list(cursor)
59
+ except Exception as e:
60
+ print(f"Error reading from MongoDB: {e}")
61
+ return []
backend/detector.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ DeepfakeDetector β€” orchestrates the full video analysis pipeline
3
+ """
4
+ import os, uuid, time
5
+ import numpy as np
6
+ import torch
7
+ import torchvision.transforms as T
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from model import HybridDeepfakeDetector
12
+ from face_extractor import FaceExtractor
13
+ from gradcam import GradCAM
14
+
15
+ # ── Image pre-processing ─────────────────────────────────────────────
16
+ MEAN = [0.485, 0.456, 0.406]
17
+ STD = [0.229, 0.224, 0.225]
18
+
19
+ transform = T.Compose([
20
+ T.ToTensor(),
21
+ T.Normalize(mean=MEAN, std=STD),
22
+ ])
23
+
24
+
25
+ class DeepfakeDetector:
26
+ """
27
+ End-to-end video deepfake detector.
28
+ Steps:
29
+ 1. Sample frames from video
30
+ 2. Crop face regions
31
+ 3. Run hybrid model inference
32
+ 4. Generate Grad-CAM heatmaps
33
+ 5. Aggregate per-frame scores into video-level verdict
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ model_path: Optional[str] = None,
39
+ device: Optional[str] = None,
40
+ max_frames: int = 32,
41
+ threshold: float = 0.5,
42
+ ):
43
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
44
+ self.threshold = threshold
45
+ self.max_frames = max_frames
46
+
47
+ # Load model
48
+ self.model = HybridDeepfakeDetector(pretrained=(model_path is None))
49
+ if model_path and Path(model_path).exists():
50
+ state = torch.load(model_path, map_location=self.device)
51
+ self.model.load_state_dict(state)
52
+ print(f"[Detector] Loaded weights from {model_path}")
53
+ else:
54
+ print("[Detector] Using ImageNet pretrained weights (demo mode). "
55
+ "Train on FF++ for research-quality results.")
56
+
57
+ self.model.to(self.device).eval()
58
+
59
+ self.extractor = FaceExtractor()
60
+ self.gradcam = GradCAM(self.model)
61
+
62
+ # ── Single frame inference ───────────────────────────────────────
63
+ def _infer_frame(self, face_rgb: np.ndarray) -> float:
64
+ """Returns fake probability for one face crop."""
65
+ tensor = transform(face_rgb).unsqueeze(0).to(self.device)
66
+ with torch.no_grad():
67
+ prob = self.model.predict_proba(tensor).item()
68
+ return float(prob)
69
+
70
+ # ── Main analysis ────────────────────────────────────────────────
71
+ def analyze(self, video_path: str, session_id: Optional[str] = None) -> dict:
72
+ t0 = time.time()
73
+ session_id = session_id or str(uuid.uuid4())
74
+
75
+ results_dir = Path("results") / session_id
76
+ results_dir.mkdir(parents=True, exist_ok=True)
77
+
78
+ # 1. Extract faces
79
+ faces = self.extractor.extract(video_path, max_frames=self.max_frames)
80
+ if not faces:
81
+ return {
82
+ "session_id": session_id,
83
+ "verdict": "UNKNOWN",
84
+ "confidence": 0.0,
85
+ "error": "No faces detected in video.",
86
+ "frames": [],
87
+ }
88
+
89
+ # 2. Per-frame inference + Grad-CAM
90
+ frame_results = []
91
+ scores = []
92
+
93
+ for i, item in enumerate(faces):
94
+ face_rgb = item["face"]
95
+ fidx = item["frame_idx"]
96
+ prob = self._infer_frame(face_rgb)
97
+ scores.append(prob)
98
+
99
+ # Generate and save Grad-CAM heatmap
100
+ cam_path = str(results_dir / f"frame_{i:04d}_cam.jpg")
101
+ self.gradcam.generate(face_rgb, cam_path)
102
+
103
+ frame_results.append({
104
+ "frame_idx": fidx,
105
+ "fake_prob": round(prob, 4),
106
+ "verdict": "FAKE" if prob >= self.threshold else "REAL",
107
+ "cam_path": f"/results/{session_id}/frame_{i:04d}_cam.jpg",
108
+ })
109
+
110
+ # 3. Video-level score
111
+ video_score = float(np.mean(scores))
112
+ verdict = "FAKE" if video_score >= self.threshold else "REAL"
113
+
114
+ elapsed = round(time.time() - t0, 2)
115
+
116
+ return {
117
+ "session_id": session_id,
118
+ "verdict": verdict,
119
+ "confidence": round(video_score * 100, 2),
120
+ "fake_prob": round(video_score, 4),
121
+ "frames_analyzed": len(faces),
122
+ "elapsed_sec": elapsed,
123
+ "frame_scores": frame_results,
124
+ }
backend/download_audio_dataset.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Download Audio Deepfake Dataset - Smart approach using HuggingFace parquet cache
3
+ Uses Audio(decode=False) to get raw bytes without torchcodec, avoids rate limiting
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import tempfile
9
+ import soundfile as sf
10
+ from datasets import load_dataset, Audio
11
+
12
+ OUTPUT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "datasets", "audio"))
13
+ REAL_TRAIN_DIR = os.path.join(OUTPUT_DIR, "train", "real")
14
+ REAL_VAL_DIR = os.path.join(OUTPUT_DIR, "val", "real")
15
+ FAKE_TRAIN_DIR = os.path.join(OUTPUT_DIR, "train", "fake")
16
+ FAKE_VAL_DIR = os.path.join(OUTPUT_DIR, "val", "fake")
17
+
18
+ os.makedirs(REAL_TRAIN_DIR, exist_ok=True)
19
+ os.makedirs(REAL_VAL_DIR, exist_ok=True)
20
+ os.makedirs(FAKE_TRAIN_DIR, exist_ok=True)
21
+ os.makedirs(FAKE_VAL_DIR, exist_ok=True)
22
+
23
+ print("=" * 60)
24
+ print(" Audio Deepfake Dataset Downloader (Smart Mode)")
25
+ print(" Dataset: garystafford/deepfake-audio-detection")
26
+ print("=" * 60)
27
+
28
+ # --- Step 1: Load dataset WITHOUT auto-decoding -------------------
29
+ # decode=False gives us raw bytes directly from the parquet
30
+ # No torchcodec needed, no individual file downloads, no rate limit!
31
+ print("\n[1/3] Loading dataset from HuggingFace (parquet only)...")
32
+ print(" Using cached parquet if available...")
33
+
34
+ ds = load_dataset(
35
+ "garystafford/deepfake-audio-detection",
36
+ split="train",
37
+ trust_remote_code=False
38
+ )
39
+
40
+ # Disable audio decoding - get raw bytes instead
41
+ ds = ds.cast_column("audio", Audio(decode=False))
42
+
43
+ print(f" [OK] Dataset loaded! Total samples: {len(ds)}")
44
+
45
+ # --- Step 2: Save each sample as WAV -----------------------------
46
+ print(f"\n[2/3] Converting and saving WAV files...")
47
+ print(f" Output: {OUTPUT_DIR}")
48
+
49
+ real_count = 0
50
+ fake_count = 0
51
+ skip_count = 0
52
+
53
+ for i, sample in enumerate(ds):
54
+ label = sample["label"] # 0 = real, 1 = fake
55
+ audio_info = sample["audio"] # dict: {"bytes": b"...", "path": "..."}
56
+
57
+ audio_bytes = audio_info.get("bytes") if isinstance(audio_info, dict) else None
58
+ audio_path = audio_info.get("path", f"sample_{i}") if isinstance(audio_info, dict) else f"sample_{i}"
59
+
60
+ if not audio_bytes:
61
+ skip_count += 1
62
+ continue
63
+
64
+ # Write raw bytes to a temp FLAC file, read it, save as WAV
65
+ try:
66
+ with tempfile.NamedTemporaryFile(suffix=".flac", delete=False) as tmp:
67
+ tmp.write(audio_bytes)
68
+ tmp_path = tmp.name
69
+
70
+ data, sr = sf.read(tmp_path)
71
+ os.unlink(tmp_path)
72
+
73
+ if label == 0:
74
+ is_val = (real_count % 5 == 0)
75
+ target_dir = REAL_VAL_DIR if is_val else REAL_TRAIN_DIR
76
+ out_path = os.path.join(target_dir, f"real_{real_count:04d}.wav")
77
+ real_count += 1
78
+ else:
79
+ is_val = (fake_count % 5 == 0)
80
+ target_dir = FAKE_VAL_DIR if is_val else FAKE_TRAIN_DIR
81
+ out_path = os.path.join(target_dir, f"fake_{fake_count:04d}.wav")
82
+ fake_count += 1
83
+
84
+ sf.write(out_path, data, sr)
85
+
86
+ except Exception as e:
87
+ skip_count += 1
88
+ if os.path.exists(tmp_path):
89
+ os.unlink(tmp_path)
90
+ continue
91
+
92
+ # Show progress every 50 files
93
+ total = real_count + fake_count
94
+ if total % 50 == 0 or total == 1:
95
+ sys.stdout.write(f"\r Progress: {total}/1866 (real={real_count}, fake={fake_count}, skipped={skip_count})")
96
+ sys.stdout.flush()
97
+
98
+ total = real_count + fake_count
99
+ print(f"\n\n[3/3] COMPLETE!")
100
+ print(f" Real WAV files : {real_count} -> {REAL_TRAIN_DIR} and {REAL_VAL_DIR}")
101
+ print(f" Fake WAV files : {fake_count} -> {FAKE_TRAIN_DIR} and {FAKE_VAL_DIR}")
102
+ print(f" Skipped : {skip_count}")
103
+ print(f" Total saved : {total}")
104
+ print("=" * 60)
backend/face_extractor.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face Extractor β€” OpenCV-based face detection and frame sampling
3
+ """
4
+ import cv2
5
+ import numpy as np
6
+ from pathlib import Path
7
+ from typing import List, Tuple, Optional
8
+
9
+
10
+ class FaceExtractor:
11
+ """
12
+ Extracts and crops face regions from video frames.
13
+ Uses OpenCV's Haar cascade (no extra dependencies).
14
+ """
15
+
16
+ def __init__(self):
17
+ cascade_path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
18
+ self.detector = cv2.CascadeClassifier(cascade_path)
19
+ if self.detector.empty():
20
+ raise RuntimeError("Failed to load Haar cascade classifier.")
21
+
22
+ # ── Frame sampling ──────────────────────────────────────────────
23
+ def sample_frames(
24
+ self,
25
+ video_path: str,
26
+ max_frames: int = 32,
27
+ sample_fps: float = 2.0,
28
+ ) -> List[Tuple[int, np.ndarray]]:
29
+ """
30
+ Returns a list of (frame_index, BGR_frame) tuples.
31
+ """
32
+ cap = cv2.VideoCapture(str(video_path))
33
+ if not cap.isOpened():
34
+ raise ValueError(f"Cannot open video: {video_path}")
35
+
36
+ video_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
37
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
38
+ interval = max(1, int(video_fps / sample_fps))
39
+
40
+ frames: List[Tuple[int, np.ndarray]] = []
41
+ idx = 0
42
+
43
+ while cap.isOpened() and len(frames) < max_frames:
44
+ ret, frame = cap.read()
45
+ if not ret:
46
+ break
47
+ if idx % interval == 0:
48
+ frames.append((idx, frame))
49
+ idx += 1
50
+
51
+ cap.release()
52
+ return frames
53
+
54
+ # ── Face crop ───────────────────────────────────────────────────
55
+ def crop_face(
56
+ self,
57
+ frame: np.ndarray,
58
+ target_size: Tuple[int, int] = (224, 224),
59
+ padding_ratio: float = 0.25,
60
+ ) -> Optional[np.ndarray]:
61
+ """
62
+ Detects the largest face and returns a padded RGB crop.
63
+ Returns None if no face detected (caller should decide what to do).
64
+ """
65
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
66
+ faces = self.detector.detectMultiScale(
67
+ gray, scaleFactor=1.1, minNeighbors=4, minSize=(48, 48)
68
+ )
69
+
70
+ h, w = frame.shape[:2]
71
+
72
+ if len(faces) == 0:
73
+ # Fallback: centre-crop as square
74
+ size = min(h, w)
75
+ y0 = (h - size) // 2
76
+ x0 = (w - size) // 2
77
+ crop = frame[y0 : y0 + size, x0 : x0 + size]
78
+ else:
79
+ # Largest face
80
+ fx, fy, fw, fh = max(faces, key=lambda r: r[2] * r[3])
81
+ pad = int(max(fw, fh) * padding_ratio)
82
+ x1 = max(0, fx - pad)
83
+ y1 = max(0, fy - pad)
84
+ x2 = min(w, fx + fw + pad)
85
+ y2 = min(h, fy + fh + pad)
86
+ crop = frame[y1:y2, x1:x2]
87
+
88
+ if crop.size == 0:
89
+ return None
90
+
91
+ crop = cv2.resize(crop, target_size, interpolation=cv2.INTER_AREA)
92
+ crop = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
93
+ return crop
94
+
95
+ # ── Full pipeline ────────────────────────────────────────────────
96
+ def extract(
97
+ self,
98
+ video_path: str,
99
+ max_frames: int = 32,
100
+ sample_fps: float = 2.0,
101
+ ) -> List[dict]:
102
+ """
103
+ Returns list of dicts: {frame_idx, face_rgb (H,W,3 uint8)}
104
+ """
105
+ raw_frames = self.sample_frames(video_path, max_frames, sample_fps)
106
+ results = []
107
+ for fidx, frame in raw_frames:
108
+ face = self.crop_face(frame)
109
+ if face is not None:
110
+ results.append({"frame_idx": fidx, "face": face})
111
+ return results
backend/gradcam.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grad-CAM β€” Gradient-weighted Class Activation Mapping
3
+ Generates heatmap overlays showing which facial regions influenced the prediction.
4
+ Essential for IEEE paper explainability section.
5
+ """
6
+ import cv2
7
+ import numpy as np
8
+ import torch
9
+ import torch.nn.functional as F
10
+ import torchvision.transforms as T
11
+ from PIL import Image
12
+
13
+
14
+ MEAN = [0.485, 0.456, 0.406]
15
+ STD = [0.229, 0.224, 0.225]
16
+
17
+ transform = T.Compose([T.ToTensor(), T.Normalize(mean=MEAN, std=STD)])
18
+
19
+
20
+ class GradCAM:
21
+ """
22
+ Hooks into the last convolutional layer of the spatial branch
23
+ to produce a class activation map.
24
+ """
25
+
26
+ def __init__(self, model):
27
+ self.model = model
28
+ self.device = next(model.parameters()).device
29
+ self._fmaps = None
30
+ self._grads = None
31
+ self._hook_fwd = None
32
+ self._hook_bwd = None
33
+ self._register_hooks()
34
+
35
+ def _register_hooks(self):
36
+ # Target: last conv block of EfficientNet spatial branch
37
+ try:
38
+ if hasattr(self.model.spatial, "backbone"):
39
+ target = self.model.spatial.backbone
40
+ # timm efficientnet: last block
41
+ if hasattr(target, "blocks"):
42
+ target_layer = target.blocks[-1]
43
+ elif hasattr(target, "features"):
44
+ target_layer = target.features[-1]
45
+ else:
46
+ target_layer = list(target.children())[-2]
47
+ else:
48
+ target_layer = list(self.model.spatial.children())[-2]
49
+
50
+ self._hook_fwd = target_layer.register_forward_hook(self._save_fmaps)
51
+ self._hook_bwd = target_layer.register_full_backward_hook(self._save_grads)
52
+ except Exception:
53
+ pass # graceful fallback β€” still saves plain overlay
54
+
55
+ def _save_fmaps(self, module, inp, out):
56
+ self._fmaps = out.detach()
57
+
58
+ def _save_grads(self, module, grad_in, grad_out):
59
+ self._grads = grad_out[0].detach()
60
+
61
+ def _compute_cam(self, face_rgb: np.ndarray) -> np.ndarray:
62
+ """Returns cam as uint8 (224,224,3) or plain red overlay on failure."""
63
+ if self._hook_fwd is None:
64
+ return self._plain_overlay(face_rgb)
65
+
66
+ tensor = transform(face_rgb).unsqueeze(0).to(self.device)
67
+ self.model.zero_grad()
68
+
69
+ logits = self.model(tensor)
70
+ score = torch.sigmoid(logits)
71
+ score.backward()
72
+
73
+ if self._fmaps is None or self._grads is None:
74
+ return self._plain_overlay(face_rgb)
75
+
76
+ weights = self._grads.mean(dim=[2, 3], keepdim=True) # (1, C, 1, 1)
77
+ cam = (weights * self._fmaps).sum(dim=1, keepdim=True) # (1, 1, H, W)
78
+ cam = F.relu(cam)
79
+ cam = cam.squeeze().cpu().numpy()
80
+
81
+ # Normalise
82
+ if cam.max() > 0:
83
+ cam = cam / cam.max()
84
+
85
+ cam_resized = cv2.resize(cam, (224, 224))
86
+ heatmap = cv2.applyColorMap(np.uint8(255 * cam_resized), cv2.COLORMAP_JET)
87
+ heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
88
+
89
+ # Overlay on original face
90
+ overlay = cv2.addWeighted(face_rgb, 0.6, heatmap, 0.4, 0)
91
+ return overlay
92
+
93
+ @staticmethod
94
+ def _plain_overlay(face_rgb: np.ndarray) -> np.ndarray:
95
+ """Fallback: red-tinted overlay when hooks unavailable."""
96
+ tint = np.zeros_like(face_rgb)
97
+ tint[:, :, 0] = 100
98
+ return cv2.addWeighted(face_rgb, 0.8, tint, 0.2, 0)
99
+
100
+ def generate(self, face_rgb: np.ndarray, save_path: str):
101
+ """Compute Grad-CAM and save to disk."""
102
+ try:
103
+ overlay = self._compute_cam(face_rgb)
104
+ except Exception:
105
+ overlay = face_rgb
106
+
107
+ img = Image.fromarray(overlay.astype(np.uint8))
108
+ img.save(save_path, quality=85)
109
+
110
+ def remove_hooks(self):
111
+ if self._hook_fwd:
112
+ self._hook_fwd.remove()
113
+ if self._hook_bwd:
114
+ self._hook_bwd.remove()
backend/image_detector.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ImageDeepfakeDetector β€” EfficientNetV2-S + MTCNN for static image deepfake detection
3
+ Detects facial inconsistencies, texture/pixel artifacts in images (JPG, PNG, WEBP).
4
+ """
5
+ import torch
6
+ import torch.nn as nn
7
+ import numpy as np
8
+ import cv2
9
+ from PIL import Image
10
+ from pathlib import Path
11
+ from typing import Optional, Tuple
12
+ import torchvision.transforms as T
13
+
14
+ try:
15
+ import timm
16
+ TIMM_AVAILABLE = True
17
+ except ImportError:
18
+ TIMM_AVAILABLE = False
19
+
20
+ try:
21
+ # facenet-pytorch works functionally even with version warning vs torch>=2.4
22
+ import warnings
23
+ with warnings.catch_warnings():
24
+ warnings.simplefilter("ignore")
25
+ from facenet_pytorch import MTCNN as FacenetMTCNN
26
+ MTCNN_AVAILABLE = True
27
+ except (ImportError, Exception):
28
+ MTCNN_AVAILABLE = False
29
+
30
+
31
+ # ─────────────────────────────────────────────────────────────────
32
+ # Image pre-processing
33
+ # ─────────────────────────────────────────────────────────────────
34
+ MEAN = [0.485, 0.456, 0.406]
35
+ STD = [0.229, 0.224, 0.225]
36
+
37
+ inference_transform = T.Compose([
38
+ T.Resize((224, 224)),
39
+ T.ToTensor(),
40
+ T.Normalize(mean=MEAN, std=STD),
41
+ ])
42
+
43
+ train_transform = T.Compose([
44
+ T.Resize((256, 256)),
45
+ T.RandomCrop(224),
46
+ T.RandomHorizontalFlip(),
47
+ T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1),
48
+ T.ToTensor(),
49
+ T.Normalize(mean=MEAN, std=STD),
50
+ ])
51
+
52
+
53
+ # ─────────────────────────────────────────────────────────────────
54
+ # EfficientNetV2-S Backbone + Classifier Head
55
+ # ─────────────────────────────────────────────────────────────────
56
+ class EfficientNetV2Detector(nn.Module):
57
+ """
58
+ EfficientNetV2-S backbone for fake image detection.
59
+ ImageNet-pretrained β†’ fine-tuned on deepfake face datasets.
60
+ Detects:
61
+ - Facial boundary blending artifacts
62
+ - Texture inconsistencies (blurriness, over-smoothing)
63
+ - GAN frequency fingerprints in pixel space
64
+ """
65
+ def __init__(self, pretrained: bool = True):
66
+ super().__init__()
67
+
68
+ if TIMM_AVAILABLE:
69
+ # Use tf_efficientnetv2_s which has pretrained weights in all timm versions
70
+ try:
71
+ self.backbone = timm.create_model(
72
+ "tf_efficientnetv2_s",
73
+ pretrained=pretrained,
74
+ num_classes=0,
75
+ global_pool="avg",
76
+ )
77
+ except Exception:
78
+ self.backbone = timm.create_model(
79
+ "efficientnet_b4",
80
+ pretrained=pretrained,
81
+ num_classes=0,
82
+ global_pool="avg",
83
+ )
84
+ self.feat_dim = self.backbone.num_features # 1280
85
+ else:
86
+ # Fallback: EfficientNet-B0 via torchvision
87
+ import torchvision.models as tv
88
+ net = tv.efficientnet_b0(pretrained=pretrained)
89
+ self.backbone = nn.Sequential(
90
+ net.features,
91
+ nn.AdaptiveAvgPool2d(1),
92
+ nn.Flatten(),
93
+ )
94
+ self.feat_dim = 1280
95
+
96
+ self.classifier = nn.Sequential(
97
+ nn.Linear(self.feat_dim, 512),
98
+ nn.BatchNorm1d(512),
99
+ nn.ReLU(inplace=True),
100
+ nn.Dropout(0.5),
101
+ nn.Linear(512, 128),
102
+ nn.ReLU(inplace=True),
103
+ nn.Dropout(0.3),
104
+ nn.Linear(128, 1),
105
+ )
106
+
107
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
108
+ features = self.backbone(x)
109
+ return self.classifier(features) # (B, 1) logits
110
+
111
+ def predict_proba(self, x: torch.Tensor) -> torch.Tensor:
112
+ with torch.no_grad():
113
+ return torch.sigmoid(self.forward(x)).squeeze(1) # (B,)
114
+
115
+ @staticmethod
116
+ def load(path: str, device: str = "cpu") -> "EfficientNetV2Detector":
117
+ model = EfficientNetV2Detector(pretrained=False)
118
+ state = torch.load(path, map_location=device, weights_only=True)
119
+ model.load_state_dict(state)
120
+ model.eval()
121
+ return model
122
+
123
+
124
+ # ─────────────────────────────────────────────────────────────────
125
+ # MTCNN Face Extractor Wrapper
126
+ # ─────────────────────────────────────────────────────────────────
127
+ class MTCNNExtractor:
128
+ """
129
+ MTCNN-based face extractor using facenet-pytorch.
130
+ Localises and aligns faces before passing to the classifier.
131
+ """
132
+ def __init__(self, device: str = "cpu"):
133
+ if MTCNN_AVAILABLE:
134
+ self.mtcnn = FacenetMTCNN(
135
+ image_size=224,
136
+ margin=30,
137
+ keep_all=False,
138
+ post_process=False,
139
+ device=device,
140
+ )
141
+ else:
142
+ self.mtcnn = None
143
+ print("[MTCNN] facenet-pytorch not installed β€” using full image fallback.")
144
+
145
+ def extract(self, image_rgb: np.ndarray) -> Tuple[np.ndarray, bool]:
146
+ """
147
+ Returns:
148
+ face_rgb : (H, W, 3) uint8 numpy array β€” face crop or full image
149
+ detected : bool β€” True if MTCNN found a face
150
+ """
151
+ if self.mtcnn is None:
152
+ return image_rgb, False
153
+
154
+ pil_img = Image.fromarray(image_rgb)
155
+ try:
156
+ face_tensor = self.mtcnn(pil_img) # (C, H, W) float in [0, 255]
157
+ if face_tensor is not None:
158
+ face_np = face_tensor.permute(1, 2, 0).byte().numpy()
159
+ return face_np, True
160
+ except Exception:
161
+ pass
162
+ return image_rgb, False
163
+
164
+
165
+ # ─────────────────────────────────────────────────────────────────
166
+ # Full Image Detection Pipeline
167
+ # ─────────────────────────────────────────────────────────────────
168
+ class ImageDeepfakeDetector:
169
+ """
170
+ End-to-end pipeline:
171
+ 1. Load image from disk
172
+ 2. Detect & crop face with MTCNN
173
+ 3. Classify with EfficientNetV2-S
174
+ 4. Return verdict + confidence
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ model_path: Optional[str] = None,
180
+ device: Optional[str] = None,
181
+ threshold: float = 0.5,
182
+ ):
183
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
184
+ self.threshold = threshold
185
+
186
+ # EfficientNetV2 model
187
+ self.model = EfficientNetV2Detector(pretrained=(model_path is None))
188
+ if model_path and Path(model_path).exists():
189
+ state = torch.load(model_path, map_location=self.device, weights_only=True)
190
+ self.model.load_state_dict(state)
191
+ print(f"[ImageDetector] Loaded weights from {model_path}")
192
+ else:
193
+ print("[ImageDetector] Using ImageNet pretrained weights (demo mode).")
194
+ self.model.to(self.device).eval()
195
+
196
+ # MTCNN face extractor
197
+ self.extractor = MTCNNExtractor(device=self.device)
198
+
199
+ def analyze(self, image_path: str) -> dict:
200
+ """Run deepfake detection on a single image file."""
201
+ # ── Load ──────────────────────────────────────────────
202
+ img_bgr = cv2.imread(str(image_path))
203
+ if img_bgr is None:
204
+ return {
205
+ "verdict": "ERROR",
206
+ "error": "Cannot read image file.",
207
+ "confidence": 0.0,
208
+ "fake_prob": 0.0,
209
+ "face_detected": False,
210
+ "modality": "image",
211
+ }
212
+
213
+ img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
214
+ h, w = img_rgb.shape[:2]
215
+
216
+ # ── Face extraction ───────────────────────────────────
217
+ face_rgb, face_detected = self.extractor.extract(img_rgb)
218
+
219
+ # ── Pre-process & infer ───────────────────────────────
220
+ pil_img = Image.fromarray(face_rgb)
221
+ tensor = inference_transform(pil_img).unsqueeze(0).to(self.device)
222
+
223
+ with torch.no_grad():
224
+ prob = self.model.predict_proba(tensor).item()
225
+
226
+ verdict = "FAKE" if prob >= self.threshold else "REAL"
227
+
228
+ return {
229
+ "verdict": verdict,
230
+ "confidence": round(prob, 4),
231
+ "fake_prob": round(prob, 4),
232
+ "faces_detected": 1 if face_detected else 0,
233
+ "image_size": f"{w}Γ—{h}",
234
+ "modality": "image",
235
+ }
236
+
237
+
238
+ # ── Quick sanity check ────────────────────────────────────────────
239
+ if __name__ == "__main__":
240
+ model = EfficientNetV2Detector(pretrained=False)
241
+ dummy = torch.randn(4, 3, 224, 224)
242
+ out = model.predict_proba(dummy)
243
+ total = sum(p.numel() for p in model.parameters())
244
+ print(f"Output shape : {out.shape}")
245
+ print(f"Total params : {total:,}")
246
+ print("EfficientNetV2Detector OK βœ“")
backend/metrics.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Metrics β€” AUC, Accuracy, EER computation for IEEE evaluation
3
+ """
4
+ import numpy as np
5
+ from sklearn.metrics import roc_auc_score, roc_curve, accuracy_score
6
+ from typing import List, Tuple
7
+
8
+
9
+ def compute_auc(y_true: List[int], y_scores: List[float]) -> float:
10
+ """AUC-ROC score. y_true: 0=real, 1=fake."""
11
+ return float(roc_auc_score(y_true, y_scores))
12
+
13
+
14
+ def compute_accuracy(y_true: List[int], y_scores: List[float], threshold: float = 0.5) -> float:
15
+ preds = [1 if s >= threshold else 0 for s in y_scores]
16
+ return float(accuracy_score(y_true, preds))
17
+
18
+
19
+ def compute_eer(y_true: List[int], y_scores: List[float]) -> float:
20
+ """
21
+ Equal Error Rate β€” threshold where FPR == FNR.
22
+ Lower is better.
23
+ """
24
+ fpr, tpr, thresholds = roc_curve(y_true, y_scores, pos_label=1)
25
+ fnr = 1.0 - tpr
26
+ # Find the threshold where |FPR - FNR| is minimised
27
+ idx = np.nanargmin(np.abs(fpr - fnr))
28
+ eer = float((fpr[idx] + fnr[idx]) / 2.0)
29
+ return eer
30
+
31
+
32
+ def compute_all(y_true: List[int], y_scores: List[float]) -> dict:
33
+ """Returns dict with all metrics β€” suitable for IEEE results table."""
34
+ auc = compute_auc(y_true, y_scores)
35
+ acc = compute_accuracy(y_true, y_scores)
36
+ eer = compute_eer(y_true, y_scores)
37
+ return {
38
+ "auc": round(auc * 100, 2),
39
+ "accuracy": round(acc * 100, 2),
40
+ "eer": round(eer * 100, 2),
41
+ }
42
+
43
+
44
+ if __name__ == "__main__":
45
+ # Quick smoke test
46
+ rng = np.random.default_rng(42)
47
+ labels = [0] * 50 + [1] * 50
48
+ scores = list(rng.uniform(0, 0.4, 50)) + list(rng.uniform(0.6, 1.0, 50))
49
+ print(compute_all(labels, scores))
backend/model.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HybridDeepfakeDetector β€” Dual-branch Spatial + Frequency CNN
3
+ Architecture for IEEE Research Paper on Deepfake Video Detection
4
+ """
5
+ import torch
6
+ import torch.nn as nn
7
+ import torch.nn.functional as F
8
+
9
+ try:
10
+ import timm
11
+ TIMM_AVAILABLE = True
12
+ except ImportError:
13
+ TIMM_AVAILABLE = False
14
+
15
+ import torchvision.models as tv_models
16
+
17
+
18
+ # ─────────────────────────────────────────────
19
+ # Frequency Analysis Branch
20
+ # ─────────────────────────────────────────────
21
+ class FrequencyBranch(nn.Module):
22
+ """
23
+ Extracts GAN fingerprint artifacts from the frequency domain.
24
+ GAN generators leave periodic patterns in the DCT/FFT spectrum
25
+ that are invisible to the human eye but detectable by CNNs.
26
+ """
27
+ def __init__(self, out_dim: int = 128):
28
+ super().__init__()
29
+ self.conv_layers = nn.Sequential(
30
+ nn.Conv2d(1, 32, kernel_size=3, padding=1, bias=False),
31
+ nn.BatchNorm2d(32),
32
+ nn.ReLU(inplace=True),
33
+ nn.MaxPool2d(2), # 112x112
34
+
35
+ nn.Conv2d(32, 64, kernel_size=3, padding=1, bias=False),
36
+ nn.BatchNorm2d(64),
37
+ nn.ReLU(inplace=True),
38
+ nn.MaxPool2d(2), # 56x56
39
+
40
+ nn.Conv2d(64, 128, kernel_size=3, padding=1, bias=False),
41
+ nn.BatchNorm2d(128),
42
+ nn.ReLU(inplace=True),
43
+ nn.MaxPool2d(2), # 28x28
44
+
45
+ nn.Conv2d(128, out_dim, kernel_size=3, padding=1, bias=False),
46
+ nn.BatchNorm2d(out_dim),
47
+ nn.ReLU(inplace=True),
48
+ nn.AdaptiveAvgPool2d((1, 1)), # 1x1
49
+ )
50
+
51
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
52
+ # Convert to grayscale: (B, 1, H, W)
53
+ gray = 0.299 * x[:, 0:1] + 0.587 * x[:, 1:2] + 0.114 * x[:, 2:3]
54
+
55
+ # 2D FFT β†’ log-magnitude spectrum
56
+ fft = torch.fft.fft2(gray)
57
+ magnitude = torch.abs(fft)
58
+ magnitude = torch.log(magnitude + 1e-8)
59
+
60
+ # Normalize per sample
61
+ b = magnitude.shape[0]
62
+ m = magnitude.view(b, -1)
63
+ mn = m.mean(dim=1, keepdim=True).view(b, 1, 1, 1)
64
+ std = m.std(dim=1, keepdim=True).view(b, 1, 1, 1) + 1e-8
65
+ magnitude = (magnitude - mn) / std
66
+
67
+ return self.conv_layers(magnitude).flatten(1) # (B, out_dim)
68
+
69
+
70
+ # ─────────────────────────────────────────────
71
+ # Spatial Branch (EfficientNet-B0 backbone)
72
+ # ─────────────────────────────────────────────
73
+ class SpatialBranch(nn.Module):
74
+ def __init__(self, pretrained: bool = True):
75
+ super().__init__()
76
+ if TIMM_AVAILABLE:
77
+ self.backbone = timm.create_model(
78
+ "efficientnet_b0",
79
+ pretrained=pretrained,
80
+ num_classes=0, # Remove classifier head
81
+ global_pool="avg",
82
+ )
83
+ self.out_dim = self.backbone.num_features # 1280
84
+ else:
85
+ # Fallback: MobileNetV3-Small from torchvision
86
+ backbone = tv_models.mobilenet_v3_small(pretrained=pretrained)
87
+ self.backbone = nn.Sequential(*list(backbone.children())[:-2],
88
+ nn.AdaptiveAvgPool2d(1), nn.Flatten())
89
+ self.out_dim = 576
90
+
91
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
92
+ return self.backbone(x) # (B, out_dim)
93
+
94
+
95
+ # ─────────────────────────────────────────────
96
+ # Fusion Classifier
97
+ # ─────────────────────────────────────────────
98
+ class FusionClassifier(nn.Module):
99
+ def __init__(self, spatial_dim: int, freq_dim: int):
100
+ super().__init__()
101
+ combined = spatial_dim + freq_dim
102
+ self.fc = nn.Sequential(
103
+ nn.Linear(combined, 512),
104
+ nn.BatchNorm1d(512),
105
+ nn.ReLU(inplace=True),
106
+ nn.Dropout(0.5),
107
+ nn.Linear(512, 128),
108
+ nn.ReLU(inplace=True),
109
+ nn.Dropout(0.3),
110
+ nn.Linear(128, 1),
111
+ )
112
+
113
+ def forward(self, spatial_feat, freq_feat):
114
+ x = torch.cat([spatial_feat, freq_feat], dim=1)
115
+ return self.fc(x) # (B, 1) β€” raw logits
116
+
117
+
118
+ # ─────────────────────────────────────────────
119
+ # HybridDeepfakeDetector (Main Model)
120
+ # ─────────────────────────────────────────────
121
+ class HybridDeepfakeDetector(nn.Module):
122
+ """
123
+ Novel dual-branch architecture combining:
124
+ - Spatial branch (EfficientNet-B0): captures texture/semantic artifacts
125
+ - Frequency branch (FFT-CNN): captures GAN frequency fingerprints
126
+ Fused via FC layers for binary Real/Fake classification.
127
+ """
128
+ def __init__(self, pretrained: bool = True, freq_dim: int = 128):
129
+ super().__init__()
130
+ self.spatial = SpatialBranch(pretrained=pretrained)
131
+ self.freq = FrequencyBranch(out_dim=freq_dim)
132
+ self.fusion = FusionClassifier(self.spatial.out_dim, freq_dim)
133
+
134
+ def forward(self, x: torch.Tensor):
135
+ s = self.spatial(x)
136
+ f = self.freq(x)
137
+ return self.fusion(s, f) # (B, 1) logits
138
+
139
+ def predict_proba(self, x: torch.Tensor) -> torch.Tensor:
140
+ """Returns fake probability in [0, 1]."""
141
+ with torch.no_grad():
142
+ logits = self.forward(x)
143
+ return torch.sigmoid(logits).squeeze(1) # (B,)
144
+
145
+ @staticmethod
146
+ def load(path: str, device: str = "cpu") -> "HybridDeepfakeDetector":
147
+ model = HybridDeepfakeDetector(pretrained=False)
148
+ state = torch.load(path, map_location=device)
149
+ model.load_state_dict(state)
150
+ model.eval()
151
+ return model
152
+
153
+
154
+ # ─────────────────────────────────────────────
155
+ # Quick sanity check
156
+ # ─────────────────────────────────────────────
157
+ if __name__ == "__main__":
158
+ model = HybridDeepfakeDetector(pretrained=False)
159
+ dummy = torch.randn(4, 3, 224, 224)
160
+ out = model.predict_proba(dummy)
161
+ total = sum(p.numel() for p in model.parameters())
162
+ print(f"Output shape : {out.shape}")
163
+ print(f"Total params : {total:,}")
164
+ print("Model OK βœ“")
backend/models/deepfake_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9071a0de0a2dfc7f7e5b1954ad4b457602b01c72132ab85c60933db1f6b1fb6b
3
+ size 84509270
backend/models/image_model_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c93144e2c6245674ea2fcd4f444dfdc0bd44635797db13d3c38cf45f2d8928f3
3
+ size 84520278
backend/multimodal_detector.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MultiModalDetector β€” Unified orchestrator for DeepShield
3
+ Handles Video (visual + audio fusion), Image, and Audio detection.
4
+ """
5
+ import os, uuid, time, subprocess
6
+ import numpy as np
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ import torch
10
+
11
+ from detector import DeepfakeDetector # visual video branch
12
+ from audio_detector import AudioDeepfakeDetector
13
+ from image_detector import ImageDeepfakeDetector
14
+
15
+
16
+ # ─────────────────────────────────────────────────────────────────
17
+ # Utility: Extract audio from video using ffmpeg
18
+ # ─────────────────────────────────────────────────────────────────
19
+ def extract_audio_from_video(video_path: str, output_wav: str) -> bool:
20
+ """
21
+ Extracts the audio track from a video file and saves as 16 kHz mono WAV.
22
+ Returns True on success, False if the video has no audio or ffmpeg fails.
23
+ """
24
+ try:
25
+ cmd = [
26
+ "ffmpeg", "-y",
27
+ "-i", video_path,
28
+ "-vn", # No video
29
+ "-ar", "16000", # Resample to 16 kHz
30
+ "-ac", "1", # Mono
31
+ "-f", "wav",
32
+ output_wav,
33
+ ]
34
+ result = subprocess.run(cmd, capture_output=True, timeout=120)
35
+ return result.returncode == 0 and Path(output_wav).exists() and Path(output_wav).stat().st_size > 0
36
+ except Exception as e:
37
+ print(f"[AudioExtract] ffmpeg failed: {e}")
38
+ return False
39
+
40
+
41
+ # ─────────────────────────────────────────────────────────────────
42
+ # MultiModalDetector
43
+ # ─────────────────────────────────────────────────────────────────
44
+ class MultiModalDetector:
45
+ """
46
+ Unified deepfake detector for three modalities:
47
+ - Video : EfficientNet (visual) + Wav2Vec2 (audio) β†’ fused score
48
+ - Image : EfficientNetV2-S + MTCNN face detection
49
+ - Audio : Wav2Vec2-base + attention-pooling classifier
50
+
51
+ Fusion strategy for video:
52
+ fused_score = 0.60 Γ— visual_score + 0.40 Γ— audio_score
53
+ (if no audio track, fused_score = visual_score)
54
+ """
55
+
56
+ VISUAL_WEIGHT = 0.60
57
+ AUDIO_WEIGHT = 0.40
58
+
59
+ def __init__(
60
+ self,
61
+ video_model_path: Optional[str] = None,
62
+ image_model_path: Optional[str] = None,
63
+ audio_model_path: Optional[str] = None,
64
+ device: Optional[str] = None,
65
+ threshold: float = 0.5,
66
+ ):
67
+ self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
68
+ self.threshold = threshold
69
+
70
+ print(f"[DeepShield] Initializing MultiModalDetector on {self.device}")
71
+
72
+ # ── Video (visual) detector ───────────────────────────
73
+ self.video_detector = DeepfakeDetector(
74
+ model_path=video_model_path,
75
+ device=self.device,
76
+ max_frames=32,
77
+ threshold=threshold,
78
+ )
79
+
80
+ # ── Image detector ────────────────────────────────────
81
+ self.image_detector = ImageDeepfakeDetector(
82
+ model_path=image_model_path,
83
+ device=self.device,
84
+ threshold=threshold,
85
+ )
86
+
87
+ # ── Audio detector ────────────────────────────────────
88
+ self.audio_available = False
89
+ self.audio_model = None
90
+ try:
91
+ from audio_detector import AudioDeepfakeDetector, WAV2VEC_AVAILABLE
92
+ if WAV2VEC_AVAILABLE:
93
+ self.audio_model = AudioDeepfakeDetector(
94
+ pretrained=True,
95
+ freeze_base=True,
96
+ )
97
+ if audio_model_path and Path(audio_model_path).exists():
98
+ state = torch.load(
99
+ audio_model_path, map_location=self.device, weights_only=True
100
+ )
101
+ self.audio_model.load_state_dict(state)
102
+ print(f"[AudioDetector] Loaded weights from {audio_model_path}")
103
+ else:
104
+ print("[AudioDetector] Using pretrained Wav2Vec2 features (demo mode).")
105
+ self.audio_model.to(self.device).eval()
106
+ self.audio_available = True
107
+ else:
108
+ print("[AudioDetector] transformers not installed β€” audio branch disabled.")
109
+ except Exception as e:
110
+ print(f"[AudioDetector] Init failed: {e}")
111
+
112
+ # ─��� VIDEO ─────────────────────────────────────────────────────
113
+ def analyze_video(self, video_path: str, session_id: Optional[str] = None) -> dict:
114
+ """
115
+ Full multimodal video analysis:
116
+ 1. Visual branch: face extraction β†’ EfficientNet inference β†’ Grad-CAM
117
+ 2. Audio branch : ffmpeg extract β†’ Wav2Vec2 inference
118
+ 3. Score fusion : 60/40 weighted average
119
+ """
120
+ t0 = time.time()
121
+ session_id = session_id or str(uuid.uuid4())
122
+
123
+ # 1. Visual analysis (existing pipeline)
124
+ visual_result = self.video_detector.analyze(video_path, session_id=session_id)
125
+ visual_score = visual_result.get("fake_prob", 0.5)
126
+
127
+ # 2. Audio analysis
128
+ audio_info = {"audio_available": False, "audio_fake_prob": None}
129
+ if self.audio_available and self.audio_model is not None:
130
+ audio_wav = str(Path("uploads") / session_id / "audio.wav")
131
+ has_audio = extract_audio_from_video(video_path, audio_wav)
132
+ if has_audio:
133
+ try:
134
+ waveform = AudioDeepfakeDetector.load_audio(audio_wav)
135
+ audio_prob = self.audio_model.predict_proba(waveform, self.device)
136
+ audio_info = {
137
+ "audio_available": True,
138
+ "audio_fake_prob": round(audio_prob, 4),
139
+ "audio_verdict": "FAKE" if audio_prob >= self.threshold else "REAL",
140
+ "audio_confidence": round(audio_prob * 100, 2),
141
+ }
142
+ except Exception as e:
143
+ audio_info = {"audio_available": False, "audio_error": str(e)}
144
+ else:
145
+ audio_info = {"audio_available": False, "audio_error": "No audio track found."}
146
+
147
+ # 3. Fusion
148
+ if audio_info.get("audio_available") and audio_info.get("audio_fake_prob") is not None:
149
+ fused_score = (
150
+ self.VISUAL_WEIGHT * visual_score
151
+ + self.AUDIO_WEIGHT * audio_info["audio_fake_prob"]
152
+ )
153
+ else:
154
+ fused_score = visual_score
155
+
156
+ fused_verdict = "FAKE" if fused_score >= self.threshold else "REAL"
157
+
158
+ return {
159
+ **visual_result,
160
+ **audio_info,
161
+ "visual_fake_prob": round(visual_score, 4),
162
+ "visual_confidence": round(visual_score * 100, 2),
163
+ "visual_verdict": "FAKE" if visual_score >= self.threshold else "REAL",
164
+ "fused_fake_prob": round(fused_score, 4),
165
+ "fused_confidence": round(fused_score, 4),
166
+ "fused_verdict": fused_verdict,
167
+ "verdict": fused_verdict,
168
+ "confidence": round(fused_score, 4),
169
+ "fake_prob": round(fused_score, 4),
170
+ "elapsed_sec": round(time.time() - t0, 2),
171
+ "modality": "video",
172
+ }
173
+
174
+ # ── IMAGE ─────────────────────────────────────────────────────
175
+ def analyze_image(self, image_path: str) -> dict:
176
+ """EfficientNetV2-S + MTCNN image pipeline."""
177
+ return self.image_detector.analyze(image_path)
178
+
179
+ # ── AUDIO ─────────────────────────────────────────────────────
180
+ def analyze_audio(self, audio_path: str) -> dict:
181
+ """Wav2Vec2 audio-only pipeline."""
182
+ if not self.audio_available or self.audio_model is None:
183
+ return {
184
+ "verdict": "ERROR",
185
+ "error": "Audio detection unavailable. Install: transformers, librosa.",
186
+ "confidence": 0.0,
187
+ "fake_prob": 0.0,
188
+ "modality": "audio",
189
+ }
190
+ try:
191
+ waveform = AudioDeepfakeDetector.load_audio(audio_path)
192
+ prob = self.audio_model.predict_proba(waveform, self.device)
193
+ return {
194
+ "verdict": "FAKE" if prob >= self.threshold else "REAL",
195
+ "confidence": round(prob, 4),
196
+ "fake_prob": round(prob, 4),
197
+ "modality": "audio",
198
+ }
199
+ except Exception as e:
200
+ return {
201
+ "verdict": "ERROR",
202
+ "error": str(e),
203
+ "confidence": 0.0,
204
+ "fake_prob": 0.0,
205
+ "modality": "audio",
206
+ }
backend/preprocess_celebdf.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ preprocess_celebdf.py β€” Extract face crops from Celeb-DF v2 for training
3
+ Reads from:
4
+ Celeb-DF/Celeb-real/ β†’ label 0 (REAL)
5
+ Celeb-DF/YouTube-real/ β†’ label 0 (REAL)
6
+ Celeb-DF/Celeb-synthesis/ β†’ label 1 (FAKE)
7
+
8
+ Outputs to:
9
+ data/train/real/ and data/train/fake/
10
+ data/val/real/ and data/val/fake/
11
+
12
+ Usage:
13
+ python preprocess_celebdf.py --dataset_dir ../Celeb-DF --out_dir ../data
14
+ --frames_per_video 15 --val_split 0.15
15
+ """
16
+ import argparse
17
+ import os
18
+ import random
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ import cv2
23
+
24
+ # ── Face detector (OpenCV, no extra deps) ────────────────────────────
25
+ CASCADE = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
26
+ face_det = cv2.CascadeClassifier(CASCADE)
27
+
28
+
29
+ def extract_faces(video_path: str, n_frames: int = 15, size: int = 224):
30
+ """Sample n_frames evenly, detect face, return list of BGR crops."""
31
+ cap = cv2.VideoCapture(video_path)
32
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
33
+ if total <= 0:
34
+ cap.release()
35
+ return []
36
+
37
+ indices = sorted(random.sample(range(total), min(n_frames, total)))
38
+ crops = []
39
+
40
+ for idx in indices:
41
+ cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
42
+ ret, frame = cap.read()
43
+ if not ret:
44
+ continue
45
+
46
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
47
+ faces = face_det.detectMultiScale(gray, 1.1, 4, minSize=(48, 48))
48
+ h, w = frame.shape[:2]
49
+
50
+ if len(faces) == 0:
51
+ # centre crop fallback
52
+ s = min(h, w)
53
+ y0, x0 = (h - s) // 2, (w - s) // 2
54
+ crop = frame[y0:y0+s, x0:x0+s]
55
+ else:
56
+ fx, fy, fw, fh = max(faces, key=lambda r: r[2]*r[3])
57
+ pad = int(max(fw, fh) * 0.2)
58
+ x1, y1 = max(0, fx-pad), max(0, fy-pad)
59
+ x2, y2 = min(w, fx+fw+pad), min(h, fy+fh+pad)
60
+ crop = frame[y1:y2, x1:x2]
61
+
62
+ if crop.size == 0:
63
+ continue
64
+ crops.append(cv2.resize(crop, (size, size)))
65
+
66
+ cap.release()
67
+ return crops
68
+
69
+
70
+ def save_crops(crops, out_dir: Path, stem: str):
71
+ saved = 0
72
+ for i, crop in enumerate(crops):
73
+ p = out_dir / f"{stem}_{i:03d}.jpg"
74
+ cv2.imwrite(str(p), crop, [cv2.IMWRITE_JPEG_QUALITY, 90])
75
+ saved += 1
76
+ return saved
77
+
78
+
79
+ def collect_videos(folders):
80
+ videos = []
81
+ for folder in folders:
82
+ p = Path(folder)
83
+ if p.exists():
84
+ videos += list(p.glob("*.mp4")) + list(p.glob("*.avi"))
85
+ return videos
86
+
87
+
88
+ def main():
89
+ parser = argparse.ArgumentParser()
90
+ parser.add_argument("--dataset_dir", default="../datasets/video",
91
+ help="Path to video dataset root (contains real/celeb_real, real/youtube_real, fake/celeb_synthesis)")
92
+ parser.add_argument("--out_dir", default="../datasets/video_crops",
93
+ help="Output directory for face crops")
94
+ parser.add_argument("--frames_per_video", type=int, default=15,
95
+ help="Face crops to extract per video")
96
+ parser.add_argument("--val_split", type=float, default=0.15,
97
+ help="Fraction of videos held out for validation")
98
+ parser.add_argument("--seed", type=int, default=42)
99
+ args = parser.parse_args()
100
+
101
+ random.seed(args.seed)
102
+ ds = Path(args.dataset_dir)
103
+
104
+ # ── Source folders ──────────────────────────────────────────────
105
+ real_folders = [ds / "real" / "celeb_real", ds / "real" / "youtube_real"]
106
+ fake_folders = [ds / "fake" / "celeb_synthesis"]
107
+
108
+ real_videos = collect_videos(real_folders)
109
+ fake_videos = collect_videos(fake_folders)
110
+
111
+ print(f"Found {len(real_videos)} REAL videos | {len(fake_videos)} FAKE videos")
112
+
113
+ def split(vids):
114
+ random.shuffle(vids)
115
+ n_val = max(1, int(len(vids) * args.val_split))
116
+ return vids[n_val:], vids[:n_val] # train, val
117
+
118
+ real_train, real_val = split(real_videos)
119
+ fake_train, fake_val = split(fake_videos)
120
+
121
+ out = Path(args.out_dir)
122
+ splits = {
123
+ ("train", "real"): real_train,
124
+ ("train", "fake"): fake_train,
125
+ ("val", "real"): real_val,
126
+ ("val", "fake"): fake_val,
127
+ }
128
+
129
+ # ── Extract ─────────────────────────────────────────────────────
130
+ total_saved = 0
131
+ for (split_name, label), vids in splits.items():
132
+ out_dir = out / split_name / label
133
+ out_dir.mkdir(parents=True, exist_ok=True)
134
+ print(f"\n[{split_name}/{label}] Processing {len(vids)} videos β†’ {out_dir}")
135
+
136
+ for i, vpath in enumerate(vids):
137
+ crops = extract_faces(str(vpath), n_frames=args.frames_per_video)
138
+ stem = vpath.stem
139
+ saved = save_crops(crops, out_dir, stem)
140
+ total_saved += saved
141
+ print(f" [{i+1:04d}/{len(vids)}] {vpath.name} β†’ {saved} crops", end="\r")
142
+
143
+ print() # newline after carriage-returns
144
+
145
+ print(f"\nβœ… Done! Total face crops saved: {total_saved}")
146
+ print(f" Output: {out.resolve()}")
147
+
148
+ # Summary
149
+ for (s, l), _ in splits.items():
150
+ d = out / s / l
151
+ n = len(list(d.glob("*.jpg")))
152
+ print(f" {s}/{l}: {n} images")
153
+
154
+
155
+ if __name__ == "__main__":
156
+ main()
backend/render.yaml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: deepshield-api
4
+ env: python
5
+ buildCommand: "pip install -r requirements.txt"
6
+ startCommand: "gunicorn app:app"
7
+ envVars:
8
+ - key: PYTHON_VERSION
9
+ value: 3.10.0
10
+ - key: MONGO_URI
11
+ sync: false
12
+ - key: FLASK_ENV
13
+ value: production
backend/requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ flask>=3.0.0
2
+ flask-cors>=4.0.0
3
+ pymongo>=4.6.0
4
+ dnspython>=2.6.0
5
+ --extra-index-url https://download.pytorch.org/whl/cpu
6
+ torch>=2.0.0
7
+ torchvision>=0.15.0
8
+ torchaudio>=2.0.0
9
+ timm>=0.9.16
10
+ facenet-pytorch>=2.5.2
11
+ transformers>=4.30.0
12
+ librosa>=0.10.0
13
+ soundfile>=0.12.1
14
+ opencv-python-headless>=4.9.0
15
+ numpy>=1.24.0
16
+ Pillow>=10.3.0
17
+ python-dotenv>=1.0.1
18
+ gunicorn>=21.2.0
backend/runtime.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ python-3.11.0
backend/train.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training Script β€” IEEE Research Experiments
3
+ Trains HybridDeepfakeDetector on FaceForensics++ or Celeb-DF v2
4
+
5
+ Usage:
6
+ python train.py --data_dir /path/to/dataset --epochs 30 --batch_size 32
7
+ """
8
+ import argparse, time, os
9
+ try:
10
+ from tqdm import tqdm
11
+ except ImportError:
12
+ tqdm = None
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import torch
17
+ import torch.nn as nn
18
+ import torch.optim as optim
19
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
20
+ import torchvision.transforms as T
21
+ from PIL import Image
22
+ from sklearn.metrics import roc_auc_score
23
+
24
+ from model import HybridDeepfakeDetector
25
+
26
+ # ── Dataset ─────────────────────────────────────────────────────────
27
+ class DeepfakeDataset(Dataset):
28
+ """
29
+ Expects directory layout:
30
+ data_dir/
31
+ real/ ← real face crops (PNG/JPG)
32
+ fake/ ← fake face crops
33
+ """
34
+ TRAIN_TF = T.Compose([
35
+ T.Resize((224, 224)),
36
+ T.RandomHorizontalFlip(),
37
+ T.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1),
38
+ T.RandomRotation(10),
39
+ T.GaussianBlur(kernel_size=3, sigma=(0.1, 1.5)),
40
+ T.ToTensor(),
41
+ T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
42
+ ])
43
+ VAL_TF = T.Compose([
44
+ T.Resize((224, 224)),
45
+ T.ToTensor(),
46
+ T.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
47
+ ])
48
+
49
+ def __init__(self, data_dir: str, split: str = "train"):
50
+ self.tf = self.TRAIN_TF if split == "train" else self.VAL_TF
51
+ self.samples = []
52
+ for label, folder in [(0, "real"), (1, "fake")]:
53
+ p = Path(data_dir) / folder
54
+ if p.exists():
55
+ for img in p.rglob("*.jpg"):
56
+ self.samples.append((str(img), label))
57
+ for img in p.rglob("*.png"):
58
+ self.samples.append((str(img), label))
59
+
60
+ if not self.samples:
61
+ raise ValueError(f"No images found in {data_dir}. "
62
+ f"Ensure real/ and fake/ sub-directories exist under {data_dir}.")
63
+
64
+ def __len__(self):
65
+ return len(self.samples)
66
+
67
+ def __getitem__(self, idx):
68
+ path, label = self.samples[idx]
69
+ img = Image.open(path).convert("RGB")
70
+ return self.tf(img), torch.tensor(label, dtype=torch.float32)
71
+
72
+
73
+ def get_sampler(dataset: DeepfakeDataset) -> WeightedRandomSampler:
74
+ labels = [s[1] for s in dataset.samples]
75
+ counts = [labels.count(0), labels.count(1)]
76
+ weights = [1.0 / counts[l] for l in labels]
77
+ return WeightedRandomSampler(weights, len(weights))
78
+
79
+
80
+ # ── Training loop ───────────────────────────────────────────────────
81
+ def train_epoch(model, loader, optimizer, criterion, device, scaler):
82
+ model.train()
83
+ total_loss, n = 0.0, 0
84
+ total_batches = len(loader)
85
+
86
+ iterator = tqdm(loader, desc=" Training", unit="batch",
87
+ bar_format="{l_bar}{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}] loss={postfix}") \
88
+ if tqdm else loader
89
+
90
+ for batch_idx, (imgs, labels) in enumerate(iterator):
91
+ imgs, labels = imgs.to(device), labels.to(device)
92
+ optimizer.zero_grad()
93
+ with torch.amp.autocast(device_type=device, enabled=scaler is not None):
94
+ logits = model(imgs).squeeze(1)
95
+ loss = criterion(logits, labels)
96
+ if scaler:
97
+ scaler.scale(loss).backward()
98
+ scaler.step(optimizer)
99
+ scaler.update()
100
+ else:
101
+ loss.backward()
102
+ optimizer.step()
103
+ total_loss += loss.item() * imgs.size(0)
104
+ n += imgs.size(0)
105
+ # Update tqdm with current avg loss
106
+ if tqdm and hasattr(iterator, 'set_postfix_str'):
107
+ iterator.set_postfix_str(f"{total_loss/n:.4f}")
108
+ elif not tqdm and (batch_idx % 50 == 0 or batch_idx == total_batches - 1):
109
+ pct = (batch_idx + 1) / total_batches * 100
110
+ print(f" Batch {batch_idx+1}/{total_batches} ({pct:.0f}%) avg_loss={total_loss/n:.4f}",
111
+ flush=True)
112
+ return total_loss / n
113
+
114
+
115
+ @torch.no_grad()
116
+ def evaluate(model, loader, device):
117
+ model.eval()
118
+ all_probs, all_labels = [], []
119
+ for imgs, labels in loader:
120
+ imgs = imgs.to(device)
121
+ probs = torch.sigmoid(model(imgs).squeeze(1)).cpu().numpy()
122
+ all_probs.extend(probs)
123
+ all_labels.extend(labels.numpy())
124
+ auc = roc_auc_score(all_labels, all_probs)
125
+ preds = [1 if p >= 0.5 else 0 for p in all_probs]
126
+ acc = np.mean(np.array(preds) == np.array(all_labels))
127
+ return {"auc": auc, "acc": acc}
128
+
129
+
130
+ # ── Main ─────────────────────────────────────────────────────────────
131
+ def main():
132
+ parser = argparse.ArgumentParser()
133
+ parser.add_argument("--data_dir", default="../datasets/video_crops")
134
+ parser.add_argument("--epochs", type=int, default=30)
135
+ parser.add_argument("--batch_size", type=int, default=32)
136
+ parser.add_argument("--lr", type=float, default=1e-4)
137
+ parser.add_argument("--save_dir", default="models")
138
+ parser.add_argument("--device", default=None)
139
+ args = parser.parse_args()
140
+
141
+ device = args.device or ("cuda" if torch.cuda.is_available() else "cpu")
142
+ print(f"[Train] Device: {device}")
143
+
144
+ # Datasets
145
+ # data_dir should be the base data/ folder; train/val subdirs are appended internally
146
+ train_split_dir = str(Path(args.data_dir) / "train")
147
+ val_split_dir = str(Path(args.data_dir) / "val")
148
+ train_ds = DeepfakeDataset(train_split_dir, "train")
149
+ val_ds = DeepfakeDataset(val_split_dir, "val")
150
+ sampler = get_sampler(train_ds)
151
+
152
+ # num_workers=0 avoids Windows multiprocessing issues
153
+ train_dl = DataLoader(train_ds, batch_size=args.batch_size, sampler=sampler, num_workers=0, pin_memory=False, drop_last=True)
154
+ val_dl = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, num_workers=0)
155
+
156
+ print(f"[Train] Train samples: {len(train_ds)} | Val samples: {len(val_ds)}")
157
+
158
+ # Model
159
+ model = HybridDeepfakeDetector(pretrained=True).to(device)
160
+
161
+ # Loss with label smoothing
162
+ criterion = nn.BCEWithLogitsLoss(label_smoothing=0.1) if hasattr(
163
+ nn.BCEWithLogitsLoss, "label_smoothing"
164
+ ) else nn.BCEWithLogitsLoss()
165
+
166
+ optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4)
167
+ scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.epochs)
168
+ scaler = torch.cuda.amp.GradScaler() if device == "cuda" else None
169
+
170
+ os.makedirs(args.save_dir, exist_ok=True)
171
+ best_auc = 0.0
172
+
173
+ for epoch in range(1, args.epochs + 1):
174
+ t0 = time.time()
175
+ loss = train_epoch(model, train_dl, optimizer, criterion, device, scaler)
176
+ mets = evaluate(model, val_dl, device)
177
+ scheduler.step()
178
+
179
+ print(f"Epoch {epoch:03d}/{args.epochs} "
180
+ f"Loss={loss:.4f} AUC={mets['auc']*100:.2f}% "
181
+ f"ACC={mets['acc']*100:.2f}% "
182
+ f"[{time.time()-t0:.1f}s]")
183
+
184
+ if mets["auc"] > best_auc:
185
+ best_auc = mets["auc"]
186
+ save_path = Path(args.save_dir) / "deepfake_model.pth"
187
+ torch.save(model.state_dict(), save_path)
188
+ print(f" βœ“ Best model saved β†’ {save_path} (AUC={best_auc*100:.2f}%)")
189
+
190
+ print(f"\n[Done] Best AUC: {best_auc*100:.2f}%")
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
backend/train_audio.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_audio.py β€” Training script for AudioDeepfakeDetector (Wav2Vec2)
3
+
4
+ Compatible dataset formats:
5
+ 1. ASVspoof 2019 / 2021 (LA / PA protocols)
6
+ 2. Custom folders:
7
+ data/audio/
8
+ train/real/ ← bonafide .wav / .flac / .mp3 files
9
+ train/fake/ ← spoofed .wav / .flac / .mp3 files
10
+ val/real/
11
+ val/fake/
12
+
13
+ Usage:
14
+ python train_audio.py \
15
+ --data_dir ../data/audio \
16
+ --epochs 20 \
17
+ --batch_size 16 \
18
+ --lr 1e-4 \
19
+ --save_dir ../models
20
+ """
21
+
22
+ import argparse, os, time, random
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+ import torch.nn as nn
28
+ from torch.utils.data import Dataset, DataLoader
29
+ from torch.optim import AdamW
30
+ from torch.optim.lr_scheduler import CosineAnnealingLR
31
+ from sklearn.metrics import roc_auc_score, accuracy_score
32
+
33
+ try:
34
+ import librosa
35
+ LIBROSA_AVAILABLE = True
36
+ except ImportError:
37
+ print("[ERROR] librosa not installed. Run: pip install librosa>=0.10.0")
38
+ LIBROSA_AVAILABLE = False
39
+
40
+ try:
41
+ from transformers import Wav2Vec2FeatureExtractor
42
+ TRANSFORMERS_AVAILABLE = True
43
+ except ImportError:
44
+ print("[ERROR] transformers not installed. Run: pip install transformers>=4.30.0")
45
+ TRANSFORMERS_AVAILABLE = False
46
+
47
+ from audio_detector import AudioDeepfakeDetector
48
+
49
+ # ─────────────────────────────────────────────────────────────────
50
+ SAMPLE_RATE = 16_000
51
+ MAX_DURATION_SEC = 6.0
52
+ MAX_SAMPLES = int(MAX_DURATION_SEC * SAMPLE_RATE)
53
+ SEED = 42
54
+
55
+
56
+ def seed_everything(seed: int = SEED):
57
+ random.seed(seed)
58
+ np.random.seed(seed)
59
+ torch.manual_seed(seed)
60
+ if torch.cuda.is_available():
61
+ torch.cuda.manual_seed_all(seed)
62
+
63
+
64
+ # ─────────────────────────────────────────────────────────────────
65
+ # Dataset
66
+ # ─────────────────────────────────────────────────────────────────
67
+ AUDIO_EXTS = {".wav", ".flac", ".mp3", ".ogg", ".m4a"}
68
+
69
+
70
+ class AudioFakeDataset(Dataset):
71
+ """
72
+ Folder-based audio dataset.
73
+ Expects: <root>/<split>/real/ and <root>/<split>/fake/
74
+ """
75
+
76
+ def __init__(self, root: str, split: str, feature_extractor):
77
+ self.feature_extractor = feature_extractor
78
+ self.samples = []
79
+
80
+ for label, name in [(0, "real"), (1, "fake")]:
81
+ folder = Path(root) / split / name
82
+ if not folder.exists():
83
+ print(f"[WARN] Folder not found: {folder}")
84
+ continue
85
+ for f in folder.rglob("*"):
86
+ if f.suffix.lower() in AUDIO_EXTS:
87
+ self.samples.append((str(f), label))
88
+
89
+ random.shuffle(self.samples)
90
+ real_n = sum(1 for _, l in self.samples if l == 0)
91
+ fake_n = sum(1 for _, l in self.samples if l == 1)
92
+ print(f"[Dataset/{split}] real={real_n} fake={fake_n} total={len(self.samples)}")
93
+
94
+ def __len__(self):
95
+ return len(self.samples)
96
+
97
+ def __getitem__(self, idx):
98
+ path, label = self.samples[idx]
99
+ try:
100
+ waveform, _ = librosa.load(path, sr=SAMPLE_RATE, mono=True)
101
+ except Exception:
102
+ waveform = np.zeros(SAMPLE_RATE, dtype=np.float32)
103
+
104
+ # Pad / truncate
105
+ if len(waveform) > MAX_SAMPLES:
106
+ start = random.randint(0, len(waveform) - MAX_SAMPLES)
107
+ waveform = waveform[start: start + MAX_SAMPLES]
108
+ else:
109
+ waveform = np.pad(waveform, (0, MAX_SAMPLES - len(waveform)))
110
+
111
+ inputs = self.feature_extractor(
112
+ waveform.astype(np.float32),
113
+ sampling_rate=SAMPLE_RATE,
114
+ return_tensors="pt",
115
+ padding=True,
116
+ )
117
+ return inputs.input_values.squeeze(0), torch.tensor(label, dtype=torch.float32)
118
+
119
+
120
+ def collate_fn(batch):
121
+ input_values, labels = zip(*batch)
122
+ # Pad to the longest sample in the batch
123
+ max_len = max(x.shape[0] for x in input_values)
124
+ padded = torch.stack([
125
+ torch.nn.functional.pad(x, (0, max_len - x.shape[0])) for x in input_values
126
+ ])
127
+ return padded, torch.stack(labels)
128
+
129
+
130
+ # ─────────────────────────────────────────────────────────────────
131
+ # Training helpers
132
+ # ─────────────────────────────────────────────────────────────────
133
+ def run_epoch(model, loader, criterion, optimizer, device, training: bool):
134
+ model.train(training)
135
+ total_loss, all_probs, all_labels = 0.0, [], []
136
+
137
+ for input_values, labels in loader:
138
+ input_values = input_values.to(device)
139
+ labels = labels.to(device)
140
+
141
+ with torch.set_grad_enabled(training):
142
+ logits = model(input_values).squeeze(1)
143
+ loss = criterion(logits, labels)
144
+
145
+ if training:
146
+ optimizer.zero_grad()
147
+ loss.backward()
148
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
149
+ optimizer.step()
150
+
151
+ total_loss += loss.item() * len(labels)
152
+ probs = torch.sigmoid(logits).detach().cpu().numpy()
153
+ all_probs.extend(probs.tolist())
154
+ all_labels.extend(labels.cpu().numpy().tolist())
155
+
156
+ avg_loss = total_loss / len(loader.dataset)
157
+ preds = [1 if p >= 0.5 else 0 for p in all_probs]
158
+ acc = accuracy_score(all_labels, preds)
159
+ try:
160
+ auc = roc_auc_score(all_labels, all_probs)
161
+ except Exception:
162
+ auc = 0.5
163
+ return avg_loss, acc, auc
164
+
165
+
166
+ # ─────────────────────────────────────────────────────────────────
167
+ # Main
168
+ # ─────────────────────────────────────────────────────────────────
169
+ def main(args):
170
+ if not LIBROSA_AVAILABLE or not TRANSFORMERS_AVAILABLE:
171
+ raise SystemExit("Missing dependencies. See error messages above.")
172
+
173
+ seed_everything()
174
+ device = "cuda" if torch.cuda.is_available() else "cpu"
175
+ print(f"[Train] Device: {device} | Data: {args.data_dir}")
176
+
177
+ # ── Model ─────────────────────────────────────────────────
178
+ model = AudioDeepfakeDetector(pretrained=True, freeze_base=True)
179
+ model.to(device)
180
+
181
+ # ── Feature extractor for dataset ─────────────────────────
182
+ feat_ext = model.feature_extractor
183
+
184
+ # ── Datasets & loaders ────────────────────────────────────
185
+ train_ds = AudioFakeDataset(args.data_dir, "train", feat_ext)
186
+ val_ds = AudioFakeDataset(args.data_dir, "val", feat_ext)
187
+
188
+ if len(train_ds) == 0:
189
+ print("\n[WARN] No training files found!")
190
+ print("Expected structure:")
191
+ print(" data/audio/train/real/*.wav")
192
+ print(" data/audio/train/fake/*.wav")
193
+ print(" data/audio/val/real/*.wav")
194
+ print(" data/audio/val/fake/*.wav")
195
+ print("\nRunning in demo mode (no actual training).")
196
+ return
197
+
198
+ train_loader = DataLoader(
199
+ train_ds, batch_size=args.batch_size, shuffle=True,
200
+ num_workers=2, pin_memory=(device == "cuda"),
201
+ collate_fn=collate_fn, drop_last=True,
202
+ )
203
+ val_loader = DataLoader(
204
+ val_ds, batch_size=args.batch_size, shuffle=False,
205
+ num_workers=2, collate_fn=collate_fn,
206
+ )
207
+
208
+ # ── Loss, optimiser, scheduler ────────────────────────────
209
+ # Compute class weights to handle imbalance
210
+ real_n = sum(1 for _, l in train_ds.samples if l == 0)
211
+ fake_n = sum(1 for _, l in train_ds.samples if l == 1)
212
+ pos_weight = torch.tensor([real_n / max(fake_n, 1)], device=device)
213
+ criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
214
+
215
+ # Only fine-tune the classifier head (Wav2Vec2 frozen)
216
+ optimizer = AdamW(
217
+ filter(lambda p: p.requires_grad, model.parameters()),
218
+ lr=args.lr, weight_decay=1e-4,
219
+ )
220
+ scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs, eta_min=1e-6)
221
+
222
+ # ── Training loop ─────────────────────────────────────────
223
+ save_dir = Path(args.save_dir)
224
+ save_dir.mkdir(parents=True, exist_ok=True)
225
+ best_auc = 0.0
226
+
227
+ print("\n" + "=" * 60)
228
+ print(" DeepShield β€” Audio Detector Training (Wav2Vec2)")
229
+ print("=" * 60)
230
+
231
+ for epoch in range(1, args.epochs + 1):
232
+ t0 = time.time()
233
+
234
+ tr_loss, tr_acc, tr_auc = run_epoch(model, train_loader, criterion, optimizer, device, training=True)
235
+ va_loss, va_acc, va_auc = run_epoch(model, val_loader, criterion, None, device, training=False)
236
+ scheduler.step()
237
+
238
+ elapsed = time.time() - t0
239
+ print(
240
+ f"Epoch {epoch:03d}/{args.epochs} "
241
+ f"| train loss={tr_loss:.4f} acc={tr_acc:.3f} AUC={tr_auc:.3f}"
242
+ f" | val loss={va_loss:.4f} acc={va_acc:.3f} AUC={va_auc:.3f}"
243
+ f" | {elapsed:.1f}s"
244
+ )
245
+
246
+ if va_auc > best_auc:
247
+ best_auc = va_auc
248
+ ckpt = save_dir / "audio_model_best.pth"
249
+ torch.save(model.state_dict(), ckpt)
250
+ print(f" βœ” Best model saved β†’ {ckpt} (AUC={best_auc:.4f})")
251
+
252
+ # Save final checkpoint
253
+ final = save_dir / "audio_model_final.pth"
254
+ torch.save(model.state_dict(), final)
255
+ print(f"\n[Done] Final model saved β†’ {final}")
256
+ print(f"[Done] Best val AUC: {best_auc:.4f}")
257
+
258
+ # ── Phase 2: Unfreeze Wav2Vec2 and fine-tune further ──────
259
+ if args.unfreeze_epochs > 0:
260
+ print(f"\n[Phase 2] Unfreezing Wav2Vec2 for {args.unfreeze_epochs} more epochs...")
261
+ for param in model.wav2vec2.parameters():
262
+ param.requires_grad = True
263
+ optimizer2 = AdamW(model.parameters(), lr=args.lr * 0.1, weight_decay=1e-4)
264
+ scheduler2 = CosineAnnealingLR(optimizer2, T_max=args.unfreeze_epochs, eta_min=1e-7)
265
+
266
+ for epoch in range(1, args.unfreeze_epochs + 1):
267
+ t0 = time.time()
268
+ tr_loss, tr_acc, tr_auc = run_epoch(model, train_loader, criterion, optimizer2, device, True)
269
+ va_loss, va_acc, va_auc = run_epoch(model, val_loader, criterion, None, device, False)
270
+ scheduler2.step()
271
+ elapsed = time.time() - t0
272
+ print(
273
+ f"[P2] Epoch {epoch:03d}/{args.unfreeze_epochs} "
274
+ f"| val AUC={va_auc:.3f} acc={va_acc:.3f} | {elapsed:.1f}s"
275
+ )
276
+ if va_auc > best_auc:
277
+ best_auc = va_auc
278
+ ckpt = save_dir / "audio_model_best.pth"
279
+ torch.save(model.state_dict(), ckpt)
280
+ print(f" βœ” Best model updated β†’ {ckpt} (AUC={best_auc:.4f})")
281
+
282
+ torch.save(model.state_dict(), save_dir / "audio_model_phase2.pth")
283
+ print(f"\n[Done] Phase-2 model saved. Best AUC = {best_auc:.4f}")
284
+
285
+
286
+ if __name__ == "__main__":
287
+ parser = argparse.ArgumentParser(description="DeepShield Audio Deepfake Detector Training")
288
+ parser.add_argument("--data_dir", type=str, default="../datasets/audio",
289
+ help="Root folder with train/val subfolders")
290
+ parser.add_argument("--epochs", type=int, default=20)
291
+ parser.add_argument("--unfreeze_epochs", type=int, default=5,
292
+ help="Extra epochs after unfreezing Wav2Vec2 backbone")
293
+ parser.add_argument("--batch_size", type=int, default=16)
294
+ parser.add_argument("--lr", type=float, default=1e-4)
295
+ parser.add_argument("--save_dir", type=str, default="../models")
296
+ args = parser.parse_args()
297
+ main(args)
backend/train_image.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_image.py β€” Training script for EfficientNetV2-S image deepfake detector
3
+ Uses MTCNN-preprocessed face crops for training.
4
+
5
+ Dataset structure:
6
+ data/images/
7
+ train/
8
+ real/ ← real face crops (JPG/PNG)
9
+ fake/ ← deepfake face crops (JPG/PNG)
10
+ val/
11
+ real/
12
+ fake/
13
+
14
+ Compatible datasets:
15
+ - FaceForensics++ (face crops from real/manipulated videos)
16
+ - Celeb-DF v2 (face crops)
17
+ - DFDC face crops
18
+ - Any real/fake image folder pair
19
+
20
+ Usage:
21
+ python train_image.py \
22
+ --data_dir ../data/images \
23
+ --epochs 30 \
24
+ --batch_size 32 \
25
+ --lr 1e-4 \
26
+ --save_dir ../models
27
+ """
28
+
29
+ import argparse, os, time, random
30
+ from pathlib import Path
31
+
32
+ import numpy as np
33
+ import torch
34
+ import torch.nn as nn
35
+ from torch.utils.data import Dataset, DataLoader
36
+ from torch.optim import AdamW
37
+ from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
38
+ from sklearn.metrics import roc_auc_score, accuracy_score
39
+ from PIL import Image
40
+
41
+ from image_detector import EfficientNetV2Detector, train_transform, inference_transform
42
+
43
+ # ─────────────────────────────────────────────────────────────────
44
+ SEED = 42
45
+ IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
46
+
47
+
48
+ def seed_everything(seed: int = SEED):
49
+ random.seed(seed)
50
+ np.random.seed(seed)
51
+ torch.manual_seed(seed)
52
+ if torch.cuda.is_available():
53
+ torch.cuda.manual_seed_all(seed)
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────
57
+ # Dataset
58
+ # ─────────────────────────────────────────────────────────────────
59
+ class ImageFakeDataset(Dataset):
60
+ """
61
+ Folder-based image dataset.
62
+ Expects: <root>/<split>/real/ and <root>/<split>/fake/
63
+ """
64
+
65
+ def __init__(self, root: str, split: str, augment: bool = False):
66
+ self.transform = train_transform if augment else inference_transform
67
+ self.samples = []
68
+
69
+ for label, name in [(0, "real"), (1, "fake")]:
70
+ folder = Path(root) / split / name
71
+ if not folder.exists():
72
+ print(f"[WARN] Folder not found: {folder}")
73
+ continue
74
+ for f in folder.rglob("*"):
75
+ if f.suffix.lower() in IMG_EXTS:
76
+ self.samples.append((str(f), label))
77
+
78
+ random.shuffle(self.samples)
79
+ real_n = sum(1 for _, l in self.samples if l == 0)
80
+ fake_n = sum(1 for _, l in self.samples if l == 1)
81
+ print(f"[Dataset/{split}] real={real_n} fake={fake_n} total={len(self.samples)}")
82
+
83
+ def __len__(self):
84
+ return len(self.samples)
85
+
86
+ def __getitem__(self, idx):
87
+ path, label = self.samples[idx]
88
+ try:
89
+ img = Image.open(path).convert("RGB")
90
+ except Exception:
91
+ img = Image.new("RGB", (224, 224), (128, 128, 128))
92
+ tensor = self.transform(img)
93
+ return tensor, torch.tensor(label, dtype=torch.float32)
94
+
95
+
96
+ # ─────────────────────────────────────────────────────────────────
97
+ # Training helpers
98
+ # ─────────────────────────────────────────────────────────────────
99
+ def mixup(x, y, alpha=0.2):
100
+ """MixUp augmentation for better generalisation."""
101
+ if alpha > 0:
102
+ lam = np.random.beta(alpha, alpha)
103
+ else:
104
+ lam = 1.0
105
+ idx = torch.randperm(x.size(0))
106
+ mixed = lam * x + (1 - lam) * x[idx]
107
+ y_mix = lam * y + (1 - lam) * y[idx]
108
+ return mixed, y_mix
109
+
110
+
111
+ from tqdm import tqdm
112
+
113
+ def run_epoch(model, loader, criterion, optimizer, device, training: bool, use_mixup: bool = False):
114
+ model.train(training)
115
+ total_loss, all_probs, all_labels = 0.0, [], []
116
+
117
+ desc = "Train" if training else "Val"
118
+ pbar = tqdm(loader, desc=desc, leave=False, dynamic_ncols=True)
119
+
120
+ for imgs, labels in pbar:
121
+ imgs = imgs.to(device)
122
+ labels = labels.to(device)
123
+
124
+ if training and use_mixup:
125
+ imgs, labels = mixup(imgs, labels, alpha=0.2)
126
+
127
+ with torch.set_grad_enabled(training):
128
+ logits = model(imgs).squeeze(1)
129
+ loss = criterion(logits, labels)
130
+
131
+ if training:
132
+ optimizer.zero_grad()
133
+ loss.backward()
134
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
135
+ optimizer.step()
136
+
137
+ total_loss += loss.item() * len(labels)
138
+ probs = torch.sigmoid(logits).detach().cpu().numpy()
139
+ all_probs.extend(probs.tolist())
140
+ all_labels.extend(labels.cpu().numpy().tolist())
141
+
142
+ # Update progress bar
143
+ pbar.set_postfix(loss=f"{(total_loss / max(len(all_labels), 1)):.4f}")
144
+
145
+ avg_loss = total_loss / max(len(loader.dataset), 1)
146
+ preds = [1 if p >= 0.5 else 0 for p in all_probs]
147
+ int_labels = [round(l) for l in all_labels]
148
+ acc = accuracy_score(int_labels, preds)
149
+ try:
150
+ auc = roc_auc_score(int_labels, all_probs)
151
+ except Exception:
152
+ auc = 0.5
153
+ return avg_loss, acc, auc
154
+
155
+
156
+ # ─────────────────────────────────────────────────────────────────
157
+ # Main
158
+ # ─────────────────────────────────────────────────────────────────
159
+ def main(args):
160
+ seed_everything()
161
+ device = "cuda" if torch.cuda.is_available() else "cpu"
162
+ print(f"[Train] Device: {device} | Data: {args.data_dir}")
163
+
164
+ # ── Datasets ──────────────────────────────────────────────
165
+ train_ds = ImageFakeDataset(args.data_dir, "train", augment=True)
166
+ val_ds = ImageFakeDataset(args.data_dir, "val", augment=False)
167
+
168
+ if len(train_ds) == 0:
169
+ print("\n[WARN] No training images found!")
170
+ print("Expected structure:")
171
+ print(" data/images/train/real/*.jpg")
172
+ print(" data/images/train/fake/*.jpg")
173
+ print(" data/images/val/real/*.jpg")
174
+ print(" data/images/val/fake/*.jpg")
175
+ print("\nYou can generate face crops from FaceForensics++ using preprocess_celebdf.py")
176
+ print("Running in demo mode (no actual training).")
177
+ return
178
+
179
+ train_loader = DataLoader(
180
+ train_ds, batch_size=args.batch_size, shuffle=True,
181
+ num_workers=4, pin_memory=(device == "cuda"), drop_last=True,
182
+ )
183
+ val_loader = DataLoader(
184
+ val_ds, batch_size=args.batch_size, shuffle=False, num_workers=4,
185
+ )
186
+
187
+ # ── Model ─────────────────────────────────────────────────
188
+ model = EfficientNetV2Detector(pretrained=True)
189
+
190
+ # Freeze backbone, only train classifier head initially
191
+ for param in model.backbone.parameters():
192
+ param.requires_grad = False
193
+ model.to(device)
194
+
195
+ # ── Loss, optimiser, scheduler ────────────────────────────
196
+ real_n = sum(1 for _, l in train_ds.samples if l == 0)
197
+ fake_n = sum(1 for _, l in train_ds.samples if l == 1)
198
+ pos_weight = torch.tensor([real_n / max(fake_n, 1)], device=device)
199
+ criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
200
+
201
+ optimizer = AdamW(
202
+ filter(lambda p: p.requires_grad, model.parameters()),
203
+ lr=args.lr, weight_decay=1e-4,
204
+ )
205
+ scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=1)
206
+
207
+ # ── Training loop β€” Phase 1 (frozen backbone) ─────────────
208
+ save_dir = Path(args.save_dir)
209
+ save_dir.mkdir(parents=True, exist_ok=True)
210
+ best_auc = 0.0
211
+
212
+ print("\n" + "=" * 65)
213
+ print(" DeepShield β€” Image Detector Training (EfficientNetV2-S)")
214
+ print("=" * 65)
215
+ print(f" Phase 1: {args.epochs} epochs with frozen backbone")
216
+
217
+ for epoch in range(1, args.epochs + 1):
218
+ t0 = time.time()
219
+ tr_loss, tr_acc, tr_auc = run_epoch(model, train_loader, criterion, optimizer, device, True, use_mixup=True)
220
+ va_loss, va_acc, va_auc = run_epoch(model, val_loader, criterion, None, device, False, use_mixup=False)
221
+ scheduler.step()
222
+
223
+ elapsed = time.time() - t0
224
+ print(
225
+ f" Epoch {epoch:03d}/{args.epochs} "
226
+ f"| train loss={tr_loss:.4f} acc={tr_acc:.3f} AUC={tr_auc:.3f}"
227
+ f" | val loss={va_loss:.4f} acc={va_acc:.3f} AUC={va_auc:.3f}"
228
+ f" | {elapsed:.1f}s"
229
+ )
230
+
231
+ if va_auc > best_auc:
232
+ best_auc = va_auc
233
+ ckpt = save_dir / "image_model_best.pth"
234
+ torch.save(model.state_dict(), ckpt)
235
+ print(f" βœ” Best model saved β†’ {ckpt} (AUC={best_auc:.4f})")
236
+
237
+ # ── Phase 2: Unfreeze + fine-tune whole network ───────────
238
+ if args.finetune_epochs > 0:
239
+ print(f"\n Phase 2: Unfreezing backbone for {args.finetune_epochs} epochs...")
240
+ for param in model.backbone.parameters():
241
+ param.requires_grad = True
242
+
243
+ optimizer2 = AdamW(model.parameters(), lr=args.lr * 0.1, weight_decay=1e-4)
244
+ scheduler2 = CosineAnnealingWarmRestarts(optimizer2, T_0=args.finetune_epochs)
245
+
246
+ for epoch in range(1, args.finetune_epochs + 1):
247
+ t0 = time.time()
248
+ tr_loss, tr_acc, tr_auc = run_epoch(model, train_loader, criterion, optimizer2, device, True)
249
+ va_loss, va_acc, va_auc = run_epoch(model, val_loader, criterion, None, device, False)
250
+ scheduler2.step()
251
+ elapsed = time.time() - t0
252
+ print(
253
+ f" [P2] Epoch {epoch:03d}/{args.finetune_epochs} "
254
+ f"| val AUC={va_auc:.3f} acc={va_acc:.3f} | {elapsed:.1f}s"
255
+ )
256
+ if va_auc > best_auc:
257
+ best_auc = va_auc
258
+ ckpt = save_dir / "image_model_best.pth"
259
+ torch.save(model.state_dict(), ckpt)
260
+ print(f" βœ” Best model updated β†’ {ckpt} (AUC={best_auc:.4f})")
261
+
262
+ torch.save(model.state_dict(), save_dir / "image_model_final.pth")
263
+ print(f"\n[Done] Final model saved β†’ {save_dir / 'image_model_final.pth'}")
264
+ print(f"[Done] Best val AUC: {best_auc:.4f}")
265
+
266
+
267
+ if __name__ == "__main__":
268
+ parser = argparse.ArgumentParser(description="DeepShield Image Deepfake Detector Training")
269
+ parser.add_argument("--data_dir", type=str, default="../datasets/images")
270
+ parser.add_argument("--epochs", type=int, default=30,
271
+ help="Phase-1 epochs (frozen backbone)")
272
+ parser.add_argument("--finetune_epochs", type=int, default=10,
273
+ help="Phase-2 epochs (full fine-tune)")
274
+ parser.add_argument("--batch_size", type=int, default=32)
275
+ parser.add_argument("--lr", type=float, default=1e-4)
276
+ parser.add_argument("--save_dir", type=str, default="../models")
277
+ args = parser.parse_args()
278
+ main(args)
backend/train_image_improved.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ train_image_improved.py β€” Improved EfficientNetV2-S image deepfake detector training
3
+ Optimised for small datasets (~2000 images). Key improvements over original:
4
+
5
+ 1. Stronger augmentation (RandomErasing, GaussianBlur, Random90Β°rotations)
6
+ 2. Label smoothing to prevent overconfident predictions
7
+ 3. Warmup + CosineAnnealing LR schedule (better convergence)
8
+ 4. Gradient accumulation (effective larger batch without RAM cost)
9
+ 5. Early stopping with patience
10
+ 6. Test-time augmentation (TTA) during validation for better AUC estimates
11
+ 7. Longer Phase-2 fine-tune with layer-wise LR decay
12
+
13
+ Dataset structure (same as original):
14
+ datasets/images/
15
+ train/
16
+ real/ ← real face crops (JPG/PNG)
17
+ fake/ ← deepfake face crops (JPG/PNG)
18
+ val/
19
+ real/
20
+ fake/
21
+
22
+ Usage:
23
+ python train_image_improved.py \
24
+ --data_dir ../datasets/images \
25
+ --epochs 40 \
26
+ --finetune_epochs 20 \
27
+ --batch_size 16 \
28
+ --lr 2e-4 \
29
+ --save_dir models
30
+ """
31
+
32
+ import argparse, os, time, random
33
+ from pathlib import Path
34
+
35
+ import numpy as np
36
+ import torch
37
+ import torch.nn as nn
38
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
39
+ from torch.optim import AdamW
40
+ from torch.optim.lr_scheduler import OneCycleLR
41
+ from sklearn.metrics import roc_auc_score, accuracy_score, f1_score
42
+ from PIL import Image
43
+ from tqdm import tqdm
44
+ import torchvision.transforms as T
45
+
46
+ from image_detector import EfficientNetV2Detector, inference_transform
47
+
48
+ # ─────────────────────────────────────────────────────────────────
49
+ SEED = 42
50
+ IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
51
+ MEAN = [0.485, 0.456, 0.406]
52
+ STD = [0.229, 0.224, 0.225]
53
+
54
+
55
+ def seed_everything(seed: int = SEED):
56
+ random.seed(seed)
57
+ np.random.seed(seed)
58
+ torch.manual_seed(seed)
59
+ if torch.cuda.is_available():
60
+ torch.cuda.manual_seed_all(seed)
61
+
62
+
63
+ # ─────────────────────────────────────────────────────────────────
64
+ # IMPROVED Augmentation Pipeline
65
+ # ─────────────────────────────────────────────────────────────────
66
+ strong_train_transform = T.Compose([
67
+ T.Resize((256, 256)),
68
+ T.RandomCrop(224),
69
+ T.RandomHorizontalFlip(p=0.5),
70
+ T.RandomApply([T.RandomRotation(degrees=10)], p=0.4),
71
+ T.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.05),
72
+ T.RandomApply([T.GaussianBlur(kernel_size=5, sigma=(0.1, 2.0))], p=0.3),
73
+ T.RandomGrayscale(p=0.05),
74
+ T.ToTensor(),
75
+ T.Normalize(mean=MEAN, std=STD),
76
+ T.RandomErasing(p=0.25, scale=(0.02, 0.15)), # simulate occlusions
77
+ ])
78
+
79
+ # TTA transforms (multiple crops + flips averaged at val time)
80
+ tta_transforms = [
81
+ T.Compose([T.Resize((224, 224)), T.ToTensor(), T.Normalize(MEAN, STD)]),
82
+ T.Compose([T.Resize((256, 256)), T.CenterCrop(224), T.ToTensor(), T.Normalize(MEAN, STD)]),
83
+ T.Compose([T.Resize((224, 224)), T.RandomHorizontalFlip(p=1.0), T.ToTensor(), T.Normalize(MEAN, STD)]),
84
+ ]
85
+
86
+
87
+ # ─────────────────────────────────────────────────────────────────
88
+ # Dataset
89
+ # ─────────────────────────────────────────────────────────────────
90
+ class ImageFakeDataset(Dataset):
91
+ def __init__(self, root: str, split: str, augment: bool = False):
92
+ self.augment = augment
93
+ self.transform = strong_train_transform if augment else inference_transform
94
+ self.samples = []
95
+
96
+ for label, name in [(0, "real"), (1, "fake")]:
97
+ folder = Path(root) / split / name
98
+ if not folder.exists():
99
+ print(f"[WARN] Folder not found: {folder}")
100
+ continue
101
+ for f in folder.rglob("*"):
102
+ if f.suffix.lower() in IMG_EXTS:
103
+ self.samples.append((str(f), label))
104
+
105
+ random.shuffle(self.samples)
106
+ real_n = sum(1 for _, l in self.samples if l == 0)
107
+ fake_n = sum(1 for _, l in self.samples if l == 1)
108
+ print(f"[Dataset/{split}] real={real_n} fake={fake_n} total={len(self.samples)}")
109
+
110
+ def __len__(self):
111
+ return len(self.samples)
112
+
113
+ def __getitem__(self, idx):
114
+ path, label = self.samples[idx]
115
+ try:
116
+ img = Image.open(path).convert("RGB")
117
+ except Exception:
118
+ img = Image.new("RGB", (224, 224), (128, 128, 128))
119
+ tensor = self.transform(img)
120
+ return tensor, torch.tensor(label, dtype=torch.float32)
121
+
122
+ def get_weights(self):
123
+ """Per-sample weights for WeightedRandomSampler (balances classes)."""
124
+ labels = [l for _, l in self.samples]
125
+ n_real = labels.count(0)
126
+ n_fake = labels.count(1)
127
+ w_real = 1.0 / n_real if n_real else 1.0
128
+ w_fake = 1.0 / n_fake if n_fake else 1.0
129
+ return [w_real if l == 0 else w_fake for l in labels]
130
+
131
+
132
+ # ─────────────────────────────────────────────────────────────────
133
+ # Label-smoothed BCE loss
134
+ # ─────────────────────────────────────────────────────────────────
135
+ class LabelSmoothingBCE(nn.Module):
136
+ """BCEWithLogitsLoss with label smoothing for better generalisation."""
137
+ def __init__(self, smoothing: float = 0.1, pos_weight=None):
138
+ super().__init__()
139
+ self.smoothing = smoothing
140
+ self.pos_weight = pos_weight
141
+
142
+ def forward(self, logits, targets):
143
+ targets = targets * (1 - self.smoothing) + 0.5 * self.smoothing
144
+ return nn.functional.binary_cross_entropy_with_logits(
145
+ logits, targets,
146
+ pos_weight=self.pos_weight,
147
+ )
148
+
149
+
150
+ # ─────────────────────────────────────────────────────────────────
151
+ # TTA inference helper
152
+ # ─────────────────────────────────────────────────────────────────
153
+ def tta_predict(model, pil_img, device):
154
+ probs = []
155
+ for tf in tta_transforms:
156
+ t = tf(pil_img).unsqueeze(0).to(device)
157
+ with torch.no_grad():
158
+ p = torch.sigmoid(model(t)).item()
159
+ probs.append(p)
160
+ return np.mean(probs)
161
+
162
+
163
+ # ─────────────────────────────────────────────────────────────────
164
+ # Epoch runner
165
+ # ─────────────────────────────────────────────────────────────────
166
+ def run_epoch(model, loader, criterion, optimizer, device, training: bool,
167
+ scheduler=None, accumulation_steps: int = 1):
168
+ model.train(training)
169
+ total_loss, all_probs, all_labels = 0.0, [], []
170
+ desc = "Train" if training else "Val "
171
+ pbar = tqdm(loader, desc=desc, leave=False, dynamic_ncols=True)
172
+
173
+ optimizer_step_count = 0
174
+ if training:
175
+ optimizer.zero_grad()
176
+
177
+ for step, (imgs, labels) in enumerate(pbar):
178
+ imgs = imgs.to(device)
179
+ labels = labels.to(device)
180
+
181
+ with torch.set_grad_enabled(training):
182
+ logits = model(imgs).squeeze(1)
183
+ loss = criterion(logits, labels)
184
+
185
+ if training:
186
+ (loss / accumulation_steps).backward()
187
+ if (step + 1) % accumulation_steps == 0:
188
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
189
+ optimizer.step()
190
+ if scheduler is not None:
191
+ scheduler.step()
192
+ optimizer.zero_grad()
193
+ optimizer_step_count += 1
194
+
195
+ total_loss += loss.item() * len(labels)
196
+ probs = torch.sigmoid(logits).detach().cpu().numpy()
197
+ all_probs.extend(probs.tolist())
198
+ all_labels.extend(labels.cpu().numpy().tolist())
199
+ pbar.set_postfix(loss=f"{(total_loss / max(len(all_labels), 1)):.4f}")
200
+
201
+ # flush remaining gradient
202
+ if training and (len(loader) % accumulation_steps != 0):
203
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
204
+ optimizer.step()
205
+ optimizer.zero_grad()
206
+
207
+ avg_loss = total_loss / max(len(loader.dataset), 1)
208
+ int_labels = [round(l) for l in all_labels]
209
+ preds = [1 if p >= 0.5 else 0 for p in all_probs]
210
+ acc = accuracy_score(int_labels, preds)
211
+ f1 = f1_score(int_labels, preds, zero_division=0)
212
+ try:
213
+ auc = roc_auc_score(int_labels, all_probs)
214
+ except Exception:
215
+ auc = 0.5
216
+ return avg_loss, acc, auc, f1
217
+
218
+
219
+ # ─────────────────────────────────────────────────────────────────
220
+ # Main
221
+ # ─────────────────────────────────────────────────────────────────
222
+ def main(args):
223
+ seed_everything()
224
+ device = "cuda" if torch.cuda.is_available() else "cpu"
225
+ print(f"\n[Train] Device: {device} | Data: {args.data_dir}")
226
+ if device == "cpu":
227
+ print("[WARN] No GPU detected β€” training will be slow. Consider using Google Colab with GPU.")
228
+
229
+ # ── Datasets ──────────────────────────────────────────────
230
+ train_ds = ImageFakeDataset(args.data_dir, "train", augment=True)
231
+ val_ds = ImageFakeDataset(args.data_dir, "val", augment=False)
232
+
233
+ if len(train_ds) == 0:
234
+ print("\n[WARN] No training images found! Expected:")
235
+ print(" datasets/images/train/real/*.jpg")
236
+ print(" datasets/images/train/fake/*.jpg")
237
+ return
238
+
239
+ # Balanced sampler β€” ensures each batch has ~50/50 real/fake
240
+ weights = train_ds.get_weights()
241
+ sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True)
242
+
243
+ train_loader = DataLoader(
244
+ train_ds, batch_size=args.batch_size, sampler=sampler,
245
+ num_workers=0, pin_memory=(device == "cuda"), drop_last=True,
246
+ )
247
+ val_loader = DataLoader(
248
+ val_ds, batch_size=args.batch_size, shuffle=False, num_workers=0,
249
+ )
250
+
251
+ # ── Model ─────────────────────────────────────────────────
252
+ model = EfficientNetV2Detector(pretrained=True)
253
+
254
+ # Phase 1: freeze backbone, only train classifier head
255
+ for param in model.backbone.parameters():
256
+ param.requires_grad = False
257
+ model.to(device)
258
+
259
+ # ── Loss ──────────────────────────────────────────────────
260
+ real_n = sum(1 for _, l in train_ds.samples if l == 0)
261
+ fake_n = sum(1 for _, l in train_ds.samples if l == 1)
262
+ pos_weight = torch.tensor([real_n / max(fake_n, 1)], device=device)
263
+ criterion = LabelSmoothingBCE(smoothing=0.1, pos_weight=pos_weight)
264
+
265
+ # ── Phase 1 optimizer + scheduler ─────────────────────────
266
+ accum_steps = max(1, 32 // args.batch_size) # effective batch β‰ˆ 32
267
+ total_steps_p1 = (len(train_loader) // accum_steps) * args.epochs
268
+
269
+ optimizer = AdamW(
270
+ filter(lambda p: p.requires_grad, model.parameters()),
271
+ lr=args.lr, weight_decay=2e-4,
272
+ )
273
+ scheduler = OneCycleLR(
274
+ optimizer, max_lr=args.lr,
275
+ total_steps=total_steps_p1,
276
+ pct_start=0.1, # 10% warmup
277
+ anneal_strategy="cos",
278
+ )
279
+
280
+ # ── Training loop – Phase 1 ───────────────────────────────
281
+ save_dir = Path(args.save_dir)
282
+ save_dir.mkdir(parents=True, exist_ok=True)
283
+ best_auc, no_improve = 0.0, 0
284
+
285
+ print("\n" + "=" * 70)
286
+ print(" DeepShield β€” Improved Image Detector Training (EfficientNetV2-S)")
287
+ print("=" * 70)
288
+ print(f" Phase 1: {args.epochs} epochs | frozen backbone | LR={args.lr}")
289
+ print(f" Grad accum steps: {accum_steps} -> effective batch ~{args.batch_size * accum_steps}")
290
+ print(f" Early stopping patience: {args.patience}")
291
+ print("=" * 70)
292
+
293
+ for epoch in range(1, args.epochs + 1):
294
+ t0 = time.time()
295
+ tr_loss, tr_acc, tr_auc, tr_f1 = run_epoch(
296
+ model, train_loader, criterion, optimizer, device,
297
+ training=True, scheduler=scheduler, accumulation_steps=accum_steps
298
+ )
299
+ va_loss, va_acc, va_auc, va_f1 = run_epoch(
300
+ model, val_loader, criterion, None, device,
301
+ training=False
302
+ )
303
+ elapsed = time.time() - t0
304
+
305
+ print(
306
+ f" [P1] Ep {epoch:03d}/{args.epochs} "
307
+ f"| train loss={tr_loss:.4f} acc={tr_acc:.3f} AUC={tr_auc:.3f} F1={tr_f1:.3f}"
308
+ f" | val loss={va_loss:.4f} acc={va_acc:.3f} AUC={va_auc:.3f} F1={va_f1:.3f}"
309
+ f" | {elapsed:.1f}s"
310
+ )
311
+
312
+ if va_auc > best_auc:
313
+ best_auc = va_auc
314
+ no_improve = 0
315
+ ckpt = save_dir / "image_model_best.pth"
316
+ torch.save(model.state_dict(), ckpt)
317
+ print(f" [BEST] Model saved -> {ckpt} (AUC={best_auc:.4f})")
318
+ else:
319
+ no_improve += 1
320
+ if no_improve >= args.patience:
321
+ print(f"\n [STOP] Early stopping at epoch {epoch} (no improvement for {args.patience} epochs)")
322
+ break
323
+
324
+ # ── Phase 2: Unfreeze + fine-tune ─────────────────────────
325
+ if args.finetune_epochs > 0:
326
+ print(f"\n Phase 2: Unfreezing all layers for {args.finetune_epochs} epochs...")
327
+
328
+ # Reload best checkpoint before fine-tuning
329
+ best_ckpt = save_dir / "image_model_best.pth"
330
+ if best_ckpt.exists():
331
+ model.load_state_dict(torch.load(str(best_ckpt), map_location=device))
332
+ print(f" Loaded best checkpoint (AUC={best_auc:.4f}) for fine-tuning")
333
+
334
+ for param in model.backbone.parameters():
335
+ param.requires_grad = True
336
+
337
+ # Layer-wise LR: backbone gets 10x lower LR than head
338
+ backbone_params = list(model.backbone.parameters())
339
+ head_params = list(model.classifier.parameters())
340
+ param_groups = [
341
+ {"params": backbone_params, "lr": args.lr * 0.05},
342
+ {"params": head_params, "lr": args.lr * 0.5},
343
+ ]
344
+
345
+ total_steps_p2 = (len(train_loader) // accum_steps) * args.finetune_epochs
346
+ optimizer2 = AdamW(param_groups, weight_decay=2e-4)
347
+ scheduler2 = OneCycleLR(
348
+ optimizer2,
349
+ max_lr=[args.lr * 0.05, args.lr * 0.5],
350
+ total_steps=total_steps_p2,
351
+ pct_start=0.15,
352
+ anneal_strategy="cos",
353
+ )
354
+
355
+ no_improve = 0
356
+ for epoch in range(1, args.finetune_epochs + 1):
357
+ t0 = time.time()
358
+ tr_loss, tr_acc, tr_auc, tr_f1 = run_epoch(
359
+ model, train_loader, criterion, optimizer2, device,
360
+ training=True, scheduler=scheduler2, accumulation_steps=accum_steps
361
+ )
362
+ va_loss, va_acc, va_auc, va_f1 = run_epoch(
363
+ model, val_loader, criterion, None, device, training=False
364
+ )
365
+ elapsed = time.time() - t0
366
+
367
+ print(
368
+ f" [P2] Ep {epoch:03d}/{args.finetune_epochs} "
369
+ f"| val loss={va_loss:.4f} acc={va_acc:.3f} AUC={va_auc:.3f} F1={va_f1:.3f}"
370
+ f" | {elapsed:.1f}s"
371
+ )
372
+
373
+ if va_auc > best_auc:
374
+ best_auc = va_auc
375
+ no_improve = 0
376
+ ckpt = save_dir / "image_model_best.pth"
377
+ torch.save(model.state_dict(), ckpt)
378
+ print(f" [BEST] Model updated -> {ckpt} (AUC={best_auc:.4f})")
379
+ else:
380
+ no_improve += 1
381
+ if no_improve >= args.patience:
382
+ print(f"\n [STOP] Early stopping at fine-tune epoch {epoch}")
383
+ break
384
+
385
+ torch.save(model.state_dict(), save_dir / "image_model_final.pth")
386
+ print(f"\n{'='*70}")
387
+ print(f" Training Complete!")
388
+ print(f" Best Val AUC : {best_auc:.4f}")
389
+ print(f" Best model : {save_dir / 'image_model_best.pth'}")
390
+ print(f" Final model : {save_dir / 'image_model_final.pth'}")
391
+ print(f"{'='*70}")
392
+ print(" NOTE: the Flask app auto-loads 'image_model_best.pth'.")
393
+ print(" Restart Flask after training to use the new weights.\n")
394
+
395
+
396
+ if __name__ == "__main__":
397
+ parser = argparse.ArgumentParser(description="DeepShield Improved Image Training")
398
+ parser.add_argument("--data_dir", type=str, default="../datasets/images")
399
+ parser.add_argument("--epochs", type=int, default=40,
400
+ help="Phase-1 epochs (frozen backbone)")
401
+ parser.add_argument("--finetune_epochs", type=int, default=20,
402
+ help="Phase-2 epochs (full fine-tune)")
403
+ parser.add_argument("--batch_size", type=int, default=16)
404
+ parser.add_argument("--lr", type=float, default=2e-4)
405
+ parser.add_argument("--save_dir", type=str, default="models")
406
+ parser.add_argument("--patience", type=int, default=10,
407
+ help="Early stopping patience (epochs without val AUC improvement)")
408
+ args = parser.parse_args()
409
+ main(args)