mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 14:38:35 +02:00
Register RunningHub, Baidu, and LibLibAI visible marks; park Qingyan and MiniMax (measured)
New engines, each calibrated on its TC260 USCC cohort and validated by a full-corpus sweep (42009 files): - runninghub: top-left corner (new corner="tl"), faint mid-gray text via the new raw-grayscale "gray" detection front-end, anchor-position gate - baidu: text-run-only template (pill is a bright-blob magnet), load-bearing Doubao+Qwen rival margins, corner-extended footprint for the white tag - liblib: bottom-center (new corner="bc"), Arial silhouette (font is the discriminative lever against latin UI text), logo-extended footprint Qingyan parked (no clean-arm separation at any render/box), MiniMax/Hailuo parked (1 visible frame, the xinghui rule); silhouettes kept as starting points.
This commit is contained in:
@@ -97,7 +97,9 @@ class TextMarkConfig:
|
||||
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal["br", "bl"] # bottom-right (Doubao/Jimeng) or bottom-left (Samsung)
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "bc"
|
||||
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
@@ -125,7 +127,12 @@ class TextMarkConfig:
|
||||
# correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
|
||||
# top-hat response against a soft template and never binarizes. See
|
||||
# TextMarkEngine.tophat_response for the measurement that motivated the split.
|
||||
detect_frontend: Literal["binary", "tophat"] = "binary"
|
||||
# "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
|
||||
# for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
|
||||
# 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"
|
||||
# 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,
|
||||
@@ -391,6 +398,42 @@ class TextMarkEngine:
|
||||
"""The detection score alone -- the box the removal mask needs is discarded here."""
|
||||
return self._tophat_best(image, loc)[0]
|
||||
|
||||
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.
|
||||
|
||||
Mirrors :meth:`_tophat_best` (same ladder sweep, same one-method contract so
|
||||
detection and the removal mask can never drift), but skips the top-hat
|
||||
entirely: the RunningHub mark is a faint mid-gray text the top-hat's
|
||||
background subtraction suppresses to clean-arm levels, while raw gray NCC
|
||||
separates (see ``TextMarkConfig.detect_frontend``). Contrast-DEPENDENT by
|
||||
construction, so the gate must be picked against the clean arm, which is
|
||||
what ``scripts/vendor_mark_calibrate.py`` does.
|
||||
"""
|
||||
c = self.config
|
||||
x, y, bw, bh = loc.bbox
|
||||
if bh < 16 or bw < 16:
|
||||
return (0.0, None)
|
||||
roi = cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
|
||||
sil = self._glyph_silhouette()
|
||||
if sil is None:
|
||||
return (0.0, None)
|
||||
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 >= roi.shape[1] or gh >= roi.shape[0]:
|
||||
continue
|
||||
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
result = cv2.matchTemplate(roi, tmpl, 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 scale_base(self, image: NDArray[Any]) -> int:
|
||||
"""The image dimension this mark's geometry scales with.
|
||||
|
||||
@@ -433,8 +476,14 @@ class TextMarkEngine:
|
||||
wm_h = max(16, int(base * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
x = max(0, w - margin_x - wm_w) if c.corner == "br" else min(margin_x, max(0, w - wm_w))
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
if c.corner == "br":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
@@ -526,6 +575,15 @@ class TextMarkEngine:
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (tophat): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if c.detect_frontend == "gray":
|
||||
# Same no-coverage-gate reasoning as tophat: the gray front-end never
|
||||
# binarizes, so a blob-area heuristic does not apply to it either.
|
||||
score = self._gray_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 (gray): 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)
|
||||
@@ -578,7 +636,14 @@ class TextMarkEngine:
|
||||
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
|
||||
ys, xs = np.where(glyph > 0)
|
||||
box: tuple[int, int, int, int] | None = None
|
||||
if xs.size >= self._MIN_GLYPH_PIXELS:
|
||||
if self.config.detect_frontend == "gray" and self.detect(image).detected:
|
||||
# The gray front-end exists for marks the top-hat under-segments, so the
|
||||
# binary blob is NOT authoritative here: trusting it first bounded the
|
||||
# fill by a PARTIAL blob (the faint head glyphs dropped out) and left the
|
||||
# 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 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:
|
||||
# A mark found only by the CONTINUOUS front-end has no binary glyph blob to
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,203 @@
|
||||
"""Baidu visible watermark detector/localizer.
|
||||
|
||||
Baidu stamps its generations with a white bold "百度" text run plus a separate
|
||||
white rounded tag carrying dark "AI生成", bottom-right -- the China TC260
|
||||
explicit AIGC label. Detection keys on the **百度 text run only**: a
|
||||
two-component template (text + pill tag) was measured and REJECTED -- the solid
|
||||
white pill is a bright-blob magnet and both front-ends scored the clean arm at
|
||||
cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5,
|
||||
2026-07-22). The text-only silhouette separates cleanly (below). The white tag
|
||||
is still removed with the mark: the fill blob covers both bright components in
|
||||
the corner box.
|
||||
|
||||
Removal is the shared **localize -> fill** (:meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only Baidu's tuned
|
||||
:class:`TextMarkConfig` (``assets/baidu_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
Measured on the vendor cohort (16 TC260 carriers whose producer USCC
|
||||
91110000802100433B names Baidu, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The 百度 text run is 0.090 of the SHORT side wide (measured on 720/768/
|
||||
1024-px frames), with its right edge ~0.099 of short off the right edge
|
||||
(the pill tag sits between the text and the corner), bottom margin
|
||||
~0.006; the locate box below covers the whole mark (text + tag).
|
||||
* Gate 0.43 (tophat front-end): on 278 hand-labelled clean frames
|
||||
(cohort-contamination-guarded) the max is 0.352 / p99 0.314, and the
|
||||
visibly-marked cohort frames score 0.386-0.65. Picked over the clean-arm
|
||||
0.37 after a full-corpus check on the 741-frame blind-labelled eval set
|
||||
surfaced 13 cross-fires at 0.38-0.43 (12 Qwen marks + one 抖音 mark) --
|
||||
see DETECT_NCC_THRESHOLD below. At 0.43 the cohort keeps 7 detections
|
||||
(0.61-0.65) and the whole 741 set fires only on the true Baidu frame.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the cohort is small (16) and
|
||||
the sub-gate band is unmeasured, so no provenance relaxation exists.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (measured basis). The
|
||||
# box covers the text run AND the pill tag to its right (tag right edge ~0.002
|
||||
# off the frame edge, text run left edge ~0.19 off).
|
||||
WM_WIDTH_FRAC = 0.25
|
||||
WM_HEIGHT_FRAC = 0.07
|
||||
MARGIN_RIGHT_FRAC = 0.002
|
||||
MARGIN_BOTTOM_FRAC = 0.002
|
||||
|
||||
# Glyph appearance: white bold text on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 278 hand-labelled clean frames
|
||||
# (clean p99 0.314 / max 0.352), THEN raised 0.37 -> 0.43 after a full-corpus
|
||||
# check: on the 741-frame blind-labelled eval set the 0.37 gate fired 14 times
|
||||
# outside the cohort, and only ONE was the vendor -- 12 were 千问AI生成 (Qwen)
|
||||
# marks (the 百/千 first glyphs are near-identical after binarization) and one
|
||||
# was a 抖音 AI创作 mark at 0.425. The Qwen fires are handled by the rival
|
||||
# margin (Qwen scores 0.58-0.76 there, beating Baidu by 0.17-0.35), but the
|
||||
# 抖音 one named no registered rival, so the gate moved above it. Cost: the
|
||||
# cohort's low trio at 0.386 (3 genuine marks) -- precision over recall on a
|
||||
# small cohort. Raised again 0.43 -> 0.48 after the full-corpus sweep
|
||||
# (2026-07-22): outside-cohort true Baidu carriers score 0.50-0.66 while the
|
||||
# false fires (大众点评 UI, a math blackboard, an 80s banner, a checkerboard)
|
||||
# top out at 0.47. Remaining cohort detections: 7 at 0.61-0.65, plus the 6
|
||||
# metadata-stripped true carriers the cohort cannot see.
|
||||
DETECT_NCC_THRESHOLD = 0.48
|
||||
|
||||
# Detection-silhouette geometry (fraction of the short side): the 百度 text run
|
||||
# only, measured 0.090 wide with aspect 0.51.
|
||||
_ALPHA_WIDTH_FRAC = 0.090
|
||||
_ALPHA_HEIGHT_FRAC = 0.046
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine), so the
|
||||
# nominal sits exactly on the measured 0.090 with +-5% rungs.
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Baidu",
|
||||
asset_name="baidu_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="tophat",
|
||||
scale_basis="short",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# Load-bearing rival margins (crossfire measured 2026-07-22): the 百度 and
|
||||
# 豆包 silhouettes share their second glyph and a similar first, and 百度 vs
|
||||
# 千问 are near-identical after binarization -- at the 0.37 gate this
|
||||
# template fires on 45.8% of 400 Doubao-marked frames AND on Qwen-marked
|
||||
# frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks,
|
||||
# Qwen's by 0.17-0.35 on Qwen marks, so the 0.10 margin suppresses all of
|
||||
# that crossfire at zero genuine-Baidu cost (cohort fire+m == fire).
|
||||
rivals=("doubao_alpha.png", "qwen_alpha.png"),
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
BaiduDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Baidu alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "百度" silhouette (255 = glyph) from the alpha map, 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 Baidu glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class BaiduEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Baidu "百度 AI生成" mark (bottom-right; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the WHOLE mark (text run + the pill tag to its right).
|
||||
|
||||
The base class's blob-bbox footprint UNDERCOVERS this mark: the white tag's
|
||||
flat interior gives no top-hat response (a top-hat answers edges, not flats),
|
||||
so the blob ends at the text run and the fill leaves the tag's right half as
|
||||
a ghost (measured 2026-07-22 on the 768x1024 cohort frame: blob bbox x
|
||||
632..746 vs the tag ending ~758). The layout is measured and fixed -- the
|
||||
text run is at the left of the locate box, the tag runs to the corner -- so
|
||||
the footprint is the detector's match box extended RIGHT to the corner.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, _gx1, gy1 = box
|
||||
pad = max(4, int(0.15 * bh))
|
||||
rx1 = max(0, bx + gx0 - pad)
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + bw) # the tag runs to the corner end of the box
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -449,6 +449,9 @@ _VISIBLE_MARK_PLATFORM = {
|
||||
"qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
|
||||
"kling": "Kuaishou Kling (visible 可灵AI 3.0 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)",
|
||||
"liblib": "LibLibAI (visible LibLibAI mark detected)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""LibLibAI visible watermark detector/localizer.
|
||||
|
||||
LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a
|
||||
white triangle logo + "LibLibAI" latin wordmark at **bottom-center** (not a
|
||||
corner -- the locate box is horizontally centered). Detection matches the
|
||||
bundled font-rendered "LibLibAI" silhouette (the triangle logo is NOT rendered
|
||||
-- logos vary, the wordmark discriminates); removal is the shared **localize ->
|
||||
fill** (the glyph blob covers logo + wordmark, both bright).
|
||||
|
||||
This module supplies only LibLibAI's tuned :class:`TextMarkConfig`
|
||||
(``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``,
|
||||
never cut from an upload).
|
||||
|
||||
Measured on the vendor cohort (15 TC260 carriers, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The wordmark is ~0.10 of the frame WIDTH wide, centered horizontally, its
|
||||
baseline ~0.94-0.95 of the height; consistent across 768..2240-px frames.
|
||||
* The silhouette font is Arial, NOT the STHeiti the CJK marks use: the real
|
||||
wordmark is a grotesque, and measured across 7 candidate fonts Arial lifts
|
||||
the cohort positives from 0.31-0.47 to 0.42-0.73 while the full-corpus
|
||||
false arm (latin UI text) drops to max 0.398. A 200x200 icon false-fired
|
||||
at 0.444, so a per-mark size floor (``_MIN_SHORT_SIDE``) backs the gate.
|
||||
* Gate 0.42 (tophat front-end): false arm max 0.398, cohort 0.43-0.59.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): small cohort, the relaxed band
|
||||
is unmeasured.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (measured basis). The box is
|
||||
# horizontally centered (corner="bc") and covers the logo + wordmark with NCC
|
||||
# slack around the measured 0.10 width.
|
||||
WM_WIDTH_FRAC = 0.20
|
||||
WM_HEIGHT_FRAC = 0.09
|
||||
MARGIN_BOTTOM_FRAC = 0.02
|
||||
|
||||
# Glyph appearance: white wordmark on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# (clean p99 0.315 / max 0.367) and re-measured after the font fix: the wordmark
|
||||
# is set in an Arial-class grotesque, and the Arial silhouette lifts the cohort
|
||||
# positives to 0.43-0.59 while the full-corpus false arm (latin UI text bands,
|
||||
# website screenshots) drops to max 0.398 -- generic latin text matches the
|
||||
# wrong font less, which is exactly where the discrimination comes from. Gate
|
||||
# 0.42 keeps all 8 marked cohort frames with a 0.022 margin over the false arm.
|
||||
DETECT_NCC_THRESHOLD = 0.42
|
||||
|
||||
# Detection-silhouette geometry (fraction of the frame width): the wordmark,
|
||||
# measured 0.10 wide with aspect 0.26.
|
||||
_ALPHA_WIDTH_FRAC = 0.10
|
||||
_ALPHA_HEIGHT_FRAC = 0.026
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine).
|
||||
_LADDER = (0.9, 1.0, 1.1)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="LibLibAI",
|
||||
asset_name="liblib_alpha.png",
|
||||
corner="bc",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=0.0, # unused for corner="bc" (horizontally centered)
|
||||
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="tophat",
|
||||
scale_basis="width",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
LibLibDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled LibLibAI alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "LibLibAI" silhouette (255 = glyph) from the alpha map, 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 LibLibAI glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class LibLibEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill)."""
|
||||
|
||||
# Per-mark size floor: the wordmark template is 0.10 of the frame width, so
|
||||
# below ~480px short side it degrades under ~48px -- the one full-corpus
|
||||
# false fire with the final Arial template was a 200x200 icon (0.444, above
|
||||
# the gate, on a 20px template; measured 2026-07-22). The smallest true
|
||||
# carrier in the cohort is 768px.
|
||||
_MIN_SHORT_SIDE = 480
|
||||
|
||||
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 or min(image.shape[:2]) < self._MIN_SHORT_SIDE:
|
||||
return TextMarkDetection()
|
||||
return super().detect(image, provenance=provenance)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the logo + wordmark, bounded by the detector's match box.
|
||||
|
||||
The base class's blob-bbox footprint is wrong in both directions here: the
|
||||
blob bleeds UP into bright background structure (on the 768x1024 cohort
|
||||
frame it reached y 931 and the fill ate the shirt's own print) and it does
|
||||
not own the triangle logo anyway. The match box bounds the wordmark exactly
|
||||
(that is what the NCC localized); the logo sits its own height to the LEFT
|
||||
of the text (measured on the cohort zoom: logo ~1.0x the glyph height, gap
|
||||
~0.3x), so the footprint is the match box extended left by ~1.3 heights.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, gx1, gy1 = box
|
||||
gh = gy1 - gy0 + 1
|
||||
pad = max(3, int(0.25 * gh))
|
||||
rx1 = max(0, bx + gx0 - int(1.3 * gh)) # the triangle logo, left of the text
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + gx1 + 1 + pad)
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -0,0 +1,180 @@
|
||||
"""RunningHub visible watermark detector/localizer.
|
||||
|
||||
RunningHub (a hosted ComfyUI platform, USCC 91340100MAEB4N8H76) stamps its
|
||||
generations with a faint light-gray "RunningHub AI生成" text mark in the
|
||||
**top-left** corner -- the China TC260 explicit AIGC label, but placed top-left
|
||||
(unlike the GB 45438-2025 house style bottom-right of Doubao/Qwen/Kling) and
|
||||
rendered in a mid-gray that the white top-hat front-end suppresses to clean-arm
|
||||
levels.
|
||||
|
||||
Detection therefore uses the ``gray`` front-end (raw-grayscale silhouette NCC,
|
||||
see ``TextMarkConfig.detect_frontend``); removal is the shared **localize ->
|
||||
fill** (the detector's best-match box feeds :meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only RunningHub's tuned
|
||||
:class:`TextMarkConfig` (``assets/runninghub_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
EVERY tuned number below was measured on the vendor cohort (73 TC260 carriers
|
||||
whose producer USCC names the entity, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* Only ~4 of the 73 cohort frames carry a visible mark (the rest are
|
||||
metadata-only TC260 carriers -- the platform labels frames it does not
|
||||
stamp), so recall of visible marks is 4/4 but the cohort fire rate is not
|
||||
a recall estimate. Positions/geometry are consistent across the positives.
|
||||
* The mark's width is ~0.32 of the frame WIDTH (0.319 measured on 832/1080/
|
||||
1536-wide frames) at ~0.008/0.006 x/y margins; the locate box below covers
|
||||
it with NCC slack.
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.128) at the
|
||||
measured width (0.27 * 1.25 rung ~= 0.3375 >= 0.32), per the standing rule
|
||||
that it is measured, not inherited.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is
|
||||
contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is
|
||||
unmeasured beyond the clean arm, so no provenance relaxation exists.
|
||||
* Gate 0.34: on 283 hand-labelled clean frames (cohort-contamination-guarded)
|
||||
corner-anchored gray NCC p99 is 0.264 / max 0.304, while the 4 positives
|
||||
score 0.38-0.54. 0.34 sits above the clean max with a small margin; the
|
||||
positives are few, so the margin is deliberately thin on the recall side.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# 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
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (the measured basis: every
|
||||
# positive is portrait, where width == short side). The mark hugs the top-left
|
||||
# corner (~0.008 of width off the left edge, ~0.006 of height off the top).
|
||||
WM_WIDTH_FRAC = 0.45
|
||||
WM_HEIGHT_FRAC = 0.10
|
||||
MARGIN_LEFT_FRAC = 0.002
|
||||
MARGIN_TOP_FRAC = 0.002
|
||||
|
||||
# Glyph appearance fields are unused by the gray front-end (it never binarizes)
|
||||
# and kept only for config parity with the other text marks.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the gray front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labelled clean frames:
|
||||
# corner-anchored gray NCC, clean p99 0.264 / max 0.304; positives 0.38-0.54.
|
||||
DETECT_NCC_THRESHOLD = 0.34
|
||||
|
||||
# Detection-silhouette geometry (fraction of the image width), measured on the
|
||||
# positives: mark width is ~0.320 of width on all three frame sizes (266px at 832,
|
||||
# 345px at 1080, 491px at 1536), and the NCC is razor-sharp in size (0.537 on-size,
|
||||
# 0.223 at +5.6% -- the same comb behaviour Qwen measured), so the nominal sits
|
||||
# exactly on the measured size with a TIGHT ladder around it, not the shared 3 rungs
|
||||
# (whose nearest rung landed 5.6% off and collapsed the match to 0.22).
|
||||
_ALPHA_WIDTH_FRAC = 0.32
|
||||
_ALPHA_HEIGHT_FRAC = 0.04
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="RunningHub",
|
||||
asset_name="runninghub_alpha.png",
|
||||
corner="tl",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_LEFT_FRAC,
|
||||
margin_bottom_frac=MARGIN_TOP_FRAC, # top margin for corner="tl"
|
||||
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="gray",
|
||||
scale_basis="width", # measured: mark width tracks the frame width (0.32)
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: contrast-dependent gray NCC; the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
RunningHubDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled RunningHub alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "RunningHub AI生成" silhouette (255 = glyph) from the alpha map, 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 RunningHub glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class RunningHubEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible RunningHub "RunningHub AI生成" mark (top-left; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
# Anchor window for the match position, as a fraction of the FRAME: the true
|
||||
# mark hugs the corner (measured x 0.008-0.014, y 0.005-0.007 of the frame on
|
||||
# every cohort positive), while the full-corpus false fires (hair, shelves,
|
||||
# window frames, CJK banners -- 37 of 42009 outside-cohort frames at the 0.34
|
||||
# gate, 2026-07-22 sweep) match off-anchor at x 0.013-0.150 / y 0.009-0.045.
|
||||
# No NCC gate separates them (false max 0.384 vs two positives at 0.381), but
|
||||
# position does: every false fire sits outside this window, every positive
|
||||
# inside. Contrast-dependent raw-gray NCC keys on "some text-like structure
|
||||
# anywhere in the box"; the anchor is what makes it about THIS mark.
|
||||
_ANCHOR_MAX_X = 0.025
|
||||
_ANCHOR_MAX_Y = 0.015
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
det = super().detect(image, provenance=provenance)
|
||||
if not det.detected:
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
_, box = self._gray_best(image, loc)
|
||||
if box is None:
|
||||
det.detected = False
|
||||
return det
|
||||
h, w = image.shape[:2]
|
||||
ax = (loc.x + box[0]) / w
|
||||
ay = (loc.y + box[1]) / h
|
||||
if ax > self._ANCHOR_MAX_X or ay > self._ANCHOR_MAX_Y:
|
||||
logger.debug(
|
||||
"RunningHub detect: score %.3f but match off-anchor (x=%.3f y=%.3f); demoting.",
|
||||
det.confidence,
|
||||
ax,
|
||||
ay,
|
||||
)
|
||||
det.detected = False
|
||||
return det
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -24,6 +24,9 @@ Entries:
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, 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).
|
||||
- ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right.
|
||||
- ``liblib`` -- LibLibAI "LibLibAI" wordmark, bottom-center.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,6 +91,9 @@ _PRODUCT_OF: dict[str, str] = {
|
||||
"qwen": "qwen",
|
||||
"kling": "kling",
|
||||
"samsung": "samsung",
|
||||
"runninghub": "runninghub",
|
||||
"baidu": "baidu",
|
||||
"liblib": "liblib",
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +379,18 @@ def _engine(key: str) -> Any:
|
||||
from remove_ai_watermarks.pill_engine import PillEngine
|
||||
|
||||
_engines[key] = PillEngine()
|
||||
elif key == "runninghub":
|
||||
from remove_ai_watermarks.runninghub_engine import RunningHubEngine
|
||||
|
||||
_engines[key] = RunningHubEngine()
|
||||
elif key == "baidu":
|
||||
from remove_ai_watermarks.baidu_engine import BaiduEngine
|
||||
|
||||
_engines[key] = BaiduEngine()
|
||||
elif key == "liblib":
|
||||
from remove_ai_watermarks.liblib_engine import LibLibEngine
|
||||
|
||||
_engines[key] = LibLibEngine()
|
||||
else: # pragma: no cover - guarded by the registry keys
|
||||
raise KeyError(key)
|
||||
return _engines[key]
|
||||
@@ -517,6 +535,9 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
_text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"),
|
||||
_text_mark("kling", "Kling 可灵AI 3.0 text", "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"),
|
||||
_text_mark("liblib", "LibLibAI wordmark", "bottom-center"),
|
||||
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
|
||||
)
|
||||
|
||||
@@ -597,7 +618,7 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
|
||||
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.
|
||||
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:
|
||||
if "doubao" in keys or "qwen" in keys or "kling" in keys or "runninghub" in keys or "baidu" in keys:
|
||||
return False
|
||||
if "jimeng" in keys:
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user