diff --git a/data/fixtures/README.md b/data/fixtures/README.md index 6379d39..468182e 100644 --- a/data/fixtures/README.md +++ b/data/fixtures/README.md @@ -63,16 +63,16 @@ the vendors' direct consumer services. `qwen-create-qwen-image-2.png` is the original 2048 x 2048 PNG downloaded from an authenticated Qwen Create generation on 2026-09-03. It was generated by `Qwen-Image 2.0` at 1:1 using the neutral still-life prompt recorded above. -The current global consumer surface differs from the supported Chinese Qwen -text mark: it places a small Qwen symbol at the bottom right, not -`千问AI生成`. The existing Qwen text detector therefore correctly does not -claim this new shape (measured strict confidence 0.178). +The current global consumer surface differs from the Chinese Qwen text mark: it +places a small Qwen symbol at the bottom right, not `千问AI生成`. The Qwen +engine has separate text and symbol templates; the symbol path scores 0.848 on +this fixture while the text path alone scores 0.178. - SHA-256: `60c39ed8aedb081193c62289be549785e74d31fc8d071961003fabd74712429d` - Expected metadata signal: TC260 AIGC label - Expected `ContentProducer`: `001191440101MA9Y9T4H7A00000` -- Expected visible result: no registered visible mark; the Qwen symbol is a - newly observed, currently unsupported variant +- Expected visible result: Qwen symbol detected and removable under the `qwen` + registry key - License: repository license; generated and explicitly cleared by the maintainer - Qwen Create terms: diff --git a/docs/module-internals.md b/docs/module-internals.md index 061d0de..f65d94d 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1369,6 +1369,18 @@ be represented by the shared base: - [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py) - [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py) +Qwen is the one image registry row that currently covers two layout families. +The shared text engine handles `千问AI生成`; `QwenEngine` also scans a square +bottom-right region for Qwen Create's three-lobe symbol. Its 0.65 NCC gate was +measured against the cleared provider fixture (0.848) and 132 decoded tracked +controls other than that positive (maximum 0.516 on 2026-09-10, zero fires). +The removal mask reuses the aligned three-lobe alpha and expands it by 6% of +the detected side; a solid square was rejected because classical inpainting +left a visible rectangular blur. +`scripts/visible_alpha_solve.py qwen_symbol` rebuilds the template from the +cleared fixture with a cubic local-background fit and retains only the three +interior lobes. + LiblibAI has two registry entries under one product. `liblib` retains the historical bottom-center wordmark detector. `liblib_pill` covers the compact top-left `AI生成` pill and never fires at strict trust: the shape is generic, so diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 4c9d32e..1d5db9f 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -13,7 +13,7 @@ The `visible` command registers these mark keys: | `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. | | `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. | | `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. | -| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. | +| `qwen` | `千问AI生成` or the three-lobe Qwen symbol | Bottom right | Separate strict templates cover the text and Qwen Create symbol variants. | | `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants. | | `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. | | `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. | diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 45ced41..6f49acf 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -213,7 +213,7 @@ not ground truth. Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner). -**Alibaba Cloud Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`. +**Alibaba Cloud Qwen -- several surfaces differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the documented mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The Chinese consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. A cleared Qwen Create `Qwen-Image 2.0` output from 2026-09-03 instead carries the three-lobe Qwen symbol alone; that symbol and the CJK text are registered under one `qwen` key with separate templates. The documented API Latin wordmark remains unsupported, so absence of either registered shape is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`. **星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred. diff --git a/scripts/README.md b/scripts/README.md index 2c85870..6dad77d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -44,7 +44,7 @@ explicitly names a tracked canonical result. | `synthid_corpus.py` | Ingest and inspect the local SynthID reference corpus. | | `vendor_cohort_harvest.py` | Partition TC260 carriers by producer code. | | `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. | -| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures. | +| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures and the cleared Qwen symbol fixture. | | `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. | | `visible_positives.py` | List corpus images carrying a registered visible mark. | | `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. | diff --git a/scripts/visible_alpha_solve.py b/scripts/visible_alpha_solve.py index 97f20e7..582b6b4 100644 --- a/scripts/visible_alpha_solve.py +++ b/scripts/visible_alpha_solve.py @@ -23,11 +23,18 @@ edges: engine reads ``alpha = max(R,G,B)/255`` directly (no background fit). Its assets are the sparkle-on-black capture cropped to two fixed logo sizes (``gemini_bg_{96,48}.png``). +**Qwen symbol** is solved from the cleared Qwen Create provider fixture. The symbol +occupies a recorded square in a smooth linen region. A cubic surface fitted outside +that square estimates the local background; the three interior components of the +positive residual are the symbol. Keeping the extraction as code makes the bundled +asset reproducible instead of a hand-cropped copy of the fixture. + Usage:: uv run python scripts/visible_alpha_solve.py doubao uv run python scripts/visible_alpha_solve.py jimeng uv run python scripts/visible_alpha_solve.py gemini + uv run python scripts/visible_alpha_solve.py qwen_symbol uv run python scripts/visible_alpha_solve.py all """ @@ -115,6 +122,12 @@ _GEMINI_ASSETS: dict[int, Path] = { 48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png", } +_QWEN_SYMBOL_CAPTURE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png" +_QWEN_SYMBOL_ASSET = _ROOT / "src" / "remove_ai_watermarks" / "assets" / "qwen_symbol_alpha.png" +# Recorded provider-symbol box (x, y, width, height) in the 2048-square fixture. +_QWEN_SYMBOL_BOX = (1925, 1917, 70, 70) +_QWEN_SYMBOL_BG_PAD = 45 + def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]: """Union bbox ``(x0, x1, y0, y1)`` of ``mask``'s connected components with area @@ -246,10 +259,48 @@ def solve_gemini() -> dict[int, NDArray[np.uint8]]: return {size: cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) for size in _GEMINI_ASSETS} +def solve_qwen_symbol() -> NDArray[np.uint8]: + """Recover the three-lobe Qwen symbol from its cleared provider fixture.""" + source = image_io.imread(str(_QWEN_SYMBOL_CAPTURE), cv2.IMREAD_COLOR) + if source is None: + raise FileNotFoundError(f"missing Qwen symbol capture {_QWEN_SYMBOL_CAPTURE}") + x, y, width, height = _QWEN_SYMBOL_BOX + pad = _QWEN_SYMBOL_BG_PAD + crop = source[y - pad : y + height + pad, x - pad : x + width + pad].astype(np.float32) + excluded = np.zeros(crop.shape[:2], dtype=bool) + excluded[pad - 8 : pad + height + 8, pad - 8 : pad + width + 8] = True + background = _cubic_background(crop, excluded) + alpha = np.clip( + ((crop - background) / np.clip(255.0 - background, 1e-3, None)).mean(axis=2), + 0.0, + 1.0, + )[pad : pad + height, pad : pad + width] + + body = (alpha > 0.10).astype(np.uint8) + count, labels, stats, _centroids = cv2.connectedComponentsWithStats(body, connectivity=8) + parts = [ + index + for index in range(1, count) + if stats[index, cv2.CC_STAT_AREA] >= 300 + and stats[index, cv2.CC_STAT_LEFT] > 0 + and stats[index, cv2.CC_STAT_TOP] > 0 + and stats[index, cv2.CC_STAT_LEFT] + stats[index, cv2.CC_STAT_WIDTH] < width + and stats[index, cv2.CC_STAT_TOP] + stats[index, cv2.CC_STAT_HEIGHT] < height + ] + if len(parts) != 3: + raise ValueError(f"expected three Qwen symbol lobes, found {len(parts)}") + selected = np.isin(labels, parts) + ys, xs = np.where(selected) + tight = np.where(selected, alpha, 0.0)[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] + tight[tight < 0.08] = 0.0 + log.info("qwen-symbol: alpha %dx%d max %.3f", tight.shape[1], tight.shape[0], float(tight.max())) + return (tight * 255.0).astype(np.uint8) + + @click.command() -@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "all"])) +@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "qwen_symbol", "all"])) def main(engine: str) -> None: - """Rebuild the alpha asset(s) for ENGINE (doubao / jimeng / gemini / all).""" + """Rebuild the alpha asset(s) for ENGINE.""" logging.basicConfig(level=logging.INFO, format="%(message)s") def _write(path: Path, img: NDArray[np.uint8], label: str) -> None: @@ -265,6 +316,8 @@ def main(engine: str) -> None: if engine in ("gemini", "all"): for size, img in solve_gemini().items(): _write(_GEMINI_ASSETS[size], img, f"gemini-{size}") + if engine in ("qwen_symbol", "all"): + _write(_QWEN_SYMBOL_ASSET, solve_qwen_symbol(), "qwen-symbol") if __name__ == "__main__": diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index 8ea72fe..732469c 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -182,6 +182,11 @@ class TextMarkDetection: provenance: bool = False +def best_detection(*detections: TextMarkDetection) -> TextMarkDetection: + """Prefer an accepted variant, then retain the strongest rejected score.""" + return max(detections, key=lambda detection: (detection.detected, detection.confidence)) + + @dataclass(frozen=True) class TextMarkScan: """The trust-level-BLIND half of text-mark detection, reusable across both levels. diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 29f29c8..43393de 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -175,7 +175,7 @@ def remove_visible( """Remove every detected known visible AI mark through localize then fill. The registry currently covers the Gemini visible watermark; Doubao, Jimeng, - Qwen, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the + Qwen text or symbol mark, Kling AI, Yuanbao, Samsung, RunningHub, and Baidu text marks; the LiblibAI wordmark and compact pill; one Microsoft top-right AI-badge variant; and the Jimeng pill. Returns ``(result_bgr, [labels removed])``. diff --git a/src/remove_ai_watermarks/assets/qwen_symbol_alpha.png b/src/remove_ai_watermarks/assets/qwen_symbol_alpha.png new file mode 100644 index 0000000..3b9b42a Binary files /dev/null and b/src/remove_ai_watermarks/assets/qwen_symbol_alpha.png differ diff --git a/src/remove_ai_watermarks/kling_engine.py b/src/remove_ai_watermarks/kling_engine.py index 53a1c22..a53954d 100644 --- a/src/remove_ai_watermarks/kling_engine.py +++ b/src/remove_ai_watermarks/kling_engine.py @@ -160,14 +160,9 @@ class KlingEngine(TextMarkEngine): 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( + return _text_mark_engine.best_detection( super().detect(image, provenance=provenance), self._latin.detect(image, provenance=provenance), ) @@ -176,7 +171,9 @@ class KlingEngine(TextMarkEngine): """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) + return _text_mark_engine.best_detection(cjk_strict, latin_strict), _text_mark_engine.best_detection( + cjk_relaxed, latin_relaxed + ) def footprint_mask( self, diff --git a/src/remove_ai_watermarks/qwen_engine.py b/src/remove_ai_watermarks/qwen_engine.py index 1847cc1..5d48732 100644 --- a/src/remove_ai_watermarks/qwen_engine.py +++ b/src/remove_ai_watermarks/qwen_engine.py @@ -1,11 +1,12 @@ """Qwen (Alibaba) visible watermark detector/localizer. -Qwen stamps its generations with a visible "千问AI生成" text strip in the -bottom-right corner -- the explicit AIGC label mandated by China's GB 45438-2025 +Qwen stamps its generations with either a visible "千问AI生成" text strip or a +small three-lobe symbol in the bottom-right corner. The text is the explicit AIGC +label mandated by China's GB 45438-2025 (the same 6-glyph house style as Doubao's "豆包AI生成": a 2-glyph vendor prefix -plus the shared `AI生成` tail), preceded by the vendor's tri-lobe logo (not part -of the detection silhouette -- logos vary between releases, the CJK run is what -discriminates). +plus the shared `AI生成` tail), preceded by the vendor's tri-lobe logo. Qwen +Create's global consumer surface uses the symbol alone; it has its own square +template and search geometry. Detection matches the bundled glyph silhouette against the corner; removal is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds @@ -49,10 +50,14 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING, Any -from remove_ai_watermarks import _text_mark_engine -from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine +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 if TYPE_CHECKING: from numpy.typing import NDArray @@ -94,6 +99,20 @@ _ALPHA_HEIGHT_FRAC = 0.0416 # its nearest rung). _LADDER = (0.78, 1.27) +# Qwen Create symbol-only mark, measured on the cleared 2048-square provider +# fixture. The mark body is ~2.7% of the short side, inset ~3% from both edges. +# The strict 0.65 gate leaves 0.13 NCC margin over the tracked-control maximum +# (0.516 across 132 decoded images other than the positive on 2026-09-10). +_SYMBOL_ASSET = "qwen_symbol_alpha.png" +_SYMBOL_SEARCH_FRAC = 0.11 +_SYMBOL_MARGIN_FRAC = 0.012 +_SYMBOL_SIZE_FRAC = 0.03125 +_SYMBOL_LADDER = (0.75, 0.875, 1.0, 1.125, 1.25) +_SYMBOL_NCC_THRESHOLD = 0.65 +_SYMBOL_MIN_SHORT_SIDE = 256 +_SYMBOL_MASK_ALPHA = 4 / 255 +_SYMBOL_MASK_DILATE_FRAC = 0.06 + _CONFIG = TextMarkConfig( name="Qwen", asset_name="qwen_alpha.png", @@ -131,8 +150,111 @@ def _glyph_silhouette() -> NDArray[Any] | None: return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name) +class QwenSymbolDetection(TextMarkDetection): + """Explicit marker for the symbol variant's distinct footprint policy.""" + + class QwenEngine(TextMarkEngine): - """Detect/localize the visible Qwen "千问AI生成" watermark (locate -> mask; mask feeds the fill).""" + """Detect and localize Qwen text and symbol visible marks.""" def __init__(self) -> None: super().__init__(_CONFIG) + template = _text_mark_engine.load_alpha_template(_SYMBOL_ASSET) + if template is None: + raise RuntimeError(f"failed to decode embedded asset: {_SYMBOL_ASSET}") + self._symbol_template = template + self._symbol_template_scales: dict[int, NDArray[Any]] = {} + + def _scaled_symbol_template(self, side: int) -> NDArray[Any]: + """Return a cached square symbol template for a measured ladder size.""" + template = self._symbol_template_scales.get(side) + if template is None: + if len(self._symbol_template_scales) >= len(_SYMBOL_LADDER) * 4: + self._symbol_template_scales.clear() + template = cv2.resize(self._symbol_template, (side, side), interpolation=cv2.INTER_AREA) + self._symbol_template_scales[side] = template + return template + + def _symbol_scan(self, image: NDArray[Any] | None) -> QwenSymbolDetection: + """Locate the strongest symbol-shaped candidate in the lower-right search box.""" + det = QwenSymbolDetection() + if image is None or image.size == 0: + return det + height, width = image.shape[:2] + base = min(height, width) + if base < _SYMBOL_MIN_SHORT_SIDE: + return det + search_side = max(80, int(base * _SYMBOL_SEARCH_FRAC)) + margin = int(base * _SYMBOL_MARGIN_FRAC) + origin_x = max(0, width - margin - search_side) + origin_y = max(0, height - margin - search_side) + roi = image_io.to_bgr(image[origin_y : origin_y + search_side, origin_x : origin_x + search_side]) + luma: NDArray[Any] = roi.mean(axis=2, dtype=np.float32) + background: NDArray[Any] = cv2.GaussianBlur(luma, (0, 0), sigmaX=max(4.0, search_side * 0.20)) + response: NDArray[Any] = np.clip(luma - background, 0.0, None) + + best_score = 0.0 + best_region = (0, 0, 0, 0) + for rung in _SYMBOL_LADDER: + side = max(12, int(base * _SYMBOL_SIZE_FRAC * rung)) + if side >= search_side: + continue + template = self._scaled_symbol_template(side) + scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED) + _minimum, score, _min_location, location = cv2.minMaxLoc(scores) + if score > best_score: + best_score = float(score) + best_region = (origin_x + location[0], origin_y + location[1], side, side) + det.confidence = best_score + det.detected = best_score >= _SYMBOL_NCC_THRESHOLD + det.region = best_region + return det + + def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection: + """Return the stronger text or symbol variant.""" + text = super().detect(image, provenance=provenance) + symbol = self._symbol_scan(image) + symbol.provenance = provenance + return _text_mark_engine.best_detection(text, symbol) + + def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]: + """Return both trust verdicts from one text scan and one symbol scan.""" + strict_text, relaxed_text = super().detect_both(image) + strict_symbol = self._symbol_scan(image) + relaxed_symbol = replace(strict_symbol, provenance=True) + return _text_mark_engine.best_detection(strict_text, strict_symbol), _text_mark_engine.best_detection( + relaxed_text, relaxed_symbol + ) + + @staticmethod + def _is_symbol_detection(detection: TextMarkDetection | None) -> bool: + return isinstance(detection, QwenSymbolDetection) and detection.detected + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + """Mask the detected symbol box or delegate to the text-mark footprint.""" + if image is None or image.size == 0: + return None + resolved = detection if detection is not None else self.detect(image) + if self._is_symbol_detection(resolved): + x, y, width, height = resolved.region + alpha = cv2.resize(self._symbol_template, (width, height), interpolation=cv2.INTER_LINEAR) + radius = dilate if dilate is not None else max(2, int(max(width, height) * _SYMBOL_MASK_DILATE_FRAC)) + local = np.pad((alpha > _SYMBOL_MASK_ALPHA).astype(np.uint8) * 255, radius) + if radius > 0: + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1)) + local = cv2.dilate(local, kernel) + mask = np.zeros(image.shape[:2], dtype=np.uint8) + target_x, target_y = x - radius, y - radius + x1, y1 = max(0, target_x), max(0, target_y) + x2, y2 = min(image.shape[1], target_x + local.shape[1]), min(image.shape[0], target_y + local.shape[0]) + local_x, local_y = x1 - target_x, y1 - target_y + mask[y1:y2, x1:x2] = local[local_y : local_y + y2 - y1, local_x : local_x + x2 - x1] + return mask + return super().footprint_mask(image, force=force, dilate=dilate, detection=resolved) diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index a2207b4..bb29c3d 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -21,7 +21,7 @@ Entries: - ``gemini`` -- Google Gemini / Nano Banana visible watermark (sparkle), bottom-right. - ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right. - ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right. - - ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text strip, bottom-right. + - ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text or three-lobe symbol, bottom-right. - ``kling`` -- Kuaishou Kling AI "可灵AI 3.0" / "KlingAI 3.0" strip, bottom-right. - ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right. - ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left. @@ -701,9 +701,9 @@ _REGISTRY: tuple[KnownMark, ...] = ( ), _text_mark( "qwen", - "Qwen 千问AI生成 text", + "Qwen 千问AI生成 text or three-lobe symbol", "bottom-right", - platform="Alibaba Cloud Qwen (visible 千问AI生成 mark detected)", + platform="Alibaba Cloud Qwen (visible text or symbol mark detected)", manufacturer="alibaba", tc260_producer_codes=("91440101MA9Y9T4H7A",), ), diff --git a/tests/test_platform.py b/tests/test_platform.py index ec869a7..8b89d95 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -460,6 +460,7 @@ class TestPlatformPaths: asset_dir = Path(__file__).parent.parent / "src" / "remove_ai_watermarks" / "assets" assert (asset_dir / "gemini_bg_48.png").exists() assert (asset_dir / "gemini_bg_96.png").exists() + assert (asset_dir / "qwen_symbol_alpha.png").exists() def test_asset_loading_works(self): """Verify embedded assets load correctly (critical for packaging).""" @@ -469,3 +470,7 @@ class TestPlatformPaths: # If we get here without error, asset loading works assert engine._alpha_small.shape == (48, 48) assert engine._alpha_large.shape == (96, 96) + + from remove_ai_watermarks.qwen_engine import QwenEngine + + assert QwenEngine()._symbol_template.shape == (53, 53) diff --git a/tests/test_qwen_engine.py b/tests/test_qwen_engine.py index fcb5af8..38a726e 100644 --- a/tests/test_qwen_engine.py +++ b/tests/test_qwen_engine.py @@ -1,4 +1,4 @@ -"""Tests for the Qwen (千问AI生成) visible-watermark engine (localize -> fill). +"""Tests for the Qwen text and symbol visible-watermark engine (localize -> fill). Every tuned constant in ``qwen_engine`` was measured on the 117-frame vendor cohort (2026-07-21, ``scripts/vendor_mark_calibrate.py``); these tests pin the @@ -8,11 +8,14 @@ geometry (the exact failure the calibration had to fix). from __future__ import annotations +from pathlib import Path + import cv2 import numpy as np import pytest from remove_ai_watermarks import watermark_registry as registry +from remove_ai_watermarks._text_mark_engine import TextMarkDetection, best_detection from remove_ai_watermarks.qwen_engine import ( _ALPHA_HEIGHT_FRAC, _ALPHA_WIDTH_FRAC, @@ -27,6 +30,8 @@ from remove_ai_watermarks.qwen_engine import ( # centres one rung on each mode. _BIG_MODE, _SMALL_MODE = 0.203, 0.124 _MARGIN = 0.025 # measured right/bottom margin of the real mark +_ROOT = Path(__file__).resolve().parents[1] +_QWEN_CREATE = _ROOT / "data" / "fixtures" / "provenance" / "qwen-create-qwen-image-2.png" def _compose(w: int, h: int, mode: float = _BIG_MODE, bg: float = 100.0): @@ -87,6 +92,7 @@ class TestConfig: mark = registry.get_mark("qwen") assert mark.location == "bottom-right" assert "千问AI生成" in mark.label + assert "symbol" in mark.label assert mark.in_auto @@ -129,6 +135,22 @@ class TestDetect: assert eng.detect(wm).detected assert not eng.detect(cv2.resize(wm, (150, 112))).detected + def test_qwen_create_symbol_detected(self): + image = cv2.imread(str(_QWEN_CREATE), cv2.IMREAD_COLOR) + assert image is not None + det = QwenEngine().detect(image) + assert det.detected + assert det.confidence >= 0.80 + x, y, width, height = det.region + assert x > image.shape[1] * 0.90 + assert y > image.shape[0] * 0.90 + assert width == height + + def test_unaccepted_symbol_candidate_cannot_hide_detected_text(self): + text_detection = TextMarkDetection(detected=True, confidence=0.45) + symbol_detection = TextMarkDetection(detected=False, confidence=0.46) + assert best_detection(text_detection, symbol_detection) is text_detection + class TestFootprintMaskAndRemoval: @pytest.mark.parametrize("mode", [_BIG_MODE, _SMALL_MODE]) @@ -153,3 +175,19 @@ class TestFootprintMaskAndRemoval: def test_clean_frame_produces_no_mask(self): clean = cv2.GaussianBlur(np.full((640, 853, 3), 120, np.uint8), (5, 5), 0) assert QwenEngine().footprint_mask(clean, force=False) is None + + def test_removes_qwen_create_symbol(self): + image = cv2.imread(str(_QWEN_CREATE), cv2.IMREAD_COLOR) + assert image is not None + before = QwenEngine().detect(image) + assert before.detected + mask = QwenEngine().footprint_mask(image, detection=before) + assert mask is not None + x, y, width, height = before.region + assert np.count_nonzero(mask[y : y + height, x : x + width]) < width * height * 0.90 + out, region = registry.get_mark("qwen").remove(image, backend="cv2") + assert region == before.region + assert not QwenEngine().detect(out).detected + assert np.array_equal( + out[: image.shape[0] // 2, : image.shape[1] // 2], image[: image.shape[0] // 2, : image.shape[1] // 2] + )