mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
feat(invisible): skip the diffusion scrub when no invisible watermark is detectable (P0#5)
Regenerating pixels removes SynthID / open watermarks but degrades a real photo, so running it on a clean image is the dominant paid score-0 cause on no-watermark uploads. Gate invisible/all/batch on identify.has_invisible_target: when no invisible AI signal is locally detectable and --force is unset, skip the regeneration. Per-command semantics: - invisible: write no output, exit EXIT_NO_INVISIBLE_SIGNAL (2) - all: skip step 2 but keep visible-removed pixels + strip metadata, exit 0 - batch: skip the scrub; copy the input through in invisible mode A skip never claims the image is clean (a pixel SynthID is undetectable once its metadata proxy is gone); the message says so and routes to --force. The gate fails safe (a detector error runs the removal). has_invisible_target wraps identify(check_visible=False, check_invisible=True) and returns the new ProvenanceReport.ai_from_metadata field (the confidence==high union), so the raiw.cc worker can reuse the same gate. Gate placed before engine construction so the skip path is cheap; shared via cli._should_skip_invisible_scrub. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a612adfef
commit
19f9ab0947
@@ -281,6 +281,16 @@ _strength_option = click.option(
|
||||
default=None,
|
||||
help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.",
|
||||
)
|
||||
_force_option = click.option(
|
||||
"--force/--no-force",
|
||||
default=False,
|
||||
help=(
|
||||
"Run the diffusion scrub even when no invisible AI watermark is locally "
|
||||
"detectable. Default: skip it (regeneration only degrades a clean image; a "
|
||||
"skip never claims the image is watermark-free -- a pixel SynthID is "
|
||||
"undetectable once its metadata proxy is gone)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_auto_polish(auto: bool, adaptive_polish: bool) -> bool:
|
||||
@@ -388,6 +398,55 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
|
||||
raise SystemExit(EXIT_NO_VISIBLE_MARK)
|
||||
|
||||
|
||||
# Same value as EXIT_NO_VISIBLE_MARK (2): a distinct-from-success / distinct-from-
|
||||
# error code that tells a wrapping service (raiw.cc) "the diffusion scrub was skipped
|
||||
# because no invisible watermark was locally detectable", so it can surface the
|
||||
# message instead of charging for and serving an unchanged image as done.
|
||||
EXIT_NO_INVISIBLE_SIGNAL = 2
|
||||
|
||||
|
||||
def _no_invisible_signal_exit(source: Path) -> NoReturn:
|
||||
"""Explain why the diffusion scrub was skipped, then exit non-zero.
|
||||
|
||||
The ``invisible`` command regenerates pixels to remove SynthID / open
|
||||
watermarks; that regeneration also degrades a real photo. When
|
||||
:func:`identify` finds no locally-detectable invisible AI signal, running it
|
||||
anyway would damage a clean image for nothing -- the dominant paid score-0
|
||||
cause on no-watermark uploads. So skip it, but do NOT imply the image is
|
||||
clean: a pixel SynthID is undetectable here once its metadata proxy is gone.
|
||||
Write no output and exit :data:`EXIT_NO_INVISIBLE_SIGNAL`; ``--force`` runs
|
||||
the scrub regardless.
|
||||
"""
|
||||
console.print(
|
||||
" No invisible AI watermark detected (no C2PA/SynthID proxy, no open\n"
|
||||
" watermark). Skipped the diffusion scrub -- regenerating the pixels would\n"
|
||||
" only degrade the image with nothing to remove, so no output was written.\n"
|
||||
" This does NOT prove the image is clean: a pixel watermark such as SynthID\n"
|
||||
" cannot be detected here once its metadata proxy is absent (it may have\n"
|
||||
" been stripped earlier). If you know the image is AI-generated and want the\n"
|
||||
" pixels regenerated regardless, re-run with --force:\n"
|
||||
f" remove-ai-watermarks invisible {source.name} --force"
|
||||
)
|
||||
raise SystemExit(EXIT_NO_INVISIBLE_SIGNAL)
|
||||
|
||||
|
||||
def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
|
||||
"""True when the diffusion scrub should be skipped for *image_path*.
|
||||
|
||||
The shared no-signal gate for ``invisible`` / ``all`` / ``batch``: skip when
|
||||
``--force`` is not set AND no invisible AI watermark is locally detectable
|
||||
(regenerating pixels would only degrade a clean image -- the dominant paid
|
||||
score-0 cause). Centralizes the condition + the lazy ``has_invisible_target``
|
||||
import so the three call sites cannot drift. ``--force`` short-circuits the
|
||||
detection entirely.
|
||||
"""
|
||||
if force:
|
||||
return False
|
||||
from remove_ai_watermarks.identify import has_invisible_target
|
||||
|
||||
return not has_invisible_target(image_path)
|
||||
|
||||
|
||||
def _read_bgr_and_alpha(path: Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
|
||||
"""Read an image preserving its alpha channel separately.
|
||||
|
||||
@@ -697,6 +756,7 @@ def cmd_erase(
|
||||
@_auto_option
|
||||
@_adaptive_polish_option
|
||||
@_tile_options
|
||||
@_force_option
|
||||
@click.pass_context
|
||||
def cmd_invisible(
|
||||
ctx: click.Context,
|
||||
@@ -721,6 +781,7 @@ def cmd_invisible(
|
||||
tile: bool,
|
||||
tile_size: int,
|
||||
tile_overlap: int,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
@@ -745,6 +806,13 @@ def cmd_invisible(
|
||||
|
||||
device_str = None if device == "auto" else device
|
||||
|
||||
# Gate BEFORE building the engine: skip the destructive regeneration when no
|
||||
# invisible AI watermark is locally detectable (it would only degrade a clean
|
||||
# image -- dominant paid score-0 cause), so the common skip path pays nothing for
|
||||
# engine construction. A skip never claims the image is clean; --force overrides.
|
||||
if _should_skip_invisible_scrub(force, source):
|
||||
_no_invisible_signal_exit(source)
|
||||
|
||||
def progress_cb(msg: str) -> None:
|
||||
console.print(f" {msg}")
|
||||
|
||||
@@ -960,6 +1028,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
@_auto_option
|
||||
@_adaptive_polish_option
|
||||
@_tile_options
|
||||
@_force_option
|
||||
@click.pass_context
|
||||
def cmd_all(
|
||||
ctx: click.Context,
|
||||
@@ -986,6 +1055,7 @@ def cmd_all(
|
||||
tile: bool,
|
||||
tile_size: int,
|
||||
tile_overlap: int,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""Remove ALL watermarks: visible + invisible + metadata.
|
||||
|
||||
@@ -1054,6 +1124,18 @@ def cmd_all(
|
||||
" Warning: Skipped - GPU dependencies not installed.\n"
|
||||
" Install them with: pip install 'remove-ai-watermarks[gpu]'"
|
||||
)
|
||||
elif _should_skip_invisible_scrub(force, source):
|
||||
# No locally-detectable invisible watermark -> skip the destructive
|
||||
# regeneration (it would only degrade the image). The visible-removed
|
||||
# pixels in tmp_path are kept and step 3 still strips metadata, so this
|
||||
# is a SUCCESS (exit 0), unlike the GPU-missing skip above. Read the
|
||||
# pristine `source`, not tmp_path whose C2PA the visible pass already
|
||||
# dropped. Not a clean-image guarantee; --force overrides.
|
||||
console.print(
|
||||
" Skipped (no invisible AI watermark detected; pixels left intact).\n"
|
||||
" Not a clean-image guarantee: a pixel SynthID is undetectable once its\n"
|
||||
" metadata proxy is gone. Re-run with --force to scrub regardless."
|
||||
)
|
||||
else:
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
@@ -1173,6 +1255,7 @@ def _process_batch_image(
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Process a single image for batch mode.
|
||||
|
||||
@@ -1203,7 +1286,11 @@ def _process_batch_image(
|
||||
is_available as invisible_available,
|
||||
)
|
||||
|
||||
if invisible_available():
|
||||
# Skip the destructive regeneration when no invisible watermark is locally
|
||||
# detectable (would only degrade a clean image). Read the pristine `img_path`;
|
||||
# `out_path` may already be the visible-processed result. --force overrides.
|
||||
skip_no_signal = _should_skip_invisible_scrub(force, img_path)
|
||||
if invisible_available() and not skip_no_signal:
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
# Cache the engine in ctx.obj so the batch builds it once (pipeline is a
|
||||
@@ -1238,6 +1325,13 @@ def _process_batch_image(
|
||||
# visible-processed `out_path` whose C2PA is already gone.
|
||||
vendor=vendor_for_strength(img_path),
|
||||
)
|
||||
elif skip_no_signal and mode == "invisible" and not out_path.exists():
|
||||
# No invisible target and the visible/all pass did not write out_path
|
||||
# (invisible mode): copy the input through so the output dir is complete
|
||||
# with the pixels deliberately left intact.
|
||||
src_bgr, src_alpha = _read_bgr_and_alpha(img_path)
|
||||
if src_bgr is not None:
|
||||
_write_bgr_with_alpha(out_path, src_bgr, src_alpha)
|
||||
|
||||
if mode in ("metadata", "all"):
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
@@ -1294,6 +1388,7 @@ def _process_batch_image(
|
||||
@_auto_option
|
||||
@_adaptive_polish_option
|
||||
@_tile_options
|
||||
@_force_option
|
||||
@click.pass_context
|
||||
def cmd_batch(
|
||||
ctx: click.Context,
|
||||
@@ -1320,6 +1415,7 @@ def cmd_batch(
|
||||
tile: bool,
|
||||
tile_size: int,
|
||||
tile_overlap: int,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""Process all images in a directory."""
|
||||
_banner()
|
||||
@@ -1383,6 +1479,7 @@ def cmd_batch(
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
force=force,
|
||||
)
|
||||
processed += 1
|
||||
|
||||
|
||||
@@ -144,6 +144,14 @@ class ProvenanceReport:
|
||||
# None -- no C2PA AI source-type (verdict, if AI, came from another
|
||||
# signal: IPTC, AIGC, local gen params, xAI, ...).
|
||||
ai_source_kind: str | None = None
|
||||
# True when the AI verdict rests on a metadata or embedded-invisible signal
|
||||
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
|
||||
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
|
||||
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
|
||||
# set of signals an invisible/diffusion scrub targets: a visible-only or
|
||||
# no-signal image has it False. Equivalent to ``confidence == "high"``;
|
||||
# surfaced as a field so callers gate on intent, not on the string.
|
||||
ai_from_metadata: bool = False
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
@@ -758,8 +766,37 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# Only meaningful when the AI verdict actually came from the C2PA source
|
||||
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
|
||||
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
|
||||
ai_from_metadata=ai_from_metadata,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
integrity_clashes=clashes,
|
||||
)
|
||||
|
||||
|
||||
def has_invisible_target(image_path: Path) -> bool:
|
||||
"""True when a locally-detectable invisible/metadata AI signal is present.
|
||||
|
||||
The decision gate for the diffusion scrub (``invisible`` / ``all`` / ``batch``):
|
||||
regenerating pixels removes an invisible watermark (SynthID, open DWT-DCT,
|
||||
TrustMark) but degrades a real photo, so it must not run when there is nothing
|
||||
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
|
||||
is handled by the separate visible pass and is NOT a diffusion target -- and
|
||||
``check_invisible=True`` so an open watermark counts. Returns
|
||||
``report.ai_from_metadata`` (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local
|
||||
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
|
||||
|
||||
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
|
||||
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
|
||||
here. A False therefore means "no locally-detectable invisible target", not
|
||||
"clean". Callers must NOT present a skip as a finished clean result.
|
||||
|
||||
Fail-safe: any error resolves to True so the removal still runs -- leaving a
|
||||
watermark on a paid removal is worse than over-regenerating a clean image.
|
||||
"""
|
||||
try:
|
||||
report = identify(image_path, check_visible=False, check_invisible=True)
|
||||
except Exception: # unreadable / detector error -> do not skip the removal
|
||||
log.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
|
||||
return True
|
||||
return report.ai_from_metadata
|
||||
|
||||
Reference in New Issue
Block a user