diff --git a/README.md b/README.md index 31cb28b..46f3acc 100644 --- a/README.md +++ b/README.md @@ -370,11 +370,11 @@ remove-ai-watermarks batch ./images --mode all Visible mark support includes: - Google Gemini and Nano Banana visible sparkle watermark; -- Doubao, the Jimeng wordmark and top-left `AI生成` pill, Qwen, Kling AI, - Yuanbao, Baidu, LiblibAI's bottom-center wordmark and compact top-left pill, - and RunningHub labels; -- one calibrated Microsoft top-right white AI-badge variant; -- one calibrated Samsung Galaxy AI label variant; +- Doubao, the Jimeng wordmark and top-left `AI生成` layouts, Qwen, Kling AI, + Yuanbao, Dola, WorkBuddy, Baidu, LiblibAI's bottom-center wordmark and compact + top-left pill, and RunningHub labels; +- calibrated Microsoft top-right white and beige AI-badge layouts; +- calibrated Italian and Korean Samsung Galaxy AI disclosures; - a generic brand-less bottom-right `AI生成` fallback for OS/gallery-level AI-edit stamps that carry no vendor wordmark of their own (vivo and Xiaomi Gallery confirmed); synthetic-only calibration, weaker evidence than the diff --git a/data/fixtures/visible/dola/example.png b/data/fixtures/visible/dola/example.png new file mode 100644 index 0000000..634e84f Binary files /dev/null and b/data/fixtures/visible/dola/example.png differ diff --git a/data/fixtures/visible/workbuddy/example.png b/data/fixtures/visible/workbuddy/example.png new file mode 100644 index 0000000..5e6a024 Binary files /dev/null and b/data/fixtures/visible/workbuddy/example.png differ diff --git a/docs/module-internals.md b/docs/module-internals.md index ca32ebc..d230063 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1596,8 +1596,8 @@ their own: the Gemini sparkle has its own higher-confidence path, and the pill alone is too weak to attribute. The set of marks that veto the Jimeng pill is derived from the registry rows: -every other product from the same manufacturer. Doubao therefore vetoes the pill -because both products are ByteDance, while Qwen, Kling, Yuanbao, RunningHub, +every other product from the same manufacturer. Doubao and Dola therefore veto the pill +because all three products are ByteDance, while Qwen, Kling, Yuanbao, RunningHub, Baidu, and LiblibAI do not become siblings merely because they use the same TC260 standard. Marks from other manufacturers cannot enable or veto the ByteDance-specific arm. @@ -1656,8 +1656,7 @@ assets and template ladder are immutable. A decorative sparkle/glint/star glyph in stylized typography -- a bright, near-white four-point accent, the same silhouette family the engine's own -template captures -- is a known, unresolved false-positive class. Incident -2026-08-27 (raiw-app, Spaces uid 7e01ef8cb99949bba14ab4607a66ae06): a book-cover +template captures -- is a known, unresolved false-positive class. A book-cover title's decorative diamond accent inside a neon-glow letterform scored 0.64, just under `_SPARKLE_FP_CONF`, and the removal step painted a permanent smudge over the artwork. Investigation (margin, gradient, saturation, and a prototyped local @@ -1697,6 +1696,11 @@ Regression coverage: [`_text_mark_engine.py`](../src/remove_ai_watermarks/_text_mark_engine.py) provides common localization, detection front ends, template caching, rival comparison, and footprint construction. +[`_text_mark_variants.py`](../src/remove_ai_watermarks/_text_mark_variants.py) +combines independently gated layouts under one registry key while retaining the +winning layout's template and mask geometry. Its bottom-right variant searches +only anchor-valid placements, so an unrelated stronger match elsewhere in the +corner ROI cannot hide the real edge-hugging mark. Each vendor module supplies a `TextMarkConfig` and only the behavior that cannot be represented by the shared base: @@ -1706,6 +1710,8 @@ be represented by the shared base: - [`qwen_engine.py`](../src/remove_ai_watermarks/qwen_engine.py) - [`kling_engine.py`](../src/remove_ai_watermarks/kling_engine.py) - [`yuanbao_engine.py`](../src/remove_ai_watermarks/yuanbao_engine.py) +- [`dola_engine.py`](../src/remove_ai_watermarks/dola_engine.py) +- [`workbuddy_engine.py`](../src/remove_ai_watermarks/workbuddy_engine.py) - [`samsung_engine.py`](../src/remove_ai_watermarks/samsung_engine.py) - [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py) - [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py) @@ -1750,7 +1756,10 @@ on 2026-09-04 uses `KlingAI 3.0`. The Latin template scored 0.429 on that 1024 x across the 94 available neighboring real and synthetic image controls, while an adversarial solid corner blob reached 0.379, so its strict gate is 0.40. This is one-positive provisional calibration, intentionally separate from the older -CJK detector rather than presented as broad recall evidence. +CJK detector rather than presented as broad recall evidence. Both layouts now +project row-wide and column-wide structure out of the winning patch before a +second correlation. This preserves glyph detail while rejecting ordinary panel +and image boundaries that previously matched the Latin template. The measured Microsoft badge variant (2026-08-27 registration) is the first `tr`-corner mark and the first `long`-side scale basis: the pill tracks the @@ -1761,7 +1770,8 @@ undersized the template by the aspect ratio (portrait carriers fell to its synthetic internal shapes knocked out - the holes are what separate it from any other bright rounded corner element (a plain white pill scores below the gate in the tests). It does not claim coverage of Microsoft's other documented icon, -wording, or position variants. +wording, or position variants beyond the separately gated beige `Made with AI` +text pill. The 2026-08-27 rerun used the registered engine through `scripts/registered_mark_calibrate.py`, rather than a copied detector @@ -1798,8 +1808,9 @@ box grows), not the whole `footprint_mask`. Baidu extends right to the corner ta and LiblibAI extends left to the triangle logo; both inherit every guard around that arithmetic. -Doubao, Kling, and Samsung are deliberate sparse-mask exceptions because their alpha assets -supply more information than a rectangle. Doubao's continuous top-hat response +Doubao, Kling, and Samsung retain deliberate sparse masks for their original +layouts because those 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, @@ -1809,6 +1820,13 @@ 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. +The added outlined Doubao, stacked Yuanbao, Dola, WorkBuddy, Korean Samsung, and +beige Microsoft layouts use solid detector-aligned footprints where a sparse +synthetic glyph mask left visible outlines, sparkles, capsules, or shadows in +rendered removal checks. Each layout keeps its own detection threshold and mask +geometry; selecting one does not replace the calibrated behavior of the older +layout under the same registry key. + Doubao keeps the original low-saturation scan for pale corners. When enough of its corner is strongly colored and that scan falls below the strict gate, a wider color filter searches for white glyphs tinted by the background. The @@ -1936,14 +1954,12 @@ exists. #### OpenArt wordmark -Registered 2026-09-21 for a confirmed production miss reported through raiw-app: -a free `visible`-mode run (2026-09-11, 1528x2712 portrait, Spaces uid -`aefb1e07306f4a7280615a8daea01a39`) left a large, prominent OpenArt wordmark +Registered 2026-09-21 after a `visible`-mode run left a large, prominent OpenArt wordmark (a bowtie/infinity icon followed by "OpenArt" in bold sans-serif, semi- transparent white) completely unremoved -- pixel-identical before and after. `identify` found nothing at all for that case: no C2PA, EXIF AI tag, or China AIGC/TC260 label, so the visible mark is the only known attribution route for -a bare OpenArt export. The user rated the result 0 ("Didn't work"). +a bare OpenArt export. **This is the only registered mark placed at the FRAME CENTER rather than a corner.** Every other engine in this family anchors to one of the four corners @@ -2007,10 +2023,14 @@ geometry, and until one exists the bare-arm question is settled by this measurement, not by gate tuning. The local calibration harness is not tracked. (cohorts A/B/C over the no-signal pool). -The weak Jimeng pill detector lives in -[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a -synthetic silhouette for detection and a fixed top-left footprint. Its public, -published real-world regression carrier is +The Jimeng pill detector lives in +[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It combines the +existing weak synthetic-outline path with a high-confidence contrast template +for the measured solid `AI生成` label. A Jimeng provenance match can corroborate +that verified label without the flat-background guard; the weak outline keeps +both the provenance and background gates described above. Removal follows the +matched label when verified and otherwise uses the fixed top-left footprint. Its +public, published real-world regression carrier is `data/fixtures/visible/jimeng_pill/provider-published-example.jpg`; the direct detector scores the pill at 0.28 and the same image's Jimeng wordmark at 0.61. diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 2f895f5..65e43f5 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -11,19 +11,21 @@ The `visible` command registers these mark keys: | Key | Mark | Expected area | Important limit | | --- | --- | --- | --- | | `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. | -| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. | +| `doubao` | `豆包AI生成` | Bottom right | Covers filled and outlined text layouts; the weak outlined layout requires matching Doubao provenance. | | `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. | | `qwen` | `千问AI生成` or the three-lobe Qwen symbol | Bottom right | Separate strict templates cover the text and Qwen Create symbol variants. | | `wan` | Wan logo and `Wan` wordmark | Bottom right, hugging the corner | Provisional: one real Wan 2.7 Pro export. Requires NCC >= 0.55 and a match that ends within 0.015 of the short side from both edges. | -| `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. | +| `kling` | `可灵AI 3.0` or `KlingAI 3.0` | Bottom right | Separate calibrated silhouettes cover the older CJK and current IMAGE 3.0 Latin variants; a glyph-detail gate rejects plain UI boundaries. | +| `yuanbao` | `元宝` over `AI生成` | Bottom right | Separate strict and provenance-corroborated templates cover the measured two-line rasterizations. | +| `dola` | `Dola AI` | Bottom right | Text detector and detector-aligned wordmark footprint. | +| `workbuddy` | `AI生成` over `WORKBUDDY>_` | Bottom right | Text detector and detector-aligned stacked-label footprint. | +| `samsung` | Italian or Korean Galaxy AI disclosure | Bottom left | Independently gated text layouts cover `Contenuti generati dall'AI` and `AI로 생성한 콘텐츠`. | | `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. | | `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. | | `liblib` | `LiblibAI` | Bottom center | Historical wordmark variant; includes a minimum image size gate. | | `liblib_pill` | Compact `AI生成` pill | Top left | Auto removal requires LiblibAI metadata or the bottom-center wordmark, plus a flat background under the pill. | -| `microsoft` | One Microsoft white AI-badge variant | Top right | Strict uses the visual gate; auto can use Microsoft provenance for the measured [relaxed gate](module-internals.md#visible-mark-removal). Other documented icon, text, and position variants are not covered. | -| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. | +| `microsoft` | Microsoft white AI badge or beige `Made with AI` pill | Top right | Each layout has its own strict visual gate; auto can use Microsoft provenance for the original badge's measured [relaxed gate](module-internals.md#visible-mark-removal). | +| `jimeng_pill` | `AI生成` pill | Top left | A verified label layout can bypass the flat-background guard only with Jimeng provenance; the weak outline detector retains the product and background gates. | | `generic_ai_label` | Bare `AI生成` text, no vendor wordmark | Bottom right | Brand-less TC260 compliance-stamp fallback (vivo and Xiaomi Gallery AI-edit output confirmed; see [module-internals.md](module-internals.md#generic-bare-ai生成-label)). Font-rendered synthetic asset; **calibrated on a synthetic corpus only**, not a real captured generator screenshot -- treat as weaker evidence than the vendor-tuned detectors above. | | `openart` | `OpenArt` wordmark (bowtie/infinity icon + brand name) | Frame center, not a corner | Confirmed from one customer-reported production case; no known metadata signal, so the visible mark is the only attribution route. **UNCALIBRATED**: procedurally reconstructed asset, no captured corpus -- see [module-internals.md](module-internals.md#openart-wordmark). | diff --git a/scripts/assets/visible-mark-candidates/README.md b/scripts/assets/visible-mark-candidates/README.md index b29318f..dcc5830 100644 --- a/scripts/assets/visible-mark-candidates/README.md +++ b/scripts/assets/visible-mark-candidates/README.md @@ -23,7 +23,6 @@ from the provider's canonical name, the provider is named separately. | `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. | | `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. | | `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. | -| `dola_alpha.png` | DolaAI on images (the video mark is registered) | 12 corpus files | POS 0.11-0.21 vs NEG max 0.30. | | `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. | | `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. | | `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. | diff --git a/scripts/assets/visible-mark-candidates/dola_alpha.png b/scripts/assets/visible-mark-candidates/dola_alpha.png deleted file mode 100644 index 374e960..0000000 Binary files a/scripts/assets/visible-mark-candidates/dola_alpha.png and /dev/null differ diff --git a/scripts/render_overlay_variants.py b/scripts/render_overlay_variants.py new file mode 100644 index 0000000..bede46e --- /dev/null +++ b/scripts/render_overlay_variants.py @@ -0,0 +1,68 @@ +"""Rebuild synthetic silhouettes for additional AI disclosure layouts.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from scripts.render_vendor_silhouettes import render # noqa: E402 + +log = logging.getLogger(__name__) +ASSETS = ROOT / "src" / "remove_ai_watermarks" / "assets" +CJK = {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2} + + +def centered_lines( + top: str, bottom: str, *, top_scale: float, bottom_height: float = 1.0, shear: float = 0.0 +) -> np.ndarray: + upper, lower = render(top, opts=CJK), render(bottom, opts=CJK) + if bottom_height != 1.0: + lower = cv2.resize(lower, (upper.shape[1], round(upper.shape[0] * bottom_height))) + else: + upper = cv2.resize(upper, (round(lower.shape[1] * top_scale), round(lower.shape[0] * 0.95))) + lines = [upper, lower] + result = np.zeros((sum(line.shape[0] for line in lines) + 2, max(line.shape[1] for line in lines)), np.uint8) + y = 0 + for line in lines: + x = (result.shape[1] - line.shape[1]) // 2 + result[y : y + line.shape[0], x : x + line.shape[1]] = line + y += line.shape[0] + 2 + if shear: + h, w = result.shape + extra = round(abs(shear) * h) + result = cv2.warpAffine(result, np.float32([[1, shear, extra], [0, 1, 0]]), (w + extra, h)) + return result + + +def silhouettes() -> dict[str, np.ndarray]: + return { + "workbuddy_alpha.png": centered_lines("AI生成", "WORKBUDDY>_", top_scale=1.0, bottom_height=0.45), + "yuanbao_compact_alpha.png": centered_lines("元宝", "AI生成", top_scale=0.65, shear=-0.15), + "yuanbao_compact_gray_alpha.png": centered_lines("元宝", "AI生成", top_scale=0.65, shear=-0.15), + "doubao_outline_alpha.png": render("豆包AI生成", opts=CJK), + "samsung_ko_alpha.png": render( + "AI로 생성한 콘텐츠", opts={"font": "/System/Library/Fonts/AppleSDGothicNeo.ttc"} + ), + "microsoft_text_alpha.png": render( + "Made with AI", opts={"font": "/System/Library/Fonts/Supplemental/Arial.ttf"} + ), + "jimeng_label_alpha.png": render("AI生成", opts={"font": "/System/Library/Fonts/STHeiti Medium.ttc"}), + } + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + for name, alpha in silhouettes().items(): + Image.fromarray(alpha).save(ASSETS / name) + log.info("Rendered %s, %s x %s", name, alpha.shape[1], alpha.shape[0]) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_vendor_silhouettes.py b/scripts/render_vendor_silhouettes.py index 93c7814..44aeafb 100644 --- a/scripts/render_vendor_silhouettes.py +++ b/scripts/render_vendor_silhouettes.py @@ -73,7 +73,7 @@ MARKS = { "gemini_text_alpha.png": "Generated with Gemini", # Candidate wordmarks measured on a local evaluation corpus (unregistered). "notebooklm_alpha.png": "NotebookLM", - "dola_alpha.png": "DolaAI", + "dola_alpha.png": "Dola AI", "mindvideo_alpha.png": "MindVideo.AI", "higgsfield_alpha.png": "HIGGSFIELD AI", "capcut_alpha.png": "CapCut AI", @@ -92,6 +92,7 @@ _REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys() # Per-mark post-processing for the multi-line / slanted stamps (see render()). MARK_OPTS: dict[str, dict[str, Any]] = { + "dola_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial Bold.ttf"}, # Hiragino Sans GB W6, tight leading, dilation, and negative shear match the # standard Yuanbao stamp without clipping the lower line. "yuanbao_alpha.png": { diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index 4c19156..922c306 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -21,7 +21,7 @@ remove-ai-watermarks visible image.png --keep-metadata -o clean.png ``` Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `wan`, `kling`, `yuanbao`, -`samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`, +`dola`, `workbuddy`, `samsung`, `runninghub`, `baidu`, `liblib`, `liblib_pill`, `microsoft`, `generic_ai_label`, `openart`, `jimeng_pill`. `wan` is new (since CLI 0.43.0). Default `--mark auto` removes every selected match. `microsoft` is the top-right AI badge, separate from the invisible InvisMark watermark on the same vendor's diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index e2747d9..3bdbe68 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -181,6 +181,7 @@ class TextMarkDetection: # ``footprint_mask`` reuses a threaded detection only when it matches the STRICT # level its own re-detect would have used -- see TextMarkEngine._strict_detection. provenance: bool = False + template_asset: str | None = None def best_detection(*detections: TextMarkDetection) -> TextMarkDetection: @@ -665,7 +666,7 @@ class TextMarkEngine: def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection: """Apply the trust-level-dependent tail to a scan, as a fresh result object.""" c = self.config - det = TextMarkDetection(provenance=provenance) + det = TextMarkDetection(provenance=provenance, template_asset=c.asset_name) if scan.loc is None or scan.box is None: return det det.region = scan.loc.bbox diff --git a/src/remove_ai_watermarks/_text_mark_variants.py b/src/remove_ai_watermarks/_text_mark_variants.py new file mode 100644 index 0000000..97618e8 --- /dev/null +++ b/src/remove_ai_watermarks/_text_mark_variants.py @@ -0,0 +1,219 @@ +"""Combine independently gated text layouts and retain their mask geometry.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import cv2 + +from remove_ai_watermarks._text_mark_engine import ( + BottomRightAnchoredEngine, + TextMarkConfig, + TextMarkDetection, + TextMarkEngine, + TextMarkLocation, + best_detection, +) + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +class TextMarkVariantsMixin(TextMarkEngine): + """Add independently configured layouts to an existing text-mark engine.""" + + _variants: tuple[TextMarkEngine, ...] = () + config: TextMarkConfig + + def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]: + pairs = [super().detect_both(image), *(engine.detect_both(image) for engine in self._variants)] + return best_detection(*(pair[0] for pair in pairs)), best_detection(*(pair[1] for pair in pairs)) + + def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection: + return self.detect_both(image)[int(provenance)] + + def variant_engine(self, detection: TextMarkDetection) -> TextMarkEngine: + for engine in self._variants: + if engine.config.asset_name == detection.template_asset: + return engine + return self + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + if image is None or image.size == 0 or force: + return super().footprint_mask(image, force=force, dilate=dilate, detection=detection) + det = detection if detection is not None else self.detect(image) + engine = self.variant_engine(det) + if engine is self: + return super().footprint_mask(image, force=False, dilate=dilate, detection=det) + return engine.footprint_mask(image, force=False, dilate=dilate, detection=det) + + +class GlyphTextMarkEngine(TextMarkEngine): + """Use the accepted template's aligned strokes instead of a textured ROI.""" + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + from remove_ai_watermarks._text_mark_engine import load_alpha_template + + if force: + return super().footprint_mask(image, force=True, dilate=dilate, detection=detection) + if image is None or image.size == 0: + return None + det = detection if detection is not None else self.detect(image) + alpha = load_alpha_template(self.config.asset_name) + if alpha is None or not det.detected: + return None + return self._aligned_alpha_mask( + image, + det, + alpha, + alpha_floor=0.05, + dilate=2 if dilate is None else max(0, dilate), + ) + + +class BottomRightGlyphTextMarkEngine(GlyphTextMarkEngine, BottomRightAnchoredEngine): + """Glyph-mask variant whose accepted match must hug the bottom-right corner.""" + + _ANCHOR_MAX_RIGHT = 0.04 + _ANCHOR_MAX_BOTTOM = 0.04 + + def _ladder_best( + self, image: NDArray[Any], loc: TextMarkLocation + ) -> tuple[float, tuple[int, int, int, int] | None]: + """Find the strongest match among placements that satisfy the anchor.""" + response = self._detect_response(image, loc) + silhouette = self._glyph_silhouette() + if response is None or silhouette is None: + return 0.0, None + frame_h, frame_w = image.shape[:2] + anchor_base = min(frame_h, frame_w) + base = self.scale_base(image) + best_score = 0.0 + best_box: tuple[int, int, int, int] | None = None + for scale in self.config.ladder: + glyph_w = max(self.config.min_gw, int(self.config.alpha_width_frac * base * scale)) + glyph_h = max(4, int(self.config.alpha_height_frac * base * scale)) + if glyph_w >= response.shape[1] or glyph_h >= response.shape[0]: + continue + template = cv2.resize(silhouette, (glyph_w, glyph_h), interpolation=cv2.INTER_AREA) + scores = cv2.matchTemplate(response, template, cv2.TM_CCOEFF_NORMED) + min_x = max(0, round(frame_w - loc.x - glyph_w - self._ANCHOR_MAX_RIGHT * anchor_base)) + max_x = min(scores.shape[1] - 1, frame_w - loc.x - glyph_w) + min_y = max(0, round(frame_h - loc.y - glyph_h - self._ANCHOR_MAX_BOTTOM * anchor_base)) + max_y = min(scores.shape[0] - 1, frame_h - loc.y - glyph_h) + if min_x > max_x or min_y > max_y: + continue + anchored = scores[min_y : max_y + 1, min_x : max_x + 1] + _, score, _, point = cv2.minMaxLoc(anchored) + if score > best_score: + x, y = min_x + int(point[0]), min_y + int(point[1]) + best_score = float(score) + best_box = (x, y, x + glyph_w - 1, y + glyph_h - 1) + return best_score, best_box + + +def _box_footprint( + engine: GlyphTextMarkEngine, + image: NDArray[Any] | None, + *, + force: bool, + dilate: int | None, + detection: TextMarkDetection | None, + pad_left_height: float, + pad_right_height: float, +) -> NDArray[Any] | None: + """Mask the full winning text rectangle for outlined or stacked layouts.""" + if image is None or image.size == 0 or force: + return GlyphTextMarkEngine.footprint_mask( + engine, + image, + force=force, + dilate=dilate, + detection=detection, + ) + det = detection if detection is not None else engine.detect(image) + if not det.detected or det.match_box is None: + return None + from remove_ai_watermarks import region_eraser + + loc = engine.locate(image) + x0, y0, x1, y1 = det.match_box + extra_left = round((y1 - y0 + 1) * pad_left_height) + extra_right = round((y1 - y0 + 1) * pad_right_height) + pad = 2 if dilate is None else max(0, dilate) + return region_eraser.boxes_to_mask( + image.shape[:2], + [ + ( + loc.x + x0 - extra_left, + loc.y + y0, + x1 - x0 + 1 + extra_left + extra_right, + y1 - y0 + 1, + ) + ], + dilate=pad, + ) + + +class BoxTextMarkEngine(GlyphTextMarkEngine): + """Text detector with a solid detector-aligned removal footprint.""" + + _BOX_PAD_LEFT_HEIGHT = 0.0 + _BOX_PAD_RIGHT_HEIGHT = 0.0 + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + return _box_footprint( + self, + image, + force=force, + dilate=dilate, + detection=detection, + pad_left_height=self._BOX_PAD_LEFT_HEIGHT, + pad_right_height=self._BOX_PAD_RIGHT_HEIGHT, + ) + + +class BottomRightBoxTextMarkEngine(BottomRightGlyphTextMarkEngine): + """Anchored text detector with a solid detector-aligned footprint.""" + + _BOX_PAD_LEFT_HEIGHT = 0.0 + _BOX_PAD_RIGHT_HEIGHT = 0.0 + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + return _box_footprint( + self, + image, + force=force, + dilate=dilate, + detection=detection, + pad_left_height=self._BOX_PAD_LEFT_HEIGHT, + pad_right_height=self._BOX_PAD_RIGHT_HEIGHT, + ) diff --git a/src/remove_ai_watermarks/assets/dola_alpha.png b/src/remove_ai_watermarks/assets/dola_alpha.png new file mode 100644 index 0000000..1dd9bf6 Binary files /dev/null and b/src/remove_ai_watermarks/assets/dola_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/doubao_outline_alpha.png b/src/remove_ai_watermarks/assets/doubao_outline_alpha.png new file mode 100644 index 0000000..9f4a0a5 Binary files /dev/null and b/src/remove_ai_watermarks/assets/doubao_outline_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/jimeng_label_alpha.png b/src/remove_ai_watermarks/assets/jimeng_label_alpha.png new file mode 100644 index 0000000..eb816d0 Binary files /dev/null and b/src/remove_ai_watermarks/assets/jimeng_label_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/microsoft_text_alpha.png b/src/remove_ai_watermarks/assets/microsoft_text_alpha.png new file mode 100644 index 0000000..c44929f Binary files /dev/null and b/src/remove_ai_watermarks/assets/microsoft_text_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/samsung_ko_alpha.png b/src/remove_ai_watermarks/assets/samsung_ko_alpha.png new file mode 100644 index 0000000..1a31f1d Binary files /dev/null and b/src/remove_ai_watermarks/assets/samsung_ko_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/workbuddy_alpha.png b/src/remove_ai_watermarks/assets/workbuddy_alpha.png new file mode 100644 index 0000000..df6f737 Binary files /dev/null and b/src/remove_ai_watermarks/assets/workbuddy_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/yuanbao_compact_alpha.png b/src/remove_ai_watermarks/assets/yuanbao_compact_alpha.png new file mode 100644 index 0000000..529aeda Binary files /dev/null and b/src/remove_ai_watermarks/assets/yuanbao_compact_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/yuanbao_compact_gray_alpha.png b/src/remove_ai_watermarks/assets/yuanbao_compact_gray_alpha.png new file mode 100644 index 0000000..529aeda Binary files /dev/null and b/src/remove_ai_watermarks/assets/yuanbao_compact_gray_alpha.png differ diff --git a/src/remove_ai_watermarks/dola_engine.py b/src/remove_ai_watermarks/dola_engine.py new file mode 100644 index 0000000..072afc1 --- /dev/null +++ b/src/remove_ai_watermarks/dola_engine.py @@ -0,0 +1,35 @@ +"""Detect the bottom-right Dola AI image disclosure, independently of video marks.""" + +from remove_ai_watermarks._text_mark_engine import TextMarkConfig +from remove_ai_watermarks._text_mark_variants import BoxTextMarkEngine + +_CONFIG = TextMarkConfig( + name="Dola AI image disclosure", + asset_name="dola_alpha.png", + corner="br", + margin_floor=4, + width_frac=0.28, + height_frac=0.09, + margin_x_frac=0.002, + margin_bottom_frac=0.002, + max_saturation=255, + logo_min_luma=110, + tophat_delta=8, + morph_open_size=3, + detect_min_coverage=0.01, + detect_ncc_threshold=0.50, + alpha_width_frac=0.10, + alpha_height_frac=0.10 * 86 / 347, + detect_frontend="tophat", + scale_basis="width", + ladder=(0.95, 1.0, 1.05, 1.20, 1.26), + provenance_ncc_factor=1.0, + min_gw=24, +) + + +class DolaEngine(BoxTextMarkEngine): + """Detect and localize the Dola AI image wordmark.""" + + def __init__(self) -> None: + super().__init__(_CONFIG) diff --git a/src/remove_ai_watermarks/doubao_engine.py b/src/remove_ai_watermarks/doubao_engine.py index 6661028..2b3a183 100644 --- a/src/remove_ai_watermarks/doubao_engine.py +++ b/src/remove_ai_watermarks/doubao_engine.py @@ -25,10 +25,12 @@ from __future__ import annotations import dataclasses 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, TextMarkScan +from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine, TextMarkVariantsMixin if TYPE_CHECKING: from numpy.typing import NDArray @@ -117,6 +119,41 @@ _CONFIG = TextMarkConfig( _TINTED_ENGINE = TextMarkEngine(dataclasses.replace(_CONFIG, max_saturation=_TINTED_MAX_SATURATION)) +class _OutlinedDoubaoEngine(BottomRightBoxTextMarkEngine): + # The outlined layout's provenance-relaxed NCC floor overlaps ordinary corner + # texture. Require bright neutral strokes and edge detail in the winning box + # to distinguish the label from unmarked corners. Metadata confirms + # Doubao as the producer, not that the export retained a visible label. + _MIN_BRIGHT_NEUTRAL_FRACTION = 0.29 + _MIN_MEAN_LAPLACIAN = 10.0 + _BOX_PAD_LEFT_HEIGHT = 1.2 + _BOX_PAD_RIGHT_HEIGHT = 0.7 + + @classmethod + def _outline_pixels_ok(cls, patch: NDArray[Any]) -> bool: + if patch.size == 0: + return False + patch = image_io.to_bgr(patch) + gray = cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY) + saturation = cv2.cvtColor(patch, cv2.COLOR_BGR2HSV)[:, :, 1] + bright_neutral = float(np.mean((gray > 160) & (saturation < 60))) + edge_detail = float(np.mean(np.abs(cv2.Laplacian(gray, cv2.CV_32F)))) + return bright_neutral >= cls._MIN_BRIGHT_NEUTRAL_FRACTION and edge_detail >= cls._MIN_MEAN_LAPLACIAN + + def _scan(self, image: NDArray[Any] | None) -> TextMarkScan: + scan = super()._scan(image) + if image is None or scan.loc is None or scan.match_box is None or scan.score is None: + return scan + x0, y0, x1, y1 = scan.match_box + patch = image[ + scan.loc.y + y0 : scan.loc.y + y1 + 1, + scan.loc.x + x0 : scan.loc.x + x1 + 1, + ] + if not self._outline_pixels_ok(patch): + return dataclasses.replace(scan, score=0.0) + return scan + + def _alpha_template() -> NDArray[Any] | None: """The bundled Doubao alpha template (float [0,1]), or None.""" return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) @@ -132,11 +169,29 @@ def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float: return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG) -class DoubaoEngine(TextMarkEngine): +class DoubaoEngine(TextMarkVariantsMixin): """Detect/localize the visible Doubao "豆包AI生成" watermark (locate -> mask; mask feeds the fill).""" def __init__(self) -> None: super().__init__(_CONFIG) + self._variants = ( + _OutlinedDoubaoEngine( + dataclasses.replace( + _CONFIG, + asset_name="doubao_outline_alpha.png", + width_frac=0.28, + height_frac=0.13, + scale_basis="width", + alpha_width_frac=0.14, + alpha_height_frac=0.14 * 0.75 * 77 / 346, + max_saturation=255, + detect_frontend="contrast", + ladder=(0.95, 1.0, 1.05), + detect_ncc_threshold=0.50, + provenance_ncc_factor=0.25, + ) + ), + ) def _scan(self, image: NDArray[Any] | None) -> TextMarkScan: base = super()._scan(image) @@ -179,12 +234,15 @@ class DoubaoEngine(TextMarkEngine): det = detection if detection is not None else self.detect(image) if not det.detected or det.match_box is None: return super().footprint_mask(image, force=False, dilate=dilate, detection=det) - alpha = _alpha_template() + engine = self.variant_engine(det) + if engine is not self: + return engine.footprint_mask(image, force=False, dilate=dilate, detection=det) + alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name) if alpha is None: return super().footprint_mask(image, force=False, dilate=dilate, detection=det) radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) - return self._aligned_alpha_mask( + return engine._aligned_alpha_mask( image, det, alpha, diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 8933c77..e80372f 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -957,7 +957,8 @@ def _integrity_clashes( clashes.append( "Conflicting AI-origin attributions from independent signals: " + " vs ".join(parts) - + " -- one provenance set was likely spoofed, transplanted, or laundered." + + " -- these fields may describe different stages of an edit chain; " + "the available evidence does not establish which attribution describes the final pixels." ) # Rule 2: a camera-capture C2PA device next to an AI-generation marker. Only @@ -967,13 +968,15 @@ def _integrity_clashes( # AI edit in ONE manifest, so the AI vendor is named only from that same # manifest (C2PA issuer + SynthID provenance) -- a legitimate edit chain, not a # spoof. An EXIF/XMP generator, IPTC field, TC260 AIGC label, or second - # manifest naming AI on a camera capture is the real laundering tell. + # manifest naming AI on a camera capture records a second stage whose relation + # to the capture cannot be resolved from these fields alone. independent_ai_marker = any(grp != _C2PA_MANIFEST_SOURCE for grp in source.values()) if camera_label and camera_has_ai_marker and independent_ai_marker: vendors = ", ".join(sorted(set(ai_vendors.values()))) or "present" clashes.append( f"Camera-capture C2PA credentials ({camera_label}) coexist with AI-generation markers " - f"({vendors}) -- a genuine camera capture is not AI-generated, so the provenance is inconsistent." + f"({vendors}) -- these fields may describe a captured image followed by an AI edit; " + "the available evidence does not establish which stage describes the final pixels." ) return clashes diff --git a/src/remove_ai_watermarks/kling_engine.py b/src/remove_ai_watermarks/kling_engine.py index a53954d..de81c70 100644 --- a/src/remove_ai_watermarks/kling_engine.py +++ b/src/remove_ai_watermarks/kling_engine.py @@ -56,6 +56,7 @@ from dataclasses import replace from typing import TYPE_CHECKING, Any import cv2 +import numpy as np from remove_ai_watermarks import _text_mark_engine from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine @@ -136,8 +137,12 @@ _CONFIG = TextMarkConfig( _LATIN_CONFIG = replace( _CONFIG, asset_name="kling_latin_alpha.png", - alpha_width_frac=0.095, - alpha_height_frac=0.028, + width_frac=0.22, + height_frac=0.07, + margin_x_frac=0.002, + margin_bottom_frac=0.002, + alpha_width_frac=0.14, + alpha_height_frac=0.032, ladder=(0.9, 1.0, 1.1), detect_ncc_threshold=0.40, ) @@ -153,12 +158,37 @@ def _glyph_silhouette() -> NDArray[Any] | None: return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name) -class KlingEngine(TextMarkEngine): +class _KlingTextEngine(TextMarkEngine): + """Require glyph structure beyond a horizontal or vertical background edge.""" + + def _ladder_best( + self, image: NDArray[Any], loc: _text_mark_engine.TextMarkLocation + ) -> tuple[float, tuple[int, int, int, int] | None]: + score, box = super()._ladder_best(image, loc) + response = self._detect_response(image, loc) + silhouette = self._glyph_silhouette() + if box is None or response is None or silhouette is None: + return score, box + x, y, x1, y1 = box + patch = response[y : y1 + 1, x : x1 + 1].astype(np.float32) + template = cv2.resize(silhouette, (patch.shape[1], patch.shape[0]), interpolation=cv2.INTER_AREA) + + def glyph_detail(array: NDArray[Any]) -> NDArray[Any]: + array = array.astype(np.float32) + return array - array.mean(axis=0, keepdims=True) - array.mean(axis=1, keepdims=True) + array.mean() + + patch, template = glyph_detail(patch), glyph_detail(template) + denominator = float(np.linalg.norm(patch) * np.linalg.norm(template)) + detail_score = float((patch * template).sum()) / denominator if denominator > 1e-6 else 0.0 + return max(0.0, min(score, detail_score)), box + + +class KlingEngine(_KlingTextEngine): """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) + self._latin = _KlingTextEngine(_LATIN_CONFIG) def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection: """Return the strongest CJK or Latin Kling wordmark verdict.""" @@ -195,14 +225,15 @@ class KlingEngine(TextMarkEngine): 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) + if not det.detected: + return None + engine = self._latin if det.template_asset == _LATIN_CONFIG.asset_name else self + legacy = TextMarkEngine.footprint_mask(engine, 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( + alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name) + if alpha is not None: + core = engine._aligned_alpha_mask( image, det, alpha, diff --git a/src/remove_ai_watermarks/liblib_engine.py b/src/remove_ai_watermarks/liblib_engine.py index febab6c..d76d8d0 100644 --- a/src/remove_ai_watermarks/liblib_engine.py +++ b/src/remove_ai_watermarks/liblib_engine.py @@ -170,7 +170,7 @@ class LibLibPillEngine: """Detect the compact top-left pill only when LiblibAI is corroborated.""" def __init__(self) -> None: - self._pill = PillEngine() + self._pill = PillEngine(include_label=False) def _raw(self, image: NDArray[Any]) -> PillDetection: return self._pill.detect(image) diff --git a/src/remove_ai_watermarks/microsoft_engine.py b/src/remove_ai_watermarks/microsoft_engine.py index 39802cc..bc44d89 100644 --- a/src/remove_ai_watermarks/microsoft_engine.py +++ b/src/remove_ai_watermarks/microsoft_engine.py @@ -38,10 +38,12 @@ status was not adjudicated, and 1200 non-overlapping no-signal controls: 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 +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection +from remove_ai_watermarks._text_mark_variants import GlyphTextMarkEngine, TextMarkVariantsMixin if TYPE_CHECKING: from numpy.typing import NDArray @@ -101,8 +103,62 @@ def _alpha_template() -> NDArray[Any] | None: return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) -class MicrosoftEngine(TextMarkEngine): +class MicrosoftEngine(TextMarkVariantsMixin): """Detect/localize the measured Microsoft top-right AI badge.""" def __init__(self) -> None: super().__init__(_CONFIG) + self._variants = ( + GlyphTextMarkEngine( + replace( + _CONFIG, + asset_name="microsoft_text_alpha.png", + width_frac=0.20, + height_frac=0.065, + margin_x_frac=0.002, + margin_bottom_frac=0.002, + scale_basis="width", + alpha_width_frac=0.14, + alpha_height_frac=0.14 * 49 / 339, + detect_frontend="contrast", + ladder=(0.95, 1.0, 1.05), + detect_ncc_threshold=0.60, + provenance_ncc_factor=1.0, + max_saturation=255, + ) + ), + ) + + def footprint_mask( + self, + image: NDArray[Any] | None, + *, + force: bool = False, + dilate: int | None = None, + detection: TextMarkDetection | None = None, + ) -> NDArray[Any] | None: + """Cover the enclosing beige pill for the text-only layout.""" + 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) + engine = self.variant_engine(det) + if engine is self or not det.detected or det.match_box is None: + return super().footprint_mask(image, force=False, dilate=dilate, detection=det) + + from remove_ai_watermarks import region_eraser + + loc = engine.locate(image) + x0, y0, x1, y1 = det.match_box + pad_x = int(image.shape[1] * 0.012) + pad_y = int(image.shape[1] * 0.014) + box = ( + max(0, loc.x + x0 - pad_x), + max(0, loc.y + y0 - pad_y), + x1 - x0 + 1 + 2 * pad_x, + y1 - y0 + 1 + 2 * pad_y, + ) + return region_eraser.boxes_to_mask( + image.shape[:2], + [box], + dilate=0 if dilate is None else max(0, dilate), + ) diff --git a/src/remove_ai_watermarks/openart_engine.py b/src/remove_ai_watermarks/openart_engine.py index 318c41e..a94f9f4 100644 --- a/src/remove_ai_watermarks/openart_engine.py +++ b/src/remove_ai_watermarks/openart_engine.py @@ -3,8 +3,7 @@ OpenArt (openart.ai) stamps free-plan generations with a semi-transparent white "OpenArt" wordmark (a bowtie/infinity icon followed by the brand name) placed **near the center of the frame**, not in a corner like every other text mark -this repository detects. Confirmed from one customer-reported production case -(raiw-app, 2026-09-11, 1528x2712 portrait output): the mark sat roughly centered +this repository detects. In the observed portrait layout, the mark sat roughly centered over the subject's torso, and neither C2PA, EXIF AI tags, nor the China AIGC/TC260 label were present -- OpenArt apparently ships no machine-readable provenance signal alongside this visible mark, so a bare export was previously reported as diff --git a/src/remove_ai_watermarks/pill_engine.py b/src/remove_ai_watermarks/pill_engine.py index a6fd6bb..3ab322f 100644 --- a/src/remove_ai_watermarks/pill_engine.py +++ b/src/remove_ai_watermarks/pill_engine.py @@ -1,18 +1,20 @@ -"""Jimeng-basic 'AI生成' pill: a CAPTURE-LESS visible mark (issue #54). +"""Jimeng-basic ``AI生成`` pill visible mark (issue #54). The Jimeng free-tier TC260 label is a rounded pill with 'AI生成' in the TOP-LEFT corner -- distinct from the ``jimeng`` "★ 即梦AI" wordmark (bottom-right). It has no -captured alpha map, so unlike the other marks it is detected purely by a synthetic -silhouette; like every mark it is then removed by the shared localize -> fill: +captured alpha map for its outline. Detection therefore has two paths before the +shared localize -> fill step: - * Detect: edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``, + * A high-confidence contrast template for the measured solid ``AI生成`` label + (``assets/jimeng_label_alpha.png``). With Jimeng provenance this verified + label can bypass the flat-background safety gate. + * Edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``, synthetic, data-safe -- see ``scripts/render_pill_silhouette.py``) against the - top-left ROI, at the pill's known width fraction. The calibrated - ``_DETECT_THRESHOLD`` is 0.22. - * Remove: place the pill footprint at the matched location and inpaint it - (MI-GAN / cv2 via the registry). Quality comes from the inpaint backend, so the - silhouette need not be pixel-accurate -- which is why a synthetic render is - sufficient and no source-derived asset is committed. + top-left ROI, at the pill's known width fraction. Its calibrated + ``_DETECT_THRESHOLD`` is 0.22 and it retains the provenance and flat-background + product gates. + * Remove: mask the verified label match or the stable outline footprint and + inpaint it (MI-GAN / cv2 via the registry). Geometry uses width ~0.161*W, height ~0.091*W, top-left, margins ~0.02-0.05. @@ -27,6 +29,7 @@ import cv2 import numpy as np from remove_ai_watermarks import image_io +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine if TYPE_CHECKING: from numpy.typing import NDArray @@ -74,6 +77,32 @@ class PillDetection(NamedTuple): detected: bool confidence: float region: tuple[int, int, int, int] # x, y, w, h of the matched pill + verified_label: bool = False + + +_LABEL_CONFIG = TextMarkConfig( + name="Jimeng AI生成 pill label", + asset_name="jimeng_label_alpha.png", + corner="tl", + margin_floor=2, + width_frac=0.34, + height_frac=0.14, + margin_x_frac=0.0, + margin_bottom_frac=0.0, + max_saturation=255, + logo_min_luma=100, + tophat_delta=8, + morph_open_size=3, + detect_min_coverage=0.01, + detect_ncc_threshold=0.55, + detect_frontend="contrast", + scale_basis="width", + ladder=(0.95, 1.0, 1.05), + alpha_width_frac=0.14, + alpha_height_frac=0.14 * 128 / 353, + min_gw=24, + provenance_ncc_factor=1.0, +) def _load_silhouette() -> NDArray[Any] | None: @@ -92,7 +121,18 @@ def _grad(gray: NDArray[Any]) -> NDArray[Any]: class PillEngine: - """Detect + build the removal mask for the top-left 'AI生成' pill (edge-NCC of a synthetic silhouette).""" + """Detect the top-left ``AI生成`` label or its synthetic pill outline.""" + + def __init__(self, *, include_label: bool = True) -> None: + self._label = TextMarkEngine(_LABEL_CONFIG) if include_label else None + + @staticmethod + def _label_box(detection: TextMarkDetection) -> tuple[int, int, int, int] | None: + if not detection.detected or detection.match_box is None: + return None + x, y, _width, _height = detection.region + x0, y0, x1, y1 = detection.match_box + return x + x0, y + y0, x1 - x0 + 1, y1 - y0 + 1 def _match(self, image: NDArray[Any]) -> tuple[float, tuple[int, int, int, int]] | None: sil = _load_silhouette() @@ -113,12 +153,33 @@ class PillEngine: _, score, _, loc = cv2.minMaxLoc(res) return float(score), (int(loc[0]), int(loc[1]), tw, th) - def detect(self, image: NDArray[Any]) -> PillDetection: - m = self._match(image) - if m is None: - return PillDetection(False, 0.0, (0, 0, 0, 0)) - score, box = m - return PillDetection(score >= _DETECT_THRESHOLD, score, box) + def _detection( + self, + match: tuple[float, tuple[int, int, int, int]] | None, + label: TextMarkDetection, + ) -> PillDetection: + label_box = self._label_box(label) + if match is None: + return PillDetection(label.detected, label.confidence, label_box or label.region, label.detected) + score, box = match + return PillDetection( + score >= _DETECT_THRESHOLD or label.detected, + max(score, label.confidence), + label_box if label.detected and label_box is not None else box, + label.detected, + ) + + def detect(self, image: NDArray[Any], *, provenance: bool = False) -> PillDetection: + """Detect the outline, plus the solid label when Jimeng is corroborated.""" + label_scan = self._label.detect(image) if self._label is not None else TextMarkDetection() + label = label_scan if provenance else TextMarkDetection() + return self._detection(self._match(image), label) + + def detect_both(self, image: NDArray[Any]) -> tuple[PillDetection, PillDetection]: + """Return strict outline and Jimeng-corroborated label verdicts from one scan.""" + match = self._match(image) + label = self._label.detect(image) if self._label is not None else TextMarkDetection() + return self._detection(match, TextMarkDetection()), self._detection(match, label) def _footprint_box(self, image: NDArray[Any]) -> tuple[int, int, int, int] | None: h, w = image.shape[:2] @@ -152,19 +213,34 @@ class PillEngine: """True when the top-left footprint is flat enough for an invisible inpaint.""" return self.footprint_texture(image) <= thresh - def footprint_mask(self, image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None: + def footprint_mask( + self, + image: NDArray[Any], + *, + force: bool = False, + detection: PillDetection | None = None, + ) -> NDArray[Any] | None: """Full-frame uint8 mask (255 = pill) over the pill's known top-left region. - Uses stable GEOMETRY (a generous fixed box), not the NCC match position: the - synthetic silhouette localizes only approximately, so a match-positioned mask - leaves outline residue, while the top-left corner is negative space, so a - generous geometric box removes the pill cleanly and harmlessly. The caller + A verified label uses its detector-aligned box with measured padding. The + weaker synthetic outline uses stable geometry because its NCC position is + only approximate and a match-positioned mask leaves rim residue. The caller gates on :meth:`detect`, so a clean corner is never masked. ``force`` is - accepted for a uniform engine signature but ignored (the geometry box is - fixed regardless).""" + accepted for a uniform engine signature but ignored.""" if image is None or image.size == 0: return None - box = self._footprint_box(image) + if detection is not None and detection.verified_label: + x, y, width, height = detection.region + pad_x = int(image.shape[1] * 0.025) + pad_y = int(image.shape[1] * 0.014) + box = ( + max(0, x - pad_x), + max(0, y - pad_y), + min(image.shape[1], x + width + pad_x), + min(image.shape[0], y + height + pad_y), + ) + else: + box = self._footprint_box(image) if box is None: return None # Same primitive the shared fill uses, rather than a private zeros/fill copy. diff --git a/src/remove_ai_watermarks/samsung_engine.py b/src/remove_ai_watermarks/samsung_engine.py index 681bcf3..5ec47a5 100644 --- a/src/remove_ai_watermarks/samsung_engine.py +++ b/src/remove_ai_watermarks/samsung_engine.py @@ -25,10 +25,12 @@ signal via the registry. from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING, Any from remove_ai_watermarks import _text_mark_engine, image_io -from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection +from remove_ai_watermarks._text_mark_variants import GlyphTextMarkEngine, TextMarkVariantsMixin if TYPE_CHECKING: from numpy.typing import NDArray @@ -107,11 +109,27 @@ def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float: return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG) -class SamsungEngine(TextMarkEngine): +class SamsungEngine(TextMarkVariantsMixin): """Detect/localize the visible Samsung Galaxy AI text mark (locate -> mask; mask feeds the fill).""" def __init__(self) -> None: super().__init__(_CONFIG) + self._variants = ( + GlyphTextMarkEngine( + replace( + _CONFIG, + asset_name="samsung_ko_alpha.png", + width_frac=0.28, + height_frac=0.065, + alpha_width_frac=0.20, + alpha_height_frac=0.20 * 49 / 344, + ladder=(0.95, 1.0, 1.05), + max_saturation=255, + detect_frontend="contrast", + detect_ncc_threshold=0.46, + ) + ), + ) def footprint_mask( self, @@ -134,10 +152,29 @@ class SamsungEngine(TextMarkEngine): image = image_io.to_bgr(image) det = detection if detection is not None else self.detect(image) - alpha = _alpha_template() + engine = self.variant_engine(det) + radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) + if engine is not self and det.detected and det.match_box is not None: + from remove_ai_watermarks import region_eraser + + x0, y0, x1, y1 = det.match_box + loc = engine.locate(image) + glyph_height = y1 - y0 + 1 + return region_eraser.boxes_to_mask( + image.shape[:2], + [ + ( + max(0, loc.x + x0 - round(1.3 * glyph_height)), + loc.y + y0, + x1 - x0 + 1 + round(1.3 * glyph_height), + glyph_height, + ) + ], + dilate=radius, + ) + alpha = _text_mark_engine.load_alpha_template(engine.config.asset_name) if alpha is not None: - radius = _FOOTPRINT_DILATE if dilate is None else max(0, dilate) - aligned = self._aligned_alpha_mask( + aligned = engine._aligned_alpha_mask( image, det, alpha, diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 4e15f85..368114f 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -24,13 +24,15 @@ Entries: - ``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. - - ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less). + - ``dola`` -- ByteDance Dola "Dola AI" image disclosure, bottom-right. + - ``workbuddy`` -- Tencent WorkBuddy "AI生成 / WORKBUDDY" disclosure, bottom-right. + - ``samsung`` -- Samsung Galaxy AI Italian or Korean disclosure, bottom-left. + - ``jimeng_pill`` -- Jimeng-basic "AI生成" pill or solid label, top-left. - ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end). - ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right. - ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center. - ``liblib_pill`` -- LiblibAI compact "AI生成" pill, top-left. - - ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right. + - ``microsoft`` -- measured Microsoft white AI badge or beige text pill, top-right. - ``generic_ai_label`` -- brand-less bare "AI生成" TC260 label (no vendor wordmark), bottom-right; the fallback for a compliance stamp not tied to any tuned mark above. - ``openart`` -- OpenArt "OpenArt" wordmark (bowtie/infinity icon + brand name), @@ -448,6 +450,8 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = { "jimeng": ("jimeng_engine", "JimengEngine"), "qwen": ("qwen_engine", "QwenEngine"), "kling": ("kling_engine", "KlingEngine"), + "dola": ("dola_engine", "DolaEngine"), + "workbuddy": ("workbuddy_engine", "WorkBuddyEngine"), "yuanbao": ("yuanbao_engine", "YuanbaoEngine"), "samsung": ("samsung_engine", "SamsungEngine"), "jimeng_pill": ("pill_engine", "PillEngine"), @@ -641,29 +645,44 @@ def _text_mark( ) -# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ── -# Detection is edge-NCC of a synthetic silhouette; the mask is a fixed top-left -# geometry box (see pill_engine). Removal is the same localize -> fill as the rest. +# ── Jimeng-basic "AI生成" pill or solid label (top-left) ── +# The weak outline uses synthetic edge-NCC and fixed geometry. The verified solid +# label uses its own contrast template and detector-aligned mask (see pill_engine). def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection: - del provenance # the pill detector is provenance-independent; its relaxation lives entirely in _keep_pill - d = _engine("jimeng_pill").detect(image) - return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region) + d = _engine("jimeng_pill").detect(image, provenance=provenance) + return MarkDetection( + "jimeng_pill", + "Jimeng AI生成 pill", + "top-left", + d.detected, + d.confidence, + d.region, + engine_detection=d, + ) def _pill_detect_both(image: NDArray[Any]) -> tuple[MarkDetection, MarkDetection]: - # The pill detector is provenance-independent (`_pill_detect` discards the flag), so - # one call answers both levels. MarkDetection is frozen, so sharing it is safe. - d = _pill_detect(image) - return d, d + strict, relaxed = _engine("jimeng_pill").detect_both(image) + + def wrap(detection: Any) -> MarkDetection: + return MarkDetection( + "jimeng_pill", + "Jimeng AI生成 pill", + "top-left", + detection.detected, + detection.confidence, + detection.region, + engine_detection=detection, + ) + + return wrap(strict), wrap(relaxed) def _pill_mask( image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None ) -> NDArray[Any] | None: - # The pill mask is a fixed top-left geometry box, independent of the detection; - # accepted for the uniform _mask signature. - del detection - return _engine("jimeng_pill").footprint_mask(image, force=force) + engine_detection = detection.engine_detection if detection is not None else None + return _engine("jimeng_pill").footprint_mask(image, force=force, detection=engine_detection) def _pill_features(image: NDArray[Any]) -> dict[str, float]: @@ -732,6 +751,24 @@ _REGISTRY: tuple[KnownMark, ...] = ( platform="Tencent Yuanbao (visible 元宝 / AI生成 mark detected)", manufacturer="tencent", ), + _text_mark( + "dola", + "Dola AI image wordmark", + "bottom-right", + platform="Dola AI (visible Dola AI disclosure)", + manufacturer="bytedance", + label_regime=None, + provenance_signals=(), + ), + _text_mark( + "workbuddy", + "AI生成 / WORKBUDDY disclosure", + "bottom-right", + platform="WorkBuddy (visible AI content disclosure)", + manufacturer="tencent", + label_regime=None, + provenance_signals=(), + ), # Samsung Galaxy AI is a device editing marker (samsung_genai), not a TC260 label. _text_mark( "samsung", @@ -940,8 +977,14 @@ def _pill_suppressors() -> set[str]: return {m.key for m in _REGISTRY if m.manufacturer == pill.manufacturer and m.product != pill.product} -def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool: - """Whether to auto-remove the capture-less 'AI生成' pill given the fired marks. +def _keep_pill( + keys: set[str], + *, + provenance: frozenset[str], + footprint_flat: bool, + verified_label: bool = False, +) -> bool: + """Whether to auto-remove the ``AI生成`` pill or verified label. Pure decision (the flatness feature is precomputed at perception time and passed in). The pill detector is weak and metadata/intent confirms the platform, not pill @@ -950,23 +993,24 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo * bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives metadata-STRIPPED images: remove the pill unrestricted; * TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) -> remove ONLY when the - top-left footprint is flat enough for an invisible fill (``footprint_flat``), - so real flat-scene pills (and harmless flat false fires) are cleaned while the - damaging textured false fires are left untouched. + solid label template verifies the text, or when the weaker outline's top-left + footprint is flat enough for an invisible fill (``footprint_flat``). Real + flat-scene pills are cleaned while damaging textured false fires are left. A Doubao image is TC260 too but is not Jimeng-basic, so its mark suppresses the - pill. Marks from other TC260 manufacturers are unrelated and do not participate in - this ByteDance-family decision. + pill. The independently attributed LiblibAI pill also wins over the generic + ``AI生成`` label shape. Marks from other TC260 manufacturers are unrelated and do + not participate in this ByteDance-family decision. No confirmation at all -> never remove (blocks false fires on non-Jimeng content). The suppressor set is derived from the manufacturer and product fields rather than from the shared TC260 standard. Marks from Google, Samsung, or another TC260 manufacturer cannot enable or veto this ByteDance-specific arm.""" - if _pill_suppressors() & keys: + if "liblib_pill" in keys or _pill_suppressors() & keys: return False if "jimeng" in keys: return True if "jimeng" in provenance: - return footprint_flat + return footprint_flat or verified_label return False @@ -990,6 +1034,8 @@ def _build_candidates(image: NDArray[Any]) -> list[Candidate]: continue strict, relaxed = m.detect_both(image) feats = m.features(image) if (strict.detected or relaxed.detected) else {} + if relaxed.engine_detection is not None and getattr(relaxed.engine_detection, "verified_label", False): + feats["verified_label"] = 1.0 cands.append( Candidate( m.key, @@ -1010,7 +1056,7 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]: All policy lives here, in one place: per-mark trust resolution (:func:`resolve_trust`, which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and - the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and + the Jimeng pill/label gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and the same decision drives every caller.""" strict_keys = {c.key for c in candidates if c.detected_strict} fired: list[Decision] = [] @@ -1026,7 +1072,13 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]: if "jimeng_pill" in keys: pill = next(d for d in fired if d.candidate.key == "jimeng_pill") flat = bool(pill.candidate.features.get("footprint_flat", 0.0)) - if not _keep_pill(keys, provenance=context.provenance, footprint_flat=flat): + verified_label = bool(pill.candidate.features.get("verified_label", 0.0)) + if not _keep_pill( + keys, + provenance=context.provenance, + footprint_flat=flat, + verified_label=verified_label, + ): fired = [d for d in fired if d.candidate.key != "jimeng_pill"] return fired diff --git a/src/remove_ai_watermarks/workbuddy_engine.py b/src/remove_ai_watermarks/workbuddy_engine.py new file mode 100644 index 0000000..f65b350 --- /dev/null +++ b/src/remove_ai_watermarks/workbuddy_engine.py @@ -0,0 +1,35 @@ +"""Detect the corner AI生成 / WORKBUDDY disclosure without inferring a base model.""" + +from remove_ai_watermarks._text_mark_engine import TextMarkConfig +from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine + +_CONFIG = TextMarkConfig( + name="WorkBuddy AI disclosure", + asset_name="workbuddy_alpha.png", + corner="br", + margin_floor=4, + width_frac=0.22, + height_frac=0.13, + margin_x_frac=0.002, + margin_bottom_frac=0.002, + max_saturation=255, + logo_min_luma=110, + tophat_delta=8, + morph_open_size=3, + detect_min_coverage=0.01, + detect_ncc_threshold=0.60, + alpha_width_frac=0.12, + alpha_height_frac=0.12 * 177 / 351, + detect_frontend="tophat", + scale_basis="width", + ladder=(0.95, 1.0, 1.05), + provenance_ncc_factor=1.0, + min_gw=24, +) + + +class WorkBuddyEngine(BottomRightBoxTextMarkEngine): + """Detect and localize the two-line WorkBuddy disclosure.""" + + def __init__(self) -> None: + super().__init__(_CONFIG) diff --git a/src/remove_ai_watermarks/yuanbao_engine.py b/src/remove_ai_watermarks/yuanbao_engine.py index 129556b..79dd4e7 100644 --- a/src/remove_ai_watermarks/yuanbao_engine.py +++ b/src/remove_ai_watermarks/yuanbao_engine.py @@ -22,6 +22,7 @@ covered by this silhouette. from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING, Any from remove_ai_watermarks import _text_mark_engine @@ -29,6 +30,7 @@ from remove_ai_watermarks._text_mark_engine import ( BottomRightAnchoredEngine, TextMarkConfig, ) +from remove_ai_watermarks._text_mark_variants import BottomRightBoxTextMarkEngine, TextMarkVariantsMixin if TYPE_CHECKING: from numpy.typing import NDArray @@ -79,7 +81,7 @@ def _alpha_template() -> NDArray[Any] | None: return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) -class YuanbaoEngine(BottomRightAnchoredEngine): +class YuanbaoEngine(TextMarkVariantsMixin, BottomRightAnchoredEngine): """Detect and localize the bottom-right Yuanbao mark.""" _ANCHOR_MAX_RIGHT = 0.04 @@ -87,3 +89,36 @@ class YuanbaoEngine(BottomRightAnchoredEngine): def __init__(self) -> None: super().__init__(_CONFIG) + self._variants = ( + BottomRightBoxTextMarkEngine( + replace( + _CONFIG, + asset_name="yuanbao_compact_alpha.png", + width_frac=0.28, + height_frac=0.13, + scale_basis="width", + alpha_width_frac=0.09, + alpha_height_frac=0.09 * 238 / 387 * 0.75, + ladder=(0.95, 1.0, 1.05), + detect_ncc_threshold=0.50, + provenance_ncc_factor=0.62, + max_saturation=255, + ) + ), + BottomRightBoxTextMarkEngine( + replace( + _CONFIG, + asset_name="yuanbao_compact_gray_alpha.png", + width_frac=0.28, + height_frac=0.13, + scale_basis="width", + alpha_width_frac=0.06, + alpha_height_frac=0.06 * 238 / 387 * 0.90, + ladder=(0.95, 1.0, 1.05), + detect_frontend="gray", + detect_ncc_threshold=0.50, + provenance_ncc_factor=0.50, + max_saturation=255, + ) + ), + ) diff --git a/tests/test_kling_boundaries.py b/tests/test_kling_boundaries.py new file mode 100644 index 0000000..816f3ae --- /dev/null +++ b/tests/test_kling_boundaries.py @@ -0,0 +1,26 @@ +"""Clean UI boundaries must not become automatic Kling removal targets.""" + +import numpy as np +import pytest + +from remove_ai_watermarks._text_mark_engine import TextMarkEngine +from remove_ai_watermarks.kling_engine import KlingEngine, _KlingTextEngine +from remove_ai_watermarks.watermark_registry import Context, _build_candidates, decide + + +def test_horizontal_boundary_is_not_a_kling_wordmark(): + image = np.full((2244, 1080, 3), (175, 183, 190), dtype=np.uint8) + image[-40:] = 0 + engine = KlingEngine() + strict, relaxed = engine.detect_both(image) + assert not strict.detected + assert not relaxed.detected + assert engine.footprint_mask(image) is None + assert "kling" not in {d.candidate.key for d in decide(_build_candidates(image), Context())} + + +def test_boundary_fixture_fires_when_glyph_detail_gate_is_removed(monkeypatch: pytest.MonkeyPatch): + image = np.full((2244, 1080, 3), (175, 183, 190), dtype=np.uint8) + image[-40:] = 0 + monkeypatch.setattr(_KlingTextEngine, "_ladder_best", TextMarkEngine._ladder_best) + assert KlingEngine().detect(image).detected diff --git a/tests/test_provenance_edit_warning.py b/tests/test_provenance_edit_warning.py new file mode 100644 index 0000000..af44372 --- /dev/null +++ b/tests/test_provenance_edit_warning.py @@ -0,0 +1,20 @@ +"""Independent vendor fields cannot prove provenance fraud.""" + +from remove_ai_watermarks.identify import _integrity_clashes + + +def test_mixed_edit_provenance_preserves_sources_without_alleging_fraud(): + warnings = _integrity_clashes({"c2pa": "Google", "xai": "xAI"}, None, camera_has_ai_marker=True) + assert len(warnings) == 1 + assert "Google" in warnings[0] + assert "xAI" in warnings[0] + assert "edit" in warnings[0].lower() + assert "spoofed" not in warnings[0] + assert "laundered" not in warnings[0] + + +def test_camera_and_ai_fields_are_described_as_a_possible_edit_chain(): + warnings = _integrity_clashes({"iptc": "Adobe Firefly"}, "Leica", camera_has_ai_marker=True) + assert len(warnings) == 1 + assert "captured image followed by an AI edit" in warnings[0] + assert "inconsistent" not in warnings[0] diff --git a/tests/test_text_mark_variants.py b/tests/test_text_mark_variants.py new file mode 100644 index 0000000..6775d24 --- /dev/null +++ b/tests/test_text_mark_variants.py @@ -0,0 +1,106 @@ +"""Synthetic regressions for independently gated text-mark layouts.""" + +from __future__ import annotations + +import cv2 +import numpy as np +import pytest + +from remove_ai_watermarks import _text_mark_engine +from remove_ai_watermarks.doubao_engine import DoubaoEngine, _OutlinedDoubaoEngine +from remove_ai_watermarks.microsoft_engine import MicrosoftEngine +from remove_ai_watermarks.samsung_engine import SamsungEngine +from remove_ai_watermarks.yuanbao_engine import YuanbaoEngine + + +def _stamp_variant(engine, variant_index: int, *, width: int = 1280, height: int = 960): + variant = engine._variants[variant_index] + config = variant.config + background = 90 if config.asset_name != "microsoft_text_alpha.png" else 220 + foreground = 235 if background < 128 else 35 + clean = np.full((height, width, 3), background, np.uint8) + alpha = _text_mark_engine.load_alpha_template(config.asset_name) + assert alpha is not None + base = variant.scale_base(clean) + glyph_width = int(config.alpha_width_frac * base) + glyph_height = int(config.alpha_height_frac * base) + alpha = cv2.resize(alpha, (glyph_width, glyph_height), interpolation=cv2.INTER_LINEAR) + loc = variant.locate(clean) + x = loc.x if config.corner in {"bl", "tl"} else loc.x + loc.w - glyph_width + y = loc.y if config.corner in {"tl", "tr"} else loc.y + loc.h - glyph_height + if config.asset_name == "samsung_ko_alpha.png": + x += round(1.3 * glyph_height) + marked = clean.copy() + if config.asset_name == "microsoft_text_alpha.png": + pad_x, pad_y = int(width * 0.012), int(width * 0.014) + cv2.rectangle( + marked, + (x - pad_x, y - pad_y), + (x + glyph_width + pad_x, y + glyph_height + pad_y), + (242, 238, 232), + -1, + ) + region = marked[y : y + glyph_height, x : x + glyph_width].astype(np.float32) + opacity = alpha[:, :, None] + marked[y : y + glyph_height, x : x + glyph_width] = np.clip( + region * (1.0 - opacity) + foreground * opacity, 0, 255 + ).astype(np.uint8) + return clean, marked, (x, y, glyph_width, glyph_height) + + +@pytest.mark.parametrize( + ("engine", "asset"), + [ + (DoubaoEngine(), "doubao_outline_alpha.png"), + (YuanbaoEngine(), "yuanbao_compact_alpha.png"), + (SamsungEngine(), "samsung_ko_alpha.png"), + (MicrosoftEngine(), "microsoft_text_alpha.png"), + ], +) +def test_variant_detection_retains_winning_asset(engine, asset: str) -> None: + _clean, marked, _box = _stamp_variant(engine, 0) + detection = engine.detect(marked) + assert detection.detected + assert detection.template_asset == asset + + +def test_microsoft_text_variant_masks_the_enclosing_pill() -> None: + clean, marked, _box = _stamp_variant(MicrosoftEngine(), 0) + detection = MicrosoftEngine().detect(marked) + mask = MicrosoftEngine().footprint_mask(marked, detection=detection) + assert mask is not None + changed = np.any(clean != marked, axis=2) + assert np.count_nonzero(mask[changed]) / np.count_nonzero(changed) >= 0.98 + + +def test_samsung_korean_variant_masks_the_leading_sparkle_area() -> None: + _clean, marked, (x, y, _width, height) = _stamp_variant(SamsungEngine(), 0) + detection = SamsungEngine().detect(marked) + mask = SamsungEngine().footprint_mask(marked, detection=detection) + assert mask is not None + sparkle_x = x - height // 2 + sparkle_y = y + height // 2 + assert mask[sparkle_y, sparkle_x] == 255 + + +def test_low_confidence_cjk_layouts_require_matching_provenance() -> None: + doubao = DoubaoEngine()._variants[0].config + yuanbao_contrast, yuanbao_gray = (variant.config for variant in YuanbaoEngine()._variants) + assert (doubao.detect_ncc_threshold, doubao.provenance_ncc_factor) == (0.50, 0.25) + assert (yuanbao_contrast.detect_ncc_threshold, yuanbao_contrast.provenance_ncc_factor) == (0.50, 0.62) + assert (yuanbao_gray.detect_ncc_threshold, yuanbao_gray.provenance_ncc_factor) == (0.50, 0.50) + + +def test_doubao_provenance_does_not_confirm_a_colored_template_shape( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _clean, marked, _box = _stamp_variant(DoubaoEngine(), 0) + gray = cv2.cvtColor(marked, cv2.COLOR_BGR2GRAY).astype(np.float32) + blend = np.clip((gray - 90) / 145, 0, 1)[:, :, None] + background = np.array([30, 60, 120], np.float32) + foreground = np.array([60, 180, 250], np.float32) + colored_shape = (background * (1 - blend) + foreground * blend).astype(np.uint8) + + assert not DoubaoEngine().detect(colored_shape, provenance=True).detected + monkeypatch.setattr(_OutlinedDoubaoEngine, "_outline_pixels_ok", lambda _cls, _patch: True) + assert DoubaoEngine().detect(colored_shape, provenance=True).detected diff --git a/tests/test_visible_fill_quality.py b/tests/test_visible_fill_quality.py index 0095c60..8dd4355 100644 --- a/tests/test_visible_fill_quality.py +++ b/tests/test_visible_fill_quality.py @@ -24,6 +24,8 @@ _ALTERNATE_SIZE = { "wan": (1280, 1280), "kling": (1280, 960), "yuanbao": (1280, 960), + "dola": (1280, 960), + "workbuddy": (1280, 960), "samsung": (2304, 1728), "runninghub": (1280, 960), "baidu": (1280, 960), diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 311c7d6..9e478ad 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -22,6 +22,8 @@ class TestCatalog: "wan", "kling", "yuanbao", + "dola", + "workbuddy", "samsung", "runninghub", "baidu", @@ -117,6 +119,8 @@ class TestScan: "wan", "kling", "yuanbao", + "dola", + "workbuddy", "samsung", "runninghub", "baidu", @@ -722,12 +726,26 @@ class TestPillSuppressors: m.key for m in reg.known_marks() if m.manufacturer == pill.manufacturer and m.product != pill.product } assert reg._pill_suppressors() == expected - assert expected == {"doubao"} + assert expected == {"dola", "doubao"} def test_other_manufacturers_do_not_suppress_the_pill(self): assert reg._keep_pill({"qwen"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0) assert reg._keep_pill({"liblib", "jimeng"}, provenance=frozenset(), footprint_flat=1.0) + def test_verified_label_bypasses_texture_only_with_jimeng_provenance(self): + assert reg._keep_pill( + {"jimeng_pill"}, + provenance=frozenset({"jimeng"}), + footprint_flat=False, + verified_label=True, + ) + assert not reg._keep_pill( + {"jimeng_pill"}, + provenance=frozenset(), + footprint_flat=False, + verified_label=True, + ) + def test_pill_survives_gemini_and_samsung(self): """Neither is a TC260 labeler, and neither can put "jimeng" into provenance, so neither may veto the arm it could not have enabled."""