mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-06-05 18:46:34 +02:00
fix(invisible): raise default strength 0.10 -> 0.30 (current SynthID threshold); flag ctrlregen experimental
An oracle-verified GPU strength study (Modal A100, native res, Gemini-app 'Verify with SynthID', n=3 fresh Gemini images, protect_text/faces off) found the current Google SynthID survives strength 0.10/0.15/0.2 and is removed only at 0.3. The previous 0.10 default (set from an n=1 result) no longer clears it -- Google hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3. Bump DEFAULT_STRENGTH to 0.30; OpenAI/ChatGPT carry C2PA not SynthID, so 0.10 is plenty there (pass --strength 0.10). Note protect_text shields the text regions SynthID hides in (use --no-protect-text for full removal on text-heavy images). The same study found ctrlregen at clean-noise strength DESTROYS real images (hallucinated micro-text in smooth regions), with no usable middle setting, so the literature's 'clean-noise is the lever' did not hold empirically. Flag ctrlregen EXPERIMENTAL in the CLI --pipeline help, README, and watermark_profiles; SDXL img2img at ~0.3 stays the shippable path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -108,15 +108,19 @@ The removal pipeline (default profile, SDXL):
|
||||
```text
|
||||
image → encode to latent space (VAE) at native resolution
|
||||
→ add controlled noise (forward diffusion)
|
||||
→ denoise (reverse diffusion, ~50 steps at strength 0.10)
|
||||
→ denoise (reverse diffusion, ~50 steps at strength 0.30)
|
||||
→ decode back to pixels (VAE)
|
||||
```
|
||||
|
||||
- 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").
|
||||
> **Default strength is `0.30`, tuned to remove the current Google SynthID.** An oracle-verified study (fresh Gemini images, "Verify with SynthID") found the current SynthID survives `0.10`/`0.15`/`0.20` and clears only at `0.30`. SynthID is a moving target (the threshold has climbed `0.05` → `0.10` → `~0.30` as Google hardens it), and there is no local SynthID detector, so the tool cannot self-check and auto-tune. If the oracle still reads SynthID, raise `--strength` further; if you care more about preserving fine text, lower it. `0.30` softens dense typography somewhat, so use the lowest value that comes back clean on the oracle.
|
||||
>
|
||||
> **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).
|
||||
> **For SynthID in text, also pass `--no-protect-text`.** Text protection preserves text regions, but SynthID hides in them, so on text-heavy images the watermark can survive inside text at `0.30` unless protection is off. This trades text crispness for full removal — a genuine tradeoff, not a bug.
|
||||
>
|
||||
> **OpenAI / ChatGPT images do not carry Google SynthID** (they use C2PA metadata, stripped by the metadata step), so `0.30` is overkill there; `--strength 0.10` preserves quality and the metadata strip is what matters.
|
||||
>
|
||||
> **`--pipeline ctrlregen` is experimental and not recommended.** On paper CtrlRegen ([ICLR 2025](https://github.com/yepengliu/CtrlRegen)) regenerates from near-clean Gaussian noise to defeat robust watermarks, but in testing on real images it **destroys content** — smooth and background regions fill with hallucinated micro-text — and it is heavy (several GB of extra models, minutes per image). It has no usable middle setting (too low removes nothing, high enough to remove wrecks the image), so the shippable path is the default SDXL pipeline at `~0.30`. CtrlRegen stays available for experimentation only.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -433,14 +433,14 @@ def cmd_erase(
|
||||
"--strength",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Denoising strength (0.0-1.0). Default: 0.10 (SDXL), 1.0 clean-noise for ctrlregen.",
|
||||
help="Denoising strength (0.0-1.0). Default: 0.30 (SDXL SynthID threshold); ctrlregen uses 1.0.",
|
||||
)
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
|
||||
)
|
||||
@click.option(
|
||||
"--device",
|
||||
@@ -680,14 +680,14 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
"--strength",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Invisible watermark denoising strength. Default: 0.10 (SDXL), 1.0 clean-noise for ctrlregen.",
|
||||
help="Invisible watermark denoising strength. Default: 0.30 (SDXL); ctrlregen uses 1.0.",
|
||||
)
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
|
||||
)
|
||||
@click.option("--model", type=str, default=None, help="HuggingFace model ID for invisible removal.")
|
||||
@click.option(
|
||||
@@ -979,7 +979,7 @@ def _process_batch_image(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
|
||||
)
|
||||
@click.option(
|
||||
"--device",
|
||||
|
||||
@@ -70,9 +70,10 @@ class InvisibleEngine:
|
||||
to break watermark patterns, and reconstructs via reverse diffusion.
|
||||
"""
|
||||
|
||||
# SDXL base is the default since May 2026: empirically defeats SynthID v2
|
||||
# at strength=0.10 / steps=50 / native ~1024px. See CLAUDE.md "Known
|
||||
# limitations" for the regression evidence ruling out SD-1.5 pipelines.
|
||||
# SDXL base is the default since May 2026; the current Google SynthID is
|
||||
# removed at strength ~0.30 / steps=50 / native res (oracle-verified, n=3 fresh
|
||||
# Gemini -- 0.10/0.15/0.2 still detected). See CLAUDE.md "Known limitations" for
|
||||
# the strength study and the regression evidence ruling out SD-1.5 pipelines.
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
|
||||
@@ -9,17 +9,21 @@ DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
# Single default denoising strength for the SDXL img2img scrub, overridable from
|
||||
# the CLI (`--strength`). Raised from the old 0.04/0.05 because that no longer
|
||||
# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30
|
||||
# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still
|
||||
# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10
|
||||
# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1
|
||||
# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending
|
||||
# (different images may need a different strength). At this higher strength small
|
||||
# text deforms more -- which is exactly why text protection (`_run_region_hires`)
|
||||
# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one
|
||||
# knob is this default plus the per-call `--strength` override.)
|
||||
DEFAULT_STRENGTH = 0.10
|
||||
# the CLI (`--strength`). Raised 0.10 -> 0.30 after an oracle-verified GPU strength
|
||||
# study (2026-05-31, Modal A100, native res, Gemini-app "Verify with SynthID", n=3
|
||||
# FRESH Gemini images + protect_text/faces OFF): the CURRENT Google SynthID survives
|
||||
# 0.10/0.15/0.2 and is only REMOVED at 0.3 (0.3 is the threshold; 0.2 still present).
|
||||
# This supersedes the earlier n=1 "0.10 removes it" note, which is now stale -- Google
|
||||
# has hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3 over time, so
|
||||
# treat this as a moving target and re-test against fresh Gemini output periodically.
|
||||
# Cost of 0.3: SSIM ~0.97 vs original (modest), but fine/dense typography softens, and
|
||||
# it is OVERKILL for non-SynthID sources (OpenAI/ChatGPT carry C2PA, not Google SynthID
|
||||
# -- 0.10 is plenty there). Two known tensions, documented but not auto-handled here:
|
||||
# (1) higher strength deforms text more (why text protection runs by default), and
|
||||
# (2) `protect_text` SHIELDS the text regions where SynthID hides, so text-region
|
||||
# SynthID can survive at 0.3 unless `--no-protect-text` is passed. (Fixed LOW/MEDIUM/
|
||||
# HIGH presets were removed -- the one knob is this default + the per-call override.)
|
||||
DEFAULT_STRENGTH = 0.30
|
||||
|
||||
# CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise,
|
||||
# NOT by the light-touch partial-noise img2img the SDXL default uses. The research
|
||||
@@ -29,9 +33,19 @@ DEFAULT_STRENGTH = 0.10
|
||||
# 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
|
||||
# profile must NOT inherit the SDXL default (`DEFAULT_STRENGTH`, a partial-noise
|
||||
# value) -- at that low strength 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).
|
||||
#
|
||||
# EXPERIMENTAL -- NOT recommended for production. The same GPU study that set the 0.3
|
||||
# SDXL threshold tested ctrlregen at its clean-noise strength and found it DESTROYS
|
||||
# images: smooth/background regions fill with hallucinated micro-text garbage, and it
|
||||
# is heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL on a large image). The pipeline
|
||||
# is effectively binary -- low strength = no-op, high strength = destroys -- with no
|
||||
# usable middle, so the literature's "clean-noise is the lever" (arXiv:2410.05470) did
|
||||
# NOT survive empirical testing on real content. SDXL img2img at ~0.3 is the shippable
|
||||
# path; ctrlregen stays opt-in and flagged experimental.
|
||||
CTRLREGEN_DEFAULT_STRENGTH = 1.0
|
||||
|
||||
|
||||
@@ -39,10 +53,10 @@ 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.
|
||||
resolves to ``DEFAULT_STRENGTH`` (the SynthID-removal default, ~0.3), 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
|
||||
|
||||
@@ -131,7 +131,7 @@ class TestResolveStrength:
|
||||
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);
|
||||
# ctrlregen must NOT inherit the SDXL DEFAULT_STRENGTH (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
|
||||
|
||||
Reference in New Issue
Block a user