| """ |
| Streamlit demo for the deepfake-detection project. |
| |
| Loads all five trained models (four baselines + the proposed hybrid) from the |
| Hugging Face Hub, runs each one on an uploaded video, and shows a side-by-side |
| comparison. Grad-CAM is shown for the hybrid since that's the proposed model. |
| |
| Run locally: |
| streamlit run app.py |
| """ |
|
|
| import os |
| import tempfile |
| from collections import OrderedDict |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import streamlit as st |
| import torch |
| from huggingface_hub import hf_hub_download |
|
|
| from gradcam_helper import ( |
| denormalize_frame_for_display, |
| generate_gradcam_overlay, |
| ) |
| from model import ( |
| CnnLstmClipModel, |
| EfficientNetFrameAverager, |
| HybridCNNTransformer, |
| ViTFrameAverager, |
| XceptionFrameAverager, |
| ) |
| from preprocessing import ( |
| FRAMES_PER_CLIP, |
| IMAGENET_MEAN, |
| IMAGENET_STD, |
| build_yunet_face_detector, |
| process_video_to_clip_tensor, |
| ) |
|
|
|
|
| |
|
|
| HUGGINGFACE_REPO_ID = "MUmairAB/deepfake-detection-ff-cn-transformer" |
|
|
| DEVICE = "cuda" if torch.cuda.is_available() else "cpu" |
|
|
| |
| GRADCAM_FRAME_INDEX = 12 |
|
|
| |
| |
| MODEL_REGISTRY = OrderedDict([ |
| ("Xception", { |
| "constructor": lambda: XceptionFrameAverager(pretrained=False), |
| "checkpoint": "xception_baseline/best_model.pt", |
| "params_millions": 20.8, |
| "is_proposed": False, |
| }), |
| ("EfficientNet-B3", { |
| "constructor": lambda: EfficientNetFrameAverager(pretrained=False), |
| "checkpoint": "efficientnet_b3_baseline/best_model.pt", |
| "params_millions": 10.7, |
| "is_proposed": False, |
| }), |
| ("CNN + BiLSTM", { |
| "constructor": lambda: CnnLstmClipModel(pretrained=False), |
| "checkpoint": "cnn_lstm_baseline/best_model.pt", |
| "params_millions": 13.7, |
| "is_proposed": False, |
| }), |
| ("ViT-Base/16", { |
| "constructor": lambda: ViTFrameAverager(pretrained=False), |
| "checkpoint": "vit_base_baseline/best_model.pt", |
| "params_millions": 85.8, |
| "is_proposed": False, |
| }), |
| ("Hybrid CNN-Transformer (ours)", { |
| "constructor": lambda: HybridCNNTransformer(pretrained=False), |
| "checkpoint": "hybrid_cnn_transformer/best_model.pt", |
| "params_millions": 74.9, |
| "is_proposed": True, |
| }), |
| ]) |
|
|
| PROPOSED_MODEL_NAME = "Hybrid CNN-Transformer (ours)" |
|
|
|
|
| |
|
|
| st.set_page_config( |
| page_title="Deepfake Detection - 5 model comparison", |
| page_icon="🎭", |
| layout="wide", |
| ) |
|
|
|
|
| |
|
|
| @st.cache_resource(show_spinner=False) |
| def load_face_detector(): |
| """Build the YuNet face detector once. The ONNX weights are pulled from |
| OpenCV's Hugging Face repo on first run and cached on disk.""" |
| return build_yunet_face_detector() |
|
|
|
|
| @st.cache_resource(show_spinner=False) |
| def load_all_models(): |
| """Download all five checkpoints from HF, build each model, load weights. |
| |
| Returns an OrderedDict mapping model name -> nn.Module (already in eval |
| mode on the right device). Total memory footprint is ~800 MB which fits |
| comfortably in the free Space's 16 GB RAM. |
| """ |
| loaded_models = OrderedDict() |
|
|
| for model_name, model_config in MODEL_REGISTRY.items(): |
| checkpoint_local_path = hf_hub_download( |
| repo_id=HUGGINGFACE_REPO_ID, |
| filename=model_config["checkpoint"], |
| ) |
| |
| |
| checkpoint = torch.load(checkpoint_local_path, map_location=DEVICE, weights_only=False) |
|
|
| model_instance = model_config["constructor"]() |
| model_instance.load_state_dict(checkpoint["model_state_dict"]) |
| model_instance.to(DEVICE).eval() |
| loaded_models[model_name] = model_instance |
|
|
| return loaded_models |
|
|
|
|
| |
|
|
| @torch.no_grad() |
| def predict_with_all_models(loaded_models, clip_normalized, progress_callback=None): |
| """Run every model in `loaded_models` on the clip. Returns a dict |
| mapping model name -> fake probability in [0, 1]. |
| |
| progress_callback(model_name, fraction) is called after each model finishes. |
| """ |
| clip_batched = clip_normalized.unsqueeze(0).to(DEVICE) |
| fake_probability_by_model = OrderedDict() |
|
|
| total_models = len(loaded_models) |
| for model_index, (model_name, model_instance) in enumerate(loaded_models.items()): |
| logit = model_instance(clip_batched).squeeze() |
| probability = torch.sigmoid(logit.float()).item() |
| fake_probability_by_model[model_name] = probability |
|
|
| if progress_callback is not None: |
| progress_callback(model_name, (model_index + 1) / total_models) |
|
|
| return fake_probability_by_model |
|
|
|
|
| def build_predictions_dataframe(fake_probability_by_model): |
| """Turn the dict of per-model probabilities into a tidy DataFrame for display.""" |
| rows = [] |
| for model_name, fake_probability in fake_probability_by_model.items(): |
| config = MODEL_REGISTRY[model_name] |
| rows.append({ |
| "Model": model_name, |
| "Params (M)": config["params_millions"], |
| "Probability (fake)": fake_probability, |
| "Verdict": "FAKE" if fake_probability >= 0.5 else "REAL", |
| }) |
| return pd.DataFrame(rows) |
|
|
|
|
| |
|
|
| with st.sidebar: |
| st.markdown("### About this demo") |
| st.markdown( |
| "Live comparison of all **five models** from our Deep Machine Learning " |
| "course project on video deepfake detection. Four baselines " |
| "(Xception, EfficientNet-B3, CNN+BiLSTM, ViT-Base/16) plus our " |
| "**proposed Hybrid CNN-Transformer**." |
| ) |
| st.markdown("---") |
| st.markdown("### Project links") |
| st.markdown( |
| f"- [Model weights]({'https://huggingface.co/' + HUGGINGFACE_REPO_ID})\n" |
| "- Trained on FaceForensics++ (C23)\n" |
| "- Five classes: original, Deepfakes, Face2Face, FaceSwap, NeuralTextures\n" |
| "- FaceShifter held out for cross-manipulation evaluation" |
| ) |
| st.markdown("---") |
| st.markdown("### Pipeline") |
| st.markdown( |
| "1. The uploaded video is decoded and 24 frames are sampled evenly.\n" |
| "2. YuNet locates the largest face in each frame.\n" |
| "3. Each face is cropped (with 30% margin) and resized to 224x224.\n" |
| "4. Every model is run on the 24-frame clip and emits one " |
| "fake / real probability for the whole clip.\n" |
| "5. Grad-CAM is computed on the middle frame of the proposed hybrid model." |
| ) |
| st.markdown("---") |
| st.caption(f"Running on **{DEVICE.upper()}**.") |
|
|
|
|
| |
|
|
| st.title("Spatiotemporal Deepfake Detection - 5 model comparison") |
| st.markdown( |
| "Upload a short face video. Every one of the five trained models runs on " |
| "it and you can compare how they agree (or disagree) on whether the clip " |
| "is real or manipulated." |
| ) |
|
|
| |
| with st.spinner("Loading face detector and all 5 models from Hugging Face (one-time, ~30s)..."): |
| face_detector = load_face_detector() |
| loaded_models = load_all_models() |
|
|
| st.success(f"All {len(loaded_models)} models loaded. Upload a video to compare them.") |
|
|
| uploaded_video = st.file_uploader( |
| "Choose a short face video (MP4 / MOV, ideally under 10 seconds)", |
| type=["mp4", "mov", "avi", "mkv"], |
| ) |
|
|
| if uploaded_video is not None: |
| |
| with tempfile.NamedTemporaryFile(suffix=Path(uploaded_video.name).suffix, delete=False) as temp_video_file: |
| temp_video_file.write(uploaded_video.read()) |
| temp_video_path = temp_video_file.name |
|
|
| |
| left_column, right_column = st.columns([1, 2]) |
| with left_column: |
| st.markdown("**Uploaded clip**") |
| st.video(temp_video_path) |
| with right_column: |
| st.markdown("**Pre-processing**") |
| preprocessing_progress = st.progress(0.0, text="Starting...") |
|
|
| def update_preprocessing_progress(message, fraction): |
| preprocessing_progress.progress(min(fraction, 1.0), text=message) |
|
|
| preprocessing_result = process_video_to_clip_tensor( |
| video_path=temp_video_path, |
| face_detector=face_detector, |
| progress_callback=update_preprocessing_progress, |
| ) |
|
|
| try: |
| os.remove(temp_video_path) |
| except OSError: |
| pass |
|
|
| if preprocessing_result.get("error"): |
| st.error(f"Pre-processing failed: {preprocessing_result['error']}") |
| st.stop() |
|
|
| clip_tensor = preprocessing_result["clip_tensor"] |
| clip_normalized = preprocessing_result["clip_normalized"] |
| face_crops_rgb = preprocessing_result["face_crops_rgb"] |
|
|
| |
| st.markdown("---") |
| st.markdown("## Running all five models") |
| inference_progress = st.progress(0.0, text="Starting inference...") |
|
|
| def update_inference_progress(model_name, fraction): |
| inference_progress.progress(fraction, text=f"Done: {model_name}") |
|
|
| fake_probability_by_model = predict_with_all_models( |
| loaded_models=loaded_models, |
| clip_normalized=clip_normalized, |
| progress_callback=update_inference_progress, |
| ) |
| inference_progress.empty() |
|
|
| |
| predictions_dataframe = build_predictions_dataframe(fake_probability_by_model) |
| number_of_fake_votes = int((predictions_dataframe["Probability (fake)"] >= 0.5).sum()) |
| number_of_real_votes = len(predictions_dataframe) - number_of_fake_votes |
| average_fake_probability = float(predictions_dataframe["Probability (fake)"].mean()) |
| proposed_model_probability = fake_probability_by_model[PROPOSED_MODEL_NAME] |
|
|
| st.markdown("## Result") |
|
|
| headline_column, hybrid_column, consensus_column = st.columns([1, 1, 1]) |
|
|
| with headline_column: |
| st.markdown("**Majority vote**") |
| if number_of_fake_votes > number_of_real_votes: |
| st.error(f"### FAKE ({number_of_fake_votes} / 5)") |
| elif number_of_real_votes > number_of_fake_votes: |
| st.success(f"### REAL ({number_of_real_votes} / 5)") |
| else: |
| st.warning("### TIED (split decision)") |
| st.caption(f"Average fake probability across models: {average_fake_probability:.2%}") |
|
|
| with hybrid_column: |
| st.markdown("**Proposed hybrid model**") |
| if proposed_model_probability >= 0.5: |
| st.error(f"### FAKE") |
| else: |
| st.success(f"### REAL") |
| st.caption(f"Hybrid says fake with probability {proposed_model_probability:.2%}.") |
|
|
| with consensus_column: |
| st.markdown("**Agreement**") |
| if number_of_fake_votes == 5 or number_of_real_votes == 5: |
| st.info("### Unanimous") |
| st.caption("All five models agree on the verdict.") |
| else: |
| st.info(f"### {number_of_fake_votes} : {number_of_real_votes}") |
| st.caption(f"{number_of_fake_votes} say FAKE, {number_of_real_votes} say REAL.") |
|
|
| |
| st.markdown("### Per-model predictions on this clip") |
| st.caption( |
| "Verdict uses a 0.5 threshold on the sigmoid output. The probability " |
| "column shows how confident each model is that the clip is a deepfake." |
| ) |
|
|
| st.dataframe( |
| predictions_dataframe, |
| column_config={ |
| "Model": st.column_config.TextColumn("Model", width="large"), |
| "Params (M)": st.column_config.NumberColumn("Params (M)", format="%.1f"), |
| "Probability (fake)": st.column_config.ProgressColumn( |
| "Probability of fake", |
| help="Sigmoid output of the model on the full 24-frame clip", |
| min_value=0.0, |
| max_value=1.0, |
| format="%.3f", |
| ), |
| "Verdict": st.column_config.TextColumn("Verdict"), |
| }, |
| hide_index=True, |
| use_container_width=True, |
| ) |
|
|
| |
| st.markdown("### Extracted face crops") |
| st.caption( |
| f"The {FRAMES_PER_CLIP} face crops every model actually sees. " |
| "8 evenly-spaced ones shown here to keep the layout compact." |
| ) |
| indices_to_show = np.linspace(0, FRAMES_PER_CLIP - 1, 8, dtype=int) |
| preview_columns = st.columns(8) |
| for column_index, frame_index in enumerate(indices_to_show): |
| with preview_columns[column_index]: |
| st.image( |
| face_crops_rgb[frame_index], |
| caption=f"frame {frame_index}", |
| use_container_width=True, |
| ) |
|
|
| |
| st.markdown("### Grad-CAM heatmap (proposed hybrid, middle frame)") |
| st.caption( |
| "Where the EfficientNet-B3 backbone of the proposed hybrid is " |
| "looking when scoring this clip. Warmer colors mean stronger contribution." |
| ) |
| with st.spinner("Generating Grad-CAM..."): |
| hybrid_model = loaded_models[PROPOSED_MODEL_NAME] |
| middle_frame_normalized = clip_normalized[GRADCAM_FRAME_INDEX] |
| middle_frame_rgb = denormalize_frame_for_display( |
| middle_frame_normalized, IMAGENET_MEAN, IMAGENET_STD |
| ) |
| gradcam_overlay = generate_gradcam_overlay( |
| hybrid_model=hybrid_model, |
| frame_normalized=middle_frame_normalized, |
| frame_rgb_zero_to_one=middle_frame_rgb, |
| device=DEVICE, |
| ) |
|
|
| original_column, heatmap_column = st.columns(2) |
| with original_column: |
| st.image(middle_frame_rgb, caption="Original middle frame", use_container_width=True) |
| with heatmap_column: |
| st.image(gradcam_overlay, caption="Grad-CAM overlay", use_container_width=True) |
|
|
|
|
| |
|
|
| st.markdown("---") |
| st.markdown("## Results from the report") |
| st.caption( |
| "Test-set results we reported in the project write-up. Every model used " |
| "the exact same training setup (AdamW, lr=1e-4, cosine schedule over 25 " |
| "epochs, BCEWithLogitsLoss, balanced sampling) so the differences below " |
| "reflect the architecture, not the tuning." |
| ) |
|
|
| in_domain_results = pd.DataFrame( |
| [ |
| {"Model": "Xception", "Params (M)": 20.8, "AUC": 0.9944, "Accuracy": 0.9744, "Precision": 0.9823, "Recall": 0.9858, "F1": 0.9840}, |
| {"Model": "EfficientNet-B3", "Params (M)": 10.7, "AUC": 0.9976, "Accuracy": 0.9829, "Precision": 0.9825, "Recall": 0.9964, "F1": 0.9894}, |
| {"Model": "CNN + BiLSTM", "Params (M)": 13.7, "AUC": 0.9805, "Accuracy": 0.9744, "Precision": 0.9823, "Recall": 0.9858, "F1": 0.9840}, |
| {"Model": "ViT-Base/16", "Params (M)": 85.8, "AUC": 0.7066, "Accuracy": 0.6695, "Precision": 0.8910, "Recall": 0.6690, "F1": 0.7642}, |
| {"Model": "Hybrid CNN-Transformer (ours)", "Params (M)": 74.9, "AUC": 0.9458, "Accuracy": 0.8462, "Precision": 0.9710, "Recall": 0.8327, "F1": 0.8966}, |
| ] |
| ) |
|
|
| cross_manipulation_results = pd.DataFrame( |
| [ |
| {"Model": "Xception", "In-domain AUC": 0.9944, "FaceShifter AUC": 0.7015, "AUC drop": 0.2929, "FaceShifter Acc": 0.3000, "FaceShifter F1": 0.3160}, |
| {"Model": "EfficientNet-B3", "In-domain AUC": 0.9976, "FaceShifter AUC": 0.7343, "AUC drop": 0.2633, "FaceShifter Acc": 0.4064, "FaceShifter F1": 0.4746}, |
| {"Model": "CNN + BiLSTM", "In-domain AUC": 0.9805, "FaceShifter AUC": 0.7591, "AUC drop": 0.2214, "FaceShifter Acc": 0.4191, "FaceShifter F1": 0.4916}, |
| {"Model": "ViT-Base/16", "In-domain AUC": 0.7066, "FaceShifter AUC": 0.6880, "AUC drop": 0.0186, "FaceShifter Acc": 0.6170, "FaceShifter F1": 0.7297}, |
| {"Model": "Hybrid CNN-Transformer (ours)", "In-domain AUC": 0.9458, "FaceShifter AUC": 0.6273, "AUC drop": 0.3185, "FaceShifter Acc": 0.3404, "FaceShifter F1": 0.3825}, |
| ] |
| ) |
|
|
| table_tab, cross_tab = st.tabs(["In-domain test set", "Cross-manipulation (FaceShifter)"]) |
|
|
| with table_tab: |
| st.markdown( |
| "Test split of FaceForensics++ C23, drawn from the same five " |
| "manipulation classes the models were trained on." |
| ) |
| st.dataframe(in_domain_results, use_container_width=True, hide_index=True) |
|
|
| with cross_tab: |
| st.markdown( |
| "FaceShifter was **never seen during training**. This measures how well " |
| "each model generalizes to a new manipulation method. Temporal models " |
| "(CNN + BiLSTM, ViT) hold up better here than pure spatial CNNs do." |
| ) |
| st.dataframe(cross_manipulation_results, use_container_width=True, hide_index=True) |
|
|