Recover malformed metadata containers, release 0.20.2

This commit is contained in:
Victor Kuznetsov
2026-07-28 12:12:26 -07:00
parent 1d333fe855
commit 59823b2eaf
9 changed files with 95 additions and 7 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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"]
+23
View File
@@ -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)
+56
View File
@@ -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
Generated
+1 -1
View File
@@ -3187,7 +3187,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.20.1"
version = "0.20.2"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },