mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Recover malformed metadata containers, release 0.20.2
This commit is contained in:
@@ -178,7 +178,10 @@ cannot parse the input.
|
||||
|
||||
`remove_ai_metadata` may copy an undecodable file through unchanged instead of
|
||||
raising. User facing callers must use `strip_and_verify` and inspect its
|
||||
surviving marker mapping before reporting success. The CLI does this.
|
||||
surviving marker mapping before reporting success. `strip_and_verify` recovers
|
||||
when `image_io` can still decode the raster by normalizing the container and
|
||||
checking again. A truly undecodable file still reports the surviving markers.
|
||||
The CLI uses this verified path.
|
||||
|
||||
### Sixteen bit PNG output is not preserved
|
||||
|
||||
|
||||
@@ -114,7 +114,10 @@ Key contracts:
|
||||
- The low-level remover is fail-safe and can copy an undecodable file through
|
||||
unchanged.
|
||||
- A caller that reports success must use `strip_and_verify`, which scans the
|
||||
written output for surviving markers.
|
||||
written output for surviving markers. If the metadata-preserving decoder
|
||||
rejected the container but `image_io` can still decode its raster,
|
||||
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
|
||||
file keeps the surviving-marker result.
|
||||
|
||||
Detection and removal must stay in parity. A new marker is incomplete until the
|
||||
scanner can find it, the remover can reach every supported placement, and a
|
||||
|
||||
+4
-1
@@ -119,7 +119,10 @@ if has_ai_metadata(source):
|
||||
|
||||
Use `strip_and_verify` when your application reports that stripping succeeded.
|
||||
It checks the written output and returns `(output_path, surviving_markers)`.
|
||||
Treat a nonempty `surviving_markers` mapping as a failure.
|
||||
When the first strip leaves markers in a malformed but raster-decodable image,
|
||||
it normalizes the container through `image_io` and checks again. That recovery
|
||||
path preserves the pixels but drops standard metadata. Treat a nonempty
|
||||
`surviving_markers` mapping as a failure.
|
||||
|
||||
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
|
||||
undecodable input through unchanged, so its return alone must not be presented
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.20.1"
|
||||
version = "0.20.2"
|
||||
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1"
|
||||
|
||||
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.20.1"
|
||||
__version__ = "0.20.2"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
|
||||
@@ -1091,9 +1091,32 @@ def strip_and_verify(
|
||||
exposed this when `metadata --remove` reported success while the output still read
|
||||
as AI.
|
||||
|
||||
If markers survive but OpenCV can still decode the raster, the verified path
|
||||
normalizes the container through :mod:`image_io` and scans once more. This
|
||||
intentionally drops standard metadata on that recovery path because the original
|
||||
container is too malformed for the metadata-preserving decoder.
|
||||
|
||||
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
|
||||
"""
|
||||
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
|
||||
remaining = get_ai_metadata(out)
|
||||
if not remaining:
|
||||
return out, {}
|
||||
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image = image_io.imread(out, cv2.IMREAD_UNCHANGED)
|
||||
if image is None:
|
||||
return out, remaining
|
||||
logger.warning(
|
||||
"AI metadata survived stripping; normalizing decodable raster: path=%s fields=%s",
|
||||
out,
|
||||
",".join(sorted(remaining)),
|
||||
)
|
||||
if not image_io.imwrite(out, image):
|
||||
raise OSError(f"Failed to normalize image after incomplete metadata stripping: {out}")
|
||||
return out, get_ai_metadata(out)
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from remove_ai_watermarks.metadata import (
|
||||
iptc_ai_system,
|
||||
remove_ai_metadata,
|
||||
samsung_genai,
|
||||
strip_and_verify,
|
||||
synthid_source,
|
||||
xai_signature,
|
||||
)
|
||||
@@ -29,6 +30,28 @@ from remove_ai_watermarks.metadata import (
|
||||
# Real, committed C2PA sample images used to ground the SynthID-source tests.
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes:
|
||||
"""Encode one PNG chunk, optionally corrupting its CRC."""
|
||||
import zlib
|
||||
|
||||
crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
|
||||
if corrupt_crc:
|
||||
crc ^= 1
|
||||
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
|
||||
|
||||
|
||||
def _corrupt_c2pa_png(path: Path) -> Path:
|
||||
"""Write a synthetic PNG that Pillow rejects but OpenCV can decode."""
|
||||
Image.new("RGB", (32, 32), (80, 120, 160)).save(path)
|
||||
png = path.read_bytes()
|
||||
idat_start = png.index(b"IDAT") - 4
|
||||
fake_c2pa = _png_chunk(b"caBX", b"\x00\x00\x00\x10jumbsynthetic-c2pa")
|
||||
bad_exif = _png_chunk(b"eXIf", b"Exif\x00\x00synthetic", corrupt_crc=True)
|
||||
path.write_bytes(png[:idat_start] + fake_c2pa + bad_exif + png[idat_start:])
|
||||
return path
|
||||
|
||||
|
||||
# ── Key detection ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -276,6 +299,39 @@ class TestHasAiMetadata:
|
||||
assert result == out
|
||||
assert out.read_bytes() == truncated.read_bytes()
|
||||
|
||||
def test_strip_and_verify_normalizes_decodable_copy_through(self, tmp_path: Path):
|
||||
"""A decodable raster must not retain C2PA after verified stripping."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
src = _corrupt_c2pa_png(tmp_path / "source.png")
|
||||
out = tmp_path / "clean.png"
|
||||
assert has_ai_metadata(src)
|
||||
before = image_io.imread(src)
|
||||
assert before is not None
|
||||
|
||||
result, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert result == out
|
||||
assert remaining == {}
|
||||
assert not has_ai_metadata(out)
|
||||
after = image_io.imread(out)
|
||||
assert after is not None
|
||||
assert np.array_equal(after, before)
|
||||
|
||||
def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
|
||||
"""A truly undecodable file keeps the established fail-safe result."""
|
||||
src = _corrupt_c2pa_png(tmp_path / "source.png")
|
||||
src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
|
||||
out = tmp_path / "copy.png"
|
||||
|
||||
result, remaining = strip_and_verify(src, out)
|
||||
|
||||
assert result == out
|
||||
assert "c2pa_manifest" in remaining
|
||||
assert out.read_bytes() == src.read_bytes()
|
||||
|
||||
|
||||
class TestC2paMarkerIn:
|
||||
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
|
||||
|
||||
Reference in New Issue
Block a user