Files
remove-ai-watermarks/scripts/vendor_cohort_harvest.py
T
Victor KuznetsovandClaude Opus 5 78d9e81d0f Collapse the duplicated detection path and lift the image pipeline into the library
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.

Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.

Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.

`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.

Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:

  * A TC260 label now relaxes the vendor its `ContentProducer` names rather than
    ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
    of them the previously relaxed pair was simply the wrong vendor, and the
    mark actually present never reached the relaxed gate its own
    `provenance_ncc_factor` was calibrated for.
  * A confident LibLibAI detection suppresses the Jimeng pill, like every other
    TC260 product's mark. It was registered alongside RunningHub and Baidu, both
    of which were added to the hand-written veto list, and it was not. 1 sample
    moves, and it is exactly the co-firing case.

Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.

Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:45 -07:00

235 lines
9.4 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"
# `uscc_of` moved into the library (`metadata.uscc_of`) when the USCC -> vendor table
# started driving `api.visible_provenance`; this script must group by the same rule the
# product uses, so it imports rather than reimplements it.
from remove_ai_watermarks.metadata import uscc_of # noqa: E402
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()