mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
feat(metadata): blank AI-generator tokens in AVIF/HEIF Exif meta-box items
Closes a documented coverage gap (P2#9): an AI Software/Make/Artist/ImageDescription token in an EXIF item (its TIFF bytes live in mdat/idat) survived remove_ai_metadata because the top-level box stripper and (absent pillow-heif) the PIL EXIF reader can't reach it. New isobmff.blank_ai_exif_tokens finds EXIF TIFF blocks by their II/MM byte-order header, validates each with piexif (a coincidental II/MM run in pixels won't parse as a TIFF IFD, so it's ignored), and overwrites any AI_GENERATOR_TOKENS- bearing value with same-length spaces -- so box sizes and iloc offsets stay valid and the coded image is untouched (mirrors blank_ai_xmp_packets; no iinf/iloc surgery, no exiftool dep). Camera/editor EXIF without an AI token is preserved. Wired into remove_ai_metadata's ISOBMFF path. Covers the realistic AI-generator-token case; xAI- signature-in-meta-box-EXIF (Grok is JPEG-only) stays out. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3f5d6a0af1
commit
d5845a72f3
@@ -871,23 +871,32 @@ 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 blank_ai_xmp_packets, is_isobmff, strip_c2pa_boxes
|
||||
from remove_ai_watermarks.noai.isobmff import (
|
||||
blank_ai_exif_tokens,
|
||||
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.
|
||||
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then the meta-box items
|
||||
# the top-level stripper can't reach (HEIF/AVIF store them in mdat/idat):
|
||||
# AI-label XMP packets and AI-generator tokens in an Exif item -- both
|
||||
# blanked in place (same length) 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)
|
||||
cleaned, exif_blanked = blank_ai_exif_tokens(cleaned)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(cleaned)
|
||||
logger.info(
|
||||
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
|
||||
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) + %d EXIF token(s) → %s",
|
||||
stripped,
|
||||
blanked,
|
||||
exif_blanked,
|
||||
output_path,
|
||||
)
|
||||
return output_path
|
||||
|
||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -157,10 +157,11 @@ 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: 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.
|
||||
(all ISOBMFF). NOTE: this drops only top-level boxes. AI metadata stored as an
|
||||
*item inside the ``meta`` box* (typical for AVIF/HEIF) is handled separately and
|
||||
in place (same length, no offset rewrite): AI-label XMP by
|
||||
:func:`blank_ai_xmp_packets`, and AI-generator tokens in an ``Exif`` item by
|
||||
:func:`blank_ai_exif_tokens`.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
@@ -223,3 +224,64 @@ def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
|
||||
return packet
|
||||
|
||||
return _XMP_PACKET_RE.sub(_scrub, data), blanked
|
||||
|
||||
|
||||
# EXIF TIFF byte-order headers: little-endian (II 0x2a 0x00) and big-endian
|
||||
# (MM 0x00 0x2a). A HEIF/AVIF ``Exif`` meta-box item stores its TIFF block in
|
||||
# ``mdat`` / ``idat``, so the block (and these headers) appear in the raw bytes.
|
||||
_TIFF_HEADERS: tuple[bytes, ...] = (b"II\x2a\x00", b"MM\x00\x2a")
|
||||
# How far past a TIFF header an EXIF block plausibly extends; bounds the slice we
|
||||
# hand to piexif and search within (EXIF blocks are small kilobyte-scale).
|
||||
_EXIF_WINDOW = 256 * 1024
|
||||
|
||||
|
||||
def blank_ai_exif_tokens(data: bytes) -> tuple[bytes, int]:
|
||||
"""Overwrite (with spaces, in place) any AI-generator token in an EXIF block
|
||||
stored as an ISOBMFF ``meta``-box ``Exif`` item; return ``(data, blanked_count)``.
|
||||
|
||||
HEIF/AVIF can carry EXIF as a ``meta``-box ``Exif`` item whose TIFF bytes live
|
||||
in ``mdat`` / ``idat`` -- out of reach of the top-level box stripper, and (when
|
||||
no pillow-heif plugin is installed) of the PIL EXIF reader too, so an AI
|
||||
``Software`` / ``Make`` / ``Artist`` / ``ImageDescription`` tag there survived
|
||||
``remove_ai_metadata`` (a documented gap). This locates EXIF TIFF blocks by
|
||||
their byte-order header, **validates each with piexif** (so a coincidental
|
||||
II/MM run in pixel data is ignored -- it will not parse as a TIFF IFD), and
|
||||
overwrites any value carrying an ``AI_GENERATOR_TOKENS`` token with spaces of
|
||||
the SAME length. Because the replacement is same-length, every box size and
|
||||
``iloc`` offset stays valid and the coded image is untouched -- only the AI tag
|
||||
content is destroyed; camera/editor EXIF without an AI token is left intact
|
||||
(mirrors ``metadata._scrub_ai_exif`` and ``blank_ai_xmp_packets``).
|
||||
"""
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
ai_tags = (
|
||||
piexif.ImageIFD.Software,
|
||||
piexif.ImageIFD.Make,
|
||||
piexif.ImageIFD.Artist,
|
||||
piexif.ImageIFD.ImageDescription,
|
||||
)
|
||||
out = bytearray(data)
|
||||
blanked = 0
|
||||
for header in _TIFF_HEADERS:
|
||||
pos = data.find(header)
|
||||
while pos != -1:
|
||||
window = bytes(out[pos : pos + _EXIF_WINDOW])
|
||||
ifd: dict[int, Any] = {}
|
||||
try:
|
||||
ifd = piexif.load(window).get("0th", {})
|
||||
except Exception:
|
||||
ifd = {}
|
||||
for tag in ai_tags:
|
||||
value = ifd.get(tag)
|
||||
if not isinstance(value, bytes):
|
||||
continue
|
||||
if any(token in value.decode("latin1", "replace").lower() for token in AI_GENERATOR_TOKENS):
|
||||
# Blank the value bytes in place, within this EXIF block only.
|
||||
vpos = out.find(value, pos, pos + _EXIF_WINDOW)
|
||||
if vpos != -1:
|
||||
out[vpos : vpos + len(value)] = b" " * len(value)
|
||||
blanked += 1
|
||||
pos = data.find(header, pos + len(header))
|
||||
return bytes(out), blanked
|
||||
|
||||
Reference in New Issue
Block a user