Files
remove-ai-watermarks/scripts/record_parity_audit.py
T
Victor KuznetsovandClaude Opus 5 1124c591be Match the vendor registries against metadata, not coded pixels
The registries are raw substrings and the shortest tokens are four and five bytes
(`Bria`, `Adobe`, `Canva`). Over a megabyte of compressed pixel data such a sequence
turns up by chance: `Bria` matched inside the entropy-coded scan of 4 of 14,707
corpus JPEGs, in none of which the manifest names Bria. The rate is what a four-byte
pattern predicts on that corpus, and the Bria entry asserts AI, so a chance match can
declare an image AI-generated rather than merely mislabel its signer.

`_metadata_region` gives the registry scans the container's metadata: JPEG marker
segments before the coded scan, PNG chunks other than IDAT, both trailers, and
whatever `scan_head` appended past the window. Every other check keeps the full
buffer -- their markers are long and distinctive. A container that does not parse is
returned whole, since dropping real evidence to avoid a chance match is the wrong
trade. `c2pa_marker_in` already refuses a bare `c2pa` substring for this reason;
this is the same defence for the registries.

Verified the way the rules require for a change that MOVES a verdict: over all 48,905
corpus images, exactly one file changed, the one named in advance, from
"C2PA Content Credentials (Bria Artificial Intelligence)" to "(unknown signer)".
Record-path parity is 0 disagreements, down from 75 when this work started.

The audit's own baseline comparison is fixed here too. It compared confidence and
signals only, and so reported "0 changed" for the run whose single intended
correction was a watermark line -- the change it exists to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00

225 lines
8.8 KiB
Python

