mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
Three gaps found while measuring the record path against the file path, each one a signal the library could not see: WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed read stops short of the label. `_riff_late_metadata` steps over the coded image to reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there. The decoder-backed fallback now covers only what it is actually for -- metadata the raw bytes do not spell, such as a compressed PNG `zTXt` packet. A C2PA reader failure returned the same `None` as a file with no manifest, so a verdict could fall back to the raw byte scan with no trace anywhere. Failures now log at warning and only genuine ones do: a file without credentials never reaches that branch, and an unsupported container is demoted to debug through the reader's own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG. `scan_dataset.py` never registered the pillow-heif opener it declares as a dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer that was 397 of 406 features NaN instead of 136. `_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping each chunk to the bytes remaining is not enough on its own: one chunk can declare a length spanning most of the file, and this runs on the memoized verdict path over images from arbitrary sources. Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the one-call entry point and the versioned JSON contract for the record path. Record-vs-file equality holds over 3,478 corpus images, and the eight files these fixes recovered still report AI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
350 lines
15 KiB
Python
350 lines
15 KiB
Python
"""Collect one image's provenance metadata into a portable, JSON-safe record.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
``extract_provenance_evidence`` reads a file and hands back evidence in memory, so
|
|
collection and verdict must happen in the same process, on the machine holding the
|
|
image. This module splits them: collect here, judge anywhere, from a record that
|
|
survives JSON.
|
|
|
|
record = collect_metadata_record(path) # touches the file
|
|
evidence = evidence_from_metadata_record(record, path=path)
|
|
report = identify_from_evidence(evidence) # touches nothing
|
|
|
|
WHAT GOES IN, AND WHY NOT SIMPLY THE FILE HEAD
|
|
|
|
The verdict reads a scan buffer that ``scan_head`` fills with the first mebibyte of
|
|
the file. Shipping that verbatim would make a record larger than a phone photo's
|
|
worth of metadata by two orders of magnitude, because for a PNG almost all of that
|
|
mebibyte is compressed pixel data in ``IDAT`` -- bytes no provenance token can ever
|
|
live in. A record carries the metadata REGIONS instead, walked per container: the
|
|
JPEG marker segments before the coded scan, every PNG chunk but ``IDAT``, the RIFF
|
|
chunks that are not coded image, the ISOBMFF provenance boxes, and in every case the
|
|
container's trailer.
|
|
|
|
COMPLETENESS IS A MEASURED PROPERTY, NOT A CLAIM
|
|
|
|
A region walker is only correct if nothing the verdict reads falls outside the
|
|
regions it keeps, and no test over fixtures can establish that: the failure mode is
|
|
a container placement nobody thought of. The contract is therefore ALSO verified
|
|
against the file path over a real corpus -- same image, both paths, identical
|
|
``ProvenanceReport``.
|
|
|
|
The placements that defeated an earlier draft of this collector, and the reason each
|
|
rule below exists, are recorded in ``docs/module-internals.md`` under "Portable
|
|
metadata record".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
import struct
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from remove_ai_watermarks._internal.constants import PNG_SIGNATURE
|
|
from remove_ai_watermarks.metadata import (
|
|
QUICK_SCAN_BYTES,
|
|
SAMSUNG_EDITOR_MARKER,
|
|
exif_text,
|
|
read_file_tail,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# The structural walk covers the same window the file path reads raw, so the two
|
|
# cannot disagree about a chunk type inside it. A smaller window would be cheaper but
|
|
# opens a blind spot: past the window only ``png_late_metadata``'s ALLOWLIST is
|
|
# collected, while the file path still sees every chunk type up to its own window --
|
|
# and a C2PA ``caBX`` chunk is in neither that allowlist nor ``IDAT``. Walking here
|
|
# costs little because the payload of the pixel stream is skipped, not copied.
|
|
HEAD_WINDOW = 1024 * 1024
|
|
# The window searched for the container's end marker. Matches the quick-scan window
|
|
# the file path uses when it goes looking for a Samsung trailer, so a trailer visible
|
|
# to one path is visible to the other.
|
|
TAIL_WINDOW = QUICK_SCAN_BYTES
|
|
# Kept from the tail when no end marker is found, so an unrecognized container still
|
|
# contributes its last bytes without carrying half a photo.
|
|
UNKNOWN_TRAILER_WINDOW = 64 * 1024
|
|
|
|
# PNG text keys the file path reads for a generator tag, in ITS order. NovelAI stamps
|
|
# Software/Source/Title rather than EXIF, and the first match wins, so order matters.
|
|
_GENERATOR_TEXT_KEYS = ("Software", "Source", "Title", "Description")
|
|
# RIFF chunks holding coded pixels rather than metadata.
|
|
_RIFF_IMAGE_CHUNKS = frozenset({b"VP8 ", b"VP8L", b"ALPH"})
|
|
|
|
|
|
def _jpeg_regions(data: bytes) -> bytes:
|
|
"""Every marker segment up to the entropy-coded scan, plus the trailer after EOI.
|
|
|
|
The scan itself is skipped by walking to SOS and then jumping to the trailing
|
|
EOI, so a 20 MB photo contributes only its markers.
|
|
|
|
TWIN: ``metadata._strip_jpeg_metadata_lossless`` walks the same marker chain. The
|
|
two were left separate on purpose -- that one couples the walk to "return False and
|
|
fall back to a PIL re-encode", a decision the lossless strip path owns and this one
|
|
must not inherit -- so a fix to marker handling belongs in BOTH.
|
|
"""
|
|
out = bytearray()
|
|
index, size = 2, len(data)
|
|
while index + 1 < size:
|
|
if data[index] != 0xFF:
|
|
break # malformed boundary: keep what was collected, the tail still follows
|
|
marker = data[index + 1]
|
|
if marker in (0xDA, 0xD9): # SOS / EOI: the coded scan follows
|
|
break
|
|
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone, no length
|
|
index += 2
|
|
continue
|
|
if index + 4 > size:
|
|
break
|
|
segment_length = int.from_bytes(data[index + 2 : index + 4], "big")
|
|
end = index + 2 + segment_length
|
|
if segment_length < 2 or end > size:
|
|
break
|
|
out += data[index:end]
|
|
index = end
|
|
return bytes(out)
|
|
|
|
|
|
def _png_regions(data: bytes) -> bytes:
|
|
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND.
|
|
|
|
TWIN: ``metadata._png_late_metadata`` walks the same chunk chain by SEEKING over
|
|
the file rather than over a buffer, and keeps an allowlist rather than skipping
|
|
``IDAT``. Both filters are deliberate: inside the window the file path sees every
|
|
chunk type raw, past it only the allowlist survives.
|
|
"""
|
|
out = bytearray()
|
|
size = len(data)
|
|
position = len(PNG_SIGNATURE)
|
|
while position + 8 <= size:
|
|
(length,) = struct.unpack(">I", data[position : position + 4])
|
|
chunk_type = data[position + 4 : position + 8]
|
|
start = position + 8
|
|
# Clamp the length to the bytes that remain: a malformed 32-bit length must
|
|
# not push the walk past EOF and abandon a genuine label chunk after it.
|
|
safe_length = max(0, min(length, size - start))
|
|
if chunk_type != b"IDAT":
|
|
out += chunk_type + data[start : start + safe_length]
|
|
position = start + safe_length + 4 # payload + CRC
|
|
if chunk_type == b"IEND":
|
|
out += data[position:] # a trailer past IEND is metadata too
|
|
break
|
|
return bytes(out)
|
|
|
|
|
|
def _riff_regions(data: bytes) -> bytes:
|
|
"""Every RIFF chunk except the coded image payloads.
|
|
|
|
TWIN: ``metadata._riff_late_metadata`` (seek-based, past the scan window) and
|
|
``_internal.riff`` (AVI ``LIST/INFO``). Same chunk-stepping arithmetic, three
|
|
input models.
|
|
"""
|
|
out = bytearray(data[:12]) # 'RIFF' + size + 'WEBP'
|
|
size = len(data)
|
|
position = 12
|
|
while position + 8 <= size:
|
|
chunk_type = data[position : position + 4]
|
|
(length,) = struct.unpack("<I", data[position + 4 : position + 8])
|
|
start = position + 8
|
|
safe_length = max(0, min(length, size - start))
|
|
if chunk_type not in _RIFF_IMAGE_CHUNKS:
|
|
out += chunk_type + data[start : start + safe_length]
|
|
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
|
|
return bytes(out)
|
|
|
|
|
|
def _isobmff_regions(image_path: Path, head: bytes) -> bytes:
|
|
"""Header window plus the provenance regions the bounded box walkers find.
|
|
|
|
ISOBMFF hides a manifest in a ``uuid``/``jumb`` box that can sit after a
|
|
multi-megabyte ``mdat``, and a TC260 label in ``moov.udta``. Both walkers seek
|
|
rather than read the media, so neither pulls the payload in.
|
|
"""
|
|
from remove_ai_watermarks._internal.isobmff import scan_c2pa_region, tc260_aigc_payloads
|
|
|
|
out = bytearray(head[:HEAD_WINDOW])
|
|
try:
|
|
out += scan_c2pa_region(image_path)
|
|
except Exception as exc:
|
|
logger.debug("ISOBMFF C2PA region scan failed on %s: %s", image_path, exc)
|
|
try:
|
|
for payload in tc260_aigc_payloads(image_path):
|
|
out += payload
|
|
except Exception as exc:
|
|
logger.debug("ISOBMFF TC260 scan failed on %s: %s", image_path, exc)
|
|
return bytes(out)
|
|
|
|
|
|
def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
|
|
"""(container label, metadata bytes) for the container ``head`` starts with.
|
|
|
|
``head`` must be the file's raw first bytes. Handing this the ``scan_head``
|
|
buffer instead is a trap: that buffer is the head CONCATENATED with late metadata
|
|
payloads, so a structural walk runs off the end of the real head and parses the
|
|
appended bytes as chunks, inflating the record and creating false signals.
|
|
"""
|
|
from remove_ai_watermarks._internal.isobmff import is_isobmff
|
|
from remove_ai_watermarks.metadata import png_late_metadata
|
|
|
|
if head.startswith(b"\xff\xd8"):
|
|
return "jpeg", _jpeg_regions(head)
|
|
if head.startswith(PNG_SIGNATURE):
|
|
# Chunks placed after the pixel stream (an XMP packet at 2.7 MB, say) are
|
|
# past the window; the same seek-past-IDAT reader the file path uses gets them.
|
|
return "png", _png_regions(head) + png_late_metadata(image_path, HEAD_WINDOW)
|
|
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
|
|
return "webp", _riff_regions(head)
|
|
if is_isobmff(head):
|
|
return "isobmff", _isobmff_regions(image_path, head)
|
|
return "unknown", head
|
|
|
|
|
|
def _raw_head(image_path: Path) -> bytes:
|
|
"""The file's first bytes, unmodified -- the input every structural walk needs."""
|
|
try:
|
|
with open(image_path, "rb") as handle:
|
|
return handle.read(HEAD_WINDOW)
|
|
except OSError as exc:
|
|
logger.debug("head read failed for %s: %s", image_path, exc)
|
|
return b""
|
|
|
|
|
|
def _trailer(image_path: Path, container: str) -> bytes:
|
|
"""The bytes that follow the container's end marker, and nothing else.
|
|
|
|
A fixed-size tail read would be almost entirely pixels: the trailer of a 20 MB
|
|
photo is a few kilobytes at most. So the end marker is located in the tail window
|
|
and only what follows it is kept. When no marker is found (an unknown container,
|
|
or one whose end lies before the window) the window is kept as-is, bounded --
|
|
that is what a byte scan of the same file would have seen anyway.
|
|
"""
|
|
tail = read_file_tail(image_path, TAIL_WINDOW)
|
|
if SAMSUNG_EDITOR_MARKER in tail:
|
|
# Galaxy AI splits its evidence: the marker sits in the post-EOI trailer, but
|
|
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan.
|
|
# Keeping only the trailer therefore carries the marker without the value and the
|
|
# verdict silently drops the Samsung signal, so a marked file keeps the whole
|
|
# window. Only Samsung-marked files pay for it.
|
|
return tail
|
|
marker = {"jpeg": b"\xff\xd9", "png": b"IEND\xae\x42\x60\x82"}.get(container)
|
|
if marker is None:
|
|
return tail[-UNKNOWN_TRAILER_WINDOW:]
|
|
index = tail.rfind(marker)
|
|
return tail[index + len(marker) :] if index >= 0 else tail[-UNKNOWN_TRAILER_WINDOW:]
|
|
|
|
|
|
def _decoder_info(image_path: Path) -> dict[str, Any]:
|
|
"""PIL's ``info`` mapping, read once.
|
|
|
|
One open for both consumers below. They want different parts of the same mapping
|
|
(the text keys, and the raw EXIF blob), and opening twice repeats the container
|
|
header parse and, for a PNG carrying ``zTXt``, the zlib inflate with it.
|
|
"""
|
|
try:
|
|
from PIL import Image
|
|
|
|
with Image.open(image_path) as img:
|
|
# PIL types this mapping with a non-string key union (a DPI tuple key
|
|
# exists), so the keys are normalized here rather than assumed.
|
|
return {str(key): value for key, value in img.info.items()}
|
|
except Exception as exc: # a container PIL cannot open
|
|
logger.debug("PIL info unavailable for %s: %s", image_path, exc)
|
|
return {}
|
|
|
|
|
|
def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
|
|
"""The 0th-IFD tags the verdict reads, under their tag NAMES.
|
|
|
|
Not a convenience: two probes key on names rather than on the raw bytes already
|
|
in the regions. ``xai_signature_pair`` wants an (ImageDescription, Artist) pair,
|
|
and ``_external_exif_generator`` looks for Software / Make / Artist /
|
|
ImageDescription. Ship the bytes alone and both silently return nothing, which
|
|
is how a collector can silently lose Grok and NovelAI verdicts.
|
|
"""
|
|
exif_bytes = info.get("exif")
|
|
if not exif_bytes:
|
|
return {}
|
|
try:
|
|
import piexif
|
|
|
|
tags = piexif.load(exif_bytes).get("0th", {})
|
|
except Exception as exc: # malformed EXIF
|
|
logger.debug("EXIF parse failed: %s", exc)
|
|
return {}
|
|
|
|
return {
|
|
name: text
|
|
for name, tag in (
|
|
("Software", piexif.ImageIFD.Software),
|
|
("Make", piexif.ImageIFD.Make),
|
|
("Artist", piexif.ImageIFD.Artist),
|
|
("ImageDescription", piexif.ImageIFD.ImageDescription),
|
|
)
|
|
if (text := exif_text(tags, tag))
|
|
}
|
|
|
|
|
|
def _pil_info(info: dict[str, Any]) -> dict[str, str]:
|
|
"""PIL's ``info`` mapping as strings, the source of PNG text keys and ``hf-job-id``."""
|
|
|
|
def text_of(value: Any) -> str:
|
|
return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
|
|
|
|
# Emitted in the file path's own candidate order. ``generator_from_metadata``
|
|
# returns the FIRST candidate carrying a known token. A record using PIL's natural
|
|
# dict order can therefore choose a different platform string than the file path,
|
|
# even though the two paths are supposed to be indistinguishable.
|
|
out: dict[str, str] = {}
|
|
for key in _GENERATOR_TEXT_KEYS:
|
|
value = info.get(key)
|
|
if value is not None and not isinstance(value, (dict, list, tuple)):
|
|
out[f"info:{key}"] = text_of(value)
|
|
for key, value in info.items():
|
|
if key in _GENERATOR_TEXT_KEYS or isinstance(value, (dict, list, tuple)):
|
|
continue
|
|
out[f"info:{key}"] = text_of(value)
|
|
return out
|
|
|
|
|
|
def collect_metadata_record(image_path: Path) -> dict[str, Any]:
|
|
"""Collect everything the provenance verdict reads, as a JSON-safe record.
|
|
|
|
The record is the transport format for
|
|
:func:`identify.evidence_from_metadata_record`: it carries the metadata regions
|
|
(base64), the C2PA manifest store, and PIL's info mapping, and it never carries
|
|
pixel data.
|
|
|
|
Args:
|
|
image_path: Path to the image.
|
|
|
|
Returns:
|
|
A JSON-serializable dict. ``metadata_base64`` holds the concatenated
|
|
container regions, ``tail_base64`` the file trailer.
|
|
"""
|
|
from remove_ai_watermarks._internal.c2pa import read_manifest_store_json
|
|
|
|
container, regions = _container_regions(image_path, _raw_head(image_path))
|
|
|
|
info = _decoder_info(image_path)
|
|
record: dict[str, Any] = {
|
|
"container": container,
|
|
"name": image_path.name,
|
|
"metadata_base64": base64.b64encode(regions).decode("ascii"),
|
|
# Always collected: Samsung's Galaxy AI marker is a post-EOI trailer, and a
|
|
# record without it loses that verdict outright.
|
|
"tail_base64": base64.b64encode(_trailer(image_path, container)).decode("ascii"),
|
|
# PIL info BEFORE exif: the file path prefers a PNG text tag over an EXIF
|
|
# one, and the normalizer walks the record in insertion order.
|
|
"pil": _pil_info(info),
|
|
"exif": _exif_pairs(info),
|
|
}
|
|
store = read_manifest_store_json(image_path)
|
|
if store is not None:
|
|
record["c2pa_store"] = store
|
|
return record
|