Spaces:
Sleeping
Sleeping
Commit ·
7513d63
0
Parent(s):
Create final Hugging Face Space snapshot
Browse files- .dockerignore +19 -0
- .gitattributes +2 -0
- .gitignore +145 -0
- App/app.py +354 -0
- App/config.py +24 -0
- App/route.py +128 -0
- App/static/Product.jsx +216 -0
- App/static/Technology.jsx +249 -0
- App/static/app.jsx +63 -0
- App/static/images.png +3 -0
- App/static/style.css +147 -0
- App/templates/index.html +19 -0
- Dockerfile +34 -0
- README.md +359 -0
- requirements.txt +12 -0
- tmp_checkpoint/best_model.keras +3 -0
.dockerignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.gitignore
|
| 3 |
+
.venv
|
| 4 |
+
.uv-cache
|
| 5 |
+
.idea
|
| 6 |
+
__pycache__/
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
*.log
|
| 11 |
+
.DS_Store
|
| 12 |
+
App/uploads
|
| 13 |
+
train_sample_videos
|
| 14 |
+
prepared_dataset
|
| 15 |
+
split_dataset
|
| 16 |
+
mtcnn
|
| 17 |
+
archive.zip
|
| 18 |
+
best_model.keras
|
| 19 |
+
tests
|
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tmp_checkpoint/best_model.keras filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
App/static/images.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# agent
|
| 10 |
+
.codex
|
| 11 |
+
|
| 12 |
+
# Distribution / packaging
|
| 13 |
+
.Python
|
| 14 |
+
build/
|
| 15 |
+
develop-eggs/
|
| 16 |
+
dist/
|
| 17 |
+
downloads/
|
| 18 |
+
eggs/
|
| 19 |
+
.eggs/
|
| 20 |
+
lib/
|
| 21 |
+
lib64/
|
| 22 |
+
parts/
|
| 23 |
+
sdist/
|
| 24 |
+
var/
|
| 25 |
+
wheels/
|
| 26 |
+
pip-wheel-metadata/
|
| 27 |
+
share/python-wheels/
|
| 28 |
+
*.egg-info/
|
| 29 |
+
.installed.cfg
|
| 30 |
+
*.egg
|
| 31 |
+
MANIFEST
|
| 32 |
+
|
| 33 |
+
# PyInstaller
|
| 34 |
+
# Usually these files are written by a python script from a template
|
| 35 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
| 36 |
+
*.manifest
|
| 37 |
+
*.spec
|
| 38 |
+
|
| 39 |
+
# Installer logs
|
| 40 |
+
pip-log.txt
|
| 41 |
+
pip-delete-this-directory.txt
|
| 42 |
+
|
| 43 |
+
# Unit test / coverage reports
|
| 44 |
+
htmlcov/
|
| 45 |
+
.tox/
|
| 46 |
+
.nox/
|
| 47 |
+
.coverage
|
| 48 |
+
.coverage.*
|
| 49 |
+
.cache
|
| 50 |
+
nosetests.xml
|
| 51 |
+
coverage.xml
|
| 52 |
+
*.cover
|
| 53 |
+
*.py,cover
|
| 54 |
+
.hypothesis/
|
| 55 |
+
.pytest_cache/
|
| 56 |
+
|
| 57 |
+
# Translations
|
| 58 |
+
*.mo
|
| 59 |
+
*.pot
|
| 60 |
+
|
| 61 |
+
# Django stuff:
|
| 62 |
+
*.log
|
| 63 |
+
local_settings.py
|
| 64 |
+
db.sqlite3
|
| 65 |
+
db.sqlite3-journal
|
| 66 |
+
|
| 67 |
+
# Flask stuff:
|
| 68 |
+
instance/
|
| 69 |
+
.webassets-cache
|
| 70 |
+
|
| 71 |
+
# Scrapy stuff:
|
| 72 |
+
.scrapy
|
| 73 |
+
|
| 74 |
+
# Sphinx documentation
|
| 75 |
+
docs/_build/
|
| 76 |
+
|
| 77 |
+
# PyBuilder
|
| 78 |
+
target/
|
| 79 |
+
|
| 80 |
+
# Jupyter Notebook
|
| 81 |
+
.ipynb_checkpoints
|
| 82 |
+
|
| 83 |
+
# IPython
|
| 84 |
+
profile_default/
|
| 85 |
+
ipython_config.py
|
| 86 |
+
|
| 87 |
+
# pyenv
|
| 88 |
+
.python-version
|
| 89 |
+
|
| 90 |
+
# pipenv
|
| 91 |
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
| 92 |
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
| 93 |
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
| 94 |
+
# install all needed dependencies.
|
| 95 |
+
#Pipfile.lock
|
| 96 |
+
|
| 97 |
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
| 98 |
+
__pypackages__/
|
| 99 |
+
|
| 100 |
+
# Celery stuff
|
| 101 |
+
celerybeat-schedule
|
| 102 |
+
celerybeat.pid
|
| 103 |
+
|
| 104 |
+
# SageMath parsed files
|
| 105 |
+
*.sage.py
|
| 106 |
+
|
| 107 |
+
# Environments
|
| 108 |
+
.env
|
| 109 |
+
.venv
|
| 110 |
+
env/
|
| 111 |
+
venv/
|
| 112 |
+
ENV/
|
| 113 |
+
env.bak/
|
| 114 |
+
venv.bak/
|
| 115 |
+
|
| 116 |
+
# Spyder project settings
|
| 117 |
+
.spyderproject
|
| 118 |
+
.spyproject
|
| 119 |
+
|
| 120 |
+
# Rope project settings
|
| 121 |
+
.ropeproject
|
| 122 |
+
|
| 123 |
+
# mkdocs documentation
|
| 124 |
+
/site
|
| 125 |
+
|
| 126 |
+
# mypy
|
| 127 |
+
.mypy_cache/
|
| 128 |
+
.dmypy.json
|
| 129 |
+
dmypy.json
|
| 130 |
+
|
| 131 |
+
# Pyre type checker
|
| 132 |
+
.pyre/
|
| 133 |
+
|
| 134 |
+
#temp dataset folders
|
| 135 |
+
tmp_*/
|
| 136 |
+
!tmp_checkpoint/
|
| 137 |
+
!tmp_checkpoint/best_model.keras
|
| 138 |
+
!tmp_checkpoint/best_model_phase1.keras
|
| 139 |
+
mtcnn/
|
| 140 |
+
/.conda
|
| 141 |
+
/train_sample_videos
|
| 142 |
+
/split_dataset
|
| 143 |
+
/prepared_dataset
|
| 144 |
+
/App/uploads
|
| 145 |
+
archive.zip
|
App/app.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import base64
|
| 3 |
+
import math
|
| 4 |
+
import logging
|
| 5 |
+
import subprocess
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
import imageio_ffmpeg
|
| 9 |
+
from mtcnn import MTCNN
|
| 10 |
+
from flask import Flask
|
| 11 |
+
from tensorflow.keras.models import load_model
|
| 12 |
+
from tensorflow.keras.applications.efficientnet import preprocess_input
|
| 13 |
+
import keras.src.layers.normalization.batch_normalization as _bn_module
|
| 14 |
+
|
| 15 |
+
import sys
|
| 16 |
+
from config import preview_face_detector_enabled, resolve_server_port
|
| 17 |
+
|
| 18 |
+
logging.basicConfig(
|
| 19 |
+
level=logging.INFO,
|
| 20 |
+
format='%(asctime)s [%(levelname)s] %(message)s',
|
| 21 |
+
datefmt='%Y-%m-%d %H:%M:%S',
|
| 22 |
+
stream=sys.stderr
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Monkey-patch BatchNormalization to accept legacy renorm kwargs
|
| 26 |
+
_OrigBN = _bn_module.BatchNormalization
|
| 27 |
+
_orig_bn_init = _OrigBN.__init__
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _patched_bn_init(self, *args, **kwargs):
|
| 31 |
+
kwargs.pop('renorm', None)
|
| 32 |
+
kwargs.pop('renorm_clipping', None)
|
| 33 |
+
kwargs.pop('renorm_momentum', None)
|
| 34 |
+
_orig_bn_init(self, *args, **kwargs)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
_OrigBN.__init__ = _patched_bn_init
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
app = Flask(__name__)
|
| 41 |
+
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(__file__), 'uploads')
|
| 42 |
+
app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024 # 200 MB limit
|
| 43 |
+
ALLOWED_EXTENSIONS = {'mp4', 'avi', 'mov', 'mkv', 'wmv'}
|
| 44 |
+
|
| 45 |
+
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
| 46 |
+
|
| 47 |
+
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def resolve_model_path():
|
| 51 |
+
"""Prefer the canonical training output, but tolerate the legacy root copy."""
|
| 52 |
+
candidates = [
|
| 53 |
+
os.path.join(PROJECT_ROOT, 'tmp_checkpoint', 'best_model.keras'),
|
| 54 |
+
os.path.join(PROJECT_ROOT, 'best_model.keras'),
|
| 55 |
+
]
|
| 56 |
+
for candidate in candidates:
|
| 57 |
+
if os.path.isfile(candidate):
|
| 58 |
+
return candidate
|
| 59 |
+
raise FileNotFoundError(
|
| 60 |
+
'No model file found. Expected one of: '
|
| 61 |
+
+ ', '.join(candidates)
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Load the trained model
|
| 66 |
+
MODEL_PATH = resolve_model_path()
|
| 67 |
+
logger.info('Loading model from %s', MODEL_PATH)
|
| 68 |
+
model = load_model(MODEL_PATH)
|
| 69 |
+
logger.info('Model loaded successfully')
|
| 70 |
+
INPUT_SIZE = 224
|
| 71 |
+
MIN_FACE_SIZE = 90 # same as 02-prepare_fake_real_dataset.py
|
| 72 |
+
|
| 73 |
+
# Initialize MTCNN face detector (same as training pipeline 01-crop_faces_with_mtcnn.py)
|
| 74 |
+
logger.info('Initializing MTCNN face detector')
|
| 75 |
+
mtcnn_detector = MTCNN()
|
| 76 |
+
logger.info('MTCNN face detector ready')
|
| 77 |
+
|
| 78 |
+
# Initialize YOLO face detector (for processed video overlay only)
|
| 79 |
+
FACE_MODEL_PATH = os.path.join(os.path.dirname(__file__), 'yolov8n-face.pt')
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def build_preview_face_detector():
|
| 83 |
+
if not preview_face_detector_enabled():
|
| 84 |
+
logger.info('Preview face detector disabled by configuration')
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
from ultralytics import YOLO
|
| 89 |
+
except ImportError:
|
| 90 |
+
logger.warning('ultralytics is unavailable; processed preview will fall back to the uploaded video')
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
logger.info('Initializing YOLO face detector')
|
| 94 |
+
detector = YOLO(FACE_MODEL_PATH)
|
| 95 |
+
logger.info('YOLO face detector ready')
|
| 96 |
+
return detector
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
face_detector = build_preview_face_detector()
|
| 100 |
+
|
| 101 |
+
# In-memory job store: job_id -> {status, result, ...}
|
| 102 |
+
jobs = {}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def allowed_file(filename):
|
| 106 |
+
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def face_to_base64(face_rgb):
|
| 110 |
+
face_bgr = cv2.cvtColor(face_rgb, cv2.COLOR_RGB2BGR)
|
| 111 |
+
_, buffer = cv2.imencode('.png', face_bgr)
|
| 112 |
+
return base64.b64encode(buffer).decode('utf-8')
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def reencode_to_h264(input_path, output_path=None):
|
| 116 |
+
"""Re-encode a video to H.264 for browser compatibility. Overwrites in-place if no output_path."""
|
| 117 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 118 |
+
if output_path is None:
|
| 119 |
+
output_path = input_path
|
| 120 |
+
tmp = input_path + '.reencode.mp4'
|
| 121 |
+
cmd = [
|
| 122 |
+
ffmpeg_exe, '-y', '-i', input_path,
|
| 123 |
+
'-c:v', 'libx264', '-preset', 'fast',
|
| 124 |
+
'-movflags', '+faststart', '-pix_fmt', 'yuv420p',
|
| 125 |
+
tmp
|
| 126 |
+
]
|
| 127 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 128 |
+
if result.returncode != 0:
|
| 129 |
+
logger.error('ffmpeg reencode failed: %s', result.stderr)
|
| 130 |
+
try:
|
| 131 |
+
os.remove(tmp)
|
| 132 |
+
except OSError:
|
| 133 |
+
pass
|
| 134 |
+
return False
|
| 135 |
+
try:
|
| 136 |
+
os.replace(tmp, output_path)
|
| 137 |
+
except OSError:
|
| 138 |
+
os.remove(input_path)
|
| 139 |
+
os.rename(tmp, output_path)
|
| 140 |
+
return True
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def scale_frame(frame):
|
| 144 |
+
"""Scale frame exactly like 00-convert_video_to_image.py"""
|
| 145 |
+
h, w = frame.shape[:2]
|
| 146 |
+
if w < 300:
|
| 147 |
+
scale_ratio = 2
|
| 148 |
+
elif w > 1900:
|
| 149 |
+
scale_ratio = 0.33
|
| 150 |
+
elif w > 1000:
|
| 151 |
+
scale_ratio = 0.5
|
| 152 |
+
else:
|
| 153 |
+
scale_ratio = 1
|
| 154 |
+
if scale_ratio != 1:
|
| 155 |
+
new_w = int(w * scale_ratio)
|
| 156 |
+
new_h = int(h * scale_ratio)
|
| 157 |
+
frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 158 |
+
return frame
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def extract_faces_from_video(video_path):
|
| 162 |
+
"""Extract faces using MTCNN — matching training pipeline (01-crop_faces_with_mtcnn.py)."""
|
| 163 |
+
logger.info('Extracting faces from video: %s', video_path)
|
| 164 |
+
faces = []
|
| 165 |
+
cap = cv2.VideoCapture(video_path)
|
| 166 |
+
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
| 167 |
+
if frame_rate == 0:
|
| 168 |
+
logger.warning('Could not read frame rate from video')
|
| 169 |
+
cap.release()
|
| 170 |
+
return faces
|
| 171 |
+
|
| 172 |
+
while cap.isOpened():
|
| 173 |
+
frame_id = cap.get(cv2.CAP_PROP_POS_FRAMES)
|
| 174 |
+
ret, frame = cap.read()
|
| 175 |
+
if not ret:
|
| 176 |
+
break
|
| 177 |
+
if frame_id % math.floor(frame_rate) == 0:
|
| 178 |
+
# Step 1: Scale frame (same as 00-convert_video_to_image.py)
|
| 179 |
+
frame = scale_frame(frame)
|
| 180 |
+
image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
| 181 |
+
h, w = image_rgb.shape[:2]
|
| 182 |
+
|
| 183 |
+
# Step 2: MTCNN face detection (same as 01-crop_faces_with_mtcnn.py)
|
| 184 |
+
results = mtcnn_detector.detect_faces(image_rgb)
|
| 185 |
+
num_faces = len(results)
|
| 186 |
+
|
| 187 |
+
for result in results:
|
| 188 |
+
bounding_box = result['box']
|
| 189 |
+
confidence = result['confidence']
|
| 190 |
+
# Same logic as training: if single face keep it, if multiple only keep > 0.95
|
| 191 |
+
if num_faces < 2 or confidence > 0.95:
|
| 192 |
+
bx, by, bw, bh = bounding_box
|
| 193 |
+
margin_x = bw * 0.3
|
| 194 |
+
margin_y = bh * 0.3
|
| 195 |
+
x1 = int(max(0, bx - margin_x))
|
| 196 |
+
x2 = int(min(w, bx + bw + margin_x))
|
| 197 |
+
y1 = int(max(0, by - margin_y))
|
| 198 |
+
y2 = int(min(h, by + bh + margin_y))
|
| 199 |
+
crop = image_rgb[y1:y2, x1:x2]
|
| 200 |
+
# Step 3: Filter small faces (same as 02-prepare_fake_real_dataset.py MIN_IMAGE_SIZE=90)
|
| 201 |
+
if crop.shape[0] < MIN_FACE_SIZE or crop.shape[1] < MIN_FACE_SIZE:
|
| 202 |
+
continue
|
| 203 |
+
if crop.size > 0:
|
| 204 |
+
crop_resized = cv2.resize(crop, (INPUT_SIZE, INPUT_SIZE))
|
| 205 |
+
faces.append(crop_resized)
|
| 206 |
+
|
| 207 |
+
cap.release()
|
| 208 |
+
logger.info('Face extraction complete — %d faces found', len(faces))
|
| 209 |
+
return faces
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def create_processed_video(video_path, output_path, face_scores=None):
|
| 213 |
+
"""Create video with face bounding boxes using ffmpeg drawbox (much faster than OpenCV)."""
|
| 214 |
+
logger.info('Creating processed video with bounding boxes: %s', output_path)
|
| 215 |
+
|
| 216 |
+
if face_detector is None:
|
| 217 |
+
logger.info('Preview face detector unavailable; re-encoding original video without overlays')
|
| 218 |
+
reencode_to_h264(video_path, output_path)
|
| 219 |
+
return
|
| 220 |
+
|
| 221 |
+
cap = cv2.VideoCapture(video_path)
|
| 222 |
+
fps = cap.get(cv2.CAP_PROP_FPS) or 30
|
| 223 |
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
| 224 |
+
duration = total_frames / fps if fps > 0 else 0
|
| 225 |
+
|
| 226 |
+
# Sample a few frames spread across the video to detect faces
|
| 227 |
+
sample_count = min(5, max(1, int(duration))) # ~1 sample per second, max 5
|
| 228 |
+
sample_positions = [int(i * total_frames / sample_count) for i in range(sample_count)]
|
| 229 |
+
|
| 230 |
+
# Collect all face boxes across sampled frames
|
| 231 |
+
all_boxes = []
|
| 232 |
+
for pos in sample_positions:
|
| 233 |
+
cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
|
| 234 |
+
ret, frame = cap.read()
|
| 235 |
+
if not ret:
|
| 236 |
+
continue
|
| 237 |
+
results = face_detector(frame, verbose=False)[0]
|
| 238 |
+
for box in results.boxes:
|
| 239 |
+
if box.conf[0] > 0.5:
|
| 240 |
+
bx1, by1, bx2, by2 = map(int, box.xyxy[0])
|
| 241 |
+
all_boxes.append((max(0, bx1), max(0, by1), bx2, by2))
|
| 242 |
+
|
| 243 |
+
cap.release()
|
| 244 |
+
|
| 245 |
+
# Build ffmpeg drawbox filter from detected boxes
|
| 246 |
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 247 |
+
if all_boxes:
|
| 248 |
+
# Use the most common box region (largest by area) for a stable overlay
|
| 249 |
+
# Deduplicate similar boxes by averaging nearby ones
|
| 250 |
+
unique_boxes = []
|
| 251 |
+
for box in all_boxes:
|
| 252 |
+
merged = False
|
| 253 |
+
for i, ub in enumerate(unique_boxes):
|
| 254 |
+
# If boxes overlap significantly, merge them
|
| 255 |
+
if (abs(box[0] - ub[0]) < 40 and abs(box[1] - ub[1]) < 40 and
|
| 256 |
+
abs(box[2] - ub[2]) < 40 and abs(box[3] - ub[3]) < 40):
|
| 257 |
+
unique_boxes[i] = (
|
| 258 |
+
(ub[0] + box[0]) // 2, (ub[1] + box[1]) // 2,
|
| 259 |
+
(ub[2] + box[2]) // 2, (ub[3] + box[3]) // 2
|
| 260 |
+
)
|
| 261 |
+
merged = True
|
| 262 |
+
break
|
| 263 |
+
if not merged:
|
| 264 |
+
unique_boxes.append(box)
|
| 265 |
+
|
| 266 |
+
drawbox_filters = []
|
| 267 |
+
for (x1, y1, x2, y2) in unique_boxes:
|
| 268 |
+
w = x2 - x1
|
| 269 |
+
h = y2 - y1
|
| 270 |
+
drawbox_filters.append(f"drawbox=x={x1}:y={y1}:w={w}:h={h}:color=green:t=2")
|
| 271 |
+
filter_str = ','.join(drawbox_filters)
|
| 272 |
+
else:
|
| 273 |
+
filter_str = 'null'
|
| 274 |
+
|
| 275 |
+
cmd = [
|
| 276 |
+
ffmpeg_exe, '-y', '-i', video_path,
|
| 277 |
+
'-vf', filter_str,
|
| 278 |
+
'-c:v', 'libx264', '-preset', 'fast',
|
| 279 |
+
'-movflags', '+faststart', '-pix_fmt', 'yuv420p',
|
| 280 |
+
output_path
|
| 281 |
+
]
|
| 282 |
+
logger.info('Running ffmpeg with %d face boxes', len(all_boxes))
|
| 283 |
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
| 284 |
+
if result.returncode != 0:
|
| 285 |
+
logger.error('ffmpeg drawbox failed: %s', result.stderr[-500:])
|
| 286 |
+
else:
|
| 287 |
+
logger.info('Processed video saved: %s', output_path)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def predict_deepfake(faces):
|
| 291 |
+
if not faces:
|
| 292 |
+
logger.warning('No faces to predict on')
|
| 293 |
+
return None, 0, []
|
| 294 |
+
|
| 295 |
+
logger.info('Running prediction on %d face(s)', len(faces))
|
| 296 |
+
|
| 297 |
+
face_array = preprocess_input(np.array(faces, dtype='float32'))
|
| 298 |
+
predictions = model.predict(face_array, verbose=0)
|
| 299 |
+
flat_preds = predictions.flatten()
|
| 300 |
+
# Use top-K mean: average the top 30% of predictions (at least 3)
|
| 301 |
+
# Rationale: real videos have many high-confidence real frames; fake videos have NONE
|
| 302 |
+
sorted_desc = np.sort(flat_preds)[::-1] # highest first
|
| 303 |
+
k = max(3, int(len(sorted_desc) * 0.3))
|
| 304 |
+
top_k = sorted_desc[:k]
|
| 305 |
+
avg_prediction = float(np.mean(top_k))
|
| 306 |
+
# Write diagnostics to file
|
| 307 |
+
diag_path = os.path.join(os.path.dirname(__file__), 'diag_log.txt')
|
| 308 |
+
with open(diag_path, 'a') as f:
|
| 309 |
+
f.write(f'Raw predictions: min={float(np.min(predictions)):.4f}, max={float(np.max(predictions)):.4f}, top{k}_mean={avg_prediction:.4f}, mean={float(np.mean(predictions)):.4f}\n')
|
| 310 |
+
f.write(f'All scores (sorted desc): {sorted_desc.tolist()}\n')
|
| 311 |
+
f.write(f'Top-{k} used: {top_k.tolist()}\n')
|
| 312 |
+
f.write(f'Num faces: {len(faces)}\n\n')
|
| 313 |
+
logger.info('Raw predictions: min=%.4f, max=%.4f, top%d_mean=%.4f, mean=%.4f, n=%d',
|
| 314 |
+
float(np.min(predictions)), float(np.max(predictions)),
|
| 315 |
+
k, avg_prediction, float(np.mean(flat_preds)), len(flat_preds))
|
| 316 |
+
|
| 317 |
+
# Build per-face details (up to 5 faces sorted by relevance)
|
| 318 |
+
is_real = avg_prediction > 0.5
|
| 319 |
+
# Sort face indices by score: highest first for REAL, lowest first for FAKE
|
| 320 |
+
sorted_indices = np.argsort(flat_preds)[::-1] if is_real else np.argsort(flat_preds)
|
| 321 |
+
indices = sorted_indices[:5].tolist()
|
| 322 |
+
|
| 323 |
+
faces_detail = []
|
| 324 |
+
for i in indices:
|
| 325 |
+
faces_detail.append({
|
| 326 |
+
'thumbnail': face_to_base64(faces[i]),
|
| 327 |
+
'score': float(predictions[i][0])
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
logger.info('Prediction complete — avg score: %.4f, faces: %d', avg_prediction, len(faces))
|
| 331 |
+
return avg_prediction, len(faces), faces_detail
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def cleanup_old_uploads(exclude=None):
|
| 335 |
+
"""Delete all files in the upload folder except those in exclude."""
|
| 336 |
+
exclude = set(exclude or [])
|
| 337 |
+
folder = app.config['UPLOAD_FOLDER']
|
| 338 |
+
for f in os.listdir(folder):
|
| 339 |
+
fpath = os.path.join(folder, f)
|
| 340 |
+
if os.path.isfile(fpath) and fpath not in exclude:
|
| 341 |
+
try:
|
| 342 |
+
os.remove(fpath)
|
| 343 |
+
except PermissionError:
|
| 344 |
+
pass
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
from route import routes
|
| 348 |
+
app.register_blueprint(routes)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
if __name__ == '__main__':
|
| 352 |
+
port = resolve_server_port()
|
| 353 |
+
logger.info('Starting Flask server on http://0.0.0.0:%s', port)
|
| 354 |
+
app.run(debug=False, host='0.0.0.0', port=port)
|
App/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
DEFAULT_APP_PORT = 5001
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def resolve_server_port(default=DEFAULT_APP_PORT):
|
| 8 |
+
raw_value = os.getenv('PORT')
|
| 9 |
+
if raw_value is None:
|
| 10 |
+
return default
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
port = int(raw_value)
|
| 14 |
+
except ValueError:
|
| 15 |
+
return default
|
| 16 |
+
|
| 17 |
+
if 1 <= port <= 65535:
|
| 18 |
+
return port
|
| 19 |
+
return default
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def preview_face_detector_enabled():
|
| 23 |
+
raw_value = os.getenv('ENABLE_PREVIEW_FACE_DETECTOR', '1').strip().lower()
|
| 24 |
+
return raw_value not in {'0', 'false', 'no', 'off'}
|
App/route.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import logging
|
| 3 |
+
import uuid
|
| 4 |
+
import threading
|
| 5 |
+
from flask import Blueprint, request, render_template, send_from_directory, jsonify
|
| 6 |
+
from werkzeug.utils import secure_filename
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
routes = Blueprint('routes', __name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _get_app_deps():
|
| 14 |
+
"""Import app-level objects to avoid circular imports."""
|
| 15 |
+
from app import (
|
| 16 |
+
app, jobs, allowed_file, cleanup_old_uploads,
|
| 17 |
+
extract_faces_from_video, predict_deepfake,
|
| 18 |
+
create_processed_video, reencode_to_h264
|
| 19 |
+
)
|
| 20 |
+
return app, jobs, allowed_file, cleanup_old_uploads, \
|
| 21 |
+
extract_faces_from_video, predict_deepfake, \
|
| 22 |
+
create_processed_video, reencode_to_h264
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@routes.route('/', methods=['GET'])
|
| 26 |
+
def index():
|
| 27 |
+
return render_template('index.html')
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@routes.route('/uploads/<filename>')
|
| 31 |
+
def uploaded_video(filename):
|
| 32 |
+
from app import app
|
| 33 |
+
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, mimetype='video/mp4')
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def process_video_job(job_id, filepath, unique_name):
|
| 37 |
+
"""Background worker: extract faces, predict, create processed video."""
|
| 38 |
+
app, jobs, _, _, extract_faces_from_video, predict_deepfake, \
|
| 39 |
+
create_processed_video, _ = _get_app_deps()
|
| 40 |
+
try:
|
| 41 |
+
logger.info('[Job %s] Starting face detection', job_id)
|
| 42 |
+
jobs[job_id]['status'] = 'detecting'
|
| 43 |
+
|
| 44 |
+
faces = extract_faces_from_video(filepath)
|
| 45 |
+
avg_score, num_faces, faces_detail = predict_deepfake(faces)
|
| 46 |
+
|
| 47 |
+
if avg_score is None:
|
| 48 |
+
logger.warning('[Job %s] No faces detected', job_id)
|
| 49 |
+
jobs[job_id].update({
|
| 50 |
+
'status': 'done',
|
| 51 |
+
'error': 'No faces detected in the video.',
|
| 52 |
+
'video_url': f'/uploads/{unique_name}',
|
| 53 |
+
})
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
is_real = avg_score > 0.5
|
| 57 |
+
label = 'REAL' if is_real else 'FAKE'
|
| 58 |
+
confidence = avg_score if is_real else (1 - avg_score)
|
| 59 |
+
|
| 60 |
+
logger.info('[Job %s] Detection done — result: %s, confidence: %.2f%%, faces: %d',
|
| 61 |
+
job_id, label, confidence * 100, num_faces)
|
| 62 |
+
jobs[job_id].update({
|
| 63 |
+
'status': 'processing_video',
|
| 64 |
+
'result': label,
|
| 65 |
+
'confidence': round(confidence * 100, 2),
|
| 66 |
+
'score': round(avg_score, 4),
|
| 67 |
+
'num_faces': num_faces,
|
| 68 |
+
'faces_detail': faces_detail,
|
| 69 |
+
'video_url': f'/uploads/{unique_name}',
|
| 70 |
+
})
|
| 71 |
+
|
| 72 |
+
logger.info('[Job %s] Starting video processing', job_id)
|
| 73 |
+
processed_name = f"processed_{unique_name}"
|
| 74 |
+
processed_path = os.path.join(app.config['UPLOAD_FOLDER'], processed_name)
|
| 75 |
+
create_processed_video(filepath, processed_path)
|
| 76 |
+
|
| 77 |
+
logger.info('[Job %s] Video processing done', job_id)
|
| 78 |
+
jobs[job_id].update({
|
| 79 |
+
'status': 'done',
|
| 80 |
+
'processed_url': f'/uploads/{processed_name}',
|
| 81 |
+
})
|
| 82 |
+
except Exception as e:
|
| 83 |
+
logger.error('[Job %s] Error: %s', job_id, e)
|
| 84 |
+
jobs[job_id].update({'status': 'done', 'error': str(e)})
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@routes.route('/predict', methods=['POST'])
|
| 88 |
+
def predict():
|
| 89 |
+
app, jobs, allowed_file, cleanup_old_uploads, _, _, _, reencode_to_h264 = _get_app_deps()
|
| 90 |
+
|
| 91 |
+
if 'video' not in request.files:
|
| 92 |
+
return jsonify({'error': 'No video file uploaded.'}), 400
|
| 93 |
+
|
| 94 |
+
file = request.files['video']
|
| 95 |
+
if file.filename == '':
|
| 96 |
+
return jsonify({'error': 'No file selected.'}), 400
|
| 97 |
+
|
| 98 |
+
if not allowed_file(file.filename):
|
| 99 |
+
return jsonify({'error': 'Invalid file type. Allowed: mp4, avi, mov, mkv, wmv'}), 400
|
| 100 |
+
|
| 101 |
+
cleanup_old_uploads()
|
| 102 |
+
|
| 103 |
+
ext = secure_filename(file.filename).rsplit('.', 1)[1].lower()
|
| 104 |
+
unique_name = f"{uuid.uuid4().hex}.{ext}"
|
| 105 |
+
filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_name)
|
| 106 |
+
file.save(filepath)
|
| 107 |
+
logger.info('Video uploaded: %s (%s)', file.filename, unique_name)
|
| 108 |
+
|
| 109 |
+
logger.info('Re-encoding uploaded video to H.264')
|
| 110 |
+
reencode_to_h264(filepath)
|
| 111 |
+
|
| 112 |
+
job_id = uuid.uuid4().hex
|
| 113 |
+
logger.info('Created job %s for %s', job_id, unique_name)
|
| 114 |
+
jobs[job_id] = {'status': 'uploading', 'video_url': f'/uploads/{unique_name}'}
|
| 115 |
+
|
| 116 |
+
thread = threading.Thread(target=process_video_job, args=(job_id, filepath, unique_name))
|
| 117 |
+
thread.start()
|
| 118 |
+
|
| 119 |
+
return jsonify({'job_id': job_id, 'video_url': f'/uploads/{unique_name}'})
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@routes.route('/status/<job_id>')
|
| 123 |
+
def job_status(job_id):
|
| 124 |
+
from app import jobs
|
| 125 |
+
job = jobs.get(job_id)
|
| 126 |
+
if not job:
|
| 127 |
+
return jsonify({'error': 'Job not found'}), 404
|
| 128 |
+
return jsonify(job)
|
App/static/Product.jsx
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Upload Area ── */
|
| 2 |
+
function UploadArea({ file, onFileChange }) {
|
| 3 |
+
const inputRef = React.useRef();
|
| 4 |
+
return (
|
| 5 |
+
<div
|
| 6 |
+
className={`upload-area${file ? ' has-file' : ''}`}
|
| 7 |
+
onClick={() => inputRef.current.click()}
|
| 8 |
+
>
|
| 9 |
+
<div className="upload-text">
|
| 10 |
+
Drop a video here or click to upload — MP4, AVI, MOV, max 200 MB
|
| 11 |
+
</div>
|
| 12 |
+
{file && <div className="file-name">{file.name}</div>}
|
| 13 |
+
<input
|
| 14 |
+
ref={inputRef}
|
| 15 |
+
type="file"
|
| 16 |
+
accept=".mp4,.avi,.mov,.mkv,.wmv"
|
| 17 |
+
onChange={e => { onFileChange(e.target.files[0] || null); e.target.value = ''; }}
|
| 18 |
+
/>
|
| 19 |
+
</div>
|
| 20 |
+
);
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* ── Status Spinner ── */
|
| 24 |
+
function StatusIndicator({ status }) {
|
| 25 |
+
if (!status || status === 'done') return null;
|
| 26 |
+
return (
|
| 27 |
+
<>
|
| 28 |
+
<div className="spinner" />
|
| 29 |
+
<p className="processing-text">{STATUS_MESSAGES[status] || 'Processing\u2026'}</p>
|
| 30 |
+
</>
|
| 31 |
+
);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/* ── Video Comparison ── */
|
| 35 |
+
const VideoComparison = React.memo(function VideoComparison({ file, processedUrl, isProcessing }) {
|
| 36 |
+
const videoRef = React.useRef(null);
|
| 37 |
+
const [localUrl, setLocalUrl] = React.useState(null);
|
| 38 |
+
|
| 39 |
+
React.useEffect(() => {
|
| 40 |
+
if (file) {
|
| 41 |
+
const url = URL.createObjectURL(file);
|
| 42 |
+
setLocalUrl(url);
|
| 43 |
+
return () => URL.revokeObjectURL(url);
|
| 44 |
+
}
|
| 45 |
+
setLocalUrl(null);
|
| 46 |
+
}, [file]);
|
| 47 |
+
|
| 48 |
+
if (!localUrl) return null;
|
| 49 |
+
const showProcessed = processedUrl || isProcessing;
|
| 50 |
+
return (
|
| 51 |
+
<section className="video-compare">
|
| 52 |
+
<h2>{showProcessed ? 'Face Detection' : 'Uploaded Video'}</h2>
|
| 53 |
+
<div className={`compare-grid${showProcessed ? '' : ' single'}`}>
|
| 54 |
+
<div className="compare-item">
|
| 55 |
+
<video ref={videoRef} controls src={localUrl} />
|
| 56 |
+
<div className="compare-label original">Original</div>
|
| 57 |
+
</div>
|
| 58 |
+
{showProcessed && (
|
| 59 |
+
<div className="compare-item">
|
| 60 |
+
{processedUrl
|
| 61 |
+
? <video controls src={processedUrl} />
|
| 62 |
+
: <div className="preview-placeholder"><div className="spinner" /></div>
|
| 63 |
+
}
|
| 64 |
+
<div className="compare-label detected">
|
| 65 |
+
{processedUrl ? 'Detected Faces' : 'Generating\u2026'}
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
)}
|
| 69 |
+
</div>
|
| 70 |
+
</section>
|
| 71 |
+
);
|
| 72 |
+
});
|
| 73 |
+
|
| 74 |
+
/* ── Face Row ── */
|
| 75 |
+
function FaceRow({ face, index }) {
|
| 76 |
+
const pct = (face.score * 100).toFixed(2);
|
| 77 |
+
const w = (face.score * 100).toFixed(1);
|
| 78 |
+
return (
|
| 79 |
+
<div className="face-row">
|
| 80 |
+
<img className="face-thumb" src={`data:image/png;base64,${face.thumbnail}`} alt={`Face ${index + 1}`} />
|
| 81 |
+
<div className="face-info">
|
| 82 |
+
<div className="face-bar-track">
|
| 83 |
+
<div className={`face-bar-fill ${barClass(face.score)}`} style={{ width: `${w}%` }} />
|
| 84 |
+
</div>
|
| 85 |
+
<div className={`face-score ${scoreClass(face.score)}`}>{pct}% authentic</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/* ── Results Panel ── */
|
| 92 |
+
function ResultsPanel({ data }) {
|
| 93 |
+
if (!data || !data.result) return null;
|
| 94 |
+
const cls = data.result.toLowerCase();
|
| 95 |
+
return (
|
| 96 |
+
<section className="results-section">
|
| 97 |
+
<div className="results-panel">
|
| 98 |
+
<h2>Results</h2>
|
| 99 |
+
<p className="results-hint">
|
| 100 |
+
Authenticity score: likelihood the face is real.{' '}
|
| 101 |
+
<span className="score-red">Red <20%</span>,{' '}
|
| 102 |
+
<span className="score-orange">Orange 20-80%</span>,{' '}
|
| 103 |
+
<span className="score-green">Green >80%</span>.
|
| 104 |
+
</p>
|
| 105 |
+
<div className={`overall-result ${cls}`}>
|
| 106 |
+
<div className="overall-label">{data.result}</div>
|
| 107 |
+
<div className="overall-details">
|
| 108 |
+
Confidence: {data.confidence}%<br />
|
| 109 |
+
Model Score: {data.score}<br />
|
| 110 |
+
Faces Analyzed: {data.num_faces}
|
| 111 |
+
</div>
|
| 112 |
+
</div>
|
| 113 |
+
{data.faces_detail && data.faces_detail.map((face, i) => (
|
| 114 |
+
<FaceRow key={i} face={face} index={i} />
|
| 115 |
+
))}
|
| 116 |
+
</div>
|
| 117 |
+
</section>
|
| 118 |
+
);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
/* ── Product Page ── */
|
| 122 |
+
function ProductPage({ file, setFile, status, setStatus, error, setError, result, setResult, submitting, setSubmitting }) {
|
| 123 |
+
const timerRef = React.useRef(null);
|
| 124 |
+
|
| 125 |
+
const reset = () => { setResult(null); setError(null); setStatus(null); };
|
| 126 |
+
const handleFileChange = (f) => { setFile(f); reset(); };
|
| 127 |
+
|
| 128 |
+
const pollJob = React.useCallback((jobId) => {
|
| 129 |
+
timerRef.current = setTimeout(async () => {
|
| 130 |
+
try {
|
| 131 |
+
const res = await fetch(`/status/${jobId}`);
|
| 132 |
+
const data = await res.json();
|
| 133 |
+
setStatus(data.status);
|
| 134 |
+
|
| 135 |
+
if (data.result) {
|
| 136 |
+
setResult(prev => ({ ...prev, ...data }));
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
if (data.status === 'done') {
|
| 140 |
+
setSubmitting(false);
|
| 141 |
+
if (data.error && !data.result) setError(data.error);
|
| 142 |
+
} else {
|
| 143 |
+
pollJob(jobId);
|
| 144 |
+
}
|
| 145 |
+
} catch {
|
| 146 |
+
setSubmitting(false);
|
| 147 |
+
setStatus(null);
|
| 148 |
+
setError('Connection lost. Please try again.');
|
| 149 |
+
}
|
| 150 |
+
}, 1000);
|
| 151 |
+
}, []);
|
| 152 |
+
|
| 153 |
+
React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
|
| 154 |
+
|
| 155 |
+
const handleSubmit = async (e) => {
|
| 156 |
+
e.preventDefault();
|
| 157 |
+
if (!file) return;
|
| 158 |
+
reset();
|
| 159 |
+
setSubmitting(true);
|
| 160 |
+
setStatus('uploading');
|
| 161 |
+
|
| 162 |
+
const fd = new FormData();
|
| 163 |
+
fd.append('video', file);
|
| 164 |
+
|
| 165 |
+
try {
|
| 166 |
+
const res = await fetch('/predict', { method: 'POST', body: fd });
|
| 167 |
+
const data = await res.json();
|
| 168 |
+
if (data.error) {
|
| 169 |
+
setError(data.error);
|
| 170 |
+
setSubmitting(false);
|
| 171 |
+
setStatus(null);
|
| 172 |
+
} else {
|
| 173 |
+
pollJob(data.job_id);
|
| 174 |
+
}
|
| 175 |
+
} catch {
|
| 176 |
+
setError('Upload failed. Please try again.');
|
| 177 |
+
setSubmitting(false);
|
| 178 |
+
setStatus(null);
|
| 179 |
+
}
|
| 180 |
+
};
|
| 181 |
+
|
| 182 |
+
return (
|
| 183 |
+
<>
|
| 184 |
+
<section className="hero">
|
| 185 |
+
<div className="hero-left">
|
| 186 |
+
<h1 className="hero-title">AI-Deepfake Video Detection</h1>
|
| 187 |
+
<p className="hero-desc">
|
| 188 |
+
Free deepfake detection tool for videos. Upload a video and get
|
| 189 |
+
per-face authenticity scores in seconds. AI-powered synthetic face detection.
|
| 190 |
+
</p>
|
| 191 |
+
|
| 192 |
+
<form onSubmit={handleSubmit}>
|
| 193 |
+
<UploadArea file={file} onFileChange={handleFileChange} />
|
| 194 |
+
<button type="submit" className="btn" disabled={!file || submitting}>
|
| 195 |
+
{submitting ? (STATUS_MESSAGES[status] || 'Processing\u2026') : 'Analyze Video'}
|
| 196 |
+
</button>
|
| 197 |
+
</form>
|
| 198 |
+
|
| 199 |
+
<StatusIndicator status={submitting ? status : null} />
|
| 200 |
+
{error && <div className="error-box">{error}</div>}
|
| 201 |
+
</div>
|
| 202 |
+
|
| 203 |
+
<div className="hero-right">
|
| 204 |
+
<img src="/static/images.png" alt="AI Deepfake Detection" className="hero-image" />
|
| 205 |
+
</div>
|
| 206 |
+
</section>
|
| 207 |
+
|
| 208 |
+
<VideoComparison
|
| 209 |
+
file={file}
|
| 210 |
+
processedUrl={result?.processed_url}
|
| 211 |
+
isProcessing={submitting}
|
| 212 |
+
/>
|
| 213 |
+
<ResultsPanel data={result} />
|
| 214 |
+
</>
|
| 215 |
+
);
|
| 216 |
+
}
|
App/static/Technology.jsx
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ── Step Diagram SVG ── */
|
| 2 |
+
function StepDiagram({ number, title, color }) {
|
| 3 |
+
return (
|
| 4 |
+
<svg width="80" height="80" viewBox="0 0 80 80" className="step-icon">
|
| 5 |
+
<circle cx="40" cy="40" r="36" fill="none" stroke={color} strokeWidth="2.5" />
|
| 6 |
+
<text x="40" y="46" textAnchor="middle" fill={color} fontSize="28" fontWeight="700">{number}</text>
|
| 7 |
+
</svg>
|
| 8 |
+
);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
/* ── Pipeline Flow Arrow ── */
|
| 12 |
+
function FlowArrow() {
|
| 13 |
+
return (
|
| 14 |
+
<div className="flow-arrow">
|
| 15 |
+
<svg width="40" height="40" viewBox="0 0 40 40">
|
| 16 |
+
<path d="M10 20 L28 20 M22 13 L30 20 L22 27" fill="none" stroke="#6c8cff" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
| 17 |
+
</svg>
|
| 18 |
+
</div>
|
| 19 |
+
);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/* ── Technology Page ── */
|
| 23 |
+
function TechnologyPage() {
|
| 24 |
+
const steps = [
|
| 25 |
+
{
|
| 26 |
+
number: 1,
|
| 27 |
+
title: 'Video to Frames',
|
| 28 |
+
color: '#6c8cff',
|
| 29 |
+
description: 'Raw training videos are split into individual image frames. One frame is extracted per second of video using OpenCV. Each frame is automatically scaled based on its resolution to normalize image sizes across the dataset.',
|
| 30 |
+
details: [
|
| 31 |
+
'Reads MP4 videos from the FaceForensics++ dataset',
|
| 32 |
+
'Extracts 1 frame per second (at the video\'s native frame rate)',
|
| 33 |
+
'Auto-scales: 2\u00d7 for small frames (<300px), 0.5\u00d7 for HD, 0.33\u00d7 for Full HD+',
|
| 34 |
+
'Saves frames as individual JPG images organized by video',
|
| 35 |
+
],
|
| 36 |
+
diagram: (
|
| 37 |
+
<svg viewBox="0 0 400 120" className="step-illustration">
|
| 38 |
+
<rect x="10" y="15" width="90" height="90" rx="8" fill="#1a1a2e" stroke="#6c8cff" strokeWidth="1.5"/>
|
| 39 |
+
<polygon points="40,35 40,80 70,57" fill="#6c8cff" opacity="0.8"/>
|
| 40 |
+
<text x="55" y="112" textAnchor="middle" fill="#888" fontSize="11">Video</text>
|
| 41 |
+
<path d="M115 60 L155 60" stroke="#6c8cff" strokeWidth="2" markerEnd="url(#arrow1)"/>
|
| 42 |
+
<defs><marker id="arrow1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#6c8cff"/></marker></defs>
|
| 43 |
+
{[0,1,2,3,4].map(i => (
|
| 44 |
+
<g key={i}>
|
| 45 |
+
<rect x={165 + i*46} y={20 + (i%2)*15} width="38" height="38" rx="4" fill="#1a1a2e" stroke="#4caf50" strokeWidth="1"/>
|
| 46 |
+
<text x={184 + i*46} y={44 + (i%2)*15} textAnchor="middle" fill="#4caf50" fontSize="9">F{i+1}</text>
|
| 47 |
+
</g>
|
| 48 |
+
))}
|
| 49 |
+
<text x="300" y="112" textAnchor="middle" fill="#888" fontSize="11">Extracted Frames (1/sec)</text>
|
| 50 |
+
</svg>
|
| 51 |
+
),
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
number: 2,
|
| 55 |
+
title: 'Face Detection & Cropping',
|
| 56 |
+
color: '#ff9800',
|
| 57 |
+
description: 'MTCNN (Multi-task Cascaded Convolutional Network) scans each extracted frame to detect faces. Detected faces are cropped with a 30% margin around the bounding box to preserve context like hair and jawline, which helps the model detect manipulation artifacts.',
|
| 58 |
+
details: [
|
| 59 |
+
'Uses MTCNN deep learning face detector for accurate face localization',
|
| 60 |
+
'Filters low-confidence detections (>95% threshold for multi-face frames)',
|
| 61 |
+
'Adds 30% margin around each face bounding box',
|
| 62 |
+
'Crops and saves individual face images for training',
|
| 63 |
+
],
|
| 64 |
+
diagram: (
|
| 65 |
+
<svg viewBox="0 0 400 120" className="step-illustration">
|
| 66 |
+
<rect x="10" y="10" width="100" height="100" rx="6" fill="#1a1a2e" stroke="#444" strokeWidth="1"/>
|
| 67 |
+
<circle cx="60" cy="45" r="15" fill="none" stroke="#ff9800" strokeWidth="1.5" strokeDasharray="3,2"/>
|
| 68 |
+
<circle cx="60" cy="42" r="6" fill="#ff9800" opacity="0.4"/>
|
| 69 |
+
<ellipse cx="60" cy="55" rx="10" ry="6" fill="#ff9800" opacity="0.3"/>
|
| 70 |
+
<rect x="35" y="25" width="50" height="50" rx="4" fill="none" stroke="#ff9800" strokeWidth="2"/>
|
| 71 |
+
<text x="60" y="112" textAnchor="middle" fill="#888" fontSize="11">Frame + Detection</text>
|
| 72 |
+
<path d="M125 55 L165 55" stroke="#ff9800" strokeWidth="2" markerEnd="url(#arrow2)"/>
|
| 73 |
+
<defs><marker id="arrow2" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#ff9800"/></marker></defs>
|
| 74 |
+
<rect x="175" y="20" width="70" height="70" rx="8" fill="#1a1a2e" stroke="#ff9800" strokeWidth="2"/>
|
| 75 |
+
<circle cx="210" cy="45" r="12" fill="none" stroke="#ff9800" strokeWidth="1.5"/>
|
| 76 |
+
<circle cx="210" cy="42" r="5" fill="#ff9800" opacity="0.5"/>
|
| 77 |
+
<ellipse cx="210" cy="52" rx="8" ry="5" fill="#ff9800" opacity="0.4"/>
|
| 78 |
+
<text x="210" y="112" textAnchor="middle" fill="#888" fontSize="11">Cropped Face (+30% margin)</text>
|
| 79 |
+
<text x="340" y="40" fill="#ff9800" fontSize="11" fontWeight="600">{'\u2713'} 95%+ confidence</text>
|
| 80 |
+
<text x="340" y="58" fill="#888" fontSize="10">30% margin padding</text>
|
| 81 |
+
<text x="340" y="76" fill="#888" fontSize="10">Context preserved</text>
|
| 82 |
+
</svg>
|
| 83 |
+
),
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
number: 3,
|
| 87 |
+
title: 'Dataset Preparation',
|
| 88 |
+
color: '#4caf50',
|
| 89 |
+
description: 'Cropped face images are organized into "real" and "fake" categories based on FaceForensics++ metadata. Small or corrupted images (<90px) are filtered out. The dataset is then split into training (80%), validation (10%), and test (10%) sets using stratified splitting.',
|
| 90 |
+
details: [
|
| 91 |
+
'Labels faces as REAL or FAKE using FaceForensics++ CSV metadata',
|
| 92 |
+
'Filters out low-quality images smaller than 90\u00d790 pixels',
|
| 93 |
+
'Balances fake samples across manipulation methods (Deepfakes, Face2Face, FaceSwap, NeuralTextures, FaceShifter)',
|
| 94 |
+
'Splits into train/val/test (80/10/10) with stratified random sampling',
|
| 95 |
+
],
|
| 96 |
+
diagram: (
|
| 97 |
+
<svg viewBox="0 0 400 120" className="step-illustration">
|
| 98 |
+
<g>
|
| 99 |
+
<rect x="10" y="15" width="55" height="40" rx="4" fill="rgba(76,175,80,0.15)" stroke="#4caf50" strokeWidth="1.5"/>
|
| 100 |
+
<text x="37" y="39" textAnchor="middle" fill="#4caf50" fontSize="11" fontWeight="600">REAL</text>
|
| 101 |
+
<rect x="10" y="65" width="55" height="40" rx="4" fill="rgba(244,67,54,0.15)" stroke="#f44336" strokeWidth="1.5"/>
|
| 102 |
+
<text x="37" y="89" textAnchor="middle" fill="#f44336" fontSize="11" fontWeight="600">FAKE</text>
|
| 103 |
+
</g>
|
| 104 |
+
<path d="M80 55 L120 55" stroke="#4caf50" strokeWidth="2" markerEnd="url(#arrow3)"/>
|
| 105 |
+
<defs><marker id="arrow3" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4caf50"/></marker></defs>
|
| 106 |
+
<rect x="130" y="8" width="100" height="28" rx="4" fill="rgba(76,175,80,0.1)" stroke="#4caf50" strokeWidth="1"/>
|
| 107 |
+
<text x="180" y="26" textAnchor="middle" fill="#4caf50" fontSize="10">Train 80%</text>
|
| 108 |
+
<rect x="130" y="44" width="100" height="28" rx="4" fill="rgba(255,152,0,0.1)" stroke="#ff9800" strokeWidth="1"/>
|
| 109 |
+
<text x="180" y="62" textAnchor="middle" fill="#ff9800" fontSize="10">Val 10%</text>
|
| 110 |
+
<rect x="130" y="80" width="100" height="28" rx="4" fill="rgba(108,140,255,0.1)" stroke="#6c8cff" strokeWidth="1"/>
|
| 111 |
+
<text x="180" y="98" textAnchor="middle" fill="#6c8cff" fontSize="10">Test 10%</text>
|
| 112 |
+
<text x="310" y="30" fill="#888" fontSize="10">{'\u2713'} Min 90\u00d790px filter</text>
|
| 113 |
+
<text x="310" y="50" fill="#888" fontSize="10">{'\u2713'} Stratified split</text>
|
| 114 |
+
<text x="310" y="70" fill="#888" fontSize="10">{'\u2713'} Multi-method balance</text>
|
| 115 |
+
<text x="310" y="90" fill="#888" fontSize="10">{'\u2713'} CSV metadata labels</text>
|
| 116 |
+
</svg>
|
| 117 |
+
),
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
number: 4,
|
| 121 |
+
title: 'CNN Training (EfficientNetB0)',
|
| 122 |
+
color: '#f44336',
|
| 123 |
+
description: 'A two-phase transfer learning approach trains an EfficientNetB0-based classifier. Phase 1 freezes the pre-trained ImageNet backbone and trains only the classification head. Phase 2 unfreezes the entire network for fine-tuning with a very low learning rate, achieving ~92% accuracy.',
|
| 124 |
+
details: [
|
| 125 |
+
'EfficientNetB0 backbone pre-trained on ImageNet (224\u00d7224 input)',
|
| 126 |
+
'Phase 1: Frozen base, train head only (lr=1e-3, up to 15 epochs)',
|
| 127 |
+
'Phase 2: Full fine-tuning (lr=1e-5, up to 30 epochs)',
|
| 128 |
+
'Data augmentation: rotation, flip, zoom, shift, brightness',
|
| 129 |
+
'Class weight balancing for imbalanced datasets',
|
| 130 |
+
'Callbacks: EarlyStopping, ReduceLROnPlateau, ModelCheckpoint',
|
| 131 |
+
'Output: binary sigmoid \u2014 score > 0.5 = REAL, \u2264 0.5 = FAKE',
|
| 132 |
+
],
|
| 133 |
+
diagram: (
|
| 134 |
+
<svg viewBox="0 0 400 140" className="step-illustration">
|
| 135 |
+
<rect x="10" y="30" width="70" height="80" rx="6" fill="#1a1a2e" stroke="#f44336" strokeWidth="1.5"/>
|
| 136 |
+
<text x="45" y="55" textAnchor="middle" fill="#f44336" fontSize="9" fontWeight="600">224\u00d7224</text>
|
| 137 |
+
<text x="45" y="70" textAnchor="middle" fill="#888" fontSize="8">Face Input</text>
|
| 138 |
+
<text x="45" y="95" textAnchor="middle" fill="#666" fontSize="8">preprocess</text>
|
| 139 |
+
<text x="45" y="106" textAnchor="middle" fill="#666" fontSize="8">[-1, 1]</text>
|
| 140 |
+
<path d="M90 70 L115 70" stroke="#f44336" strokeWidth="1.5" markerEnd="url(#arrow4)"/>
|
| 141 |
+
<rect x="120" y="15" width="100" height="110" rx="6" fill="#1a1a2e" stroke="#ff9800" strokeWidth="1.5"/>
|
| 142 |
+
<text x="170" y="35" textAnchor="middle" fill="#ff9800" fontSize="10" fontWeight="600">EfficientNetB0</text>
|
| 143 |
+
<text x="170" y="52" textAnchor="middle" fill="#888" fontSize="8">(ImageNet weights)</text>
|
| 144 |
+
{[0,1,2,3].map(i => (
|
| 145 |
+
<rect key={i} x="135" y={60 + i*14} width="70" height="10" rx="2" fill={`rgba(255,152,0,${0.15 + i*0.1})`} stroke="#ff9800" strokeWidth="0.5"/>
|
| 146 |
+
))}
|
| 147 |
+
<defs><marker id="arrow4" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#f44336"/></marker></defs>
|
| 148 |
+
<path d="M230 70 L255 70" stroke="#f44336" strokeWidth="1.5" markerEnd="url(#arrow4)"/>
|
| 149 |
+
<rect x="260" y="25" width="80" height="90" rx="6" fill="#1a1a2e" stroke="#6c8cff" strokeWidth="1.5"/>
|
| 150 |
+
<text x="300" y="45" textAnchor="middle" fill="#6c8cff" fontSize="9" fontWeight="600">Head</text>
|
| 151 |
+
<text x="300" y="62" textAnchor="middle" fill="#888" fontSize="7">GlobalAvgPool</text>
|
| 152 |
+
<text x="300" y="74" textAnchor="middle" fill="#888" fontSize="7">BatchNorm</text>
|
| 153 |
+
<text x="300" y="86" textAnchor="middle" fill="#888" fontSize="7">Dense(256)+Dropout</text>
|
| 154 |
+
<text x="300" y="98" textAnchor="middle" fill="#888" fontSize="7">Dense(1, sigmoid)</text>
|
| 155 |
+
<path d="M350 70 L375 70" stroke="#4caf50" strokeWidth="1.5" markerEnd="url(#arrow5)"/>
|
| 156 |
+
<defs><marker id="arrow5" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4caf50"/></marker></defs>
|
| 157 |
+
<text x="388" y="65" fill="#4caf50" fontSize="11" fontWeight="700">0\u20131</text>
|
| 158 |
+
<text x="388" y="80" fill="#888" fontSize="8">Score</text>
|
| 159 |
+
</svg>
|
| 160 |
+
),
|
| 161 |
+
},
|
| 162 |
+
];
|
| 163 |
+
|
| 164 |
+
return (
|
| 165 |
+
<section className="tech-page">
|
| 166 |
+
<div className="tech-hero">
|
| 167 |
+
<h1 className="tech-title">How It Works</h1>
|
| 168 |
+
<p className="tech-subtitle">
|
| 169 |
+
Our deepfake detection pipeline processes videos through four stages — from raw video
|
| 170 |
+
to a trained AI model that scores each face for authenticity.
|
| 171 |
+
</p>
|
| 172 |
+
</div>
|
| 173 |
+
|
| 174 |
+
{/* Pipeline overview */}
|
| 175 |
+
<div className="pipeline-overview">
|
| 176 |
+
{steps.map((step, i) => (
|
| 177 |
+
<React.Fragment key={step.number}>
|
| 178 |
+
<div className="pipeline-step-mini">
|
| 179 |
+
<StepDiagram number={step.number} title={step.title} color={step.color} />
|
| 180 |
+
<span style={{ color: step.color, fontWeight: 600, fontSize: 13 }}>{step.title}</span>
|
| 181 |
+
</div>
|
| 182 |
+
{i < steps.length - 1 && <FlowArrow />}
|
| 183 |
+
</React.Fragment>
|
| 184 |
+
))}
|
| 185 |
+
</div>
|
| 186 |
+
|
| 187 |
+
{/* Detailed steps */}
|
| 188 |
+
{steps.map((step) => (
|
| 189 |
+
<div className="tech-step" key={step.number}>
|
| 190 |
+
<div className="tech-step-header">
|
| 191 |
+
<StepDiagram number={step.number} title={step.title} color={step.color} />
|
| 192 |
+
<div>
|
| 193 |
+
<h2 className="tech-step-title" style={{ color: step.color }}>
|
| 194 |
+
Step {step.number}: {step.title}
|
| 195 |
+
</h2>
|
| 196 |
+
</div>
|
| 197 |
+
</div>
|
| 198 |
+
<p className="tech-step-desc">{step.description}</p>
|
| 199 |
+
<div className="tech-step-diagram">
|
| 200 |
+
{step.diagram}
|
| 201 |
+
</div>
|
| 202 |
+
<ul className="tech-step-details">
|
| 203 |
+
{step.details.map((d, i) => <li key={i}>{d}</li>)}
|
| 204 |
+
</ul>
|
| 205 |
+
</div>
|
| 206 |
+
))}
|
| 207 |
+
|
| 208 |
+
{/* Inference section */}
|
| 209 |
+
<div className="tech-step">
|
| 210 |
+
<div className="tech-step-header">
|
| 211 |
+
<svg width="80" height="80" viewBox="0 0 80 80" className="step-icon">
|
| 212 |
+
<circle cx="40" cy="40" r="36" fill="none" stroke="#6c8cff" strokeWidth="2.5"/>
|
| 213 |
+
<text x="40" y="46" textAnchor="middle" fill="#6c8cff" fontSize="22" fontWeight="700">{'\u25b6'}</text>
|
| 214 |
+
</svg>
|
| 215 |
+
<div>
|
| 216 |
+
<h2 className="tech-step-title" style={{ color: '#6c8cff' }}>
|
| 217 |
+
Real-Time Inference
|
| 218 |
+
</h2>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
<p className="tech-step-desc">
|
| 222 |
+
When you upload a video, the app uses YOLOv8 for fast face detection on each frame,
|
| 223 |
+
then feeds cropped faces through the trained EfficientNetB0 model. Each face gets an
|
| 224 |
+
authenticity score (0 = fake, 1 = real), and the processed video shows bounding boxes
|
| 225 |
+
with per-face REAL/FAKE labels overlaid in real time.
|
| 226 |
+
</p>
|
| 227 |
+
<div className="tech-step-diagram">
|
| 228 |
+
<svg viewBox="0 0 400 80" className="step-illustration">
|
| 229 |
+
<rect x="5" y="15" width="65" height="50" rx="6" fill="#1a1a2e" stroke="#6c8cff" strokeWidth="1.5"/>
|
| 230 |
+
<text x="37" y="44" textAnchor="middle" fill="#6c8cff" fontSize="9" fontWeight="600">Upload</text>
|
| 231 |
+
<path d="M80 40 L105 40" stroke="#6c8cff" strokeWidth="1.5" markerEnd="url(#arrowI)"/>
|
| 232 |
+
<rect x="110" y="15" width="65" height="50" rx="6" fill="#1a1a2e" stroke="#ff9800" strokeWidth="1.5"/>
|
| 233 |
+
<text x="142" y="38" textAnchor="middle" fill="#ff9800" fontSize="9" fontWeight="600">YOLOv8</text>
|
| 234 |
+
<text x="142" y="50" textAnchor="middle" fill="#888" fontSize="7">Face Detect</text>
|
| 235 |
+
<path d="M185 40 L210 40" stroke="#ff9800" strokeWidth="1.5" markerEnd="url(#arrowI)"/>
|
| 236 |
+
<rect x="215" y="15" width="65" height="50" rx="6" fill="#1a1a2e" stroke="#f44336" strokeWidth="1.5"/>
|
| 237 |
+
<text x="247" y="38" textAnchor="middle" fill="#f44336" fontSize="8" fontWeight="600">EfficientNet</text>
|
| 238 |
+
<text x="247" y="50" textAnchor="middle" fill="#888" fontSize="7">Predict</text>
|
| 239 |
+
<path d="M290 40 L315 40" stroke="#4caf50" strokeWidth="1.5" markerEnd="url(#arrowI)"/>
|
| 240 |
+
<rect x="320" y="15" width="70" height="50" rx="6" fill="#1a1a2e" stroke="#4caf50" strokeWidth="1.5"/>
|
| 241 |
+
<text x="355" y="38" textAnchor="middle" fill="#4caf50" fontSize="9" fontWeight="600">REAL</text>
|
| 242 |
+
<text x="355" y="50" textAnchor="middle" fill="#f44336" fontSize="9" fontWeight="600">FAKE</text>
|
| 243 |
+
<defs><marker id="arrowI" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#6c8cff"/></marker></defs>
|
| 244 |
+
</svg>
|
| 245 |
+
</div>
|
| 246 |
+
</div>
|
| 247 |
+
</section>
|
| 248 |
+
);
|
| 249 |
+
}
|
App/static/app.jsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const { useState, useRef, useEffect, useCallback } = React;
|
| 2 |
+
|
| 3 |
+
const STATUS_MESSAGES = {
|
| 4 |
+
uploading: 'Uploading video\u2026',
|
| 5 |
+
detecting: 'Detecting faces & predicting deepfake\u2026',
|
| 6 |
+
processing_video: 'Generating face detection video\u2026',
|
| 7 |
+
};
|
| 8 |
+
|
| 9 |
+
function barClass(s) { return s > 0.8 ? 'bar-green' : s > 0.2 ? 'bar-orange' : 'bar-red'; }
|
| 10 |
+
function scoreClass(s) { return s > 0.8 ? 'score-green' : s > 0.2 ? 'score-orange' : 'score-red'; }
|
| 11 |
+
|
| 12 |
+
/* ── Simple hash-based router ── */
|
| 13 |
+
function useHashRoute() {
|
| 14 |
+
const [route, setRoute] = useState(window.location.hash || '#/');
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const onHash = () => setRoute(window.location.hash || '#/');
|
| 17 |
+
window.addEventListener('hashchange', onHash);
|
| 18 |
+
return () => window.removeEventListener('hashchange', onHash);
|
| 19 |
+
}, []);
|
| 20 |
+
return route;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
/* ── Navbar ── */
|
| 24 |
+
function Navbar({ route }) {
|
| 25 |
+
return (
|
| 26 |
+
<header className="navbar">
|
| 27 |
+
<div className="logo"><span>AI</span>-Deepfake Video Detection</div>
|
| 28 |
+
<nav>
|
| 29 |
+
<a href="#/" className={route === '#/' || route === '' ? 'active' : ''}>Product</a>
|
| 30 |
+
<a href="#/technology" className={route === '#/technology' ? 'active' : ''}>Technology</a>
|
| 31 |
+
</nav>
|
| 32 |
+
</header>
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* ── Main App ── */
|
| 37 |
+
function App() {
|
| 38 |
+
const [file, setFile] = useState(null);
|
| 39 |
+
const [status, setStatus] = useState(null);
|
| 40 |
+
const [error, setError] = useState(null);
|
| 41 |
+
const [result, setResult] = useState(null);
|
| 42 |
+
const [submitting, setSubmitting] = useState(false);
|
| 43 |
+
const route = useHashRoute();
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<>
|
| 47 |
+
<Navbar route={route} />
|
| 48 |
+
{route === '#/technology' ? (
|
| 49 |
+
<TechnologyPage />
|
| 50 |
+
) : (
|
| 51 |
+
<ProductPage
|
| 52 |
+
file={file} setFile={setFile}
|
| 53 |
+
status={status} setStatus={setStatus}
|
| 54 |
+
error={error} setError={setError}
|
| 55 |
+
result={result} setResult={setResult}
|
| 56 |
+
submitting={submitting} setSubmitting={setSubmitting}
|
| 57 |
+
/>
|
| 58 |
+
)}
|
| 59 |
+
</>
|
| 60 |
+
);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
App/static/images.png
ADDED
|
Git LFS Details
|
App/static/style.css
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 2 |
+
body {
|
| 3 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
| 4 |
+
background: #0b0b1a;
|
| 5 |
+
color: #d0d0d0;
|
| 6 |
+
min-height: 100vh;
|
| 7 |
+
}
|
| 8 |
+
.navbar {
|
| 9 |
+
display: flex; align-items: center; justify-content: space-between;
|
| 10 |
+
padding: 16px 40px; background: #0b0b1a; border-bottom: 1px solid #1a1a2e;
|
| 11 |
+
}
|
| 12 |
+
.navbar .logo { font-size: 22px; font-weight: 700; color: #fff; letter-spacing: 0.5px; }
|
| 13 |
+
.navbar .logo span { color: #6c8cff; }
|
| 14 |
+
.navbar nav a {
|
| 15 |
+
color: #888; text-decoration: none; margin-left: 32px; font-size: 14px; transition: color 0.2s;
|
| 16 |
+
}
|
| 17 |
+
.navbar nav a:hover, .navbar nav a.active { color: #6c8cff; }
|
| 18 |
+
.hero {
|
| 19 |
+
display: flex; align-items: flex-start; justify-content: space-between;
|
| 20 |
+
max-width: 1100px; margin: 60px auto 0; padding: 0 40px; gap: 60px;
|
| 21 |
+
}
|
| 22 |
+
.hero-left { flex: 1; max-width: 480px; }
|
| 23 |
+
.hero-title { font-size: 48px; font-weight: 800; color: #fff; line-height: 1.1; margin-bottom: 18px; }
|
| 24 |
+
.hero-desc { font-size: 15px; color: #999; line-height: 1.7; margin-bottom: 32px; }
|
| 25 |
+
.upload-area {
|
| 26 |
+
border: 2px dashed #2a2a40; border-radius: 12px; padding: 28px;
|
| 27 |
+
text-align: center; cursor: pointer; transition: border-color 0.3s; margin-bottom: 14px;
|
| 28 |
+
}
|
| 29 |
+
.upload-area:hover { border-color: #6c8cff; }
|
| 30 |
+
.upload-area.has-file { border-color: #4caf50; }
|
| 31 |
+
.upload-text { color: #666; font-size: 14px; }
|
| 32 |
+
.file-name { color: #6c8cff; font-weight: 600; margin-top: 8px; word-break: break-all; font-size: 14px; }
|
| 33 |
+
input[type="file"] { display: none; }
|
| 34 |
+
.btn {
|
| 35 |
+
display: block; width: 100%; padding: 13px; background: #6c8cff; color: #fff;
|
| 36 |
+
border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.3s;
|
| 37 |
+
}
|
| 38 |
+
.btn:hover { background: #5a7ae6; }
|
| 39 |
+
.btn:disabled { background: #2a2a40; color: #555; cursor: not-allowed; }
|
| 40 |
+
.video-preview { flex: 1; display: flex; justify-content: center; align-items: center; }
|
| 41 |
+
.video-preview video {
|
| 42 |
+
max-width: 100%; max-height: 400px; border-radius: 12px; border: 1px solid #1e1e35; background: #111122;
|
| 43 |
+
}
|
| 44 |
+
.hero-right { flex: 1; display: flex; justify-content: center; align-items: stretch; }
|
| 45 |
+
.hero-image {
|
| 46 |
+
width: 100%; height: 100%; border-radius: 14px; border: 1px solid #1e1e35;
|
| 47 |
+
object-fit: cover; box-shadow: 0 8px 32px rgba(108,140,255,0.10);
|
| 48 |
+
}
|
| 49 |
+
.preview-placeholder {
|
| 50 |
+
width: 100%; max-width: 460px; height: 280px; border-radius: 12px; background: #111122;
|
| 51 |
+
border: 1px solid #1e1e35; display: flex; align-items: center; justify-content: center; color: #333; font-size: 48px;
|
| 52 |
+
}
|
| 53 |
+
.spinner {
|
| 54 |
+
margin: 16px auto; width: 36px; height: 36px; border: 3px solid #1a1a30;
|
| 55 |
+
border-top: 3px solid #6c8cff; border-radius: 50%; animation: spin 0.7s linear infinite;
|
| 56 |
+
}
|
| 57 |
+
.processing-text { text-align: center; color: #666; font-size: 13px; margin-top: 8px; }
|
| 58 |
+
@keyframes spin { to { transform: rotate(360deg); } }
|
| 59 |
+
.error-box {
|
| 60 |
+
margin-top: 20px; padding: 16px; background: rgba(244,67,54,0.1); border: 1px solid rgba(244,67,54,0.3);
|
| 61 |
+
border-radius: 10px; color: #f44336; text-align: center; font-size: 14px;
|
| 62 |
+
}
|
| 63 |
+
.video-compare { max-width: 1100px; margin: 40px auto 0; padding: 0 40px; }
|
| 64 |
+
.video-compare h2 { font-size: 20px; font-weight: 700; color: #fff; margin-bottom: 16px; }
|
| 65 |
+
.compare-grid { display: flex; gap: 24px; }
|
| 66 |
+
.compare-grid.single { justify-content: center; }
|
| 67 |
+
.compare-grid.single .compare-item { max-width: 640px; }
|
| 68 |
+
.compare-item { flex: 1; text-align: center; }
|
| 69 |
+
.compare-item video {
|
| 70 |
+
width: 100%; max-height: 360px; border-radius: 12px; border: 1px solid #1e1e35; background: #111122;
|
| 71 |
+
}
|
| 72 |
+
.compare-label { margin-top: 8px; font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; }
|
| 73 |
+
.compare-label.original { color: #6c8cff; }
|
| 74 |
+
.compare-label.detected { color: #4caf50; }
|
| 75 |
+
.results-section { max-width: 1100px; margin: 50px auto 60px; padding: 0 40px; }
|
| 76 |
+
.results-panel { background: #111122; border: 1px solid #1e1e35; border-radius: 14px; padding: 30px 36px; }
|
| 77 |
+
.results-panel h2 { font-size: 20px; font-weight: 700; color: #fff; margin-bottom: 6px; }
|
| 78 |
+
.results-hint { font-size: 13px; color: #777; margin-bottom: 24px; line-height: 1.5; }
|
| 79 |
+
.overall-result { display: flex; align-items: center; gap: 20px; padding: 20px; border-radius: 12px; margin-bottom: 24px; }
|
| 80 |
+
.overall-result.real { background: rgba(76,175,80,0.08); border: 1px solid rgba(76,175,80,0.3); }
|
| 81 |
+
.overall-result.fake { background: rgba(244,67,54,0.08); border: 1px solid rgba(244,67,54,0.3); }
|
| 82 |
+
.overall-label { font-size: 32px; font-weight: 800; }
|
| 83 |
+
.overall-result.real .overall-label { color: #4caf50; }
|
| 84 |
+
.overall-result.fake .overall-label { color: #f44336; }
|
| 85 |
+
.overall-details { font-size: 14px; color: #aaa; line-height: 1.6; }
|
| 86 |
+
.face-row { display: flex; align-items: center; gap: 16px; padding: 14px 0; border-top: 1px solid #1a1a30; }
|
| 87 |
+
.face-thumb { width: 52px; height: 52px; border-radius: 8px; object-fit: cover; border: 2px solid #222; background: #1a1a2e; }
|
| 88 |
+
.face-info { flex: 1; }
|
| 89 |
+
.face-bar-track { height: 8px; background: #1a1a30; border-radius: 4px; overflow: hidden; margin-bottom: 6px; }
|
| 90 |
+
.face-bar-fill { height: 100%; border-radius: 4px; transition: width 0.6s ease; }
|
| 91 |
+
.bar-green { background: #4caf50; } .bar-orange { background: #ff9800; } .bar-red { background: #f44336; }
|
| 92 |
+
.face-score { font-size: 13px; font-weight: 600; }
|
| 93 |
+
.score-green { color: #4caf50; } .score-orange { color: #ff9800; } .score-red { color: #f44336; }
|
| 94 |
+
@media (max-width: 768px) {
|
| 95 |
+
.hero { flex-direction: column; padding: 0 20px; margin-top: 30px; gap: 30px; }
|
| 96 |
+
.hero-left { max-width: 100%; } .hero-title { font-size: 32px; }
|
| 97 |
+
.hero-right { display: none; }
|
| 98 |
+
.results-section { padding: 0 20px; } .results-panel { padding: 20px; }
|
| 99 |
+
.navbar { padding: 14px 20px; } .navbar nav a { margin-left: 16px; font-size: 13px; }
|
| 100 |
+
.compare-grid { flex-direction: column; }
|
| 101 |
+
.pipeline-overview { flex-direction: column; align-items: center; }
|
| 102 |
+
.flow-arrow { transform: rotate(90deg); }
|
| 103 |
+
.tech-step-header { flex-direction: column; align-items: center; text-align: center; }
|
| 104 |
+
.tech-page { padding: 0 16px; }
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/* ── Technology Page ── */
|
| 108 |
+
.tech-page { max-width: 900px; margin: 0 auto; padding: 0 40px 60px; }
|
| 109 |
+
.tech-hero { text-align: center; margin: 50px 0 40px; }
|
| 110 |
+
.tech-title { font-size: 42px; font-weight: 800; color: #fff; margin-bottom: 14px; }
|
| 111 |
+
.tech-subtitle { font-size: 15px; color: #999; line-height: 1.7; max-width: 600px; margin: 0 auto; }
|
| 112 |
+
|
| 113 |
+
.pipeline-overview {
|
| 114 |
+
display: flex; align-items: center; justify-content: center; gap: 12px;
|
| 115 |
+
margin-bottom: 50px; flex-wrap: wrap;
|
| 116 |
+
}
|
| 117 |
+
.pipeline-step-mini {
|
| 118 |
+
display: flex; flex-direction: column; align-items: center; gap: 8px; text-align: center; width: 110px;
|
| 119 |
+
}
|
| 120 |
+
.flow-arrow { display: flex; align-items: center; color: #6c8cff; }
|
| 121 |
+
|
| 122 |
+
.tech-step {
|
| 123 |
+
background: #111122; border: 1px solid #1e1e35; border-radius: 14px;
|
| 124 |
+
padding: 30px 36px; margin-bottom: 28px;
|
| 125 |
+
}
|
| 126 |
+
.tech-step-header { display: flex; align-items: center; gap: 20px; margin-bottom: 16px; }
|
| 127 |
+
.tech-step-title { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
| 128 |
+
.tech-file {
|
| 129 |
+
font-size: 12px; color: #6c8cff; background: rgba(108,140,255,0.1);
|
| 130 |
+
padding: 3px 10px; border-radius: 4px; font-family: 'Consolas', monospace;
|
| 131 |
+
}
|
| 132 |
+
.tech-step-desc { font-size: 14px; color: #aaa; line-height: 1.7; margin-bottom: 20px; }
|
| 133 |
+
.tech-step-diagram {
|
| 134 |
+
background: #0b0b1a; border: 1px solid #1a1a2e; border-radius: 10px;
|
| 135 |
+
padding: 20px; margin-bottom: 20px; text-align: center; overflow-x: auto;
|
| 136 |
+
}
|
| 137 |
+
.step-illustration { max-width: 100%; height: auto; }
|
| 138 |
+
.step-icon { flex-shrink: 0; }
|
| 139 |
+
.tech-step-details {
|
| 140 |
+
list-style: none; padding: 0;
|
| 141 |
+
}
|
| 142 |
+
.tech-step-details li {
|
| 143 |
+
font-size: 13px; color: #bbb; padding: 6px 0 6px 20px; position: relative; line-height: 1.5;
|
| 144 |
+
}
|
| 145 |
+
.tech-step-details li::before {
|
| 146 |
+
content: '→'; position: absolute; left: 0; color: #6c8cff; font-weight: 700;
|
| 147 |
+
}
|
App/templates/index.html
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>AI-Deepfake Video Detection</title>
|
| 7 |
+
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
| 8 |
+
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
| 9 |
+
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
| 10 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
|
| 15 |
+
<script type="text/babel" src="/static/Product.jsx"></script>
|
| 16 |
+
<script type="text/babel" src="/static/Technology.jsx"></script>
|
| 17 |
+
<script type="text/babel" src="/static/app.jsx"></script>
|
| 18 |
+
</body>
|
| 19 |
+
</html>
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 4 |
+
ffmpeg \
|
| 5 |
+
libgl1 \
|
| 6 |
+
libglib2.0-0 \
|
| 7 |
+
libsm6 \
|
| 8 |
+
libxext6 \
|
| 9 |
+
libxrender1 \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
RUN useradd -m -u 1000 user
|
| 13 |
+
|
| 14 |
+
USER user
|
| 15 |
+
ENV HOME=/home/user \
|
| 16 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 17 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 18 |
+
PYTHONUNBUFFERED=1 \
|
| 19 |
+
PORT=7860
|
| 20 |
+
|
| 21 |
+
ENV ENABLE_PREVIEW_FACE_DETECTOR=0
|
| 22 |
+
|
| 23 |
+
WORKDIR $HOME/app
|
| 24 |
+
|
| 25 |
+
RUN pip install --no-cache-dir --upgrade pip
|
| 26 |
+
|
| 27 |
+
COPY --chown=user requirements.txt $HOME/app/requirements.txt
|
| 28 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 29 |
+
|
| 30 |
+
COPY --chown=user . $HOME/app
|
| 31 |
+
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
CMD ["python", "App/app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DeepFake Detect
|
| 3 |
+
emoji: 🎭
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
short_description: Face-level deepfake video detection with a Docker-based web app.
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# DeepFake-Detect
|
| 12 |
+
|
| 13 |
+
A Python-based deepfake video detection project with two main parts:
|
| 14 |
+
|
| 15 |
+
1. An offline training pipeline that extracts frames, crops faces, prepares labeled data, and trains a binary classifier.
|
| 16 |
+
2. A Flask web application that accepts uploaded videos, analyzes faces, returns authenticity scores, and generates a processed preview video with face boxes.
|
| 17 |
+
|
| 18 |
+
The current repository is focused on face-level deepfake detection rather than general-purpose video understanding.
|
| 19 |
+
|
| 20 |
+
## What This Project Does
|
| 21 |
+
|
| 22 |
+
- Takes videos as input and samples frames once per second.
|
| 23 |
+
- Uses `MTCNN` to detect and crop faces from extracted frames.
|
| 24 |
+
- Uses FaceForensics++ CSV metadata to label face samples as `REAL` or `FAKE`.
|
| 25 |
+
- Filters out very small face crops and builds `train / val / test` datasets.
|
| 26 |
+
- Trains an `EfficientNetB0`-based classifier with transfer learning and fine-tuning.
|
| 27 |
+
- Provides a Flask web app for interactive video analysis.
|
| 28 |
+
|
| 29 |
+
## Tech Stack
|
| 30 |
+
|
| 31 |
+
- Backend: `Flask`
|
| 32 |
+
- Deep learning: `TensorFlow / Keras`
|
| 33 |
+
- Main classifier: `EfficientNetB0`
|
| 34 |
+
- Face detection for training/inference crops: `MTCNN`
|
| 35 |
+
- Face detection for preview overlays: `YOLOv8 face`
|
| 36 |
+
- Video processing: `OpenCV`, `ffmpeg` via `imageio-ffmpeg`
|
| 37 |
+
- Dataset splitting: `split-folders`
|
| 38 |
+
|
| 39 |
+
## Detection Pipeline
|
| 40 |
+
|
| 41 |
+
### Training Stage
|
| 42 |
+
|
| 43 |
+
#### 1. Convert videos to frames
|
| 44 |
+
Script: [00-convert_video_to_image.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/00-convert_video_to_image.py)
|
| 45 |
+
|
| 46 |
+
- Reads videos from subfolders under `train_sample_videos/FaceForensics++_C23/`.
|
| 47 |
+
- Extracts 1 frame per second.
|
| 48 |
+
- Dynamically rescales each frame based on source resolution:
|
| 49 |
+
- Width `< 300`: scale to `2x`
|
| 50 |
+
- Width `> 1900`: scale to `0.33x`
|
| 51 |
+
- Width `1000 ~ 1900`: scale to `0.5x`
|
| 52 |
+
- Otherwise: keep original size
|
| 53 |
+
- Stores extracted PNG frames in a per-video directory.
|
| 54 |
+
|
| 55 |
+
#### 2. Detect and crop faces
|
| 56 |
+
Script: [01-crop_faces_with_mtcnn.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/01-crop_faces_with_mtcnn.py)
|
| 57 |
+
|
| 58 |
+
- Runs `MTCNN` on every extracted frame.
|
| 59 |
+
- If a frame contains only one face, that detection is kept.
|
| 60 |
+
- If a frame contains multiple faces, only detections with confidence above `0.95` are kept.
|
| 61 |
+
- Expands each bounding box by `30%` to preserve more facial context.
|
| 62 |
+
- Saves cropped faces into a `faces/` subdirectory for each video.
|
| 63 |
+
|
| 64 |
+
#### 3. Prepare the dataset
|
| 65 |
+
Script: [02-prepare_fake_real_dataset.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/02-prepare_fake_real_dataset.py)
|
| 66 |
+
|
| 67 |
+
- Reads labels from `csv/*.csv`.
|
| 68 |
+
- Copies face crops into:
|
| 69 |
+
- `prepared_dataset/real`
|
| 70 |
+
- `prepared_dataset/fake`
|
| 71 |
+
- Filters out any image with width or height smaller than `90px`.
|
| 72 |
+
- Splits the final dataset with `split-folders` into:
|
| 73 |
+
- `split_dataset/train`
|
| 74 |
+
- `split_dataset/val`
|
| 75 |
+
- `split_dataset/test`
|
| 76 |
+
|
| 77 |
+
#### 4. Train the classifier
|
| 78 |
+
Script: [03-train_cnn.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/03-train_cnn.py)
|
| 79 |
+
|
| 80 |
+
- Input size: `224x224`
|
| 81 |
+
- Backbone: `EfficientNetB0(weights="imagenet")`
|
| 82 |
+
- Output: single-unit `sigmoid` binary classifier
|
| 83 |
+
- Training strategy:
|
| 84 |
+
- Phase 1: freeze the backbone and train only the head with learning rate `1e-3`
|
| 85 |
+
- Phase 2: unfreeze the full model and fine-tune with learning rate `1e-5`
|
| 86 |
+
- Data augmentation includes:
|
| 87 |
+
- rotation
|
| 88 |
+
- horizontal flip
|
| 89 |
+
- zoom
|
| 90 |
+
- translation
|
| 91 |
+
- brightness jitter
|
| 92 |
+
- Uses:
|
| 93 |
+
- `EarlyStopping`
|
| 94 |
+
- `ModelCheckpoint`
|
| 95 |
+
- `ReduceLROnPlateau`
|
| 96 |
+
- Applies class weights to reduce `fake/real` imbalance.
|
| 97 |
+
|
| 98 |
+
The best trained model is saved to:
|
| 99 |
+
|
| 100 |
+
```text
|
| 101 |
+
tmp_checkpoint/best_model.keras
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
This is the canonical model output path used by the training pipeline.
|
| 105 |
+
|
| 106 |
+
## Web App Inference Flow
|
| 107 |
+
|
| 108 |
+
App entry point: [App/app.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/app.py)
|
| 109 |
+
|
| 110 |
+
Routes: [App/route.py](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/route.py)
|
| 111 |
+
|
| 112 |
+
Frontend files:
|
| 113 |
+
|
| 114 |
+
- [App/templates/index.html](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/templates/index.html)
|
| 115 |
+
- [App/static/app.jsx](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/static/app.jsx)
|
| 116 |
+
- [App/static/Product.jsx](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/static/Product.jsx)
|
| 117 |
+
- [App/static/Technology.jsx](/Users/zhangke/Documents/Projects/DeepFake-Detect/App/static/Technology.jsx)
|
| 118 |
+
|
| 119 |
+
### Actual inference logic
|
| 120 |
+
|
| 121 |
+
When a video is uploaded, the backend performs these steps:
|
| 122 |
+
|
| 123 |
+
1. Validates the file type: `mp4`, `avi`, `mov`, `mkv`, `wmv`
|
| 124 |
+
2. Re-encodes the uploaded video to browser-friendly `H.264`
|
| 125 |
+
3. Reads frames roughly once per second
|
| 126 |
+
4. Uses `MTCNN` to extract faces for classification
|
| 127 |
+
5. Runs `EfficientNetB0` inference on each face crop
|
| 128 |
+
6. Sorts all face scores from highest to lowest and averages the top `30%`
|
| 129 |
+
7. If the averaged score is `> 0.5`, the video is labeled `REAL`; otherwise `FAKE`
|
| 130 |
+
8. Returns:
|
| 131 |
+
- final label
|
| 132 |
+
- confidence
|
| 133 |
+
- model score
|
| 134 |
+
- number of faces analyzed
|
| 135 |
+
- face thumbnails with per-face scores
|
| 136 |
+
9. Uses `YOLOv8 face` to generate a preview video with face bounding boxes
|
| 137 |
+
|
| 138 |
+
Notes:
|
| 139 |
+
|
| 140 |
+
- `MTCNN` is used for the actual face crops fed into the classifier.
|
| 141 |
+
- `YOLOv8 face` is currently used for visualization in the processed preview video, not as the classifier itself.
|
| 142 |
+
|
| 143 |
+
## Repository Structure
|
| 144 |
+
|
| 145 |
+
```text
|
| 146 |
+
DeepFake-Detect/
|
| 147 |
+
├── 00-convert_video_to_image.py
|
| 148 |
+
├── 01-crop_faces_with_mtcnn.py
|
| 149 |
+
├── 02-prepare_fake_real_dataset.py
|
| 150 |
+
├── 03-train_cnn.py
|
| 151 |
+
├── tmp_checkpoint/
|
| 152 |
+
│ ├── best_model.keras
|
| 153 |
+
│ └── best_model_phase1.keras
|
| 154 |
+
├── App/
|
| 155 |
+
│ ├── app.py
|
| 156 |
+
│ ├── route.py
|
| 157 |
+
│ ├── yolov8n-face.pt
|
| 158 |
+
│ ├── static/
|
| 159 |
+
│ └── templates/
|
| 160 |
+
├── train_sample_videos/
|
| 161 |
+
│ └── FaceForensics++_C23/
|
| 162 |
+
├── best_model.keras
|
| 163 |
+
├── pyproject.toml
|
| 164 |
+
└── uv.lock
|
| 165 |
+
```
|
| 166 |
+
|
| 167 |
+
## Dataset Layout
|
| 168 |
+
|
| 169 |
+
The training scripts expect the dataset root at:
|
| 170 |
+
|
| 171 |
+
```text
|
| 172 |
+
train_sample_videos/FaceForensics++_C23/
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
The repository currently contains these visible subdirectories:
|
| 176 |
+
|
| 177 |
+
- `original`
|
| 178 |
+
- `Deepfakes`
|
| 179 |
+
- `DeepFakeDetection`
|
| 180 |
+
- `Face2Face`
|
| 181 |
+
- `FaceSwap`
|
| 182 |
+
- `FaceShifter`
|
| 183 |
+
- `NeuralTextures`
|
| 184 |
+
- `csv`
|
| 185 |
+
|
| 186 |
+
CSV files include fields such as:
|
| 187 |
+
|
| 188 |
+
- `File Path`
|
| 189 |
+
- `Label`
|
| 190 |
+
- `Frame Count`
|
| 191 |
+
- `Width`
|
| 192 |
+
- `Height`
|
| 193 |
+
- `Codec`
|
| 194 |
+
- `File Size(MB)`
|
| 195 |
+
|
| 196 |
+
## Requirements
|
| 197 |
+
|
| 198 |
+
- Python `>= 3.12`
|
| 199 |
+
- `uv` is recommended
|
| 200 |
+
- For training, an NVIDIA GPU that TensorFlow can detect is strongly recommended
|
| 201 |
+
- For the web app, a working `ffmpeg` runtime is required; the project accesses it through `imageio-ffmpeg`
|
| 202 |
+
|
| 203 |
+
## Install Dependencies
|
| 204 |
+
|
| 205 |
+
### Option 1: use uv
|
| 206 |
+
|
| 207 |
+
```bash
|
| 208 |
+
uv sync
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
### Option 2: use venv + pip
|
| 212 |
+
|
| 213 |
+
```bash
|
| 214 |
+
python -m venv .venv
|
| 215 |
+
source .venv/bin/activate
|
| 216 |
+
pip install -e .
|
| 217 |
+
```
|
| 218 |
+
|
| 219 |
+
## Quick Start
|
| 220 |
+
|
| 221 |
+
### 1. Prepare the model file
|
| 222 |
+
|
| 223 |
+
The web app resolves the model in this order:
|
| 224 |
+
|
| 225 |
+
```text
|
| 226 |
+
tmp_checkpoint/best_model.keras
|
| 227 |
+
best_model.keras
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
`tmp_checkpoint/best_model.keras` is the canonical location. The root-level `best_model.keras` is treated as a compatibility fallback only.
|
| 231 |
+
|
| 232 |
+
If you want the project to use the standard training output path, place the model here:
|
| 233 |
+
|
| 234 |
+
```bash
|
| 235 |
+
mkdir -p tmp_checkpoint
|
| 236 |
+
cp best_model.keras tmp_checkpoint/best_model.keras
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
If you want to train from scratch, follow the training sequence below. The training script will generate this file automatically.
|
| 240 |
+
|
| 241 |
+
### 2. Start the web app
|
| 242 |
+
|
| 243 |
+
```bash
|
| 244 |
+
uv run python App/app.py
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
Then open:
|
| 248 |
+
|
| 249 |
+
```text
|
| 250 |
+
http://127.0.0.1:5001
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
The app now defaults to port `5001`. You can override it with an environment variable:
|
| 254 |
+
|
| 255 |
+
```bash
|
| 256 |
+
PORT=5050 uv run python App/app.py
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
## Hugging Face Spaces Deployment
|
| 260 |
+
|
| 261 |
+
This repository is now prepared for a Docker-based Hugging Face Space.
|
| 262 |
+
|
| 263 |
+
Deployment files:
|
| 264 |
+
|
| 265 |
+
- [Dockerfile](/Users/zhangke/Documents/Projects/DeepFake-Detect/Dockerfile)
|
| 266 |
+
- [.dockerignore](/Users/zhangke/Documents/Projects/DeepFake-Detect/.dockerignore)
|
| 267 |
+
- [requirements.txt](/Users/zhangke/Documents/Projects/DeepFake-Detect/requirements.txt)
|
| 268 |
+
|
| 269 |
+
The Space configuration is defined in the YAML header at the top of this README:
|
| 270 |
+
|
| 271 |
+
- `sdk: docker`
|
| 272 |
+
- `app_port: 7860`
|
| 273 |
+
|
| 274 |
+
Container runtime behavior:
|
| 275 |
+
|
| 276 |
+
- The container runs the Flask app with `python App/app.py`
|
| 277 |
+
- The Docker image sets `PORT=7860`
|
| 278 |
+
- The Docker image sets `ENABLE_PREVIEW_FACE_DETECTOR=0`
|
| 279 |
+
- The app itself already supports `PORT`, so it matches Hugging Face Spaces routing
|
| 280 |
+
|
| 281 |
+
Recommended deployment steps:
|
| 282 |
+
|
| 283 |
+
1. Create a new Hugging Face Space and choose `Docker` as the SDK.
|
| 284 |
+
2. Push this repository to that Space repository.
|
| 285 |
+
3. Wait for the image build to complete.
|
| 286 |
+
4. Open the Space once the container becomes healthy.
|
| 287 |
+
|
| 288 |
+
Notes for this project on Spaces:
|
| 289 |
+
|
| 290 |
+
- The Docker build excludes local training data and the duplicate root-level `best_model.keras` from the build context.
|
| 291 |
+
- The canonical runtime model remains `tmp_checkpoint/best_model.keras`.
|
| 292 |
+
- The app uses CPU by default unless you assign GPU hardware to the Space.
|
| 293 |
+
- To keep the Docker image smaller and easier to build, the Space disables YOLO-based preview overlays by default and falls back to a re-encoded original video.
|
| 294 |
+
|
| 295 |
+
Local Docker smoke test:
|
| 296 |
+
|
| 297 |
+
```bash
|
| 298 |
+
docker build -t deepfake-detect-space .
|
| 299 |
+
docker run --rm -p 7860:7860 deepfake-detect-space
|
| 300 |
+
```
|
| 301 |
+
|
| 302 |
+
Then open:
|
| 303 |
+
|
| 304 |
+
```text
|
| 305 |
+
http://127.0.0.1:7860
|
| 306 |
+
```
|
| 307 |
+
|
| 308 |
+
## Training Order
|
| 309 |
+
|
| 310 |
+
To reproduce the full training pipeline, run the scripts in this order:
|
| 311 |
+
|
| 312 |
+
```bash
|
| 313 |
+
uv run python 00-convert_video_to_image.py
|
| 314 |
+
uv run python 01-crop_faces_with_mtcnn.py
|
| 315 |
+
uv run python 02-prepare_fake_real_dataset.py
|
| 316 |
+
uv run python 03-train_cnn.py
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
## Key Outputs
|
| 320 |
+
|
| 321 |
+
- Frame extraction output: per-video frame folders
|
| 322 |
+
- Face crops: `faces/` inside each processed video folder
|
| 323 |
+
- Aggregated dataset: `prepared_dataset/`
|
| 324 |
+
- Train/validation/test splits: `split_dataset/`
|
| 325 |
+
- Trained model: `tmp_checkpoint/best_model.keras`
|
| 326 |
+
- Phase 1 checkpoint: `tmp_checkpoint/best_model_phase1.keras`
|
| 327 |
+
- Compatibility fallback model: `best_model.keras`
|
| 328 |
+
- Web upload directory: `App/uploads/`
|
| 329 |
+
- Inference diagnostics log: `App/diag_log.txt`
|
| 330 |
+
|
| 331 |
+
## Important Implementation Details
|
| 332 |
+
|
| 333 |
+
These details matter when understanding the current system:
|
| 334 |
+
|
| 335 |
+
- This is a face-crop-based binary classifier, not an end-to-end video transformer.
|
| 336 |
+
- The model score semantics are: values closer to `1` mean more likely real, values closer to `0` mean more likely fake.
|
| 337 |
+
- The video-level decision is not a plain average across all faces; it uses the mean of the highest-scoring subset.
|
| 338 |
+
- The upload endpoint enforces a `200 MB` limit.
|
| 339 |
+
- The app cleans old uploaded files, so `App/uploads/` should not be treated as persistent storage.
|
| 340 |
+
|
| 341 |
+
## Known Limitations
|
| 342 |
+
|
| 343 |
+
- Both training and inference depend heavily on face detection quality.
|
| 344 |
+
- The current sampling strategy uses only 1 frame per second, which may miss short-lived manipulation artifacts.
|
| 345 |
+
- The repository does not currently provide a standalone CLI inference script; the primary entry point is the Flask app.
|
| 346 |
+
- The canonical model path is `tmp_checkpoint/best_model.keras`, but the app also supports `best_model.keras` in the repository root as a fallback.
|
| 347 |
+
- This README is based on the current codebase behavior. If UI text and code behavior differ, trust the code.
|
| 348 |
+
|
| 349 |
+
## Possible Next Improvements
|
| 350 |
+
|
| 351 |
+
- Add a CLI inference entry point
|
| 352 |
+
- Move paths, thresholds, and input/output directories into configuration
|
| 353 |
+
- Persist training metrics and experiment logs
|
| 354 |
+
- Add batch video inference support
|
| 355 |
+
- Add Docker and deployment documentation
|
| 356 |
+
|
| 357 |
+
## License
|
| 358 |
+
|
| 359 |
+
No explicit license file is present in the repository at the moment. If you plan to publish or use this project commercially, add a proper license first.
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask>=3.1.3
|
| 2 |
+
h5py>=3.14.0
|
| 3 |
+
imageio-ffmpeg>=0.6.0
|
| 4 |
+
mtcnn>=0.1.0
|
| 5 |
+
numpy>=2.4.4
|
| 6 |
+
opencv-python>=4.1.0
|
| 7 |
+
pandas>=3.0.2
|
| 8 |
+
pillow>=12.2.0
|
| 9 |
+
scikit-learn>=1.8.0
|
| 10 |
+
split-folders>=0.6.1
|
| 11 |
+
tensorflow==2.21.0
|
| 12 |
+
werkzeug>=3.1.8
|
tmp_checkpoint/best_model.keras
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:64588ac82fba3224df0d322785d260899abd2e7fec5122fbaa61ad798cc785b4
|
| 3 |
+
size 53251173
|