mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 22:48:37 +02:00
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:
co-authored by
Claude Opus 4.8
parent
190dc89d23
commit
a4c901ff39
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user