Files
remove-ai-watermarks/scripts/metadata_removal_audit.py
T

263 lines
9.6 KiB
Python

"""Audit AI-metadata REMOVAL over a local image corpus (detection<->removal parity).
`corpus_gap_scan.py` proves the DETECTOR sees a marker; this proves the STRIPPER
reaches it. For every file that carries an AI-metadata signal, run
``remove_ai_metadata`` and re-scan the output with the SAME oracle
(``get_ai_metadata``). Any signal that survives is a real parity bug: a re-served
file still reads as AI. Also assert the strip is lossless -- decoded pixels
(and alpha) bit-identical before/after -- since the removal must only touch
metadata, never the coded image.
A no-op control set (clean images with no AI metadata) verifies the stripper
neither ADDS a signal nor corrupts pixels on files it should leave alone.
Operates on gitignored local data only; writes nothing tracked.
uv run python scripts/metadata_removal_audit.py \
--corpus .local-eval/originals --identify .local-eval/identify \
--out .local-eval/metadata-removal-audit.csv --jobs 8
"""
from __future__ import annotations
import csv
import json
import logging
import random
import tempfile
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
import click
import numpy as np
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
log = logging.getLogger(__name__)
# identify-JSON watermark substrings that imply a METADATA-borne signal (as
# opposed to a purely visual sparkle/text mark). Used only to pick the candidate
# population fast; get_ai_metadata is the per-file ground truth.
_META_HINTS = (
"C2PA",
"Content Credentials",
"IPTC",
"Made with AI",
"AIGC",
"TC260",
"EXIF",
"Signature",
"SynthID",
"hf-job",
"HuggingFace",
"Samsung",
"soft-binding",
"metadata",
)
def _pixels(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
"""Decoded BGR + alpha, for the lossless-strip integrity check."""
from remove_ai_watermarks import image_io
return image_io.read_bgr_and_alpha(path)
def _same_pixels(a: Path, b: Path) -> bool | None:
"""True/False if both decode; None if either is undecodable (can't compare)."""
try:
bgr_a, al_a = _pixels(a)
bgr_b, al_b = _pixels(b)
except Exception:
return None
if bgr_a is None or bgr_b is None:
return None
if bgr_a.shape != bgr_b.shape or not np.array_equal(bgr_a, bgr_b):
return False
if (al_a is None) != (al_b is None):
return False
return al_a is None or al_b is None or np.array_equal(al_a, al_b)
def _audit_one(path_str: str) -> dict[str, object]:
"""Worker: detect -> strip -> re-detect + pixel-integrity for one file."""
from remove_ai_watermarks.metadata import get_ai_metadata, remove_ai_metadata
path = Path(path_str)
row: dict[str, object] = {
"path": path.name,
"ext": path.suffix.lower(),
"carrier": False,
"before": "",
"after": "",
"parity_ok": "",
"pixels_identical": "",
"status": "ok",
}
try:
before = get_ai_metadata(path)
except Exception as exc:
row["status"] = f"scan_error:{type(exc).__name__}"
return row
row["before"] = "|".join(sorted(before))
row["carrier"] = bool(before)
with tempfile.TemporaryDirectory() as td:
out = Path(td) / f"clean{path.suffix.lower()}"
try:
remove_ai_metadata(path, out)
except Exception as exc:
row["status"] = f"strip_error:{type(exc).__name__}"
return row
if not out.exists():
row["status"] = "no_output"
return row
try:
after = get_ai_metadata(out)
except Exception as exc:
row["status"] = f"rescan_error:{type(exc).__name__}"
return row
row["after"] = "|".join(sorted(after))
row["parity_ok"] = not after # every AI signal must be gone
same = _same_pixels(path, out)
row["pixels_identical"] = "" if same is None else same
return row
def _candidate_paths(corpus: Path, identify: Path | None, clean_sample: int) -> tuple[list[Path], list[Path]]:
"""Return (carriers, clean_controls). Uses identify JSONs when present to pick
metadata carriers fast; falls back to scanning every file."""
if identify is None or not identify.exists():
files = sorted(p for p in corpus.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED_FORMATS)
return files, []
carriers: list[Path] = []
clean: list[Path] = []
for jf in identify.rglob("*.json"):
try:
d = json.loads(jf.read_text())
except Exception: # noqa: S112 -- skip an unreadable identify JSON, not security-relevant
continue
src = d.get("src")
if not src:
continue
img = corpus / jf.parent.name / src
if not img.exists() or img.suffix.lower() not in SUPPORTED_FORMATS:
continue
wm = " | ".join(d.get("watermarks") or [])
if any(h in wm for h in _META_HINTS):
carriers.append(img)
elif not d.get("is_ai_generated"):
clean.append(img)
rng = random.Random(0) # noqa: S311 -- deterministic sampling seed, not cryptographic
rng.shuffle(clean)
return carriers, clean[:clean_sample]
@click.command()
@click.option(
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path(".local-eval/originals"),
)
@click.option(
"--identify",
type=click.Path(path_type=Path),
default=Path(".local-eval/identify"),
help="identify-JSON dir to pick carriers (skip = scan all).",
)
@click.option(
"--out",
type=click.Path(path_type=Path),
default=Path(".local-eval/metadata-removal-audit.csv"),
)
@click.option(
"--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op."
)
@click.option("--limit", type=int, default=0, help="Cap carriers scanned (0 = all).")
@click.option("--jobs", type=int, default=8)
def main(corpus: Path, identify: Path | None, out: Path, clean_sample: int, limit: int, jobs: int) -> None:
logging.basicConfig(level=logging.ERROR, format="%(message)s")
carriers, clean = _candidate_paths(corpus, identify, clean_sample)
if limit:
carriers = carriers[:limit]
tasks = [(p, "carrier") for p in carriers] + [(p, "clean") for p in clean]
click.echo(f"Carriers: {len(carriers)} | clean controls: {len(clean)} | jobs {jobs}")
rows: list[dict[str, object]] = []
with ProcessPoolExecutor(max_workers=jobs) as ex:
futs = {ex.submit(_audit_one, str(p)): k for p, k in tasks}
for done, fut in enumerate(as_completed(futs), 1):
row = fut.result()
row["kind"] = futs[fut]
rows.append(row)
if done % 500 == 0:
click.echo(f" {done}/{len(tasks)}")
out.parent.mkdir(parents=True, exist_ok=True)
fields = ["kind", "path", "ext", "carrier", "before", "after", "parity_ok", "pixels_identical", "status"]
with out.open("w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
for r in rows:
w.writerow({k: r.get(k, "") for k in fields})
# ---- Summary ----
car = [r for r in rows if r["kind"] == "carrier"]
ctl = [r for r in rows if r["kind"] == "clean"]
real_carriers = [r for r in car if r["carrier"] and r["status"] == "ok"]
parity_fail = [r for r in real_carriers if r["parity_ok"] is False]
pixel_fail = [r for r in real_carriers if r["pixels_identical"] is False]
errors = [r for r in rows if r["status"] != "ok"]
click.echo("\n===== METADATA REMOVAL PARITY =====")
click.echo(f"Carrier candidates: {len(car)}")
click.echo(f"Confirmed carriers: {len(real_carriers)} (get_ai_metadata non-empty)")
click.echo(f"Parity FAILS (signal survives strip): {len(parity_fail)}")
click.echo(f"Pixel-integrity FAILS (strip altered pixels): {len(pixel_fail)}")
click.echo(f"Errors (scan/strip/decode): {len(errors)}")
# Per-signal parity breakdown.
sig_total: Counter[str] = Counter()
sig_fail: Counter[str] = Counter()
for r in real_carriers:
for s in str(r["before"]).split("|"):
if s:
sig_total[s] += 1
if r["parity_ok"] is False:
for s in str(r["after"]).split("|"):
if s:
sig_fail[s] += 1
click.echo("\nPer-signal (carriers / surviving-after-strip):")
for s, n in sig_total.most_common():
click.echo(f" {s:24} {n:6} survived: {sig_fail.get(s, 0)}")
click.echo("\n===== NO-OP CONTROL (clean images) =====")
ctl_ok = [r for r in ctl if r["status"] == "ok"]
added = [r for r in ctl_ok if r["after"]] # strip must not ADD a signal
corrupted = [r for r in ctl_ok if r["pixels_identical"] is False]
click.echo(f"Clean controls scanned: {len(ctl_ok)}")
click.echo(f"Strip ADDED a signal: {len(added)}")
click.echo(f"Strip corrupted pixels: {len(corrupted)}")
if parity_fail:
click.echo("\n--- Parity failures (first 30) ---")
for r in parity_fail[:30]:
click.echo(f" {r['ext']:6} survived=[{r['after']}] {r['path']}")
if pixel_fail:
click.echo("\n--- Pixel-integrity failures (first 30) ---")
for r in pixel_fail[:30]:
click.echo(f" {r['ext']:6} {r['path']}")
if errors:
ec: Counter[str] = Counter(str(r["status"]) for r in errors)
click.echo("\n--- Errors by kind ---")
for k, n in ec.most_common():
click.echo(f" {n:5} {k}")
click.echo(f"\nReport: {out}")
if __name__ == "__main__":
main()