mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 22:48:37 +02:00
217 lines
8.9 KiB
Python
217 lines
8.9 KiB
Python
"""Tier A1: diff today's `identify` against previously recorded verdict sidecars.
|
|
|
|
An identify-results directory holds the verdict a past run produced for each image in a
|
|
local source directory. Re-running identify and diffing provides a behavioral regression
|
|
suite without requiring new labels.
|
|
|
|
WHAT A DIFF MEANS -- READ THIS BEFORE PANICKING
|
|
The sidecars were written by OLDER versions, so an intended improvement shows up as a
|
|
diff exactly like a regression does. The output is therefore CLASSIFIED, not pass/fail:
|
|
|
|
lost_ai verdict was AI, now is not <- the alarm; almost always a bug
|
|
lost_signal a signal family stopped firing <- the alarm
|
|
new_ai verdict was not AI, now is <- usually an improvement
|
|
new_signal a signal family started firing <- usually an improvement
|
|
platform attribution changed
|
|
confidence confidence level changed
|
|
unchanged identical on every compared axis
|
|
|
|
Only `lost_*` is a regression by default. Everything else needs a look before the
|
|
baseline is moved.
|
|
|
|
WHY FAMILIES, NOT RAW STRINGS
|
|
Watermark descriptions are human-readable prose and their WORDING has changed between
|
|
versions ("C2PA Content Credentials (OpenAI)" vs "... (OpenAI, Truepic)"). Diffing raw
|
|
strings would report every rewording as a lost+new signal pair and bury the real
|
|
regressions. Signals are normalized to families (c2pa, synthid, visible_sparkle, ...)
|
|
so the comparison tracks BEHAVIOR, not phrasing.
|
|
|
|
DATA SAFETY
|
|
Treat input datasets as sensitive and read-only. Output goes under the gitignored
|
|
.local-eval/ directory and records identifiers, never image content.
|
|
|
|
uv run python scripts/sidecar_regression.py --sample 500 # representative trial
|
|
uv run python scripts/sidecar_regression.py # full corpus, resumable
|
|
|
|
Use `--sample` for a trial, never `--limit`: the sidecar list is sorted by day, so
|
|
`--limit N` reads only the EARLIEST day and its result does not generalize (the first
|
|
trial run of this script took 200 files that were all 2026-05-29).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import glob
|
|
import json
|
|
import os
|
|
import random
|
|
import sys
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
IDENTIFY_DIR = REPO / ".local-eval" / "identify"
|
|
ORIGINALS = REPO / ".local-eval" / "originals"
|
|
OUT = REPO / ".local-eval" / "sidecar-regression.jsonl"
|
|
|
|
# Map a watermark description to a stable behavior family. Order matters: the first
|
|
# matching pattern wins, so put the specific tokens above the generic ones.
|
|
_FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
|
("synthid", ("synthid",)),
|
|
("visible_sparkle", ("sparkle",)),
|
|
("visible_doubao", ("豆包", "doubao")),
|
|
("visible_jimeng", ("即梦", "jimeng")),
|
|
("visible_samsung", ("galaxy ai", "generati dall")),
|
|
("aigc_tc260", ("aigc", "tc260")),
|
|
("iptc", ("iptc", "digitalsourcetype", "made with ai", "made-with-ai")),
|
|
("c2pa", ("c2pa", "content credentials")),
|
|
("trustmark", ("trustmark",)),
|
|
("open_invisible", ("open invisible", "dwt", "stable diffusion xl")),
|
|
("xai_signature", ("xai", "grok signature")),
|
|
("exif_generator", ("exif", "software tag", "png text")),
|
|
)
|
|
|
|
|
|
def family_of(description: str) -> str:
|
|
low = description.lower()
|
|
for name, tokens in _FAMILIES:
|
|
if any(t in low for t in tokens):
|
|
return name
|
|
return "other"
|
|
|
|
|
|
def families(descriptions: list[str]) -> set[str]:
|
|
return {family_of(d) for d in descriptions or []}
|
|
|
|
|
|
def compare(sidecar: dict, report: object) -> dict:
|
|
"""Classify the difference between a recorded verdict and a fresh one."""
|
|
old_ai, new_ai = sidecar.get("is_ai_generated"), getattr(report, "is_ai_generated", None)
|
|
old_fam = families(sidecar.get("watermarks") or [])
|
|
new_fam = families(list(getattr(report, "watermarks", []) or []))
|
|
old_plat, new_plat = sidecar.get("platform"), getattr(report, "platform", None)
|
|
old_conf, new_conf = sidecar.get("confidence"), getattr(report, "confidence", None)
|
|
|
|
classes: list[str] = []
|
|
# Treat only a real True->not-True transition as lost. None means "unknown", and the
|
|
# library never asserts False, so None->None is not a change.
|
|
if bool(old_ai) and not bool(new_ai):
|
|
classes.append("lost_ai")
|
|
if not bool(old_ai) and bool(new_ai):
|
|
classes.append("new_ai")
|
|
if old_fam - new_fam:
|
|
classes.append("lost_signal")
|
|
if new_fam - old_fam:
|
|
classes.append("new_signal")
|
|
if old_plat != new_plat:
|
|
classes.append("platform")
|
|
if old_conf != new_conf:
|
|
classes.append("confidence")
|
|
|
|
return {
|
|
"classes": classes or ["unchanged"],
|
|
"old_ai": old_ai,
|
|
"new_ai": new_ai,
|
|
"lost_families": sorted(old_fam - new_fam),
|
|
"new_families": sorted(new_fam - old_fam),
|
|
"old_platform": old_plat,
|
|
"new_platform": new_plat,
|
|
"old_confidence": old_conf,
|
|
"new_confidence": new_conf,
|
|
}
|
|
|
|
|
|
def _one(sidecar_path: str) -> dict:
|
|
uid = os.path.basename(sidecar_path)[:-5]
|
|
day = os.path.basename(os.path.dirname(sidecar_path))
|
|
try:
|
|
with open(sidecar_path, encoding="utf-8") as fh:
|
|
sidecar = json.load(fh)
|
|
except Exception as e: # a corrupt sidecar must not kill the sweep
|
|
return {"uid": uid, "day": day, "status": "sidecar_unreadable", "error": str(e)[:200]}
|
|
|
|
src = sidecar.get("src") or ""
|
|
image = ORIGINALS / day / src
|
|
if not src or not image.exists():
|
|
found = glob.glob(str(ORIGINALS / day / f"{uid}*"))
|
|
if not found:
|
|
return {"uid": uid, "day": day, "status": "image_missing"}
|
|
image = Path(found[0])
|
|
|
|
try:
|
|
from remove_ai_watermarks.identify import identify
|
|
|
|
report = identify(image)
|
|
except Exception as e: # record and continue; a crash IS a finding
|
|
return {"uid": uid, "day": day, "status": "identify_raised", "error": f"{type(e).__name__}: {e}"[:300]}
|
|
|
|
return {"uid": uid, "day": day, "status": "ok", **compare(sidecar, report)}
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--limit", type=int, default=0, help="only N sidecars (trial run)")
|
|
ap.add_argument(
|
|
"--sample",
|
|
type=int,
|
|
default=0,
|
|
help="randomly sample N across ALL days -- use this for a trial, not --limit: "
|
|
"the sidecar list is sorted, so --limit takes one day and is not representative",
|
|
)
|
|
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
|
ap.add_argument("--out", type=Path, default=OUT)
|
|
ap.add_argument("--restart", action="store_true", help="ignore existing output and start over")
|
|
a = ap.parse_args()
|
|
|
|
sidecars = sorted(glob.glob(str(IDENTIFY_DIR / "*" / "*.json")))
|
|
if a.sample:
|
|
random.Random(7).shuffle(sidecars) # noqa: S311 -- deterministic sampling, not crypto
|
|
sidecars = sorted(sidecars[: a.sample])
|
|
elif a.limit:
|
|
sidecars = sidecars[: a.limit]
|
|
if not sidecars:
|
|
raise SystemExit(f"no sidecars under {IDENTIFY_DIR}")
|
|
|
|
# Resume: a full sweep is ~1 h, so never redo work an interrupted run already did.
|
|
done: set[str] = set()
|
|
if a.restart and a.out.exists():
|
|
a.out.unlink() # --restart must TRUNCATE; the file is reopened in append mode below
|
|
if a.out.exists() and not a.restart:
|
|
with open(a.out, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
try:
|
|
done.add(json.loads(line)["uid"])
|
|
except Exception: # noqa: S112 -- tolerate a torn last line from an interrupted run
|
|
continue
|
|
todo = [s for s in sidecars if os.path.basename(s)[:-5] not in done]
|
|
print(f"sidecars {len(sidecars)} already done {len(done)} to do {len(todo)} workers {a.workers}")
|
|
|
|
counts: collections.Counter[str] = collections.Counter()
|
|
a.out.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
|
futures = {ex.submit(_one, s): s for s in todo}
|
|
for i, fut in enumerate(as_completed(futures), 1):
|
|
rec = fut.result()
|
|
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
if rec["status"] != "ok":
|
|
counts[rec["status"]] += 1
|
|
else:
|
|
for c in rec["classes"]:
|
|
counts[c] += 1
|
|
if i % 500 == 0:
|
|
fh.flush()
|
|
print(f" {i}/{len(todo)} " + " ".join(f"{k}={v}" for k, v in counts.most_common(6)), flush=True)
|
|
|
|
print(f"\n{'=' * 70}\nSIDECAR REGRESSION processed={len(todo)}\n{'=' * 70}")
|
|
for k, v in counts.most_common():
|
|
flag = " <-- REGRESSION" if k.startswith("lost_") or k in ("identify_raised",) else ""
|
|
print(f" {k:22s} {v:7d}{flag}")
|
|
print(f"\nfull records: {a.out}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|