diff --git a/notebooks/v3_check.py b/notebooks/v3_check.py new file mode 100644 index 0000000..a004167 --- /dev/null +++ b/notebooks/v3_check.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +v3_check.py — summarize one amispoof session JSON against the V3 texture-collapse +veto logic. No deps beyond the stdlib. + + python notebooks/v3_check.py # newest *.json in notebooks/data + python notebooks/v3_check.py path/to/file.json + +Prints: environment, verdict, incident breakdown, and the two V3 signals +(texture.details.texture_score, screen_replay.details.skin_score) with the +exact fractions the veto checks (texture_score < 25 ; skin_score >= 30). +""" +import glob +import json +import os +import statistics as st +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +DATA = os.path.join(HERE, "data") + +# V3 constants (mirror SessionEngine.checkTextureCollapseReplay) +TEXTURE_SPOOF_THRESHOLD = 25 +TEXTURE_LOW_FRACTION = 0.30 +COSIGNAL_SKIN_MIN = 30 + + +def pick_file(argv): + if len(argv) > 1: + return argv[1] + files = glob.glob(os.path.join(DATA, "*.json")) + if not files: + sys.exit(f"no JSON in {DATA}") + return max(files, key=os.path.getmtime) + + +def series(frame_log, *path): + out = [] + for f in frame_log: + cur = f.get("analyzer_scores") or {} + try: + for p in path: + cur = cur[p] + if isinstance(cur, (int, float)): + out.append(cur) + except Exception: + pass + return out + + +def stat_line(name, xs, extra=""): + if not xs: + print(f" {name:34s}: (none)") + return + print(f" {name:34s}: n={len(xs)} min={min(xs):.1f} med={st.median(xs):.1f} " + f"mean={sum(xs)/len(xs):.1f} max={max(xs):.1f} {extra}") + + +def main(): + path = pick_file(sys.argv) + d = json.load(open(path, encoding="utf-8")) + env = d.get("environment", {}) + v = d.get("verdict", {}) + fl = d.get("frame_log", []) + + print(f"=== {os.path.basename(path)} ===") + print(f" label={env.get('capture_label')} ambient={env.get('ambient_label')} " + f"device={env.get('replay_device')} notes={env.get('notes')}") + print(f" VERDICT: is_live={v.get('is_live')} conf={v.get('confidence')} " + f"threat={v.get('dominant_threat')} fps={d.get('fps_smoothed')}") + print(f" {v.get('session_duration_sec')}s {v.get('frames_analyzed')} frames " + f"blinks={v.get('blink_count')} quality_uncertain={v.get('quality_uncertain')}") + cs = v.get("category_scores") or {} + print(" category_scores:", {k: round(x, 3) for k, x in cs.items()}) + + inc = v.get("incidents") or [] + from collections import Counter + cats = Counter((i.get("category") if isinstance(i, dict) else i) for i in inc) + print(f" INCIDENTS ({len(inc)}):", dict(cats)) + for i in inc[:8]: + if isinstance(i, dict): + print(f" - t={i.get('timestamp')} {i.get('category')}: {i.get('description')}") + + tex = series(fl, "texture", "details", "texture_score") + skin = series(fl, "screen_replay", "details", "skin_score") + print("=== V3 SIGNALS ===") + tfrac = sum(1 for x in tex if x < TEXTURE_SPOOF_THRESHOLD) / len(tex) if tex else 0 + sfrac = sum(1 for x in skin if x >= COSIGNAL_SKIN_MIN) / len(skin) if skin else 0 + stat_line("texture.texture_score", tex, + f"<25 in {tfrac:.0%} of frames (veto needs >=30% in a 30-frame window)") + stat_line("screen_replay.skin_score", skin, + f">=30 in {sfrac:.0%} (co-signal: median-in-window >=30)") + smed = st.median(skin) if skin else 0 + print("=== READING ===") + if tex and skin: + texture_collapsing = tfrac >= TEXTURE_LOW_FRACTION + cosignal = smed >= COSIGNAL_SKIN_MIN + if texture_collapsing and cosignal: + print(" -> V3 WOULD FIRE: texture collapsed AND skin_score median >=30 (REPLAY-like)") + elif texture_collapsing and not cosignal: + print(f" -> SPARED: texture collapsed but skin_score median {smed:.1f} < 30 (LIVE-like)") + elif not texture_collapsing: + print(f" -> QUIET: texture did not broadly collapse (only {tfrac:.0%} < 25)") + + +if __name__ == "__main__": + main() diff --git a/notebooks/yolo_bench.py b/notebooks/yolo_bench.py new file mode 100644 index 0000000..3f74fa6 --- /dev/null +++ b/notebooks/yolo_bench.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +""" +yolo_bench.py — run the CVZone YOLOv8 fake/real anti-spoof model as an +independent benchmark against amispoof, on the SAME scene. + +This is the model from the CVZone / Murtaza Hassan "Anti-Spoofing" course +(models/l_version_1_300/l_version_1_300.pt — YOLOv8l, classes ["fake","real"]). +It is a RESEARCH REFERENCE only: a 2-class object detector trained on one +person's 640x480 webcam, known to overfit. We use it to cross-check amispoof's +verdict in a given lighting regime, NOT as a component. + +Unlike the course's Main.py, this logs per-frame (verdict, confidence, fps) to a +CSV so the run is comparable to an amispoof session — no eyeballing. + +Requires (pip, ~2.5 GB incl. torch): + pip install ultralytics opencv-python + +WEBCAM (live head-to-head, default — opens a window, press q to quit): + python notebooks/yolo_bench.py --cam 0 --csv notebooks/data/yolo_live.csv + + # tip: the course used cam index 1 (external cam). A laptop's built-in + # cam is usually 0. Try --cam 0 first, then --cam 1. + +IMAGE FOLDER (offline, no camera — score a directory of frames): + python notebooks/yolo_bench.py --images path/to/frames --csv out.csv + +HEADLESS webcam (no window, e.g. over SSH — logs only, --seconds to bound it): + python notebooks/yolo_bench.py --cam 0 --no-show --seconds 20 --csv out.csv +""" +import argparse +import csv +import glob +import os +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +DEFAULT_MODEL = os.path.join(HERE, "..", "models", "l_version_1_300", "l_version_1_300.pt") +CLASS_NAMES = ["fake", "real"] # index 0 = fake, 1 = real (CVZone convention) + + +def load_model(model_path): + try: + from ultralytics import YOLO + except ImportError: + sys.exit( + "ultralytics not installed. Run: pip install ultralytics opencv-python\n" + "(pulls torch, ~2.5 GB — fine on unlimited wifi)" + ) + if not os.path.exists(model_path): + sys.exit(f"model not found: {model_path}") + return YOLO(model_path) + + +IMGSZ = 640 # set by --imgsz; lower = faster on CPU (the only real lever here) + + +def best_detection(result, conf_thresh): + """Return (label, conf) of the highest-confidence box above threshold, or (None, 0).""" + best_label, best_conf = None, 0.0 + for box in result.boxes: + conf = float(box.conf[0]) + if conf > conf_thresh and conf > best_conf: + cls = int(box.cls[0]) + best_label = CLASS_NAMES[cls] if 0 <= cls < len(CLASS_NAMES) else str(cls) + best_conf = conf + return best_label, best_conf + + +def run_webcam(model, cam, conf_thresh, show, seconds, writer, summary): + import cv2 + + cap = cv2.VideoCapture(cam) + cap.set(3, 640) + cap.set(4, 480) + if not cap.isOpened(): + sys.exit(f"could not open camera index {cam} — try --cam 0 or --cam 1") + + prev = time.time() + start = prev + frame_idx = 0 + try: + while True: + ok, img = cap.read() + if not ok: + break + results = model(img, stream=True, verbose=False, imgsz=IMGSZ) + label, conf = None, 0.0 + for r in results: + label, conf = best_detection(r, conf_thresh) + if show: + _draw(cv2, img, r, conf_thresh) + + now = time.time() + fps = 1.0 / (now - prev) if now != prev else 0.0 + prev = now + _record(writer, summary, frame_idx, label, conf, fps) + frame_idx += 1 + + if show: + cv2.putText(img, f"{(label or 'none').upper()} {int(conf*100)}% {fps:.1f}fps", + (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) + cv2.imshow("yolo_bench (q to quit)", img) + if cv2.waitKey(1) & 0xFF == ord("q"): + break + if seconds and (now - start) >= seconds: + break + finally: + cap.release() + if show: + cv2.destroyAllWindows() + + +def _draw(cv2, img, r, conf_thresh): + for box in r.boxes: + conf = float(box.conf[0]) + if conf <= conf_thresh: + continue + cls = int(box.cls[0]) + label = CLASS_NAMES[cls] if 0 <= cls < len(CLASS_NAMES) else str(cls) + color = (0, 255, 0) if label == "real" else (0, 0, 255) + x1, y1, x2, y2 = (int(v) for v in box.xyxy[0]) + cv2.rectangle(img, (x1, y1), (x2, y2), color, 3) + + +def run_images(model, folder, conf_thresh, writer, summary): + paths = [] + for ext in ("*.jpg", "*.jpeg", "*.png", "*.bmp"): + paths.extend(glob.glob(os.path.join(folder, ext))) + paths.sort() + if not paths: + sys.exit(f"no images found in {folder}") + for i, p in enumerate(paths): + results = model(p, verbose=False, imgsz=IMGSZ) + label, conf = None, 0.0 + for r in results: + label, conf = best_detection(r, conf_thresh) + _record(writer, summary, i, label, conf, 0.0, name=os.path.basename(p)) + print(f"{os.path.basename(p):40s} -> {(label or 'none'):5s} {conf:.2f}") + + +def _record(writer, summary, idx, label, conf, fps, name=""): + if writer: + writer.writerow([idx, name, label or "none", f"{conf:.4f}", f"{fps:.2f}"]) + summary["frames"] += 1 + if label == "fake": + summary["fake"] += 1 + elif label == "real": + summary["real"] += 1 + else: + summary["none"] += 1 + summary["fps_sum"] += fps + + +def main(): + ap = argparse.ArgumentParser(description="CVZone YOLOv8 fake/real benchmark for amispoof cross-check") + ap.add_argument("--model", default=DEFAULT_MODEL, help="path to l_version_1_300.pt") + ap.add_argument("--cam", type=int, default=0, help="webcam index (0 built-in, 1 external)") + ap.add_argument("--images", help="run on a folder of images instead of the webcam") + ap.add_argument("--conf", type=float, default=0.6, help="confidence threshold") + ap.add_argument("--imgsz", type=int, default=640, help="inference size (320/416 = faster on CPU)") + ap.add_argument("--no-show", dest="show", action="store_false", help="headless (no window)") + ap.add_argument("--seconds", type=float, default=0, help="auto-stop webcam after N seconds (0 = until q)") + ap.add_argument("--csv", help="write per-frame log here") + args = ap.parse_args() + + global IMGSZ + IMGSZ = args.imgsz + model = load_model(args.model) + summary = {"frames": 0, "fake": 0, "real": 0, "none": 0, "fps_sum": 0.0} + + fh = writer = None + if args.csv: + os.makedirs(os.path.dirname(os.path.abspath(args.csv)), exist_ok=True) + fh = open(args.csv, "w", newline="") + writer = csv.writer(fh) + writer.writerow(["frame", "name", "verdict", "confidence", "fps"]) + + try: + if args.images: + run_images(model, args.images, args.conf, writer, summary) + else: + run_webcam(model, args.cam, args.conf, args.show, args.seconds, writer, summary) + finally: + if fh: + fh.close() + + n = max(1, summary["frames"]) + print("\n=== yolo_bench summary ===") + print(f"frames : {summary['frames']}") + print(f"fake : {summary['fake']:5d} ({100*summary['fake']/n:.1f}%)") + print(f"real : {summary['real']:5d} ({100*summary['real']/n:.1f}%)") + print(f"no-face : {summary['none']:5d} ({100*summary['none']/n:.1f}%)") + if not args.images: + print(f"avg fps : {summary['fps_sum']/n:.1f}") + if args.csv: + print(f"csv : {args.csv}") + verdict = "fake" if summary["fake"] >= summary["real"] else "real" + print(f"MAJORITY : {verdict.upper()}") + + +if __name__ == "__main__": + main() diff --git a/web/__tests__/SessionEngine.test.ts b/web/__tests__/SessionEngine.test.ts index 2d447c5..f9ff990 100644 --- a/web/__tests__/SessionEngine.test.ts +++ b/web/__tests__/SessionEngine.test.ts @@ -104,6 +104,46 @@ function buildFrame(opts: FrameOpts): FrameAnalysis { }; } +/** + * A spoof frame (pReal=0.05) where `dominant` is the strict-max non-real + * category, so checkSpoofIncident categorizes the incident as `dominant`. + */ +function buildSpoofFrame( + frameId: number, + dominant: SpoofCategory, + blinks: number, +): FrameAnalysis { + const face_id = 0; + const face: FaceROI = { + face_id, + bbox: new BBox(100, 100, 540, 540), + confidence: 0.99, + landmarks: makeUniformLandmarks(), + }; + const analyzers: Record = { + blink: makeAnalyzerResult("blink", 80, { blinks }), + minifasnet: makeAnalyzerResult("minifasnet", 5, {}), + }; + const probs: Record = { + [SpoofCategory.REAL]: 0.05, + [SpoofCategory.STATIC_IMAGE]: 0.05, + [SpoofCategory.VIDEO_REPLAY]: 0.05, + [SpoofCategory.MASK_3D]: 0.05, + [SpoofCategory.HEAVY_MAKEUP]: 0.05, + [SpoofCategory.AR_FILTER]: 0.05, + [SpoofCategory.DEEPFAKE_INJECT]: 0.05, + }; + probs[dominant] = 0.65; + const cls = classificationFromProbabilities(face_id, probs, analyzers); + return { + frame_id: frameId, + faces: [face], + classifications: { [face_id]: cls }, + frame_signals: {}, + total_ms: 5, + }; +} + /** A frame where face detection found nothing (camera dark / occluded). */ function buildMissingFrame(frameId: number): FrameAnalysis { return { @@ -248,6 +288,58 @@ describe("SessionEngine", () => { expect(v.is_live).toBe(false); }); + it("dominant_threat follows the incident category, not the fooled fusion", () => { + // 2026-06-01 bug: a phone replay read real≈0.82 in the fuser with the + // residual ranking static_image > video_replay, yet 30 texture-collapse + // VIDEO_REPLAY incidents fired. The threat label must follow the incidents + // that actually flip the verdict, not the fusion's fooled residual. + const engine = new SessionEngine(); + engine.start(); + for (let i = 1; i <= 200; i++) { + tick(33); + const isSpike = i === 60 || i === 130 || i === 195; + engine.ingest( + isSpike + ? buildSpoofFrame(i, SpoofCategory.VIDEO_REPLAY, Math.floor(i / 30)) + : buildFrame({ + frameId: i, + pReal: 0.9, + blinks: Math.floor(i / 30), + miniFasNetScore: 95, + drift: (i % 10) * 0.5, + }), + ); + } + const v = engine.getVerdict(); + expect(v.is_live).toBe(false); + expect(v.incidents.length).toBeGreaterThanOrEqual(3); + expect(v.dominant_threat).toBe(SpoofCategory.VIDEO_REPLAY); + }); + + it("static_image threat is re-labelled when the subject blinks (a photo can't blink)", () => { + // Even when the fusion's top spoof category ties on static_image, observed + // blinks mean the presentation MOVES → a dynamic spoof, never a still photo. + const engine = new SessionEngine(); + engine.start(); + for (let i = 1; i <= 200; i++) { + tick(33); + const isSpike = i === 60 || i === 130 || i === 195; + engine.ingest( + buildFrame({ + frameId: i, + pReal: isSpike ? 0.05 : 0.9, + blinks: Math.floor(i / 30), + miniFasNetScore: isSpike ? 5 : 95, + drift: (i % 10) * 0.5, + }), + ); + } + const v = engine.getVerdict(); + expect(v.is_live).toBe(false); + expect(v.blink_count).toBeGreaterThanOrEqual(1); + expect(v.dominant_threat).not.toBe(SpoofCategory.STATIC_IMAGE); + }); + it("requireProverLive is opt-in: default false leaves verdict to fusion only", () => { // With requireProverLive default (false), even a never-proved prover // (score 0) shouldn't block a clean live session. diff --git a/web/amispoof/.htaccess b/web/amispoof/.htaccess index 7d57cac..3d36a40 100644 --- a/web/amispoof/.htaccess +++ b/web/amispoof/.htaccess @@ -12,6 +12,18 @@ # indexable per the SEO update (2026-05-16). The page-level meta robots # + JSON-LD SoftwareApplication + Open Graph tags are in index.html. Header always set Permissions-Policy 'camera=(self), microphone=()' + + # Cross-origin isolation → SharedArrayBuffer → multi-threaded ORT WASM. + # Without these, the live domain runs single-threaded (the local server.mjs + # already sets them, which is why local fps was higher). Verified safe: + # every cross-origin subresource is jsdelivr, which replies with both + # `access-control-allow-origin: *` and `cross-origin-resource-policy: + # cross-origin`, so COEP: require-corp does not block the ORT/MediaPipe + # bundles. (The old app.fivucsas.com/launcher.js — the one non-compliant + # resource — was removed from index.html in the same change.) + # ROLLBACK: delete these two lines; Hostinger Apache picks it up instantly. + Header always set Cross-Origin-Opener-Policy 'same-origin' + Header always set Cross-Origin-Embedder-Policy 'require-corp' diff --git a/web/amispoof/app.js b/web/amispoof/app.js index 6e2cbe4..c51fbbc 100644 --- a/web/amispoof/app.js +++ b/web/amispoof/app.js @@ -18,12 +18,12 @@ import { FlashTemporalAnalyzer, ReadinessGate, DEFAULT_ANALYZER_WEIGHTS, -} from "./lib/spoof-detector.js?v=2026-06-01-prod-cdn-restore"; +} from "./lib/spoof-detector.js?v=2026-06-01-threat-coop"; // Version handshake — checked by the inline script in index.html. // If the user is running a stale cached app.js (no AMISPOOF_VERSION), // the HTML triggers a one-shot reload after 4 s. -window.AMISPOOF_VERSION = "2026-06-01-prod-cdn-restore"; +window.AMISPOOF_VERSION = "2026-06-01-threat-coop"; // SessionEngine.getVerdict() returns a confidence in [0, 0.88] when the // LivenessProver is wired (structural ceiling — see SessionEngine.ts diff --git a/web/amispoof/index.html b/web/amispoof/index.html index 6b348fb..983cbe6 100644 --- a/web/amispoof/index.html +++ b/web/amispoof/index.html @@ -1123,7 +1123,7 @@

