From a8f3536d3e4f3523612a4d4cdea99fdc18a6b8c1 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Thu, 16 Jul 2026 17:40:46 -0700 Subject: [PATCH] Refactor watermark detection and provenance handling --- AGENTS.md | 95 ++++ CLAUDE.md | 4 +- README.md | 2 +- docs/module-internals.md | 17 +- src/remove_ai_watermarks/api.py | 122 +++-- src/remove_ai_watermarks/cli.py | 432 ++++++++++-------- src/remove_ai_watermarks/identify.py | 66 +-- src/remove_ai_watermarks/metadata.py | 4 +- .../watermark_registry.py | 100 +++- tests/test_api.py | 18 + tests/test_metadata.py | 13 + tests/test_watermark_registry.py | 74 ++- 12 files changed, 654 insertions(+), 293 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4be40fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# Remove-AI-Watermarks + +You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI watermarks from images. + +## Scope and non-goals + +The mission is removing **AI-provenance watermarks** that a platform stamps onto content the user generated themselves — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is user autonomy over their own generated output. + +It deliberately does **not** remove watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled / diagonal "preview" watermark whose job is to gate a purchase. Stripping those makes a paid resource free off someone else's work; out of scope **by principle, not by technical difficulty**. The line: a visible mark is in scope when it labels the user's **own** AI generation, and out of scope when it protects a **third party's paid asset**. + +Consequences for contributors (do not drift back into the stock niche just because it is technically feasible): +- Do not add stock / agency / classifieds watermark removal to `watermark_registry.py` or the eraser, and do not build tiled-overlay or multi-image watermark-estimation features aimed at them. +- `erase --region` stays a generic **user-driven** tool (the user points at their own object); do not ship an *automatic* stock-watermark detector/remover on top of it. +- New visible-mark templates are for **AI-generation labels only**. + +(Established 2026-06-13 by user instruction: "Я пытаюсь сделать платные ресурсы бесплатными — это не то, против чего мы боремся.") + +## How to run + +Per-command exit-code semantics (the no-signal / GPU-missing skip branches), test traps, and regression-guard paths live in `docs/module-internals.md` (section "CLI commands (`cli.py`)") — read it before changing any command's skip/exit behavior. + +- `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|assume-ai` (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`, `--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", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one. `--sensitivity auto|strict|assume-ai` (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; `assume-ai` relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured 2026-07-16 end to end: recall 55.0% -> 62.8% on metadata-stripped Google-C2PA images, at 2.3% false fire on clean camera captures vs strict's 0.0%). Metadata provenance is read automatically and feeds `auto`; the library cannot infer AI from a stripped image, so only `assume-ai` reaches the higher recall there. **`assume-ai` asserts the image is AI, NOT which vendor made it**, so a mark relaxed on assumption alone must still clear `_ASSUMED_CONF_FLOOR` — do not remove that floor (see the registry bullet). 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) +- `uv run remove-ai-watermarks metadata --remove -o ` — strip all AI metadata +- `uv run remove-ai-watermarks batch ` — process every supported image in a directory (output defaults to `_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0. + +## Test and lint + +- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. +- **Release flow + distribution channels** (PyPI publish via `publish.yml`/`uv publish`, the automated Homebrew-tap + HF-Space bumps in `distribute.yml`, conda-forge, ComfyUI Registry, the sdist `data/` exclusion, hatchling pin history): see `docs/release-and-distribution.md` before cutting a release. +- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scoped `src/`, see the OOM note below), pytest -n auto. The helper tools live in the `dev` extra (`pytest-xdist`, plus `uv-outdated`/`uv-secure` marker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without `--extra dev` does not have them. +- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. The `uv-secure` CVE-resolution history (idna/aiohttp bumps, retired basicsr, the dismissed torch `GHSA-rrmf-rvhw-rf47`) lives in `docs/release-and-distribution.md` — read it before re-triaging a dependency alert. +- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly. +- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch. +- **Stale `trustmark` remnant in site-packages after an extras change:** the `trustmark` package downloads model weights INTO its own package dir, so when a narrower `uv sync` prunes the package, a `trustmark/models/` directory survives as an empty namespace package. Symptom: pyright `"TrustMark" is unknown import symbol` on `trustmark_detector.py` and `find_spec("trustmark")` returning a loader-less spec (so `is_available()` lies True). Fix: `rm -rf .venv/lib/python3.12/site-packages/trustmark` (regenerable weights cache). +- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand. +- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC; `flux-1.png` / `flux-1.jpg` = real Black Forest Labs FLUX.2 Playground output, signed C2PA (issuer "Black Forest Labs" + `trainedAlgorithmicMedia`) -- `flux-1.jpg` is the first committed **JPEG-with-C2PA** fixture, exercising the c2pa-python non-PNG reader path end to end; whether BFL hosted output also embeds the open DWT-DCT pixel watermark is UNRESOLVED -- our detector returns None on these fox samples, but they are high-texture carriers where even a known-embedded watermark fails the round-trip, see the content-fragility caveat in `docs/watermarking-landscape.md`); synthetic byte blobs cover the remaining JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent). +- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles. **`cleaned/` examples must be produced by a CURRENT shipped removal method** -- the default SDXL img2img pass (optionally `--max-resolution`). Do NOT archive cleaned outputs from methods that are no longer in the pipeline (ctrlregen, the old text/face-protection, IP-Adapter FaceID, CodeFormer) or from the experimental opt-in paths (controlnet, face restore) as corpus examples; a cleaned reference should represent the canonical removal, and a removed method's output is not a reproducible example. Keep those experiment outputs in a local working dir, never in the committed corpus. + +## Configuration + +- GPU/ML modules (invisible_engine, watermark_remover) are optional — guard imports with `is_available()` checks +- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent. +- Optional `esrgan` extra (spandrel only): Real-ESRGAN pre-diffusion super-resolution for small inputs (`upscaler.py`, CLI `--upscaler esrgan` on `invisible`/`all`/`batch`). Guarded by `upscaler.is_available()`; the default upscaler stays Lanczos (cv2, no deps) and the engine falls back to Lanczos when the extra is absent or the model errors. spandrel is MIT and pulls NO basicsr (only torch/torchvision/safetensors/numpy/einops); Real-ESRGAN weights are BSD-3-Clause and download on first use via `torch.hub` (never bundled). Kept OUT of `all` (heavy + model download). +- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale-cap-vs-upscale-floor, `test_invisible_engine.py`), `humanizer.unsharp_mask`/`adaptive_polish` (`test_humanizer.py`), and the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover). Don't skip these as "ML, needs a model" — only `remove_watermark`/the diffusion bodies do. + +## Key modules + +Compact map. The full per-module detail (design decisions, tuned thresholds, calibration history, incident records, and the regression-guard map) lives in `docs/module-internals.md` — **read the relevant section there before changing any module below.** + +- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use). +- `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`). **`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 / 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`/`assume_ai`, 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/assume decide (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); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). **`resolve_trust` resolves THREE levels, and the `assumed` vs `confirmed` split is load-bearing:** both bypass the engine's false-positive gate, but 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"). `assume_ai` asserts the image is AI, NOT which vendor made it, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (gemini 0.50). **Do NOT collapse `assumed` back into `confirmed`** (regression 2026-07-16, `tests/test_watermark_registry.py::TestArbiter::test_assume_ai_drops_sparkle_below_the_assumed_floor`): `assume_ai` used to pass `trust_provenance=True` on the bare assertion, leaving only the raw 0.35 detector threshold, which fired on **59.8% of 256 genuine camera captures** -- `--sensitivity assume-ai` filled a phantom sparkle on ~6 of every 10 CLEAN photos, and the public `api.remove_visible(sensitivity="assume_ai")` did it to 8/15 of the committed verified-clean negatives. The floor makes the assumed relax MONOTONIC over strict (a strict-accepted mark is never dropped by it), so `assume_ai` only ever adds recall. Corpus-measured end to end 2026-07-16 (400 Google-C2PA positives with metadata hidden from the detector; 256 camera-capture negatives where a sparkle cannot exist): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3%. The earlier "~46% -> ~92%" claim measured recall only, on Google-C2PA files where the answer was always Google, and never measured false fire on non-Google content. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes a SMALL false-fire rate acceptable -- it is not 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** detection, 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) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise 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 holds even under `assume_ai`. 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. `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 three 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). +- `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. +- `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²). **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. +- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. **Both SDXL loaders (`_load_pipeline`, `_load_controlnet_pipeline`) pass `add_watermarker=False`** — diffusers otherwise embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on EVERY SDXL output whenever `invisible-watermark` is installed (the `detect` extra), so a watermark REMOVER would re-stamp a detectable AI watermark and the cleaned output re-reads as AI (`identify` → "Open invisible watermark: Stable Diffusion XL"; corpus-observed on the SynthID sample before the fix). Only the pipeline accepts the kwarg, not the `ControlNetModel` sub-model; qwen is not SDXL and has no such watermarker. Regression: `tests/test_platform.py::TestNoReembeddedWatermark`. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one). +- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). `WatermarkRemover.remove_watermark` branches to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner. +- `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit). +- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text. +- `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches. `read_bgr_and_alpha`/`write_bgr_with_alpha` (+ `ALPHA_FORMATS`) are the alpha-preserving IO helpers shared by the CLI and the library `api` (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). cv2/numpy import lazily, so importing `image_io` is cheap. **`imread` has a Pillow fallback (`_pil_read`) for HEIC/AVIF**: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core `pillow-heif` dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). **`imwrite` PRESERVES the input format at max quality** ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (`_pil_write`) since cv2 has NO encoder for them (writing `.heic` via cv2 RAISES — a HEIC input used to crash on save). `imwrite` never raises (catches `cv2.error`). **`api.remove_visible` copies the original bytes verbatim on a no-op** (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. `noai/constants.SUPPORTED_FORMATS` now includes `.heic`/`.heif`/`.avif` alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so `batch` discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). **The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top:** `watermark_remover` saves the regenerated output through `image_io.imwrite` (not raw `PIL.save`, which defaults to JPEG q75), `invisible_engine` writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level `metadata.remove_ai_metadata` (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical. +- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`. + +For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`. + +## Watermarking landscape + +Who embeds what (C2PA / IPTC / EXIF / TC260 AIGC / xAI signature / open and proprietary invisible watermarks), whether each is locally detectable, the C2PA 2.4 durable-credentials implications, and the regulatory driver table live in `docs/watermarking-landscape.md` (research 2026-05-24, updated through 2026-06-10). Read it before adding a new `identify` signal, vendor token, or metadata marker. See `identify.py` for what we read today. + +## Known limitations + +Compact list. Full measurements, incident history, and oracle-validation runs live in `docs/known-limitations.md` — **read the relevant section there before changing the diffusion pipelines, strength defaults, resolution handling, or metadata coverage.** + +- **Visible-mark fill quality is background/backend-dependent.** The fill only touches the mark footprint (no outside-box damage) and whether the mark is removed is fill-independent — cv2/MI-GAN/LaMa all strip the shape; only the recovered region's *quality* differs. Flat backgrounds: all clean (cv2 often crispest). Textured/regular-structured (fabric, grid): cv2 smears, MI-GAN can ghost/hallucinate, LaMa best. The old reverse-alpha recovered true pixels so it was sometimes cleaner on structure, but localize -> fill trades that for robustness (moved/re-rendered marks, no per-mark capture); `auto` = LaMa > MI-GAN > cv2 with a one-time cv2-fallback warning. Head-to-head vs v0.12.1 on the full visible set: doubao/jimeng identical (100%/100%), gemini strict coverage a few points lower (the metadata-stripped faint ones now mostly recovered by the default white-core rescue in the gemini FP gate; the residual via `assume-ai`), clearance ~98% both. Detail in `docs/known-limitations.md`. +- `invisible` processes at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (`--min-resolution 0` disables; `--max-resolution N` is an opt-in cap to bound GPU/MPS memory). MPS OOM is memory-tier dependent, not a hard limit: ~24 GB unified memory falls back to CPU (slow but weight-identical output), 32 GB runs native on MPS. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size` — keep the logic there, unit-tested without the model. For large inputs that OOM, `--tile` is the **lossless** alternative to `--max-resolution`: sliding-window diffusion at native resolution, each tile near SDXL's 1024 training size, feather-blended over the overlap (`noai/tiling.py`). It only engages when the long side exceeds `--tile-size`; the geometry (`plan_tiles`) and the blend window (`feather_weights`) are pure and unit-tested (`tests/test_tiling.py`). Caveat: each tile is an independent low-strength regeneration, so at the certified removal strengths (0.20-0.30) tile drift is minimal but not zero; tiling is a memory workaround, not a quality upgrade over a single native pass. +- fp16 VAE black-output (issues #29/#41): the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix`) is swapped in for the default SDXL checkpoint on cuda/xpu fp16, plus a model-agnostic backstop that detects a degenerate (all-black) fp16 output and re-runs once in fp32. cpu/mps run fp32 and never reproduce the bug. +- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files. +- A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (`ModuleNotFoundError`), not `UnidentifiedImageError`, when opening a file. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`. +- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design. +- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists). +- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here. +- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content. +- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based. +- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.10** / Gemini **0.15** / unknown **0.15** when `--strength` is unset (the 2026-06-14 lowering from the 2026-06-04 cert floors of 0.20/0.30 — the single source of truth is `watermark_profiles.py`, and the full cert/lowering history is in `docs/known-limitations.md`); explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type. +- **`controlnet` is the default pipeline**; `--pipeline sdxl` is the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR. diff --git a/CLAUDE.md b/CLAUDE.md index 0025d1f..4be40fe 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|assume-ai` (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`, `--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", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one. `--sensitivity auto|strict|assume-ai` (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; `assume-ai` relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured Gemini recall ~46% -> ~92%, at the cost of a small fill on some clean corners). Metadata provenance is read automatically and feeds `auto`; the library cannot infer AI from a stripped image, so only `assume-ai` reaches the high recall there. 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", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one. `--sensitivity auto|strict|assume-ai` (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; `assume-ai` relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured 2026-07-16 end to end: recall 55.0% -> 62.8% on metadata-stripped Google-C2PA images, at 2.3% false fire on clean camera captures vs strict's 0.0%). Metadata provenance is read automatically and feeds `auto`; the library cannot infer AI from a stripped image, so only `assume-ai` reaches the higher recall there. **`assume-ai` asserts the image is AI, NOT which vendor made it**, so a mark relaxed on assumption alone must still clear `_ASSUMED_CONF_FLOOR` — do not remove that floor (see the registry bullet). 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,7 +56,7 @@ 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`). **`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 / 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`/`assume_ai`, 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_relax` + 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/assume decide (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); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under `strict`/metadata-free `auto` and ~92% under `assume_ai` (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's `assume_ai` reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes `assume_ai` acceptable. 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** detection, 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) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise 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 holds even under `assume_ai`. 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. `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 / 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`/`assume_ai`, 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/assume decide (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); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). **`resolve_trust` resolves THREE levels, and the `assumed` vs `confirmed` split is load-bearing:** both bypass the engine's false-positive gate, but 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"). `assume_ai` asserts the image is AI, NOT which vendor made it, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (gemini 0.50). **Do NOT collapse `assumed` back into `confirmed`** (regression 2026-07-16, `tests/test_watermark_registry.py::TestArbiter::test_assume_ai_drops_sparkle_below_the_assumed_floor`): `assume_ai` used to pass `trust_provenance=True` on the bare assertion, leaving only the raw 0.35 detector threshold, which fired on **59.8% of 256 genuine camera captures** -- `--sensitivity assume-ai` filled a phantom sparkle on ~6 of every 10 CLEAN photos, and the public `api.remove_visible(sensitivity="assume_ai")` did it to 8/15 of the committed verified-clean negatives. The floor makes the assumed relax MONOTONIC over strict (a strict-accepted mark is never dropped by it), so `assume_ai` only ever adds recall. Corpus-measured end to end 2026-07-16 (400 Google-C2PA positives with metadata hidden from the detector; 256 camera-capture negatives where a sparkle cannot exist): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3%. The earlier "~46% -> ~92%" claim measured recall only, on Google-C2PA files where the answer was always Google, and never measured false fire on non-Google content. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes a SMALL false-fire rate acceptable -- it is not 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** detection, 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) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise 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 holds even under `assume_ai`. 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. `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 three 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). - `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. diff --git a/README.md b/README.md index def35db..189c0e7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ It does **not** target watermarks that protect someone else's paid or copyrighte ## 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, 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; `--sensitivity assume-ai` relaxes every mark on the assertion that the image is AI, recovering the moved or re-rendered marks the conservative gate skips on a metadata-stripped screenshot (`strict` never relaxes). `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, 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; `--sensitivity assume-ai` relaxes every mark on the assertion that the image is AI, recovering the moved or re-rendered marks the conservative gate skips on a metadata-stripped screenshot (`strict` never relaxes). Because asserting "this is AI" says nothing about *which* vendor made it, a mark relaxed on that assertion alone still has to clear a confidence floor, so a clean photo is left untouched. `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 diff --git a/docs/module-internals.md b/docs/module-internals.md index db18240..bb03b77 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -61,11 +61,22 @@ module. `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. -**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged). +**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_trust` turns the policy + evidence into the per-mark trust level the engines consume as `provenance = level != "strict"` — `strict` never relaxes; `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI, e.g. a metadata-stripped screenshot). **Three levels, not two: `strict` / `assumed` / `confirmed`.** Relaxing bypasses the engine's false-positive gate outright, and that bypass is contracted to mean the vendor is CONFIRMED (`GeminiEngine.detect_watermark`'s `trust_provenance`: "external metadata already proves this is a Google generation"). An `assume_ai` caller asserts the image is AI, which says nothing about WHICH vendor, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (`assumed_floor_ok`; gemini 0.50) — see "Assumed-trust confidence floor" below. **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_trust` cross-mark corroboration + the assumed-trust floor + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior was byte-identical to the pre-arbiter two-pass when the arbiter landed; `assume_ai` has since gained the assumed-trust confidence floor (see below), which deliberately changes its verdict on weak gate-bypassed matches. **Head-to-head validation (v0.12.1 reverse-alpha vs the current localize -> fill):** run over the full labelled visible-mark set, with the cv2 / MI-GAN / LaMa fills each compared against the old reverse-alpha. **doubao and jimeng are identical** across every backend -- 100% coverage and 100% clearance either way. **gemini** strict coverage is a few points below reverse-alpha's (the deliberate false-positive tightening), but the metadata-stripped faint ones are now mostly recovered by the DEFAULT white-core rescue in the FP gate (`gemini_engine`: a bright near-WHITE core distinguishes a real faint sparkle from a colored bright corner -- ~14/20 recovered at ~1.25% clean false-fire; a learned classifier on the same features measured worse, 2026-07 tier-1), the residual under `assume_ai`; clearance is equal (~98% both), and neither version touches pixels outside the mark box (outside-box PSNR ~99). **Clearance is fill-independent** -- cv2, MI-GAN and LaMa all strip the mark's shape equally, so the re-detect metric does not separate them; the difference is purely the *visual fill quality* on the recovered region, and it is background-dependent. reverse-alpha recovered textured and especially regular/structured backgrounds (a lattice, a grid) more cleanly than any inpaint; **LaMa closes most of that gap** (the best learned backend), **MI-GAN can ghost or hallucinate structure**, and **cv2 smears** (the last-resort floor). This is why `auto` resolves `LaMa > MI-GAN > cv2` (`preferred_inpaint_backend`) and warns once on the cv2 fallback; on flat backgrounds every backend is clean. -**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). 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. +**Assumed-trust confidence floor (`_ASSUMED_CONF_FLOOR` / `assumed_floor_ok`, 2026-07-16):** relaxing a mark bypasses the engine's false-positive gate ENTIRELY, leaving only the bare detector threshold (gemini: fused confidence >= 0.35). That is defensible when metadata names the vendor (`confirmed`) and indefensible on a bare "assume this is AI" (`assumed`), because the assertion carries no vendor information. Measured on 256 genuine camera captures (Make/Model/exposure/aperture present, no AI token -- a Gemini sparkle cannot be there) vs 697 Google-C2PA positives with the metadata used only as a label: + +| bypassed threshold | recall | false fire on clean photos | +|---|---|---| +| 0.35 (the bare gate) | 82.6% | **59.8%** | +| 0.45 | 66.6% | 12.5% | +| **0.50 (chosen)** | 59.4% | **0.0%** | +| strict gate | 56.4% | 0.0% | + +0.35 sat on a cliff: +26pp recall over strict bought by filling a corner on ~6 of every 10 CLEAN photos, and `api.remove_visible(sensitivity="assume_ai")` reproduced it on 8/15 of the committed verified-clean negatives. The floor is applied in the arbiter and is **monotonic over strict** -- a mark the strict gate accepted is never dropped by it, so `assume_ai` only ever adds recall. End-to-end after the fix (400 Google-C2PA positives with metadata hidden from the detector, 256 camera negatives, through the public `api.remove_visible`): recall strict 55.0% / auto 55.2% / assume_ai 62.8%; false fire 0.0% / 0.0% / 2.3%. The residual 2.3% contains **no gemini at all** (doubao 2, jimeng_pill 2, jimeng 1, samsung 1 of 256) -- the text marks' own relaxed gates (<1% each) plus the pill's flat-footprint arm, all pre-existing and benign. The superseded "~46% -> ~92%" figure measured recall only, on Google-C2PA files where the answer was always Google; false fire on non-Google content was never measured. Marks other than gemini carry no floor because their bypassed false-fire is already under 1%. Regression: `tests/test_watermark_registry.py::TestArbiter::{test_assume_ai_drops_sparkle_below_the_assumed_floor, test_assume_ai_keeps_sparkle_confirmed_by_metadata_below_the_floor, test_assume_ai_is_monotonic_over_strict}`. + +**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). That gain is why the bypass exists, and it is conditional on the metadata actually naming the vendor — a caller merely ASSUMING the image is AI does not get it unconditionally (see the assumed-trust confidence floor above). 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. **Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag (lowered to 0.35 under the Google/Gemini provenance prior) — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto` → `remove_auto_marks` (removes every detected mark), `--mark ` → that mark; `--mark` choices come from `mark_keys()`. @@ -230,7 +241,7 @@ Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alterna ### `visible` -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, 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 (LaMa is auto-preferred when a learned backend is present; a memory-tight deploy pins migan). `--sensitivity auto|strict|assume-ai` (default `auto`) controls how hard a borderline mark is trusted (see the registry section: the visual detectors are metadata-independent; `auto` relaxes a mark only on same-product evidence, `assume-ai` relaxes every mark on the caller's AI assertion — the only path to high recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — ~27% precise, its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). 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. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark ` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0). +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, 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 (LaMa is auto-preferred when a learned backend is present; a memory-tight deploy pins migan). `--sensitivity auto|strict|assume-ai` (default `auto`) controls how hard a borderline mark is trusted (see the registry section: the visual detectors are metadata-independent; `auto` relaxes a mark only on same-product evidence, `assume-ai` relaxes every mark on the caller's AI assertion, subject to the assumed-trust confidence floor where the vendor is unconfirmed — the only path to higher recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — ~27% precise, its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). 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. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark ` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0). ### `batch` diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 6148f09..3bde8b9 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -16,6 +16,7 @@ Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is from __future__ import annotations +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -25,6 +26,16 @@ if TYPE_CHECKING: from remove_ai_watermarks.watermark_registry import Backend, Sensitivity +@dataclass(frozen=True) +class _VisibleInput: + """Normalized visible-removal input with its file-only context.""" + + bgr: NDArray[Any] + alpha: NDArray[Any] | None = None + path: Path | None = None + provenance: frozenset[str] = frozenset() + + def visible_provenance(source: str | Path) -> frozenset[str]: """Vendor keys that the file's local metadata confirms, the evidence that drives the ``auto`` sensitivity (relaxing a corroborated mark's detection trust gate). @@ -36,19 +47,70 @@ def visible_provenance(source: str | Path) -> frozenset[str]: """ import contextlib - keys: set[str] = set() + path = Path(source) with contextlib.suppress(Exception): - from remove_ai_watermarks import identify, metadata + from remove_ai_watermarks import identify - rep = identify.identify(Path(source), check_visible=False, check_invisible=False) + rep = identify.identify(path, check_visible=False, check_invisible=False) + signal_names = {signal.name for signal in rep.signals} + keys: set[str] = set() platform = (rep.platform or "").lower() if "google" in platform or "gemini" in platform: keys.add("gemini") - if metadata.aigc_label(Path(source)): + if "aigc" in signal_names: keys |= {"doubao", "jimeng"} - if metadata.samsung_genai(Path(source)): + if "samsung_genai" in signal_names: keys.add("samsung") - return frozenset(keys) + return frozenset(keys) + return frozenset() + + +def _load_visible_input(source: str | Path | NDArray[Any]) -> _VisibleInput: + """Normalize a path/array source without making the public operation stateful.""" + if not isinstance(source, (str, Path)): + return _VisibleInput(source) + + from remove_ai_watermarks import image_io + + path = Path(source) + bgr, alpha = image_io.read_bgr_and_alpha(path) + if bgr is None: + raise ValueError(f"Could not read image: {source}") + return _VisibleInput(bgr=bgr, alpha=alpha, path=path, provenance=visible_provenance(path)) + + +def _write_visible_result( + loaded: _VisibleInput, + result: NDArray[Any], + removed: list[str], + output: str | Path, + *, + strip_metadata: bool, + write_noop: bool, +) -> None: + """Write one visible-removal result while preserving a true no-op losslessly.""" + if not removed and not write_noop: + return + + from remove_ai_watermarks import image_io + + out_path = Path(output) + out_path.parent.mkdir(parents=True, exist_ok=True) + source_path = loaded.path + if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower(): + # Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place + # call needs no copy and would otherwise raise shutil.SameFileError. + if source_path.resolve() != out_path.resolve(): + import shutil + + shutil.copyfile(source_path, out_path) + else: + image_io.write_bgr_with_alpha(out_path, result, loaded.alpha) + + if strip_metadata: + from remove_ai_watermarks import metadata + + metadata.remove_ai_metadata(out_path, out_path) def remove_visible( @@ -88,40 +150,22 @@ def remove_visible( output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI ``visible`` no-mark contract) does not clobber a pre-existing file at that path. """ - from remove_ai_watermarks import image_io, watermark_registry - - alpha: NDArray[Any] | None = None - provenance: frozenset[str] = frozenset() - if isinstance(source, (str, Path)): - path = Path(source) - bgr, alpha = image_io.read_bgr_and_alpha(path) - if bgr is None: - raise ValueError(f"Could not read image: {source}") - provenance = visible_provenance(path) - else: - bgr = source + from remove_ai_watermarks import watermark_registry + loaded = _load_visible_input(source) result, removed = watermark_registry.remove_auto_marks( - bgr, sensitivity=sensitivity, provenance=provenance, backend=backend + loaded.bgr, + sensitivity=sensitivity, + provenance=loaded.provenance, + backend=backend, ) - if output is not None and (removed or write_noop): - out_path = Path(output) - out_path.parent.mkdir(parents=True, exist_ok=True) - same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == out_path.suffix.lower() - if not removed and same_format: - # Nothing was removed: copy the ORIGINAL bytes verbatim instead of a lossy - # re-encode of its decode, so the pixels stay bit-identical (the metadata - # strip below is lossless, so it does not disturb them either). Skip the copy - # for an in-place call (output == source): the bytes are already there, and - # shutil.copyfile would raise SameFileError. - if Path(source).resolve() != out_path.resolve(): # type: ignore[arg-type] - import shutil - - shutil.copyfile(source, out_path) # type: ignore[arg-type] - else: - image_io.write_bgr_with_alpha(out_path, result, alpha) - if strip_metadata: - from remove_ai_watermarks import metadata - - metadata.remove_ai_metadata(out_path, out_path) + if output is not None: + _write_visible_result( + loaded, + result, + removed, + output, + strip_metadata=strip_metadata, + write_noop=write_noop, + ) return result, removed diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 17a07bb..79d07a1 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -12,6 +12,7 @@ import contextlib import json import logging import time +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NoReturn @@ -311,8 +312,9 @@ _visible_sensitivity_option = click.option( help="How hard to trust a borderline mark. auto: relax a mark only when metadata " "or a same-product sibling mark corroborates it (safe; clean images untouched). " "strict: high-precision visual gate only, never relaxed. assume-ai: treat the " - "image as AI and relax every mark (best recall on metadata-stripped screenshots, " - "at the cost of a small fill on some clean corners).", + "image as AI and relax every mark, keeping a confidence floor where the vendor is " + "unconfirmed (best recall on metadata-stripped screenshots; a clean image is still " + "left untouched).", ) @@ -525,6 +527,113 @@ def main(ctx: click.Context, verbose: bool) -> None: # ── Visible (Gemini) watermark removal ── +def _run_visible_auto( + source: Path, + output: Path, + *, + backend: watermark_registry.Backend, + sensitivity: watermark_registry.Sensitivity, + strip_metadata: bool, +) -> None: + """Run the registry-wide visible pass and render its CLI result.""" + from remove_ai_watermarks import api + + t0 = time.monotonic() + try: + with console.status("Detecting & removing visible marks..."): + result, removed = api.remove_visible( + str(source), + str(output), + sensitivity=sensitivity, + backend=backend, + strip_metadata=strip_metadata, + write_noop=False, + ) + except RuntimeError as e: # selected migan/lama backend whose extra is absent + console.print(f" Error: {e}") + raise SystemExit(1) from e + except (ValueError, OSError) as e: # unreadable / truncated / non-image input + console.print(f" Error: cannot read image {source.name}: {e}") + raise SystemExit(1) from e + + elapsed = time.monotonic() - t0 + h, w = result.shape[:2] + console.print(f" Input: {source.name} ({w}x{h})") + if not removed: + # write_noop=False means nothing was written, so a pre-existing output is intact. + console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).") + _no_visible_mark_exit(source, sensitivity=sensitivity) + console.print(f" Removed: {', '.join(removed)}") + size_kb = output.stat().st_size / 1024 + console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") + + +def _run_visible_explicit( + ctx: click.Context, + source: Path, + output: Path, + *, + detect: bool, + mark: str, + backend: watermark_registry.Backend, + sensitivity: watermark_registry.Sensitivity, + resolved_backend: str, + strip_metadata: bool, +) -> None: + """Run one explicitly selected visible-mark detector/remover.""" + image, alpha = image_io.read_bgr_and_alpha(source) + if image is None: + console.print(f"Error: Failed to read image: {source}") + raise SystemExit(1) + h, w = image.shape[:2] + console.print(f" Input: {source.name} ({w}x{h})") + + provenance = _visible_provenance(source) + target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback + chosen = watermark_registry.get_mark(target) + # A single explicit mark has no sibling corroboration. Keep its trust resolution + # aligned with the registry arbiter, including the assumption-only floor. + trust = watermark_registry.resolve_trust( + chosen.key, + sensitivity=sensitivity, + provenance=provenance, + strict_keys=set(), + ) + relax = trust != "strict" + detection = chosen.detect(image, provenance=relax) + if trust == "assumed" and not watermark_registry.assumed_floor_ok(chosen.key, detection.confidence): + relax = False + detection = chosen.detect(image, provenance=False) + if detect and not detection.detected: + console.print(f" {chosen.label} not detected (conf {detection.confidence:.2f}). Use --no-detect to force.") + _no_visible_mark_exit(source, sensitivity=sensitivity) + if detection.detected: + console.print(f" {chosen.label} detected ({chosen.location}, conf {detection.confidence:.2f})") + + t0 = time.monotonic() + try: + with console.status(f"Removing {chosen.label}... ({resolved_backend})"): + result, _ = chosen.remove(image, backend=backend, provenance=relax, force=not detect) + except RuntimeError as e: # selected migan/lama backend whose extra is absent + console.print(f" Error: {e}") + raise SystemExit(1) from e + elapsed = time.monotonic() - t0 + + output.parent.mkdir(parents=True, exist_ok=True) + image_io.write_bgr_with_alpha(output, result, alpha) + if strip_metadata: + try: + from remove_ai_watermarks.metadata import remove_ai_metadata + + remove_ai_metadata(output, output) + except Exception as e: + if ctx.obj.get("verbose"): + console.print(f" Warning: Failed to strip metadata: {e}") + + size_kb = output.stat().st_size / 1024 + console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") + + @main.command("visible") @click.argument("source", type=click.Path(exists=True, path_type=Path)) @click.option( @@ -560,18 +669,16 @@ def cmd_visible( MI-GAN > cv2). ``--mark auto`` removes every detected mark in one pass. For arbitrary logos/objects, use ``erase``. """ - from remove_ai_watermarks import watermark_registry as registry - _banner() source = _validate_image(source) if output is None: output = source.with_stem(source.stem + "_clean") - bk: registry.Backend = backend # type: ignore[assignment] + bk: watermark_registry.Backend = backend # type: ignore[assignment] sens = _parse_sensitivity(sensitivity) - resolved_backend = registry.resolve_backend(bk) - if resolved_backend == "cv2" and not registry.inpaint_model_available(): + resolved_backend = watermark_registry.resolve_backend(bk) + if resolved_backend == "cv2" and not watermark_registry.inpaint_model_available(): console.print(" Note: using cv2 fill (install the 'migan' extra for a lightweight ONNX model).") # ``auto`` removes EVERY detected in_auto mark in one pass (a Jimeng-basic image @@ -579,82 +686,20 @@ def cmd_visible( # read -> provenance -> localize/fill -> write -> metadata-strip to the library # entry point, so the CLI and the library go through ONE path (no drift). if mark == "auto" and detect: - from remove_ai_watermarks import api - - t0 = time.monotonic() - try: - with console.status("Detecting & removing visible marks..."): - result, removed = api.remove_visible( - str(source), - str(output), - sensitivity=sens, - backend=bk, - strip_metadata=strip_metadata, - write_noop=False, - ) - except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent - console.print(f" Error: {e}") - raise SystemExit(1) from e - except (ValueError, OSError) as e: # unreadable / truncated / non-image input - console.print(f" Error: cannot read image {source.name}: {e}") - raise SystemExit(1) from e - elapsed = time.monotonic() - t0 - h, w = result.shape[:2] - console.print(f" Input: {source.name} ({w}x{h})") - if not removed: - # write_noop=False means nothing was written, so a pre-existing file at the - # output path is left intact (the no-mark contract writes nothing). - console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).") - _no_visible_mark_exit(source, sensitivity=sens) - console.print(f" Removed: {', '.join(removed)}") - size_kb = output.stat().st_size / 1024 - console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") + _run_visible_auto(source, output, backend=bk, sensitivity=sens, strip_metadata=strip_metadata) return - # Explicit single mark (or --no-detect): needs the decoded array + the per-mark gate, - # so it keeps its own read/remove/write (still through the shared io + registry). - image, alpha = image_io.read_bgr_and_alpha(source) - if image is None: - console.print(f"Error: Failed to read image: {source}") - raise SystemExit(1) - h, w = image.shape[:2] - console.print(f" Input: {source.name} ({w}x{h})") - provenance = _visible_provenance(source) - target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback - chosen = registry.get_mark(target) - # A single explicit mark has no cross-mark pass (no sibling corroboration), so use the - # canonical arbiter policy with an empty strict-sibling set instead of re-deriving it - # inline (keeps this in lockstep with `decide`). - prov = registry.resolve_relax(chosen.key, sensitivity=sens, provenance=provenance, strict_keys=set()) - det = chosen.detect(image, provenance=prov) - if detect and not det.detected: - console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.") - _no_visible_mark_exit(source, sensitivity=sens) - if det.detected: - console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})") - t0 = time.monotonic() - try: - with console.status(f"Removing {chosen.label}... ({resolved_backend})"): - result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect) - except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent - console.print(f" Error: {e}") - raise SystemExit(1) from e - elapsed = time.monotonic() - t0 - - # Save (rejoins the original alpha plane unchanged) + strip metadata. - output.parent.mkdir(parents=True, exist_ok=True) - image_io.write_bgr_with_alpha(output, result, alpha) - if strip_metadata: - try: - from remove_ai_watermarks.metadata import remove_ai_metadata - - remove_ai_metadata(output, output) - except Exception as e: - if ctx.obj.get("verbose"): - console.print(f" Warning: Failed to strip metadata: {e}") - - size_kb = output.stat().st_size / 1024 - console.print(f" Saved: {output} ({size_kb:.0f} KB, {elapsed:.2f}s)") + _run_visible_explicit( + ctx, + source, + output, + detect=detect, + mark=mark, + backend=bk, + sensitivity=sens, + resolved_backend=resolved_backend, + strip_metadata=strip_metadata, + ) # ── Universal region eraser ── @@ -1261,32 +1306,106 @@ def _passthrough_copy(img_path: Path, out_path: Path) -> None: image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha) +@dataclass(frozen=True) +class _BatchOptions: + """Validated processing options shared by every image in one batch. + + Click necessarily exposes these as individual command parameters, but the + processing core should receive one coherent value instead of a 21-argument + call. Keeping the object immutable also makes it safe to reuse while the + batch caches model instances in ``ctx.obj``. + """ + + strength: float | None + steps: int + pipeline: str + device: str + seed: int | None + hf_token: str | None + humanize: float + backend: str = "auto" + sensitivity: str = "auto" + unsharp: float = 0.0 + max_resolution: int = 0 + min_resolution: int = 1024 + controlnet_scale: float = 1.0 + upscaler: str = "lanczos" + model: str | None = None + guidance_scale: float | None = None + adaptive_polish: bool = False + tile: bool = False + tile_size: int = 1024 + tile_overlap: int = 128 + force: bool = False + + +def _run_batch_invisible( + ctx: click.Context, + img_path: Path, + out_path: Path, + mode: str, + options: _BatchOptions, +) -> bool: + """Run or safely skip the invisible pass for one batch image. + + Returns ``True`` only when a detectable target could not be processed because + the GPU dependencies are missing. The availability probe is intentionally + evaluated once so branching cannot observe inconsistent optional-dependency + state. + """ + from remove_ai_watermarks.invisible_engine import is_available as invisible_available + + skip_no_signal = _should_skip_invisible_scrub(options.force, img_path) + available = invisible_available() + if available and not skip_no_signal: + from remove_ai_watermarks.invisible_engine import InvisibleEngine + + # Cache the engine in ctx.obj so the batch builds it once (pipeline is a + # single CLI value, constant across the run). + engines = ctx.obj.setdefault("_inv_engines", {}) + if options.pipeline not in engines: + engines[options.pipeline] = InvisibleEngine( + model_id=options.model, + device=None if options.device == "auto" else options.device, + pipeline=options.pipeline, + hf_token=options.hf_token, + controlnet_conditioning_scale=options.controlnet_scale, + ) + engines[options.pipeline].remove_watermark( + img_path if mode == "invisible" else out_path, + out_path, + strength=options.strength, + num_inference_steps=options.steps, + guidance_scale=options.guidance_scale, + seed=options.seed, + humanize=options.humanize, + unsharp=options.unsharp, + adaptive_polish=options.adaptive_polish, + max_resolution=options.max_resolution, + min_resolution=options.min_resolution, + upscaler=options.upscaler, + tile=options.tile, + tile_size=options.tile_size, + tile_overlap=options.tile_overlap, + # Detect the vendor from the pristine original (`img_path`), not the + # visible-processed `out_path` whose C2PA is already gone. + vendor=vendor_for_strength(img_path), + ) + return False + + # Invisible-only mode has no preceding visible pass to create ``out_path``. + # Preserve a complete output directory while deliberately leaving pixels intact. + if mode == "invisible" and not out_path.exists(): + _passthrough_copy(img_path, out_path) + return not available and not skip_no_signal + + def _process_batch_image( ctx: click.Context, img_path: Path, out_path: Path, mode: str, - strength: float | None, - steps: int, - pipeline: str, - device: str, - seed: int | None, - hf_token: str | None, - humanize: float, - backend: str = "auto", - sensitivity: str = "auto", - unsharp: float = 0.0, - max_resolution: int = 0, - min_resolution: int = 1024, - controlnet_scale: float = 1.0, - upscaler: str = "lanczos", - model: str | None = None, - guidance_scale: float | None = None, - adaptive_polish: bool = False, - tile: bool = False, - tile_size: int = 1024, - tile_overlap: int = 128, - force: bool = False, + options: _BatchOptions, ) -> bool: """Process a single image for batch mode. @@ -1312,71 +1431,21 @@ def _process_batch_image( if image is None: raise ValueError("Failed to read image") - result, _ = _remove_visible_auto(image, source_path=img_path, backend=backend, sensitivity=sensitivity) + result, _ = _remove_visible_auto( + image, + source_path=img_path, + backend=options.backend, + sensitivity=options.sensitivity, + ) image_io.write_bgr_with_alpha(out_path, result, alpha) saved_alpha = alpha if mode in ("invisible", "all"): - from remove_ai_watermarks.invisible_engine import ( - is_available as invisible_available, - ) - # Skip the destructive regeneration when no invisible watermark is locally # detectable (would only degrade a clean image). Read the pristine `img_path`; # `out_path` may already be the visible-processed result. --force overrides. - skip_no_signal = _should_skip_invisible_scrub(force, img_path) - if invisible_available() and not skip_no_signal: - from remove_ai_watermarks.invisible_engine import InvisibleEngine - - # Cache the engine in ctx.obj so the batch builds it once (pipeline is a - # single CLI value, constant across the run). - engines = ctx.obj.setdefault("_inv_engines", {}) - if pipeline not in engines: - engines[pipeline] = InvisibleEngine( - model_id=model, - device=None if device == "auto" else device, - pipeline=pipeline, - hf_token=hf_token, - controlnet_conditioning_scale=controlnet_scale, - ) - engine_inv = engines[pipeline] - engine_inv.remove_watermark( - img_path if mode == "invisible" else out_path, - out_path, - strength=strength, - num_inference_steps=steps, - guidance_scale=guidance_scale, - seed=seed, - humanize=humanize, - unsharp=unsharp, - adaptive_polish=adaptive_polish, - max_resolution=max_resolution, - min_resolution=min_resolution, - upscaler=upscaler, - tile=tile, - tile_size=tile_size, - tile_overlap=tile_overlap, - # Detect the vendor from the pristine original (`img_path`), not the - # visible-processed `out_path` whose C2PA is already gone. - vendor=vendor_for_strength(img_path), - ) - elif not invisible_available() and not skip_no_signal: - # An invisible signal IS present but the GPU deps are missing, so the - # SynthID scrub cannot run. Mirror the single `all` command's loud skip: - # flag it for a batch-level warning + non-zero exit (a silently retained - # SynthID watermark is the #1 "it didn't work" report). For invisible mode - # nothing wrote out_path yet -> copy the input through so the output dir is - # complete with the pixels deliberately left intact (without this, a - # signal-bearing image in a GPU-less --mode invisible run got NO output). - synthid_skipped = True - if mode == "invisible" and not out_path.exists(): - _passthrough_copy(img_path, out_path) - elif skip_no_signal and mode == "invisible" and not out_path.exists(): - # No invisible target and the visible/all pass did not write out_path - # (invisible mode): copy the input through so the output dir is complete - # with the pixels deliberately left intact. - _passthrough_copy(img_path, out_path) + synthid_skipped = _run_batch_invisible(ctx, img_path, out_path, mode, options) if mode in ("metadata", "all"): from remove_ai_watermarks.metadata import remove_ai_metadata @@ -1485,6 +1554,29 @@ def cmd_batch( if mode in ("invisible", "all"): _warn_if_esrgan_unavailable(upscaler) adaptive_polish = _resolve_auto_polish(auto, adaptive_polish) + options = _BatchOptions( + strength=strength, + steps=steps, + pipeline=pipeline, + device=device, + seed=seed, + hf_token=hf_token, + humanize=humanize, + backend=backend, + sensitivity=sensitivity, + unsharp=unsharp, + max_resolution=max_resolution, + min_resolution=min_resolution, + controlnet_scale=controlnet_scale, + upscaler=upscaler, + model=model, + guidance_scale=guidance_scale, + adaptive_polish=adaptive_polish, + tile=tile, + tile_size=tile_size, + tile_overlap=tile_overlap, + force=force, + ) processed = 0 errors = 0 @@ -1510,27 +1602,7 @@ def cmd_batch( img_path=img_path, out_path=out_path, mode=mode, - strength=strength, - steps=steps, - pipeline=pipeline, - device=device, - seed=seed, - hf_token=hf_token, - humanize=humanize, - backend=backend, - sensitivity=sensitivity, - unsharp=unsharp, - max_resolution=max_resolution, - min_resolution=min_resolution, - controlnet_scale=controlnet_scale, - upscaler=upscaler, - model=model, - guidance_scale=guidance_scale, - adaptive_polish=adaptive_polish, - tile=tile, - tile_size=tile_size, - tile_overlap=tile_overlap, - force=force, + options=options, ): synthid_skipped_count += 1 processed += 1 diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 51b6e4e..c973407 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -505,6 +505,42 @@ def _trustmark(image_path: Path) -> str | None: return detect_trustmark(image_path) +def _collect_visible_signals( + image_path: Path, + signals: list[Signal], + watermarks: list[str], + platform: str | None, +) -> str | None: + """Decode once, append every trusted visible-mark signal, and return platform. + + Keeping this stage separate from metadata aggregation makes the optional cv2 + boundary explicit and guarantees that all visible detectors share one decoded + BGR array. A decode failure preserves the detectors' historical fallback/no-op + behavior. + """ + image: NDArray[Any] | None = None + try: + from remove_ai_watermarks.image_io import imread + + image = imread(image_path) + except Exception as exc: # cv2 missing - detectors fall back / no-op + logger.debug("visible-mark decode unavailable: %s", exc) + + sparkle_conf = _visible_sparkle(image_path, image=image) + if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD: + signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium")) + watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})") + if platform is None: + platform = "Google Gemini family (visible sparkle detected)" + + for detection in _visible_text_marks(image_path, image=image): + signals.append(Signal(f"visible_{detection.key}", f"NCC confidence {detection.confidence:.2f}", "medium")) + watermarks.append(f"Visible {detection.label} (confidence {detection.confidence:.2f})") + if platform is None: + platform = _VISIBLE_MARK_PLATFORM[detection.key] + return platform + + def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport: """Identify an image's origin platform and watermark inventory. @@ -755,36 +791,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b or xai_sig ) - # Decode the file ONCE for every visible-mark detector. The sparkle and the - # text-mark detectors both consume a BGR array; letting each re-read the file - # was two full cv2 decodes of the same bitmap, which spikes memory on a small - # worker. None (cv2 missing / unreadable container) makes each detector fall - # back to its own read, preserving the old behavior. - vis_image: NDArray[Any] | None = None if check_visible: - try: - from remove_ai_watermarks.image_io import imread - - vis_image = imread(image_path) - except Exception as exc: # cv2 missing - detectors fall back / no-op - logger.debug("visible-mark decode unavailable: %s", exc) - - # ── Visible Gemini sparkle (fallback for stripped-metadata case) ─ - sparkle_conf = _visible_sparkle(image_path, image=vis_image) if check_visible else None - if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD: - signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium")) - watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})") - if platform is None: - platform = "Google Gemini family (visible sparkle detected)" - - # ── Visible Doubao / Jimeng text marks (registry; same stripped-metadata - # fallback role as the Gemini sparkle above) ─ - if check_visible: - for det in _visible_text_marks(image_path, image=vis_image): - signals.append(Signal(f"visible_{det.key}", f"NCC confidence {det.confidence:.2f}", "medium")) - watermarks.append(f"Visible {det.label} (confidence {det.confidence:.2f})") - if platform is None: - platform = _VISIBLE_MARK_PLATFORM[det.key] + platform = _collect_visible_signals(image_path, signals, watermarks, platform) visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata hf_only = bool(hf_job) and not ai_from_metadata diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 8bb8b33..6395723 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -347,7 +347,7 @@ def has_ai_metadata(image_path: Path) -> bool: return True # China TC260 AIGC label as a PNG text chunk (the byte scan above catches # only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse). - if aigc_label(image_path): + if aigc_label(image_path) is not None: return True # HuggingFace-hosted job marker (hf-job-id PNG text chunk). if huggingface_job(image_path): @@ -887,7 +887,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: result["soft_binding"] = ", ".join(vendors) # China TC260 AI-content label (Doubao and other China-served generators). - if aigc := aigc_label(image_path): + if (aigc := aigc_label(image_path)) is not None: producer = aigc.get("ContentProducer", "") result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}" diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index ac059a2..6789918 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -50,16 +50,24 @@ Backend = Literal["auto", "cv2", "migan", "lama"] # a clean corner). Lowest recall on faint/moved marks. # * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product # evidence the mark is there -- metadata provenance for that vendor, or a confidently -# detected sibling mark of the same product (see ``resolve_relax``). No evidence -> +# detected sibling mark of the same product (see ``resolve_trust``). No evidence -> # stays strict. Safe: it only escalates where the mark is corroborated. # * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts # the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded -# to a watermark remover). Recovers the faint/moved marks the strict gate demotes -# (~49% -> ~89% Gemini recall, corpus-measured), at the cost of a harmless small fill -# on some clean corners. The library CANNOT infer this from a stripped image -- only the -# caller's out-of-band context (the user uploaded to remove a mark) justifies it. +# to a watermark remover). Recovers the faint/moved marks the strict gate demotes. The +# library CANNOT infer this from a stripped image -- only the caller's out-of-band +# context (the user uploaded to remove a mark) justifies it. An assertion that the +# image is AI is NOT evidence of WHICH vendor made it, so a mark relaxed on assumption +# alone must still clear ``_ASSUMED_CONF_FLOOR``; see that constant for why. Sensitivity = Literal["auto", "strict", "assume_ai"] +# The trust level a mark's detection gate is resolved to (see ``resolve_trust``). The +# split between ``assumed`` and ``confirmed`` is load-bearing: both bypass the engine's +# false-positive gate, but only ``confirmed`` has evidence naming THIS vendor, which is +# exactly what that bypass is documented to require (see GeminiEngine.detect_watermark's +# ``trust_provenance`` contract). ``assumed`` therefore carries a confidence floor. +Trust = Literal["strict", "assumed", "confirmed"] + # Product family per mark, for the ``auto`` cross-mark corroboration: a confidently # detected mark relaxes only OTHER marks of the SAME product (different corners, one # product -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance @@ -120,6 +128,8 @@ class Candidate: Carries the mark's verdict at BOTH trust levels (``detected_strict`` = the conservative gate, ``detected_relaxed`` = the gate the engine relaxes to under provenance/assume), so the arbiter can pick per mark without re-running detection. + ``relaxed_confidence`` is the gate-bypassed detection's confidence, which the arbiter + needs to apply :func:`assumed_floor_ok` when a mark is relaxed on assumption alone. ``features`` is a generic bag of physical measurements a mark's gate may need (the mark owns which it reports via ``KnownMark._features``); e.g. the pill supplies ``footprint_flat`` (0/1). Empty for marks whose gate needs no extra evidence.""" @@ -128,6 +138,7 @@ class Candidate: label: str detected_strict: bool detected_relaxed: bool + relaxed_confidence: float features: dict[str, float] # generic; both construction sites always supply it (empty when none) @@ -446,28 +457,59 @@ def detect_marks( return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto] -def resolve_relax( +# Minimum gate-bypassed confidence a mark must reach when it is relaxed on ASSUMPTION +# (``assume_ai``) rather than on evidence naming its vendor. Relaxing bypasses the +# engine's false-positive gate entirely, which is justified by vendor CONFIRMATION; an +# assumption that the image is AI says nothing about WHICH vendor, so the bypassed +# detector needs its own floor or it fires on ordinary content. +# +# Corpus-measured 2026-07-16 (256 genuine camera captures -- Make/Model/exposure/aperture +# present and no AI token, so a Gemini sparkle cannot be there -- vs 697 Google-C2PA +# positives, metadata used only as the label, never fed to the detector): +# +# bypassed threshold recall false-fire on clean photos +# 0.35 82.6% 59.8% <- the bare detector gate +# 0.45 66.6% 12.5% +# 0.50 59.4% 0.0% <- chosen +# strict gate 56.4% 0.0% +# +# So 0.35 sat on a cliff: it bought +26pp recall over strict by filling a corner on ~6 +# of every 10 CLEAN photos. At 0.50 the flag is honest -- it still beats strict, for free. +# Marks absent from this dict relax identically at both levels; their bypassed false-fire +# on the same negatives is under 1% (doubao 0.8%, jimeng 0.4%, samsung 0.4%). +_ASSUMED_CONF_FLOOR: dict[str, float] = {"gemini": 0.50} + + +def assumed_floor_ok(key: str, confidence: float) -> bool: + """Whether an ``assumed``-trust detection of ``key`` at ``confidence`` is trustworthy + enough to act on (see :data:`_ASSUMED_CONF_FLOOR`). Marks with no floor always pass.""" + floor = _ASSUMED_CONF_FLOOR.get(key) + return floor is None or confidence >= floor + + +def resolve_trust( key: str, *, sensitivity: Sensitivity, provenance: frozenset[str], strict_keys: set[str], -) -> bool: - """Whether mark ``key``'s detection gate is relaxed (strict -> assume level). +) -> Trust: + """The trust level mark ``key``'s detection gate is resolved to. The single place that turns the ``sensitivity`` policy + evidence into a per-mark - boolean (which the engines consume): ``strict`` never relaxes, ``assume_ai`` always - relaxes, and ``auto`` relaxes only on same-product evidence -- the vendor confirmed - by metadata (``key in provenance``) or a confidently strict-detected sibling of the - same product (``_PRODUCT_OF``).""" + level (which the engines consume as ``provenance = level != "strict"``). ``strict`` + never relaxes. A mark is ``confirmed`` only on same-product evidence -- the vendor + confirmed by metadata (``key in provenance``) or a confidently strict-detected + sibling of the same product (``_PRODUCT_OF``). Without that evidence, ``assume_ai`` + yields ``assumed`` (relaxed, but subject to :func:`assumed_floor_ok`) and ``auto`` + stays ``strict``.""" if sensitivity == "strict": - return False - if sensitivity == "assume_ai": - return True - if key in provenance: - return True + return "strict" product = _PRODUCT_OF[key] - return any(_PRODUCT_OF[k] == product for k in strict_keys if k != key) + confirmed = key in provenance or any(_PRODUCT_OF[k] == product for k in strict_keys if k != key) + if confirmed: + return "confirmed" + return "assumed" if sensitivity == "assume_ai" else "strict" def _keep_pill(keys: set[str], *, provenance: frozenset[str], sensitivity: Sensitivity, footprint_flat: bool) -> bool: @@ -515,7 +557,7 @@ def _build_candidates(image: NDArray[Any]) -> list[Candidate]: strict = m.detect(image, provenance=False) relaxed = m.detect(image, provenance=True) feats = m.features(image) if (strict.detected or relaxed.detected) else {} - cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats)) + cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, relaxed.confidence, feats)) return cands @@ -523,17 +565,25 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]: """The removal ARBITER: a pure function turning perception + context into the ordered list of marks to remove (and the trust level each was accepted at). - All policy lives here, in one place: per-mark relaxation (:func:`resolve_relax`, - which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and - the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is - unit-testable in isolation and the same decision drives every caller.""" + All policy lives here, in one place: per-mark trust resolution (:func:`resolve_trust`, + which needs the strict-detected siblings for ``auto`` cross-mark corroboration), the + assumed-trust confidence floor (:func:`assumed_floor_ok`) and the capture-less pill + gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and + the same decision drives every caller.""" strict_keys = {c.key for c in candidates if c.detected_strict} fired: list[Decision] = [] for c in candidates: - relax = resolve_relax( + trust = resolve_trust( c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys ) - if c.detected_relaxed if relax else c.detected_strict: + relax = trust != "strict" + ok = c.detected_relaxed if relax else c.detected_strict + if trust == "assumed" and not assumed_floor_ok(c.key, c.relaxed_confidence): + # Relaxed on assumption alone and too weak to trust: fall back to the strict + # verdict rather than dropping the mark, so assume_ai is monotonic -- it only + # ever ADDS recall over strict, never removes less than strict would. + ok, relax = c.detected_strict, False + if ok: fired.append(Decision(c, relax)) keys = {d.candidate.key for d in fired} if "jimeng_pill" in keys: diff --git a/tests/test_api.py b/tests/test_api.py index ed6d1ea..5212865 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -104,6 +104,24 @@ class TestVisibleProvenance: def test_unreadable_path_is_empty(self, tmp_path): assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset() + def test_uses_report_signals_for_falsy_metadata_values(self, monkeypatch, tmp_path): + """An empty TC260 object and Samsung genAIType=0 are still present signals. + + The report has already normalized those values, so the public API must not + re-read the file and accidentally discard them by truthiness. + """ + from types import SimpleNamespace + + from remove_ai_watermarks import identify + + report = SimpleNamespace( + platform=None, + signals=[SimpleNamespace(name="aigc"), SimpleNamespace(name="samsung_genai")], + ) + monkeypatch.setattr(identify, "identify", lambda *args, **kwargs: report) + + assert raiw.visible_provenance(tmp_path / "synthetic.png") == frozenset({"doubao", "jimeng", "samsung"}) + class TestRemoveVisibleOutputPath: """Output-path robustness: in-place clean (#3) and a missing output dir (#4).""" diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 926f540..43db90f 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -978,6 +978,19 @@ class TestAIGCLabel: assert "aigc_label" in meta assert "TC260" in meta["aigc_label"] + def test_empty_namespaced_label_is_still_surfaced(self, tmp_path: Path): + """The namespaced element is unambiguous even when its JSON object is empty.""" + from remove_ai_watermarks.metadata import aigc_label + + p = tmp_path / "empty_aigc.png" + Image.new("RGB", (32, 32)).save(p) + with open(p, "ab") as f: + f.write(b"{}") + + assert aigc_label(p) == {} + assert has_ai_metadata(p) + assert "aigc_label" in get_ai_metadata(p) + def _aigc_chunk_png(self, tmp_path: Path, producer: str = "doubao") -> Path: """Doubao writes the TC260 object as a PNG ``tEXt`` chunk keyed ``AIGC`` with raw JSON (no XMP, no namespaced marker).""" diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 043f4a4..4f0951c 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -165,36 +165,60 @@ class TestLocalizeFill: class TestSensitivity: - """``resolve_relax`` turns the sensitivity policy + evidence into the per-mark - relaxation boolean the engines consume.""" + """``resolve_trust`` turns the sensitivity policy + evidence into the per-mark + trust level the engines consume.""" def test_strict_never_relaxes(self): # even with metadata provenance, strict keeps the conservative gate assert ( - reg.resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set()) - is False + reg.resolve_trust("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set()) + == "strict" ) - def test_assume_ai_always_relaxes(self): - assert reg.resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True + def test_assume_ai_without_evidence_is_assumed_not_confirmed(self): + # asserting the image is AI says nothing about WHICH vendor made it, so the mark + # is relaxed on assumption only -- it must not inherit the confirmed-vendor bypass + assert ( + reg.resolve_trust("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) == "assumed" + ) + + def test_assume_ai_with_metadata_is_confirmed(self): + assert ( + reg.resolve_trust("gemini", sensitivity="assume_ai", provenance=frozenset({"gemini"}), strict_keys=set()) + == "confirmed" + ) def test_auto_relaxes_on_own_metadata(self): assert ( - reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) is True + reg.resolve_trust("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) + == "confirmed" ) def test_auto_strict_without_evidence(self): - assert reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False + assert reg.resolve_trust("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) == "strict" def test_auto_cross_mark_same_product(self): # a detected Jimeng wordmark relaxes the Jimeng pill (same product, other corner) assert ( - reg.resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is True + reg.resolve_trust("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) + == "confirmed" ) def test_auto_no_cross_mark_across_products(self): # a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner) - assert reg.resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False + assert ( + reg.resolve_trust("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) == "strict" + ) + + def test_assumed_floor_rejects_weak_sparkle_but_passes_strong(self): + # the gate-bypassed sparkle detector fires on ~60% of ordinary photos at its bare + # 0.35 threshold; only a match well clear of that floor is trustworthy on assumption + assert reg.assumed_floor_ok("gemini", 0.35) is False + assert reg.assumed_floor_ok("gemini", 0.50) is True + + def test_assumed_floor_default_passes_for_unfloored_marks(self): + # text marks relax cleanly (<1% bypassed false-fire), so they carry no floor + assert reg.assumed_floor_ok("doubao", 0.36) is True def test_remove_auto_marks_accepts_all_sensitivities(self): blank = np.zeros((256, 256, 3), np.uint8) @@ -209,9 +233,11 @@ class TestArbiter: Candidates -- this is the payoff of separating decision from perception.""" @staticmethod - def _c(key, *, strict=False, relaxed=False, flat=False): + def _c(key, *, strict=False, relaxed=False, flat=False, relaxed_conf=1.0): + # relaxed_conf defaults high so a test that does not care about the assumed-trust + # confidence floor exercises the trust logic, not the floor. feats = {"footprint_flat": 1.0} if flat else {} - return reg.Candidate(key, f"L:{key}", strict, relaxed, feats) + return reg.Candidate(key, f"L:{key}", strict, relaxed, relaxed_conf, feats) def _keys(self, cands, ctx): return {d.candidate.key for d in reg.decide(cands, ctx)} @@ -228,6 +254,30 @@ class TestArbiter: assert [d.candidate.key for d in fired] == ["gemini"] assert fired[0].relax is True + def test_assume_ai_drops_sparkle_below_the_assumed_floor(self): + # REGRESSION (2026-07-16): assume_ai passed trust_provenance=True to the engine, + # bypassing the sparkle false-positive gate on the mere ASSERTION that the image is + # AI -- but that flag is contracted to mean "metadata proved this vendor". The bare + # bypassed gate (conf 0.35) fired on 59.8% of 256 genuine camera captures, so + # `--sensitivity assume-ai` filled a phantom sparkle on ~6 of every 10 clean photos. + weak = self._c("gemini", relaxed=True, relaxed_conf=0.40) + assert reg.decide([weak], reg.Context(sensitivity="assume_ai")) == [] + + def test_assume_ai_keeps_sparkle_confirmed_by_metadata_below_the_floor(self): + # the floor exists because the vendor is UNKNOWN; once metadata names Google the + # bypass is contract-legal again, so a weak match is still trusted + weak = self._c("gemini", relaxed=True, relaxed_conf=0.40) + ctx = reg.Context(sensitivity="assume_ai", provenance=frozenset({"gemini"})) + assert [d.candidate.key for d in reg.decide([weak], ctx)] == ["gemini"] + + def test_assume_ai_is_monotonic_over_strict(self): + # a mark the STRICT gate accepted must never be dropped by the assumed floor: + # assume_ai only ever adds recall + weak_but_strict = self._c("gemini", strict=True, relaxed=True, relaxed_conf=0.40) + fired = reg.decide([weak_but_strict], reg.Context(sensitivity="assume_ai")) + assert [d.candidate.key for d in fired] == ["gemini"] + assert fired[0].relax is False # accepted on the strict verdict, so mask at strict + def test_auto_relaxes_on_provenance(self): c = [self._c("gemini", relaxed=True)] assert self._keys(c, reg.Context(provenance=frozenset({"gemini"}))) == {"gemini"}