vicliv commited on
Commit
c3e4914
·
1 Parent(s): cb1c2b4

first commit

Browse files
Files changed (9) hide show
  1. Dockerfile +26 -0
  2. README.md +31 -4
  3. app/__init__.py +0 -0
  4. app/main.py +78 -0
  5. app/model.py +76 -0
  6. app/requirements.txt +9 -0
  7. app/static/index.html +477 -0
  8. app/video.py +32 -0
  9. model.safetensors +3 -0
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
+ libgl1 \
5
+ libglib2.0-0 \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /code
9
+
10
+ COPY app/requirements.txt /code/requirements.txt
11
+ RUN pip install --no-cache-dir --extra-index-url https://download.pytorch.org/whl/cpu \
12
+ -r /code/requirements.txt
13
+
14
+ ENV HF_HOME=/code/.hf-cache
15
+ ENV PYTHONUNBUFFERED=1
16
+
17
+ RUN python -c "from transformers import AutoImageProcessor, AutoModelForImageClassification; \
18
+ AutoImageProcessor.from_pretrained('microsoft/swinv2-base-patch4-window16-256', cache_dir='/code/.hf-cache'); \
19
+ AutoModelForImageClassification.from_pretrained('microsoft/swinv2-base-patch4-window16-256', cache_dir='/code/.hf-cache')"
20
+
21
+ COPY model.safetensors /code/model.safetensors
22
+ COPY app /code/app
23
+
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,38 @@
1
  ---
2
  title: OpenFakeDemo
3
- emoji:
4
- colorFrom: green
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  license: cc-by-nc-4.0
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: OpenFakeDemo
3
+ emoji: 🔍
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  license: cc-by-nc-4.0
10
  ---
11
 
