mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-06-04 18:18:00 +02:00
fix(invisible): ctrlregen defaults to clean-noise strength, not the SDXL 0.10
The ctrlregen profile inherited the SDXL img2img --strength default (0.10), a near-identity pass that loaded ControlNet + DINOv2-giant and barely changed the image -- a no-op for removal. resolve_strength() now resolves an unset strength per profile: 0.10 for the SDXL default, CTRLREGEN_DEFAULT_STRENGTH (1.0, clean-noise) for ctrlregen. It checks `is None` rather than falsiness, so an explicit 0.0 is respected (the old `strength or DEFAULT` swallowed it). Research basis: CtrlRegen (ICLR 2025, arXiv:2410.05470) removes robust watermarks by regenerating from clean Gaussian noise; partial-noise img2img retains watermark info that diffuses back, so a high (clean-noise) strength is the lever, not a knob on the light SDXL pass. CLI wiring (--strength default None) lands with the cli refactor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user