Spaces:
Sleeping
Sleeping
Venkatkalyan21 commited on
Commit Β·
1970a5b
0
Parent(s):
Deploy clean backend to Hugging Face
Browse files- .gitattributes +1 -0
- .gitignore +5 -0
- Dockerfile +23 -0
- backend/.env +7 -0
- backend/.env.example +7 -0
- backend/Procfile +1 -0
- backend/app.py +220 -0
- backend/audio_detector.py +163 -0
- backend/db.py +61 -0
- backend/detector.py +124 -0
- backend/download_audio_dataset.py +104 -0
- backend/face_extractor.py +111 -0
- backend/gradcam.py +114 -0
- backend/image_detector.py +246 -0
- backend/metrics.py +49 -0
- backend/model.py +164 -0
- backend/models/deepfake_model.pth +3 -0
- backend/models/image_model_best.pth +3 -0
- backend/multimodal_detector.py +206 -0
- backend/preprocess_celebdf.py +156 -0
- backend/render.yaml +13 -0
- backend/requirements.txt +18 -0
- backend/runtime.txt +1 -0
- backend/train.py +194 -0
- backend/train_audio.py +297 -0
- backend/train_image.py +278 -0
- backend/train_image_improved.py +409 -0
.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)
|