Faucon / predict.py
RDTvlokip's picture
Faucon 2: two learned zooms with memory, zoom level, scam-type head, learned stopping; Faucon 1 moved to Faucon-1/
7cddc91 verified
Raw History Blame Contribute Delete
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()