"""Render SYNTHETIC detection silhouettes for the CJK vendor text marks (data-safe). Adding a mark needs only a DETECTION silhouette, and it must be font-rendered rather than derived from user uploads: the corpus is real user content and may never reach a tracked asset (see the repo CLAUDE.md data-safety rule). Seeing real samples to learn the glyphs, weight and layout is fine; the committed template stays synthetic. Covered here: qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text The leading LOGO is deliberately NOT rendered. It is the part that varies most between releases and is hardest to reproduce synthetically, while the CJK run is stable and is what actually discriminates one vendor from another (the shared `AI生成` tail is exactly what does NOT discriminate -- see the rival-margin mechanism in _text_mark_engine). 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: 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% qwen n=14 mean NCC 0.170 median 0.179 >= 0.40 gate: 0% Three checks ruled out the obvious explanations, in order: 1. NOT the synthetic render. A template cut from an ACTUAL Qwen mark scores the same as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308) -- and real masks do not even match EACH OTHER. 2. NOT the morphology kernel size. Scaling MORPH_OPEN/CLOSE with the box height (they are fixed 5px, ~9% of a 57px-tall box) gained only +0.014 mean and moved nothing across the gate. 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. 星绘 additionally has only ONE confirmed example in the corpus, so even a working front-end could not have its threshold calibrated yet. UPDATE 2026-07-20: the named blocker is GONE, and the retry is still inconclusive. `detect_frontend="tophat"` (built later, for doubao) is exactly the "grayscale correlation on the raw top-hat" this note asked for, so the 2026-07-18 ruling rests on a premise that no longer holds and must not simply be inherited. Two things were measured against it, and neither settles the question: * A GENERIC template of the shared `AI生成` tail -- attractive because GB 45438-2025 guarantees that run across vendors, so one template would cover 千问 / 百度 / 星绘 and anything compliant that ships next. Measured on the tophat front-end at the shipped 3-rung ladder: a bold 千问 positive scores 0.407 against clean corners at p99 0.298 / max 0.321. It separates on that one frame, but only by a hair, and a 4-glyph template is inherently less specific than a 6-glyph one -- the shorter the run, the more arbitrary corner structure correlates with it. * The FULL 千问 template on the same front-end scores 0.248 against a clean max of 0.537, i.e. no separation at all -- WORSE than the generic tail, which is the opposite of what the specificity argument predicts and is itself a reason to distrust n=1. 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. """ from __future__ import annotations import sys from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont _ASSETS = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets" # STHeiti Medium approximates the semibold CJK sans these marks are set in; the exact # family is unpublished for every vendor (GB 45438-2025 only requires a legible face). _FONT = "/System/Library/Fonts/STHeiti Medium.ttc" MARKS = { "qwen_alpha.png": "千问AI生成", "xinghui_alpha.png": "星绘AI生成", } def render(text: str, width: int = 335) -> np.ndarray: """Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention. Matching doubao's 335px asset width keeps the `alpha_*_frac` numbers transferable, since these marks are the same house style and scale. """ probe = Image.new("L", (10, 10)) d0 = ImageDraw.Draw(probe) size = 8 while size < 200: # grow until the run fills the target width f = ImageFont.truetype(_FONT, size) if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.98: break size += 1 font = ImageFont.truetype(_FONT, size) bb = d0.textbbox((0, 0), text, font=font) w, h = bb[2] - bb[0], bb[3] - bb[1] pad = max(2, int(h * 0.12)) im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0) ImageDraw.Draw(im).text((pad - bb[0], pad - bb[1]), text, font=font, fill=255) return np.array(im) def main() -> None: try: for name, text in MARKS.items(): sil = render(text) Image.fromarray(sil).save(_ASSETS / name) print(f"wrote {_ASSETS / name} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}") except OSError as e: print(f"Font not found ({e}); install a CJK font or edit _FONT.", file=sys.stderr) raise SystemExit(1) from e if __name__ == "__main__": main()