#!/usr/bin/env python3 """ Faucon 2 — scam screenshot detector that looks at the whole image, then zooms twice where it wants, remembers what it saw, and tells you what it suspects. Minimal inference with ONNX Runtime (no PyTorch needed). pip install onnxruntime pillow numpy python predict.py screenshot.png [more.png ...] [--threshold 0.6] [--out glimpses] [--no-images] For every image you get a verdict in the terminal AND (by default) a picture in `glimpses/` showing WHERE it zoomed: the screenshot with its two zoom boxes (1 = red, 2 = blue), the two zoomed regions side by side, the score, how many looks counted and the kind of scam it suspects. ONNX outputs (input `image`, float32 NCHW, dynamic batch / height / width): logits (N, 2) [clean, scam] → softmax, index 1 = scam probability glimpse_centers (N, 2, 2) (x, y) in [-1, 1] of zoom 1 and zoom 2 glimpse_windows (N, 2) side of each zoom box as a fraction of the image side (0.5 = ×2, 0.33 = ×3) reason_probs (N, 6) what it suspects after its last look (see REASONS) looks (N,) how many looks counted (1 = the first look was enough, up to 3) Preprocessing must match training exactly: 1. open as RGB (transparent pixels → white background); 2. shrink so the longest side is at most 768 px (bicubic); 3. pick the closest aspect-ratio bucket (height / width in 0.5, 2/3, 1, 1.5, 2) and resize to its shape at "double resolution" (same pixel count as 448 × 448, sides multiple of 16), bilinear; 4. scale to [0, 1], then normalize with mean 0.5 / std 0.5 per channel. """ import argparse import math import sys from pathlib import Path import numpy as np import onnxruntime as ort from PIL import Image, ImageDraw, ImageFont MODEL = Path(__file__).with_name("Faucon.onnx") CACHE_SIZE, INPUT_SIZE, MEAN, STD = 768, 448, 0.5, 0.5 BUCKETS = [0.5, 2 / 3, 1.0, 1.5, 2.0] REASONS = ["fake crypto giveaway", "fake airdrop / presale", "account / wallet theft", "fake game gift / gift card", "fake casino / prize", "fake investment"] COLORS = [(255, 77, 77), (63, 169, 255)] def bucket_shape(ratio: float, size: int) -> tuple[int, int]: """(height, width), multiples of 16, aspect ratio `ratio`, about size² pixels.""" return (max(16, round(size * math.sqrt(ratio) / 16) * 16), max(16, round(size / math.sqrt(ratio) / 16) * 16)) def load_rgb(path: Path) -> Image.Image: with Image.open(path) as img: img.draft("RGB", (CACHE_SIZE, CACHE_SIZE)) if img.mode in ("RGBA", "LA", "P", "PA") or "transparency" in img.info: rgba = img.convert("RGBA") out = Image.new("RGB", rgba.size, (255, 255, 255)) out.paste(rgba, mask=rgba.getchannel("A")) else: out = img.convert("RGB") out.thumbnail((CACHE_SIZE, CACHE_SIZE), Image.BICUBIC) return out def preprocess(img: Image.Image) -> np.ndarray: ratio = img.height / img.width bucket = min(BUCKETS, key=lambda b: abs(math.log(ratio / b))) h, w = bucket_shape(bucket, INPUT_SIZE) x = np.asarray(img.resize((w, h), Image.BILINEAR), dtype=np.float32) / 255.0 return ((x - MEAN) / STD).transpose(2, 0, 1)[None] def where(cx: float, cy: float) -> str: v = "top" if cy < -0.33 else "bottom" if cy > 0.33 else "middle" h = "left" if cx < -0.33 else "right" if cx > 0.33 else "center" return "center" if (v, h) == ("middle", "center") else f"{v} {h}" def box(img: Image.Image, center, window) -> tuple[float, float, float, float]: w, h = img.size cx, cy = (center[0] + 1) / 2 * w, (center[1] + 1) / 2 * h return cx - w * window / 2, cy - h * window / 2, cx + w * window / 2, cy + h * window / 2 def font(size: int): for name in ("DejaVuSans.ttf", "arial.ttf", "Arial.ttf"): try: return ImageFont.truetype(name, size) except OSError: continue return ImageFont.load_default() def panel(img: Image.Image, score: float, threshold: float, centers, windows, reason: str, looks: float) -> Image.Image: """The screenshot with its zoom boxes + the two zoomed regions + the verdict.""" view = img.copy() view.thumbnail((640, 480), Image.BICUBIC) zooms = [img.crop(tuple(map(int, box(img, c, wnd)))) for c, wnd in zip(centers, windows)] draw = ImageDraw.Draw(view) for k, (c, wnd) in enumerate(zip(centers, windows)): x0, y0, x1, y1 = box(view, c, wnd) draw.rectangle([x0, y0, x1, y1], outline=COLORS[k], width=4) tx, ty = (x0 + 4, y0 + 4) if k == 0 else (x1 - 22, y1 - 24) draw.rectangle([tx, ty, tx + 18, ty + 20], fill=COLORS[k]) draw.text((tx + 5, ty + 1), str(k + 1), fill=(0, 0, 0), font=font(16)) zh = view.height // 2 - 6 tiles = [] for k, z in enumerate(zooms): z = z.resize((max(1, int(z.width * zh / max(1, z.height))), zh), Image.BICUBIC) tile = Image.new("RGB", (z.width + 8, z.height + 8), COLORS[k]) tile.paste(z, (4, 4)) tiles.append(tile) right_w = max(t.width for t in tiles) + 20 scam = score >= threshold canvas = Image.new("RGB", (view.width + right_w + 30, view.height + 92), (15, 17, 23)) canvas.paste(view, (10, 10)) y = 10 for t in tiles: canvas.paste(t, (view.width + 30, y)) y += t.height + 4 d = ImageDraw.Draw(canvas) verdict = f"{'SCAM' if scam else 'clean'} {score:.0%}" d.text((10, view.height + 18), verdict, fill=(255, 92, 92) if scam else (76, 209, 138), font=font(26)) zinfo = " · ".join(f"zoom {k + 1}: x{1 / wnd:.1f} {where(*c)}" for k, (c, wnd) in enumerate(zip(centers, windows))) d.text((200, view.height + 16), zinfo, fill=(232, 232, 239), font=font(15)) why = f"suspects: {reason}" if score >= 0.3 else "nothing suspicious" d.text((200, view.height + 40), f"{looks:.1f} looks counted · {why}", fill=(138, 143, 163), font=font(15)) d.text((10, view.height + 66), "red box = zoom 1, blue box = zoom 2 (the model chose where and how much to zoom)", fill=(138, 143, 163), font=font(12)) return canvas def main(): parser = argparse.ArgumentParser(description="Faucon 2 — scam screenshot detector that shows where it zooms") parser.add_argument("images", nargs="+", type=Path) parser.add_argument("--threshold", type=float, default=0.6, help="Scam threshold (default 0.6 ≈ 0.5 %% false positives on everyday images; 0.5 = more " "scams caught, for human review)") parser.add_argument("--out", type=Path, default=Path("glimpses"), help="Folder for the zoom pictures") parser.add_argument("--no-images", action="store_true", help="Only print verdicts, save no pictures") args = parser.parse_args() session = ort.InferenceSession(str(MODEL), providers=["CPUExecutionProvider"]) if not args.no_images: args.out.mkdir(parents=True, exist_ok=True) for path in args.images: try: img = load_rgb(path) logits, centers, windows, reasons, looks = session.run(None, {"image": preprocess(img)}) except Exception as error: print(f"{path}: unreadable ({error})", file=sys.stderr) continue e = np.exp(logits[0] - logits[0].max()) score = float(e[1] / e.sum()) reason = REASONS[int(reasons[0].argmax())] verdict = "SCAM" if score >= args.threshold else "clean" zooms = ", ".join(f"x{1 / w:.1f} {where(*c)}" for c, w in zip(centers[0], windows[0])) why = f" suspects: {reason}" if score >= 0.3 else "" print(f"{verdict:5s} {score:.3f} {float(looks[0]):.1f} looks zooms: {zooms}{why} {path}") if not args.no_images: panel(img, score, args.threshold, centers[0], windows[0], reason, float(looks[0])).save( args.out / f"{path.stem}_faucon.jpg", quality=90) if __name__ == "__main__": main()