mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
Add Tencent Yuanbao visible watermark removal
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user