diff --git a/README.md b/README.md index 19ca6bd..db50f8a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ image → encode to latent space (VAE) at native resolution - Native resolution avoids shrinking the input to 1024 px first; that down-then-up round-trip was the main quality loss (issue #10). Use `--max-resolution N` only to cap GPU/MPS memory on very large inputs. > **If SynthID still verifies after the run, raise `--strength`.** The default `0.10` is the value that clears the watermark today, but SynthID is a moving target: both Google and OpenAI tighten it over time, and a larger image carries a stronger watermark (a 1600x1600 image needs more than a 400x400 one). There is no single permanent number, and there is no local SynthID detector, so the tool cannot self-check and auto-tune. The rule is simple: if the verifier still reads SynthID, step the strength up — try `--strength 0.12`, then `0.15`. Higher strength changes more detail and text, so use the lowest value that comes back clean on the oracle ([openai.com/research/verify](https://openai.com/research/verify/) or the Gemini app's "Verify with SynthID"). +> +> **For images that survive even high strength, try `--pipeline ctrlregen`.** The default SDXL pass is a light partial-noise img2img; some images (often dense, high-texture composites) keep the watermark through it at any strength. CtrlRegen regenerates the image from near-clean Gaussian noise while holding structure with a Canny ControlNet and a DINOv2 adapter — the approach the research ([CtrlRegen, ICLR 2025](https://github.com/yepengliu/CtrlRegen)) identifies as the lever against watermarks that partial-noise regeneration cannot remove. It defaults to clean-noise strength `1.0`, downloads extra models (~several GB), runs slower, and changes the image more, so reserve it for the stubborn cases. Note: heavier regeneration is also *more* detectable as a removal-processed image (see the forensic note below). SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output. diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index 4ae6531..6d5d0aa 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -19,8 +19,6 @@ import warnings from pathlib import Path from typing import TYPE_CHECKING -from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH - if TYPE_CHECKING: from collections.abc import Callable @@ -135,7 +133,8 @@ class InvisibleEngine: Args: image_path: Path to the watermarked image. output_path: Output path (None = overwrite source). - strength: Denoising strength (0.0-1.0). None -> DEFAULT_STRENGTH (0.10). + strength: Denoising strength (0.0-1.0). None -> profile default + (0.10 for SDXL, 1.0 clean-noise for ctrlregen). steps: Number of denoising steps. guidance_scale: Classifier-free guidance scale. seed: Random seed for reproducibility. @@ -281,7 +280,7 @@ class InvisibleEngine: self, input_dir: Path, output_dir: Path, - strength: float = DEFAULT_STRENGTH, + strength: float | None = None, steps: int = 50, ) -> list[Path]: """Remove invisible watermarks from all images in a directory.""" diff --git a/src/remove_ai_watermarks/noai/watermark_profiles.py b/src/remove_ai_watermarks/noai/watermark_profiles.py index 19c3f14..759e6c4 100644 --- a/src/remove_ai_watermarks/noai/watermark_profiles.py +++ b/src/remove_ai_watermarks/noai/watermark_profiles.py @@ -21,6 +21,33 @@ CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen" # knob is this default plus the per-call `--strength` override.) DEFAULT_STRENGTH = 0.10 +# CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise, +# NOT by the light-touch partial-noise img2img the SDXL default uses. The research +# is explicit (CtrlRegen, ICLR 2025, arXiv:2410.05470): partial-noise regeneration +# "struggles with high-perturbation watermarks" because a small noise step "retains" +# watermark information that diffuses back into the output; the fix is to start from +# clean noise. With the StableDiffusionControlNetImg2ImgPipeline that maps to a high +# strength (~1.0 = full noise at the first timestep, structure held by the canny +# ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). So the ctrlregen +# profile must NOT inherit the SDXL 0.10 default -- at 0.10 it loads ControlNet + +# DINOv2-giant and then barely changes the image (a no-op for removal). Tunable via +# `--strength`; lower it to trade removal strength for fidelity (the CtrlRegen+ regime). +CTRLREGEN_DEFAULT_STRENGTH = 1.0 + + +def resolve_strength(strength: float | None, profile: str) -> float: + """Resolve the denoising strength, applying the profile-specific default when unset. + + ``None`` means "the user did not pass ``--strength``": the SDXL default profile + resolves to ``DEFAULT_STRENGTH`` (a light SynthID-tuned touch), while ``ctrlregen`` + resolves to ``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration). An explicit + value always wins. Shared by the CLI (for display) and the engine (for execution) + so the two never disagree. + """ + if strength is not None: + return strength + return CTRLREGEN_DEFAULT_STRENGTH if profile == "ctrlregen" else DEFAULT_STRENGTH + def get_model_id_for_profile(profile: str) -> str: """Map CLI model profile names to concrete Hugging Face model IDs.""" diff --git a/src/remove_ai_watermarks/noai/watermark_remover.py b/src/remove_ai_watermarks/noai/watermark_remover.py index adb3c28..546ce7f 100644 --- a/src/remove_ai_watermarks/noai/watermark_remover.py +++ b/src/remove_ai_watermarks/noai/watermark_remover.py @@ -33,6 +33,7 @@ from remove_ai_watermarks.noai.watermark_profiles import ( DEFAULT_MODEL_ID, DEFAULT_STRENGTH, detect_model_profile, + resolve_strength, ) logger = logging.getLogger(__name__) @@ -474,7 +475,7 @@ class WatermarkRemover: if output_path is None: output_path = image_path - strength = strength or self.DEFAULT_STRENGTH + strength = resolve_strength(strength, self.model_profile) if not 0.0 <= strength <= 1.0: raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}") @@ -894,12 +895,16 @@ class WatermarkRemover: def remove_watermark( image_path: Path, output_path: Path | None = None, - strength: float = DEFAULT_STRENGTH, + strength: float | None = None, model_id: str | None = None, device: str | None = None, hf_token: str | None = None, ) -> Path: - """Convenience function to remove watermark from an image.""" + """Convenience function to remove watermark from an image. + + ``strength=None`` lets the profile pick its default (0.10 for SDXL, clean-noise + 1.0 for ctrlregen); pass a value to override. + """ remover = WatermarkRemover(model_id=model_id, device=device, hf_token=hf_token) return remover.remove_watermark( image_path=image_path, diff --git a/tests/test_platform.py b/tests/test_platform.py index 3a305a2..e07961b 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -13,8 +13,11 @@ import pytest from remove_ai_watermarks.noai.progress import is_mps_error from remove_ai_watermarks.noai.utils import get_image_format, is_supported_format from remove_ai_watermarks.noai.watermark_profiles import ( + CTRLREGEN_DEFAULT_STRENGTH, + DEFAULT_STRENGTH, detect_model_profile, get_model_id_for_profile, + resolve_strength, ) from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available @@ -121,6 +124,29 @@ class TestModelProfiles: assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen" +class TestResolveStrength: + """resolve_strength applies the profile default only when strength is unset.""" + + def test_none_default_profile_uses_sdxl_default(self): + assert resolve_strength(None, "default") == DEFAULT_STRENGTH + + def test_none_ctrlregen_uses_clean_noise_default(self): + # ctrlregen must NOT inherit the light SDXL 0.10 (that makes it a no-op); + # clean-noise regeneration is the lever against robust marks. + assert resolve_strength(None, "ctrlregen") == CTRLREGEN_DEFAULT_STRENGTH + assert CTRLREGEN_DEFAULT_STRENGTH > DEFAULT_STRENGTH + + def test_explicit_value_overrides_both_profiles(self): + assert resolve_strength(0.3, "default") == 0.3 + assert resolve_strength(0.3, "ctrlregen") == 0.3 + + def test_explicit_zero_is_respected_not_treated_as_unset(self): + # 0.0 is falsy but explicit -- must not fall through to the profile default + # (the old `strength or DEFAULT` bug would have). Range validation lives in + # remove_watermark, not here. + assert resolve_strength(0.0, "ctrlregen") == 0.0 + + # ── Format utilities ────────────────────────────────────────────────