Add Tencent Yuanbao visible watermark removal

This commit is contained in:
Victor Kuznetsov
2026-07-25 20:52:15 -07:00
parent 952ce822fd
commit 214c9bb3e7
18 changed files with 431 additions and 81 deletions
+61 -1
View File
@@ -132,7 +132,10 @@ class TextMarkConfig:
# max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
# vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
# p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
detect_frontend: Literal["binary", "tophat", "gray"] = "binary"
# "contrast" correlates against the ABSOLUTE local-luma residual. It is for a mark
# whose renderer switches between light-on-dark and dark-on-light while preserving
# one silhouette (Tencent Yuanbao); a one-polarity white top-hat misses the latter.
detect_frontend: Literal["binary", "tophat", "gray", "contrast"] = "binary"
# Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
template_blur: float = 0.0
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
@@ -398,6 +401,46 @@ class TextMarkEngine:
"""The detection score alone -- the box the removal mask needs is discarded here."""
return self._tophat_best(image, loc)[0]
def _contrast_best(
self, image: NDArray[Any], loc: TextMarkLocation
) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best silhouette match against the absolute local-luma residual.
Unlike the white top-hat, this response is polarity-independent: the same
watermark can be lighter or darker than its local background. Detection and
removal share the returned box, preserving the front-end parity contract.
"""
c = self.config
x, y, bw, bh = loc.bbox
if bh < 16 or bw < 16:
return (0.0, None)
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
luma = roi.mean(axis=2)
sat = roi.max(axis=2) - roi.min(axis=2)
sigma = max(4.0, bh * 0.4)
response = np.abs(luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma))
response *= sat < c.max_saturation
peak = float(response.max())
sil = self._glyph_silhouette()
if peak <= 1e-6 or sil is None:
return (0.0, None)
response = (response / peak * 255).astype(np.uint8)
base = self.scale_base(image)
best_score = 0.0
best_box: tuple[int, int, int, int] | None = None
for scale in c.ladder:
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
gh = max(4, int(c.alpha_height_frac * base * scale))
if gw >= response.shape[1] or gh >= response.shape[0]:
continue
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
result = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED)
_, score, _, top_left = cv2.minMaxLoc(result)
if score > best_score:
tx, ty = int(top_left[0]), int(top_left[1])
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
return (best_score, best_box)
def _gray_best(self, image: NDArray[Any], loc: TextMarkLocation) -> tuple[float, tuple[int, int, int, int] | None]:
"""Best TM_CCOEFF_NORMED of the silhouette against the raw GRAYSCALE ROI, and
the ROI-local box (x0, y0, x1, y1) of that best match.
@@ -584,6 +627,19 @@ class TextMarkEngine:
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug("%s detect (gray): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
return det
if c.detect_frontend == "contrast":
score = self._contrast_best(image, loc)[0]
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
det.confidence = score
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
logger.debug(
"%s detect (contrast): ncc=%.2f thr=%.2f detected=%s",
c.name,
score,
threshold,
det.detected,
)
return det
if coverage >= c.detect_min_coverage:
score = self._template_match_score(box, self.scale_base(image))
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
@@ -643,6 +699,10 @@ class TextMarkEngine:
# leftmost "Runni" of "RunningHub AI生成" unremoved (2026-07-22). Use the
# detector's own best-match box, same as the tophat faint path below.
_, box = self._gray_best(image, loc)
elif self.config.detect_frontend == "contrast" and self.detect(image).detected:
# A dark-on-light Yuanbao mark has no WHITE top-hat blob at all. Bound
# the fill by the polarity-independent detector's own match box.
_, box = self._contrast_best(image, loc)
elif xs.size >= self._MIN_GLYPH_PIXELS:
box = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()))
elif self.config.detect_frontend == "tophat" and self.detect(image).detected:
+3 -3
View File
@@ -128,9 +128,9 @@ def remove_visible(
strip_metadata: bool = True,
write_noop: bool = True,
) -> tuple[NDArray[Any], list[str]]:
"""Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/
Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr,
[labels removed])``.
"""Remove every detected known visible AI mark (Gemini sparkle, the registered
vendor text marks including Tencent Yuanbao, and the Jimeng pill) via
localize -> fill, returning ``(result_bgr, [labels removed])``.
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

