mirror of
https://github.com/aloshdenny/reverse-SynthID.git
synced 2026-08-09 07:26:03 +02:00
Merge pull request #53 from drune9d/main
Add drag-and-drop GUI for the V3 bypass, gated by watermark detection
This commit is contained in:
@@ -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.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Python-3.10+-blue?style=flat-square&logo=python" alt="Python">
|
||||
<img src="https://img.shields.io/badge/License-Research-green?style=flat-square" alt="License">
|
||||
|
||||
Executable
+11
@@ -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
|
||||
@@ -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.
|
||||
+313
@@ -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("<Button-1>", lambda e: self._choose_files())
|
||||
|
||||
if HAS_DND:
|
||||
self.drop.drop_target_register(DND_FILES)
|
||||
self.drop.dnd_bind("<<Drop>>", 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.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:
|
||||
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 = []
|
||||
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"Checking {s}…"))
|
||||
try:
|
||||
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…"))
|
||||
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:
|
||||
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():
|
||||
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)
|
||||
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()
|
||||
@@ -0,0 +1,7 @@
|
||||
numpy
|
||||
scipy
|
||||
opencv-python-headless
|
||||
pillow
|
||||
tkinterdnd2
|
||||
PyWavelets
|
||||
scikit-learn
|
||||
Reference in New Issue
Block a user