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

135 lines
5.6 KiB
Python

"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file.
Ground truth is `uid -> the set of visible marks actually present`, hand-labelled
blind against contact sheets with a two-sided control in every round. Rounds so far:
2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms)
2026-07-18 gemini round : 356 cells (gemini relaxation additions)
DATA SAFETY: the corpus is real user uploads. This script reads the gitignored
corpus and writes a gitignored ground-truth file. Neither the images nor this
output may be committed; only the harness is. See the repo CLAUDE.md.
The labels record what the LABELLER SAW in the crop, one of:
doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain
`other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for
(千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is
also not "clean" -- it is exactly what the relaxed jimeng detector confuses.
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's
honest doubt never becomes a fabricated data point.
"""
from __future__ import annotations
import csv
import json
import sys
from pathlib import Path
SEEN_TO_MARK = {
"doubao": "doubao",
"jimeng": "jimeng",
"pill": "jimeng_pill",
"sparkle": "gemini",
}
# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible
# in the same crop). Doubao and Jimeng share the bottom-right corner.
_ADJUDICATES = {
"doubao": ("doubao", "jimeng"),
"jimeng": ("doubao", "jimeng"),
"jimeng_pill": ("jimeng_pill",),
"gemini": ("gemini",),
"samsung": ("samsung",),
}
ROUNDS = [
("textmark", "labels.csv", "manifest.csv"),
("gemini", "gemini_labels.csv", "gemini_manifest.csv"),
]
def metadata_provenance(path: str) -> list[str]:
"""The vendor keys LOCAL METADATA confirms -- what cli._visible_provenance reads.
Must come from the file's own metadata, never from the labels: deriving it from
the ground truth would hand the detector the answer it is being scored on (a
relaxation only fires when provenance names the vendor, so label-derived
provenance makes every arm look near-perfect). Read from the corpus `identify`
sidecar, which is the same signal production computes.
"""
p = Path(path)
sidecar = Path(str(p.parent).replace("/originals/", "/identify/")) / (p.name.split("_src")[0] + ".json")
if not sidecar.exists():
return []
try:
with sidecar.open() as fh:
wm = " | ".join(json.load(fh).get("watermarks", []))
except Exception:
return []
keys: list[str] = []
if "China AIGC label" in wm:
keys += ["doubao", "jimeng"]
if "C2PA Content Credentials (Google LLC" in wm:
keys.append("gemini")
if "Samsung Galaxy AI" in wm:
keys.append("samsung")
return keys
def main() -> None:
root = Path(sys.argv[1] if len(sys.argv) > 1 else "data/spaces/_research_20260718_textmark_relaxation")
out = root / "groundtruth.jsonl"
rows: dict[str, dict] = {}
stats: dict[str, int] = {}
for round_name, labels_file, manifest_file in ROUNDS:
with (root / labels_file).open() as fh:
labels = {int(r["idx"]): r["seen"] for r in csv.DictReader(fh)}
with (root / manifest_file).open() as fh:
manifest_rows = list(csv.DictReader(fh))
for m in manifest_rows:
seen = labels.get(int(m["idx"]))
if seen is None:
continue
stats[seen] = stats.get(seen, 0) + 1
if seen == "uncertain":
continue # excluded by design -- never coerce a doubt into a label
rec = rows.setdefault(
m["uid"],
{"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []},
)
# ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets
# the labeller rule on marks visible IN THAT CROP. A pill crop (top-left)
# says nothing about a bottom-right wordmark, so scoring jimeng against a
# pill-round image would book real detections as false fires (~61% of pills
# carry a wordmark). Bottom-right marks co-adjudicate each other: one crop
# of that corner shows whichever of Doubao/Jimeng is there.
for k in _ADJUDICATES.get(m["key"], (m["key"],)):
if k not in rec["adjudicated"]:
rec["adjudicated"].append(k)
mark = SEEN_TO_MARK.get(seen)
if mark and mark not in rec["present"]:
rec["present"].append(mark)
if seen not in rec["seen"]:
rec["seen"].append(seen)
if round_name not in rec["rounds"]:
rec["rounds"].append(round_name)
for r in rows.values():
r["provenance"] = metadata_provenance(r["path"])
with out.open("w") as fh:
for r in rows.values():
fh.write(json.dumps(r) + "\n")
print(f"wrote {out} images={len(rows)}")
print("label distribution across all rounds:", dict(sorted(stats.items(), key=lambda kv: -kv[1])))
n_pos = sum(1 for r in rows.values() if r["present"])
print(f"images with at least one registered mark: {n_pos}; clean-of-registered-marks: {len(rows) - n_pos}")
adj: dict[str, int] = {}
for r in rows.values():
for k in r["adjudicated"]:
adj[k] = adj.get(k, 0) + 1
print("images each mark can be SCORED on (adjudication scope):", dict(sorted(adj.items())))
if __name__ == "__main__":
main()