mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Register RunningHub, Baidu, and LibLibAI visible marks; park Qingyan and MiniMax (measured)
New engines, each calibrated on its TC260 USCC cohort and validated by a full-corpus sweep (42009 files): - runninghub: top-left corner (new corner="tl"), faint mid-gray text via the new raw-grayscale "gray" detection front-end, anchor-position gate - baidu: text-run-only template (pill is a bright-blob magnet), load-bearing Doubao+Qwen rival margins, corner-extended footprint for the white tag - liblib: bottom-center (new corner="bc"), Arial silhouette (font is the discriminative lever against latin UI text), logo-extended footprint Qingyan parked (no clean-arm separation at any render/box), MiniMax/Hailuo parked (1 visible frame, the xinghui rule); silhouettes kept as starting points.
This commit is contained in:
@@ -21,13 +21,13 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
|
||||
## Scope
|
||||
|
||||
This tool removes **AI-provenance watermarks** that a platform stamps onto content **you generated yourself** — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Qwen / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is your autonomy over your own output.
|
||||
This tool removes **AI-provenance watermarks** that a platform stamps onto content **you generated yourself** — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Qwen / Kling / Baidu / LibLibAI / RunningHub / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is your autonomy over your own output.
|
||||
|
||||
It does **not** target watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled "preview" watermark whose job is to gate a purchase. Removing those is out of scope by design. `erase` is a generic, user-driven region tool for your own objects, not an automatic stock-watermark remover.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Qwen "千问AI生成" text strip, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `strict` never relaxes. There is deliberately no "assume this is AI" mode: asserting that an image is AI says nothing about *which* vendor made it or *where* the mark is, which is exactly what a gate bypass needs. If you can SEE a mark the detector missed, point at it with `erase --region x,y,w,h`, or force a known text mark by name with `--mark <name> --no-detect` -- both act on what you actually see instead of relaxing every detector at once. `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Qwen "千问AI生成" and Kling "可灵AI 3.0" text strips, the Baidu "百度 AI生成" text + tag, the LibLibAI wordmark (bottom-center), the RunningHub "RunningHub AI生成" text (top-left), and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `strict` never relaxes. There is deliberately no "assume this is AI" mode: asserting that an image is AI says nothing about *which* vendor made it or *where* the mark is, which is exactly what a gate bypass needs. If you can SEE a mark the detector missed, point at it with `erase --region x,y,w,h`, or force a known text mark by name with `--mark <name> --no-detect` -- both act on what you actually see instead of relaxing every detector at once. `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or color. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
|
||||
@@ -36,7 +36,7 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
|
||||
- **Text and face preservation (default)** — the default pipeline is a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Use `--pipeline sdxl` for plain SDXL img2img (lighter, no extra model download) on inputs without text or faces. An experimental `--pipeline qwen` runs Qwen-Image (20B, Apache-2.0) img2img, which preserves **text** (including CJK and small text) better than SDXL at equal strength; it is CUDA/cloud-class (does not fit MPS), and its strength floors are not yet certified (pass an explicit `--strength`, especially for Gemini content). Note: measured fidelity (`scripts/fidelity_metrics.py`) shows Qwen wins on text but controlnet preserves **faces** better (Qwen smooths skin more), so Qwen is not a universal upgrade. Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness). The library does not ship a face-restore extra: every approach evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned) regenerated the face via SDXL and made the output look more AI-generated than the cleaned image. The cleaned controlnet output is the least-AI face state achievable without re-introducing SynthID.
|
||||
- **Batch processing** — process entire directories
|
||||
- **Detection** — three-stage NCC watermark detection with confidence scoring
|
||||
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -62,14 +62,18 @@ It does **not** target watermarks that protect someone else's paid or copyrighte
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Localize glyph footprint + fill + metadata strip |
|
||||
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Localize glyph footprint + fill + metadata strip |
|
||||
| **Qwen / Tongyi Qianwen** (千问, Alibaba) | ✅ "千问AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Kling** (可灵, Kuaishou) | ✅ "可灵AI 3.0" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Baidu** | ✅ "百度 AI生成" text + tag (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **LibLibAI** (哩布哩布AI) | ✅ "LibLibAI" wordmark (bottom-center) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **RunningHub** | ✅ "RunningHub AI生成" text (top-left) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip |
|
||||
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), by Alibaba's Qwen / Tongyi Qianwen ("千问AI生成" corner text), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command.
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), by Alibaba's Qwen / Tongyi Qianwen ("千问AI生成" corner text), by Kuaishou's Kling ("可灵AI 3.0" corner text), by Baidu ("百度 AI生成" text + tag), by LibLibAI ("LibLibAI" wordmark, bottom-center), by RunningHub ("RunningHub AI生成", top-left), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command.
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -332,8 +336,10 @@ remove-ai-watermarks identify image.png
|
||||
|
||||
# Visible watermark only — fast, offline, CPU. --mark auto (default) removes every
|
||||
# detected known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
|
||||
# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with
|
||||
# --mark gemini / doubao / jimeng / qwen / samsung. Removal localizes each mark to a
|
||||
# Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI /
|
||||
# RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI");
|
||||
# force one with --mark gemini / doubao / jimeng / qwen / kling / baidu / liblib /
|
||||
# runninghub / samsung. Removal localizes each mark to a
|
||||
# footprint mask and inpaints it with a shared fill; --backend auto|cv2|migan|lama
|
||||
# (default auto) picks the fill (auto = LaMa > MI-GAN > cv2, best available).
|
||||
# --sensitivity auto|strict (default auto) sets how hard a borderline mark is
|
||||
|
||||
@@ -271,6 +271,32 @@ The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit
|
||||
|
||||
**The clean-arm contamination trap (load-bearing for any future calibration):** the 2026-07-18 `present: []` labels are in the vocabulary of the REGISTERED marks only, so 146 of the 432 "clean" frames sit in a TC260 cohort -- including Qwen-cohort frames visibly carrying 千问AI生成. They made up the clean arm's entire top tail (clean p99 0.37 -> 0.69). `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort from the clean arm; a gate read off the unguarded arm is meaningless.
|
||||
|
||||
## `runninghub_engine.py`
|
||||
|
||||
`runninghub_engine.py` — **thin `TextMarkEngine` subclass, registered 2026-07-22.** RunningHub (hosted ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort) "RunningHub AI生成" detector + localizer, **top-left** (the first `corner="tl"` mark), faint mid-gray latin+CJK text. Feeds `identify` as `visible_runninghub`.
|
||||
|
||||
* **`gray` front-end (the third one, added for this mark):** the mark's faint gray is suppressed by the white top-hat to clean-arm levels (positives 0.16-0.23 vs clean p99 0.31), while raw-grayscale silhouette NCC separates (positives 0.38-0.54 vs clean p99 0.264 / max 0.304 on 283 guarded clean frames). It is contrast-DEPENDENT, unlike tophat -- one method `_gray_best` serves both detection and the mask, same one-method parity contract as `_tophat_best`.
|
||||
* **Tight ladder (0.95, 1.0, 1.05) exactly on the measured 0.32-of-width:** the NCC comb is razor-sharp in size (0.537 on-size, 0.223 at +5.6% off -- Qwen's comb behaviour, measured again here), so the shared 3 rungs (nearest rung 5.6% off) collapsed the match to 0.22 and the first calibration showed no separation at all.
|
||||
* **Anchor gate in `detect`:** the full-corpus sweep (`data/spaces/_sweep_new_marks.py`, 42009 files) fired on 37 outside-cohort frames at 0.34-0.38 (hair tops, shelves, window frames, CJK banners) -- no NCC threshold separates them from the 0.381 positives. Every positive sits at the measured corner (x 0.008-0.014, y 0.005-0.007 of the frame) and every false fire off it (x 0.013-0.150, y 0.009-0.045), so detection additionally requires the match box inside x<=0.025 / y<=0.015 of the frame. 0/37 false, 4/4 positives kept.
|
||||
* **Footprint is always the detector's match box** (never the binary blob): the blob under-segments the faint head glyphs and left "Runni" unremoved (caught visually on the first removal). Gate 0.34, STRICT ONLY. Regression: `tests/test_runninghub_engine.py`.
|
||||
|
||||
## `baidu_engine.py`
|
||||
|
||||
`baidu_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** Baidu (USCC 91110000802100433B, 16-frame cohort) "百度 AI生成" detector + localizer, bottom-right: a white bold 百度 text run + a separate white rounded tag with dark "AI生成". Feeds `identify` as `visible_baidu`.
|
||||
|
||||
* **Detection keys on the 百度 text run ONLY.** A two-component (text+pill) template was measured and rejected: the solid white pill is a bright-blob magnet and both front-ends scored the clean arm at cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5). The text-only silhouette separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is still removed: the footprint extends to the corner (below).
|
||||
* **Two load-bearing rival margins** (`rivals=("doubao_alpha.png","qwen_alpha.png")`): 百度 vs 豆包 share their second glyph, and 百度 vs 千问 are near-identical after binarization -- at the 0.37 gate the template fired on 45.8% of 400 Doubao-marked frames and, on the 741-frame blind-labelled eval set, on 12 Qwen-marked frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks, Qwen's by 0.17-0.35 on Qwen marks; the 0.10 margin suppresses all crossfire at zero genuine-Baidu cost (cohort fire+m == fire).
|
||||
* **Gate history, each step measured:** 0.37 from the clean arm (max 0.352) -> 0.43 after the eval-set crossfires (the one 抖音 AI创作 fire at 0.425 named no registered rival) -> **0.48** after the full-corpus sweep put outside-cohort TRUE carriers at 0.50-0.66 vs the false arm max 0.47 (大众点评 UI, a math blackboard, an 80s banner). Cohort keeps 7/16 (all true); the sweep also found 6 metadata-STRIPPED true Baidu carriers the TC260 cohort cannot see -- the direct evidence that registration pays beyond the cohort.
|
||||
* **Custom `footprint_mask`:** the tag's flat white interior gives no top-hat response (a top-hat answers edges, not flats), so the base blob bbox ended at the text run and the fill left the tag as a ghost. The mask is the detector's match box extended RIGHT to the corner. STRICT ONLY. Regression: `tests/test_baidu_engine.py`.
|
||||
|
||||
## `liblib_engine.py`
|
||||
|
||||
`liblib_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A, 15-frame cohort) triangle logo + "LibLibAI" wordmark detector + localizer, **bottom-CENTER** (the first `corner="bc"` mark; the locate box is horizontally centered). Feeds `identify` as `visible_liblib`.
|
||||
|
||||
* **The discriminative lever is the silhouette FONT.** With the CJK house font (STHeiti) the cohort scored 0.31-0.47 against a full-corpus false arm (latin UI text bands, website screenshots) at 0.50 -- no separation at any gate. Measured across 7 candidate fonts, **Arial** lifts the cohort to 0.42-0.73 and DROPS the false arm to max 0.398: generic latin text matches the wrong font less, which is where the discrimination comes from. Gate 0.42 keeps all 8 marked cohort frames (0.43-0.59).
|
||||
* **Per-mark size floor (`_MIN_SHORT_SIDE=480`):** the one false fire with the final template was a 200x200 icon (0.444, on a 20px template). The shared `_MIN_DETECT_SHORT_SIDE` (200) is a crash guard, not a discrimination floor; the template needs ~48px to discriminate.
|
||||
* **Custom `footprint_mask`:** the base blob bbox was wrong in both directions -- it bled UP into background structure (ate a shirt's real print on the 768x1024 cohort frame) and never owned the triangle logo. The mask is the detector's match box extended LEFT by ~1.3 glyph heights (the logo is ~1.0x the glyph height, gap ~0.3x, measured on the cohort zoom). STRICT ONLY. Regression: `tests/test_liblib_engine.py`.
|
||||
|
||||
## `region_eraser.py`
|
||||
|
||||
`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
|
||||
|
||||
@@ -742,8 +742,10 @@ priority order:
|
||||
against the contamination-guarded clean arm, crossfire against doubao/jimeng. 可灵
|
||||
additionally stamps a second mark bottom-LEFT, which no current text-mark config
|
||||
expresses (the pill is top-left; a bottom-left CJK mark needs a `corner="bl"` CJK
|
||||
config -- samsung is `bl` but Latin-script and width-based). 星绘/百度 are NOT in the
|
||||
corpus in labelable quantity -- verified, do not hunt them again.
|
||||
config -- samsung is `bl` but Latin-script and width-based). 星绘 is NOT in the
|
||||
corpus in labelable quantity -- verified, do not hunt it again. (百度 WAS found
|
||||
later via the USCC cohort harvest and is registered since 2026-07-22 -- see
|
||||
"The 2026-07-22 vendor round" below.)
|
||||
|
||||
**STATUS 2026-07-21 (same day): 可灵 REGISTERED, 元宝 measured and PARKED.**
|
||||
* **可灵 (`kling_engine.py`)** -- "可灵AI 3.0" bottom-right, strict-only, gate
|
||||
@@ -794,6 +796,68 @@ to confirm the whole surface still works after a change is
|
||||
`uv run python scripts/real_examples_e2e.py` (~2 min, real corpus examples through the real
|
||||
CLI) plus `uv run python scripts/robustness_suite.py` (~3 min, adversarial inputs).
|
||||
|
||||
### The 2026-07-22 vendor round -- 3 REGISTERED (runninghub / baidu / liblib), 2 parked
|
||||
|
||||
A fresh metadata-mining pass over the whole corpus (`data/spaces/_mine_signals.py`)
|
||||
found NO new metadata signals (the channel is saturated), so the round worked the
|
||||
visible-mark cohorts (`vendor_cohort_harvest.py`, 4606 TC260 carriers / 46 entities).
|
||||
Registered, each by the qwen playbook (synthetic silhouette -> measured geometry ->
|
||||
clean-arm gate -> crossfire -> full-corpus sweep):
|
||||
|
||||
* **RunningHub (`runninghub_engine.py`)** -- "RunningHub AI生成" TOP-LEFT (a new
|
||||
`corner="tl"`), faint mid-gray text. The white top-hat suppresses it to clean-arm
|
||||
levels (positives 0.16-0.23 vs clean p99 0.31), so it introduced the third
|
||||
detection front-end, **`gray`** (raw-grayscale silhouette NCC, contrast-DEPENDENT):
|
||||
positives 0.38-0.54 vs clean max 0.295 -> gate 0.34, strict-only. The NCC comb is
|
||||
razor-sharp in size (0.537 on-size, 0.223 at +5.6%), so the ladder is a tight
|
||||
(0.95, 1.0, 1.05) exactly on the measured 0.32-of-width. Two measured traps with
|
||||
their fixes: (1) the binary blob under-segments the faint head glyphs, so the
|
||||
blob-bbox footprint left "Runni" unremoved -- the gray front-end's footprint is
|
||||
always the detector's own match box; (2) the full-corpus sweep surfaced 37/42009
|
||||
outside-cohort false fires at 0.34-0.38 (hair, shelves, CJK banners) with no NCC
|
||||
separation from the 0.381 positives -- the **anchor gate** (the match must sit at
|
||||
the measured corner, x<=0.025/y<=0.015 of the frame) rejects all 37 at zero
|
||||
positive cost.
|
||||
* **Baidu (`baidu_engine.py`)** -- "百度" white bold text + a white rounded tag
|
||||
"AI生成", bottom-right. Detection keys on the 百度 TEXT RUN ONLY: a
|
||||
text+pill template was a measured bright-blob magnet (no separation on either
|
||||
front-end). Gate history, each step measured: 0.37 from the clean arm (max 0.352);
|
||||
the 741-frame eval set then fired 14x outside the cohort and 13 were NOT the
|
||||
vendor (12x 千问 -- 百/千 are near-identical after binarization -- plus one 抖音
|
||||
AI创作 at 0.425), so `rivals=("doubao_alpha.png","qwen_alpha.png")` (both margins
|
||||
load-bearing, zero genuine cost) and the gate moved 0.37 -> 0.43; the full-corpus
|
||||
sweep then put outside-cohort true carriers at 0.50-0.66 vs the false arm max
|
||||
0.47, so the gate settled at **0.48**. Cohort: 7/16 fire (all true). The
|
||||
footprint is custom: the tag's flat white interior gives no top-hat response, so
|
||||
a blob bbox leaves the tag as a ghost -- the mask is the match box extended right
|
||||
to the corner.
|
||||
* **LibLibAI (`liblib_engine.py`)** -- triangle logo + "LibLibAI" wordmark
|
||||
**bottom-CENTER** (a new `corner="bc"`). The discriminative lever was the FONT:
|
||||
STHeiti scored the cohort 0.31-0.47 against a false arm (latin UI text bands) at
|
||||
0.50; measured across 7 fonts, **Arial** lifts the cohort to 0.42-0.73 and DROPS
|
||||
the false arm to max 0.398 (generic latin text matches the wrong font less). Gate
|
||||
0.42, strict-only; a per-mark size floor (`_MIN_SHORT_SIDE=480`) backs it (the
|
||||
one remaining false fire was a 200x200 icon on a 20px template). Custom
|
||||
footprint: match box extended left by ~1.3 glyph heights for the triangle logo
|
||||
(the blob bbox both bled into background structure -- ate a shirt's real print --
|
||||
and did not own the logo).
|
||||
|
||||
Parked, both as measured negatives with the silhouette kept in
|
||||
`render_vendor_silhouettes.py` as the starting point:
|
||||
|
||||
* **Zhipu Qingyan (清言·AI生成)** -- 7-frame cohort, white semi-transparent text +
|
||||
swirl logo. On both front-ends the cohort scores 0.34-0.39 vs clean max
|
||||
0.34-0.37 -- no separation at any render/box setting (text-only and
|
||||
logo-composite templates, two CJK fonts). Same wall class as 元宝.
|
||||
* **MiniMax / Hailuo AI** -- only 1 of 6 cohort frames carries a visible mark
|
||||
(Hailuo is a video product; the mark is a video-frame stamp). The xinghui rule:
|
||||
nothing registered off a single frame.
|
||||
|
||||
The full-corpus sweep harness is `data/spaces/_sweep_new_marks.py` (read-only,
|
||||
gitignored); its artifact `_new_marks_sweep.jsonl` records every fire. The sweep
|
||||
also proved the outside-cohort value of registration: 6 metadata-STRIPPED true
|
||||
Baidu carriers the TC260 cohort cannot see are now detected and cleaned.
|
||||
|
||||
### The 千问 harvest (2026-07-21) -- RESOLVED, registered the same day
|
||||
|
||||
**The unlock: the TC260 label is not anonymous.** Its `ContentProducer` field carries the
|
||||
|
||||
@@ -70,7 +70,7 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp
|
||||
|
||||
**星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred.
|
||||
|
||||
**Baidu: could not establish.** No primary or credible secondary source names the exact string or position; it could be `百度AI生成`, `文心一格AI生成`, or product-specific. Harvest the glyphs from corpus positives, not the web.
|
||||
**Baidu: RESOLVED 2026-07-22, registered (`baidu_engine.py`).** The mark is a white bold "百度" text run + a separate white rounded tag with dark "AI生成", bottom-right -- settled by the TC260 USCC cohort harvest (16 frames, USCC 91110000802100433B), not by web research. Detection keys on the text run only; details in `docs/module-internals.md`.
|
||||
|
||||
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict (bottom-left vs bottom-right) and one claims newer Meta models dropped the visible mark for invisible watermarking; none survived a fetch. Do NOT encode a corner without a corpus sample. Meta also embeds IPTC + invisible watermarks, which `identify` already reads. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
|
||||
|
||||
|
||||
@@ -127,6 +127,33 @@ MARKS = {
|
||||
# max on a diverse clean arm, so registration is a gate pick (0.42) the moment
|
||||
# more unique carriers arrive.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
# RunningHub (ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort
|
||||
# 2026-07-22): white one-line "RunningHub AI生成" text mark.
|
||||
"runninghub_alpha.png": "RunningHub AI生成",
|
||||
# LibLibAI / 哩布哩布AI (USCC 91110105MACJ6K1C8A, 15-frame cohort): white
|
||||
# "LibLibAI" wordmark with a triangle logo (logo not rendered, logos vary).
|
||||
"liblib_alpha.png": "LibLibAI",
|
||||
# Zhipu Qingyan (USCC 91110108MA01KP2T5U, 7-frame cohort): white bold
|
||||
# "清言·AI生成" with a circular logo (logo not rendered). PARKED 2026-07-22
|
||||
# as a measured negative: on both front-ends the cohort scores 0.34-0.39
|
||||
# against a clean-arm max of 0.34-0.37 -- no separation at any render/box
|
||||
# setting (text-only and logo-composite templates both plateau ~0.34 raw;
|
||||
# the white semi-transparent text on variable backgrounds is the wall).
|
||||
# Silhouette stays as the starting point for a structural/learned lever.
|
||||
"qingyan_alpha.png": "清言·AI生成",
|
||||
# MiniMax / Hailuo (6-frame cohort): "MINIMAX" + "Hailuo AI" latin wordmarks.
|
||||
# PARKED 2026-07-22: only 1 of the 6 cohort frames carries a visible mark --
|
||||
# nothing to calibrate recall against (the xinghui rule). Registration is a
|
||||
# gate pick once more unique carriers arrive.
|
||||
"hailuo_alpha.png": "Hailuo AI",
|
||||
# Baidu (USCC 91110000802100433B, 16-frame cohort): white bold "百度" text
|
||||
# + a separate white rounded tag with dark "AI生成", bottom-right. Detection
|
||||
# keys on the 百度 text run ONLY: a two-component template (text+pill) scored
|
||||
# at clean-arm levels (pill = bright-blob magnet, clean p95 0.45-0.55 vs cohort
|
||||
# ~0.5, no separation on either front-end, 2026-07-22); the text-only silhouette
|
||||
# separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is removed
|
||||
# with the mark because the fill blob covers both bright components.
|
||||
"baidu_alpha.png": "百度",
|
||||
}
|
||||
|
||||
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
|
||||
@@ -136,6 +163,15 @@ MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
# tight gap + stroke dilation + shear -0.75 reaches 0.65-0.70 on the same frames,
|
||||
# at/above the real-vs-real ceiling (~0.6).
|
||||
"yuanbao_alpha.png": {"gap_frac": 0.05, "dilate": 2, "shear": -0.75},
|
||||
# Qingyan's real stamp is a heavier weight than STHeiti Medium -- Hiragino
|
||||
# Sans GB W6 matches the measured stroke (2026-07-22; with Medium the
|
||||
# silhouette aspect came out 0.19 vs the real 0.28 and NCC plateaued ~0.3).
|
||||
"qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2},
|
||||
# LibLibAI's wordmark is set in an Arial-class grotesque, not STHeiti:
|
||||
# measured 2026-07-22 across 7 candidate fonts, Arial lifts the cohort
|
||||
# positives from 0.31-0.47 to 0.42-0.73 while the full-corpus false-fire arm
|
||||
# DROPS to max 0.398 (generic latin UI text matches the wrong font less).
|
||||
"liblib_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial.ttf"},
|
||||
}
|
||||
|
||||
|
||||
@@ -151,16 +187,18 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n
|
||||
gap_frac = float(opts.get("gap_frac", 0.15))
|
||||
dilate = int(opts.get("dilate", 0))
|
||||
shear_k = float(opts.get("shear", 0.0))
|
||||
font_path = str(opts.get("font", _FONT))
|
||||
font_index = int(opts.get("font_index", 0))
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
lines = text.split("\n")
|
||||
size = 8
|
||||
while size < 200: # grow until the LONGEST line fills the target width
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
f = ImageFont.truetype(font_path, size, index=font_index)
|
||||
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
font = ImageFont.truetype(font_path, size, index=font_index)
|
||||
boxes = [d0.textbbox((0, 0), ln, font=font) for ln in lines]
|
||||
line_h = max(bb[3] - bb[1] for bb in boxes)
|
||||
gap = max(1, int(line_h * gap_frac))
|
||||
|
||||
@@ -538,6 +538,12 @@ def main() -> None:
|
||||
ap.add_argument("--name", default="", help="label for output files (defaults to the asset stem)")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--scale-basis", choices=("short", "width"), default="short")
|
||||
ap.add_argument(
|
||||
"--corner",
|
||||
choices=("br", "bl", "tl"),
|
||||
default=None,
|
||||
help="override the inherited br corner (e.g. tl for RunningHub)",
|
||||
)
|
||||
ap.add_argument("--sheets", action="store_true")
|
||||
ap.add_argument(
|
||||
"--fit-geometry",
|
||||
@@ -573,6 +579,8 @@ def main() -> None:
|
||||
):
|
||||
if arg is not None:
|
||||
overrides[field] = arg
|
||||
if a.corner is not None:
|
||||
overrides["corner"] = a.corner
|
||||
if ladder is not None:
|
||||
overrides["ladder"] = ladder
|
||||
|
||||
|
||||
@@ -97,7 +97,9 @@ class TextMarkConfig:
|
||||
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal["br", "bl"] # bottom-right (Doubao/Jimeng) or bottom-left (Samsung)
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "bc"
|
||||
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
@@ -125,7 +127,12 @@ class TextMarkConfig:
|
||||
# correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
|
||||
# top-hat response against a soft template and never binarizes. See
|
||||
# TextMarkEngine.tophat_response for the measurement that motivated the split.
|
||||
detect_frontend: Literal["binary", "tophat"] = "binary"
|
||||
# "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
|
||||
# for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
|
||||
# max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
|
||||
# vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
|
||||
# p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
|
||||
detect_frontend: Literal["binary", "tophat", "gray"] = "binary"
|
||||
# Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
|
||||
template_blur: float = 0.0
|
||||
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
|
||||
@@ -391,6 +398,42 @@ class TextMarkEngine:
|
||||
"""The detection score alone -- the box the removal mask needs is discarded here."""
|
||||
return self._tophat_best(image, loc)[0]
|
||||
|
||||
def _gray_best(self, image: NDArray[Any], loc: TextMarkLocation) -> tuple[float, tuple[int, int, int, int] | None]:
|
||||
"""Best TM_CCOEFF_NORMED of the silhouette against the raw GRAYSCALE ROI, and
|
||||
the ROI-local box (x0, y0, x1, y1) of that best match.
|
||||
|
||||
Mirrors :meth:`_tophat_best` (same ladder sweep, same one-method contract so
|
||||
detection and the removal mask can never drift), but skips the top-hat
|
||||
entirely: the RunningHub mark is a faint mid-gray text the top-hat's
|
||||
background subtraction suppresses to clean-arm levels, while raw gray NCC
|
||||
separates (see ``TextMarkConfig.detect_frontend``). Contrast-DEPENDENT by
|
||||
construction, so the gate must be picked against the clean arm, which is
|
||||
what ``scripts/vendor_mark_calibrate.py`` does.
|
||||
"""
|
||||
c = self.config
|
||||
x, y, bw, bh = loc.bbox
|
||||
if bh < 16 or bw < 16:
|
||||
return (0.0, None)
|
||||
roi = cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
|
||||
sil = self._glyph_silhouette()
|
||||
if sil is None:
|
||||
return (0.0, None)
|
||||
base = self.scale_base(image)
|
||||
best_score = 0.0
|
||||
best_box: tuple[int, int, int, int] | None = None
|
||||
for scale in c.ladder:
|
||||
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
|
||||
gh = max(4, int(c.alpha_height_frac * base * scale))
|
||||
if gw >= roi.shape[1] or gh >= roi.shape[0]:
|
||||
continue
|
||||
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
result = cv2.matchTemplate(roi, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
_, score, _, top_left = cv2.minMaxLoc(result)
|
||||
if score > best_score:
|
||||
tx, ty = int(top_left[0]), int(top_left[1])
|
||||
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
|
||||
return (best_score, best_box)
|
||||
|
||||
def scale_base(self, image: NDArray[Any]) -> int:
|
||||
"""The image dimension this mark's geometry scales with.
|
||||
|
||||
@@ -433,8 +476,14 @@ class TextMarkEngine:
|
||||
wm_h = max(16, int(base * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
x = max(0, w - margin_x - wm_w) if c.corner == "br" else min(margin_x, max(0, w - wm_w))
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
if c.corner == "br":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
@@ -526,6 +575,15 @@ class TextMarkEngine:
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (tophat): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if c.detect_frontend == "gray":
|
||||
# Same no-coverage-gate reasoning as tophat: the gray front-end never
|
||||
# binarizes, so a blob-area heuristic does not apply to it either.
|
||||
score = self._gray_best(image, loc)[0]
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (gray): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if coverage >= c.detect_min_coverage:
|
||||
score = self._template_match_score(box, self.scale_base(image))
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
@@ -578,7 +636,14 @@ class TextMarkEngine:
|
||||
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
|
||||
ys, xs = np.where(glyph > 0)
|
||||
box: tuple[int, int, int, int] | None = None
|
||||
if xs.size >= self._MIN_GLYPH_PIXELS:
|
||||
if self.config.detect_frontend == "gray" and self.detect(image).detected:
|
||||
# The gray front-end exists for marks the top-hat under-segments, so the
|
||||
# binary blob is NOT authoritative here: trusting it first bounded the
|
||||
# fill by a PARTIAL blob (the faint head glyphs dropped out) and left the
|
||||
# leftmost "Runni" of "RunningHub AI生成" unremoved (2026-07-22). Use the
|
||||
# detector's own best-match box, same as the tophat faint path below.
|
||||
_, box = self._gray_best(image, loc)
|
||||
elif xs.size >= self._MIN_GLYPH_PIXELS:
|
||||
box = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()))
|
||||
elif self.config.detect_frontend == "tophat" and self.detect(image).detected:
|
||||
# A mark found only by the CONTINUOUS front-end has no binary glyph blob to
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,203 @@
|
||||
"""Baidu visible watermark detector/localizer.
|
||||
|
||||
Baidu stamps its generations with a white bold "百度" text run plus a separate
|
||||
white rounded tag carrying dark "AI生成", bottom-right -- the China TC260
|
||||
explicit AIGC label. Detection keys on the **百度 text run only**: a
|
||||
two-component template (text + pill tag) was measured and REJECTED -- the solid
|
||||
white pill is a bright-blob magnet and both front-ends scored the clean arm at
|
||||
cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5,
|
||||
2026-07-22). The text-only silhouette separates cleanly (below). The white tag
|
||||
is still removed with the mark: the fill blob covers both bright components in
|
||||
the corner box.
|
||||
|
||||
Removal is the shared **localize -> fill** (:meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only Baidu's tuned
|
||||
:class:`TextMarkConfig` (``assets/baidu_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
Measured on the vendor cohort (16 TC260 carriers whose producer USCC
|
||||
91110000802100433B names Baidu, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The 百度 text run is 0.090 of the SHORT side wide (measured on 720/768/
|
||||
1024-px frames), with its right edge ~0.099 of short off the right edge
|
||||
(the pill tag sits between the text and the corner), bottom margin
|
||||
~0.006; the locate box below covers the whole mark (text + tag).
|
||||
* Gate 0.43 (tophat front-end): on 278 hand-labelled clean frames
|
||||
(cohort-contamination-guarded) the max is 0.352 / p99 0.314, and the
|
||||
visibly-marked cohort frames score 0.386-0.65. Picked over the clean-arm
|
||||
0.37 after a full-corpus check on the 741-frame blind-labelled eval set
|
||||
surfaced 13 cross-fires at 0.38-0.43 (12 Qwen marks + one 抖音 mark) --
|
||||
see DETECT_NCC_THRESHOLD below. At 0.43 the cohort keeps 7 detections
|
||||
(0.61-0.65) and the whole 741 set fires only on the true Baidu frame.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the cohort is small (16) and
|
||||
the sub-gate band is unmeasured, so no provenance relaxation exists.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (measured basis). The
|
||||
# box covers the text run AND the pill tag to its right (tag right edge ~0.002
|
||||
# off the frame edge, text run left edge ~0.19 off).
|
||||
WM_WIDTH_FRAC = 0.25
|
||||
WM_HEIGHT_FRAC = 0.07
|
||||
MARGIN_RIGHT_FRAC = 0.002
|
||||
MARGIN_BOTTOM_FRAC = 0.002
|
||||
|
||||
# Glyph appearance: white bold text on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 278 hand-labelled clean frames
|
||||
# (clean p99 0.314 / max 0.352), THEN raised 0.37 -> 0.43 after a full-corpus
|
||||
# check: on the 741-frame blind-labelled eval set the 0.37 gate fired 14 times
|
||||
# outside the cohort, and only ONE was the vendor -- 12 were 千问AI生成 (Qwen)
|
||||
# marks (the 百/千 first glyphs are near-identical after binarization) and one
|
||||
# was a 抖音 AI创作 mark at 0.425. The Qwen fires are handled by the rival
|
||||
# margin (Qwen scores 0.58-0.76 there, beating Baidu by 0.17-0.35), but the
|
||||
# 抖音 one named no registered rival, so the gate moved above it. Cost: the
|
||||
# cohort's low trio at 0.386 (3 genuine marks) -- precision over recall on a
|
||||
# small cohort. Raised again 0.43 -> 0.48 after the full-corpus sweep
|
||||
# (2026-07-22): outside-cohort true Baidu carriers score 0.50-0.66 while the
|
||||
# false fires (大众点评 UI, a math blackboard, an 80s banner, a checkerboard)
|
||||
# top out at 0.47. Remaining cohort detections: 7 at 0.61-0.65, plus the 6
|
||||
# metadata-stripped true carriers the cohort cannot see.
|
||||
DETECT_NCC_THRESHOLD = 0.48
|
||||
|
||||
# Detection-silhouette geometry (fraction of the short side): the 百度 text run
|
||||
# only, measured 0.090 wide with aspect 0.51.
|
||||
_ALPHA_WIDTH_FRAC = 0.090
|
||||
_ALPHA_HEIGHT_FRAC = 0.046
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine), so the
|
||||
# nominal sits exactly on the measured 0.090 with +-5% rungs.
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Baidu",
|
||||
asset_name="baidu_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# Load-bearing rival margins (crossfire measured 2026-07-22): the 百度 and
|
||||
# 豆包 silhouettes share their second glyph and a similar first, and 百度 vs
|
||||
# 千问 are near-identical after binarization -- at the 0.37 gate this
|
||||
# template fires on 45.8% of 400 Doubao-marked frames AND on Qwen-marked
|
||||
# frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks,
|
||||
# Qwen's by 0.17-0.35 on Qwen marks, so the 0.10 margin suppresses all of
|
||||
# that crossfire at zero genuine-Baidu cost (cohort fire+m == fire).
|
||||
rivals=("doubao_alpha.png", "qwen_alpha.png"),
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
BaiduDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Baidu alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "百度" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Baidu glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class BaiduEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Baidu "百度 AI生成" mark (bottom-right; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the WHOLE mark (text run + the pill tag to its right).
|
||||
|
||||
The base class's blob-bbox footprint UNDERCOVERS this mark: the white tag's
|
||||
flat interior gives no top-hat response (a top-hat answers edges, not flats),
|
||||
so the blob ends at the text run and the fill leaves the tag's right half as
|
||||
a ghost (measured 2026-07-22 on the 768x1024 cohort frame: blob bbox x
|
||||
632..746 vs the tag ending ~758). The layout is measured and fixed -- the
|
||||
text run is at the left of the locate box, the tag runs to the corner -- so
|
||||
the footprint is the detector's match box extended RIGHT to the corner.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, _gx1, gy1 = box
|
||||
pad = max(4, int(0.15 * bh))
|
||||
rx1 = max(0, bx + gx0 - pad)
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + bw) # the tag runs to the corner end of the box
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -449,6 +449,9 @@ _VISIBLE_MARK_PLATFORM = {
|
||||
"qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
|
||||
"kling": "Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
|
||||
"samsung": "Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
|
||||
"runninghub": "RunningHub (visible RunningHub AI生成 mark detected)",
|
||||
"baidu": "Baidu (visible 百度 AI生成 mark detected)",
|
||||
"liblib": "LibLibAI (visible LibLibAI mark detected)",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""LibLibAI visible watermark detector/localizer.
|
||||
|
||||
LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a
|
||||
white triangle logo + "LibLibAI" latin wordmark at **bottom-center** (not a
|
||||
corner -- the locate box is horizontally centered). Detection matches the
|
||||
bundled font-rendered "LibLibAI" silhouette (the triangle logo is NOT rendered
|
||||
-- logos vary, the wordmark discriminates); removal is the shared **localize ->
|
||||
fill** (the glyph blob covers logo + wordmark, both bright).
|
||||
|
||||
This module supplies only LibLibAI's tuned :class:`TextMarkConfig`
|
||||
(``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``,
|
||||
never cut from an upload).
|
||||
|
||||
Measured on the vendor cohort (15 TC260 carriers, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The wordmark is ~0.10 of the frame WIDTH wide, centered horizontally, its
|
||||
baseline ~0.94-0.95 of the height; consistent across 768..2240-px frames.
|
||||
* The silhouette font is Arial, NOT the STHeiti the CJK marks use: the real
|
||||
wordmark is a grotesque, and measured across 7 candidate fonts Arial lifts
|
||||
the cohort positives from 0.31-0.47 to 0.42-0.73 while the full-corpus
|
||||
false arm (latin UI text) drops to max 0.398. A 200x200 icon false-fired
|
||||
at 0.444, so a per-mark size floor (``_MIN_SHORT_SIDE``) backs the gate.
|
||||
* Gate 0.42 (tophat front-end): false arm max 0.398, cohort 0.43-0.59.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): small cohort, the relaxed band
|
||||
is unmeasured.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (measured basis). The box is
|
||||
# horizontally centered (corner="bc") and covers the logo + wordmark with NCC
|
||||
# slack around the measured 0.10 width.
|
||||
WM_WIDTH_FRAC = 0.20
|
||||
WM_HEIGHT_FRAC = 0.09
|
||||
MARGIN_BOTTOM_FRAC = 0.02
|
||||
|
||||
# Glyph appearance: white wordmark on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# (clean p99 0.315 / max 0.367) and re-measured after the font fix: the wordmark
|
||||
# is set in an Arial-class grotesque, and the Arial silhouette lifts the cohort
|
||||
# positives to 0.43-0.59 while the full-corpus false arm (latin UI text bands,
|
||||
# website screenshots) drops to max 0.398 -- generic latin text matches the
|
||||
# wrong font less, which is exactly where the discrimination comes from. Gate
|
||||
# 0.42 keeps all 8 marked cohort frames with a 0.022 margin over the false arm.
|
||||
DETECT_NCC_THRESHOLD = 0.42
|
||||
|
||||
# Detection-silhouette geometry (fraction of the frame width): the wordmark,
|
||||
# measured 0.10 wide with aspect 0.26.
|
||||
_ALPHA_WIDTH_FRAC = 0.10
|
||||
_ALPHA_HEIGHT_FRAC = 0.026
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine).
|
||||
_LADDER = (0.9, 1.0, 1.1)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="LibLibAI",
|
||||
asset_name="liblib_alpha.png",
|
||||
corner="bc",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=0.0, # unused for corner="bc" (horizontally centered)
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="width",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
LibLibDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled LibLibAI alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "LibLibAI" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the LibLibAI glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class LibLibEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill)."""
|
||||
|
||||
# Per-mark size floor: the wordmark template is 0.10 of the frame width, so
|
||||
# below ~480px short side it degrades under ~48px -- the one full-corpus
|
||||
# false fire with the final Arial template was a 200x200 icon (0.444, above
|
||||
# the gate, on a 20px template; measured 2026-07-22). The smallest true
|
||||
# carrier in the cohort is 768px.
|
||||
_MIN_SHORT_SIDE = 480
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def detect(self, image: NDArray[Any] | None, *, provenance: bool = False) -> TextMarkDetection:
|
||||
if image is None or not image.size or min(image.shape[:2]) < self._MIN_SHORT_SIDE:
|
||||
return TextMarkDetection()
|
||||
return super().detect(image, provenance=provenance)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the logo + wordmark, bounded by the detector's match box.
|
||||
|
||||
The base class's blob-bbox footprint is wrong in both directions here: the
|
||||
blob bleeds UP into bright background structure (on the 768x1024 cohort
|
||||
frame it reached y 931 and the fill ate the shirt's own print) and it does
|
||||
not own the triangle logo anyway. The match box bounds the wordmark exactly
|
||||
(that is what the NCC localized); the logo sits its own height to the LEFT
|
||||
of the text (measured on the cohort zoom: logo ~1.0x the glyph height, gap
|
||||
~0.3x), so the footprint is the match box extended left by ~1.3 heights.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, gx1, gy1 = box
|
||||
gh = gy1 - gy0 + 1
|
||||
pad = max(3, int(0.25 * gh))
|
||||
rx1 = max(0, bx + gx0 - int(1.3 * gh)) # the triangle logo, left of the text
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + gx1 + 1 + pad)
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -0,0 +1,180 @@
|
||||
"""RunningHub visible watermark detector/localizer.
|
||||
|
||||
RunningHub (a hosted ComfyUI platform, USCC 91340100MAEB4N8H76) stamps its
|
||||
generations with a faint light-gray "RunningHub AI生成" text mark in the
|
||||
**top-left** corner -- the China TC260 explicit AIGC label, but placed top-left
|
||||
(unlike the GB 45438-2025 house style bottom-right of Doubao/Qwen/Kling) and
|
||||
rendered in a mid-gray that the white top-hat front-end suppresses to clean-arm
|
||||
levels.
|
||||
|
||||
Detection therefore uses the ``gray`` front-end (raw-grayscale silhouette NCC,
|
||||
see ``TextMarkConfig.detect_frontend``); removal is the shared **localize ->
|
||||
fill** (the detector's best-match box feeds :meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only RunningHub's tuned
|
||||
:class:`TextMarkConfig` (``assets/runninghub_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
EVERY tuned number below was measured on the vendor cohort (73 TC260 carriers
|
||||
whose producer USCC names the entity, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* Only ~4 of the 73 cohort frames carry a visible mark (the rest are
|
||||
metadata-only TC260 carriers -- the platform labels frames it does not
|
||||
stamp), so recall of visible marks is 4/4 but the cohort fire rate is not
|
||||
a recall estimate. Positions/geometry are consistent across the positives.
|
||||
* The mark's width is ~0.32 of the frame WIDTH (0.319 measured on 832/1080/
|
||||
1536-wide frames) at ~0.008/0.006 x/y margins; the locate box below covers
|
||||
it with NCC slack.
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.128) at the
|
||||
measured width (0.27 * 1.25 rung ~= 0.3375 >= 0.32), per the standing rule
|
||||
that it is measured, not inherited.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is
|
||||
contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is
|
||||
unmeasured beyond the clean arm, so no provenance relaxation exists.
|
||||
* Gate 0.34: on 283 hand-labelled clean frames (cohort-contamination-guarded)
|
||||
corner-anchored gray NCC p99 is 0.264 / max 0.304, while the 4 positives
|
||||
score 0.38-0.54. 0.34 sits above the clean max with a small margin; the
|
||||
positives are few, so the margin is deliberately thin on the recall side.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (the measured basis: every
|
||||
# positive is portrait, where width == short side). The mark hugs the top-left
|
||||
# corner (~0.008 of width off the left edge, ~0.006 of height off the top).
|
||||
WM_WIDTH_FRAC = 0.45
|
||||
WM_HEIGHT_FRAC = 0.10
|
||||
MARGIN_LEFT_FRAC = 0.002
|
||||
MARGIN_TOP_FRAC = 0.002
|
||||
|
||||
# Glyph appearance fields are unused by the gray front-end (it never binarizes)
|
||||
# and kept only for config parity with the other text marks.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the gray front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labelled clean frames:
|
||||
# corner-anchored gray NCC, clean p99 0.264 / max 0.304; positives 0.38-0.54.
|
||||
DETECT_NCC_THRESHOLD = 0.34
|
||||
|
||||
# Detection-silhouette geometry (fraction of the image width), measured on the
|
||||
# positives: mark width is ~0.320 of width on all three frame sizes (266px at 832,
|
||||
# 345px at 1080, 491px at 1536), and the NCC is razor-sharp in size (0.537 on-size,
|
||||
# 0.223 at +5.6% -- the same comb behaviour Qwen measured), so the nominal sits
|
||||
# exactly on the measured size with a TIGHT ladder around it, not the shared 3 rungs
|
||||
# (whose nearest rung landed 5.6% off and collapsed the match to 0.22).
|
||||
_ALPHA_WIDTH_FRAC = 0.32
|
||||
_ALPHA_HEIGHT_FRAC = 0.04
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="RunningHub",
|
||||
asset_name="runninghub_alpha.png",
|
||||
corner="tl",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_LEFT_FRAC,
|
||||
margin_bottom_frac=MARGIN_TOP_FRAC, # top margin for corner="tl"
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="gray",
|
||||
scale_basis="width", # measured: mark width tracks the frame width (0.32)
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: contrast-dependent gray NCC; the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
RunningHubDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled RunningHub alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "RunningHub AI生成" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the RunningHub glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class RunningHubEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible RunningHub "RunningHub AI生成" mark (top-left; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
# Anchor window for the match position, as a fraction of the FRAME: the true
|
||||
# mark hugs the corner (measured x 0.008-0.014, y 0.005-0.007 of the frame on
|
||||
# every cohort positive), while the full-corpus false fires (hair, shelves,
|
||||
# window frames, CJK banners -- 37 of 42009 outside-cohort frames at the 0.34
|
||||
# gate, 2026-07-22 sweep) match off-anchor at x 0.013-0.150 / y 0.009-0.045.
|
||||
# No NCC gate separates them (false max 0.384 vs two positives at 0.381), but
|
||||
# position does: every false fire sits outside this window, every positive
|
||||
# inside. Contrast-dependent raw-gray NCC keys on "some text-like structure
|
||||
# anywhere in the box"; the anchor is what makes it about THIS mark.
|
||||
_ANCHOR_MAX_X = 0.025
|
||||
_ANCHOR_MAX_Y = 0.015
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
det = super().detect(image, provenance=provenance)
|
||||
if not det.detected:
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
_, box = self._gray_best(image, loc)
|
||||
if box is None:
|
||||
det.detected = False
|
||||
return det
|
||||
h, w = image.shape[:2]
|
||||
ax = (loc.x + box[0]) / w
|
||||
ay = (loc.y + box[1]) / h
|
||||
if ax > self._ANCHOR_MAX_X or ay > self._ANCHOR_MAX_Y:
|
||||
logger.debug(
|
||||
"RunningHub detect: score %.3f but match off-anchor (x=%.3f y=%.3f); demoting.",
|
||||
det.confidence,
|
||||
ax,
|
||||
ay,
|
||||
)
|
||||
det.detected = False
|
||||
return det
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -24,6 +24,9 @@ Entries:
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
|
||||
- ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end).
|
||||
- ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right.
|
||||
- ``liblib`` -- LibLibAI "LibLibAI" wordmark, bottom-center.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -88,6 +91,9 @@ _PRODUCT_OF: dict[str, str] = {
|
||||
"qwen": "qwen",
|
||||
"kling": "kling",
|
||||
"samsung": "samsung",
|
||||
"runninghub": "runninghub",
|
||||
"baidu": "baidu",
|
||||
"liblib": "liblib",
|
||||
}
|
||||
|
||||
|
||||
@@ -373,6 +379,18 @@ def _engine(key: str) -> Any:
|
||||
from remove_ai_watermarks.pill_engine import PillEngine
|
||||
|
||||
_engines[key] = PillEngine()
|
||||
elif key == "runninghub":
|
||||
from remove_ai_watermarks.runninghub_engine import RunningHubEngine
|
||||
|
||||
_engines[key] = RunningHubEngine()
|
||||
elif key == "baidu":
|
||||
from remove_ai_watermarks.baidu_engine import BaiduEngine
|
||||
|
||||
_engines[key] = BaiduEngine()
|
||||
elif key == "liblib":
|
||||
from remove_ai_watermarks.liblib_engine import LibLibEngine
|
||||
|
||||
_engines[key] = LibLibEngine()
|
||||
else: # pragma: no cover - guarded by the registry keys
|
||||
raise KeyError(key)
|
||||
return _engines[key]
|
||||
@@ -517,6 +535,9 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
_text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"),
|
||||
_text_mark("kling", "Kling 可灵AI 3.0 text", "bottom-right"),
|
||||
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
|
||||
_text_mark("runninghub", "RunningHub AI生成 text", "top-left"),
|
||||
_text_mark("baidu", "Baidu 百度 AI生成 text", "bottom-right"),
|
||||
_text_mark("liblib", "LibLibAI wordmark", "bottom-center"),
|
||||
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
|
||||
)
|
||||
|
||||
@@ -597,7 +618,7 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo
|
||||
Doubao detection; a Qwen image likewise (another vendor's bottom-right mark naming
|
||||
its own product), so a confident Qwen detection suppresses the pill the same way.
|
||||
No confirmation at all -> never remove (blocks false fires on non-Jimeng content)."""
|
||||
if "doubao" in keys or "qwen" in keys or "kling" in keys:
|
||||
if "doubao" in keys or "qwen" in keys or "kling" in keys or "runninghub" in keys or "baidu" in keys:
|
||||
return False
|
||||
if "jimeng" in keys:
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the Baidu ("百度 AI生成") visible-watermark engine.
|
||||
|
||||
Every tuned constant in ``baidu_engine`` was measured on the 16-frame vendor
|
||||
cohort (2026-07-22); these tests pin the load-bearing ones: detection keys on
|
||||
the 百度 text run ONLY (the text+pill template was a measured bright-blob
|
||||
magnet), the load-bearing Doubao rival margin, the strict-only gate, and the
|
||||
corner-extended footprint (the tag's flat white interior gives no top-hat
|
||||
response, so a blob-bbox mask leaves the tag as a ghost).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.baidu_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
BaiduEngine,
|
||||
_alpha_template,
|
||||
)
|
||||
|
||||
_TEXT_FRAC = 0.090 # measured 百度 text-run width, fraction of the short side
|
||||
_TEXT_RIGHT = 0.099 # measured right margin of the text run (the tag is right of it)
|
||||
_TAG_FRAC = 0.075 # the white tag's width, approx (text-right to corner)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the 百度 text run + a solid white tag at the measured layout."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
short = min(w, h)
|
||||
gw = int(_TEXT_FRAC * short)
|
||||
gh = max(4, int(_TEXT_FRAC * (_ALPHA_HEIGHT_FRAC / _ALPHA_WIDTH_FRAC) * short))
|
||||
margin_b = int(0.006 * short)
|
||||
ax = w - int(_TEXT_RIGHT * short) - gw
|
||||
ay = h - margin_b - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
# the white rounded tag between the text and the corner
|
||||
tx0 = w - int(0.015 * short) - int(_TAG_FRAC * short)
|
||||
amap[ay - gh // 8 : ay + gh + gh // 8, tx0 : w - int(0.015 * short)] = 1.0
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, (ax, ay, gw, gh, tx0)
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert 2048 - (loc.x + loc.w) < 40
|
||||
assert 2048 - (loc.y + loc.h) < 40
|
||||
|
||||
def test_box_scales_with_short_side(self):
|
||||
eng = BaiduEngine()
|
||||
landscape = eng.locate(np.zeros((640, 1280, 3), np.uint8))
|
||||
wider = eng.locate(np.zeros((640, 2560, 3), np.uint8))
|
||||
assert wider.w == landscape.w
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_tophat_frontend(self):
|
||||
assert BaiduEngine().config.detect_frontend == "tophat"
|
||||
|
||||
def test_doubao_rival_margin(self):
|
||||
# 百度 vs 豆包 share a glyph and a corner: the candidate fires on 45.8% of
|
||||
# Doubao-marked frames at the gate, and the 0.10 margin suppresses ALL of
|
||||
# it at zero genuine-detection cost (crossfire, 2026-07-22).
|
||||
assert "doubao_alpha.png" in BaiduEngine().config.rivals
|
||||
|
||||
def test_strict_only_no_provenance_relaxation(self):
|
||||
assert BaiduEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# Clean arm scored p99 0.314 / max 0.352 on 278 hand-labelled frames;
|
||||
# the 741-frame eval set surfaced cross-fires up to 0.426 (a 抖音
|
||||
# AI创作 mark no rival can suppress), and the full-corpus sweep put the
|
||||
# outside-cohort false arm at 0.47 max vs true carriers at 0.50-0.66,
|
||||
# so the gate sits at 0.48.
|
||||
assert BaiduEngine().config.detect_ncc_threshold >= 0.48
|
||||
|
||||
def test_qwen_is_a_rival(self):
|
||||
# 百度 vs 千问 are near-identical after binarization: 12 of 14 full-corpus
|
||||
# cross-fires at the 0.37 gate were Qwen marks (Qwen's template beats
|
||||
# Baidu's there by 0.17-0.35, so the margin suppresses them).
|
||||
assert "qwen_alpha.png" in BaiduEngine().config.rivals
|
||||
|
||||
def test_registry_row(self):
|
||||
mark = registry.get_mark("baidu")
|
||||
assert mark.location == "bottom-right"
|
||||
assert mark.in_auto
|
||||
|
||||
|
||||
class TestDetectAndMask:
|
||||
def test_detects_composed_mark(self):
|
||||
eng = BaiduEngine()
|
||||
wm, _ = _compose(1024, 1024)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected, f"composed mark missed (conf={det.confidence:.3f})"
|
||||
|
||||
def test_clean_frame_stays_quiet(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.full((1024, 1024, 3), 100, np.uint8)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_mask_extends_to_the_corner_tag(self):
|
||||
"""Regression (2026-07-22): the tag's flat white interior gives no top-hat
|
||||
response, so a blob-bbox mask ended at the text run and the fill left the
|
||||
tag as a ghost. The footprint must extend right to the corner."""
|
||||
eng = BaiduEngine()
|
||||
wm, (ax, _ay, gw, _gh, tx0) = _compose(1024, 1024)
|
||||
mask = eng.footprint_mask(wm)
|
||||
assert mask is not None
|
||||
_ys, xs = np.where(mask > 0)
|
||||
assert xs.min() <= ax + int(0.1 * gw) # covers the text run's left edge
|
||||
assert xs.max() >= tx0 + 10 # covers the white tag right of the text
|
||||
|
||||
def test_no_mask_on_clean_frame(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.full((1024, 1024, 3), 100, np.uint8)
|
||||
assert eng.footprint_mask(img) is None
|
||||
|
||||
|
||||
class TestPillInteraction:
|
||||
def test_confident_baidu_detection_suppresses_the_jimeng_pill(self):
|
||||
# A Baidu image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/
|
||||
# Kling, a confident Baidu detection must veto the pill (``_keep_pill``).
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"baidu"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for the LibLibAI ("LibLibAI" wordmark) visible-watermark engine.
|
||||
|
||||
Every tuned constant in ``liblib_engine`` was measured on the 15-frame vendor
|
||||
cohort (2026-07-22); these tests pin the load-bearing ones: the bottom-CENTER
|
||||
anchor, the strict-only gate, and the match-box footprint (the blob bbox both
|
||||
bled into background structure and did not own the triangle logo).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.liblib_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
LibLibEngine,
|
||||
_alpha_template,
|
||||
)
|
||||
|
||||
_MARK_FRAC = 0.10 # measured wordmark width, fraction of the frame WIDTH
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite a triangle logo + the LibLibAI wordmark, bottom-center."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw = int(_MARK_FRAC * w)
|
||||
gh = max(4, int(_MARK_FRAC * (_ALPHA_HEIGHT_FRAC / _ALPHA_WIDTH_FRAC) * w))
|
||||
ax = (w - gw) // 2
|
||||
ay = int(0.94 * h) - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
# the triangle logo, its own height to the LEFT of the wordmark
|
||||
lx1 = ax - int(0.3 * gh)
|
||||
lx0 = lx1 - gh
|
||||
cv2.fillPoly(amap, [np.array([(lx0, ay + gh), (lx1, ay + gh), (lx1, ay)])], 1.0)
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, (ax, ay, gw, gh, lx0)
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_horizontally_centered(self):
|
||||
eng = LibLibEngine()
|
||||
img = np.zeros((2048, 1536, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert (1536 - loc.w) // 2 == pytest.approx(loc.x, abs=2) # corner="bc"
|
||||
assert 2048 - (loc.y + loc.h) > 0 # bottom-anchored
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
eng = LibLibEngine()
|
||||
narrow = eng.locate(np.zeros((2048, 1024, 3), np.uint8))
|
||||
wide = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert wide.w == pytest.approx(narrow.w * 2, rel=0.05)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_tophat_frontend(self):
|
||||
assert LibLibEngine().config.detect_frontend == "tophat"
|
||||
|
||||
def test_strict_only_no_provenance_relaxation(self):
|
||||
assert LibLibEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# With the Arial silhouette the full-corpus false arm (latin UI text)
|
||||
# tops out at 0.398 while the cohort sits at 0.43-0.59; gate 0.42.
|
||||
assert LibLibEngine().config.detect_ncc_threshold >= 0.42
|
||||
|
||||
def test_small_image_size_floor(self):
|
||||
# The one full-corpus false fire with the final template was a 200x200
|
||||
# icon on a 20px template; the engine refuses small images outright.
|
||||
eng = LibLibEngine()
|
||||
assert not eng.detect(np.full((200, 200, 3), 100, np.uint8)).detected
|
||||
wm, _ = _compose(200, 200)
|
||||
assert not eng.detect(wm).detected # even a composed mark under the floor
|
||||
|
||||
def test_registry_row(self):
|
||||
mark = registry.get_mark("liblib")
|
||||
assert mark.location == "bottom-center"
|
||||
assert mark.in_auto
|
||||
|
||||
|
||||
class TestDetectAndMask:
|
||||
def test_detects_composed_mark(self):
|
||||
eng = LibLibEngine()
|
||||
wm, _ = _compose(1792, 2400)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected, f"composed mark missed (conf={det.confidence:.3f})"
|
||||
|
||||
def test_clean_frame_stays_quiet(self):
|
||||
eng = LibLibEngine()
|
||||
img = np.full((2400, 1792, 3), 100, np.uint8)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_mask_covers_logo_and_wordmark(self):
|
||||
"""The footprint must cover the triangle logo LEFT of the wordmark while
|
||||
staying bounded by the match box vertically (the blob bbox bled into
|
||||
background structure and ate real content, 2026-07-22)."""
|
||||
eng = LibLibEngine()
|
||||
wm, (ax, ay, gw, gh, lx0) = _compose(1792, 2400)
|
||||
mask = eng.footprint_mask(wm)
|
||||
assert mask is not None
|
||||
ys, xs = np.where(mask > 0)
|
||||
assert xs.min() <= lx0 + gh // 2 # covers the logo
|
||||
assert xs.max() >= ax + gw - int(0.05 * gw) # covers the wordmark's right edge
|
||||
assert ys.min() >= ay - gh # does not bleed far above the mark
|
||||
|
||||
def test_no_mask_on_clean_frame(self):
|
||||
eng = LibLibEngine()
|
||||
img = np.full((2400, 1792, 3), 100, np.uint8)
|
||||
assert eng.footprint_mask(img) is None
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for the RunningHub ("RunningHub AI生成") visible-watermark engine.
|
||||
|
||||
Every tuned constant in ``runninghub_engine`` was measured on the 73-frame
|
||||
vendor cohort (2026-07-22, ``scripts/vendor_cohort_harvest.py`` +
|
||||
``scripts/vendor_mark_calibrate.py``); these tests pin the load-bearing ones:
|
||||
the top-left corner, the gray front-end, the exact-size tight ladder, the
|
||||
strict-only gate, and the mask/coverage parity regression (the partial-blob
|
||||
"Runni" miss).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.runninghub_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
RunningHubEngine,
|
||||
_alpha_template,
|
||||
)
|
||||
|
||||
_MARK_FRAC = 0.32 # measured mark width, fraction of the frame WIDTH
|
||||
|
||||
|
||||
def _compose(w: int, h: int, mode: float = _MARK_FRAC, bg: float = 100.0):
|
||||
"""Composite the RunningHub silhouette at the measured size, top-left."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw = int(mode * w)
|
||||
gh = max(4, int(mode * (_ALPHA_HEIGHT_FRAC / _ALPHA_WIDTH_FRAC) * w))
|
||||
ax, ay = int(0.008 * w), int(0.006 * h)
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, (ax, ay, gw, gh)
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_top_left(self):
|
||||
eng = RunningHubEngine()
|
||||
img = np.zeros((2048, 1536, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert loc.x < 40 # hugs the left edge
|
||||
assert loc.y < 40 # hugs the top edge (corner="tl")
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
# scale_basis="width" (measured: mark width is 0.32 of the frame width).
|
||||
eng = RunningHubEngine()
|
||||
narrow = eng.locate(np.zeros((2048, 1024, 3), np.uint8))
|
||||
wide = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert wide.w == pytest.approx(narrow.w * 2, rel=0.05)
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_gray_frontend(self):
|
||||
# The mark is a faint mid-gray the top-hat suppresses to clean-arm levels;
|
||||
# the raw-grayscale front-end is what separates (measured 2026-07-22).
|
||||
assert RunningHubEngine().config.detect_frontend == "gray"
|
||||
|
||||
def test_tight_ladder(self):
|
||||
# The NCC comb is razor-sharp in size (0.537 on-size, 0.223 at +5.6%), so
|
||||
# the nominal sits exactly on the measured 0.32 with +-5% rungs.
|
||||
assert RunningHubEngine().config.ladder == (0.95, 1.0, 1.05)
|
||||
assert RunningHubEngine().config.alpha_width_frac == pytest.approx(0.32)
|
||||
|
||||
def test_strict_only_no_provenance_relaxation(self):
|
||||
assert RunningHubEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# Clean arm scored p99 0.273 / max 0.295 on 286 hand-labelled frames.
|
||||
assert RunningHubEngine().config.detect_ncc_threshold > 0.295
|
||||
|
||||
def test_registry_row(self):
|
||||
mark = registry.get_mark("runninghub")
|
||||
assert mark.location == "top-left"
|
||||
assert mark.in_auto
|
||||
|
||||
|
||||
class TestDetectAndMask:
|
||||
def test_detects_composed_mark(self):
|
||||
eng = RunningHubEngine()
|
||||
wm, _ = _compose(1080, 1620)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected, f"composed mark missed (conf={det.confidence:.3f})"
|
||||
|
||||
def test_clean_frame_stays_quiet(self):
|
||||
eng = RunningHubEngine()
|
||||
img = np.full((1620, 1080, 3), 100, np.uint8)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_mask_covers_the_whole_mark(self):
|
||||
"""Regression (2026-07-22): the binary blob under-segments the faint head
|
||||
glyphs, so a blob-bbox mask left "Runni" unremoved. The gray front-end's
|
||||
mask must come from the detector's own match box and cover the mark."""
|
||||
eng = RunningHubEngine()
|
||||
wm, (ax, ay, gw, gh) = _compose(1080, 1620)
|
||||
mask = eng.footprint_mask(wm)
|
||||
assert mask is not None
|
||||
ys, xs = np.where(mask > 0)
|
||||
assert xs.min() <= ax + int(0.05 * gw) # covers the LEFT edge of the mark
|
||||
assert xs.max() >= ax + gw - int(0.05 * gw)
|
||||
assert ys.min() <= ay + gh // 2 <= ys.max()
|
||||
|
||||
def test_no_mask_on_clean_frame(self):
|
||||
eng = RunningHubEngine()
|
||||
img = np.full((1620, 1080, 3), 100, np.uint8)
|
||||
assert eng.footprint_mask(img) is None
|
||||
|
||||
def test_anchor_window_rejects_off_corner_match(self):
|
||||
"""The raw-gray front-end false-fires on text-like structure ANYWHERE in
|
||||
the box (37/42009 outside-cohort frames in the 2026-07-22 sweep); the
|
||||
anchor window is what keeps it about THIS mark. A composed mark placed
|
||||
off the measured corner anchor must NOT be detected."""
|
||||
eng = RunningHubEngine()
|
||||
wm, _ = _compose(1080, 1620)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected # on-anchor control
|
||||
# the same mark shifted right/down, off the anchor window
|
||||
shifted = np.full((1620, 1080, 3), 100, np.uint8)
|
||||
region = wm[10:60, 12:360]
|
||||
shifted[100 : 100 + region.shape[0], 200 : 200 + region.shape[1]] = region
|
||||
assert not eng.detect(shifted).detected
|
||||
|
||||
|
||||
class TestPillInteraction:
|
||||
def test_confident_runninghub_detection_suppresses_the_jimeng_pill(self):
|
||||
# A RunningHub frame names its own product; its detection must veto the
|
||||
# Jimeng pill the same way Doubao/Qwen/Kling do (``_keep_pill``).
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"runninghub"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
@@ -14,7 +14,18 @@ DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doub
|
||||
|
||||
class TestCatalog:
|
||||
def test_keys(self):
|
||||
assert reg.mark_keys() == ["gemini", "doubao", "jimeng", "qwen", "kling", "samsung", "jimeng_pill"]
|
||||
assert reg.mark_keys() == [
|
||||
"gemini",
|
||||
"doubao",
|
||||
"jimeng",
|
||||
"qwen",
|
||||
"kling",
|
||||
"samsung",
|
||||
"runninghub",
|
||||
"baidu",
|
||||
"liblib",
|
||||
"jimeng_pill",
|
||||
]
|
||||
|
||||
def test_all_in_auto(self):
|
||||
assert all(m.in_auto for m in reg.known_marks())
|
||||
@@ -43,7 +54,18 @@ class TestScan:
|
||||
def test_detect_marks_scans_all(self):
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
keys = {d.key for d in reg.detect_marks(img)}
|
||||
assert keys == {"gemini", "doubao", "jimeng", "qwen", "kling", "samsung", "jimeng_pill"}
|
||||
assert keys == {
|
||||
"gemini",
|
||||
"doubao",
|
||||
"jimeng",
|
||||
"qwen",
|
||||
"kling",
|
||||
"samsung",
|
||||
"runninghub",
|
||||
"baidu",
|
||||
"liblib",
|
||||
"jimeng_pill",
|
||||
}
|
||||
|
||||
def test_blank_image_no_auto_mark(self):
|
||||
dets = reg.detect_marks(np.zeros((256, 256, 3), np.uint8), include_explicit=False)
|
||||
|
||||
Reference in New Issue
Block a user