feat(invisible): vendor-adaptive default strength (OpenAI 0.10 / Google 0.15)

The default img2img strength is now chosen from the detected SynthID vendor
(C2PA issuer) instead of a single fixed 0.30: OpenAI gpt-image -> 0.10, Google
Gemini -> 0.15, unknown source -> 0.15. Explicit --strength always wins.

Basis: an oracle-verified June 2026 controlled study (clean v0.8.6, text/face
protection OFF, per-image openai.com/verify or Gemini-app verdict). OpenAI's
SynthID clears at 0.05 across 1024-1600 px (n=4, resolution-independent);
Google's is ~3x more robust and needs 0.15 on the capped-1536 path (n=4). The
dominant factor is the VENDOR, not resolution. The earlier single 0.30 default
and the "resolution dependence" lore came from contaminated tests run with the
protect-text bug ON (issue #14) -- re-running those same 1600x1600 images clean
removes SynthID at 0.05.

`vendor_for_strength(path)` reads metadata.synthid_source on the ORIGINAL input
and is threaded through cli (invisible/all/batch) -> invisible_engine ->
watermark_remover -> resolve_strength(strength, profile, vendor), so display and
execution use the same vendor (the engine sees a temp path whose C2PA the visible
pass already stripped, so detection must happen in the CLI on the pristine
source). Caveat: Google's 0.15 was validated only on --max-resolution 1536;
native 2816 Gemini was not locally measurable (OOM on Apple Silicon) and is
pending GPU validation on raiw.cc.

Docs: docs/synthid.md sections 2.2/4.4/5.2 corrected (the contaminated
resolution-dependence findings replaced with the clean oracle-verified table);
README and CLAUDE.md updated; CLI --strength help reflects the adaptive default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-06-01 19:29:47 -07:00
parent 1708857772
commit 96038f960f
8 changed files with 243 additions and 87 deletions
+19 -5
View File
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING, Any, Literal
import click
from remove_ai_watermarks import __version__, watermark_registry
from remove_ai_watermarks.noai.watermark_profiles import resolve_strength
from remove_ai_watermarks.noai.watermark_profiles import resolve_strength, vendor_for_strength
if TYPE_CHECKING:
from collections.abc import Generator
@@ -452,7 +452,8 @@ def cmd_erase(
"--strength",
type=float,
default=None,
help="Denoising strength (0.0-1.0). Default: 0.30 (SDXL SynthID threshold); ctrlregen uses 1.0.",
help="Denoising strength (0.0-1.0). Default: vendor-adaptive (OpenAI 0.10 / Google 0.15 / "
"unknown 0.15, from the C2PA issuer); ctrlregen uses 1.0.",
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
@click.option(
@@ -527,9 +528,12 @@ def cmd_invisible(
progress_callback=progress_cb,
)
# Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the
# displayed and executed strength agree on the vendor-adaptive default.
vendor = vendor_for_strength(source)
console.print(f" Input: {source.name}")
console.print(f" Pipeline: {pipeline}")
console.print(f" Strength: {resolve_strength(strength, pipeline)} Steps: {steps}")
console.print(f" Strength: {resolve_strength(strength, pipeline, vendor)} Steps: {steps}")
t0 = time.monotonic()
result_path = engine.remove_watermark(
@@ -543,6 +547,7 @@ def cmd_invisible(
protect_text=protect_text,
protect_faces=protect_faces,
max_resolution=max_resolution,
vendor=vendor,
)
elapsed = time.monotonic() - t0
@@ -689,7 +694,8 @@ 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.30 (SDXL); ctrlregen uses 1.0.",
help="Invisible watermark denoising strength. Default: vendor-adaptive "
"(OpenAI 0.10 / Google 0.15 / unknown 0.15); ctrlregen uses 1.0.",
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@click.option(
@@ -818,7 +824,11 @@ def cmd_all(
progress_callback=progress_cb,
)
console.print(f" Strength: {resolve_strength(strength, pipeline)} Steps: {steps}")
# Detect the vendor from the pristine ORIGINAL (`source`); `tmp_path` has
# already lost its C2PA to the visible-removal pass, so reading it would
# always resolve to the unknown-vendor default.
vendor = vendor_for_strength(source)
console.print(f" Strength: {resolve_strength(strength, pipeline, vendor)} Steps: {steps}")
inv_engine.remove_watermark(
image_path=tmp_path,
output_path=tmp_path,
@@ -829,6 +839,7 @@ def cmd_all(
protect_text=protect_text,
protect_faces=protect_faces,
max_resolution=max_resolution,
vendor=vendor,
)
console.print(" Invisible watermark removed")
@@ -941,6 +952,9 @@ def _process_batch_image(
seed=seed,
humanize=humanize,
max_resolution=max_resolution,
# Detect the vendor from the pristine original (`img_path`), not the
# visible-processed `out_path` whose C2PA is already gone.
vendor=vendor_for_strength(img_path),
)
if mode in ("metadata", "all"):
@@ -128,6 +128,7 @@ class InvisibleEngine:
protect_faces: bool = False,
protect_text: bool = False,
max_resolution: int = 0,
vendor: str | None = None,
) -> Path:
"""Remove invisible watermark from an image.
@@ -217,6 +218,7 @@ class InvisibleEngine:
guidance_scale=guidance_scale,
seed=seed,
protect_text=protect_text,
vendor=vendor,
)
# Optional: Face restoration & Humanizer (Phase 2 - Post-processing)
@@ -5,28 +5,41 @@ Pure configuration and lookup functions with no ML dependencies.
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
if TYPE_CHECKING:
from pathlib import Path
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 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). protect_text is RECOMMENDED ON for SynthID removal (A/B
# verified 2026-05-31): SynthID is GLOBAL, so 0.3 clears it whether protection is on or
# off, and protection salvages medium-text fidelity (~3x runtime); only the very finest
# text still softens at 0.3. (An earlier comment claimed protect_text shields the
# watermark -- that was wrong, it mistook the 0.10 strength failure for a protection
# effect.) The only true tension is the finest typography softening at this aggressive
# strength. (Fixed LOW/MEDIUM/HIGH presets were removed -- the one knob is this default
# plus the per-call override.)
DEFAULT_STRENGTH = 0.30
# Vendor-adaptive default denoising strength for the SDXL img2img scrub, overridable
# from the CLI (`--strength`). The right strength depends on which vendor's SynthID is
# present, detected from the C2PA issuer (metadata.synthid_source). Oracle-verified
# controlled study (2026-06-01, clean v0.8.6 with protect_text/faces OFF, per-image
# openai.com/verify or Gemini-app verdict; see docs/synthid.md section 2.2):
# - OpenAI gpt-image: removed at 0.05 across 1024-1600 (n=4), resolution-independent.
# OPENAI_STRENGTH 0.10 = the 0.05 floor plus a 2x margin (keeps quality high).
# - Google Gemini: removed at 0.15 on the capped-1536 path (n=4); 0.05/0.10 do NOT
# clear. GEMINI_STRENGTH 0.15. CAVEAT: 0.15 was validated only on
# `--max-resolution 1536`; native 2816 (the default path) was not locally
# measurable (OOM on Apple Silicon) and may need more -- pending GPU validation on
# the raiw.cc backend. If a native large Gemini still verifies positive at 0.15,
# raise `--strength`.
# - Unknown vendor (metadata stripped, or non-OpenAI/Google C2PA): UNKNOWN_STRENGTH
# 0.15, the safe middle that clears both vendors at the tested resolutions.
# The dominant factor is VENDOR, not resolution: Google's SynthID is ~3x more robust
# than OpenAI's. The earlier single 0.30 default (and the "resolution dependence" lore)
# came from contaminated tests run with protect_text ON -- see docs/synthid.md 2.2.
OPENAI_STRENGTH = 0.10
GEMINI_STRENGTH = 0.15
UNKNOWN_STRENGTH = 0.15
# Backwards-compatible alias: the vendor-unknown default (what a caller gets without a
# detected vendor). Kept as DEFAULT_STRENGTH for existing references.
DEFAULT_STRENGTH = UNKNOWN_STRENGTH
# Detected-vendor -> default strength. Vendor strings come from `vendor_for_strength`.
_VENDOR_STRENGTH = {"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH}
# CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise,
# NOT by the light-touch partial-noise img2img the SDXL default uses. The research
@@ -52,18 +65,45 @@ DEFAULT_STRENGTH = 0.30
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.
def resolve_strength(strength: float | None, profile: str, vendor: str | None = None) -> float:
"""Resolve the denoising strength, applying the profile/vendor default when unset.
``None`` means "the user did not pass ``--strength``": the SDXL default profile
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.
``None`` means "the user did not pass ``--strength``". ``ctrlregen`` resolves to
``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration). The SDXL default profile
resolves **vendor-adaptively**: ``vendor`` (``"openai"`` / ``"google"`` / None, from
``vendor_for_strength``) selects ``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
``UNKNOWN_STRENGTH``. An explicit value always wins (including ``0.0`` -- the check is
``is None``, not falsiness). Shared by the CLI (for display) and the engine (for
execution) so the two never disagree -- both must pass the SAME ``vendor``.
"""
if strength is not None:
return strength
return CTRLREGEN_DEFAULT_STRENGTH if profile == "ctrlregen" else DEFAULT_STRENGTH
if profile == "ctrlregen":
return CTRLREGEN_DEFAULT_STRENGTH
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
"""Detect the SynthID vendor for strength selection: ``"openai"`` / ``"google"`` / None.
Reads the C2PA SynthID proxy (``metadata.synthid_source``) on the ORIGINAL input,
so it must run before any pass that strips metadata. When both issuers appear (a
rare multi-sign anomaly) Google wins -- the more-robust watermark -> safer (higher)
strength. Returns None when metadata is stripped or the issuer is neither vendor,
which maps to ``UNKNOWN_STRENGTH``. Lazy-imports ``metadata`` to keep this module
dependency-light.
"""
try:
from remove_ai_watermarks.metadata import synthid_source
src = (synthid_source(image_path) or "").lower()
except Exception: # metadata unreadable -> treat as unknown vendor
return None
if "google" in src:
return "google"
if "openai" in src:
return "openai"
return None
def get_model_id_for_profile(profile: str) -> str:
@@ -447,13 +447,15 @@ class WatermarkRemover:
guidance_scale: float | None = None,
seed: int | None = None,
protect_text: bool = True,
vendor: str | None = None,
) -> Path:
"""Remove watermark from an image using regeneration attack.
Args:
image_path: Path to the watermarked image.
output_path: Path for the cleaned image. If None, modifies in place.
strength: Denoising strength (0.0-1.0).
strength: Denoising strength (0.0-1.0). None -> the vendor-adaptive
default (see ``vendor``).
num_inference_steps: Number of denoising steps.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
@@ -461,6 +463,11 @@ class WatermarkRemover:
Diffusion when any are found (SDXL default profile only). On by
default; the detector decides per image, and text-free inputs run
the standard pass at no extra cost.
vendor: SynthID vendor (``"openai"`` / ``"google"`` / None) used to pick the
default strength when ``strength`` is None. Detect it from the ORIGINAL
input with ``watermark_profiles.vendor_for_strength`` before processing
strips the metadata; the caller passes it down so display and execution
agree.
Returns:
Path to the cleaned image.
@@ -475,7 +482,7 @@ class WatermarkRemover:
if output_path is None:
output_path = image_path
strength = resolve_strength(strength, self.model_profile)
strength = resolve_strength(strength, self.model_profile, vendor)
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
@@ -902,12 +909,16 @@ def remove_watermark(
) -> Path:
"""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.
``strength=None`` lets the profile pick its default: vendor-adaptive for SDXL
(0.10 OpenAI / 0.15 Google / 0.15 unknown, from the C2PA SynthID proxy on the
input), clean-noise 1.0 for ctrlregen. Pass a value to override.
"""
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
remover = WatermarkRemover(model_id=model_id, device=device, hf_token=hf_token)
return remover.remove_watermark(
image_path=image_path,
output_path=output_path,
strength=strength,
vendor=vendor_for_strength(image_path),
)