diff --git a/docs/module-internals.md b/docs/module-internals.md index 804e6df..a5d743d 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1279,15 +1279,15 @@ belongs in the `_post_gate` hook, never in a `detect` override: an override is invisible to the single-pass path, and the RunningHub and Yuanbao anchor gates were briefly skipped there for exactly that reason. -A mark whose removable footprint differs from what the detector localizes +A mark whose removable rectangular footprint differs from what the detector localizes overrides `_footprint_rect` (which policy) and `_extend_match_box` (how far the box grows), not the whole `footprint_mask`. Baidu extends right to the corner tag and LiblibAI extends left to the triangle logo; both inherit every guard around that arithmetic. -Doubao is the deliberate exception because its captured alpha supplies more -information than a rectangle. Its continuous top-hat response locates the mark, -then `DoubaoEngine.footprint_mask` resizes the alpha to that same winning +Doubao and Kling are deliberate sparse-mask exceptions because their alpha assets +supply more information than a rectangle. Doubao's continuous top-hat response +locates the mark, then `DoubaoEngine.footprint_mask` resizes the alpha to that same winning `match_box` and masks only the glyphs. The canonical 2048-pixel fixture has bright branch texture behind the bottom-edge wordmark; bounding the thresholded response, padding it, and dilating it produced a solid 404-by-134 mask clamped to the bottom @@ -1296,6 +1296,18 @@ with large triangular wedges. The aligned sparse mask keeps both frame edges untouched and still clears the detector. `force` cannot align a missing detection, so it retains the shared geometry-box fallback. +Kling keeps the pixel-derived footprint for release-specific strokes, then unions +it with the synthetic alpha aligned to the detector's winning match box. This is +necessary because its faint canonical overlay produced a blob covering only the +left half of `可灵AI 3.0`, leaving `3.0` after fill. On 60 constructed pairs from 20 +tracked source images crossed with the three detector scales, the hybrid covered +100% of the known stamped footprint and cleared 60/60, versus 29/60 for the blob +alone. In the same paired region the hybrid improved median PSNR by 6.59 dB and +median SSIM by 0.062; both moved in the favorable direction on 52 pairs, tied on +seven, and regressed on one. The union is deliberate: replacing the old footprint +with the synthetic core alone would overfit the font render and could discard a +real variant stroke the pixel mask already found. + Yuanbao uses the polarity-independent `contrast` front end because its standard two-line mark can be light on dark scenes or dark on light scenes. Its detector and footprint both use the same best-match box. The separate one-line overlay diff --git a/scripts/fill_quality.py b/scripts/fill_quality.py index d82ab28..69cda76 100644 --- a/scripts/fill_quality.py +++ b/scripts/fill_quality.py @@ -54,7 +54,7 @@ CORPUS = REPO / ".local-eval" / "originals" OUT = REPO / ".local-eval" / "fill-quality.jsonl" # Text marks: a bundled alpha PNG plus the engine's own corner geometry. -STAMPABLE = ("doubao", "jimeng", "samsung") +STAMPABLE = ("doubao", "jimeng", "kling", "samsung") # Marks with no bundled alpha asset. Both expose a default footprint via # `footprint_mask(force=True)`, so they are stamped by fitting their own alpha source # into that slot: Gemini's alpha is derived from its background captures, the pill's diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index da3542d..97e23e6 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -793,6 +793,44 @@ class TextMarkEngine: return None return self._extend_match_box(det.match_box, loc, image.shape[:2]) + def _aligned_alpha_mask( + self, + image: NDArray[Any], + detection: TextMarkDetection, + alpha: NDArray[Any], + *, + alpha_floor: float, + dilate: int, + ) -> NDArray[Any] | None: + """Place an alpha footprint on the detector's winning template box. + + Continuous front ends locate faint glyphs that the thresholded pixel response + can only partly recover. The winning match already fixes the template's position + and scale, so engines with a trustworthy alpha asset can reuse that alignment + for their removal mask instead of running another search. + """ + if not detection.detected or detection.match_box is None: + return None + source = image_io.to_bgr(image) + loc = self.locate(source) + x0, y0, x1, y1 = detection.match_box # inclusive ROI-local coordinates + x0, y0 = max(0, x0), max(0, y0) + x1, y1 = min(loc.w - 1, x1), min(loc.h - 1, y1) + if x1 < x0 or y1 < y0: + return None + + glyph_w, glyph_h = x1 - x0 + 1, y1 - y0 + 1 + aligned = cv2.resize(alpha, (glyph_w, glyph_h), interpolation=cv2.INTER_LINEAR) + local_mask = np.zeros((loc.h, loc.w), np.uint8) + local_mask[y0 : y0 + glyph_h, x0 : x0 + glyph_w] = (aligned > alpha_floor).astype(np.uint8) * 255 + if dilate > 0: + kernel = np.ones((2 * dilate + 1, 2 * dilate + 1), np.uint8) + local_mask = cv2.dilate(local_mask, kernel) + + mask = np.zeros(source.shape[:2], np.uint8) + mask[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w] = local_mask + return mask + def footprint_mask( self, image: NDArray[Any] | None, diff --git a/src/remove_ai_watermarks/doubao_engine.py b/src/remove_ai_watermarks/doubao_engine.py index a599e9b..25b0eb7 100644 --- a/src/remove_ai_watermarks/doubao_engine.py +++ b/src/remove_ai_watermarks/doubao_engine.py @@ -24,9 +24,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any -import cv2 -import numpy as np - from remove_ai_watermarks import _text_mark_engine, image_io from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine @@ -159,21 +156,11 @@ class DoubaoEngine(TextMarkEngine): if alpha is None: return super().footprint_mask(image, force=False, dilate=dilate, detection=det) - loc = self.locate(image) - x0, y0, x1, y1 = det.match_box # inclusive ROI-local coordinates - x0, y0 = max(0, x0), max(0, y0) - x1, y1 = min(loc.w - 1, x1), min(loc.h - 1, y1) - if x1 < x0 or y1 < y0: - return None - glyph_w, glyph_h = x1 - x0 + 1, y1 - y0 + 1 - aligned = cv2.resize(alpha, (glyph_w, glyph_h), interpolation=cv2.INTER_LINEAR) - - local_mask = np.zeros((loc.h, loc.w), np.uint8) - local_mask[y0 : y0 + glyph_h, x0 : x0 + glyph_w] = (aligned > _FOOTPRINT_ALPHA_FLOOR).astype(np.uint8) * 255 radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) - if radius > 0: - kernel = np.ones((2 * radius + 1, 2 * radius + 1), np.uint8) - local_mask = cv2.dilate(local_mask, kernel) - mask = np.zeros(image.shape[:2], np.uint8) - mask[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w] = local_mask - return mask + return self._aligned_alpha_mask( + image, + det, + alpha, + alpha_floor=_FOOTPRINT_ALPHA_FLOOR, + dilate=radius, + ) diff --git a/src/remove_ai_watermarks/kling_engine.py b/src/remove_ai_watermarks/kling_engine.py index e57c6c5..e35f86a 100644 --- a/src/remove_ai_watermarks/kling_engine.py +++ b/src/remove_ai_watermarks/kling_engine.py @@ -7,13 +7,13 @@ Known variants: an "Omni" suffix release, a latin "KlingAI 3.0" release, and a version-less "可灵AI" -- the silhouette targets the common "可灵AI 3.0" core, so the suffix variants are only caught when the core run is bold enough (measured below). -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 module supplies only Kling AI's tuned -:class:`TextMarkConfig` (``assets/kling_alpha.png`` -- a font-rendered synthetic -silhouette from ``scripts/render_vendor_silhouettes.py``, never cut from an -upload). It also feeds ``identify`` as the medium-confidence ``visible_kling`` -signal via the registry. +Detection matches the bundled glyph silhouette against the corner. Removal unions +the pixel-derived footprint with that silhouette aligned to the winning match box, +then sends the hybrid mask through the shared **localize -> fill** path in +``region_eraser``; it is not reverse-alpha. The asset +(``assets/kling_alpha.png``) is a font-rendered synthetic silhouette from +``scripts/render_vendor_silhouettes.py``, never cut from an upload. The detector also +feeds ``identify`` as the medium-confidence ``visible_kling`` signal via the registry. EVERY tuned number below was measured on the vendor cohort (30 TC260 carriers whose producer USCC 91110108335469089C names the entity, 2026-07-21; harness @@ -51,8 +51,10 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any +import cv2 + from remove_ai_watermarks import _text_mark_engine -from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine if TYPE_CHECKING: from numpy.typing import NDArray @@ -86,6 +88,13 @@ DETECT_NCC_THRESHOLD = 0.35 _ALPHA_WIDTH_FRAC = 0.12 _ALPHA_HEIGHT_FRAC = 0.0287 +# A faint Kling overlay can yield a partial thresholded blob even though the +# continuous detector aligned the complete text core. Keep that pixel-derived mask +# for release-specific strokes, then add the aligned synthetic core with a one-pixel +# halo so every glyph that justified the detection is removed on the first fill. +_FOOTPRINT_ALPHA_FLOOR = 0.05 +_FOOTPRINT_DILATE = 1 + _CONFIG = TextMarkConfig( name="Kling AI", asset_name="kling_alpha.png", @@ -128,3 +137,39 @@ class KlingEngine(TextMarkEngine): def __init__(self) -> None: super().__init__(_CONFIG) + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + """Add the detector-aligned Kling core to the pixel-derived footprint. + + The legacy mask remains in the union because it can cover variant-specific + strokes outside the synthetic core. The aligned alpha closes holes caused by + a faint top-hat blob without replacing that evidence or triggering another + detector pass. Explicit ``force`` has no trustworthy alignment and retains the + shared geometry-box behavior. + """ + if force or image is None or image.size == 0: + return super().footprint_mask(image, force=force, dilate=dilate, detection=detection) + + det = detection if detection is not None else self.detect(image) + legacy = super().footprint_mask(image, force=False, dilate=dilate, detection=det) + alpha = _alpha_template() + if alpha is None: + return legacy + radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) + core = self._aligned_alpha_mask( + image, + det, + alpha, + alpha_floor=_FOOTPRINT_ALPHA_FLOOR, + dilate=radius, + ) + if core is None: + return legacy + return core if legacy is None else cv2.bitwise_or(legacy, core) diff --git a/tests/test_kling_engine.py b/tests/test_kling_engine.py index c98d8f4..30bf8d2 100644 --- a/tests/test_kling_engine.py +++ b/tests/test_kling_engine.py @@ -8,6 +8,8 @@ geometry or relax the measured strict-only gate. from __future__ import annotations +from pathlib import Path + import cv2 import numpy as np import pytest @@ -136,6 +138,20 @@ class TestFootprintMaskAndRemoval: h, w = wm.shape[:2] assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2]) # far region exact + def test_removes_complete_faint_canonical_mark(self): + """A partial top-hat blob must not truncate the detector's full match.""" + path = Path(__file__).parents[1] / "data" / "fixtures" / "visible" / "kling" / "example.png" + image = cv2.imread(str(path), cv2.IMREAD_COLOR) + assert image is not None + + mark = registry.get_mark("kling") + before = mark.detect(image) + assert before.detected + output, region = mark.remove(image, backend="cv2", detection=before) + + assert region is not None + assert not KlingEngine().detect(output).detected + def test_footprint_mask_in_bottom_right(self): wm, _ = _compose(853, 640) mask = KlingEngine().footprint_mask(wm)