mirror of
https://github.com/aloshdenny/reverse-SynthID.git
synced 2026-08-09 15:36:02 +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)
|
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">
|
<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/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">
|
<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