mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
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:
co-authored by
Claude Opus 5
parent
f481e6f944
commit
0c5961a0ed
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user