From 9b0f8d1a26f1c17b0ebbd1abd22a4e21de58968b Mon Sep 17 00:00:00 2001 From: Aina Olaoluwa Date: Wed, 15 Jul 2026 13:53:59 +0100 Subject: [PATCH 1/4] Add drag-and-drop GUI for the V3 spectral bypass Small Tkinter app: drop images in, get SynthID-cleaned copies out, with an option to strip EXIF/XMP/IPTC metadata from the output. Runs offline on the existing V3 codebook, no PyTorch required. --- gui/Launch SynthID Cleaner.command | 11 ++ gui/README.md | 41 ++++++ gui/gui.py | 212 +++++++++++++++++++++++++++++ gui/requirements-gui.txt | 5 + 4 files changed, 269 insertions(+) create mode 100755 gui/Launch SynthID Cleaner.command create mode 100644 gui/README.md create mode 100644 gui/gui.py create mode 100644 gui/requirements-gui.txt diff --git a/gui/Launch SynthID Cleaner.command b/gui/Launch SynthID Cleaner.command new file mode 100755 index 0000000..52ac47f --- /dev/null +++ b/gui/Launch SynthID Cleaner.command @@ -0,0 +1,11 @@ +#!/bin/bash +cd "$(dirname "$0")" +if [ ! -d venv ]; then + python3 -m venv venv + source venv/bin/activate + pip install --quiet --upgrade pip + pip install --quiet -r requirements-gui.txt +else + source venv/bin/activate +fi +python gui.py diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..e9c1987 --- /dev/null +++ b/gui/README.md @@ -0,0 +1,41 @@ +# SynthID Cleaner (GUI) + +A small drag-and-drop desktop app around this repo's V3 spectral bypass +(`src/extraction/synthid_bypass.py`). Useful for e.g. removing the SynthID +watermark left behind after using generative AI to restore old photographs. + +- Drop images in, get cleaned copies out — no command line needed after setup. +- Optionally strips all EXIF/XMP/IPTC metadata from the output (including any + AI-generation provenance tags such as C2PA content credentials or IPTC + `DigitalSourceType`), by rebuilding the file from raw pixel data. +- Runs fully offline. No network calls, no telemetry. +- Uses V3 (pure signal processing — numpy/scipy/opencv only, no PyTorch + required, no GPU needed). + +## Setup + +```bash +cd gui +python3 -m venv venv +source venv/bin/activate +pip install -r requirements-gui.txt +``` + +## Run + +Double-click `Launch SynthID Cleaner.command`, or from the terminal: + +```bash +cd gui +source venv/bin/activate +python gui.py +``` + +The first double-click sets up the venv automatically if it doesn't exist yet. + +## Notes + +- Reads the codebook from `../artifacts/spectral_codebook_v3.npz` (already in + this repo) — no extra downloads. +- Subject to this repo's [LICENSE](../LICENSE): non-commercial use, with + required attribution to the original author. diff --git a/gui/gui.py b/gui/gui.py new file mode 100644 index 0000000..37f80dc --- /dev/null +++ b/gui/gui.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +SynthID Cleaner — drag-and-drop GUI around the V3 spectral bypass in this repo. + +Removes Google SynthID's invisible watermark from images (e.g. AI-restored +old photographs), and optionally strips EXIF/XMP/IPTC metadata (including any +AI-generation provenance tags such as C2PA content credentials or IPTC +DigitalSourceType). No network calls, no telemetry — everything runs locally. +""" +import os +import sys +import threading +import traceback +from pathlib import Path + +REPO_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_DIR / "src" / "extraction")) + +import numpy as np +import tkinter as tk +from tkinter import filedialog, ttk + +try: + from tkinterdnd2 import DND_FILES, TkinterDnD + HAS_DND = True +except ImportError: + HAS_DND = False + +from PIL import Image +from synthid_bypass import SynthIDBypass, SpectralCodebook + +CODEBOOK_PATH = REPO_DIR / "artifacts" / "spectral_codebook_v3.npz" +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"} + + +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 + (e.g. C2PA content credentials, IPTC DigitalSourceType, Software tag). + """ + im = Image.open(path) + arr = np.array(im) + clean = Image.fromarray(arr, mode=im.mode) + clean.save(path) + + +def parse_dnd_paths(data: str): + """Tk's dnd event data is a space-joined, brace-quoted path list.""" + paths, buf, in_brace = [], "", False + for ch in data: + if ch == "{": + in_brace = True + elif ch == "}": + in_brace = False + elif ch == " " and not in_brace: + if buf: + paths.append(buf) + buf = "" + else: + buf += ch + if buf: + paths.append(buf) + return paths + + +class App: + def __init__(self, root): + self.root = root + root.title("SynthID Cleaner") + root.geometry("560x480") + root.minsize(480, 400) + + self.strength = tk.StringVar(value="aggressive") + self.strip_meta = tk.BooleanVar(value=True) + self.out_dir = tk.StringVar(value=str(Path.home() / "Desktop" / "synthid-cleaned")) + self.status = tk.StringVar(value="Loading codebook…") + self.queue = [] + + self._build_ui() + self.root.after(100, self._load_codebook) + + def _build_ui(self): + pad = {"padx": 10, "pady": 6} + + drop_text = ( + "Drag images here\n(or click to choose files)" + if HAS_DND else + "Click to choose images" + ) + self.drop = tk.Label( + self.root, text=drop_text, relief="groove", bd=2, + font=("Helvetica", 14), fg="#444", height=8, bg="#f4f4f4", + cursor="hand2", + ) + self.drop.pack(fill="both", expand=True, **pad) + self.drop.bind("", lambda e: self._choose_files()) + + if HAS_DND: + self.drop.drop_target_register(DND_FILES) + self.drop.dnd_bind("<>", self._on_drop) + + row = tk.Frame(self.root) + row.pack(fill="x", **pad) + tk.Label(row, text="Strength:").pack(side="left") + ttk.OptionMenu( + row, self.strength, self.strength.get(), + "gentle", "moderate", "aggressive", "maximum", + ).pack(side="left", padx=8) + tk.Checkbutton( + row, text="Strip EXIF/XMP/IPTC metadata", + variable=self.strip_meta, + ).pack(side="left", padx=8) + + out_row = tk.Frame(self.root) + out_row.pack(fill="x", **pad) + tk.Label(out_row, text="Save to:").pack(side="left") + tk.Entry(out_row, textvariable=self.out_dir).pack( + side="left", fill="x", expand=True, padx=8) + tk.Button(out_row, text="Browse…", command=self._choose_out_dir).pack(side="left") + + self.progress = ttk.Progressbar(self.root, mode="determinate") + self.progress.pack(fill="x", **pad) + + tk.Label(self.root, textvariable=self.status, anchor="w", + wraplength=520, justify="left").pack(fill="x", **pad) + + if not HAS_DND: + tk.Label( + self.root, + text="(tkinterdnd2 not installed — using file picker instead of drag-and-drop)", + fg="#888", font=("Helvetica", 10), + ).pack(**pad) + + def _load_codebook(self): + def work(): + try: + self.bypass = SynthIDBypass() + self.codebook = SpectralCodebook() + self.codebook.load(str(CODEBOOK_PATH)) + self.root.after(0, lambda: self.status.set( + "Ready. Drag images in or click the box above.")) + except Exception as e: + self.root.after(0, lambda: self.status.set(f"Failed to load codebook: {e}")) + threading.Thread(target=work, daemon=True).start() + + def _choose_out_dir(self): + d = filedialog.askdirectory(initialdir=self.out_dir.get() or str(Path.home())) + if d: + self.out_dir.set(d) + + def _choose_files(self): + paths = filedialog.askopenfilenames( + title="Choose images", + filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.tif *.tiff *.webp")], + ) + if paths: + self._process(list(paths)) + + def _on_drop(self, event): + paths = [p for p in parse_dnd_paths(event.data) + if Path(p).suffix.lower() in IMAGE_EXTS] + if paths: + self._process(paths) + + def _process(self, paths): + out_dir = Path(self.out_dir.get()) + out_dir.mkdir(parents=True, exist_ok=True) + strength = self.strength.get() + do_strip_meta = self.strip_meta.get() + + self.drop.config(state="disabled") + self.progress.config(maximum=len(paths), value=0) + + def work(): + done = 0 + errors = [] + for p in paths: + src = Path(p) + dst = out_dir / f"{src.stem}_clean{src.suffix}" + self.root.after(0, lambda s=src.name: self.status.set(f"Cleaning {s}…")) + try: + self.bypass.bypass_v3_file( + str(src), str(dst), self.codebook, + strength=strength, verify=False, + ) + if do_strip_meta: + strip_metadata(str(dst)) + except Exception as e: + errors.append(f"{src.name}: {e}") + traceback.print_exc() + done += 1 + self.root.after(0, lambda d=done: self.progress.config(value=d)) + + def finish(): + msg = f"Done: {done - len(errors)}/{len(paths)} cleaned → {out_dir}" + if errors: + msg += f"\n{len(errors)} failed: " + "; ".join(errors[:3]) + self.status.set(msg) + self.drop.config(state="normal") + self.root.after(0, finish) + + threading.Thread(target=work, daemon=True).start() + + +def main(): + root = TkinterDnD.Tk() if HAS_DND else tk.Tk() + App(root) + root.mainloop() + + +if __name__ == "__main__": + main() diff --git a/gui/requirements-gui.txt b/gui/requirements-gui.txt new file mode 100644 index 0000000..7e5713f --- /dev/null +++ b/gui/requirements-gui.txt @@ -0,0 +1,5 @@ +numpy +scipy +opencv-python-headless +pillow +tkinterdnd2 From adf7ead01b31af5c2cabe205f6bcdd4abc5f2d98 Mon Sep 17 00:00:00 2001 From: Aina Olaoluwa Date: Wed, 15 Jul 2026 14:03:56 +0100 Subject: [PATCH 2/4] Gate spectral bypass on watermark detection Blindly subtracting the codebook's carrier pattern from an image that never had the watermark would imprint one instead of removing it. Now each image is run through RobustSynthIDExtractor first, and the V3 bypass only runs when a watermark is actually detected; otherwise the image passes through untouched (still eligible for metadata stripping). --- gui/README.md | 14 ++++++++++---- gui/gui.py | 41 ++++++++++++++++++++++++++++++++++------ gui/requirements-gui.txt | 2 ++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/gui/README.md b/gui/README.md index e9c1987..998410c 100644 --- a/gui/README.md +++ b/gui/README.md @@ -5,12 +5,17 @@ A small drag-and-drop desktop app around this repo's V3 spectral bypass watermark left behind after using generative AI to restore old photographs. - Drop images in, get cleaned copies out — no command line needed after setup. +- Runs `RobustSynthIDExtractor` on each image first. The spectral bypass only + runs if a watermark is actually detected; otherwise the image is passed + through untouched. (Blindly subtracting the codebook's carrier pattern from + an image that never had it would imprint a watermark-shaped artifact + instead of removing one — this is why detection gates the bypass.) - Optionally strips all EXIF/XMP/IPTC metadata from the output (including any AI-generation provenance tags such as C2PA content credentials or IPTC `DigitalSourceType`), by rebuilding the file from raw pixel data. - Runs fully offline. No network calls, no telemetry. -- Uses V3 (pure signal processing — numpy/scipy/opencv only, no PyTorch - required, no GPU needed). +- Uses V3 (pure signal processing — numpy/scipy/opencv/PyWavelets/ + scikit-learn only, no PyTorch required, no GPU needed). ## Setup @@ -35,7 +40,8 @@ The first double-click sets up the venv automatically if it doesn't exist yet. ## Notes -- Reads the codebook from `../artifacts/spectral_codebook_v3.npz` (already in - this repo) — no extra downloads. +- Reads the bypass codebook from `../artifacts/spectral_codebook_v3.npz` and + the detector codebook from `../artifacts/codebook/robust_codebook.pkl` + (both already in this repo) — no extra downloads. - Subject to this repo's [LICENSE](../LICENSE): non-commercial use, with required attribution to the original author. diff --git a/gui/gui.py b/gui/gui.py index 37f80dc..d382fe1 100644 --- a/gui/gui.py +++ b/gui/gui.py @@ -28,11 +28,20 @@ except ImportError: from PIL import Image from synthid_bypass import SynthIDBypass, SpectralCodebook +from robust_extractor import RobustSynthIDExtractor CODEBOOK_PATH = REPO_DIR / "artifacts" / "spectral_codebook_v3.npz" +DETECTOR_CODEBOOK_PATH = REPO_DIR / "artifacts" / "codebook" / "robust_codebook.pkl" IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"} +def copy_pixels(src_path: str, dst_path: str): + """Re-save the image unchanged (no spectral subtraction applied).""" + im = Image.open(src_path) + arr = np.array(im) + Image.fromarray(arr, mode=im.mode).save(dst_path) + + 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 @@ -137,6 +146,8 @@ class App: self.bypass = SynthIDBypass() self.codebook = SpectralCodebook() self.codebook.load(str(CODEBOOK_PATH)) + self.detector = RobustSynthIDExtractor( + codebook_path=str(DETECTOR_CODEBOOK_PATH)) self.root.after(0, lambda: self.status.set( "Ready. Drag images in or click the box above.")) except Exception as e: @@ -174,15 +185,29 @@ class App: def work(): done = 0 errors = [] + skipped = [] for p in paths: src = Path(p) dst = out_dir / f"{src.stem}_clean{src.suffix}" - self.root.after(0, lambda s=src.name: self.status.set(f"Cleaning {s}…")) + self.root.after(0, lambda s=src.name: self.status.set(f"Checking {s}…")) try: - self.bypass.bypass_v3_file( - str(src), str(dst), self.codebook, - strength=strength, verify=False, - ) + det = self.detector.detect(str(src)) + 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…")) + self.bypass.bypass_v3_file( + str(src), str(dst), self.codebook, + strength=strength, verify=False, + ) + skipped.append(False) + else: + # No watermark detected: subtracting the codebook's carrier + # pattern anyway would imprint it onto a clean image instead + # of removing one, so just pass the image through untouched. + self.root.after(0, lambda s=src.name: + self.status.set(f"No watermark in {s} — left untouched")) + copy_pixels(str(src), str(dst)) + skipped.append(True) if do_strip_meta: strip_metadata(str(dst)) except Exception as e: @@ -192,7 +217,11 @@ class App: self.root.after(0, lambda d=done: self.progress.config(value=d)) def finish(): - msg = f"Done: {done - len(errors)}/{len(paths)} cleaned → {out_dir}" + n_ok = done - len(errors) + n_skipped = sum(skipped) + msg = f"Done: {n_ok}/{len(paths)} processed → {out_dir}" + if n_skipped: + msg += f"\n({n_skipped} had no watermark and were left untouched)" if errors: msg += f"\n{len(errors)} failed: " + "; ".join(errors[:3]) self.status.set(msg) diff --git a/gui/requirements-gui.txt b/gui/requirements-gui.txt index 7e5713f..5136518 100644 --- a/gui/requirements-gui.txt +++ b/gui/requirements-gui.txt @@ -3,3 +3,5 @@ scipy opencv-python-headless pillow tkinterdnd2 +PyWavelets +scikit-learn From 7bafd3e7d76183fcd92439e27662849148030cc4 Mon Sep 17 00:00:00 2001 From: Aina Olaoluwa Date: Wed, 15 Jul 2026 14:20:28 +0100 Subject: [PATCH 3/4] Expand GUI usage docs, link to it from the main README --- README.md | 2 ++ gui/README.md | 69 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 398e5b7..47a15f3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Visit us on [PitchHut](https://www.pitchhut.com/project/reverse-synthid-engineering) +> This fork adds a drag and drop desktop app for the V3 bypass, no command line needed after setup. See [gui/README.md](gui/README.md) for setup and usage. +

