mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
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>
225 lines
8.8 KiB
Python
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())
|