mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Read WebP metadata past the scan window and surface C2PA reader failures
Three gaps found while measuring the record path against the file path, each one a signal the library could not see: WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed read stops short of the label. `_riff_late_metadata` steps over the coded image to reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there. The decoder-backed fallback now covers only what it is actually for -- metadata the raw bytes do not spell, such as a compressed PNG `zTXt` packet. A C2PA reader failure returned the same `None` as a file with no manifest, so a verdict could fall back to the raw byte scan with no trace anywhere. Failures now log at warning and only genuine ones do: a file without credentials never reaches that branch, and an unsupported container is demoted to debug through the reader's own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG. `scan_dataset.py` never registered the pillow-heif opener it declares as a dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer that was 397 of 406 features NaN instead of 136. `_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping each chunk to the bytes remaining is not enough on its own: one chunk can declare a length spanning most of the file, and this runs on the memoized verdict path over images from arbitrary sources. Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the one-call entry point and the versioned JSON contract for the record path. Record-vs-file equality holds over 3,478 corpus images, and the eight files these fixes recovered still report AI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0c5961a0ed
commit
2668f1302d
@@ -81,8 +81,7 @@ rules follow, and both were broken in practice before they were written down:
|
||||
builder; a mask path that re-runs its own sweep is how the two drift apart.
|
||||
|
||||
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
|
||||
URNs and instance ids, so two reads of one unchanged file matched in 54 of 120 measured
|
||||
cases. Compare the derived `c2pa_info`, never the raw store.
|
||||
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
|
||||
|
||||
A third seam reaches the same verdict: `collect_metadata_record` ->
|
||||
`evidence_from_metadata_record` -> `identify_from_evidence`, the path a caller uses when
|
||||
@@ -90,9 +89,8 @@ collection and verdict run on different machines. Its contract is equality with
|
||||
`identify(path, check_visible=False, check_invisible=False)` on the same image, and it
|
||||
can break from EITHER side -- a region the collector stops walking, or a placement the
|
||||
file path learns to read and the record does not. `tests/test_metadata_record.py` pins
|
||||
it over the tracked fixtures; the corpus comparison is what actually finds the gaps
|
||||
(three real ones so far, recorded in `docs/module-internals.md`). Change either side and
|
||||
re-run both.
|
||||
it over the tracked fixtures; a separate local evaluation corpus catches placements the
|
||||
fixtures do not cover. Change either side and re-run both.
|
||||
|
||||
Before changing anything in the detection path, record the detectors' exact verdicts
|
||||
over a local sample first and diff them after. A refactor here is only correct if that
|
||||
|
||||
+18
-10
@@ -340,7 +340,16 @@ metadata scanners and `remove_ai_metadata`.
|
||||
|
||||
Key contracts:
|
||||
|
||||
- `scan_head` is the shared cached input for bounded byte scans.
|
||||
- `scan_head` is the shared cached input for bounded byte scans. It fills the buffer
|
||||
in two layers. Structural readers first, one per container, each seeking past the
|
||||
pixel payload to reach metadata placed beyond the window: `isobmff.scan_c2pa_region`,
|
||||
`_png_late_metadata`, `_riff_late_metadata`. A decoder-backed fallback last,
|
||||
`_decoder_visible_text`, for metadata the raw bytes do not spell at all — a
|
||||
zlib-compressed PNG `zTXt` packet is readable only after inflation. The layers are
|
||||
ordered that way because the structural readers work on files no decoder can open.
|
||||
- A C2PA reader failure is logged at warning, not debug. It returns the same `None` as
|
||||
a file with no manifest, so nothing downstream can distinguish "no credentials" from
|
||||
"the credentials could not be read", and the second silently downgrades a verdict.
|
||||
- JPEG stripping walks metadata segments and preserves the entropy-coded image
|
||||
scan.
|
||||
- ISOBMFF containers use
|
||||
@@ -395,21 +404,20 @@ 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.
|
||||
end of the real head and parses appended bytes as chunks, inflating the record and
|
||||
creating false signals.
|
||||
- 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.
|
||||
the entropy-coded scan. 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.
|
||||
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.
|
||||
Verified over 3,609 research-scan records — dropping every pixel section changed no
|
||||
verdict.
|
||||
Verdict equivalence is checked over tracked fixtures and a separate local evaluation
|
||||
corpus.
|
||||
|
||||
The DWT-DCT detector and the visible-mark stage share a single decode of the
|
||||
source, held by
|
||||
|
||||
+10
-10
@@ -191,29 +191,29 @@ 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.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
|
||||
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
|
||||
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
|
||||
payload = report.to_dict() # versioned JSON contract
|
||||
```
|
||||
|
||||
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.
|
||||
contract and is verified over the tracked provenance fixtures and a separate local
|
||||
evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary: it
|
||||
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
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 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)`.
|
||||
**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 WebP it appends the `EXIF` / `XMP ` / `ICCP` / `C2PA` chunks past the window (`_riff_late_metadata`, stepping over the coded image), which is how an IPTC "Made with AI" tag stored after the pixels is caught; and for a file at least `size` bytes long it finally appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`), which covers a compressed PNG `zTXt` packet no byte scan can spell; 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
|
||||
|
||||
@@ -39,7 +39,6 @@ DATA SAFETY
|
||||
Read-only over a local dataset. Writes only to the given output prefix, which
|
||||
belongs outside the repository.
|
||||
|
||||
uv run python scripts/detection_timing.py data/spaces/originals .local-eval/timing/run
|
||||
uv run python scripts/detection_timing.py <dataset> <prefix> --limit 200
|
||||
"""
|
||||
|
||||
|
||||
@@ -72,6 +72,18 @@ from typing import Any
|
||||
from PIL import Image
|
||||
from PIL.IptcImagePlugin import getiptcinfo
|
||||
|
||||
# Pillow cannot open HEIC/HEIF without this opener, and it does not auto-register.
|
||||
# Skipping it does not fail loudly: HEIC records lose EXIF and most pixel features
|
||||
# while the scan continues as if the container were merely unreadable. Deliberately a
|
||||
# copy of `image_io._register_heif` rather than an import: this script keeps the
|
||||
# minimal dependency set its docstring advertises and never imports the package. The
|
||||
# suppression is as wide as the original's, so a broken libheif degrades the scan
|
||||
# instead of killing it at import.
|
||||
with contextlib.suppress(Exception):
|
||||
import pillow_heif
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
|
||||
SUPPORTED = {
|
||||
".png",
|
||||
".jpg",
|
||||
|
||||
@@ -29,7 +29,9 @@ if TYPE_CHECKING:
|
||||
from typing import BinaryIO
|
||||
|
||||
_C2paReader: Any = None
|
||||
_C2paError: Any = None
|
||||
with contextlib.suppress(Exception):
|
||||
from c2pa import C2paError as _C2paError # pyright: ignore[reportMissingTypeStubs]
|
||||
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
|
||||
|
||||
_C2PA_READER_AVAILABLE = _C2paReader is not None
|
||||
@@ -48,10 +50,22 @@ def reader_available() -> bool:
|
||||
|
||||
|
||||
def _manifest_json_uncached(path: str) -> str | None:
|
||||
"""The manifest store as JSON, or None when this file has no readable manifest.
|
||||
|
||||
Two outcomes are routine and stay at debug: a file with no manifest (``try_create``
|
||||
returns None) and a container the reader does not support. ANY other failure is
|
||||
logged at warning, because the caller cannot tell the difference from the return
|
||||
value and the consequence is severe: the verdict silently falls back to the raw
|
||||
byte scan and can lose a high-confidence signal. The log line preserves the
|
||||
diagnostic context needed to investigate an intermittent reader failure.
|
||||
"""
|
||||
try:
|
||||
reader = _C2paReader.try_create(path)
|
||||
except _C2paError.NotSupported as error:
|
||||
logger.debug("C2PA reader does not support %s: %s", path, error)
|
||||
return None
|
||||
except Exception as error:
|
||||
logger.debug("C2PA reader rejected %s: %s", path, error)
|
||||
logger.warning("C2PA reader failed to open %s: %s: %s", path, type(error).__name__, error)
|
||||
return None
|
||||
if reader is None:
|
||||
return None
|
||||
@@ -59,7 +73,9 @@ def _manifest_json_uncached(path: str) -> str | None:
|
||||
with reader:
|
||||
return cast("str", reader.json())
|
||||
except Exception as error:
|
||||
logger.debug("C2PA reader could not serialize %s: %s", path, error)
|
||||
# The reader opened the file, so a manifest is there; failing to serialize it
|
||||
# is never routine.
|
||||
logger.warning("C2PA reader could not serialize %s: %s: %s", path, type(error).__name__, error)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable JSON contract for callers that pass a verdict between services. Bump this
|
||||
# only for a breaking shape or semantic change; adding optional fields is compatible.
|
||||
PROVENANCE_REPORT_SCHEMA_VERSION = 1
|
||||
|
||||
# How much of a non-PNG container to binary-scan for the C2PA issuer.
|
||||
_SCAN_BYTES = 1024 * 1024
|
||||
|
||||
@@ -383,6 +387,32 @@ 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]:
|
||||
"""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.
|
||||
"""
|
||||
return {
|
||||
"schema_version": PROVENANCE_REPORT_SCHEMA_VERSION,
|
||||
"is_ai_generated": self.is_ai_generated,
|
||||
"platform": self.platform,
|
||||
"confidence": self.confidence,
|
||||
"ai_source_kind": self.ai_source_kind,
|
||||
"ai_from_metadata": self.ai_from_metadata,
|
||||
"watermarks": list(self.watermarks),
|
||||
"signals": [
|
||||
{
|
||||
"name": signal.name,
|
||||
"detail": signal.detail,
|
||||
"confidence": signal.confidence,
|
||||
}
|
||||
for signal in self.signals
|
||||
],
|
||||
"caveats": list(self.caveats),
|
||||
"integrity_clashes": list(self.integrity_clashes),
|
||||
}
|
||||
|
||||
|
||||
def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence:
|
||||
"""Read all file-backed metadata needed by provenance verdict logic once."""
|
||||
@@ -1170,6 +1200,16 @@ def identify_from_evidence(
|
||||
)
|
||||
|
||||
|
||||
def identify_metadata_record(record: dict[str, Any], *, path: Path) -> ProvenanceReport:
|
||||
"""Build a metadata-only verdict from a portable metadata record.
|
||||
|
||||
This is the service-integration entry point: the source file is never opened,
|
||||
and callers receive the same verdict as the explicit
|
||||
``evidence_from_metadata_record`` / ``identify_from_evidence`` sequence.
|
||||
"""
|
||||
return identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
|
||||
|
||||
def identify(
|
||||
image_path: Path,
|
||||
*,
|
||||
|
||||
@@ -285,6 +285,54 @@ 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.
|
||||
|
||||
The WebP layout puts ``XMP ``/``EXIF`` AFTER the pixels, so a fixed read can stop
|
||||
before an IPTC or C2PA AI label. This is the RIFF analogue of
|
||||
:func:`_png_late_metadata`; it returns only chunks past ``window`` so bytes
|
||||
already in the head are not duplicated, and empty when there are none.
|
||||
|
||||
``max_total`` caps what a metadata scan can pull into memory, the same ceiling
|
||||
``isobmff.scan_c2pa_region`` applies. Clamping each chunk to the bytes that remain
|
||||
is not enough on its own: a corrupt or crafted file can declare one ``XMP `` chunk
|
||||
spanning most of itself, and this runs on the memoized verdict path for images from
|
||||
arbitrary sources. A label that needs more than 4 MB of XMP does not exist.
|
||||
"""
|
||||
out = bytearray()
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
if f.read(4) != b"RIFF":
|
||||
return b""
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
position = 12 # 'RIFF' + size + form type
|
||||
while position + 8 <= file_size and len(out) < max_total:
|
||||
f.seek(position)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
chunk_type = header[:4]
|
||||
(length,) = struct.unpack("<I", header[4:8])
|
||||
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:
|
||||
f.seek(start)
|
||||
out += f.read(min(safe_length, max_total - len(out)))
|
||||
position = start + safe_length + (safe_length & 1) # chunks are word-aligned
|
||||
except OSError as exc:
|
||||
logger.debug("RIFF late-metadata scan failed on %s: %s", image_path, exc)
|
||||
return b""
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _stat_key(image_path: Path) -> tuple[str, int, int] | None:
|
||||
"""Cache key identifying this file's exact CONTENT, or None when it cannot stat.
|
||||
|
||||
@@ -352,6 +400,8 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes:
|
||||
# len(head) == size means the file is at least `size` bytes, so metadata
|
||||
# chunks may lie beyond the window; otherwise the whole PNG is in `head`.
|
||||
head += _png_late_metadata(image_path, size)
|
||||
elif head[:4] == b"RIFF" and head[8:12] == b"WEBP" and len(head) == size:
|
||||
head += _riff_late_metadata(image_path, size)
|
||||
if len(head) >= size:
|
||||
head += _decoder_visible_text(image_path, head)
|
||||
return head
|
||||
@@ -369,17 +419,16 @@ _DECODER_BINARY_KEYS = frozenset({"icc_profile"})
|
||||
def _decoder_visible_text(image_path: Path, head: bytes) -> bytes:
|
||||
"""Metadata text PIL can decode but the raw window does not contain.
|
||||
|
||||
Two placements defeat a fixed byte read, and both were found in a real corpus
|
||||
rather than imagined:
|
||||
This is the last of two layers, not the first. Metadata placed BEYOND the window
|
||||
is the structural readers' job (``_png_late_metadata``, ``_riff_late_metadata``,
|
||||
the ISOBMFF box walk), and they work on a file no decoder can open. What is left
|
||||
for this one is metadata the bytes do not spell at all:
|
||||
|
||||
* COMPRESSED -- a PNG ``zTXt`` chunk is zlib-deflated, so an XMP packet carrying
|
||||
a TC260 AIGC label is unreadable as bytes while PIL inflates it on open. Five
|
||||
corpus files carried a China AIGC label that ``identify`` reported as no signal
|
||||
at all.
|
||||
* BEYOND THE WINDOW in a container with no late-chunk reader -- a WebP XMP chunk
|
||||
at offset 1 093 039 sits 44 kB past the 1 MiB window, and unlike PNG and
|
||||
ISOBMFF, RIFF has no seek-past-the-pixels extension here. Three corpus files
|
||||
hid an IPTC "Made with AI" tag and a C2PA ``trainedAlgorithmicMedia`` that way.
|
||||
a TC260 AIGC label is unreadable as bytes while PIL inflates it on open.
|
||||
|
||||
It stays container-agnostic on purpose: it is the net under a placement no
|
||||
structural reader here knows about yet.
|
||||
|
||||
Only text ALREADY MISSING from ``head`` is appended, so the common case adds
|
||||
nothing and no detector sees a value twice. Skipped entirely when the file fits
|
||||
|
||||
@@ -82,6 +82,11 @@ def _jpeg_regions(data: bytes) -> bytes:
|
||||
|
||||
The scan itself is skipped by walking to SOS and then jumping to the trailing
|
||||
EOI, so a 20 MB photo contributes only its markers.
|
||||
|
||||
TWIN: ``metadata._strip_jpeg_metadata_lossless`` walks the same marker chain. The
|
||||
two were left separate on purpose -- that one couples the walk to "return False and
|
||||
fall back to a PIL re-encode", a decision the lossless strip path owns and this one
|
||||
must not inherit -- so a fix to marker handling belongs in BOTH.
|
||||
"""
|
||||
out = bytearray()
|
||||
index, size = 2, len(data)
|
||||
@@ -106,7 +111,13 @@ def _jpeg_regions(data: bytes) -> bytes:
|
||||
|
||||
|
||||
def _png_regions(data: bytes) -> bytes:
|
||||
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND."""
|
||||
"""Every chunk except the ``IDAT`` payloads, plus whatever follows IEND.
|
||||
|
||||
TWIN: ``metadata._png_late_metadata`` walks the same chunk chain by SEEKING over
|
||||
the file rather than over a buffer, and keeps an allowlist rather than skipping
|
||||
``IDAT``. Both filters are deliberate: inside the window the file path sees every
|
||||
chunk type raw, past it only the allowlist survives.
|
||||
"""
|
||||
out = bytearray()
|
||||
size = len(data)
|
||||
position = len(PNG_SIGNATURE)
|
||||
@@ -127,7 +138,12 @@ def _png_regions(data: bytes) -> bytes:
|
||||
|
||||
|
||||
def _riff_regions(data: bytes) -> bytes:
|
||||
"""Every RIFF chunk except the coded image payloads."""
|
||||
"""Every RIFF chunk except the coded image payloads.
|
||||
|
||||
TWIN: ``metadata._riff_late_metadata`` (seek-based, past the scan window) and
|
||||
``_internal.riff`` (AVI ``LIST/INFO``). Same chunk-stepping arithmetic, three
|
||||
input models.
|
||||
"""
|
||||
out = bytearray(data[:12]) # 'RIFF' + size + 'WEBP'
|
||||
size = len(data)
|
||||
position = 12
|
||||
@@ -168,10 +184,9 @@ def _container_regions(image_path: Path, head: bytes) -> tuple[str, bytes]:
|
||||
"""(container label, metadata bytes) for the container ``head`` starts with.
|
||||
|
||||
``head`` must be the file's raw first bytes. Handing this the ``scan_head``
|
||||
buffer instead is a trap that was walked into once: that buffer is the head
|
||||
CONCATENATED with late metadata payloads, so a structural walk runs off the end
|
||||
of the real head and parses the appended bytes as chunks -- which produced 11 MB
|
||||
records and a phantom AIGC signal before the two were separated.
|
||||
buffer instead is a trap: that buffer is the head CONCATENATED with late metadata
|
||||
payloads, so a structural walk runs off the end of the real head and parses the
|
||||
appended bytes as chunks, inflating the record and creating false signals.
|
||||
"""
|
||||
from remove_ai_watermarks._internal.isobmff import is_isobmff
|
||||
from remove_ai_watermarks.metadata import png_late_metadata
|
||||
@@ -211,9 +226,8 @@ def _trailer(image_path: Path, container: str) -> bytes:
|
||||
tail = read_file_tail(image_path, TAIL_WINDOW)
|
||||
if SAMSUNG_EDITOR_MARKER in tail:
|
||||
# Galaxy AI splits its evidence: the marker sits in the post-EOI trailer, but
|
||||
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan
|
||||
# (measured: marker at 580 619, value at 382 953 in the same file). Keeping
|
||||
# only the trailer therefore carries the marker without the value and the
|
||||
# the `genAIType` value it is gated on can sit INSIDE the entropy-coded scan.
|
||||
# Keeping only the trailer therefore carries the marker without the value and the
|
||||
# verdict silently drops the Samsung signal, so a marked file keeps the whole
|
||||
# window. Only Samsung-marked files pay for it.
|
||||
return tail
|
||||
@@ -250,8 +264,7 @@ def _exif_pairs(info: dict[str, Any]) -> dict[str, str]:
|
||||
in the regions. ``xai_signature_pair`` wants an (ImageDescription, Artist) pair,
|
||||
and ``_external_exif_generator`` looks for Software / Make / Artist /
|
||||
ImageDescription. Ship the bytes alone and both silently return nothing, which
|
||||
is exactly how a first draft of this collector lost every Grok and NovelAI
|
||||
verdict in a corpus run.
|
||||
is how a collector can silently lose Grok and NovelAI verdicts.
|
||||
"""
|
||||
exif_bytes = info.get("exif")
|
||||
if not exif_bytes:
|
||||
@@ -283,11 +296,9 @@ def _pil_info(info: dict[str, Any]) -> dict[str, str]:
|
||||
return value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
|
||||
|
||||
# Emitted in the file path's own candidate order. ``generator_from_metadata``
|
||||
# returns the FIRST candidate carrying a known token, and a record that listed
|
||||
# PIL's keys in their natural dict order picked a different string for the same
|
||||
# image -- nine NovelAI files came back as "NovelAI generated image" where the
|
||||
# file path says "NovelAI". Same verdict, different platform text, and the two
|
||||
# paths are supposed to be indistinguishable.
|
||||
# returns the FIRST candidate carrying a known token. A record using PIL's natural
|
||||
# dict order can therefore choose a different platform string than the file path,
|
||||
# even though the two paths are supposed to be indistinguishable.
|
||||
out: dict[str, str] = {}
|
||||
for key in _GENERATOR_TEXT_KEYS:
|
||||
value = info.get(key)
|
||||
|
||||
@@ -1316,6 +1316,30 @@ class TestAIGCLabel:
|
||||
assert b"ContentProducer" in _png_late_metadata(p, 8)
|
||||
assert b"ContentProducer" in scan_head(p, 8)
|
||||
|
||||
def test_scan_head_collects_webp_metadata_past_window(self, tmp_path: Path):
|
||||
"""WebP stores ``XMP `` AFTER the pixels, so on any WebP above the window a
|
||||
fixed read can stop short of an IPTC "Made with AI" tag."""
|
||||
from remove_ai_watermarks.metadata import _riff_late_metadata, scan_head
|
||||
|
||||
p = tmp_path / "late.webp"
|
||||
xmp = (
|
||||
b"<x:xmpmeta><photoshop:DigitalSourceType>trainedAlgorithmicMedia</photoshop:DigitalSourceType></x:xmpmeta>"
|
||||
)
|
||||
Image.new("RGB", (16, 16)).save(p, "WEBP", xmp=xmp)
|
||||
|
||||
assert b"trainedAlgorithmicMedia" in _riff_late_metadata(p, 12)
|
||||
assert b"trainedAlgorithmicMedia" in scan_head(p, 12)
|
||||
|
||||
def test_riff_late_metadata_ignores_the_coded_image(self, tmp_path: Path):
|
||||
"""The point of stepping chunk by chunk rather than reading through: the
|
||||
pixel payload never enters the scan buffer."""
|
||||
from remove_ai_watermarks.metadata import _riff_late_metadata
|
||||
|
||||
p = tmp_path / "plain.webp"
|
||||
Image.new("RGB", (64, 64), (200, 30, 30)).save(p, "WEBP")
|
||||
|
||||
assert _riff_late_metadata(p, 12) == b""
|
||||
|
||||
|
||||
class TestHuggingFaceJob:
|
||||
"""HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""
|
||||
|
||||
@@ -3,10 +3,12 @@ consolidated metadata strip (formerly legacy metadata helper)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
_parse_c2pa_chunk,
|
||||
@@ -835,3 +837,38 @@ class TestTc260ContainerRouting:
|
||||
|
||||
readers = _tc260_container_readers()
|
||||
assert [r.__module__.rsplit(".", 1)[-1] for r in readers] == ["isobmff", "ebml", "riff", "flv"]
|
||||
|
||||
|
||||
class TestC2paReaderFailureIsVisible:
|
||||
"""A reader failure and a file with no manifest both return None, so the caller
|
||||
cannot tell them apart -- and the consequence is not symmetric. A file with no
|
||||
manifest is a normal verdict; a reader that could not read a file it was handed
|
||||
can silently downgrade one, so the log level must make the failure observable."""
|
||||
|
||||
def _records(self, caplog, path: str) -> list[str]:
|
||||
from remove_ai_watermarks._internal import c2pa
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger="remove_ai_watermarks._internal.c2pa"):
|
||||
assert c2pa._manifest_json_uncached(path) is None
|
||||
return [f"{r.levelname} {r.getMessage()}" for r in caplog.records]
|
||||
|
||||
def test_an_unreadable_file_warns(self, caplog):
|
||||
records = self._records(caplog, "/nonexistent/definitely-not-here.png")
|
||||
|
||||
assert any(r.startswith("WARNING") for r in records), records
|
||||
|
||||
def test_an_unsupported_container_stays_quiet(self, caplog, tmp_path: Path):
|
||||
target = tmp_path / "notes.txt"
|
||||
target.write_text("plain text, not a container the reader handles")
|
||||
|
||||
records = self._records(caplog, str(target))
|
||||
|
||||
assert not any(r.startswith("WARNING") for r in records), records
|
||||
|
||||
def test_a_plain_image_without_a_manifest_logs_nothing(self, caplog, tmp_path: Path):
|
||||
target = tmp_path / "plain.png"
|
||||
Image.new("RGB", (8, 8)).save(target)
|
||||
|
||||
records = self._records(caplog, str(target))
|
||||
|
||||
assert records == []
|
||||
|
||||
@@ -17,10 +17,12 @@ import pytest
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.identify import (
|
||||
PROVENANCE_REPORT_SCHEMA_VERSION,
|
||||
ProvenanceReport,
|
||||
evidence_from_metadata_record,
|
||||
identify,
|
||||
identify_from_evidence,
|
||||
identify_metadata_record,
|
||||
)
|
||||
from remove_ai_watermarks.metadata_record import HEAD_WINDOW, collect_metadata_record
|
||||
|
||||
@@ -31,7 +33,7 @@ COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_f
|
||||
def _verdict_via_record(path: Path) -> ProvenanceReport:
|
||||
"""The contractor's path: collect, serialize, judge -- without the file."""
|
||||
record = json.loads(json.dumps(collect_metadata_record(path)))
|
||||
return identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
return identify_metadata_record(record, path=path)
|
||||
|
||||
|
||||
def _assert_same_verdict(path: Path) -> None:
|
||||
@@ -168,6 +170,26 @@ class TestRecordShape:
|
||||
assert record["metadata_base64"] == ""
|
||||
|
||||
|
||||
class TestReportTransport:
|
||||
def test_report_contract_is_versioned_json_and_omits_local_path(self, tmp_path: Path):
|
||||
path = _noise_png(tmp_path / "plain.png")
|
||||
|
||||
payload = identify_metadata_record(collect_metadata_record(path), path=path).to_dict()
|
||||
|
||||
assert payload["schema_version"] == PROVENANCE_REPORT_SCHEMA_VERSION == 1
|
||||
assert "path" not in payload
|
||||
assert json.loads(json.dumps(payload)) == payload
|
||||
|
||||
def test_convenience_entry_point_matches_explicit_sequence(self, tmp_path: Path):
|
||||
path = _noise_png(tmp_path / "plain.png")
|
||||
record = collect_metadata_record(path)
|
||||
|
||||
explicit = identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
convenience = identify_metadata_record(record, path=path)
|
||||
|
||||
assert convenience == explicit
|
||||
|
||||
|
||||
def test_a_webp_record_matches(tmp_path: Path):
|
||||
"""RIFF has its own walk; a chunk kept or dropped wrongly shows up here."""
|
||||
path = tmp_path / "image.webp"
|
||||
|
||||
@@ -18,10 +18,14 @@ from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import tracemalloc
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from remove_ai_watermarks import metadata
|
||||
from remove_ai_watermarks._internal import c2pa, isobmff
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
_HUGE = 0x7FFFFFFF # ~2 GiB declared length on a tiny file
|
||||
|
||||
@@ -128,3 +132,28 @@ class TestIsobmffStripFailSafe:
|
||||
assert stripped == 0
|
||||
assert cleaned == data
|
||||
assert len(cleaned) == len(data)
|
||||
|
||||
|
||||
class TestRiffLateMetadataIsBounded:
|
||||
"""A metadata scan must not become a near-full-file read because one chunk lied
|
||||
about its length. This runs on the memoized verdict path, over images from
|
||||
arbitrary sources."""
|
||||
|
||||
def _webp_with_declared_length(self, path: Path, declared: int, payload: bytes) -> Path:
|
||||
chunk = b"XMP " + declared.to_bytes(4, "little") + payload
|
||||
body = b"WEBP" + b"VP8 " + (4).to_bytes(4, "little") + b"\x00\x00\x00\x00" + chunk
|
||||
path.write_bytes(b"RIFF" + len(body).to_bytes(4, "little") + body)
|
||||
return path
|
||||
|
||||
def test_a_chunk_claiming_the_whole_file_is_clamped_to_what_remains(self, tmp_path: Path):
|
||||
target = self._webp_with_declared_length(tmp_path / "liar.webp", 1 << 30, b"AI" * 64)
|
||||
|
||||
collected = metadata._riff_late_metadata(target, 12)
|
||||
|
||||
assert collected == b"AI" * 64
|
||||
|
||||
def test_the_total_is_capped(self, tmp_path: Path):
|
||||
payload = b"x" * 4096
|
||||
target = self._webp_with_declared_length(tmp_path / "big.webp", len(payload), payload)
|
||||
|
||||
assert len(metadata._riff_late_metadata(target, 12, max_total=512)) == 512
|
||||
|
||||
Reference in New Issue
Block a user