From adf7ead01b31af5c2cabe205f6bcdd4abc5f2d98 Mon Sep 17 00:00:00 2001 From: Aina Olaoluwa Date: Wed, 15 Jul 2026 14:03:56 +0100 Subject: [PATCH] Gate spectral bypass on watermark detection Blindly subtracting the codebook's carrier pattern from an image that never had the watermark would imprint one instead of removing it. Now each image is run through RobustSynthIDExtractor first, and the V3 bypass only runs when a watermark is actually detected; otherwise the image passes through untouched (still eligible for metadata stripping). --- gui/README.md | 14 ++++++++++---- gui/gui.py | 41 ++++++++++++++++++++++++++++++++++------ gui/requirements-gui.txt | 2 ++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/gui/README.md b/gui/README.md index e9c1987..998410c 100644 --- a/gui/README.md +++ b/gui/README.md @@ -5,12 +5,17 @@ A small drag-and-drop desktop app around this repo's V3 spectral bypass watermark left behind after using generative AI to restore old photographs. - Drop images in, get cleaned copies out — no command line needed after setup. +- Runs `RobustSynthIDExtractor` on each image first. The spectral bypass only + runs if a watermark is actually detected; otherwise the image is passed + through untouched. (Blindly subtracting the codebook's carrier pattern from + an image that never had it would imprint a watermark-shaped artifact + instead of removing one — this is why detection gates the bypass.) - Optionally strips all EXIF/XMP/IPTC metadata from the output (including any AI-generation provenance tags such as C2PA content credentials or IPTC `DigitalSourceType`), by rebuilding the file from raw pixel data. - Runs fully offline. No network calls, no telemetry. -- Uses V3 (pure signal processing — numpy/scipy/opencv only, no PyTorch - required, no GPU needed). +- Uses V3 (pure signal processing — numpy/scipy/opencv/PyWavelets/ + scikit-learn only, no PyTorch required, no GPU needed). ## Setup @@ -35,7 +40,8 @@ The first double-click sets up the venv automatically if it doesn't exist yet. ## Notes -- Reads the codebook from `../artifacts/spectral_codebook_v3.npz` (already in - this repo) — no extra downloads. +- Reads the bypass codebook from `../artifacts/spectral_codebook_v3.npz` and + the detector codebook from `../artifacts/codebook/robust_codebook.pkl` + (both already in this repo) — no extra downloads. - 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 index 37f80dc..d382fe1 100644 --- a/gui/gui.py +++ b/gui/gui.py @@ -28,11 +28,20 @@ except ImportError: from PIL import Image from synthid_bypass import SynthIDBypass, SpectralCodebook +from robust_extractor import RobustSynthIDExtractor CODEBOOK_PATH = REPO_DIR / "artifacts" / "spectral_codebook_v3.npz" +DETECTOR_CODEBOOK_PATH = REPO_DIR / "artifacts" / "codebook" / "robust_codebook.pkl" 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 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 @@ -137,6 +146,8 @@ class App: self.bypass = SynthIDBypass() self.codebook = SpectralCodebook() self.codebook.load(str(CODEBOOK_PATH)) + self.detector = RobustSynthIDExtractor( + codebook_path=str(DETECTOR_CODEBOOK_PATH)) self.root.after(0, lambda: self.status.set( "Ready. Drag images in or click the box above.")) except Exception as e: @@ -174,15 +185,29 @@ class App: 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"Cleaning {s}…")) + self.root.after(0, lambda s=src.name: self.status.set(f"Checking {s}…")) try: - self.bypass.bypass_v3_file( - str(src), str(dst), self.codebook, - strength=strength, verify=False, - ) + det = self.detector.detect(str(src)) + 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: @@ -192,7 +217,11 @@ class App: 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}" + 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) diff --git a/gui/requirements-gui.txt b/gui/requirements-gui.txt index 7e5713f..5136518 100644 --- a/gui/requirements-gui.txt +++ b/gui/requirements-gui.txt @@ -3,3 +3,5 @@ scipy opencv-python-headless pillow tkinterdnd2 +PyWavelets +scikit-learn