Download app.py from Manar55/truscan: direct link, hf CLI and curl.
- Browser
- Download file 87.4 kB
-
https://huggingface.co/spaces/Manar55/truscan/resolve/main/app.py
- Command line
-
hf download hf://spaces/Manar55/truscan/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Manar55/truscan/resolve/main/app.py
87.4 kB
| """ | |
| 100% local server - makes no external network calls. | |
| Runs on your machine only (127.0.0.1) to serve the UI and analyze files locally. | |
| """ | |
| import os | |
| import sys | |
| os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1") | |
| # Hidden "app" mode (launched via TruScan.vbs with pythonw.exe): there is no | |
| # console, so send every print/log line to cache\logs\truescan.log instead. | |
| if sys.stdout is None or os.environ.get("TRUESCAN_HIDDEN"): | |
| _log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cache", "logs") | |
| os.makedirs(_log_dir, exist_ok=True) | |
| _log_path = os.path.join(_log_dir, "truescan.log") | |
| try: | |
| if os.path.getsize(_log_path) > 5 * 1024 * 1024: # keep it small | |
| os.replace(_log_path, _log_path + ".old") | |
| except OSError: | |
| pass | |
| sys.stdout = sys.stderr = open(_log_path, "a", encoding="utf-8", buffering=1) | |
| # Keep the last lines of output in memory so the app window can show them | |
| # (the "black screen" moved inside the app: click the status pill). | |
| import collections as _collections | |
| _RECENT_LINES = _collections.deque(maxlen=200) | |
| class _Tee: | |
| def __init__(self, stream): | |
| self._stream = stream | |
| self._buf = "" | |
| def write(self, text): | |
| try: | |
| if self._stream is not None: | |
| self._stream.write(text) | |
| except Exception: | |
| pass | |
| self._buf += text | |
| while "\n" in self._buf: | |
| line, self._buf = self._buf.split("\n", 1) | |
| line = line.rstrip("\r") | |
| if line.strip(): | |
| _RECENT_LINES.append(line[-400:]) | |
| # tqdm progress bars use \r without \n - keep the latest one visible | |
| if "\r" in self._buf: | |
| parts = [p.strip() for p in self._buf.split("\r") if p.strip()] | |
| last = parts[-1] if parts else "" | |
| self._buf = "" | |
| if last: | |
| _RECENT_LINES.append(last[-400:]) | |
| return len(text) | |
| def flush(self): | |
| try: | |
| if self._stream is not None: | |
| self._stream.flush() | |
| except Exception: | |
| pass | |
| def __getattr__(self, name): | |
| return getattr(self._stream, name) | |
| sys.stdout = _Tee(sys.stdout) | |
| sys.stderr = _Tee(sys.stderr) | |
| import json | |
| import hashlib | |
| import tempfile | |
| import webbrowser | |
| import traceback | |
| import threading | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor | |
| # Project layout: this file lives at the project root; the actual app code | |
| # lives in route/, and model weights/packages live in models/. Both need | |
| # to be importable — route/ for `from analysis_engine import ...` and for | |
| # analysis_engine's own sibling imports (ai_detector, face_detector, ...), | |
| # and models/ because it holds Python PACKAGES (aidetector/, bioverify_engine/) | |
| # that get imported directly (e.g. `import aidetector`), not just data files. | |
| _PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) | |
| _ROUTE_DIR = os.path.join(_PROJECT_ROOT, "route") | |
| _MODELS_DIR = os.path.join(_PROJECT_ROOT, "models") | |
| for _p in (_ROUTE_DIR, _MODELS_DIR): | |
| if _p not in sys.path: | |
| sys.path.insert(0, _p) | |
| from flask import Flask, request, jsonify, render_template | |
| from werkzeug.utils import secure_filename | |
| # --------------------------------------------------------------------------- | |
| # Optional detector files. If one is missing from this installation (e.g. it | |
| # was never committed to GitHub), the app still starts: that one signal | |
| # reports "unavailable" (the same value the detector itself returns when its | |
| # model can't load) and the status pill lists it as missing. | |
| # --------------------------------------------------------------------------- | |
| _OPTIONAL_MODULES = { | |
| "nonescape_bridge": {"classify_image": None}, | |
| "steganographia_detector": {"classify_image": None}, | |
| "pixel_forensics_detector": {"classify_image": None}, | |
| "sdxl_face_detector": {"classify_image": None}, | |
| "video_metadata_detector": {"scan_video_metadata": (0, [], False)}, | |
| "voice_deepfake_detector": {"classify_audio": (0, None, False)}, | |
| "voice_descriptive_stats": {}, | |
| "speaker_verification": {}, | |
| } | |
| MISSING_MODULES = [] | |
| def _install_missing_stubs(): | |
| import importlib.util | |
| import types | |
| for name, funcs in _OPTIONAL_MODULES.items(): | |
| if importlib.util.find_spec(name) is not None: | |
| continue | |
| mod = types.ModuleType(name) | |
| mod.__truescan_stub__ = True | |
| for fn, ret in funcs.items(): | |
| setattr(mod, fn, (lambda ret=ret: (lambda *a, **k: ret))()) | |
| def __getattr__(attr, _name=name): | |
| if attr.startswith("__"): | |
| raise AttributeError(attr) | |
| def _missing(*a, **k): | |
| raise RuntimeError(f"{_name}.py is missing from this installation") | |
| return _missing | |
| mod.__getattr__ = __getattr__ | |
| sys.modules[name] = mod | |
| MISSING_MODULES.append(name) | |
| if MISSING_MODULES: | |
| print(f"[app] Missing detector files (those signals are skipped): {', '.join(MISSING_MODULES)}", flush=True) | |
| _install_missing_stubs() | |
| from analysis_engine import analyze_image_bytes, analyze_video_bytes, r | |
| import forensic_scope_api | |
| import document_forensics | |
| import voice_deepfake_detector | |
| import voice_descriptive_stats | |
| import speaker_verification | |
| import plate_reader | |
| import vehicle_classifier | |
| import image_enhancer | |
| import video_enhancer | |
| import audio_extractor | |
| import ai_detector | |
| import deepfake_face_detector | |
| import face_detector | |
| import efficientnetb3_detector | |
| import sdxl_face_detector | |
| import noise_residual_detector | |
| import clone_detection_module | |
| import pixel_forensics_detector | |
| import community_forensics_detector | |
| import steganographia_detector | |
| import insightface_detector | |
| app = Flask(__name__) | |
| import logging | |
| class _NoPolling(logging.Filter): | |
| """Hides the UI's every-few-seconds counter polling from the console so | |
| real messages (warm-up, errors, timings) stay readable.""" | |
| _QUIET = ("/api/calibration-log/count", "/api/correct-plate/count", "/api/ready", "/api/activity", "/favicon.ico") | |
| def filter(self, record): | |
| msg = record.getMessage() | |
| return not any(p in msg for p in self._QUIET) | |
| logging.getLogger("werkzeug").addFilter(_NoPolling()) | |
| app.config["MAX_CONTENT_LENGTH"] = 300 * 1024 * 1024 # 300MB max | |
| _SHARE_PW_FILE = os.path.join(_PROJECT_ROOT, "cache", "share_password.txt") | |
| _share_pw_cache = {"mtime": None, "pw": ""} | |
| def _share_password(): | |
| """Password for remote visitors: APP_PASSWORD (server/Docker) or the one | |
| set by "Share Online.bat" (Windows PC shared through Tailscale).""" | |
| if os.environ.get("APP_PASSWORD"): | |
| return os.environ["APP_PASSWORD"] | |
| try: | |
| m = os.path.getmtime(_SHARE_PW_FILE) | |
| except OSError: | |
| return "" | |
| if m != _share_pw_cache["mtime"]: | |
| with open(_SHARE_PW_FILE, "r", encoding="utf-8-sig") as f: | |
| _share_pw_cache.update(mtime=m, pw=f.read().strip()) | |
| return _share_pw_cache["pw"] | |
| _PROXY_HEADERS = ("X-Forwarded-For", "X-Forwarded-Host", "X-Forwarded-Proto", | |
| "Tailscale-User-Login", "Tailscale-Funnel-Request") | |
| def _is_local_user(): | |
| """The person sitting at this PC (app window) — not a visitor arriving | |
| through Tailscale serve/funnel, which also connects from 127.0.0.1 but | |
| adds proxy headers.""" | |
| return (request.remote_addr in ("127.0.0.1", "::1") | |
| and not any(h in request.headers for h in _PROXY_HEADERS) | |
| and not os.environ.get("TRUESCAN_SERVER")) | |
| _AUTH_COOKIE = "ts_auth" | |
| _OPEN_PATHS = ("/login", "/logout", "/favicon.ico") | |
| def _session_token(pw): | |
| import hashlib | |
| import hmac | |
| return hmac.new(pw.encode(), b"truscan-session-v1", hashlib.sha256).hexdigest() | |
| def _password_ok(given, pw): | |
| import hmac | |
| return given is not None and hmac.compare_digest(str(given).encode(), pw.encode()) | |
| _LOGIN_PAGE = """<!doctype html><html lang="ar" dir="rtl"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1"><title>TrueScan</title> | |
| <style> | |
| *{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center; | |
| background:#0F172A;color:#F1F5F9;font:15px 'Segoe UI',Tahoma,system-ui,sans-serif;padding:16px} | |
| .card{width:100%;max-width:380px;background:#1E293B;border:1px solid #26334A;border-radius:18px;padding:28px 24px} | |
| .logo{width:56px;height:56px;border-radius:50%;margin:0 auto 14px;display:flex;align-items:center;justify-content:center; | |
| border:2px solid #2563EB;box-shadow:0 0 18px rgba(37,99,235,.45)} | |
| h1{font-size:20px;text-align:center;margin:0 0 4px}p{color:#94A3B8;text-align:center;margin:0 0 20px;font-size:13px} | |
| label{display:block;font-size:12.5px;color:#94A3B8;margin-bottom:6px} | |
| input{width:100%;padding:12px;border-radius:10px;border:1px solid #334155;background:#0F172A;color:#F1F5F9;font-size:15px} | |
| button{width:100%;margin-top:14px;padding:12px;border:0;border-radius:10px;background:#2563EB;color:#fff;font-size:15px;font-weight:700;cursor:pointer} | |
| .err{color:#F87171;font-size:13px;text-align:center;margin-top:12px} | |
| </style></head><body><form class="card" method="post" action="/login"> | |
| <div class="logo"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><circle cx="12" cy="11" r="2.5"/></svg></div> | |
| <h1>TrueScan</h1><p>يُرجى إدخال كلمة المرور للمتابعة<br>Please enter the password to continue</p> | |
| <input type="hidden" name="next" value="__NEXT__"> | |
| <label for="pw">كلمة المرور / Password</label> | |
| <input id="pw" name="password" type="password" autocomplete="current-password" autofocus required> | |
| <button type="submit">دخول / Sign in</button>__ERR__ | |
| <div style="margin-top:18px;padding:10px 12px;border-radius:10px;border:1px solid #92400e;background:rgba(245,158,11,.08);font-size:12px;line-height:1.7;color:#fcd34d"> | |
| <b>تنبيه الخصوصية:</b> يعمل البرنامج على خوادم سحابية (Hugging Face). يُرجى عدم رفع مواد حساسة أو بيانات شخصية تخص الآخرين.<br> | |
| <span dir="ltr" style="display:block;text-align:left;color:#d6b25e">Privacy notice: hosted on cloud servers (Hugging Face). Do not upload sensitive material or other people's personal data.</span></div> | |
| </form></body></html>""" | |
| def login_page(): | |
| from flask import Response, redirect | |
| import html | |
| pw = _share_password() | |
| nxt = request.values.get("next") or "/" | |
| if not nxt.startswith("/") or nxt.startswith("//"): | |
| nxt = "/" | |
| err = "" | |
| if request.method == "POST" and pw: | |
| if _password_ok(request.form.get("password"), pw): | |
| resp = redirect(nxt) | |
| # SameSite=None so the session also works inside the huggingface.co page frame. | |
| resp.set_cookie(_AUTH_COOKIE, _session_token(pw), max_age=30 * 24 * 3600, | |
| httponly=True, secure=request.is_secure or bool(os.environ.get("SPACE_ID")), | |
| samesite="None" if (request.is_secure or os.environ.get("SPACE_ID")) else "Lax") | |
| return resp | |
| time.sleep(1.0) # slows down guessing | |
| err = '<div class="err">كلمة المرور غير صحيحة / Incorrect password</div>' | |
| page = _LOGIN_PAGE.replace("__NEXT__", html.escape(nxt, quote=True)).replace("__ERR__", err) | |
| return Response(page, 200, {"Content-Type": "text/html; charset=utf-8"}) | |
| def logout_page(): | |
| from flask import redirect | |
| resp = redirect("/login") | |
| resp.delete_cookie(_AUTH_COOKIE) | |
| return resp | |
| def _require_password(): | |
| """Remote visitors sign in once on the login page (a cookie keeps them | |
| signed in for 30 days). The local app window never asks.""" | |
| if _is_local_user() or request.path in _OPEN_PATHS: | |
| return None | |
| from flask import Response, redirect, jsonify as _json | |
| import hmac | |
| pw = _share_password() | |
| if not pw: | |
| if os.environ.get("SPACE_ID"): | |
| # Hosted on Hugging Face: never serve the app without a password, | |
| # whether the Space is public or private. | |
| return Response("TrueScan: set the APP_PASSWORD secret in GitHub and run the deploy again.", 403) | |
| if os.environ.get("TRUESCAN_SERVER"): | |
| return None # Docker without a password = private tailnet only | |
| return Response("Sharing is on but no password is set - run Share Online.bat again.", 403) | |
| tok = request.cookies.get(_AUTH_COOKIE) | |
| if tok and hmac.compare_digest(tok, _session_token(pw)): | |
| return None | |
| if _password_ok(request.headers.get("X-App-Password"), pw): | |
| return None # scripted checks (smoke test) | |
| auth = request.authorization | |
| if auth and auth.type == "basic" and _password_ok(auth.password, pw): | |
| return None | |
| if request.path.startswith("/api/"): | |
| return _json({"error": "Login required"}), 401 | |
| from urllib.parse import quote | |
| return redirect("/login?next=" + quote(request.full_path.rstrip("?"))) | |
| # --------------------------------------------------------------------------- | |
| # FAST STARTUP (2026-09): the server + browser now start immediately and the | |
| # models load in a background thread. Analysis endpoints wait for that load | |
| # to finish (so no request ever sees a half-loaded model); the UI itself, | |
| # settings and counters respond instantly. | |
| # --------------------------------------------------------------------------- | |
| _models_ready = threading.Event() # set when EVERY model finished | |
| _model_events = {} # name -> Event, set when THAT model finished | |
| _warmup_status = {"done": 0, "total": 0, "failed": [], "seconds": {}} | |
| # Which models each endpoint actually needs. Plate reading waits ONLY for | |
| # its own two models, not for all 13 detectors. None = wait for everything. | |
| _ENDPOINT_NEEDS = { | |
| "/api/read-plate": ["plate_reader"], | |
| "/api/analyze": None, | |
| "/api/analyze-document": None, | |
| "/api/forensic-scope": None, | |
| } | |
| def _event(name): | |
| return _model_events.setdefault(name, threading.Event()) | |
| def _wait_for_models(): | |
| if request.path not in _ENDPOINT_NEEDS: | |
| return | |
| needs = _ENDPOINT_NEEDS[request.path] | |
| if needs is None: | |
| _models_ready.wait() | |
| else: | |
| for name in needs: | |
| _event(name).wait() | |
| # One heavy analysis at a time (TRUESCAN_PARALLEL to change). On a small CPU | |
| # (e.g. a 4-core Intel N95) two analyses side by side are SLOWER than two in | |
| # a row and can run out of memory; the second visitor just waits its turn. | |
| _HEAVY_PATHS = {"/api/analyze", "/api/analyze-document", "/api/read-plate", "/api/forensic-scope", | |
| "/api/analyze-voice", "/api/compare-voices"} | |
| # (enhancement is plain OpenCV - light enough to run beside an analysis, so | |
| # the preview doesn't wait behind the AI check that runs at the same time) | |
| _heavy_gate = threading.BoundedSemaphore(max(1, int(os.environ.get("TRUESCAN_PARALLEL", "1")))) | |
| def _take_turn(): | |
| if request.path in _HEAVY_PATHS: | |
| _heavy_gate.acquire() | |
| request.environ["truescan.heavy"] = True | |
| def _give_turn(exc=None): | |
| try: | |
| if request.environ.pop("truescan.heavy", False): | |
| _heavy_gate.release() | |
| except Exception: | |
| pass | |
| def favicon(): | |
| """Window + taskbar icon when running as an app window.""" | |
| from flask import send_file | |
| # Prefer the project's original icon (TrueScan.ico); truscan.ico is the fallback. | |
| for name in ("TrueScan.ico", "truscan.ico"): | |
| ico = os.path.join(_PROJECT_ROOT, name) | |
| if os.path.exists(ico): | |
| return send_file(ico, mimetype="image/x-icon") | |
| return ("", 404) | |
| _active = {} # request id -> (label, start time) | |
| _ACTIVITY_LABELS = { | |
| "/api/analyze": "فحص الملف", "/api/analyze-document": "فحص المستند", | |
| "/api/read-plate": "قراءة اللوحة", | |
| "/api/analyze-voice": "فحص الصوت", "/api/compare-voices": "مقارنة الأصوات", | |
| "/api/forensic-scope": "الفحص الجنائي", | |
| "/api/enhance/render": "معالجة الصورة", | |
| "/api/jpeg-forensics": "كشف التلاعب", "/api/prnu": "مطابقة بصمة الكاميرا", "/api/file-structure": "بنية الملف", | |
| "/api/enhance/video/average": "دمج إطارات الفيديو", | |
| } | |
| def _track_start(): | |
| label = _ACTIVITY_LABELS.get(request.path) | |
| if label: | |
| _active[id(request._get_current_object())] = (label, time.time()) | |
| def _track_end(exc=None): | |
| try: | |
| _active.pop(id(request._get_current_object()), None) | |
| except Exception: | |
| pass | |
| def activity(): | |
| now = time.time() | |
| return jsonify({ | |
| "ready": _models_ready.is_set(), | |
| "done": _warmup_status["done"], "total": _warmup_status["total"], | |
| "busy": [{"label": l, "seconds": int(now - t0)} for l, t0 in list(_active.values())], | |
| }) | |
| _STATUS_WIDGET = """ | |
| <div id="ts-status" style="position:fixed;left:14px;bottom:14px;z-index:99999; | |
| font:12.5px system-ui,Segoe UI,Tahoma,sans-serif;pointer-events:none"> | |
| <div id="ts-pill" style="background:rgba(15,27,51,.92);color:#dbe7ff;border:1px solid #2a3f66; | |
| border-radius:999px;padding:6px 12px;box-shadow:0 4px 14px rgba(0,0,0,.3);display:flex;gap:8px;align-items:center"> | |
| <span id="ts-dot" style="width:8px;height:8px;border-radius:50%;background:#f5a524;display:inline-block"></span> | |
| <span id="ts-text">جارٍ التشغيل…</span> | |
| </div> | |
| </div> | |
| <script> | |
| (function(){ | |
| // A non-JSON reply (server restarting, proxy error page, timeout) used to | |
| // surface as Safari's cryptic "The string did not match the expected | |
| // pattern". Turn it into a readable message with the HTTP status. | |
| var origJson=Response.prototype.json; | |
| Response.prototype.json=function(){ | |
| var r=this; | |
| return r.text().then(function(t){ | |
| try{ return JSON.parse(t); }catch(e){ | |
| var hint=(r.status===502||r.status===503||r.status===504)?' — الخادم قيد إعادة التشغيل أو مشغول، يُرجى المحاولة بعد دقيقة': | |
| (r.status===401?' — انتهت جلسة الدخول، يُرجى تحديث الصفحة وتسجيل الدخول مجددًا': | |
| (r.status===413?' — حجم الملف يتجاوز الحد المسموح':'')); | |
| if(document.documentElement.lang==='en'){ | |
| hint=(r.status===502||r.status===503||r.status===504)?' — the server is restarting or busy, please retry in a minute': | |
| (r.status===401?' — session expired, please reload and sign in again':(r.status===413?' — file is too large':'')); | |
| throw new Error('Unexpected server response (HTTP '+r.status+')'+hint); | |
| } | |
| throw new Error('استجابة غير متوقعة من الخادم (HTTP '+r.status+')'+hint); | |
| } | |
| }); | |
| }; | |
| function EN(){return document.documentElement.lang==='en';} | |
| var LBL={'قراءة اللوحة':'Reading plate','فحص الملف':'Analyzing file','فحص المستند':'Analyzing document', | |
| 'فحص الصوت':'Analyzing voice','مقارنة الأصوات':'Comparing voices','الفحص الجنائي':'Forensic scan','معالجة الصورة':'Processing image', | |
| 'كشف التلاعب':'Tampering detection','مطابقة بصمة الكاميرا':'Camera fingerprint match','بنية الملف':'File structure'}; | |
| var dot=document.getElementById('ts-dot'),txt=document.getElementById('ts-text'),box=document.getElementById('ts-status'); | |
| function place(){ // keep the pill away from the side menu | |
| var a=document.querySelector('aside'),r=a&&a.getBoundingClientRect(); | |
| var left=r&&r.width<window.innerWidth*0.6&&r.left<120; | |
| box.style.left=left?'auto':'14px'; box.style.right=left?'14px':'auto'; | |
| } | |
| function tick(){ | |
| place(); | |
| fetch('/api/activity').then(function(r){return r.json();}).then(function(a){ | |
| if(a.busy&&a.busy.length){dot.style.background='#3b82f6'; | |
| var l=a.busy[0].label; txt.textContent=(EN()?(LBL[l]||'Working'):l)+' — '+a.busy[0].seconds+(EN()?' s':' ث');} | |
| else if(!a.ready){dot.style.background='#f5a524'; | |
| txt.textContent=(EN()?'Starting up ':'جارٍ تهيئة النظام ')+(a.total?Math.round(100*a.done/a.total):0)+'%';} | |
| else{dot.style.background='#22c55e';txt.textContent=EN()?'Ready':'جاهز';} | |
| }).catch(function(){dot.style.background='#ef4444';txt.textContent=EN()?'Server offline':'الخادم غير متصل';}); | |
| } | |
| tick(); setInterval(tick,2000); | |
| })(); | |
| </script> | |
| """ | |
| _PLATE_PICKER = r""" | |
| <div id="ts-pick" dir="rtl" style="display:none;position:fixed;inset:0;z-index:100000;background:rgba(3,8,20,.94); | |
| font:15px system-ui,Segoe UI,Tahoma,sans-serif;color:#e6eeff;align-items:center;justify-content:center;flex-direction:column;gap:10px;padding:10px;box-sizing:border-box"> | |
| <div id="ts-pick-title" style="font-size:16px;font-weight:700;text-align:center"></div> | |
| <div style="display:none"> | |
| <button id="ts-m-quad" class="ts-m"></button><button id="ts-m-rect" class="ts-m"></button> | |
| </div> | |
| <div id="ts-pick-hint" style="opacity:.85;font-size:13px;text-align:center;max-width:94vw"></div> | |
| <div id="ts-pick-stage" style="position:relative;width:94vw;height:58vh;overflow:hidden;border-radius:10px;background:#000;touch-action:none;user-select:none;-webkit-user-select:none"> | |
| <div id="ts-pick-box" style="position:absolute;left:0;top:0;transform-origin:0 0"> | |
| <img id="ts-pick-img" draggable="false" style="display:block;pointer-events:none"> | |
| <svg id="ts-pick-svg" style="position:absolute;inset:0;width:100%;height:100%;overflow:visible;pointer-events:none"></svg> | |
| </div> | |
| <div style="position:absolute;bottom:8px;left:8px;display:flex;gap:6px"> | |
| <button id="ts-z-in" class="ts-z">+</button><button id="ts-z-out" class="ts-z">−</button><button id="ts-z-fit" class="ts-z" style="font-size:13px;width:auto;padding:0 10px"></button> | |
| </div> | |
| </div> | |
| <div style="display:flex;gap:10px;flex-wrap:wrap;justify-content:center"> | |
| <button id="ts-pick-ok" disabled class="ts-b" style="background:#2563eb;color:#fff;border:0;opacity:.5"></button> | |
| <button id="ts-pick-undo" class="ts-b"></button> | |
| <button id="ts-pick-skip" class="ts-b"></button> | |
| </div> | |
| <style> | |
| .ts-b,.ts-m{background:#1e293b;color:#cbd5e1;border:1px solid #334155;border-radius:10px;padding:10px 18px;font:inherit;cursor:pointer} | |
| .ts-m.on{background:#2563eb;color:#fff;border-color:#2563eb} | |
| .ts-z{width:40px;height:40px;border-radius:10px;border:1px solid #334155;background:rgba(15,23,42,.85);color:#e2e8f0;font-size:20px;cursor:pointer} | |
| </style> | |
| </div> | |
| <script> | |
| (function(){ | |
| // window.tsPickPlate(file, title, src) -> Promise of the marked plate area: | |
| // [x0,y0,x1,y1] or 8 numbers (4 corners, clockwise from top-left), | |
| // fractions 0..1 of the image; null = cancelled. src (optional) is the | |
| // image to display (e.g. the enhanced view) — same geometry as file. | |
| window.tsPickPlate=function(file,title,src){ | |
| return new Promise(function(resolve){ | |
| var $=function(i){return document.getElementById(i);}; | |
| var en=document.documentElement.lang==='en'; | |
| var ov=$('ts-pick'),stage=$('ts-pick-stage'),box=$('ts-pick-box'),img=$('ts-pick-img'),svg=$('ts-pick-svg'), | |
| ok=$('ts-pick-ok'),undo=$('ts-pick-undo'),skip=$('ts-pick-skip'),hint=$('ts-pick-hint'); | |
| var CORNERS=en?['top-left','top-right','bottom-right','bottom-left']:['أعلى اليسار','أعلى اليمين','أسفل اليمين','أسفل اليسار']; | |
| $('ts-pick-title').textContent=title||(en?'Mark the plate':'حدّد موضع اللوحة'); | |
| $('ts-m-quad').textContent=en?'4 corners':'أربع زوايا'; | |
| $('ts-m-rect').textContent=en?'Rectangle':'مستطيل'; | |
| $('ts-z-fit').textContent=en?'Fit':'ملاءمة'; | |
| ok.textContent=en?'Read selected area':'قراءة المنطقة المحددة'; | |
| undo.textContent=en?'Undo':'تراجع'; skip.textContent=en?'Cancel':'إلغاء'; | |
| var own=!src, url=src||URL.createObjectURL(file); | |
| var s=1,tx=0,ty=0,fit=1,NW=1,NH=1,pointers={},g=null,pts=[],rect=null,mode='quad'; | |
| function enable(on){ok.disabled=!on;ok.style.opacity=on?1:.5;} | |
| function clamp(){var W=stage.clientWidth,H=stage.clientHeight,w=NW*s,h=NH*s; | |
| tx=w<=W?(W-w)/2:Math.min(0,Math.max(W-w,tx)); ty=h<=H?(H-h)/2:Math.min(0,Math.max(H-h,ty));} | |
| function apply(){clamp();box.style.transform='translate('+tx+'px,'+ty+'px) scale('+s+')';draw();} | |
| function zoomAt(ns,cx,cy){ns=Math.min(Math.max(ns,fit),fit*16);var r=stage.getBoundingClientRect(),px=cx-r.left,py=cy-r.top; | |
| tx=px-(px-tx)*ns/s;ty=py-(py-ty)*ns/s;s=ns;apply();} | |
| function fitView(){fit=Math.min(stage.clientWidth/NW,stage.clientHeight/NH);s=fit;tx=0;ty=0;apply();} | |
| function toImg(cx,cy){var r=stage.getBoundingClientRect(); | |
| return [Math.min(1,Math.max(0,(cx-r.left-tx)/s/NW)),Math.min(1,Math.max(0,(cy-r.top-ty)/s/NH))];} | |
| function draw(){ | |
| svg.setAttribute('viewBox','0 0 '+NW+' '+NH); | |
| var k=1/s,h='',P=pts.map(function(p){return [p[0]*NW,p[1]*NH];}); | |
| if(mode==='quad'&&P.length>1) h+='<polygon points="'+P.map(function(p){return p.join(',');}).join(' ')+'" fill="rgba(34,197,94,.18)" stroke="#22c55e" stroke-width="'+(3*k)+'"/>'; | |
| if(mode==='quad') P.forEach(function(p,i){h+='<circle cx="'+p[0]+'" cy="'+p[1]+'" r="'+(6*k)+'" fill="#22c55e" stroke="#fff" stroke-width="'+(2*k)+'"/>'+ | |
| '<text x="'+(p[0]+9*k)+'" y="'+(p[1]-9*k)+'" fill="#fff" font-size="'+(15*k)+'" font-weight="700" stroke="#000" stroke-width="'+(0.6*k)+'">'+(i+1)+'</text>';}); | |
| if(mode==='rect'&&rect){var x=Math.min(rect[0],rect[2])*NW,y=Math.min(rect[1],rect[3])*NH,w=Math.abs(rect[2]-rect[0])*NW,hh=Math.abs(rect[3]-rect[1])*NH; | |
| h+='<rect x="'+x+'" y="'+y+'" width="'+w+'" height="'+hh+'" fill="rgba(34,197,94,.15)" stroke="#22c55e" stroke-width="'+(3*k)+'"/>';} | |
| svg.innerHTML=h; | |
| enable(mode==='quad'?pts.length===4:!!(rect&&Math.abs(rect[2]-rect[0])*NW>4&&Math.abs(rect[3]-rect[1])*NH>3)); | |
| undo.style.display=mode==='quad'?'':'none'; | |
| if(mode==='rect') hint.textContent=en?'Drag a rectangle around the plate. Zoom with two fingers or the + button. The plate is straightened automatically.':'اسحب مستطيلًا حول اللوحة، ويمكنك التكبير بإصبعين أو بزر +. تُقوَّم اللوحة المائلة تلقائيًا.'; | |
| else hint.textContent=pts.length<4 | |
| ?(en?'Zoom in with two fingers, then tap corner '+(pts.length+1)+' of 4 (clockwise): '+CORNERS[pts.length] | |
| :'كبّر بإصبعين للدقة، ثم المس الزاوية '+(pts.length+1)+' من 4 باتجاه عقارب الساعة: '+CORNERS[pts.length]) | |
| :(en?'All 4 corners set — the tilt will be corrected automatically.':'تم تحديد الزوايا الأربع — سيُقوَّم الميلان تلقائيًا.'); | |
| } | |
| function setMode(m){mode=m;pts=[];rect=null;$('ts-m-quad').classList.toggle('on',m==='quad');$('ts-m-rect').classList.toggle('on',m==='rect');draw();} | |
| stage.onpointerdown=function(e){e.preventDefault();try{stage.setPointerCapture(e.pointerId);}catch(_){} | |
| pointers[e.pointerId]={x:e.clientX,y:e.clientY};var ids=Object.keys(pointers); | |
| if(ids.length===2){var a=pointers[ids[0]],b=pointers[ids[1]]; | |
| g={t:'pinch',d:Math.hypot(a.x-b.x,a.y-b.y),s:s,mx:(a.x+b.x)/2,my:(a.y+b.y)/2};} | |
| else if(ids.length===1) g={t:'tap',x:e.clientX,y:e.clientY,lx:e.clientX,ly:e.clientY,p0:toImg(e.clientX,e.clientY)};}; | |
| stage.onpointermove=function(e){if(!pointers[e.pointerId]||!g)return;pointers[e.pointerId]={x:e.clientX,y:e.clientY}; | |
| var ids=Object.keys(pointers); | |
| if(g.t==='pinch'&&ids.length===2){var a=pointers[ids[0]],b=pointers[ids[1]],mx=(a.x+b.x)/2,my=(a.y+b.y)/2; | |
| tx+=mx-g.mx;ty+=my-g.my;g.mx=mx;g.my=my;zoomAt(g.s*Math.hypot(a.x-b.x,a.y-b.y)/g.d,mx,my);return;} | |
| if(ids.length!==1)return; | |
| var moved=Math.hypot(e.clientX-g.x,e.clientY-g.y)>8; | |
| if(g.t==='tap'&&moved) g.t=(mode==='rect')?'rect':'pan'; | |
| if(g.t==='rect'){var p=toImg(e.clientX,e.clientY);rect=[g.p0[0],g.p0[1],p[0],p[1]];draw();} | |
| else if(g.t==='pan'){tx+=e.clientX-g.lx;ty+=e.clientY-g.ly;apply();} | |
| g.lx=e.clientX;g.ly=e.clientY;}; | |
| function up(e){if(!pointers[e.pointerId])return;var n=Object.keys(pointers).length;delete pointers[e.pointerId]; | |
| if(n===1&&g&&g.t==='tap'&&mode==='quad'&&pts.length<4){pts.push(toImg(e.clientX,e.clientY));draw();} | |
| if(Object.keys(pointers).length===0) g=null; else if(g&&g.t==='pinch') g={t:'none'};} | |
| stage.onpointerup=up; stage.onpointercancel=function(e){delete pointers[e.pointerId];g=null;}; | |
| stage.onwheel=function(e){e.preventDefault();zoomAt(s*(e.deltaY<0?1.2:1/1.2),e.clientX,e.clientY);}; | |
| $('ts-z-in').onclick=function(e){e.stopPropagation();var r=stage.getBoundingClientRect();zoomAt(s*1.6,r.left+r.width/2,r.top+r.height/2);}; | |
| $('ts-z-out').onclick=function(e){e.stopPropagation();var r=stage.getBoundingClientRect();zoomAt(s/1.6,r.left+r.width/2,r.top+r.height/2);}; | |
| $('ts-z-fit').onclick=function(e){e.stopPropagation();fitView();}; | |
| ['ts-z-in','ts-z-out','ts-z-fit'].forEach(function(id){$(id).onpointerdown=function(e){e.stopPropagation();};}); | |
| $('ts-m-quad').onclick=function(){setMode('quad');}; $('ts-m-rect').onclick=function(){setMode('rect');}; | |
| undo.onclick=function(){pts.pop();draw();}; | |
| function done(v){ov.style.display='none';if(own)URL.revokeObjectURL(url);resolve(v);} | |
| ok.onclick=function(){ | |
| if(mode==='quad'&&pts.length===4) done([].concat.apply([],pts)); | |
| else if(mode==='rect'&&rect) done([Math.min(rect[0],rect[2]),Math.min(rect[1],rect[3]),Math.max(rect[0],rect[2]),Math.max(rect[1],rect[3])]);}; | |
| skip.onclick=function(){done(null);}; | |
| ov.style.display='flex'; | |
| img.onload=function(){NW=img.naturalWidth;NH=img.naturalHeight;box.style.width=NW+'px';box.style.height=NH+'px';setMode('rect');fitView();}; | |
| img.src=url; | |
| }); | |
| }; | |
| })(); | |
| </script> | |
| """ | |
| _LETTER_SYNC = r""" | |
| <script> | |
| (function(){ | |
| // Correction form: typing the English letters fills the Arabic ones and | |
| // vice versa (each plate letter has one fixed counterpart). On the plate, | |
| // each Arabic letter sits above its English twin, so Arabic reading order | |
| // (right->left) is the English order reversed. | |
| var EN2AR={A:'أ',B:'ب',J:'ح',D:'د',R:'ر',S:'س',X:'ص',T:'ط',E:'ع',G:'ق',K:'ك',L:'ل',Z:'م',N:'ن',H:'هـ',U:'و',V:'ى'}; | |
| var AR2EN={}; Object.keys(EN2AR).forEach(function(k){AR2EN[EN2AR[k]]=k;}); | |
| var NORM={'ا':'أ','إ':'أ','آ':'أ','ي':'ى','ه':'هـ','ة':'هـ','ھ':'هـ'}; | |
| var busy=false; | |
| function kind(input){ | |
| var el=input.parentElement; | |
| for(var i=0;i<3&⪙i++,el=el.parentElement){ | |
| var t=el.textContent||''; | |
| var en=/إنجليزي|English/i.test(t), ar=/عربي|Arabic/i.test(t); | |
| if(en&&!ar) return 'en'; if(ar&&!en) return 'ar'; if(en&&ar) return null; | |
| } | |
| return null; | |
| } | |
| function partner(input,want){ | |
| var box=input.parentElement; | |
| for(var i=0;i<4&&box;i++,box=box.parentElement){ | |
| var list=box.querySelectorAll('input'); | |
| for(var j=0;j<list.length;j++) if(list[j]!==input&&kind(list[j])===want) return list[j]; | |
| } | |
| return null; | |
| } | |
| function setVal(input,v){ | |
| var setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set; | |
| setter.call(input,v); input.dispatchEvent(new Event('input',{bubbles:true})); | |
| } | |
| function enToAr(v){ | |
| var out=[]; v.toUpperCase().replace(/[^A-Z]/g,'').split('').forEach(function(c){if(EN2AR[c])out.push(EN2AR[c]);}); | |
| return out.reverse().join(' '); | |
| } | |
| function arToEn(v){ | |
| var s=v.replace(/\s+/g,'').replace(/هـ/g,'ه').replace(/ـ/g,''); | |
| var out=[]; s.split('').forEach(function(c){c=NORM[c]||c; if(c==='ه')c='هـ'; if(AR2EN[c])out.push(AR2EN[c]);}); | |
| return out.reverse().join(' '); | |
| } | |
| document.addEventListener('input',function(e){ | |
| if(busy) return; var t=e.target; if(!t||t.tagName!=='INPUT') return; | |
| var k=kind(t); if(!k) return; | |
| var other=partner(t,k==='en'?'ar':'en'); if(!other) return; | |
| busy=true; | |
| try{ setVal(other, k==='en'?enToAr(t.value):arToEn(t.value)); } finally { busy=false; } | |
| },true); | |
| })(); | |
| </script> | |
| """ | |
| def _with_status(html): | |
| extra = '<script src="/ts-viewer.js"></script><script src="/ts-gps.js"></script>' + _STATUS_WIDGET + _PLATE_PICKER + _LETTER_SYNC | |
| i = html.lower().rfind("</body>") | |
| return html[:i] + extra + html[i:] if i != -1 else html + extra | |
| # --------------------------------------------------------------------------- | |
| # Image enhancement tool (/enhance) — Amped-style filter chain for any photo. | |
| # --------------------------------------------------------------------------- | |
| _ENHANCE_CACHE = {} # token -> (PIL image, sha256); last few uploads only | |
| def enhance_page(): | |
| path = os.path.join(app.root_path, "templates", "enhance.html") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read(), 200, {"Content-Type": "text/html; charset=utf-8"} | |
| _VIDEO_CACHE = {} # vtoken -> {"path", "sha256", "info"}; last 2 videos only | |
| _ENHANCE_TMP = os.path.join(_PROJECT_ROOT, "cache", "enhance_tmp") | |
| def _jpeg(rgb_or_pil, max_side=1280, quality=88): | |
| import io | |
| from PIL import Image | |
| from flask import send_file | |
| im = rgb_or_pil if isinstance(rgb_or_pil, Image.Image) else Image.fromarray(rgb_or_pil) | |
| if max(im.size) > max_side: | |
| s = max_side / max(im.size) | |
| im = im.resize((int(im.width * s), int(im.height * s)), Image.LANCZOS) | |
| buf = io.BytesIO() | |
| im.convert("RGB").save(buf, "JPEG", quality=quality) | |
| buf.seek(0) | |
| r = send_file(buf, mimetype="image/jpeg") | |
| r.headers["Cache-Control"] = "no-store" | |
| return r | |
| def _enhance_video_upload(file): | |
| import uuid | |
| os.makedirs(_ENHANCE_TMP, exist_ok=True) | |
| ext = (file.filename or "video.mp4").rsplit(".", 1)[-1].lower() | |
| vtoken = uuid.uuid4().hex | |
| path = os.path.join(_ENHANCE_TMP, f"{vtoken}.{ext if ext.isalnum() else 'mp4'}") | |
| file.save(path) | |
| try: | |
| meta = video_enhancer.info(path) | |
| except Exception as e: | |
| video_enhancer.cleanup(path) | |
| return jsonify({"error": f"Could not read this video: {e}"}), 400 | |
| _VIDEO_CACHE[vtoken] = {"path": path, "sha256": video_enhancer.sha256_file(path), "info": meta} | |
| while len(_VIDEO_CACHE) > 2: | |
| old = _VIDEO_CACHE.pop(next(iter(_VIDEO_CACHE))) | |
| video_enhancer.cleanup(old["path"]) | |
| return jsonify({"kind": "video", "vtoken": vtoken, "sha256": _VIDEO_CACHE[vtoken]["sha256"], **meta}) | |
| def enhance_video_frame(): | |
| v = _VIDEO_CACHE.get(request.args.get("vtoken", "")) | |
| if not v: | |
| return jsonify({"error": "Video expired - please load it again"}), 404 | |
| try: | |
| return _jpeg(video_enhancer.frame(v["path"], int(request.args.get("i", 0)))) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 400 | |
| def enhance_video_average(): | |
| """Range Selector + Local Stabilization + Frame Averaging. The merged | |
| frame becomes a normal image token, so every image filter applies.""" | |
| import uuid | |
| from PIL import Image | |
| body = request.get_json(silent=True) or {} | |
| v = _VIDEO_CACHE.get(body.get("vtoken", "")) | |
| if not v: | |
| return jsonify({"error": "Video expired - please load it again"}), 404 | |
| roi = body.get("roi") | |
| if roi is not None and (not isinstance(roi, list) or len(roi) != 4): | |
| roi = None | |
| t0 = time.perf_counter() | |
| try: | |
| merged, ref, stats = video_enhancer.average( | |
| v["path"], body.get("start", 0), body.get("end", v["info"]["frames"] - 1), | |
| roi=roi, stabilize=bool(body.get("stabilize", True)), | |
| method="median" if body.get("method") == "median" else "mean") | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 400 | |
| print(f"[enhance] video average {stats} in {time.perf_counter() - t0:.1f}s", flush=True) | |
| token = uuid.uuid4().hex | |
| _ENHANCE_CACHE[token] = (Image.fromarray(merged), v["sha256"]) | |
| _ENHANCE_REF[token] = Image.fromarray(ref) | |
| while len(_ENHANCE_CACHE) > 4: | |
| k = next(iter(_ENHANCE_CACHE)) | |
| _ENHANCE_CACHE.pop(k) | |
| _ENHANCE_REF.pop(k, None) | |
| return jsonify({"token": token, "width": merged.shape[1], "height": merged.shape[0], "stats": stats}) | |
| _ENHANCE_REF = {} # token -> single reference frame with the same crop (the "before") | |
| def enhance_ref(token): | |
| im = _ENHANCE_REF.get(token) | |
| if im is None: | |
| return ("", 404) | |
| return _jpeg(im, max_side=1400, quality=92) | |
| def enhance_upload(): | |
| from PIL import Image | |
| import io | |
| import uuid | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| f = request.files["file"] | |
| ext = (f.filename or "").rsplit(".", 1)[-1].lower() | |
| if (f.mimetype or "").startswith("video/") or ext in VIDEO_EXTS: | |
| return _enhance_video_upload(f) | |
| data = f.read() | |
| try: | |
| img = image_enhancer.load(Image.open(io.BytesIO(data))) | |
| except Exception: | |
| return jsonify({"error": "Could not read this image"}), 400 | |
| token = uuid.uuid4().hex | |
| _ENHANCE_CACHE[token] = (img, image_enhancer.sha256(data)) | |
| while len(_ENHANCE_CACHE) > 4: | |
| _ENHANCE_CACHE.pop(next(iter(_ENHANCE_CACHE))) | |
| return jsonify({"token": token, "sha256": _ENHANCE_CACHE[token][1], | |
| "width": img.width, "height": img.height}) | |
| def enhance_render(): | |
| import io | |
| from flask import send_file | |
| body = request.get_json(silent=True) or {} | |
| item = _ENHANCE_CACHE.get(body.get("token")) | |
| if not item: | |
| return jsonify({"error": "Image expired - please load it again"}), 404 | |
| img, _ = item | |
| if body.get("ref") and body.get("token") in _ENHANCE_REF: | |
| img = _ENHANCE_REF[body["token"]] # single video frame, for the "before" side | |
| full = bool(body.get("full")) | |
| t0 = time.perf_counter() | |
| try: | |
| out, applied = image_enhancer.enhance(img, body.get("steps"), max_side=None if full else 1400) | |
| except Exception as e: | |
| return jsonify({"error": f"Enhancement failed: {e}"}), 500 | |
| print(f"[enhance] {'full' if full else 'preview'} {out.size} in {time.perf_counter() - t0:.1f}s: " | |
| f"{[a['step'] for a in applied]}", flush=True) | |
| buf = io.BytesIO() | |
| out.save(buf, "PNG" if full else "JPEG", **({} if full else {"quality": 90})) | |
| buf.seek(0) | |
| resp = send_file(buf, mimetype="image/png" if full else "image/jpeg") | |
| resp.headers["X-Applied"] = json.dumps(applied) | |
| resp.headers["Cache-Control"] = "no-store" | |
| return resp | |
| def forensics_page(): | |
| path = os.path.join(app.root_path, "templates", "forensics.html") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read(), 200, {"Content-Type": "text/html; charset=utf-8"} | |
| def jpeg_forensics_endpoint(): | |
| f = request.files.get("file") | |
| if not f: | |
| return jsonify({"error": "No file attached"}), 400 | |
| try: | |
| import jpeg_forensics | |
| data = f.read() | |
| out = jpeg_forensics.analyze(data) | |
| try: | |
| _add_learned_map(out, data) | |
| except Exception as e: | |
| print(f"[trufor] failed: {e}", flush=True) | |
| try: | |
| import platform_id | |
| out["platform"] = platform_id.identify(data, f.filename or "") | |
| except Exception as e: | |
| print(f"[platform] failed: {e}", flush=True) | |
| return jsonify(out) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": f"Analysis failed: {e}"}), 500 | |
| def _add_learned_map(out, data): | |
| """Learned manipulation heatmap + integrity score (non-profit licence).""" | |
| import io as _io | |
| import numpy as np | |
| import trufor_detector | |
| from PIL import Image as _Image, ImageOps as _Ops | |
| import jpeg_forensics | |
| rgb = np.asarray(_Ops.exif_transpose(_Image.open(_io.BytesIO(data))).convert("RGB")) | |
| r = trufor_detector.analyze(rgb) | |
| if r is None: | |
| out["learned"] = {"available": False} | |
| return | |
| import cv2 as _cv2 | |
| h, w = r["heatmap"].shape | |
| base = _cv2.resize(rgb, (w, h), interpolation=_cv2.INTER_AREA) | |
| heat = r["heatmap"] | |
| rel = r["reliability"] | |
| score = r["score"] | |
| out["learned"] = {"available": True, "score": round(score, 3)} | |
| out["maps"].insert(0, {"key": "learned", "ar": "خريطة التلاعب (نموذج مدرّب)", "en": "Manipulation map (trained model)", | |
| "help_ar": "الألوان الدافئة مناطق يرجّح النموذج أنها معدّلة أو ملصوقة.", | |
| "help_en": "Warm colours are areas the model considers edited or pasted.", | |
| "image": jpeg_forensics._heat_png(heat, base, alpha=0.5)}) | |
| if rel is not None: | |
| out["maps"].insert(1, {"key": "reliability", "ar": "موثوقية خريطة التلاعب", "en": "Manipulation map reliability", | |
| "help_ar": "المناطق الداكنة قد تكون نتيجة الخريطة فيها غير موثوقة (إنذار كاذب محتمل).", | |
| "help_en": "Dark areas are where the manipulation map may be unreliable (possible false alarm).", | |
| "image": jpeg_forensics._heat_png(rel, base, alpha=0.5)}) | |
| pct = round(score * 100) | |
| if score >= 0.7: | |
| lv, ar, en = "high", f"النموذج المدرّب يقدّر احتمال التلاعب بالصورة بنحو {pct}% — راجع خريطة التلاعب.", \ | |
| f"The trained model estimates a {pct}% probability the image was manipulated — see the manipulation map." | |
| elif score >= 0.5: | |
| lv, ar, en = "medium", f"النموذج المدرّب يقدّر احتمال التلاعب بنحو {pct}% — نتيجة غير حاسمة تستدعي المراجعة.", \ | |
| f"The trained model estimates a {pct}% probability of manipulation — inconclusive, needs review." | |
| else: | |
| lv, ar, en = "info", f"النموذج المدرّب لم يرصد تلاعبًا واضحًا (احتمال {pct}%).", \ | |
| f"The trained model did not find clear manipulation ({pct}% probability)." | |
| out["findings"].insert(0, {"level": lv, "ar": ar, "en": en}) | |
| if lv == "high": | |
| out["status"] = "high" | |
| elif lv == "medium" and out.get("status") == "low": | |
| out["status"] = "medium" | |
| def jpeg_ghosts_endpoint(): | |
| f = request.files.get("file") | |
| if not f: | |
| return jsonify({"error": "No file attached"}), 400 | |
| try: | |
| import jpeg_forensics | |
| out = jpeg_forensics.ghost_data(f.read()) | |
| return jsonify(out or {"error": "Image too small"}) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": f"Analysis failed: {e}"}), 500 | |
| def file_structure_endpoint(): | |
| f = request.files.get("file") | |
| if not f: | |
| return jsonify({"error": "No file attached"}), 400 | |
| try: | |
| import file_structure | |
| return jsonify(file_structure.analyze(f.read(), f.filename or "")) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": f"Analysis failed: {e}"}), 500 | |
| def prnu_endpoint(): | |
| refs = request.files.getlist("refs") | |
| q = request.files.get("file") | |
| if not refs or not q: | |
| return jsonify({"error": "Reference photos and a questioned photo are required"}), 400 | |
| try: | |
| import prnu | |
| return jsonify(prnu.compare([r.read() for r in refs[:40]], q.read())) | |
| except Exception as e: | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": f"Comparison failed: {e}"}), 500 | |
| def gps_endpoint(): | |
| """Location embedded in a photo or video, if any.""" | |
| f = request.files.get("file") | |
| if not f: | |
| return jsonify({"found": False}) | |
| ext = _file_ext(f) or "jpg" | |
| import gps_extractor | |
| fd, path = tempfile.mkstemp(suffix="." + ext) | |
| try: | |
| with os.fdopen(fd, "wb") as out: | |
| f.save(out) | |
| out = gps_extractor.extract(path, ext) | |
| try: | |
| import metadata_check | |
| out["meta"] = metadata_check.check(path, ext, out) | |
| if ext not in gps_extractor.VIDEO_EXTS: | |
| import platform_id | |
| with open(path, "rb") as fh: | |
| plat = platform_id.identify(fh.read(), f.filename or "") | |
| if plat: | |
| out["meta"]["platform"] = plat["candidates"] | |
| except Exception as e: | |
| print(f"[metadata] failed: {e}", flush=True) | |
| return jsonify(out) | |
| except Exception as e: | |
| print(f"[gps] failed: {e}", flush=True) | |
| return jsonify({"found": False}) | |
| finally: | |
| try: | |
| os.remove(path) | |
| except OSError: | |
| pass | |
| def ts_gps_js(): | |
| path = os.path.join(app.root_path, "templates", "ts-gps.js") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read(), 200, {"Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache"} | |
| def ts_viewer_js(): | |
| path = os.path.join(app.root_path, "templates", "ts-viewer.js") | |
| with open(path, "r", encoding="utf-8") as f: | |
| return f.read(), 200, {"Content-Type": "application/javascript; charset=utf-8", "Cache-Control": "no-cache"} | |
| def ready(): | |
| """Lets the UI show a 'loading models x/y' indicator if wanted.""" | |
| return jsonify({"ready": _models_ready.is_set(), "done": _warmup_status["done"], | |
| "total": _warmup_status["total"]}) | |
| IMAGE_EXTS = {"jpg", "jpeg", "png", "bmp", "webp"} | |
| VIDEO_EXTS = {"mp4", "mov", "avi", "mkv", "webm"} | |
| _MIME_EXT = {"video/quicktime": "mov", "video/mp4": "mp4", "video/x-msvideo": "avi", "video/x-matroska": "mkv", | |
| "video/webm": "webm", "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp", | |
| "image/heic": "heic", "audio/mpeg": "mp3", "audio/wav": "wav", "audio/x-wav": "wav", | |
| "audio/mp4": "m4a", "audio/x-m4a": "m4a", "audio/ogg": "ogg", "audio/webm": "webm"} | |
| def _file_ext(f, default=""): | |
| """Extension from the ORIGINAL file name. secure_filename() drops Arabic | |
| letters, so "قناة الغد.mov" became "mov" with no dot and the file was | |
| rejected as an unsupported format.""" | |
| name = (f.filename or "").strip() | |
| if "." in name: | |
| e = name.rsplit(".", 1)[-1].lower() | |
| if e.isalnum() and len(e) <= 5: | |
| return e | |
| return _MIME_EXT.get((f.mimetype or "").split(";")[0].strip().lower(), default) | |
| def _safe_name(f): | |
| """secure_filename(), keeping the real extension even when the name was | |
| all Arabic (which secure_filename reduces to just "pdf" / "mov").""" | |
| name = secure_filename(f.filename or "upload") | |
| ext = _file_ext(f) | |
| if ext and not name.lower().endswith("." + ext): | |
| name = f"upload.{ext}" | |
| return name | |
| def get_settings(): | |
| """Exposes general settings from config.json to the frontend (currently | |
| just default_language) — lets the default UI language be changed by | |
| editing config.json, no code change needed.""" | |
| config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") | |
| default_language = "ar" | |
| try: | |
| with open(config_path, "r", encoding="utf-8") as f: | |
| default_language = json.load(f).get("general", {}).get("default_language", "ar") | |
| except Exception: | |
| pass | |
| return jsonify({"default_language": default_language}) | |
| def index(): | |
| """Main UI — TrueScan design (React, wired to /api/analyze). | |
| Served as a raw static response (not through Jinja) since the page's | |
| inline JS uses {{ }}-style JSX/object literals that Jinja would | |
| otherwise try to parse as template variables.""" | |
| path = os.path.join(app.root_path, "templates", "truescan.html") | |
| with open(path, "r", encoding="utf-8") as f: | |
| html = f.read() | |
| return _with_status(html), 200, {"Content-Type": "text/html; charset=utf-8"} | |
| def truescan(): | |
| """Alias for the main page (same TrueScan UI).""" | |
| path = os.path.join(app.root_path, "templates", "truescan.html") | |
| with open(path, "r", encoding="utf-8") as f: | |
| html = f.read() | |
| return _with_status(html), 200, {"Content-Type": "text/html; charset=utf-8"} | |
| def analyze(): | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| file = request.files["file"] | |
| filename = _safe_name(file) | |
| ext = _file_ext(file) | |
| data = file.read() | |
| # Result caching disabled (2026-08, per request) — every analysis now | |
| # always runs fresh, even for a repeated identical file. This matters | |
| # because otherwise a cached result from before a weight/config change | |
| # would keep being served instead of reflecting the update. | |
| try: | |
| if ext in IMAGE_EXTS: | |
| result = analyze_image_bytes(data) | |
| elif ext in VIDEO_EXTS: | |
| tmp_path = os.path.join(tempfile.gettempdir(), f"_forensics_tmp_{os.getpid()}.{ext}") | |
| result = analyze_video_bytes(data, tmp_path) | |
| else: | |
| return jsonify({"error": f"Unsupported format: .{ext}"}), 400 | |
| except Exception as e: | |
| print("=" * 60, file=sys.stderr) | |
| print("[api/analyze] FULL ERROR TRACEBACK:", file=sys.stderr) | |
| traceback.print_exc() | |
| print("=" * 60, file=sys.stderr) | |
| return jsonify({"error": f"Analysis failed: {e}"}), 500 | |
| result["filename"] = filename | |
| result["from_cache"] = False | |
| return jsonify(result) | |
| DOCUMENT_EXTS = {"pdf", "docx"} | |
| def analyze_document(): | |
| """ | |
| Forensic metadata inspection for PDF/Word files — a completely | |
| different domain from the image pipeline (no ML models, pure | |
| file-structure inspection: creation/modification dates, producing | |
| software, incremental-save history, track-changes markup). These | |
| are FORENSIC INDICATORS for a human to investigate, not a validated | |
| real/fake classifier like the image pipeline — no labeled dataset | |
| backs these heuristics yet. | |
| """ | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| file = request.files["file"] | |
| filename = _safe_name(file) | |
| data = file.read() | |
| try: | |
| result = document_forensics.analyze_document_bytes(data, filename) | |
| except Exception as e: | |
| return jsonify({"error": f"Document analysis failed: {e}"}), 500 | |
| result["filename"] = filename | |
| return jsonify(result) | |
| AUDIO_EXTS = {"mp3", "wav", "m4a", "aac", "ogg", "flac", "wma", "webm"} | |
| def read_plate_endpoint(): | |
| """Reads license plate text + guesses vehicle make/model from an image.""" | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| from PIL import Image | |
| import io | |
| file = request.files["file"] | |
| filename = _safe_name(file) | |
| data = file.read() | |
| try: | |
| pil_image = Image.open(io.BytesIO(data)) | |
| except Exception: | |
| return jsonify({"error": "Could not read this image"}), 400 | |
| # Optional plate area drawn by the user: "x0,y0,x1,y1" as fractions 0..1. | |
| manual_box = None | |
| if request.form.get("plate_box"): | |
| try: | |
| manual_box = [float(v) for v in request.form["plate_box"].split(",")] | |
| if len(manual_box) not in (4, 8): | |
| manual_box = None | |
| except ValueError: | |
| manual_box = None | |
| try: | |
| # Decode the file fully now: a lazily-loaded JPEG read later from | |
| # another thread failed with "assert self.fp is not None". | |
| pil_image.load() | |
| t0 = time.perf_counter() | |
| plate_result = plate_reader.read_plate(pil_image, manual_box) | |
| print(f"[read-plate] done in {time.perf_counter() - t0:.1f}s", flush=True) | |
| except Exception: | |
| import traceback | |
| traceback.print_exc() | |
| plate_result = {"available": True, "status": "review", "readings": [], | |
| "fields": {}, "needs_selection": not manual_box} | |
| enhanced = None | |
| if not manual_box: # automatic enhancement of the whole photo, for viewing | |
| try: | |
| import base64 | |
| out, _ = image_enhancer.enhance(pil_image, max_side=1600) | |
| buf = io.BytesIO() | |
| out.save(buf, "JPEG", quality=88) | |
| enhanced = "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() | |
| except Exception as e: | |
| print(f"[read-plate] enhancement failed: {e}", flush=True) | |
| return jsonify({ | |
| "filename": filename, | |
| "plate": plate_result, | |
| "enhanced": enhanced, | |
| }) | |
| PLATE_TRAINING_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "route", ".plate_training_data") | |
| def correct_plate_endpoint(): | |
| """Saves the plate image + the person's corrected true reading as a | |
| labeled training example. This is how a real, dedicated plate | |
| character classifier eventually gets built: not from a generic OCR | |
| model guessing, but from actual verified Saudi-plate photos with | |
| ground-truth labels — the same 'corrections build real data' | |
| approach already used for the main fake/real image detector.""" | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| # Stored without spaces (display shows "B B B", training labels keep "BBB"). | |
| digits = request.form.get("digits", "").replace(" ", "").strip() | |
| letters_ar = request.form.get("letters_ar", "").replace(" ", "").strip() | |
| letters_en = request.form.get("letters_en", "").replace(" ", "").upper().strip() | |
| if not (digits or letters_ar or letters_en): | |
| return jsonify({"error": "No corrected text provided"}), 400 | |
| from datetime import datetime, timezone | |
| os.makedirs(PLATE_TRAINING_DIR, exist_ok=True) | |
| file = request.files["file"] | |
| data = file.read() | |
| ext = (file.filename or "plate.jpg").rsplit(".", 1)[-1].lower() | |
| if ext not in ("jpg", "jpeg", "png", "webp"): | |
| ext = "jpg" | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f") | |
| image_filename = None | |
| # On the hosted server no plate photos are kept (privacy); a local | |
| # install keeps them as training examples. | |
| if not os.environ.get("SPACE_ID") or os.environ.get("PLATE_SAVE_IMAGES") == "1": | |
| image_filename = f"{timestamp}.{ext}" | |
| with open(os.path.join(PLATE_TRAINING_DIR, image_filename), "wb") as f: | |
| f.write(data) | |
| # Feedback for the error statistics: which positions were misread, not | |
| # the plate itself. | |
| try: | |
| import feedback_store | |
| pred = json.loads(request.form.get("predicted") or "{}") | |
| pd, pe = str(pred.get("digits") or "").replace(" ", ""), str(pred.get("letters_en") or "").replace(" ", "").upper() | |
| conf = [] | |
| for a, b in zip(pd.ljust(len(digits)), digits.ljust(len(pd))): | |
| if a != b: | |
| conf.append(f"{a or '∅'}→{b or '∅'}") | |
| for a, b in zip(pe.ljust(len(letters_en)), letters_en.ljust(len(pe))): | |
| if a != b: | |
| conf.append(f"{a or '∅'}→{b or '∅'}") | |
| feedback_store.add({"section": "plate", "verdict_ok": not conf and bool(pd or pe), | |
| "predicted": "read" if (pd or pe) else "none", | |
| "correct": "confirmed" if not conf else "corrected", | |
| "details": {"confusions": conf, "n_digits": len(digits), "n_letters": len(letters_en), | |
| "manual_area": bool(pred.get("manual"))}}, | |
| extra_files=_FEEDBACK_EXTRA) | |
| except Exception as e: | |
| print(f"[feedback] plate: {e}", flush=True) | |
| if image_filename is None: | |
| return jsonify({"ok": True}) | |
| label_entry = { | |
| "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), | |
| "image_file": image_filename, | |
| "digits": digits, | |
| "letters_ar": letters_ar, | |
| "letters_en": letters_en, | |
| } | |
| labels_path = os.path.join(PLATE_TRAINING_DIR, "labels.jsonl") | |
| with open(labels_path, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(label_entry, ensure_ascii=False) + "\n") | |
| return jsonify({"ok": True}) | |
| def correct_plate_count(): | |
| """Number of labeled plate-training examples collected so far.""" | |
| labels_path = os.path.join(PLATE_TRAINING_DIR, "labels.jsonl") | |
| if not os.path.exists(labels_path): | |
| return jsonify({"count": 0}) | |
| with open(labels_path, "r", encoding="utf-8") as f: | |
| count = sum(1 for line in f if line.strip()) | |
| return jsonify({"count": count}) | |
| def analyze_voice(): | |
| """ | |
| Standalone audio analysis: voice-deepfake classification + purely | |
| descriptive acoustic stats (pitch/rate/pauses — no deception/emotion | |
| judgment, see voice_descriptive_stats.py for why). | |
| """ | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| file = request.files["file"] | |
| filename = _safe_name(file) | |
| ext = _file_ext(file) | |
| if ext not in AUDIO_EXTS: | |
| return jsonify({"error": f"Unsupported audio format: .{ext}"}), 400 | |
| data = file.read() | |
| unique = hashlib.sha256(data).hexdigest()[:8] | |
| raw_path = os.path.join(tempfile.gettempdir(), f"_voice_raw_{os.getpid()}_{unique}.{ext}") | |
| wav_path = os.path.join(tempfile.gettempdir(), f"_voice_wav_{os.getpid()}_{unique}.wav") | |
| with open(raw_path, "wb") as f: | |
| f.write(data) | |
| try: | |
| # Normalize to WAV via ffmpeg first — guarantees librosa can read | |
| # it regardless of source format (browser mic recordings come in | |
| # as .webm/opus, which isn't reliably decodable otherwise). | |
| if not audio_extractor.extract_audio(raw_path, wav_path): | |
| return jsonify({"error": "Could not decode this audio file"}), 400 | |
| voice_score, voice_reason_raw, voice_available = voice_deepfake_detector.classify_audio(wav_path) | |
| stats = voice_descriptive_stats.compute_stats(wav_path) | |
| except Exception as e: | |
| return jsonify({"error": f"Voice analysis failed: {e}"}), 500 | |
| finally: | |
| for p in (raw_path, wav_path): | |
| if os.path.exists(p): | |
| os.remove(p) | |
| categories = [] | |
| if voice_available: | |
| reason = r(voice_reason_raw["key"], voice_reason_raw["text"], **voice_reason_raw["params"]) | |
| categories.append({"key": "cat_voice_deepfake", "name": "Voice Deepfake Detector", "score": round(voice_score), "reasons": [reason]}) | |
| suspicion_score = round(voice_score) if voice_available else 0 | |
| verdict_level = "high" if suspicion_score >= 70 else "medium" if suspicion_score >= 50 else "low" | |
| result = { | |
| "type": "audio", | |
| "suspicion_score": suspicion_score, | |
| "verdict_level": verdict_level, | |
| "categories": categories, | |
| "descriptive_stats": stats, | |
| "filename": filename, | |
| } | |
| return jsonify(result) | |
| def compare_voices_endpoint(): | |
| """Speaker verification: are these two recordings the same person?""" | |
| if "file_a" not in request.files or "file_b" not in request.files: | |
| return jsonify({"error": "Two audio files required (file_a, file_b)"}), 400 | |
| raw_paths, wav_paths = [], [] | |
| try: | |
| for key in ("file_a", "file_b"): | |
| f = request.files[key] | |
| ext = _file_ext(f, "wav") | |
| if ext not in AUDIO_EXTS: | |
| return jsonify({"error": f"Unsupported audio format: .{ext}"}), 400 | |
| data = f.read() | |
| unique = hashlib.sha256(data).hexdigest()[:8] | |
| rp = os.path.join(tempfile.gettempdir(), f"_voicecmp_raw_{os.getpid()}_{key}_{unique}.{ext}") | |
| wp = os.path.join(tempfile.gettempdir(), f"_voicecmp_wav_{os.getpid()}_{key}_{unique}.wav") | |
| with open(rp, "wb") as out: | |
| out.write(data) | |
| if not audio_extractor.extract_audio(rp, wp): | |
| return jsonify({"error": f"Could not decode {key}"}), 400 | |
| raw_paths.append(rp) | |
| wav_paths.append(wp) | |
| result = speaker_verification.compare_voices(wav_paths[0], wav_paths[1]) | |
| except Exception as e: | |
| return jsonify({"error": f"Voice comparison failed: {e}"}), 500 | |
| finally: | |
| for p in raw_paths + wav_paths: | |
| if os.path.exists(p): | |
| os.remove(p) | |
| return jsonify(result) | |
| def forensic_scope(): | |
| """ | |
| Manual visual inspection ("magnifier") — NOT part of the automated | |
| scoring pipeline. Takes an image (and optionally a region box the | |
| user dragged/selected on the frontend) and returns zoomed/edges/ | |
| sharpness/noise/ELA maps for the person to visually judge themselves, | |
| same purpose as forensic_scope.py's desktop tool. | |
| """ | |
| if "file" not in request.files: | |
| return jsonify({"error": "No file attached"}), 400 | |
| file = request.files["file"] | |
| data = file.read() | |
| box = None | |
| if all(k in request.form for k in ("x1", "y1", "x2", "y2")): | |
| try: | |
| box = ( | |
| float(request.form["x1"]), float(request.form["y1"]), | |
| float(request.form["x2"]), float(request.form["y2"]), | |
| ) | |
| except ValueError: | |
| return jsonify({"error": "Invalid box coordinates"}), 400 | |
| try: | |
| zoom_factor = 4 if box is not None else 1 | |
| result = forensic_scope_api.analyze_region_for_web(data, box=box, zoom_factor=zoom_factor) | |
| except ValueError as e: | |
| return jsonify({"error": str(e)}), 400 | |
| except Exception as e: | |
| return jsonify({"error": f"Forensic scope failed: {e}"}), 500 | |
| return jsonify(result) | |
| CORRECTION_LOG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "route", ".calibration_log.jsonl") | |
| # Mirrored together with the feedback log (plate labels are NOT mirrored: | |
| # they contain full plate numbers). | |
| _FEEDBACK_EXTRA = [(CORRECTION_LOG_PATH, "calibration_log.jsonl")] | |
| def feedback_endpoint(): | |
| """Result evaluation from any section: right or wrong, the right answer | |
| and an optional note. Only results/scores are stored, never the file.""" | |
| import json as _json | |
| import feedback_store | |
| p = request.get_json(silent=True) or {} | |
| section = str(p.get("section") or "")[:20] | |
| if section not in feedback_store.SECTIONS: | |
| return jsonify({"error": "unknown section"}), 400 | |
| ok = bool(p.get("verdict_ok")) | |
| entry = {"section": section, "verdict_ok": ok, | |
| "predicted": str(p.get("predicted") or "")[:40], | |
| "correct": str(p.get("correct") or ("same" if ok else ""))[:40], | |
| "note": str(p.get("note") or "")[:500], | |
| "details": p.get("details") if isinstance(p.get("details"), dict) else {}} | |
| api_result = p.get("api_result") | |
| if section in ("image", "video") and isinstance(api_result, dict) and api_result.get("categories"): | |
| cats = [{"key": c.get("key"), "score": c.get("score")} for c in api_result["categories"]] | |
| entry["details"]["categories"] = cats | |
| entry["details"]["score"] = api_result.get("suspicion_score") | |
| # Same record in the calibration log used to re-weight the detectors. | |
| score = api_result.get("suspicion_score", 0) or 0 | |
| tier = "fake" if score >= 60 else "edited" if score >= 50 else "real" | |
| truth = tier if ok else entry["correct"] | |
| if truth in ("real", "edited", "fake"): | |
| from datetime import datetime, timezone | |
| rec = {"timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), | |
| "filename": "web_feedback", "file_type": api_result.get("type", section), | |
| "predicted_score": score, "predicted_level": api_result.get("verdict_level"), | |
| "predicted_tier": tier, "ground_truth": truth, "is_correct": tier == truth, | |
| "categories": [{"name": c.get("name"), "score": c.get("score")} for c in api_result["categories"]], | |
| "note": "web_feedback"} | |
| os.makedirs(os.path.dirname(CORRECTION_LOG_PATH), exist_ok=True) | |
| with open(CORRECTION_LOG_PATH, "a", encoding="utf-8") as f: | |
| f.write(_json.dumps(rec, ensure_ascii=False) + "\n") | |
| feedback_store.add(entry, extra_files=_FEEDBACK_EXTRA) | |
| return jsonify({"ok": True}) | |
| def feedback_stats(): | |
| import feedback_store | |
| return jsonify(feedback_store.stats()) | |
| # Downloading the evaluation logs needs a separate password. Only its hash | |
| # is kept here; EXPORT_PASSWORD (environment) overrides it. | |
| _EXPORT_PW_HASH = "f67093b464b51b513580b0f8b15b9d80da8c5315bbb02646be4782dbdef8b460" | |
| def _export_allowed(): | |
| import hashlib | |
| import hmac | |
| given = request.headers.get("X-Export-Password") or request.form.get("password") or "" | |
| env = os.environ.get("EXPORT_PASSWORD") | |
| if env: | |
| ok = hmac.compare_digest(given.encode(), env.encode()) | |
| else: | |
| ok = hmac.compare_digest(hashlib.sha256(given.encode()).hexdigest(), _EXPORT_PW_HASH) | |
| if not ok: | |
| time.sleep(1.0) # slows down guessing | |
| return ok | |
| def feedback_export(): | |
| import feedback_store | |
| if not _export_allowed(): | |
| return jsonify({"error": "Incorrect password"}), 403 | |
| return feedback_store.export_csv(), 200, { | |
| "Content-Type": "text/csv; charset=utf-8", | |
| "Content-Disposition": "attachment; filename=truscan_feedback.csv"} | |
| def correct_label(): | |
| """ | |
| Lets a person correct the app's verdict from the results screen | |
| ("this was actually real/edited/fake"). Appends an entry to the SAME | |
| .calibration_log.jsonl file bulk_calibrate.py writes to, in the exact | |
| same schema — so recalibrate.py and train_meta_classifier.py can read | |
| these corrections with zero changes. This does NOT retrain anything | |
| automatically; it just adds a labeled sample for the next time you | |
| run recalibrate.py. | |
| """ | |
| import json | |
| from datetime import datetime, timezone | |
| payload = request.get_json(silent=True) or {} | |
| ground_truth = payload.get("ground_truth") | |
| if ground_truth not in ("real", "edited", "fake"): | |
| return jsonify({"error": "ground_truth must be 'real', 'edited', or 'fake'"}), 400 | |
| api_result = payload.get("api_result") | |
| filename = payload.get("filename", "web_correction") | |
| if not api_result or "categories" not in api_result: | |
| return jsonify({"error": "Missing api_result with categories"}), 400 | |
| score = api_result.get("suspicion_score", 0) | |
| # Same 3-tier thresholds the frontend uses for display (<50 real, | |
| # 50-60 edited, >=60 fake) — kept in sync here so "is_correct" | |
| # reflects an actual 3-way match instead of collapsing "edited" | |
| # corrections into "fake" the way the old real-vs-fake-only check did. | |
| predicted_tier = "fake" if score >= 60 else "edited" if score >= 50 else "real" | |
| is_correct = predicted_tier == ground_truth | |
| entry = { | |
| "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), | |
| "filename": filename, | |
| "file_type": api_result.get("type", "image"), | |
| "predicted_score": api_result.get("suspicion_score"), | |
| "predicted_level": api_result.get("verdict_level"), | |
| "predicted_tier": predicted_tier, | |
| "ground_truth": ground_truth, | |
| "is_correct": is_correct, | |
| "categories": [{"name": c["name"], "score": c["score"]} for c in api_result["categories"]], | |
| "note": "web_correction", | |
| } | |
| os.makedirs(os.path.dirname(CORRECTION_LOG_PATH), exist_ok=True) | |
| with open(CORRECTION_LOG_PATH, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(entry, ensure_ascii=False) + "\n") | |
| return jsonify({"ok": True, "logged_as": ground_truth}) | |
| def download_calibration_log(): | |
| """Lets the person download .calibration_log.jsonl straight from the | |
| app — no need to dig into the hidden Engine folder. Useful for pulling | |
| it out after a batch of corrections, or to merge with logs collected | |
| from other machines/people running the same distributed app.""" | |
| from flask import send_file | |
| if not _export_allowed(): | |
| return jsonify({"error": "Incorrect password"}), 403 | |
| if not os.path.exists(CORRECTION_LOG_PATH): | |
| return jsonify({"error": "No corrections recorded yet"}), 404 | |
| return send_file(CORRECTION_LOG_PATH, as_attachment=True, download_name="calibration_log.jsonl") | |
| def count_calibration_log(): | |
| """Corrections recorded so far, broken down into how many times the | |
| program's own prediction was right vs wrong — answers 'how many | |
| mistakes has it made' directly, not just a raw total. Also flags | |
| whether it's a good time to recalibrate: needs BOTH enough total | |
| samples (recalibrate.py's own docstring says it needs ~15-20+ to be | |
| statistically meaningful — a suggestion based on fewer would be | |
| noise) AND a meaningful error rate (20%+) — a raw error count alone | |
| is misleading (5 wrong out of 5 tries is very different from 5 wrong | |
| out of 500).""" | |
| if not os.path.exists(CORRECTION_LOG_PATH): | |
| return jsonify({"count": 0, "correct": 0, "incorrect": 0, "should_recalibrate": False}) | |
| total = correct = incorrect = 0 | |
| with open(CORRECTION_LOG_PATH, "r", encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| total += 1 | |
| try: | |
| entry = json.loads(line) | |
| if entry.get("is_correct") is True: | |
| correct += 1 | |
| elif entry.get("is_correct") is False: | |
| incorrect += 1 | |
| except Exception: | |
| pass | |
| MIN_SAMPLES = 20 # matches recalibrate.py's own stated minimum | |
| MIN_ERROR_RATE = 0.20 # 1-in-5 wrong or worse | |
| error_rate = (incorrect / total) if total else 0 | |
| should_recalibrate = total >= MIN_SAMPLES and error_rate >= MIN_ERROR_RATE | |
| return jsonify({ | |
| "count": total, "correct": correct, "incorrect": incorrect, | |
| "should_recalibrate": should_recalibrate, | |
| "error_rate": round(error_rate * 100), | |
| }) | |
| def _warm_plate_reader(): | |
| # Raise on failure so the offline marker is NOT written while a model | |
| # is still missing from the cache. | |
| if plate_reader._get_reader() is None: | |
| raise RuntimeError("EasyOCR not loaded") | |
| try: | |
| import plate_chars_yolo | |
| plate_chars_yolo.available() # loads models/plate_chars.pt if present | |
| import plate_superres | |
| plate_superres.available() # downloads/loads the plate upscaler | |
| except Exception as e: | |
| print(f"[warm_up] plate character model skipped: {e}", flush=True) | |
| def _warm_vehicle(img): | |
| vehicle_classifier.classify_vehicle(img) | |
| st = vehicle_classifier._state | |
| if st.get("cls") is None or st.get("clip") is None: | |
| raise RuntimeError("classifier or CLIP make-gate not loaded") | |
| def warm_up_models(): | |
| """ | |
| Loads every per-image detector's model ONCE, in parallel threads, | |
| before the server starts accepting requests — instead of the old | |
| behavior where each model lazy-loaded one at a time on whatever | |
| image happened to be analyzed first (serial downloads/loads, which | |
| is what made the very first analysis take several minutes). | |
| Doesn't touch Nonescape (its own isolated-venv subprocess, not a | |
| one-time load in this process) or the audio models (need an actual | |
| audio file to trigger their loaders meaningfully). | |
| """ | |
| import numpy as np | |
| from PIL import Image as PILImage | |
| dummy_pil = PILImage.new("RGB", (64, 64), color=(128, 128, 128)) | |
| dummy_bgr = np.zeros((64, 64, 3), dtype=np.uint8) | |
| tasks = [ | |
| ("ai_detector", lambda: ai_detector.classify_image(dummy_pil)), | |
| ("deepfake_face_detector", lambda: deepfake_face_detector.classify_face(dummy_pil)), | |
| ("face_detector", lambda: face_detector.detect_faces(dummy_bgr)), | |
| ("efficientnetb3_detector", lambda: efficientnetb3_detector.classify_image(dummy_pil)), | |
| ("sdxl_face_detector", lambda: sdxl_face_detector.classify_image(dummy_pil)), | |
| ("noise_residual_detector", lambda: noise_residual_detector.classify_image(dummy_pil)), | |
| ("clone_detection_module", lambda: clone_detection_module.classify_image(dummy_pil)), | |
| ("pixel_forensics_detector", lambda: pixel_forensics_detector.classify_image(dummy_pil)), | |
| ("community_forensics_detector", lambda: community_forensics_detector.classify_image(dummy_pil)), | |
| ("steganographia_detector", lambda: steganographia_detector.classify_image(dummy_pil)), | |
| ("insightface_detector", lambda: insightface_detector.analyze(dummy_pil)), | |
| ] | |
| # Plate models first: they're light and the plate tab should be usable early. | |
| tasks = [("plate_reader", _warm_plate_reader)] + tasks | |
| # Manipulation-localization model: loaded in the background, outside the | |
| # readiness gate (its first download can take a while). | |
| def _warm_trufor(): | |
| try: | |
| import trufor_detector | |
| trufor_detector.available() | |
| except Exception as e: | |
| print(f"[warm_up] manipulation map model failed: {e}", flush=True) | |
| threading.Thread(target=_warm_trufor, daemon=True).start() | |
| try: | |
| import feedback_store | |
| threading.Thread(target=feedback_store.restore, args=(_FEEDBACK_EXTRA,), daemon=True).start() | |
| except Exception as e: | |
| print(f"[warm_up] feedback restore failed: {e}", flush=True) | |
| for name, _ in tasks: | |
| _event(name) | |
| _warmup_status["total"] = len(tasks) | |
| _warmup_status["failed"].extend(f"{m} (missing file)" for m in MISSING_MODULES) | |
| # Import the heavy libraries ONCE, in order, before the parallel loads. | |
| # Several threads importing torch/open_clip/easyocr at the same moment | |
| # can leave a module half-initialised, which made unrelated detectors | |
| # fail at startup ("EasyOCR not loaded", "install open_clip_torch"). | |
| import importlib | |
| for mod in ("torch", "torchvision", "transformers", "open_clip", "easyocr", "cv2"): | |
| t0 = time.perf_counter() | |
| try: | |
| importlib.import_module(mod) | |
| print(f"[warm_up] import {mod} ({time.perf_counter() - t0:.1f}s)", flush=True) | |
| except Exception as e: | |
| print(f"[warm_up] import {mod} failed: {e}", flush=True) | |
| def run_one(name, fn): | |
| t0 = time.perf_counter() | |
| try: | |
| fn() | |
| secs = time.perf_counter() - t0 | |
| print(f"[warm_up] {name} ready ({secs:.1f}s).", flush=True) | |
| except Exception as e: | |
| secs = time.perf_counter() - t0 | |
| _warmup_status["failed"].append(name) | |
| # A warm-up failure here isn't fatal — the real analysis call | |
| # will just lazy-load (and report) the same error itself. | |
| print(f"[warm_up] {name} failed to warm up ({e}) — will retry on first real use.", flush=True) | |
| _warmup_status["seconds"][name] = round(secs, 1) | |
| _warmup_status["done"] += 1 | |
| _event(name).set() | |
| print("=" * 60) | |
| print(" Loading detection models in the background — the page is") | |
| print(" already usable; analysis starts as soon as loading finishes.") | |
| print("=" * 60) | |
| t_all = time.perf_counter() | |
| # 3 workers, not one per model: 11+ simultaneous loads from the same | |
| # (often USB) drive thrash the disk and end up SLOWER than a few at a time. | |
| workers = int(os.environ.get("WARMUP_WORKERS", "3")) | |
| with ThreadPoolExecutor(max_workers=workers) as pool: | |
| list(pool.map(lambda t: run_one(*t), tasks)) | |
| slowest = sorted(_warmup_status["seconds"].items(), key=lambda kv: -kv[1])[:5] | |
| print(f"[warm_up] All models loaded in {time.perf_counter() - t_all:.1f}s. " | |
| f"Slowest: {slowest}", flush=True) | |
| # Once everything has loaded successfully at least once, all weights are | |
| # in the local cache — run.bat sees this marker and switches Hugging Face | |
| # to OFFLINE mode, skipping the per-model network checks on later launches. | |
| if not _warmup_status["failed"]: | |
| try: | |
| marker = os.path.join(_PROJECT_ROOT, "cache", ".models_ready") | |
| os.makedirs(os.path.dirname(marker), exist_ok=True) | |
| with open(marker, "w") as f: | |
| f.write("ok") | |
| except Exception: | |
| pass | |
| _models_ready.set() | |
| def _find_app_browser(): | |
| """Edge ships with every Windows 10/11 install; Chrome is the fallback.""" | |
| candidates = [] | |
| for base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles"), | |
| os.environ.get("LOCALAPPDATA")): | |
| if base: | |
| candidates += [os.path.join(base, "Microsoft", "Edge", "Application", "msedge.exe"), | |
| os.path.join(base, "Google", "Chrome", "Application", "chrome.exe")] | |
| return next((c for c in candidates if os.path.exists(c)), None) | |
| def _real_user_profile(): | |
| """The PC's real user folder (C:\\Users\\<name>), found WITHOUT relying on | |
| run.bat — so this works even with an older run.bat. run.bat only | |
| redirects USERPROFILE/HOME; LOCALAPPDATA and HOMEDRIVE/HOMEPATH still | |
| point at the real account.""" | |
| candidates = [os.environ.get("TRUESCAN_REAL_USERPROFILE")] | |
| lad = os.environ.get("LOCALAPPDATA") | |
| if lad: | |
| candidates.append(os.path.dirname(os.path.dirname(lad))) # ...\\AppData\\Local -> user folder | |
| if os.environ.get("HOMEDRIVE") and os.environ.get("HOMEPATH"): | |
| candidates.append(os.environ["HOMEDRIVE"] + os.environ["HOMEPATH"]) | |
| for c in candidates: | |
| if c and os.path.isdir(os.path.join(c, "Desktop")) and "cache" + os.sep + "home" not in c: | |
| return c | |
| for c in candidates: | |
| if c and "cache" + os.sep + "home" not in c: | |
| return c | |
| return os.environ.get("USERPROFILE", "") | |
| def _kill_stale_app_windows(profile): | |
| """Closes leftover browser processes that use OUR private profile (never | |
| the user's normal Edge/Chrome). Edge can keep running in the background | |
| after its window closes; a new window would then be handed to that old | |
| process and inherit its stale settings.""" | |
| if os.name != "nt": | |
| return | |
| import subprocess | |
| marker = "TruScanApp" # matches this and every older app-window profile | |
| ps = ("Get-CimInstance Win32_Process -Filter \"Name='msedge.exe' OR Name='chrome.exe'\" | " | |
| f"Where-Object {{ $_.CommandLine -like '*{marker}*' }} | " | |
| "ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }") | |
| try: | |
| subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], | |
| timeout=8, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), | |
| capture_output=True) | |
| except Exception as e: | |
| print(f"[app] Could not check for leftover app windows: {e}", flush=True) | |
| def _clear_stale_dialog_folder(profile): | |
| """The browser remembers the last folder used in "Open file". If that | |
| folder no longer exists (e.g. an old path on the flash drive), every | |
| dialog starts with "Location is not available". Drop it so the dialog | |
| opens normally.""" | |
| pref = os.path.join(profile, "Default", "Preferences") | |
| try: | |
| with open(pref, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| last = (data.get("selectfile") or {}).get("last_directory") | |
| if last and (not os.path.isdir(last) or "cache" + os.sep + "home" in last): | |
| data["selectfile"].pop("last_directory", None) | |
| with open(pref, "w", encoding="utf-8") as f: | |
| json.dump(data, f) | |
| print(f"[app] Cleared stale file-dialog folder: {last}", flush=True) | |
| except Exception: | |
| pass | |
| def _open_app_window(url, shutdown_on_close=True): | |
| """Opens the UI as a standalone app window (no tabs, no address bar). | |
| Closing that window shuts the server down, like closing a program.""" | |
| import subprocess | |
| browser = _find_app_browser() | |
| if not browser: | |
| print("[app] Edge/Chrome not found - opening in the default browser.", flush=True) | |
| webbrowser.open(url) | |
| return | |
| # The window's browser profile lives on the PC's own fast disk, NOT on | |
| # the USB drive: a fresh Edge profile on a flash drive takes a long time | |
| # to create/load, which showed up as a blank white window at startup. | |
| base = os.environ.get("LOCALAPPDATA") or os.environ.get("TEMP") or os.path.join(_PROJECT_ROOT, "cache") | |
| # New folder name on purpose: an Edge process started under the OLD | |
| # settings could still be alive in the background with the flash-drive | |
| # "Desktop" baked in, and new windows were being handed to it. | |
| profile = os.path.join(base, "TruScanApp3") # own profile -> own process | |
| os.makedirs(profile, exist_ok=True) | |
| if shutdown_on_close: | |
| _kill_stale_app_windows(profile) | |
| _clear_stale_dialog_folder(profile) | |
| args = [browser, f"--app={url}", f"--user-data-dir={profile}", "--window-size=1440,900", | |
| "--no-first-run", "--no-default-browser-check", "--disable-sync", | |
| "--disable-background-mode", | |
| "--disable-features=Translate,msEdgeSidebarV2,msEdgeStartupBoost"] | |
| t0 = time.perf_counter() | |
| # run.bat points USERPROFILE/HOME at the flash drive so Python libraries | |
| # cache there. The browser window must NOT inherit that, or its | |
| # file-open dialog looks for Desktop/Documents on the flash drive | |
| # ("Location is not available"). Give it the PC's real user folders. | |
| env = dict(os.environ) | |
| env["USERPROFILE"] = env["HOME"] = _real_user_profile() | |
| for k in ("XDG_CACHE_HOME", "HF_HOME", "TORCH_HOME", "PYTHONIOENCODING", "TRUESCAN_HIDDEN"): | |
| env.pop(k, None) | |
| proc = subprocess.Popen(args, env=env) | |
| if not shutdown_on_close: | |
| return | |
| proc.wait() | |
| if time.perf_counter() - t0 < 3: | |
| # The browser handed the window to an already-running instance, so we | |
| # can't tell when it closes - keep serving instead of quitting. | |
| print("[app] App window handed off to an existing browser process; server stays up.", flush=True) | |
| return | |
| print("[app] App window closed - shutting down.", flush=True) | |
| _close_hidden_launcher() | |
| os._exit(0) | |
| _console_hidden = False | |
| def _hide_console(): | |
| """Hides the black console window (whichever launcher opened it). The | |
| same messages are still available inside the app: click the status pill | |
| at the bottom-left. Set TRUESCAN_SHOW_CONSOLE=1 (run.bat /console) to | |
| keep it visible for troubleshooting.""" | |
| global _console_hidden | |
| if os.name != "nt" or os.environ.get("TRUESCAN_SHOW_CONSOLE"): | |
| return | |
| try: | |
| import ctypes | |
| hwnd = ctypes.windll.kernel32.GetConsoleWindow() | |
| if hwnd: | |
| ctypes.windll.user32.ShowWindow(hwnd, 0) # SW_HIDE | |
| _console_hidden = True | |
| except Exception: | |
| pass | |
| def _close_hidden_launcher(): | |
| """When the console was hidden, the .bat that started us would sit | |
| invisibly at 'pause' after we exit — close it too.""" | |
| if not _console_hidden: | |
| return | |
| try: | |
| import subprocess | |
| ppid = os.getppid() | |
| out = subprocess.run(["tasklist", "/FI", f"PID eq {ppid}", "/FO", "CSV", "/NH"], | |
| capture_output=True, text=True, timeout=5, | |
| creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)).stdout.lower() | |
| if "cmd.exe" in out: | |
| subprocess.Popen(["taskkill", "/PID", str(ppid), "/F"], | |
| creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) | |
| except Exception: | |
| pass | |
| def _server_already_running(port): | |
| import urllib.request | |
| try: | |
| urllib.request.urlopen(f"http://127.0.0.1:{port}/api/settings", timeout=1) | |
| return True | |
| except Exception: | |
| return False | |
| if __name__ == "__main__": | |
| _config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") | |
| _port = 5050 | |
| try: | |
| with open(_config_path, "r", encoding="utf-8") as f: | |
| _general = json.load(f).get("general", {}) | |
| _port = int(_general.get("port", 5050)) | |
| except Exception as e: | |
| print(f"[app] Could not load general settings from config.json ({e}) — using defaults (port 5050).") | |
| # ---- Online / server mode (Docker, Hugging Face Spaces) ---- | |
| # No app window, no console tricks: listen on all interfaces on $PORT. | |
| if os.environ.get("TRUESCAN_SERVER"): | |
| _port = int(os.environ.get("PORT", "7860")) | |
| threading.Thread(target=warm_up_models, daemon=True).start() | |
| print("=" * 60) | |
| print(f" TrueScan server mode on port {_port}" | |
| f"{' - password protected' if os.environ.get('APP_PASSWORD') else ' - NO PASSWORD SET'}") | |
| try: | |
| import torch | |
| print(f" GPU: {torch.cuda.get_device_name(0)}" if torch.cuda.is_available() | |
| else " GPU: none - running on CPU") | |
| except Exception as e: | |
| print(f" GPU check failed: {e}") | |
| print("=" * 60, flush=True) | |
| app.run(host="0.0.0.0", port=_port, debug=False, threaded=True) | |
| sys.exit(0) | |
| _url = f"http://127.0.0.1:{_port}" | |
| _hide_console() | |
| # Second launch while already open: just bring up another window. | |
| if _server_already_running(_port): | |
| _open_app_window(_url, shutdown_on_close=False) | |
| sys.exit(0) | |
| threading.Thread(target=warm_up_models, daemon=True).start() | |
| print("=" * 60) | |
| print(" Local server only - no external network connections") | |
| print(f" Address: {_url}") | |
| print("=" * 60) | |
| threading.Timer(1.0, lambda: _open_app_window(_url)).start() | |
| app.run(host="127.0.0.1", port=_port, debug=False, threaded=True) | |