// older cached copy (no AMISPOOF_VERSION export at all), force a // one-shot hard reload so the user doesn't get stuck on a broken // bundle. Guarded by sessionStorage to avoid infinite loops. - window.AMISPOOF_HTML_VERSION = "2026-06-01-prod-cdn-restore"; + window.AMISPOOF_HTML_VERSION = "2026-06-01-threat-coop"; setTimeout(function () { if ( window.AMISPOOF_VERSION !== window.AMISPOOF_HTML_VERSION && @@ -1134,15 +1134,6 @@

} }, 4000); - - - + diff --git a/web/src/application/SessionEngine.ts b/web/src/application/SessionEngine.ts index 93a6778..6d38452 100644 --- a/web/src/application/SessionEngine.ts +++ b/web/src/application/SessionEngine.ts @@ -752,15 +752,67 @@ export class SessionEngine { } } - let dominantThreat: SpoofCategory | null = null; + // --- Dominant threat TYPE (not the live/spoof verdict — that's below) --- + // The per-frame fusion is easily fooled on screen replays: a phone replay + // can read real=0.82 in the fuser, with the residual split so that + // static_image (0.08) edges out video_replay (0.06) — yet that same + // session trips 30 texture-collapse VIDEO_REPLAY incidents. Reporting + // "static_image" there is wrong and was user-flagged (2026-06-01): the + // attack visibly MOVES (12 blinks, landmark motion), so it cannot be a + // still photo. Two corrections: + // + // 1. Incident evidence names the threat. The analyzers that actually + // flip the verdict (incident_override) are a stronger threat-type + // signal than the fusion's fooled residual. When incidents exist, + // their most-frequent non-real category wins. + // 2. Physical sanity on static_image. A STATIC_IMAGE presents a frozen + // photo and cannot blink. If we still land on static_image but >=1 + // blink was observed, re-label to the strongest *dynamic* spoof + // category (video_replay / deepfake / mask) — motion rules out a still. + let threatFromFusion: SpoofCategory | null = null; let dominantP = -Infinity; for (const cat of ALL_SPOOF_CATEGORIES) { if (cat === SpoofCategory.REAL) continue; const p = categoryScores[cat]; if (p > dominantP) { dominantP = p; - dominantThreat = cat; + threatFromFusion = cat; + } + } + + let threatFromIncidents: SpoofCategory | null = null; + if (this.incidents.length > 0) { + const counts = new Map(); + for (const inc of this.incidents) { + if (inc.category === SpoofCategory.REAL) continue; + counts.set(inc.category, (counts.get(inc.category) ?? 0) + 1); + } + let best = 0; + for (const [cat, n] of counts) { + if (n > best) { + best = n; + threatFromIncidents = cat; + } + } + } + + let dominantThreat: SpoofCategory | null = + threatFromIncidents ?? threatFromFusion; + + if (dominantThreat === SpoofCategory.STATIC_IMAGE && this.lastBlinkCount >= 1) { + let dynBest = -Infinity; + let dynCat: SpoofCategory | null = null; + for (const cat of ALL_SPOOF_CATEGORIES) { + if (cat === SpoofCategory.REAL || cat === SpoofCategory.STATIC_IMAGE) { + continue; + } + const p = categoryScores[cat]; + if (p > dynBest) { + dynBest = p; + dynCat = cat; + } } + if (dynCat) dominantThreat = dynCat; } // dataConfidence ramps from 0 → 1 as we accumulate evidence. Was