"""How much recall is the coarse scale ladder costing, and what would a denser one cost? THE FINDING THIS MEASURES `_tophat_score` sweeps three rungs -- (0.8, 1.0, 1.25) -- and the `binary` front-end sweeps none at all. Measured on stamped marks over controlled backgrounds (`scripts/detector_response.py`), the response is a COMB: doubao scores 0.99 exactly at each rung and collapses to 0.37-0.48 between them, against a 0.50 gate. So a mark whose rendered size lands mid-gap is missed at FULL contrast. Jimeng (binary, one nominal size) holds a single 0.90-1.20 lobe; samsung only 0.95-1.05. WHY A SYNTHETIC SWEEP IS NOT ENOUGH TO JUSTIFY A FIX Dead zones only cost recall if real marks land in them. The fractions were calibrated on real captures, so it is entirely possible that real marks cluster at ratio 1.0 and the gaps are never visited. That question is not answerable by stamping -- it needs the corpus. THE MEASUREMENT Positives are images carrying INDEPENDENT vendor provenance (TC260 / C2PA metadata naming the vendor), which is evidence that does not come from the pixel detector we are grading -- the same discriminator that settled the pill gate. For every such image the detector currently MISSES, rescore at a dense ladder and ask whether it would now cross the gate, and at which scale. Negatives are images with NO metadata signal at all. That is deliberately NOT "and no mark was detected": defining the set by the detector's own verdict would make its false-fire rate 0 by construction and the comparison vacuous. The cost is that a metadata-stripped screenshot of a marked image sits in the negative set and its correct detection is counted as a false fire -- but that impurity is identical under both ladders, so the DELTA between them, which is what the fix is judged on, stays sound. Two more impurities to keep in view when reading the positive arm. Provenance says the vendor produced the file, not that a visible mark is on it. And the TC260 label names no specific vendor, so `visible_provenance` maps it to BOTH doubao and jimeng -- a "doubao positive" may carry a jimeng mark or none. Both inflate the miss count, so the recovery percentage is an upper bound on what a denser ladder buys. DATA SAFETY Corpus images are user uploads: read-only, local analysis, gitignored output. uv run python scripts/ladder_headroom.py --mark doubao --n 4000 """ from __future__ import annotations import argparse import collections import glob import json import math 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)) REPO = Path(__file__).resolve().parents[1] CORPUS = REPO / "data" / "spaces" / "originals" OUT = REPO / "data" / "spaces" / "_ladder_headroom.jsonl" # The rungs the product ships today, and the dense ladder under evaluation. The dense one # is geometric with a ~6% step, chosen from the measured half-width of a rung's lobe # (doubao holds >=0.5 for about +/-6% around each peak), so no gap is left uncovered. SHIPPED = (0.8, 1.0, 1.25) LADDERS: dict[str, tuple[float, ...]] = { # A geometric ~6% step across the whole plausible range. Measures the CEILING of what # any ladder change can buy, and the worst case of what it costs in false fires. "dense": tuple(round(0.70 * (1.06**i), 4) for i in range(13)), # 0.70 .. ~1.49 # The targeted alternative. On the full run, 26 of 28 recoveries came from ONE rung # (~1.116) and 22 of 28 were LANDSCAPE frames -- that is a geometry gap, not a density # gap, so the honest comparison is one extra rung against thirteen. "plus_one": (0.8, 1.0, 1.1157, 1.25), "shipped": SHIPPED, } DENSE = LADDERS["dense"] # Score every rung any candidate ladder might use and store them all, so a new candidate # is evaluated by re-reading the JSONL instead of re-running the hour-long sweep. PROBE_SCALES: tuple[float, ...] = tuple(sorted({s for rungs in LADDERS.values() for s in rungs})) def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]: if n == 0: return (0.0, 0.0) p, d = k / n, 1 + z * z / n c = (p + z * z / (2 * n)) / d hw = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d return (100 * (c - hw), 100 * (c + hw)) def score_at_scales(engine: Any, image: np.ndarray, scales: tuple[float, ...]) -> dict[float, float]: """`_tophat_score`'s inner loop, opened up so the ladder is a parameter. Deliberately reaches into the engine (`tophat_response`, `_glyph_silhouette`): a measurement script may, product code may not. Kept a faithful copy of the shipped scoring -- if it drifts, the numbers below stop describing the product. """ c = engine.config loc = engine.locate(image) resp = engine.tophat_response(image, loc) sil = engine._glyph_silhouette() if resp is None or sil is None: return {} base = engine.scale_base(image) out: dict[float, float] = {} for scale in scales: gw = max(c.min_gw, int(c.alpha_width_frac * base * scale)) gh = max(4, int(c.alpha_height_frac * base * scale)) if gw >= resp.shape[1] or gh >= resp.shape[0]: continue tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) if c.template_blur > 0: tmpl = cv2.GaussianBlur(tmpl, (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur) out[scale] = float(cv2.matchTemplate(resp, tmpl.astype(np.uint8), cv2.TM_CCOEFF_NORMED).max()) return out def _one(args: tuple[str, str]) -> dict[str, Any] | None: path_str, mark_key = args from remove_ai_watermarks.api import visible_provenance from remove_ai_watermarks.identify import identify from remove_ai_watermarks.image_io import imread from remove_ai_watermarks.watermark_registry import get_mark path = Path(path_str) try: prov = visible_provenance(path) except Exception: return None positive = mark_key in prov if not positive: # A negative is a frame with NO metadata signal at all -- evidence independent of # the detector being graded. Do NOT also require that no mark was detected: that # defines the negative set by the very verdict under test, and "false fire today" # would then be 0 by construction on every run. try: if identify(path, check_visible=False).signals: return None except Exception: return None img = imread(path_str) if img is None or min(img.shape[:2]) < 200: return None mark = get_mark(mark_key) det = mark.detect(img) import importlib mod = importlib.import_module(f"remove_ai_watermarks.{mark_key}_engine") cls = next( o for n, o in vars(mod).items() if isinstance(o, type) and n.endswith("Engine") and n != "TextMarkEngine" ) engine = cls() scores = score_at_scales(engine, img, PROBE_SCALES) if not scores: return None gate = engine.config.detect_ncc_threshold dense = {s: v for s, v in scores.items() if s in LADDERS["dense"]} best_scale = max(dense, key=lambda s: dense[s]) if dense else 0.0 h, w = img.shape[:2] return { "src": path.name, "arm": "positive" if positive else "negative", "detected_now": bool(det.detected), "conf_now": round(float(det.confidence), 4), "gate": gate, # Every rung, so a new candidate ladder is evaluated by re-reading this file. "scores": {str(s): round(v, 4) for s, v in scores.items()}, "dense_best": round(dense[best_scale], 4) if dense else 0.0, "dense_best_scale": best_scale, "dense_crosses": bool(dense) and dense[best_scale] >= gate, "aspect": "portrait" if w / h < 0.9 else ("landscape" if w / h > 1.1 else "square"), } def main() -> None: ap = argparse.ArgumentParser() # doubao only, and that is not laziness. `score_at_scales` reproduces the TOPHAT # front-end, which is the only one with a ladder to widen; scoring it for jimeng or # samsung would measure a code path their detectors never run. Doubao is also the one # mark that declares NO rival, so crossing its NCC gate really is the whole verdict -- # for a mark with rivals, `dense_crosses` would ignore the competitive margin and read # optimistically. Widening the binary front-end is a separate experiment. ap.add_argument("--mark", default="doubao", choices=["doubao"]) ap.add_argument("--n", type=int, default=4000, help="corpus files to scan") 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") a = ap.parse_args() out = a.out.with_name(f"{a.out.stem}_{a.mark}{a.out.suffix}") if a.report_only: report([json.loads(x) for x in out.read_text(encoding="utf-8").splitlines() if x.strip()], a.mark) return pool = glob.glob(str(CORPUS / "*" / "*")) random.Random(19).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto pool = pool[: a.n] print(f"mark={a.mark} scanning {len(pool)} corpus files workers={a.workers}") print(f"shipped ladder {SHIPPED}\ndense ladder {DENSE}\n", flush=True) rows: list[dict[str, Any]] = [] out.parent.mkdir(parents=True, exist_ok=True) with open(out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex: futures = [ex.submit(_one, (p, a.mark)) 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() pos = sum(1 for r in rows if r["arm"] == "positive") print(f" {i}/{len(pool)} usable={len(rows)} (pos={pos})", flush=True) report(rows, a.mark) def report(rows: list[dict[str, Any]], mark: str) -> None: pos = [r for r in rows if r["arm"] == "positive"] neg = [r for r in rows if r["arm"] == "negative"] print(f"\n{'=' * 82}\nLADDER HEADROOM mark={mark} positives={len(pos)} verified-clean negatives={len(neg)}") print(f"{'=' * 82}") if not pos and not neg: print("no usable rows") return if pos: miss = [r for r in pos if not r["detected_now"]] rec = [r for r in miss if r["dense_crosses"]] now = len(pos) - len(miss) lo, hi = wilson(len(rec), len(miss)) if miss else (0.0, 0.0) print("\nPOSITIVE ARM (images whose METADATA names this vendor -- an upper bound,") print("provenance says the vendor produced the file, not that a mark is visible on it)\n") print(f" detected today {now:5d} / {len(pos)} ({100 * now / len(pos):.1f}%)") print(f" of the {len(miss)} misses, the dense ladder crosses the gate on {len(rec)}") if miss: print(f" ({100 * len(rec) / len(miss):.1f}%, 95% CI {lo:.1f}-{hi:.1f})") after = now + len(rec) print(f" detection would go {100 * now / len(pos):.1f}% -> {100 * after / len(pos):.1f}%") if rec: by_scale = collections.Counter(r["dense_best_scale"] for r in rec) print("\n which rung recovers them (a rung near a SHIPPED one recovering many means") print(" the gain is the finer STEP, not the wider RANGE):") for s, n in sorted(by_scale.items()): near = " <- shipped" if any(abs(s - x) < 0.02 for x in SHIPPED) else "" print(f" scale {s:<6} {n:4d}{near}") by_asp = collections.Counter(r["aspect"] for r in rec) print(f"\n by aspect: {dict(by_asp)}") if neg: fires_now = sum(1 for r in neg if r["detected_now"]) fires_dense = sum(1 for r in neg if r["dense_crosses"]) lo0, hi0 = wilson(fires_now, len(neg)) lo1, hi1 = wilson(fires_dense, len(neg)) print("\n\nNEGATIVE ARM (no metadata signal; NOT filtered on the detector's own verdict)\n") p0, p1 = 100 * fires_now / len(neg), 100 * fires_dense / len(neg) print(f" false fire today {fires_now:4d} / {len(neg)} {p0:.2f}% (CI {lo0:.2f}-{hi0:.2f})") print(f" false fire, dense ladder {fires_dense:4d} / {len(neg)} {p1:.2f}% (CI {lo1:.2f}-{hi1:.2f})") print("\n This is the price. A denser ladder gives a spurious blob more chances to") print(" match at SOME scale, so the gain above is only real if this line barely moves.") ladder_table(pos, neg) def _crosses(row: dict[str, Any], rungs: tuple[float, ...]) -> bool: """Would this frame cross its gate on the given ladder, from the stored per-rung scores.""" sc = row.get("scores") or {} gate = row["gate"] return any(v >= gate for k, v in sc.items() if float(k) in rungs) def ladder_table(pos: list[dict[str, Any]], neg: list[dict[str, Any]]) -> None: """Every candidate ladder side by side: what it recovers against what it costs.""" if not (pos and neg) or "scores" not in pos[0]: return # older run without per-rung scores miss = [r for r in pos if not r["detected_now"]] base_fire = sum(1 for r in neg if r["detected_now"]) print("\n\nLADDER CANDIDATES -- recovered marks against the false fires they cost\n") print("A candidate is only worth shipping if the recovered column beats the added-fires") print("column by enough to survive the base rates: clean frames vastly outnumber marked") print("ones in real traffic, so a small percentage on the negative arm is a large count.\n") hdr = f"{'ladder':10s} {'rungs':>6s} {'recovered':>10s} {'of misses':>10s}" print(f"{hdr} {'false fire':>11s} {'added':>7s} {'ratio':>7s}") for name, rungs in LADDERS.items(): rec = sum(1 for r in miss if _crosses(r, rungs)) fire = sum(1 for r in neg if _crosses(r, rungs) or r["detected_now"]) added = fire - base_fire ratio = f"{rec / added:.1f}:1" if added > 0 else ("inf" if rec else "-") pct = 100 * rec / len(miss) if miss else 0.0 print(f"{name:10s} {len(rungs):6d} {rec:10d} {pct:9.1f}% {100 * fire / len(neg):10.2f}% {added:7d} {ratio:>7s}") if __name__ == "__main__": main()