Support Qwen Create symbol watermark

This commit is contained in:
Victor Kuznetsov committed 2026-09-10 17:36:26 -07:00
1 parent 7cf067d3a5
commit c903346a6b
14 files changed
+263 -31

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ explicitly names a tracked canonical result.
| `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. |
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures. |
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures and the cleared Qwen symbol fixture. |
| `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. |
| `visible_positives.py` | List corpus images carrying a registered visible mark. |
| `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. |
+55 -2
View File
@@ -23,11 +23,18 @@ edges:
engine reads ``alpha = max(R,G,B)/255`` directly (no background fit). Its assets are
the sparkle-on-black capture cropped to two fixed logo sizes (``gemini_bg_{96,48}.png``).
**Qwen symbol** is solved from the cleared Qwen Create provider fixture. The symbol
occupies a recorded square in a smooth linen region. A cubic surface fitted outside
that square estimates the local background; the three interior components of the
positive residual are the symbol. Keeping the extraction as code makes the bundled
asset reproducible instead of a hand-cropped copy of the fixture.
Usage::
uv run python scripts/visible_alpha_solve.py doubao
uv run python scripts/visible_alpha_solve.py jimeng
uv run python scripts/visible_alpha_solve.py gemini
uv run python scripts/visible_alpha_solve.py qwen_symbol
uv run python scripts/visible_alpha_solve.py all
"""
@@ -115,6 +122,12 @@ _GEMINI_ASSETS: dict[int, Path] = {
48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png",
}
_QWEN_SYMBOL_CAPTURE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png"
_QWEN_SYMBOL_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "qwen_symbol_alpha.png"
# Recorded provider-symbol box (x, y, width, height) in the 2048-square fixture.
_QWEN_SYMBOL_BOX = (1925, 1917, 70, 70)
_QWEN_SYMBOL_BG_PAD = 45
def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]:
"""Union bbox ``(x0, x1, y0, y1)`` of ``mask``'s connected components with area
@@ -246,10 +259,48 @@ def solve_gemini() -> dict[int, NDArray[np.uint8]]:
return {size: cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) for size in _GEMINI_ASSETS}
def solve_qwen_symbol() -> NDArray[np.uint8]:
"""Recover the three-lobe Qwen symbol from its cleared provider fixture."""
source = image_io.imread(str(_QWEN_SYMBOL_CAPTURE), cv2.IMREAD_COLOR)
if source is None:
raise FileNotFoundError(f"missing Qwen symbol capture {_QWEN_SYMBOL_CAPTURE}")
x, y, width, height = _QWEN_SYMBOL_BOX
pad = _QWEN_SYMBOL_BG_PAD
crop = source[y - pad : y + height + pad, x - pad : x + width + pad].astype(np.float32)
excluded = np.zeros(crop.shape[:2], dtype=bool)
excluded[pad - 8 : pad + height + 8, pad - 8 : pad + width + 8] = True
background = _cubic_background(crop, excluded)
alpha = np.clip(
((crop - background) / np.clip(255.0 - background, 1e-3, None)).mean(axis=2),
0.0,
1.0,
)[pad : pad + height, pad : pad + width]
body = (alpha > 0.10).astype(np.uint8)
count, labels, stats, _centroids = cv2.connectedComponentsWithStats(body, connectivity=8)
parts = [
index
for index in range(1, count)
if stats[index, cv2.CC_STAT_AREA] >= 300
and stats[index, cv2.CC_STAT_LEFT] > 0
and stats[index, cv2.CC_STAT_TOP] > 0
and stats[index, cv2.CC_STAT_LEFT] + stats[index, cv2.CC_STAT_WIDTH] < width
and stats[index, cv2.CC_STAT_TOP] + stats[index, cv2.CC_STAT_HEIGHT] < height
]
if len(parts) != 3:
raise ValueError(f"expected three Qwen symbol lobes, found {len(parts)}")
selected = np.isin(labels, parts)
ys, xs = np.where(selected)
tight = np.where(selected, alpha, 0.0)[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
tight[tight < 0.08] = 0.0
log.info("qwen-symbol: alpha %dx%d max %.3f", tight.shape[1], tight.shape[0], float(tight.max()))
return (tight * 255.0).astype(np.uint8)
@click.command()
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "all"]))
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "qwen_symbol", "all"]))
def main(engine: str) -> None:
"""Rebuild the alpha asset(s) for ENGINE (doubao / jimeng / gemini / all)."""
"""Rebuild the alpha asset(s) for ENGINE."""
logging.basicConfig(level=logging.INFO, format="%(message)s")
def _write(path: Path, img: NDArray[np.uint8], label: str) -> None:
@@ -265,6 +316,8 @@ def main(engine: str) -> None:
if engine in ("gemini", "all"):
for size, img in solve_gemini().items():
_write(_GEMINI_ASSETS[size], img, f"gemini-{size}")
if engine in ("qwen_symbol", "all"):
_write(_QWEN_SYMBOL_ASSET, solve_qwen_symbol(), "qwen-symbol")
if __name__ == "__main__":