Files
remove-ai-watermarks/scripts/cjk_tail_probe.py
T
Victor KuznetsovandClaude Opus 4.8 52eb40c2ca Mask faint text marks with the detector's match box, not a response threshold
The faint-mask fallback added for the tophat front-end thresholded the
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel,
not "half the peak" as its comment claimed -- and filled ~120% of the corner
box on textured frames. Measured on 14 real faint-path frames (cv2 fill,
detector re-run after): the detector's own best-match box fills a 58.7%-median
corner box vs 120.9% for the threshold, both 100% detector-clean. Detection and
the mask now read one method, _tophat_best, whose score gates detection and
whose argmax box bounds the fill, so the two cannot drift by construction --
which is how the mismatch arose. The 0.5 constant is deleted.

Parity could not catch this (a mask that fills everything is trivially
detector-clean) and the regression test could not either: its flat fixture
gives every threshold the same box, so mutating the constant to 99.0 stayed
green. The fixture now carries texture and asserts the mask area is bounded,
not merely non-empty; it reproduces the corpus number (127% pre-fix).

Also lands the Tier B2 verification harnesses that found and bounded this:
- detector_response.py: response curves (detected AND maskable per cell); found
  the size response is a comb, contrast is near-irrelevant, no unmaskable cells.
- ladder_headroom.py: measured that a denser scale ladder recovers 7.6% of
  misses for a 2.52%->3.05% false-fire rise, and the one landscape rung that
  helps is a geometry shift that helps and hurts equally (1.7:1) -- do not add.
- cjk_tail_probe.py: a generic shared-tail (AI生成) template does not separate
  uncovered vendors from clean corners (0.407 vs clean p99 0.298).

Records the visible-parity re-run confirming the earlier front-end fix (doubao
91.8% -> 99.3%), and dedups the thrice-written stamp forward model into one
fill_quality.composite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:41:18 -07:00

273 lines
11 KiB
Python