"""Audit the record path against the file path over a whole dataset.
WHY THIS EXISTS
Two seams reach the same provenance verdict:
identify(path, check_visible=False, check_invisible=False)
identify_metadata_record(collect_metadata_record(path), path=path)
Their equality is the record's entire contract, 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; those cover the signal families we already know about. This covers the ones
we do not: every real placement in a real corpus, which is where all three defects
found so far actually came from.
The record is round-tripped through ``json.dumps``/``loads`` before it is judged, so
a value that only survives in memory fails here rather than at a customer.
WHAT IT REPORTS
One JSONL row per image: both verdicts, whether they agree, the record size, and any
exception from either side. The summary counts disagreements by field and by signal,
so "the record lost samsung_genai on 13 files" reads directly off the output instead
of being reconstructed.
Pass ``--baseline`` with an earlier run to also diff against it. That answers the
other question a detection change raises: which files changed verdict, and are they
exactly the ones that were meant to.
DATA SAFETY
Read-only over a local dataset. Writes only the given output path, which belongs
outside the repository. Resumable: rerunning skips files already recorded.
uv run python scripts/record_parity_audit.py data/spaces/originals .local-eval/parity.jsonl
uv run python scripts/record_parity_audit.py <dataset> <out> --baseline .local-eval/previous.jsonl
"""
from __future__ import annotations
import argparse
import collections
import json
import logging
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Iterator
# The package's OWN tree, not the repository root: from a worktree, an editable
# install resolves `remove_ai_watermarks` to the MAIN checkout, so a script measuring
# this tree would silently import a different one.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from remove_ai_watermarks.identify import identify, identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
log = logging.getLogger(__name__)
SUPPORTED = frozenset({".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"})
# Every field of the verdict a caller can act on. `path` is excluded: it is extraction
# context, and the two paths are handed the same one by construction.
COMPARED = ("is_ai_generated", "platform", "confidence", "ai_source_kind", "ai_from_metadata")
def _verdict(report: Any) -> dict[str, Any]:
return {
**{field: getattr(report, field) for field in COMPARED},
"signals": sorted(signal.name for signal in report.signals),
"watermarks": sorted(report.watermarks),
}
def _audit(path: Path) -> dict[str, Any]:
row: dict[str, Any] = {"path": str(path)}
try:
row["bytes"] = path.stat().st_size
except OSError as exc:
return {**row, "error": f"stat: {exc}"}
try:
started = time.perf_counter()
record = json.loads(json.dumps(collect_metadata_record(path)))
row["collect_ms"] = (time.perf_counter() - started) * 1000
row["record_bytes"] = len(json.dumps(record))
row["container"] = record.get("container")
via_record = _verdict(identify_metadata_record(record, path=path))
except Exception as exc:
return {**row, "error": f"record path: {type(exc).__name__}: {exc}"}
try:
via_file = _verdict(identify(path, check_visible=False, check_invisible=False))
except Exception as exc:
return {**row, "error": f"file path: {type(exc).__name__}: {exc}"}
row["record"] = via_record
row["file"] = via_file
row["agree"] = via_record == via_file
return row
def _iter_images(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED:
yield path
def _done(out_path: Path) -> set[str]:
if not out_path.exists():
return set()
done: set[str] = set()
with out_path.open(encoding="utf-8") as handle:
for line in handle:
try:
done.add(json.loads(line)["path"])
except (ValueError, KeyError):
continue
return done
def _summarize(rows: list[dict[str, Any]], baseline: Path | None) -> None:
failed = [r for r in rows if "error" in r]
usable = [r for r in rows if "error" not in r]
disagreed = [r for r in usable if not r["agree"]]
print(f"\nimages: {len(rows)} errors: {len(failed)} compared: {len(usable)}")
print(f"record path disagrees with file path: {len(disagreed)}")
for row in failed[:10]:
print(f" ERROR {Path(row['path']).name}: {row['error']}")
fields: collections.Counter[str] = collections.Counter()
for row in disagreed:
fields.update(field for field in COMPARED if row["record"][field] != row["file"][field])
for name in set(row["file"]["signals"]) - set(row["record"]["signals"]):
fields[f"signal missing from record: {name}"] += 1
for name in set(row["record"]["signals"]) - set(row["file"]["signals"]):
fields[f"signal only in record: {name}"] += 1
for label, count in fields.most_common():
print(f" {label}: {count}")
for row in disagreed[:10]:
print(f" {Path(row['path']).name}\n record: {row['record']}\n file: {row['file']}")
if baseline is None:
return
previous = {}
with baseline.open(encoding="utf-8") as handle:
for line in handle:
try:
item = json.loads(line)
except ValueError:
continue
if "error" not in item:
previous[Path(item["path"]).name] = item
changed = []
for row in usable:
was = previous.get(Path(row["path"]).name)
if was is None:
continue
# The WHOLE verdict, not a chosen subset. A first version compared confidence
# and signals only and reported "0 changed" for a run whose single intended
# correction was a watermark line -- the change it existed to show.
before = was.get("file") or {}
if before and before != row["file"]:
changed.append((row["path"], before, row["file"]))
print(f"\nverdicts changed against the baseline: {len(changed)}")
moved: collections.Counter[str] = collections.Counter()
for _, before, after in changed:
for name in set(after["signals"]) - set(before.get("signals") or []):
moved[f"gained signal {name}"] += 1
for name in set(before.get("signals") or []) - set(after["signals"]):
moved[f"LOST signal {name}"] += 1
for field in (*COMPARED, "watermarks"):
if before.get(field) != after.get(field):
moved[f"{field} changed"] += 1
for label, count in moved.most_common():
print(f" {label}: {count}")
for path, before, after in changed[:10]:
print(f" {Path(path).name}\n before: {before}\n after: {after}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dataset", type=Path)
parser.add_argument("out", type=Path)
parser.add_argument("--baseline", type=Path, default=None, help="an earlier run to diff verdicts against")
parser.add_argument("--limit", type=int, default=0)
parser.add_argument("--progress-every", type=int, default=2000)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
args.out.parent.mkdir(parents=True, exist_ok=True)
done = _done(args.out)
if done:
log.info("resuming: %d images already audited", len(done))
processed = 0
started = time.monotonic()
with args.out.open("a", encoding="utf-8") as handle:
for path in _iter_images(args.dataset):
if str(path) in done:
continue
handle.write(json.dumps(_audit(path), ensure_ascii=False, default=str) + "\n")
handle.flush()
processed += 1
if processed % args.progress_every == 0:
log.info("%d images, %.1f/s", processed, processed / (time.monotonic() - started))
if args.limit and processed >= args.limit:
break
log.info("audited %d images in %.1f s", processed, time.monotonic() - started)
with args.out.open(encoding="utf-8") as handle:
rows = [json.loads(line) for line in handle]
_summarize(rows, args.baseline)
return 0
if __name__ == "__main__":
raise SystemExit(main())