mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Register the Kling 可灵AI 3.0 visible text mark; park Yuanbao and cat-logo (measured)
Kling (USCC cohort 91110108335469089C, n=30): kling_engine.py, gate 0.35 (clean p99 0.304 / max 0.320), strict-only, unimodal 0.12/short on the shared ladder, fitted locate box, no rival margin (crossfire 1/400 doubao below gate, 0 jimeng, 0 clean), parity 9/9 detect->fill->re-detect. Suppresses the jimeng pill like doubao/qwen. identify gains visible_kling. Yuanbao: measured negative -- the two-line italic block does not separate from clean corners on either front-end at any render/box/font setting; the fitted recipe stays in render_vendor_silhouettes.py MARK_OPTS. cat-logo: cohort has only 2 unique carriers, parked on evidence; the draw_catlogo silhouette already separates (0.50 vs clean max 0.333), so registration is a gate pick once more uniques arrive. vendor_mark_calibrate: --fit-geometry takes locate-box overrides (two-line marks were clipped by the inherited box) and the aspect sweep reaches 0.62.
This commit is contained in:
@@ -6,8 +6,15 @@ tracked asset (see the repo CLAUDE.md data-safety rule). Seeing real samples to
|
||||
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
|
||||
qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text
|
||||
xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text
|
||||
yuanbao "元宝\nAI生成" -- Tencent Yuanbao, bottom-right, two-line italic block
|
||||
(MEASURED NEGATIVE 2026-07-21, parked: the slanted two-line template does
|
||||
not separate the cohort from clean corners on either front-end; the recipe
|
||||
+ MARK_OPTS stay as the starting point if a structural/learned lever is
|
||||
built -- full record in docs/verification-plan.md)
|
||||
kling "可灵AI 3.0" -- Kuaishou Kling, bottom-right, spiral logo + text
|
||||
(REGISTERED 2026-07-21, kling_engine.py)
|
||||
|
||||
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
|
||||
@@ -91,6 +98,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
@@ -103,36 +111,137 @@ _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 (measured on the 2026-07-21 cohort sheet + real tophat
|
||||
# responses); 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" cohort (USCC 91110108562144110X) stamps an outline cat-head +
|
||||
# bold "AI生成", bottom-right. PARKED 2026-07-21: the cohort is 19 copies of
|
||||
# only 2 unique carriers -- nothing to calibrate recall against (the xinghui
|
||||
# rule). The probe is ready: this silhouette scores 0.50 on the mark vs 0.333
|
||||
# max on a diverse clean arm, so registration is a gate pick (0.42) the moment
|
||||
# more unique carriers arrive.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
}
|
||||
|
||||
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
|
||||
MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
# Fitted against real tophat responses on the Yuanbao cohort (2026-07-21): a
|
||||
# right-aligned, gapped, unslanted render plateaued at ~0.34 NCC; left-align +
|
||||
# tight gap + stroke dilation + shear -0.75 reaches 0.65-0.70 on the same frames,
|
||||
# at/above the real-vs-real ceiling (~0.6).
|
||||
"yuanbao_alpha.png": {"gap_frac": 0.05, "dilate": 2, "shear": -0.75},
|
||||
}
|
||||
|
||||
|
||||
def render(text: str, width: int = 335) -> np.ndarray:
|
||||
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.
|
||||
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))
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
lines = text.split("\n")
|
||||
size = 8
|
||||
while size < 200: # grow until the run fills the target width
|
||||
while size < 200: # grow until the LONGEST line fills the target width
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.98:
|
||||
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
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
|
||||
sil = cv2.warpAffine(sil, np.float32([[1, shear_k, 0], [0, 1, 0]]), (ww + int(abs(shear_k) * hh), hh))
|
||||
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 measured layout (cat ~1.08x
|
||||
the glyph height, stroke ~9%, gap ~35%). Proportions were iterated against a real
|
||||
tophat response (2026-07-21): a solid filled head scored 0.35, this outline form
|
||||
0.50 -- the parked probe, see 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)
|
||||
w, h = bb[2] - bb[0], bb[3] - bb[1]
|
||||
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)
|
||||
ImageDraw.Draw(im).text((pad - bb[0], pad - bb[1]), text, font=font, fill=255)
|
||||
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 = render(text)
|
||||
sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name))
|
||||
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:
|
||||
|
||||
@@ -229,7 +229,7 @@ _FIT_SCALES = tuple(round(0.4 * (1.03**i), 4) for i in range(80)) # 0.40 .. ~4.
|
||||
_SHIPPED_LADDER = (0.8, 1.0, 1.25)
|
||||
|
||||
|
||||
def _fit_one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
def _fit_one(args: tuple[str, str, dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Best match over the WIDE ladder, reported as a mark width in pixels.
|
||||
|
||||
Also measures the template ASPECT at the winning width: the mark's true height is
|
||||
@@ -238,14 +238,14 @@ def _fit_one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
(that inflated the clean p99 from 0.30 to 0.58 on the 2026-07-18 attempt) and not
|
||||
inherited from doubao.
|
||||
"""
|
||||
path_str, asset = args
|
||||
path_str, asset, overrides = args
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
cfg = build_config(asset, "fit", "width")
|
||||
cfg = build_config(asset, "fit", "width", overrides)
|
||||
eng = TextMarkEngine(cfg)
|
||||
img = imread(path_str)
|
||||
if img is None:
|
||||
@@ -269,11 +269,15 @@ def _fit_one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
if v > best:
|
||||
best, best_gw, best_tl = v, gw, (int(tl[0]), int(tl[1]))
|
||||
# Aspect fit at the winning width: sweep gh/gw and keep the argmax. Range covers
|
||||
# everything between samsung's 0.12 and jimeng's 0.29 house styles, plus slack.
|
||||
# everything between samsung's 0.12 and jimeng's 0.29 house styles, plus the
|
||||
# two-line stacked marks (Yuanbao ~0.45), plus slack.
|
||||
best_aspect = 0.0
|
||||
if best_gw > 0:
|
||||
best_gh_score = -1.0
|
||||
for ratio in np.arange(0.12, 0.42, 0.01):
|
||||
# Upper bound raised 0.42 -> 0.62 for two-line marks (Yuanbao's stacked block
|
||||
# has silhouette aspect ~0.45; the old range's 0.12 floor was its own trap --
|
||||
# the fit "won" by squashing the template to a one-line strip).
|
||||
for ratio in np.arange(0.12, 0.62, 0.01):
|
||||
gh = max(4, int(best_gw * float(ratio)))
|
||||
if gh >= resp.shape[0]:
|
||||
continue
|
||||
@@ -299,17 +303,27 @@ def _fit_one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
}
|
||||
|
||||
|
||||
def fit_geometry(paths: list[str], asset: str, workers: int, floor: float = 0.50, paths_name: str = "cohort") -> None:
|
||||
def fit_geometry(
|
||||
paths: list[str],
|
||||
asset: str,
|
||||
workers: int,
|
||||
floor: float = 0.50,
|
||||
paths_name: str = "cohort",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Which basis and fraction does this vendor's mark actually scale with?
|
||||
|
||||
Only frames matching above ``floor`` are used: below it the winning size is the
|
||||
ladder's best fit to background texture, not a measurement of the mark.
|
||||
``overrides`` adjusts the LOCATE box for the fit (a two-line mark like Yuanbao's
|
||||
is taller than Doubao's inherited box -- scoring it in the inherited box clips
|
||||
the template to zero overlap).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
for f in as_completed([ex.submit(_fit_one, (p, asset)) for p in paths]):
|
||||
for f in as_completed([ex.submit(_fit_one, (p, asset, overrides or {})) for p in paths]):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the fit
|
||||
@@ -565,7 +579,7 @@ def main() -> None:
|
||||
pos_paths, neg_paths = load_sets(a.cohort)
|
||||
if a.fit_geometry:
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates")
|
||||
fit_geometry(pos_paths, a.asset, a.workers, paths_name=name)
|
||||
fit_geometry(pos_paths, a.asset, a.workers, paths_name=name, overrides=overrides)
|
||||
return
|
||||
|
||||
if a.crossfire:
|
||||
|
||||
Reference in New Issue
Block a user