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:
Victor Kuznetsov
2026-05-31 16:38:49 -07:00
parent 729f5f2ecd
commit cddbaf6413
6 changed files with 50 additions and 31 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+7 -3
View File
@@ -108,15 +108,19 @@ The removal pipeline (default profile, SDXL):
```text ```text
image → encode to latent space (VAE) at native resolution image → encode to latent space (VAE) at native resolution
→ add controlled noise (forward diffusion) → 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) → 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. - 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. 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.
+5 -5
View File
@@ -433,14 +433,14 @@ def cmd_erase(
"--strength", "--strength",
type=float, type=float,
default=None, 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("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
@click.option( @click.option(
"--pipeline", "--pipeline",
type=click.Choice(["default", "ctrlregen"]), type=click.Choice(["default", "ctrlregen"]),
default="default", default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).", help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
) )
@click.option( @click.option(
"--device", "--device",
@@ -680,14 +680,14 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
"--strength", "--strength",
type=float, type=float,
default=None, 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("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@click.option( @click.option(
"--pipeline", "--pipeline",
type=click.Choice(["default", "ctrlregen"]), type=click.Choice(["default", "ctrlregen"]),
default="default", 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("--model", type=str, default=None, help="HuggingFace model ID for invisible removal.")
@click.option( @click.option(
@@ -979,7 +979,7 @@ def _process_batch_image(
"--pipeline", "--pipeline",
type=click.Choice(["default", "ctrlregen"]), type=click.Choice(["default", "ctrlregen"]),
default="default", default="default",
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).", help="Pipeline profile (default=SDXL; ctrlregen=CtrlRegen, EXPERIMENTAL/destructive at clean-noise).",
) )
@click.option( @click.option(
"--device", "--device",
+4 -3
View File
@@ -70,9 +70,10 @@ class InvisibleEngine:
to break watermark patterns, and reconstructs via reverse diffusion. to break watermark patterns, and reconstructs via reverse diffusion.
""" """
# SDXL base is the default since May 2026: empirically defeats SynthID v2 # SDXL base is the default since May 2026; the current Google SynthID is
# at strength=0.10 / steps=50 / native ~1024px. See CLAUDE.md "Known # removed at strength ~0.30 / steps=50 / native res (oracle-verified, n=3 fresh
# limitations" for the regression evidence ruling out SD-1.5 pipelines. # 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" DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen" CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
@@ -9,17 +9,21 @@ DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen" CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
# Single default denoising strength for the SDXL img2img scrub, overridable from # 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 # the CLI (`--strength`). Raised 0.10 -> 0.30 after an oracle-verified GPU strength
# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30 # study (2026-05-31, Modal A100, native res, Gemini-app "Verify with SynthID", n=3
# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still # FRESH Gemini images + protect_text/faces OFF): the CURRENT Google SynthID survives
# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10 # 0.10/0.15/0.2 and is only REMOVED at 0.3 (0.3 is the threshold; 0.2 still present).
# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1 # This supersedes the earlier n=1 "0.10 removes it" note, which is now stale -- Google
# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending # has hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3 over time, so
# (different images may need a different strength). At this higher strength small # treat this as a moving target and re-test against fresh Gemini output periodically.
# text deforms more -- which is exactly why text protection (`_run_region_hires`) # Cost of 0.3: SSIM ~0.97 vs original (modest), but fine/dense typography softens, and
# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one # it is OVERKILL for non-SynthID sources (OpenAI/ChatGPT carry C2PA, not Google SynthID
# knob is this default plus the per-call `--strength` override.) # -- 0.10 is plenty there). Two known tensions, documented but not auto-handled here:
DEFAULT_STRENGTH = 0.10 # (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, # CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise,
# NOT by the light-touch partial-noise img2img the SDXL default uses. The research # 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 # clean noise. With the StableDiffusionControlNetImg2ImgPipeline that maps to a high
# strength (~1.0 = full noise at the first timestep, structure held by the canny # 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 # 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 + # profile must NOT inherit the SDXL default (`DEFAULT_STRENGTH`, a partial-noise
# DINOv2-giant and then barely changes the image (a no-op for removal). Tunable via # 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). # `--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 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. """Resolve the denoising strength, applying the profile-specific default when unset.
``None`` means "the user did not pass ``--strength``": the SDXL default profile ``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 ``DEFAULT_STRENGTH`` (the SynthID-removal default, ~0.3), while
resolves to ``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration). An explicit ``ctrlregen`` resolves to ``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration).
value always wins. Shared by the CLI (for display) and the engine (for execution) An explicit value always wins. Shared by the CLI (for display) and the engine (for
so the two never disagree. execution) so the two never disagree.
""" """
if strength is not None: if strength is not None:
return strength return strength
+1 -1
View File
@@ -131,7 +131,7 @@ class TestResolveStrength:
assert resolve_strength(None, "default") == DEFAULT_STRENGTH assert resolve_strength(None, "default") == DEFAULT_STRENGTH
def test_none_ctrlregen_uses_clean_noise_default(self): 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. # clean-noise regeneration is the lever against robust marks.
assert resolve_strength(None, "ctrlregen") == CTRLREGEN_DEFAULT_STRENGTH assert resolve_strength(None, "ctrlregen") == CTRLREGEN_DEFAULT_STRENGTH
assert CTRLREGEN_DEFAULT_STRENGTH > DEFAULT_STRENGTH assert CTRLREGEN_DEFAULT_STRENGTH > DEFAULT_STRENGTH