Add versioned forensic metadata transports

This commit is contained in:
Victor Kuznetsov
2026-08-05 21:10:55 -07:00
parent 1124c591be
commit a83952e375
18 changed files with 1832 additions and 130 deletions
+21
View File
@@ -45,6 +45,27 @@ jobs:
- name: Run tests
run: uv run pytest -q
transport-contract-python-314:
name: versioned transport contracts py3.14
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.14"
- name: Create clean runtime contract environment
run: uv venv --python 3.14 .venv-contract
- name: Install runtime contract dependencies
run: >-
uv pip install --python .venv-contract/bin/python
".[pixels,heif]" "numpy>=2,<3" "pytest>=8"
- name: Run versioned transport contract tests
run: >-
.venv-contract/bin/python -m pytest -q
tests/test_forensic_metadata.py
tests/test_metadata_record.py
tests/test_pixel_evidence.py
video-e2e:
name: video full-clip end-to-end
runs-on: ubuntu-latest
+4 -11
View File
@@ -20,8 +20,7 @@ Thumbs.db
*.swp
*.swo
# SynthID corpus reference fills (synthetic black/white calibration tiles,
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
# Generated calibration inputs
data/synthid_corpus/refs/
# Reference materials
@@ -35,11 +34,7 @@ yolov8n.pt
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
# committed (content-free: a solid colour + the watermark; the source for
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
# synthetic seeds (regenerable) and any real-content validation download (a real
# generated scene, kept local for privacy) are NOT committed.
# Generated calibration inputs and local evaluation artifacts
data/doubao_capture/seeds/
data/jimeng_capture/seeds/
data/jimeng_capture/captures/jimeng_content_*.png
@@ -48,10 +43,8 @@ data/gemini_capture/captures/gemini_content_*.png
data/samsung_capture/seeds/
data/samsung_capture/captures/samsung_content_*
# Leftover GFPGAN weights dir from the retired face-restore experiments
# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working
# dir on first use). Runtime artifact, never committed.
# Runtime model artifacts
gfpgan/
# Local-only working data for analysis (not a committed corpus; never tracked)
# Local evaluation data
.local-eval/
+34 -8
View File
@@ -388,9 +388,10 @@ metadata extraction from verdict logic:
- `extract_provenance_evidence` reads the supported metadata signals into
`ProvenanceEvidence`.
- `evidence_from_metadata_record` normalizes an externally collected nested
metadata record into the same evidence type without file access. Diagnostic
values under `error` and `kind` are excluded from evidence while nested raw
bytes remain available through encoded binary fields.
metadata record into the same evidence type without file access. Versioned native
records accept only source-derived fields; filenames, hashes, timings, errors,
prior verdicts, and pixel results cannot become evidence. Unknown native schema
versions and other record types are rejected.
- The vendor registries are matched over `_metadata_region(head)`, not the whole scan
buffer: they see the container's metadata and not its coded pixels. The tokens are
raw substrings and the shortest are four and five bytes, so over a megabyte of
@@ -427,19 +428,44 @@ defects found while establishing that equality are the reason each rule exists:
returns the FIRST candidate carrying a known token, so preserving candidate order
is part of verdict equivalence.
Pixel forensics are deliberately absent: nothing in the provenance path reads them.
The transport is independently versioned as `provenance_metadata` schema 1. Native
records require the exact integer schema version and a `complete` status. Source
read failures are explicit error records and cannot be judged. WebP walks the full
declared RIFF container by seeking over `VP8`, `VP8L`, `ALPH`, and `ANMF`, so late
XMP/C2PA remains visible without shipping coded frames or parsing appended trailer
bytes as chunks.
Pixel forensics are deliberately absent: the provenance path does not read them.
Verdict equivalence is checked over tracked fixtures and a separate local evaluation
corpus.
### Experimental pixel forensics
### Broad forensic metadata
[`forensic_metadata.py`](../src/remove_ai_watermarks/forensic_metadata.py) owns the
wide metadata-only inspection record: hashes and timestamps, full EXIF/IPTC, C2PA,
container inventories, bounded binary metadata, and embedded-thumbnail forensics.
It is a separate `forensic_metadata` record type and is deliberately rejected by the
provenance normalizer. Integration code publishes the strict
`ProvenanceReport.to_dict()` alongside it rather than letting operational fields or
derived results influence detection.
### Pixel forensics
[`pixel_evidence.py`](../src/remove_ai_watermarks/pixel_evidence.py) measures six
families of scale-robust pixel statistics (block-DCT histograms and Benford
deviation, FFT band energies and CFA peaks, high-pass residual, error level,
gradient, colour) in a single decode, sharing the intermediate maps between them.
gradient, color) in a single decode, sharing the intermediate maps between them.
It has no consumer. Nothing in the package reads it -- not the verdict, not removal,
not the CLI -- and the shape is unstable until something does.
It remains independent of verdict and removal. `PixelEvidence.to_dict()` is the
versioned service boundary: it omits the local path, exposes complete/partial/error
status, keeps exception details in logs, and can include opt-in per-stage timings.
The provenance metadata collector, broad forensic collector, provenance report,
and pixel report all accept an explicit output `schema_version`. Package releases
may add an output schema while retaining older serializers, so a rolling consumer
can keep requesting the version it already understands. Within one schema, changes
are additive; existing fields, types, meanings, signal names, and watermark labels
remain stable. Unsupported selections raise before a different shape is returned.
`artifacts=True` additionally returns the spatial layer: a perceptual hash, a 128px
JPEG thumbnail, and coarse ELA, residual and phase maps. Those identify the source
+66 -9
View File
@@ -194,13 +194,20 @@ import json
from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
record = collect_metadata_record(Path("input.png")) # reads the file
record = collect_metadata_record(Path("input.png"), schema_version=1) # reads the file
blob = json.dumps(record) # ship it anywhere
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
payload = report.to_dict() # versioned JSON contract
payload = report.to_dict(schema_version=1) # versioned JSON contract
```
The collection record has `record_type="provenance_metadata"`,
`schema_version=1`, and a `status`. A vanished or unreadable source produces an
`error` record with structured `issues`; `identify_metadata_record` rejects that
record instead of turning a collection failure into an unknown-image verdict.
Unknown schema versions, non-integer aliases, and native records without a
`complete` collection status are rejected explicitly.
The verdict is the same one `identify(path, check_visible=False,
check_invisible=False)` returns for that file. That equality is the record's whole
contract and is verified over the tracked provenance fixtures and a separate local
@@ -208,9 +215,18 @@ evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary:
adds a `schema_version`, contains only JSON-safe values, and deliberately omits the
local source path.
A record carries metadata regions, never pixels: marker segments before the JPEG
scan, every PNG chunk except `IDAT`, RIFF chunks except the coded image, the
ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
Package and transport versions evolve independently. Long-lived consumers should
request the schema they implement, as above, instead of assuming the installed
package's latest schema. Within schema 1, existing fields, types, meanings,
`signals[].name` values, and `watermarks[]` labels remain compatible; releases may
add fields that consumers must ignore. A breaking change requires a new schema while
the schema 1 serializer remains available for rolling upgrades. Asking a release for
an unsupported schema raises `ValueError` rather than silently returning another
shape.
A record carries metadata regions, not the primary coded-pixel stream: marker
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size
is bounded by those metadata regions and trailers; images with large embedded
manifests naturally produce larger records.
@@ -236,13 +252,54 @@ evidence = evidence_from_metadata_record(record, path=Path("input.png"))
report = identify_from_evidence(evidence)
```
The normalizer recursively preserves text and byte values. It also decodes
Unversioned third-party records are normalized recursively for compatibility. The
normalizer preserves text and byte values. It also decodes
strings prefixed with `hex:` and fields named `base64` or ending in
`_base64`. Diagnostic values under `error` and `kind` are ignored because they
describe the collector rather than the source file. Pass a C2PA manifest-store
dictionary in `record["c2pa_store"]`, or through the explicit
`_base64`. Diagnostic, transport, timing, hash, provenance-result, and pixel-result
subtrees are ignored because they describe the collector or a derived result rather
than the source file. A versioned portable record is stricter still: only
`metadata_base64`, `tail_base64`, `pil`, `exif`, and `c2pa_store` are accepted as
source evidence. Other `record_type` values are rejected, so do not pass a broad
forensic inspection record to this API. Pass a C2PA manifest-store dictionary in
`record["c2pa_store"]`, or through the explicit
`c2pa_manifest_store` argument.
### Broad metadata inspection
`collect_forensic_metadata` provides the wide metadata-only record used by forensic
inspection and migration adapters. It preserves hashes and timestamps, full EXIF and
IPTC, C2PA, container inventories, bounded raw metadata payloads, and embedded
thumbnail forensics. It does not calculate a provenance verdict or pixel statistics.
```python
from remove_ai_watermarks.forensic_metadata import collect_forensic_metadata
record = collect_forensic_metadata(Path("input.png"), schema_version=1)
assert record["record_type"] == "forensic_metadata"
```
This record is intentionally not accepted by `identify_metadata_record`. Collect the
small strict provenance record separately and publish the resulting
`ProvenanceReport.to_dict()` as the detector contract.
### Pixel evidence
`extract_pixel_evidence` decodes once and calculates the DCT, FFT, residual, ELA,
gradient, and color families. Its versioned `to_dict()` result has a semantic
`status`: `complete`, `partial` when an individual family failed, or `error` when the
source could not be decoded. Transported errors contain only the exception class, so
local paths stay in the caller's logs rather than crossing the service boundary.
```python
from remove_ai_watermarks.pixel_evidence import extract_pixel_evidence
pixels = extract_pixel_evidence(Path("input.png"), artifacts=False, timings=True)
payload = pixels.to_dict(schema_version=1)
```
Timings and spatial artifacts are opt-in. Artifacts include image-identifying data
such as a thumbnail and perceptual hash; aggregate feature families do not.
`identify_from_evidence` does not reopen the source file by default: it evaluates
metadata only, and registered visible marks and pixel-backed invisible watermarks
remain in the path-based `identify` call.
+13 -4
View File
@@ -71,10 +71,10 @@ The source distribution uses an explicit allowlist for `/src`, `/LICENSE`,
`[tool.hatch.build.targets.sdist]` in `pyproject.toml`. It also defensively
excludes `/data`, `/tmp`, and `/.sc`. Keep both controls: calibration captures,
test corpora, generated research outputs, and local session state do not belong
in the published package archive. `.gitignore` covers `tmp/` and `.sc/`, so those
never reach a commit, but ignore rules are not the build boundary -- hatchling may
include untracked files, and `data/` is deliberately tracked, so the sdist exclude
is the only control keeping it out of the archive.
in the published package archive. Hatchling always adds the root `.gitignore` to
the sdist, so keep its comments generic and free of local operational context.
Ignore rules are not the build boundary: `data/` is deliberately tracked, while
the sdist configuration keeps it and the other excluded paths out of the archive.
## Build backend
@@ -97,6 +97,15 @@ write access.
## Release verification
Forensic transports are versioned independently from the package. Before publishing
a change to provenance metadata, provenance reports, broad forensic metadata, or
pixel evidence, run their schema 1 contract tests. Additive fields are compatible;
renaming a field, changing its type or meaning, changing a signal name or watermark
label, or removing a field requires a new output schema. Add the new serializer
without removing schema 1 so long-lived consumers can update separately. A package
release must never silently substitute its latest schema when a caller explicitly
requests an older supported one.
After publication, verify:
- both wheel and source distribution exist on PyPI;
+1
View File
@@ -37,6 +37,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Multimedia :: Graphics",
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
@@ -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
+106 -24
View File
@@ -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,
+15 -13
View File
@@ -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
+67 -12
View File
@@ -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"),
+111 -27
View File
@@ -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)
+236
View File
@@ -0,0 +1,236 @@
"""Tests for the metadata-only forensic collector."""
from __future__ import annotations
import base64
import json
import zlib
from typing import TYPE_CHECKING
import piexif
import pytest
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from remove_ai_watermarks.forensic_metadata import (
FORENSIC_METADATA_RECORD_TYPE,
FORENSIC_METADATA_SCHEMA_VERSION,
SUPPORTED_EXTENSIONS,
_b64,
_decode_exif_value,
_jpeg_forensics_bytes,
_png_text_decode,
_safe_str,
apple_live_photo_id,
collect_forensic_metadata,
read_full_exif,
read_isobmff_inventory,
read_isobmff_provenance_path,
read_jpeg_segments,
read_pil_info,
read_png_chunks,
read_png_late_metadata_path,
read_webp_chunks,
sha256_of,
sniff_format,
xattr_quarantine,
xattr_where_from,
)
if TYPE_CHECKING:
from pathlib import Path
def _jpeg(path: Path) -> Path:
Image.new("RGB", (48, 32), (20, 80, 160)).save(path, "JPEG", quality=87)
return path
def _png_chunk(chunk_type: bytes, payload: bytes) -> bytes:
crc = zlib.crc32(chunk_type + payload).to_bytes(4, "big")
return len(payload).to_bytes(4, "big") + chunk_type + payload + crc
def test_supported_extensions_are_media_not_documents():
assert {".jpg", ".png", ".webp", ".heic", ".mp4"}.issubset(SUPPORTED_EXTENSIONS)
assert ".pdf" not in SUPPORTED_EXTENSIONS
def test_json_helpers_and_format_sniffer():
class BadString:
def __str__(self):
raise RuntimeError("no string")
assert _safe_str("ok") == "ok"
assert "BadString" in _safe_str(BadString())
assert _b64(b"abc") == base64.b64encode(b"abc").decode("ascii")
assert _b64(b"x" * 20, cap=4) == "eHh4eA==...TRUNCATED(20 bytes total)"
assert _decode_exif_value(b"ascii") == "ascii"
assert _decode_exif_value(b"\xff").startswith("hex:")
assert _decode_exif_value((1, b"two")) == [1, "two"]
assert sniff_format(b"\x89PNG\r\n\x1a\n") == "png"
assert sniff_format(b"\xff\xd8\xff\xe0") == "jpeg"
assert sniff_format(b"RIFF....WEBP") == "webp"
assert sniff_format(b"....ftypheic").startswith("isobmff:")
assert sniff_format(b"unknown").startswith("unknown:")
def test_png_text_and_container_metadata_are_preserved(tmp_path: Path):
info = PngInfo()
info.add_text("parameters", "Steps: 20, Model: SDXL", zip=True)
path = tmp_path / "workflow.png"
Image.new("RGB", (32, 32)).save(path, pnginfo=info)
trailer = b'<TC260:AIGC>{"Label":"1"}</TC260:AIGC>'
path.write_bytes(path.read_bytes() + trailer)
record = collect_forensic_metadata(path)
assert record["schema_version"] == FORENSIC_METADATA_SCHEMA_VERSION == 1
assert record["record_type"] == FORENSIC_METADATA_RECORD_TYPE == "forensic_metadata"
assert record["content_format"] == "png"
assert any(chunk.get("type") == "zTXt" for chunk in record["png_chunks"])
assert record["png_post_iend_bytes"] == len(trailer)
assert base64.b64decode(record["png_post_iend_base64"]) == trailer
assert "Steps: 20" in json.dumps(record)
assert json.loads(json.dumps(record, allow_nan=False)) == record
def test_png_text_decoders_and_direct_chunk_reader(tmp_path: Path):
assert "hello" in _png_text_decode("tEXt", b"key\x00hello")
compressed = b"prompt\x00\x00" + zlib.compress(b"workflow")
assert "workflow" in _png_text_decode("zTXt", compressed)
assert "value" in _png_text_decode("iTXt", b"key\x00\x00\x00\x00\x00value")
path = tmp_path / "plain.png"
Image.new("RGB", (8, 8)).save(path)
chunks, trailer = read_png_chunks(path.read_bytes())
assert chunks[0]["type"] == "IHDR"
assert trailer == b""
def test_jpeg_exif_segments_encoder_and_trailer(tmp_path: Path):
path = tmp_path / "camera.jpg"
exif = piexif.dump(
{
"0th": {
piexif.ImageIFD.Make: b"Camera Corp",
piexif.ImageIFD.Software: b"Camera Firmware",
},
"Exif": {},
"GPS": {},
"1st": {},
}
)
Image.new("RGB", (64, 48)).save(path, "JPEG", exif=exif, quality=82)
trailer = b'PhotoEditor_Re_Edit_Data{"genAIType":1}'
path.write_bytes(path.read_bytes() + trailer)
record = collect_forensic_metadata(path)
assert record["exif"]["0th"]["Make"] == "Camera Corp"
assert record["jpeg"]["post_eoi_bytes"] == len(trailer)
assert base64.b64decode(record["jpeg"]["post_eoi_base64"]) == trailer
assert record["jpeg_forensics"]["quant_tables"]
assert sha256_of(path.read_bytes()) == record["sha256"]
def test_direct_exif_pil_and_jpeg_readers(tmp_path: Path):
path = _jpeg(tmp_path / "plain.jpg")
exif, thumbnail = read_full_exif(path)
pil, iptc, exif_blob = read_pil_info(path)
segments = read_jpeg_segments(path.read_bytes())
assert isinstance(exif, dict)
assert thumbnail is None
assert pil["width"] == 48
assert pil["height"] == 32
assert isinstance(iptc, dict)
assert exif_blob is None or isinstance(exif_blob, bytes)
assert isinstance(segments["segments"], list)
assert _jpeg_forensics_bytes(path.read_bytes())["quant_tables"]
assert _jpeg_forensics_bytes(b"not a jpeg") == {}
def test_webp_inventory_keeps_metadata_but_not_frame_pixels(tmp_path: Path):
path = tmp_path / "image.webp"
xmp = b"<x:xmpmeta>metadata</x:xmpmeta>"
Image.new("RGB", (32, 32), (30, 40, 50)).save(path, "WEBP", xmp=xmp)
chunks = read_webp_chunks(path.read_bytes())
xmp_chunk = next(chunk for chunk in chunks if chunk["type"] == "XMP ")
assert xmp_chunk["text"] == xmp.decode()
assert all("base64" not in chunk for chunk in chunks if chunk["type"] in {"VP8 ", "VP8L", "ANMF"})
def test_isobmff_inventory_and_streaming_provenance(tmp_path: Path):
path = tmp_path / "signed.mp4"
ftyp = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
payload = b"jumb c2pa trainedAlgorithmicMedia"
uuid_box = (8 + len(payload)).to_bytes(4, "big") + b"uuid" + payload
path.write_bytes(ftyp + b"\x00\x00\x00\x08mdat" + uuid_box)
inventory = read_isobmff_inventory(path.read_bytes())
streamed = read_isobmff_provenance_path(path)
assert "ftyp" in inventory["boxes"]
assert base64.b64decode(inventory["provenance_boxes"][0]["base64"]) == payload
assert base64.b64decode(streamed["provenance_boxes"][0]["base64"]) == payload
def test_oversized_path_keeps_bounded_windows_and_late_png_metadata(tmp_path: Path, monkeypatch):
path = tmp_path / "late.png"
Image.new("RGB", (16, 16)).save(path)
source = path.read_bytes()
iend = source.rfind(b"\x00\x00\x00\x00IEND")
padding = _png_chunk(b"vpAg", b"\x00" * ((1 << 20) + 1))
metadata = b'AIGC\x00{"Label":"1"}'
path.write_bytes(source[:iend] + padding + _png_chunk(b"tEXt", metadata) + source[iend:])
monkeypatch.setattr("remove_ai_watermarks.forensic_metadata._MAX_FULL_READ", 1)
record = collect_forensic_metadata(path)
assert record["oversized"]["head_scanned_bytes"] == path.stat().st_size
assert base64.b64decode(record["raw_metadata_windows"]["head_base64"])
assert base64.b64decode(record["png_late_metadata_chunks"][0]["base64"]) == metadata
def test_collection_registers_optional_heif_and_missing_file_raises(tmp_path: Path, monkeypatch):
registered = False
def mark_registered():
nonlocal registered
registered = True
monkeypatch.setattr("remove_ai_watermarks.image_io._register_heif", mark_registered)
collect_forensic_metadata(_jpeg(tmp_path / "plain.jpg"))
assert registered is True
with pytest.raises(FileNotFoundError):
collect_forensic_metadata(tmp_path / "missing.jpg")
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_collection_rejects_unsupported_output_schema_before_reading(tmp_path: Path, schema_version: object):
with pytest.raises(ValueError, match="Unsupported forensic metadata schema"):
collect_forensic_metadata(
tmp_path / "missing.jpg",
schema_version=schema_version, # type: ignore[arg-type]
)
def test_xattrs_and_live_photo_probe_are_safe_on_plain_file(tmp_path: Path):
path = _jpeg(tmp_path / "plain.jpg")
assert xattr_where_from(path) == [] or isinstance(xattr_where_from(path), list)
assert xattr_quarantine(path) is None or isinstance(xattr_quarantine(path), str)
assert apple_live_photo_id(path.read_bytes()) is None
def test_late_png_reader_soft_fails_on_non_png(tmp_path: Path):
path = tmp_path / "plain.bin"
path.write_bytes(b"not png")
assert read_png_late_metadata_path(path) == []
+42
View File
@@ -112,6 +112,36 @@ class TestProvenanceEvidence:
assert evidence.exif_generator == "NovelAI"
@pytest.mark.parametrize(
"record",
[
{"name": "trainedAlgorithmicMedia.jpg"},
{"sha256": "jumb-c2pa-OpenAI-trainedAlgorithmicMedia"},
{"pil": {"trainedAlgorithmicMedia": "plain"}},
{"signals": {"provenance": {"is_ai_generated": True}}},
{"pixel": {"error": "trainedAlgorithmicMedia"}},
],
)
def test_external_diagnostics_and_arbitrary_keys_are_not_evidence(self, tmp_path: Path, record: dict):
report = identify_from_evidence(evidence_from_metadata_record(record, path=tmp_path / "plain.jpg"))
assert report.is_ai_generated is None
assert report.signals == []
def test_external_metadata_value_is_evidence(self, tmp_path: Path):
record = {
"exif": {
"0th": {
"ImageDescription": "digitalSourceType=trainedAlgorithmicMedia",
}
}
}
report = identify_from_evidence(evidence_from_metadata_record(record, path=tmp_path / "generated.jpg"))
assert report.is_ai_generated is True
assert report.ai_source_kind == "generated"
@pytest.mark.parametrize(
"filename",
[
@@ -443,6 +473,18 @@ class TestIdentifyRealSamples:
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.platform == "Apple Photos (Clean Up AI edit)"
assert r.ai_source_kind == "enhanced"
def test_standalone_iptc_composite_synthetic_is_enhanced(self, tmp_path: Path):
p = tmp_path / "composite.jpg"
p.write_bytes(
b'\xff\xd8\xff\xe1<x:xmpmeta Iptc4xmpExt:DigitalSourceType="compositeSynthetic"></x:xmpmeta>\xff\xd9'
)
r = identify(p, check_visible=False, check_invisible=False)
assert r.is_ai_generated is True
assert r.ai_source_kind == "enhanced"
def test_flux_bfl_c2pa_png(self):
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
+120 -3
View File
@@ -24,7 +24,12 @@ from remove_ai_watermarks.identify import (
identify_from_evidence,
identify_metadata_record,
)
from remove_ai_watermarks.metadata_record import HEAD_WINDOW, collect_metadata_record
from remove_ai_watermarks.metadata_record import (
HEAD_WINDOW,
METADATA_RECORD_SCHEMA_VERSION,
METADATA_RECORD_TYPE,
collect_metadata_record,
)
FIXTURES = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_from_metadata")
@@ -138,7 +143,25 @@ class TestRecordShape:
def test_the_record_survives_json(self, tmp_path: Path):
record = collect_metadata_record(_noise_png(tmp_path / "plain.png"))
assert json.loads(json.dumps(record))["container"] == "png"
assert json.loads(json.dumps(record, allow_nan=False))["container"] == "png"
assert record["schema_version"] == METADATA_RECORD_SCHEMA_VERSION == 1
assert record["record_type"] == METADATA_RECORD_TYPE == "provenance_metadata"
@pytest.mark.parametrize("keep_version", [True, False])
def test_transport_filename_is_not_evidence(self, tmp_path: Path, keep_version: bool):
"""The path labels a record; detector tokens in it do not describe pixels."""
path = tmp_path / "jumb-c2pa-OpenAI-trainedAlgorithmicMedia.jpg"
Image.fromarray(np.zeros((64, 64, 3), dtype=np.uint8)).save(path, "JPEG")
record = collect_metadata_record(path)
if not keep_version:
record.pop("schema_version")
record.pop("record_type")
report = identify_metadata_record(record, path=path)
assert report.is_ai_generated is None
assert report.platform is None
assert report.confidence == "none"
def test_pixels_are_not_carried(self, tmp_path: Path):
"""The reason the record walks regions instead of shipping the head: a record
@@ -168,6 +191,60 @@ class TestRecordShape:
assert record["container"] == "unknown"
assert record["metadata_base64"] == ""
assert record["status"] == "error"
assert record["issues"] == [{"stage": "source", "code": "unavailable"}]
def test_unknown_record_schema_is_rejected(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
record["schema_version"] = 2
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("schema_version", [True, 1.0, "1", None])
def test_native_record_schema_requires_the_integer_one(self, tmp_path: Path, schema_version: object):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
record["schema_version"] = schema_version
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("status", [None, "partial", "unknown", True])
def test_native_record_requires_complete_collection_status(self, tmp_path: Path, status: object):
path = _noise_png(tmp_path / "plain.png")
record = collect_metadata_record(path)
if status is None:
record.pop("status")
else:
record["status"] = status
with pytest.raises(ValueError, match="collection status"):
identify_metadata_record(record, path=path)
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_collection_rejects_unsupported_output_schema_before_reading(self, tmp_path: Path, schema_version: object):
with pytest.raises(ValueError, match="Unsupported provenance metadata schema"):
collect_metadata_record(
tmp_path / "missing.png",
schema_version=schema_version, # type: ignore[arg-type]
)
def test_broad_forensic_record_is_not_detector_input(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
with pytest.raises(ValueError, match="Unsupported metadata record type"):
identify_metadata_record(
{"record_type": "forensic_metadata", "schema_version": 1},
path=path,
)
def test_failed_collection_cannot_be_judged_as_an_unknown_image(self, tmp_path: Path):
path = tmp_path / "missing.png"
with pytest.raises(ValueError, match="collection failed"):
identify_metadata_record(collect_metadata_record(path), path=path)
class TestReportTransport:
@@ -178,7 +255,15 @@ class TestReportTransport:
assert payload["schema_version"] == PROVENANCE_REPORT_SCHEMA_VERSION == 1
assert "path" not in payload
assert json.loads(json.dumps(payload)) == payload
assert json.loads(json.dumps(payload, allow_nan=False)) == payload
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_report_rejects_unsupported_output_schema(self, tmp_path: Path, schema_version: object):
path = _noise_png(tmp_path / "plain.png")
report = identify_metadata_record(collect_metadata_record(path), path=path)
with pytest.raises(ValueError, match="Unsupported provenance report schema"):
report.to_dict(schema_version=schema_version) # type: ignore[arg-type]
def test_convenience_entry_point_matches_explicit_sequence(self, tmp_path: Path):
path = _noise_png(tmp_path / "plain.png")
@@ -199,6 +284,38 @@ def test_a_webp_record_matches(tmp_path: Path):
_assert_same_verdict(path)
def test_a_webp_record_collects_metadata_after_a_large_frame(tmp_path: Path):
"""The RIFF walker seeks past coded pixels instead of stopping at the head window."""
path = tmp_path / "late.webp"
rng = np.random.default_rng(7)
pixels = rng.integers(0, 255, (900, 900, 3), dtype=np.uint8)
xmp = b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
Image.fromarray(pixels).save(path, "WEBP", lossless=True, xmp=xmp)
assert path.stat().st_size > HEAD_WINDOW
_assert_same_verdict(path)
def test_a_webp_record_does_not_carry_animation_frame_pixels(tmp_path: Path):
"""ANMF is a coded-frame container, not a metadata chunk."""
from remove_ai_watermarks.metadata_record import _riff_regions
frame = b"jumb c2pa OpenAI trainedAlgorithmicMedia" * 20
riff = b"RIFF" + (4 + 8 + len(frame)).to_bytes(4, "little") + b"WEBP"
riff += b"ANMF" + len(frame).to_bytes(4, "little") + frame
assert frame not in _riff_regions(riff)
def test_an_invalid_short_riff_size_does_not_turn_the_container_into_a_trailer(tmp_path: Path):
from remove_ai_watermarks.metadata_record import _trailer
path = tmp_path / "invalid.webp"
path.write_bytes(b"RIFF\x00\x00\x00\x00WEBPjumb c2pa trainedAlgorithmicMedia")
assert _trailer(path, "webp") == b""
def test_the_record_never_reopens_the_source(tmp_path: Path, monkeypatch):
"""The reason the record exists: the verdict must run where the file is not."""
path = FIXTURES / "chatgpt-1.png" if (FIXTURES / "chatgpt-1.png").exists() else _noise_png(tmp_path / "x.png")
+107 -10
View File
@@ -1,10 +1,9 @@
"""Tests for the experimental pixel-forensics collector.
"""Tests for the pixel-forensics collector.
It has no consumer, so there is no downstream behavior to pin. What these guard is
the part a future consumer would rely on and could not discover from the code: that
a family is empty rather than wrong when the image is too small for it, that one
failing family does not lose the other five, and that the artifacts which identify
the source image stay behind their opt-in.
These pin the service contract and the edge cases a consumer cannot infer safely:
a family is empty rather than wrong when the image is too small for it, one failing
family does not lose the other five, and artifacts that identify the source image
stay behind their opt-in.
"""
from __future__ import annotations
@@ -16,7 +15,16 @@ import numpy as np
import pytest
from PIL import Image
from remove_ai_watermarks.pixel_evidence import PixelEvidence, extract_pixel_evidence, is_available
from remove_ai_watermarks.pixel_evidence import (
AC_POSITIONS,
PIXEL_EVIDENCE_SCHEMA_VERSION,
PixelEvidence,
_dct_matrix,
dct_features,
extract_pixel_evidence,
is_available,
perceptual_hash,
)
if TYPE_CHECKING:
from pathlib import Path
@@ -25,7 +33,7 @@ FAMILIES = ("dct", "fft", "noise", "ela", "gradient", "color")
def _textured(path: Path, size: tuple[int, int] = (192, 160), *, seed: int = 0) -> Path:
"""A textured image. Flat colour would make several families degenerate (zero
"""A textured image. Flat color would make several families degenerate (zero
residual, empty gradient histogram) and hide a real break."""
rng = np.random.default_rng(seed)
base = rng.integers(0, 255, (size[1], size[0], 3), dtype=np.uint8)
@@ -61,6 +69,28 @@ class TestFamilies:
assert extract_pixel_evidence(path).decode == {"width": 3000, "height": 80}
def test_selected_dct_coefficients_match_the_full_transform(self):
gray = np.random.default_rng(7).uniform(0, 255, (24, 32)).astype(np.float32)
basis = _dct_matrix(np)
blocks = gray.reshape(3, 8, 4, 8).swapaxes(1, 2)
full = np.einsum("ij,abjk,lk->abil", basis, blocks, basis)
bins = np.linspace(-20.5, 20.5, 22)
expected = [
np.histogram(full[:, :, row, column].ravel(), bins=bins)[0].tolist() for row, column in AC_POSITIONS
]
assert dct_features(gray)["dct_ac_hist"] == expected
def test_perceptual_hash_matches_the_full_transform(self):
gray = np.random.default_rng(8).uniform(0, 255, (32, 32)).astype(np.float32)
basis = _dct_matrix(np, 32)
coefficients = basis @ gray @ basis.T
low = coefficients[:8, :8].ravel()[1:]
bits = low > np.median(low)
expected = f"{int(''.join('1' if bit else '0' for bit in bits), 2):016x}"
assert perceptual_hash(gray) == expected
class TestDegenerateInputs:
def test_undecodable_file_reports_the_error_and_stays_empty(self, tmp_path: Path):
@@ -90,12 +120,14 @@ class TestDegenerateInputs:
path = _textured(tmp_path / "textured.png")
def boom(*args, **kwargs):
raise ValueError("family exploded")
raise ValueError(f"family failed for {path}")
monkeypatch.setattr("remove_ai_watermarks.pixel_evidence.color_features", boom)
evidence = extract_pixel_evidence(path)
assert "error" in evidence.color
assert evidence.color == {"error": "ValueError"}
assert evidence.status == "partial"
assert evidence.to_dict()["status"] == "partial"
assert evidence.dct != {}
assert evidence.gradient != {}
@@ -154,3 +186,68 @@ def test_evidence_is_frozen(tmp_path: Path):
assert isinstance(evidence, PixelEvidence)
with pytest.raises(dataclasses.FrozenInstanceError):
evidence.decode = {} # type: ignore[misc]
def test_transport_contract_is_versioned_json_and_omits_path(tmp_path: Path):
import json
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"), timings=True)
payload = evidence.to_dict()
assert payload["schema_version"] == PIXEL_EVIDENCE_SCHEMA_VERSION == 1
assert payload["status"] == "complete"
assert "path" not in payload
assert payload["timing_ms"]["total"] >= 0
assert json.loads(json.dumps(payload, allow_nan=False)) == payload
@pytest.mark.parametrize("schema_version", [2, True, 1.0])
def test_transport_rejects_unsupported_output_schema(tmp_path: Path, schema_version: object):
evidence = extract_pixel_evidence(_textured(tmp_path / "textured.png"))
with pytest.raises(ValueError, match="Unsupported pixel evidence schema"):
evidence.to_dict(schema_version=schema_version) # type: ignore[arg-type]
def test_decode_error_transport_does_not_leak_the_local_path(tmp_path: Path):
import json
path = tmp_path / "broken.png"
path.write_bytes(b"not an image")
payload = extract_pixel_evidence(path).to_dict()
assert payload["status"] == "error"
assert payload["decode"]["error"] == "UnidentifiedImageError"
assert str(path) not in json.dumps(payload)
def test_timings_are_opt_in(tmp_path: Path):
path = _textured(tmp_path / "textured.png")
assert extract_pixel_evidence(path).timing_ms == {}
assert set(extract_pixel_evidence(path, artifacts=True, timings=True).timing_ms) == {
"decode",
"noise",
"fft",
"ela",
"dct",
"gradient",
"color",
"full_artifacts",
"total",
}
def test_pixel_decode_registers_optional_heif_opener(tmp_path: Path, monkeypatch):
registered = False
def mark_registered():
nonlocal registered
registered = True
monkeypatch.setattr("remove_ai_watermarks.image_io._register_heif", mark_registered)
extract_pixel_evidence(_textured(tmp_path / "textured.png"))
assert registered is True