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

242 lines
9.6 KiB
Python

"""Partition China-AIGC carriers into VENDOR COHORTS by their TC260 producer code.
THE PROBLEM THIS SOLVES
Coverage of uncovered vendors is the largest remaining detection lever
(`docs/verification-plan.md`, "Where detection work should go next"), and it is
blocked on EVIDENCE: nothing may be registered off a single frame, and the
previous session found exactly one confirmed positive each for `千问` and `百度`.
Harvesting more by PIXELS is circular -- a detector is what we are trying to
build -- and the generic shared-tail probe is too weak to label with (0.407 on a
bold positive against a clean p99 of 0.298; see `cjk_tail_probe.py`).
THE KEY
The TC260 label is not anonymous. Its `ContentProducer` field carries the
producer's Chinese Unified Social Credit Code (USCC), e.g.
``001191110102MACQD9K64010000`` -> USCC ``91110102MACQD9K640``, which names a
specific legal entity. So the metadata partitions carriers into per-ENTITY
cohorts without looking at a single pixel. A cohort is a LABEL: once one frame
in it is eyeballed, every frame in it is a labelled example of that vendor's
mark. That is what turns "one confirmed positive" into "30+ per vendor".
CLAUDE.md's "the generic TC260 label names no specific vendor" is about the
label MARKER (the bare presence of `TC260:AIGC`), which indeed names nobody.
The producer FIELD inside the block is a different thing and does name one.
Caveat kept in view: the code names the SIGNING ENTITY, which is not always the
consumer brand (an aggregator or a cloud host signs for several apps, and one
vendor can hold several codes). So a cohort is a strong grouping key and a
hypothesis about the brand -- the brand itself is settled by reading the crop,
which is what `--sheets` is for.
WHAT IT COSTS
Metadata only. The expensive pixel pass is NOT re-run: which detectors fired is
joined from `_visible_positives.jsonl` (a completed local evaluation artifact), per
the standing rule against relaunching finished sweeps to re-check them.
DATA SAFETY
Treat input datasets as sensitive and read-only. Contact sheets stay under
`.local-eval/`; nothing generated here is committed.
uv run python scripts/vendor_cohort_harvest.py
uv run python scripts/vendor_cohort_harvest.py --report-only --sheets 12
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
from collections import Counter
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "vendor-cohorts.jsonl"
FIRED = REPO / ".local-eval" / "visible-positives.jsonl"
SHEET_DIR = REPO / ".local-eval" / "vendor-cohort-sheets"
# A producer code is `001` + `1` + USCC(18) + a 5-digit app/product suffix, so two
# codes sharing the USCC are the same legal entity registering different products.
# Slicing is defensive: anything not matching the layout is grouped by its raw value.
_USCC_START, _USCC_END = 4, 22
def uscc_of(code: str) -> str:
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
if len(code) >= _USCC_END and code[:3] == "001":
return code[_USCC_START:_USCC_END]
return code
def _one(path_str: str) -> dict[str, Any] | None:
from remove_ai_watermarks.metadata import aigc_label
try:
label = aigc_label(Path(path_str))
except Exception:
return None
if not label:
return None
producer = str(label.get("ContentProducer") or "")
return {
"path": path_str,
"producer": producer,
"uscc": uscc_of(producer),
"propagator": str(label.get("ContentPropagator") or ""),
"service_provider": str(label.get("ServiceProvider") or ""),
}
def load_fired() -> dict[str, list[str]]:
"""Map each path to detector keys from a completed local evaluation artifact."""
if not FIRED.exists():
print(f"WARNING: {FIRED.name} missing; cohorts will show no detector state")
return {}
out: dict[str, list[str]] = {}
for line in FIRED.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rec = json.loads(line)
out[rec["path"]] = rec.get("keys") or []
return out
def scan(limit: int, workers: int, out_path: Path) -> list[dict[str, Any]]:
pool = sorted(glob.glob(str(CORPUS / "*" / "*")))
if limit:
pool = pool[:limit]
print(f"scanning {len(pool)} local files for TC260 labels workers={workers}", flush=True)
rows: list[dict[str, Any]] = []
out_path.parent.mkdir(parents=True, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_one, p) for p in pool]
for i, fut in enumerate(as_completed(futures), 1):
try:
rec = fut.result()
except Exception: # noqa: S112 -- one bad file must not kill the scan
continue
if rec is not None:
fh.write(json.dumps(rec) + "\n")
rows.append(rec)
if i % 5000 == 0:
fh.flush()
print(f" {i}/{len(pool)} carriers={len(rows)}", flush=True)
return rows
def report(rows: list[dict[str, Any]], fired: dict[str, list[str]], min_size: int) -> None:
by_uscc: dict[str, list[dict[str, Any]]] = {}
for r in rows:
by_uscc.setdefault(r["uscc"], []).append(r)
print(f"\n{'=' * 92}\nVENDOR COHORTS ({len(rows)} TC260 carriers, {len(by_uscc)} distinct entities)\n{'=' * 92}")
print("\n`fires` = share of the cohort where SOME registered detector fires.")
print("A large cohort with a low fire rate is an uncovered vendor -- the harvest target.\n")
print(f"{'entity (USCC)':22s} {'n':>6s} {'fires':>7s} {'detectors seen':38s} {'products':>8s}")
print("-" * 92)
cohorts = sorted(by_uscc.items(), key=lambda kv: -len(kv[1]))
for uscc, members in cohorts:
if len(members) < min_size:
continue
keys: Counter[str] = Counter()
hit = 0
for m in members:
ks = fired.get(m["path"], [])
if ks:
hit += 1
keys.update(ks)
seen = ", ".join(f"{k}:{c}" for k, c in keys.most_common(4)) or "-- none --"
products = len({m["producer"] for m in members})
print(f"{uscc:22s} {len(members):6d} {100 * hit / len(members):6.1f}% {seen:38s} {products:8d}")
small = sum(1 for _, m in cohorts if len(m) < min_size)
if small:
print(f"\n({small} cohorts below --min-size {min_size} not shown)")
def _bands(img: Any, width: int, band: int) -> list[Any]:
"""Full-width top and bottom bands, scaled to a readable common width.
An unregistered vendor's placement is unknown, so cropping the bottom-RIGHT
corner (where the marks we already cover happen to sit) would beg the
question. Full-width bands catch any horizontal position, and the two bands
together cover every corner the standard's implementers actually use.
"""
import cv2
h = img.shape[0]
strip = max(24, int(h * band))
out = []
for piece in (img[:strip], img[h - strip :]):
scale = width / max(1, piece.shape[1])
out.append(cv2.resize(piece, (width, max(12, int(piece.shape[0] * scale))), interpolation=cv2.INTER_AREA))
return out
def sheets(rows: list[dict[str, Any]], fired: dict[str, list[str]], per: int, min_size: int) -> None:
"""Top/bottom bands per uncovered cohort, so the vendor and mark can be read off."""
import cv2
import numpy as np
from remove_ai_watermarks.image_io import imread
by_uscc: dict[str, list[dict[str, Any]]] = {}
for r in rows:
by_uscc.setdefault(r["uscc"], []).append(r)
SHEET_DIR.mkdir(parents=True, exist_ok=True)
print(f"\nwriting contact sheets -> {SHEET_DIR}")
for uscc, members in sorted(by_uscc.items(), key=lambda kv: -len(kv[1])):
if len(members) < min_size:
continue
quiet = [m for m in members if not fired.get(m["path"])]
if not quiet:
continue
width = 900
tiles: list[Any] = []
for m in quiet[:per]:
img = imread(m["path"])
if img is None:
continue
for b in _bands(img, width, 0.10):
tiles.append(b)
tiles.append(np.full((3, width, 3), 60, np.uint8))
if tiles:
dest = SHEET_DIR / f"{uscc}_n{len(members)}_quiet{len(quiet)}.png"
cv2.imwrite(str(dest), np.vstack(tiles))
print(f" {dest.name} ({len(tiles) // 4} frames)")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=0, help="cap files scanned (0 = all)")
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("--report-only", action="store_true")
ap.add_argument("--min-size", type=int, default=5, help="hide cohorts smaller than this")
ap.add_argument("--sheets", type=int, default=0, help="crops per cohort contact sheet")
a = ap.parse_args()
if a.report_only:
rows = [json.loads(x) for x in a.out.read_text(encoding="utf-8").splitlines() if x.strip()]
else:
rows = scan(a.limit, a.workers, a.out)
fired = load_fired()
report(rows, fired, a.min_size)
if a.sheets:
sheets(rows, fired, a.sheets, a.min_size)
if __name__ == "__main__":
main()