Download predict.py from RDTvlokip/Faucon: direct link, hf CLI and curl.
- Browser
- Download file 7.96 kB
-
https://huggingface.co/RDTvlokip/Faucon/resolve/main/predict.py
- Command line
-
hf download hf://RDTvlokip/Faucon/predict.py
-
curl -L -o predict.py https://huggingface.co/RDTvlokip/Faucon/resolve/main/predict.py
7.96 kB
| #!/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() | |