Spaces:
Running
Running
first commit
Browse files- Dockerfile +26 -0
- README.md +31 -4
- app/__init__.py +0 -0
- app/main.py +78 -0
- app/model.py +76 -0
- app/requirements.txt +9 -0
- app/static/index.html +477 -0
- app/video.py +32 -0
- 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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: cc-by-nc-4.0
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|