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