mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-09-02 02:30:48 +02:00
Align watermark content with detector coverage
This commit is contained in:
@@ -34,6 +34,7 @@ explicitly names a tracked canonical result.
|
||||
| `detector_response.py` | Measure detector response over mark size, contrast, background, and aspect. |
|
||||
| `fill_quality.py` | Measure visible-fill quality against constructed ground truth. |
|
||||
| `ladder_headroom.py` | Measure recall cost from the coarse scale ladder. |
|
||||
| `registered_mark_calibrate.py` | Measure a registered detector without conflating visual positives, metadata cohorts, adjudicated negatives, and unlabeled controls. |
|
||||
| `synthid_corpus.py` | Ingest and inspect the local SynthID reference corpus. |
|
||||
| `vendor_cohort_harvest.py` | Partition TC260 carriers by producer code. |
|
||||
| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. |
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Unregistered visible-mark candidates
|
||||
|
||||
Synthetic detection silhouettes for marks that are measured but NOT registered:
|
||||
none of these separates its positives from clean negatives yet, so shipping them
|
||||
would attribute and fill corners on content that carries no mark (a false fill
|
||||
is the worse error). Assets are regenerated by
|
||||
`scripts/render_vendor_silhouettes.py`; a candidate is deleted from here on the
|
||||
day its mark registers (the asset moves to the package `assets/`).
|
||||
|
||||
Measurements below are local calibration snapshots. Candidate pools are
|
||||
provenance cohorts, not automatically visible-mark positives; comparison controls
|
||||
are independently selected no-signal images and are not adjudicated negatives.
|
||||
Each needs capture-solved alphas or vendor-accurate font work before it can
|
||||
ship; `scripts/vendor_mark_calibrate.py` is the candidate-detector harness.
|
||||
|
||||
| Asset | Mark | Evidence | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. |
|
||||
| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. |
|
||||
| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. |
|
||||
| `dola_alpha.png` | DolaAI on images (the video mark is registered) | 12 corpus files | POS 0.11-0.21 vs NEG max 0.30. |
|
||||
| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. |
|
||||
| `xinghui_alpha.png` | 星绘AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
| `qingyan_alpha.png` | 清言·AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
| `hailuo_alpha.png` | Hailuo AI image wordmark (parked before this set; the VIDEO label is registered) | -- | prior parking, unchanged. |
|
||||
| `catlogo_alpha.png` | outline cat-head + AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,7 +1,7 @@
|
||||
"""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
|
||||
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Cloud 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
|
||||
|
||||
@@ -39,7 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from detection_timing import COMPONENTS as _TIMED
|
||||
|
||||
# Taken from the script that WROTE the rows, in its order, so a probe added, removed
|
||||
# or reordered there cannot silently leave a column missing or mislabelled here.
|
||||
# or reordered there cannot silently leave a column missing or mislabeled here.
|
||||
COMPONENTS = tuple(name for name, _ in _TIMED)
|
||||
METHODS = (
|
||||
("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"),
|
||||
|
||||
@@ -81,7 +81,7 @@ def find_visible_positive(mark: str) -> Path | None:
|
||||
|
||||
|
||||
class Results:
|
||||
"""Collects one row per checked behaviour.
|
||||
"""Collects one row per checked behavior.
|
||||
|
||||
A FAIL keeps the command's OUTPUT. That is not cosmetic: the first run of this harness
|
||||
discarded it, an `all` invocation failed once with exit 1, and because the output was
|
||||
@@ -166,7 +166,7 @@ def check_visible(res: Results, tmp: Path) -> None:
|
||||
"""Real marked images -> the PRODUCT'S DECISION is honoured, and a removed mark clears.
|
||||
|
||||
The success criterion is not "the mark is always gone" -- it is "the product did what it
|
||||
decided, and the decision is right". Two designed behaviours make a blind re-detect
|
||||
decided, and the decision is right". Two designed behaviors make a blind re-detect
|
||||
misleading:
|
||||
* The pill is GATED (`_keep_pill`): a low-confidence pill with no corroboration is
|
||||
deliberately NOT removed, so `visible` correctly writes nothing and exits 2. That is
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Measure a registered visible-mark detector on independently labeled arms.
|
||||
|
||||
The input is a JSONL manifest with exactly two fields per row:
|
||||
|
||||
``path``
|
||||
Absolute path, or a path relative to the manifest.
|
||||
``arm``
|
||||
``positive`` for a visually confirmed carrier, ``metadata`` for a
|
||||
provenance-only cohort, ``negative`` for an independently adjudicated
|
||||
no-mark image, or ``control`` for an unlabeled comparison image with no
|
||||
known local signal.
|
||||
|
||||
The arms stay separate because metadata names a provider, not the presence of a
|
||||
visible mark, and missing local signals do not make an image a true negative. The
|
||||
script imports the registered engine and reads its shipped gate, so calibration
|
||||
cannot silently use a copied configuration.
|
||||
|
||||
Input images and manifests are read-only. Keep private inputs and generated
|
||||
manifests outside the repository or in a gitignored evaluation directory.
|
||||
|
||||
uv run python scripts/registered_mark_calibrate.py microsoft manifest.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from remove_ai_watermarks import watermark_registry # noqa: E402
|
||||
from remove_ai_watermarks.image_io import imread # noqa: E402
|
||||
|
||||
Arm = Literal["positive", "metadata", "negative", "control"]
|
||||
_ARMS: tuple[Arm, ...] = ("positive", "metadata", "negative", "control")
|
||||
|
||||
|
||||
class ManifestRow(TypedDict):
|
||||
path: str
|
||||
arm: Arm
|
||||
|
||||
|
||||
class ArmSummary(TypedDict):
|
||||
n: int
|
||||
unreadable: int
|
||||
min: float | None
|
||||
p50: float | None
|
||||
p90: float | None
|
||||
p99: float | None
|
||||
max: float | None
|
||||
fires: int
|
||||
|
||||
|
||||
def _percentile(ordered: list[float], fraction: float) -> float | None:
|
||||
"""Return a nearest-rank percentile from values sorted in ascending order."""
|
||||
if not ordered:
|
||||
return None
|
||||
return ordered[max(0, math.ceil(fraction * len(ordered)) - 1)]
|
||||
|
||||
|
||||
def summarize(values: list[float], *, unreadable: int, fires: int) -> ArmSummary:
|
||||
"""Summarize one independently defined arm without inferring its label."""
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"n": len(values),
|
||||
"unreadable": unreadable,
|
||||
"min": _percentile(ordered, 0.0),
|
||||
"p50": _percentile(ordered, 0.5),
|
||||
"p90": _percentile(ordered, 0.9),
|
||||
"p99": _percentile(ordered, 0.99),
|
||||
"max": _percentile(ordered, 1.0),
|
||||
"fires": fires,
|
||||
}
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> list[ManifestRow]:
|
||||
"""Load and validate a manifest, resolving relative paths beside it."""
|
||||
rows: list[ManifestRow] = []
|
||||
seen: set[Path] = set()
|
||||
with path.open(encoding="utf-8") as manifest:
|
||||
for line_number, line in enumerate(manifest, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
raw = json.loads(line)
|
||||
if not isinstance(raw, dict) or set(raw) != {"path", "arm"}:
|
||||
raise ValueError(f"{path}:{line_number}: expected exactly path and arm")
|
||||
raw_path = raw["path"]
|
||||
raw_arm = raw["arm"]
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
raise ValueError(f"{path}:{line_number}: path must be a non-empty string")
|
||||
if raw_arm not in _ARMS:
|
||||
raise ValueError(f"{path}:{line_number}: arm must be one of {', '.join(_ARMS)}")
|
||||
image_path = Path(raw_path)
|
||||
if not image_path.is_absolute():
|
||||
image_path = path.parent / image_path
|
||||
image_path = image_path.resolve()
|
||||
if image_path in seen:
|
||||
raise ValueError(f"{path}:{line_number}: duplicate image path {image_path}")
|
||||
seen.add(image_path)
|
||||
rows.append({"path": str(image_path), "arm": cast("Arm", raw_arm)})
|
||||
if not rows:
|
||||
raise ValueError(f"{path}: manifest is empty")
|
||||
return rows
|
||||
|
||||
|
||||
def measure(mark: str, rows: list[ManifestRow]) -> tuple[float, dict[Arm, ArmSummary]]:
|
||||
"""Score every row with the registered engine and its current shipped gate."""
|
||||
registered_mark = watermark_registry.get_mark(mark)
|
||||
# Calibration intentionally resolves the registry's concrete engine so it
|
||||
# cannot drift onto a copied configuration.
|
||||
engine = watermark_registry._engine(mark)
|
||||
config = getattr(engine, "config", None)
|
||||
gate = getattr(config, "detect_ncc_threshold", None)
|
||||
if not isinstance(gate, int | float):
|
||||
raise ValueError(f"registered mark {mark!r} does not expose a text-detector NCC gate")
|
||||
|
||||
scores: dict[Arm, list[float]] = {arm: [] for arm in _ARMS}
|
||||
unreadable: Counter[Arm] = Counter()
|
||||
fires: Counter[Arm] = Counter()
|
||||
for row in rows:
|
||||
image = imread(row["path"])
|
||||
if image is None:
|
||||
unreadable[row["arm"]] += 1
|
||||
continue
|
||||
detection: Any = registered_mark.detect(image)
|
||||
scores[row["arm"]].append(float(detection.confidence))
|
||||
fires[row["arm"]] += bool(detection.detected)
|
||||
|
||||
threshold = float(gate)
|
||||
return threshold, {arm: summarize(scores[arm], unreadable=unreadable[arm], fires=fires[arm]) for arm in _ARMS}
|
||||
|
||||
|
||||
def _format_stat(value: float | None) -> str:
|
||||
return "-" if value is None else f"{value:.3f}"
|
||||
|
||||
|
||||
def print_report(mark: str, gate: float, summaries: dict[Arm, ArmSummary]) -> None:
|
||||
"""Print a compact human-readable calibration report."""
|
||||
print(f"registered mark: {mark} gate: {gate:.3f}")
|
||||
print(
|
||||
f"{'arm':10s} {'n':>5s} {'bad':>5s} {'min':>7s} {'p50':>7s} {'p90':>7s} {'p99':>7s} {'max':>7s} {'fires':>7s}"
|
||||
)
|
||||
for arm in _ARMS:
|
||||
row = summaries[arm]
|
||||
print(
|
||||
f"{arm:10s} {row['n']:5d} {row['unreadable']:5d} "
|
||||
f"{_format_stat(row['min']):>7s} {_format_stat(row['p50']):>7s} "
|
||||
f"{_format_stat(row['p90']):>7s} {_format_stat(row['p99']):>7s} "
|
||||
f"{_format_stat(row['max']):>7s} {row['fires']:7d}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mark", choices=watermark_registry.mark_keys())
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = load_manifest(args.manifest)
|
||||
gate, summaries = measure(args.mark, rows)
|
||||
if args.json:
|
||||
print(json.dumps({"mark": args.mark, "gate": gate, "arms": summaries}, sort_keys=True))
|
||||
else:
|
||||
print_report(args.mark, gate, summaries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -12,11 +12,14 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
@@ -42,7 +45,7 @@ MARKS = {
|
||||
"kling_alpha.png": "可灵AI 3.0",
|
||||
# The "cat-logo" candidate stamps an outline cat-head plus bold "AI生成",
|
||||
# bottom-right. It remains unregistered pending sufficient calibration coverage.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
"catlogo_alpha.png": "cat logo + AI生成",
|
||||
# RunningHub top-left text mark.
|
||||
"runninghub_alpha.png": "RunningHub AI生成",
|
||||
# LibLibAI bottom-center wordmark.
|
||||
@@ -53,6 +56,19 @@ MARKS = {
|
||||
"hailuo_alpha.png": "Hailuo AI",
|
||||
# Baidu bottom-right text run.
|
||||
"baidu_alpha.png": "百度",
|
||||
# Measured Microsoft top-right white AI-badge variant. Sentinel: drawn by
|
||||
# draw_msbadge(), not font-rendered.
|
||||
"microsoft_alpha.png": "Made with AI",
|
||||
# Samsung Galaxy AI label, English locale (the registered samsung_alpha.png is
|
||||
# the Italian "Contenuti generati dall'AI" silhouette; EN is the literal
|
||||
# translation with the same leading sparkle). Sentinel: draw_samsung_en().
|
||||
"samsung_en_alpha.png": "AI-generated content",
|
||||
# Gemini text-form label (the registered gemini mark is the sparkle icon).
|
||||
"gemini_text_alpha.png": "Generated with Gemini",
|
||||
# Candidate wordmarks measured on a local evaluation corpus (unregistered).
|
||||
"notebooklm_alpha.png": "NotebookLM",
|
||||
"dola_alpha.png": "DolaAI",
|
||||
"mindvideo_alpha.png": "MindVideo.AI",
|
||||
}
|
||||
_REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys()
|
||||
|
||||
@@ -74,6 +90,24 @@ MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def _fit_font(
|
||||
font_path: str,
|
||||
reaches_target: Callable[[ImageFont.FreeTypeFont], bool],
|
||||
*,
|
||||
index: int = 0,
|
||||
) -> ImageFont.FreeTypeFont:
|
||||
"""Return the smallest 8-200 px font that reaches a render target."""
|
||||
low, high = 8, 200
|
||||
while low < high:
|
||||
size = (low + high) // 2
|
||||
font = ImageFont.truetype(font_path, size, index=index)
|
||||
if reaches_target(font):
|
||||
high = size
|
||||
else:
|
||||
low = size + 1
|
||||
return ImageFont.truetype(font_path, low, index=index)
|
||||
|
||||
|
||||
def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> np.ndarray:
|
||||
"""Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention.
|
||||
|
||||
@@ -91,13 +125,11 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
lines = text.split("\n")
|
||||
size = 8
|
||||
while size < 200: # grow until the LONGEST line fills the target width
|
||||
f = ImageFont.truetype(font_path, size, index=font_index)
|
||||
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(font_path, size, index=font_index)
|
||||
font = _fit_font(
|
||||
font_path,
|
||||
lambda f: max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98,
|
||||
index=font_index,
|
||||
)
|
||||
boxes = [d0.textbbox((0, 0), ln, font=font) for ln in lines]
|
||||
line_h = max(bb[3] - bb[1] for bb in boxes)
|
||||
gap = max(1, int(line_h * gap_frac))
|
||||
@@ -138,13 +170,7 @@ def draw_catlogo(width: int = 335) -> np.ndarray:
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
text = "AI生成"
|
||||
size = 8
|
||||
while size < 200:
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
font = _fit_font(_FONT, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
||||
cs = int(th * 1.08)
|
||||
@@ -182,10 +208,90 @@ def draw_catlogo(width: int = 335) -> np.ndarray:
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def _star_pts(cx: float, cy: float, r: float, waist: float) -> list[tuple[float, float]]:
|
||||
return [
|
||||
(cx, cy - r),
|
||||
(cx + r * waist, cy - r * waist),
|
||||
(cx + r, cy),
|
||||
(cx + r * waist, cy + r * waist),
|
||||
(cx, cy + r),
|
||||
(cx - r * waist, cy + r * waist),
|
||||
(cx - r, cy),
|
||||
(cx - r * waist, cy - r * waist),
|
||||
]
|
||||
|
||||
|
||||
def _sparkle(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
||||
"""Draw the four-point cutout used by synthetic candidate silhouettes."""
|
||||
draw.polygon(_star_pts(cx, cy, r, 0.22), fill=0)
|
||||
|
||||
|
||||
def draw_msbadge(width: int = 335) -> np.ndarray:
|
||||
"""Synthetic silhouette for the measured Microsoft top-right white pill.
|
||||
|
||||
The top-hat front-end sees the bright pill with dark-text holes, so the template
|
||||
carries the same holes -- that is what discriminates this pill from any other
|
||||
white rounded element in the top-right corner. The text and four-point cutout
|
||||
approximate the measured internal shape; they do not assert one universal
|
||||
Microsoft icon or wording. Geometry measured on 17 visually confirmed carriers
|
||||
on 2026-08-27: pill 0.152W x 0.040W, margins ~0.010W right / ~0.007W top,
|
||||
glyph height ~0.39 of pill height.
|
||||
"""
|
||||
h = round(width / 3.78)
|
||||
im = Image.new("L", (width, h), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([0, 0, width - 1, h - 1], radius=h // 2, fill=255)
|
||||
font_path = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
text = "Made with AI"
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[3] >= h * 0.39)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
th = bb[3] - bb[1]
|
||||
r = h * 0.20 # sparkle radius, ~half the text height
|
||||
pad_l = h * 0.22
|
||||
cx = pad_l + r
|
||||
cy = h / 2 - 1
|
||||
tx = int(pad_l + 2 * r + h * 0.22)
|
||||
d.text((tx - bb[0], (h - th) // 2 - bb[1]), text, font=font, fill=0)
|
||||
_sparkle(d, cx, cy, r)
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def draw_samsung_en(width: int = 335) -> np.ndarray:
|
||||
"""Samsung Galaxy AI English label: "AI-generated content" with the leading
|
||||
4-point sparkle, light-gray glyphs (same class as the registered Italian asset)."""
|
||||
text = "AI-generated content"
|
||||
font_path = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.80)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
||||
r = th * 0.55
|
||||
gap = th * 0.45
|
||||
im = Image.new("L", (int(tw + gap + 2 * r + 8), th + 8), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
# sparkle as bright glyph (this silhouette is light-glyph class, not a pill)
|
||||
d.polygon(_star_pts(4 + r, 4 + th / 2, r, 0.22), fill=255)
|
||||
d.text((4 + 2 * r + gap - bb[0], 4 - bb[1]), text, font=font, fill=255)
|
||||
arr = np.array(im)
|
||||
ys, xs = np.where(arr > 0)
|
||||
return arr[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
|
||||
|
||||
_CUSTOM_RENDERERS = {
|
||||
"catlogo_alpha.png": draw_catlogo,
|
||||
"microsoft_alpha.png": draw_msbadge,
|
||||
"samsung_en_alpha.png": draw_samsung_en,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
for name, text in MARKS.items():
|
||||
sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name))
|
||||
renderer = _CUSTOM_RENDERERS.get(name)
|
||||
sil = renderer() if renderer is not None else render(text, opts=MARK_OPTS.get(name))
|
||||
output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = output_dir / name
|
||||
|
||||
@@ -15,7 +15,7 @@ THE KEY
|
||||
``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
|
||||
in it is eyeballed, every frame in it is a labeled 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
|
||||
|
||||
@@ -14,7 +14,7 @@ WHERE THE POSITIVES COME FROM
|
||||
absence) visible directly.
|
||||
|
||||
NEGATIVES
|
||||
The 432 frames hand-labelled `present: []` in the 2026-07-18 round -- already-adjudicated
|
||||
The 432 frames hand-labeled `present: []` in the 2026-07-18 round -- already-adjudicated
|
||||
no-visible-mark images, so the false-fire arm rests on human labels rather than on the
|
||||
absence of a detection.
|
||||
|
||||
@@ -115,7 +115,7 @@ def load_sets(cohort: str) -> tuple[list[str], list[str]]:
|
||||
# The 2026-07-18 labels are in the vocabulary of the REGISTERED marks only
|
||||
# (gemini/doubao/jimeng/jimeng_pill): `present: []` means "no registered mark", NOT
|
||||
# "no mark at all" -- 146 of the 432 sit in a TC260 cohort, and qwen-cohort frames
|
||||
# visibly carrying 千问AI生成 are labelled `present: []` there (measured 2026-07-21:
|
||||
# visibly carrying 千问AI生成 are labeled `present: []` there (measured 2026-07-21:
|
||||
# they made up the clean arm's whole top tail, clean p99 0.37 -> 0.69). A gate read
|
||||
# off that arm is meaningless, so the clean arm excludes every frame in ANY TC260
|
||||
# cohort -- cohort membership is the cheap proxy for "may carry a CJK AI label".
|
||||
@@ -168,7 +168,7 @@ def report(pos: list[dict[str, Any]], neg: list[dict[str, Any]], name: str) -> N
|
||||
|
||||
p = np.array([r["score"] for r in pos])
|
||||
n = np.array([r["score"] for r in neg])
|
||||
print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labelled clean\n{'=' * 78}")
|
||||
print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labeled clean\n{'=' * 78}")
|
||||
print(f"\n{'arm':10s} {'n':>5s} {'p10':>7s} {'p50':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
|
||||
for label, arr in (("cohort", p), ("clean", n)):
|
||||
if not len(arr):
|
||||
@@ -602,7 +602,7 @@ def main() -> None:
|
||||
crossfire(pools, cand, a.workers, a.gate)
|
||||
return
|
||||
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labelled")
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labeled")
|
||||
print(f"scale_basis={a.scale_basis} overrides={overrides}")
|
||||
pos = run(pos_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
neg = run(neg_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Benchmark harness for the visible-mark detectors.
|
||||
|
||||
Run this before AND after any detector change. It re-runs perception over the
|
||||
hand-labelled ground truth and reports, per mark, how often a fire is correct --
|
||||
hand-labeled ground truth and reports, per mark, how often a fire is correct --
|
||||
with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
uv run python scripts/visible_eval.py # score current code
|
||||
@@ -10,10 +10,10 @@ with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number:
|
||||
|
||||
* PRECISION: sound. Every labelled crop is centred on the region a detector
|
||||
* PRECISION: sound. Every labeled crop is centered on the region a detector
|
||||
pointed at, so "the detector fired mark K here, was K actually there" is
|
||||
exactly the question the labels answer.
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labelled
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labeled
|
||||
images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls),
|
||||
so images carrying a mark that every detector missed are absent by construction.
|
||||
Computing recall on this set would divide by a denominator that excludes exactly
|
||||
@@ -133,7 +133,7 @@ def report(res: dict, prev: dict | None = None) -> None:
|
||||
conf = {k: v for k, v in res["confusion"][m].items() if k != m}
|
||||
if conf:
|
||||
print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labeled marks it did not fire on.")
|
||||
print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images")
|
||||
print(" every detector missed are absent by construction. Use it only to catch a change")
|
||||
print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file.
|
||||
"""Consolidate the hand-labeled contact-sheet rounds into ONE ground-truth file.
|
||||
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labelled
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labeled
|
||||
blind against contact sheets with a two-sided control in every round. Rounds so far:
|
||||
|
||||
2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms)
|
||||
@@ -10,12 +10,12 @@ DATA SAFETY: treat the input dataset as sensitive. This script reads a gitignore
|
||||
dataset and writes a gitignored ground-truth file. Neither the images nor this
|
||||
output may be committed; only the harness is. See the repo CLAUDE.md.
|
||||
|
||||
The labels record what the LABELLER SAW in the crop, one of:
|
||||
The labels record what the labeler saw in the crop, one of:
|
||||
doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain
|
||||
`other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for
|
||||
(千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is
|
||||
also not "clean" -- it is exactly what the relaxed jimeng detector confuses.
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeler's
|
||||
honest doubt never becomes a fabricated data point.
|
||||
"""
|
||||
|
||||
@@ -32,7 +32,7 @@ SEEN_TO_MARK = {
|
||||
"pill": "jimeng_pill",
|
||||
"sparkle": "gemini",
|
||||
}
|
||||
# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible
|
||||
# Which marks a crop centered on `key` lets the labeler rule on (same corner = visible
|
||||
# in the same crop). Doubao and Jimeng share the bottom-right corner.
|
||||
_ADJUDICATES = {
|
||||
"doubao": ("doubao", "jimeng"),
|
||||
@@ -83,7 +83,7 @@ def main() -> None:
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=Path(".local-eval/textmark-relaxation"),
|
||||
help="Directory containing the blinded labelling rounds",
|
||||
help="Directory containing the blinded labeling rounds",
|
||||
)
|
||||
root = parser.parse_args().root
|
||||
out = root / "groundtruth.jsonl"
|
||||
@@ -105,8 +105,8 @@ def main() -> None:
|
||||
m["uid"],
|
||||
{"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []},
|
||||
)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets
|
||||
# the labeller rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centered on one mark only lets
|
||||
# the labeler rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# says nothing about a bottom-right wordmark, so scoring jimeng against a
|
||||
# pill-round image would book real detections as false fires (~61% of pills
|
||||
# carry a wordmark). Bottom-right marks co-adjudicate each other: one crop
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Build an UNBIASED random sample for measuring visible-mark RECALL.
|
||||
|
||||
Every earlier labelling round sampled where detectors FIRED, so images that every
|
||||
Every earlier labeling round sampled where detectors FIRED, so images that every
|
||||
detector missed were absent by construction and recall was unmeasurable. This round
|
||||
samples at random within a provenance class and shows the labeller the corners where
|
||||
samples at random within a provenance class and shows the labeler the corners where
|
||||
a mark can physically be, so a MISSED mark is visible as such.
|
||||
|
||||
Design decisions that matter:
|
||||
@@ -13,11 +13,11 @@ Design decisions that matter:
|
||||
recall over all uploads would mostly measure how often each vendor appears.
|
||||
* NATIVE RESOLUTION crops, never a downscaled whole image: a 220px preview destroys a
|
||||
faint mark (measured in an earlier round), which would inflate the miss count with
|
||||
the labeller's own blindness rather than the detector's.
|
||||
the labeler's own blindness rather than the detector's.
|
||||
* BOTH corners per image (top-left pill, bottom-right wordmark/strip/sparkle), so one
|
||||
pass adjudicates every registered mark instead of one mark per crop.
|
||||
* The detector's verdict is NOT shown and is not in the sheet order -- the manifest
|
||||
holds it and must not be opened until labelling ends.
|
||||
holds it and must not be opened until labeling ends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""Build BLIND contact sheets for hand-labelling relaxation additions.
|
||||
"""Build BLIND contact sheets for hand-labeling relaxation additions.
|
||||
|
||||
Crops are centered on the DETECTED REGION (not the corner), padded by ~0.9x the mark
|
||||
size, and resized to 240px with INTER_NEAREST -- a downscaled preview destroys a faint
|
||||
mark, so nothing here may smooth. The manifest is written to a separate file that must
|
||||
NOT be read until labelling is finished.
|
||||
NOT be read until labeling is finished.
|
||||
|
||||
Each sheet mixes three strata in shuffled order:
|
||||
add - the relaxation additions whose precision we are measuring
|
||||
pos - strict-consistent detections (a mark is really there): labeller sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeller specificity
|
||||
pos - strict-consistent detections (a mark is really there): labeler sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeler specificity
|
||||
The two control strata are what make a low measured precision trustworthy.
|
||||
"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user