mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-20 22:50:52 +02:00
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:
@@ -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
@@ -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,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.6.8"
|
||||
__version__ = "0.6.9"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user