mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Register the Qwen 千问AI生成 visible text mark
Calibrated on the 117-frame TC260-producer cohort (vendor_cohort_harvest + vendor_mark_calibrate, both committed here): per-mark 2-rung ladder (0.78, 1.27) for the two measured size modes, fitted locate box (the mark sits ~0.025 of the short side off the edge; doubao's box clipped the first glyph), measured template aspect 0.26, gate 0.45 (clean p99 0.301). Strict-only (the sub-gate band is non-Qwen banners), no rival margin (0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames). 83/83 real marks detector-clean after cv2 fill. TextMarkConfig gains a per-mark ladder field; the shipped 3-rung default is unchanged for every other mark.
This commit is contained in:
@@ -180,7 +180,7 @@ def check_visible(res: Results, tmp: Path) -> None:
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks, get_mark, remove_auto_marks
|
||||
|
||||
print("\nvisible --mark auto -- real marked image per mark, product decision then re-detect")
|
||||
for mark in ("doubao", "jimeng", "gemini", "samsung", "jimeng_pill"):
|
||||
for mark in ("doubao", "jimeng", "qwen", "gemini", "samsung", "jimeng_pill"):
|
||||
src = find_visible_positive(mark)
|
||||
if src is None:
|
||||
res.add("visible", mark, True, "no live positive in bucket (skipped, not a failure)")
|
||||
|
||||
@@ -16,10 +16,17 @@ what does NOT discriminate -- see the rival-margin mechanism in _text_mark_engin
|
||||
|
||||
Regenerate with: uv run python scripts/render_vendor_silhouettes.py
|
||||
|
||||
STATUS 2026-07-18: these two marks are NOT registered, and this script is kept as the
|
||||
method + the record of why. Measured on 14 hand-verified 千问 positives from the corpus,
|
||||
the current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED) cannot
|
||||
see this mark AT ALL:
|
||||
STATUS 2026-07-21: `qwen_alpha.png` IS registered (`qwen_engine.py`) -- the 2026-07-18
|
||||
blocker quoted below turned out to be mis-sized GEOMETRY (two size modes + a locate box
|
||||
that clipped the first glyph), not segmentation, and was solved by the TC260-producer
|
||||
cohort harvest + `vendor_mark_calibrate.py` (117 labelled frames; full record in
|
||||
`docs/verification-plan.md`). `xinghui_alpha.png` is still NOT registered: one confirmed
|
||||
corpus example is nothing to calibrate a gate against.
|
||||
|
||||
--- the 2026-07-18 record, kept as the history of the failed first attempt ---
|
||||
Measured on 14 hand-verified 千问 positives from the corpus,
|
||||
the then-current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED)
|
||||
could not see this mark AT ALL:
|
||||
|
||||
same pipeline, each mark scored with its OWN template, on real positives
|
||||
doubao n=40 mean NCC 0.723 median 0.835 >= 0.40 gate: 82%
|
||||
@@ -35,13 +42,12 @@ Three checks ruled out the obvious explanations, in order:
|
||||
3. NOT the appearance thresholds. Sweeping tophat_delta / logo_min_luma / kernel
|
||||
reached at best mean 0.35 with 4/14 over the gate.
|
||||
|
||||
The blocker is SEGMENTATION on a faint mark: Doubao is stamped bold and opaque, so the
|
||||
white top-hat returns a clean glyph blob; the Qwen mark is a thin translucent overlay
|
||||
that shatters into specks, and no template can match a blob that is not there. Adding
|
||||
it therefore needs a detection front-end that does not depend on binarizing the glyph
|
||||
(grayscale/edge correlation on the raw top-hat, or a learned patch classifier) -- not a
|
||||
new silhouette. Shipping it on the current front-end would mean a detector that finds
|
||||
almost nothing and, at any threshold low enough to fire, fires on arbitrary corner text.
|
||||
The blocker was named SEGMENTATION on a faint mark: Doubao is stamped bold and opaque,
|
||||
so the white top-hat returns a clean glyph blob; the Qwen mark is a thin translucent
|
||||
overlay that shatters into specks, and no template can match a blob that is not there.
|
||||
The `tophat` front-end (built later, for doubao) removed that blocker -- and 千问 STILL
|
||||
did not register, because the real residual was geometry. See the 2026-07-21 status
|
||||
above.
|
||||
|
||||
星绘 additionally has only ONE confirmed example in the corpus, so even a working
|
||||
front-end could not have its threshold calibrated yet.
|
||||
@@ -66,14 +72,19 @@ neither settles the question:
|
||||
The blocker is now EVIDENCE, not architecture: this session found exactly one 千问 and one
|
||||
百度 positive (both by eyeballing doubao-provenance misses), and the 14 positives quoted
|
||||
above were not preserved anywhere the current scripts can reach. Nothing should be
|
||||
registered off a single frame. What it takes: harvest 30+ confirmed positives per vendor
|
||||
-- `scripts/cjk_tail_probe.py` exists for exactly this, scoring TC260-provenance frames
|
||||
that no detector fires on and writing a contact sheet of the top scorers to label -- then
|
||||
calibrate a gate against the clean arm. Two traps worth knowing before repeating this
|
||||
measurement: score with `alpha_height_frac`, not the silhouette's own aspect ratio (the
|
||||
latter inflated the clean p99 from 0.30 to 0.58 and made every comparison meaningless),
|
||||
and keep the ladder at the shipped 3 rungs, since a wide sweep hands clean corners many
|
||||
extra chances to match.
|
||||
registered off a single frame.
|
||||
|
||||
UPDATE 2026-07-21 (the resolution): the evidence arrived via the TC260 producer-USCC
|
||||
cohort trick (`scripts/vendor_cohort_harvest.py` -- 117 labelled 千问 frames from metadata
|
||||
alone), and the registration shipped the same day (`qwen_engine.py`). The "no separation
|
||||
at all" reading above was the MIS-SIZED geometry, not the mark: at the fitted geometry the
|
||||
full template separates the cohort from clean corners 0.662 vs 0.134 (p50). The traps
|
||||
below still bind any NEXT vendor: score with `alpha_height_frac`, not the silhouette's own
|
||||
aspect ratio (the latter inflated the clean p99 from 0.30 to 0.58 and made every
|
||||
comparison meaningless); keep the ladder at the shipped rungs for gate-setting, since a
|
||||
wide sweep hands clean corners many extra chances to match; and re-filter the clean arm
|
||||
per candidate -- the 2026-07-18 `present: []` labels mean "no REGISTERED mark", so qwen
|
||||
-cohort frames visibly carrying 千问AI生成 sat in it (see `vendor_mark_calibrate.load_sets`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Partition China-AIGC carriers into VENDOR COHORTS by their TC260 producer code.
|
||||
|
||||
THE PROBLEM THIS SOLVES
|
||||
Coverage of uncovered vendors is the largest remaining detection lever
|
||||
(`docs/verification-plan.md`, "Where detection work should go next"), and it is
|
||||
blocked on EVIDENCE: nothing may be registered off a single frame, and the
|
||||
previous session found exactly one confirmed positive each for `千问` and `百度`.
|
||||
Harvesting more by PIXELS is circular -- a detector is what we are trying to
|
||||
build -- and the generic shared-tail probe is too weak to label with (0.407 on a
|
||||
bold positive against a clean p99 of 0.298; see `cjk_tail_probe.py`).
|
||||
|
||||
THE KEY
|
||||
The TC260 label is not anonymous. Its `ContentProducer` field carries the
|
||||
producer's Chinese Unified Social Credit Code (USCC), e.g.
|
||||
``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
|
||||
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
|
||||
label MARKER (the bare presence of `TC260:AIGC`), which indeed names nobody.
|
||||
The producer FIELD inside the block is a different thing and does name one.
|
||||
|
||||
Caveat kept in view: the code names the SIGNING ENTITY, which is not always the
|
||||
consumer brand (an aggregator or a cloud host signs for several apps, and one
|
||||
vendor can hold several codes). So a cohort is a strong grouping key and a
|
||||
hypothesis about the brand -- the brand itself is settled by reading the crop,
|
||||
which is what `--sheets` is for.
|
||||
|
||||
WHAT IT COSTS
|
||||
Metadata only. The expensive pixel pass is NOT re-run: which detectors fired is
|
||||
joined from `_visible_positives.jsonl` (the completed full-corpus artifact), per
|
||||
the standing rule against relaunching finished sweeps to re-check them.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are real user uploads: read-only, local analysis, gitignored
|
||||
output. Contact sheets stay under `data/spaces/`; nothing here is committed.
|
||||
|
||||
uv run python scripts/vendor_cohort_harvest.py
|
||||
uv run python scripts/vendor_cohort_harvest.py --report-only --sheets 12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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" / "_vendor_cohorts.jsonl"
|
||||
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_cohort_sheets"
|
||||
|
||||
# A producer code is `001` + `1` + USCC(18) + a 5-digit app/product suffix, so two
|
||||
# codes sharing the USCC are the same legal entity registering different products.
|
||||
# Slicing is defensive: anything not matching the layout is grouped by its raw value.
|
||||
_USCC_START, _USCC_END = 4, 22
|
||||
|
||||
|
||||
def uscc_of(code: str) -> str:
|
||||
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
|
||||
if len(code) >= _USCC_END and code[:3] == "001":
|
||||
return code[_USCC_START:_USCC_END]
|
||||
return code
|
||||
|
||||
|
||||
def _one(path_str: str) -> dict[str, Any] | None:
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
try:
|
||||
label = aigc_label(Path(path_str))
|
||||
except Exception:
|
||||
return None
|
||||
if not label:
|
||||
return None
|
||||
producer = str(label.get("ContentProducer") or "")
|
||||
return {
|
||||
"path": path_str,
|
||||
"producer": producer,
|
||||
"uscc": uscc_of(producer),
|
||||
"propagator": str(label.get("ContentPropagator") or ""),
|
||||
"service_provider": str(label.get("ServiceProvider") or ""),
|
||||
}
|
||||
|
||||
|
||||
def load_fired() -> dict[str, list[str]]:
|
||||
"""path -> detector keys that fired, from the completed full-corpus artifact."""
|
||||
if not FIRED.exists():
|
||||
print(f"WARNING: {FIRED.name} missing; cohorts will show no detector state")
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for line in FIRED.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
out[rec["path"]] = rec.get("keys") or []
|
||||
return out
|
||||
|
||||
|
||||
def scan(limit: int, workers: int, out_path: Path) -> list[dict[str, Any]]:
|
||||
pool = sorted(glob.glob(str(CORPUS / "*" / "*")))
|
||||
if limit:
|
||||
pool = pool[:limit]
|
||||
print(f"scanning {len(pool)} corpus files for TC260 labels workers={workers}", flush=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=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 scan
|
||||
continue
|
||||
if rec is not None:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
rows.append(rec)
|
||||
if i % 5000 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(pool)} carriers={len(rows)}", flush=True)
|
||||
return rows
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]], fired: dict[str, list[str]], min_size: int) -> None:
|
||||
by_uscc: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
by_uscc.setdefault(r["uscc"], []).append(r)
|
||||
|
||||
print(f"\n{'=' * 92}\nVENDOR COHORTS ({len(rows)} TC260 carriers, {len(by_uscc)} distinct entities)\n{'=' * 92}")
|
||||
print("\n`fires` = share of the cohort where SOME registered detector fires.")
|
||||
print("A large cohort with a low fire rate is an uncovered vendor -- the harvest target.\n")
|
||||
print(f"{'entity (USCC)':22s} {'n':>6s} {'fires':>7s} {'detectors seen':38s} {'products':>8s}")
|
||||
print("-" * 92)
|
||||
|
||||
cohorts = sorted(by_uscc.items(), key=lambda kv: -len(kv[1]))
|
||||
for uscc, members in cohorts:
|
||||
if len(members) < min_size:
|
||||
continue
|
||||
keys: Counter[str] = Counter()
|
||||
hit = 0
|
||||
for m in members:
|
||||
ks = fired.get(m["path"], [])
|
||||
if ks:
|
||||
hit += 1
|
||||
keys.update(ks)
|
||||
seen = ", ".join(f"{k}:{c}" for k, c in keys.most_common(4)) or "-- none --"
|
||||
products = len({m["producer"] for m in members})
|
||||
print(f"{uscc:22s} {len(members):6d} {100 * hit / len(members):6.1f}% {seen:38s} {products:8d}")
|
||||
|
||||
small = sum(1 for _, m in cohorts if len(m) < min_size)
|
||||
if small:
|
||||
print(f"\n({small} cohorts below --min-size {min_size} not shown)")
|
||||
|
||||
|
||||
def _bands(img: Any, width: int, band: int) -> list[Any]:
|
||||
"""Full-width top and bottom bands, scaled to a readable common width.
|
||||
|
||||
An unregistered vendor's placement is unknown, so cropping the bottom-RIGHT
|
||||
corner (where the marks we already cover happen to sit) would beg the
|
||||
question. Full-width bands catch any horizontal position, and the two bands
|
||||
together cover every corner the standard's implementers actually use.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
h = img.shape[0]
|
||||
strip = max(24, int(h * band))
|
||||
out = []
|
||||
for piece in (img[:strip], img[h - strip :]):
|
||||
scale = width / max(1, piece.shape[1])
|
||||
out.append(cv2.resize(piece, (width, max(12, int(piece.shape[0] * scale))), interpolation=cv2.INTER_AREA))
|
||||
return out
|
||||
|
||||
|
||||
def sheets(rows: list[dict[str, Any]], fired: dict[str, list[str]], per: int, min_size: int) -> None:
|
||||
"""Top/bottom bands per uncovered cohort, so the vendor and mark can be read off."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
by_uscc: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
by_uscc.setdefault(r["uscc"], []).append(r)
|
||||
|
||||
SHEET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"\nwriting contact sheets -> {SHEET_DIR}")
|
||||
|
||||
for uscc, members in sorted(by_uscc.items(), key=lambda kv: -len(kv[1])):
|
||||
if len(members) < min_size:
|
||||
continue
|
||||
quiet = [m for m in members if not fired.get(m["path"])]
|
||||
if not quiet:
|
||||
continue
|
||||
width = 900
|
||||
tiles: list[Any] = []
|
||||
for m in quiet[:per]:
|
||||
img = imread(m["path"])
|
||||
if img is None:
|
||||
continue
|
||||
for b in _bands(img, width, 0.10):
|
||||
tiles.append(b)
|
||||
tiles.append(np.full((3, width, 3), 60, np.uint8))
|
||||
if tiles:
|
||||
dest = SHEET_DIR / f"{uscc}_n{len(members)}_quiet{len(quiet)}.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f" {dest.name} ({len(tiles) // 4} frames)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--limit", type=int, default=0, help="cap files scanned (0 = whole corpus)")
|
||||
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("--min-size", type=int, default=5, help="hide cohorts smaller than this")
|
||||
ap.add_argument("--sheets", type=int, default=0, help="crops per cohort contact sheet")
|
||||
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()]
|
||||
else:
|
||||
rows = scan(a.limit, a.workers, a.out)
|
||||
|
||||
fired = load_fired()
|
||||
report(rows, fired, a.min_size)
|
||||
if a.sheets:
|
||||
sheets(rows, fired, a.sheets, a.min_size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,598 @@
|
||||
"""Calibrate a candidate text-mark detector for an UNCOVERED vendor, on real positives.
|
||||
|
||||
WHERE THE POSITIVES COME FROM
|
||||
`vendor_cohort_harvest.py` partitions China-AIGC carriers into per-entity cohorts by
|
||||
their TC260 producer code, so a cohort is a vendor LABEL that owes nothing to any
|
||||
pixel detector. That is what makes this calibration non-circular: the previous attempt
|
||||
(`render_vendor_silhouettes.py`, 2026-07-18) died at n=1 because the only way it knew
|
||||
to find 千问 frames was to eyeball the misses of a detector that cannot see them.
|
||||
|
||||
A cohort is NOT automatically a set of visible-mark positives: TC260 provenance is
|
||||
metadata, and a vendor may label a frame without stamping it. So the cohort is the
|
||||
CANDIDATE pool, and mark presence is settled by eye -- `--sheets` writes the corner
|
||||
crops sorted by score, which makes that pass cheap and makes the separation (or its
|
||||
absence) visible directly.
|
||||
|
||||
NEGATIVES
|
||||
The 432 frames hand-labelled `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.
|
||||
|
||||
THE TRAPS, INHERITED FROM THE 2026-07-18 MEASUREMENT
|
||||
Both are encoded below rather than left to the caller:
|
||||
* size the template with `alpha_height_frac`, NOT the silhouette's own aspect ratio
|
||||
(the latter inflated the clean p99 from 0.30 to 0.58 and made comparison meaningless)
|
||||
* keep the ladder at the shipped 3 rungs -- a wide sweep hands clean corners extra
|
||||
chances to match, which flatters the positives and the negatives alike
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are real user uploads: read-only, local, gitignored output. The template
|
||||
is font-rendered synthetic (`render_vendor_silhouettes.py`), never cut from an upload.
|
||||
|
||||
uv run python scripts/vendor_mark_calibrate.py --cohort 91440101MA9Y9T4H7A \\
|
||||
--asset qwen_alpha.png --sheets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
COHORTS = REPO / "data" / "spaces" / "_vendor_cohorts.jsonl"
|
||||
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_calib_sheets"
|
||||
OUT = REPO / "data" / "spaces" / "_vendor_calibration.jsonl"
|
||||
|
||||
|
||||
def build_config(
|
||||
asset: str,
|
||||
name: str,
|
||||
scale_basis: str = "short",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""A candidate config: doubao's tuned geometry with this vendor's silhouette.
|
||||
|
||||
Transferring doubao's numbers is justified by LAYOUT, not by hope: every one of these
|
||||
marks is the same GB 45438-2025 house style -- a 2-glyph vendor prefix, then the
|
||||
mandated `AI生成` tail, set in a semibold CJK sans in the bottom-right corner. So
|
||||
`豆包AI生成` and `千问AI生成` are the same 6 glyph cells at the same scale, and the
|
||||
width/height fractions carry over. The NCC gate does NOT carry over and is what this
|
||||
script exists to measure. Any tuned value can be overridden with what
|
||||
`--fit-geometry` measured -- inheriting the locate box blindly clipped the big-mode
|
||||
qwen mark, which is exactly the trap this tool exists to avoid.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG
|
||||
|
||||
return dataclasses.replace(
|
||||
TextMarkConfig(**dataclasses.asdict(_CONFIG)),
|
||||
name=name,
|
||||
asset_name=asset,
|
||||
scale_basis=scale_basis,
|
||||
**(overrides or {}),
|
||||
)
|
||||
|
||||
|
||||
ScoreArgs = tuple[str, str, str, str, "dict[str, Any]"]
|
||||
|
||||
|
||||
def _score(args: ScoreArgs) -> dict[str, Any] | None:
|
||||
path_str, asset, name, basis, overrides = args
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 64:
|
||||
return None
|
||||
eng = TextMarkEngine(build_config(asset, name, basis, overrides))
|
||||
loc = eng.locate(img)
|
||||
try:
|
||||
score, box = eng._tophat_best(img, loc)
|
||||
except Exception:
|
||||
return None
|
||||
return {"path": path_str, "score": round(float(score), 4), "box": box}
|
||||
|
||||
|
||||
NEGATIVES = REPO / "data" / "spaces" / "_research_20260718_textmark_relaxation" / "groundtruth.jsonl"
|
||||
|
||||
|
||||
def load_sets(cohort: str) -> tuple[list[str], list[str]]:
|
||||
pos = [
|
||||
json.loads(x)["path"]
|
||||
for x in COHORTS.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip() and json.loads(x)["uscc"] == cohort
|
||||
]
|
||||
# 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:
|
||||
# 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".
|
||||
in_any_cohort = {
|
||||
str(Path(json.loads(x)["path"]).resolve())
|
||||
for x in COHORTS.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip()
|
||||
}
|
||||
neg: list[str] = []
|
||||
dropped = 0
|
||||
for line in NEGATIVES.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("present"):
|
||||
continue
|
||||
p = str((REPO / rec["path"]).resolve())
|
||||
if p in in_any_cohort:
|
||||
dropped += 1
|
||||
continue
|
||||
neg.append(p)
|
||||
if dropped:
|
||||
print(f"clean arm: dropped {dropped} negatives that sit in a TC260 cohort (contamination guard)")
|
||||
return pos, neg
|
||||
|
||||
|
||||
def run(
|
||||
paths: list[str],
|
||||
asset: str,
|
||||
name: str,
|
||||
workers: int,
|
||||
basis: str = "short",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
futs = [ex.submit(_score, (p, asset, name, basis, overrides or {})) for p in paths]
|
||||
for f in as_completed(futs):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the sweep
|
||||
continue
|
||||
if r:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def report(pos: list[dict[str, Any]], neg: list[dict[str, Any]], name: str) -> None:
|
||||
import numpy as np
|
||||
|
||||
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{'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):
|
||||
continue
|
||||
qs = [np.percentile(arr, q) for q in (10, 50, 90, 95, 99)]
|
||||
print(f"{label:10s} {len(arr):5d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {arr.max():7.3f}")
|
||||
|
||||
if len(p) and len(n):
|
||||
print("\n\nOPERATING POINTS -- gate set on the CLEAN arm")
|
||||
print("`cohort fire` is an UPPER BOUND on recall: the cohort also holds")
|
||||
print("metadata-only frames that carry no visible mark to find.\n")
|
||||
print(f"{'gate':>7s} {'clean fire':>12s} {'cohort fire':>13s} {'cohort n':>10s}")
|
||||
for q in (90, 95, 99, 99.5, 100):
|
||||
t = float(np.percentile(n, q))
|
||||
cf, pf = 100 * float((n >= t).mean()), 100 * float((p >= t).mean())
|
||||
print(f"{t:7.3f} {cf:11.2f}% {pf:12.1f}% {int((p >= t).sum()):10d}")
|
||||
|
||||
|
||||
def sheets(pos: list[dict[str, Any]], name: str, per_sheet: int = 24) -> None:
|
||||
"""Corner crops sorted by score, so mark presence and the gate are read in one pass."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
eng = TextMarkEngine(build_config("doubao_alpha.png", "roi"))
|
||||
SHEET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ranked = sorted(pos, key=lambda r: -r["score"])
|
||||
width = 660
|
||||
for start in range(0, len(ranked), per_sheet):
|
||||
chunk = ranked[start : start + per_sheet]
|
||||
tiles: list[Any] = []
|
||||
for rank, r in enumerate(chunk, start + 1):
|
||||
img = imread(r["path"])
|
||||
if img is None:
|
||||
continue
|
||||
loc = eng.locate(img)
|
||||
crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
|
||||
if not crop.size:
|
||||
continue
|
||||
tile = cv2.resize(crop, (width, 96), interpolation=cv2.INTER_AREA)
|
||||
cv2.rectangle(tile, (0, 0), (118, 22), (0, 0, 0), -1)
|
||||
cv2.putText(tile, f"#{rank} {r['score']:.3f}", (4, 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
tiles.append(tile)
|
||||
tiles.append(np.full((2, width, 3), 70, np.uint8))
|
||||
if tiles:
|
||||
dest = SHEET_DIR / f"{name}_ranked_{start // per_sheet:02d}.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f" {dest.name} (#{start + 1}..#{start + len(chunk)})")
|
||||
|
||||
|
||||
# Wide and dense, for the geometry FIT only. An unregistered vendor's glyph size is
|
||||
# genuinely unknown, which is the one case a dense ladder earns its cost -- but it also
|
||||
# hands clean corners extra chances to match, so it must never set a gate.
|
||||
_FIT_SCALES = tuple(round(0.4 * (1.03**i), 4) for i in range(80)) # 0.40 .. ~4.1
|
||||
# The ladder the product actually ships (`_text_mark_engine._tophat_best`).
|
||||
_SHIPPED_LADDER = (0.8, 1.0, 1.25)
|
||||
|
||||
|
||||
def _fit_one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
"""Best match over the WIDE ladder, reported as a mark width in pixels.
|
||||
|
||||
Also measures the template ASPECT at the winning width: the mark's true height is
|
||||
fitted by sweeping gh at the winning gw and reading the argmax, because
|
||||
`alpha_height_frac` must be measured, not taken from the silhouette's own aspect
|
||||
(that inflated the clean p99 from 0.30 to 0.58 on the 2026-07-18 attempt) and not
|
||||
inherited from doubao.
|
||||
"""
|
||||
path_str, asset = args
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
cfg = build_config(asset, "fit", "width")
|
||||
eng = TextMarkEngine(cfg)
|
||||
img = imread(path_str)
|
||||
if img is None:
|
||||
return None
|
||||
loc = eng.locate(img)
|
||||
resp = eng.tophat_response(img, loc)
|
||||
sil = eng._glyph_silhouette()
|
||||
if resp is None or sil is None:
|
||||
return None
|
||||
w = img.shape[1]
|
||||
best, best_gw = 0.0, 0
|
||||
best_tl = (0, 0)
|
||||
for s in _FIT_SCALES:
|
||||
gw = max(cfg.min_gw, int(cfg.alpha_width_frac * w * s))
|
||||
gh = max(4, int(cfg.alpha_height_frac * w * s))
|
||||
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
res = cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED)
|
||||
_, v, _, tl = cv2.minMaxLoc(res)
|
||||
if v > best:
|
||||
best, best_gw, best_tl = v, gw, (int(tl[0]), int(tl[1]))
|
||||
# Aspect fit at the winning width: sweep gh/gw and keep the argmax. Range covers
|
||||
# everything between samsung's 0.12 and jimeng's 0.29 house styles, plus slack.
|
||||
best_aspect = 0.0
|
||||
if best_gw > 0:
|
||||
best_gh_score = -1.0
|
||||
for ratio in np.arange(0.12, 0.42, 0.01):
|
||||
gh = max(4, int(best_gw * float(ratio)))
|
||||
if gh >= resp.shape[0]:
|
||||
continue
|
||||
t = cv2.resize(sil, (best_gw, gh), interpolation=cv2.INTER_AREA)
|
||||
v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
|
||||
if v > best_gh_score:
|
||||
best_gh_score, best_aspect = v, float(ratio)
|
||||
# The ABSOLUTE mark rect, so the LOCATE box fractions can be fitted too: inheriting
|
||||
# doubao's corner box clipped the big-mode qwen mark's first glyph (the qwen mark
|
||||
# sits ~0.025 of the short side off the right edge, doubao's box assumes ~0.004),
|
||||
# which collapsed an exact-size template to 0.26.
|
||||
ax = loc.x + best_tl[0]
|
||||
ay = loc.y + best_tl[1]
|
||||
return {
|
||||
"path": path_str,
|
||||
"best": round(best, 4),
|
||||
"mark_w": best_gw,
|
||||
"aspect": round(best_aspect, 3),
|
||||
"x": ax,
|
||||
"y": ay,
|
||||
"w": w,
|
||||
"h": img.shape[0],
|
||||
}
|
||||
|
||||
|
||||
def fit_geometry(paths: list[str], asset: str, workers: int, floor: float = 0.50, paths_name: str = "cohort") -> None:
|
||||
"""Which basis and fraction does this vendor's mark actually scale with?
|
||||
|
||||
Only frames matching above ``floor`` are used: below it the winning size is the
|
||||
ladder's best fit to background texture, not a measurement of the mark.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
for f in as_completed([ex.submit(_fit_one, (p, asset)) for p in paths]):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the fit
|
||||
continue
|
||||
if r:
|
||||
rows.append(r)
|
||||
|
||||
strong = [r for r in rows if r["best"] >= floor]
|
||||
fit_out = REPO / "data" / "spaces" / f"_vendor_fit_{paths_name}.jsonl"
|
||||
fit_out.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
|
||||
print(f"\n{'=' * 78}\nGEOMETRY FIT (n={len(rows)}, usable best>={floor}: {len(strong)})\n{'=' * 78}")
|
||||
print(f"rows -> {fit_out}")
|
||||
if len(strong) < 20:
|
||||
print("too few strong frames to fit a basis -- do not ship a fraction off this")
|
||||
return
|
||||
|
||||
mw = np.array([r["mark_w"] for r in strong], float)
|
||||
w = np.array([r["w"] for r in strong], float)
|
||||
h = np.array([r["h"] for r in strong], float)
|
||||
bases = {
|
||||
"width": w,
|
||||
"height": h,
|
||||
"short": np.minimum(w, h),
|
||||
"long": np.maximum(w, h),
|
||||
"sqrt(w*h)": np.sqrt(w * h),
|
||||
"diagonal": np.hypot(w, h),
|
||||
}
|
||||
print(f"\n{'basis':12s} {'mean frac':>10s} {'CV':>8s} {'p10':>8s} {'p90':>8s}")
|
||||
print("-" * 52)
|
||||
for nm, b in sorted(bases.items(), key=lambda kv: float(np.std(mw / kv[1]) / np.mean(mw / kv[1]))):
|
||||
r = mw / b
|
||||
print(
|
||||
f"{nm:12s} {np.mean(r):10.4f} {float(np.std(r) / np.mean(r)):8.3f} "
|
||||
f"{np.percentile(r, 10):8.4f} {np.percentile(r, 90):8.4f}"
|
||||
)
|
||||
|
||||
lo_l, hi_l = _SHIPPED_LADDER[0], _SHIPPED_LADDER[-1]
|
||||
print(f"\nCoverage by a single fraction on the SHIPPED ladder ({lo_l} .. {hi_l}, span {hi_l / lo_l:.3f}x):")
|
||||
print(f"{'basis':12s} {'frac':>7s} {'window':>16s} {'covered':>9s}")
|
||||
for nm in ("short", "sqrt(w*h)", "width"):
|
||||
fs = mw / bases[nm]
|
||||
best_f, best_cov = 0.0, -1.0
|
||||
for f in np.arange(float(fs.min()) * 0.9, float(fs.max()) * 1.1, 0.002):
|
||||
cov = float(((fs >= f * lo_l) & (fs <= f * hi_l)).mean())
|
||||
if cov > best_cov:
|
||||
best_f, best_cov = float(f), cov
|
||||
print(f"{nm:12s} {best_f:7.3f} {best_f * lo_l:7.3f}-{best_f * hi_l:.3f} {100 * best_cov:8.1f}%")
|
||||
|
||||
# The raw distribution behind the coverage number: where the mark actually sits,
|
||||
# so the mode structure (and what a 4th rung would recover) is visible directly.
|
||||
fs = mw / bases["short"]
|
||||
qs = [np.percentile(fs, q) for q in (5, 25, 50, 75, 95)]
|
||||
print(f"\nfrac_short distribution: p5 {qs[0]:.3f} p25 {qs[1]:.3f} p50 {qs[2]:.3f} p75 {qs[3]:.3f} p95 {qs[4]:.3f}")
|
||||
hist, edges = np.histogram(fs, bins=16)
|
||||
for c, e0, e1 in zip(hist, edges[:-1], edges[1:], strict=True):
|
||||
print(f" {e0:.3f}-{e1:.3f} {'#' * c}")
|
||||
|
||||
# Template aspect at the winning width -> the alpha_height_frac recommendation.
|
||||
# Measured, per the standing rule: not the silhouette's own aspect, not doubao's.
|
||||
aspects = np.array([r["aspect"] for r in strong if r["aspect"] > 0], float)
|
||||
if len(aspects) >= 20:
|
||||
med = float(np.median(aspects))
|
||||
print(
|
||||
f"\nASPECT FIT (n={len(aspects)}): p10 {np.percentile(aspects, 10):.3f} "
|
||||
f"p50 {med:.3f} p90 {np.percentile(aspects, 90):.3f}"
|
||||
)
|
||||
print("alpha_height_frac = alpha_width_frac * p50(aspect), per basis:")
|
||||
for nm in ("short", "sqrt(w*h)", "width"):
|
||||
fxs = mw / bases[nm]
|
||||
best_f = max(
|
||||
np.arange(float(fxs.min()) * 0.9, float(fxs.max()) * 1.1, 0.002),
|
||||
key=lambda f: float(((fxs >= f * lo_l) & (fxs <= f * hi_l)).mean()),
|
||||
)
|
||||
print(f" {nm:12s} width {best_f:.3f} -> height {best_f * med:.4f}")
|
||||
|
||||
# LOCATE-box fit. The box fractions are as mark-specific as the template size:
|
||||
# doubao's box clipped qwen's big-mode mark (see _fit_one). Derive the box from the
|
||||
# measured absolute mark rects: margins must not exceed the mark's own (else the
|
||||
# mark exits the anchored box), and the box must cover the mark plus NCC slack.
|
||||
if len(aspects) >= 20:
|
||||
short = np.minimum(w, h).astype(float)
|
||||
mark_h = np.array([r["mark_w"] * r["aspect"] for r in strong], float)
|
||||
ax = np.array([r["x"] for r in strong], float)
|
||||
ay = np.array([r["y"] for r in strong], float)
|
||||
right = (w - (ax + mw)) / short # frame right edge to mark right edge
|
||||
bottom = (h - (ay + mark_h)) / short
|
||||
print(f"\nLOCATE FIT (basis=short, n={len(strong)}):")
|
||||
print(f" right-margin frac p5 {np.percentile(right, 5):.4f} p50 {np.percentile(right, 50):.4f}")
|
||||
print(f" bottom-margin frac p5 {np.percentile(bottom, 5):.4f} p50 {np.percentile(bottom, 50):.4f}")
|
||||
print(
|
||||
f" mark height frac p50 {np.percentile(mark_h / short, 50):.4f} "
|
||||
f"p95 {np.percentile(mark_h / short, 95):.4f}"
|
||||
)
|
||||
mx = max(0.002, float(np.percentile(right, 5)) - 0.004)
|
||||
mb = max(0.002, float(np.percentile(bottom, 5)) - 0.004)
|
||||
need_w = float(np.percentile(mw / short + right, 95)) - mx + 0.02
|
||||
need_h = float(np.percentile(mark_h / short + bottom, 95)) - mb + 0.015
|
||||
print(f" recommended: margin_x_frac={mx:.4f} margin_bottom_frac={mb:.4f}")
|
||||
print(f" width_frac={need_w:.3f} height_frac={need_h:.3f}")
|
||||
|
||||
print("\nThese are DIAGNOSTIC. Re-score both arms on the shipped ladder with the")
|
||||
print("fitted geometry before reading any gate off the clean arm.")
|
||||
|
||||
|
||||
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
|
||||
|
||||
def _fired_pool(mark: str, limit: int, seed: int = 7) -> list[str]:
|
||||
"""Paths where ``mark`` fired, from the COMPLETED full-corpus artifact -- the
|
||||
standing rule: detector firings are joined, never re-run."""
|
||||
import random
|
||||
|
||||
pool = [
|
||||
json.loads(x)["path"]
|
||||
for x in FIRED.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip() and mark in (json.loads(x).get("keys") or [])
|
||||
]
|
||||
rng = random.Random(seed) # noqa: S311 -- reproducible sampling, not crypto
|
||||
rng.shuffle(pool)
|
||||
return pool[:limit]
|
||||
|
||||
|
||||
def _cross_score(args: tuple[str, Any, Any]) -> dict[str, Any] | None:
|
||||
"""One frame scored by BOTH the candidate and the doubao production configs."""
|
||||
path_str, cand_cfg, db_cfg = args
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 200:
|
||||
return None
|
||||
out: dict[str, Any] = {"path": path_str}
|
||||
for key, cfg in (("cand", cand_cfg), ("doubao", db_cfg)):
|
||||
eng = TextMarkEngine(cfg)
|
||||
loc = eng.locate(img)
|
||||
try:
|
||||
score, _ = eng._tophat_best(img, loc)
|
||||
except Exception:
|
||||
return None
|
||||
out[key] = round(float(score), 4)
|
||||
return out
|
||||
|
||||
|
||||
def crossfire(
|
||||
pools: dict[str, list[str]],
|
||||
cand_cfg: Any,
|
||||
workers: int,
|
||||
gate: float,
|
||||
margin: float = 0.10,
|
||||
) -> None:
|
||||
"""Score the candidate AND doubao's production template on the same frames.
|
||||
|
||||
The registration question a cohort-vs-clean run cannot answer: the candidate shares
|
||||
the mandated `AI生成` tail with doubao (4 of 6 glyph cells), so its template will
|
||||
correlate with doubao marks too. If the candidate fires on the doubao pool at the
|
||||
candidate gate, registering it double-fills every doubao frame and mislabels it --
|
||||
unless the rival margin suppresses it, which then has to be shown NOT to kill the
|
||||
candidate on its own marks. Measured here in the tophat domain (the gate's domain);
|
||||
production's `_rival_margin_ok` runs the same comparison on the binary blob.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG as db_cfg
|
||||
|
||||
print(f"\n{'=' * 78}\nCROSSFIRE -- candidate vs doubao, same frames, tophat domain\n{'=' * 78}")
|
||||
print(f"candidate gate {gate:.3f} | rival margin {margin:.2f}\n")
|
||||
print(
|
||||
f"{'pool':8s} {'n':>5s} {'cand p50':>9s} {'cand p90':>9s} {'db p50':>7s} {'db p90':>7s} "
|
||||
f"{'m-d p10':>8s} {'m-d p50':>8s} {'fire':>7s} {'fire+m':>7s}"
|
||||
)
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
for pool_name, paths in pools.items():
|
||||
rows: list[dict[str, Any]] = []
|
||||
futs = [ex.submit(_cross_score, (p, cand_cfg, db_cfg)) for p in paths]
|
||||
for f in as_completed(futs):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the pool
|
||||
continue
|
||||
if r:
|
||||
rows.append(r)
|
||||
if not rows:
|
||||
continue
|
||||
c = np.array([r["cand"] for r in rows])
|
||||
d = np.array([r["doubao"] for r in rows])
|
||||
m = c - d
|
||||
fire = c >= gate
|
||||
fire_m = fire & (m >= margin)
|
||||
print(
|
||||
f"{pool_name:8s} {len(rows):5d} {np.percentile(c, 50):9.3f} {np.percentile(c, 90):9.3f} "
|
||||
f"{np.percentile(d, 50):7.3f} {np.percentile(d, 90):7.3f} "
|
||||
f"{np.percentile(m, 10):8.3f} {np.percentile(m, 50):8.3f} "
|
||||
f"{100 * float(fire.mean()):6.1f}% {100 * float(fire_m.mean()):6.1f}%"
|
||||
)
|
||||
print("\nReading: on `qwen` the margin column must stay high (the candidate keeps its")
|
||||
print("own marks); on `doubao` fire+m must sit near zero (the candidate stays off")
|
||||
print("doubao marks). If fire is high on `doubao` and fire+m is not, the rival margin")
|
||||
print("is load-bearing for registration; if both are high, the mark cannot be")
|
||||
print("registered on this front-end at all.")
|
||||
|
||||
|
||||
def _parse_ladder(raw: str) -> tuple[float, ...] | None:
|
||||
if not raw:
|
||||
return None
|
||||
return tuple(float(x) for x in raw.split(","))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cohort", required=True, help="cohort USCC from vendor_cohort_harvest.py")
|
||||
ap.add_argument("--asset", required=True, help="silhouette asset name, e.g. qwen_alpha.png")
|
||||
ap.add_argument("--name", default="", help="label for output files (defaults to the asset stem)")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--scale-basis", choices=("short", "width"), default="short")
|
||||
ap.add_argument("--sheets", action="store_true")
|
||||
ap.add_argument(
|
||||
"--fit-geometry",
|
||||
action="store_true",
|
||||
help="fit the basis + fraction + template aspect the mark scales with",
|
||||
)
|
||||
ap.add_argument("--width-frac", type=float, default=None, help="fitted alpha_width_frac (default: inherit doubao)")
|
||||
ap.add_argument(
|
||||
"--height-frac", type=float, default=None, help="fitted alpha_height_frac (default: inherit doubao)"
|
||||
)
|
||||
ap.add_argument("--ladder", default="", help="comma scale rungs, e.g. 0.8,1.0,1.25,1.6 (default: shipped 3)")
|
||||
ap.add_argument("--gate", type=float, default=0.45, help="candidate gate for the crossfire fire rates")
|
||||
ap.add_argument("--box-width-frac", type=float, default=None, help="fitted locate width_frac")
|
||||
ap.add_argument("--box-height-frac", type=float, default=None, help="fitted locate height_frac")
|
||||
ap.add_argument("--margin-x-frac", type=float, default=None, help="fitted locate margin_x_frac")
|
||||
ap.add_argument("--margin-bottom-frac", type=float, default=None, help="fitted locate margin_bottom_frac")
|
||||
ap.add_argument(
|
||||
"--crossfire",
|
||||
action="store_true",
|
||||
help="score the candidate AND doubao on the cohort, the doubao/jimeng pools and the clean arm",
|
||||
)
|
||||
a = ap.parse_args()
|
||||
name = a.name or a.asset.split("_")[0]
|
||||
ladder = _parse_ladder(a.ladder)
|
||||
overrides: dict[str, Any] = {}
|
||||
for arg, field in (
|
||||
(a.width_frac, "alpha_width_frac"),
|
||||
(a.height_frac, "alpha_height_frac"),
|
||||
(a.box_width_frac, "width_frac"),
|
||||
(a.box_height_frac, "height_frac"),
|
||||
(a.margin_x_frac, "margin_x_frac"),
|
||||
(a.margin_bottom_frac, "margin_bottom_frac"),
|
||||
):
|
||||
if arg is not None:
|
||||
overrides[field] = arg
|
||||
if ladder is not None:
|
||||
overrides["ladder"] = ladder
|
||||
|
||||
pos_paths, neg_paths = load_sets(a.cohort)
|
||||
if a.fit_geometry:
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates")
|
||||
fit_geometry(pos_paths, a.asset, a.workers, paths_name=name)
|
||||
return
|
||||
|
||||
if a.crossfire:
|
||||
cand = build_config(a.asset, name, a.scale_basis, overrides)
|
||||
pools = {
|
||||
"qwen": pos_paths,
|
||||
"doubao": _fired_pool("doubao", 400),
|
||||
"jimeng": _fired_pool("jimeng", 300),
|
||||
"clean": neg_paths,
|
||||
}
|
||||
print("pools: " + ", ".join(f"{k}={len(v)}" for k, v in pools.items()))
|
||||
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"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)
|
||||
OUT.write_text(
|
||||
"\n".join(json.dumps({**r, "arm": arm}) for arm, rows in (("cohort", pos), ("clean", neg)) for r in rows),
|
||||
encoding="utf-8",
|
||||
)
|
||||
report(pos, neg, name)
|
||||
if a.sheets:
|
||||
print(f"\nsheets -> {SHEET_DIR}")
|
||||
sheets(pos, name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user