""" 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 = """ TrueScan

TrueScan

يُرجى إدخال كلمة المرور للمتابعة
Please enter the password to continue

__ERR__
تنبيه الخصوصية: يعمل البرنامج على خوادم سحابية (Hugging Face). يُرجى عدم رفع مواد حساسة أو بيانات شخصية تخص الآخرين.
Privacy notice: hosted on cloud servers (Hugging Face). Do not upload sensitive material or other people's personal data.
""" @app.route("/login", methods=["GET", "POST"]) 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 = '
كلمة المرور غير صحيحة / Incorrect password
' page = _LOGIN_PAGE.replace("__NEXT__", html.escape(nxt, quote=True)).replace("__ERR__", err) return Response(page, 200, {"Content-Type": "text/html; charset=utf-8"}) @app.route("/logout") def logout_page(): from flask import redirect resp = redirect("/login") resp.delete_cookie(_AUTH_COOKIE) return resp @app.before_request 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()) @app.before_request 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")))) @app.before_request def _take_turn(): if request.path in _HEAVY_PATHS: _heavy_gate.acquire() request.environ["truescan.heavy"] = True @app.teardown_request def _give_turn(exc=None): try: if request.environ.pop("truescan.heavy", False): _heavy_gate.release() except Exception: pass @app.route("/favicon.ico") 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": "دمج إطارات الفيديو", } @app.before_request def _track_start(): label = _ACTIVITY_LABELS.get(request.path) if label: _active[id(request._get_current_object())] = (label, time.time()) @app.teardown_request def _track_end(exc=None): try: _active.pop(id(request._get_current_object()), None) except Exception: pass @app.route("/api/activity") 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 = """
جارٍ التشغيل…
""" _PLATE_PICKER = r""" """ _LETTER_SYNC = r""" """ def _with_status(html): extra = '' + _STATUS_WIDGET + _PLATE_PICKER + _LETTER_SYNC i = html.lower().rfind("") 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 @app.route("/enhance") 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}) @app.route("/api/enhance/video/frame") 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 @app.route("/api/enhance/video/average", methods=["POST"]) 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") @app.route("/api/enhance/ref/") def enhance_ref(token): im = _ENHANCE_REF.get(token) if im is None: return ("", 404) return _jpeg(im, max_side=1400, quality=92) @app.route("/api/enhance/upload", methods=["POST"]) 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}) @app.route("/api/enhance/render", methods=["POST"]) 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 @app.route("/forensics") 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"} @app.route("/api/jpeg-forensics", methods=["POST"]) 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" @app.route("/api/jpeg-ghosts", methods=["POST"]) 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 @app.route("/api/file-structure", methods=["POST"]) 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 @app.route("/api/prnu", methods=["POST"]) 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 @app.route("/api/gps", methods=["POST"]) 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 @app.route("/ts-gps.js") 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"} @app.route("/ts-viewer.js") 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"} @app.route("/api/ready") 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 @app.route("/api/settings") 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}) @app.route("/") 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"} @app.route("/truescan") 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"} @app.route("/api/analyze", methods=["POST"]) 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"} @app.route("/api/analyze-document", methods=["POST"]) 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"} @app.route("/api/read-plate", methods=["POST"]) 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") @app.route("/api/correct-plate", methods=["POST"]) 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}) @app.route("/api/correct-plate/count") 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}) @app.route("/api/analyze-voice", methods=["POST"]) 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) @app.route("/api/compare-voices", methods=["POST"]) 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) @app.route("/api/forensic-scope", methods=["POST"]) 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")] @app.route("/api/feedback", methods=["POST"]) 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}) @app.route("/api/feedback/stats") 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 @app.route("/api/feedback/export", methods=["GET", "POST"]) 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"} @app.route("/api/correct-label", methods=["POST"]) 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}) @app.route("/api/calibration-log/download", methods=["GET", "POST"]) 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") @app.route("/api/calibration-log/count") 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\\), 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)