12
+ # Deepfake Detector
13
+
14
+ Upload an image or short video to check if it's AI-generated. Powered by a Swin Transformer V2 model fine-tuned on real vs synthetic images.
15
+
16
+ ## How it works
17
+
18
+ - **Images**: a single forward pass through `microsoft/swinv2-base-patch4-window16-256` with a 2-class fine-tuned head (label 0 = real, label 1 = fake). The reliability score is `1 - P(fake)`.
19
+ - **Videos**: 5 frames are sampled uniformly across the duration; the per-frame `P(fake)` values are averaged.
20
+
21
+ ## Endpoints
22
+
23
+ - `GET /` — the web UI (English / French toggle)
24
+ - `POST /api/predict` — multipart upload, field `file`. Returns `{ media_type, p_fake, reliability, n_frames, frame_probs? }`.
25
+
26
+ ## Local development
27
+
28
+ ```bash
29
+ docker build -t openfake-demo .
30
+ docker run --rm -p 7860:7860 openfake-demo
31
+ ```
32
+
33
+ Then open http://localhost:7860.
34
+
35
+ ## Notes
36
+
37
+ - Free Hugging Face Spaces sleep after 48 hours of inactivity. The first request after sleep takes 30–60 seconds while the container boots.
38
+ - Uploaded files are processed in memory and not persisted.
app/__init__.py ADDED
File without changes
app/main.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import tempfile
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, File, HTTPException, UploadFile
6
+ from fastapi.staticfiles import StaticFiles
7
+ from PIL import Image
8
+
9
+ from .model import load_detector, predict_image
10
+ from .video import sample_frames
11
+
12
+ MAX_IMAGE_SIZE_MB = 20
13
+ MAX_VIDEO_SIZE_MB = 100
14
+ N_VIDEO_FRAMES = 5
15
+
16
+ IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
17
+ VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"}
18
+
19
+ app = FastAPI(title="Deepfake Detector")
20
+
21
+
22
+ @app.on_event("startup")
23
+ def warmup():
24
+ load_detector()
25
+
26
+
27
+ @app.post("/api/predict")
28
+ async def predict(file: UploadFile = File(...)):
29
+ content_type = (file.content_type or "").lower()
30
+ raw = await file.read()
31
+ size_mb = len(raw) / (1024 * 1024)
32
+
33
+ if content_type in IMAGE_TYPES:
34
+ if size_mb > MAX_IMAGE_SIZE_MB:
35
+ raise HTTPException(413, f"Image exceeds {MAX_IMAGE_SIZE_MB} MB")
36
+ try:
37
+ image = Image.open(io.BytesIO(raw))
38
+ except Exception:
39
+ raise HTTPException(400, "Invalid image")
40
+ p_fake = predict_image(image)
41
+ return {
42
+ "media_type": "image",
43
+ "p_fake": p_fake,
44
+ "reliability": 1.0 - p_fake,
45
+ "n_frames": 1,
46
+ }
47
+
48
+ if content_type in VIDEO_TYPES:
49
+ if size_mb > MAX_VIDEO_SIZE_MB:
50
+ raise HTTPException(413, f"Video exceeds {MAX_VIDEO_SIZE_MB} MB")
51
+ suffix = Path(file.filename or "video.mp4").suffix or ".mp4"
52
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
53
+ tmp.write(raw)
54
+ tmp_path = tmp.name
55
+ try:
56
+ frames = sample_frames(tmp_path, N_VIDEO_FRAMES)
57
+ except ValueError as e:
58
+ raise HTTPException(400, str(e))
59
+ finally:
60
+ try:
61
+ Path(tmp_path).unlink(missing_ok=True)
62
+ except Exception:
63
+ pass
64
+ probs = [predict_image(f) for f in frames]
65
+ p_fake = sum(probs) / len(probs)
66
+ return {
67
+ "media_type": "video",
68
+ "p_fake": p_fake,
69
+ "reliability": 1.0 - p_fake,
70
+ "n_frames": len(frames),
71
+ "frame_probs": probs,
72
+ }
73
+
74
+ raise HTTPException(415, f"Unsupported media type: {content_type}")
75
+
76
+
77
+ static_dir = Path(__file__).parent / "static"
78
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
app/model.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import torch
5
+ import torch.nn as nn
6
+ from PIL import Image
7
+ from safetensors.torch import load_file
8
+ from transformers import AutoImageProcessor, AutoModelForImageClassification
9
+
10
+ HF_NAME = "microsoft/swinv2-base-patch4-window16-256"
11
+ WEIGHTS_PATH = Path(__file__).parent.parent / "model.safetensors"
12
+ NUM_LABELS = 2
13
+
14
+ _device = torch.device("cpu")
15
+ _processor = None
16
+ _model = None
17
+
18
+
19
+ def _strip_prefixes(state_dict: dict) -> dict:
20
+ """Strip common wrapper prefixes (DDP, Lightning) from state dict keys."""
21
+ prefixes = ("module.", "model.")
22
+ cleaned = {}
23
+ for k, v in state_dict.items():
24
+ new_k = k
25
+ for p in prefixes:
26
+ if new_k.startswith(p):
27
+ new_k = new_k[len(p):]
28
+ break
29
+ cleaned[new_k] = v
30
+ return cleaned
31
+
32
+
33
+ def load_detector():
34
+ global _processor, _model
35
+ if _model is not None:
36
+ return _processor, _model
37
+
38
+ cache_dir = os.environ.get("HF_HOME", "/tmp/hf-cache")
39
+
40
+ processor = AutoImageProcessor.from_pretrained(
41
+ HF_NAME, cache_dir=cache_dir
42
+ )
43
+ model = AutoModelForImageClassification.from_pretrained(
44
+ HF_NAME, cache_dir=cache_dir
45
+ )
46
+
47
+ model.num_labels = NUM_LABELS
48
+ model.config.num_labels = NUM_LABELS
49
+ model.config.id2label = {0: "real", 1: "fake"}
50
+ model.config.label2id = {"real": 0, "fake": 1}
51
+ model.classifier = nn.Linear(model.swinv2.num_features, NUM_LABELS)
52
+
53
+ state_dict = load_file(str(WEIGHTS_PATH))
54
+ state_dict = _strip_prefixes(state_dict)
55
+ missing, unexpected = model.load_state_dict(state_dict, strict=False)
56
+ if missing:
57
+ print(f"[load_detector] missing keys ({len(missing)}): {missing[:10]}")
58
+ if unexpected:
59
+ print(f"[load_detector] unexpected keys ({len(unexpected)}): {unexpected[:10]}")
60
+
61
+ model.eval().to(_device)
62
+ _processor = processor
63
+ _model = model
64
+ return _processor, _model
65
+
66
+
67
+ @torch.no_grad()
68
+ def predict_image(image: Image.Image) -> float:
69
+ """Returns P(fake) in [0, 1]."""
70
+ processor, model = load_detector()
71
+ if image.mode != "RGB":
72
+ image = image.convert("RGB")
73
+ inputs = processor(images=image, return_tensors="pt").to(_device)
74
+ logits = model(**inputs).logits
75
+ probs = torch.softmax(logits, dim=-1)
76
+ return float(probs[0, 1].item())
app/requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ torch==2.4.1
4
+ transformers==4.45.2
5
+ safetensors==0.4.5
6
+ Pillow==10.4.0
7
+ opencv-python-headless==4.10.0.84
8
+ python-multipart==0.0.12
9
+ numpy==1.26.4
app/static/index.html ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Deepfake detector</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <style>
12
+ html, body { font-family: 'Inter', system-ui, -apple-system, sans-serif; }
13
+ body { background: #f8fafc; color: #0f172a; }
14
+ .arc-fg { transition: stroke-dashoffset 900ms cubic-bezier(0.22, 1, 0.36, 1); }
15
+ .drop-active { border-color: #1d4ed8; background-color: #eff6ff; }
16
+ .fade-in { animation: fadeIn 350ms ease-out both; }
17
+ @keyframes fadeIn {
18
+ from { opacity: 0; transform: translateY(6px); }
19
+ to { opacity: 1; transform: translateY(0); }
20
+ }
21
+ </style>
22
+ </head>
23
+ <body class="min-h-screen">
24
+ <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
25
+ <header class="flex items-center justify-between">
26
+ <div class="flex items-center gap-2">
27
+ <div class="w-9 h-9 rounded-lg bg-blue-600 flex items-center justify-center text-white font-bold">OF</div>
28
+ <span class="font-bold text-lg" data-i18n="title">Deepfake detector</span>
29
+ </div>
30
+ <div class="inline-flex items-center rounded-full border border-gray-200 bg-white p-1 text-sm">
31
+ <button id="lang-en" class="px-3 py-1 rounded-full font-semibold">EN</button>
32
+ <button id="lang-fr" class="px-3 py-1 rounded-full font-semibold">FR</button>
33
+ </div>
34
+ </header>
35
+
36
+ <main class="mt-10 sm:mt-16">
37
+ <section class="text-center max-w-2xl mx-auto">
38
+ <h1 class="text-4xl sm:text-5xl font-extrabold tracking-tight" data-i18n="title">Deepfake detector</h1>
39
+ <p class="mt-4 text-gray-600 text-lg" data-i18n="subtitle">Upload an image or short video to check if it's AI-generated.</p>
40
+ </section>
41
+
42
+ <section id="card-root" class="mt-10">
43
+ <!-- Upload zone -->
44
+ <div id="upload-card" class="bg-white rounded-2xl border border-gray-200 p-6 sm:p-10 shadow-sm">
45
+ <label id="dropzone" for="file-input"
46
+ class="block border-2 border-dashed border-gray-300 rounded-xl p-10 text-center cursor-pointer hover:border-blue-400 transition-colors">
47
+ <input id="file-input" type="file" class="hidden"
48
+ accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm" />
49
+ <div id="upload-prompt">
50
+ <div class="mx-auto w-12 h-12 rounded-full bg-blue-50 flex items-center justify-center mb-4">
51
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
52
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M12 12V4m0 0l-4 4m4-4l4 4"/>
53
+ </svg>
54
+ </div>
55
+ <div class="text-base font-semibold text-gray-900" data-i18n="upload_cta">Choose a file or drag and drop</div>
56
+ <div class="text-sm text-gray-500 mt-1" data-i18n="upload_hint">JPG, PNG, MP4 up to 100 MB</div>
57
+ </div>
58
+ <div id="preview" class="hidden">
59
+ <div id="preview-media" class="mx-auto mb-4 max-h-64 flex justify-center"></div>
60
+ <div id="preview-filename" class="text-sm font-medium text-gray-900 truncate"></div>
61
+ <div id="preview-size" class="text-xs text-gray-500 mt-1"></div>
62
+ </div>
63
+ </label>
64
+
65
+ <div id="error-banner" class="hidden mt-4 rounded-lg bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm"></div>
66
+
67
+ <div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
68
+ <button id="analyze-btn" disabled
69
+ class="px-6 py-3 rounded-xl bg-blue-600 text-white font-semibold disabled:bg-gray-300 disabled:cursor-not-allowed hover:bg-blue-700 transition-colors">
70
+ <span data-i18n="analyze">Analyze</span>
71
+ </button>
72
+ <button id="reset-btn" class="hidden px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
73
+ <span data-i18n="clear">Clear</span>
74
+ </button>
75
+ </div>
76
+ </div>
77
+
78
+ <!-- Loading -->
79
+ <div id="loading-card" class="hidden bg-white rounded-2xl border border-gray-200 p-10 shadow-sm text-center">
80
+ <div class="mx-auto w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
81
+ <div id="loading-text" class="mt-5 text-gray-700 font-medium" data-i18n="analyzing">Analyzing...</div>
82
+ </div>
83
+
84
+ <!-- Result -->
85
+ <div id="result-card" class="hidden bg-white rounded-2xl border border-gray-200 p-6 sm:p-10 shadow-sm fade-in">
86
+ <div class="flex items-start justify-between flex-wrap gap-2">
87
+ <div>
88
+ <div class="text-sm font-semibold text-gray-900 flex items-center gap-1.5">
89
+ <span data-i18n="reliability_score">Reliability score</span>
90
+ <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
91
+ <path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
92
+ </svg>
93
+ </div>
94
+ <button id="how-link" class="mt-1 text-sm text-gray-500 underline decoration-gray-300 hover:text-gray-700">
95
+ <span data-i18n="how_calculated">How is this calculated?</span>
96
+ </button>
97
+ </div>
98
+ </div>
99
+
100
+ <div class="mt-8 grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
101
+ <div class="flex flex-col items-center">
102
+ <div class="relative w-48 h-48 sm:w-56 sm:h-56">
103
+ <svg viewBox="0 0 200 200" class="w-full h-full -rotate-90">
104
+ <circle cx="100" cy="100" r="80" fill="none" stroke="#e5e7eb" stroke-width="14" stroke-linecap="round"/>
105
+ <circle id="arc-fg" cx="100" cy="100" r="80" fill="none" stroke="#1d4ed8" stroke-width="14" stroke-linecap="round"
106
+ stroke-dasharray="502.65" stroke-dashoffset="502.65" class="arc-fg"/>
107
+ </svg>
108
+ <div class="absolute inset-0 flex flex-col items-center justify-center">
109
+ <div class="text-xs uppercase tracking-wider text-gray-500 font-semibold" data-i18n="reliability">Reliability</div>
110
+ <div id="reliability-pct" class="text-4xl sm:text-5xl font-extrabold mt-1 text-gray-900">--%</div>
111
+ </div>
112
+ </div>
113
+ </div>
114
+
115
+ <div>
116
+ <div id="verdict-text" class="text-3xl sm:text-4xl font-extrabold leading-tight text-blue-600"></div>
117
+ <div id="advice-text" class="mt-3 text-lg sm:text-xl font-semibold text-gray-900"></div>
118
+ <div id="frames-info" class="mt-4 text-sm text-gray-500"></div>
119
+ </div>
120
+ </div>
121
+
122
+ <div class="mt-8 flex justify-center">
123
+ <button id="analyze-another-btn"
124
+ class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
125
+ <span data-i18n="analyze_another">Analyze another file</span>
126
+ </button>
127
+ </div>
128
+ </div>
129
+ </section>
130
+
131
+ <footer class="mt-16 text-center text-xs text-gray-400">
132
+ <span data-i18n="privacy_note">Files are processed in memory and not stored.</span>
133
+ </footer>
134
+ </main>
135
+ </div>
136
+
137
+ <!-- Modal -->
138
+ <div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
139
+ <div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
140
+ <div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
141
+ <h2 id="modal-title" class="text-xl font-bold" data-i18n="how_calculated_title">How the score is computed</h2>
142
+ <p id="modal-body" class="mt-3 text-gray-700 leading-relaxed" data-i18n="how_calculated_body"></p>
143
+ <div class="mt-5 text-right">
144
+ <button id="modal-close" class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700">
145
+ <span data-i18n="close">Close</span>
146
+ </button>
147
+ </div>
148
+ </div>
149
+ </div>
150
+
151
+ <script>
152
+ const I18N = {
153
+ en: {
154
+ title: "Deepfake detector",
155
+ subtitle: "Upload an image or short video to check if it's AI-generated.",
156
+ upload_cta: "Choose a file or drag and drop",
157
+ upload_hint: "JPG, PNG, MP4 up to 100 MB",
158
+ analyze: "Analyze",
159
+ clear: "Clear",
160
+ analyzing: "Analyzing...",
161
+ analyzing_video: "Sampling frames and running the model...",
162
+ reliability_score: "Reliability score",
163
+ how_calculated: "How is this calculated?",
164
+ reliability: "Reliability",
165
+ verdict_low_image: "This image is likely not reliable,",
166
+ verdict_low_video: "This video is likely not reliable,",
167
+ verdict_mid_image: "We're uncertain about this image,",
168
+ verdict_mid_video: "We're uncertain about this video,",
169
+ verdict_high_image: "This image is likely authentic,",
170
+ verdict_high_video: "This video is likely authentic,",
171
+ advice_low: "you should not share it with your network.",
172
+ advice_mid: "verify it from trusted sources before sharing.",
173
+ advice_high: "but always cross-check important content.",
174
+ analyze_another: "Analyze another file",
175
+ error_generic: "Something went wrong. Please try again.",
176
+ error_size: "File is too large.",
177
+ error_type: "Unsupported file type.",
178
+ frames_info: "Averaged over {n} frames.",
179
+ how_calculated_title: "How the score is computed",
180
+ how_calculated_body: "We use a Swin Transformer V2 model fine-tuned to distinguish real photographs from AI-generated images. For videos, we sample 5 frames evenly across the duration and average the model's confidence. The reliability score is 1 minus the model's probability that the content is AI-generated.",
181
+ close: "Close",
182
+ privacy_note: "Files are processed in memory and not stored.",
183
+ },
184
+ fr: {
185
+ title: "Détecteur de deepfake",
186
+ subtitle: "Téléversez une image ou une courte vidéo pour vérifier si elle est générée par IA.",
187
+ upload_cta: "Choisissez un fichier ou glissez-déposez",
188
+ upload_hint: "JPG, PNG, MP4 jusqu'à 100 Mo",
189
+ analyze: "Analyser",
190
+ clear: "Effacer",
191
+ analyzing: "Analyse en cours...",
192
+ analyzing_video: "Échantillonnage des images et exécution du modèle...",
193
+ reliability_score: "Score de fiabilité",
194
+ how_calculated: "Comment est-ce calculé ?",
195
+ reliability: "Fiabilité",
196
+ verdict_low_image: "Cette image n'est probablement pas fiable,",
197
+ verdict_low_video: "Cette vidéo n'est probablement pas fiable,",
198
+ verdict_mid_image: "Nous ne sommes pas certains pour cette image,",
199
+ verdict_mid_video: "Nous ne sommes pas certains pour cette vidéo,",
200
+ verdict_high_image: "Cette image est probablement authentique,",
201
+ verdict_high_video: "Cette vidéo est probablement authentique,",
202
+ advice_low: "vous ne devriez pas la partager avec votre réseau.",
203
+ advice_mid: "vérifiez auprès de sources fiables avant de partager.",
204
+ advice_high: "vérifiez tout de même les contenus importants.",
205
+ analyze_another: "Analyser un autre fichier",
206
+ error_generic: "Une erreur est survenue. Veuillez réessayer.",
207
+ error_size: "Le fichier est trop volumineux.",
208
+ error_type: "Type de fichier non pris en charge.",
209
+ frames_info: "Moyenne sur {n} images.",
210
+ how_calculated_title: "Comment le score est calculé",
211
+ how_calculated_body: "Nous utilisons un modèle Swin Transformer V2 entraîné pour distinguer les vraies photographies des images générées par IA. Pour les vidéos, nous échantillonnons 5 images réparties uniformément sur la durée et faisons la moyenne de la confiance du modèle. Le score de fiabilité correspond à 1 moins la probabilité estimée que le contenu soit généré par IA.",
212
+ close: "Fermer",
213
+ privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
214
+ },
215
+ };
216
+
217
+ const CIRCUMFERENCE = 2 * Math.PI * 80;
218
+
219
+ const state = {
220
+ lang: localStorage.getItem("lang") || (navigator.language.startsWith("fr") ? "fr" : "en"),
221
+ file: null,
222
+ result: null,
223
+ loading: false,
224
+ error: null,
225
+ };
226
+
227
+ const $ = (id) => document.getElementById(id);
228
+
229
+ function t() {
230
+ return I18N[state.lang];
231
+ }
232
+
233
+ function applyI18n() {
234
+ document.documentElement.lang = state.lang;
235
+ document.querySelectorAll("[data-i18n]").forEach((el) => {
236
+ const key = el.getAttribute("data-i18n");
237
+ if (t()[key] != null) el.textContent = t()[key];
238
+ });
239
+ $("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
240
+ (state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
241
+ $("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
242
+ (state.lang === "fr" ? "bg-blue-600 text-white" : "text-gray-600");
243
+ if (state.result) renderResultText();
244
+ }
245
+
246
+ function setLang(lang) {
247
+ state.lang = lang;
248
+ localStorage.setItem("lang", lang);
249
+ applyI18n();
250
+ }
251
+
252
+ function getVerdict(reliability, mediaType) {
253
+ const T = t();
254
+ if (reliability >= 0.70) {
255
+ return {
256
+ verdict: mediaType === "video" ? T.verdict_high_video : T.verdict_high_image,
257
+ advice: T.advice_high,
258
+ tone: "high",
259
+ };
260
+ }
261
+ if (reliability >= 0.40) {
262
+ return {
263
+ verdict: mediaType === "video" ? T.verdict_mid_video : T.verdict_mid_image,
264
+ advice: T.advice_mid,
265
+ tone: "mid",
266
+ };
267
+ }
268
+ return {
269
+ verdict: mediaType === "video" ? T.verdict_low_video : T.verdict_low_image,
270
+ advice: T.advice_low,
271
+ tone: "low",
272
+ };
273
+ }
274
+
275
+ function showPreview(file) {
276
+ const previewMedia = $("preview-media");
277
+ previewMedia.innerHTML = "";
278
+ if (file.type.startsWith("image/")) {
279
+ const img = document.createElement("img");
280
+ img.src = URL.createObjectURL(file);
281
+ img.className = "max-h-64 rounded-lg object-contain";
282
+ img.onload = () => URL.revokeObjectURL(img.src);
283
+ previewMedia.appendChild(img);
284
+ } else {
285
+ const video = document.createElement("video");
286
+ video.src = URL.createObjectURL(file);
287
+ video.className = "max-h-64 rounded-lg";
288
+ video.muted = true;
289
+ video.playsInline = true;
290
+ video.preload = "metadata";
291
+ previewMedia.appendChild(video);
292
+ }
293
+ $("upload-prompt").classList.add("hidden");
294
+ $("preview").classList.remove("hidden");
295
+ $("preview-filename").textContent = file.name;
296
+ $("preview-size").textContent = `${(file.size / (1024 * 1024)).toFixed(2)} MB`;
297
+ $("analyze-btn").disabled = false;
298
+ $("reset-btn").classList.remove("hidden");
299
+ }
300
+
301
+ function resetUpload() {
302
+ state.file = null;
303
+ state.error = null;
304
+ $("file-input").value = "";
305
+ $("upload-prompt").classList.remove("hidden");
306
+ $("preview").classList.add("hidden");
307
+ $("analyze-btn").disabled = true;
308
+ $("reset-btn").classList.add("hidden");
309
+ $("error-banner").classList.add("hidden");
310
+ }
311
+
312
+ function showError(msg) {
313
+ const banner = $("error-banner");
314
+ banner.textContent = msg;
315
+ banner.classList.remove("hidden");
316
+ }
317
+
318
+ function setFile(file) {
319
+ $("error-banner").classList.add("hidden");
320
+ if (!file) return;
321
+ const isImage = file.type.startsWith("image/");
322
+ const isVideo = file.type.startsWith("video/");
323
+ if (!isImage && !isVideo) {
324
+ showError(t().error_type);
325
+ return;
326
+ }
327
+ const maxMB = isVideo ? 100 : 20;
328
+ if (file.size / (1024 * 1024) > maxMB) {
329
+ showError(t().error_size);
330
+ return;
331
+ }
332
+ state.file = file;
333
+ showPreview(file);
334
+ }
335
+
336
+ function renderResultText() {
337
+ if (!state.result) return;
338
+ const r = state.result.reliability;
339
+ const v = getVerdict(r, state.result.media_type);
340
+ $("verdict-text").textContent = v.verdict;
341
+ $("advice-text").textContent = v.advice;
342
+ $("reliability-pct").textContent = `${Math.round(r * 100)}%`;
343
+ const arc = $("arc-fg");
344
+ const tones = {
345
+ high: "#16a34a",
346
+ mid: "#d97706",
347
+ low: "#1d4ed8",
348
+ };
349
+ arc.setAttribute("stroke", tones[v.tone]);
350
+ if (state.result.media_type === "video") {
351
+ $("frames-info").textContent = t().frames_info.replace("{n}", state.result.n_frames);
352
+ } else {
353
+ $("frames-info").textContent = "";
354
+ }
355
+ }
356
+
357
+ function animateArc(reliability) {
358
+ const arc = $("arc-fg");
359
+ arc.style.transition = "none";
360
+ arc.setAttribute("stroke-dashoffset", CIRCUMFERENCE);
361
+ requestAnimationFrame(() => {
362
+ requestAnimationFrame(() => {
363
+ arc.style.transition = "";
364
+ const target = CIRCUMFERENCE * (1 - Math.max(0, Math.min(1, reliability)));
365
+ arc.setAttribute("stroke-dashoffset", target);
366
+ });
367
+ });
368
+ }
369
+
370
+ function showCard(name) {
371
+ ["upload-card", "loading-card", "result-card"].forEach((id) => {
372
+ $(id).classList.toggle("hidden", id !== name);
373
+ });
374
+ }
375
+
376
+ async function analyze() {
377
+ if (!state.file) return;
378
+ state.loading = true;
379
+ state.error = null;
380
+ const isVideo = state.file.type.startsWith("video/");
381
+ $("loading-text").textContent = isVideo ? t().analyzing_video : t().analyzing;
382
+ showCard("loading-card");
383
+
384
+ const form = new FormData();
385
+ form.append("file", state.file);
386
+
387
+ try {
388
+ const res = await fetch("/api/predict", { method: "POST", body: form });
389
+ if (!res.ok) {
390
+ let detail = t().error_generic;
391
+ if (res.status === 413) detail = t().error_size;
392
+ else if (res.status === 415) detail = t().error_type;
393
+ else {
394
+ const body = await res.json().catch(() => ({}));
395
+ if (body.detail) detail = body.detail;
396
+ }
397
+ throw new Error(detail);
398
+ }
399
+ state.result = await res.json();
400
+ renderResultText();
401
+ showCard("result-card");
402
+ animateArc(state.result.reliability);
403
+ } catch (e) {
404
+ showError(e.message);
405
+ showCard("upload-card");
406
+ } finally {
407
+ state.loading = false;
408
+ }
409
+ }
410
+
411
+ function openModal() {
412
+ $("modal").classList.remove("hidden");
413
+ $("modal").classList.add("flex");
414
+ $("modal-backdrop").classList.remove("hidden");
415
+ }
416
+ function closeModal() {
417
+ $("modal").classList.add("hidden");
418
+ $("modal").classList.remove("flex");
419
+ $("modal-backdrop").classList.add("hidden");
420
+ }
421
+
422
+ function init() {
423
+ applyI18n();
424
+
425
+ $("lang-en").addEventListener("click", () => setLang("en"));
426
+ $("lang-fr").addEventListener("click", () => setLang("fr"));
427
+
428
+ const fileInput = $("file-input");
429
+ fileInput.addEventListener("change", (e) => {
430
+ const f = e.target.files && e.target.files[0];
431
+ if (f) setFile(f);
432
+ });
433
+
434
+ const dz = $("dropzone");
435
+ ["dragenter", "dragover"].forEach((evt) => {
436
+ dz.addEventListener(evt, (e) => {
437
+ e.preventDefault();
438
+ e.stopPropagation();
439
+ dz.classList.add("drop-active");
440
+ });
441
+ });
442
+ ["dragleave", "drop"].forEach((evt) => {
443
+ dz.addEventListener(evt, (e) => {
444
+ e.preventDefault();
445
+ e.stopPropagation();
446
+ dz.classList.remove("drop-active");
447
+ });
448
+ });
449
+ dz.addEventListener("drop", (e) => {
450
+ const f = e.dataTransfer.files && e.dataTransfer.files[0];
451
+ if (f) setFile(f);
452
+ });
453
+
454
+ $("analyze-btn").addEventListener("click", analyze);
455
+ $("reset-btn").addEventListener("click", (e) => {
456
+ e.preventDefault();
457
+ e.stopPropagation();
458
+ resetUpload();
459
+ });
460
+ $("analyze-another-btn").addEventListener("click", () => {
461
+ state.result = null;
462
+ resetUpload();
463
+ showCard("upload-card");
464
+ });
465
+
466
+ $("how-link").addEventListener("click", openModal);
467
+ $("modal-close").addEventListener("click", closeModal);
468
+ $("modal-backdrop").addEventListener("click", closeModal);
469
+ document.addEventListener("keydown", (e) => {
470
+ if (e.key === "Escape") closeModal();
471
+ });
472
+ }
473
+
474
+ init();
475
+ </script>
476
+ </body>
477
+ </html>
app/video.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ from PIL import Image
4
+
5
+
6
+ def sample_frames(video_path: str, n_frames: int = 5) -> list[Image.Image]:
7
+ """Sample n_frames uniformly across the video. Returns RGB PIL images."""
8
+ cap = cv2.VideoCapture(video_path)
9
+ if not cap.isOpened():
10
+ raise ValueError("Could not open video file")
11
+
12
+ total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
13
+ if total <= 0:
14
+ cap.release()
15
+ raise ValueError("Video has no readable frames")
16
+
17
+ n = min(n_frames, total)
18
+ indices = np.linspace(0, total - 1, n, dtype=int)
19
+
20
+ frames = []
21
+ for idx in indices:
22
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(idx))
23
+ ok, frame = cap.read()
24
+ if not ok:
25
+ continue
26
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
27
+ frames.append(Image.fromarray(frame))
28
+
29
+ cap.release()
30
+ if not frames:
31
+ raise ValueError("No frames could be decoded from video")
32
+ return frames
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:69e90d17644cfd2599af271900168562cec8f102411b0dfa58e1451694ff7f8a
3
+ size 347645480