"""Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on?
THE OPPORTUNITY
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and
`百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover,
bold and plainly legible, with no detector able to fire on either. `docs/...landscape`
puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in
the covered ones (a dense scale ladder recovers 1 miss in 26; see ladder_headroom.py).
WHY A SHARED TEMPLATE IS EVEN PLAUSIBLE
China's GB 45438-2025 mandates the label, and every compliant vendor ends it with the
same run: `AI生成`. The vendor PREFIX varies (豆包 / 千问 / 百度 / 星绘) and is what makes
per-vendor silhouettes expensive; the TAIL is guaranteed. So the tail is the part worth
detecting, and vendor attribution -- which the shared tail cannot provide anyway
(measured 千问-vs-doubao AUC ~0.5) -- moves to metadata, where it is reliable.
WHY IT IS WORTH RETRYING NOW
`render_vendor_silhouettes.py` records 千问 as ruled out, and its reasoning was sound:
the then-current front-end binarized the glyph, and a thin translucent overlay shatters
into specks that no template can match. It closes by naming what would be needed --
"a detection front-end that does not depend on binarizing the glyph (grayscale/edge
correlation on the raw top-hat)". That front-end now EXISTS: `detect_frontend="tophat"`
was built for doubao and correlates a soft template against the continuous response.
The blocker was removed by unrelated work, so the ruling deserves re-measurement rather
than inheritance.
THE MEASUREMENT
Three arms, one scorer (the tail template on the tophat response, swept over a dense
size ladder because an unregistered vendor's glyph size is genuinely unknown -- the one
place a dense ladder earns its cost):
covered a registered mark already fires -- a sanity arm; the tail is inside those
marks too, so a tail template that cannot score THESE is simply broken
uncovered TC260 provenance but no detector fires -- the target population
clean no metadata signal at all -- the precision arm
The decision rests on whether `uncovered` carries a high-scoring subpopulation that
`clean` does not. It does NOT rest on the raw rate: TC260 provenance does not imply a
visible mark, so a modest fire rate on that arm is expected even if the detector is
perfect. Any threshold this suggests must then be confirmed by eyeballing the crops it
selects -- the script writes a contact sheet for exactly that.
DATA SAFETY
Corpus images are user uploads: read-only, local analysis, gitignored output. The
template is font-rendered synthetic, never cut from a user upload.
uv run python scripts/cjk_tail_probe.py --n 6000
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import random
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any
import cv2
import numpy as np
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 / "data" / "spaces" / "originals"
OUT = REPO / "data" / "spaces" / "_cjk_tail_probe.jsonl"
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
# what writes the committed silhouette into src/.../assets/.
TAIL_PNG = REPO / "data" / "spaces" / "_cjk_tail_silhouette.png"
# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
# unknown here -- unlike a registered mark, whose fraction is calibrated. Hence a wide,
# dense ladder: this is the case a dense ladder is actually for.
TAIL_SCALES = tuple(round(0.30 * (1.08**i), 4) for i in range(18)) # ~0.30 .. ~1.15
def build_tail_silhouette() -> np.ndarray:
"""Font-rendered `AI生成` silhouette, cached next to this script (data-safe)."""
if TAIL_PNG.exists():
img = cv2.imread(str(TAIL_PNG), cv2.IMREAD_GRAYSCALE)
if img is not None:
return img
from render_vendor_silhouettes import render
sil = render("AI生成", width=200)
cv2.imwrite(str(TAIL_PNG), sil)
return sil
def tail_score(engine: Any, image: np.ndarray, sil: np.ndarray) -> tuple[float, float]:
"""Best tail correlation over the size ladder; returns (score, winning_scale)."""
loc = engine.locate(image)
resp = engine.tophat_response(image, loc)
if resp is None:
return (0.0, 0.0)
c = engine.config
base = engine.scale_base(image)
ar = sil.shape[0] / max(1, sil.shape[1])
best, best_s = 0.0, 0.0
for s in TAIL_SCALES:
gw = max(12, int(c.alpha_width_frac * base * s))
gh = max(6, int(gw * ar))
if gw >= resp.shape[1] or gh >= resp.shape[0]:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
if v > best:
best, best_s = v, s
return (best, best_s)
def _one(path_str: str) -> dict[str, Any] | None:
from remove_ai_watermarks.api import visible_provenance
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import detect_marks
path = Path(path_str)
try:
prov = visible_provenance(path)
except Exception:
return None
tc260 = bool({"doubao", "jimeng"} & prov)
if not tc260:
try:
if identify(path, check_visible=False).signals:
return None # some other provenance: neither target nor clean control
except Exception:
return None
img = imread(path_str)
if img is None or min(img.shape[:2]) < 200:
return None
try:
fired = [d.key for d in detect_marks(img) if d.detected]
except Exception:
return None
if fired:
arm = "covered"
elif tc260:
arm = "uncovered"
else:
arm = "clean"
score, scale = tail_score(DoubaoEngine(), img, build_tail_silhouette())
return {
"src": path.name,
"arm": arm,
"fired": fired,
"tail_score": round(score, 4),
"tail_scale": scale,
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=6000)
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(
"--sheet-at", type=float, default=0.0, help="write a contact sheet of `uncovered` crops scoring >= this"
)
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()]
report(rows)
if a.sheet_at:
contact_sheet(rows, a.sheet_at)
return
build_tail_silhouette()
pool = glob.glob(str(CORPUS / "*" / "*"))
random.Random(19).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto
pool = pool[: a.n]
print(f"scanning {len(pool)} corpus files workers={a.workers}")
print(f"tail ladder {TAIL_SCALES[0]} .. {TAIL_SCALES[-1]} ({len(TAIL_SCALES)} rungs)\n", flush=True)
rows: list[dict[str, Any]] = []
a.out.parent.mkdir(parents=True, exist_ok=True)
with open(a.out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.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 sweep
continue
if rec is None:
continue
fh.write(json.dumps(rec) + "\n")
rows.append(rec)
if i % 500 == 0:
fh.flush()
print(f" {i}/{len(pool)} usable={len(rows)}", flush=True)
report(rows)
if a.sheet_at:
contact_sheet(rows, a.sheet_at)
def report(rows: list[dict[str, Any]]) -> None:
arms = {k: [r for r in rows if r["arm"] == k] for k in ("covered", "uncovered", "clean")}
print(f"\n{'=' * 80}\nGENERIC CJK TAIL PROBE ({', '.join(f'{k}={len(v)}' for k, v in arms.items())})\n{'=' * 80}")
print("\nScore distribution of the shared `AI生成` tail on the tophat response\n")
print(f"{'arm':11s} {'n':>6s} {'p50':>7s} {'p75':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
for k, v in arms.items():
if not v:
continue
s = np.array([r["tail_score"] for r in v])
qs = [np.percentile(s, q) for q in (50, 75, 90, 95, 99)]
print(f"{k:11s} {len(v):6d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {s.max():7.3f}")
if arms["clean"] and arms["uncovered"]:
clean = np.array([r["tail_score"] for r in arms["clean"]])
unc = np.array([r["tail_score"] for r in arms["uncovered"]])
print("\n\nOPERATING POINTS -- threshold set on the CLEAN arm, yield read on `uncovered`")
print("`clean` fires are the cost; `uncovered` fires are candidate recoveries that")
print("still need eyeballing, since TC260 provenance does not imply a visible mark.\n")
print(f"{'threshold':>10s} {'clean fire':>12s} {'uncovered fire':>16s} {'candidates':>12s}")
for q in (95, 97.5, 99, 99.5, 99.9):
t = float(np.percentile(clean, q))
cf = float((clean >= t).mean())
uf = float((unc >= t).mean())
print(f"{t:10.3f} {100 * cf:11.2f}% {100 * uf:15.1f}% {int((unc >= t).sum()):12d}")
cov = arms["covered"]
if cov:
s = np.array([r["tail_score"] for r in cov])
print(f"\n\nSANITY: on frames where a registered mark fires, the tail scores median {np.median(s):.3f}.")
print("A tail template that cannot score these is broken, whatever it does elsewhere.")
def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) -> None:
"""Crop the corner of the top-scoring `uncovered` frames so the fires can be judged."""
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.image_io import imread
eng = DoubaoEngine()
picks = sorted(
(r for r in rows if r["arm"] == "uncovered" and r["tail_score"] >= thresh),
key=lambda r: -r["tail_score"],
)[:limit]
tiles = []
for r in picks:
hits = glob.glob(str(CORPUS / "*" / r["src"]))
if not hits:
continue
img = imread(hits[0])
if img is None:
continue
loc = eng.locate(img)
crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
if crop.size:
tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA))
if tiles:
dest = REPO / "data" / "spaces" / "_cjk_tail_sheet.png"
cv2.imwrite(str(dest), np.vstack(tiles))
print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))
if __name__ == "__main__":
main()