Files
remove-ai-watermarks/scripts/render_vendor_silhouettes.py
T

307 lines
12 KiB
Python

"""Render synthetic detection silhouettes for vendor text marks.
Committed assets must be font-rendered and contain no source-image pixels. Local
evaluation inputs may be used only to learn glyphs, weight, layout, and detector
thresholds. Candidate assets stay outside the installed package until calibrated.
Regenerate with:
uv run python scripts/render_vendor_silhouettes.py
"""
from __future__ import annotations
import sys
from pathlib import Path
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"))
from remove_ai_watermarks.watermark_registry import mark_keys # noqa: E402
_PACKAGE_ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets"
_CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates"
# 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生成",
# Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly
# stacked and ITALIC-SLANTED. A rare one-line variant exists, but the stacked block
# is dominant.
"yuanbao_alpha.png": "元宝\nAI生成",
# Kling (可灵) stamps a thin light-gray one-line "可灵AI 3.0" bottom-right (an
# "Omni" suffix variant and a latin "KlingAI 3.0" variant also exist; the CJK
# run without the suffix is the common core). The leading spiral logo is NOT
# rendered (logos vary; the text run discriminates).
"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": "cat logo + AI生成",
# RunningHub top-left text mark.
"runninghub_alpha.png": "RunningHub AI生成",
# LibLibAI bottom-center wordmark.
"liblib_alpha.png": "LibLibAI",
# Zhipu Qingyan candidate text mark.
"qingyan_alpha.png": "清言·AI生成",
# MiniMax / Hailuo candidate wordmark.
"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()
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
MARK_OPTS: dict[str, dict[str, Any]] = {
# Hiragino Sans GB W6, tight leading, dilation, and negative shear match the
# standard Yuanbao stamp without clipping the lower line.
"yuanbao_alpha.png": {
"gap_frac": 0.05,
"dilate": 2,
"shear": -0.60,
"font": "/System/Library/Fonts/Hiragino Sans GB.ttc",
"font_index": 2,
},
# Qingyan uses a heavier weight than STHeiti Medium.
"qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2},
# LibLibAI uses an Arial-class grotesque.
"liblib_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial.ttf"},
}
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.
Matching doubao's 335px asset width keeps the `alpha_*_frac` numbers transferable,
since these marks are the same house style and scale. A "\n" in ``text`` renders a
multi-line block: lines drawn left-aligned at one shared font size with a tight
gap, then optional stroke dilation and an italic shear (see MARK_OPTS).
"""
opts = opts or {}
gap_frac = float(opts.get("gap_frac", 0.15))
dilate = int(opts.get("dilate", 0))
shear_k = float(opts.get("shear", 0.0))
font_path = str(opts.get("font", _FONT))
font_index = int(opts.get("font_index", 0))
probe = Image.new("L", (10, 10))
d0 = ImageDraw.Draw(probe)
lines = text.split("\n")
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))
w = max(bb[2] - bb[0] for bb in boxes)
h = line_h * len(lines) + gap * (len(lines) - 1)
pad = max(2, int(line_h * 0.12))
im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0)
draw = ImageDraw.Draw(im)
y = pad
for ln, bb in zip(lines, boxes, strict=True):
draw.text((pad - bb[0], y - bb[1]), ln, font=font, fill=255)
y += line_h + gap
sil = np.array(im)
if dilate or shear_k:
import cv2
if dilate:
sil = cv2.dilate(sil, np.ones((dilate, dilate), np.uint8))
if shear_k:
hh, ww = sil.shape
extra = int(abs(shear_k) * hh)
offset = extra if shear_k < 0 else 0
sil = cv2.warpAffine(
sil,
np.float32([[1, shear_k, offset], [0, 1, 0]]),
(ww + extra, hh),
)
ys, xs = np.where(sil > 0)
if xs.size:
sil = sil[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
return sil
def draw_catlogo(width: int = 335) -> np.ndarray:
"""The cat-logo mark: an outline cat-head (integrated pointy ears, two dot eyes)
+ a bold "AI生成" run, drawn synthetically from the calibrated layout. The outline
form is the parked candidate described in MARKS."""
probe = Image.new("L", (10, 10))
d0 = ImageDraw.Draw(probe)
text = "AI生成"
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)
stroke = max(2, int(th * 0.09))
gap = int(th * 0.35)
def head(s: int) -> Image.Image:
im = Image.new("L", (s, s), 0)
d = ImageDraw.Draw(im)
f = float(s)
pts = [
(0.12 * f, 0.95 * f),
(0.10 * f, 0.45 * f),
(0.12 * f, 0.30 * f),
(0.20 * f, 0.05 * f), # left ear tip
(0.40 * f, 0.24 * f), # left ear valley
(0.60 * f, 0.24 * f), # right ear valley
(0.80 * f, 0.05 * f), # right ear tip
(0.88 * f, 0.30 * f),
(0.90 * f, 0.45 * f),
(0.88 * f, 0.95 * f),
]
d.line([*pts, pts[0]], fill=255, width=stroke, joint="curve")
r = max(1.5, stroke * 0.7)
d.ellipse([0.35 * f - r, 0.60 * f - r, 0.35 * f + r, 0.60 * f + r], fill=255)
d.ellipse([0.65 * f - r, 0.60 * f - r, 0.65 * f + r, 0.60 * f + r], fill=255)
return im
w = cs + gap + tw
h = max(th, cs)
pad = max(2, int(h * 0.12))
im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0)
im.paste(head(cs), (pad, pad + (h - cs) // 2))
ImageDraw.Draw(im).text((pad + cs + gap - bb[0], pad + (h - th) // 2 - bb[1]), text, font=font, fill=255)
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():
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
Image.fromarray(sil).save(output)
print(f"wrote {output} ({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()