mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 16:10:33 +02:00
Add versioned forensic metadata transports
This commit is contained in:
@@ -20,6 +20,9 @@ AI_KEYWORDS = _tokens(
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
C2PA_CHUNK_TYPE = b"caBX"
|
||||
PNG_METADATA_CHUNKS = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
|
||||
RIFF_METADATA_CHUNKS = frozenset({b"EXIF", b"XMP ", b"ICCP", b"C2PA"})
|
||||
RIFF_CODED_IMAGE_CHUNKS = frozenset({b"VP8 ", b"VP8L", b"ALPH", b"ANMF"})
|
||||
C2PA_SIGNATURES = tuple(
|
||||
token.encode() for token in _tokens("c2pa|C2PA|jumb|jumd|JUMBF|jumbf|cbor|contentcreds|digid|assertions|manifest")
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ _AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_
|
||||
# blanked in place (see ``blank_ai_xmp_packets``).
|
||||
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
|
||||
_STREAM_COPY_BYTES = 1024 * 1024
|
||||
_STREAM_SCAN_BYTES = 4 * 1024 * 1024
|
||||
STREAM_SCAN_BYTES = 4 * 1024 * 1024
|
||||
|
||||
|
||||
# TC260-PG-20257A stores an MP4/MOV label as an ``AIGC`` key in
|
||||
@@ -133,7 +133,7 @@ def _read_box_header(
|
||||
return end, box_type, payload_off
|
||||
|
||||
|
||||
def _iter_file_boxes(
|
||||
def iter_file_boxes(
|
||||
stream: BinaryIO,
|
||||
start: int,
|
||||
end: int,
|
||||
@@ -193,17 +193,17 @@ def _tc260_aigc_regions(
|
||||
Each tuple is ``(key_start, key_end, value_start, value_end, value)``.
|
||||
"""
|
||||
regions: list[tuple[int, int, int, int, bytes]] = []
|
||||
for _moov_start, moov_end, moov_type, moov_payload in _iter_file_boxes(stream, 0, file_size):
|
||||
for _moov_start, moov_end, moov_type, moov_payload in iter_file_boxes(stream, 0, file_size):
|
||||
if moov_type != b"moov":
|
||||
continue
|
||||
for _udta_start, udta_end, udta_type, udta_payload in _iter_file_boxes(
|
||||
for _udta_start, udta_end, udta_type, udta_payload in iter_file_boxes(
|
||||
stream,
|
||||
moov_payload,
|
||||
moov_end,
|
||||
):
|
||||
if udta_type != b"udta":
|
||||
continue
|
||||
for _meta_start, meta_end, meta_type, meta_payload in _iter_file_boxes(
|
||||
for _meta_start, meta_end, meta_type, meta_payload in iter_file_boxes(
|
||||
stream,
|
||||
udta_payload,
|
||||
udta_end,
|
||||
@@ -212,7 +212,7 @@ def _tc260_aigc_regions(
|
||||
continue
|
||||
keys: dict[int, tuple[int, int]] = {}
|
||||
ilst_boxes: list[tuple[int, int]] = []
|
||||
for _child_start, child_end, child_type, child_payload in _iter_file_boxes(
|
||||
for _child_start, child_end, child_type, child_payload in iter_file_boxes(
|
||||
stream,
|
||||
meta_payload + 4,
|
||||
meta_end,
|
||||
@@ -224,7 +224,7 @@ def _tc260_aigc_regions(
|
||||
if not keys:
|
||||
continue
|
||||
for ilst_payload, ilst_end in ilst_boxes:
|
||||
for _item_start, item_end, item_type, item_payload in _iter_file_boxes(
|
||||
for _item_start, item_end, item_type, item_payload in iter_file_boxes(
|
||||
stream,
|
||||
ilst_payload,
|
||||
ilst_end,
|
||||
@@ -233,7 +233,7 @@ def _tc260_aigc_regions(
|
||||
key_span = keys.get(index)
|
||||
if key_span is None:
|
||||
continue
|
||||
for _data_start, data_end, data_type, data_payload in _iter_file_boxes(
|
||||
for _data_start, data_end, data_type, data_payload in iter_file_boxes(
|
||||
stream,
|
||||
item_payload,
|
||||
item_end,
|
||||
@@ -425,7 +425,7 @@ def strip_isobmff_media_file(
|
||||
source: str | Path,
|
||||
output: str | Path,
|
||||
*,
|
||||
max_box_scan: int = _STREAM_SCAN_BYTES,
|
||||
max_box_scan: int = STREAM_SCAN_BYTES,
|
||||
) -> tuple[int, int]:
|
||||
"""Stream-copy an MP4/MOV/M4A while removing supported AI metadata.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Shared validation for versioned JSON transport contracts."""
|
||||
|
||||
from collections.abc import Collection
|
||||
|
||||
|
||||
def require_schema_version(
|
||||
value: object,
|
||||
*,
|
||||
contract: str,
|
||||
supported: Collection[int],
|
||||
) -> int:
|
||||
"""Return an explicitly supported integer schema version or raise."""
|
||||
if type(value) is not int or value not in supported:
|
||||
versions = ", ".join(str(version) for version in sorted(supported))
|
||||
raise ValueError(f"Unsupported {contract} schema: {value!r}; supported versions: {versions}")
|
||||
return value
|
||||
@@ -0,0 +1,861 @@
|
||||
"""Collect JSON-safe metadata and container forensics for one media file.
|
||||
|
||||
The collector is deliberately evidence-only: it preserves raw EXIF, IPTC, C2PA,
|
||||
container metadata, encoder structure, hashes, timestamps, and bounded binary
|
||||
payloads without deciding whether the content is AI-generated. Provenance verdicts
|
||||
and pixel statistics are separate library stages.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import plistlib
|
||||
import re
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import piexif
|
||||
from PIL import Image
|
||||
from PIL.IptcImagePlugin import getiptcinfo
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks._internal.constants import (
|
||||
PNG_METADATA_CHUNKS,
|
||||
RIFF_CODED_IMAGE_CHUNKS,
|
||||
RIFF_METADATA_CHUNKS,
|
||||
)
|
||||
from remove_ai_watermarks._internal.isobmff import (
|
||||
C2PA_BOX_TYPES,
|
||||
STREAM_SCAN_BYTES,
|
||||
iter_file_boxes,
|
||||
)
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import QUICK_SCAN_BYTES
|
||||
from remove_ai_watermarks.metadata_record import HEAD_WINDOW
|
||||
|
||||
__all__ = [
|
||||
"FORENSIC_METADATA_RECORD_TYPE",
|
||||
"FORENSIC_METADATA_SCHEMA_VERSION",
|
||||
"SUPPORTED_EXTENSIONS",
|
||||
"collect_forensic_metadata",
|
||||
]
|
||||
|
||||
SUPPORTED_EXTENSIONS = {
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".webp",
|
||||
".heic",
|
||||
".heif",
|
||||
".avif",
|
||||
".tif",
|
||||
".tiff",
|
||||
".bmp",
|
||||
".gif",
|
||||
# video/px containers: no pixel decode, but C2PA reads them (Sora/Veo
|
||||
# carry C2PA manifests) and the byte scans still apply
|
||||
".mp4",
|
||||
".mov",
|
||||
".m4v",
|
||||
".jxl",
|
||||
}
|
||||
|
||||
FORENSIC_METADATA_SCHEMA_VERSION = 1
|
||||
FORENSIC_METADATA_RECORD_TYPE = "forensic_metadata"
|
||||
|
||||
_B64_CAP = 1 << 20 # 1 MB safety ceiling per embedded blob
|
||||
_TEXT_CAP = 1 << 20 # decoded PNG text ceiling per chunk
|
||||
# Preserve enough top-level ISOBMFF uuid/jumb payload data for downstream
|
||||
# provenance algorithms without requiring them to reopen the source file.
|
||||
_PROVENANCE_B64_CAP = STREAM_SCAN_BYTES
|
||||
_RAW_SCAN_HEAD = HEAD_WINDOW
|
||||
_RAW_SCAN_TAIL = QUICK_SCAN_BYTES
|
||||
|
||||
|
||||
def _safe_str(v: Any) -> str:
|
||||
try:
|
||||
return str(v)
|
||||
except Exception:
|
||||
return repr(v)
|
||||
|
||||
|
||||
def _b64(b: bytes, *, cap: int = _B64_CAP) -> str:
|
||||
"""Legacy base64 value, with an explicit marker when the payload is capped."""
|
||||
encoded = base64.b64encode(b[:cap]).decode("ascii")
|
||||
return encoded + f"...TRUNCATED({len(b)} bytes total)" if len(b) > cap else encoded
|
||||
|
||||
|
||||
def _decode_exif_value(v: Any) -> Any:
|
||||
"""Make a piexif value JSON-safe; bytes are kept in full as hex."""
|
||||
if isinstance(v, bytes):
|
||||
if len(v) <= 64:
|
||||
try:
|
||||
return v.decode("utf-8", "strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return f"hex:{v.hex()}"
|
||||
return f"hex:{v.hex()}"
|
||||
if isinstance(v, tuple | list):
|
||||
sequence = cast("list[Any] | tuple[Any, ...]", v)
|
||||
return [_decode_exif_value(item) for item in sequence]
|
||||
return v
|
||||
|
||||
|
||||
def read_full_exif(
|
||||
path: Path, exif_blob: bytes | None = None, data: bytes | None = None
|
||||
) -> tuple[dict[str, Any], bytes | None]:
|
||||
"""All EXIF IFDs with decoded tag names (piexif, no re-encode), plus the
|
||||
raw embedded-thumbnail bytes for the caller's own thumbnail forensics.
|
||||
|
||||
``exif_blob`` is the PIL-exposed EXIF blob (PNG/WebP/HEIC path) so the
|
||||
caller's single Image.open is not repeated here. ``data`` is the
|
||||
already-read file bytes so piexif does not re-read the file."""
|
||||
try:
|
||||
exif: dict[str, Any] = piexif.load(data) if data is not None else piexif.load(str(path))
|
||||
except Exception:
|
||||
if not exif_blob:
|
||||
return {}, None
|
||||
try:
|
||||
exif = piexif.load(exif_blob)
|
||||
except Exception as exc:
|
||||
return {"error": _safe_str(exc)}, None
|
||||
out: dict[str, Any] = {}
|
||||
thumbnail: bytes | None = None
|
||||
for ifd, tags in exif.items():
|
||||
if ifd == "thumbnail":
|
||||
thumbnail = tags if isinstance(tags, bytes) else None
|
||||
out["thumbnail"] = f"{len(tags)} bytes" if isinstance(tags, bytes) else None
|
||||
continue
|
||||
if not isinstance(tags, dict):
|
||||
continue
|
||||
all_tag_names = cast("dict[str, dict[int, dict[str, Any]]]", getattr(piexif, "TAGS", {}))
|
||||
tag_names = all_tag_names.get(ifd, {})
|
||||
decoded: dict[str, Any] = {}
|
||||
for tag, value in cast("dict[int, Any]", tags).items():
|
||||
name = str(tag_names.get(tag, {}).get("name", f"tag_{tag}"))
|
||||
if name == "MakerNote" and isinstance(value, bytes):
|
||||
# full hex, no cap: measured on real uploads, Apple is ~2 KB
|
||||
# but Canon reaches 28 KB and Sony 38 KB (AF data, serials,
|
||||
# embedded previews) -- a cap would silently drop exactly the
|
||||
# camera-original evidence this scan exists to preserve
|
||||
decoded[name] = f"hex:{value.hex()}"
|
||||
else:
|
||||
decoded[name] = _decode_exif_value(value)
|
||||
out[ifd] = decoded
|
||||
return out, thumbnail
|
||||
|
||||
|
||||
def _png_text_decode(ctype: str, body: bytes) -> str:
|
||||
"""Decode a tEXt/zTXt/iTXt chunk, inflating zlib where used.
|
||||
|
||||
The compressed forms are where ComfyUI / Automatic1111 hide the
|
||||
generation workflow and prompt, so skipping the inflate would drop
|
||||
the strongest AI-provenance text a PNG can carry."""
|
||||
if ctype == "tEXt":
|
||||
suffix = b"...TRUNCATED" if len(body) > _TEXT_CAP else b""
|
||||
return (body[:_TEXT_CAP] + suffix).decode("utf-8", "replace")
|
||||
if ctype == "zTXt":
|
||||
nul = body.find(b"\x00")
|
||||
if nul == -1:
|
||||
return body[:_TEXT_CAP].decode("utf-8", "replace")
|
||||
keyword = body[:nul].decode("latin-1", "replace")
|
||||
# body[nul+1] = compression method (0 = zlib)
|
||||
try:
|
||||
inflater = zlib.decompressobj()
|
||||
decoded = inflater.decompress(body[nul + 2 :], _TEXT_CAP + 1)
|
||||
suffix = "...TRUNCATED" if len(decoded) > _TEXT_CAP else ""
|
||||
text = decoded[:_TEXT_CAP].decode("utf-8", "replace") + suffix
|
||||
except zlib.error:
|
||||
text = body[:_TEXT_CAP].decode("utf-8", "replace")
|
||||
return f"{keyword}\x00{text}"
|
||||
# iTXt: keyword\0 compflag(1) compmethod(1) lang\0 translated\0 text
|
||||
parts = body.split(b"\x00", 1)
|
||||
if len(parts) < 2:
|
||||
return body[:_TEXT_CAP].decode("utf-8", "replace")
|
||||
keyword = parts[0].decode("latin-1", "replace")
|
||||
rest = parts[1]
|
||||
if len(rest) < 2:
|
||||
return body[:_TEXT_CAP].decode("utf-8", "replace")
|
||||
compflag = rest[0]
|
||||
tail = rest[2:]
|
||||
for _ in range(2): # skip language tag and translated keyword
|
||||
nul = tail.find(b"\x00")
|
||||
if nul == -1:
|
||||
return body[:_TEXT_CAP].decode("utf-8", "replace")
|
||||
tail = tail[nul + 1 :]
|
||||
if compflag:
|
||||
with contextlib.suppress(zlib.error):
|
||||
inflater = zlib.decompressobj()
|
||||
tail = inflater.decompress(tail, _TEXT_CAP + 1)
|
||||
if len(tail) > _TEXT_CAP:
|
||||
tail = tail[:_TEXT_CAP] + b"...TRUNCATED"
|
||||
return f"{keyword}\x00{tail.decode('utf-8', 'replace')}"
|
||||
|
||||
|
||||
def read_png_chunks(data: bytes) -> tuple[list[dict[str, Any]], bytes]:
|
||||
"""Every PNG chunk in order (type, length; text chunks decoded and
|
||||
inflated, binary chunks as base64) plus the post-IEND trailer bytes."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
post_iend = b""
|
||||
try:
|
||||
pos = 8
|
||||
while pos + 12 <= len(data):
|
||||
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
||||
ctype = data[pos + 4 : pos + 8].decode("latin-1")
|
||||
body = data[pos + 8 : pos + 8 + length]
|
||||
entry: dict[str, Any] = {"type": ctype, "length": length}
|
||||
if ctype in ("tEXt", "zTXt", "iTXt"):
|
||||
entry["text"] = _png_text_decode(ctype, body)
|
||||
if entry["text"].startswith("XML:com.adobe.xmp"):
|
||||
entry["kind"] = "xmp"
|
||||
elif ctype == "tIME" and length == 7:
|
||||
y, mo, d, h, mi, s = struct.unpack(">HBBBBB", body)
|
||||
entry["time"] = f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{s:02d}Z"
|
||||
elif ctype == "gAMA" and length == 4:
|
||||
entry["gamma"] = struct.unpack(">I", body)[0] / 100000
|
||||
elif ctype == "sRGB" and length == 1:
|
||||
entry["rendering_intent"] = body[0]
|
||||
elif ctype == "iCCP":
|
||||
nul = body.find(b"\x00")
|
||||
if nul > 0:
|
||||
entry["profile_name"] = body[:nul].decode("latin-1", "replace")
|
||||
entry["base64"] = _b64(body)
|
||||
elif ctype == "iDOT":
|
||||
# present in iOS/macOS screenshots
|
||||
entry["apple_screenshot_marker"] = True
|
||||
elif ctype in ("IHDR", "IDAT"):
|
||||
pass # pixel-data / header chunks: length is signal enough
|
||||
elif length:
|
||||
entry["base64"] = _b64(body)
|
||||
chunks.append(entry)
|
||||
pos += 12 + length
|
||||
if ctype == "IEND":
|
||||
post_iend = data[pos:]
|
||||
break
|
||||
except Exception as exc:
|
||||
chunks.append({"error": _safe_str(exc)})
|
||||
return chunks, post_iend
|
||||
|
||||
|
||||
def _set_jpeg_trailer(result: dict[str, Any], data: bytes, eoi: int) -> None:
|
||||
"""Preserve bytes after JPEG EOI for Samsung Galaxy AI detection."""
|
||||
trailer = data[eoi + 2 :]
|
||||
result["post_eoi_bytes"] = len(trailer)
|
||||
if trailer:
|
||||
result["post_eoi_base64"] = _b64(trailer)
|
||||
|
||||
|
||||
def read_jpeg_segments(data: bytes) -> dict[str, Any]:
|
||||
"""Every JPEG APP segment in order, plus post-EOI trailer size.
|
||||
|
||||
XMP APP1 segments are kept as full text; every other segment body is
|
||||
kept as full base64 (1 MB ceiling per segment).
|
||||
"""
|
||||
result: dict[str, Any] = {"segments": [], "post_eoi_bytes": 0}
|
||||
try:
|
||||
pos = 2
|
||||
while pos + 4 <= len(data):
|
||||
if data[pos] != 0xFF:
|
||||
break
|
||||
marker = data[pos + 1]
|
||||
if marker == 0xD9: # EOI
|
||||
_set_jpeg_trailer(result, data, pos)
|
||||
break
|
||||
if marker == 0xDA: # SOS: entropy-coded data follows
|
||||
eoi = data.rfind(b"\xff\xd9")
|
||||
if eoi != -1:
|
||||
_set_jpeg_trailer(result, data, eoi)
|
||||
break
|
||||
if not (0xE0 <= marker <= 0xEF):
|
||||
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
||||
pos += 2 + length
|
||||
continue
|
||||
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
||||
body = data[pos + 4 : pos + 2 + length]
|
||||
name = f"APP{marker - 0xE0}"
|
||||
entry: dict[str, Any] = {"marker": name, "length": length}
|
||||
# Adobe JPEG XMP APP1 magic (namespace URI in the packet, not a request).
|
||||
if body.startswith(b"http://ns.adobe.com/xap/1.0/\x00"): # NOSONAR
|
||||
entry["kind"] = "xmp"
|
||||
entry["text"] = body[29:].decode("utf-8", "replace")
|
||||
elif name == "APP2" and body.startswith(b"MPF\x00"):
|
||||
# Multi-Picture Format: Ultra HDR gain map, Samsung dual shot
|
||||
entry["kind"] = "mpf"
|
||||
entry["base64"] = _b64(body)
|
||||
elif name == "APP2" and body.startswith(b"ICC_PROFILE"):
|
||||
entry["kind"] = "icc"
|
||||
entry["base64"] = _b64(body)
|
||||
elif name == "APP2" and body.startswith(b"FPXR"):
|
||||
entry["kind"] = "flashpix"
|
||||
entry["base64"] = _b64(body)
|
||||
elif name == "APP11":
|
||||
entry["kind"] = "c2pa_or_jumbf"
|
||||
# the parsed manifest is in c2pa_store, but the raw JUMBF
|
||||
# also carries assertion thumbnails the JSON may omit
|
||||
entry["base64"] = _b64(body)
|
||||
elif body.startswith(b"Exif\x00\x00"):
|
||||
entry["kind"] = "exif"
|
||||
entry["base64"] = _b64(body)
|
||||
elif body.startswith(b"Photoshop 3.0\x00"):
|
||||
entry["kind"] = "iptc_iim"
|
||||
entry["base64"] = _b64(body)
|
||||
else:
|
||||
entry["base64"] = _b64(body)
|
||||
result["segments"].append(entry)
|
||||
pos += 2 + length
|
||||
except Exception as exc:
|
||||
result["error"] = _safe_str(exc)
|
||||
return result
|
||||
|
||||
|
||||
def read_pil_info(path: Path) -> tuple[dict[str, Any], dict[str, Any], bytes | None]:
|
||||
"""One Image.open serving all PIL-derived data: container basics,
|
||||
img.info passthrough (XMP, comments), the IPTC-IIM dataset, and the
|
||||
raw EXIF blob (for the caller's piexif parse on PNG/WebP/HEIC)."""
|
||||
out: dict[str, Any] = {}
|
||||
iptc: dict[str, Any] = {}
|
||||
exif_blob: bytes | None = None
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
out["format"] = img.format
|
||||
out["mode"] = img.mode
|
||||
out["width"], out["height"] = img.size
|
||||
out["n_frames"] = getattr(img, "n_frames", 1)
|
||||
dpi = img.info.get("dpi")
|
||||
if dpi:
|
||||
out["dpi"] = [round(float(d), 2) for d in dpi]
|
||||
icc = img.info.get("icc_profile")
|
||||
if icc:
|
||||
out["icc_profile"] = {
|
||||
"length": len(icc),
|
||||
# header: profile class, color space, PCS (bytes 12-24)
|
||||
"header_hex": icc[12:24].hex() if len(icc) >= 24 else "",
|
||||
"base64": _b64(icc),
|
||||
}
|
||||
blob = img.info.get("exif")
|
||||
if isinstance(blob, bytes):
|
||||
exif_blob = blob
|
||||
try:
|
||||
info = getiptcinfo(img)
|
||||
except Exception:
|
||||
info = None
|
||||
if info:
|
||||
iptc = {f"{k[0]}:{k[1]}": _decode_exif_value(v) for k, v in info.items()}
|
||||
for key, value in img.info.items():
|
||||
if key in ("icc_profile", "exif", "dpi"):
|
||||
continue
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
out[f"info:{key}"] = value.decode("utf-8", "strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
out[f"info:{key}"] = f"base64:{_b64(value)}"
|
||||
else:
|
||||
out[f"info:{key}"] = _safe_str(value)
|
||||
except Exception as exc:
|
||||
out["error"] = _safe_str(exc)
|
||||
return out, iptc, exif_blob
|
||||
|
||||
|
||||
def read_c2pa_store(path: Path) -> dict[str, Any]:
|
||||
"""Full C2PA manifest store through the package's cached reader."""
|
||||
from remove_ai_watermarks._internal.c2pa import read_manifest_store_json
|
||||
|
||||
raw = read_manifest_store_json(path)
|
||||
if raw is None:
|
||||
return {}
|
||||
try:
|
||||
value: Any = json.loads(raw)
|
||||
return (
|
||||
cast("dict[str, Any]", value)
|
||||
if isinstance(value, dict)
|
||||
else {"error": "C2PA manifest store is not an object"}
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
return {"error": _safe_str(exc)}
|
||||
|
||||
|
||||
def sniff_format(head: bytes) -> str:
|
||||
if head.startswith(b"\x89PNG"):
|
||||
return "png"
|
||||
if head.startswith(b"\xff\xd8"):
|
||||
return "jpeg"
|
||||
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
|
||||
return "webp"
|
||||
if head[:6] in (b"GIF87a", b"GIF89a"):
|
||||
return "gif"
|
||||
if head.startswith(b"BM"):
|
||||
return "bmp"
|
||||
if head.startswith((b"II*\x00", b"MM\x00*")):
|
||||
return "tiff"
|
||||
if head[4:8] == b"ftyp":
|
||||
return f"isobmff:{head[8:12].decode('latin-1', 'replace')}"
|
||||
return f"unknown:{head[:16].hex()}"
|
||||
|
||||
|
||||
# --- JPEG encoder structure (metadata layer) ---
|
||||
|
||||
|
||||
def _jpeg_forensics_bytes(data: bytes) -> dict[str, Any]:
|
||||
"""Structure-level JPEG forensics: DQT tables (encoder fingerprint),
|
||||
SOF type (baseline/progressive) + chroma subsampling, DHT Huffman
|
||||
tables (custom = optimizing encoder), per-scan spectral selection
|
||||
(progressive scan script), JFIF/Adobe app markers, COM, DRI."""
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
if not data.startswith(b"\xff\xd8"):
|
||||
return out
|
||||
pos = 2
|
||||
scans: list[dict[str, int]] = []
|
||||
dqt: dict[str, list[int]] = {}
|
||||
dht: list[str] = []
|
||||
comments: list[str] = []
|
||||
while pos + 4 <= len(data):
|
||||
if data[pos] != 0xFF:
|
||||
break
|
||||
marker = data[pos + 1]
|
||||
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
||||
pos += 2
|
||||
continue
|
||||
if marker == 0xD9:
|
||||
break
|
||||
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
||||
body = data[pos + 4 : pos + 2 + length]
|
||||
if marker == 0xDB: # DQT
|
||||
off = 0
|
||||
while off < len(body):
|
||||
tid = body[off] & 0x0F
|
||||
prec = body[off] >> 4
|
||||
n = 128 if prec else 64
|
||||
vals = list(body[off + 1 : off + 1 + n])
|
||||
if prec: # 16-bit entries
|
||||
vals = [struct.unpack(">H", bytes(vals[i : i + 2]))[0] for i in range(0, len(vals) - 1, 2)]
|
||||
dqt[str(tid)] = vals[:64]
|
||||
off += 1 + n
|
||||
elif marker == 0xC4: # DHT: custom tables mean an optimizing encoder
|
||||
dht.append(body.hex())
|
||||
elif marker == 0xDD and len(body) >= 2: # DRI
|
||||
out["restart_interval"] = struct.unpack(">H", body[:2])[0]
|
||||
elif marker == 0xE0 and body.startswith(b"JFIF\x00") and len(body) >= 12:
|
||||
out["jfif"] = {
|
||||
"version": f"{body[5]}.{body[6]}",
|
||||
"density_units": body[7],
|
||||
"x_density": struct.unpack(">H", body[8:10])[0],
|
||||
"y_density": struct.unpack(">H", body[10:12])[0],
|
||||
}
|
||||
elif marker == 0xEE and body.startswith(b"Adobe") and len(body) >= 12:
|
||||
out["adobe_transform"] = body[11]
|
||||
elif marker in (0xC0, 0xC1, 0xC2) and len(body) >= 6:
|
||||
out["progressive"] = marker == 0xC2
|
||||
out["precision_bits"] = body[0]
|
||||
out["sof_height"] = struct.unpack(">H", body[1:3])[0]
|
||||
out["sof_width"] = struct.unpack(">H", body[3:5])[0]
|
||||
comps: list[dict[str, int]] = []
|
||||
for i in range(body[5]):
|
||||
c = body[6 + i * 3 : 9 + i * 3]
|
||||
if len(c) == 3:
|
||||
comps.append({"h": c[1] >> 4, "v": c[1] & 0x0F, "tq": c[2]})
|
||||
if len(comps) >= 3:
|
||||
lum = comps[0]
|
||||
subs = {1: "4:4:4", 2: "4:2:2"}.get(lum["h"] * lum["v"])
|
||||
out["subsampling"] = subs or f"{lum['h']}x{lum['v']}"
|
||||
elif marker == 0xFE: # COM
|
||||
comments.append(body.decode("utf-8", "replace")[:2000])
|
||||
elif marker == 0xDA:
|
||||
# SOS spectral selection: the progressive scan script
|
||||
# differs across libjpeg / mozjpeg / Photoshop
|
||||
if len(body) >= 3:
|
||||
ns = body[0]
|
||||
tail = body[1 + ns * 2 :]
|
||||
if len(tail) >= 3:
|
||||
scans.append({"ss": tail[0], "se": tail[1], "ah": tail[2] >> 4, "al": tail[2] & 0x0F})
|
||||
# skip entropy-coded data to the next marker
|
||||
end = data.find(b"\xff\xd9", pos)
|
||||
nxt = data.find(b"\xff", pos + 2)
|
||||
while nxt != -1 and nxt + 1 < len(data) and data[nxt + 1] == 0x00:
|
||||
nxt = data.find(b"\xff", nxt + 2)
|
||||
if nxt == -1 or (end != -1 and nxt >= end):
|
||||
break
|
||||
pos = nxt
|
||||
continue
|
||||
pos += 2 + length
|
||||
if dqt:
|
||||
out["quant_tables"] = dqt
|
||||
if dht:
|
||||
out["huffman_tables_hex"] = dht
|
||||
if comments:
|
||||
out["comments"] = comments
|
||||
if scans:
|
||||
out["scan_count"] = len(scans)
|
||||
out["scan_script"] = scans
|
||||
except Exception as exc:
|
||||
out["error"] = _safe_str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def read_webp_chunks(data: bytes) -> list[dict[str, Any]]:
|
||||
"""WebP RIFF chunk inventory (VP8X/VP8/VP8L/EXIF/XMP/ICCP/ANIM...)."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
try:
|
||||
pos = 12
|
||||
declared_end = 8 + struct.unpack("<I", data[4:8])[0] if len(data) >= 12 else len(data)
|
||||
container_end = min(len(data), declared_end)
|
||||
while pos + 8 <= container_end:
|
||||
chunk_type = data[pos : pos + 4]
|
||||
ctype = chunk_type.decode("latin-1")
|
||||
length = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
|
||||
body = data[pos + 8 : min(pos + 8 + length, container_end)]
|
||||
entry: dict[str, Any] = {"type": ctype, "length": length}
|
||||
if ctype == "XMP ":
|
||||
entry["kind"] = "xmp"
|
||||
entry["text"] = body.decode("utf-8", "replace")
|
||||
elif chunk_type in RIFF_CODED_IMAGE_CHUNKS:
|
||||
pass # pixel-data chunks: length is signal enough
|
||||
elif length:
|
||||
entry["base64"] = _b64(body)
|
||||
chunks.append(entry)
|
||||
pos += 8 + length + (length & 1) # chunks are 2-byte aligned
|
||||
except Exception as exc:
|
||||
chunks.append({"error": _safe_str(exc)})
|
||||
return chunks
|
||||
|
||||
|
||||
def read_webp_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[dict[str, Any]]:
|
||||
"""Stream metadata chunks after ``window`` while seeking over coded frames."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with open(path, "rb") as handle:
|
||||
header = handle.read(12)
|
||||
if len(header) < 12 or not header.startswith(b"RIFF") or header[8:12] != b"WEBP":
|
||||
return chunks
|
||||
container_end = min(file_size, 8 + struct.unpack("<I", header[4:8])[0])
|
||||
position = 12
|
||||
while position + 8 <= container_end:
|
||||
handle.seek(position)
|
||||
chunk_header = handle.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
break
|
||||
chunk_type = chunk_header[:4]
|
||||
(length,) = struct.unpack("<I", chunk_header[4:8])
|
||||
start = position + 8
|
||||
safe_length = max(0, min(length, container_end - start))
|
||||
if chunk_type in RIFF_METADATA_CHUNKS and start >= window:
|
||||
handle.seek(start)
|
||||
body = handle.read(min(safe_length, _B64_CAP))
|
||||
entry: dict[str, Any] = {
|
||||
"type": chunk_type.decode("latin-1"),
|
||||
"length": length,
|
||||
"base64": _b64(body),
|
||||
}
|
||||
if len(body) < safe_length:
|
||||
entry["truncated"] = True
|
||||
chunks.append(entry)
|
||||
position = start + safe_length + (safe_length & 1)
|
||||
except (OSError, struct.error) as exc:
|
||||
chunks.append({"error": _safe_str(exc)})
|
||||
return chunks
|
||||
|
||||
|
||||
def sha256_of(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def xattr_where_from(path: Path) -> list[str]:
|
||||
"""macOS download-source URLs (kMDItemWhereFroms), empty elsewhere."""
|
||||
try:
|
||||
getter = cast("Any", os.getxattr) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
raw = cast("bytes", getter(path, "com.apple.metadata:kMDItemWhereFroms"))
|
||||
value = plistlib.loads(raw)
|
||||
values = cast("list[Any]", value) if isinstance(value, list) else [value]
|
||||
return [str(item) for item in values]
|
||||
except (AttributeError, OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def xattr_quarantine(path: Path) -> str | None:
|
||||
"""macOS quarantine string: flags; timestamp; downloading agent (Safari,
|
||||
Telegram, Chrome...). Presence alone means 'came from the internet'."""
|
||||
try:
|
||||
getter = cast("Any", os.getxattr) # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
|
||||
raw = cast("bytes", getter(path, "com.apple.quarantine"))
|
||||
return raw.decode("utf-8", "replace")[:500]
|
||||
except (AttributeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def read_isobmff_inventory(data: bytes) -> dict[str, Any]:
|
||||
"""HEIC/AVIF/MOV box inventory: top-level boxes plus the meta item
|
||||
types (Exif, mime=XMP, auxl depth/gain-map, aae Apple-edits plist,
|
||||
irot derived images). Strong phone-provenance signal."""
|
||||
out: dict[str, Any] = {}
|
||||
try:
|
||||
stream = io.BytesIO(data)
|
||||
|
||||
def boxes(start: int, end: int) -> list[tuple[str, int, int]]:
|
||||
return [
|
||||
(box_type.decode("latin-1"), payload_offset, box_end)
|
||||
for _, box_end, box_type, payload_offset in iter_file_boxes(stream, start, end)
|
||||
]
|
||||
|
||||
top = boxes(0, len(data))
|
||||
out["boxes"] = [t for t, _, _ in top]
|
||||
provenance_boxes: list[dict[str, Any]] = []
|
||||
for t, s, e in top:
|
||||
if t.encode("latin-1") in C2PA_BOX_TYPES:
|
||||
provenance_boxes.append(
|
||||
{"type": t, "length": e - s, "base64": _b64(data[s:e], cap=_PROVENANCE_B64_CAP)}
|
||||
)
|
||||
if t == "moov":
|
||||
for ct, cs, ce in boxes(s, e):
|
||||
if ct == "mvhd" and ce - cs >= 24:
|
||||
# full box + creation/modification times (1904 epoch)
|
||||
version = data[cs]
|
||||
base = cs + 4
|
||||
creation = struct.unpack(">I", data[base : base + 4])[0] if version == 0 else None
|
||||
if creation:
|
||||
out["mvhd_creation_time"] = creation - 2082844800
|
||||
elif t == "meta":
|
||||
# full box: 4 bytes version/flags, then child boxes
|
||||
for ct, cs, ce in boxes(s + 4, e):
|
||||
if ct == "iinf":
|
||||
# full box + entry count, then infe entries
|
||||
count = struct.unpack(">H", data[cs + 4 : cs + 6])[0]
|
||||
out["meta_item_count"] = count
|
||||
item_types: list[str] = []
|
||||
for it, is_, ie in boxes(cs + 6, ce):
|
||||
if it == "infe" and ie - is_ >= 8:
|
||||
# infe full box: version(1)+flags(3), then
|
||||
# v2: item_ID(2)+protection(2)+item_type(4)
|
||||
# v3: item_ID(4)+protection(2)+item_type(4)
|
||||
version = data[is_]
|
||||
off = is_ + 4 + (4 if version == 3 else 2) + 2
|
||||
if off + 4 <= ie:
|
||||
item_types.append(data[off : off + 4].decode("latin-1", "replace"))
|
||||
if item_types:
|
||||
out["meta_item_types"] = sorted(set(item_types))
|
||||
elif ct == "iprp":
|
||||
out["has_iprp"] = True
|
||||
for pt, ps, pe in boxes(cs, ce):
|
||||
if pt == "ipco":
|
||||
props = [t for t, _, _ in boxes(ps, pe)]
|
||||
out["ipco_properties"] = props
|
||||
# auxC holds the auxiliary image type URN
|
||||
for box_type, qs, qe in boxes(ps, pe):
|
||||
if box_type == "auxC":
|
||||
out["auxc_types"] = (
|
||||
data[qs + 4 : qe].split(b"\x00")[0].decode("latin-1", "replace")
|
||||
)
|
||||
elif ct == "iref":
|
||||
out["has_iref"] = True
|
||||
if provenance_boxes:
|
||||
out["provenance_boxes"] = provenance_boxes
|
||||
# QuickTime metadata keys (©mak/©mod/©swr) for the MOV side of
|
||||
# Live Photos: tolerant printable-string grab after each atom
|
||||
qt: dict[str, str] = {}
|
||||
for atom, key in ((b"\xa9mak", "make"), (b"\xa9mod", "model"), (b"\xa9swr", "software")):
|
||||
idx = data.find(atom)
|
||||
if idx != -1:
|
||||
m = re.search(rb"[ -~]{4,80}", data[idx + 4 : idx + 200])
|
||||
if m:
|
||||
qt[key] = m.group(0).decode("ascii", "replace")
|
||||
if qt:
|
||||
out["quicktime"] = qt
|
||||
except Exception as exc:
|
||||
out["error"] = _safe_str(exc)
|
||||
return out
|
||||
|
||||
|
||||
def read_isobmff_provenance_path(path: Path) -> dict[str, Any]:
|
||||
"""Stream top-level ISOBMFF boxes and preserve provenance payloads.
|
||||
|
||||
This is the large-file counterpart to :func:`read_isobmff_inventory`.
|
||||
It seeks over media payloads instead of loading them into memory.
|
||||
"""
|
||||
out: dict[str, Any] = {"boxes": []}
|
||||
provenance_boxes: list[dict[str, Any]] = []
|
||||
collected = 0
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with open(path, "rb") as f:
|
||||
for _, box_end, box_type_raw, payload_offset in iter_file_boxes(f, 0, file_size):
|
||||
box_type = box_type_raw.decode("latin-1")
|
||||
out["boxes"].append(box_type)
|
||||
payload_length = box_end - payload_offset
|
||||
if box_type_raw in C2PA_BOX_TYPES and collected < _PROVENANCE_B64_CAP:
|
||||
to_read = min(payload_length, _PROVENANCE_B64_CAP - collected)
|
||||
f.seek(payload_offset)
|
||||
payload = f.read(to_read)
|
||||
entry: dict[str, Any] = {
|
||||
"type": box_type,
|
||||
"length": payload_length,
|
||||
"base64": _b64(payload, cap=_PROVENANCE_B64_CAP),
|
||||
}
|
||||
if to_read < payload_length:
|
||||
entry["truncated"] = True
|
||||
provenance_boxes.append(entry)
|
||||
collected += len(payload)
|
||||
except (OSError, struct.error) as exc:
|
||||
out["error"] = _safe_str(exc)
|
||||
if provenance_boxes:
|
||||
out["provenance_boxes"] = provenance_boxes
|
||||
return out
|
||||
|
||||
|
||||
def read_png_late_metadata_path(path: Path, window: int = _RAW_SCAN_HEAD) -> list[dict[str, Any]]:
|
||||
"""Stream PNG metadata chunks whose payload starts after ``window``."""
|
||||
chunks: list[dict[str, Any]] = []
|
||||
try:
|
||||
file_size = path.stat().st_size
|
||||
with open(path, "rb") as f:
|
||||
if f.read(8) != b"\x89PNG\r\n\x1a\n":
|
||||
return chunks
|
||||
pos = 8
|
||||
while pos + 12 <= file_size:
|
||||
f.seek(pos)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
length, chunk_type = struct.unpack(">I4s", header)
|
||||
data_start = pos + 8
|
||||
safe_length = max(0, min(length, file_size - data_start))
|
||||
if chunk_type in PNG_METADATA_CHUNKS and data_start >= window:
|
||||
body = f.read(min(safe_length, _B64_CAP))
|
||||
entry: dict[str, Any] = {
|
||||
"type": chunk_type.decode("latin-1"),
|
||||
"length": length,
|
||||
"base64": _b64(body),
|
||||
}
|
||||
if len(body) < safe_length:
|
||||
entry["truncated"] = True
|
||||
chunks.append(entry)
|
||||
pos = data_start + safe_length + 4
|
||||
if chunk_type == b"IEND":
|
||||
break
|
||||
except (OSError, struct.error) as exc:
|
||||
chunks.append({"error": _safe_str(exc)})
|
||||
return chunks
|
||||
|
||||
|
||||
def apple_live_photo_id(head: bytes) -> str | None:
|
||||
"""Apple Live Photo content identifier (links the still to its MOV).
|
||||
|
||||
The UUID sits in the Apple MakerNote (tag 17) of the still and in the
|
||||
MOV metadata; a raw head scan finds it in either container."""
|
||||
# the UUID string sits next to "content.identifier" in the MOV, but in
|
||||
# the STILL it is a bare UUID inside the Apple MakerNote (whose header
|
||||
# is "Apple iOS"), so gate on either marker
|
||||
if b"content.identifier" not in head and b"com.apple.quicktime" not in head and b"Apple iOS" not in head:
|
||||
return None
|
||||
m = re.search(rb"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", head)
|
||||
return m.group(0).decode("ascii") if m else None
|
||||
|
||||
|
||||
_MAX_FULL_READ = 256 << 20 # files bigger than this are scanned head-only
|
||||
_HEAD_READ = 4 << 20
|
||||
|
||||
|
||||
def _sha256_stream(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for block in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def collect_forensic_metadata(
|
||||
path: Path,
|
||||
*,
|
||||
schema_version: int = FORENSIC_METADATA_SCHEMA_VERSION,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect the versioned, metadata-only forensic record for ``path``.
|
||||
|
||||
This broad inspection record is not provenance-detector input. Use
|
||||
:func:`remove_ai_watermarks.metadata_record.collect_metadata_record` for the
|
||||
strict record accepted by ``identify_metadata_record``. Long-lived consumers
|
||||
should request the schema they implement; unsupported versions raise before the
|
||||
source is read.
|
||||
"""
|
||||
schema_version = require_schema_version(
|
||||
schema_version,
|
||||
contract="forensic metadata",
|
||||
supported=(1,),
|
||||
)
|
||||
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
|
||||
stat = path.stat()
|
||||
oversized = stat.st_size > _MAX_FULL_READ
|
||||
if oversized:
|
||||
data = None
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(_HEAD_READ)
|
||||
else:
|
||||
data = path.read_bytes()
|
||||
head = data
|
||||
record: dict[str, Any] = {
|
||||
"schema_version": schema_version,
|
||||
"record_type": FORENSIC_METADATA_RECORD_TYPE,
|
||||
"file": str(path),
|
||||
"name": path.name,
|
||||
"extension": path.suffix.lower(),
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
"birthtime": getattr(stat, "st_birthtime", None),
|
||||
"sha256": _sha256_stream(path) if data is None else sha256_of(data),
|
||||
"content_format": sniff_format(head),
|
||||
}
|
||||
if oversized:
|
||||
# Preserve the same bounded byte windows used by downstream provenance
|
||||
# algorithms while path-based readers (PIL, piexif, C2PA) run normally.
|
||||
record["oversized"] = {"head_scanned_bytes": len(head)}
|
||||
record["raw_metadata_windows"] = {"head_base64": _b64(head[:_RAW_SCAN_HEAD])}
|
||||
if stat.st_size > _RAW_SCAN_TAIL:
|
||||
with open(path, "rb") as f:
|
||||
f.seek(-_RAW_SCAN_TAIL, 2)
|
||||
record["raw_metadata_windows"]["tail_base64"] = _b64(f.read())
|
||||
where_from = xattr_where_from(path)
|
||||
if where_from:
|
||||
record["download_source_urls"] = where_from
|
||||
quarantine = xattr_quarantine(path)
|
||||
if quarantine:
|
||||
record["quarantine"] = quarantine
|
||||
live_photo_id = apple_live_photo_id(head[: 2 << 20])
|
||||
if live_photo_id:
|
||||
record["live_photo_content_id"] = live_photo_id
|
||||
record["pil"], record["iptc"], exif_blob = read_pil_info(path)
|
||||
record["exif"], thumbnail = read_full_exif(path, exif_blob, data)
|
||||
record["c2pa_store"] = read_c2pa_store(path)
|
||||
if data is not None:
|
||||
fmt = record["content_format"]
|
||||
if fmt == "png":
|
||||
record["png_chunks"], post_iend = read_png_chunks(data)
|
||||
if post_iend:
|
||||
record["png_post_iend_bytes"] = len(post_iend)
|
||||
record["png_post_iend_base64"] = _b64(post_iend)
|
||||
elif fmt == "jpeg":
|
||||
record["jpeg"] = read_jpeg_segments(data)
|
||||
record["jpeg_forensics"] = _jpeg_forensics_bytes(data)
|
||||
elif fmt == "webp":
|
||||
record["webp_chunks"] = read_webp_chunks(data)
|
||||
elif fmt.startswith("isobmff"):
|
||||
record["isobmff"] = read_isobmff_inventory(data)
|
||||
elif record["content_format"] == "png":
|
||||
late_chunks = read_png_late_metadata_path(path)
|
||||
if late_chunks:
|
||||
record["png_late_metadata_chunks"] = late_chunks
|
||||
elif record["content_format"] == "webp":
|
||||
late_chunks = read_webp_late_metadata_path(path)
|
||||
if late_chunks:
|
||||
record["webp_late_metadata_chunks"] = late_chunks
|
||||
elif record["content_format"].startswith("isobmff"):
|
||||
record["isobmff"] = read_isobmff_provenance_path(path)
|
||||
if thumbnail:
|
||||
record["has_exif_thumbnail"] = True
|
||||
# the embedded thumbnail is its own JPEG; after an edit its encoder
|
||||
# forensics commonly MISMATCH the main image (classic tamper tell)
|
||||
thumb_forensics = _jpeg_forensics_bytes(thumbnail)
|
||||
thumb_forensics["base64"] = _b64(thumbnail)
|
||||
record["exif_thumbnail_forensics"] = thumb_forensics
|
||||
return record
|
||||
@@ -40,6 +40,7 @@ from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
)
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
AIGC_MARKERS,
|
||||
@@ -176,7 +177,32 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
|
||||
"""Index nested metadata and recover common encoded binary values in one pass."""
|
||||
pairs: list[tuple[str, Any]] = []
|
||||
parts: list[bytes] = []
|
||||
diagnostic_keys = {"error", "kind"}
|
||||
diagnostic_keys = {
|
||||
"artifacts",
|
||||
"birthtime",
|
||||
"color",
|
||||
"content_format",
|
||||
"dct",
|
||||
"ela",
|
||||
"error",
|
||||
"extension",
|
||||
"fft",
|
||||
"file",
|
||||
"filename",
|
||||
"full",
|
||||
"gradient",
|
||||
"kind",
|
||||
"mtime",
|
||||
"name",
|
||||
"noise",
|
||||
"path",
|
||||
"pixel",
|
||||
"provenance",
|
||||
"sha256",
|
||||
"signals",
|
||||
"size_bytes",
|
||||
"timing_ms",
|
||||
}
|
||||
|
||||
def visit(item: Any) -> None:
|
||||
if isinstance(item, dict):
|
||||
@@ -184,7 +210,6 @@ def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
|
||||
for key, nested in mapping.items():
|
||||
key_text = str(key)
|
||||
pairs.append((key_text, nested))
|
||||
parts.append(key_text.encode("utf-8", "replace"))
|
||||
if key_text.lower() in diagnostic_keys:
|
||||
continue
|
||||
if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")):
|
||||
@@ -248,17 +273,69 @@ def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str |
|
||||
return generator_from_metadata(candidates, scan)
|
||||
|
||||
|
||||
def _metadata_source_kind(info: dict[str, Any], scan: bytes) -> str | None:
|
||||
"""Normalize the source type wherever it is carried: C2PA or IPTC/XMP.
|
||||
|
||||
A composite marker contains ``TrainedAlgorithmicMedia`` as a substring, so it
|
||||
is removed before looking for a standalone full-generation marker. When a file
|
||||
genuinely carries both kinds, full generation wins.
|
||||
"""
|
||||
structured = info.get("ai_source_kind")
|
||||
without_composites = scan.replace(b"compositeWithTrainedAlgorithmicMedia", b"").replace(b"compositeSynthetic", b"")
|
||||
generated = structured == "generated" or any(
|
||||
marker in without_composites for marker in (b"trainedAlgorithmicMedia", b"TrainedAlgorithmicMedia")
|
||||
)
|
||||
if generated:
|
||||
return "generated"
|
||||
if structured == "enhanced" or any(
|
||||
marker in scan for marker in (b"compositeWithTrainedAlgorithmicMedia", b"compositeSynthetic")
|
||||
):
|
||||
return "enhanced"
|
||||
return None
|
||||
|
||||
|
||||
def evidence_from_metadata_record(
|
||||
record: dict[str, Any], *, path: Path, c2pa_manifest_store: str | dict[str, Any] | None = None
|
||||
) -> ProvenanceEvidence:
|
||||
"""Normalize an externally collected metadata record into provenance evidence.
|
||||
|
||||
The record may contain arbitrary nested dictionaries and lists. Text, bytes,
|
||||
hexadecimal values prefixed with ``hex:``, and fields named ``base64`` or
|
||||
ending in ``_base64`` are included in the shared byte scan. No source file is
|
||||
opened.
|
||||
Unversioned external records may contain arbitrary nested dictionaries and
|
||||
lists. Versioned native records accept only the source-derived fields emitted by
|
||||
``collect_metadata_record``; other native record types and unknown schema
|
||||
versions are rejected. No source file is opened.
|
||||
"""
|
||||
pairs, scan = _external_metadata(record)
|
||||
from remove_ai_watermarks.metadata_record import METADATA_RECORD_SCHEMA_VERSION, METADATA_RECORD_TYPE
|
||||
|
||||
# Records produced by ``collect_metadata_record`` are a versioned transport
|
||||
# contract. Only their source-derived fields are evidence: the filename,
|
||||
# container label and schema bookkeeping describe the collector and must never
|
||||
# become detector input. Shape-detect the pre-versioned form as well so records
|
||||
# emitted by 0.26 remain safe and readable.
|
||||
record_type = record.get("record_type")
|
||||
if record_type not in (None, METADATA_RECORD_TYPE):
|
||||
raise ValueError(f"Unsupported metadata record type: {record_type!r}")
|
||||
if record_type == METADATA_RECORD_TYPE:
|
||||
require_schema_version(
|
||||
record.get("schema_version"),
|
||||
contract="provenance metadata",
|
||||
supported=(METADATA_RECORD_SCHEMA_VERSION,),
|
||||
)
|
||||
status = record.get("status")
|
||||
if status == "error":
|
||||
raise ValueError("Provenance metadata collection failed")
|
||||
if status != "complete":
|
||||
raise ValueError(f"Unsupported provenance metadata collection status: {status!r}")
|
||||
is_portable_record = record_type == METADATA_RECORD_TYPE or {
|
||||
"container",
|
||||
"metadata_base64",
|
||||
"tail_base64",
|
||||
}.issubset(record)
|
||||
evidence_record = (
|
||||
{key: record[key] for key in ("metadata_base64", "tail_base64", "pil", "exif") if key in record}
|
||||
if is_portable_record
|
||||
else record
|
||||
)
|
||||
pairs, scan = _external_metadata(evidence_record)
|
||||
store = c2pa_manifest_store
|
||||
if store is None:
|
||||
candidate = record.get("c2pa_store")
|
||||
@@ -365,13 +442,13 @@ class ProvenanceReport:
|
||||
is_ai_generated: bool | None # True / False is never asserted; None = unknown
|
||||
platform: str | None
|
||||
confidence: str # "high" | "medium" | "none"
|
||||
# Coarse AI-origin kind from the C2PA digital-source-type, so a caller can
|
||||
# branch on full generation vs an AI-touched real photo:
|
||||
# Coarse AI-origin kind from a C2PA or standalone IPTC/XMP digital-source-type,
|
||||
# so a caller can branch on full generation vs an AI-touched real photo:
|
||||
# "generated" -- digitalSourceType trainedAlgorithmicMedia (fully AI).
|
||||
# "enhanced" -- compositeWithTrainedAlgorithmicMedia (real content with an
|
||||
# AI-composited region; scrub the AI region, keep the photo).
|
||||
# None -- no C2PA AI source-type (verdict, if AI, came from another
|
||||
# signal: IPTC, AIGC, local gen params, xAI, ...).
|
||||
# None -- no AI digital-source-type (verdict, if AI, came from another
|
||||
# signal: AIGC, local gen params, xAI, ...).
|
||||
ai_source_kind: str | None = None
|
||||
# True when the AI verdict rests on a metadata or embedded-invisible signal
|
||||
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
|
||||
@@ -390,14 +467,24 @@ class ProvenanceReport:
|
||||
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
|
||||
integrity_clashes: list[str] = field(default_factory=list[str])
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
def to_dict(
|
||||
self,
|
||||
*,
|
||||
schema_version: int = PROVENANCE_REPORT_SCHEMA_VERSION,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the versioned, JSON-safe verdict contract.
|
||||
|
||||
``path`` is deliberately omitted. It is extraction context, not part of the
|
||||
verdict, and local filesystem paths should not cross a service boundary.
|
||||
Request an explicit schema for a long-lived transport consumer.
|
||||
"""
|
||||
schema_version = require_schema_version(
|
||||
schema_version,
|
||||
contract="provenance report",
|
||||
supported=(1,),
|
||||
)
|
||||
return {
|
||||
"schema_version": PROVENANCE_REPORT_SCHEMA_VERSION,
|
||||
"schema_version": schema_version,
|
||||
"is_ai_generated": self.is_ai_generated,
|
||||
"platform": self.platform,
|
||||
"confidence": self.confidence,
|
||||
@@ -997,12 +1084,7 @@ def _identify_from_evidence(
|
||||
# _internal.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
|
||||
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
|
||||
# where extract_c2pa_info returns {}. Full generation wins when both appear.
|
||||
c2pa_source_kind = info.get("ai_source_kind")
|
||||
if c2pa_source_kind is None:
|
||||
if b"trainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "generated"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "enhanced"
|
||||
source_kind = _metadata_source_kind(info, head)
|
||||
# An identity-AI issuer (a pure-generator brand like Dreamina) asserts AI even
|
||||
# without a digitalSourceType -- some ByteDance/Dreamina manifests ship no
|
||||
# trainedAlgorithmicMedia, so the registered generator name is the only signal.
|
||||
@@ -1010,7 +1092,7 @@ def _identify_from_evidence(
|
||||
# does not reopen the incidental-mention problem the common-word issuers have.
|
||||
issuer_blob = " ".join(issuers)
|
||||
c2pa_identity_ai = has_c2pa and any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
|
||||
c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai
|
||||
c2pa_is_ai = source_kind is not None or c2pa_identity_ai
|
||||
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
|
||||
# for other containers. Best-effort -- some manifests key it as
|
||||
# `claim_generator_info` (Pixel), so this can be None even when a device is
|
||||
@@ -1066,7 +1148,7 @@ def _identify_from_evidence(
|
||||
# for its own callers; the verdict no longer depends on which extractor ran.
|
||||
synthid = meta.get("synthid_watermark")
|
||||
# The literal byte checks mirror `metadata.synthid_source` exactly rather than
|
||||
# reusing the derived `has_c2pa` / `c2pa_source_kind` above, which are broader:
|
||||
# reusing the derived `has_c2pa` / `source_kind` above, which are broader:
|
||||
# the file path's answer must not move.
|
||||
trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head
|
||||
if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_vendors_in(region)):
|
||||
@@ -1254,9 +1336,9 @@ def _identify_from_evidence(
|
||||
is_ai_generated=is_ai,
|
||||
platform=platform,
|
||||
confidence=confidence,
|
||||
# Only meaningful when the AI verdict actually came from the C2PA source
|
||||
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
|
||||
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
|
||||
# Meaningful for the same digitalSourceType whether carried by C2PA or a
|
||||
# standalone IPTC/XMP label. Other AI signals leave it None.
|
||||
ai_source_kind=source_kind if (is_ai and (has_c2pa or iptc)) else None,
|
||||
ai_from_metadata=ai_from_metadata,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
|
||||
@@ -18,6 +18,11 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterable
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks._internal.constants import (
|
||||
PNG_METADATA_CHUNKS,
|
||||
RIFF_METADATA_CHUNKS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Smaller scan_head window for the cheap marker checks (has_ai_metadata,
|
||||
@@ -236,11 +241,6 @@ def _is_ai_value(value: str) -> bool:
|
||||
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
|
||||
|
||||
|
||||
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
|
||||
# Never IDAT -- that is the compressed pixel stream.
|
||||
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
|
||||
|
||||
|
||||
def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
"""Payloads of PNG metadata chunks that start *beyond* the first ``window``
|
||||
bytes, found by seeking past the (large) ``IDAT`` pixel stream.
|
||||
@@ -272,7 +272,7 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
# Clamp the attacker-controlled 32-bit length to the bytes that
|
||||
# actually remain, so a malformed huge length can't allocate GBs.
|
||||
safe_length = max(0, min(length, file_size - data_start))
|
||||
if chunk_type in _PNG_META_CHUNKS and data_start >= window:
|
||||
if chunk_type in PNG_METADATA_CHUNKS and data_start >= window:
|
||||
f.seek(data_start)
|
||||
out += f.read(safe_length)
|
||||
# Advance by the CLAMPED length: a malformed/inflated `length` that
|
||||
@@ -285,10 +285,6 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# RIFF/WebP chunks that carry metadata rather than coded pixels.
|
||||
_RIFF_META_CHUNKS: frozenset[bytes] = frozenset({b"EXIF", b"XMP ", b"ICCP", b"C2PA"})
|
||||
|
||||
|
||||
def _riff_late_metadata(image_path: Path, window: int, *, max_total: int = 4 * 1024 * 1024) -> bytes:
|
||||
"""Payloads of RIFF metadata chunks that start *beyond* the first ``window``
|
||||
bytes, found by stepping over the (large) coded-image chunk.
|
||||
@@ -311,8 +307,13 @@ def _riff_late_metadata(image_path: Path, window: int, *, max_total: int = 4 * 1
|
||||
return b""
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
f.seek(4)
|
||||
declared_size = f.read(4)
|
||||
if len(declared_size) < 4:
|
||||
return b""
|
||||
container_end = min(file_size, 8 + struct.unpack("<I", declared_size)[0])
|
||||
position = 12 # 'RIFF' + size + form type
|
||||
while position + 8 <= file_size and len(out) < max_total:
|
||||
while position + 8 <= container_end and len(out) < max_total:
|
||||
f.seek(position)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
@@ -322,8 +323,8 @@ def _riff_late_metadata(image_path: Path, window: int, *, max_total: int = 4 * 1
|
||||
start = position + 8
|
||||
# Clamp to what remains: 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, file_size - start))
|
||||
if chunk_type in _RIFF_META_CHUNKS and start >= window:
|
||||
safe_length = max(0, min(length, container_end - start))
|
||||
if chunk_type in RIFF_METADATA_CHUNKS and start >= window:
|
||||
f.seek(start)
|
||||
out += f.read(min(safe_length, max_total - len(out)))
|
||||
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
|
||||
@@ -1603,4 +1604,5 @@ QUICK_SCAN_BYTES = _QUICK_SCAN_BYTES
|
||||
SAMSUNG_EDITOR_MARKER = _SAMSUNG_EDITOR_MARKER
|
||||
read_file_tail = _read_file_tail
|
||||
png_late_metadata = _png_late_metadata
|
||||
riff_late_metadata = _riff_late_metadata
|
||||
exif_text = _exif_text
|
||||
|
||||
@@ -45,7 +45,8 @@ 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._internal.constants import PNG_SIGNATURE, RIFF_CODED_IMAGE_CHUNKS
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import (
|
||||
QUICK_SCAN_BYTES,
|
||||
SAMSUNG_EDITOR_MARKER,
|
||||
@@ -73,8 +74,11 @@ 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"})
|
||||
# Stable transport contract for records produced by this module. The version is
|
||||
# deliberately separate from the verdict version: collection and interpretation can
|
||||
# evolve independently as long as old records remain readable.
|
||||
METADATA_RECORD_SCHEMA_VERSION = 1
|
||||
METADATA_RECORD_TYPE = "provenance_metadata"
|
||||
|
||||
|
||||
def _jpeg_regions(data: bytes) -> bytes:
|
||||
@@ -145,14 +149,15 @@ def _riff_regions(data: bytes) -> bytes:
|
||||
input models.
|
||||
"""
|
||||
out = bytearray(data[:12]) # 'RIFF' + size + 'WEBP'
|
||||
size = len(data)
|
||||
declared_end = 8 + struct.unpack("<I", data[4:8])[0] if len(data) >= 12 else len(data)
|
||||
size = min(len(data), declared_end)
|
||||
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:
|
||||
if chunk_type not in RIFF_CODED_IMAGE_CHUNKS:
|
||||
out += chunk_type + data[start : start + safe_length]
|
||||
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
|
||||
return bytes(out)
|
||||
@@ -189,7 +194,7 @@ def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
|
||||
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
|
||||
from remove_ai_watermarks.metadata import png_late_metadata, riff_late_metadata
|
||||
|
||||
if head.startswith(b"\xff\xd8"):
|
||||
return "jpeg", _jpeg_regions(head)
|
||||
@@ -198,7 +203,7 @@ def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
|
||||
# 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)
|
||||
return "webp", _riff_regions(head) + riff_late_metadata(image_path, HEAD_WINDOW)
|
||||
if is_isobmff(head):
|
||||
return "isobmff", _isobmff_regions(image_path, head)
|
||||
return "unknown", head
|
||||
@@ -223,6 +228,31 @@ def _trailer(image_path: Path, container: str) -> bytes:
|
||||
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.
|
||||
"""
|
||||
if container == "webp":
|
||||
# RIFF declares its structural end in bytes 4..8. A fixed tail window is
|
||||
# normally the last animation/frame payload, not a trailer, so preserve
|
||||
# only bytes appended after the declared RIFF container.
|
||||
try:
|
||||
with open(image_path, "rb") as handle:
|
||||
header = handle.read(12)
|
||||
if len(header) < 12 or not header.startswith(b"RIFF"):
|
||||
return b""
|
||||
declared_end = 8 + struct.unpack("<I", header[4:8])[0]
|
||||
handle.seek(0, 2)
|
||||
file_size = handle.tell()
|
||||
if declared_end < 12 or declared_end >= file_size:
|
||||
return b""
|
||||
handle.seek(declared_end)
|
||||
return handle.read(min(file_size - declared_end, UNKNOWN_TRAILER_WINDOW))
|
||||
except OSError as exc:
|
||||
logger.debug("RIFF trailer read failed for %s: %s", image_path, exc)
|
||||
return b""
|
||||
if container == "isobmff":
|
||||
# ISOBMFF has no out-of-container trailer convention. Its bounded box
|
||||
# walkers already collect late provenance while skipping ``mdat``; keeping
|
||||
# a blind tail here would carry coded media bytes.
|
||||
return b""
|
||||
|
||||
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
|
||||
@@ -311,27 +341,52 @@ def _pil_info(info: dict[str, Any]) -> dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def collect_metadata_record(image_path: Path) -> dict[str, Any]:
|
||||
def collect_metadata_record(
|
||||
image_path: Path,
|
||||
*,
|
||||
schema_version: int = METADATA_RECORD_SCHEMA_VERSION,
|
||||
) -> 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.
|
||||
(base64), the C2PA manifest store, and PIL's info mapping without carrying the
|
||||
primary coded-pixel stream. Schema and collection status are explicit so a
|
||||
consumer cannot mistake a failed read for an unknown provenance verdict.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image.
|
||||
schema_version: Output schema implemented by the consumer.
|
||||
|
||||
Returns:
|
||||
A JSON-serializable dict. ``metadata_base64`` holds the concatenated
|
||||
container regions, ``tail_base64`` the file trailer.
|
||||
A versioned JSON-serializable dict. ``metadata_base64`` holds the
|
||||
concatenated container regions, ``tail_base64`` the file trailer.
|
||||
"""
|
||||
schema_version = require_schema_version(
|
||||
schema_version,
|
||||
contract="provenance metadata",
|
||||
supported=(1,),
|
||||
)
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import read_manifest_store_json
|
||||
|
||||
try:
|
||||
image_path.stat()
|
||||
status = "complete"
|
||||
issues: list[dict[str, str]] = []
|
||||
except OSError as exc:
|
||||
logger.debug("metadata source unavailable for %s: %s", image_path, exc)
|
||||
status = "error"
|
||||
issues = [{"stage": "source", "code": "unavailable"}]
|
||||
|
||||
container, regions = _container_regions(image_path, _raw_head(image_path))
|
||||
|
||||
info = _decoder_info(image_path)
|
||||
record: dict[str, Any] = {
|
||||
"schema_version": schema_version,
|
||||
"record_type": METADATA_RECORD_TYPE,
|
||||
"status": status,
|
||||
"issues": issues,
|
||||
"container": container,
|
||||
"name": image_path.name,
|
||||
"metadata_base64": base64.b64encode(regions).decode("ascii"),
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
|
||||
"""Experimental: the complete pixel-forensics layer for one image.
|
||||
"""The complete pixel-forensics layer for one image.
|
||||
|
||||
STATUS
|
||||
|
||||
Experimental and unused. Nothing in this package reads it -- not the provenance
|
||||
verdict, not removal, not the CLI. It is here because the research scanner that
|
||||
produced these measurements is gone, and the capability was worth keeping: whatever
|
||||
asks for pixel forensics next starts from a tested implementation instead of
|
||||
rebuilding one. Treat the shape as unstable until a caller exists.
|
||||
Independent from provenance verdicts, removal, and the CLI. Consumers use the
|
||||
versioned :meth:`PixelEvidence.to_dict` boundary; feature extraction failures are
|
||||
reported per family without discarding successful measurements.
|
||||
|
||||
WHAT IS MEASURED
|
||||
|
||||
@@ -16,7 +14,7 @@ One decode, then six families of scale-robust statistics over it:
|
||||
* ``dct`` -- AC coefficient histograms over the 8x8 block DCT, plus the deviation of
|
||||
leading digits from Benford's law.
|
||||
* ``fft`` -- radial band energies of the log-magnitude spectrum, plus the
|
||||
colour-filter-array periodicity peaks a demosaiced camera capture leaves.
|
||||
color-filter-array periodicity peaks a demosaiced camera capture leaves.
|
||||
* ``noise`` -- standard deviation and kurtosis of a high-pass residual.
|
||||
* ``ela`` -- error level after a quality-90 JPEG re-save.
|
||||
* ``gradient`` -- gradient-magnitude histogram and Laplacian variance.
|
||||
@@ -46,9 +44,12 @@ from __future__ import annotations
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -63,6 +64,7 @@ FFT_BANDS = 8
|
||||
# A Bayer CFA shows as symmetric peaks at half the Nyquist on the diagonals.
|
||||
BAYER_OFFSETS = ((1, 1), (1, -1))
|
||||
INSTALL_HINT = "install the pixel extra: uv add 'remove-ai-watermarks[pixels]'"
|
||||
PIXEL_EVIDENCE_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -86,12 +88,48 @@ class PixelEvidence:
|
||||
color: dict[str, Any] = field(default_factory=dict[str, Any])
|
||||
# Identifies the source image; see the module note. Empty unless asked for.
|
||||
artifacts: dict[str, Any] = field(default_factory=dict[str, Any])
|
||||
# Opt-in timings for callers measuring pipeline latency. Empty by default so
|
||||
# repeated evidence collection remains value-deterministic.
|
||||
timing_ms: dict[str, float] = field(default_factory=dict[str, float])
|
||||
|
||||
@property
|
||||
def decoded(self) -> bool:
|
||||
"""False when the source could not be decoded at all."""
|
||||
return "error" not in self.decode
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""``complete``, ``partial`` for a failed family, or ``error`` on decode."""
|
||||
if not self.decoded:
|
||||
return "error"
|
||||
sections = (self.dct, self.fft, self.noise, self.ela, self.gradient, self.color, self.artifacts)
|
||||
return "partial" if any("error" in section for section in sections) else "complete"
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
*,
|
||||
schema_version: int = PIXEL_EVIDENCE_SCHEMA_VERSION,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the selected JSON-safe transport schema without a local path."""
|
||||
schema_version = require_schema_version(
|
||||
schema_version,
|
||||
contract="pixel evidence",
|
||||
supported=(1,),
|
||||
)
|
||||
return {
|
||||
"schema_version": schema_version,
|
||||
"status": self.status,
|
||||
"decode": dict(self.decode),
|
||||
"dct": dict(self.dct),
|
||||
"fft": dict(self.fft),
|
||||
"noise": dict(self.noise),
|
||||
"ela": dict(self.ela),
|
||||
"gradient": dict(self.gradient),
|
||||
"color": dict(self.color),
|
||||
"artifacts": dict(self.artifacts),
|
||||
"timing_ms": dict(self.timing_ms),
|
||||
}
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True when the optional pixel dependencies are installed."""
|
||||
@@ -120,14 +158,17 @@ def _dct_matrix(np: Any, n: int = 8) -> Any:
|
||||
|
||||
|
||||
def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]:
|
||||
"""Decode to float32 grayscale (and RGB for colour stats), downscaled.
|
||||
"""Decode to float32 grayscale (and RGB for color stats), downscaled.
|
||||
|
||||
Pillow, not cv2, and the source dimensions are recorded BEFORE the downscale.
|
||||
"""
|
||||
np = _numpy()
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
try:
|
||||
image_io._register_heif() # pyright: ignore[reportPrivateUsage]
|
||||
with Image.open(image_path) as img:
|
||||
info: dict[str, Any] = {"width": img.width, "height": img.height}
|
||||
if max(img.size) > MAX_SIDE:
|
||||
@@ -136,7 +177,9 @@ def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]:
|
||||
gray = np.asarray(img.convert("L"), dtype=np.float32)
|
||||
except Exception as exc:
|
||||
logger.debug("pixel decode failed for %s: %s", image_path, exc)
|
||||
return None, None, {"error": f"{type(exc).__name__}: {exc}"}
|
||||
# Exception text from Pillow commonly embeds the absolute source path.
|
||||
# Keep that detail in the log, not in the pathless transport contract.
|
||||
return None, None, {"error": type(exc).__name__}
|
||||
return gray, rgb, info
|
||||
|
||||
|
||||
@@ -150,11 +193,13 @@ def dct_features(gray: Any) -> dict[str, Any]:
|
||||
basis = _dct_matrix(np)
|
||||
bins = np.linspace(-20.5, 20.5, 22)
|
||||
blocks = gray[:h8, :w8].reshape(h8 // 8, 8, w8 // 8, 8).swapaxes(1, 2)
|
||||
coeff = np.einsum("ij,abjk,lk->abil", basis, blocks, basis)
|
||||
rows = basis[[row for row, _ in AC_POSITIONS]]
|
||||
columns = basis[[column for _, column in AC_POSITIONS]]
|
||||
coeff = np.einsum("ki,abij,kj->abk", rows, blocks, columns)
|
||||
hists = []
|
||||
lead_vals: list[Any] = []
|
||||
for dy, dx in AC_POSITIONS:
|
||||
values = coeff[:, :, dy, dx].ravel()
|
||||
for index in range(len(AC_POSITIONS)):
|
||||
values = coeff[:, :, index].ravel()
|
||||
hists.append(np.histogram(values, bins=bins)[0].tolist())
|
||||
lead_vals.append(np.abs(values))
|
||||
out: dict[str, Any] = {"dct_ac_hist": hists}
|
||||
@@ -290,8 +335,8 @@ def perceptual_hash(gray: Any) -> str:
|
||||
|
||||
small = np.asarray(Image.fromarray(gray.astype(np.float32), mode="F").resize((32, 32), Image.Resampling.LANCZOS))
|
||||
basis = _dct_matrix(np, 32)
|
||||
coeff = basis @ small @ basis.T
|
||||
low = coeff[:8, :8].ravel()[1:] # drop DC
|
||||
low_basis = basis[:8]
|
||||
low = (low_basis @ small @ low_basis.T).ravel()[1:] # drop DC
|
||||
bits = low > np.median(low)
|
||||
return f"{int(''.join('1' if bit else '0' for bit in bits), 2):016x}"
|
||||
|
||||
@@ -343,7 +388,7 @@ def spatial_artifacts(gray: Any, rgb: Any, *, ela: Any, residual: Any, phase: An
|
||||
return out
|
||||
|
||||
|
||||
def extract_pixel_evidence(image_path: Path, *, artifacts: bool = False) -> PixelEvidence:
|
||||
def extract_pixel_evidence(image_path: Path, *, artifacts: bool = False, timings: bool = False) -> PixelEvidence:
|
||||
"""Measure every pixel-statistic family for one image in a single decode.
|
||||
|
||||
The image is decoded ONCE and the intermediate maps (high-pass residual, ELA
|
||||
@@ -361,40 +406,79 @@ def extract_pixel_evidence(image_path: Path, *, artifacts: bool = False) -> Pixe
|
||||
artifacts: Also return the spatial layer -- perceptual hash, thumbnail and
|
||||
coarse maps. Off by default: those identify the source image, so asking
|
||||
for them is a decision the caller makes explicitly.
|
||||
timings: Measure each stage and include rounded milliseconds in
|
||||
:attr:`PixelEvidence.timing_ms`.
|
||||
|
||||
Returns:
|
||||
A :class:`PixelEvidence`.
|
||||
"""
|
||||
gray, rgb, info = read_gray(image_path)
|
||||
if gray is None or rgb is None:
|
||||
return PixelEvidence(path=image_path, decode=info)
|
||||
started = time.perf_counter()
|
||||
stage_started = started
|
||||
measured: dict[str, float] = {}
|
||||
|
||||
residual = noise_residual_map(gray)
|
||||
spectrum = fft_decompose(gray)
|
||||
error = ela_map(rgb)
|
||||
gray, rgb, info = read_gray(image_path)
|
||||
measured["decode"] = time.perf_counter() - stage_started
|
||||
if gray is None or rgb is None:
|
||||
measured["total"] = time.perf_counter() - started
|
||||
timing_ms = {name: round(seconds * 1000, 1) for name, seconds in measured.items()} if timings else {}
|
||||
return PixelEvidence(path=image_path, decode=info, timing_ms=timing_ms)
|
||||
|
||||
families: dict[str, dict[str, Any]] = {}
|
||||
|
||||
residual = None
|
||||
stage_started = time.perf_counter()
|
||||
try:
|
||||
residual = noise_residual_map(gray)
|
||||
families["noise"] = noise_features(residual) if residual is not None else {}
|
||||
except Exception as exc:
|
||||
logger.debug("pixel family noise failed for %s: %s", image_path, exc)
|
||||
families["noise"] = {"error": type(exc).__name__}
|
||||
measured["noise"] = time.perf_counter() - stage_started
|
||||
|
||||
spectrum = None
|
||||
stage_started = time.perf_counter()
|
||||
try:
|
||||
spectrum = fft_decompose(gray)
|
||||
families["fft"] = fft_features(spectrum[0]) if spectrum is not None else {}
|
||||
except Exception as exc:
|
||||
logger.debug("pixel family fft failed for %s: %s", image_path, exc)
|
||||
families["fft"] = {"error": type(exc).__name__}
|
||||
measured["fft"] = time.perf_counter() - stage_started
|
||||
|
||||
error = None
|
||||
stage_started = time.perf_counter()
|
||||
try:
|
||||
error = ela_map(rgb)
|
||||
families["ela"] = ela_features(error) if error is not None else {}
|
||||
except Exception as exc:
|
||||
logger.debug("pixel family ela failed for %s: %s", image_path, exc)
|
||||
families["ela"] = {"error": type(exc).__name__}
|
||||
measured["ela"] = time.perf_counter() - stage_started
|
||||
|
||||
for name, compute in (
|
||||
("noise", lambda: noise_features(residual) if residual is not None else {}),
|
||||
("fft", lambda: fft_features(spectrum[0]) if spectrum is not None else {}),
|
||||
("ela", lambda: ela_features(error) if error is not None else {}),
|
||||
("dct", lambda: dct_features(gray)),
|
||||
("gradient", lambda: gradient_features(gray)),
|
||||
("color", lambda: color_features(rgb)),
|
||||
):
|
||||
stage_started = time.perf_counter()
|
||||
try:
|
||||
families[name] = compute()
|
||||
except Exception as exc: # one bad family must not lose the other five
|
||||
logger.debug("pixel family %s failed for %s: %s", name, image_path, exc)
|
||||
families[name] = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
families[name] = {"error": type(exc).__name__}
|
||||
measured[name] = time.perf_counter() - stage_started
|
||||
|
||||
if artifacts:
|
||||
stage_started = time.perf_counter()
|
||||
try:
|
||||
families["artifacts"] = spatial_artifacts(
|
||||
gray, rgb, ela=error, residual=residual, phase=spectrum[1] if spectrum is not None else None
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("pixel artifacts failed for %s: %s", image_path, exc)
|
||||
families["artifacts"] = {"error": f"{type(exc).__name__}: {exc}"}
|
||||
families["artifacts"] = {"error": type(exc).__name__}
|
||||
measured["full_artifacts"] = time.perf_counter() - stage_started
|
||||
|
||||
return PixelEvidence(path=image_path, decode=info, **families)
|
||||
measured["total"] = time.perf_counter() - started
|
||||
timing_ms = {name: round(seconds * 1000, 1) for name, seconds in measured.items()} if timings else {}
|
||||
return PixelEvidence(path=image_path, decode=info, timing_ms=timing_ms, **families)
|
||||
|
||||
Reference in New Issue
Block a user