+3 -3
View File
@@ -418,7 +418,7 @@ def _remove_visible_auto(
Routes the ``all``/``batch`` visible step through the same registry path the
standalone ``visible`` command uses, so EVERY registered mark is handled (the
Gemini sparkle AND the Doubao/Jimeng/Samsung text marks), not just the sparkle.
Gemini sparkle and all registered vendor text marks), not just the sparkle.
Returns ``(result, label-or-None)``; when no ``in_auto`` mark fires the image is
returned unchanged with ``None``. ``backend`` selects the shared fill; ``sensitivity``
controls how hard a borderline mark is trusted (auto reads metadata provenance)."""
@@ -475,7 +475,7 @@ def _no_visible_mark_exit(source: Path) -> NoReturn:
"""Explain why no visible watermark was removed, then exit non-zero.
The visible registry handles only known visual marks (the Gemini sparkle and
the Doubao/Jimeng/Qwen/Samsung text strips). Most real uploads carry no such mark
the registered vendor text marks). Most real uploads carry no such mark
-- frequently an invisible/metadata watermark instead (e.g. an OpenAI or
Gemini image whose only signal is C2PA + SynthID). Returning the input
unchanged with exit 0 reads as success to a caller and re-serves the
@@ -639,7 +639,7 @@ def _run_visible_auto(
console.print(f" Input: {source.name} ({w}x{h})")
if not removed:
# write_noop=False means nothing was written, so a pre-existing output is intact.
console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).")
console.print(" No registered visible mark detected.")
_no_visible_mark_exit(source)
console.print(f" Removed: {', '.join(removed)}")
size_kb = output.stat().st_size / 1024
+1 -1
View File
@@ -6,7 +6,7 @@ label mandated by China's TC260 standard, a near-white semi-transparent overlay.
Detection matches the bundled glyph silhouette against the corner candidate; removal
is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/doubao_alpha.png`` -- the detection silhouette, rebuilt by
+2 -1
View File
@@ -451,6 +451,7 @@ _VISIBLE_MARK_PLATFORM = {
"jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
"qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
"kling": "Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
"yuanbao": "Tencent Yuanbao (visible 元宝 / AI生成 mark detected)",
"samsung": "Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
"runninghub": "RunningHub (visible RunningHub AI生成 mark detected)",
"baidu": "Baidu (visible 百度 AI生成 mark detected)",
@@ -555,7 +556,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
Args:
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
check_visible: Also run the visible-mark detectors (cv2) -- the Gemini
sparkle and the Doubao/Jimeng text marks from the registry. Set
sparkle and vendor text marks from the registry. Set
False for a pure-metadata, dependency-light scan.
check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via
the optional imwatermark library. No-op when it is not installed.
+1 -1
View File
@@ -6,7 +6,7 @@ class as the Doubao text strip.
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Jimeng's tuned :class:`TextMarkConfig` (bottom-right corner,
``assets/jimeng_alpha.png`` -- the detection silhouette, rebuilt by
+1 -1
View File
@@ -9,7 +9,7 @@ Doubao/Jimeng marks but bottom-left.
Detection matches the bundled glyph silhouette against the corner; removal is the
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph
luma since the mark is faint, ``assets/samsung_alpha.png`` -- the detection silhouette,
+19 -5
View File
@@ -22,6 +22,7 @@ Entries:
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
- ``qwen`` -- Alibaba Tongyi Qianwen "千问AI生成" text strip, bottom-right.
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
- ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end).
@@ -90,6 +91,7 @@ _PRODUCT_OF: dict[str, str] = {
"jimeng_pill": "jimeng", # same product as the Jimeng wordmark
"qwen": "qwen",
"kling": "kling",
"yuanbao": "yuanbao",
"samsung": "samsung",
"runninghub": "runninghub",
"baidu": "baidu",
@@ -371,6 +373,10 @@ def _engine(key: str) -> Any:
from remove_ai_watermarks.kling_engine import KlingEngine
_engines[key] = KlingEngine()
elif key == "yuanbao":
from remove_ai_watermarks.yuanbao_engine import YuanbaoEngine
_engines[key] = YuanbaoEngine()
elif key == "samsung":
from remove_ai_watermarks.samsung_engine import SamsungEngine
@@ -473,8 +479,8 @@ def _gemini_mask(
return _engine("gemini").footprint_mask(image, force=force, region=region)
# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine
# interface, so one parameterized adapter pair drives all of them -- a new
# The registered text-mark engines share the TextMarkEngine interface, so one
# parameterized adapter pair drives all of them -- a new
# text mark is one `_text_mark(...)` row below, not another copy-paste of these
# bodies. Detection matches the glyph silhouette; the mask is the template-free
# glyph-bbox footprint (see TextMarkEngine.footprint_mask).
@@ -534,6 +540,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
_text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"),
_text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"),
_text_mark("kling", "Kling 可灵AI 3.0 text", "bottom-right"),
_text_mark("yuanbao", "Tencent Yuanbao 元宝 / AI生成 mark", "bottom-right"),
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
_text_mark("runninghub", "RunningHub AI生成 text", "top-left"),
_text_mark("baidu", "Baidu 百度 AI生成 text", "bottom-right"),
@@ -615,10 +622,17 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
so real flat-scene pills (and harmless flat false fires) are cleaned while the
damaging textured false fires are left untouched.
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
Doubao detection; a Qwen image likewise (another vendor's bottom-right mark naming
its own product), so a confident Qwen detection suppresses the pill the same way.
Doubao detection; Qwen, Kling, Yuanbao, RunningHub, and Baidu detections likewise
name other products and suppress the pill.
No confirmation at all -> never remove (blocks false fires on non-Jimeng content)."""
if "doubao" in keys or "qwen" in keys or "kling" in keys or "runninghub" in keys or "baidu" in keys:
if (
"doubao" in keys
or "qwen" in keys
or "kling" in keys
or "yuanbao" in keys
or "runninghub" in keys
or "baidu" in keys
):
return False
if "jimeng" in keys:
return True
+138
View File
@@ -0,0 +1,138 @@
"""Tencent Yuanbao visible watermark detector and localizer.
Yuanbao stamps a compact italic two-line mark, ``元宝`` over ``AI生成``, in the
bottom-right corner. The same silhouette is rendered light on dark scenes and
dark on pale scenes, so a one-polarity white top-hat cannot detect it reliably.
This engine uses the shared text-mark pipeline with the ``contrast`` front-end:
normalized absolute local-luma residual followed by silhouette NCC.
The bundled silhouette is synthetic and font-rendered by
``scripts/render_vendor_silhouettes.py``. Removal follows the shared
localize-then-fill path and uses the detector's own match box.
Calibration (2026-07-25) used the metadata-harvested Tencent cohort after byte
deduplication and visual adjudication. The standard two-line variant was detected
on 26 of 28 unique marked carriers (92.9%) at gate 0.38, with 0 fires on 286
hand-labeled clean frames. The separate photographer-overlay variant is not
covered by this silhouette.
"""
# The module-level helpers are imported by tests.
# pyright: reportUnusedFunction=false
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from remove_ai_watermarks import _text_mark_engine
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from pathlib import Path
from numpy.typing import NDArray
WM_WIDTH_FRAC = 0.20
WM_HEIGHT_FRAC = 0.15
MARGIN_RIGHT_FRAC = 0.002
MARGIN_BOTTOM_FRAC = 0.002
MAX_SATURATION = 55
LOGO_MIN_LUMA = 150
TOPHAT_DELTA = 12
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.38
_ALPHA_WIDTH_FRAC = 0.08
_ALPHA_HEIGHT_FRAC = 0.0446
_LADDER = (0.95, 1.0, 1.05)
_CONFIG = TextMarkConfig(
name="Tencent Yuanbao",
asset_name="yuanbao_alpha.png",
corner="br",
margin_floor=4,
width_frac=WM_WIDTH_FRAC,
height_frac=WM_HEIGHT_FRAC,
margin_x_frac=MARGIN_RIGHT_FRAC,
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
max_saturation=MAX_SATURATION,
logo_min_luma=LOGO_MIN_LUMA,
tophat_delta=TOPHAT_DELTA,
morph_open_size=5,
detect_min_coverage=DETECT_MIN_COVERAGE,
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
detect_frontend="contrast",
scale_basis="short",
ladder=_LADDER,
alpha_width_frac=_ALPHA_WIDTH_FRAC,
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
min_gw=32,
provenance_ncc_factor=1.0,
)
YuanbaoDetection = TextMarkDetection
def _alpha_template() -> NDArray[Any] | None:
"""The bundled Yuanbao alpha template (float [0,1]), or None."""
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
def _glyph_silhouette() -> NDArray[Any] | None:
"""Binary two-line Yuanbao silhouette (255 = glyph), or None."""
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
"""TM_CCOEFF_NORMED of the Yuanbao silhouette against ``box_mask``."""
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
class YuanbaoEngine(TextMarkEngine):
"""Detect and localize the bottom-right Yuanbao mark."""
_ANCHOR_MAX_RIGHT = 0.04
_ANCHOR_MAX_BOTTOM = 0.04
def __init__(self) -> None:
super().__init__(_CONFIG)
def detect(self, image: NDArray[Any] | None, *, provenance: bool = False) -> TextMarkDetection:
if image is None or not image.size:
return TextMarkDetection()
detection = super().detect(image, provenance=provenance)
if not detection.detected:
return detection
location = self.locate(image)
_, box = self._contrast_best(image, location)
if box is None:
detection.detected = False
return detection
h, w = image.shape[:2]
base = min(h, w)
right = (w - (location.x + box[2] + 1)) / base
bottom = (h - (location.y + box[3] + 1)) / base
if not (0 <= right <= self._ANCHOR_MAX_RIGHT and 0 <= bottom <= self._ANCHOR_MAX_BOTTOM):
logger.debug(
"Yuanbao detect: score %.3f but match off-anchor (right=%.3f bottom=%.3f); demoting.",
detection.confidence,
right,
bottom,
)
detection.detected = False
return detection
def load_image_bgr(path: str | Path) -> NDArray[Any]:
"""Read an image as a BGR ndarray."""
from remove_ai_watermarks import image_io
image = image_io.imread(path)
if image is None:
raise FileNotFoundError(f"Failed to read image: {path}")
return image