Add AI disclosure layouts, tighten detection and provenance warnings
No files matched your search
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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.
|
||||
|
||||
|
||||
@@ -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). |
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -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()
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||