mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
273 lines
11 KiB
Python
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
|
|
Treat input datasets as sensitive and read-only, and keep output gitignored. The
|
|
template is font-rendered synthetic, never cut from an input image.
|
|
|
|
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 / ".local-eval" / "originals"
|
|
OUT = REPO / ".local-eval" / "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 / ".local-eval" / "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 / ".local-eval" / "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()
|