Add a portable metadata record so collection and verdict can run apart

`collect_metadata_record` returns a JSON-safe record carrying an image's
provenance metadata regions -- never its pixels -- and the existing
`evidence_from_metadata_record` + `identify_from_evidence` build the verdict
from it without opening the file. The contract is equality with
`identify(path, metadata only)`, verified over the tracked fixtures and over a
local corpus of 3,478 images (every file carrying a rare signal, plus a random
slice): zero differences.

Three placements defeated earlier drafts and each is now a rule with a test:
the `scan_head` buffer is the head CONCATENATED with late metadata, so a
structural walk must read the raw head instead; Samsung splits its evidence
between a post-EOI trailer and the coded scan; and PIL's info keys must be
emitted in the file path's candidate order, since the first token match wins.

Also fix a real detection gap found while establishing that equality: a label
the decoder can read but a raw byte scan cannot -- a compressed PNG `zTXt`
packet, or a WebP XMP chunk past the scan window -- was invisible to
`identify`. Eight corpus files carrying a China TC260 AIGC label or an IPTC
"Made with AI" tag were reported as no signal at all.

`scripts/detection_timing.py` and its report script measure the metadata path
per method; they write outside the repository and are read-only over a dataset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-08-05 21:10:38 -07:00
co-authored by Claude Opus 5
parent f481e6f944
commit 0c5961a0ed
9 changed files with 1159 additions and 6 deletions
+25
View File
@@ -386,6 +386,31 @@ metadata extraction from verdict logic:
- `identify` preserves the path-based API and adds the optional registered
visible-mark and open invisible-watermark decoders after extraction.
### Portable metadata record
[`metadata_record.py`](../src/remove_ai_watermarks/metadata_record.py) produces the
record `evidence_from_metadata_record` consumes, so collection and verdict can run
on different machines. Its contract is equality with the file path, and the three
defects found while establishing that equality are the reason each rule exists:
- It walks the file's RAW head, never the `scan_head` buffer. That buffer is the
head concatenated with late metadata payloads, so a structural walk runs off the
end of the real head and parses appended bytes as chunks — which produced 11 MB
records and a phantom AIGC signal.
- Samsung Galaxy AI splits its evidence: the `PhotoEditor_Re_Edit_Data` marker sits
in the post-EOI trailer while the `genAIType` value it is gated on can sit inside
the entropy-coded scan (measured on one file: marker at 580 619, value at
382 953). A marked file therefore keeps the whole tail window, not just the
trailer.
- PIL's info keys are emitted in the file path's own candidate order
(`Software`, `Source`, `Title`, `Description`, then EXIF). `generator_from_metadata`
returns the FIRST candidate carrying a known token, so dict order alone changed
the reported platform on nine NovelAI files.
Pixel forensics are deliberately absent: nothing in the provenance path reads them.
Verified over 3,609 research-scan records — dropping every pixel section changed no
verdict.
The DWT-DCT detector and the visible-mark stage share a single decode of the
source, held by
a per-call `_SharedDecode`. It exposes two accessors because the two arms need
+39 -2
View File
@@ -182,8 +182,45 @@ evidence = extract_provenance_evidence(Path("input.png"))
report = identify_from_evidence(evidence)
```
If metadata was collected by another component, normalize its nested record
without reopening the original file:
### Collect once, judge elsewhere
`collect_metadata_record` splits the two halves apart: it is the only step that
touches the file, and it returns a JSON-serializable record the verdict can be
built from on another machine, in another process, or later.
```python
import json
from remove_ai_watermarks.identify import evidence_from_metadata_record, identify_from_evidence
from remove_ai_watermarks.metadata_record import collect_metadata_record
record = collect_metadata_record(Path("input.png")) # reads the file
blob = json.dumps(record) # ship it anywhere
evidence = evidence_from_metadata_record(json.loads(blob), path=Path("input.png"))
report = identify_from_evidence(evidence) # reads nothing
```
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 it is verified two ways: over the tracked provenance fixtures in
`tests/test_metadata_record.py`, and over a local corpus, where 3,478 images
(every file carrying a rare signal, plus a random slice) produced identical
reports through both paths.
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
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Typical
size is 13 kB (p90 93 kB); the tail belongs to images carrying a large embedded
manifest, where the store itself dominates.
The `path` argument is metadata: it labels the report and is never opened by
either function, so a record collected elsewhere can be judged against a path that
does not exist locally.
If metadata was collected by another component instead, normalize its nested
record the same way:
```python
from remove_ai_watermarks.identify import (
+1 -1
View File
@@ -68,7 +68,7 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
Metadata stripping for supported audio containers is a separate implemented
path.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for every other input, and for any file that fits inside `size`, it is exactly `f.read(size)`.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for a file at least `size` bytes long it also appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`) — a compressed PNG `zTXt` packet, or a WebP XMP chunk past the window, both of which hid real AI labels in a corpus; for any file that fits inside `size`, it is exactly `f.read(size)`.
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so