truscan / app.py
Manar55's picture
Deploy 95c1e97
2e8a1a1 verified
Raw History Blame Contribute Delete
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>"""
@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 = '<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"})
@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 = """
<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">&minus;</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&&el;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
@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/<token>")
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\\<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)