feat(metadata): blank AI-label XMP inside the HEIF/AVIF meta box (v0.6.9)

HEIF/AVIF store XMP as a meta-box `mime` item whose bytes live in mdat/idat, out
of reach of the top-level uuid/jumb box stripper. An AI-label XMP packet there
(TC260 AIGC, IPTC "Made with AI", IPTC 2025.1) was therefore left in place.

isobmff.blank_ai_xmp_packets locates each XMP packet by its <?xpacket begin ...
end?> delimiters and, if it carries an AI marker (_AI_LABEL_MARKERS), overwrites
it with spaces of the SAME length. Equal length means no box size or iloc offset
shifts -- the coded image stays bit-for-bit intact, the item stays structurally
valid, only the AI label content is destroyed. Plain (non-AI) XMP is left alone,
mirroring the top-level XMP-uuid content match. Wired into remove_ai_metadata's
ISOBMFF branch after strip_c2pa_boxes.

Chosen over exiftool (a non-bundled binary dep) to stay pure-Python and
droplet-compatible; over full iinf/iloc surgery to avoid offset-rewrite
corruption risk. The AI labels we target are all XMP, so this closes the
practical gap. An Exif *item* inside the meta box (rare) still needs iinf/iloc
surgery or exiftool -- documented.

4 new tests (TestMetaBoxXmpBlanking): AI packet blanked (same length, marker
gone, surrounding image bytes intact), plain XMP preserved, no-packet no-op, and
end-to-end remove_ai_metadata on a .heic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
test-user
2026-05-27 18:15:48 -07:00
parent 31f0a82906
commit 5bfed00553
8 changed files with 118 additions and 12 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -324,10 +324,10 @@ Tracked but not yet implemented:
- **Grow the SynthID reference corpus** (`data/synthid_corpus/`) with oracle-labeled samples per model and resolution (Gemini app for Google, openai.com/verify for OpenAI). Prerequisite for any pixel-detector attempt and for an automated removal-regression set.
- **Real non-PNG C2PA fixtures**. SynthID-source detection for JPEG / WebP / AVIF is currently covered only by synthetic byte blobs; replace with real vendor-emitted files to ground the binary-scan path.
- **Maintenance debt**. Clear strict-pyright debt in `remove_ai_metadata` / `cli.py` (untyped piexif / PIL / click / rich) so `maintain.sh` can finish green. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
- **AVIF / HEIF EXIF/XMP inside the `meta` box**. Removal already strips top-level C2PA `uuid` / JUMBF `jumb` boxes and any AI-labelled top-level XMP `uuid` box, and non-ISOBMFF audio/video (WebM, MP3, WAV, FLAC, OGG) is stripped losslessly via ffmpeg. Still open: EXIF/XMP stored as *items inside the `meta` box* (typical for AVIF/HEIF stills) — needs `meta`-box surgery (iinf/iloc + mdat splice) or `exiftool` (a non-bundled binary dependency).
- **AVIF / HEIF meta-box XMP** — *shipped (v0.6.9)*. An AI-label XMP packet stored as a `meta`-box `mime` item (HEIF/AVIF, out of reach of the top-level box stripper) is now blanked in place: located by its `<?xpacket?>` delimiters and, if it carries an AI marker, overwritten with spaces of the same length, so box sizes / `iloc` offsets stay valid and the coded image is untouched. Still open: an `Exif` *item* inside the `meta` box (rare — AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) or `exiftool` (a non-bundled binary dependency).
- **Multi-signal contradiction reporting ("Integrity Clash")** — *shipped (v0.6.7)*. `identify` now surfaces contradictions between independent provenance signals (two different AI vendors named by separate stamps, or camera-capture C2PA credentials next to AI-generation markers) as `integrity_clashes` (shown in red in the table view and in `--json`), rather than collapsing to a single verdict. Inspired by [arXiv:2603.02378](https://arxiv.org/abs/2603.02378).
- **More C2PA device signers**. Leica, Nikon, Google Pixel, Sony, and Truepic are mapped (each verified against a real signed file). Canon and Samsung Galaxy (AI-edit) are deferred until a real signed sample surfaces — no public direct-download C2PA file exists for them today (upload-to-verify / news-agency-licensed only).
- **C2PA detection window for streaming MP4** — *shipped (v0.6.8)*. Detection no longer relies on a fixed first-MB read: for ISOBMFF containers it walks the top-level boxes (seeking past `mdat` by size) to find a C2PA / AIGC / IPTC manifest placed after the media data, so a streaming / non-faststart MP4 is caught. The remaining gap is EXIF/XMP stored as items *inside the `meta` box* (needs meta-box surgery or `exiftool`).
- **C2PA detection window for streaming MP4** — *shipped (v0.6.8)*. Detection no longer relies on a fixed first-MB read: for ISOBMFF containers it walks the top-level boxes (seeking past `mdat` by size) to find a C2PA / AIGC / IPTC manifest placed after the media data, so a streaming / non-faststart MP4 is caught.
- **Resemble PerTh audio detection** — evaluated, not feasible with the public API: `get_watermark()` returns a raw bit array with no presence/confidence flag, so watermarked vs. clean audio can't be reliably separated without Resemble's fixed payload or a confidence service. Same wall as the SynthID pixel detector.
- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.6.8"
version = "0.6.9"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
__version__ = "0.6.8"
__version__ = "0.6.9"
+11 -2
View File
@@ -575,16 +575,25 @@ def remove_ai_metadata(
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
# sniff, so a correctly-shaped container is handled whatever its extension.
from remove_ai_watermarks.noai.isobmff import is_isobmff, strip_c2pa_boxes
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets, is_isobmff, strip_c2pa_boxes
with open(source_path, "rb") as f:
head = f.read(12)
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
data = source_path.read_bytes()
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then AI-label XMP that
# lives inside a meta-box ``mime`` item (HEIF/AVIF) -- blanked in place so
# box sizes and iloc offsets stay valid and the coded image is untouched.
cleaned, stripped = strip_c2pa_boxes(data)
cleaned, blanked = blank_ai_xmp_packets(cleaned)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_bytes(cleaned)
logger.info("Stripped %d AI-provenance box(es) → %s", stripped, output_path)
logger.info(
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
stripped,
blanked,
output_path,
)
return output_path
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
+38 -3
View File
@@ -17,6 +17,7 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
from __future__ import annotations
import re
import struct
from typing import TYPE_CHECKING
@@ -43,6 +44,12 @@ C2PA_BOX_TYPES: frozenset[bytes] = frozenset({b"uuid", b"jumb"})
# XMP (copyright, camera info) is kept.
_AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS
# Adobe XMP packet delimiters (XMP spec part 3). In HEIF/AVIF the XMP packet
# sits inside a ``meta``-box ``mime`` item whose bytes live in ``mdat`` / ``idat``,
# out of reach of the top-level box stripper, so an AI-label packet there is
# blanked in place (see ``blank_ai_xmp_packets``).
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
@@ -145,9 +152,10 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
(all ISOBMFF). NOTE: EXIF/XMP stored as *items inside the ``meta`` box*
(typical for AVIF/HEIF images) is not removed -- that needs meta-box surgery
and is a documented limitation.
(all ISOBMFF). NOTE: this drops only top-level boxes. An AI-label XMP packet
stored as an *item inside the ``meta`` box* (typical for AVIF/HEIF) is handled
separately by :func:`blank_ai_xmp_packets`; an ``Exif`` meta-box item is still
not removed (would need meta-box surgery) and remains a documented limitation.
"""
if not is_isobmff(data):
return data, 0
@@ -167,3 +175,30 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
continue
out.extend(data[start:end])
return bytes(out), stripped
def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
"""Overwrite (with spaces, in place) any XMP packet carrying an AI-label
marker; return ``(data, blanked_count)``.
HEIF/AVIF store XMP as a ``meta``-box ``mime`` item whose bytes live in
``mdat`` / ``idat``, which ``strip_c2pa_boxes`` cannot remove without
meta-box surgery (``iinf`` / ``iloc`` rewrite). Instead, the XMP packet is
located by its ``<?xpacket begin ... end?>`` delimiters and, when it carries
an AI-label marker (TC260 AIGC / IPTC / IPTC-2025.1), overwritten with spaces.
Because the replacement is the **same length**, every box size and ``iloc``
offset stays valid and the coded image data is untouched -- only the AI label
content is destroyed. Packets without an AI marker (plain copyright / camera
XMP) are left intact, mirroring the top-level XMP-``uuid`` content match.
"""
blanked = 0
def _scrub(match: re.Match[bytes]) -> bytes:
nonlocal blanked
packet = match.group()
if any(marker in packet for marker in _AI_LABEL_MARKERS):
blanked += 1
return b" " * len(packet)
return packet
return _XMP_PACKET_RE.sub(_scrub, data), blanked
+62
View File
@@ -712,6 +712,68 @@ class TestLateProvenanceBox:
assert has_ai_metadata(p) is True
_AI_XMP = (
b'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
b'<?xpacket end="w"?>'
)
_PLAIN_XMP = (
b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
b'<?xpacket end="w"?>'
)
class TestMetaBoxXmpBlanking:
"""HEIF/AVIF store XMP as a meta-box ``mime`` item (bytes in mdat/idat), out of
reach of the top-level box stripper. An AI-label XMP packet there is blanked
in place (same length -> iloc offsets and image data stay intact)."""
def test_blanks_ai_packet_only(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
before, after = b"IMG_BEFORE" * 4, b"IMG_AFTER" * 4
data = before + _AI_XMP + after + _PLAIN_XMP
out, n = blank_ai_xmp_packets(data)
assert n == 1
assert len(out) == len(data) # same length -> no offset shifts
assert b"TC260:AIGC" not in out # AI label destroyed
assert before in out # surrounding (image) bytes intact
assert after in out
assert b"dc:rights" in out # plain XMP left alone
def test_no_packet_is_noop(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
data = b"just some mdat bytes, no xmp here"
assert blank_ai_xmp_packets(data) == (data, 0)
def test_plain_xmp_untouched(self):
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
out, n = blank_ai_xmp_packets(_PLAIN_XMP)
assert n == 0
assert out == _PLAIN_XMP
def test_remove_ai_metadata_blanks_meta_box_xmp(self, tmp_path: Path):
# End-to-end: a HEIF with an AI XMP packet inside mdat is cleaned without
# touching the surrounding (coded image) bytes or the file length.
heic_ftyp = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
img = b"CODEDIMAGE" * 8
mdat = _box(b"mdat", img + _AI_XMP + img)
src = tmp_path / "ai.heic"
src.write_bytes(heic_ftyp + mdat)
assert has_ai_metadata(src) is True
out = tmp_path / "clean.heic"
remove_ai_metadata(src, out)
res = out.read_bytes()
assert len(res) == src.stat().st_size # length preserved
assert b"TC260:AIGC" not in res
assert img in res # coded image bytes intact
assert has_ai_metadata(out) is False
class TestIsobmffMetadataRemoval:
"""Container-level AI-provenance stripping across ISOBMFF image/video/audio."""
Generated
+1 -1
View File
@@ -2865,7 +2865,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.6.8"
version = "0.6.9"
source = { editable = "." }
dependencies = [
{ name = "click" },