mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-31 17:50:35 +02:00
307 lines
12 KiB
Python
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()
|