Spaces:
Running
Running
Add disclaimer section and error report form with HF upload
Browse files- app/main.py +80 -1
- app/requirements.txt +1 -0
- app/static/index.html +279 -5
app/main.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
import io
|
|
|
|
|
|
|
| 2 |
import random
|
| 3 |
import tempfile
|
|
|
|
|
|
|
| 4 |
from pathlib import Path
|
| 5 |
|
| 6 |
-
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 7 |
from fastapi.staticfiles import StaticFiles
|
| 8 |
from PIL import Image, ImageOps
|
| 9 |
|
|
@@ -18,6 +22,9 @@ N_VIDEO_FRAMES = 5
|
|
| 18 |
IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
|
| 19 |
VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
| 21 |
app = FastAPI(title="Deepfake Detector")
|
| 22 |
|
| 23 |
|
|
@@ -123,5 +130,77 @@ async def predict(file: UploadFile = File(...)):
|
|
| 123 |
raise HTTPException(415, f"Unsupported media type: {content_type}")
|
| 124 |
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
static_dir = Path(__file__).parent / "static"
|
| 127 |
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
|
|
|
|
|
| 1 |
import io
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
import random
|
| 5 |
import tempfile
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from PIL import Image, ImageOps
|
| 13 |
|
|
|
|
| 22 |
IMAGE_TYPES = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
|
| 23 |
VIDEO_TYPES = {"video/mp4", "video/quicktime", "video/webm", "video/x-matroska"}
|
| 24 |
|
| 25 |
+
HF_REPORT_REPO = os.environ.get("HF_REPORT_REPO", "ComplexDataLab/openfake-reports")
|
| 26 |
+
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 27 |
+
|
| 28 |
app = FastAPI(title="Deepfake Detector")
|
| 29 |
|
| 30 |
|
|
|
|
| 130 |
raise HTTPException(415, f"Unsupported media type: {content_type}")
|
| 131 |
|
| 132 |
|
| 133 |
+
@app.post("/api/report")
|
| 134 |
+
async def report(
|
| 135 |
+
file: UploadFile = File(...),
|
| 136 |
+
is_real: str = Form(...),
|
| 137 |
+
reason: str = Form(...),
|
| 138 |
+
reason_other: str = Form(""),
|
| 139 |
+
comment: str = Form(""),
|
| 140 |
+
p_fake: float = Form(...),
|
| 141 |
+
consent: str = Form(...),
|
| 142 |
+
):
|
| 143 |
+
"""Save an error report (form answers + image) to a Hugging Face dataset repo."""
|
| 144 |
+
if consent != "true":
|
| 145 |
+
raise HTTPException(400, "Image saving consent is required.")
|
| 146 |
+
|
| 147 |
+
if not HF_TOKEN:
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
503, "Reporting is not configured (missing HF_TOKEN)."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Read the uploaded image
|
| 153 |
+
raw = await file.read()
|
| 154 |
+
content_type = (file.content_type or "").lower()
|
| 155 |
+
if content_type not in IMAGE_TYPES:
|
| 156 |
+
raise HTTPException(415, "Only images can be reported.")
|
| 157 |
+
|
| 158 |
+
# Build report payload
|
| 159 |
+
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%S")
|
| 160 |
+
short_id = uuid.uuid4().hex[:8]
|
| 161 |
+
folder_name = f"{ts}_{short_id}"
|
| 162 |
+
|
| 163 |
+
report_data = {
|
| 164 |
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
| 165 |
+
"is_real": is_real,
|
| 166 |
+
"reason": reason,
|
| 167 |
+
"reason_other": reason_other if reason == "other" else "",
|
| 168 |
+
"comment": comment,
|
| 169 |
+
"p_fake": p_fake,
|
| 170 |
+
"original_filename": file.filename or "unknown",
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
# Write to a temp directory then upload to HF
|
| 174 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 175 |
+
report_dir = Path(tmpdir) / folder_name
|
| 176 |
+
report_dir.mkdir()
|
| 177 |
+
|
| 178 |
+
# Save report JSON
|
| 179 |
+
(report_dir / "report.json").write_text(
|
| 180 |
+
json.dumps(report_data, indent=2, ensure_ascii=False)
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
# Save image with original extension
|
| 184 |
+
ext = Path(file.filename or "image.jpg").suffix or ".jpg"
|
| 185 |
+
(report_dir / f"image{ext}").write_bytes(raw)
|
| 186 |
+
|
| 187 |
+
# Upload to HF dataset repo
|
| 188 |
+
try:
|
| 189 |
+
from huggingface_hub import HfApi
|
| 190 |
+
|
| 191 |
+
api = HfApi(token=HF_TOKEN)
|
| 192 |
+
api.upload_folder(
|
| 193 |
+
folder_path=str(report_dir),
|
| 194 |
+
path_in_repo=f"reports/{folder_name}",
|
| 195 |
+
repo_id=HF_REPORT_REPO,
|
| 196 |
+
repo_type="dataset",
|
| 197 |
+
)
|
| 198 |
+
except Exception as e:
|
| 199 |
+
raise HTTPException(500, f"Failed to upload report: {e}")
|
| 200 |
+
|
| 201 |
+
return {"status": "ok"}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
static_dir = Path(__file__).parent / "static"
|
| 205 |
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
| 206 |
+
|
app/requirements.txt
CHANGED
|
@@ -6,3 +6,4 @@ Pillow==10.4.0
|
|
| 6 |
opencv-python-headless==4.10.0.84
|
| 7 |
python-multipart==0.0.12
|
| 8 |
numpy==1.26.4
|
|
|
|
|
|
| 6 |
opencv-python-headless==4.10.0.84
|
| 7 |
python-multipart==0.0.12
|
| 8 |
numpy==1.26.4
|
| 9 |
+
huggingface_hub>=0.23.0
|
app/static/index.html
CHANGED
|
@@ -127,22 +127,46 @@
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
-
<div class="mt-8 flex justify-center">
|
| 131 |
<button id="analyze-another-btn"
|
| 132 |
class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
|
| 133 |
<span data-i18n="analyze_another">Analyze another file</span>
|
| 134 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
</div>
|
| 136 |
</div>
|
| 137 |
</section>
|
| 138 |
|
| 139 |
-
<
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
</footer>
|
| 142 |
</main>
|
| 143 |
</div>
|
| 144 |
|
| 145 |
-
<!-- Modal -->
|
| 146 |
<div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
|
| 147 |
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
| 148 |
<div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
|
|
@@ -156,6 +180,88 @@
|
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
<script>
|
| 160 |
const I18N = {
|
| 161 |
en: {
|
|
@@ -192,6 +298,32 @@
|
|
| 192 |
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 score shown is the model's estimated probability that the content was generated by AI.",
|
| 193 |
close: "Close",
|
| 194 |
privacy_note: "Files are processed in memory and not stored.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
},
|
| 196 |
fr: {
|
| 197 |
title: "Détecteur d'hypertrucage",
|
|
@@ -227,6 +359,32 @@
|
|
| 227 |
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 affiché correspond à la probabilité estimée que le contenu ait été généré par IA.",
|
| 228 |
close: "Fermer",
|
| 229 |
privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
},
|
| 231 |
};
|
| 232 |
|
|
@@ -252,6 +410,10 @@
|
|
| 252 |
const key = el.getAttribute("data-i18n");
|
| 253 |
if (t()[key] != null) el.textContent = t()[key];
|
| 254 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
$("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
|
| 256 |
(state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
|
| 257 |
$("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
|
|
@@ -492,6 +654,7 @@
|
|
| 492 |
}
|
| 493 |
}
|
| 494 |
|
|
|
|
| 495 |
function openModal() {
|
| 496 |
$("modal").classList.remove("hidden");
|
| 497 |
$("modal").classList.add("flex");
|
|
@@ -503,6 +666,93 @@
|
|
| 503 |
$("modal-backdrop").classList.add("hidden");
|
| 504 |
}
|
| 505 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
function init() {
|
| 507 |
applyI18n();
|
| 508 |
|
|
@@ -547,11 +797,35 @@
|
|
| 547 |
showCard("upload-card");
|
| 548 |
});
|
| 549 |
|
|
|
|
| 550 |
$("how-link").addEventListener("click", openModal);
|
| 551 |
$("modal-close").addEventListener("click", closeModal);
|
| 552 |
$("modal-backdrop").addEventListener("click", closeModal);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
document.addEventListener("keydown", (e) => {
|
| 554 |
-
if (e.key === "Escape")
|
|
|
|
|
|
|
|
|
|
| 555 |
});
|
| 556 |
}
|
| 557 |
|
|
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
|
| 130 |
+
<div class="mt-8 flex flex-col sm:flex-row gap-3 justify-center">
|
| 131 |
<button id="analyze-another-btn"
|
| 132 |
class="px-6 py-3 rounded-xl border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
|
| 133 |
<span data-i18n="analyze_another">Analyze another file</span>
|
| 134 |
</button>
|
| 135 |
+
<button id="report-btn"
|
| 136 |
+
class="px-6 py-3 rounded-xl border border-amber-300 bg-amber-50 text-amber-800 font-semibold hover:bg-amber-100 transition-colors flex items-center gap-2 justify-center">
|
| 137 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 138 |
+
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
| 139 |
+
</svg>
|
| 140 |
+
<span data-i18n="report_error">Signal an error</span>
|
| 141 |
+
</button>
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
</section>
|
| 145 |
|
| 146 |
+
<!-- Disclaimer -->
|
| 147 |
+
<footer class="mt-16 max-w-2xl mx-auto">
|
| 148 |
+
<div class="rounded-xl border border-gray-200 bg-white p-5 shadow-sm">
|
| 149 |
+
<div class="flex items-start gap-3">
|
| 150 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-gray-400 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
| 151 |
+
<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"/>
|
| 152 |
+
</svg>
|
| 153 |
+
<div class="text-sm text-gray-500 space-y-2">
|
| 154 |
+
<p data-i18n="privacy_note">Files are processed in memory and not stored.</p>
|
| 155 |
+
<p class="font-medium text-gray-600" data-i18n="disclaimer_mistakes">This model can make mistakes.</p>
|
| 156 |
+
<p class="font-medium text-gray-600" data-i18n="disclaimer_known_issues_title">Known limitations:</p>
|
| 157 |
+
<ul class="list-disc list-inside space-y-1 text-gray-500">
|
| 158 |
+
<li data-i18n="disclaimer_lipsync">Lip sync and small inpainting may not be reliably detected.</li>
|
| 159 |
+
<li data-i18n="disclaimer_text_overlay">Images with text overlay are often incorrectly flagged as AI-generated.</li>
|
| 160 |
+
<li data-i18n="disclaimer_full_gen">The model is very good at detecting fully AI-generated images.</li>
|
| 161 |
+
</ul>
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
</footer>
|
| 166 |
</main>
|
| 167 |
</div>
|
| 168 |
|
| 169 |
+
<!-- How-it-works Modal -->
|
| 170 |
<div id="modal-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
|
| 171 |
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4">
|
| 172 |
<div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl">
|
|
|
|
| 180 |
</div>
|
| 181 |
</div>
|
| 182 |
|
| 183 |
+
<!-- Report Error Modal -->
|
| 184 |
+
<div id="report-backdrop" class="hidden fixed inset-0 bg-black/40 z-40"></div>
|
| 185 |
+
<div id="report-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
|
| 186 |
+
<div class="bg-white rounded-2xl max-w-lg w-full p-6 shadow-xl my-8">
|
| 187 |
+
<h2 class="text-xl font-bold" data-i18n="report_title">Signal an error</h2>
|
| 188 |
+
<p class="mt-2 text-sm text-gray-500" data-i18n="report_description">Help us improve by reporting an incorrect result.</p>
|
| 189 |
+
|
| 190 |
+
<form id="report-form" class="mt-6 space-y-6">
|
| 191 |
+
<!-- Is the image real or AI? -->
|
| 192 |
+
<fieldset>
|
| 193 |
+
<legend class="text-sm font-semibold text-gray-900" data-i18n="report_is_real_label">Is this image real or AI-generated?</legend>
|
| 194 |
+
<div class="mt-3 space-y-2">
|
| 195 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 196 |
+
<input type="radio" name="is_real" value="real" class="w-4 h-4 text-blue-600" required />
|
| 197 |
+
<span data-i18n="report_real">Real (authentic)</span>
|
| 198 |
+
</label>
|
| 199 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 200 |
+
<input type="radio" name="is_real" value="ai" class="w-4 h-4 text-blue-600" />
|
| 201 |
+
<span data-i18n="report_ai">AI-generated or manipulated</span>
|
| 202 |
+
</label>
|
| 203 |
+
</div>
|
| 204 |
+
</fieldset>
|
| 205 |
+
|
| 206 |
+
<!-- Reason -->
|
| 207 |
+
<fieldset>
|
| 208 |
+
<legend class="text-sm font-semibold text-gray-900" data-i18n="report_reason_label">How do you know?</legend>
|
| 209 |
+
<div class="mt-3 space-y-2">
|
| 210 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 211 |
+
<input type="radio" name="reason" value="self_made" class="w-4 h-4 text-blue-600" required />
|
| 212 |
+
<span data-i18n="report_reason_self">I generated or took this image myself</span>
|
| 213 |
+
</label>
|
| 214 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 215 |
+
<input type="radio" name="reason" value="evidence" class="w-4 h-4 text-blue-600" />
|
| 216 |
+
<span data-i18n="report_reason_evidence">Clear evidence of manipulation</span>
|
| 217 |
+
</label>
|
| 218 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 219 |
+
<input type="radio" name="reason" value="known_source" class="w-4 h-4 text-blue-600" />
|
| 220 |
+
<span data-i18n="report_reason_source">Known source of the image</span>
|
| 221 |
+
</label>
|
| 222 |
+
<label class="flex items-center gap-2 cursor-pointer">
|
| 223 |
+
<input type="radio" name="reason" value="other" class="w-4 h-4 text-blue-600" />
|
| 224 |
+
<span data-i18n="report_reason_other">Other</span>
|
| 225 |
+
</label>
|
| 226 |
+
</div>
|
| 227 |
+
<div id="reason-other-wrap" class="hidden mt-3">
|
| 228 |
+
<input id="reason-other-input" type="text" placeholder="" data-i18n-placeholder="report_reason_other_placeholder"
|
| 229 |
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" />
|
| 230 |
+
</div>
|
| 231 |
+
</fieldset>
|
| 232 |
+
|
| 233 |
+
<!-- Comment -->
|
| 234 |
+
<div>
|
| 235 |
+
<label class="text-sm font-semibold text-gray-900" data-i18n="report_comment_label">Additional comments (optional)</label>
|
| 236 |
+
<textarea id="report-comment" rows="3"
|
| 237 |
+
class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none"></textarea>
|
| 238 |
+
</div>
|
| 239 |
+
|
| 240 |
+
<!-- Consent -->
|
| 241 |
+
<label class="flex items-start gap-3 cursor-pointer">
|
| 242 |
+
<input id="report-consent" type="checkbox" class="w-4 h-4 mt-0.5 text-blue-600 rounded" />
|
| 243 |
+
<span class="text-sm text-gray-700" data-i18n="report_consent">I allow saving this image for research purposes. This is required to submit the report.</span>
|
| 244 |
+
</label>
|
| 245 |
+
|
| 246 |
+
<!-- Error / success banners -->
|
| 247 |
+
<div id="report-error" class="hidden rounded-lg bg-red-50 border border-red-200 text-red-700 px-4 py-3 text-sm"></div>
|
| 248 |
+
<div id="report-success" class="hidden rounded-lg bg-green-50 border border-green-200 text-green-700 px-4 py-3 text-sm" data-i18n="report_success">Thank you! Your report has been submitted.</div>
|
| 249 |
+
|
| 250 |
+
<!-- Buttons -->
|
| 251 |
+
<div class="flex gap-3 justify-end">
|
| 252 |
+
<button type="button" id="report-cancel"
|
| 253 |
+
class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 font-semibold hover:bg-gray-50">
|
| 254 |
+
<span data-i18n="report_cancel">Cancel</span>
|
| 255 |
+
</button>
|
| 256 |
+
<button type="submit" id="report-submit"
|
| 257 |
+
class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
|
| 258 |
+
<span data-i18n="report_submit">Submit report</span>
|
| 259 |
+
</button>
|
| 260 |
+
</div>
|
| 261 |
+
</form>
|
| 262 |
+
</div>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
<script>
|
| 266 |
const I18N = {
|
| 267 |
en: {
|
|
|
|
| 298 |
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 score shown is the model's estimated probability that the content was generated by AI.",
|
| 299 |
close: "Close",
|
| 300 |
privacy_note: "Files are processed in memory and not stored.",
|
| 301 |
+
disclaimer_mistakes: "This model can make mistakes.",
|
| 302 |
+
disclaimer_known_issues_title: "Known limitations:",
|
| 303 |
+
disclaimer_lipsync: "Lip sync and small inpainting may not be reliably detected.",
|
| 304 |
+
disclaimer_text_overlay: "Images with text overlay are often incorrectly flagged as AI-generated.",
|
| 305 |
+
disclaimer_full_gen: "The model is very good at detecting fully AI-generated images.",
|
| 306 |
+
report_error: "Signal an error",
|
| 307 |
+
report_title: "Signal an error",
|
| 308 |
+
report_description: "Help us improve by reporting an incorrect result.",
|
| 309 |
+
report_is_real_label: "Is this image real or AI-generated?",
|
| 310 |
+
report_real: "Real (authentic)",
|
| 311 |
+
report_ai: "AI-generated or manipulated",
|
| 312 |
+
report_reason_label: "How do you know?",
|
| 313 |
+
report_reason_self: "I generated or took this image myself",
|
| 314 |
+
report_reason_evidence: "Clear evidence of manipulation",
|
| 315 |
+
report_reason_source: "Known source of the image",
|
| 316 |
+
report_reason_other: "Other",
|
| 317 |
+
report_reason_other_placeholder: "Please specify…",
|
| 318 |
+
report_comment_label: "Additional comments (optional)",
|
| 319 |
+
report_consent: "I allow saving this image for research purposes. This is required to submit the report.",
|
| 320 |
+
report_cancel: "Cancel",
|
| 321 |
+
report_submit: "Submit report",
|
| 322 |
+
report_submitting: "Submitting...",
|
| 323 |
+
report_success: "Thank you! Your report has been submitted.",
|
| 324 |
+
report_error_consent: "You must allow saving the image to submit a report.",
|
| 325 |
+
report_error_fields: "Please fill in all required fields.",
|
| 326 |
+
report_error_generic: "Failed to submit the report. Please try again.",
|
| 327 |
},
|
| 328 |
fr: {
|
| 329 |
title: "Détecteur d'hypertrucage",
|
|
|
|
| 359 |
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 affiché correspond à la probabilité estimée que le contenu ait été généré par IA.",
|
| 360 |
close: "Fermer",
|
| 361 |
privacy_note: "Les fichiers sont traités en mémoire et ne sont pas conservés.",
|
| 362 |
+
disclaimer_mistakes: "Ce modèle peut faire des erreurs.",
|
| 363 |
+
disclaimer_known_issues_title: "Limitations connues :",
|
| 364 |
+
disclaimer_lipsync: "La synchronisation labiale et les petits inpaintings peuvent ne pas être détectés de manière fiable.",
|
| 365 |
+
disclaimer_text_overlay: "Les images avec du texte superposé sont souvent incorrectement signalées comme générées par IA.",
|
| 366 |
+
disclaimer_full_gen: "Le modèle est très performant pour détecter les images entièrement générées par IA.",
|
| 367 |
+
report_error: "Signaler une erreur",
|
| 368 |
+
report_title: "Signaler une erreur",
|
| 369 |
+
report_description: "Aidez-nous à nous améliorer en signalant un résultat incorrect.",
|
| 370 |
+
report_is_real_label: "Cette image est-elle réelle ou générée par IA ?",
|
| 371 |
+
report_real: "Réelle (authentique)",
|
| 372 |
+
report_ai: "Générée par IA ou manipulée",
|
| 373 |
+
report_reason_label: "Comment le savez-vous ?",
|
| 374 |
+
report_reason_self: "J'ai généré ou pris cette image moi-même",
|
| 375 |
+
report_reason_evidence: "Preuve claire de manipulation",
|
| 376 |
+
report_reason_source: "Source connue de l'image",
|
| 377 |
+
report_reason_other: "Autre",
|
| 378 |
+
report_reason_other_placeholder: "Veuillez préciser…",
|
| 379 |
+
report_comment_label: "Commentaires supplémentaires (optionnel)",
|
| 380 |
+
report_consent: "J'autorise la sauvegarde de cette image à des fins de recherche. Ceci est requis pour soumettre le signalement.",
|
| 381 |
+
report_cancel: "Annuler",
|
| 382 |
+
report_submit: "Envoyer le signalement",
|
| 383 |
+
report_submitting: "Envoi en cours...",
|
| 384 |
+
report_success: "Merci ! Votre signalement a été soumis.",
|
| 385 |
+
report_error_consent: "Vous devez autoriser la sauvegarde de l'image pour soumettre un signalement.",
|
| 386 |
+
report_error_fields: "Veuillez remplir tous les champs obligatoires.",
|
| 387 |
+
report_error_generic: "Échec de l'envoi du signalement. Veuillez réessayer.",
|
| 388 |
},
|
| 389 |
};
|
| 390 |
|
|
|
|
| 410 |
const key = el.getAttribute("data-i18n");
|
| 411 |
if (t()[key] != null) el.textContent = t()[key];
|
| 412 |
});
|
| 413 |
+
document.querySelectorAll("[data-i18n-placeholder]").forEach((el) => {
|
| 414 |
+
const key = el.getAttribute("data-i18n-placeholder");
|
| 415 |
+
if (t()[key] != null) el.placeholder = t()[key];
|
| 416 |
+
});
|
| 417 |
$("lang-en").className = "px-3 py-1 rounded-full font-semibold " +
|
| 418 |
(state.lang === "en" ? "bg-blue-600 text-white" : "text-gray-600");
|
| 419 |
$("lang-fr").className = "px-3 py-1 rounded-full font-semibold " +
|
|
|
|
| 654 |
}
|
| 655 |
}
|
| 656 |
|
| 657 |
+
/* --- How-it-works modal --- */
|
| 658 |
function openModal() {
|
| 659 |
$("modal").classList.remove("hidden");
|
| 660 |
$("modal").classList.add("flex");
|
|
|
|
| 666 |
$("modal-backdrop").classList.add("hidden");
|
| 667 |
}
|
| 668 |
|
| 669 |
+
/* --- Report modal --- */
|
| 670 |
+
function openReportModal() {
|
| 671 |
+
// Reset the form
|
| 672 |
+
$("report-form").reset();
|
| 673 |
+
$("reason-other-wrap").classList.add("hidden");
|
| 674 |
+
$("report-error").classList.add("hidden");
|
| 675 |
+
$("report-success").classList.add("hidden");
|
| 676 |
+
$("report-submit").disabled = false;
|
| 677 |
+
$("report-submit").querySelector("[data-i18n]").textContent = t().report_submit;
|
| 678 |
+
// Show modal
|
| 679 |
+
$("report-modal").classList.remove("hidden");
|
| 680 |
+
$("report-modal").classList.add("flex");
|
| 681 |
+
$("report-backdrop").classList.remove("hidden");
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function closeReportModal() {
|
| 685 |
+
$("report-modal").classList.add("hidden");
|
| 686 |
+
$("report-modal").classList.remove("flex");
|
| 687 |
+
$("report-backdrop").classList.add("hidden");
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
async function submitReport(e) {
|
| 691 |
+
e.preventDefault();
|
| 692 |
+
const T = t();
|
| 693 |
+
const errorEl = $("report-error");
|
| 694 |
+
const successEl = $("report-success");
|
| 695 |
+
errorEl.classList.add("hidden");
|
| 696 |
+
successEl.classList.add("hidden");
|
| 697 |
+
|
| 698 |
+
// Validate consent
|
| 699 |
+
if (!$("report-consent").checked) {
|
| 700 |
+
errorEl.textContent = T.report_error_consent;
|
| 701 |
+
errorEl.classList.remove("hidden");
|
| 702 |
+
return;
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
// Gather radio values
|
| 706 |
+
const isRealRadio = document.querySelector('input[name="is_real"]:checked');
|
| 707 |
+
const reasonRadio = document.querySelector('input[name="reason"]:checked');
|
| 708 |
+
if (!isRealRadio || !reasonRadio) {
|
| 709 |
+
errorEl.textContent = T.report_error_fields;
|
| 710 |
+
errorEl.classList.remove("hidden");
|
| 711 |
+
return;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
// If reason is "other", require the text input
|
| 715 |
+
const reasonValue = reasonRadio.value;
|
| 716 |
+
const reasonOther = $("reason-other-input").value.trim();
|
| 717 |
+
if (reasonValue === "other" && !reasonOther) {
|
| 718 |
+
errorEl.textContent = T.report_error_fields;
|
| 719 |
+
errorEl.classList.remove("hidden");
|
| 720 |
+
return;
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
// Build form data
|
| 724 |
+
const formData = new FormData();
|
| 725 |
+
formData.append("file", state.file);
|
| 726 |
+
formData.append("is_real", isRealRadio.value);
|
| 727 |
+
formData.append("reason", reasonValue);
|
| 728 |
+
formData.append("reason_other", reasonOther);
|
| 729 |
+
formData.append("comment", $("report-comment").value.trim());
|
| 730 |
+
formData.append("p_fake", state.result ? state.result.p_fake : 0);
|
| 731 |
+
formData.append("consent", "true");
|
| 732 |
+
|
| 733 |
+
// Disable button and show loading
|
| 734 |
+
const submitBtn = $("report-submit");
|
| 735 |
+
submitBtn.disabled = true;
|
| 736 |
+
submitBtn.querySelector("[data-i18n]").textContent = T.report_submitting;
|
| 737 |
+
|
| 738 |
+
try {
|
| 739 |
+
const res = await fetch("/api/report", { method: "POST", body: formData });
|
| 740 |
+
if (!res.ok) {
|
| 741 |
+
const body = await res.json().catch(() => ({}));
|
| 742 |
+
throw new Error(body.detail || T.report_error_generic);
|
| 743 |
+
}
|
| 744 |
+
successEl.textContent = T.report_success;
|
| 745 |
+
successEl.classList.remove("hidden");
|
| 746 |
+
// Auto-close after 2s
|
| 747 |
+
setTimeout(() => closeReportModal(), 2000);
|
| 748 |
+
} catch (err) {
|
| 749 |
+
errorEl.textContent = err.message || T.report_error_generic;
|
| 750 |
+
errorEl.classList.remove("hidden");
|
| 751 |
+
submitBtn.disabled = false;
|
| 752 |
+
submitBtn.querySelector("[data-i18n]").textContent = T.report_submit;
|
| 753 |
+
}
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
function init() {
|
| 757 |
applyI18n();
|
| 758 |
|
|
|
|
| 797 |
showCard("upload-card");
|
| 798 |
});
|
| 799 |
|
| 800 |
+
// How-it-works modal
|
| 801 |
$("how-link").addEventListener("click", openModal);
|
| 802 |
$("modal-close").addEventListener("click", closeModal);
|
| 803 |
$("modal-backdrop").addEventListener("click", closeModal);
|
| 804 |
+
|
| 805 |
+
// Report modal
|
| 806 |
+
$("report-btn").addEventListener("click", openReportModal);
|
| 807 |
+
$("report-cancel").addEventListener("click", closeReportModal);
|
| 808 |
+
$("report-backdrop").addEventListener("click", closeReportModal);
|
| 809 |
+
$("report-form").addEventListener("submit", submitReport);
|
| 810 |
+
|
| 811 |
+
// Toggle "other" reason text input
|
| 812 |
+
document.querySelectorAll('input[name="reason"]').forEach((radio) => {
|
| 813 |
+
radio.addEventListener("change", () => {
|
| 814 |
+
const wrap = $("reason-other-wrap");
|
| 815 |
+
if (radio.value === "other" && radio.checked) {
|
| 816 |
+
wrap.classList.remove("hidden");
|
| 817 |
+
} else {
|
| 818 |
+
wrap.classList.add("hidden");
|
| 819 |
+
}
|
| 820 |
+
});
|
| 821 |
+
});
|
| 822 |
+
|
| 823 |
+
// Escape key closes any open modal
|
| 824 |
document.addEventListener("keydown", (e) => {
|
| 825 |
+
if (e.key === "Escape") {
|
| 826 |
+
closeModal();
|
| 827 |
+
closeReportModal();
|
| 828 |
+
}
|
| 829 |
});
|
| 830 |
}
|
| 831 |
|