mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
218 lines
10 KiB
Python
218 lines
10 KiB
Python
"""Kling AI (可灵AI, Kuaishou) visible watermark detector/localizer.
|
|
|
|
Kling AI stamps its generations with a thin, light-gray text strip in the
|
|
bottom-right corner, preceded by the vendor's spiral logo (not part of the
|
|
detection silhouette -- logos vary between releases, the text run is what
|
|
discriminates). Separate silhouettes cover the older "可灵AI 3.0" release and
|
|
the current IMAGE 3.0 "KlingAI 3.0" release. An "Omni" suffix and a version-less
|
|
"可灵AI" remain outside the calibrated pair.
|
|
|
|
Detection matches the bundled glyph silhouette against the corner. Removal unions
|
|
the pixel-derived footprint with the known silhouettes 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. This module supplies two tuned
|
|
:class:`TextMarkConfig` instances (``assets/kling_alpha.png`` and
|
|
``assets/kling_latin_alpha.png`` -- font-rendered synthetic silhouettes 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
|
|
``scripts/vendor_mark_calibrate.py``), NOT inherited from Doubao:
|
|
|
|
* The mark scales with the SHORT side at ~0.12 of it (mark_w/short measured
|
|
0.118-0.122 across portrait AND landscape carriers -- unimodal, so the shipped
|
|
3-rung ladder covers it) and sits ~0.03 off the right/bottom edges; the locate
|
|
box fractions below are fitted from the measured absolute mark rects.
|
|
* ``alpha_height_frac`` comes from the silhouette aspect (0.239) at the fitted
|
|
width, matching the aspect the fit converged on (0.25).
|
|
* The CJK gate is 0.35, one step above the clean arm's max: on the cohort-vs-clean run
|
|
(cohort-contamination-guarded, 286 hand-labeled clean frames) the clean arm
|
|
scored p99 0.304 / max 0.320, and every cohort frame >= 0.35 carries a visible
|
|
可灵AI 3.0 mark (9 of ~19 eyeballed visible marks fire = ~47% recall of visible
|
|
marks; the misses are the faint "Omni"-suffix release and the version-less
|
|
"可灵AI", which score 0.17-0.25 and cannot be reached without engulfing the
|
|
clean arm). The separately measured Latin IMAGE 3.0 variant is documented
|
|
beside ``_LATIN_CONFIG`` below.
|
|
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the sub-gate band holds real Kling
|
|
variants AND the clean arm's top (clean p90 0.220 vs variant marks at 0.17-0.25
|
|
-- they overlap), so a provenance-relaxed arm cannot separate them. No
|
|
provenance relaxation exists for this mark.
|
|
* No rival margin: at the shipped gate the template fires on 1 of 400
|
|
Doubao-marked frames (0.2%, a 豆包 frame sitting INSIDE the Kling cohort, still
|
|
below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labeled clean
|
|
frames, and a 0.10 rival margin costs zero genuine Kling detections -- so it is
|
|
simply unnecessary (same conclusion shape as Qwen).
|
|
"""
|
|
# 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 dataclasses import replace
|
|
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, TextMarkDetection, TextMarkEngine
|
|
|
|
if TYPE_CHECKING:
|
|
from numpy.typing import NDArray
|
|
|
|
# Locate geometry as a fraction of the image SHORT side (measured basis -- see
|
|
# scale_base). The box is fitted to the measured mark rects: the mark's right
|
|
# margin is ~0.034 of the short side and its bottom margin ~0.027; width/height
|
|
# cover the mark plus NCC slack.
|
|
WM_WIDTH_FRAC = 0.19
|
|
WM_HEIGHT_FRAC = 0.05
|
|
MARGIN_RIGHT_FRAC = 0.03
|
|
MARGIN_BOTTOM_FRAC = 0.023
|
|
|
|
# Glyph appearance: a light, low-saturation gray rendered brighter than the local
|
|
# background (white top-hat), same overlay class as Doubao -- inherited, and
|
|
# 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-21 on the vendor cohort vs 286 hand-labeled clean frames
|
|
# (cohort-contamination-guarded): clean p99 0.304 / max 0.320, and every cohort
|
|
# frame scoring >= 0.35 carries a visible 可灵AI 3.0 mark. 0.35 was picked over
|
|
# 0.33 (also zero clean fires) for margin against unseen clean content at a cost
|
|
# of zero measured cohort detections.
|
|
DETECT_NCC_THRESHOLD = 0.35
|
|
|
|
# Detection-silhouette geometry (fraction of the short side), fitted on the
|
|
# cohort: the mark's width (0.12, unimodal) and the silhouette aspect (0.239).
|
|
_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",
|
|
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", # measured: mark_w/short 0.118-0.122 across orientations
|
|
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
|
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
|
min_gw=8,
|
|
# STRICT ONLY: the sub-gate band (real Kling variants at 0.17-0.25) overlaps
|
|
# the clean arm's top (p90 0.220), so provenance relaxation is disabled
|
|
# outright (factor 1.0 = never relaxed).
|
|
provenance_ncc_factor=1.0,
|
|
)
|
|
|
|
# Direct Kling IMAGE 3.0 export, 2026-09-04: the current Latin run measures
|
|
# 0.095 x 0.028 of the short side inside the same bottom-right locate box. The
|
|
# narrow ladder covers small rasterization shifts without admitting any of the
|
|
# 94 available neighboring real/synthetic image controls (max 0.349, versus
|
|
# 0.429 on the provider original). A deliberately adversarial solid corner blob
|
|
# reaches 0.379, so the 0.40 gate stays above both measured negative arms. Keep
|
|
# this a separate silhouette and gate:
|
|
# replacing the CJK template would discard the older measured release.
|
|
_LATIN_CONFIG = replace(
|
|
_CONFIG,
|
|
asset_name="kling_latin_alpha.png",
|
|
alpha_width_frac=0.095,
|
|
alpha_height_frac=0.028,
|
|
ladder=(0.9, 1.0, 1.1),
|
|
detect_ncc_threshold=0.40,
|
|
)
|
|
|
|
|
|
def _alpha_template() -> NDArray[Any] | None:
|
|
"""The bundled Kling AI alpha template (float [0,1]), or None."""
|
|
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
|
|
|
|
|
def _glyph_silhouette() -> NDArray[Any] | None:
|
|
"""Binary "可灵AI 3.0" silhouette (255 = glyph) from the alpha map, or None."""
|
|
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
|
|
|
|
|
class KlingEngine(TextMarkEngine):
|
|
"""Detect/localize Kling AI's CJK and Latin 3.0 marks (locate -> mask -> fill)."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(_CONFIG)
|
|
self._latin = TextMarkEngine(_LATIN_CONFIG)
|
|
|
|
@staticmethod
|
|
def _best(*detections: TextMarkDetection) -> TextMarkDetection:
|
|
"""Prefer an accepted variant, then retain the strongest rejected score."""
|
|
return max(detections, key=lambda detection: (detection.detected, detection.confidence))
|
|
|
|
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
|
"""Return the strongest CJK or Latin Kling wordmark verdict."""
|
|
return self._best(
|
|
super().detect(image, provenance=provenance),
|
|
self._latin.detect(image, provenance=provenance),
|
|
)
|
|
|
|
def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
|
|
"""Scan each immutable variant once and combine strict/relaxed verdicts."""
|
|
cjk_strict, cjk_relaxed = super().detect_both(image)
|
|
latin_strict, latin_relaxed = self._latin.detect_both(image)
|
|
return self._best(cjk_strict, latin_strict), self._best(cjk_relaxed, latin_relaxed)
|
|
|
|
def footprint_mask(
|
|
self,
|
|
image: NDArray[Any] | None,
|
|
*,
|
|
force: bool = False,
|
|
dilate: int | None = None,
|
|
detection: TextMarkDetection | None = None,
|
|
) -> NDArray[Any] | None:
|
|
"""Add detector-aligned Kling cores 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)
|
|
radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate)
|
|
result = legacy
|
|
for config in (_CONFIG, _LATIN_CONFIG):
|
|
alpha = _text_mark_engine.load_alpha_template(config.asset_name)
|
|
if alpha is None:
|
|
continue
|
|
core = self._aligned_alpha_mask(
|
|
image,
|
|
det,
|
|
alpha,
|
|
alpha_floor=_FOOTPRINT_ALPHA_FLOOR,
|
|
dilate=radius,
|
|
)
|
|
if core is not None:
|
|
result = core if result is None else cv2.bitwise_or(result, core)
|
|
return result
|