mirror of
https://github.com/aloshdenny/reverse-SynthID.git
synced 2026-08-09 07:26:03 +02:00
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.
213 lines
7.1 KiB
Python
213 lines
7.1 KiB
Python
#!/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()
|