mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
165 lines
6.6 KiB
Python
165 lines
6.6 KiB
Python
"""Benchmark harness for the visible-mark detectors.
|
|
|
|
Run this before AND after any detector change. It re-runs perception over the
|
|
hand-labelled ground truth and reports, per mark, how often a fire is correct --
|
|
with Wilson intervals, so a change inside the noise is visible as such.
|
|
|
|
uv run python scripts/visible_eval.py # score current code
|
|
uv run python scripts/visible_eval.py --save baseline # snapshot for comparison
|
|
uv run python scripts/visible_eval.py --vs baseline # diff against a snapshot
|
|
|
|
WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number:
|
|
|
|
* PRECISION: sound. Every labelled crop is centred on the region a detector
|
|
pointed at, so "the detector fired mark K here, was K actually there" is
|
|
exactly the question the labels answer.
|
|
* RECALL: NOT measurable here, and the harness refuses to print it. The labelled
|
|
images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls),
|
|
so images carrying a mark that every detector missed are absent by construction.
|
|
Computing recall on this set would divide by a denominator that excludes exactly
|
|
the failures recall is meant to expose, and would report a flattering number.
|
|
Recall needs a RANDOM corpus sample laballed exhaustively -- a separate round.
|
|
|
|
* `other_ai_label` (千问 / 百度 / 星绘 / 抖音) counts as a FALSE fire for any
|
|
registered mark, because it is a different vendor's label. It is tracked
|
|
separately in the confusion output since it is the dominant jimeng failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import sys
|
|
from collections import Counter
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from remove_ai_watermarks import watermark_registry as wr
|
|
from remove_ai_watermarks.image_io import imread
|
|
|
|
GT = Path(".local-eval/textmark-relaxation/groundtruth.jsonl")
|
|
SNAP = Path(".local-eval/textmark-relaxation/snapshots")
|
|
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
|
|
|
|
|
|
def wilson(k: int, n: int) -> tuple[float, float]:
|
|
if n == 0:
|
|
return (0.0, 0.0)
|
|
z, p = 1.96, k / n
|
|
d = 1 + z * z / n
|
|
c = p + z * z / (2 * n)
|
|
s = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
|
return ((c - s) / d, (c + s) / d)
|
|
|
|
|
|
def provenance_for(rec: dict) -> frozenset[str]:
|
|
"""The provenance production would have: read from the file's own METADATA.
|
|
|
|
Never derive this from the labels. A relaxation arm only fires when provenance
|
|
names the vendor, so label-derived provenance silently tells the detector the
|
|
answer and every arm scores near-perfect (observed: gemini 99% instead of 34%).
|
|
"""
|
|
return frozenset(rec.get("provenance", []))
|
|
|
|
|
|
def score(sensitivity: str = "auto") -> dict:
|
|
recs = [json.loads(line) for line in GT.open()]
|
|
per: dict[str, Counter] = {m: Counter() for m in MARKS}
|
|
confusion: dict[str, Counter] = {m: Counter() for m in MARKS}
|
|
missing = 0
|
|
for rec in recs:
|
|
img = imread(rec["path"])
|
|
if img is None:
|
|
missing += 1
|
|
continue
|
|
cands = wr._build_candidates(img)
|
|
ctx = wr.Context(sensitivity=sensitivity, provenance=provenance_for(rec))
|
|
fired = {d.candidate.key for d in wr.decide(cands, ctx)}
|
|
for m in MARKS:
|
|
# Only score a mark on images whose shown crop could rule on it. Scoring
|
|
# outside that scope books real detections as false fires -- see the
|
|
# adjudication note in visible_groundtruth.py.
|
|
if m not in rec.get("adjudicated", []):
|
|
continue
|
|
if m not in fired:
|
|
per[m]["fn"] += 1 if m in rec["present"] else 0
|
|
continue
|
|
if m in rec["present"]:
|
|
per[m]["tp"] += 1
|
|
else:
|
|
per[m]["fp"] += 1
|
|
for s in rec["seen"]:
|
|
confusion[m][s] += 1
|
|
scope = {m: sum(1 for r in recs if m in r.get("adjudicated", [])) for m in MARKS}
|
|
return {
|
|
"per": {m: dict(c) for m, c in per.items()},
|
|
"scope": scope,
|
|
"confusion": {m: dict(c) for m, c in confusion.items()},
|
|
"missing": missing,
|
|
"n": len(recs),
|
|
"sensitivity": sensitivity,
|
|
}
|
|
|
|
|
|
def report(res: dict, prev: dict | None = None) -> None:
|
|
print(f"\nground truth: {res['n']} images ({res['missing']} unreadable) sensitivity={res['sensitivity']}")
|
|
print("=" * 78)
|
|
print(
|
|
f"{'mark':12s} {'scope':>6s} {'fires':>6s} {'correct':>8s} "
|
|
f"{'precision':>11s} {'95% CI':>13s} {'missed':>7s} delta"
|
|
)
|
|
print("-" * 78)
|
|
for m in MARKS:
|
|
c = res["per"][m]
|
|
tp, fp, fn = c.get("tp", 0), c.get("fp", 0), c.get("fn", 0)
|
|
n = tp + fp
|
|
scope = res["scope"].get(m, 0)
|
|
if n == 0:
|
|
print(f"{m:12s} {scope:6d} {0:6d} {'-':>8s} {'-':>11s} {'-':>13s} {fn:7d}")
|
|
continue
|
|
lo, hi = wilson(tp, n)
|
|
delta = ""
|
|
if prev:
|
|
pc = prev["per"][m]
|
|
pn = pc.get("tp", 0) + pc.get("fp", 0)
|
|
if pn:
|
|
d = tp / n - pc.get("tp", 0) / pn
|
|
delta = f"{d:+.1%} (fires {pn}->{n})"
|
|
print(f"{m:12s} {scope:6d} {n:6d} {tp:8d} {tp / n:10.0%} {lo:5.0%}-{hi:<6.0%} {fn:7d} {delta}")
|
|
print("\nwhat the FALSE fires actually were:")
|
|
for m in MARKS:
|
|
conf = {k: v for k, v in res["confusion"][m].items() if k != m}
|
|
if conf:
|
|
print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}")
|
|
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.")
|
|
print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images")
|
|
print(" every detector missed are absent by construction. Use it only to catch a change")
|
|
print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.")
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--save", metavar="NAME")
|
|
ap.add_argument("--vs", metavar="NAME")
|
|
ap.add_argument("--sensitivity", default="auto")
|
|
a = ap.parse_args()
|
|
res = score(a.sensitivity)
|
|
prev = None
|
|
if a.vs:
|
|
f = SNAP / f"{a.vs}.json"
|
|
if f.exists():
|
|
prev = json.loads(f.read_text())
|
|
else:
|
|
print(f"(no snapshot {f}; showing absolute numbers)")
|
|
report(res, prev)
|
|
if a.save:
|
|
SNAP.mkdir(parents=True, exist_ok=True)
|
|
(SNAP / f"{a.save}.json").write_text(json.dumps(res, indent=1))
|
|
print(f"\nsaved snapshot -> {SNAP / f'{a.save}.json'}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|