mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
320 lines
15 KiB
Python
320 lines
15 KiB
Python
"""Tier B2: detector response curves -- recall vs mark size, contrast, background and aspect.
|
|
|
|
WHY THIS EXISTS
|
|
Every recall number the project quotes rests on found positives: doubao 89% on n=240,
|
|
jimeng 71% on n=14, the pill 50% on n=6. Those tell you how the detectors do on the
|
|
marks a corpus happened to contain, and nothing about WHERE they fall over. Two bugs
|
|
have now shipped in exactly that blind spot:
|
|
|
|
* `scale_basis` -- doubao detected 0 of 435 LANDSCAPE marks, a 100% miss, because
|
|
every fraction was calibrated on portrait captures where width == short side.
|
|
* the front-end mismatch -- detection moved to the continuous `tophat` response while
|
|
the removal mask still came from the BINARIZED blob, so ~8% of doubao detections
|
|
produced an empty mask and removal was a silent no-op.
|
|
|
|
Both are geometry/plumbing failures at the edge of the operating range, and both were
|
|
invisible to a corpus sweep because the corpus is concentrated in the middle of it.
|
|
This harness constructs the edges instead of waiting for them to turn up.
|
|
|
|
THE AGGREGATE HERE IS NOT RECALL
|
|
The grid deliberately visits sizes and opacities the engines were never calibrated for,
|
|
so a mark counted as missed at size=0.6 is not evidence of a weak detector -- it is the
|
|
point of the sweep. Quote the NOMINAL cell (size=1.0, alpha=1.0) as the reference, and
|
|
read the per-axis tables for shape. Production recall comes from an unbiased corpus
|
|
sample (`scripts/visible_recall_sample.py`), never from here.
|
|
|
|
WHAT IT MEASURES -- TWO NUMBERS, NOT ONE
|
|
`detected` the detector fires.
|
|
`maskable` the same call path then yields a NON-EMPTY removal mask.
|
|
|
|
The second is the one that has no natural home anywhere else. A mark that is detected
|
|
but not maskable is reported by `identify` and silently skipped by `visible` -- the user
|
|
sees a mark the tool says it removed. Anything that measures only detection scores that
|
|
bug as a success. They are reported side by side and their GAP is the headline.
|
|
|
|
THE CONSTRUCTION
|
|
Take a verified-clean corpus image, stamp a real mark onto it with the forward model the
|
|
reverse-alpha work established (`stamped = (1-a)*bg + a*white`), and sweep two knobs the
|
|
engine's own geometry normally pins:
|
|
|
|
size multiplies the glyph box the engine would use (a re-rendered or rescaled mark)
|
|
alpha multiplies the captured opacity (bold stamp -> faint translucent overlay)
|
|
|
|
Background texture and frame aspect are not swept -- they come from the corpus and are
|
|
recorded, so the grid is crossed with real backgrounds rather than synthetic flats.
|
|
|
|
WHY EVERY SOURCE ALSO RUNS UNSTAMPED
|
|
A recall curve with no false-fire baseline is uninterpretable in the same way a fill
|
|
PSNR with no damage baseline is: "detected on 70% of faint marks" is a triumph or a
|
|
scandal depending on whether the detector also fires on 70% of the clean frames those
|
|
marks were stamped onto. The control runs the identical call path on the identical
|
|
image with no stamp.
|
|
|
|
READING `mask_hit`
|
|
Fraction of the stamped glyph box the removal mask covers -- real localization accuracy
|
|
for the text marks and gemini, because the mask has to be placed from a detection.
|
|
For `jimeng_pill` it is CONSTRUCTED and means nothing: the pill's footprint is a fixed
|
|
top-left geometry box, so it covers the stamp by definition. Reported, and flagged.
|
|
|
|
DATA SAFETY
|
|
Treat input datasets as sensitive and read-only. Keep generated reports under
|
|
.local-eval/. Reports record source filenames and measurements, never image content.
|
|
|
|
uv run python scripts/detector_response.py --n 12 # trial, measures throughput
|
|
uv run python scripts/detector_response.py --n 150 # the real run, resumable
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
|
|
from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
OUT = REPO / ".local-eval" / "detector-response.jsonl"
|
|
|
|
# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
|
|
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
|
|
# front-end was introduced for) and above it (a larger re-render, or an upscaled upload).
|
|
SIZES = (0.6, 0.8, 1.0, 1.3)
|
|
ALPHAS = (0.3, 0.5, 0.75, 1.0)
|
|
MARKS = (*STAMPABLE, *SLOT_STAMPABLE)
|
|
|
|
|
|
def aspect_of(image: np.ndarray) -> str:
|
|
h, w = image.shape[:2]
|
|
r = w / max(1, h)
|
|
return "portrait" if r < 0.9 else ("landscape" if r > 1.1 else "square")
|
|
|
|
|
|
def _probe(image: np.ndarray, mark_key: str, box: tuple[int, int, int, int] | None) -> dict[str, Any]:
|
|
"""Run the product detect -> localize path once and score it.
|
|
|
|
``box`` is the stamped glyph box, or None for the unstamped control.
|
|
"""
|
|
from remove_ai_watermarks.watermark_registry import get_mark
|
|
|
|
mark = get_mark(mark_key)
|
|
loc = mark.localize(image, force=False)
|
|
mask_px = 0 if loc.mask is None else int(np.count_nonzero(loc.mask))
|
|
hit = None
|
|
if box is not None and loc.mask is not None:
|
|
x, y, w, h = box
|
|
inside = loc.mask[y : y + h, x : x + w]
|
|
hit = round(float(np.count_nonzero(inside)) / max(1, w * h), 3)
|
|
return {
|
|
"detected": bool(loc.detected),
|
|
"confidence": round(float(loc.confidence), 4),
|
|
"maskable": mask_px > 0,
|
|
"mask_px": mask_px,
|
|
"mask_hit": hit,
|
|
}
|
|
|
|
|
|
def _one_source(path_str: str) -> list[dict[str, Any]]:
|
|
"""Every (mark, size, alpha) cell for one clean background, plus its controls."""
|
|
from remove_ai_watermarks.image_io import imread
|
|
|
|
base = imread(path_str)
|
|
if base is None:
|
|
return []
|
|
name = Path(path_str).name
|
|
asp = aspect_of(base)
|
|
h, w = base.shape[:2]
|
|
rows: list[dict[str, Any]] = []
|
|
|
|
for key in MARKS:
|
|
common = {"src": name, "mark": key, "aspect": asp, "px": w * h}
|
|
# Control first: the same call path on the same frame with nothing stamped.
|
|
rows.append({**common, "cell": "control", "size": None, "alpha": None, **_probe(base, key, None)})
|
|
for size in SIZES:
|
|
for alpha in ALPHAS:
|
|
st = stamp_any(base, key, size_mult=size, alpha_mult=alpha)
|
|
if st is None:
|
|
continue
|
|
stamped, box = st
|
|
# Texture of the region the mark sits on, from the CLEAN frame -- the
|
|
# same median-Sobel proxy the pill's flatness gate uses.
|
|
x, y, gw, gh = box
|
|
rows.append(
|
|
{
|
|
**common,
|
|
"cell": "stamped",
|
|
"size": size,
|
|
"alpha": alpha,
|
|
"texture": round(texture_of(base[y : y + gh, x : x + gw]), 2),
|
|
**_probe(stamped, key, box),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
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 main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--n", type=int, default=150, help="verified-clean source images")
|
|
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("--restart", action="store_true")
|
|
ap.add_argument("--report-only", action="store_true", help="re-read --out and print the report")
|
|
a = ap.parse_args()
|
|
|
|
if a.report_only:
|
|
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
report(rows)
|
|
return
|
|
|
|
if a.restart and a.out.exists():
|
|
a.out.unlink()
|
|
done: set[str] = set()
|
|
if a.out.exists():
|
|
with open(a.out, encoding="utf-8") as fh:
|
|
for line in fh:
|
|
try:
|
|
done.add(json.loads(line)["src"])
|
|
except Exception: # noqa: S112 -- tolerate a torn last line
|
|
continue
|
|
|
|
print(f"selecting {a.n} verified-clean sources (already measured: {len(done)})...", flush=True)
|
|
sources = [p for p in clean_sources(a.n) if p.name not in done]
|
|
print(f"to do {len(sources)} workers {a.workers} grid {len(MARKS)}x{len(SIZES)}x{len(ALPHAS)}\n", flush=True)
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
a.out.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
|
futures = {ex.submit(_one_source, str(p)): p for p in sources}
|
|
for i, fut in enumerate(as_completed(futures), 1):
|
|
try:
|
|
got = fut.result()
|
|
except Exception as e: # a crashed source must not kill the sweep
|
|
print(f" source failed: {type(e).__name__}: {e}", flush=True)
|
|
continue
|
|
for r in got:
|
|
fh.write(json.dumps(r) + "\n")
|
|
rows += got
|
|
if i % 10 == 0:
|
|
fh.flush()
|
|
print(f" {i}/{len(sources)}", flush=True)
|
|
|
|
if done: # merge the resumed rows so the report covers the whole file
|
|
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
|
|
report(rows)
|
|
|
|
|
|
def _rate(rs: list[dict[str, Any]], field: str) -> tuple[float, float, float, int]:
|
|
k, n = sum(bool(r[field]) for r in rs), len(rs)
|
|
lo, hi = wilson(k, n)
|
|
return (100 * k / n if n else 0.0, lo, hi, n)
|
|
|
|
|
|
def report(rows: list[dict[str, Any]]) -> None:
|
|
stamped = [r for r in rows if r["cell"] == "stamped"]
|
|
control = [r for r in rows if r["cell"] == "control"]
|
|
if not stamped:
|
|
print("no measurements")
|
|
return
|
|
srcs = len({r["src"] for r in rows})
|
|
print(f"\n{'=' * 84}\nDETECTOR RESPONSE {len(stamped)} stamped cells over {srcs} clean backgrounds\n{'=' * 84}")
|
|
|
|
# Lead with the nominal cell. The grid deliberately includes sizes and opacities the
|
|
# engines were never calibrated for, so an aggregate over the whole grid is an
|
|
# adversarial score, NOT production recall -- quoting it as recall would understate
|
|
# the detectors badly. The nominal cell is the honest reference point: the mark at the
|
|
# exact geometry and opacity the engine assumes, on a real clean background.
|
|
print("\nNOMINAL CELL (size=1.0, alpha=1.0) -- a mark exactly as the engine expects it.")
|
|
print("Read every other number against THIS, not as production recall: the grid is")
|
|
print("adversarial by design and its aggregate is not a recall figure.\n")
|
|
print(f"{'mark':13s} {'n':>5s} {'detected':>10s} {'maskable':>10s}")
|
|
for key in MARKS:
|
|
rs = [r for r in stamped if r["mark"] == key and r["size"] == 1.0 and r["alpha"] == 1.0]
|
|
if rs:
|
|
print(f"{key:13s} {len(rs):5d} {_rate(rs, 'detected')[0]:9.1f}% {_rate(rs, 'maskable')[0]:9.1f}%")
|
|
|
|
print("\n\nFALSE FIRE on the unstamped controls (the baseline every recall below is read against)\n")
|
|
print(f"{'mark':13s} {'n':>5s} {'fires':>8s} {'95% CI':>16s}")
|
|
for key in MARKS:
|
|
rs = [r for r in control if r["mark"] == key]
|
|
if rs:
|
|
pct, lo, hi, n = _rate(rs, "detected")
|
|
print(f"{key:13s} {n:5d} {pct:7.1f}% {f'{lo:.1f}-{hi:.1f}':>16s}")
|
|
|
|
print("\n\nDETECTED vs MASKABLE by mark. The GAP is the silent-no-op rate:")
|
|
print("a mark counted here as detected-but-not-maskable is reported by `identify` and")
|
|
print("skipped by `visible`, so the user is told it was removed when it was not.\n")
|
|
print(f"{'mark':13s} {'n':>6s} {'detected':>10s} {'maskable':>10s} {'gap':>7s} {'mask_hit':>9s}")
|
|
for key in MARKS:
|
|
rs = [r for r in stamped if r["mark"] == key]
|
|
if not rs:
|
|
continue
|
|
det = [r for r in rs if r["detected"]]
|
|
d, m = _rate(rs, "detected")[0], _rate(rs, "maskable")[0]
|
|
gap = (100 * sum(1 for r in det if not r["maskable"]) / len(det)) if det else 0.0
|
|
hits = [r["mask_hit"] for r in det if r["mask_hit"] is not None]
|
|
note = " (constructed)" if key == "jimeng_pill" else ""
|
|
hv = f"{float(np.median(hits)):.2f}" if hits else "-"
|
|
print(f"{key:13s} {len(rs):6d} {d:9.1f}% {m:9.1f}% {gap:6.1f}% {hv:>9s}{note}")
|
|
|
|
for axis, label in (("alpha", "CONTRAST (alpha multiplier)"), ("size", "SIZE (glyph box multiplier)")):
|
|
print(f"\n\nRECALL BY {label} -- 1.0 is the geometry/opacity the engine assumes\n")
|
|
vals = sorted({r[axis] for r in stamped})
|
|
print(f"{'mark':13s}" + "".join(f"{v:>12}" for v in vals))
|
|
for key in MARKS:
|
|
cells = []
|
|
for v in vals:
|
|
rs = [r for r in stamped if r["mark"] == key and r[axis] == v]
|
|
cells.append(f"{_rate(rs, 'detected')[0]:10.0f}%" if rs else f"{'-':>11s}")
|
|
print(f"{key:13s}" + "".join(f"{c:>12}" for c in cells))
|
|
|
|
print("\n\nRECALL BY FRAME ASPECT -- the axis the `scale_basis` bug lived on\n")
|
|
asps = ["portrait", "square", "landscape"]
|
|
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in asps))
|
|
for key in MARKS:
|
|
cells = []
|
|
for asp in asps:
|
|
rs = [r for r in stamped if r["mark"] == key and r["aspect"] == asp]
|
|
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
|
|
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
|
|
|
|
tex = sorted(r["texture"] for r in stamped if "texture" in r)
|
|
if tex:
|
|
t1, t2 = tex[len(tex) // 3], tex[2 * len(tex) // 3]
|
|
print(f"\n\nRECALL BY BACKGROUND TEXTURE (terciles at {t1:.1f} / {t2:.1f} median-Sobel)\n")
|
|
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in ("flat", "mid", "textured")))
|
|
for key in MARKS:
|
|
cells = []
|
|
for lo, hi in ((-1, t1), (t1, t2), (t2, 1e9)):
|
|
rs = [r for r in stamped if r["mark"] == key and lo < r.get("texture", -2) <= hi]
|
|
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
|
|
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
|
|
|
|
worst = collections.Counter((r["mark"], r["size"], r["alpha"]) for r in stamped if not r["detected"]).most_common(8)
|
|
if worst:
|
|
print("\n\nWORST CELLS (most misses)\n")
|
|
for (mark, size, alpha), n in worst:
|
|
print(f" {mark:13s} size={size} alpha={alpha} {n} misses")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|