Fix false negatives on native-resolution Gemini downloads

The V3 detector's fixed 512x512 stretch-resize and the V4 detector's
naive closest-profile matching both miss images whose delivered
resolution is a clean upscale of one of the codebook's captured
profiles in the transposed orientation (e.g. a portrait 1792x2390
download that is exactly 2x a landscape 896x1195 reference profile).
Confirmed on a real Gemini download: was scoring 0.20-0.36 confidence
(false negative), now correctly detected at 0.50.

detect_watermark() now checks for a profile the image is a clean
scaled/rotated version of (aspect ratio within 0.3%) and runs V4
detection against it when found, keeping the max confidence against
the V3 baseline. An earlier version of this that tried every
orientation against every profile introduced false positives on clean
images by taking the max over too many independent noisy checks; the
strict tolerance requirement (empirically: genuine matches land at
~0.00% deviation, coincidental ones at 1.5%+) avoids that while still
catching the real case. Re-verified against the full validation set
(4 clean images, still 0.03-0.08 confidence) plus two known-watermarked
references (both still correctly flagged).
This commit is contained in:
Aina Olaoluwa
2026-07-15 18:18:12 +01:00
parent 7bafd3e7d7
commit 87f4b84e47
+73 -1
View File
@@ -16,6 +16,7 @@ from pathlib import Path
REPO_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_DIR / "src" / "extraction"))
import cv2
import numpy as np
import tkinter as tk
from tkinter import filedialog, ttk
@@ -28,10 +29,12 @@ except ImportError:
from PIL import Image
from synthid_bypass import SynthIDBypass, SpectralCodebook
from synthid_bypass_v4 import SpectralCodebookV4
from robust_extractor import RobustSynthIDExtractor
CODEBOOK_PATH = REPO_DIR / "artifacts" / "spectral_codebook_v3.npz"
DETECTOR_CODEBOOK_PATH = REPO_DIR / "artifacts" / "codebook" / "robust_codebook.pkl"
V4_CODEBOOK_PATH = REPO_DIR / "artifacts" / "spectral_codebook_v4.npz"
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}
@@ -42,6 +45,73 @@ def copy_pixels(src_path: str, dst_path: str):
Image.fromarray(arr, mode=im.mode).save(dst_path)
def find_exact_v4_profile(h: int, w: int, v4_codebook: SpectralCodebookV4,
tolerance: float = 0.003):
"""Find a V4 codebook profile that (h, w) is a clean scaled and/or
90-degree-rotated version of, based on a strict aspect-ratio match.
Real Gemini downloads that don't exactly match one of the codebook's 14
captured resolutions are typically a clean integer up-scale of one of
them (e.g. exactly 2.000x in both dimensions after accounting for a
90-degree rotation) — some delivery paths export at a higher resolution
than the one the reference set was built from. An unrelated image that
merely has a similar aspect ratio by coincidence will not hit this tight
a tolerance (empirically: genuine matches land at ~0.00% deviation,
coincidental ones at 1.5%+), so this is deliberately strict rather than
"closest available profile" — a loose match is worse than no match, since
resizing to fit a wrong profile is what produces false positives.
Returns (target_h, target_w, needs_rotation) for the tightest match
within tolerance, or None if nothing matches closely enough to trust.
"""
img_ar = h / w
seen_resolutions = set()
best = None
for (_, ph, pw) in v4_codebook.profiles:
if (ph, pw) in seen_resolutions:
continue
seen_resolutions.add((ph, pw))
profile_ar = ph / pw
# Rotating the image 90 degrees swaps its H/W, so its aspect ratio
# becomes 1/img_ar; the resize target is always the profile's own
# (ph, pw) either way, only the orientation to compare against differs.
for rotate, candidate_ar in [(False, profile_ar), (True, pw / ph)]:
diff = abs(img_ar - candidate_ar) / candidate_ar
if best is None or diff < best[0]:
best = (diff, ph, pw, rotate)
if best is not None and best[0] <= tolerance:
return best[1], best[2], best[3]
return None
def detect_watermark(path: str, detector: RobustSynthIDExtractor,
v4_codebook: SpectralCodebookV4):
"""Check for a SynthID watermark, combining the V3 detector (works at
any resolution but loses some signal to its fixed 512x512 stretch) with
the V4 detector (native-resolution, much more sensitive, but only valid
at an exact profile match) when a trustworthy V4 match exists. Returns
the more confident of the two.
"""
img_bgr = cv2.imread(path)
if img_bgr is None:
raise ValueError(f"Could not load: {path}")
img = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
h, w = img.shape[:2]
candidates = [detector.detect_array(img)]
match = find_exact_v4_profile(h, w, v4_codebook)
if match is not None:
target_h, target_w, rotate = match
oriented = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE) if rotate else img
resized = cv2.resize(oriented, (target_w, target_h), interpolation=cv2.INTER_AREA)
for model in v4_codebook.models:
candidates.append(
detector.detect_from_v4_codebook(resized, v4_codebook, model=model))
return max(candidates, key=lambda r: r.confidence)
def strip_metadata(path: str):
"""Rebuild the file from raw pixel data so no EXIF/XMP/IPTC/ICC/GPS
chunk survives — including any AI-generation provenance tags
@@ -148,6 +218,8 @@ class App:
self.codebook.load(str(CODEBOOK_PATH))
self.detector = RobustSynthIDExtractor(
codebook_path=str(DETECTOR_CODEBOOK_PATH))
self.v4_codebook = SpectralCodebookV4()
self.v4_codebook.load(str(V4_CODEBOOK_PATH))
self.root.after(0, lambda: self.status.set(
"Ready. Drag images in or click the box above."))
except Exception as e:
@@ -191,7 +263,7 @@ class App:
dst = out_dir / f"{src.stem}_clean{src.suffix}"
self.root.after(0, lambda s=src.name: self.status.set(f"Checking {s}"))
try:
det = self.detector.detect(str(src))
det = detect_watermark(str(src), self.detector, self.v4_codebook)
if det.is_watermarked:
self.root.after(0, lambda s=src.name, c=det.confidence:
self.status.set(f"Watermark found in {s} (conf {c:.2f}) — cleaning…"))