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:
Victor Kuznetsov
2026-05-31 15:07:19 -07:00
parent 33bd401e2a
commit 2d49c3cb58
5 changed files with 66 additions and 7 deletions
+3 -4
View File
@@ -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,