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. +
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..e9022d3
--- /dev/null
+++ b/gui/README.md
@@ -0,0 +1,60 @@
+# SynthID Cleaner (GUI)
+
+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.
+
+## Setup (one time only)
+
+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
+python3 -m venv venv
+source venv/bin/activate
+pip install -r requirements-gui.txt
+```
+
+## Using it
+
+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.
+
+Each output file keeps the original name with `_clean` added, so
+`photo.png` becomes `photo_clean.png` in the output folder.
+
+## 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
+
+- 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.
diff --git a/gui/gui.py b/gui/gui.py
new file mode 100644
index 0000000..4aedac6
--- /dev/null
+++ b/gui/gui.py
@@ -0,0 +1,313 @@
+#!/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 cv2
+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
+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"}
+
+
+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 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
+ (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("