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