Python License diff --git a/gui/README.md b/gui/README.md index 998410c..e9022d3 100644 --- a/gui/README.md +++ b/gui/README.md @@ -1,23 +1,17 @@ # SynthID Cleaner (GUI) -A small drag-and-drop desktop app around this repo's V3 spectral bypass -(`src/extraction/synthid_bypass.py`). Useful for e.g. removing the SynthID -watermark left behind after using generative AI to restore old photographs. +A drag and drop desktop app built on this repo's V3 spectral bypass +(`src/extraction/synthid_bypass.py`). Made for removing the SynthID +watermark left over after using generative AI to restore old photographs, +though it works on any Gemini generated image. -- Drop images in, get cleaned copies out — no command line needed after setup. -- Runs `RobustSynthIDExtractor` on each image first. The spectral bypass only - runs if a watermark is actually detected; otherwise the image is passed - through untouched. (Blindly subtracting the codebook's carrier pattern from - an image that never had it would imprint a watermark-shaped artifact - instead of removing one — this is why detection gates the bypass.) -- Optionally strips all EXIF/XMP/IPTC metadata from the output (including any - AI-generation provenance tags such as C2PA content credentials or IPTC - `DigitalSourceType`), by rebuilding the file from raw pixel data. -- Runs fully offline. No network calls, no telemetry. -- Uses V3 (pure signal processing — numpy/scipy/opencv/PyWavelets/ - scikit-learn only, no PyTorch required, no GPU needed). +## Setup (one time only) -## Setup +Double click `Launch SynthID Cleaner.command`. On first run it creates a +virtual environment and installs everything it needs automatically. This +takes a minute or two depending on your connection. + +If you would rather do it by hand from the terminal: ```bash cd gui @@ -26,22 +20,41 @@ source venv/bin/activate pip install -r requirements-gui.txt ``` -## Run +## Using it -Double-click `Launch SynthID Cleaner.command`, or from the terminal: +1. Double click `Launch SynthID Cleaner.command`. A small window opens. +2. Drag your images into the box, or click the box to pick files from a + dialog instead. +3. Pick a strength from the dropdown: gentle, moderate, aggressive, or + maximum. Aggressive is the default and works well for most photos. +4. Leave "Strip EXIF/XMP/IPTC metadata" checked if you also want camera and + software tags, plus any AI provenance metadata, removed from the output + files. +5. Set the output folder in the "Save to" field. It defaults to a + synthid-cleaned folder on your Desktop. +6. Watch the status line at the bottom while it works. For each image it + reports whether a watermark was found and removed, or whether the image + was already clean and left untouched. -```bash -cd gui -source venv/bin/activate -python gui.py -``` +Each output file keeps the original name with `_clean` added, so +`photo.png` becomes `photo_clean.png` in the output folder. -The first double-click sets up the venv automatically if it doesn't exist yet. +## What it actually does + +Every image is checked first with this repo's `RobustSynthIDExtractor`. +Only images where a watermark is actually detected go through the spectral +bypass. Images with no detectable watermark are copied through unchanged +instead, so the tool cannot accidentally stamp a watermark shaped pattern +onto a photo that never had one. + +Everything runs locally: no network calls, no telemetry. It uses the V3 +pipeline only (numpy, scipy, opencv, PyWavelets, scikit-learn), so no +PyTorch install or GPU is required. ## Notes -- Reads the bypass codebook from `../artifacts/spectral_codebook_v3.npz` and - the detector codebook from `../artifacts/codebook/robust_codebook.pkl` - (both already in this repo) — no extra downloads. -- Subject to this repo's [LICENSE](../LICENSE): non-commercial use, with +- Codebooks are read straight from this repo: `../artifacts/spectral_codebook_v3.npz` + for the bypass, `../artifacts/codebook/robust_codebook.pkl` for detection. + Nothing extra to download. +- Subject to this repo's [LICENSE](../LICENSE): non commercial use, with required attribution to the original author. From 87f4b84e47fe10280706c1573ca2ee4d770bb53f Mon Sep 17 00:00:00 2001 From: Aina Olaoluwa Date: Wed, 15 Jul 2026 18:18:12 +0100 Subject: [PATCH 4/4] 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). --- gui/gui.py | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/gui/gui.py b/gui/gui.py index d382fe1..4aedac6 100644 --- a/gui/gui.py +++ b/gui/gui.py @@ -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…"))