mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
201 lines
8.0 KiB
Python
201 lines
8.0 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 Any
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
_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": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
|
# 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": "百度",
|
|
}
|
|
_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 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")
|
|
size = 8
|
|
while size < 200: # grow until the LONGEST line fills the target width
|
|
f = ImageFont.truetype(font_path, size, index=font_index)
|
|
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
|
break
|
|
size += 1
|
|
font = ImageFont.truetype(font_path, size, 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生成"
|
|
size = 8
|
|
while size < 200:
|
|
f = ImageFont.truetype(_FONT, size)
|
|
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60:
|
|
break
|
|
size += 1
|
|
font = ImageFont.truetype(_FONT, size)
|
|
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 main() -> None:
|
|
try:
|
|
for name, text in MARKS.items():
|
|
sil = draw_catlogo() if text == "CATLOGO" 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()
|