From 214c9bb3e7a36baf2f2a14a4cd275cdcadfc0203 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sat, 25 Jul 2026 20:52:15 -0700 Subject: [PATCH] Add Tencent Yuanbao visible watermark removal --- CLAUDE.md | 11 +- README.md | 20 +-- docs/module-internals.md | 22 ++- docs/verification-plan.md | 71 +++++---- docs/watermarking-landscape.md | 2 + scripts/render_vendor_silhouettes.py | 32 ++-- src/remove_ai_watermarks/_text_mark_engine.py | 62 +++++++- src/remove_ai_watermarks/api.py | 6 +- .../assets/yuanbao_alpha.png | Bin 6264 -> 7673 bytes src/remove_ai_watermarks/cli.py | 6 +- src/remove_ai_watermarks/doubao_engine.py | 2 +- src/remove_ai_watermarks/identify.py | 3 +- src/remove_ai_watermarks/jimeng_engine.py | 2 +- src/remove_ai_watermarks/samsung_engine.py | 2 +- .../watermark_registry.py | 24 ++- src/remove_ai_watermarks/yuanbao_engine.py | 138 ++++++++++++++++++ tests/test_watermark_registry.py | 15 +- tests/test_yuanbao_engine.py | 94 ++++++++++++ 18 files changed, 431 insertions(+), 81 deletions(-) create mode 100644 src/remove_ai_watermarks/yuanbao_engine.py create mode 100644 tests/test_yuanbao_engine.py diff --git a/CLAUDE.md b/CLAUDE.md index c70e277..b700f7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r ## Scope and non-goals -The mission is removing **AI-provenance watermarks** that a platform stamps onto content the user generated themselves — 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 user autonomy over their own generated output. +The mission is removing **AI-provenance watermarks** that a platform stamps onto content the user generated themselves — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Qwen / Kling / Tencent Yuanbao / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is user autonomy over their own generated output. It deliberately does **not** remove watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled / diagonal "preview" watermark whose job is to gate a purchase. Stripping those makes a paid resource free off someone else's work; out of scope **by principle, not by technical difficulty**. The line: a visible mark is in scope when it labels the user's **own** AI generation, and out of scope when it protects a **third party's paid asset**. @@ -21,7 +21,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes - `uv run remove-ai-watermarks all -o ` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) and `--sensitivity auto|strict` (default `auto`) for the localize -> fill visible removal (see the `visible` bullet). Skips step 2 (invisible/SynthID) when the `[gpu]` extra is absent or no invisible signal is detectable; see the module doc for the distinct exit codes. - `uv run remove-ai-watermarks invisible -o ` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default except resolution-adaptive `qwen-zimage`), `--steps` (**interacts with `--strength`** on the diffusers profiles; `watermark_profiles.viable_steps` prevents zero effective steps. `qwen-zimage` instead fixes its Lightning stage at 4 steps), `--guidance-scale`, `--pipeline sdxl|controlnet|qwen|qwen-zimage` (default `controlnet`; `qwen` and `qwen-zimage` are manual opt-ins), `--controlnet-scale`, `--model`, `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize`, `--unsharp`, `--adaptive-polish/--no-adaptive-polish`, `--tile/--no-tile` + `--tile-size`/`--tile-overlap`, `--cpu-offload/--no-cpu-offload`, `--force/--no-force`. `--cpu-offload` trades speed for lower CUDA VRAM use by moving Diffusers model components between CPU and GPU; on `qwen-zimage` it forces the face stack to offload instead of using automatic residency. It has no effect on CPU/MPS. ControlNet is the compatibility and cost default, not the highest-fidelity mode. Recommend the CUDA-only `qwen-zimage` profile when output quality, especially face identity, matters more than runtime and cost; it needs the separate extra, uses a fixed Qwen-Image-2512 + Z-Image stack, rejects `--model`, defaults to the oracle-candidate seed 0, and supports tiling only for its global Qwen pass. The full-frame face stage runs once after tile blending. Tiled outputs still need separate oracle certification. `--auto` is deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable; see the module doc. -- `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Qwen "千问AI生成", Samsung Galaxy AI "✦ Contenuti generati dall'AI", Baidu "百度 AI生成", LibLibAI wordmark (bottom-center), RunningHub "RunningHub AI生成" (top-left), and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|qwen|kling|samsung|baidu|liblib|runninghub|jimeng_pill` forces one. `--sensitivity auto|strict` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes. Metadata provenance is read automatically and feeds `auto`. (`assume-ai` was REMOVED in 0.16 — see the registry bullet; a user who can SEE a missed mark should point at it with `erase --region`, or name it with `--mark --no-detect`.) For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. +- `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Qwen "千问AI生成", Kling "可灵AI 3.0", Tencent Yuanbao "元宝 / AI生成", Samsung Galaxy AI "✦ Contenuti generati dall'AI", Baidu "百度 AI生成", LibLibAI wordmark (bottom-center), RunningHub "RunningHub AI生成" (top-left), and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|qwen|kling|yuanbao|samsung|baidu|liblib|runninghub|jimeng_pill` forces one. `--sensitivity auto|strict` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes. Metadata provenance is read automatically and feeds `auto`. (`assume-ai` was REMOVED in 0.16 — see the registry bullet; a user who can SEE a missed mark should point at it with `erase --region`, or name it with `--mark --no-detect`.) For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. - `uv run remove-ai-watermarks erase --region x,y,w,h -o ` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps), `--backend migan` (MI-GAN via onnxruntime, extra `migan`; ~28 MB, ~1 GB RAM, near-LaMa), or `--backend lama` (big-LaMa, extra `lama`; best quality but ~4.7 GB RAM); `--region` is repeatable. - `uv run remove-ai-watermarks identify ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector - `uv run remove-ai-watermarks metadata --check` — inspect AI metadata (C2PA, EXIF, PNG chunks) @@ -57,13 +57,14 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`. - `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; corpus-observed on real Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the common real-corpus placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): ~2% of real uploads are misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals" (corpus-measured ~0.9% of files). A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; ~0.2% of real uploads) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`. - `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. Corpus-validated: adding the vendors introduced 0 new clashes on 5000 carriers. -- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. On its first form that left only the raw 0.35 detector threshold and it fired on **59.8% of 256 genuine camera captures**, filling a phantom sparkle on ~6 of every 10 CLEAN photos; a confidence floor made it tolerable, and the mode was removed outright in 0.16. Corpus-measured 2026-07-16 before removal (400 Google-C2PA positives with metadata hidden; 256 camera-capture negatives): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3% -- the extra recall was never free. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao**, **Qwen**, **Kling**, **RunningHub** or **Baidu** detection (each of those marks names its own product, and none is Jimeng-basic), and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) — the metadata-only arm is only **21% precise raw (CI 16-29%), 29% (CI 20-40%) among the flat footprints the guard PASSES** (re-measured 2026-07-18, 149 blind-labelled fires) and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard always holds. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection can skip binarization** (`TextMarkConfig.detect_frontend`): `binary` thresholds the top-hat into a glyph blob (the original path), `tophat` correlates the CONTINUOUS top-hat against a soft template, turning the saturation/luma gates into weights and max-normalizing so the score is contrast-invariant. **Doubao uses `tophat`** (recall 89% -> 92% at an unchanged 99% precision on the unbiased sample); jimeng/samsung stay `binary` until measured per mark. **Detection and the REMOVAL MASK must use the SAME front-end.** `tophat` detection does not binarize, but `extract_mask` (which bounds the fill) still does, so a mark faint enough to be found only by the continuous response produced an EMPTY mask: `localize` returned None, `remove()` was a silent no-op, and `identify` reported `visible_doubao` while `visible` said "no visible mark" on the same file. Corpus-measured 2026-07-20: 57 of 60 sampled still-detected Doubao marks were untouched, ~8% of all its detections. When the binary blob is empty on a `tophat` mark the detector fired on, `footprint_mask` now bounds the fill with **the detector's OWN best-match box** (`_tophat_best`, the shared method whose score gates detection and whose box bounds the mask -- one method, so the two can never drift). **Gated on an actual detection** (on a clean corner the box would be spurious). The FIRST version of this fix (2026-07-19) instead thresholded the max-normalized uint8 response at `_FAINT_GLYPH_LEVEL = 0.5`, which selects every non-zero pixel (NOT "half the peak" as its comment claimed) and filled ~120% of the corner box on textured frames; corpus-measured 2026-07-20 over 14 real faint-path frames, the match box fills 58.7% and the whole-corner threshold 120.9%, both 100% detector-clean. So the threshold LOOKED fixed on parity (a mask that fills everything is trivially detector-clean) and was only caught by measuring fill AREA. The `0.5` constant is gone. Regression: `tests/test_text_mark_faint_mask.py` (its fixture MUST carry texture -- on a flat frame the response is non-zero only on the glyph, so every threshold gives the same box and the test cannot see a wrong one; mutating the old constant to 99.0 left the flat test green). **Any future front-end change must move BOTH paths or re-check this.** **The gate is front-end specific -- re-calibrate, never port it**: the continuous response scores higher (mean 0.809 vs 0.723), so the binary-era 0.40 ran 96%/91% (8 false fires) while 0.50 runs 92%/99% (1). A first pass at 0.40 also silently halved the PILL's recall, since `_keep_pill` suppresses it whenever doubao fires. The front-end fixes DETECTION of faint marks; ATTRIBUTION across the shared `AI生成` tail turned out to be a mis-sizing artifact, not a front-end limit: at the fitted geometry an exact-size 6-glyph template separates 千问 from Doubao with zero cross-fire (measured 2026-07-21 on 400 doubao-marked frames), so per-vendor registration shipped and the generic CJK-class detector is shelved unless a long-tail vendor needs it. **Adding a new text mark is cheap once the mark is stamped BOLD and its geometry is MEASURED** -- 千问 is registered since 2026-07-21 (see the `qwen_engine.py` bullet); 星绘 remains unregistered (one confirmed corpus example, nothing to calibrate against). The 2026-07-18 measurement that held 千问 out (mean NCC 0.170, 0% over gate, segmentation blamed) is recorded in `docs/module-internals.md`; its real residual was geometry (two size modes + a clipping locate box), which the cohort calibration then solved. Evidence chain in `scripts/render_vendor_silhouettes.py`. **RECALL is measured on an unbiased random sample** (`scripts/visible_recall_sample.py`; 240 images drawn at random per provenance class and labelled exhaustively, 2026-07-18): doubao **89% recall / 99% precision**, gemini **96% / 80%**, jimeng 71%/71% (n=14), jimeng_pill 50%/60% (n=6). The `scale_basis` fix moved doubao recall **71% -> 89%** on this same sample. **Gemini's real precision is 80%, NOT the 41% `visible_eval.py` reports** -- that harness scores an addition-sampled set, so it measures the relaxation arm's marginal cases, not what production sees; quote 80% for the product. Landscape is improved but unsolved (doubao by aspect: portrait 92% / square 92% / landscape 79%). The largest remaining gap is not tuning but COVERAGE: **6% of sampled images carry an uncovered vendor's mark** (百度/星绘/抖音-class -- 千问 was the head of this class and is registered since 2026-07-21, 百度 since 2026-07-22) that no registered detector can fire on -- researched specs are in `docs/watermarking-landscape.md`. **Mark geometry scales with a PER-MARK dimension** (`TextMarkConfig.scale_basis` / `TextMarkEngine.scale_base`): doubao `short` (= min(h,w)), jimeng and samsung `width`. All the tuned fractions were calibrated on PORTRAIT captures where width == short side, so the basis went unexercised until landscape inputs were measured -- and **doubao detected 0 of 435 landscape TC260 images, a 100% miss rate**. It is a LOCALIZATION failure, not a threshold one (median doubao NCC on the 1452 no-detection images was 0.057, only 2.7% in a threshold-reachable band), so no amount of gate tuning could reach it. Short-side geometry recovers **56% of the previously-undetected landscape set**. The basis is per-mark because the SAME switch took jimeng's landscape positives from 13/13 to 0/13 -- its wordmark tracks the width even though both marks are ByteDance and share a corner; samsung stays `width` because it is unmeasured (1 addition corpus-wide). GB 45438-2025 5.2(e) mandates glyph height >= 5% of the shortest side, which is why short-side is the prior -- but measurement overrides the standard's wording. This was invisible for months because **precision was measured repeatedly and recall never was**; the harness now reports a `missed` column, which is what caught the jimeng regression the fix introduced. **Detection among same-corner marks is COMPETITIVE** (`TextMarkConfig.rivals` / `_rival_margin_ok`): a mark's template must beat every same-corner rival's on the SAME glyph blob by `rival_margin` (0.10). Absolute-only scoring could never separate Doubao and Jimeng (both bottom-right near-white CJK, near-identical after binarization) -- measured separability: absolute `ncc_jimeng` 0.96, `ncc_jimeng` MINUS `ncc_doubao` **0.99**. Corpus effect: **jimeng precision 38% -> 63% with genuine detections unchanged (false fires 65 -> 23)**, so it is a pure precision gain and the earlier 0.85 threshold patch was reverted to 0.70. **Asymmetric by measurement:** doubao declares NO rival -- the symmetric gate cost it 7 genuine detections to prevent 5 false (1.4:1 against) while jimeng gained 25pp for free. **Benchmark any detector change with `uv run python scripts/visible_eval.py --vs `** (741 blind-labelled corpus images; `scripts/visible_groundtruth.py` builds the set, `scripts/visible_sheets.py` makes new labelling rounds). Three harness rules are load-bearing: score a mark only within its crop's **adjudication scope**, take **provenance from metadata not from labels** (label-derived provenance scored gemini at 99% vs the true 41%), and **never report recall** from this set -- it was sampled where detectors fired, so an unbiased random sample is still needed. **The provenance NCC relaxation is PER MARK (`TextMarkConfig.provenance_ncc_factor`), not one shared multiplier** — measured 2026-07-18 on the default `auto` path over 4417 unique TC260 carriers (blind hand-label, two-sided control, labeller sensitivity 100%/96% and specificity 100%/100%), the old shared 0.7 ran at **76% precision on doubao but 17% on jimeng**. Doubao stays 0.70 (both its bands return more true marks than false fills). Jimeng moves to **0.85**: its relaxed silhouette keys on "text in the bottom-right corner" rather than the wordmark — of 68 false additions **33 were DOUBAO marks** and 17 were other vendors' AI labels, and 45 of the 68 filled a corner nothing else would touch; 0.85 costs 8 genuine recoveries to prevent 60 false fills (7.5:1), lifting the arm to 43%. That is a patch on a DETECTOR problem — jimeng's silhouette is not discriminative against doubao's, and no threshold fixes that. **A weak mark must not CORROBORATE a sibling** (`_CANNOT_CORROBORATE`): sibling corroboration grants `confirmed` trust, which bypasses the sibling's FP gate, so the pill (~7% raw false-fire) handing that bypass to jimeng created a closed loop on the DEFAULT path — pill false-fires → jimeng relaxes and false-fires → `_keep_pill`'s wordmark arm then removes the pill UNRESTRICTED, skipping the flatness guard (3/578 negatives ran the full loop, one with `footprint_flat=0`). Cutting the pill out of corroboration removed all 3 and cost NOTHING on the TC260 carriers (jimeng 398 → 398). `_keep_pill` already distrusted the pill's ACTION; this closes the gap that its TESTIMONY was ungated. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark needs only a DETECTION silhouette (removal is template-free — the glyph-blob bbox is filled, no capture involved). Produce that silhouette SYNTHETICALLY: font-render the mark's glyphs (the pill's `scripts/render_pill_silhouette.py` is the pattern; commit the rendered PNG under `assets/`) and calibrate the NCC threshold on real positives. The old solid/gray/white app-capture workflow (`scripts/visible_alpha_solve.py`) is RETIRED with reverse-alpha — existing marks still carry their captured silhouettes, but a NEW mark does NOT require captures. (The 2026-06-22 "synthetic reconstruction below the quality bar" objection was about reverse-alpha PIXEL recovery, which is gone; it does not apply to a synthetic detection silhouette.) Data-safety still binds the committed asset: the silhouette must be font-rendered synthetic, never derived from user uploads — seeing a real sample to learn the glyphs / font / position / locale is fine, but the committed template stays synthetic. So nothing is parked for lack of a capture: Meta AI and more Samsung locales just need the glyphs + font + locale + calibration positives; any Grok visible mark additionally needs confirming it even HAS one (its known signal is EXIF-only `xai_signature`). +- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / yuanbao / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. On its first form that left only the raw 0.35 detector threshold and it fired on **59.8% of 256 genuine camera captures**, filling a phantom sparkle on ~6 of every 10 CLEAN photos; a confidence floor made it tolerable, and the mode was removed outright in 0.16. Corpus-measured 2026-07-16 before removal (400 Google-C2PA positives with metadata hidden; 256 camera-capture negatives): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3% -- the extra recall was never free. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao**, **Qwen**, **Kling**, **Tencent Yuanbao**, **RunningHub** or **Baidu** detection (each of those marks names its own product, and none is Jimeng-basic), and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) — the metadata-only arm is only **21% precise raw (CI 16-29%), 29% (CI 20-40%) among the flat footprints the guard PASSES** (re-measured 2026-07-18, 149 blind-labelled fires) and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard always holds. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection can skip binarization** (`TextMarkConfig.detect_frontend`): `binary` thresholds the top-hat into a glyph blob (the original path), `tophat` correlates the CONTINUOUS top-hat against a soft template, turning the saturation/luma gates into weights and max-normalizing so the score is contrast-invariant. **Doubao uses `tophat`** (recall 89% -> 92% at an unchanged 99% precision on the unbiased sample); jimeng/samsung stay `binary` until measured per mark. **Detection and the REMOVAL MASK must use the SAME front-end.** `tophat` detection does not binarize, but `extract_mask` (which bounds the fill) still does, so a mark faint enough to be found only by the continuous response produced an EMPTY mask: `localize` returned None, `remove()` was a silent no-op, and `identify` reported `visible_doubao` while `visible` said "no visible mark" on the same file. Corpus-measured 2026-07-20: 57 of 60 sampled still-detected Doubao marks were untouched, ~8% of all its detections. When the binary blob is empty on a `tophat` mark the detector fired on, `footprint_mask` now bounds the fill with **the detector's OWN best-match box** (`_tophat_best`, the shared method whose score gates detection and whose box bounds the mask -- one method, so the two can never drift). **Gated on an actual detection** (on a clean corner the box would be spurious). The FIRST version of this fix (2026-07-19) instead thresholded the max-normalized uint8 response at `_FAINT_GLYPH_LEVEL = 0.5`, which selects every non-zero pixel (NOT "half the peak" as its comment claimed) and filled ~120% of the corner box on textured frames; corpus-measured 2026-07-20 over 14 real faint-path frames, the match box fills 58.7% and the whole-corner threshold 120.9%, both 100% detector-clean. So the threshold LOOKED fixed on parity (a mask that fills everything is trivially detector-clean) and was only caught by measuring fill AREA. The `0.5` constant is gone. Regression: `tests/test_text_mark_faint_mask.py` (its fixture MUST carry texture -- on a flat frame the response is non-zero only on the glyph, so every threshold gives the same box and the test cannot see a wrong one; mutating the old constant to 99.0 left the flat test green). **Any future front-end change must move BOTH paths or re-check this.** **The gate is front-end specific -- re-calibrate, never port it**: the continuous response scores higher (mean 0.809 vs 0.723), so the binary-era 0.40 ran 96%/91% (8 false fires) while 0.50 runs 92%/99% (1). A first pass at 0.40 also silently halved the PILL's recall, since `_keep_pill` suppresses it whenever doubao fires. The front-end fixes DETECTION of faint marks; ATTRIBUTION across the shared `AI生成` tail turned out to be a mis-sizing artifact, not a front-end limit: at the fitted geometry an exact-size 6-glyph template separates 千问 from Doubao with zero cross-fire (measured 2026-07-21 on 400 doubao-marked frames), so per-vendor registration shipped and the generic CJK-class detector is shelved unless a long-tail vendor needs it. **Adding a new text mark is cheap once the mark is stamped BOLD and its geometry is MEASURED** -- 千问 is registered since 2026-07-21 (see the `qwen_engine.py` bullet); 星绘 remains unregistered (one confirmed corpus example, nothing to calibrate against). The 2026-07-18 measurement that held 千问 out (mean NCC 0.170, 0% over gate, segmentation blamed) is recorded in `docs/module-internals.md`; its real residual was geometry (two size modes + a clipping locate box), which the cohort calibration then solved. Evidence chain in `scripts/render_vendor_silhouettes.py`. **RECALL is measured on an unbiased random sample** (`scripts/visible_recall_sample.py`; 240 images drawn at random per provenance class and labelled exhaustively, 2026-07-18): doubao **89% recall / 99% precision**, gemini **96% / 80%**, jimeng 71%/71% (n=14), jimeng_pill 50%/60% (n=6). The `scale_basis` fix moved doubao recall **71% -> 89%** on this same sample. **Gemini's real precision is 80%, NOT the 41% `visible_eval.py` reports** -- that harness scores an addition-sampled set, so it measures the relaxation arm's marginal cases, not what production sees; quote 80% for the product. Landscape is improved but unsolved (doubao by aspect: portrait 92% / square 92% / landscape 79%). The largest remaining gap is not tuning but COVERAGE: **6% of sampled images carry an uncovered vendor's mark** (百度/星绘/抖音-class -- 千问 was the head of this class and is registered since 2026-07-21, 百度 since 2026-07-22) that no registered detector can fire on -- researched specs are in `docs/watermarking-landscape.md`. **Mark geometry scales with a PER-MARK dimension** (`TextMarkConfig.scale_basis` / `TextMarkEngine.scale_base`): doubao `short` (= min(h,w)), jimeng and samsung `width`. All the tuned fractions were calibrated on PORTRAIT captures where width == short side, so the basis went unexercised until landscape inputs were measured -- and **doubao detected 0 of 435 landscape TC260 images, a 100% miss rate**. It is a LOCALIZATION failure, not a threshold one (median doubao NCC on the 1452 no-detection images was 0.057, only 2.7% in a threshold-reachable band), so no amount of gate tuning could reach it. Short-side geometry recovers **56% of the previously-undetected landscape set**. The basis is per-mark because the SAME switch took jimeng's landscape positives from 13/13 to 0/13 -- its wordmark tracks the width even though both marks are ByteDance and share a corner; samsung stays `width` because it is unmeasured (1 addition corpus-wide). GB 45438-2025 5.2(e) mandates glyph height >= 5% of the shortest side, which is why short-side is the prior -- but measurement overrides the standard's wording. This was invisible for months because **precision was measured repeatedly and recall never was**; the harness now reports a `missed` column, which is what caught the jimeng regression the fix introduced. **Detection among same-corner marks is COMPETITIVE** (`TextMarkConfig.rivals` / `_rival_margin_ok`): a mark's template must beat every same-corner rival's on the SAME glyph blob by `rival_margin` (0.10). Absolute-only scoring could never separate Doubao and Jimeng (both bottom-right near-white CJK, near-identical after binarization) -- measured separability: absolute `ncc_jimeng` 0.96, `ncc_jimeng` MINUS `ncc_doubao` **0.99**. Corpus effect: **jimeng precision 38% -> 63% with genuine detections unchanged (false fires 65 -> 23)**, so it is a pure precision gain and the earlier 0.85 threshold patch was reverted to 0.70. **Asymmetric by measurement:** doubao declares NO rival -- the symmetric gate cost it 7 genuine detections to prevent 5 false (1.4:1 against) while jimeng gained 25pp for free. **Benchmark any detector change with `uv run python scripts/visible_eval.py --vs `** (741 blind-labelled corpus images; `scripts/visible_groundtruth.py` builds the set, `scripts/visible_sheets.py` makes new labelling rounds). Three harness rules are load-bearing: score a mark only within its crop's **adjudication scope**, take **provenance from metadata not from labels** (label-derived provenance scored gemini at 99% vs the true 41%), and **never report recall** from this set -- it was sampled where detectors fired, so an unbiased random sample is still needed. **The provenance NCC relaxation is PER MARK (`TextMarkConfig.provenance_ncc_factor`), not one shared multiplier** — measured 2026-07-18 on the default `auto` path over 4417 unique TC260 carriers (blind hand-label, two-sided control, labeller sensitivity 100%/96% and specificity 100%/100%), the old shared 0.7 ran at **76% precision on doubao but 17% on jimeng**. Doubao stays 0.70 (both its bands return more true marks than false fills). Jimeng moves to **0.85**: its relaxed silhouette keys on "text in the bottom-right corner" rather than the wordmark — of 68 false additions **33 were DOUBAO marks** and 17 were other vendors' AI labels, and 45 of the 68 filled a corner nothing else would touch; 0.85 costs 8 genuine recoveries to prevent 60 false fills (7.5:1), lifting the arm to 43%. That is a patch on a DETECTOR problem — jimeng's silhouette is not discriminative against doubao's, and no threshold fixes that. **A weak mark must not CORROBORATE a sibling** (`_CANNOT_CORROBORATE`): sibling corroboration grants `confirmed` trust, which bypasses the sibling's FP gate, so the pill (~7% raw false-fire) handing that bypass to jimeng created a closed loop on the DEFAULT path — pill false-fires → jimeng relaxes and false-fires → `_keep_pill`'s wordmark arm then removes the pill UNRESTRICTED, skipping the flatness guard (3/578 negatives ran the full loop, one with `footprint_flat=0`). Cutting the pill out of corroboration removed all 3 and cost NOTHING on the TC260 carriers (jimeng 398 → 398). `_keep_pill` already distrusted the pill's ACTION; this closes the gap that its TESTIMONY was ungated. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark needs only a DETECTION silhouette (removal is template-free — the glyph-blob bbox is filled, no capture involved). Produce that silhouette SYNTHETICALLY: font-render the mark's glyphs (the pill's `scripts/render_pill_silhouette.py` is the pattern; commit the rendered PNG under `assets/`) and calibrate the NCC threshold on real positives. The old solid/gray/white app-capture workflow (`scripts/visible_alpha_solve.py`) is RETIRED with reverse-alpha — existing marks still carry their captured silhouettes, but a NEW mark does NOT require captures. (The 2026-06-22 "synthetic reconstruction below the quality bar" objection was about reverse-alpha PIXEL recovery, which is gone; it does not apply to a synthetic detection silhouette.) Data-safety still binds the committed asset: the silhouette must be font-rendered synthetic, never derived from user uploads — seeing a real sample to learn the glyphs / font / position / locale is fine, but the committed template stays synthetic. So nothing is parked for lack of a capture: Meta AI and more Samsung locales just need the glyphs + font + locale + calibration positives; any Grok visible mark additionally needs confirming it even HAS one (its known signal is EXIF-only `xai_signature`). - `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list. -- `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray` (`gray` = raw-grayscale NCC for the faint mid-gray runninghub mark, added 2026-07-22; contrast-DEPENDENT, so its gates never port). The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2). +- `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the detected glyph blob or the detector's own match box, filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray`/`contrast`. `gray` is raw-grayscale NCC for the faint mid-gray RunningHub mark and stays contrast-dependent. `contrast` is absolute local-luma residual NCC for polarity-changing marks such as Yuanbao, whose stamp may be light or dark depending on the scene. Front-end gates do not port between modes. The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2). - `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates. - `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture. - `qwen_engine.py` — the Qwen (Alibaba Tongyi Qianwen) "千问AI生成" text mark (bottom-right), registered 2026-07-21 off the 117-frame TC260-producer cohort (`scripts/vendor_cohort_harvest.py` + `scripts/vendor_mark_calibrate.py`; the full calibration record is in `docs/module-internals.md`). Same `TextMarkEngine` subclass shape, but EVERY tuned number is measured on the cohort, not inherited from doubao: `scale_basis=short`, a **per-mark 2-rung `ladder=(0.78, 1.27)`** (the mark sits in two size modes ~0.124/~0.203 of the short side, ratio 1.64 -- wider than the shared ladder's 1.5625 span, so the best single fraction covers only 74.5% and the small mode falls into the comb's collapse zone; `TextMarkConfig.ladder` was added for this, default `(0.8, 1.0, 1.25)` unchanged for every other mark), a fitted locate box (the real mark sits ~0.025 of short off the right edge and doubao's 0.004-margin box clipped the first glyph, 0.73 -> 0.26 NCC), `alpha_height_frac` from the measured 0.260 aspect (not the silhouette's, not doubao's), gate **0.45** (clean p99 0.301; 83 of ~96 eyeballed visible marks fire = 86% recall of visible marks). **STRICT ONLY** (`provenance_ncc_factor` 1.0, no provenance mapping): the sub-gate band is dominated by non-Qwen banners (夸克 strip 0.274, 造点 mark 0.253), so a relaxed arm would be mostly false fills. **No rival margin** (0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames at the gate; a 0.10 margin would cost ~10% of genuine qwen detections). A confident qwen detection suppresses the jimeng pill like doubao's does. Parity: 83/83 real marks detector-clean after cv2 fill. **Calibration trap for the next vendor:** the 2026-07-18 `present: []` clean labels are in the vocabulary of the REGISTERED marks only -- qwen-cohort frames visibly carrying 千问AI生成 sat in the "clean" arm and were its whole top tail (p99 0.69); `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort. -- `kling_engine.py` — the Kling (Kuaishou 可灵) "可灵AI 3.0" text mark (bottom-right), registered 2026-07-21 off the 30-frame TC260-producer cohort, same playbook as qwen. UNIMODAL at 0.12 of the short side, so it keeps the shared 3-rung ladder and gets a fitted locate box (margins ~0.03/0.023); gate **0.35** (clean p99 0.304 / max 0.320), STRICT ONLY (the faint "Omni"-suffix / latin "KlingAI" / version-less variants score 0.17-0.25, inside the clean arm's top tail -- unreachable, capping recall at ~47% of visible marks, precision 9/9), no rival margin (crossfire 1/400 doubao below gate, 0 jimeng, 0 clean). Parity 9/9. Suppresses the jimeng pill like doubao/qwen. **元宝 (Tencent Yuanbao) was attempted the same day and PARKED as a measured negative** -- its two-line italic-slanted block does not separate from clean corners on either front-end at any render/box setting (full record in `docs/verification-plan.md`); the fitted render recipe stays in `render_vendor_silhouettes.py` `MARK_OPTS` as the starting point for a future structural/learned lever. The **cat-logo** cohort was also probed and parked: 19 frames but only 2 unique carriers, so nothing to calibrate recall against; the `draw_catlogo` silhouette already separates (mark 0.50 vs clean max 0.333) and registration is a gate pick (~0.42) once more unique carriers arrive. +- `kling_engine.py` — the Kling (Kuaishou 可灵) "可灵AI 3.0" text mark (bottom-right), registered 2026-07-21 off the 30-frame TC260-producer cohort, same playbook as qwen. UNIMODAL at 0.12 of the short side, so it keeps the shared 3-rung ladder and gets a fitted locate box (margins ~0.03/0.023); gate **0.35** (clean p99 0.304 / max 0.320), STRICT ONLY (the faint "Omni"-suffix / latin "KlingAI" / version-less variants score 0.17-0.25, inside the clean arm's top tail -- unreachable, capping recall at ~47% of visible marks, precision 9/9), no rival margin (crossfire 1/400 doubao below gate, 0 jimeng, 0 clean). Parity 9/9. Suppresses the jimeng pill like doubao/qwen. The **cat-logo** cohort was also probed and parked: 19 frames but only 2 unique carriers, so nothing to calibrate recall against; the `draw_catlogo` silhouette already separates (mark 0.50 vs clean max 0.333) and registration is a gate pick (~0.42) once more unique carriers arrive. +- `yuanbao_engine.py` — the Tencent Yuanbao two-line "元宝 / AI生成" mark (bottom-right), registered 2026-07-25. The first calibration was invalid because negative shear clipped the lower line out of the synthetic asset and left a blank tail that the matcher then squeezed; the corrected renderer translates before shearing and tightly crops the result. Yuanbao uses the polarity-independent `contrast` front-end because the same stamp is light on dark scenes and dark on pale scenes. Gate **0.38**, STRICT ONLY, with a measured bottom-right anchor. It detects 26 of 28 standard two-line marks (92.9%) across 33 byte-unique cohort frames, fires on 0 of 286 byte-unique clean controls (clean max 0.348), and clears 26/26 detected real marks after cv2 fill. A separate one-line photographer-overlay variant remains unregistered because only one example exists. - `runninghub_engine.py` — the RunningHub "RunningHub AI生成" mark (**top-left**, the first `corner="tl"`), registered 2026-07-22 off the 73-frame TC260 cohort. Faint mid-gray text that the top-hat suppresses to clean-arm levels, so it introduced the **third detection front-end `gray`** (raw-grayscale silhouette NCC, contrast-DEPENDENT, one `_gray_best` method serving detection and mask): positives 0.38-0.54 vs clean max 0.295, gate **0.34**, STRICT ONLY. Two measured traps: the tight ladder `(0.95, 1.0, 1.05)` sits exactly on the measured 0.32-of-width (the comb is razor-sharp: 0.537 on-size vs 0.223 at +5.6%), and an **anchor gate** in `detect` (match must sit at x<=0.025 / y<=0.015 of the frame) kills the 37/42009 full-corpus false fires (hair, shelves, CJK banners) that no NCC threshold separates from the 0.381 positives. Footprint is always the detector's match box (the binary blob under-segments the faint head glyphs and left "Runni" unremoved on the first try). - `baidu_engine.py` — the Baidu "百度 AI生成" mark (bottom-right), registered 2026-07-22 off the 16-frame cohort (USCC names Baidu). Detection keys on the 百度 **text run ONLY** (a text+pill template was a measured bright-blob magnet with no separation); the white "AI生成" tag is still removed because the custom `footprint_mask` extends the match box right to the corner (the tag's flat white interior gives no top-hat response, so a blob bbox left it as a ghost). Gate settled at **0.48** after three measured steps (0.37 clean-arm; the 741-eval set fired on 12 Qwen marks + one 抖音 mark, so `rivals=("doubao_alpha.png","qwen_alpha.png")` with load-bearing margins; the full-corpus sweep put true outside-cohort carriers at 0.50-0.66 vs the false arm max 0.47). Cohort 7/16, all true, plus 6 metadata-stripped true carriers the cohort cannot see. STRICT ONLY. - `liblib_engine.py` — the LibLibAI (哩布哩布AI) "LibLibAI" wordmark (**bottom-center**, the first `corner="bc"`), registered 2026-07-22 off the 15-frame cohort. The discriminative lever is the silhouette **font**: STHeiti scored the cohort at the false arm's level (latin UI text bands); measured across 7 fonts, **Arial** lifts the cohort to 0.42-0.73 and drops the false arm to max 0.398. Gate **0.42**, STRICT ONLY, plus a per-mark size floor (`_MIN_SHORT_SIDE=480`: the one remaining false fire was a 200x200 icon on a 20px template). Custom footprint = match box extended left ~1.3 glyph heights for the triangle logo (the blob bbox bled into background structure and never owned the logo). **Zhipu Qingyan (清言·AI生成) and MiniMax/Hailuo were attempted the same day and PARKED** as measured negatives (qingyan: cohort 0.34-0.39 vs clean max 0.34-0.37, no separation at any render/box; minimax: 1 of 6 frames visibly marked, the xinghui rule) -- record in `docs/verification-plan.md` ("The 2026-07-22 vendor round"). diff --git a/README.md b/README.md index 27b8741..aab2a69 100644 --- a/README.md +++ b/README.md @@ -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 / 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. +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 / Tencent Yuanbao / 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生成" 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 --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 Tencent Yuanbao two-line "元宝 / AI生成" mark, 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 or synthetic silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` and synthetic assets from `scripts/render_vendor_silhouettes.py` are used for detection, not 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 --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 - **Two SynthID quality tiers** — SDXL with canny ControlNet remains the default because it is much faster, cheaper, and supports CUDA, XPU, MPS, and CPU. It is the compatibility baseline, not the highest-fidelity option: at the denoise strength needed to remove SynthID it can visibly drift face identity and soften or alter fine content. For the best available visual quality, especially on faces and face-heavy scenes, install `pip install "remove-ai-watermarks[qwen-zimage]"` and select `--pipeline qwen-zimage`. This CUDA-only profile ports the two-stage Synthid-Bypass architecture: Qwen-Image-2512 with the 4-step Lightning LoRA and DiffSynth Canny ControlNet regenerates the full image, then YuNet + SAM isolate faces and Z-Image Turbo regenerates the original face crops before a feathered paste. Direct comparison on two official upstream examples measured much higher ArcFace identity than the current ControlNet result, close to the published upstream output. On July 25, 2026, all six current outputs from the full `visible -> qwen-zimage -> metadata` candidate were checked in provider-separated groups with the corresponding OpenAI and Gemini oracles; none retained the provider watermark signal. This supports the high-quality recommendation for those exact outputs, but is not broad certification across seeds, resolutions, and content classes. The tradeoff is substantial: `qwen-zimage` uses a large model stack, costs much more, runs only on CUDA, does not support a custom `--model`, and can still degrade very small text. It supports `--tile` for the global Qwen pass; after the tiles are blended, face detection and Z-Image restoration run once on the full frame. Tiled outputs require separate oracle validation because the seed-0 certification covers only the exact non-tiled candidate bytes. YuNet replaces the reference workflow's YOLO detector to avoid an AGPL runtime dependency. - **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生成" / 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) +- **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" / Tencent Yuanbao "元宝 / AI生成" / 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 @@ -78,6 +78,7 @@ signal. Click an image to inspect it at full resolution. | **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 | +| **Yuanbao** (元宝, Tencent) | ✅ two-line "元宝 / AI生成" mark (bottom-right) | — | ✅ TC260 AIGC label, including Tencent Cloud's service-provider schema | Polarity-independent local contrast + 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 | @@ -86,9 +87,9 @@ signal. Click an image to inspect it at full resolution. | **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), 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. +> 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 Tencent Yuanbao (two-line "元宝 / AI生成" mark), 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 ` 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. +> **Detection:** `remove-ai-watermarks identify ` 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" / Tencent Yuanbao "元宝 / AI生成" / 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 @@ -366,10 +367,11 @@ 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" / -# Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI / +# Qwen "千问AI生成" / Kling "可灵AI 3.0" / Tencent Yuanbao "元宝 / AI生成" / +# 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 +# force one with --mark gemini / doubao / jimeng / qwen / kling / yuanbao / 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 @@ -438,7 +440,7 @@ remove-ai-watermarks batch ./images/ --mode all ### Python API -One high-level call removes every detected visible mark (Gemini sparkle, Doubao / Jimeng / Qwen / Samsung text, the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use). +One high-level call removes every detected visible mark (Gemini sparkle, the registered vendor text marks including Tencent Yuanbao, and the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use). ```python import remove_ai_watermarks as raiw diff --git a/docs/module-internals.md b/docs/module-internals.md index 01c7f27..f6e9b9a 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -25,11 +25,11 @@ module. ## `identify.py` -`identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, **C2PA cloud-manifest reference** via `metadata.c2pa_cloud_manifest` — signal `c2pa_cloud`, **medium**, provenance-only (does NOT set `is_ai`, excluded from `ai_from_metadata` + clash vendors): a C2PA 2.4 Durable-Content-Credentials case where the embedded manifest is stripped but an XMP `dcterms:provenance` pointer to the vendor's cloud manifest store (`_C2PA_MANIFEST_REPOSITORIES`, today `cai-manifests.adobe.com` → "Adobe Content Authenticity") survives, so the credentials stay recoverable server-side; only emitted when no embedded manifest already attributed the file — surfaced on 2 corpus PNGs 2026-06-10 that read fully `unknown` before, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI / Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. +`identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, **C2PA cloud-manifest reference** via `metadata.c2pa_cloud_manifest` — signal `c2pa_cloud`, **medium**, provenance-only (does NOT set `is_ai`, excluded from `ai_from_metadata` + clash vendors): a C2PA 2.4 Durable-Content-Credentials case where the embedded manifest is stripped but an XMP `dcterms:provenance` pointer to the vendor's cloud manifest store (`_C2PA_MANIFEST_REPOSITORIES`, today `cai-manifests.adobe.com` → "Adobe Content Authenticity") survives, so the credentials stay recoverable server-side; only emitted when no embedded manifest already attributed the file — surfaced on 2 corpus PNGs 2026-06-10 that read fully `unknown` before, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the registered vendor marks, including Tencent Yuanbao 元宝 / AI生成 and Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)". -**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_qwen` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Qwen/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.50, Jimeng 0.45, Qwen 0.45, Samsung 0.40). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. +**Visible-mark detection** (`check_visible`, signals `visible_sparkle` plus `visible_` for every registered vendor mark): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while all registered vendor marks reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own calibrated engine threshold via `MarkDetection.detected`. Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. @@ -61,7 +61,7 @@ module. `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. -**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `qwen` (bottom-right "千问AI生成", Alibaba Tongyi Qianwen), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_trust` turns the policy + evidence into the per-mark trust level the engines consume as `provenance = level != "strict"` — `strict` never relaxes; `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI, e.g. a metadata-stripped screenshot). **Three levels, not two: `strict` / `assumed` / `confirmed`.** Relaxing bypasses the engine's false-positive gate outright, and that bypass is contracted to mean the vendor is CONFIRMED (`GeminiEngine.detect_watermark`'s `trust_provenance`: "external metadata already proves this is a Google generation"). An `assume_ai` caller asserts the image is AI, which says nothing about WHICH vendor, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (`assumed_floor_ok`; gemini 0.50) — see "Assumed-trust confidence floor" below. **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_trust` cross-mark corroboration + the assumed-trust floor + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior was byte-identical to the pre-arbiter two-pass when the arbiter landed; `assume_ai` has since gained the assumed-trust confidence floor (see below), which deliberately changes its verdict on weak gate-bypassed matches. +**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — all registered marks, including Yuanbao. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today include `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `qwen` (bottom-right "千问AI生成", Alibaba Tongyi Qianwen), `yuanbao` (bottom-right two-line "元宝 / AI生成", Tencent), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_trust` turns the policy + evidence into the per-mark trust level the engines consume as `provenance = level != "strict"` — `strict` never relaxes; `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI, e.g. a metadata-stripped screenshot). **Three levels, not two: `strict` / `assumed` / `confirmed`.** Relaxing bypasses the engine's false-positive gate outright, and that bypass is contracted to mean the vendor is CONFIRMED (`GeminiEngine.detect_watermark`'s `trust_provenance`: "external metadata already proves this is a Google generation"). An `assume_ai` caller asserts the image is AI, which says nothing about WHICH vendor, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (`assumed_floor_ok`; gemini 0.50) — see "Assumed-trust confidence floor" below. **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_trust` cross-mark corroboration + the assumed-trust floor + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior was byte-identical to the pre-arbiter two-pass when the arbiter landed; `assume_ai` has since gained the assumed-trust confidence floor (see below), which deliberately changes its verdict on weak gate-bypassed matches. **Head-to-head validation (v0.12.1 reverse-alpha vs the current localize -> fill):** run over the full labelled visible-mark set, with the cv2 / MI-GAN / LaMa fills each compared against the old reverse-alpha. **doubao and jimeng are identical** across every backend -- 100% coverage and 100% clearance either way. **gemini** strict coverage is a few points below reverse-alpha's (the deliberate false-positive tightening), but the metadata-stripped faint ones are now mostly recovered by the DEFAULT white-core rescue in the FP gate (`gemini_engine`: a bright near-WHITE core distinguishes a real faint sparkle from a colored bright corner -- ~14/20 recovered at ~1.25% clean false-fire; a learned classifier on the same features measured worse, 2026-07 tier-1), the residual under `assume_ai`; clearance is equal (~98% both), and neither version touches pixels outside the mark box (outside-box PSNR ~99). **Clearance is fill-independent** -- cv2, MI-GAN and LaMa all strip the mark's shape equally, so the re-detect metric does not separate them; the difference is purely the *visual fill quality* on the recovered region, and it is background-dependent. reverse-alpha recovered textured and especially regular/structured backgrounds (a lattice, a grid) more cleanly than any inpaint; **LaMa closes most of that gap** (the best learned backend), **MI-GAN can ghost or hallucinate structure**, and **cv2 smears** (the last-resort floor). This is why `auto` resolves `LaMa > MI-GAN > cv2` (`preferred_inpaint_backend`) and warns once on the cv2 fallback; on flat backgrounds every backend is clean. @@ -215,12 +215,14 @@ The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit ## `_text_mark_engine.py` -`_text_mark_engine.py` — **shared base for the three text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16, and since 2026-07-21 `ladder` — the scale rungs `_tophat_best` sweeps, per-mark because 千问's two size modes do not fit the shared 3-rung comb (default `(0.8, 1.0, 1.25)`, unchanged for every other mark; densifying the SHARED ladder was measured and rejected -- see the verification plan's B2). plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`. +`_text_mark_engine.py` — **shared base for the registered text-mark engines, extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16, and since 2026-07-21 `ladder` — the scale rungs `_tophat_best` sweeps, per-mark because 千问's two size modes do not fit the shared 3-rung comb (default `(0.8, 1.0, 1.25)`, unchanged for every other mark; densifying the SHARED ladder was measured and rejected -- see the verification plan's B2). plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`. **Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap. +**Polarity-independent contrast front-end (`detect_frontend="contrast"`, 2026-07-25).** Some vendors choose light or dark text from the scene under the mark, so a white top-hat cannot represent both. The contrast response subtracts a local Gaussian luma estimate, takes the absolute residual, suppresses saturated pixels, and max-normalizes before silhouette NCC. One `_contrast_best` method supplies both the score and the winning match box; `footprint_mask` uses that same box after a successful detection, preserving the detector-to-mask parity contract. This mode was added for Tencent Yuanbao and its 0.38 gate is specific to that response. It must not be copied to another front-end or mark without recalibration. + ## `doubao_engine.py` `doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" detector + localizer (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). @@ -271,6 +273,18 @@ 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. +## `yuanbao_engine.py` + +`yuanbao_engine.py` — **thin `TextMarkEngine` subclass, registered 2026-07-25.** Tencent Yuanbao's standard visible mark is a compact italic two-line block, `元宝` over `AI生成`, at the bottom-right. It feeds `identify` as `visible_yuanbao`. + +* **The earlier measured negative was invalidated at the renderer.** A negative shear was applied without an x translation, clipping much of the lower `AI生成` line off the left edge while retaining a wide blank tail. The matcher then squeezed that malformed asset into the fitted geometry, so the old "no separation" result measured a renderer bug rather than the mark. `render_vendor_silhouettes.py` now translates before negative shear and tightly crops the result. +* **Polarity-independent `contrast` front-end:** real Yuanbao frames switch between light-on-dark and dark-on-light stamps. Absolute local-luma residual preserves both forms, while the white top-hat misses the dark one. The synthetic silhouette uses Hiragino Sans GB W6, tight line spacing, a two-pixel dilation, and -0.60 shear. +* **Measured gate and anchor:** gate **0.38**, STRICT ONLY, plus a bottom-right anchor requiring both margins at most 0.04 of the short side. Across 33 byte-unique cohort frames, 28 carry the standard two-line mark and 26 fire (92.9% recall of that variant). The guarded clean arm is 0/286 fires, maximum score 0.348. +* **Removal parity:** detect -> cv2 fill -> re-detect is clean on 26/26 detected real marks. The detector's own match box is the footprint, with median full-frame area 0.70% and maximum 0.95%. +* **Known variant limit:** one frame carries a separate single-line photographer overlay rather than the standard two-line stamp. It stays unregistered because one example cannot support a recall or false-positive calibration. + +Regression: `tests/test_yuanbao_engine.py` covers both polarities, the anchor, the footprint, registry wiring, and detector-clean removal. A confident Yuanbao detection suppresses the Jimeng pill because both can occur on TC260-labelled content but Yuanbao is not Jimeng-basic. + ## `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`. diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 62e2752..62981da 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -737,17 +737,20 @@ priority order: measurement. 2. **The two small correctness items** (open defects 3 and 4) -- both are contained, both have the fix written out below. -3. **The bonus vendors from the harvest** (元宝 n=50, 可灵 n=30, cat-logo n=19) repeat - the 千问 playbook each: font-rendered silhouette, `--fit-geometry`, gate calibration - 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). 星绘 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.) +3. **The bonus vendors from the harvest** (元宝 n=50, 可灵 n=30, cat-logo n=19) + started with the 千问 playbook: font-rendered silhouette, `--fit-geometry`, gate + calibration against the contamination-guarded clean arm, and crossfire against + doubao/jimeng. 可灵 registered on 2026-07-21; 元宝 registered on 2026-07-25 after + correcting a clipped synthetic render and adding polarity-independent local + contrast. The cat-logo remains evidence-limited to two byte-unique carriers. + 可灵 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). + 星绘 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.** + **STATUS 2026-07-25: 可灵 and 元宝 REGISTERED.** * **可灵 (`kling_engine.py`)** -- "可灵AI 3.0" bottom-right, strict-only, gate 0.35, no rival margin, shared 3-rung ladder (the mark is UNIMODAL at 0.12 of the short side). Cohort-vs-clean (286 guarded clean frames): clean p99 0.304 / max @@ -760,24 +763,18 @@ priority order: the jimeng pill exactly like doubao's/qwen's does. The bottom-LEFT `AI生成` pill variant was NOT seen in this cohort's contact sheet at registration time and stays unhandled. - * **元宝 -- MEASURED NEGATIVE, parked.** The mark is a TWO-LINE, italic-slanted - block (元宝 over AI生成), ~5% of the short side. After fitting the render - against real tophat responses (left-align, tight gap, stroke dilation, shear - -0.75 -- which lifted marked frames to 0.65-0.70, at the real-vs-real ceiling - ~0.6), the CLEAN arm rose in lockstep (clean p99 0.643 vs cohort p50 0.472): - the slanted two-line template correlates with generic corner texture at the - same rate it gains on the mark, on BOTH the tophat and binary front-ends, in - wide and tight boxes, in three CJK fonts. Every separation metric measured was - negative. This is the 2026-07-18 千问-style wall, except it survived the - geometry fix: the mark is small + slanted + half-shared-tail, and no synthetic - template separates it on this front-end. The residual levers are a structural - two-line verification stage or a learned patch classifier -- both outside the - cheap playbook. `yuanbao_alpha.png` + its `MARK_OPTS` recipe stay in - `render_vendor_silhouettes.py` as the documented starting point if that lever - is ever built. Fit-trap found en route (now guarded): `_fit_one`'s tiny-gw NCC - inflation -- a sub-30px template scores spuriously high on smooth tophat - responses, so the auto-fit picked a degenerate 0.026 width fraction; the - numbers above come from a gw-floored re-fit. + * **元宝 (`yuanbao_engine.py`) -- REGISTERED 2026-07-25.** The earlier measured + negative was invalid because the synthetic renderer applied negative shear + without an x translation. That clipped most of the lower `AI生成` line off the + left edge and left a wide blank tail, which the matcher then squeezed into the + fitted box. The corrected renderer translates before shearing and tightly + crops the result. A polarity-independent local-contrast front-end then covers + both light-on-dark and dark-on-light stamps. Gate 0.38 plus the measured + bottom-right anchor detects 26 of 28 standard two-line marks across 33 + byte-unique cohort frames (92.9%) and fires on 0 of 286 byte-unique clean + controls (clean max 0.348). Detect -> cv2 fill -> re-detect is clean on 26/26. + The separate one-line photographer overlay remains unregistered because only + one example exists. * **cat-logo -- probe READY, parked on evidence.** The cohort (USCC 91110108562144110X) is 19 frames but only **2 unique carriers** (byte-unique) -- the xinghui rule (nothing registered off ~one frame) applies. The mark is an @@ -848,7 +845,8 @@ Parked, both as measured negatives with the silhouette kept in * **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 元宝. + logo-composite templates, two CJK fonts). Unlike the old Yuanbao result, no + malformed-render explanation has been found for this overlap. * **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. @@ -963,9 +961,9 @@ meant "no registered mark", not "no mark" -- and a calibration clean arm has to re-filtered per candidate, or the gate is read off frames that carry the very mark being calibrated. -The bonus vendors (元宝, 可灵, cat-logo) need their own font-rendered silhouettes before -any of this repeats for them; 可灵 additionally puts a second mark bottom-LEFT, which no -current text-mark config expresses. +元宝 and 可灵 now have registered font-rendered silhouettes. The cat-logo still needs +more than its two byte-unique carriers before registration; 可灵's separate second +bottom-left mark remains uncovered. ### Open defects @@ -1058,10 +1056,11 @@ evidence supports: 2. **Coverage of uncovered vendors is the largest lever.** 千问 was the head of this item and is now CLOSED (registered 2026-07-21, see the harvest section above): the blocker turned out to be evidence, and the TC260 producer-USCC cohort trick removed it. The - remaining named vendors are 元宝 (n=50), 可灵 (n=30) and cat-logo (n=19) -- each needs - a font-rendered silhouette, then the same calibrate-and-crossfire chain. `百度` and the - 星绘/抖音 class are NOT in the corpus in labelable quantity (verified twice; do not - hunt them again). Nothing may be registered off a single frame. + 元宝 and 可灵 are now CLOSED (registered 2026-07-25 and 2026-07-21 respectively). + The cat-logo remains blocked by only two byte-unique carriers. `百度` was also + registered after a later cohort harvest; the 星绘/抖音 class is NOT in the corpus + in labelable quantity (verified twice; do not hunt it again). Nothing may be + registered off a single frame. 3. **A generic shared-tail template is not a shortcut.** `AI生成` is guaranteed across compliant vendors by GB 45438-2025, so one template covering all of them is the obvious idea -- and measured on the tophat front-end it separates a bold 千问 positive from clean diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index d99ba89..c61ea1b 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -72,6 +72,8 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp **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`. +**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The corrected synthetic silhouette and corpus calibration are recorded in `docs/module-internals.md`; a separate one-line photographer-overlay variant remains evidence-limited to one example. + **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/`. **Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found. diff --git a/scripts/render_vendor_silhouettes.py b/scripts/render_vendor_silhouettes.py index 258ba3c..db1d1c9 100644 --- a/scripts/render_vendor_silhouettes.py +++ b/scripts/render_vendor_silhouettes.py @@ -9,10 +9,8 @@ Covered here: qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text yuanbao "元宝\nAI生成" -- Tencent Yuanbao, bottom-right, two-line italic block - (MEASURED NEGATIVE 2026-07-21, parked: the slanted two-line template does - not separate the cohort from clean corners on either front-end; the recipe - + MARK_OPTS stay as the starting point if a structural/learned lever is - built -- full record in docs/verification-plan.md) + (REGISTERED 2026-07-25 after fixing the negative-shear clipping in this + renderer and matching both light and dark mark polarities) kling "可灵AI 3.0" -- Kuaishou Kling, bottom-right, spiral logo + text (REGISTERED 2026-07-21, kling_engine.py) @@ -158,11 +156,16 @@ MARKS = { # Per-mark post-processing for the multi-line / slanted stamps (see render()). MARK_OPTS: dict[str, dict[str, Any]] = { - # Fitted against real tophat responses on the Yuanbao cohort (2026-07-21): a - # right-aligned, gapped, unslanted render plateaued at ~0.34 NCC; left-align + - # 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}, + # Re-fitted 2026-07-25 after the old affine transform was found to clip the + # lower line and retain a large blank right half. Hiragino Sans GB W6, tight + # leading, a 2px dilation, and -0.60 shear match the standard Yuanbao stamp. + "yuanbao_alpha.png": { + "gap_frac": 0.05, + "dilate": 2, + "shear": -0.60, + "font": "/System/Library/Fonts/Hiragino Sans GB.ttc", + "font_index": 2, + }, # 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). @@ -219,7 +222,16 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n sil = cv2.dilate(sil, np.ones((dilate, dilate), np.uint8)) if shear_k: hh, ww = sil.shape - sil = cv2.warpAffine(sil, np.float32([[1, shear_k, 0], [0, 1, 0]]), (ww + int(abs(shear_k) * hh), hh)) + extra = int(abs(shear_k) * hh) + offset = extra if shear_k < 0 else 0 + sil = cv2.warpAffine( + sil, + np.float32([[1, shear_k, offset], [0, 1, 0]]), + (ww + extra, hh), + ) + ys, xs = np.where(sil > 0) + if xs.size: + sil = sil[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] return sil diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index 8f238c3..b80891e 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -132,7 +132,10 @@ class TextMarkConfig: # 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" + # "contrast" correlates against the ABSOLUTE local-luma residual. It is for a mark + # whose renderer switches between light-on-dark and dark-on-light while preserving + # one silhouette (Tencent Yuanbao); a one-polarity white top-hat misses the latter. + detect_frontend: Literal["binary", "tophat", "gray", "contrast"] = "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, @@ -398,6 +401,46 @@ class TextMarkEngine: """The detection score alone -- the box the removal mask needs is discarded here.""" return self._tophat_best(image, loc)[0] + def _contrast_best( + self, image: NDArray[Any], loc: TextMarkLocation + ) -> tuple[float, tuple[int, int, int, int] | None]: + """Best silhouette match against the absolute local-luma residual. + + Unlike the white top-hat, this response is polarity-independent: the same + watermark can be lighter or darker than its local background. Detection and + removal share the returned box, preserving the front-end parity contract. + """ + c = self.config + x, y, bw, bh = loc.bbox + if bh < 16 or bw < 16: + return (0.0, None) + roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32) + luma = roi.mean(axis=2) + sat = roi.max(axis=2) - roi.min(axis=2) + sigma = max(4.0, bh * 0.4) + response = np.abs(luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)) + response *= sat < c.max_saturation + peak = float(response.max()) + sil = self._glyph_silhouette() + if peak <= 1e-6 or sil is None: + return (0.0, None) + response = (response / peak * 255).astype(np.uint8) + 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 >= response.shape[1] or gh >= response.shape[0]: + continue + template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA) + result = cv2.matchTemplate(response, template, 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 _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. @@ -584,6 +627,19 @@ class TextMarkEngine: 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 c.detect_frontend == "contrast": + score = self._contrast_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 (contrast): 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) @@ -643,6 +699,10 @@ class TextMarkEngine: # 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 self.config.detect_frontend == "contrast" and self.detect(image).detected: + # A dark-on-light Yuanbao mark has no WHITE top-hat blob at all. Bound + # the fill by the polarity-independent detector's own match box. + _, box = self._contrast_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: diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 4dbd9aa..bd86bb6 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -128,9 +128,9 @@ def remove_visible( strip_metadata: bool = True, write_noop: bool = True, ) -> tuple[NDArray[Any], list[str]]: - """Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/ - Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr, - [labels removed])``. + """Remove every detected known visible AI mark (Gemini sparkle, the registered + vendor text marks including Tencent Yuanbao, and the Jimeng pill) via + localize -> fill, returning ``(result_bgr, [labels removed])``. ``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the diff --git a/src/remove_ai_watermarks/assets/yuanbao_alpha.png b/src/remove_ai_watermarks/assets/yuanbao_alpha.png index 0f6f48a84efd7be2a6121feb9b52665683c08ae7..3743bb6c61b41fe6b7fc7a509e4a34822049ec9b 100644 GIT binary patch literal 7673 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 z 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 diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 0583002..e88da89 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -418,7 +418,7 @@ def _remove_visible_auto( Routes the ``all``/``batch`` visible step through the same registry path the standalone ``visible`` command uses, so EVERY registered mark is handled (the - Gemini sparkle AND the Doubao/Jimeng/Samsung text marks), not just the sparkle. + Gemini sparkle and all registered vendor text marks), not just the sparkle. Returns ``(result, label-or-None)``; when no ``in_auto`` mark fires the image is returned unchanged with ``None``. ``backend`` selects the shared fill; ``sensitivity`` controls how hard a borderline mark is trusted (auto reads metadata provenance).""" @@ -475,7 +475,7 @@ def _no_visible_mark_exit(source: Path) -> NoReturn: """Explain why no visible watermark was removed, then exit non-zero. The visible registry handles only known visual marks (the Gemini sparkle and - the Doubao/Jimeng/Qwen/Samsung text strips). Most real uploads carry no such mark + the registered vendor text marks). Most real uploads carry no such mark -- frequently an invisible/metadata watermark instead (e.g. an OpenAI or Gemini image whose only signal is C2PA + SynthID). Returning the input unchanged with exit 0 reads as success to a caller and re-serves the @@ -639,7 +639,7 @@ def _run_visible_auto( console.print(f" Input: {source.name} ({w}x{h})") if not removed: # write_noop=False means nothing was written, so a pre-existing output is intact. - console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).") + console.print(" No registered visible mark detected.") _no_visible_mark_exit(source) console.print(f" Removed: {', '.join(removed)}") size_kb = output.stat().st_size / 1024 diff --git a/src/remove_ai_watermarks/doubao_engine.py b/src/remove_ai_watermarks/doubao_engine.py index 3589fe1..bfae0d2 100644 --- a/src/remove_ai_watermarks/doubao_engine.py +++ b/src/remove_ai_watermarks/doubao_engine.py @@ -6,7 +6,7 @@ label mandated by China's TC260 standard, a near-white semi-transparent overlay. Detection matches the bundled glyph silhouette against the corner candidate; removal is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds -``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that +``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner, ``assets/doubao_alpha.png`` -- the detection silhouette, rebuilt by diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index e6e0739..dfe0ae3 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -451,6 +451,7 @@ _VISIBLE_MARK_PLATFORM = { "jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)", "qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)", "kling": "Kuaishou Kling (visible 可灵AI 3.0 mark detected)", + "yuanbao": "Tencent Yuanbao (visible 元宝 / AI生成 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)", @@ -555,7 +556,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b Args: image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container). check_visible: Also run the visible-mark detectors (cv2) -- the Gemini - sparkle and the Doubao/Jimeng text marks from the registry. Set + sparkle and vendor text marks from the registry. Set False for a pure-metadata, dependency-light scan. check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via the optional imwatermark library. No-op when it is not installed. diff --git a/src/remove_ai_watermarks/jimeng_engine.py b/src/remove_ai_watermarks/jimeng_engine.py index 56121bc..1bb2618 100644 --- a/src/remove_ai_watermarks/jimeng_engine.py +++ b/src/remove_ai_watermarks/jimeng_engine.py @@ -6,7 +6,7 @@ class as the Doubao text strip. Detection matches the bundled glyph silhouette against the corner; removal is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds -``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that +``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module supplies only Jimeng's tuned :class:`TextMarkConfig` (bottom-right corner, ``assets/jimeng_alpha.png`` -- the detection silhouette, rebuilt by diff --git a/src/remove_ai_watermarks/samsung_engine.py b/src/remove_ai_watermarks/samsung_engine.py index 919a2fc..b1c10f8 100644 --- a/src/remove_ai_watermarks/samsung_engine.py +++ b/src/remove_ai_watermarks/samsung_engine.py @@ -9,7 +9,7 @@ Doubao/Jimeng marks but bottom-left. Detection matches the bundled glyph silhouette against the corner; removal is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds -``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that +``region_eraser``), NOT reverse-alpha. This is one of the registered text-mark engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph luma since the mark is faint, ``assets/samsung_alpha.png`` -- the detection silhouette, diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index 4d66d77..50b7c5a 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -22,6 +22,7 @@ Entries: - ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right. - ``qwen`` -- Alibaba Tongyi Qianwen "千问AI生成" text strip, bottom-right. - ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right. + - ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right. - ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left. - ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less). - ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end). @@ -90,6 +91,7 @@ _PRODUCT_OF: dict[str, str] = { "jimeng_pill": "jimeng", # same product as the Jimeng wordmark "qwen": "qwen", "kling": "kling", + "yuanbao": "yuanbao", "samsung": "samsung", "runninghub": "runninghub", "baidu": "baidu", @@ -371,6 +373,10 @@ def _engine(key: str) -> Any: from remove_ai_watermarks.kling_engine import KlingEngine _engines[key] = KlingEngine() + elif key == "yuanbao": + from remove_ai_watermarks.yuanbao_engine import YuanbaoEngine + + _engines[key] = YuanbaoEngine() elif key == "samsung": from remove_ai_watermarks.samsung_engine import SamsungEngine @@ -473,8 +479,8 @@ def _gemini_mask( return _engine("gemini").footprint_mask(image, force=force, region=region) -# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine -# interface, so one parameterized adapter pair drives all of them -- a new +# The registered text-mark engines share the TextMarkEngine interface, so one +# parameterized adapter pair drives all of them -- a new # text mark is one `_text_mark(...)` row below, not another copy-paste of these # bodies. Detection matches the glyph silhouette; the mask is the template-free # glyph-bbox footprint (see TextMarkEngine.footprint_mask). @@ -534,6 +540,7 @@ _REGISTRY: tuple[KnownMark, ...] = ( _text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"), _text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"), _text_mark("kling", "Kling 可灵AI 3.0 text", "bottom-right"), + _text_mark("yuanbao", "Tencent Yuanbao 元宝 / AI生成 mark", "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"), @@ -615,10 +622,17 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo so real flat-scene pills (and harmless flat false fires) are cleaned while the damaging textured false fires are left untouched. A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a - 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. + Doubao detection; Qwen, Kling, Yuanbao, RunningHub, and Baidu detections likewise + name other products and suppress the pill. 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 or "runninghub" in keys or "baidu" in keys: + if ( + "doubao" in keys + or "qwen" in keys + or "kling" in keys + or "yuanbao" in keys + or "runninghub" in keys + or "baidu" in keys + ): return False if "jimeng" in keys: return True diff --git a/src/remove_ai_watermarks/yuanbao_engine.py b/src/remove_ai_watermarks/yuanbao_engine.py new file mode 100644 index 0000000..d93659a --- /dev/null +++ b/src/remove_ai_watermarks/yuanbao_engine.py @@ -0,0 +1,138 @@ +"""Tencent Yuanbao visible watermark detector and localizer. + +Yuanbao stamps a compact italic two-line mark, ``元宝`` over ``AI生成``, in the +bottom-right corner. The same silhouette is rendered light on dark scenes and +dark on pale scenes, so a one-polarity white top-hat cannot detect it reliably. +This engine uses the shared text-mark pipeline with the ``contrast`` front-end: +normalized absolute local-luma residual followed by silhouette NCC. + +The bundled silhouette is synthetic and font-rendered by +``scripts/render_vendor_silhouettes.py``. Removal follows the shared +localize-then-fill path and uses the detector's own match box. + +Calibration (2026-07-25) used the metadata-harvested Tencent cohort after byte +deduplication and visual adjudication. The standard two-line variant was detected +on 26 of 28 unique marked carriers (92.9%) at gate 0.38, with 0 fires on 286 +hand-labeled clean frames. The separate photographer-overlay variant is not +covered by this silhouette. +""" + +# The module-level helpers are imported by tests. +# 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 + +WM_WIDTH_FRAC = 0.20 +WM_HEIGHT_FRAC = 0.15 +MARGIN_RIGHT_FRAC = 0.002 +MARGIN_BOTTOM_FRAC = 0.002 + +MAX_SATURATION = 55 +LOGO_MIN_LUMA = 150 +TOPHAT_DELTA = 12 + +DETECT_MIN_COVERAGE = 0.04 +DETECT_NCC_THRESHOLD = 0.38 + +_ALPHA_WIDTH_FRAC = 0.08 +_ALPHA_HEIGHT_FRAC = 0.0446 +_LADDER = (0.95, 1.0, 1.05) + +_CONFIG = TextMarkConfig( + name="Tencent Yuanbao", + asset_name="yuanbao_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="contrast", + scale_basis="short", + ladder=_LADDER, + alpha_width_frac=_ALPHA_WIDTH_FRAC, + alpha_height_frac=_ALPHA_HEIGHT_FRAC, + min_gw=32, + provenance_ncc_factor=1.0, +) + +YuanbaoDetection = TextMarkDetection + + +def _alpha_template() -> NDArray[Any] | None: + """The bundled Yuanbao alpha template (float [0,1]), or None.""" + return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) + + +def _glyph_silhouette() -> NDArray[Any] | None: + """Binary two-line Yuanbao silhouette (255 = glyph), 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 Yuanbao silhouette against ``box_mask``.""" + return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG) + + +class YuanbaoEngine(TextMarkEngine): + """Detect and localize the bottom-right Yuanbao mark.""" + + _ANCHOR_MAX_RIGHT = 0.04 + _ANCHOR_MAX_BOTTOM = 0.04 + + 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: + return TextMarkDetection() + detection = super().detect(image, provenance=provenance) + if not detection.detected: + return detection + location = self.locate(image) + _, box = self._contrast_best(image, location) + if box is None: + detection.detected = False + return detection + h, w = image.shape[:2] + base = min(h, w) + right = (w - (location.x + box[2] + 1)) / base + bottom = (h - (location.y + box[3] + 1)) / base + if not (0 <= right <= self._ANCHOR_MAX_RIGHT and 0 <= bottom <= self._ANCHOR_MAX_BOTTOM): + logger.debug( + "Yuanbao detect: score %.3f but match off-anchor (right=%.3f bottom=%.3f); demoting.", + detection.confidence, + right, + bottom, + ) + detection.detected = False + return detection + + +def load_image_bgr(path: str | Path) -> NDArray[Any]: + """Read an image as a BGR ndarray.""" + from remove_ai_watermarks import image_io + + image = image_io.imread(path) + if image is None: + raise FileNotFoundError(f"Failed to read image: {path}") + return image diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index f45cc60..fdb8ff5 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -20,6 +20,7 @@ class TestCatalog: "jimeng", "qwen", "kling", + "yuanbao", "samsung", "runninghub", "baidu", @@ -42,6 +43,7 @@ class TestCatalog: assert by_key["gemini"].location == "bottom-right" assert by_key["doubao"].location == "bottom-right" assert by_key["jimeng"].location == "bottom-right" + assert by_key["yuanbao"].location == "bottom-right" assert by_key["samsung"].location == "bottom-left" assert by_key["jimeng_pill"].location == "top-left" @@ -60,6 +62,7 @@ class TestScan: "jimeng", "qwen", "kling", + "yuanbao", "samsung", "runninghub", "baidu", @@ -89,7 +92,7 @@ class TestScan: forced remove on a zero-size ndarray crashed (cv2.error on an empty Mat). detect already guarded this; footprint_mask must too. Covers the text + gemini engines.""" empty = np.zeros(shape, np.uint8) - for key in ("doubao", "jimeng", "qwen", "samsung", "gemini"): + for key in ("doubao", "jimeng", "qwen", "yuanbao", "samsung", "gemini"): _result, mask = reg.get_mark(key).remove(empty, force=True) assert mask is None @@ -354,6 +357,16 @@ class TestArbiter: assert "qwen" in keys assert "jimeng_pill" not in keys + def test_pill_dropped_on_yuanbao(self): + # The standard Yuanbao mark identifies a different TC260 product, so a + # coincident top-left pill match must not be treated as Jimeng-basic. + cands = [ + self._c("yuanbao", strict=True, relaxed=True), + self._c("jimeng_pill", strict=True, relaxed=True, flat=True), + ] + keys = self._keys(cands, reg.Context(provenance=frozenset({"jimeng"}))) + assert keys == {"yuanbao"} + def test_pill_metadata_arm_gated_on_flatness(self): ctx = reg.Context(provenance=frozenset({"jimeng"})) assert self._keys([self._c("jimeng_pill", strict=True, relaxed=True, flat=True)], ctx) == {"jimeng_pill"} diff --git a/tests/test_yuanbao_engine.py b/tests/test_yuanbao_engine.py new file mode 100644 index 0000000..608b401 --- /dev/null +++ b/tests/test_yuanbao_engine.py @@ -0,0 +1,94 @@ +"""Tests for the Tencent Yuanbao (元宝 / AI生成) visible-watermark engine.""" + +from __future__ import annotations + +import cv2 +import numpy as np + +from remove_ai_watermarks import watermark_registry as registry +from remove_ai_watermarks.yuanbao_engine import ( + _ALPHA_HEIGHT_FRAC, + _ALPHA_WIDTH_FRAC, + YuanbaoEngine, + _alpha_template, +) + +_MARK_WIDTH_FRAC = 0.08 +_RIGHT_MARGIN_FRAC = 0.028 +_BOTTOM_MARGIN_FRAC = 0.031 + + +def _compose(w: int, h: int, *, bg: float, foreground: float): + """Composite the synthetic Yuanbao silhouette at its measured geometry.""" + image = np.full((h, w, 3), bg, np.float32) + alpha = _alpha_template() + assert alpha is not None + short = min(w, h) + gw = int(_MARK_WIDTH_FRAC * short) + gh = max(6, int((_ALPHA_HEIGHT_FRAC / _ALPHA_WIDTH_FRAC) * gw)) + ax = w - int(_RIGHT_MARGIN_FRAC * short) - gw + ay = h - int(_BOTTOM_MARGIN_FRAC * short) - gh + mark_alpha = np.zeros((h, w), np.float32) + mark_alpha[ay : ay + gh, ax : ax + gw] = cv2.resize(alpha, (gw, gh)) + a3 = mark_alpha[:, :, None] + composed = (a3 * foreground + (1 - a3) * image).clip(0, 255).astype(np.uint8) + return composed, (ax, ay, gw, gh) + + +class TestConfig: + def test_uses_two_polarity_contrast_frontend(self): + assert YuanbaoEngine().config.detect_frontend == "contrast" + + def test_strict_only(self): + assert YuanbaoEngine().config.provenance_ncc_factor == 1.0 + + def test_registry_row(self): + mark = registry.get_mark("yuanbao") + assert mark.location == "bottom-right" + assert "元宝" in mark.label + assert mark.in_auto + + +class TestDetectAndMask: + def test_detects_light_mark_on_dark_background(self): + watermark, _ = _compose(1024, 1024, bg=60, foreground=230) + detection = YuanbaoEngine().detect(watermark) + assert detection.detected + assert detection.confidence >= 0.80 + + def test_detects_dark_mark_on_light_background(self): + """Yuanbao switches mark polarity with the background. + + A white top-hat alone misses the dark-gray stamp used on pale scenes. + """ + watermark, _ = _compose(1024, 1024, bg=230, foreground=110) + detection = YuanbaoEngine().detect(watermark) + assert detection.detected + assert detection.confidence >= 0.80 + + def test_clean_gradient_stays_quiet(self): + ramp = np.tile(np.linspace(40, 220, 1024, dtype=np.uint8), (1024, 1)) + image = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR) + assert not YuanbaoEngine().detect(image).detected + + def test_match_must_hug_bottom_right_anchor(self): + watermark, (ax, ay, gw, gh) = _compose(1024, 1024, bg=60, foreground=230) + assert YuanbaoEngine().detect(watermark).detected + shifted = np.full_like(watermark, 60) + shifted[600 : 600 + gh, 600 : 600 + gw] = watermark[ay : ay + gh, ax : ax + gw] + assert not YuanbaoEngine().detect(shifted).detected + + def test_mask_uses_detector_box_for_dark_mark(self): + watermark, (ax, ay, gw, gh) = _compose(1024, 1024, bg=230, foreground=110) + mask = YuanbaoEngine().footprint_mask(watermark) + assert mask is not None + ys, xs = np.where(mask > 0) + assert xs.min() <= ax + int(0.05 * gw) + assert xs.max() >= ax + gw - int(0.05 * gw) + assert ys.min() <= ay + gh // 2 <= ys.max() + + def test_remove_clears_detector(self): + watermark, _ = _compose(1024, 1024, bg=60, foreground=230) + output, region = registry.get_mark("yuanbao").remove(watermark, backend="cv2") + assert region is not None + assert not YuanbaoEngine().detect(output).detected