mirror of
https://github.com/aloshdenny/reverse-SynthID.git
synced 2026-08-09 07:26:03 +02:00
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.
This commit is contained in:
+212
@@ -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("<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.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()
|
||||
Reference in New Issue
Block a user