fix: metadata-strip parity, input robustness, and detection/clash coverage

Bug fixes (each with a regression test):
- metadata strip parity across every marker placement: IPTC digitalSourceType
  in XMP, the Samsung post-EOI trailer, the China TC260 AIGC block in EXIF
  UserComment, a bare AIGC block in a non-standard APP segment, and the ISOBMFF
  EXIF path (AIGC + xAI) are all now stripped -- anything a scanner flags, the
  strip reaches
- Samsung genAIType detected when its trailer sits past the 512 KB scan window
  (file-tail read on large photos)
- crashes on edge inputs: Gemini detector on images with a short side < 16px,
  footprint_mask on a zero-size ndarray, the humanizer on chromatic_shift >=
  width, and the CLI on unreadable/corrupt/empty input (clean error, not a
  traceback)
- WebP written losslessly (cv2 quality 101), not lossy at 100
- the IPTC digitalSourceType algorithmicMedia (procedural, not trained on
  sampled data) is no longer flagged as AI-generated, so clean procedural
  content is not scrubbed
- c2pa source-type: compositeWithTrainedAlgorithmicMedia is checked before the
  bare algorithmicMedia token, so an AI-enhanced composite is not misclassified

Detection:
- integrity-clash coverage now normalizes ByteDance / Canva / ElevenLabs /
  Black Forest Labs, so a transplanted manifest next to an independent
  conflicting stamp is caught; the generic China TC260 AIGC label is attributed
  to a co-present TC260 vendor, so a legit Doubao image (its own C2PA + TC260
  label) does not clash (corpus-validated: 0 new clashes on 5000 carriers)

CLI:
- batch exits non-zero (with a warning) when any image errors or a GPU-missing
  SynthID scrub is skipped, and copies the input through so the output dir stays
  complete -- it used to always exit 0 and could silently drop files

Perf:
- GeminiEngine reused as a process-wide singleton with a precomputed template
  ladder: -24% on the identify sparkle path, detection byte-identical

Internal: one shared _ai_exif_targets rule set feeds both EXIF scrubbers so
their coverage cannot drift; docs synced; maintain.sh hardened so the uv-secure
internal teardown crash no longer aborts the gate (still fails on a real finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-13 10:49:24 +03:00
co-authored by Claude Opus 4.8
parent 190dc89d23
commit a4c901ff39
25 changed files with 894 additions and 97 deletions
+55 -7
View File
@@ -595,6 +595,9 @@ def cmd_visible(
except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent
console.print(f" Error: {e}")
raise SystemExit(1) from e
except (ValueError, OSError) as e: # unreadable / truncated / non-image input
console.print(f" Error: cannot read image {source.name}: {e}")
raise SystemExit(1) from e
elapsed = time.monotonic() - t0
h, w = result.shape[:2]
console.print(f" Input: {source.name} ({w}x{h})")
@@ -933,7 +936,11 @@ def cmd_metadata(
return
# Remove
out = remove_ai_metadata(source, output, keep_standard=keep_standard)
try:
out = remove_ai_metadata(source, output, keep_standard=keep_standard)
except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
console.print(f" Error: cannot process {source.name}: {e}")
raise SystemExit(1) from e
console.print(f" AI metadata stripped -> {out}")
@@ -1246,6 +1253,14 @@ def cmd_all(
# ── Batch command ──
def _passthrough_copy(img_path: Path, out_path: Path) -> None:
"""Copy the input's pixels through to ``out_path`` unchanged (the invisible-mode skip
paths), so the output dir stays complete without touching the pixels."""
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
if src_bgr is not None:
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
def _process_batch_image(
ctx: click.Context,
img_path: Path,
@@ -1272,16 +1287,21 @@ def _process_batch_image(
tile_size: int = 1024,
tile_overlap: int = 128,
force: bool = False,
) -> None:
) -> bool:
"""Process a single image for batch mode.
Applies the requested watermark removal steps (visible, invisible,
metadata) to *img_path* and writes the result to *out_path*.
Returns True if the invisible (SynthID) scrub was skipped because the GPU deps
are missing while a signal was present -- so the batch caller can warn + exit
non-zero, mirroring the single ``all`` command.
Raises:
ValueError: If the image cannot be opened.
"""
saved_alpha: NDArray[Any] | None = None
synthid_skipped = False
if mode in ("visible", "all"):
# Always read the ORIGINAL source: the visible pass is the first step, so a
@@ -1341,13 +1361,22 @@ def _process_batch_image(
# visible-processed `out_path` whose C2PA is already gone.
vendor=vendor_for_strength(img_path),
)
elif not invisible_available() and not skip_no_signal:
# An invisible signal IS present but the GPU deps are missing, so the
# SynthID scrub cannot run. Mirror the single `all` command's loud skip:
# flag it for a batch-level warning + non-zero exit (a silently retained
# SynthID watermark is the #1 "it didn't work" report). For invisible mode
# nothing wrote out_path yet -> copy the input through so the output dir is
# complete with the pixels deliberately left intact (without this, a
# signal-bearing image in a GPU-less --mode invisible run got NO output).
synthid_skipped = True
if mode == "invisible" and not out_path.exists():
_passthrough_copy(img_path, out_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 = image_io.read_bgr_and_alpha(img_path)
if src_bgr is not None:
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
_passthrough_copy(img_path, out_path)
if mode in ("metadata", "all"):
from remove_ai_watermarks.metadata import remove_ai_metadata
@@ -1361,6 +1390,8 @@ def _process_batch_image(
if final_bgr is not None:
image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
return synthid_skipped
@main.command("batch")
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
@@ -1457,6 +1488,7 @@ def cmd_batch(
processed = 0
errors = 0
synthid_skipped_count = 0
with Progress(
SpinnerColumn(),
@@ -1473,7 +1505,7 @@ def cmd_batch(
progress.update(task, description=f"{img_path.name}")
try:
_process_batch_image(
if _process_batch_image(
ctx=ctx,
img_path=img_path,
out_path=out_path,
@@ -1499,7 +1531,8 @@ def cmd_batch(
tile_size=tile_size,
tile_overlap=tile_overlap,
force=force,
)
):
synthid_skipped_count += 1
processed += 1
except Exception as e:
@@ -1511,6 +1544,21 @@ def cmd_batch(
console.print(f"\n {processed} processed" + (f" {errors} errors" if errors else ""))
if synthid_skipped_count:
# Mirror the single `all` command: a silently retained SynthID watermark is the
# #1 "it didn't work" report, so make the skipped scrub impossible to miss.
console.print(
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
f"so those outputs still carry the invisible watermark.\n"
f" Install the extra and rerun: pip install 'remove-ai-watermarks[gpu]'"
)
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
# to always exit 0, hiding both per-image errors and skipped SynthID scrubs).
if errors or synthid_skipped_count:
raise SystemExit(1)
if __name__ == "__main__":
main()