""" Produce "shared copy" variants of eval images: what survives being passed around. Why --- Every P0 measurement so far is on *untouched downloads*, where a C2PA manifest decides the verdict and detection is 100%. That is not how images reach a visitor. A messaging app, a social upload or a re-save downscales the image, recompresses it as JPEG, and discards the metadata — including the signature. At that point the model is alone. This script isolates exactly that: same pixels, minus the manifest, plus the recompression an ordinary sharing path applies. Comparing the two conditions gives the cost of losing provenance, measured rather than assumed. What this is NOT ---------------- **Not the screenshot arm.** A screenshot adds display rescaling, a second round of resampling, possible UI chrome and colour-profile conversion. This is the milder, cleaner case: metadata stripped and one recompression. Treat the numbers as an OPTIMISTIC bound on the sharing path — real screenshots should be no better and are likely worse. Owner-collected screenshots remain the thing that settles it; see the P0 spec. Output is written to `ai//reencoded_sim/`, deliberately suffixed `_sim` so no reader mistakes a generated condition for collected evidence. Usage ----- python scripts/dataset/simulate_shared_copy.py --root data/closed_eval python scripts/dataset/simulate_shared_copy.py --root data/closed_eval --force """ from __future__ import annotations import argparse from pathlib import Path from PIL import Image # Chosen to mimic a mainstream messaging/social pipeline rather than a worst # case: most resize the long edge to roughly this and recompress at high-ish # quality. A harsher setting would flatter the argument for P1 by making the # baseline look worse than reality. LONG_EDGE = 1080 JPEG_QUALITY = 85 def shared_copy(src: Path, dst: Path) -> None: """Downscale, recompress as JPEG, and drop every metadata block.""" with Image.open(src) as im: im = im.convert("RGB") w, h = im.size scale = LONG_EDGE / max(w, h) if scale < 1.0: im = im.resize((round(w * scale), round(h * scale)), Image.LANCZOS) # A fresh image object carries no info dict, so the C2PA chunk, EXIF and # ICC profile are all left behind — the same way a share pipeline does it. out = Image.new("RGB", im.size) out.putdata(list(im.getdata())) out.save(dst, "JPEG", quality=JPEG_QUALITY, optimize=True) def main() -> None: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--root", type=Path, default=Path("data/closed_eval")) ap.add_argument("--force", action="store_true", help="Regenerate even if the output folder already has files.") args = ap.parse_args() ai_root = args.root / "ai" if not ai_root.is_dir(): raise SystemExit(f"{ai_root} does not exist.") markers = (b"caBX", b"jumbf", b"c2pa") total = 0 for tool_dir in sorted(p for p in ai_root.iterdir() if p.is_dir()): src_dir = tool_dir / "original" if not src_dir.is_dir(): continue dst_dir = tool_dir / "reencoded_sim" dst_dir.mkdir(parents=True, exist_ok=True) existing = list(dst_dir.glob("*.jpg")) if existing and not args.force: print(f"{tool_dir.name}: {len(existing)} already present, skipping " f"(use --force to regenerate)") continue made = leaked = 0 for src in sorted(src_dir.glob("*.png"), key=lambda p: p.stem): dst = dst_dir / f"{src.stem}.jpg" shared_copy(src, dst) # Verify the point of the exercise actually happened. If a manifest # survived, the condition is not what it claims to be. if any(m in dst.read_bytes() for m in markers): leaked += 1 made += 1 total += made status = "all manifests stripped" if not leaked else f"{leaked} STILL CARRY C2PA" print(f"{tool_dir.name}: wrote {made} shared copies -> {dst_dir} ({status})") print(f"\n{total} images written. Re-run the eval to compare conditions:") print(" python scripts/eval_closed_generators.py --dir data/closed_eval") if __name__ == "__main__": main()