mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-06-10 12:53:56 +02:00
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:
@@ -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),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user