diff --git a/.gitignore b/.gitignore index 5372352..491809c 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,4 @@ gfpgan/ scripts/_qwen_exp_out/ # Local-only working data for analysis (not a committed corpus; never tracked) -data/spaces/ +.local-eval/ diff --git a/CLAUDE.md b/CLAUDE.md index b700f7e..f1b631b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes - `uv run remove-ai-watermarks invisible -o ` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default except resolution-adaptive `qwen-zimage`), `--steps` (**interacts with `--strength`** on the diffusers profiles; `watermark_profiles.viable_steps` prevents zero effective steps. `qwen-zimage` instead fixes its Lightning stage at 4 steps), `--guidance-scale`, `--pipeline sdxl|controlnet|qwen|qwen-zimage` (default `controlnet`; `qwen` and `qwen-zimage` are manual opt-ins), `--controlnet-scale`, `--model`, `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize`, `--unsharp`, `--adaptive-polish/--no-adaptive-polish`, `--tile/--no-tile` + `--tile-size`/`--tile-overlap`, `--cpu-offload/--no-cpu-offload`, `--force/--no-force`. `--cpu-offload` trades speed for lower CUDA VRAM use by moving Diffusers model components between CPU and GPU; on `qwen-zimage` it forces the face stack to offload instead of using automatic residency. It has no effect on CPU/MPS. ControlNet is the compatibility and cost default, not the highest-fidelity mode. Recommend the CUDA-only `qwen-zimage` profile when output quality, especially face identity, matters more than runtime and cost; it needs the separate extra, uses a fixed Qwen-Image-2512 + Z-Image stack, rejects `--model`, defaults to the oracle-candidate seed 0, and supports tiling only for its global Qwen pass. The full-frame face stage runs once after tile blending. Tiled outputs still need separate oracle certification. `--auto` is deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable; see the module doc. - `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Qwen "千问AI生成", Kling "可灵AI 3.0", Tencent Yuanbao "元宝 / AI生成", Samsung Galaxy AI "✦ Contenuti generati dall'AI", Baidu "百度 AI生成", LibLibAI wordmark (bottom-center), RunningHub "RunningHub AI生成" (top-left), and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|qwen|kling|yuanbao|samsung|baidu|liblib|runninghub|jimeng_pill` forces one. `--sensitivity auto|strict` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes. Metadata provenance is read automatically and feeds `auto`. (`assume-ai` was REMOVED in 0.16 — see the registry bullet; a user who can SEE a missed mark should point at it with `erase --region`, or name it with `--mark --no-detect`.) For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. - `uv run remove-ai-watermarks erase --region x,y,w,h -o ` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps), `--backend migan` (MI-GAN via onnxruntime, extra `migan`; ~28 MB, ~1 GB RAM, near-LaMa), or `--backend lama` (big-LaMa, extra `lama`; best quality but ~4.7 GB RAM); `--region` is repeatable. -- `uv run remove-ai-watermarks identify ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector +- `uv run remove-ai-watermarks identify ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip both registered visible detectors and the optional open invisible-watermark decoder - `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. @@ -39,8 +39,9 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes - 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. `quality_sets/full_pipeline_quality_2026-07-25.csv` is the reusable full-pipeline test set: read its single corpus copy, preserve `source_filename` in outputs, and keep provider groups separate for their respective 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. +- Metadata/C2PA tests assert against real committed fixtures in `data/fixtures/provenance/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-1.png` = 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 `clean_photo` conftest fixture generates a deterministic metadata-free PNG; no real negative photo is committed for tests. +- Repository data follows `data/README.md`: executable provenance fixtures live in `data/fixtures/`, minimal detector rebuild inputs in `data/calibration/`, canonical provider-oracle originals in `data/synthid/`, and evaluation-only ground truth in `data/evaluations/`. Store each binary once and point every consumer at the canonical path. +- SynthID oracle fixtures: `scripts/synthid_corpus.py` ingests labeled originals into `data/synthid/originals/`. The tracked `manifest.csv` is kept in sync with the files on disk, one row per image. `full-pipeline-quality.csv` is the reusable full-pipeline test set: read its single canonical copy, preserve `source_filename` in outputs, and keep provider groups separate for their respective oracles. Generated or cleaned outputs stay outside the repository; record their reproducible command, hash, and oracle verdict instead. ## Configuration @@ -55,20 +56,20 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `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`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; corpus-observed on real Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the common real-corpus placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): ~2% of real uploads are misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals" (corpus-measured ~0.9% of files). A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; ~0.2% of real uploads) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`. -- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. Corpus-validated: adding the vendors introduced 0 new clashes on 5000 carriers. -- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / yuanbao / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. On its first form that left only the raw 0.35 detector threshold and it fired on **59.8% of 256 genuine camera captures**, filling a phantom sparkle on ~6 of every 10 CLEAN photos; a confidence floor made it tolerable, and the mode was removed outright in 0.16. Corpus-measured 2026-07-16 before removal (400 Google-C2PA positives with metadata hidden; 256 camera-capture negatives): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3% -- the extra recall was never free. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao**, **Qwen**, **Kling**, **Tencent Yuanbao**, **RunningHub** or **Baidu** detection (each of those marks names its own product, and none is Jimeng-basic), and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) — the metadata-only arm is only **21% precise raw (CI 16-29%), 29% (CI 20-40%) among the flat footprints the guard PASSES** (re-measured 2026-07-18, 149 blind-labelled fires) and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard always holds. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection can skip binarization** (`TextMarkConfig.detect_frontend`): `binary` thresholds the top-hat into a glyph blob (the original path), `tophat` correlates the CONTINUOUS top-hat against a soft template, turning the saturation/luma gates into weights and max-normalizing so the score is contrast-invariant. **Doubao uses `tophat`** (recall 89% -> 92% at an unchanged 99% precision on the unbiased sample); jimeng/samsung stay `binary` until measured per mark. **Detection and the REMOVAL MASK must use the SAME front-end.** `tophat` detection does not binarize, but `extract_mask` (which bounds the fill) still does, so a mark faint enough to be found only by the continuous response produced an EMPTY mask: `localize` returned None, `remove()` was a silent no-op, and `identify` reported `visible_doubao` while `visible` said "no visible mark" on the same file. Corpus-measured 2026-07-20: 57 of 60 sampled still-detected Doubao marks were untouched, ~8% of all its detections. When the binary blob is empty on a `tophat` mark the detector fired on, `footprint_mask` now bounds the fill with **the detector's OWN best-match box** (`_tophat_best`, the shared method whose score gates detection and whose box bounds the mask -- one method, so the two can never drift). **Gated on an actual detection** (on a clean corner the box would be spurious). The FIRST version of this fix (2026-07-19) instead thresholded the max-normalized uint8 response at `_FAINT_GLYPH_LEVEL = 0.5`, which selects every non-zero pixel (NOT "half the peak" as its comment claimed) and filled ~120% of the corner box on textured frames; corpus-measured 2026-07-20 over 14 real faint-path frames, the match box fills 58.7% and the whole-corner threshold 120.9%, both 100% detector-clean. So the threshold LOOKED fixed on parity (a mask that fills everything is trivially detector-clean) and was only caught by measuring fill AREA. The `0.5` constant is gone. Regression: `tests/test_text_mark_faint_mask.py` (its fixture MUST carry texture -- on a flat frame the response is non-zero only on the glyph, so every threshold gives the same box and the test cannot see a wrong one; mutating the old constant to 99.0 left the flat test green). **Any future front-end change must move BOTH paths or re-check this.** **The gate is front-end specific -- re-calibrate, never port it**: the continuous response scores higher (mean 0.809 vs 0.723), so the binary-era 0.40 ran 96%/91% (8 false fires) while 0.50 runs 92%/99% (1). A first pass at 0.40 also silently halved the PILL's recall, since `_keep_pill` suppresses it whenever doubao fires. The front-end fixes DETECTION of faint marks; ATTRIBUTION across the shared `AI生成` tail turned out to be a mis-sizing artifact, not a front-end limit: at the fitted geometry an exact-size 6-glyph template separates 千问 from Doubao with zero cross-fire (measured 2026-07-21 on 400 doubao-marked frames), so per-vendor registration shipped and the generic CJK-class detector is shelved unless a long-tail vendor needs it. **Adding a new text mark is cheap once the mark is stamped BOLD and its geometry is MEASURED** -- 千问 is registered since 2026-07-21 (see the `qwen_engine.py` bullet); 星绘 remains unregistered (one confirmed corpus example, nothing to calibrate against). The 2026-07-18 measurement that held 千问 out (mean NCC 0.170, 0% over gate, segmentation blamed) is recorded in `docs/module-internals.md`; its real residual was geometry (two size modes + a clipping locate box), which the cohort calibration then solved. Evidence chain in `scripts/render_vendor_silhouettes.py`. **RECALL is measured on an unbiased random sample** (`scripts/visible_recall_sample.py`; 240 images drawn at random per provenance class and labelled exhaustively, 2026-07-18): doubao **89% recall / 99% precision**, gemini **96% / 80%**, jimeng 71%/71% (n=14), jimeng_pill 50%/60% (n=6). The `scale_basis` fix moved doubao recall **71% -> 89%** on this same sample. **Gemini's real precision is 80%, NOT the 41% `visible_eval.py` reports** -- that harness scores an addition-sampled set, so it measures the relaxation arm's marginal cases, not what production sees; quote 80% for the product. Landscape is improved but unsolved (doubao by aspect: portrait 92% / square 92% / landscape 79%). The largest remaining gap is not tuning but COVERAGE: **6% of sampled images carry an uncovered vendor's mark** (百度/星绘/抖音-class -- 千问 was the head of this class and is registered since 2026-07-21, 百度 since 2026-07-22) that no registered detector can fire on -- researched specs are in `docs/watermarking-landscape.md`. **Mark geometry scales with a PER-MARK dimension** (`TextMarkConfig.scale_basis` / `TextMarkEngine.scale_base`): doubao `short` (= min(h,w)), jimeng and samsung `width`. All the tuned fractions were calibrated on PORTRAIT captures where width == short side, so the basis went unexercised until landscape inputs were measured -- and **doubao detected 0 of 435 landscape TC260 images, a 100% miss rate**. It is a LOCALIZATION failure, not a threshold one (median doubao NCC on the 1452 no-detection images was 0.057, only 2.7% in a threshold-reachable band), so no amount of gate tuning could reach it. Short-side geometry recovers **56% of the previously-undetected landscape set**. The basis is per-mark because the SAME switch took jimeng's landscape positives from 13/13 to 0/13 -- its wordmark tracks the width even though both marks are ByteDance and share a corner; samsung stays `width` because it is unmeasured (1 addition corpus-wide). GB 45438-2025 5.2(e) mandates glyph height >= 5% of the shortest side, which is why short-side is the prior -- but measurement overrides the standard's wording. This was invisible for months because **precision was measured repeatedly and recall never was**; the harness now reports a `missed` column, which is what caught the jimeng regression the fix introduced. **Detection among same-corner marks is COMPETITIVE** (`TextMarkConfig.rivals` / `_rival_margin_ok`): a mark's template must beat every same-corner rival's on the SAME glyph blob by `rival_margin` (0.10). Absolute-only scoring could never separate Doubao and Jimeng (both bottom-right near-white CJK, near-identical after binarization) -- measured separability: absolute `ncc_jimeng` 0.96, `ncc_jimeng` MINUS `ncc_doubao` **0.99**. Corpus effect: **jimeng precision 38% -> 63% with genuine detections unchanged (false fires 65 -> 23)**, so it is a pure precision gain and the earlier 0.85 threshold patch was reverted to 0.70. **Asymmetric by measurement:** doubao declares NO rival -- the symmetric gate cost it 7 genuine detections to prevent 5 false (1.4:1 against) while jimeng gained 25pp for free. **Benchmark any detector change with `uv run python scripts/visible_eval.py --vs `** (741 blind-labelled corpus images; `scripts/visible_groundtruth.py` builds the set, `scripts/visible_sheets.py` makes new labelling rounds). Three harness rules are load-bearing: score a mark only within its crop's **adjudication scope**, take **provenance from metadata not from labels** (label-derived provenance scored gemini at 99% vs the true 41%), and **never report recall** from this set -- it was sampled where detectors fired, so an unbiased random sample is still needed. **The provenance NCC relaxation is PER MARK (`TextMarkConfig.provenance_ncc_factor`), not one shared multiplier** — measured 2026-07-18 on the default `auto` path over 4417 unique TC260 carriers (blind hand-label, two-sided control, labeller sensitivity 100%/96% and specificity 100%/100%), the old shared 0.7 ran at **76% precision on doubao but 17% on jimeng**. Doubao stays 0.70 (both its bands return more true marks than false fills). Jimeng moves to **0.85**: its relaxed silhouette keys on "text in the bottom-right corner" rather than the wordmark — of 68 false additions **33 were DOUBAO marks** and 17 were other vendors' AI labels, and 45 of the 68 filled a corner nothing else would touch; 0.85 costs 8 genuine recoveries to prevent 60 false fills (7.5:1), lifting the arm to 43%. That is a patch on a DETECTOR problem — jimeng's silhouette is not discriminative against doubao's, and no threshold fixes that. **A weak mark must not CORROBORATE a sibling** (`_CANNOT_CORROBORATE`): sibling corroboration grants `confirmed` trust, which bypasses the sibling's FP gate, so the pill (~7% raw false-fire) handing that bypass to jimeng created a closed loop on the DEFAULT path — pill false-fires → jimeng relaxes and false-fires → `_keep_pill`'s wordmark arm then removes the pill UNRESTRICTED, skipping the flatness guard (3/578 negatives ran the full loop, one with `footprint_flat=0`). Cutting the pill out of corroboration removed all 3 and cost NOTHING on the TC260 carriers (jimeng 398 → 398). `_keep_pill` already distrusted the pill's ACTION; this closes the gap that its TESTIMONY was ungated. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark needs only a DETECTION silhouette (removal is template-free — the glyph-blob bbox is filled, no capture involved). Produce that silhouette SYNTHETICALLY: font-render the mark's glyphs (the pill's `scripts/render_pill_silhouette.py` is the pattern; commit the rendered PNG under `assets/`) and calibrate the NCC threshold on real positives. The old solid/gray/white app-capture workflow (`scripts/visible_alpha_solve.py`) is RETIRED with reverse-alpha — existing marks still carry their captured silhouettes, but a NEW mark does NOT require captures. (The 2026-06-22 "synthetic reconstruction below the quality bar" objection was about reverse-alpha PIXEL recovery, which is gone; it does not apply to a synthetic detection silhouette.) Data-safety still binds the committed asset: the silhouette must be font-rendered synthetic, never derived from user uploads — seeing a real sample to learn the glyphs / font / position / locale is fine, but the committed template stays synthetic. So nothing is parked for lack of a capture: Meta AI and more Samsung locales just need the glyphs + font + locale + calibration positives; any Grok visible mark additionally needs confirming it even HAS one (its known signal is EXIF-only `xai_signature`). +- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; verified on Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported 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): inputs can be 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". 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; some inputs) 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. The vendor normalization must not introduce clashes on clean compatibility samples. +- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. It produced unacceptable false positives on clean camera images and was removed. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is capture-less.** It uses a synthetic silhouette and a fixed top-left footprint. `_keep_pill` requires either a confirmed sibling wordmark or Jimeng provenance with a flat footprint. Do not loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection and removal-mask extraction must use the same front-end.** The continuous `tophat` path can detect a mark whose binary glyph blob is empty, so its fallback mask uses the detector's own best-match box. Keep the textured regression fixture when changing this path. **Detector thresholds and geometry are calibrated per mark; do not port them between vendors without a fresh evaluation.** **New mark assets must be synthetic.** Font-render the mark, calibrate it on local evaluation inputs, and commit only the synthetic silhouette. Never derive a committed asset from source images. - `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list. -- `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the detected glyph blob or the detector's own match box, filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray`/`contrast`. `gray` is raw-grayscale NCC for the faint mid-gray RunningHub mark and stays contrast-dependent. `contrast` is absolute local-luma residual NCC for polarity-changing marks such as Yuanbao, whose stamp may be light or dark depending on the scene. Front-end gates do not port between modes. The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2). -- `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates. -- `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture. -- `qwen_engine.py` — the Qwen (Alibaba Tongyi Qianwen) "千问AI生成" text mark (bottom-right), registered 2026-07-21 off the 117-frame TC260-producer cohort (`scripts/vendor_cohort_harvest.py` + `scripts/vendor_mark_calibrate.py`; the full calibration record is in `docs/module-internals.md`). Same `TextMarkEngine` subclass shape, but EVERY tuned number is measured on the cohort, not inherited from doubao: `scale_basis=short`, a **per-mark 2-rung `ladder=(0.78, 1.27)`** (the mark sits in two size modes ~0.124/~0.203 of the short side, ratio 1.64 -- wider than the shared ladder's 1.5625 span, so the best single fraction covers only 74.5% and the small mode falls into the comb's collapse zone; `TextMarkConfig.ladder` was added for this, default `(0.8, 1.0, 1.25)` unchanged for every other mark), a fitted locate box (the real mark sits ~0.025 of short off the right edge and doubao's 0.004-margin box clipped the first glyph, 0.73 -> 0.26 NCC), `alpha_height_frac` from the measured 0.260 aspect (not the silhouette's, not doubao's), gate **0.45** (clean p99 0.301; 83 of ~96 eyeballed visible marks fire = 86% recall of visible marks). **STRICT ONLY** (`provenance_ncc_factor` 1.0, no provenance mapping): the sub-gate band is dominated by non-Qwen banners (夸克 strip 0.274, 造点 mark 0.253), so a relaxed arm would be mostly false fills. **No rival margin** (0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames at the gate; a 0.10 margin would cost ~10% of genuine qwen detections). A confident qwen detection suppresses the jimeng pill like doubao's does. Parity: 83/83 real marks detector-clean after cv2 fill. **Calibration trap for the next vendor:** the 2026-07-18 `present: []` clean labels are in the vocabulary of the REGISTERED marks only -- qwen-cohort frames visibly carrying 千问AI生成 sat in the "clean" arm and were its whole top tail (p99 0.69); `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort. -- `kling_engine.py` — the Kling (Kuaishou 可灵) "可灵AI 3.0" text mark (bottom-right), registered 2026-07-21 off the 30-frame TC260-producer cohort, same playbook as qwen. UNIMODAL at 0.12 of the short side, so it keeps the shared 3-rung ladder and gets a fitted locate box (margins ~0.03/0.023); gate **0.35** (clean p99 0.304 / max 0.320), STRICT ONLY (the faint "Omni"-suffix / latin "KlingAI" / version-less variants score 0.17-0.25, inside the clean arm's top tail -- unreachable, capping recall at ~47% of visible marks, precision 9/9), no rival margin (crossfire 1/400 doubao below gate, 0 jimeng, 0 clean). Parity 9/9. Suppresses the jimeng pill like doubao/qwen. The **cat-logo** cohort was also probed and parked: 19 frames but only 2 unique carriers, so nothing to calibrate recall against; the `draw_catlogo` silhouette already separates (mark 0.50 vs clean max 0.333) and registration is a gate pick (~0.42) once more unique carriers arrive. -- `yuanbao_engine.py` — the Tencent Yuanbao two-line "元宝 / AI生成" mark (bottom-right), registered 2026-07-25. The first calibration was invalid because negative shear clipped the lower line out of the synthetic asset and left a blank tail that the matcher then squeezed; the corrected renderer translates before shearing and tightly crops the result. Yuanbao uses the polarity-independent `contrast` front-end because the same stamp is light on dark scenes and dark on pale scenes. Gate **0.38**, STRICT ONLY, with a measured bottom-right anchor. It detects 26 of 28 standard two-line marks (92.9%) across 33 byte-unique cohort frames, fires on 0 of 286 byte-unique clean controls (clean max 0.348), and clears 26/26 detected real marks after cv2 fill. A separate one-line photographer-overlay variant remains unregistered because only one example exists. -- `runninghub_engine.py` — the RunningHub "RunningHub AI生成" mark (**top-left**, the first `corner="tl"`), registered 2026-07-22 off the 73-frame TC260 cohort. Faint mid-gray text that the top-hat suppresses to clean-arm levels, so it introduced the **third detection front-end `gray`** (raw-grayscale silhouette NCC, contrast-DEPENDENT, one `_gray_best` method serving detection and mask): positives 0.38-0.54 vs clean max 0.295, gate **0.34**, STRICT ONLY. Two measured traps: the tight ladder `(0.95, 1.0, 1.05)` sits exactly on the measured 0.32-of-width (the comb is razor-sharp: 0.537 on-size vs 0.223 at +5.6%), and an **anchor gate** in `detect` (match must sit at x<=0.025 / y<=0.015 of the frame) kills the 37/42009 full-corpus false fires (hair, shelves, CJK banners) that no NCC threshold separates from the 0.381 positives. Footprint is always the detector's match box (the binary blob under-segments the faint head glyphs and left "Runni" unremoved on the first try). -- `baidu_engine.py` — the Baidu "百度 AI生成" mark (bottom-right), registered 2026-07-22 off the 16-frame cohort (USCC names Baidu). Detection keys on the 百度 **text run ONLY** (a text+pill template was a measured bright-blob magnet with no separation); the white "AI生成" tag is still removed because the custom `footprint_mask` extends the match box right to the corner (the tag's flat white interior gives no top-hat response, so a blob bbox left it as a ghost). Gate settled at **0.48** after three measured steps (0.37 clean-arm; the 741-eval set fired on 12 Qwen marks + one 抖音 mark, so `rivals=("doubao_alpha.png","qwen_alpha.png")` with load-bearing margins; the full-corpus sweep put true outside-cohort carriers at 0.50-0.66 vs the false arm max 0.47). Cohort 7/16, all true, plus 6 metadata-stripped true carriers the cohort cannot see. STRICT ONLY. -- `liblib_engine.py` — the LibLibAI (哩布哩布AI) "LibLibAI" wordmark (**bottom-center**, the first `corner="bc"`), registered 2026-07-22 off the 15-frame cohort. The discriminative lever is the silhouette **font**: STHeiti scored the cohort at the false arm's level (latin UI text bands); measured across 7 fonts, **Arial** lifts the cohort to 0.42-0.73 and drops the false arm to max 0.398. Gate **0.42**, STRICT ONLY, plus a per-mark size floor (`_MIN_SHORT_SIDE=480`: the one remaining false fire was a 200x200 icon on a 20px template). Custom footprint = match box extended left ~1.3 glyph heights for the triangle logo (the blob bbox bled into background structure and never owned the logo). **Zhipu Qingyan (清言·AI生成) and MiniMax/Hailuo were attempted the same day and PARKED** as measured negatives (qingyan: cohort 0.34-0.39 vs clean max 0.34-0.37, no separation at any render/box; minimax: 1 of 6 frames visibly marked, the xinghui rule) -- record in `docs/verification-plan.md` ("The 2026-07-22 vendor round"). -- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **Measured end to end 2026-07-20** (`scripts/resource_ceilings.py`, fresh process per cell, 1 MP → 25 MP): `migan` 603 → 775 MB and `lama` 4679 → 4779 MB, both **flat in input size** — the crop-around-the-mask design holds and both documented figures reproduce. **`cv2` is the only backend that GROWS with the input** (74 → 440 MB, 5.9x) because it inpaints the full frame rather than a crop; still the cheapest tier, but size it for the largest upload accepted. Cold wall time 0.02-0.12 s (cv2) / ~0.6 s (migan) / ~3.8 s (lama), model load included. (The harness's own no-op check originally allocated a full-frame temp before reading peak RSS and inflated these by up to 17% at 25 MP; it now compares only the mask box. The conclusion survived re-measurement, the digits moved.) **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged). +- `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray` (`gray` = raw-grayscale NCC for the faint mid-gray runninghub mark, added 2026-07-22; contrast-DEPENDENT, so its gates never port). The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2). +- `pill_engine.py` — detects the capture-less Jimeng "AI生成" pill with a synthetic silhouette and removes it through the shared fill path. Its weak detector is registry-gated: a sibling wordmark may confirm it, while metadata-only removal also requires a flat footprint. 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. Calibration confirms that doubao and jimeng localize and remove cleanly, while clean compatibility images remain unchanged. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture. +- `qwen_engine.py` — detects the Qwen "千问AI生成" bottom-right text mark with a synthetic silhouette and per-mark geometry. Keep its calibration independent from similar CJK marks. +- `kling_engine.py` — detects the Kling "可灵AI 3.0" bottom-right text mark. It uses short-side geometry and a synthetic silhouette. +- `yuanbao_engine.py` — detects the standard two-line Tencent Yuanbao "元宝 / AI生成" bottom-right mark through polarity-independent local contrast. The one-line overlay variant remains unsupported. +- `runninghub_engine.py` — detects the faint top-left RunningHub mark through grayscale silhouette matching. The anchor gate and detector-owned match box are part of its false-positive control. +- `baidu_engine.py` — detects the Baidu "百度 AI生成" bottom-right mark. Rival margins separate it from similar CJK marks, and its custom footprint includes the adjacent tag. +- `liblib_engine.py` — detects the bottom-center LibLibAI wordmark with a synthetic silhouette, contrast gating, and detector-owned footprint. +- `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 for the user-directed `erase` command, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s, the memory-tight learned tier), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak, best quality but too heavy for a minimal worker). The visible registry's `auto` resolution is **LaMa > MI-GAN > cv2**; select MI-GAN or OpenCV explicitly when memory matters. Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **Measured end to end 2026-07-20** (`scripts/resource_ceilings.py`, fresh process per cell, 1 MP → 25 MP): `migan` 603 → 775 MB and `lama` 4679 → 4779 MB, both **flat in input size** — the crop-around-the-mask design holds and both documented figures reproduce. **`cv2` is the only backend that GROWS with the input** (74 → 440 MB, 5.9x) because it inpaints the full frame rather than a crop; still the cheapest tier, but size it for the largest upload accepted. Cold wall time 0.02-0.12 s (cv2) / ~0.6 s (migan) / ~3.8 s (lama), model load included. (The harness's own no-op check originally allocated a full-frame temp before reading peak RSS and inflated these by up to 17% at 25 MP; it now compares only the mask box. The conclusion survived re-measurement, the digits moved.) **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — verified). 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 four diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the compatibility and cost DEFAULT since 2026-06-09**), `qwen` (Qwen-Image 20B img2img), and `qwen-zimage` (delegates to the fixed two-stage runtime below). Removal comes from img2img strength. Both SDXL loaders pass `add_watermarker=False`; diffusers otherwise re-stamps an open SDXL DWT-DCT watermark. Qwen's certified floors and fidelity results remain as documented below. The base `qwen` profile stays the manual text lane; `qwen-zimage` is the recommended high-quality manual mode, especially for face identity, while remaining experimental rather than an auto-router. @@ -76,7 +77,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). The SDXL, ControlNet, and base Qwen paths branch 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). `qwen-zimage` instead calls `run_tiled` only around its global Qwen stage, blends the tiles, then runs one full-frame face stage. 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 adaptive polish is ON by default for the original profiles, while `qwen-zimage` leaves it off to preserve the upstream two-stage output). - `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). **`write_bgr_with_alpha` RETURNS `imwrite`'s success flag and every caller must check it** — `imwrite` is contractually non-raising, so that bool is the ONLY signal the file was not created. The wrapper used to return `None` and swallow it, so a write that a read-only directory silently prevented ran on to `output.stat()` and died with a bare `FileNotFoundError` traceback pointing at the stat rather than the write (Tier E, 2026-07-20). **Three different layers each needed their own handling, and the right one is NOT the same everywhere:** the single-image commands (`visible --mark `, `erase`, `all`) write through the shared `cli._write_output_or_exit`, which exits; `api._write_visible_result` (behind `visible --mark auto`) RAISES `OSError` so a library caller gets an accurate error instead of a confusing `FileNotFoundError` from the downstream metadata strip; and the **batch** sites raise too, never `SystemExit` — the batch loop catches per-image exceptions, counts them and exits non-zero, so aborting there would kill the whole run. Discarding the flag in batch made a read-only output directory produce **zero files and still exit 0** — silent data loss contradicting the documented batch contract. Regression: `tests/test_cli_robustness.py`. 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. +- `image_io.py` — centralizes Unicode-safe image IO, alpha preservation, content-based format sniffing, and HEIC/AVIF fallbacks. Callers must check `imwrite` success. No-op visible removal preserves original bytes when the output format is unchanged. - `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`. diff --git a/README.md b/README.md index aab2a69..99ffea4 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# Remove-AI-Watermarks +# Remove AI Watermarks -Remove **visible** and **invisible** AI watermarks from images generated by Google Gemini (Nano Banana), ChatGPT / DALL-E, Stable Diffusion, Adobe Firefly, Midjourney, and other AI models. +Remove AI provenance marks from images you generated yourself: -Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command. +- known visible labels such as the Gemini sparkle and vendor text marks; +- invisible pixel watermarks through diffusion regeneration; +- C2PA, EXIF, XMP, IPTC, and related AI metadata. -> ## Try it online: [raiw.cc](https://raiw.cc) -> -> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee. +> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python +> or run diffusion models locally. [![PyPI](https://img.shields.io/pypi/v/remove-ai-watermarks?logo=pypi&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/) [![Python](https://img.shields.io/pypi/pyversions/remove-ai-watermarks?logo=python&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/) @@ -15,590 +16,249 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi [![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml) [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta) -If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta). +> This project is for lawful use on content you own. It does not target stock +> agency previews or other watermarks that protect third party paid content. +> See [scope, safety, and legal notes](docs/legal-and-safety.md). -> **Intended for lawful use only.** Publishing and running this software is lawful; responsibility for any downstream use, and for compliance with local law, rests entirely with the user. Some jurisdictions restrict removing an AI label as such (see [Legal](#legal)). The authors do not condone use for deception, fraud, or any unlawful activity. +## Choose what you want to do -## Scope +| Goal | Command | GPU | +| --- | --- | --- | +| Find provenance signals and watermarks | `identify` | No | +| Remove known visible AI marks | `visible` | No | +| Erase a region you select | `erase` | No | +| Strip AI metadata | `metadata` | No | +| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended | +| Run visible, invisible, and metadata removal | `all` | Recommended | +| Process a directory | `batch` | Depends on mode | -This tool removes **AI-provenance watermarks** that a platform stamps onto content **you generated yourself** — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Qwen / Kling / Tencent Yuanbao / Baidu / LibLibAI / RunningHub / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is your autonomy over your own output. +## Quick start -It does **not** target watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled "preview" watermark whose job is to gate a purchase. Removing those is out of scope by design. `erase` is a generic, user-driven region tool for your own objects, not an automatic stock-watermark remover. +Install the core CLI: -## Features +```bash +uv tool install remove-ai-watermarks +``` -- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Qwen "千问AI生成" and Kling "可灵AI 3.0" text strips, the Tencent Yuanbao two-line "元宝 / AI生成" mark, the Baidu "百度 AI生成" text + tag, the LibLibAI wordmark (bottom-center), the RunningHub "RunningHub AI生成" text (top-left), and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured or synthetic silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` and synthetic assets from `scripts/render_vendor_silhouettes.py` are used for detection, not pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `strict` never relaxes. There is deliberately no "assume this is AI" mode: asserting that an image is AI says nothing about *which* vendor made it or *where* the mark is, which is exactly what a gate bypass needs. If you can SEE a mark the detector missed, point at it with `erase --region x,y,w,h`, or force a known text mark by name with `--mark --no-detect` -- both act on what you actually see instead of relaxing every detector at once. `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.) -- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or color. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality -- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc)) -- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType -- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph) -- **Analog Humanizer** — optional film grain and chromatic aberration post-processing -- **Two SynthID quality tiers** — SDXL with canny ControlNet remains the default because it is much faster, cheaper, and supports CUDA, XPU, MPS, and CPU. It is the compatibility baseline, not the highest-fidelity option: at the denoise strength needed to remove SynthID it can visibly drift face identity and soften or alter fine content. For the best available visual quality, especially on faces and face-heavy scenes, install `pip install "remove-ai-watermarks[qwen-zimage]"` and select `--pipeline qwen-zimage`. This CUDA-only profile ports the two-stage Synthid-Bypass architecture: Qwen-Image-2512 with the 4-step Lightning LoRA and DiffSynth Canny ControlNet regenerates the full image, then YuNet + SAM isolate faces and Z-Image Turbo regenerates the original face crops before a feathered paste. Direct comparison on two official upstream examples measured much higher ArcFace identity than the current ControlNet result, close to the published upstream output. On July 25, 2026, all six current outputs from the full `visible -> qwen-zimage -> metadata` candidate were checked in provider-separated groups with the corresponding OpenAI and Gemini oracles; none retained the provider watermark signal. This supports the high-quality recommendation for those exact outputs, but is not broad certification across seeds, resolutions, and content classes. The tradeoff is substantial: `qwen-zimage` uses a large model stack, costs much more, runs only on CUDA, does not support a custom `--model`, and can still degrade very small text. It supports `--tile` for the global Qwen pass; after the tiles are blended, face detection and Z-Image restoration run once on the full frame. Tiled outputs require separate oracle validation because the seed-0 certification covers only the exact non-tiled candidate bytes. YuNet replaces the reference workflow's YOLO detector to avoid an AGPL runtime dependency. -- **Batch processing** — process entire directories -- **Detection** — three-stage NCC watermark detection with confidence scoring -- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Tencent Yuanbao "元宝 / AI生成" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output) +Inspect an image: + +```bash +remove-ai-watermarks identify image.png +``` + +Remove a known visible mark and AI metadata: + +```bash +remove-ai-watermarks visible image.png -o clean.png +``` + +Strip metadata without running visible inpainting or diffusion: + +```bash +remove-ai-watermarks metadata image.png --remove -o clean.png +``` + +For invisible watermark removal, install the diffusion dependencies: + +```bash +uv tool install --force "remove-ai-watermarks[gpu]" +remove-ai-watermarks invisible image.png -o clean.png +``` + +If the local detectors cannot confirm an invisible watermark but you know the +image came from an AI generator, add `--force`: + +```bash +remove-ai-watermarks invisible image.png -o clean.png --force +``` + +See the [installation guide](docs/installation.md) for Homebrew, uv, optional +features, and development setup. ## Examples -| Before (Watermarked) | After (Cleaned) | +### Visible Gemini mark + +| Before | After | | --- | --- | -| ![Before](demo_banana_before.png) | ![After](demo_banana_after.png) | +| ![Image with a visible Gemini watermark](demo_banana_before.png) | ![Image after visible watermark removal](demo_banana_after.png) | -### High-quality SynthID removal with `qwen-zimage` +### High quality invisible removal -The examples below use the complete `visible -> qwen-zimage -> metadata` pipeline -with seed 0. These exact cleaned files were checked with the corresponding OpenAI -and Gemini oracles on July 25, 2026; neither retained the provider watermark -signal. Click an image to inspect it at full resolution. +The `qwen-zimage` profile is the highest fidelity option for face heavy images. +It is CUDA only and uses a much larger model stack than the default ControlNet +profile. -| Face-heavy OpenAI example: before | Face-heavy OpenAI example: after | +```bash +uv tool install --force "remove-ai-watermarks[qwen-zimage]" +remove-ai-watermarks invisible image.png -o clean.png \ + --pipeline qwen-zimage --force +``` + +| OpenAI example before | OpenAI example after | | --- | --- | -| [![OpenAI portrait grid before qwen-zimage](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png)](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png) | [![OpenAI portrait grid after qwen-zimage](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png)](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png) | +| [![OpenAI portrait grid before qwen-zimage](data/synthid/originals/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png)](data/synthid/originals/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png) | [![OpenAI portrait grid after qwen-zimage](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png)](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png) | -| CJK text and visible Gemini mark: before | CJK text and visible Gemini mark: after | +| Gemini example before | Gemini example after | | --- | --- | -| [![Gemini CJK sign before qwen-zimage](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u.png)](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u.png) | [![Gemini CJK sign after qwen-zimage](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png)](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png) | +| [![Gemini sign before qwen-zimage](data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png)](data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png) | [![Gemini sign after qwen-zimage](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png)](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png) | -## Supported models +These exact output files were checked with the matching provider verifiers. That +result applies to these files, not to every seed, image, or future watermark +version. -| AI model | Visible watermark | Invisible watermark | Metadata | Our approach | -| --- | --- | --- | --- | --- | -| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Localize + fill + diffusion + metadata strip | -| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip | -| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ✅ SynthID + content-specific pixel watermark (since May 2026; no local decoder, openai.com/verify oracle) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip | -| **Stable Diffusion / SDXL (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ PNG text chunks | Diffusion regeneration + metadata strip | -| **Black Forest Labs FLUX** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ C2PA (FLUX.2 Pro) | Diffusion regeneration + metadata strip | -| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip | -| **Stability AI** (DreamStudio / Stable Image) | — | — | ✅ C2PA ("Stability AI Ltd") | Metadata strip | -| **Microsoft Designer / Bing Image Creator** | — | ✅ SynthID via DALL-E backend (Designer) | ✅ C2PA (Bing runs MAI-Image, signed "Microsoft") | Metadata strip | -| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip | -| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip | -| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) | -| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Localize glyph footprint + fill + metadata strip | -| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Localize glyph footprint + fill + metadata strip | -| **Qwen / Tongyi Qianwen** (千问, Alibaba) | ✅ "千问AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip | -| **Kling** (可灵, Kuaishou) | ✅ "可灵AI 3.0" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip | -| **Yuanbao** (元宝, Tencent) | ✅ two-line "元宝 / AI生成" mark (bottom-right) | — | ✅ TC260 AIGC label, including Tencent Cloud's service-provider schema | Polarity-independent local contrast + fill + metadata strip | -| **Baidu** | ✅ "百度 AI生成" text + tag (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip | -| **LibLibAI** (哩布哩布AI) | ✅ "LibLibAI" wordmark (bottom-center) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip | -| **RunningHub** | ✅ "RunningHub AI生成" text (top-left) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip | -| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip | -| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip | -| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration | -| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration | +## Common recipes -> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), by Alibaba's Qwen / Tongyi Qianwen ("千问AI生成" corner text), by Kuaishou's Kling ("可灵AI 3.0" corner text), by Tencent Yuanbao (two-line "元宝 / AI生成" mark), by Baidu ("百度 AI生成" text + tag), by LibLibAI ("LibLibAI" wordmark, bottom-center), by RunningHub ("RunningHub AI生成", top-left), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command. +### Remove every detected visible mark -> **Detection:** `remove-ai-watermarks identify ` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Tencent Yuanbao "元宝 / AI生成" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only. +```bash +remove-ai-watermarks visible image.png -o clean.png +``` + +The default `--mark auto` checks all registered visible marks and removes every +match. If the mark is visible to you but the detector misses it, select its +region explicitly: + +```bash +remove-ai-watermarks erase image.png \ + --region 1640,1930,400,100 \ + -o clean.png +``` + +`--region` uses `x,y,width,height` and may be repeated. + +### Use a learned fill backend + +The core install uses OpenCV inpainting when no learned backend is installed. +For more difficult backgrounds: + +```bash +uv tool install --force "remove-ai-watermarks[migan]" +remove-ai-watermarks visible image.png -o clean.png --backend migan +``` + +```bash +uv tool install --force "remove-ai-watermarks[lama]" +remove-ai-watermarks visible image.png -o clean.png --backend lama +``` + +### Reduce CUDA memory use + +```bash +remove-ai-watermarks invisible image.png -o clean.png \ + --cpu-offload --force +``` + +CPU offload lowers CUDA memory pressure by moving model components between CPU +and GPU. It is slower and has no effect on CPU or MPS. + +### Process a directory + +```bash +remove-ai-watermarks batch ./images --mode visible +remove-ai-watermarks batch ./images --mode all +``` + +## What the tool can recognize + +Visible mark support includes: + +- Google Gemini and Nano Banana sparkle; +- Doubao, Jimeng, Qwen, Kling, Baidu, LibLibAI, and RunningHub labels; +- one calibrated Samsung Galaxy AI label variant. + +Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common +generator parameters, China TC260 AIGC labels, and several vendor specific +signals. Optional decoders add support for open DWT-DCT watermarks and Adobe +TrustMark. + +The exact support matrix, including important locale and detector limits, lives +in [supported signals](docs/supported-signals.md). ## How it works -### Removing the Gemini / Nano Banana sparkle watermark +Visible removal follows three steps: -Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo to generated images using alpha blending: +1. Detect a registered mark in its expected area. +2. Build a mask around the mark. +3. Fill only the masked region with OpenCV, MI-GAN, or LaMa. -```text -watermarked = α × logo + (1 − α) × original -``` +Metadata removal uses format aware stripping. JPEG metadata removal preserves +the encoded image scan instead of recompressing it. Other supported containers +use their corresponding metadata path. -A multi-scale NCC (Normalized Cross-Correlation) detector finds the sparkle position and scale dynamically in the bottom-right region (with a false-positive gate), so it works even if the image was resized or cropped. Removal is **localize then fill**: the sparkle footprint is built from the captured alpha thresholded low (so the faint halo is included) and dilated by a sparkle-relative margin, and that mask is inpainted by the shared fill. The captured alpha maps are used only to detect the sparkle and to shape the mask, not to recover pixels. If local metadata already confirms Google (a Google / Gemini C2PA issuer), the detection trust gate is relaxed so a moved or re-rendered sparkle is still caught. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in). +Invisible removal is different. It regenerates the image through a diffusion +pipeline to disrupt pixel and frequency domain watermarks. This changes the +image and cannot guarantee that a proprietary verifier will reject every +output. -**Speed**: fast on CPU with the default cv2 fill. No GPU needed. +See [supported signals](docs/supported-signals.md) and +[known limitations](docs/known-limitations.md) for the full technical boundary. -### Removing the Doubao "豆包AI生成" text watermark - -Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. Detection matches the glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners. Removal is **localize then fill**: the light glyph blob is localized to a solid, dilated footprint mask (template-free, so a re-rendered or differently-placed mark is still masked) and the shared fill inpaints it. On corpus images the filled region blends into its surroundings within a few LAB levels, with no color shift and no dark pit. Pick the fill with `--backend` (cv2 default, MI-GAN or big-LaMa opt-in). - -**Speed**: fast on CPU with the default cv2 fill, no GPU needed. - -### Removing the Jimeng "★ 即梦AI" wordmark - -Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. Detection keys on the wordmark's glyph shape (NCC against a captured silhouette). `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). Removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it, so a re-rendered or differently-placed mark is still cleared. On corpus images the filled region blends into its surroundings within a few LAB levels, no color shift. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa). - -```bash -remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng -remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png -``` - -### Removing the Samsung Galaxy AI "✦ Contenuti generati dall'AI" mark - -Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. Detection matches the glyph silhouette (NCC), and removal is **localize then fill**: the glyph blob is localized to a solid, dilated footprint mask and the shared fill inpaints it. `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. **Detection is locale-specific** — this build detects only the Italian "Contenuti generati dall'AI" variant, so other Samsung locales are not detected (and thus not removed) and need their own captured detection template (open a sample on issue #37). The fill mask itself is locale-independent. - -```bash -remove-ai-watermarks visible samsung.jpg -o clean.jpg # --mark auto picks Samsung -remove-ai-watermarks visible samsung.jpg --mark samsung -o clean.jpg -``` - -### Universal region eraser - -For any visible mark the dedicated engines do not cover — a logo anywhere, any color — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call. - -### Removing SynthID and other invisible watermarks - -Google embeds **SynthID** into every image generated by Gemini / Nano Banana. Other AI services use StableSignature, TreeRing, and similar schemes. These imperceptible frequency-domain patterns survive cropping, resizing, and JPEG compression. - -The removal pipeline (default profile, SDXL): - -```text -image → encode to latent space (VAE) at native resolution - → add controlled noise (forward diffusion) - → denoise (reverse diffusion, ~50 steps; strength is vendor-adaptive: - 0.10 OpenAI / 0.15 Google / 0.15 unknown for SDXL and ControlNet; - override with --strength) - → decode back to pixels (VAE) -``` - -- Large inputs run at native resolution (no down-then-up round-trip, which was the main quality loss in issue #10); use `--max-resolution N` only to cap GPU/MPS memory on very large inputs. For inputs that run out of GPU/MPS memory at native resolution, `--tile` is the lossless alternative to `--max-resolution`: it regenerates the image in overlapping, feather-blended tiles so there is no downscale and no visible seam. It engages only when the long side exceeds `--tile-size` (default 1024; overlap `--tile-overlap`, default 128); pair it with `--max-resolution 0`. SDXL and ControlNet run their complete pass per tile. `qwen-zimage` tiles only its global Qwen pass, then runs face detection, SAM masking, and Z-Image restoration once on the blended full frame. Small inputs (long side under 1024 px) are auto-upscaled to a 1024 px floor before diffusion, because SDXL distorts on a tiny latent, and the result is restored to the original size (a transparent quality boost). Disable the floor with `--min-resolution 0`. The floor upscale uses Lanczos by default; `--upscaler esrgan` (the `esrgan` extra) runs Real-ESRGAN first for sharper detail and falls back to Lanczos if the extra is absent. ESRGAN is a generic photo/texture GAN with no face/glyph prior, so it is best for photo/texture content -- it can degrade faces (the diffusion pass regenerates them, so the final recovers) and thin text; keep Lanczos for text-heavy inputs. -- On a memory-constrained CUDA card, `--cpu-offload` moves Diffusers model components between CPU and GPU instead of keeping the full pipeline in VRAM. This lowers peak VRAM use at the cost of transfer overhead. With `qwen-zimage`, the flag forces the face stack to offload instead of using its automatic residency decision. It has no effect on CPU or MPS. - -> **Default strength is profile-aware (no flag needed).** SDXL and ControlNet read the C2PA issuer and use **OpenAI gpt-image `0.10`**, **Google Gemini `0.15`**, or **unknown source `0.15`**. The same ladder applies to those two profiles. `qwen` has its own measured ladder. `qwen-zimage` instead ports the upstream resolution-adaptive denoise formula: approximately `0.084` at 0.30 MP through `0.154` at 3.70 MP and above, plus a separate face-adaptive `0.05` through `0.28` pass. There is no local SynthID decoder, so if the corresponding provider oracle still reads the signal, raise `--strength` and re-check the output. -> -> **The default pipeline is `controlnet` — it preserves text and face structure.** It runs the same SDXL img2img scrub but adds a canny ControlNet that conditions the regeneration on the image's edge map, so text and structure stay sharp at the strengths that remove SynthID. The watermark removal still comes from the img2img regeneration (`--strength`); the ControlNet only preserves structure — no original pixels are copied or frozen. The current default ladder is OpenAI `0.10` / Google `0.15` / unknown `0.15`. `--controlnet-scale` tunes the preservation strength (higher = closer to the original structure). Runs fp32 on mps/cpu (fp16 only on cuda/xpu, where the fp16-fixed SDXL VAE is loaded automatically). Pass `--pipeline sdxl` for plain SDXL img2img (lighter, no extra model download) on inputs without text or faces. -> -> **For the best visual quality, prefer `qwen-zimage`.** ControlNet remains the default for compatibility and cost, but its canny conditioning preserves edges rather than identity. At the SynthID scrub floor, faces can drift substantially. The CUDA-only `qwen-zimage` profile instead regenerates SAM-masked crops from the original faces with Z-Image Turbo. On two direct upstream comparisons it retained ArcFace identity at `0.950` and `0.947`, versus `0.701` and `0.548` for polished ControlNet. Use `--pipeline qwen-zimage` when fidelity matters more than latency, model size, and GPU cost. It defaults to seed `0`, matching the exact six-output full-clean candidate that cleared the corresponding provider oracles after visible-mark removal, `qwen-zimage`, and metadata stripping. Broader cross-seed removal and text certification remain open. - -SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output. - -> **Oracle vs `identify` can disagree, and that is expected.** An online verifier reads the actual SynthID *pixel* watermark and detects only its own vendor's content — [openai.com/research/verify](https://openai.com/research/verify/) states "OpenAI generation signals will only be detected if the image was generated with our tools". Our `identify` cannot decode the pixel watermark (no vendor ships a local decoder), so it infers SynthID from the **C2PA metadata** instead. So after the SDXL pass the oracle can read "no SynthID" (pixel watermark gone) while `identify` still reports SynthID from a surviving C2PA manifest. They measure different signals. Run `metadata --remove` (or `all`) to also strip the manifest; note that a quiet metadata proxy is not proof the pixel watermark itself is gone. - -> **Technical deep-dive:** see [`docs/synthid.md`](docs/synthid.md) for a primary-source-cited breakdown of how SynthID works mechanically (post-hoc encoder/decoder, 136-bit payload, pixel-space embedding), what it empirically survives (JPEG, crop, resize: ~99.98% TPR at 0.1% FPR from arXiv:2510.09263), what removes it, and the forensic-stealth tradeoff (all known removal attacks are detectable at >98% TPR@1%FPR per arXiv:2605.09203). - -**Text and face preservation:** the default ControlNet pipeline is the fast, broadly compatible baseline. It keeps edge structure sharper than plain SDXL but can still change face identity and fine content substantially. For the best available output fidelity, use the CUDA-only `--pipeline qwen-zimage`; use `--pipeline sdxl` only as the lighter plain-SDXL option. - -**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.) - -### Stripping C2PA, EXIF, and "Made with AI" metadata - -AI tools embed generation metadata that social platforms use to show "Made with AI" labels: - -- **EXIF tags** — prompt, seed, model hash, sampler settings (Stable Diffusion, Midjourney) -- **XMP DigitalSourceType** — `trainedAlgorithmicMedia` tag used by Instagram, Facebook, and X (Twitter) to show "Made with AI" -- **PNG text chunks** — ComfyUI workflows, AUTOMATIC1111 parameters -- **C2PA Content Credentials** — cryptographic provenance manifests from Google Imagen, OpenAI DALL-E, Adobe Firefly - -The cleaner parses each layer, removes AI-related fields, and preserves standard metadata (Author, Copyright, Title). - -## Installation - -### Homebrew (macOS / Linux) - -```bash -brew install wiltodelta/tap/remove-ai-watermarks -``` - -This installs the core command surface (`identify`, `metadata`, `visible`, -`erase`) as a self-contained CLI. The diffusion-based `invisible` / `all` -pipeline needs heavy ML dependencies (torch, diffusers, multi-GB) and is kept -out of the Homebrew build; add it with the `gpu` extra via pip if you need it: - -```bash -pip install "remove-ai-watermarks[gpu]" -``` - -The recommended high-quality Qwen-Image-2512 plus Z-Image profile has its own -CUDA-only extra: - -```bash -pip install "remove-ai-watermarks[qwen-zimage]" -remove-ai-watermarks invisible image.png -o clean.png --pipeline qwen-zimage --force -``` - -### conda - -A conda-forge recipe is under review -([staged-recipes PR](https://github.com/conda-forge/staged-recipes/pull/33674)). -Once it merges, the core package installs with: - -```bash -conda install -c conda-forge remove-ai-watermarks -``` - -Like the Homebrew build, this is the core command surface; add the diffusion -`invisible` / `all` pipeline with the pip `gpu` extra. - -### Recommended - -Install as an isolated CLI tool — no need to manage virtual environments: - -```bash -# Using pipx (https://pipx.pypa.io) -pipx install git+https://github.com/wiltodelta/remove-ai-watermarks.git - -# Or using uv (https://docs.astral.sh/uv) -uv tool install git+https://github.com/wiltodelta/remove-ai-watermarks.git -``` - -To update to the latest version: - -```bash -pipx upgrade remove-ai-watermarks - -# or -uv tool upgrade remove-ai-watermarks -``` - -### Install from repository - -**Prerequisites:** Python 3.10.1+ and `pip` (or [`uv`](https://docs.astral.sh/uv/)). - -```bash -# 1. Clone the repository -git clone https://github.com/wiltodelta/remove-ai-watermarks.git -cd remove-ai-watermarks - -# 2. Install the package in editable mode -pip install -e . - -# Or, if you use uv: -uv pip install -e . -``` - -After installation the `remove-ai-watermarks` command is available system-wide. - -> **Note**: The base install covers visible watermark removal and metadata stripping. -> For invisible watermark removal (SynthID etc.), install GPU dependencies: -> -> ```bash -> pip install -e ".[gpu]" # or: uv pip install -e ".[gpu]" -> ``` -> -> For the CUDA-only Qwen-Image-2512 + Z-Image face-preserving profile: -> -> ```bash -> pip install -e ".[qwen-zimage]" -> ``` -> -> Without the `[gpu]` extra, `all` still runs the visible and metadata steps, but -> it skips the invisible (SynthID) step, prints a clear warning, and exits with a -> non-zero status so a skipped step is not mistaken for a clean result. -> -> To let `identify` decode the open Stable Diffusion / SDXL / FLUX invisible -> watermarks, install the `detect` extra (adds the `invisible-watermark` decoder): -> -> ```bash -> pip install -e ".[detect]" # or: uv pip install -e ".[detect]" -> ``` -> -> To also decode the open **Adobe TrustMark** watermark (behind Adobe Durable -> Content Credentials), install the `trustmark` extra (pulls torch and downloads -> model weights on first use): -> -> ```bash -> pip install -e ".[trustmark]" # or: uv pip install -e ".[trustmark]" -> ``` -> -> For sharper upscaling of small inputs before diffusion (`--upscaler esrgan`, -> Real-ESRGAN), install the `esrgan` extra. It loads via spandrel (MIT, no basicsr); -> the Real-ESRGAN weights (BSD-3-Clause) download on first use: -> -> ```bash -> pip install -e ".[esrgan]" # or: uv pip install -e ".[esrgan]" -> ``` - -#### Invisible watermark removal - -Invisible removal uses diffusion models and a GPU for reasonable speed. - -```bash -# On first run, the model (~2 GB) will be downloaded automatically. -# Device is auto-detected: CUDA (Linux/Windows) > MPS (macOS) > CPU. -# To force a device: --device cuda / --device mps / --device cpu - -# Optional: set a HuggingFace token for gated/private models -cp .env.example .env -# Edit .env and set HF_TOKEN=hf_your_token_here -``` - -#### Developer setup - -```bash -# Install with dev dependencies (pytest, ruff, pyright) -pip install -e ".[dev]" -# Or with uv: -uv pip install -e ".[dev]" - -# Run tests -pytest - -# Run linters -./maintain.sh -``` - -## ComfyUI - -Custom nodes are available so the watermark tools run inside a ComfyUI graph: -[ComfyUI-remove-ai-watermarks](https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks) -([registry](https://registry.comfy.org/nodes/remove-ai-watermarks)). - -Install via ComfyUI Manager (search "Remove AI Watermarks") or manually: - -```bash -cd ComfyUI/custom_nodes -git clone https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks -pip install -r ComfyUI-remove-ai-watermarks/requirements.txt -``` - -Nodes: Remove Visible Watermark, Detect Visible Watermark, Erase Region (by -mask), and Remove Invisible Watermark / SynthID (needs the `gpu` extra). - -## Usage - -### CLI - -```bash -# Remove all watermarks from a single image (visible + invisible + metadata) -remove-ai-watermarks all image.png -o clean.png - -# Process an entire directory -remove-ai-watermarks batch ./images/ --mode all -``` - -#### Individual commands - -```bash -# Identify provenance: where an image was made + its watermark inventory. -# Aggregates C2PA, IPTC "Made with AI", embedded SD/ComfyUI params, EXIF/XMP -# generator tags (incl. inside AVIF/HEIF), the SynthID proxy, the visible Gemini -# sparkle, and (with the [detect] extra) the open SD/SDXL/FLUX invisible -# watermark into one verdict. Reports "unknown" -# (never "clean") when no signal is found, since stripped metadata is not proof -# of a clean origin. Add --json for machine-readable output. -remove-ai-watermarks identify image.png - -# Visible watermark only — fast, offline, CPU. --mark auto (default) removes every -# detected known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" / -# Qwen "千问AI生成" / Kling "可灵AI 3.0" / Tencent Yuanbao "元宝 / AI生成" / -# Baidu "百度 AI生成" / LibLibAI / -# RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI"); -# force one with --mark gemini / doubao / jimeng / qwen / kling / yuanbao / baidu / -# liblib / runninghub / samsung. Removal localizes each mark to a -# footprint mask and inpaints it with a shared fill; --backend auto|cv2|migan|lama -# (default auto) picks the fill (auto = LaMa > MI-GAN > cv2, best available). -# --sensitivity auto|strict (default auto) sets how hard a borderline mark is -# trusted; there is no "assume this is AI" mode -- to act on a mark you can see -# but the detector missed, point at it with `erase --region` (below). -# If no known visible mark is found, it writes no output and exits 2 (not 0), -# pointing you to `all` (for an invisible/metadata mark) or `erase` (for an -# arbitrary logo) instead of handing back the unchanged image. -remove-ai-watermarks visible image.png -o clean.png - -# Metadata-stripped screenshot where you can SEE the mark but detection missed it: -# point at it. This executes what you see instead of relaxing every detector at once. -remove-ai-watermarks erase screenshot.png --region 812,1180,190,44 -o clean.png - -# Erase arbitrary region(s) — universal, any logo/watermark/object, any position. -# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama'). -remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png - -# Invisible watermark only (SynthID etc.) — requires GPU -remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0 --unsharp 0.5 -# Recommended high-quality mode: full Qwen-Image-2512 Canny pass plus -# SAM-masked Z-Image face repair. It is much slower and more expensive. -remove-ai-watermarks invisible image.png -o clean.png --pipeline qwen-zimage --force -# Low-VRAM CUDA mode. Slower because model components move between CPU and GPU. -remove-ai-watermarks invisible image.png -o clean.png --cpu-offload --force -# --humanize adds film grain, --unsharp counters the soft "AI" look (both opt-in). -# Large images run at native resolution; small ones are upscaled to a 1024 floor -# first (disable with --min-resolution 0); --upscaler esrgan uses Real-ESRGAN for -# that floor upscale (needs the 'esrgan' extra). On a very large image that OOMs the -# GPU/MPS, either cap the long side (--max-resolution 2048, lossy) or pass --tile -# to regenerate in overlapping feather-blended tiles at native resolution (lossless). -# Strength is vendor-adaptive by default for SDXL/ControlNet (OpenAI 0.10 / -# Google 0.15 / unknown 0.15); override with --strength. qwen-zimage instead -# uses its upstream resolution-adaptive formula and defaults to seed 0. -# controlnet (text/face -# structure preservation) is the default pipeline; --pipeline sdxl opts down -# to plain SDXL for non-structure inputs. Tune structure preservation with -# --controlnet-scale, the CFG with --guidance-scale (default 7.5), and the -# diffusion model with --model (default: SDXL base). -# --adaptive-polish (ON by default except qwen-zimage) restores the input's -# detail level (sparing text) to counter the over-smoothed look. Override the -# profile default with --adaptive-polish or --no-adaptive-polish. -# By default, if no invisible AI watermark is locally detectable, the diffusion -# scrub is SKIPPED (regenerating pixels would only degrade a clean image): for -# `invisible` that writes no output and exits 2, for `all` it skips step 2 but -# still strips metadata and exits 0. A skip never claims the image is clean -# (a pixel SynthID is undetectable once its metadata is gone). Pass --force to -# regenerate regardless when you know the image is AI-generated. - -# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels) -# --check also flags SynthID-bearing sources: a C2PA manifest signed by -# Google or OpenAI implies an invisible SynthID watermark in the pixels -# (both vendors pair the two). Adobe Firefly / Microsoft sign C2PA without -# SynthID, so they are reported as C2PA only. -remove-ai-watermarks metadata image.png --check -remove-ai-watermarks metadata image.png --remove - -# Batch with a specific mode -remove-ai-watermarks batch ./images/ --mode visible - -# Batch accepts the full invisible knob set (--strength/--guidance-scale/--model/ -# --pipeline/--cpu-offload/...); --adaptive-polish is on by default except qwen-zimage -# (--adaptive-polish/--no-adaptive-polish overrides the profile default) -remove-ai-watermarks batch ./images/ --mode all -``` - -### Python API - -One high-level call removes every detected visible mark (Gemini sparkle, the registered vendor text marks including Tencent Yuanbao, and the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use). +## Python API ```python import remove_ai_watermarks as raiw -# Clean a file -> writes clean.png, returns the array and what was removed. result, removed = raiw.remove_visible("watermarked.png", "clean.png") -print("removed:", removed) # e.g. ['Google Gemini sparkle'] -# An empty list means nothing known was found (route to `all` or `erase` instead). - -# Array in, array out (BGR numpy). Pick the fill backend: cv2 (default), migan, lama. -import cv2 -clean, removed = raiw.remove_visible(cv2.imread("in.png"), backend="cv2") - -# Metadata-stripped screenshot you know is AI-generated: relax detection to recover a -# moved or faint mark (a wrong guess just fills a small corner near-losslessly). -raiw.remove_visible("screenshot.png", "clean.png", sensitivity="strict") - -# What the file's metadata confirms (drives the default `auto` sensitivity): -raiw.visible_provenance("in.png") # e.g. frozenset({'gemini'}) - -# Full provenance verdict (platform + watermark inventory + confidence): -from remove_ai_watermarks.identify import identify -report = identify("in.png") +print(removed) ``` -#### Invisible removal (diffusion) +The high level API accepts a file path or a BGR NumPy array. For path inputs it +also reads provenance metadata, preserves alpha, and can strip AI metadata from +the written result. -```python -from pathlib import Path -from remove_ai_watermarks.invisible_engine import InvisibleEngine +See the [Python API guide](docs/python-api.md) for visible removal, provenance +inspection, metadata stripping, and diffusion usage. -# pipeline: "controlnet" (default), "sdxl", "qwen", or CUDA-only "qwen-zimage". -# model_id=None uses the SDXL base; controlnet_conditioning_scale tunes preservation. -# cpu_offload=True lowers CUDA VRAM use at the cost of transfer overhead. -engine = InvisibleEngine(pipeline="controlnet", cpu_offload=True) +## ComfyUI -# Prefer this CUDA-only profile when output fidelity matters more than cost: -# engine = InvisibleEngine(pipeline="qwen-zimage") +The separate +[ComfyUI Remove AI Watermarks](https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks) +package provides nodes for visible removal, detection, region erasing, and +invisible removal. -engine.remove_watermark( - Path("watermarked.png"), - Path("clean.png"), - strength=None, # None = profile default (ControlNet: OpenAI 0.10 / Google 0.15) - num_inference_steps=None, # None = 4 for qwen-zimage, 100 via this API otherwise - guidance_scale=None, # None = 1.0 for qwen-zimage, 7.5 otherwise - seed=None, # random for ControlNet; qwen-zimage resolves None to seed 0 - adaptive_polish=True, # detail-targeted polish, self-gating (default on in the CLI) - min_resolution=1024, # upscale tiny inputs to this floor before diffusion - max_resolution=0, # 0 = native; set only to cap GPU/MPS memory - upscaler="lanczos", # or "esrgan" for the floor upscale (needs the 'esrgan' extra) -) -``` +## Important limitations -### Metadata stripping +- A missing local signal means unknown, not clean. Proprietary pixel + watermarks may remain after metadata has been stripped. +- Visible removal reconstructs a small region. Results depend on the background + and selected fill backend. +- Invisible removal changes the whole image and may alter faces, text, or fine + detail. +- `qwen-zimage` requires CUDA. The other diffusion profiles also support the + devices listed by `remove-ai-watermarks invisible --help`. +- Provider watermark systems can change. Validate important outputs with the + provider's own verifier when one is available. -```python -from remove_ai_watermarks.metadata import has_ai_metadata, remove_ai_metadata -from pathlib import Path +## Documentation -if has_ai_metadata(Path("image.png")): - remove_ai_metadata(Path("image.png"), Path("clean.png")) -``` +Start with the [documentation index](docs/index.md). -## Requirements +- [Installation](docs/installation.md) +- [CLI guide](docs/cli.md) +- [Python API](docs/python-api.md) +- [Supported signals](docs/supported-signals.md) +- [Known limitations](docs/known-limitations.md) +- [Scope, safety, and legal notes](docs/legal-and-safety.md) +- [Module internals](docs/module-internals.md) +- [Release and distribution](docs/release-and-distribution.md) -- Python ≥ 3.10.1 -- **Visible removal / metadata**: CPU only, no GPU required -- **Invisible removal**: GPU recommended (CUDA or MPS), works on CPU (slow) +Research notes and historical experiments are listed separately in the +[documentation index](docs/index.md). They explain past decisions but do not +define the current public API. -## Troubleshooting +## Contributing -**SSL certificate error** (`CERTIFICATE_VERIFY_FAILED`): +Install the development environment and run the project gate: ```bash -# Install certifi (the tool auto-detects it) -pip install certifi - -# macOS only: run the Python certificate installer -/Applications/Python\ 3.*/Install\ Certificates.command +uv sync --frozen --extra dev +bash maintain.sh ``` -**First run is slow** — this is expected. The tool downloads model weights (~2 GB) on first launch. Subsequent runs use cached models. - -## Credits - -- [noai-watermark](https://github.com/mertizci/noai-watermark) by mertizci — invisible watermark removal engine -- [GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) by Allen Kuo (MIT) — visible watermark removal algorithm -- [controlnet-canny-sdxl-1.0](https://huggingface.co/xinsir/controlnet-canny-sdxl-1.0) by xinsir — SDXL canny ControlNet used by the `controlnet` pipeline to preserve text/face structure -- NeuralBleach (MIT) — analog humanizer technique - -## Roadmap - -Tracked but not yet implemented: - -- **SynthID-Image v2 automated regression test**. The default SDXL profile defeats v2 per manual checks against the [Gemini app](https://support.google.com/gemini/answer/16722517)'s "Verify with SynthID" feature on a Gemini 3 Pro output (May 2026). An automated end-to-end test would need either programmatic access to the [SynthID Detector portal](https://blog.google/innovation-and-ai/products/google-synthid-ai-content-detector/) (waitlist for media professionals and researchers) or an offline surrogate detector. The spectral phase-coherence surrogate from [reverse-SynthID](https://github.com/aloshdenny/reverse-SynthID) was evaluated and does not separate watermarked from cleaned real-content images (it only fires on controlled solid-color references at exact resolution), so it is not a usable oracle. Open. -- **Local SynthID *pixel* detector**. Not feasible today: Google's decoder is proprietary, and magnitude/carrier spectral methods do not separate real content (confirmed by three independent evaluations, including a from-scratch gpt-image pilot; see docs/known-limitations.md). Blocked on either (a) a programmatic generation path (OpenAI / Gemini API) to build a per-(model, resolution) labeled corpus at scale, or (b) a raw watermarked-output dataset. If data arrives, the next approach to try is a learned classifier on diverse content rather than a fixed carrier codebook. -- **Grow the SynthID reference corpus** (`data/synthid_corpus/`) with oracle-labeled samples per model and resolution (Gemini app for Google, openai.com/verify for OpenAI). Prerequisite for any pixel-detector attempt and for an automated removal-regression set. -- **Real non-PNG C2PA fixtures**. SynthID-source detection for JPEG / WebP / AVIF is currently covered only by synthetic byte blobs; replace with real vendor-emitted files to ground the binary-scan path. -- **Maintenance debt**. Strict pyright is now clean across `src/` (0 errors): pure-logic files are fully typed, the cv2 / torch / diffusers boundary files carry a documented per-file relax pragma, and a local `typings/piexif` stub covers piexif. Remaining: full-project `pyright` (no path) still OOMs node on this ML-heavy repo, so it must be scoped to `src/`; narrowing the boundary pragmas back toward full strict (as upstream stubs improve) is the long tail. (`uv-secure` is already clean since `idna` was bumped to 3.16.) -- **AVIF / HEIF `Exif` item inside the `meta` box**. An AI-label *XMP* packet in a `meta`-box item is now blanked in place (v0.6.9), but EXIF stored as a `meta`-box `Exif` *item* is still not removed — it needs full `iinf`/`iloc` surgery (offset rewrite, corruption risk) or `exiftool` (a non-bundled binary dependency). Low priority: the AI labels we target are XMP, not EXIF, so an EXIF-only meta-box case is rare. -- **More C2PA device signers**. Leica, Nikon, Google Pixel, Sony, and Truepic capture cameras are mapped (each verified against a real signed file); **Samsung Galaxy AI**, **Black Forest Labs (FLUX)**, and **ByteDance Volcano Engine** (Doubao / Jimeng) are now attributed too (verified on real signed files). Canon is still deferred until a real signed sample surfaces — no public direct-download C2PA file exists for it today (upload-to-verify / news-agency-licensed only). -- **Resemble PerTh audio detection** — evaluated, not feasible with the public API: `get_watermark()` returns a raw bit array with no presence/confidence flag, so watermarked vs. clean audio can't be reliably separated without Resemble's fixed payload or a confidence service. Same wall as the SynthID pixel detector. -- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo. - -Won't fix: - -- **Nightshade / Glaze / PhotoGuard removal**. These are defensive perturbations used by artists to protect their work from being scraped into AI training sets. Removing them attacks artists, not AI provenance. Out of scope. - -## Limitations - -- **Visible-mark removal is localized inpainting; metadata removal is lossless.** Each visible mark is localized to a footprint mask and filled by inpainting (cv2, MI-GAN, or big-LaMa), so only the small masked region is reconstructed and it blends into its surroundings within a few LAB levels; a slightly-off localization just fills a small region near-losslessly rather than leaving a color-shifted smear. Metadata stripping never touches image data. -- **The invisible (SynthID) path is lossy and not guaranteed.** The default ControlNet mode is the fast compatibility tier, but it can visibly alter faces and fine content at the scrub floor. Use the CUDA-only `qwen-zimage` profile for the best available visual fidelity; it is substantially larger, slower, and more expensive, and very small text can still degrade. There is no public SynthID decoder, so the tool cannot verify removal locally; confirm with the vendor's oracle. A vendor can change the scheme at any time, so treat this as an arms race, not a permanent fix. -- **Large images: native by default, opt-in tiling for OOM.** The SynthID path runs at the diffusion model's native resolution; on a memory-constrained GPU/MPS you can either cap the long side with `--max-resolution` (lossy downscale) or pass `--tile` to regenerate in overlapping, feather-blended tiles at native resolution (lossless, no seam). Tiling is a memory workaround, not a quality upgrade over a single native pass: each tile is an independent low-strength regeneration. With `qwen-zimage`, only the global Qwen pass is tiled; the face stage runs once after the full image is blended. The current seed-0 oracle verdict does not certify this tiled path. (Nano Banana 2 is natively 1024px; GPT Image 2 supports 4K experimentally.) -- **Out of scope:** defeating trained AI-vs-real classifiers like Hive (see [Threat model](#threat-model)), visible-logo removal from video, and any guarantee that a stripped copy is untraceable server-side. - -## Legal - -Watermarking and provenance for AI-generated content is now regulated in several jurisdictions. The table below summarises the May 2026 status. None of this is legal advice. - -| Jurisdiction | Instrument | Status (May 2026) | Relevance | -| --- | --- | --- | --- | -| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. | -| US (federal) | COPIED Act (S. 1396, 119th Cong.) | **Reintroduced April 2025; not enacted** (referred to Senate Commerce Committee). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. | -| US (state) | CA AB 2655, TX SB 751 (2019), similar | TX SB 751 (2019) in force; **CA AB 2655 struck down** by a federal court (E.D. Cal., Aug 2025, *Kohls v. Bonta*) as preempted by **Section 230**; the court did not reach the First Amendment (the companion law AB 2839 was separately enjoined on First Amendment grounds). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. | -| US (state) | CA AB 853 (amends the California AI Transparency Act) | Core provider duties operative **2 August 2026** (delayed from 1 January 2026); large platforms 1 January 2027; capture devices 1 January 2028. | Covered providers (1M+ monthly users) must embed a latent disclosure that is "permanent or extraordinarily difficult to remove" and offer a free detection tool. Removing that disclosure is what this tool does. | -| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **22 January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. | -| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels across image / audio / video; tampering with, forging, or removing labels is prohibited. | -| India | IT (Intermediary Guidelines and Digital Media Ethics Code) Amendment Rules, 2026 | In force since **20 February 2026** (notified 10 February 2026). | All "synthetically generated information" must be **prominently labelled** and carry **permanent metadata / a provenance identifier**; the rules expressly **prohibit modifying, suppressing, or removing** that label or metadata. Covers image, audio, and audio-visual content. | -| UK | Online Safety Act 2023 / Ofcom guidance | In force, but **no statutory AI-provenance or watermarking obligation**. | Ofcom encourages watermarking / provenance metadata as voluntary "attribution measures"; platform duties, not user obligations. | - -## Threat model - -This tool removes specific, known signals: the embedded SynthID pixel watermark, the visible vendor marks, and the C2PA / EXIF / IPTC provenance metadata that platforms read to apply automatic "Made with AI" labels. It is **not** a general detector-evasion tool. It does **not** defeat trained statistical AI-vs-real classifiers (for example Hive Moderation), and a light diffusion pass will not reliably fool those, so a clean classifier hit after removal is expected, not a bug. It also does **not** retroactively anonymise generation. And watermarking is a weak trust signal in the first place: a marker that is almost always present yet trivially removable can make a cleaned forgery look more trustworthy, not less, which is why durable provenance more likely comes from signing genuine content than from watermarking synthetic content. - -In particular, **SynthID** (Google DeepMind) is embedded across Google's generative media stack — Imagen (images), Veo (video), Lyria (audio) — and Gemini app image outputs (Nano Banana / Gemini 3 Pro, which we verified positive via the Gemini app's SynthID oracle); Google reported over 10 billion items watermarked by December 2025. It carries a **multi-bit payload** — the research paper's SynthID-O variant encodes 136-bit payloads in 512x512 images ([arxiv 2510.09263](https://arxiv.org/abs/2510.09263)). The payload is believed to encode a user / session identifier. If the original watermarked file ever passed through a system controlled by the prompt originator (a saved Gemini account history, a screenshot uploaded to a Google product, a backup), Google retains the ability to link that original to the generating account. Stripping the watermark from a copy you possess does not erase Google's server-side record. - -Use cases where the threat model fits: -- You generated the image yourself, want to publish it as your own work, and accept the consequences if Google ever publishes their detector logs. -- You are running a security / robustness evaluation. -- A real photo of yours was lightly AI-edited (a retouch in Gemini or ChatGPT, say) and now carries a SynthID or C2PA label that overstates how AI-generated it is, and you want to clear that label from your own copy. - -Use cases where the threat model **does not** fit: -- Generating an image, expecting that removing the watermark anonymises you to Google. It doesn't. -- Distributing AI-generated content while claiming human authorship. The watermark is one of several traceability layers. - -This tool is intended for legitimate purposes such as: - -- Privacy protection (removing metadata that leaks user account identifiers). -- Art preservation and fair-use research. -- Removing false-positive "Made with AI" labels from human-edited photographs. -- Security research and watermark robustness study. - -**Who bears the liability.** This is general-purpose software and is itself lawful to publish and run; legal responsibility attaches to the person who removes a marker and to how the result is then used, and the hinge is intent. Removing AI provenance to pass AI-generated content off as human-made, to commit fraud, to produce non-consensual deepfakes, or to conceal copyright infringement can expose the remover to liability. Two kinds of exposure are worth knowing: - -- **The downstream act.** Deception, fraud, defamation, IP infringement, or breaking a platform's terms — judged by intent and harm, not by the act of editing metadata itself. In the US, the **DMCA (17 U.S.C. § 1202)** specifically bars removing "copyright management information" *with intent to conceal or enable infringement*. -- **The removal itself.** Some jurisdictions penalise tampering with the label/metadata as such, regardless of downstream use — notably **China** (Labeling Measures) and **India** (IT Amendment Rules 2026), which expressly prohibit removing or suppressing the AI label and provenance metadata. The US **COPIED Act** would do the same if enacted. - -Legitimate uses — publishing your own work, privacy (stripping metadata that leaks an account identifier), security / robustness research, or removing a false-positive "Made with AI" label from a human-edited photograph — are generally lawful. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations. None of this is legal advice. +See [module internals](docs/module-internals.md) before changing a subsystem +with documented invariants. ## License diff --git a/data/README.md b/data/README.md new file mode 100644 index 0000000..55d99be --- /dev/null +++ b/data/README.md @@ -0,0 +1,38 @@ +# Repository data + +Tracked data is organized by purpose: + +```text +data/ + fixtures/ + provenance/ Real format and provenance fixtures used by tests + calibration/ + / Minimal controlled inputs needed to rebuild detector assets + synthid/ + originals/ Canonical provider-oracle fixtures, stored once + manifest.csv Provenance and verification record for each original + full-pipeline-quality.csv + Reusable full-pipeline evaluation selection + evaluations/ + fidelity/ Evaluation instructions and hand-verified ground truth +``` + +## Storage rules + +1. Store each binary image once. Evaluation manifests and documentation point + to its canonical location. +2. Put executable test fixtures in `fixtures/`. +3. Put only the minimal reproducible detector inputs in `calibration/`. +4. Put externally verified SynthID originals in `synthid/originals/` and keep + both CSV files synchronized. +5. Keep evaluation outputs outside the repository. Record reproducible + commands, hashes, and oracle verdicts instead of committing another corpus + copy. A small curated before-and-after example may live in `docs/images/` + when it is part of the public documentation. +6. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`. + Unregistered research candidates belong in + `scripts/assets/visible-mark-candidates/` so they are not shipped in the + wheel. + +The source distribution excludes `data/`; the wheel contains only package +runtime assets. diff --git a/data/doubao_capture/README.md b/data/calibration/doubao/README.md similarity index 69% rename from data/doubao_capture/README.md rename to data/calibration/doubao/README.md index b17d7d8..d54e28b 100644 --- a/data/doubao_capture/README.md +++ b/data/calibration/doubao/README.md @@ -1,17 +1,17 @@ # Doubao visible watermark capture -> **Status (captured 2026-05-29; alpha rebuilt 2026-05-31):** the black/gray/white captures were -> taken and are now **committed** in `captures/` (solid colour + watermark, content-free). The alpha -> map is rebuilt by `scripts/visible_alpha_solve.py doubao` (the careful gray-self solve shared with -> Jimeng). The first build claimed "pixel-exact" but left a readable outline on the real sample (issue -> #13 follow-up); removal now reverse-alphas, NCC-aligns, and applies a thin residual inpaint. See the -> `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the historical capture plan. +> **Status (captured 2026-05-29; asset rebuilt 2026-05-31):** the +> black and gray captures are committed here. The generated alpha +> asset is now a detection silhouette and mask-shaping input only. Current +> removal localizes the mark and sends its footprint to the shared fill backend. +> Reverse-alpha recovery was retired. The details below preserve the historical +> capture rationale. Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can -build a per-pixel alpha map and a reverse-alpha-blend remover, the same way the Gemini sparkle -engine works (`src/remove_ai_watermarks/gemini_engine.py`). +rebuild and validate the detection silhouette used by +`src/remove_ai_watermarks/doubao_engine.py`. -## What we already know (verified from prior art, 2026-05-26) +## Historical findings from the capture work - Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`, `logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`. @@ -19,14 +19,13 @@ engine works (`src/remove_ai_watermarks/gemini_engine.py`). both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray capture below settles it empirically. - Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size. - Confirmed by our sample `data/samples/doubao-1.png` (2048x2048) plus three sources. + Confirmed by our sample `data/fixtures/provenance/doubao-1.png` (2048x2048) plus three sources. - Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh. -- The planning assumption was that clean inversion leaves residue on textured backgrounds, so the - remover would pair the alpha map with inpainting. After the capture this turned out unnecessary at - the native width (recovery is pixel-exact there and inpaint is off); the shipped remover is - reverse-alpha only, with a residual inpaint applied off-native only. +- The original implementation used reverse-alpha recovery. That path was later + removed in favor of localize then fill because the latter also handles moved + and re-rendered marks. ## Use doubao.com specifically @@ -35,7 +34,7 @@ the captured template matches our target. ## How to capture (image-edit path, most reliable) -For each seed in `seeds/`: +For each locally generated solid-color seed: 1. Open Doubao image generation, use the image-edit / reference mode, upload the seed. 2. Prompt (Chinese preferred): @@ -70,16 +69,15 @@ extracted by per-pixel min/median. - Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it. - No crop / edit / re-save. Default settings, watermark left ON. -## Naming, drop into `captures/` +## Committed inputs ``` doubao_black_1x1_1.png -doubao_white_1x1_1.png -doubao_gray128_1x1_1.png -doubao_black_16x9_1.png -doubao_content_1x1_1.png +doubao_gray_1x1_1.png ``` +The black and gray captures feed `scripts/visible_alpha_solve.py doubao`. + ## Also report back 1. Which resolutions and aspect ratios the Doubao UI actually offers. diff --git a/data/doubao_capture/captures/doubao_black_1x1_1.png b/data/calibration/doubao/doubao_black_1x1_1.png similarity index 100% rename from data/doubao_capture/captures/doubao_black_1x1_1.png rename to data/calibration/doubao/doubao_black_1x1_1.png diff --git a/data/doubao_capture/captures/doubao_gray_1x1_1.png b/data/calibration/doubao/doubao_gray_1x1_1.png similarity index 100% rename from data/doubao_capture/captures/doubao_gray_1x1_1.png rename to data/calibration/doubao/doubao_gray_1x1_1.png diff --git a/data/gemini_capture/README.md b/data/calibration/gemini/README.md similarity index 75% rename from data/gemini_capture/README.md rename to data/calibration/gemini/README.md index 26c1e83..210b2b9 100644 --- a/data/gemini_capture/README.md +++ b/data/calibration/gemini/README.md @@ -1,8 +1,10 @@ # Gemini (Nano Banana) visible sparkle capture -> **Status (captured 2026-05-31):** black/gray/white captures taken and **committed** -> in `captures/` (solid colour + the sparkle, content-free). The sparkle-on-black assets +> **Status (captured 2026-05-31):** the required black capture is committed +> here. The sparkle-on-black assets > `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`. +> They support detection and footprint geometry. Current pixel recovery uses the +> shared fill backend, not reverse-alpha inversion. Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng @@ -20,7 +22,7 @@ text marks, the sparkle is captured over a **pure-black** background, where ## How to capture (image-edit path) -For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored): +For each locally generated black, gray, or white 2048 px seed: 1. Open Gemini image generation, image-edit / reference mode, upload the seed. 2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything` @@ -28,16 +30,15 @@ For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check. -## Naming, drop into `captures/` +## Committed input ``` gemini_black_2048.png # the key capture (sparkle on black) -gemini_gray_2048.png -gemini_white_2048.png ``` -The solid captures are **committed** (content-free). The synthetic `seeds/` and any -real-content `gemini_content_*.png` validation download are gitignored (local-only). +The gray and white captures were calibration cross-checks and are not needed to +rebuild the assets. Synthetic seeds and real-content validation downloads stay +local. Rebuild the assets with: ``` diff --git a/data/gemini_capture/captures/gemini_black_2048.png b/data/calibration/gemini/gemini_black_2048.png similarity index 100% rename from data/gemini_capture/captures/gemini_black_2048.png rename to data/calibration/gemini/gemini_black_2048.png diff --git a/data/jimeng_capture/README.md b/data/calibration/jimeng/README.md similarity index 64% rename from data/jimeng_capture/README.md rename to data/calibration/jimeng/README.md index 1ca6cfa..b3f3ead 100644 --- a/data/jimeng_capture/README.md +++ b/data/calibration/jimeng/README.md @@ -1,15 +1,13 @@ # Jimeng (即梦AI) visible watermark capture -> **Status (completed 2026-05-30):** solid black/gray/white Jimeng captures were -> obtained (issue #13, from @powersee) and the alpha map was solved. Removal is -> reverse-alpha plus a residual inpaint over the glyph footprint; see the -> `jimeng_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the -> capture plan. +> **Status (completed 2026-05-30):** solid black and gray Jimeng captures were +> obtained and the detection asset was solved. The asset now supports detection +> and mask geometry only. Current removal localizes the wordmark and uses the +> shared fill backend. The text below records the capture and calibration work. Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat -backgrounds so we can build a per-pixel alpha map and a reverse-alpha remover, the -same way the Gemini sparkle and Doubao strip engines work -(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`). +backgrounds so we can rebuild and validate the silhouette used by +`src/remove_ai_watermarks/jimeng_engine.py`. ## What we learned (verified from the captures, 2026-05-30) @@ -32,11 +30,10 @@ same way the Gemini sparkle and Doubao strip engines work width; a real 1440-wide download matched width_frac ~0.21. - **Per-image render variation:** the alpha maps solved independently from the black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng - re-rasterizes the mark per generation AND jitters its position a few px, so a - single alpha map does NOT pixel-cancel the mark the way Doubao's deterministic - overlay does. Removal therefore: NCC-aligns the alpha to the actual mark (always, - not only off-native), reverse-alphas, then clears the residual with a THIN inpaint - over the glyph footprint (a wide full-footprint pass smeared the texture/edges). + re-rasterizes the mark per generation and jitters its position a few pixels. + This was one reason the old reverse-alpha path needed alignment and residual + inpainting. The current path instead detects the silhouette, localizes the + glyph footprint, and fills that footprint. ## How to capture (image-edit path, most reliable) @@ -56,19 +53,15 @@ pure white (the mark is nearly invisible on white, as expected). - Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it. - No crop / edit / re-save. Default settings, watermark left ON. -## Naming, drop into `captures/` +## Committed inputs ``` jimeng_cap_A.png # black seed run through Jimeng -jimeng_cap_B.png # white seed jimeng_cap_C.png # gray seed -jimeng_content_1.png # a normal-content download, for end-to-end validation ``` -The solid `jimeng_cap_{A,B,C}.png` captures are **committed** (content-free: a solid -colour + the watermark; the source for `scripts/visible_alpha_solve.py jimeng`). The -synthetic `seeds/` and the real-content `jimeng_content_*.png` validation download are -gitignored (local-only). Rebuild the alpha asset with: +The A and C captures feed +`scripts/visible_alpha_solve.py jimeng`. Rebuild the detection asset with: ``` uv run python scripts/visible_alpha_solve.py jimeng # or: all diff --git a/data/jimeng_capture/captures/jimeng_cap_A.png b/data/calibration/jimeng/jimeng_cap_A.png similarity index 100% rename from data/jimeng_capture/captures/jimeng_cap_A.png rename to data/calibration/jimeng/jimeng_cap_A.png diff --git a/data/jimeng_capture/captures/jimeng_cap_C.png b/data/calibration/jimeng/jimeng_cap_C.png similarity index 100% rename from data/jimeng_capture/captures/jimeng_cap_C.png rename to data/calibration/jimeng/jimeng_cap_C.png diff --git a/data/samsung_capture/README.md b/data/calibration/samsung/README.md similarity index 70% rename from data/samsung_capture/README.md rename to data/calibration/samsung/README.md index 47dcb3c..7955c90 100644 --- a/data/samsung_capture/README.md +++ b/data/calibration/samsung/README.md @@ -1,15 +1,14 @@ # Samsung Galaxy AI visible watermark capture -> **Status (built 2026-06-05):** flat black/gray/white Samsung Galaxy AI captures -> were obtained (issue #37, from @f-liva) and the alpha map was solved. Removal is -> reverse-alpha plus a thin residual inpaint over the glyph footprint; see the -> `samsung_engine.py` notes in the root `CLAUDE.md`. The text below is the capture -> plan and the open quality follow-up. +> **Status (built 2026-06-05):** flat black and gray Samsung Galaxy AI captures +> were obtained and the detection asset was solved. The asset now supports +> detection and mask geometry only. Current removal localizes the wordmark and +> uses the shared fill backend. The text below records the capture plan and its +> locale and resolution limits. Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'AI" visible wordmark -over known flat backgrounds so we can build a per-pixel alpha map and a reverse-alpha -remover, the same way the Gemini sparkle and the Doubao / Jimeng strips work -(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`, `jimeng_engine.py`). +over known flat backgrounds so we can rebuild and validate the silhouette used +by `src/remove_ai_watermarks/samsung_engine.py`. ## What we learned (verified from the captures, 2026-06-05) @@ -51,15 +50,11 @@ On a Samsung Galaxy AI device (set the UI language to the target locale): ## Files -- `captures/samsung_black_1.png`, `samsung_gray_1.png`, `samsung_white_1.png` -- - portrait flat edits (1086 wide), the primary calibration set. -- `captures/samsung_black_2.png`, `samsung_gray_2.png`, `samsung_white_2.png` -- - a second (landscape 1920) set. -- `captures/samsung_content_*` -- real-photo validation downloads, **gitignored** - (user content, repo is public). -- `seeds/` -- synthetic solid-color inputs, gitignored (regenerable). +- `samsung_black_1.png` and `samsung_gray_1.png` are the retained solver inputs. +- White, secondary-resolution, and real-content captures were validation + material and are not required to rebuild the asset. -Rebuild the alpha asset with: +Rebuild the detection asset with: ``` uv run python scripts/visible_alpha_solve.py samsung diff --git a/data/samsung_capture/captures/samsung_black_1.png b/data/calibration/samsung/samsung_black_1.png similarity index 100% rename from data/samsung_capture/captures/samsung_black_1.png rename to data/calibration/samsung/samsung_black_1.png diff --git a/data/samsung_capture/captures/samsung_gray_1.png b/data/calibration/samsung/samsung_gray_1.png similarity index 100% rename from data/samsung_capture/captures/samsung_gray_1.png rename to data/calibration/samsung/samsung_gray_1.png diff --git a/data/doubao_capture/captures/doubao_white_1x1_1.png b/data/doubao_capture/captures/doubao_white_1x1_1.png deleted file mode 100644 index e1f6abc..0000000 Binary files a/data/doubao_capture/captures/doubao_white_1x1_1.png and /dev/null differ diff --git a/data/evaluations/fidelity/README.md b/data/evaluations/fidelity/README.md new file mode 100644 index 0000000..9896e21 --- /dev/null +++ b/data/evaluations/fidelity/README.md @@ -0,0 +1,37 @@ +# Pipeline fidelity evaluation + +This evaluation compares diffusion pipelines with +`scripts/fidelity_metrics.py`. The images themselves have one canonical home +in `data/synthid/originals/`; this directory stores only evaluation-specific +ground truth and instructions. + +| Original | Provider | Content | Exercises | +| --- | --- | --- | --- | +| `ChatGPT Image May 31, 2026, 02_03_55 PM.png` | OpenAI | Multilingual typography | Text preservation | +| `Gemini_Generated_Image_633uuy633uuy633u.png` | Google | Landscape with a Chinese sign | CJK text preservation | +| `Gemini_Generated_Image_y48j3cy48j3cy48j.png` | Google | Portrait grid | Face identity and skin texture | + +## Text ground truth + +`ground-truth.json` contains hand-verified OCR for the two text-bearing +originals. To regenerate an OCR seed: + +```bash +uv run scripts/fidelity_metrics.py ocr \ + "data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png" \ + data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \ + --langs en,ru,ch \ + --out data/evaluations/fidelity/ground-truth.json +``` + +Verify and correct the generated text by hand before using it as ground truth. + +## Compare + +```bash +uv run scripts/fidelity_metrics.py compare \ + --original data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png \ + --variant controlnet=.png \ + --variant qwen=.png \ + --ocr-langs "" +``` diff --git a/data/evaluations/fidelity/ground-truth.json b/data/evaluations/fidelity/ground-truth.json new file mode 100644 index 0000000..240deff --- /dev/null +++ b/data/evaluations/fidelity/ground-truth.json @@ -0,0 +1,4 @@ +{ + "ChatGPT Image May 31, 2026, 02_03_55 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.", + "Gemini_Generated_Image_633uuy633uuy633u.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。" +} diff --git a/data/samples/chatgpt-1.png b/data/fixtures/provenance/chatgpt-1.png similarity index 100% rename from data/samples/chatgpt-1.png rename to data/fixtures/provenance/chatgpt-1.png diff --git a/data/samples/chatgpt-2.png b/data/fixtures/provenance/chatgpt-2.png similarity index 100% rename from data/samples/chatgpt-2.png rename to data/fixtures/provenance/chatgpt-2.png diff --git a/data/samples/doubao-1.png b/data/fixtures/provenance/doubao-1.png similarity index 100% rename from data/samples/doubao-1.png rename to data/fixtures/provenance/doubao-1.png diff --git a/data/samples/firefly-1.png b/data/fixtures/provenance/firefly-1.png similarity index 100% rename from data/samples/firefly-1.png rename to data/fixtures/provenance/firefly-1.png diff --git a/data/samples/flux-1.jpg b/data/fixtures/provenance/flux-1.jpg similarity index 100% rename from data/samples/flux-1.jpg rename to data/fixtures/provenance/flux-1.jpg diff --git a/data/samples/flux-1.png b/data/fixtures/provenance/flux-1.png similarity index 100% rename from data/samples/flux-1.png rename to data/fixtures/provenance/flux-1.png diff --git a/data/samples/grok-1.jpg b/data/fixtures/provenance/grok-1.jpg similarity index 100% rename from data/samples/grok-1.jpg rename to data/fixtures/provenance/grok-1.jpg diff --git a/data/samples/mj-1.png b/data/fixtures/provenance/mj-1.png similarity index 100% rename from data/samples/mj-1.png rename to data/fixtures/provenance/mj-1.png diff --git a/data/gemini_capture/captures/gemini_gray_2048.png b/data/gemini_capture/captures/gemini_gray_2048.png deleted file mode 100644 index 2c2db4a..0000000 Binary files a/data/gemini_capture/captures/gemini_gray_2048.png and /dev/null differ diff --git a/data/gemini_capture/captures/gemini_white_2048.png b/data/gemini_capture/captures/gemini_white_2048.png deleted file mode 100644 index 79018cd..0000000 Binary files a/data/gemini_capture/captures/gemini_white_2048.png and /dev/null differ diff --git a/data/jimeng_capture/captures/jimeng_cap_B.png b/data/jimeng_capture/captures/jimeng_cap_B.png deleted file mode 100644 index 25d1232..0000000 Binary files a/data/jimeng_capture/captures/jimeng_cap_B.png and /dev/null differ diff --git a/data/qwen_in/README.md b/data/qwen_in/README.md deleted file mode 100644 index 6fcc603..0000000 --- a/data/qwen_in/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# qwen_in — pipeline-fidelity eval set - -A small, **stable** set of AI-generated images used to compare the diffusion -removal pipelines (`controlnet` / `sdxl` / `qwen`) for fidelity with -`scripts/fidelity_metrics.py`. Fixing the set in the repo keeps comparisons -reproducible across runs and pipelines. - -All four are AI-generated test content (they carry SynthID + C2PA from their -generator — verify with `remove-ai-watermarks identify`), same class as the -`data/samples/` fixtures. No real-person photos. - -| file | vendor (SynthID) | content | exercises | -|---|---|---|---| -| `openai_1_original.png` | OpenAI | typography sheet (EN + RU + ZH) | text (multi-script) | -| `openai_2_original.png` | OpenAI | Raiw.cc poster | text (EN, small) | -| `gemini_1_original.png` | Google | landscape + Chinese sign | text (CJK) | -| `gemini_3_original.png` | Google | 3x3 portrait grid | faces (identity / skin texture) | - -## Text ground truth - -`ground_truth.json` (`{basename: text}`) is the **hand-verified** OCR of the -text-bearing originals, seeded by `fidelity_metrics.py ocr` and corrected by -hand (PaddleOCR mis-reads stylized Cyrillic in particular). It is the reference -for the text CER metric — much cleaner than OCR-vs-OCR. Regenerate the seed with: - - uv run scripts/fidelity_metrics.py ocr data/qwen_in/openai_1_original.png \ - data/qwen_in/openai_2_original.png data/qwen_in/gemini_1_original.png \ - --langs en,ru,ch --out data/qwen_in/ground_truth.json - # then re-verify by hand before trusting it. - -## Compare - - uv run scripts/fidelity_metrics.py compare \ - --original data/qwen_in/gemini_3_original.png \ - --variant controlnet=.png --variant qwen=.png --ocr-langs "" diff --git a/data/qwen_in/gemini_1_original.png b/data/qwen_in/gemini_1_original.png deleted file mode 100644 index 229f943..0000000 Binary files a/data/qwen_in/gemini_1_original.png and /dev/null differ diff --git a/data/qwen_in/ground_truth.json b/data/qwen_in/ground_truth.json deleted file mode 100644 index 4d84aa7..0000000 --- a/data/qwen_in/ground_truth.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "openai_1_original.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.", - "openai_2_original.png": "raiw.cc\nRaiw.cc – The Platform for the Future\nRaiw.cc is a modern project built for those who value quality, innovation, and reliability.\nSecurity & Reliability\nWe use advanced technologies to protect your data and ensure stable service performance.\nInnovation\nWe continuously explore and implement better solutions to help you stay one step ahead.\nCommunity\nRaiw.cc is an active community of like-minded people who share knowledge, experience, and ideas.\nProgress Together\nWhether you're a developer, creator, or dreamer — raiw.cc is your partner in achieving your goals.\nOur mission is to create opportunities and inspire people to reach new heights.\nRaiw.cc\nVisit raiw.cc today\nExplore more possibilities and start your journey to the future!\nGet Started\nSecure\nInnovative\nCommunity\nForward-Thinking\nINNOVATE · CONNECT · CREATE · GROW", - "gemini_1_original.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。" -} diff --git a/data/qwen_in/openai_2_original.png b/data/qwen_in/openai_2_original.png deleted file mode 100644 index e3b01fb..0000000 Binary files a/data/qwen_in/openai_2_original.png and /dev/null differ diff --git a/data/samples/mj-2.png b/data/samples/mj-2.png deleted file mode 100644 index b4ff1c6..0000000 Binary files a/data/samples/mj-2.png and /dev/null differ diff --git a/data/samples/openai-images-2/README.md b/data/samples/openai-images-2/README.md deleted file mode 100644 index 2f492a3..0000000 --- a/data/samples/openai-images-2/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# OpenAI ChatGPT Images 2.0 sample - -Reference image generated by OpenAI's ChatGPT Images 2.0 (`gpt-image-2`, launched 2026-04-21). Kept in the repo so the C2PA manifest parser and invisible-watermark pipeline can be re-verified against a real production output. - -## `amur-leopard.png` - -- Resolution: 1055 x 1491, PNG, 3.0 MB -- Downloaded: 2026-04-22 -- Content: AI-generated infographic about the Amur leopard (*Panthera pardus orientalis*), chosen to exercise the model's new accurate-text-rendering feature - -### Embedded C2PA manifest (caBX chunk, 23607 bytes) - -Parsed by `remove-ai-watermarks metadata --check`: - -| Field | Value | -| --- | --- | -| `claim_generator` | GPT-4o | -| `c2pa_spec` | 2.2.0 | -| `digital_source_type` | http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia | -| `c2pa_actions` | created, converted | -| Signer | OpenAI OpCo, LLC / OpenAI Media Service API | -| Claim signing CA | Trufo C2PA Claim Signing CA (2025) -> Trufo C2PA Root CA (ECC P384) | -| Timestamp authority | OpenAI TSA Issuing CA -> OpenAI TSA Root CA | - -The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Instagram, Facebook, and X. - -### Invisible pixel-level watermark - -OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. This sample was downloaded 2026-04-22, before OpenAI's 19 May 2026 rollout of Google's SynthID watermark across ChatGPT / Codex / the API, so it likely predates SynthID and carries only the original content-specific watermark. Since that rollout, the openai.com/verify tool (in preview) is the public oracle for both signals; there is still no local decoder, so bypass cannot be verified empirically here without the oracle. - -## Reproducing the removal - -```bash -# C2PA strip only -remove-ai-watermarks metadata amur-leopard.png --remove -o amur-leopard.clean.png - -# Full pipeline (visible + diffusion regeneration + metadata) -remove-ai-watermarks all amur-leopard.png -o amur-leopard.all.png --device mps -``` - -Note: the diffusion step runs at native resolution by default (no pre-downscale), so fine text on a text-heavy infographic like this one is preserved. Pass `--max-resolution N` only if a very large image OOMs the GPU/MPS (that reintroduces a lossy downscale then upscale round-trip). diff --git a/data/samples/openai-images-2/amur-leopard.png b/data/samples/openai-images-2/amur-leopard.png deleted file mode 100644 index 17757c9..0000000 Binary files a/data/samples/openai-images-2/amur-leopard.png and /dev/null differ diff --git a/data/samsung_capture/captures/samsung_black_2.png b/data/samsung_capture/captures/samsung_black_2.png deleted file mode 100644 index 4d92ccf..0000000 Binary files a/data/samsung_capture/captures/samsung_black_2.png and /dev/null differ diff --git a/data/samsung_capture/captures/samsung_gray_2.png b/data/samsung_capture/captures/samsung_gray_2.png deleted file mode 100644 index 55003aa..0000000 Binary files a/data/samsung_capture/captures/samsung_gray_2.png and /dev/null differ diff --git a/data/samsung_capture/captures/samsung_white_1.png b/data/samsung_capture/captures/samsung_white_1.png deleted file mode 100644 index af041de..0000000 Binary files a/data/samsung_capture/captures/samsung_white_1.png and /dev/null differ diff --git a/data/samsung_capture/captures/samsung_white_2.png b/data/samsung_capture/captures/samsung_white_2.png deleted file mode 100644 index d3e5eba..0000000 Binary files a/data/samsung_capture/captures/samsung_white_2.png and /dev/null differ diff --git a/data/synthid_corpus/README.md b/data/synthid/README.md similarity index 70% rename from data/synthid_corpus/README.md rename to data/synthid/README.md index de1b11f..762424f 100644 --- a/data/synthid_corpus/README.md +++ b/data/synthid/README.md @@ -1,11 +1,10 @@ -# SynthID reference corpus +# SynthID oracle fixtures -A locally-collected, labeled image corpus for SynthID work. Two downstream uses: +A compact set of externally verified originals for two uses: -1. **Per-resolution spectral codebook** for an experimental SynthID detector - (carrier frequencies are resolution-dependent, so labels must record the - exact native resolution). -2. **Removal regression set** — verify that our pipeline turns a SynthID-positive +1. **Signal and detector-limit research** with exact native resolutions and + recorded verification sources. +2. **Removal regression set**: verify that a pipeline turns a SynthID-positive image into a negative one. There is no reliable local detector of the SynthID pixel watermark (Google's @@ -15,35 +14,29 @@ external oracle, recorded per image in `verified_via` (see below). ## Layout ``` -data/synthid_corpus/ - README.md # this protocol (committed) - manifest.csv # labels + provenance (committed; one row per tracked image) - quality_sets/ # reusable test-suite manifests over corpus images - images/ # the labeled corpus (committed) - pos/ # SynthID present - neg/ # SynthID absent (incl. reviewed real photos) - cleaned/ # our pipeline output from a pos image - refs/ # synthetic black/white calibration fills (gitignored, regenerable) +data/synthid/ + README.md # collection and verification protocol + manifest.csv # one row per committed original + full-pipeline-quality.csv # reusable evaluation selection + originals/ # one canonical copy of each verified source ``` -The labeled images are committed so the corpus is reproducible and the removal -regression set runs in CI. `manifest.csv` is kept in sync with the files on -disk (one row per tracked image; dangling rows are pruned when files are -removed). Before adding any image, confirm it carries no private or -identifiable content you would not publish -- this is a public repo and git -history is permanent. The synthetic `refs/` fills stay gitignored (regenerable, -not part of the labeled set). +The originals are committed so the evaluation set is reproducible and its +hashes can be checked in CI. `manifest.csv` is kept in sync with +`originals/`, one row per file. Generated outputs do not belong here. Store +them outside the repository and record only a reproducible verdict in the +evaluation manifest or documentation. ## Reusable removal-quality set -`quality_sets/full_pipeline_quality_2026-07-25.csv` is the canonical reusable -input set for full-pipeline visual-quality and watermark-removal tests. Its -fixtures are cleared for permanent public test reuse. Tests, benchmarks, and -manual evaluation bundles may read these images repeatedly without requesting -new permission. +`full-pipeline-quality.csv` is the canonical reusable input set for +full-pipeline visual-quality and watermark-removal tests. Its fixtures are +cleared for permanent public test reuse. Tests, benchmarks, and manual +evaluation bundles may read these images repeatedly without requesting new +permission. The CSV preserves each platform's original filename and maps it to the one -SHA-256-addressed corpus copy. Use `corpus_path` to read the input, but preserve +canonical corpus copy. Use `corpus_path` to read the input, but preserve `source_filename` in generated result names and keep OpenAI and Gemini outputs in separate provider groups. This avoids duplicating large PNGs while retaining the names needed for the corresponding provider oracle. @@ -67,33 +60,25 @@ Ground-truth quality, strongest first: - `third-party` — label asserted by an external dataset, not independently verified. - `none` — unverified. -Prefer `gemini-app` for any image that will train the codebook or gate a test. +Prefer `gemini-app` for any Google image that will gate a test. ## What to collect -For the **codebook** (per target resolution, e.g. 1024x1024, 1024x1536, 1536x2816): - -- 30-50+ SynthID-positive outputs per resolution (more is better; ~150-200 per - resolution materially improves carrier discovery). -- At each target resolution, also a batch of **pure-black (#000000)** and - **pure-white (#FFFFFF)** fills generated by the SynthID model — these isolate - the content-independent carrier (the watermark is most of the signal there). - For the **regression set**: -- A handful of `pos` images, their `cleaned` counterparts (run through our - pipeline), and the cleaned re-verified via `gemini-app` (should read negative). -- `neg` controls: non-AI photos and outputs from non-SynthID models (SD, - Midjourney, Firefly) verified negative. +- A small number of positive originals with a recorded provider oracle. +- A quality-set row for every original used by repeatable evaluation. +- Re-run the provider oracle on each newly generated output. Do not commit the + generated output as another corpus copy. The corpus is committed to a public repo: review every image before adding it and keep out anything private or identifiable you would not publish. ## Ingesting -Use `scripts/synthid_corpus.py` — it copies a file in, records its sha256, -resolution, format, and C2PA issuer (via our own detector), and appends a row -to `manifest.csv`: +Use `scripts/synthid_corpus.py` to copy a file into `originals/`, record its +SHA-256, resolution, format, and C2PA issuer through our detector, and append a +row to `manifest.csv`: ```bash uv run python scripts/synthid_corpus.py ingest path/to/*.png \ @@ -105,6 +90,10 @@ uv run python scripts/synthid_corpus.py status # counts by label / resolution ## Autonomous collection via Chrome MCP +> Historical collection notes. Browser interfaces and download behavior can +> change independently of this repository. These steps do not define package +> behavior. + Generation can be driven through the browser (the account must be logged in): - **Gemini** (`gemini.google.com`): type `Create an image: `, wait, hover the @@ -145,8 +134,8 @@ Download; Leonardo serves the original JPEG in-chat (download button matches). ## Per-platform watermark map (observed, May 2026) -What each platform actually embeds, verified by byte-scan (and Gemini-app oracle -where noted). The detector's coverage is complementary: metadata catches C2PA / +This is a dated observation table, not a live provider compatibility promise. +The detector's coverage is complementary: metadata catches C2PA / IPTC; `exif_generator` catches EXIF `Make`/`Software` + XMP `CreatorTool`; `invisible_watermark.py` (imwatermark) catches the open SD/SDXL/FLUX DWT-DCT watermark on pristine files; the visible detector catches the Gemini-family diff --git a/data/synthid/full-pipeline-quality.csv b/data/synthid/full-pipeline-quality.csv new file mode 100644 index 0000000..7644d50 --- /dev/null +++ b/data/synthid/full-pipeline-quality.csv @@ -0,0 +1,8 @@ +provider,source_filename,corpus_path,sha256,original_signal_verified_via,final_clean_oracle +openai,"ChatGPT Image May 30, 2026, 10_31_08 AM.png","originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png",28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,openai-verify,openai-verify-negative-2026-07-25 +openai,"ChatGPT Image May 31, 2026, 02_02_23 PM.png","originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png",228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,c2pa-metadata,not-checked-in-final-candidate +openai,"ChatGPT Image May 31, 2026, 02_03_55 PM.png","originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png",05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,openai-verify,openai-verify-negative-2026-07-25 +gemini,"Gemini_Generated_Image_3mc4t93mc4t93mc4.png","originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png",2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,gemini-app,gemini-app-negative-2026-07-25 +gemini,"Gemini_Generated_Image_633uuy633uuy633u.png","originals/Gemini_Generated_Image_633uuy633uuy633u.png",4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,gemini-app,gemini-app-negative-2026-07-25 +gemini,"Gemini_Generated_Image_akdbeiakdbeiakdb.png","originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png",8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,gemini-app,gemini-app-negative-2026-07-25 +gemini,"Gemini_Generated_Image_y48j3cy48j3cy48j.png","originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png",45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,gemini-app,gemini-app-negative-2026-07-25 diff --git a/data/synthid/manifest.csv b/data/synthid/manifest.csv new file mode 100644 index 0000000..4cc269a --- /dev/null +++ b/data/synthid/manifest.csv @@ -0,0 +1,8 @@ +sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes +05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2) +28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,"ChatGPT Image May 30, 2026, 10_31_08 AM.png",pos,ChatGPT,gpt-image,1448,1086,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2) +4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,Gemini_Generated_Image_633uuy633uuy633u.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) +8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,Gemini_Generated_Image_akdbeiakdbeiakdb.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) +45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,Gemini_Generated_Image_y48j3cy48j3cy48j.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) +2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,Gemini_Generated_Image_3mc4t93mc4t93mc4.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) +228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,c2pa-metadata,2026-07-25T20:21:33Z,Reusable full-pipeline quality fixture; original filename preserved in quality-set manifest; cleared for public test reuse diff --git a/docs/images/qwen-zimage/ChatGPT/ChatGPT Image May 30, 2026, 10_31_08 AM.png b/data/synthid/originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png similarity index 100% rename from docs/images/qwen-zimage/ChatGPT/ChatGPT Image May 30, 2026, 10_31_08 AM.png rename to data/synthid/originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png diff --git a/data/synthid_corpus/images/pos/228e5441-ChatGPT Image May 31, 2026, 02_02_23 PM.png b/data/synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png similarity index 100% rename from data/synthid_corpus/images/pos/228e5441-ChatGPT Image May 31, 2026, 02_02_23 PM.png rename to data/synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png diff --git a/data/qwen_in/openai_1_original.png b/data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png similarity index 100% rename from data/qwen_in/openai_1_original.png rename to data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png diff --git a/data/synthid_corpus/images/pos/2c33e75a-gemini_4_original.png b/data/synthid/originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png similarity index 100% rename from data/synthid_corpus/images/pos/2c33e75a-gemini_4_original.png rename to data/synthid/originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png diff --git a/docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u.png b/data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png similarity index 100% rename from docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u.png rename to data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png diff --git a/data/synthid_corpus/images/pos/8c1a6fb0-gemini_2_original.png b/data/synthid/originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png similarity index 100% rename from data/synthid_corpus/images/pos/8c1a6fb0-gemini_2_original.png rename to data/synthid/originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png diff --git a/data/qwen_in/gemini_3_original.png b/data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png similarity index 100% rename from data/qwen_in/gemini_3_original.png rename to data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png diff --git a/data/synthid_corpus/images/cleaned/2e4ce41c-openai_1_clean_s005.png b/data/synthid_corpus/images/cleaned/2e4ce41c-openai_1_clean_s005.png deleted file mode 100644 index e52c76b..0000000 Binary files a/data/synthid_corpus/images/cleaned/2e4ce41c-openai_1_clean_s005.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/356196dd-gemini_3_clean_s015_max1536.png b/data/synthid_corpus/images/cleaned/356196dd-gemini_3_clean_s015_max1536.png deleted file mode 100644 index c6f183a..0000000 Binary files a/data/synthid_corpus/images/cleaned/356196dd-gemini_3_clean_s015_max1536.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/37b34274-openai_2_clean_s010.png b/data/synthid_corpus/images/cleaned/37b34274-openai_2_clean_s010.png deleted file mode 100644 index 4306c2b..0000000 Binary files a/data/synthid_corpus/images/cleaned/37b34274-openai_2_clean_s010.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/4aa5f61c-gemini_2_clean_s015_max1536.png b/data/synthid_corpus/images/cleaned/4aa5f61c-gemini_2_clean_s015_max1536.png deleted file mode 100644 index f95254a..0000000 Binary files a/data/synthid_corpus/images/cleaned/4aa5f61c-gemini_2_clean_s015_max1536.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/9e4160bb-gemini_4_clean_s015_max1536.png b/data/synthid_corpus/images/cleaned/9e4160bb-gemini_4_clean_s015_max1536.png deleted file mode 100644 index 75a9030..0000000 Binary files a/data/synthid_corpus/images/cleaned/9e4160bb-gemini_4_clean_s015_max1536.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/f6dd47a5-4ef377bd-gpt-image-2-cleaned.png b/data/synthid_corpus/images/cleaned/f6dd47a5-4ef377bd-gpt-image-2-cleaned.png deleted file mode 100644 index a054fe0..0000000 Binary files a/data/synthid_corpus/images/cleaned/f6dd47a5-4ef377bd-gpt-image-2-cleaned.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/f7c52cdf-openai_3_clean_s005.png b/data/synthid_corpus/images/cleaned/f7c52cdf-openai_3_clean_s005.png deleted file mode 100644 index a7c7d12..0000000 Binary files a/data/synthid_corpus/images/cleaned/f7c52cdf-openai_3_clean_s005.png and /dev/null differ diff --git a/data/synthid_corpus/images/cleaned/f99bd9a5-gemini_1_clean_s015_max1536.png b/data/synthid_corpus/images/cleaned/f99bd9a5-gemini_1_clean_s015_max1536.png deleted file mode 100644 index 45f3136..0000000 Binary files a/data/synthid_corpus/images/cleaned/f99bd9a5-gemini_1_clean_s015_max1536.png and /dev/null differ diff --git a/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC b/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC deleted file mode 100644 index 927c126..0000000 Binary files a/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC b/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC deleted file mode 100644 index 39b1b63..0000000 Binary files a/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC b/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC deleted file mode 100644 index 56beaaa..0000000 Binary files a/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC b/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC deleted file mode 100644 index 2898b12..0000000 Binary files a/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC b/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC deleted file mode 100644 index d46484c..0000000 Binary files a/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC b/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC deleted file mode 100644 index a8c38f2..0000000 Binary files a/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC b/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC deleted file mode 100644 index 0903ae4..0000000 Binary files a/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC b/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC deleted file mode 100644 index c4dd62a..0000000 Binary files a/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC b/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC deleted file mode 100644 index 1039d28..0000000 Binary files a/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC b/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC deleted file mode 100644 index 54cb5ba..0000000 Binary files a/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC b/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC deleted file mode 100644 index 951430a..0000000 Binary files a/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC b/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC deleted file mode 100644 index cd529c9..0000000 Binary files a/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC b/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC deleted file mode 100644 index f7cb2cf..0000000 Binary files a/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC b/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC deleted file mode 100644 index b6c47ea..0000000 Binary files a/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC b/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC deleted file mode 100644 index 8b44209..0000000 Binary files a/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC and /dev/null differ diff --git a/data/synthid_corpus/images/pos/05b836ec-openai_1_original.png b/data/synthid_corpus/images/pos/05b836ec-openai_1_original.png deleted file mode 100644 index c1325f7..0000000 Binary files a/data/synthid_corpus/images/pos/05b836ec-openai_1_original.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/1f81827c-Designer.png b/data/synthid_corpus/images/pos/1f81827c-Designer.png deleted file mode 100644 index c800a3e..0000000 Binary files a/data/synthid_corpus/images/pos/1f81827c-Designer.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png b/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png deleted file mode 100644 index b69afed..0000000 Binary files a/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/28ff8732-openai_3_original.png b/data/synthid_corpus/images/pos/28ff8732-openai_3_original.png deleted file mode 100644 index 59365a1..0000000 Binary files a/data/synthid_corpus/images/pos/28ff8732-openai_3_original.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/45d79a68-gemini_3_original.png b/data/synthid_corpus/images/pos/45d79a68-gemini_3_original.png deleted file mode 100644 index 7ffcbf4..0000000 Binary files a/data/synthid_corpus/images/pos/45d79a68-gemini_3_original.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/4affd7f2-gemini_1_original.png b/data/synthid_corpus/images/pos/4affd7f2-gemini_1_original.png deleted file mode 100644 index 229f943..0000000 Binary files a/data/synthid_corpus/images/pos/4affd7f2-gemini_1_original.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png b/data/synthid_corpus/images/pos/4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png deleted file mode 100644 index d10d79e..0000000 Binary files a/data/synthid_corpus/images/pos/4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/794e023e-openai_2_original.png b/data/synthid_corpus/images/pos/794e023e-openai_2_original.png deleted file mode 100644 index e3b01fb..0000000 Binary files a/data/synthid_corpus/images/pos/794e023e-openai_2_original.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png b/data/synthid_corpus/images/pos/7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png deleted file mode 100644 index d1a4f95..0000000 Binary files a/data/synthid_corpus/images/pos/7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png b/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png deleted file mode 100644 index 976739f..0000000 Binary files a/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png b/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png deleted file mode 100644 index 205945f..0000000 Binary files a/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png b/data/synthid_corpus/images/pos/d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png deleted file mode 100644 index 04e7337..0000000 Binary files a/data/synthid_corpus/images/pos/d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png b/data/synthid_corpus/images/pos/d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png deleted file mode 100644 index 459d9f3..0000000 Binary files a/data/synthid_corpus/images/pos/d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png and /dev/null differ diff --git a/data/synthid_corpus/images/pos/fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png b/data/synthid_corpus/images/pos/fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png deleted file mode 100644 index 0af81f0..0000000 Binary files a/data/synthid_corpus/images/pos/fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png and /dev/null differ diff --git a/data/synthid_corpus/manifest.csv b/data/synthid_corpus/manifest.csv deleted file mode 100644 index 067e8cc..0000000 --- a/data/synthid_corpus/manifest.csv +++ /dev/null @@ -1,41 +0,0 @@ -sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes -4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e,"4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,openai-verify,2026-05-23T21:48:12Z,fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected -d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656,d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-23T21:52:40Z,"user: latest Gemini, SynthID v2" -7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a,"7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png",pos,ChatGPT,gpt-image,1602,982,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:20:25Z,content: misty pine forest at dawn -fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32,fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:30:25Z,content: elderly fisherman portrait -d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae,d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:33:15Z,content: red coffee mug product shot -28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe,28f32334-chatgpt_fisherman.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:39:20Z,content: elderly fisherman portrait (fetch+blob dl) -88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84,88e61a38-chatgpt_tokyo.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:42:02Z,content: tokyo street night (fetch+blob dl) -1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440,1fa5f77f-IMG_0786.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified) -2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067,2ea228ed-IMG_1791.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified) -0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6,0bb4c176-IMG_1790.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified) -3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846,3fc0c425-IMG_1450.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723,2b148db2-IMG_1832.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36,2d02fed0-IMG_1520.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772,8f170c06-IMG_1078.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e,3fc4c831-IMG_2566.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1,5fe59521-IMG_1496.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38,4ead7918-IMG_1300.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified) -4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35,4c795238-IMG_3034.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified) -5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d,5abfaccb-IMG_3018.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified) -5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33,5fed1923-IMG_0272.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified) -06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b,06b04d8f-IMG_1474.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified) -8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062,8fdb574a-IMG_3557.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified) -c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501,c8697342-aistudio_lake.png,pos,Google AI Studio (Nano Banana),gemini-2.5-flash-image,1024,1024,png,,,gemini-app,2026-05-24T21:39:09Z,"API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot" -1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a,1f81827c-Designer.png,pos,Microsoft Designer,dall-e (Designer),1024,1024,png,"OpenAI, Microsoft",yes,c2pa-metadata,2026-05-24T22:18:40Z,C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA) -f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e,f6dd47a5-4ef377bd-gpt-image-2-cleaned.png,cleaned,"our pipeline (invisible/SDXL, native-res default)",stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-25T20:50:38Z,"cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU." -05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,05b836ec-openai_1_original.png,pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2) -794e023ea7ae321267fe5af76f4080c98a84a9865669c0733ebfb9757b8638df,794e023e-openai_2_original.png,pos,ChatGPT,gpt-image,1024,1536,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2) -28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,28ff8732-openai_3_original.png,pos,ChatGPT,gpt-image,1448,1086,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2) -4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,4affd7f2-gemini_1_original.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) -8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,8c1a6fb0-gemini_2_original.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) -45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,45d79a68-gemini_3_original.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) -2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,2c33e75a-gemini_4_original.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2) -2e4ce41cfab456c1d9ea0898e47a5a1d434266ba24e88d4cc807a4180a56925f,2e4ce41c-openai_1_clean_s005.png,cleaned,"our pipeline (SDXL img2img, native)",stabilityai/stable-diffusion-xl-base-1.0,1122,1402,png,,,openai-verify,2026-06-04T00:08:05Z,cleaned at strength 0.05 native; openai.com/verify: SynthID NOT detected (docs/synthid.md 2.2) -f7c52cdfeb14a6be2fff449e89b0181e66e365f36635ee4fcb21567e4cb770ef,f7c52cdf-openai_3_clean_s005.png,cleaned,"our pipeline (SDXL img2img, native)",stabilityai/stable-diffusion-xl-base-1.0,1448,1086,png,,,openai-verify,2026-06-04T00:08:05Z,cleaned at strength 0.05 native; openai.com/verify: SynthID NOT detected (docs/synthid.md 2.2) -37b34274210888702e56eb74f4aa36578f15bf57157a3ebf394f0b8eaa820e19,37b34274-openai_2_clean_s010.png,cleaned,"our pipeline (SDXL img2img, native)",stabilityai/stable-diffusion-xl-base-1.0,1024,1536,png,,,openai-verify,2026-06-04T00:08:05Z,cleaned at strength 0.10 native (min captured for this subject); openai.com/verify: SynthID NOT detected -f99bd9a51814265a23de467d14792db903fef99678b7e7c960d0c6813ed9b0fc,f99bd9a5-gemini_1_clean_s015_max1536.png,cleaned,"our pipeline (SDXL img2img, --max-resolution 1536)",stabilityai/stable-diffusion-xl-base-1.0,2816,1536,png,,,gemini-app,2026-06-04T00:08:05Z,cleaned at strength 0.15 --max-resolution 1536; Gemini-app: SynthID NOT detected (docs/synthid.md 2.2) -4aa5f61c55c1f3fa9bbc49dffff8a404527722637ae694a932245629635b3f2b,4aa5f61c-gemini_2_clean_s015_max1536.png,cleaned,"our pipeline (SDXL img2img, --max-resolution 1536)",stabilityai/stable-diffusion-xl-base-1.0,2816,1536,png,,,gemini-app,2026-06-04T00:08:05Z,cleaned at strength 0.15 --max-resolution 1536; Gemini-app: SynthID NOT detected (docs/synthid.md 2.2) -356196dd63abf011b30b582a0408ccecb726d746065af20ad3611dde72a88725,356196dd-gemini_3_clean_s015_max1536.png,cleaned,"our pipeline (SDXL img2img, --max-resolution 1536)",stabilityai/stable-diffusion-xl-base-1.0,2816,1536,png,,,gemini-app,2026-06-04T00:08:05Z,cleaned at strength 0.15 --max-resolution 1536; Gemini-app: SynthID NOT detected (docs/synthid.md 2.2) -9e4160bb8e3e915d2d4593e37c71495ee7cfcec183602541166f622ebfd84403,9e4160bb-gemini_4_clean_s015_max1536.png,cleaned,"our pipeline (SDXL img2img, --max-resolution 1536)",stabilityai/stable-diffusion-xl-base-1.0,2816,1536,png,,,gemini-app,2026-06-04T00:08:05Z,cleaned at strength 0.15 --max-resolution 1536; Gemini-app: SynthID NOT detected (docs/synthid.md 2.2) -228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,"228e5441-ChatGPT Image May 31, 2026, 02_02_23 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,c2pa-metadata,2026-07-25T20:21:33Z,Reusable full-pipeline quality fixture; original filename preserved in quality-set manifest; cleared for public test reuse diff --git a/data/synthid_corpus/quality_sets/full_pipeline_quality_2026-07-25.csv b/data/synthid_corpus/quality_sets/full_pipeline_quality_2026-07-25.csv deleted file mode 100644 index bcfe1e1..0000000 --- a/data/synthid_corpus/quality_sets/full_pipeline_quality_2026-07-25.csv +++ /dev/null @@ -1,8 +0,0 @@ -provider,source_filename,corpus_path,sha256,original_signal_verified_via,final_clean_oracle -openai,"ChatGPT Image May 30, 2026, 10_31_08 AM.png","images/pos/28ff8732-openai_3_original.png",28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,openai-verify,openai-verify-negative-2026-07-25 -openai,"ChatGPT Image May 31, 2026, 02_02_23 PM.png","images/pos/228e5441-ChatGPT Image May 31, 2026, 02_02_23 PM.png",228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,c2pa-metadata,not-checked-in-final-candidate -openai,"ChatGPT Image May 31, 2026, 02_03_55 PM.png","images/pos/05b836ec-openai_1_original.png",05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,openai-verify,openai-verify-negative-2026-07-25 -gemini,"Gemini_Generated_Image_3mc4t93mc4t93mc4.png","images/pos/2c33e75a-gemini_4_original.png",2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,gemini-app,gemini-app-negative-2026-07-25 -gemini,"Gemini_Generated_Image_633uuy633uuy633u.png","images/pos/4affd7f2-gemini_1_original.png",4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,gemini-app,gemini-app-negative-2026-07-25 -gemini,"Gemini_Generated_Image_akdbeiakdbeiakdb.png","images/pos/8c1a6fb0-gemini_2_original.png",8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,gemini-app,gemini-app-negative-2026-07-25 -gemini,"Gemini_Generated_Image_y48j3cy48j3cy48j.png","images/pos/45d79a68-gemini_3_original.png",45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,gemini-app,gemini-app-negative-2026-07-25 diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..da76b8e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,248 @@ +# CLI guide + +The command line interface is organized around the type of work you want to do. + +```text +remove-ai-watermarks [OPTIONS] COMMAND [ARGS] +``` + +Run `remove-ai-watermarks COMMAND --help` for the complete option list and +defaults. This page focuses on choosing the right command. + +## Inspect an image + +```bash +remove-ai-watermarks identify image.png +``` + +`identify` combines supported metadata and pixel signals into one provenance +report. When no signal is found, it reports the origin as unknown. It does not +claim the image is clean. + +Machine readable output: + +```bash +remove-ai-watermarks identify image.png --json +``` + +Metadata only inspection: + +```bash +remove-ai-watermarks identify image.png --no-visible +``` + +Despite the historical option name, `--no-visible` skips both visible and open +invisible pixel detectors. Metadata inspection still runs. + +## Remove known visible marks + +```bash +remove-ai-watermarks visible image.png -o clean.png +``` + +The default behavior: + +- checks every registered visible mark; +- removes every detected match; +- selects the best installed fill backend; +- strips AI metadata from the output. + +Use a specific mark: + +```bash +remove-ai-watermarks visible image.png --mark gemini -o clean.png +``` + +Available mark names are printed by: + +```bash +remove-ai-watermarks visible --help +``` + +Keep metadata: + +```bash +remove-ai-watermarks visible image.png --keep-metadata -o clean.png +``` + +Use the strict visual gate without metadata or sibling corroboration: + +```bash +remove-ai-watermarks visible image.png --sensitivity strict -o clean.png +``` + +When no known mark is detected, the command does not write a new output. Use +`erase` if you can identify the affected region yourself. + +## Erase a region + +```bash +remove-ai-watermarks erase image.png \ + --region 1640,1930,400,100 \ + -o clean.png +``` + +The region format is `x,y,width,height`. Repeat `--region` to erase more than +one box: + +```bash +remove-ai-watermarks erase image.png \ + --region 20,20,180,60 \ + --region 1640,1930,400,100 \ + -o clean.png +``` + +Choose the fill backend: + +```bash +remove-ai-watermarks erase image.png \ + --region 1640,1930,400,100 \ + --backend migan \ + -o clean.png +``` + +`erase` accepts `cv2`, `migan`, and `lama`. The corresponding optional extra +must be installed for a learned backend. + +## Strip AI metadata + +Inspect metadata: + +```bash +remove-ai-watermarks metadata image.png --check +``` + +Remove AI metadata and write a new file: + +```bash +remove-ai-watermarks metadata image.png --remove -o clean.png +``` + +When `-o` is omitted, removal overwrites the source. Standard metadata is kept +unless you pass `--remove-all`. + +The command also supports the audio and video containers listed in +[supported signals](supported-signals.md). ffmpeg must be available for the +non-ISOBMFF audio and video path. + +## Remove invisible watermarks + +Install the diffusion dependencies first: + +```bash +uv tool install --force "remove-ai-watermarks[gpu]" +``` + +Then run: + +```bash +remove-ai-watermarks invisible image.png -o clean.png +``` + +The command normally skips regeneration when no supported local signal is +detected. Use `--force` when you know the image should be processed: + +```bash +remove-ai-watermarks invisible image.png -o clean.png --force +``` + +### Choose a pipeline + +| Pipeline | When to use it | +| --- | --- | +| `controlnet` | Default compatibility profile with structural conditioning | +| `sdxl` | Lighter plain SDXL regeneration | +| `qwen` | Large CUDA oriented Qwen Image profile | +| `qwen-zimage` | CUDA only high fidelity profile with a separate face stage | + +Example: + +```bash +remove-ai-watermarks invisible image.png -o clean.png \ + --pipeline qwen-zimage --force +``` + +The legacy `default` value is an alias for `sdxl`. The `--auto` option is +deprecated, emits a warning, and changes nothing. + +### Work with limited memory + +Lower CUDA memory pressure: + +```bash +remove-ai-watermarks invisible image.png -o clean.png \ + --cpu-offload --force +``` + +Keep large images at native resolution while processing them in overlapping +tiles: + +```bash +remove-ai-watermarks invisible image.png -o clean.png \ + --tile --max-resolution 0 --force +``` + +Or set a resolution cap: + +```bash +remove-ai-watermarks invisible image.png -o clean.png \ + --max-resolution 2048 --force +``` + +Tiling avoids the explicit downscale but each tile is regenerated separately. +It is a memory strategy, not a guarantee of better quality. + +## Run the full pipeline + +```bash +remove-ai-watermarks all image.png -o clean.png +``` + +The command runs: + +1. visible mark removal; +2. invisible watermark removal when available and applicable; +3. AI metadata stripping. + +The visible options and diffusion options are also available on `all`. + +If diffusion is required but the `gpu` extra is unavailable, `all` still +writes the result of the visible and metadata stages, prints a prominent +warning, and exits with code 1. This prevents a partial result from being +reported as complete. + +## Process a directory + +```bash +remove-ai-watermarks batch ./images --mode visible +``` + +Modes: + +- `visible`; +- `invisible`; +- `metadata`; +- `all`. + +Set an output directory: + +```bash +remove-ai-watermarks batch ./images \ + --mode all \ + --output-dir ./clean +``` + +The invisible and full modes accept the same main diffusion controls as their +single image counterparts. Run `batch --help` for the authoritative option +list. + +## Exit behavior + +The CLI uses nonzero exit codes for meaningful incomplete outcomes, including +no detected target on commands that would otherwise regenerate or create a +misleading unchanged result, processing errors, and a required invisible step +that could not run. + +Scripts should check the process exit code and the output path. The detailed +per-command contract is maintained in +[module internals](module-internals.md#command-line-interface). diff --git a/docs/controlnet-removal-pipeline-research.md b/docs/controlnet-removal-pipeline-research.md index 07d1651..65db754 100644 --- a/docs/controlnet-removal-pipeline-research.md +++ b/docs/controlnet-removal-pipeline-research.md @@ -1,5 +1,10 @@ # ControlNet-as-removal-pipeline research: can structure-conditioned regeneration scrub SynthID and keep text? +> Research archive. This document records experiments, superseded defaults, and +> deployment considerations from the time of the study. It does not define the +> current CLI or Python API. See `README.md`, `docs/cli.md`, and +> `docs/known-limitations.md` for current behavior. + Date: 2026-06-02. Source: a manual primary-source pass (WebSearch + WebFetch over the watermark-removal-attack and SDXL-ControlNet literature). Prompted by issue #35 (@newideas99 / Jacob): "as we use SDXL even at low strength that kills small text ... Do you @@ -109,11 +114,11 @@ Gemini app; the two payloads are vendor-specific and never cross-checked): - **OpenAI 0.20 transfers to prod as-is** (OpenAI removal is resolution-independent: the study clears it at 0.05 across 1024-1600). - **Gemini 0.30 is the floor at <= 1536 only.** Gemini is resolution-sensitive (study: - native 2816 likely needs >= 0.30 even on `default`), and **raiw.cc runs NATIVE** - (`max_resolution=0` in `modal_app.py`). So either CAP Gemini to <= 1536 in raiw.cc and + native 2816 likely needs >= 0.30 even on `default`). A native-resolution + deployment should either cap Gemini to <= 1536 and use 0.30, or run a native-resolution Gemini cert and expect a higher floor (~0.35+). -### Recommendations for a removal pipeline (raiw.cc) +### Recommendations for a removal pipeline - **Treat controlnet as PRESERVATION, not removal.** Choose it for text/structure content, `default` for photoreal; removal efficacy comes from STRENGTH in both. @@ -205,7 +210,9 @@ on each tile. This mirrors the `_run_region_hires` insight (text needs MORE pixe regeneration so strokes exceed the VAE's ~8 px latent floor), but ctrlregen runs the regeneration at LOW res, the opposite. CtrlRegen's paper gives no resolution/tiling spec to contradict this. -**Sources.** internal (`src/remove_ai_watermarks/noai/ctrlregen/engine.py`); resolution-omission +**Sources.** the former internal +`src/remove_ai_watermarks/noai/ctrlregen/engine.py` (removed after this study); +resolution-omission confirmed against https://arxiv.org/html/2410.05470v1 ### Finding 5 — confidence: high @@ -287,7 +294,7 @@ ControlNet + activations in **fp32** (MPS fp16 decodes to all-black NaN — issu on run 1 below; fp32 is the required default on mps/cpu) — fits the 32 GB budget with vae-tiling + attention-slicing; ~1-2 min/image, so a coarse sweep is a sub-hour background run. A dedicated GPU is needed ONLY for the separate -native-large-Gemini (2816 px) case, which OOMs even without a ControlNet (that stays a raiw.cc +native-large-Gemini (2816 px) case, which OOMs even without a ControlNet (that requires a GPU task). The genuine external dependency is NOT compute but the **manual SynthID oracle**: there is no local SynthID detector, so removal is verified by hand in the Gemini app ("Verify with SynthID") per image, regardless of where the diffusion runs. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..9128ba7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,38 @@ +# Documentation + +This documentation is split by purpose. Start with the user guides if you want +to run the tool. Use the maintainer references only when changing the code. + +## User guides + +| Page | Use it when | +| --- | --- | +| [Installation](installation.md) | You need the CLI, an optional model backend, or a development environment. | +| [CLI guide](cli.md) | You want a command for one image, a directory, or a specific watermark type. | +| [Python API](python-api.md) | You want to call the package from Python. | +| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. | +| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. | +| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. | + +## Maintainer references + +| Page | Purpose | +| --- | --- | +| [Module internals](module-internals.md) | Current architecture, invariants, and regression guards by module. | +| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. | +| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, and release workflow. | +| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. | +| [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. | + +## Research archive + +These pages record experiments and the evidence behind past decisions. They are +not command references and may describe prototypes that were later removed. +The current behavior is defined by the code, tests, README, and user guides. + +- [ControlNet removal research](controlnet-removal-pipeline-research.md) +- [Qwen improvement research](qwen-improvement-research.md) +- [Doubao reverse-alpha research](research-doubao-distillation.md) +- [SynthID identity research](synthid-robust-identity-research.md) +- [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md) +- [Text protection research](text-protection-research.md) diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..4f7d2b2 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,136 @@ +# Installation + +Python 3.10.1 or newer is required. + +## Core install + +The core package provides: + +- provenance inspection; +- visible watermark removal with OpenCV; +- manual region erasing with OpenCV; +- AI metadata inspection and removal. + +Install it as an isolated command with uv: + +```bash +uv tool install remove-ai-watermarks +``` + +Or with pipx: + +```bash +pipx install remove-ai-watermarks +``` + +You can also install the Homebrew package on macOS or Linux: + +```bash +brew install wiltodelta/tap/remove-ai-watermarks +``` + +## Invisible watermark removal + +Diffusion based removal needs the `gpu` extra: + +```bash +uv tool install --force "remove-ai-watermarks[gpu]" +``` + +The code supports CUDA, XPU, MPS, and CPU devices. A GPU is recommended because +CPU inference is slow. + +For the CUDA only Qwen Image plus Z-Image profile: + +```bash +uv tool install --force "remove-ai-watermarks[qwen-zimage]" +``` + +The `qwen-zimage` extra includes the normal `gpu` dependencies. + +## Optional features + +Install only what you need: + +| Extra | Adds | +| --- | --- | +| `migan` | MI-GAN ONNX fill backend | +| `lama` | big-LaMa ONNX fill backend | +| `detect` | Open DWT-DCT watermark decoder used by `identify` | +| `trustmark` | Adobe TrustMark decoder | +| `esrgan` | Real-ESRGAN upscaling before diffusion | +| `qwen-zimage` | CUDA only Qwen Image plus Z-Image pipeline | + +Example: + +```bash +uv tool install --force "remove-ai-watermarks[migan,detect]" +``` + +Some optional models download their weights on first use. + +## Install from the repository + +```bash +git clone https://github.com/wiltodelta/remove-ai-watermarks.git +cd remove-ai-watermarks +uv sync --frozen +``` + +Add the feature groups required for your work: + +```bash +uv sync --frozen --extra dev +uv sync --frozen --extra dev --extra gpu +``` + +Run commands from the repository root: + +```bash +uv run remove-ai-watermarks --help +``` + +## Development setup + +Install development dependencies: + +```bash +uv sync --frozen --extra dev +``` + +Run the complete project gate: + +```bash +bash maintain.sh +``` + +The script runs dependency checks, linting, formatting checks, type checking, +and the test suite. + +## Hugging Face authentication + +Pass a Hugging Face token directly when the selected model or account requires +one: + +```bash +remove-ai-watermarks invisible image.png --hf-token "$HF_TOKEN" +``` + +The CLI also loads `HF_TOKEN` from the environment and from a local `.env` +file. The same name is documented in `.env.example`. + +## Troubleshooting + +### The first model run is slow + +Diffusion and learned fill backends may download model weights on first use. +Later runs reuse their caches. + +### The command skips invisible removal + +The normal behavior is to skip diffusion when no supported local signal is +found. A missing signal does not prove that the image is clean. If you know the +image came from a relevant generator, use `--force`. + +If the CLI reports that diffusion dependencies are unavailable, install the +`gpu` extra. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 7a9b502..94ad4fc 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -1,212 +1,213 @@ -# Known limitations: full detail +# Known limitations -> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded -> context small. Long single-line entries were reformatted into paragraphs; -> no content was changed or summarized. +This page describes current product limits. Historical measurements and +superseded experiments live in the research archive listed in +[the documentation index](index.md). -Full detail behind the compact Known-limitations list in `CLAUDE.md`: -measurements, incident history, oracle runs, and the reasoning behind each -decision. Read the relevant section here before changing the diffusion -pipelines, strength defaults, or metadata coverage. +## Visible removal -## Visible-mark fill quality is background- and backend-dependent +### Fill quality depends on the background -Visible-mark removal is localize -> fill (the reverse-alpha pixel recovery was -dropped; see `docs/module-internals.md`). The fill only touches the mark's -footprint, so there is never collateral damage outside it, and whether the mark -is *removed* is fill-independent -- cv2, MI-GAN and LaMa all strip the mark's -shape. What varies is the *quality of the recovered region*, and it depends on -the background: +Visible removal changes only the selected mask, but the hidden pixels still +have to be reconstructed. -- **Flat backgrounds:** every backend is clean; cv2 is often the crispest. -- **Textured / regular-structured backgrounds** (fabric, foliage, a lattice or - grid): an inpaint can only guess the hidden pixels. `cv2` (the classical - no-deps floor) visibly smears; `migan` (light, learned) can leave a ghost or - hallucinate structure; `lama` (heavy, learned) is the most reliable and - recovers structure best. +- OpenCV is fast and dependency free. It works well on flat backgrounds but + can smear texture or repeated structure. +- MI-GAN is a lighter learned backend. It can improve natural texture but may + ghost or invent structure. +- LaMa is the heaviest learned backend and is generally the strongest option + for difficult backgrounds. -The old reverse-alpha recovered the *true* pixels under a well-captured, static -mark, so on structured backgrounds it was sometimes cleaner than any inpaint. -The trade for localize -> fill is robustness (it also handles moved / re-rendered -marks and needs no per-mark alpha capture) and a simpler, swappable pipeline. -`auto` resolves best-first (`LaMa > MI-GAN > cv2`) and warns once when it falls -back to cv2 because no learned backend is installed; a memory-tight deployment -that cannot afford LaMa's ~4.7 GB peak pins `--backend migan` explicitly. +`--backend auto` selects LaMa when available, then MI-GAN, then OpenCV. -## Invisible-pipeline resolution handling (native / 1024 floor / `--max-resolution`; MPS memory tiers) +No backend can recover detail that is completely hidden by an opaque mark. A +successful detection therefore does not guarantee a visually perfect fill. -`invisible` pipeline processes at **native resolution for inputs whose long side is >= 1024px**, and **auto-upscales smaller inputs UP to a 1024px floor** (`min_resolution=1024`, the default; `--min-resolution 0` disables) before diffusion -- SDXL img2img distorts badly on a tiny latent (a 381x512 portrait wrecks at native, the #36 follow-up), and the output is restored to the original input size so the floor is a transparent quality boost (it adds time/memory on small inputs). The floor upscale uses Lanczos by default; **`--upscaler esrgan`** (opt-in, the `esrgan` extra) runs Real-ESRGAN first for better detail before the Lanczos resize to the exact target (`upscaler.py` / `InvisibleEngine._esrgan_upscale`, falls back to Lanczos if the extra is absent). `max_resolution=0` (default) means no downscale cap, matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip for LARGE images was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need a downscale. +### Automatic detection covers registered variants only -**CUDA model CPU offload (`--cpu-offload`):** Diffusers normally places the complete SDXL, ControlNet, or base Qwen pipeline on the selected CUDA device. The opt-in offload mode instead uses Diffusers model-level CPU offload, keeping one model component on the GPU at a time and moving it back to CPU before the next component runs. This reduces peak VRAM use but adds transfer overhead. The custom `qwen-zimage` runtime already manages its global model placement; the same flag forces its face stack to stay on the offload path instead of becoming resident on a high-memory GPU. CPU and MPS behavior is unchanged. +The registry contains vendor and locale specific templates. A redesigned mark, +an unsupported locale, a different position, or a crop may be missed. -**Final `--unsharp` post-filter (`humanizer.unsharp_mask`, opt-in, default 0):** applied LAST (after the face-restore pass, else it would be smoothed over) to counter the soft/over-smoothed look diffusion + restoration leave (an AI tell); ~0.5-0.8 safe, higher risks halos. Pairs with `--humanize` (grain adds sensor-noise texture, unsharp adds crispness). `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, **`--tile` is the lossless alternative** -- see the tiled-diffusion subsection below. +Known examples: -### Tiled diffusion for large inputs (`--tile`, issue #10) +- Samsung detection is calibrated for the Italian + `Contenuti generati dall'AI` text variant. +- The Jimeng top-left pill has a weak visual detector and is intentionally + subject to additional product and background checks. +- Kling support covers the calibrated variants rather than every Kling label. -`--tile` (OFF by default; `--tile-size` default 1024, `--tile-overlap` default 128) processes the diffusion pass in overlapping sliding-window tiles instead of one forward pass, so a large image is regenerated at **native resolution** without the OOM and without the lossy `--max-resolution` downscale round-trip. It engages only when the long side exceeds `--tile-size`; a sub-tile image runs a single pass unchanged. The SDXL, ControlNet, and base Qwen paths refactor the single-image `_generate` into a per-tile `_generate_one` (the ControlNet canny edge map is rebuilt per tile, so structure preservation works tile-local) and route it through `noai.tiling.run_tiled` when tiling is active. `qwen-zimage` instead tiles only its global Qwen pass, feather-blends that result, and then runs YuNet, SAM, and Z-Image once against the full original/result pair. The geometry and blend math are pure helpers, unit-tested without the model (`tests/test_tiling.py`): +Use `erase --region` when you can see and select an unsupported or missed mark. -- `plan_tiles(w, h, tile_size, overlap)` lays out a row-major grid where every tile is exactly `tile_size` (the last tile on each axis is pulled back flush to the far edge, simply overlapping its predecessor more). Uniform tile size keeps each diffusion pass at SDXL's preferred dimension. -- `feather_weights(w, h, overlap)` is a separable linear taper, ~1 in the interior and ramping toward each edge, kept **strictly positive** so the normalized accumulate-and-divide blend (`accum / weight_sum`) is a partition of unity: a region covered by one feathered edge (an image corner) still divides cleanly. Identical (unchanged) tiles therefore reconstruct the input exactly -- the seam-free guarantee, asserted in `test_identity_generate_reconstructs_image`. +### Strict and automatic sensitivity trade recall for precision -CAVEAT: each tile is an **independent** low-strength regeneration. At the current SDXL/ControlNet defaults (0.10-0.15) the per-tile drift is small and the feather blend hides the seams, but tiling is a memory workaround, not a quality upgrade over a single native pass -- a 32 GB MPS box that clears the native UNet peak should prefer no tiling. The MPS->CPU fallback still applies per tile; if the first tile falls back to CPU, the device stays CPU for the rest of the image. +`--sensitivity strict` uses the visual gate alone. The default `auto` mode may +relax a mark only when metadata or a confidently detected sibling mark +corroborates the same product. -For `qwen-zimage`, the global denoise is still computed from the full-frame megapixel count and the same resolved seed is reused for every tile. The profile defaults to seed 0, matching the release-candidate oracle run; an explicit seed overrides it. Running the face stage only after blending avoids duplicate regeneration and boundary-local face misses. A real H100 smoke on 2026-07-25 exercised the shipped branch on a 4096x3072 input (20 tiles at 1024 with 128 px overlap, seed 0, strength 0.154): it completed in 653.367 seconds after 43.741 seconds of setup, preserved the exact dimensions, and peaked at 22.732 GiB allocated / 23.861 GiB reserved CUDA memory. Visual inspection found no tile seams. The worst tile-boundary gradient-change line was at the 98.563 percentile of all image lines (2.522 standard deviations), below the preselected 99th-percentile outlier threshold; overview fidelity was MAE 3.332%, PSNR 26.564 dB, and global SSIM 0.988627. This no-face input validates the global tiled execution and blend, not the post-blend face path. The July 25 seed-0 oracle result still certifies exact non-tiled candidate bytes only; tiled SynthID efficacy requires a separate provider-oracle check. +There is no blanket "this image is AI" relaxation. That information does not +identify the vendor, mark, or location and caused unacceptable false +detections in the removed experimental mode. -**Concrete MPS data points (the OOM is memory-tier-dependent, NOT a hard MPS limit):** on a ~24 GB unified-memory machine (verified 2026-05-25, 1254x1254 gpt-image SDXL, fp32) native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. On a **32 GB** unified-memory machine the same default SDXL pass runs entirely on MPS with **no CPU fallback** (verified 2026-05-31, 1122x1402 gpt-image, `all`/default, ~155 s end-to-end), so 32 GB clears the native-res UNet peak that 24 GB could not. Adding `enable_vae_tiling()` alone does NOT prevent the 24 GB OOM (the peak is the UNet, not the VAE). The fast Mac workarounds for memory-constrained machines are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The `controlnet` pipeline adds the canny ControlNet weights on top of SDXL, so its peak is a bit higher than the plain `default` pass; the same MPS->CPU fallback covers an OOM. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution, min_resolution)` (returns `None` for native, a target tuple for a downscale cap OR an upscale floor; cap takes precedence, the floor is skipped on a min>max misconfig) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15/#36 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it. +## Invisible removal -## fp16 VAE black-output fix (issue #29) + degenerate-output fp32 backstop (issue #41) +### Regeneration is lossy -**fp16 VAE black-output fix (issue #29, 2026-05-30):** on a **CUDA/XPU fp16** backend the stock SDXL VAE overflows to NaN and the *plain* img2img path decodes to an **all-black** image (reproduced on the raiw.cc result: a 1086x1448 input -> a uniformly black 4.6 KB PNG, mean 0). `watermark_remover._load_pipeline` / `_load_controlnet_pipeline` swap in the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix` = `_SDXL_FP16_VAE_ID`) when `_needs_fp16_vae_fix(model_id, DEFAULT_MODEL_ID, is_fp16)` is true -- only the default SDXL checkpoint on fp16. +Invisible removal does not decode and delete a payload. It regenerates the +image through a diffusion pipeline. Faces, text, colors, and fine detail can +change even when the watermark is successfully disrupted. -**cpu/mps run fp32** (the stock VAE is fine there, which is why the bug never reproduces on Mac). A custom non-SDXL `model_id` keeps its own VAE (the fp16-fix VAE is SDXL-architecture-specific). The decision is a pure helper, unit-tested without a download (`tests/test_platform.py::TestFp16VaeFix`); the actual black->clean recovery needs a CUDA GPU. +ControlNet is the default compatibility profile. It conditions on edges to +preserve structure, but edges do not preserve identity or exact texture. -**Confirmed on real CUDA hardware 2026-06-03:** running `all` on a 1086x1448 OpenAI gpt-image (the #29 repro size) at fp16 produced a normal (non-black) output, so the fp16-fix VAE swap resolves the all-black decode. (It was not reproducible on this MPS machine, which runs fp32, so the verification had to happen on an NVIDIA box.) +The CUDA only `qwen-zimage` profile adds a separate face stage and is the +highest fidelity option in the current implementation. It is larger, slower, +and still may alter small text or difficult faces. -**Follow-up safety net (issue #41, 2026-06-04):** the swap is gated to `model_id == DEFAULT_MODEL_ID`, so a custom model, a stale pre-fix install, or a fal/custom loader can still hit the black decode -- a new reporter did (gpt-image 1448x1086, the #29 size, with the exact `image_processor.py:142 invalid value encountered in cast` warning the NaN->0 cast emits). `remove_watermark` now adds a model-agnostic backstop: after generation, if the run was fp16 AND the output is degenerate (`_is_degenerate_image`: mean and std both below `_DEGENERATE_THRESHOLD` 1.0 -- a uniform all-black/NaN frame; the variance guard spares a legitimately dark-but-textured photo), it rebuilds the pipeline in fp32 on the SAME device and re-runs once. fp32 is the verified-clean path, so the user never gets a black image regardless of model_id/version. Mirrors the existing MPS->CPU fallback's self-mutation pattern (reset `torch_dtype` + clear `_pipeline`/`_controlnet_pipeline`); `batch` inherits it through `remove_watermark`, and once one image trips it the rest of the batch stays on the safe fp32. The detector is a pure helper, unit-tested without a model (`tests/test_platform.py::TestDegenerateOutputGuard`); the full fp16->detect->fp32-retry chain was verified e2e on this MPS machine by forcing fp16 with the swap disabled (first pass black, guard fired, retry produced a normal image). CAVEAT: the fp32 retry uses ~2x memory, so on a VRAM-constrained GPU it can OOM (a visible error, still better than a silent black frame; the MPS->CPU fallback covers that path). The reporter's "CPU also black" symptom is NOT reproducible here -- fp32 (cpu/mps) decodes clean -- so it points at an old version or a non-fp32 run, pending their version + command. +### Removal cannot be verified locally for proprietary SynthID -## rich was dropped (plain-text CLI and scripts) +The project has no public local SynthID pixel decoder. It can infer likely +presence from supported provenance metadata, but a missing metadata proxy is +not a negative pixel verdict. -**rich was dropped (CLI + scripts print plain text via `click.echo`).** +For important outputs: -`cli.py` renders through small `_Console`/`_Table`/`_Progress` shims; the analysis scripts (`scripts/synthid_corpus.py`, `synthid_pixel_probe.py`, `text_detection_benchmark.py`, `corpus_gap_scan.py`) import `Console`/`Table` from the shared `scripts/_plain_console.py` shim (markup like `[bold]`/`[/]` is stripped, tables render aligned). Consequences: (1) `rich` is NOT a dependency, so anything that imports it breaks a clean `uv sync --frozen` (CI installs core+dev only) — this exact gap red-failed CI after the refactor when those 4 scripts still imported rich; if you add a script, use the `_plain_console` shim, not rich. (2) The old `[gpu]`-bracket-eaten bug (#19) is gone — plain `click.echo` prints `pip install 'remove-ai-watermarks[gpu]'` verbatim, no escaping needed (regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`). (3) No Unicode glyphs / colors / progress bars in CLI output by design. +1. preserve the original; +2. process a copy; +3. verify with the matching provider tool when available; +4. do not assume one provider's verifier covers another provider's payload. -## AVIF/HEIF/JPEG-XL metadata, ISOBMFF/ffmpeg removal, audio watermark detection +Provider systems can change, so a result verified on one file, seed, or version +is not a permanent certification. -Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. +### Strength is content and seed dependent -**Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). +For SDXL and ControlNet, the CLI resolves an unset strength from the detected +vendor: -**Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). +- OpenAI: `0.10`; +- Google: `0.15`; +- unknown: `0.15`. -**`Exif` item inside the `meta` box (AVIF/HEIF), now handled in place (2026-06-19):** an AI-generator token in an EXIF item (its TIFF bytes live in `mdat`/`idat`) is blanked by `isobmff.blank_ai_exif_tokens` — it finds EXIF TIFF blocks by their II/MM byte-order header, validates each with **piexif** (a coincidental II/MM run in pixel data won't parse as a TIFF IFD, so it is ignored), and overwrites any `Software`/`Make`/`Artist`/`ImageDescription` value carrying an `AI_GENERATOR_TOKENS` token with spaces of the **same length**. Same-length means every box size and `iloc` offset stays valid and the coded image is untouched — so it avoids the full `iinf`/`iloc` surgery (offset rewrite) that exiftool would need (exiftool is a non-installed binary dep, deliberately not used). It scrubs only the AI value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Because the ISOBMFF branch never runs the JPEG `_scrub_ai_exif`, this is the ONLY EXIF scrubber on that path and must stay in PARITY with it: it now also blanks the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value` — Doubao producer + Tencent service-provider schemas) and the xAI/Grok `Signature:` + UUID-`Artist` pair, not just `AI_GENERATOR_TOKENS` in `Software`/`Make`/`Artist`/`ImageDescription` (regression `test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`). **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists). +An explicit `--strength` overrides these defaults. The defaults are operating +points, not universal guarantees. Near a removal threshold, different content +or a different random seed may change the verifier result. -**Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.) +The base Qwen and `qwen-zimage` profiles have profile specific strength +behavior. Consult `remove-ai-watermarks invisible --help` and the source of +[`watermark_profiles.py`](../src/remove_ai_watermarks/noai/watermark_profiles.py) +for the current resolver. -## SynthID detection is metadata-only (no local pixel detector) +### Pipelines have different quality tradeoffs -**SynthID detection is metadata-only.** +| Pipeline | Main limit | +| --- | --- | +| `controlnet` | Edge conditioning can preserve a watermark carrying region too closely, and faces may drift. | +| `sdxl` | Flat graphics and precise structure may receive too little or unhelpful change. | +| `qwen` | Large CUDA oriented model; face smoothing can still be significant. | +| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. | -There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. +The legacy `default` profile name maps to `sdxl`. The `--auto` flag is +deprecated, emits a warning, and changes nothing. -**Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). +## Resolution and memory -**Each vendor's oracle detects only its OWN content (verified on the page 2026-05-31):** `openai.com/research/verify` states verbatim "OpenAI generation signals will only be detected if the image was generated with our tools" and "Content could also still be AI-generated by another company's model, which the tool currently does not detect" -- SynthID is shared tech but the verifier is keyed to its own vendor's payload, so a Google-SynthID image reads clean on OpenAI's verifier and vice-versa. +### Small images are enlarged before SDXL based diffusion -**This explains the recurring "oracle says clean but `identify` still flags SynthID" report (#14):** the oracle reads the *pixel* watermark (gone after our SDXL pass), while `identify` reads the *C2PA-metadata proxy* (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (`metadata --remove` / `all`) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone. +The SDXL, ControlNet, and base Qwen paths use a default minimum long side of +`1024`. Smaller inputs are enlarged before diffusion and restored to their +original dimensions afterward. Set `--min-resolution 0` to disable the floor. -**Consequence for the P0#5 no-signal skip (`has_invisible_target`, 2026-06-22):** `invisible`/`all`/`batch` skip the diffusion scrub by default when no invisible AI signal is *locally* detectable, to avoid degrading a clean image (`--force` overrides). Because SynthID detection is metadata-only, a real AI image whose C2PA was **already stripped** (e.g. a re-encoded download, or the API/playground surfaces above that never emit C2PA) reads as no-signal and is therefore **skipped** — leaving its pixel SynthID in place. This is the deliberate trade: the skip's message never claims the image is clean, and the user re-runs with `--force` when they know it is AI. The blind spot is the same metadata-only ceiling, not a new bug; the visible-sparkle path (`check_visible`) still catches the no-C2PA Gemini-playground case for the *visible* mark, but not the invisible one. +`qwen-zimage` does not apply this SDXL minimum resolution floor. -**SynthID is durable to JPEG re-encode by design, so a GitHub-recompressed issue attachment is still a valid SynthID test subject** (verified 2026-06-01 on issue #14's pic3: the GitHub-served JPEG survived re-encoding and openai.com/verify still detected SynthID). Do NOT dismiss issue-attachment JPEGs as "not faithful originals" when reproducing a SynthID-survival report: the recompression strips the **C2PA metadata** (so `identify` reads Unknown on the attachment) but NOT the **pixel watermark** that openai.com/verify reads. A true byte-original only matters for the metadata/C2PA path, not for the pixel-SynthID-removal test. (Contrast the open imwatermark above, which IS fragile to JPEG.) The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. +### Large images stay at native resolution unless capped -**Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) +`--max-resolution 0` means no explicit downscale cap. A positive value caps the +long side before diffusion and restores the result afterward. This reduces +memory use but introduces a downscale and upscale round trip. -**But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. +`--tile` preserves the input dimensions while running the diffusion stage in +overlapping tiles. It avoids the explicit downscale, but it is not pixel +lossless: each tile is independently regenerated. With `qwen-zimage`, only the +global Qwen stage is tiled; the face stage runs after tile blending. -## External AI-vs-real classifier models are out of scope +### CPU offload is CUDA only -**External AI-vs-real classifier models are out of scope (decided 2026-05-24).** +`--cpu-offload` moves Diffusers model components between CPU and CUDA instead of +keeping the complete standard pipeline in GPU memory. For `qwen-zimage`, it +forces the face stack to use its offload path. -Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency. +The option reduces CUDA memory pressure at the cost of speed. It has no effect +on CPU or MPS and fails loudly when a CUDA Diffusers pipeline does not expose +the required offload method. -## Default strength is vendor-adaptive, one ladder for both pipelines +### MPS may fall back to CPU -**DEFAULT STRENGTH IS VENDOR-ADAPTIVE, ONE LADDER FOR BOTH PIPELINES (LOWERED 2026-06-14; raised + unified 2026-06-09; vendor-adaptive since 2026-06-01, SUPERSEDES every fixed-default claim in this bullet and the next).** +The SDXL paths include an MPS out-of-memory fallback that reloads on CPU. A run +that appears much slower after an MPS failure may be continuing on CPU. -`resolve_strength(strength, vendor)` + `vendor_for_strength(path)` (`watermark_profiles.py`) read the C2PA issuer (`metadata.synthid_source`) on the ORIGINAL input and pick `OPENAI_STRENGTH` **0.10** / `GEMINI_STRENGTH` **0.15** / `UNKNOWN_STRENGTH` **0.15** when `--strength` is unset; explicit `--strength` always wins. +Memory needs depend on the pipeline, input size, dtype, and machine. Use tiling, +a resolution cap, or a lighter pipeline when necessary. -**The SAME ladder applies to BOTH pipelines** (`sdxl` and `controlnet`). **2026-06-14: lowered from the 2026-06-04 cert floors (OpenAI 0.20 / Google 0.30) back toward the original 2026-06-01 study (OpenAI ~0.05-0.10 / Google 0.15).** A re-test on the deployed Modal controlnet worker cleared SynthID on the oracle at OpenAI 0.10 (2 photoreal, 1402/1448 px) and Google 0.15 (2 NATIVE 2816x1536 images -- retiring the "native ~2816 likely needs >=0.30" guess), while a pixel sweep showed 0.20/0.30 over-regenerated for no efficacy gain (Google MAE -20% at 0.15). See `watermark_profiles.py` "Data basis". **CAVEATS that stand:** (1) removal near this floor is SEED-NON-DETERMINISTIC (the 2026-06-09 finding below) -- a SERVICE on this ladder must pin a fixed, oracle-verified seed, not rely on a random one; (2) the re-test is n=2 per vendor on photoreal/landscape, NOT flat graphics (the `sdxl` weak spot), so raise `--strength` if an oracle reads SynthID on a flat output. +## Metadata and formats -**Why one ladder (NOT a per-pipeline split):** the cert was run on controlnet and does NOT transfer to `sdxl` by symmetry (opposite hard cases -- controlnet leaves SynthID on photoreal, `sdxl` on flat graphics), BUT on its OWN hard case (flat fills) `sdxl` is the WEAKER remover (plain img2img barely perturbs a flat region at low strength), so it needs AT LEAST controlnet's strength -- hence the certified floor is the right floor for `sdxl` too. It is a MARGIN argument for `sdxl`, not a fresh certification (no local SynthID detector to self-verify); raise `--strength` if an oracle still reads a flat `sdxl` output. The higher strength costs little quality because `controlnet` is now the default pipeline AND the only `--auto` pick, so `sdxl` is reached only via an explicit `--pipeline sdxl` (a deliberate opt-down for inputs without faces/text), where over-regeneration has nothing to damage. (A short-lived per-pipeline split ladder -- `sdxl` 0.15/0.20 vs controlnet 0.20/0.30 -- existed on 2026-06-09 before being unified the same day; the `resolve_strength` `pipeline` param and the `CONTROLNET_*_STRENGTH` constants were removed.) The CLI detects the vendor from the pristine source (before the visible pass / metadata-strip removes C2PA from the temp file) and passes it to display calls so display and execution agree; `cmd_invisible`/`cmd_all`/`batch` thread `vendor`. +### Missing metadata does not mean clean -**This replaces the single 0.30 default AND the prior "do NOT build a vendor-adaptive default" policy** -- both came from the now-debunked region-rescrub-contaminated study (the per-region re-scrub that contaminated those numbers was removed in the controlnet refactor). Basis: the oracle-verified June 2026 controlled study (clean v0.8.6, protect OFF): OpenAI clears at 0.05 across 1024-1600 (n=4, resolution-independent); Google needs 0.15 on the capped-1536 path (n=4). `docs/synthid.md` §2.2 (data) + §5.2 (the adaptive default) are authoritative. +Screenshots, social platforms, and re-encoding can remove metadata while a +pixel watermark remains. `identify` therefore reports unknown rather than +clean when no supported signal is found. -**CAVEAT (oracle pass 2026-06-04): the OpenAI 0.10 default is content-dependent, NOT universal -- a flat-graphic OpenAI logo/poster still read SynthID-detected after `default` at 0.10, and photoreal images after controlnet at 0.10/0.15 (low-change regions under-perturbed). Removal at 0.10/0.15 is content×pipeline dependent (see the controlnet Known-limitations bullet); the lever is a higher strength, oracle-revalidated per content type. Do NOT assume the vendor-adaptive default clears every image.** +### JPEG XL is metadata only -CAVEAT: Google's 0.15 was validated only on `--max-resolution 1536`; native large Gemini (2816) was not locally measurable (OOM on M-series) and is pending GPU validation on raiw.cc -- if it survives 0.15 native, raise `--strength`. +The metadata path recognizes JPEG XL containers, but the visible and diffusion +image paths do not list `.jxl` as a supported pixel format because the package +does not include a JPEG XL pixel decoder. -**Everything below in this bullet about a fixed 0.10/0.30 default is HISTORICAL; trust the vendor-adaptive constants + docs/synthid.md.** +### HEIC, HEIF, and AVIF use a Pillow fallback -## SynthID removal: strength + oracle scope +OpenCV does not decode these formats in the project. `image_io.imread` falls +back to Pillow with `pillow-heif`. A corrupt or truncated file may still fail to +decode. -**SynthID removal: strength + oracle scope.** +### Some metadata removal requires ffmpeg -Default strength is vendor-adaptive (see the bullet above); `docs/synthid.md` §2.2 is authoritative for the numbers. +WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is stripped +through ffmpeg with stream copying. The operation fails if ffmpeg is absent or +cannot parse the input. -**Oracle scope (load-bearing):** the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (detects Google's mark on any image); `openai.com/verify` is scoped to OpenAI provenance (its own C2PA), NOT a SynthID oracle -- a negative there is meaningless for SynthID. There is no local SynthID detector, so the tool cannot self-check; if the oracle still reads SynthID, raise `--strength` to the lowest value that verifies clean. The profiles are `sdxl` (plain SDXL img2img; `default` is a back-compat alias), `controlnet` (SDXL + canny ControlNet), `qwen` (Qwen-Image img2img), and the experimental `qwen-zimage` two-stage stack. +### Metadata transformation is fail safe -**Forensic-stealth caveat** (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility -- independent detectors flag *removal-processed* images vs genuinely-clean ones at >98% TPR@1%FPR, so do not over-claim "indistinguishable from a real photo". +`remove_ai_metadata` may copy an undecodable file through unchanged instead of +raising. User facing callers must use `strip_and_verify` and inspect its +surviving marker mapping before reporting success. The CLI does this. -## `controlnet` pipeline: content x pipeline removal, certified floors, no face-restore +### Sixteen bit PNG output is not preserved -**`controlnet` pipeline (text/face STRUCTURE preservation, THE DEFAULT since 2026-06-09; `--pipeline default` opts down to plain SDXL).** +The Pillow based PNG metadata rewrite uses the normal image save path and may +reduce a sixteen bit PNG to eight bits. A byte-level PNG metadata stripper +would be required to preserve that bit depth. -SDXL + the canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` via `StableDiffusionXLControlNetImg2ImgPipeline` (`watermark_remover._run_controlnet` / `_load_controlnet_pipeline`). +## Detection extras -**Removal still comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE by conditioning on the canny edge map** (`cv2.Canny(gray, 100, 200)`, 3-channel). Canny preserves edges, NOT face identity (a regenerated face drifts in likeness). The drifted cleaned face is the LEAST-AI state we can reach without re-introducing SynthID; **the library does NOT ship a face-restore extra** (every approach evaluated 2026-06-04 - 2026-06-08 -- GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned at three parameter sweeps -- regenerated the face via SDXL and made it look MORE AI-generated). Full empirical conclusion in `docs/synthid-robust-identity-research-2026-06-08.md` "Empirical follow-up". For production face preservation, ship the cleaned image as-is. No original pixels are copied or frozen, **BUT removal at the low vendor-adaptive strength is CONTENT × PIPELINE dependent and NEITHER pipeline clears all content -- oracle-validated against the OpenAI verifier 2026-06-04 (8 images, strength 0.10/0.15, `--max-resolution 1536`).** +The `detect` extra decodes an open DWT-DCT watermark used in some Stable +Diffusion, SDXL, and FLUX workflows. That decoder is sensitive to the carrier +and transformations. A negative result is not a universal negative. -The survivors FLIP by content type: **photoreal** (a 9-face grid, a bracelet product photo) SURVIVES controlnet but CLEARS `default` (controlnet's dense edge map keeps the regen too close to the original, so the SynthID-destroying perturbation never happens; plain img2img perturbs photoreal texture enough); **flat graphic** (a logo/poster with large flat color fills) SURVIVES `default` but CLEARS controlnet (at low strength img2img barely changes flat fills so SynthID persists there, while controlnet repaints them more freely); a flat **text** card cleared under both. +The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains +an additional JPEG re-encode gate because isolated decoder hits can otherwise +be content noise. -**Root cause is insufficient STRENGTH, not the pipeline: at 0.10 the low-change regions -- dense-edge photoreal under controlnet, large flat fills under `default` -- are not perturbed enough to destroy SynthID. The vendor-adaptive 0.10 from the June study is NOT universally sufficient (that study's content happened to clear at 0.10).** +External AI versus real image classifiers are out of scope. The project +identifies concrete local provenance signals instead of shipping a generic +statistical classifier. -The robust fix is a HIGHER strength, oracle-revalidated per content type (controlnet can be cranked harder without losing structure; a lower `controlnet_conditioning_scale` also frees the regen on photoreal). So at today's default strength **both pipelines AND `--auto` can LEAVE SynthID on some content** -- a removal-priority caller (raiw.cc) MUST oracle-validate strength across content types before adopting, not pick a pipeline and assume removal. +## Output and traceability -**Follow-up same day: re-running the two photoreal survivors through controlnet at an explicit `--strength 0.15` cleared BOTH on the oracle -- BUT one of them (the bracelet) had SURVIVED the SAME 0.15 controlnet config in the first pass (only the random, unset seed differed). So removal near the threshold is SEED-NON-DETERMINISTIC: the same image+pipeline+strength+resolution can pass or fail run-to-run (img2img uses `seed=None`/random unless `--seed` is passed, and there is no local SynthID detector to self-verify). 0.15 is the borderline, NOT a robust floor -- pick a strength with MARGIN (controlnet ~>= 0.20) rather than exactly on it; the content×pipeline table's 0.15 data point is near-threshold noise. A confirming run at `--strength 0.20` controlnet cleared BOTH photoreal survivors on the oracle (ladder: 0.10 grid detected → 0.15 borderline/non-deterministic → 0.20 both clean), so **0.20 is the recommended robust controlnet floor for OpenAI photoreal** (one margin run, not an N-run repeatability proof -- a service should add margin or verify repeatability since there is no local SynthID detector to self-check). +Removing file-local signals does not remove: -**Engineering follow-up DONE 2026-06-09 (three coupled changes):** (1) **strength raised + unified** -- `resolve_strength(strength, vendor)` now applies ONE vendor-adaptive ladder (the certified controlnet floors 0.20/0.30/0.30) to BOTH pipelines; see the DEFAULT STRENGTH bullet above for why one ladder covers `sdxl`. (2) **`controlnet` is now the DEFAULT pipeline** (CLI `--pipeline` default = `controlnet` + both engine ctors). Rationale: with the certified higher ladder it clears BOTH content classes that flipped in the content-x-pipeline table (photoreal AND flat graphic), whereas plain SDXL left SynthID on flat graphics -- so controlnet is the more removal-robust default. Cost: every non-`--auto` run now downloads the canny ControlNet weights + a higher memory peak (MPS->CPU fallback covers OOM). (3) **the plain-SDXL profile was renamed `default` -> `sdxl`** (`watermark_profiles.SDXL_PROFILE`/`normalize_profile`); `default` stays as a back-compat CLI/ctor alias (the `--pipeline` Choice accepts `sdxl`/`controlnet`/`default`, a click callback `_normalize_pipeline` maps `default`->`sdxl` AND warns that `default` is deprecated). (4) **the content-detection layer + `--auto` planner were removed and `--auto` was retired to a deprecated alias for `--adaptive-polish`** -- see the dedicated `auto_config.py`-removal bullet above (controlnet is the default pipeline and the polish self-gates, so detection changed nothing). A production caller still needs its own per-vendor/content calibration at its deployed native resolution. The Gemini-native resolution caveat stands: controlnet 0.30 is certified only <=1536.** **CERTIFIED 2026-06-04 via an isolated Modal certification harness, restore OFF, ≤1536, each vendor on its own oracle: controlnet floors are OpenAI 0.20 (2 photoreal × 3 seeds = 6/6 clean; the 0.15-flipper is seed-robust at 0.20) and Gemini 0.30 (0.20 detected → 0.30 clean on 2/2 seeds). OpenAI 0.20 transfers to production (resolution-independent); Gemini 0.30 holds only ≤1536 — Gemini is resolution-sensitive, so a native-resolution caller should cap Gemini to ≤1536 at 0.30 or calibrate its native path (~0.35+). Production recipe: controlnet + per-vendor floor in `resolve_strength` (not the default ladder) + FIXED seed (kills the non-determinism). +- provider account history; +- server side copies or provenance stores; +- perceptual fingerprints; +- evidence that an image passed through a removal pipeline; +- legal disclosure duties. -**No face-restore runs in the default controlnet profile:** every earlier approach evaluated there (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned, 2026-06-04 - 2026-06-08 cert sweeps) regenerated the face via SDXL diffusion -- the output face inherited SDXL "clean skin" gloss and lost original identity precision, looking MORE AI-generated than the cleaned image, not less. The separate experimental `qwen-zimage` profile now tests a different architecture, Z-Image regeneration from the original SAM-masked face crop. Its first ArcFace/LPIPS run is recorded below, but it still needs its own oracle and multi-image face/text matrix.** - -See `docs/synthid.md` §5.5 + `docs/controlnet-removal-pipeline-research.md` (certified floors table).** **Lesson: visual-quality + face-recovery validation does NOT prove watermark removal -- only the SynthID oracle does, across MULTIPLE content types; never infer removal from sharpness/identity, and never conclude from a partial result (the photoreal-only data first read as "controlnet shields, default removes" -- the flat-graphic result reversed it).** - -`controlnet_conditioning_scale` (CLI `--controlnet-scale`, default 1.0) is the structure-preservation knob (higher = closer to the original structure); fp32 on cpu/mps, fp16-fixed VAE on cuda/xpu. The `controlnet` profile is threaded explicitly (`WatermarkRemover(pipeline=...)` / `InvisibleEngine(pipeline=...)`), NOT inferred from `model_id`. This productionizes the `scripts/controlnet_sweep.py` prototype; see `docs/controlnet-removal-pipeline-research.md`. - -**Forensic-stealth caveat still applies** (arXiv:2605.09203): defeating the SynthID verifier is not forensic invisibility -- a "this image went through a removal pipeline" classifier can still flag the output. - -## `qwen` pipeline (experimental, Qwen-Image 20B, certified floors) - -`--pipeline qwen` runs `QwenImageImg2ImgPipeline` on `Qwen/Qwen-Image` (20B MMDiT, Apache-2.0 code AND weights), as an img2img alternative to the SDXL pipelines. Motivation: the controlnet over-regeneration problem above (it plasticizes real photos / loses fine text at the scrub floor). Qwen-Image renders text natively (incl. CJK) and preserves structure markedly better, so at the strength that removes SynthID it damages real content far less. - -The scrub still comes from the img2img `strength` (same lever as SDXL); the call shape lives in the pure `_build_qwen_kwargs` (uses Qwen's `true_cfg_scale`, not SDXL's `guidance_scale` — the CLI `--guidance-scale` maps onto it, and ~4.0 is typical vs the SDXL default 7.5). bf16 on CUDA. It is **CUDA/cloud-class — the 20B does not fit MPS — so `_run_qwen` has NO MPS→CPU fallback** (unlike the SDXL paths). Cost on Modal A100-80GB is ~$0.05-0.10/image vs SDXL. - -**Certified oracle floors (Modal A100-80GB, 2026-06-20):** on native-resolution OpenAI and Gemini cert inputs (`data/qwen_in/`, both controls SynthID-POSITIVE): **OpenAI 0.10** (0.05 and 0.075 still detected; 0.10 clean and SEED-ROBUST — clean on seeds 0-4, so a random seed is safe) and **Gemini 0.25** (0.20 still detected, 0.25 clean on both images; lowered from the 0.30 first measured). Gemini seed-repeat is single-seed (seed 0): the Gemini oracle rate-limits volume, so PIN a seed in production rather than relying on seed-robustness there. - -**Fidelity vs controlnet was MEASURED, not eyeballed (`scripts/fidelity_metrics.py`, text scored against a vision-transcribed ground truth in `data/qwen_in/ground_truth.json` + PaddleOCR on the variants; an initial eyeball read was wrong and overturned by the metrics).** Methodology rule: only compare fidelity at each pipeline's OWN oracle-confirmed scrub floor -- i.e. between outputs where SynthID is actually removed in BOTH (controlnet OpenAI 0.10 / Gemini 0.15; Qwen OpenAI 0.10 / Gemini 0.25). An equal-strength comparison is invalid where it leaves one pipeline un-scrubbed (Qwen at 0.15 does NOT clear Gemini SynthID, so that run was dropped). At those scrub floors: -- **Text:** Qwen wins on substantial Latin/mixed-script text -- OCR CER, controlnet vs Qwen: openai_1 (EN+RU+ZH, both 0.10) 0.385 vs **0.241**, openai_2 (EN, both 0.10) 0.341 vs **0.290**. On a SHORT CJK sign (gemini_1, cnet 0.15 / Qwen 0.25) it is a TIE (0.037 vs 0.037 -- both near-perfect; the earlier Qwen 0.000 was at the higher 0.30, not the certified floor). -- **Faces:** controlnet wins -- gemini_3, 18 faces (cnet 0.15 / Qwen 0.25): ArcFace identity 0.546 vs 0.382, Laplacian-variance retention 0.62 vs 0.40, face LPIPS 0.09 vs 0.17 (Qwen smooths faces MORE; the gap narrows vs Qwen 0.30 but controlnet still wins clearly). - -**Conclusion: Qwen wins TEXT only for clean body text on a plain background with NO faces; controlnet wins faces AND display/decorative text in a scene. So `qwen` is a MANUAL `--pipeline qwen` opt-in, not a routed lane.** A content `--pipeline auto` router + a faces+text mixed dual-pass were prototyped and DROPPED (2026-06-20): on the canonical faces+text case (the abba poster, faces + display text) controlnet won EVERY metric incl. text (CER 0.114 vs qwen 0.379), so grafting qwen text only hurts; and "text→qwen" is undecidable cheaply (body-vs-display text is what matters). Caveat: `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`, Gemini 0.25), so `--pipeline qwen` gets the 0.25 Gemini floor automatically — the old manual `--strength 0.25` workaround is retired. `_build_qwen_kwargs` now passes an explicit height/width (qwen squished non-square inputs to 1024² without it). Flat-graphic content was not in the sample. - -**Improving Qwen (ship vs improve):** the cited research lives in `docs/qwen-improvement-research.md` -- read it before extending the `qwen` pipeline. Verdict: shippable as an opt-in text lane. **The "add a Qwen-Image ControlNet to fix face smoothing" lead was built, measured, and CLOSED (2026-06-20):** a DiffSynth-Studio Qwen + Apache-2.0 blockwise-canny ControlNet at the Gemini floor 0.25 did NOT restore face skin texture (face Laplacian-variance retention flat 0.40 -> 0.40, 13/16 faces within +-0.02; the SDXL+canny target 0.62 was not approached), because canny carries edges not skin grain and Qwen's higher Gemini floor (0.25 vs SDXL+canny 0.15) forces more smoothing -- and a deep-research sweep confirmed NO permissively-licensed Qwen tile/detail/realism/skin ControlNet exists anywhere (every Qwen conditioning is geometry). So **base Qwen stays the text lane, not a face fix.** The distinct Z-Image face-crop lead is now implemented as `qwen-zimage`; direct face comparisons are below, and its exact current six-output candidate is negative in the corresponding provider oracles. Broad seeded removal and text behavior remain unmeasured. Non-regenerative high-frequency detail re-injection is NOT safe by assumption (the "clean-output high frequencies do not carry the watermark" claim was refuted) -- it must be oracle-gated. - -**Seed as a quality lever (measured, openai_1 at 0.10, seeds 0-4):** the seed barely moves whole-image fidelity (img LPIPS 0.062-0.065, SSIM 0.855-0.857, PSNR 28.5-28.7 — flat) but does shift TEXT legibility (OCR CER 0.241-0.290, ~17% spread) -- the seed changes WHICH details get regenerated, not the overall level. So a per-image best-of-N-seed selection is a WEAK, text-only lever (pick the lowest-CER seed that still scrubs; fidelity selection needs no oracle). Not worth the N× cost for general use -- pin one decent seed in prod; reserve best-of-N for text-heavy premium cases. - -## `qwen-zimage` pipeline - -`--pipeline qwen-zimage` is the recommended high-quality SynthID removal mode when CUDA capacity is available and fidelity matters more than latency or cost. It remains a manual opt-in so the broadly compatible, much cheaper ControlNet path can stay the default. The profile ports the upstream two-stage workflow: an input-resolution Qwen-Image-2512 Lightning Canny pass regenerates the frame, then original face crops are segmented and regenerated with Z-Image Turbo before a feathered paste. DiffSynth requires both pixel inputs and the requested dimensions to use the same /16 latent grid, so each stage makes that small alignment resize internally and restores the global result to the original dimensions. The profile defaults to deterministic seed 0 because the release-candidate oracle evidence was produced at that seed; explicit callers can still override it. - -The port is architectural, not bit-identical. The active graph was traced from upstream commit `3007d0351596ae0a78b7074dae7ad179710b1e48`, including its linked Impact Pack implementation. It confirms that the active face path is YOLO + SAM; the MediaPipe node visible on the canvas is unconnected. The port keeps the two adaptive-denoise formulas, four-step Qwen Lightning stage, Canny thresholds and scale, AuraFlow shift 3 equivalent, original-image face source, SAM center + box prompts, IoU-0.93 proposal union with highest-score fallback, detector-box intersection, crop factor 2.5, 768 face guide, 1024 crop cap, eight-step face stage, and paste feather 10. - -Four runtime differences remain. This package uses full safetensors instead of the source graph's quantized GGUF models, YuNet instead of Ultralytics YOLO to avoid an AGPL runtime, DiffSynth FlowMatch samplers instead of ComfyUI's DPM++ 2M / SGM Uniform and `res_2s` / `bong_tangent` pairs, and no latent-space 20 px detailer noise-mask feather. The face crop is regenerated in full, then only the feathered SAM pixels are composited back, so generated pixels outside that mask are discarded. These differences prevent an exact-output claim even though the architecture and active decision path match. - -The default full-frame denoise is resolution-adaptive, not vendor-adaptive. The face denoise is separate and scales from the largest detected face. `--strength` overrides only the global Qwen stage. The profile fixes the global step count at four because its Lightning LoRA is distilled for that schedule; the face stage uses its own eight-step schedule. `--model` is unsupported. `--tile` follows the global-only route described above, with one full-frame face stage after blending. - -Direct comparison now covers two official upstream before/after pairs plus the existing crowded `gemini_3` fixture. The published upstream examples were scored against their own original inputs, with the upstream output resized back only for metric alignment where necessary: - -| Case | Result | ArcFace identity | Face LPIPS | Texture retention | Image LPIPS | SSIM | -|---|---:|---:|---:|---:|---:|---:| -| Upstream example 10 | published upstream | 0.976 | 0.172 | 0.166 | 0.259 | 0.627 | -| Upstream example 10 | local `qwen-zimage` | 0.950 | 0.045 | 0.570 | 0.167 | 0.765 | -| Upstream example 10 | current polished ControlNet | 0.701 | 0.105 | 0.941 | 0.094 | 0.781 | -| Upstream example 12, matched size | published upstream | 0.976 | 0.014 | 0.873 | 0.111 | 0.777 | -| Upstream example 12, matched size | local `qwen-zimage` | 0.947 | 0.015 | 0.708 | 0.085 | 0.896 | -| Upstream example 12, matched size | current polished ControlNet | 0.548 | 0.061 | 0.961 | 0.105 | 0.887 | - -The result reproduces the upstream architecture's main advantage: identity retention is far stronger than the current ControlNet path. On the group example, local face LPIPS nearly matches the published upstream output and whole-image fidelity is better; upstream still leads slightly on ArcFace identity and texture retention. ControlNet preserves more global detail and, on example 10, lower provisional OCR CER, but its faces drift to different identities. The OCR reference for example 10 came from the original image's OCR rather than hand transcription, so it is supporting evidence, not a text certification. The published upstream outputs are also downscaled relative to their originals, which penalizes their detail metrics but is the actual result the repository presents. - -The comparison exposed a real implementation defect on a non-/16 input: the requested DiffSynth dimensions were floored while the PIL image remained at its original size, so the VAE latent and noise grid disagreed. Regression tests were written to fail on that mismatch, then both global and face inputs were changed to use the exact same aligned grid as their `height` and `width`. - -**Final candidate oracle result (2026-07-25):** the user checked every image in the provider-separated `full-clean-final-candidate-2026-07-25-by-oracle` bundle with the corresponding provider oracle and confirmed that none of the six outputs retained SynthID or the provider generation signal. These are the current seed-0 bytes after the complete `visible -> qwen-zimage -> metadata` route, including the calibrated YuNet 0.5 gate and the prompt-cache/model-residency optimizations. This supersedes the earlier first-port batch check as the release-candidate result. It certifies these exact outputs, not every seed, resolution, or content class. - -YuNet's score threshold is 0.5, not the upstream graph's YOLO threshold of 0.2: detector scores are not interchangeable. The copied 0.2 threshold admitted false/duplicate boxes and multiplied serial Z-Image calls. The calibrated gate retained every visible face in the public and upstream fixtures while reducing `gemini_3` from 36 boxes to 18 and the poster from 30 to 10. Serial face regeneration still scales with the retained detector count. Visual QA also found that the smallest multilingual text degraded on the typography sheet even though the larger headings survived. Keep `controlnet` as the compatibility and cost default, but recommend `qwen-zimage` when the user prioritizes output fidelity, especially face identity. The final exact-output oracle check covers the current YuNet threshold and runtime optimizations; do not call the profile broadly certified until a wider seeded face/text matrix is complete. - -**Modal runtime measurement (2026-07-24 through 2026-07-25, seed 0, GPU stage only):** the exact paired A100-40GB run measured ControlNet at 3.342-12.543 seconds per image. `qwen-zimage` took 133.556-188.493 seconds on the three zero-face images and 1212.496 seconds on the 18-face group. The same group initially took 262.072 seconds on an exact H100, including 181.764 seconds in serial face regeneration. On H100 the three zero-face cases took 45.029-65.071 seconds. The shipped fast-load resident placement reduced the group to 133.543 seconds total and 38.272 seconds for face regeneration while producing a pixel-identical output; peak CUDA allocation rose from 24.364 to 43.477 GiB. Setup increased from 32.282 to 43.960 seconds, so even a cold one-request total fell from 294.354 to 177.503 seconds. Reusing the fixed prompt embeddings reduced a warm 18-face request further to 78.474 seconds after an earlier request populated the Qwen embedding; the cached and uncached outputs were pixel-identical, and peak VRAM was unchanged. The Qwen cache helps from the second request in one container, while the Z-Image cache helps after the first face in a multi-face request. Residency is automatic at 64 GiB VRAM or above; smaller cards retain offload. H100 remains both faster and cheaper at the live Modal rates for this workload. Pricing is intentionally not copied here; calculate from the current Modal rate and the recorded GPU seconds. Model setup must be added to an un-warmed single call or amortized over a warm batch. +See [scope, safety, and legal notes](legal-and-safety.md). diff --git a/docs/legal-and-safety.md b/docs/legal-and-safety.md new file mode 100644 index 0000000..61e8b64 --- /dev/null +++ b/docs/legal-and-safety.md @@ -0,0 +1,88 @@ +# Scope, safety, and legal notes + +This page explains the project's intended boundary. It is not legal advice. +Laws and platform rules change, so check the current rules that apply to your +location and use case. + +## Intended scope + +The project removes AI provenance marks that a platform adds to content the +user generated or edited themselves. Examples include: + +- visible AI generation labels; +- invisible provenance watermarks; +- C2PA and metadata based AI disclosures. + +The purpose is user control over the user's own output, false positive cleanup, +interoperability work, and watermark robustness research. + +## Out of scope + +The project does not provide automatic removal for marks that protect a third +party's paid or copyrighted asset, including: + +- stock agency previews; +- marketplace and classifieds marks; +- tiled overlays used to gate a purchase; +- artist protection systems such as Nightshade or Glaze. + +The `erase` command is a generic region tool. Users are responsible for having +the right to edit the selected content. + +## What removal does not prove + +Removing a local signal does not: + +- prove that an image is human made; +- remove server side generation history; +- anonymize the generating account; +- defeat every statistical AI detector; +- guarantee that a provider's current verifier will reject the result; +- make deceptive or unlawful use permissible. + +An original file may remain linked to an account or generation session in a +provider's systems even after a local copy is changed. + +## Legal context + +Some jurisdictions and platforms require AI generated content to carry visible +or machine readable disclosures. Rules may apply to providers, publishers, +users, or a combination of them. Some laws also restrict removing or +suppressing provenance information. + +Before removing a mark, consider: + +1. whether you own or are authorized to edit the content; +2. whether a disclosure is legally required where the content will be used; +3. whether removal would mislead a viewer about authorship or origin; +4. whether copyright management information is involved; +5. whether a platform's terms prohibit the change. + +The repository does not provide jurisdiction specific legal advice. The user is +responsible for checking current law and policy. + +## Appropriate uses + +Examples that fit the project scope include: + +- removing metadata that exposes an account identifier from your own file; +- correcting an AI label applied to a human photograph after a limited edit; +- publishing your own generated artwork under the disclosure rules that apply + to you; +- testing the robustness of watermarking and provenance systems; +- evaluating image processing pipelines in a controlled environment. + +## Uses the project does not condone + +- fraud or impersonation; +- nonconsensual sexual imagery; +- hiding copyright infringement; +- presenting generated content as human made where that claim is deceptive; +- evading a disclosure that the law requires; +- removing protection from someone else's paid asset. + +## Reporting security or safety concerns + +Open a GitHub issue when the concern can be discussed publicly without exposing +private data. Do not attach confidential images, credentials, or personal +information to a public issue. diff --git a/docs/module-internals.md b/docs/module-internals.md index f6e9b9a..a9822a1 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -1,435 +1,391 @@ # Module internals -> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded -> context small. Long single-line entries were reformatted into paragraphs; -> no content was changed or summarized. +This page documents the current implementation contract. It intentionally +avoids experiment logs, corpus counts, and calibration history. Those records +live in [the verification plan](verification-plan.md) and the research archive +listed in [the documentation index](index.md). -Full per-module detail: design decisions, tuned thresholds, calibration -history, incident records, and the regression-guard map. The compact module -list lives in `CLAUDE.md`; read the relevant section here before changing a -module. +Read the relevant section before changing a subsystem. When this page and the +code disagree, the code and its tests are authoritative and this page must be +updated in the same change. -## `noai/c2pa.py` +## Architecture -`noai/c2pa.py` — C2PA reading, **official c2pa-python `Reader` first, hand-rolled parser as fallback** (migrated 2026-06-18; the official lib is a core dep, MIT/Apache, spec-tracking). `read_manifest_store_json(path)` runs `Reader.try_create` with a default `Context` (NO trust enforcement — we report what is in the file, we do not gate on cert trust) and returns the **whole** manifest-store JSON (every manifest plus ingredient manifests); it is memoized per (path, mtime) (`lru_cache(maxsize=8)`) because one `identify`/`get_ai_metadata` call invokes the structured parser ~3x on the same file. `extract_c2pa_info(path)` builds its dict from that store JSON (`_info_from_store_json`: structured `claim_generator` from the active manifest's `claim_generator` / `claim_generator_info[].name`, `timestamp` from `signature_info.time`) and falls back to the legacy caBX parser (`_extract_c2pa_info_png`) when the reader is unavailable (broken/absent wheel, `reader_available()` False) or finds no parseable manifest (synthetic/partial test blobs, the inject round-trip's re-stitched chunk). **Both paths share `_populate_registry_fields(buf, info)`** — the issuer / AI-tool / action / source-type / SynthID / soft-binding registry byte-scan applied to the store JSON (reader path) or the raw caBX bytes (fallback) — so the return-dict shape is identical and the registry stays the single source of truth. Whole-store scanning is load-bearing: a ChatGPT *edit* of a Sora generation keeps `trainedAlgorithmicMedia` + issuer "OpenAI" on the **parent/ingredient** manifest, not the active "opened" one (the active manifest's `signature_info.issuer` is "OpenAI", `common_name` "Truepic Lens CLI in Sora", so the issuer field now reads "OpenAI, Truepic" — first-match-wins platform attribution still resolves OpenAI). `extract_c2pa_info` now also serves non-PNG containers (JPEG/AVIF/MP4) structurally via the reader; the consumers (`identify`, `synthid_source`, `get_ai_metadata`) already merge `info OR byte-scan`, so this strictly upgrades the non-PNG path with no double-counting. `synthid_watermark`/`synthid_vendors` is set when the manifest is signed by a SynthID-using vendor on AI content; `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both paths and the non-PNG binary path). `extract_c2pa_chunk` / `inject_c2pa_chunk` / `has_c2pa_metadata` stay the PNG caBX byte tools (raw-chunk extraction for `extractor.py`, test injection, fallback detection). PNG/caBX chunk reads are clamped to the remaining file size (`safe_length = min(length, remaining)`; skipped chunks use seek) so a malformed huge `length` cannot drive a multi-GB allocation (shared safety discipline matching `isobmff.scan_c2pa_region`). Regression-guarded by `tests/test_noai.py::TestC2PARealSamples::{test_extract_info_uses_reader_store,test_fallback_to_png_parser_when_reader_unavailable}`. +The package has four main paths: -## `noai/constants.py` +```mermaid +flowchart LR + Input[Input file] --> Identify[Identify provenance] + Input --> Visible[Visible mark removal] + Input --> Invisible[Diffusion regeneration] + Input --> Metadata[Metadata stripping] -`noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, and `C2PA_AI_VENDORS` — the single `C2paAiVendor` registry of C2PA-signing vendors (issuer byte, resolved org name, the `identify` platform label, and a `synthid` flag), from which `C2PA_ISSUERS`, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `identify._ISSUER_PLATFORM` are all **derived** — plus `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new C2PA vendor as one `C2PA_AI_VENDORS` entry (never edit the derived dicts), a new soft-binding to `C2PA_SOFT_BINDINGS`; not inline. A vendor that signs under multiple legal names needs one entry PER distinctive issuer byte string: e.g. ByteDance's Volcano Engine is registered both as latin `volcengine` AND the Chinese legal entity `北京火山引擎科技有限公司` (UTF-8; the latin needle misses the Chinese-named certs entirely) — both normalize to the same "ByteDance" needle/platform. ElevenLabs ("Eleven Labs Inc.", pure generative-AI) is registered as a generator. A vendor may also set **`asserts_ai=True`** — its presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type; the derived `C2PA_IDENTITY_AI_ORGS` frozenset feeds `identify`, which lifts the AI verdict for such an issuer. Set it ONLY for a pure-generator brand with a distinctive issuer/generator string: **Dreamina** (ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a `Dreamina/x.y` claim generator and NO source-type — the caBX / store-JSON byte-scan sees the `Dreamina` token across active + ingredient manifests, where the active one is often a plain `c2pa-tool` transcode; verified on the retained corpus 2026-07; normalizes to the shared "ByteDance" needle/platform). Do NOT set `asserts_ai` on common-word issuers (Adobe/Google/OpenAI/Microsoft) — they appear incidentally in unrelated XMP/trust-chain bytes, so they must stay source-type-gated. Deliberately EXCLUDED (mined-corpus candidates 2026-06-20, documented in the file): TikTok Inc. (a content-provenance / AI-labeling signer on uploads, not a generator) and PixelBin.io / "Fynd" (an image transform / CDN signer) — registering either as a generator would mis-label human uploads as AI; the `is_ai` verdict keys off the digitalSourceType, which is already honored. + Identify --> Report[ProvenanceReport] + Visible --> VisibleOutput[Localized and filled image] + Invisible --> InvisibleOutput[Regenerated image] + Metadata --> MetadataOutput[Container with AI metadata removed] +``` -## `metadata.py` +The `all` command runs visible removal, optional invisible regeneration, and +metadata stripping in that order. -`metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus the payloads of any provenance metadata found beyond that window — for ISOBMFF, the late provenance boxes from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); for **PNG**, the late `tEXt`/`iTXt`/`zTXt`/`eXIf`/`iCCP` chunks from `_png_late_metadata` (catches an XMP/EXIF packet appended after a large `IDAT`, e.g. a TC260 AIGC label at ~2.7 MB). Behavior-neutral (`f.read(size)`) for non-ISOBMFF inputs and for any file that fits within `size`. Use it instead of `open().read(1MB)` for any new marker scan. +## Command line interface -**Memoized per (path, size, mtime)** (added 2026-06-09, `_scan_head_cached` lru_cache, `maxsize=8`): one `identify`/`get_ai_metadata` call fans out to ~8 byte-scan detectors that each re-read the same file head, so the cache turns those into a single read; the mtime key invalidates on change, a stat failure falls back to an uncached read. `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: ` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output. `strip_c2pa_boxes` is **fail-safe** on a malformed box: it returns the original bytes unchanged with a logged warning instead of truncating the tail to EOF (detection-only `scan_c2pa_region` still stops at a malformed box). `_png_late_metadata` clamps each late-chunk read to the remaining file size (`safe_length = min(length, remaining)`) so a malformed `length` cannot drive a multi-GB allocation, AND advances the cursor by `safe_length` (not the raw `length`) so an inflated length cannot jump past EOF and abort the scan, silently skipping a genuine AI-label chunk after it. +[`cli.py`](../src/remove_ai_watermarks/cli.py) owns command parsing and +user-facing exit behavior. -## `identify.py` +Important contracts: -`identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, **C2PA cloud-manifest reference** via `metadata.c2pa_cloud_manifest` — signal `c2pa_cloud`, **medium**, provenance-only (does NOT set `is_ai`, excluded from `ai_from_metadata` + clash vendors): a C2PA 2.4 Durable-Content-Credentials case where the embedded manifest is stripped but an XMP `dcterms:provenance` pointer to the vendor's cloud manifest store (`_C2PA_MANIFEST_REPOSITORIES`, today `cai-manifests.adobe.com` → "Adobe Content Authenticity") survives, so the credentials stay recoverable server-side; only emitted when no embedded manifest already attributed the file — surfaced on 2 corpus PNGs 2026-06-10 that read fully `unknown` before, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the registered vendor marks, including Tencent Yuanbao 元宝 / AI生成 and Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. +- Single-image arguments reject directories. +- `visible` writes no output when no registered mark is selected and exits with + `EXIT_NO_VISIBLE_MARK`. +- `invisible` writes no output when no supported local signal is found, unless + `--force` is supplied. +- The two no-signal conditions currently share exit code `2`. +- Hard processing and write failures exit with code `1`. +- `all` can still write the completed visible and metadata stages when the + diffusion dependencies are unavailable, but exits with code `1` so the + partial result is not reported as complete. +- `batch` counts per-file failures and exits nonzero if any file failed or an + applicable invisible stage was skipped because its dependencies were absent. -**AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)". +The decorators for diffusion options are shared by `invisible`, `all`, and +`batch`. The runtime help generated by Click is the source of truth for option +names and defaults. -**Visible-mark detection** (`check_visible`, signals `visible_sparkle` plus `visible_` for every registered vendor mark): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while all registered vendor marks reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own calibrated engine threshold via `MarkDetection.detected`. Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. +The deprecated `--auto` option does not select a pipeline or change adaptive +polishing. [`_resolve_auto_polish`](../src/remove_ai_watermarks/cli.py) emits a +warning and returns the explicit polish value unchanged. -**`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. +Regression coverage: -**C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). +- [`test_cli.py`](../tests/test_cli.py) +- [`test_cli_robustness.py`](../tests/test_cli_robustness.py) +- [`test_optional_deps.py`](../tests/test_optional_deps.py) -**Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. +## High-level Python API -**Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. +[`api.py`](../src/remove_ai_watermarks/api.py) provides: -**Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`. +- `remove_visible` +- `visible_provenance` -**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed; Galaxy AI appends it as a trailer AFTER the JPEG EOI, so `samsung_genai` reads the file TAIL when the 512 KB quick-scan head misses it — else a multi-MB photo's trailer past the window went undetected while removal, which reads the whole file, would still strip it; removal truncates the post-EOI Samsung trailer via `metadata._strip_samsung_trailer`, pixels bit-identical): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel). +The package root exposes both lazily through +[`__getattr__`](../src/remove_ai_watermarks/__init__.py), keeping a plain package +import free of the heavier image and model imports. -**Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. **The one exception is an identity-AI issuer** (`c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai`, where `c2pa_identity_ai` is any resolved issuer org in `C2PA_IDENTITY_AI_ORGS`): a vendor flagged `asserts_ai` (today only Dreamina) sets `c2pa_is_ai` True on its own, so its platform resolves even though the manifest carries no `trainedAlgorithmicMedia`. This is safe precisely because the flag is restricted to distinctive brand strings, not the incidental-mention-prone common words. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_ISSUER_PLATFORM`), not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription`, XMP `CreatorTool`, and PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps its generator there, not EXIF) are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. Tokens mined from the retained corpus 2026-06-22: `novelai`, `reve.com` (full token, not bare `reve`), `aphrodite ai` — all no-C2PA generator stamps that previously read as no-signal (and under the P0#5 no-signal skip would have skipped the scrub). +For path inputs, `remove_visible` reads provenance metadata, preserves alpha, +and optionally writes and strips metadata. Array inputs are treated as BGR +arrays and have no file provenance or separate alpha plane. -**Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read. +When no visible mark is removed, a same-format path copy preserves the original +bytes. `write_noop=False` leaves the requested output path untouched instead. -**Integrity-clash detection** (`_integrity_clashes`, surfaced as `ProvenanceReport.integrity_clashes`, printed in red by `identify` and serialized to `--json`): contradictions between independent generator stamps are a laundering/spoofing tell. Two rules: (1) two or more distinct AI-origin vendors named by **independent** signals (e.g. C2PA OpenAI + EXIF `Make="Ideogram AI"`), and (2) a camera-capture C2PA device (`_DEVICE_C2PA_PLATFORM`) coexisting with an AI-generation marker **from a source INDEPENDENT of the camera's own manifest**. +Regression coverage: -**Rule 2's independence gate (added 2026-06-11):** a device that both captures and runs on-device generative AI (Google Pixel Magic Editor / Pixel Studio) records the capture AND the AI edit in ONE C2PA manifest — so the AI vendor is named only from that same manifest (`c2pa` issuer + `synthid` proxy, both `c2pa_manifest` source) — a legitimate edit chain, NOT a clash. Rule 2 therefore fires only when some `ai_vendor_claims` family has a source `!= "c2pa_manifest"` (EXIF/XMP generator, IPTC, TC260 AIGC, a second manifest naming AI on a camera capture — the real laundering tell). This killed a false-positive class on the corpus: 2 real Pixel generative-edit PNGs (`computationalCapture` + `trainedAlgorithmicMedia` + "Applied imperceptible SynthID watermark" in one Google manifest) read as camera-vs-AI clashes before the gate. Pure cameras (Leica/Sony/Nikon/Truepic) that do NOT generate AI still clash on any within-manifest AI marker only if it is independent — they never legitimately carry one, so the gate is behavior-neutral for them while fixing Pixel (regression-guarded by `test_identify.py::TestIntegrityClashesHelper::{test_pixel_generative_edit_same_manifest_no_clash,test_camera_plus_independent_ai_marker_still_clashes}` + `TestIntegrityClashEndToEnd::test_pixel_generative_edit_no_clash`). +- [`test_api.py`](../tests/test_api.py) +- [`test_image_io.py`](../tests/test_image_io.py) -**Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash). `_AI_VENDOR_TOKENS` covers ByteDance (all brands: bytedance/doubao/jimeng/dreamina/volcengine), Canva, ElevenLabs, and Black Forest Labs in addition to the OpenAI/Google/Adobe/... set — without them a transplanted ByteDance/Canva/BFL C2PA manifest next to an independent conflicting stamp was silently missed. **The generic `China AIGC (TC260)` label names no SPECIFIC vendor** (any Chinese generator applies it), so it cannot vendor-conflict in the spoofing sense: when a Chinese TC260-applying vendor (`_TC260_VENDORS`, today `{ByteDance}`) is co-attributed, Rule 1 attributes the label to that vendor (a legit Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and must not clash); against a NON-TC260 vendor (OpenAI etc.) the label stays generic and still clashes as a laundering tell (`test_bytedance_c2pa_plus_own_aigc_no_clash`, `test_foreign_vendor_plus_aigc_still_clashes`, `test_bytedance_c2pa_plus_foreign_generator_clashes`). Corpus-validated: 0 new clashes on 5000 ByteDance/AIGC/Canva/FLUX carriers. +## Metadata and provenance -**High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (both are low-precision/positive-only signals; the open watermark was also historically a by-product of our own SDXL removal pass, until `watermark_remover` was fixed to load the SDXL pipelines with `add_watermarker=False` — it stays excluded as a fuzzy signal regardless). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`). +### C2PA -**`ai_from_metadata` field + `has_invisible_target` helper (P0#5, 2026-06-22):** the high-confidence union (everything that sets `confidence == "high"`: C2PA AI-issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark — the medium-confidence `hf_only`/`visible_only`/`samsung_only` are excluded) is now surfaced as the public `ProvenanceReport.ai_from_metadata` boolean, so callers gate on intent rather than on the `confidence` string. `has_invisible_target(path)` wraps `identify(path, check_visible=False, check_invisible=True)` and returns that field — it is the decision gate for the diffusion scrub (the CLI `invisible`/`all`/`batch` no-signal skip, `cli._no_invisible_signal_exit`): a visible-only or no-signal image has it False, so regeneration (which would only degrade a clean image) does not run. It fails SAFE — any detector exception returns True so the removal still runs (leaving a watermark on a paid removal is worse than over-regenerating). It does NOT prove a pixel SynthID is absent (SynthID is detectable only via its metadata proxy, gone once stripped), so a False means "no locally-detectable target", never "clean". Guarded by `test_identify.py::{TestIdentifyRealSamples::test_has_invisible_target_*,TestHasInvisibleTargetFailSafe}`. +[`noai/c2pa.py`](../src/remove_ai_watermarks/noai/c2pa.py) reads C2PA with the +official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback +for partial and synthetic fixtures that the official reader rejects. -## `watermark_registry.py` +Vendor attribution comes from the registry in +[`noai/constants.py`](../src/remove_ai_watermarks/noai/constants.py). Derived +issuer and platform maps should not be maintained separately. -`watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. +### Metadata scanning and stripping -**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — all registered marks, including Yuanbao. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today include `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `qwen` (bottom-right "千问AI生成", Alibaba Tongyi Qianwen), `yuanbao` (bottom-right two-line "元宝 / AI生成", Tencent), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_trust` turns the policy + evidence into the per-mark trust level the engines consume as `provenance = level != "strict"` — `strict` never relaxes; `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI, e.g. a metadata-stripped screenshot). **Three levels, not two: `strict` / `assumed` / `confirmed`.** Relaxing bypasses the engine's false-positive gate outright, and that bypass is contracted to mean the vendor is CONFIRMED (`GeminiEngine.detect_watermark`'s `trust_provenance`: "external metadata already proves this is a Google generation"). An `assume_ai` caller asserts the image is AI, which says nothing about WHICH vendor, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (`assumed_floor_ok`; gemini 0.50) — see "Assumed-trust confidence floor" below. **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_trust` cross-mark corroboration + the assumed-trust floor + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior was byte-identical to the pre-arbiter two-pass when the arbiter landed; `assume_ai` has since gained the assumed-trust confidence floor (see below), which deliberately changes its verdict on weak gate-bypassed matches. +[`metadata.py`](../src/remove_ai_watermarks/metadata.py) contains the shared +metadata scanners and `remove_ai_metadata`. -**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. +Key contracts: -**`assume_ai` removed (2026-07-19).** It relaxed EVERY mark's false-positive gate on the caller's bare assertion that an image is AI. That assertion says nothing about WHICH vendor or WHERE the mark is, which is exactly what a gate bypass is contracted to require -- before it carried a confidence floor it filled a phantom sparkle on 59.8% of genuine camera photos, and even with the floor it was a statistical gamble rather than an instruction. It also had no place in the product's model: detector finds a mark -> remove it; detector finds nothing -> leave the image alone; the USER sees a mark and says so -> act on that. +- `scan_head` is the shared cached input for bounded byte scans. +- JPEG stripping walks metadata segments and preserves the entropy-coded image + scan. +- ISOBMFF containers use + [`noai/isobmff.py`](../src/remove_ai_watermarks/noai/isobmff.py). +- Supported non-ISOBMFF audio and video containers use ffmpeg stream copying. +- The low-level remover is fail-safe and can copy an undecodable file through + unchanged. +- A caller that reports success must use `strip_and_verify`, which scans the + written output for surviving markers. -Removing it collapsed the trust ladder from three levels to two (`strict` / `confirmed`) and took `_ASSUMED_CONF_FLOOR`, `assumed_floor_ok` and the `assumed` level with it. `_keep_pill` lost its `sensitivity` parameter (its assume-arm is gone; the metadata arm and its flatness guard are unchanged). Verified on the 240-image unbiased recall sample: doubao 92%/99%, gemini 96%/80%, jimeng 71%/71%, pill 50%/60% -- identical before and after, so nothing on the default path moved. +Detection and removal must stay in parity. A new marker is incomplete until the +scanner can find it, the remover can reach every supported placement, and a +test proves that it no longer appears in the output. -**The replacement advice is PER MARK, because the forced paths are not equally reliable** (measured 2026-07-19): +Regression coverage: -| path | reliability | -|---|---| -| `erase --region x,y,w,h` | sound by construction -- the user supplies the coordinates | -| `--mark --no-detect` | reasonable: the forced mask is the real glyph blob, non-empty on 13/13 missed doubao marks | -| `--mark gemini --no-detect` | **NOT recommended** -- falls back to a fixed default sparkle slot, which covered the true sparkle on only **31% of 97** sparkles the strict gate missed (median offset 63px up-and-left). The other 69% fill a clean corner AND report a removal that did not happen. | +- [`test_metadata.py`](../tests/test_metadata.py) +- [`test_noai.py`](../tests/test_noai.py) +- [`test_security_clamp.py`](../tests/test_security_clamp.py) -`cli._no_visible_mark_exit` therefore recommends `erase --region` first and a named text mark second, and never suggests forcing gemini. It previously recommended `--sensitivity assume-ai`, i.e. the product's own hint contradicted its model. +### Provenance report -**Migration is LOUD, not silent.** `Sensitivity` is a `Literal` and unenforced at runtime, so a 0.15 caller passing `sensitivity="assume_ai"` would otherwise get `auto` behaviour in silence -- a quiet semantic change on exactly the release where they need telling. `validate_sensitivity` (called by `api.remove_visible` and by `Context.__post_init__`) raises a `ValueError` naming the replacement. Regression: `tests/test_watermark_registry.py::TestNoBlanketRelaxation`. +[`identify.py`](../src/remove_ai_watermarks/identify.py) combines metadata, +registered visible marks, and optional open invisible-watermark decoders into a +`ProvenanceReport`. -**Continuous top-hat detection front-end (`detect_frontend` / `tophat_response`, 2026-07-18).** `extract_mask` thresholds the white top-hat into a 0/255 glyph blob and correlates a binary silhouette against it. That is fine for a mark stamped bold and opaque, and destructive for a faint one: a thin translucent overlay shatters into specks under the threshold, and no template can match a blob that is not there (千问 measured 0.170 mean NCC, **0%** over its gate, against doubao's 0.723 / 82% -- same pipeline, each with its own template). The `tophat` front-end never binarizes: the saturation and absolute-luma gates become WEIGHTS instead of hard cuts, so a faint stroke contributes in proportion to its strength, and the response is max-normalized, which makes the score contrast-invariant. +`is_ai_generated` is `True` or `None`; absence of evidence is not reported as a +human-made verdict. `ai_source_kind` distinguishes fully generated content from +AI-enhanced composites when the source metadata provides that distinction. -Doubao is switched to it; jimeng and samsung stay `binary` until measured, because a front-end change must be measured per mark before it ships. Corpus effect on the 240-image unbiased recall sample: +TrustMark is reported as a watermark signal but does not by itself assert AI +origin because it can also protect human-authored content. -| mark | recall before | recall after | precision before | precision after | -|---|---|---|---|---| -| doubao | 89% | **92%** | 99% | **99%** | -| jimeng / gemini / pill | unchanged | unchanged | unchanged | unchanged | +Regression coverage: -**The gate is FRONT-END SPECIFIC and must be re-calibrated, not ported.** The continuous response scores higher overall (mean 0.809 vs 0.723 on the same 90 positives), so the binary-era 0.40 left the provenance-relaxed gate (x0.7) far too low: at 0.40 the arm ran 96% recall / 91% precision (8 false fires), at 0.50 it runs 92% / 99% (1 false fire). 0.50 was chosen because it beats the binary front-end on recall at IDENTICAL precision -- a front-end that only trades one for the other would not have been worth shipping. A first pass at 0.40 also silently depressed the PILL (recall 50% -> 33%), because `_keep_pill` suppresses the pill whenever doubao fires; a coupling worth remembering when tuning any bottom-right mark. +- [`test_identify.py`](../tests/test_identify.py) +- [`test_trustmark_detector.py`](../tests/test_trustmark_detector.py) +- [`test_invisible_watermark.py`](../tests/test_invisible_watermark.py) -**The removal MASK must ride the same front-end, and how it does so was fixed twice.** `tophat` detection does not binarize, but `extract_mask` (which bounds the fill) still does, so a mark faint enough to be found only by the continuous response produced an EMPTY binary blob: `localize` returned `mask=None`, `remove()` was a silent no-op, and `identify` reported `visible_doubao` while `visible` said "no visible mark" on the same file (corpus-measured 2026-07-20: 57 of 60 sampled still-detected Doubao marks untouched, ~8% of its detections). The FIRST fallback (2026-07-19) thresholded the continuous response and took the bounding box of everything above the level -- but the level was `0.5` compared against the max-normalized **uint8 0..255** response, so it selected every non-zero pixel and filled ~120% of the corner box on textured frames (a padded whole-ROI box). It passed parity (a mask that fills everything is trivially detector-clean) and its regression test (a FLAT fixture, where the response is non-zero only on the glyph, so every threshold yields the same box). The SECOND fallback (2026-07-20) uses the detector's OWN best-match box instead: `_tophat_score` was split into **`_tophat_best(image, loc) -> (score, box)`**, the single method whose score gates detection and whose argmax box bounds the mask -- so the two cannot drift by construction, which is how the mismatch arose in the first place. Measured over 14 real faint-path frames (cv2 fill, detector re-run after): the match box fills a **58.7%**-median corner box vs the threshold's **120.9%**, both 100% detector-clean. The largest-connected-component alternative was tighter (10.5%) but removed the mark on only 21% of frames, so it does not cover it and was rejected. Regression: `tests/test_text_mark_faint_mask.py`, whose fixture now carries texture (the flatness of the old one is exactly why it could not see the threshold bug -- mutating the constant to 99.0 left it green). **Any future front-end change must move both the detection and the mask path, or re-check this.** +## Visible mark removal -**Vendor ATTRIBUTION for the shared-suffix marks -- solved for 千问 by exact sizing, not by a generic template.** The 2026-07-18 worry was that "千问AI生成" and "豆包AI生成" share the `AI生成` tail (four of six glyph cells), so their templates would cross-fire. That held while Qwen was scored at the WRONG SIZE (AUC 0.41-0.59, a coin flip). Re-measured 2026-07-21 at the fitted geometry on real pools (`scripts/vendor_mark_calibrate.py --crossfire`): Qwen's template scores p50 0.224 / p90 0.242 on 400 Doubao-marked frames against a 0.45 gate -- **0 cross-fires**, and 0 on 298 Jimeng-marked frames and 286 clean frames -- because an exact-size 6-glyph template is specific enough that the 2-glyph prefix mismatch dominates the shared tail. Qwen was therefore registered WITHOUT a rival margin (a 0.10 margin would have suppressed ~10% of genuine Qwen detections, whose margin p10 sits at 0.00). A GENERIC shared-tail template remains a harvesting aid, not a detector (measured: 0.407 on a bold positive vs clean p99 0.298). +### Registry and decision flow -**千问 is registered since 2026-07-21; 星绘 is NOT (still one confirmed example).** The 2026-07-18 measurement below is why 千问 was originally held out -- it is kept because it records the failure CLASS that the registration then had to solve. Measured back then on 14 hand-verified corpus positives, same pipeline, each mark scored with its OWN template: +[`watermark_registry.py`](../src/remove_ai_watermarks/watermark_registry.py) is +the only visible-mark registry. `mark_keys()` supplies the CLI choices, so the +CLI must not maintain a separate mark list. -| mark | n | mean NCC | median | above the 0.40 gate | -|---|---|---|---|---| -| doubao | 40 | 0.723 | 0.835 | **82%** | -| qwen | 14 | 0.170 | 0.179 | **0%** | +Automatic removal has three distinct stages: -Three candidate explanations were ruled out in order, each by measurement: -1. **Not the synthetic render.** A template cut from an ACTUAL Qwen mark scores the same as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308), and real masks do not match EACH OTHER. -2. **Not the morphology kernel.** `MORPH_OPEN`/`MORPH_CLOSE` use fixed 5px kernels regardless of mark size (~9% of a 57px-tall box, ~2.7% of a 188px one). Scaling them with the box height gained +0.014 mean and moved nothing across the gate. -3. **Not the appearance thresholds.** Sweeping `tophat_delta` / `logo_min_luma` / kernel size peaked at mean 0.35 with 4/14 over the gate. +1. Perception: each registered detector produces strict and relaxed candidates. +2. Decision: the pure `decide` arbiter applies sensitivity and corroborating + provenance. +3. Action: each selected mark is localized to a mask and passed to the shared + fill function. -The blocker was named as SEGMENTATION on a faint mark -- and the `tophat` front-end (built for Doubao the same week) removed exactly that blocker, yet 千问 still did not register, because the real residual was never segmentation alone: it was **mis-sized geometry** (two size modes the shared 3-rung ladder cannot straddle, plus a locate box that clipped the first glyph). The 2026-07-21 cohort harvest (117 labelled frames) and the full calibration chain are in the `qwen_engine.py` section below and `docs/verification-plan.md`. 星绘 remains where it was: ONE confirmed corpus example, so nothing to calibrate a gate against -- do not register it off a single frame. The synthetic renderer and the evidence chain are kept in `scripts/render_vendor_silhouettes.py`; researched vendor specs are in `docs/watermarking-landscape.md`. +`sensitivity="strict"` never relaxes a detector. `sensitivity="auto"` can relax +one only when metadata or a sufficiently strong same-product sibling confirms +that product. The removed blanket `assume_ai` mode is rejected explicitly. -**RECALL, measured at last (unbiased random sample, 2026-07-18).** Every earlier round sampled where detectors FIRED, so recall was structurally unmeasurable. This round draws 240 images at RANDOM within each provenance class (160 TC260, 80 Google-C2PA) and labels them EXHAUSTIVELY -- both corners shown at native scale, so a missed mark is visible as a miss rather than absent from the data. Build it with `scripts/visible_recall_sample.py`; labels live in the gitignored research dir. +The Jimeng pill has an additional decision gate because its visual detector is +weaker than the other registered marks. Keep that policy in the registry, not +inside unrelated detector engines. -| mark | present | recall | 95% CI | precision | 95% CI | -|---|---|---|---|---|---| -| doubao | 90 | **89%** | 81-94% | **99%** | 93-100% | -| gemini | 46 | **96%** | 85-99% | **80%** | 68-88% | -| jimeng | 14 | 71% | 45-88% | 71% | 45-88% | -| jimeng_pill | 6 | 50% | 19-81% | 60% | 23-88% | +`remove_auto_marks` removes every selected mark, not only the strongest one. +This matters for images that carry marks in more than one corner. -Effect of the `scale_basis` fix on the same sample (strict verdicts recorded before it): **doubao recall 71% -> 89%**, gemini 91% -> 96%, jimeng 64% -> 71%. Part of the doubao gain is the provenance relaxation rather than the basis alone, since the after-numbers run the full `auto` path. +Regression coverage: -**Three corrections this forced to earlier numbers:** -* **Gemini precision is 80% on an unbiased sample, not the 41% the addition-sampled harness reports.** The 41% is precision restricted to relaxation ADDITIONS, which are by construction the marginal cases; production sees mostly strict fires, which are near-perfect. Quote 80% for the product and 41% only when discussing the relaxation arm. -* Doubao is in excellent shape (89/99) and is no longer the problem it looked like before the basis fix. -* Landscape is improved but NOT solved: doubao recall by aspect is portrait 92% / square 92% / **landscape 79%**, so a residual geometry gap remains beyond the basis. +- [`test_watermark_registry.py`](../tests/test_watermark_registry.py) +- [`test_api.py`](../tests/test_api.py) -**Where the remaining loss actually is:** jimeng and the pill, both at small n with intervals too wide to tune against (a 14-positive and a 6-positive sample), plus **uncovered vendors at 6% of all sampled images** (千问/百度/星绘/抖音-class marks that no registered detector can ever fire on). Adding those vendors is now a larger win than any further tuning of the covered four, and `docs/watermarking-landscape.md` carries their researched specs. +### Gemini sparkle -**Per-mark geometry scaling (`scale_basis` / `scale_base`, 2026-07-18) -- the largest single recall defect found so far.** Every tuned fraction in `TextMarkConfig` was calibrated on PORTRAIT captures, where the width and the short side coincide, so the scaling basis was never exercised until landscape inputs were measured. Corpus-measured on 2572 unique TC260 carriers, BEFORE the fix: +[`gemini_engine.py`](../src/remove_ai_watermarks/gemini_engine.py) uses a +multi-scale shape search and a false-positive gate. Its captured sparkle assets +serve detection and mask geometry only. Pixel recovery is performed by the +shared fill backend. -| aspect ratio | detected | missed | miss rate | -|---|---|---|---| -| tall portrait <0.70 | 323 | 212 | 40% | -| portrait 0.70-0.95 | 607 | 533 | 47% | -| square ~1.0 | 190 | 272 | 59% | -| landscape 1.15-1.6 | 0 | 143 | **100%** | -| wide >1.6 | 0 | 292 | **100%** | +`detect_sparkle_confidence` uses a process-wide shared engine because its loaded +assets and template ladder are immutable. -**Not one landscape image in the corpus ever produced a detection** -- 435 of them, zero. A width-scaled box is inflated by the aspect ratio on a wide image, so the glyph never lands inside it and the blob never gets scored: of the 1452 no-detection TC260 images the median doubao NCC was **0.057**, with 49% at ~zero. This is a LOCALIZATION failure, not a threshold one -- only 2.7% of those images sat in the band a threshold change could reach, which is why a day of threshold tuning could never have found it. Re-running the previously-undetected set with a short-side basis recovers **56% of landscape** (12% square, 4% portrait; 20% overall). +Regression coverage: -**The basis is PER MARK because the vendors genuinely differ.** The same switch took jimeng's labelled landscape positives from 13/13 to **0/13**: the Jimeng wordmark tracks the WIDTH while the Doubao strip tracks the short side, even though both are ByteDance and share a corner. So doubao is `short`, jimeng is `width`, and samsung stays `width` because there is no corpus evidence either way (1 addition corpus-wide) and an unmeasured change is not an improvement. China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the shortest side", which is why short-side is the natural prior -- but jimeng's measured behaviour overrides the prior. Regression: `tests/test_text_mark_engine.py::TestScaleBasis`. +- [`test_gemini_engine.py`](../tests/test_gemini_engine.py) -**How this was missed for so long:** precision was measured repeatedly and recall never was. The eval harness now reports a `missed` column for exactly this reason, and it is what caught the jimeng regression the short-side switch introduced. +### Text mark engines -**Competitive detection among same-corner marks (`rivals` / `_rival_margin_ok`, 2026-07-18).** Detection was purely ABSOLUTE -- every engine scored its own template against its own threshold, so nothing ever asked the discriminative question "does this blob match the NEIGHBOUR's mark better than mine?". Doubao "豆包AI生成" and Jimeng "★ 即梦AI" both sit bottom-right in near-white CJK and survive the top-hat binarization as very similar blobs, so no absolute gate can separate them. Measured on hand-labelled examples, scoring BOTH templates against the SAME glyph blob (n=40 jimeng / 75 doubao / 20 other-vendor labels / 89 clean): +[`_text_mark_engine.py`](../src/remove_ai_watermarks/_text_mark_engine.py) +provides common localization, detection front ends, template caching, rival +comparison, and footprint construction. -| feature | separability (0.5 = useless, 1.0 = perfect) | -|---|---| -| absolute `ncc_jimeng` | 0.96 | -| `ncc_jimeng` MINUS `ncc_doubao` | **0.99** | +Each vendor module supplies a `TextMarkConfig` and only the behavior that cannot +be represented by the shared base: -At a 0.10 margin: real Jimeng wordmarks pass **100%**, Doubao strips 8%, other vendors' AI labels (千问/百度/星绘/抖音) 55%, no-mark corners 12%. Corpus effect (`scripts/visible_eval.py`, 741 labelled images): **jimeng precision 38% -> 63%, genuine detections unchanged at 40, false fires 65 -> 23.** Because real marks pass at 100% this is a pure precision gain, unlike raising a threshold -- so the earlier 0.85 relaxation patch was REVERTED to 0.70 and the recall it had sacrificed came back. **The gate is deliberately asymmetric:** doubao declares no rival, because the symmetric gate cost it 7 genuine detections to prevent 5 false ones (1.4:1 against) while jimeng gained 25pp for free -- doubao's absolute detector is already 86% precise and has nothing to buy. A rival's config is looked up lazily by asset name (`_rival_config`) so its template is scored at ITS own geometry; scoring it at the host mark's geometry would compare a correctly-sized template against a mis-sized one and hand the margin a free win. Regression: `tests/test_text_mark_engine.py::TestRivalMargin`. +- [`doubao_engine.py`](../src/remove_ai_watermarks/doubao_engine.py) +- [`jimeng_engine.py`](../src/remove_ai_watermarks/jimeng_engine.py) +- [`qwen_engine.py`](../src/remove_ai_watermarks/qwen_engine.py) +- [`kling_engine.py`](../src/remove_ai_watermarks/kling_engine.py) +- [`yuanbao_engine.py`](../src/remove_ai_watermarks/yuanbao_engine.py) +- [`samsung_engine.py`](../src/remove_ai_watermarks/samsung_engine.py) +- [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py) +- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py) +- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py) -**Evaluation harness (`scripts/visible_eval.py` + `scripts/visible_groundtruth.py`, 2026-07-18).** Run before AND after any detector change; `--save NAME` snapshots, `--vs NAME` diffs. Ground truth is 741 blind-labelled corpus images (779 cells across two rounds, two-sided control each). Three properties of the harness are load-bearing and were each added after the naive version produced a wrong number: +The detector and removal mask must use compatible geometry. A detector that +fires while producing an empty or misplaced mask is a removal failure even if +the detection test passes. -* **Adjudication scope.** A crop centred on one mark only lets the labeller rule on marks visible IN THAT CROP. Scoring jimeng against a pill-round image (top-left crop) books real bottom-right detections as false fires -- ~61% of pills carry a wordmark. Each image records which marks its crop could rule on; bottom-right marks co-adjudicate each other. -* **Provenance must come from METADATA, never from the labels.** A relaxation arm only fires when provenance names the vendor, so label-derived provenance hands the detector the answer: it scored gemini at 99% instead of the true 41%. -* **Recall is NOT reported.** The labelled set was sampled where detectors fired, so images every detector missed are absent by construction; a recall computed here would divide by a denominator that excludes exactly the failures recall exists to expose. The `missed` column catches a change LOSING marks it used to find, nothing more. True recall needs a random corpus sample labelled exhaustively -- not yet done. +Yuanbao uses the polarity-independent `contrast` front end because its standard +two-line mark can be light on dark scenes or dark on light scenes. Its detector +and footprint both use the same best-match box. The separate one-line overlay +variant is not covered. -Baseline at the time of writing (sensitivity `auto`, provenance from metadata): gemini 41% (321 fires), doubao 86% (77), jimeng 63% (63), jimeng_pill 64% (83), samsung unmeasurable (1 addition corpus-wide). +The capture-less Jimeng pill lives in +[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a +synthetic silhouette for detection and a fixed top-left footprint. -**Per-mark provenance NCC relaxation + the corroboration gate (2026-07-18).** Two defects, both on the DEFAULT `auto` path (no flag, driven by TC260 metadata), found by blind hand-labelling the ADDITIONS (accepted with provenance, rejected without) over 4417 unique TC260 carriers. Two-sided control: labeller sensitivity 100% (doubao) / 96% (jimeng), specificity 100% / 100% — the controls are what make the low numbers trustworthy, and the "clean" stratum is structural (another vendor's C2PA image, where a ByteDance mark cannot exist) rather than detector-defined, so it is not circular. +Each engine has a corresponding test module under [`tests/`](../tests/). +Shared behavior is covered by: -(1) **One shared `_PROVENANCE_NCC_FACTOR = 0.7` meant two different things per mark:** +- [`test_text_mark_engine.py`](../tests/test_text_mark_engine.py) +- [`test_text_mark_faint_mask.py`](../tests/test_text_mark_faint_mask.py) +- [`test_text_mark_memory.py`](../tests/test_text_mark_memory.py) -| mark | band | precision | 95% CI | n | -|---|---|---|---|---| -| doubao | whole arm | 76% | 61-87% | 42 | -| | [0.280,0.340) | 58% | 36-77% | 19 | -| | [0.340,0.400) | 91% | 73-98% | 23 | -| jimeng | whole arm | 17% | 10-27% | 82 | -| | [0.315,0.383) | 12% | 6-22% | 68 | -| | [0.383,0.450) | 43% | 21-67% | 14 | +### Fill backends and region erasing -The factor is now a per-mark `TextMarkConfig.provenance_ncc_factor`. Doubao stays 0.70 — both bands return more true marks than false fills, so tightening would cost 11 genuine recoveries to prevent 8. Jimeng moves to 0.85 (gate 0.3825), dropping the 12% band: −8 genuine recoveries, −60 false fills (7.5:1), arm precision 17% → 43%. **Why jimeng fails is a detector problem, not a threshold one:** of its 68 false additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问 / 百度 / 星绘 / 抖音) — relaxed, the silhouette keys on "some text in the bottom-right corner", not on "★ 即梦AI". Damage was scored separately because doubao and jimeng share a corner: 45 of the 68 fill a corner nothing else would touch, the other 23 are harmless (doubao fires strictly there and fills the same box anyway). A better silhouette, not a lower factor, is the real fix. +[`region_eraser.py`](../src/remove_ai_watermarks/region_eraser.py) implements the +same backends used by visible removal and the user-directed `erase` command: -(2) **A weak detector must not corroborate a sibling (`_CANNOT_CORROBORATE`).** `resolve_trust` grants `confirmed` on a strict-detected sibling of the same `_PRODUCT_OF`, and `confirmed` bypasses the sibling's FP gate outright. The pill (~7% documented raw false-fire; 5.5% on 578 vendor negatives) maps to product "jimeng", so it could hand that bypass to the wordmark — a closed loop: pill false-fires on clean non-ByteDance content → jimeng relaxes 0.45 → 0.3825 and false-fires → `_keep_pill` sees "jimeng" in keys and takes the WORDMARK arm, removing the pill **unrestricted**, skipping the flatness guard written to stop exactly that smear. 3 of 578 negatives ran the full loop, one with `footprint_flat=0`. The fix costs nothing: negatives 3 → 0, TC260 carriers unchanged (jimeng 398 → 398, pill 117 → 117). `_keep_pill` already encoded this distrust for the pill's ACTION; the gap was that its TESTIMONY was ungated. +- `cv2` +- `migan` +- `lama` -**Pill arms, re-measured on the same corpus** (149 blind-labelled TC260-arm fires, 35 wordmark, 33 unconfirmed): wordmark **94%** (CI 81-98%, confirming the original claim), TC260-metadata-only **21%** raw (CI 16-29%, consistent with the original ~27%) — **29%** (CI 20-40%) among the flat footprints the guard PASSES vs 14% among those it blocks. The guard works directionally but weakly: the shipped arm still runs at ~2.4 false fills per genuine one. Whether an arm that inaccurate belongs on the default path is a product call, not a tuning one. +`watermark_registry.resolve_backend` selects LaMa first, then MI-GAN, then +OpenCV for `auto`. A memory-constrained caller should explicitly select MI-GAN +or OpenCV instead of relying on `auto`. -**Samsung's relaxation is UNMEASURED and not measurable here:** the corpus holds 14 `samsung_genai` carriers and 3 visible Samsung detections total. Any precision estimate would carry a Wilson interval spanning most of [0,1]. Likely structural — detection is calibrated to the Italian locale string only. +MI-GAN and LaMa crop around the mask before model inference and paste back only +masked pixels. Their model sessions are loaded lazily. MI-GAN uses the inverse +mask polarity expected by its ONNX model. +Regression coverage: -**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. +- [`test_region_eraser.py`](../tests/test_region_eraser.py) +- [`test_inpaint_fallback.py`](../tests/test_inpaint_fallback.py) -**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()`. +## Invisible watermark regeneration -**`cli._remove_visible_auto` is the shared visible-removal helper used by `cmd_all`/`cmd_batch` too** (they no longer hardcode `GeminiEngine`), so `all`/`batch` remove Doubao/Jimeng/Qwen/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (the remove adapter localizes the glyph footprint and hands it to the shared `fill` only when detected/forced, else skipped); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map for the detection silhouette); do not re-add per-mark `if` branches or copy-paste adapters. +### Profiles and strength -**Alpha-on-save policy (issue #30):** `image_io.write_bgr_with_alpha` (it lives in `image_io`, not `cli` — moved so the CLI and the library `api` share ONE implementation) rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. The fill reconstructs real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region, but the CLI no longer uses it to clear alpha. **It returns `imwrite`'s success flag and callers must check it** (2026-07-20): `imwrite` is contractually non-raising, so that bool is the only signal the file was not created. The wrapper previously returned `None` and swallowed it, so every CLI write site ran `output.stat()` to report the size and a read-only destination died with a bare `FileNotFoundError` traceback pointing at the stat instead of the write. The CLI now writes through the shared `cli._write_output_or_exit`. Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`. +[`noai/watermark_profiles.py`](../src/remove_ai_watermarks/noai/watermark_profiles.py) +is the source of truth for: -## `gemini_engine.py` +- profile aliases; +- default model identifiers; +- default steps and seeds; +- vendor-adaptive strength resolution; +- the minimum viable step calculation. -`gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`. The public entry points normalize a grayscale (2D) or RGBA (4-channel) input to BGR up front so a non-BGR image does not crash the cv2 pipeline. +The current profiles are `controlnet`, `sdxl`, `qwen`, and `qwen-zimage`. +`default` is a legacy alias for `sdxl`. There is no content-dependent automatic +router. -**Detection localization (issue #36):** `detect_watermark`'s global multi-scale NCC search applies a size weight (`(scale/96)**0.5`) that suppresses tiny-patch false positives but can let a larger, mediocre match (e.g. a bright collar in a portrait) outrank a small, near-perfect sparkle in the corner — so a faint sparkle on a busy background scored below threshold and read as clean (the regression osachub reported from widening the search window 256px->512px between v0.7.2 and v0.8.8). `_corner_promote` adds a bottom-right-corner raw-NCC pass on top of the global search: a match with raw NCC >= `_CORNER_PROMOTE_NCC` 0.85 that beats the global pick overrides it (it only ever replaces a lower-fidelity pick, so it cannot weaken an existing detection), rescuing the buried sparkle without reverting the wider window. The corner side is **relative-clamped** (`_CORNER_PROMOTE_FRAC` 0.20 of the short side, clamped to `[_CORNER_PROMOTE_MIN` 96, `_CORNER_PROMOTE_MAX` 384`]`): a fixed 256px is a true corner on a large image but covers ~70% of a small portrait, where a real photo raw-matches the star at ~0.81 (relative tightening drops that worst case to ~0.69, while the upper clamp stops the corner ballooning on huge images where a real photo reached ~0.83 at 512px). The 0.85 gate sits midway between the worst real-photo corner match (~0.78 across native + downscaled negatives) and a genuine faint sparkle (~0.93), so promotion adds true detections with zero corpus false positives (Gemini's sparkle sits ~60-160px from the corner at fixed margins, covered by the [96, 384] band at every measured size). Regression-guarded by `test_gemini_engine.py::TestCornerPromotion`. +[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) handles +image sizing, optional pre-upscaling, postprocessing, and the public engine +interface. It delegates model execution to +[`noai/watermark_remover.py`](../src/remove_ai_watermarks/noai/watermark_remover.py). -**Top-K fusion selection (osachub follow-up 2026-06-12):** `_corner_promote`'s 0.85 raw-NCC gate still missed a class the 256->512 widening exposed — a genuine MID-scale sparkle whose raw NCC sits *below* 0.85 but is buried by a LARGER, low-fidelity decoy that wins the size weight. The reporter's image (a scale-48 sparkle on light bedding) measured spatial 0.775 / grad 0.960 / fusion 0.676 at the true sparkle, but the size-weighted argmax instead locked onto a decoy at spatial 0.628 / grad 0.036 (fusion 0.325) — so `identify` read `unknown` on v0.8-0.11 where v0.7.2 (256px window) had caught it at 0.676. Fix: `detect_watermark` now keeps the **top-`_SELECT_TOPK` (3)** size-weighted candidates (NMS-deduped by location) plus the corner-promote candidate, scores EACH by the full fusion (spatial+gradient+variance) via the extracted `_grad_var_scores` helper, and selects the highest — the gradient term (the discriminator a contrast-invariant spatial NCC lacks) lifts the true sparkle over the decoy. Critically, selection ranks by the SIZE-WEIGHTED score, NOT raw NCC: a raw-NCC argmax (tried first) re-admitted the exact tiny-patch (scale 16-18) false positives the size weight exists to suppress — it flagged 14/65 doubao + 4/11 jimeng visible-corpus images (non-Gemini content) as Gemini sparkles. Top-K keeps tiny-patch suppression intact: a coincidental 16px match never ranks in the size-weighted top-K, so widening selection added **zero** flips on the doubao/jimeng corpora and left the 495-image Gemini set unchanged (479 detected, both before and after) while recovering the reporter's image. Regression-guarded by `test_gemini_engine.py::TestCornerPromotion::test_low_gradient_decoy_loses_to_high_gradient_corner_sparkle` (mirrors the real spatial/grad signature via a monkeypatched scan) and `test_size_weighted_search_alone_traps_on_the_decoy`. +The Python engine and CLI do not have identical defaults for every optional +postprocessing argument. Integrations that require reproducibility should pass +the relevant values explicitly. -**Square-image residual misses are NOT fixable by lowering the detector threshold (measured + REJECTED 2026-06-11):** osachub (#36 follow-up) reported the corner-promote still misses Gemini sparkles on Google **square (1:1)** outputs. Reproduced on the spaces corpus: of 330 square Google-C2PA images, 140 score below the identify 0.5 threshold, and visual review confirmed a real class -- faint white sparkles on dark/textured/colored backgrounds (raw NCC 0.46-0.73, below the 0.85 promote gate) landing at fusion conf 0.41-0.47. A margin-gated promote (promote when raw NCC >= 0.50 AND `_core_ring_margin` >= 40) rescued 32/33 confirmed misses at an apparent 0 FP, but that 0 was a **measurement artifact** -- the negative set was the margin<40 misses, which a margin>=40 gate excludes by construction. On an honest 518-image non-Google pool the same gate fired on **~174 (≈33%)**, visually content (screenshots, Chinese "AI生成" Doubao/Jimeng text marks, logos, bright textures), not sparkles. Adding an achromatic-core constraint (`chroma <= 15`) did not separate them either (kept 15/33 POS, 41 NEG still firing). Root cause is the documented contrast-invariant-NCC wall: a faint sparkle on a busy background is indistinguishable from a bright/ornate content corner at the (shape-NCC, brightness-margin, core-chroma) feature level. +Regression coverage: -**Conclusion: keep the 0.85 corner gate; do NOT add a margin/chroma-gated lower promote.** +- [`test_watermark_profiles.py`](../tests/test_watermark_profiles.py) +- [`test_invisible_engine.py`](../tests/test_invisible_engine.py) +- [`test_img2img_runner.py`](../tests/test_img2img_runner.py) +- [`test_platform.py`](../tests/test_platform.py) -The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit -- the visible sparkle is a medium-confidence stripped-metadata fallback, and intact Gemini is caught by C2PA in `identify` regardless. Remaining square misses are an accepted known limitation; a real fix would need a sparkle-specific discriminator (template match on a background-subtracted image, or a hard fixed-margin position prior), which is open research, not a threshold tweak. +### CPU offload -**Removal is localize -> fill** (`footprint_mask` → `watermark_registry.fill`): `footprint_mask` returns the sparkle footprint = the captured alpha (computed `alpha = max(R,G,B)/255` from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, capture max ~130 for the ~51%-opaque overlay) thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin. That binary mask is inpainted by the shared fill (cv2 / MI-GAN / big-LaMa). The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. This replaced the old reverse-alpha removal path; because the fill only reconstructs the masked footprint from its surroundings (rather than dividing by `1-a`), the whole reverse-alpha removal tail — the over-subtraction guard (`_reverse_alpha_oversubtracts`, the dark-background black-pit fix), the under-subtraction alpha-gain estimate (`_estimate_alpha_gain`), and the self-verify repair — is GONE, along with the near-white `1/(1-a)` ill-conditioning and the "color changed, not removed" failure mode those guards patched around. A slightly-off localization now just fills a small region near-losslessly instead of leaving a color-shifted smear. +CPU offload is enabled only when requested on CUDA. The standard Diffusers +profiles call `enable_model_cpu_offload`. The `qwen-zimage` profile uses the +same flag to force its face stack out of automatic device residency. -**False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any low-confidence (`< _SPARKLE_FP_CONF` 0.65) match that shows NEITHER real-sparkle signature: a bright core (`_core_ring_margin >= _SPARKLE_FP_MARGIN` 5) OR a crisp star silhouette (`gradient_score >= _SPARKLE_FP_GRAD` 0.55). I.e. demote when `low_margin OR low_grad`. Real sparkles escape via high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong), high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), OR high gradient (a real sparkle is grad ~0.97–1.0). **The gradient condition (added 2026-06-26) closes the bright-background FP class** the margin check alone missed: a snow+sky photo and a white-background product render both scored ~0.51 at `identify`, because a bright background gives the match a HIGH core-ring margin (it genuinely IS brighter than its surroundings), so the brightness gate read it as a real overlay — but a smooth luminance blob that shape-NCC-matches the rough diamond has low gradient fidelity (the two FPs measured grad 0.105 and 0.463 vs ≥0.8 for real sparkles), so the gradient floor demotes them. The OR is **strictly a superset** of the old margin-only demotion (it only ADDS demotions on bright backgrounds, where a real sparkle keeps grad ~0.97), so it cannot regress a dark/mid sparkle (kept by margin) or a white-bg one (kept by confidence ≥ 0.65). The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs); the 2026-06-26 corpus re-sweep flipped only OpenAI/ChatGPT content (no Gemini sparkle exists there) and already-`cleaned/` outputs, all sub-0.5 (below the `identify` threshold), so no provenance verdict changed. The original gate demoted 16/495 flagged sparkles on the validation corpus (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway). `_core_ring_margin` uses the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). This gate is detection-side and unchanged by the localize -> fill refactor; the provenance prior skips it when a Google/Gemini C2PA issuer confirms the vendor. Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate` (incl. `test_bright_background_low_gradient_match_demoted`). +Regression coverage: -**The reverse-alpha removal tail is retired.** The self-verify repair (`_verify_and_repair`), the offset+scale alignment search, and the near-white `1/(1-a)` ill-conditioning survivors were all artifacts of solving the sparkle by inverting the alpha map. Under localize -> fill the footprint is reconstructed from its surroundings by the shared fill, so those failure classes and their guards no longer exist. The lesson from that era still holds and generalizes: a re-detect-confidence audit metric is gameable by reshaping the residual, so judge a visible removal by physical inspection of the footprint, not the detector alone. +- [`test_cpu_offload.py`](../tests/test_cpu_offload.py) -**The bg assets are rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor). +### Qwen plus Z-Image -## `_text_mark_engine.py` +[`noai/qwen_zimage_pipeline.py`](../src/remove_ai_watermarks/noai/qwen_zimage_pipeline.py) +implements the fixed CUDA-only two-stage profile: -`_text_mark_engine.py` — **shared base for the registered text-mark engines, extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16, and since 2026-07-21 `ladder` — the scale rungs `_tophat_best` sweeps, per-mark because 千问's two size modes do not fit the shared 3-rung comb (default `(0.8, 1.0, 1.25)`, unchanged for every other mark; densifying the SHARED ladder was measured and rejected -- see the verification plan's B2). plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`. +1. Qwen Image with Canny conditioning regenerates the frame. +2. YuNet locates faces, SAM builds masks, and Z-Image regenerates the selected + face regions. -**Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. +The profile rejects a custom model identifier. Its global and face model stack +is fixed by the implementation. When tiling is enabled, only the global stage +is tiled; the face stage runs once after the tiles are blended. -**The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap. +Regression coverage: -**Polarity-independent contrast front-end (`detect_frontend="contrast"`, 2026-07-25).** Some vendors choose light or dark text from the scene under the mark, so a white top-hat cannot represent both. The contrast response subtracts a local Gaussian luma estimate, takes the absolute residual, suppresses saturated pixels, and max-normalizes before silhouette NCC. One `_contrast_best` method supplies both the score and the winning match box; `footprint_mask` uses that same box after a successful detection, preserving the detector-to-mask parity contract. This mode was added for Tencent Yuanbao and its 0.38 gate is specific to that response. It must not be copied to another front-end or mark without recalibration. +- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py) +- [`test_cpu_offload.py`](../tests/test_cpu_offload.py) -## `doubao_engine.py` +### Tiling -`doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" detector + localizer (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). +[`noai/tiling.py`](../src/remove_ai_watermarks/noai/tiling.py) contains pure +tile planning, feather weights, tile orchestration, and region compositing. -**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask (`extract_mask` over the located box) and the shared `watermark_registry.fill` inpaints it. On corpus images this removes at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit). +Tiling engages only when requested and the long side exceeds the tile size. +It avoids an explicit full-image downscale but does not make diffusion +pixel-preserving. Each tile is still regenerated. -**The detection template (`assets/doubao_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py`** (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng — the captures are committed in `data/doubao_capture/captures/`. It is used only as the detection silhouette, not for pixel recovery. +`feather_region_composite` changes only the requested box and leaves pixels +outside it unchanged. -**The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** so a re-rasterized, corner-ward-shifted mark still falls inside the localized box; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the mark shifted on a known texture). **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2)** — an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) once fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12**; real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. The registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable template (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`. **Lesson from the reverse-alpha era (still holds): a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** +Regression coverage: -## `jimeng_engine.py` +- [`test_tiling.py`](../tests/test_tiling.py) -`jimeng_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" detector + localizer (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Shares the base with `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`. +### Upscaling and postprocessing -**The detection template (`assets/jimeng_alpha.png`) is rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black). The captured template is used only as the detection silhouette, not for pixel recovery. Solver geometry at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029. +[`upscaler.py`](../src/remove_ai_watermarks/upscaler.py) is the optional +Real-ESRGAN path used only when enlarging a small image to the minimum +resolution floor. Failure or an absent extra falls back to Lanczos. -**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it; the `WM_*` locate box is generous so a re-rasterized, corner-ward-shifted mark stays inside the localized box (the same widen that fixed Doubao). On corpus images this removes at ~100% with clean footprints (blends within a few LAB levels, no color shift). The registry gates removal on `detect`. +[`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit +grain, unsharp masking, and adaptive polish helpers. -**No committed real sample** (only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled template, and `test_recovers_shifted_mark_on_texture` guards the localize-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal. +Regression coverage: -## `samsung_engine.py` +- [`test_upscaler.py`](../tests/test_upscaler.py) +- [`test_humanizer.py`](../tests/test_humanizer.py) -`samsung_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" detector + localizer (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Shares the base but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs). +## Image input and output -**The detection template (`assets/samsung_alpha.png`) is solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064. Used only as the detection silhouette, not for pixel recovery. +[`image_io.py`](../src/remove_ai_watermarks/image_io.py) is the shared image +codec boundary. -**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it. Verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector. The registry gates removal on `detect`. +Contracts: -**Detection is locale-specific** (the string differs per language); this build detects only the Italian "Contenuti generati dall'AI" variant, so non-Italian Samsung locales are not detected — and, because detection gates removal, not removed — even though the fill mask itself is locale-independent. Other locales need their own detection silhouette — the locale string font-rendered and calibrated on real positives (the pill's `scripts/render_pill_silhouette.py` pattern), NOT an app capture (the solid/gray/white capture workflow retired with reverse-alpha). This is a pre-existing limit, unchanged by the localize -> fill refactor. +- All package OpenCV file reads and writes use `image_io.imread` and + `image_io.imwrite`. +- `to_bgr` normalizes grayscale and alpha-bearing arrays. +- `read_bgr_and_alpha` and `write_bgr_with_alpha` preserve the alpha plane. +- `imwrite` returns a success flag; every caller must check it. +- HEIC, HEIF, and AVIF fall back to Pillow plus `pillow-heif`. +- A visible no-op can preserve the original file bytes. -**No committed real sample** (only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled template (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the localize-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback). +Regression coverage: -## `qwen_engine.py` +- [`test_image_io.py`](../tests/test_image_io.py) +- [`test_cli_robustness.py`](../tests/test_cli_robustness.py) -`qwen_engine.py` — **a thin `TextMarkEngine` subclass (config only), registered 2026-07-21.** visible Qwen (Alibaba Tongyi Qianwen) "千问AI生成" detector + localizer (cv2/numpy, no GPU), bottom-right, the same GB 45438-2025 6-glyph house style as Doubao (2-glyph vendor prefix + mandated `AI生成` tail; the vendor's tri-lobe logo precedes the text and is deliberately NOT in the silhouette -- logos vary between releases, the CJK run is what discriminates). The detection silhouette `assets/qwen_alpha.png` is font-rendered synthetic (`scripts/render_vendor_silhouettes.py`), never cut from an upload. Feeds `identify` as the medium-confidence `visible_qwen` signal via the registry. +## Adding or changing behavior -**Why this registration took two attempts:** the 2026-07-18 attempt died at n=1 positives; the unlock was the TC260 label's `ContentProducer` field, whose USCC names the signing entity and partitions carriers into per-vendor cohorts from metadata alone (`scripts/vendor_cohort_harvest.py` -- 117 labelled Qwen frames, owing nothing to any pixel detector). Every constant was then MEASURED on that cohort against 286 hand-labelled clean frames (`scripts/vendor_mark_calibrate.py`), not inherited from Doubao: +For a new visible mark: -* **Basis `short`** (frac_short CV 0.189 vs width 0.273). -* **Per-mark 2-rung `ladder=(0.78, 1.27)`** against `alpha_width_frac` 0.160: the mark sits in TWO size modes (~0.124 and ~0.203 of the short side, ratio 1.64, both clusters tight) -- wider than the shared 3-rung ladder's 1.5625 span, so the best single fraction covers only 74.5% and the small mode lands in the comb's collapse zone (real marks at the exact rung score ~0.94 vs ~0.67 on the shared ladder). A 4-rung variant scored strictly worse (its big-mode rung sits 4.6% off the mode). The shared default is untouched for every other mark. -* **Fitted locate box** (`width_frac` 0.231, `height_frac` 0.074, margins ~0.021): the real mark sits ~0.025 of the short side off the right edge, and Doubao's box (0.004 margin) clipped the first glyph -- an exact-size template collapsed 0.73 -> 0.26 on a real frame. -* **`alpha_height_frac` 0.0416 from the aspect fit** (p50 aspect 0.260 at the winning width) -- not the silhouette's own aspect (0.2219) and not Doubao's ratio. -* **Gate 0.45**: clean p99 0.301 / max 0.316 (286 frames), and every cohort frame >= 0.45 carries a visible mark (83 of ~96 eyeballed visible marks fire = 86% recall of visible marks; the misses are white-on-near-white contrast losses). 0.45 was picked over 0.32 for margin against unseen clean content at zero measured recall cost. -* **STRICT ONLY (`provenance_ncc_factor` 1.0):** the score band just below the gate is dominated by non-Qwen banners on same-cohort frames (a 夸克 anti-forgery strip at 0.274, a 造点 mark at 0.253), so a provenance-relaxed arm would be mostly false fills. There is no provenance mapping for qwen and no relaxed arm. -* **No rival margin:** 0 cross-fires on 400 Doubao-marked / 298 Jimeng-marked / 286 clean frames at the gate, while a 0.10 margin would have cost ~10% of genuine Qwen detections. A confident Qwen detection suppresses the Jimeng pill exactly like Doubao's does (`_keep_pill` -- a Qwen frame is TC260 too but is not Jimeng-basic). +1. create a synthetic detection silhouette; +2. add or extend a vendor engine; +3. add one registry entry; +4. test detection, false positives, localization, and actual pixel change; +5. update [supported signals](supported-signals.md). -**Parity:** detect -> cv2 fill -> re-detect is clean on **83/83** real cohort marks, no empty masks (the `tophat` faint-mask fallback rides the same `_tophat_best`); the e2e suite drives one live cohort positive through the real CLI (`scripts/real_examples_e2e.py`, qwen bucket = symlinks under the gitignored `_visible_datasets/`). Regression: `tests/test_qwen_engine.py` (pins the ladder, the strict-only factor, the box anchor, and both size modes at a score floor that discriminates the shared-ladder and Doubao-margin mutations). +For a new metadata signal: -**The clean-arm contamination trap (load-bearing for any future calibration):** the 2026-07-18 `present: []` labels are in the vocabulary of the REGISTERED marks only, so 146 of the 432 "clean" frames sit in a TC260 cohort -- including Qwen-cohort frames visibly carrying 千问AI生成. They made up the clean arm's entire top tail (clean p99 0.37 -> 0.69). `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort from the clean arm; a gate read off the unguarded arm is meaningless. +1. add the scanner; +2. add every supported removal placement; +3. verify the output through `strip_and_verify`; +4. add identification and removal tests; +5. update [supported signals](supported-signals.md) and, when relevant, + [the watermarking landscape](watermarking-landscape.md). -## `yuanbao_engine.py` +For a diffusion change: -`yuanbao_engine.py` — **thin `TextMarkEngine` subclass, registered 2026-07-25.** Tencent Yuanbao's standard visible mark is a compact italic two-line block, `元宝` over `AI生成`, at the bottom-right. It feeds `identify` as `visible_yuanbao`. - -* **The earlier measured negative was invalidated at the renderer.** A negative shear was applied without an x translation, clipping much of the lower `AI生成` line off the left edge while retaining a wide blank tail. The matcher then squeezed that malformed asset into the fitted geometry, so the old "no separation" result measured a renderer bug rather than the mark. `render_vendor_silhouettes.py` now translates before negative shear and tightly crops the result. -* **Polarity-independent `contrast` front-end:** real Yuanbao frames switch between light-on-dark and dark-on-light stamps. Absolute local-luma residual preserves both forms, while the white top-hat misses the dark one. The synthetic silhouette uses Hiragino Sans GB W6, tight line spacing, a two-pixel dilation, and -0.60 shear. -* **Measured gate and anchor:** gate **0.38**, STRICT ONLY, plus a bottom-right anchor requiring both margins at most 0.04 of the short side. Across 33 byte-unique cohort frames, 28 carry the standard two-line mark and 26 fire (92.9% recall of that variant). The guarded clean arm is 0/286 fires, maximum score 0.348. -* **Removal parity:** detect -> cv2 fill -> re-detect is clean on 26/26 detected real marks. The detector's own match box is the footprint, with median full-frame area 0.70% and maximum 0.95%. -* **Known variant limit:** one frame carries a separate single-line photographer overlay rather than the standard two-line stamp. It stays unregistered because one example cannot support a recall or false-positive calibration. - -Regression: `tests/test_yuanbao_engine.py` covers both polarities, the anchor, the footprint, registry wiring, and detector-clean removal. A confident Yuanbao detection suppresses the Jimeng pill because both can occur on TC260-labelled content but Yuanbao is not Jimeng-basic. - -## `runninghub_engine.py` - -`runninghub_engine.py` — **thin `TextMarkEngine` subclass, registered 2026-07-22.** RunningHub (hosted ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort) "RunningHub AI生成" detector + localizer, **top-left** (the first `corner="tl"` mark), faint mid-gray latin+CJK text. Feeds `identify` as `visible_runninghub`. - -* **`gray` front-end (the third one, added for this mark):** the mark's faint gray is suppressed by the white top-hat to clean-arm levels (positives 0.16-0.23 vs clean p99 0.31), while raw-grayscale silhouette NCC separates (positives 0.38-0.54 vs clean p99 0.264 / max 0.304 on 283 guarded clean frames). It is contrast-DEPENDENT, unlike tophat -- one method `_gray_best` serves both detection and the mask, same one-method parity contract as `_tophat_best`. -* **Tight ladder (0.95, 1.0, 1.05) exactly on the measured 0.32-of-width:** the NCC comb is razor-sharp in size (0.537 on-size, 0.223 at +5.6% off -- Qwen's comb behaviour, measured again here), so the shared 3 rungs (nearest rung 5.6% off) collapsed the match to 0.22 and the first calibration showed no separation at all. -* **Anchor gate in `detect`:** the full-corpus sweep (`data/spaces/_sweep_new_marks.py`, 42009 files) fired on 37 outside-cohort frames at 0.34-0.38 (hair tops, shelves, window frames, CJK banners) -- no NCC threshold separates them from the 0.381 positives. Every positive sits at the measured corner (x 0.008-0.014, y 0.005-0.007 of the frame) and every false fire off it (x 0.013-0.150, y 0.009-0.045), so detection additionally requires the match box inside x<=0.025 / y<=0.015 of the frame. 0/37 false, 4/4 positives kept. -* **Footprint is always the detector's match box** (never the binary blob): the blob under-segments the faint head glyphs and left "Runni" unremoved (caught visually on the first removal). Gate 0.34, STRICT ONLY. Regression: `tests/test_runninghub_engine.py`. - -## `baidu_engine.py` - -`baidu_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** Baidu (USCC 91110000802100433B, 16-frame cohort) "百度 AI生成" detector + localizer, bottom-right: a white bold 百度 text run + a separate white rounded tag with dark "AI生成". Feeds `identify` as `visible_baidu`. - -* **Detection keys on the 百度 text run ONLY.** A two-component (text+pill) template was measured and rejected: the solid white pill is a bright-blob magnet and both front-ends scored the clean arm at cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5). The text-only silhouette separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is still removed: the footprint extends to the corner (below). -* **Two load-bearing rival margins** (`rivals=("doubao_alpha.png","qwen_alpha.png")`): 百度 vs 豆包 share their second glyph, and 百度 vs 千问 are near-identical after binarization -- at the 0.37 gate the template fired on 45.8% of 400 Doubao-marked frames and, on the 741-frame blind-labelled eval set, on 12 Qwen-marked frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks, Qwen's by 0.17-0.35 on Qwen marks; the 0.10 margin suppresses all crossfire at zero genuine-Baidu cost (cohort fire+m == fire). -* **Gate history, each step measured:** 0.37 from the clean arm (max 0.352) -> 0.43 after the eval-set crossfires (the one 抖音 AI创作 fire at 0.425 named no registered rival) -> **0.48** after the full-corpus sweep put outside-cohort TRUE carriers at 0.50-0.66 vs the false arm max 0.47 (大众点评 UI, a math blackboard, an 80s banner). Cohort keeps 7/16 (all true); the sweep also found 6 metadata-STRIPPED true Baidu carriers the TC260 cohort cannot see -- the direct evidence that registration pays beyond the cohort. -* **Custom `footprint_mask`:** the tag's flat white interior gives no top-hat response (a top-hat answers edges, not flats), so the base blob bbox ended at the text run and the fill left the tag as a ghost. The mask is the detector's match box extended RIGHT to the corner. STRICT ONLY. Regression: `tests/test_baidu_engine.py`. - -## `liblib_engine.py` - -`liblib_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A, 15-frame cohort) triangle logo + "LibLibAI" wordmark detector + localizer, **bottom-CENTER** (the first `corner="bc"` mark; the locate box is horizontally centered). Feeds `identify` as `visible_liblib`. - -* **The discriminative lever is the silhouette FONT.** With the CJK house font (STHeiti) the cohort scored 0.31-0.47 against a full-corpus false arm (latin UI text bands, website screenshots) at 0.50 -- no separation at any gate. Measured across 7 candidate fonts, **Arial** lifts the cohort to 0.42-0.73 and DROPS the false arm to max 0.398: generic latin text matches the wrong font less, which is where the discrimination comes from. Gate 0.42 keeps all 8 marked cohort frames (0.43-0.59). -* **Per-mark size floor (`_MIN_SHORT_SIDE=480`):** the one false fire with the final template was a 200x200 icon (0.444, on a 20px template). The shared `_MIN_DETECT_SHORT_SIDE` (200) is a crash guard, not a discrimination floor; the template needs ~48px to discriminate. -* **Custom `footprint_mask`:** the base blob bbox was wrong in both directions -- it bled UP into background structure (ate a shirt's real print on the 768x1024 cohort frame) and never owned the triangle logo. The mask is the detector's match box extended LEFT by ~1.3 glyph heights (the logo is ~1.0x the glyph height, gap ~0.3x, measured on the cohort zoom). STRICT ONLY. Regression: `tests/test_liblib_engine.py`. - -## `region_eraser.py` - -`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends. -- `cv2` (default, no deps): `cv2.inpaint`. -- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. Like `erase_lama`, it crops a padded region around the mask (`pad = max(256, 2*bbox)`), feeds only that crop to the ONNX model, and pastes only masked pixels back — but since MI-GAN accepts arbitrary dims (unlike LaMa's fixed 512² square) the crop is fed at NATIVE resolution (no resize). This **bounds the ONNX working set by the mark size, not the image**: feeding the whole frame made peak RAM scale with the upload (~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds it roughly constant (~0.6-0.9 GB), so a memory-tight host (a 1-2 GB web worker) can run MI-GAN on a 25 MP upload. The crop does not degrade the fill — a small mark only needs local context, and on real marks the cropped fill is on par with / sometimes cleaner than the full-frame fill (a tighter view gives the GAN less room to hallucinate large background structure; verified by eye on real Gemini/Doubao marks + a ground-truth reconstruction sweep). **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(crop_mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.19 s. This is the **preferred default fill** for the visible localize -> fill path. -- `lama` (extra `lama`, `Carve/LaMa-ONNX` Apache-2.0, ~200 MB): `erase_lama` crops a padded region around the mask, runs at LaMa's fixed 512² input, pastes only masked pixels back. Best quality but ~4.7 GB peak — explicit opt-in only, NOT auto-selected. -Lazy `_get_{lama,migan}_session` singletons; `{lama,migan}_available()` guard the optional imports (both == onnxruntime present). Note both extras install the same onnxruntime, so the two `*_available()` checks are identical — the fill's `auto` backend therefore resolves to MI-GAN whenever onnxruntime is present, else cv2, and big-LaMa is reachable only by an explicit `lama` backend (`--backend lama` on `erase`, or the shared fill's `backend="lama"`). - -**LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal. - -## `invisible_watermark.py` - -`invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. - -**Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs. - -## `trustmark_detector.py` - -`trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton guarded by a double-checked `threading.Lock` so concurrent callers do not double-download the weights, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. - -**False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise. - -## `noai/watermark_remover.py` - -`noai/watermark_remover.py` — the `WatermarkRemover` class has four diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id`). `sdxl`/`controlnet` share the SDXL base (`DEFAULT_MODEL_ID`); `qwen` is its own base (`QWEN_MODEL_ID`); `qwen-zimage` delegates to the fixed two-stage stack in `noai/qwen_zimage_pipeline.py`. - -**`sdxl`** (renamed from `default` 2026-06-09; `default` kept as a back-compat alias via `normalize_profile`) runs plain SDXL img2img (`_run_img2img`); it is the lighter opt-down alternative (no ControlNet weights). - -**`qwen`** (`_run_qwen`, `_load_qwen_pipeline`) runs `QwenImageImg2ImgPipeline` on `Qwen/Qwen-Image` (20B MMDiT, Apache-2.0 code AND weights). The scrub still comes from the img2img `strength`; Qwen's value is **text preservation** (incl. CJK and small text). **Metric-measured nuance (2026-06-19, `scripts/fidelity_metrics.py`, do NOT trust the eyeball here — it misled). Compare ONLY at each pipeline's oracle-confirmed scrub floor (outputs where SynthID is removed in BOTH — an equal-strength compare is invalid where it leaves one un-scrubbed; Qwen at 0.15 does not clear Gemini): Qwen wins TEXT (lower OCR CER across EN/RU/ZH, perfect Chinese) but controlnet wins FACES (higher Laplacian-variance retention and lower LPIPS — Qwen smooths faces MORE; ArcFace identity favors controlnet 0.546 vs 0.331 at the Gemini floors).** So Qwen is the better text-preserving remover, NOT a universal fidelity win — controlnet's canny edge map holds face skin detail better. Specifics: bf16 on CUDA (fp16 risks overflow on the 20B MMDiT — see the dtype branch in `__init__`); loads `QWEN_MODEL_ID` unless `--model` is overridden; the call shape lives in the pure module helper `_build_qwen_kwargs` (unit-tested without torch in `tests/test_platform.py::TestQwenKwargs`), which uses Qwen's `true_cfg_scale` (NOT SDXL's `guidance_scale` — the CLI `--guidance-scale` maps onto it; ~4.0 is typical, the SDXL default 7.5 is high for Qwen) and an explicit `negative_prompt` (`_QWEN_PROMPT`/`_QWEN_NEGATIVE`). It is CUDA/cloud-class (the 20B does not fit MPS), so `_run_qwen` has NO MPS->CPU fallback — an error propagates. `_load_qwen_pipeline` raises a clear ImportError if the installed diffusers lacks `QwenImageImg2ImgPipeline`. **CERTIFIED oracle floors (Modal A100-80GB, 2026-06-20): OpenAI 0.10 (seed-robust — clean on seeds 0-4), Gemini 0.25 (seed 0 verified on 2 images; the Gemini oracle rate-limits volume seed-repeat, so PIN a seed in prod). The Gemini floor (0.25) is HIGHER than the certified 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 the pure `_qwen_target_size`); WITHOUT it the img2img pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (the abba 2816x1536 case came back 1024x1024, distorting the scene and garbling text — fixed 2026-06-20, tested in `TestQwenKwargs`).** Fidelity vs controlnet was measured at the certified floors (`scripts/fidelity_metrics.py`), NOT eyeballed. **`qwen` is a MANUAL opt-in only — there is NO auto-router (one was prototyped and DROPPED, see below).** It wins ONE niche: clean body text on a plain background, NO faces (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES 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). **`--pipeline auto` + a faces+text mixed dual-pass were built and DROPPED (2026-06-20):** on the canonical faces+text case controlnet wins EVERY metric incl. text, so grafting qwen text would only hurt; and "text→qwen" is undecidable cheaply (it is body-vs-display text that matters). The router/detector/mixed modules were removed; the geometry fix + the Qwen strength ladder were kept (they make the manual `--pipeline qwen` correct). **Do NOT retry "add a Qwen ControlNet to close the face gap" — it was built, measured, and CLOSED 2026-06-20:** a DiffSynth blockwise-canny Qwen ControlNet did not restore face skin texture (lapvar flat 0.40, canny carries edges not skin grain) and no permissively-licensed Qwen tile/detail/skin ControlNet exists anywhere (all conditioning is geometry). The Z-Image face-crop lead is now implemented as the separate `qwen-zimage` profile and has direct face metrics on two official upstream examples plus one crowded fixture. Its exact current six-output candidate is negative in the corresponding provider oracles, while broad seeded removal and text behavior remain unmeasured. Full record + the deep-research sweep in `docs/qwen-improvement-research.md`. - -## `noai/qwen_zimage_pipeline.py` - -`qwen-zimage` is the recommended high-quality, manual CUDA profile ported from `cebeuq/Synthid-Bypass` v2. `controlnet` remains the default for compatibility and cost; callers that prioritize output fidelity, especially face identity, should select `qwen-zimage`. The full-frame stage uses DiffSynth `QwenImagePipeline` with `Qwen/Qwen-Image-2512`, `lightx2v/Qwen-Image-2512-Lightning` at four steps, and `DiffSynth-Studio/Qwen-Image-Blockwise-ControlNet-Canny`. The Lightning scheduler uses `exponential_shift_mu=log(3)`, the DiffSynth equivalent of the source graph's AuraFlow shift 3. Its default denoise is the source custom node's exact megapixel formula at adaptive level 6; an explicit `--strength` overrides that global value. Its profile seed defaults to `0`, matching the oracle-negative release candidate; an explicit seed still wins. Other profiles keep their existing random default. - -The face stage detects boxes on the original input with OpenCV YuNet and follows the active Impact Pack SAM path from the source graph: each box supplies both the `center-1` positive point and the box prompt; proposals at predicted IoU >= 0.93 are unioned, or the highest-IoU proposal is used when none passes; the result is intersected with the detector box. The inactive MediaPipe node in the workflow has no downstream link. YuNet uses its own calibrated score threshold, 0.5: copying the upstream YOLO threshold of 0.2 admitted background/decorative false positives and duplicate boxes, which multiplies the serial face-stage cost. The 0.5 gate retained all visible faces in the public and upstream comparison fixtures while reducing the crowded group from 36 boxes to 18 and the poster from 30 to 10. Crops expand by the source graph's factor 2.5 and run `Tongyi-MAI/Z-Image-Turbo` for eight steps. Every face uses the denoise derived from the largest face's area ratio, matching the source graph's `largest_face` mode, then pastes through the clipped SAM mask with feather 10. If SAM fails, a box-derived ellipse mask is used rather than aborting the global removal. - -The active graph was traced from upstream commit `3007d0351596ae0a78b7074dae7ad179710b1e48` and its linked Impact Pack implementation, not inferred from the README or node names. YuNet is one intentional substitution: the active reference path uses an Ultralytics YOLO face detector, while this package avoids adding its AGPL runtime. The first-use model download targets GitHub's media endpoint rather than the repository's 131-byte Git LFS pointer and verifies the published 232589-byte model by SHA-256 before caching it. The other runtime differences are full safetensors rather than quantized GGUF models, DiffSynth's first-order Qwen Lightning and Z-Image FlowMatch samplers rather than the graph's DPM++ 2M / SGM Uniform and `res_2s` / `bong_tangent` pairs, and the absence of the detailer's 20 px latent noise-mask feather. This port regenerates the expanded crop and composites only the feathered SAM mask; generated pixels outside the face mask are discarded. The architecture and active decision path match the graph, but the runtime is not bit-identical to ComfyUI. - -The implementation has its own `qwen-zimage` optional dependency group because DiffSynth, torchvision, and the additional model downloads are large. `--model` is rejected for this fixed profile. `--tile` runs only the global Qwen stage through `noai.tiling.run_tiled`; the global denoise is still derived from the full-frame megapixel count and the same seed is reused for each deterministic tile. After feather blending, YuNet, SAM, and Z-Image run once against the full original/global result, so faces are neither duplicated nor dropped at tile boundaries. The SDXL minimum-resolution floor is disabled, and CLI adaptive polish defaults off for this profile, so the two-stage result is not followed by a repository-specific post-process. DiffSynth requires the PIL input, Canny control, and explicit dimensions to agree on the same /16 latent grid: `_resize_to_target` aligns global and face pixels before `build_global_kwargs` / `build_face_kwargs`, and the global output is restored to the exact original size. Passing floored dimensions with unaligned pixels caused a real VAE/noise-grid shape mismatch on the official example 12 input; the call-shape assertions were observed failing before the fix. An explicit `--adaptive-polish` still opts in. Pure helpers cover both adaptive denoise formulas, /16 dimensions, call shapes, Canny generation, masked compositing, and the qwen-zimage tiling seam; an integration test guards dispatch from `WatermarkRemover`. A real 4096x3072, 20-tile H100 smoke completed through this exact branch on 2026-07-25 with dimensions preserved and no visible or 99th-percentile gradient outlier at a tile boundary; the measured runtime, memory, and fidelity figures are in `docs/known-limitations.md`. The exact seed-0 non-tiled release candidate is oracle-verified; tiled outputs still require their own provider-oracle check. - -DiffSynth normally offloads the Z-Image text encoder, DiT, and VAE to CPU after every face call. That placement dominated crowded-scene latency even though the eight diffusion steps themselves were fast. `resolve_face_model_residency` keeps the full face stack on CUDA when total VRAM is at least 64 GiB; smaller cards preserve the original offload path. Callers can explicitly override the decision through `QwenZImagePipeline.keep_face_models_on_device`. The implementation intentionally loads the stack with the normal CPU-managed config, rewrites the managed modules' offload/onload/preparing placement to their CUDA computation device, and moves them once. Loading the same models directly into CUDA cut face inference further but increased setup from 32.282 to 254.632 seconds, making a cold single request more expensive; that variant was rejected. The shipped fast-load residency changes only model placement, not weights, dtypes, prompts, seeds, schedules, masks, or compositing. On the 18-face H100 fixture it produced a pixel-identical output versus offload while reducing face regeneration from 181.764 to 38.272 seconds and total inference from 262.072 to 133.543 seconds. Setup rose only from 32.282 to 43.960 seconds, so cold setup plus inference fell from 294.354 to 177.503 seconds. Peak CUDA allocation rose from 24.364 to 43.477 GiB. - -Both stage prompts are constants, but DiffSynth 2.0.18 exposes their embeddings only through internal PipelineUnits and re-runs the corresponding text encoder on every call. `_cache_static_prompt_embeddings` wraps the exact prompt unit selected by its output signature and memoizes its returned tensors by prompt text. It bypasses the cache whenever `edit_image` participates, so image-conditioned embeddings cannot be reused accidentally. With CFG 1.0, the unit runner already shares the positive result with the negative branch. On a warm H100 sequence, a preceding no-face request populated the Qwen prompt cache; the 18-face case then fell from 133.543 to 78.474 seconds. The global stage fell from 89.720 to 43.334 seconds, and the face stage from 38.272 to 30.592 seconds as the fixed Z-Image prompt was encoded only once. Both the no-face and 18-face cached outputs were pixel-identical to their no-cache references, and peak allocation remained 43.477 GiB. The Qwen saving applies from the second request in a container; the Z-Image saving applies within the first multi-face request after its first face. - -Validation status is deliberately narrower than the existing `qwen` certification. The user reported the upstream workflow as Gemini-oracle negative. The current port has API, unit, dispatch, GPU integration runs, and a direct comparison with two official upstream before/after pairs. On 2026-07-25 the user checked all six current outputs in the provider-separated `full-clean-final-candidate-2026-07-25-by-oracle` bundle with the corresponding provider oracles and confirmed that none retained SynthID or the provider generation signal. The checked bytes used the complete `visible -> qwen-zimage -> metadata` route, the calibrated YuNet 0.5 gate, and the shipped prompt-cache/model-residency optimizations. This supersedes the earlier first-port batch check as the release-candidate result. It certifies the exact seed-0 outputs, not every seed, resolution, or content class. Broad text certification remains open, so the profile stays an experimental manual opt-in even though it is the recommended quality mode. - -**Direct port measurement (2026-07-24, seed 0):** on official upstream examples 10 and 12, local `qwen-zimage` retained ArcFace identity at 0.950 and 0.947 versus 0.701 and 0.548 for the current polished ControlNet output. Face LPIPS was 0.045 and 0.015 versus ControlNet's 0.105 and 0.061. The published upstream outputs retained identity at 0.976 on both and face LPIPS at 0.172 and 0.014; upstream example 10 is strongly penalized by its published downscale, while example 12 was compared at the same published dimensions. Local whole-image LPIPS / SSIM were 0.167 / 0.765 and 0.085 / 0.896, better than the published upstream 0.259 / 0.627 and 0.111 / 0.777. ControlNet still preserved more texture, but the faces drifted. The earlier crowded `gemini_3` run showed the same identity direction, 0.795 versus 0.587/0.588, while smoothing skin and changing the full frame more. The final July 25 oracle check supplies the removal verdict for the exact current candidate bytes only. - -Two integration failures from the first Modal passes are regression-guarded. SAM model pixels must be cast to the model's bfloat16 while geometric prompts remain float32; casting everything either fails or changes prompt semantics. SAM `pred_masks` and `iou_scores` must then be converted through float32 before NumPy because NumPy rejects bfloat16. A third visually severe failure came from accepting an unconstrained SAM mask: it split faces with hard seams. The center point plus box prompt and the final detector-box intersection are both load-bearing. - -**`controlnet`** (**the DEFAULT pipeline since 2026-06-09** for `invisible`/`all`/`batch` and both engine ctors; `_run_controlnet`, `_load_controlnet_pipeline`) runs `StableDiffusionXLControlNetImg2ImgPipeline` with the SDXL-native canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` (`watermark_profiles.CONTROLNET_CANNY_MODEL`): the control image is `cv2.Canny(gray, 100, 200)` stacked to 3 channels (`_CANNY_LOW`/`_CANNY_HIGH`, prompt `_CONTROLNET_PROMPT` / `_CONTROLNET_NEGATIVE`). - -**Removal comes from the img2img regeneration (`strength`); the ControlNet only PRESERVES text and face STRUCTURE via the edge map.** - -No original pixels are copied or frozen, BUT **validation 2026-06-04 disproved the old "so SynthID does not survive" claim: SynthID CAN survive controlnet on photoreal/high-detail content.** - -At the shared low removal strength the canny edge-conditioning keeps the regeneration so close to the original that the pixel perturbation that destroys SynthID does not happen (oracle-confirmed: an OpenAI bracelet photo + a 9-face grid read **SynthID-detected** after controlnet at strength 0.10/0.15, but **SynthID-not-detected** after the `default` pipeline at the SAME strength + resolution -- only the pipeline differed). - -**But the reverse also holds: a flat-graphic logo/poster SURVIVED `default` while clearing controlnet** -- removal at the low strength is content×pipeline dependent and neither pipeline is universally safe; the real lever is a higher strength. See the controlnet Known-limitations bullet for the full table + root cause. Canny holds face STRUCTURE but NOT identity (the regenerated face drifts in likeness -- canny carries edges, not identity). The drifted cleaned face is the LEAST-AI state we can reach without re-introducing SynthID; the library does NOT ship a face-restore extra. Every restore approach we evaluated (GFPGAN-on-cleaned, PhotoMaker-V2 txt2img, InstantID txt2img, InstantID img2img-on-cleaned at three parameter sweeps, 2026-06-04 - 2026-06-08 Modal cert sweeps) regenerated the face from an ArcFace embedding via SDXL diffusion -- which makes the output face look MORE AI-generated, not less. Empirical conclusion in `docs/synthid-robust-identity-research-2026-06-08.md` "Empirical follow-up". For production face preservation, ship the cleaned image as-is. `controlnet_conditioning_scale` (ctor arg, default 1.0) is the structure-preservation knob. Same dtype rule as `default` (fp32 on cpu/mps, fp16 only on cuda/xpu; the fp16-fixed SDXL VAE `_SDXL_FP16_VAE_ID` is swapped in on fp16 GPUs -- issue #29) and the same MPS->CPU fallback (reload on cpu/fp32, drop a non-cpu generator, retry once). - -**Tiled diffusion (`tile`/`tile_size`/`tile_overlap` ctor-path args, CLI `--tile`, issue #10):** for large inputs that OOM at native resolution, `remove_watermark` can process the diffusion pass in overlapping sliding-window tiles instead of one forward pass — the lossless alternative to a `--max-resolution` downscale. For SDXL, ControlNet, and base Qwen, the single-image generation closure was refactored into `_generate_one(img)` (dispatches controlnet/img2img, generator shared so the seed advances deterministically across tiles), and `_generate()` routes it through `noai.tiling.run_tiled` when `tile` is set AND `max(init_image.size) > tile_size` (a sub-tile image runs one pass unchanged). The ControlNet canny edge map is rebuilt per tile inside `_generate_one`, so structure preservation is tile-local. `qwen-zimage` takes a different route: `_generate()` dispatches once to `QwenZImagePipeline.run`, that runtime tiles only `_run_global`, then performs one full-frame face stage after blending. See `noai/tiling.py` below and the tiled-diffusion subsection in `docs/known-limitations.md` for the geometry, the partition-of-unity blend, and the quality caveat. - -## `noai/tiling.py` - -Pure sliding-window tiling for the diffusion path (no torch import; numpy/PIL only). `plan_tiles(w, h, tile_size, overlap)` returns a row-major grid of uniform-size `Tile` boxes — every tile is exactly `tile_size`, with the last tile on each axis pulled back flush to the far edge (`_axis_positions` clamps a pathological `overlap >= tile` to `tile - 1` so the step stays >= 1). `feather_weights(w, h, overlap)` is a separable linear taper (1 in the interior, ramping toward each edge) floored at `_WEIGHT_EPS` so it is **strictly positive everywhere** — that makes the normalized `accum / weight_sum` blend a partition of unity, so identical/unchanged tiles reconstruct the input exactly (the seam-free guarantee). `run_tiled(generate_tile, image, tile_size, overlap, set_progress)` is the orchestration loop: crop each planned tile, call `generate_tile` (one diffusion pass on a single PIL tile — injected, so this stays decoupled from the pipeline), resize a latent-grid-rounded result back to the exact tile size, and feather-accumulate. All three are unit-tested without the model (`tests/test_tiling.py`: axis math, grid coverage, taper shape/symmetry/positivity, identity reconstruction, per-tile call count, and the resize-back path). New blend tuning belongs in these pure helpers, not inlined into the runner. - -`feather_region_composite(base, regenerated, box, *, feather)` is the pure region-targeted compositor for **AI-enhanced composites** (roadmap P1#8; `identify` `ai_source_kind == "enhanced"`, digitalSourceType `compositeWithTrainedAlgorithmicMedia`). It blends `regenerated` over `base` inside `box = (x, y, w, h)` with a separable linear taper of `feather` px at the box edges (the taper anchors to ~0 at the boundary, so unlike `feather_weights` it is NOT floored — the result equals `base` EXACTLY outside the box), preserving dtype and supporting HxW or HxWxC. It backs `WatermarkRemover.remove_watermark(region=..., region_feather=...)`: the remover regenerates the frame (or tiles), then composites only the AI box back over the original input, so the real photo outside the box stays pixel-exact and only the AI region is scrubbed. The box is caller-supplied (a C2PA composite manifest carries no reliable machine-readable region); the no-model lossless region path remains `region_eraser.erase`. Unit-tested in `tests/test_tiling.py::TestFeatherRegionComposite` (outside-box exactness, interior == regenerated, hard-paste at feather 0, monotonic seam ramp, dtype/grayscale/clamp/empty-box/shape-mismatch). - -## `auto_config.py` (REMOVED 2026-06-09) - -**`auto_config.py` + the content-detection layer were REMOVED 2026-06-09.** - -History: `auto_config.plan()` was a content-adaptive planner that detected faces/text/edges (bundled OpenCV YuNet + PP-OCRv3 DBNet models) to route the pipeline and toggle the adaptive polish. Once `controlnet` became the default-and-only auto pipeline (it no longer downgrades a structure-less image to `sdxl`) and the adaptive polish was confirmed to **self-gate by detail level** (`humanizer.adaptive_polish` no-ops when the cleaned image already meets the input's Laplacian variance, so it does real work only on over-smoothed photo/face texture and ~nothing on text/flat), the detection no longer changed any behavior — it only annotated a `reason` string. So the whole layer was deleted: `auto_config.py`, `tests/test_auto_config.py`, and the two detection assets (`assets/face_detection_yunet_2023mar.onnx`, `assets/text_detection_ppocrv3_2023may.onnx`, ~2.6 MB). - -**`--auto` is now a DEPRECATED no-op** (`cli._resolve_auto_polish`): controlnet is already the default pipeline AND the adaptive polish is ON by default, so `--auto` has nothing left to do — it only prints a deprecation warning and passes `adaptive_polish` through unchanged (an explicit `--no-adaptive-polish` still wins). (Originally it re-enabled the polish; once the polish default flipped to ON the same day, the parameter-source branch became dead and was dropped.) The **adaptive polish itself lives on** in `humanizer.adaptive_polish` (CLI `--adaptive-polish/--no-adaptive-polish`, **ON by default since 2026-06-09 for the original profiles** — it self-gates to a no-op where there is no detail deficit; `qwen-zimage` defaults it off to preserve its upstream-matching output, and an explicit flag overrides either default) — see the `humanizer` test note. `batch` resolves the polish once before the loop (one warning) and caches the invisible engine per pipeline (`ctx.obj["_inv_engines"]`). - -## Content `--pipeline auto` router + faces+text mixed dual-pass — PROTOTYPED and DROPPED (2026-06-20) - -A `--pipeline auto` content router (`pipeline_router.py` + `content_detect.py`: Haar faces + MSER text → route text→qwen / faces→controlnet / both→mixed) and a faces+text **mixed dual-pass** (`mixed_pipeline.py`: scrub the whole frame on BOTH pipelines, then graft the qwen text regions onto the controlnet base via `tiling.feather_region_composite`) were built, run on Modal (the abba poster: faces + display text), measured, and **removed**. Why it failed: -- On the canonical faces+text image **controlnet wins EVERY metric, including text** (CER 0.114 vs qwen 0.379; ID 0.64 vs 0.36; lapvar 0.71 vs 0.59) — canny holds the existing letter shapes, qwen re-renders display/decorative text and garbles it. So grafting qwen text onto the controlnet base only HURTS. -- qwen beats controlnet on text ONLY for clean body text on a plain background with no faces (openai_1/2) — a niche where there are no faces to route around anyway, so `--pipeline qwen` alone covers it. The faces+clean-body-text intersection is near-empty. -- "text→qwen" is not cheaply decidable: it is body-vs-display text that matters, which face/text detectors can't tell apart. MSER also over-fired (47% of the busy poster, incl. faces). - -KEPT from that work (independently valid for the manual `--pipeline qwen`): the qwen **geometry fix** (`_qwen_target_size` + `_build_qwen_kwargs` height/width — qwen squished non-square inputs to 1024² without it) and the **pipeline-aware `resolve_strength`** Qwen ladder (Gemini 0.25). Also kept: the `fidelity_metrics.py` one-to-one face matcher. The throwaway Modal eval scripts were removed after the run (findings recorded here and in `docs/qwen-improvement-research.md`). - -## `upscaler.py` - -`upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (spandrel boundary, top-of-file pyright pragma). `is_available()` gates on spandrel+torch (via `importlib.util.find_spec`); `upscale(bgr, device=None)` loads a lazily-built spandrel `ImageModelDescriptor` singleton (double-checked lock) and upscales by the model's native factor (x2), with a non-CPU→CPU device fallback mirroring the diffusion engine's MPS→CPU retry. Weights (`RealESRGAN_x2plus.pth`, BSD-3-Clause) download on first use to the `torch.hub` checkpoints cache; never bundled. Used only when UPscaling to the `min_resolution` floor (a `max_resolution` downscale always uses Lanczos). The wiring is `InvisibleEngine._esrgan_upscale(pil, target)` — Real-ESRGAN at native factor, then a Lanczos resize to the exact target, falling back to a plain Lanczos resize if the extra is absent or the model errors (so an optional upscaler can never break removal). The default `--upscaler` is `lanczos` (cv2, no deps). - -**ESRGAN is a generic photo/texture GAN with no face/glyph prior**, so it best fits photo/texture content and can degrade faces (glassy/asymmetric eyes -- the diffusion pass regenerates faces so the full-pipeline final recovers) and thin/small text (the GAN invents wrong strokes, and low-strength diffusion will not fix it). Verified 2026-06-04: isolated upscale lap-var ~5x Lanczos on faces+textures but glassy eyes; end-to-end `invisible` final lap-var 1634 vs Lanczos 663 with natural faces (diffusion cleaned the artifact). Kept a **manual opt-in knob** (the auto plan never selects it) with `lanczos` the default; not content-gated by design (use Lanczos for text-heavy inputs). spandrel is MIT and pulls no basicsr. Unit-tested without the model: `tests/test_upscaler.py` (availability guard + the not-installed RuntimeError) and `tests/test_invisible_engine.py::TestEsrganUpscale` (the three `_esrgan_upscale` branches via a monkeypatched `upscaler`). - -## `image_io.py` - -`image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). - -**Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** - -`imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). - -**`to_bgr(image)` (added 2026-06-09)** is the shared channel normalizer: promotes 2D grayscale / (h,w,1) / 4-channel BGRA to 3-channel BGR (a 3-channel input is returned unchanged, no copy). Use it instead of inlining the `cvtColor(GRAY2BGR/BGRA2BGR)` branch — the gemini engine and the `TextMarkEngine` base both route through it so a grayscale/BGRA input (a real Gemini-app export is opaque RGBA) does not crash the `axis=2` channel reductions. cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env. - -## CLI commands (`cli.py`) - -Full per-command behavior for the skip/exit branches summarized in `CLAUDE.md`'s "How to run". The CLI distinguishes three exit codes: success (0), hard error (1), and a "nothing to do" code (2, `EXIT_NO_VISIBLE_MARK` / `EXIT_NO_INVISIBLE_SIGNAL`) so a wrapping service (raiw.cc) can surface guidance instead of treating an unchanged image as done (the production "it didn't work" / score-0 trap). - -**Every single-image command's `source` argument declares `dir_okay=False`** (2026-07-20). `click.Path(exists=True)` accepts a directory unless told otherwise, so `identify ` sailed past argument parsing and raised `IsADirectoryError` out of `metadata.scan_head`'s `open()` — a traceback, not a usage error. Refusing it at the argument layer is the right place: every command gets it, and none needs its own check. (`batch`'s `directory` argument was already correct with `file_okay=False`.) Found by the Tier E adversarial sweep; regression: `tests/test_cli_robustness.py::TestDirectoryInputIsRejected`. - -### `all` - -Full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) that picks the fill for the localize -> fill visible removal. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped** — `all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **No-signal skip (P0#5):** step 2 also runs the same `has_invisible_target` gate (see `invisible` below) — when no invisible watermark is detectable and `--force` is not set, step 2 is skipped and the pixels are left intact, but unlike the GPU-missing skip this is a **SUCCESS (exit 0)**: the visible pass + metadata strip still ran and a file is written (the message says so without claiming the image is clean). Distinct exit semantics by design: GPU-missing = couldn't do the work (non-zero); no-signal = nothing to do (zero). Regression-guarded by `test_all_skips_invisible_on_no_signal_but_succeeds`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True. - -### `invisible` - -Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alternative to a `--max-resolution` downscale for large inputs that OOM on MPS/GPU: it engages only when the long side exceeds `--tile-size` (default 1024); tiles are feather-blended over `--tile-overlap` px (default 128); pair with `--max-resolution 0`. `--cpu-offload/--no-cpu-offload` trades speed for lower CUDA VRAM use: SDXL, ControlNet, and base Qwen call Diffusers `enable_model_cpu_offload(device="cuda")`, which moves whole model components between CPU and GPU; `qwen-zimage` instead forces its face stack to use the existing offload path rather than automatic high-VRAM residency. The flag has no effect on CPU/MPS and fails loudly if a CUDA Diffusers pipeline lacks the offload method. `--adaptive-polish` is a detail-targeted polish that self-gates to a no-op where there is no deficit; it defaults off only on `qwen-zimage`. `--auto` is deprecated and now a no-op that only warns. **No-signal skip (P0#5, roadmap):** before the diffusion runs, the command checks `identify.has_invisible_target(source)` (the `ProvenanceReport.ai_from_metadata` union: C2PA AI-issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark — visible marks do NOT count, they are a separate pass). When nothing is locally detectable it does NOT regenerate (that would only degrade a clean image — the dominant paid score-0 cause on no-watermark uploads): it writes NO output, prints guidance that does NOT claim the image is clean (a pixel SynthID is undetectable once its metadata proxy is gone), and exits **`EXIT_NO_INVISIBLE_SIGNAL` (2)** — same value/role as the visible `EXIT_NO_VISIBLE_MARK`. `--force/--no-force` (**default skip = ON**) runs the scrub regardless. The check fails SAFE (a detector exception → run, since leaving a watermark on a paid removal is worse than over-regenerating). Helpers `cli._no_invisible_signal_exit` + `identify.has_invisible_target`; regression-guarded by `tests/test_cli.py::TestInvisibleCommand::{test_invisible_no_signal_skips_and_exits_two,test_invisible_force_runs_scrub_on_no_signal,test_invisible_runs_without_force_when_signal_present,test_invisible_cpu_offload_flows_to_engine}`, `tests/test_cli.py::TestAllCommand::test_all_cpu_offload_flows_to_engine`, `tests/test_cli.py::TestBatchCommand::test_batch_cpu_offload_flows_to_cached_engine`, and `tests/test_identify.py::TestHasInvisibleTargetFailSafe`. **Test trap:** any `invisible`/`all`/`batch` test that exercises the diffusion path on a signal-LESS fixture (e.g. the synthetic `sample_png`) MUST pass `--force`, or the new gate skips step 2 (so `mock_engine.remove_watermark` is never called / `invisible` exits 2). - -### `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, 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 Qwen "千问AI生成" text strip, 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 or Qwen, 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 — **re-measured 2026-07-18 on 149 blind-labelled pill fires: 21% precise raw (CI 16-29%), 29% (CI 20-40%) among the flat footprints the guard actually PASSES, 14% among those it blocks** — 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|qwen|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` - -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** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--cpu-offload`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default except on `qwen-zimage`; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop. **Exit code (`batch` used to always exit 0, hiding failures):** `cmd_batch` raises `SystemExit(1)` when any image errored, OR when a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent so its SynthID scrub was skipped — mirroring single `all`, it emits a loud "the invisible watermark was NOT removed on N image(s)" warning and (invisible mode) copies the input through so the output dir stays complete, rather than silently dropping the signal-bearing files that most needed processing. `_process_batch_image` returns that skipped-scrub flag; the loop tallies it. Regression-guarded by `tests/test_cli.py::TestBatchCommand::{test_batch_errors_exit_nonzero, test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero}`. +1. keep model-free logic in pure helpers where possible; +2. test option propagation and dispatch without downloading models; +3. run a real model smoke for the changed model path; +4. treat provider-verifier results as specific to the exact checked output; +5. update [known limitations](known-limitations.md). diff --git a/docs/python-api.md b/docs/python-api.md new file mode 100644 index 0000000..82879bf --- /dev/null +++ b/docs/python-api.md @@ -0,0 +1,173 @@ +# Python API + +Use the high level API for normal application integration. Low level detector +and pipeline modules are intended for maintainers and specialized workflows. + +## Remove visible marks + +```python +import remove_ai_watermarks as raiw + +result, removed = raiw.remove_visible( + "watermarked.png", + "clean.png", +) +``` + +The function returns: + +- the result as a BGR NumPy array; +- a list of labels that were removed. + +An empty `removed` list means that no registered visible mark was selected. It +does not prove the image has no metadata or invisible watermark. + +### Path input + +For a path input, `remove_visible`: + +- reads metadata provenance for the default `auto` sensitivity; +- preserves a separate alpha channel; +- writes the output when an output path is supplied; +- strips AI metadata from the written output by default; +- preserves the original bytes for a same-format no-op copy. + +```python +result, removed = raiw.remove_visible( + "watermarked.png", + "clean.png", + sensitivity="auto", + backend="auto", + strip_metadata=True, +) +``` + +Set `write_noop=False` if the output path must remain untouched when nothing is +removed: + +```python +result, removed = raiw.remove_visible( + "input.png", + "clean.png", + write_noop=False, +) +``` + +### Array input + +Array inputs are BGR NumPy arrays. They do not carry file metadata or a separate +alpha plane: + +```python +import cv2 +import remove_ai_watermarks as raiw + +image = cv2.imread("input.png") +result, removed = raiw.remove_visible(image, backend="cv2") +``` + +## Inspect provenance + +Get the vendor keys used by visible removal: + +```python +import remove_ai_watermarks as raiw + +vendors = raiw.visible_provenance("input.png") +``` + +Get the full provenance report: + +```python +from pathlib import Path + +from remove_ai_watermarks.identify import identify + +report = identify(Path("input.png")) +print(report.platform) +print(report.signals) +``` + +Use `check_visible=False` and `check_invisible=False` for metadata only +inspection: + +```python +report = identify( + Path("input.png"), + check_visible=False, + check_invisible=False, +) +``` + +## Strip metadata + +```python +from pathlib import Path + +from remove_ai_watermarks.metadata import has_ai_metadata, strip_and_verify + +source = Path("input.png") +output = Path("clean.png") + +if has_ai_metadata(source): + output_path, surviving_markers = strip_and_verify(source, output) + if surviving_markers: + raise RuntimeError( + f"AI metadata remains in {output_path}: {surviving_markers}" + ) +``` + +Use `strip_and_verify` when your application reports that stripping succeeded. +It checks the written output and returns `(output_path, surviving_markers)`. +Treat a nonempty `surviving_markers` mapping as a failure. + +`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an +undecodable input through unchanged, so its return alone must not be presented +as proof that metadata was removed. + +## Remove invisible watermarks + +```python +from pathlib import Path + +from remove_ai_watermarks.invisible_engine import InvisibleEngine + +engine = InvisibleEngine( + pipeline="controlnet", + device=None, + cpu_offload=False, +) + +engine.remove_watermark( + Path("watermarked.png"), + Path("clean.png"), +) +``` + +`device=None` selects the device automatically. Supported explicit values are +defined by the CLI and runtime device resolver. + +For limited CUDA memory: + +```python +engine = InvisibleEngine( + pipeline="controlnet", + cpu_offload=True, +) +``` + +For the CUDA only high fidelity profile: + +```python +engine = InvisibleEngine(pipeline="qwen-zimage") +``` + +The `qwen-zimage` extra must be installed for that profile. + +The full `remove_watermark` signature includes strength, steps, guidance, +seeding, tiling, resolution, upscaling, and postprocessing controls. Read the +method signature in +[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use +the CLI guide for the concepts. +Defaults can differ between the Python method and CLI profile resolution, so +pass values explicitly when reproducibility matters. diff --git a/docs/qwen-improvement-research.md b/docs/qwen-improvement-research.md index 2c75995..90e2f5e 100644 --- a/docs/qwen-improvement-research.md +++ b/docs/qwen-improvement-research.md @@ -1,5 +1,9 @@ # Qwen-Image improvement research (2026-06-20) +> Research archive. This page records experiments and decisions from the date +> above. It may mention prototypes or defaults that were later changed. Use the +> user guides and current source code for the supported interface. + Cited research behind the decision **"ship the `qwen` pipeline as-is, or improve it first?"** Produced by the multi-source deep-research harness (5 search angles, 22 sources fetched, 85 claims extracted, 25 verified by a 3-vote adversarial check, 20 diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 4da176d..6f1bdbc 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -1,39 +1,85 @@ # Release and distribution -> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded -> context small. Long single-line entries were reformatted into paragraphs; -> no content was changed or summarized. +This page describes the release behavior defined in this repository. External +registry state can change independently, so verify it during a release. -Release flow and every distribution channel (PyPI, Homebrew tap, conda-forge, -ComfyUI Registry, HF Space), plus sdist/build-backend history. The CI summary -stays in `CLAUDE.md`; read this before cutting a release. +## Release sources of truth -`publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building, then uploads via `uv publish` (PyPI trusted publishing over OIDC, no token — replaced the `pypa/gh-action-pypi-publish` action so the upload no longer depends on that action's bundled twine accepting the Metadata-Version; the `id-token: write` permission + `pypi` environment + workflow filename are unchanged, so PyPI's trusted-publisher entry still matches). +The package version appears in: -**Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry — find it with `grep -n 'name = "remove-ai-watermarks"' uv.lock`, the `version =` line right below it, ~line 2246), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. +- `pyproject.toml`; +- `src/remove_ai_watermarks/__init__.py`; +- the root package entry generated in `uv.lock`. -**After the PyPI sdist is live, bump the Homebrew formula** in the separate public tap repo `wiltodelta/homebrew-tap` (`Formula/remove-ai-watermarks.rb`): update `url` to the new sdist URL (from `https://pypi.org/pypi/remove-ai-watermarks//json`, the `sdist` entry's `url`) and `sha256` to its hash, commit + push there — otherwise `brew install wiltodelta/tap/remove-ai-watermarks` keeps installing the old version. The formula is a core-only venv that pip-installs the sdist (no vendored resources, so pip pulls the binary numpy/opencv wheels per platform at install time); only those two lines change per release. +Update the first two, then refresh the lock file with uv. Do not edit a +line-number-specific location in `uv.lock`; its package order changes. -**This is now AUTOMATED:** the main repo's `.github/workflows/distribute.yml` fires on the GitHub Release `published` event, waits for the sdist to appear on PyPI (poll loop, the Release event races publish.yml's upload), rewrites the formula's `url`+`sha256`, and pushes to the tap using the `HOMEBREW_TAP_TOKEN` repo secret (a fine-grained PAT with Contents:write on `homebrew-tap`). The SAME workflow also factory-rebuilds the HF Space (`HfApi.restart_space(..., factory_reboot=True)`, `HF_TOKEN` secret) so the Space reinstalls the new sdist (it pins `remove-ai-watermarks>=...` and only re-resolves on a rebuild). The manual Homebrew steps above are the fallback / what the workflow automates — a normal release needs no Homebrew or HF action. +## Publish flow -**Where the HF Space's own source lives (its demo code, NOT this library):** the private repo **`wiltodelta/raiw-hf-space`** (locally `~/Documents/GitHub/raiw-hf-space`) — `app.py` (CPU-core Gradio demo), `requirements.txt`, `README.md` (the Space card), `assets/`, `examples/`. **Deploying it is a plain `git push` to that repo's `main`:** its `.github/workflows/sync-to-hf.yml` mirrors the files onto the Space via the Hub API (`HfApi.upload_folder`, secret `HF_TOKEN` = a **write**-role token), which adds a commit on top of the Space's own history — deliberately NOT a `git push --force` to the Space, which would clobber it. Do NOT edit the Space through the huggingface.co web UI any more: that was the pre-2026-07-16 workflow (it is why every Space commit before then is authored `@users.noreply.huggingface.co` and why no local write token ever existed), and a web edit now silently diverges from the GitHub source of truth. Note the demo tracks the library's **CPU-core** surface only (`identify` / `visible` / `metadata`); the invisible/SynthID path needs a GPU and stays out. Two distinct automations touch the Space and must not be confused: `sync-to-hf.yml` (in `raiw-hf-space`) ships **demo code changes**; `distribute.yml` (in this repo) factory-rebuilds the Space on a **library release** so its `remove-ai-watermarks>=...` pin re-resolves to the new version. +PyPI publishing is triggered by a published GitHub Release, not by a tag push +alone. -**If the distribute.yml Homebrew job fails with "Bad credentials" (or the tap push 403s),** the `HOMEBREW_TAP_TOKEN` secret has expired or been revoked — fine-grained PATs expire on a fixed date, so this recurs. Fix: rotate the PAT (a fine-grained token with Contents:write on `wiltodelta/homebrew-tap`), update the `HOMEBREW_TAP_TOKEN` repo secret, then re-run the failed job (`gh run rerun --failed`). While the token is being rotated, the manual formula bump above unblocks the release. The same rotate-secret-and-rerun applies to any distribute.yml credential failure (`HF_TOKEN` for the Space rebuild). +The expected sequence: -**Other distribution channels:** (1) **conda-forge** — recipe source of truth committed at `packaging/conda/recipe.yaml` (v1 `recipe.yaml`, noarch core-only: pillow/piexif/numpy/py-opencv/click/python-dotenv); the initial submission is `conda-forge/staged-recipes` PR #33674 (went green only after **`pip_check: false`** in the python test — rattler-build's `pip check` defaults to ON and fails on the ancient conda-forge `piexif py_2` build's stale metadata with "piexif 1.1.3 is not supported on this platform", though the package installs/imports/works; keep it disabled). Once that merges and the `remove-ai-watermarks-feedstock` exists, the `regro-cf-autotick-bot` auto-opens a version-bump PR on the feedstock when each new PyPI sdist is detected — just review + merge it (hand-edit only if run-deps changed; keep `packaging/conda/recipe.yaml` in sync as the reference copy). (2) **ComfyUI Registry** — the node package is a SEPARATE repo `wiltodelta/ComfyUI-remove-ai-watermarks` with its OWN `pyproject.toml` `version` (independent of the library version). Publish a new node version by bumping that `version` in the node repo's `pyproject.toml` and pushing to `main` — the node repo's `.github/workflows/publish.yml` (`Comfy-Org/publish-node-action@main`, triggered on a push that touches `pyproject.toml`, secret `COMFY_REGISTRY_TOKEN`) **auto-publishes** it; `comfy node publish --token ` is the manual/local fallback. It is NOT auto-published on a library release (the node has its own version), so only bump it when the node code or its `remove-ai-watermarks>=` dependency floor changes. +1. update the version sources and lock file; +2. run the complete project gate; +3. commit the release change; +4. create an annotated `vX.Y.Z` tag; +5. push the commit and tag; +6. publish the GitHub Release. -**Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (the multi-hundred-MB synthid_corpus images + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. +`.github/workflows/publish.yml` then: -**A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch. +1. checks that the release tag matches `pyproject.toml`; +2. builds the package with uv; +3. publishes with `uv publish` through PyPI trusted publishing. -**Build backend is unpinned `hatchling`** (`[build-system] requires`) since 2026-06-09. History: it was pinned `<1.31` because hatchling 1.30.0 made Metadata-Version 2.5 (PEP 794) the default and the twine bundled in `pypa/gh-action-pypi-publish@release/v1` rejected it (`"'2.5' is not a valid Metadata-Version"`), which **failed the v0.8.3 PyPI upload on 2026-06-01**; hatchling 1.30.1 reverted the default to 2.4. After the workflow moved to `uv publish` (whose uploader accepts 2.5) the pin was belt-and-suspenders only, and once v0.9.0 + v0.10.0 both published wheel+sdist through that path (verified on PyPI) it was dropped. If a future hatchling flips the default to 2.5 again and some consumer chokes, re-pin with a dated comment. +The workflow uses GitHub OIDC through the `pypi` environment. It does not read a +PyPI API token from the repository. -## Dependency CVE-resolution history (`uv-secure`) +## Post-release distribution -The standing `uv-secure` gate in `maintain.sh` is clean; this is the changelog of how each alert was resolved, so a future alert is not re-triaged from scratch. +`.github/workflows/distribute.yml` runs on the same published-release event. It +waits for the matching source distribution to appear on PyPI, then: -- **idna** bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx. -- **aiohttp** bumped 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8. -- **basicsr** Dependabot alert GHSA-86w8-vhw6-q9qq is resolved by removal: the experimental `restore` extra was retired and basicsr is no longer anywhere in the dependency tree. -- **torch** Dependabot alert **GHSA-rrmf-rvhw-rf47** (`torch.jit.script` memory corruption, alert range `<= 2.12.1`) was dismissed `not_used` on 2026-06-10 (torch is a transitive dep of the optional `gpu` extra only and the codebase never calls `torch.jit`) and **resolved by upgrade on 2026-07-21**: the lock carries torch **2.13.0**, above the patched floor, so `uv-secure` is clean. If the GitHub alert has not auto-closed on the lock bump, close it manually as fixed. -- **setuptools** bumped 81.0.0 -> 83.0.0 (2026-07-21), fixing PYSEC-2026-3447. +- updates the Homebrew tap formula URL and SHA-256; +- triggers a factory rebuild of the Hugging Face Space. + +The workflow can also be started manually with an optional version input. +Conda-forge updates are outside this workflow. + +If a distribution job fails because a repository or Hugging Face credential is +invalid, rotate the corresponding GitHub secret and rerun the failed job. A +manual Homebrew formula update is the fallback when its automation is blocked. + +## Source distribution boundary + +The wheel includes the package under `src/`. + +The source distribution explicitly excludes `/data` through +`[tool.hatch.build.targets.sdist]` in `pyproject.toml`. Keep that exclusion: +calibration captures and test corpora do not belong in the published package +archive. + +## Build backend + +The package uses hatchling through the unpinned `hatchling` build requirement in +`pyproject.toml`. Uploading uses uv rather than the older twine-based action. + +## Other channels + +The repository includes a conda recipe under `packaging/conda/recipe.yaml`. +Keep its runtime dependencies aligned with `pyproject.toml`. + +The ComfyUI nodes are maintained and versioned separately from this package. +A library release does not by itself publish a new ComfyUI node version. + +## Release verification + +After publication, verify: + +- both wheel and source distribution exist on PyPI; +- the package version matches the tag; +- the Homebrew formula points to the new source distribution; +- the distribution workflow completed successfully; +- a clean install can run `remove-ai-watermarks --version`. diff --git a/docs/research-doubao-distillation.md b/docs/research-doubao-distillation.md index 918eccd..2b668c8 100644 --- a/docs/research-doubao-distillation.md +++ b/docs/research-doubao-distillation.md @@ -1,20 +1,32 @@ # Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29) +> Research archive. Reverse-alpha pixel recovery is no longer part of the +> current visible-removal pipeline. The current implementation uses +> localize-then-fill; see `docs/module-internals.md`. + > Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded > context small. Long single-line entries were reformatted into paragraphs; > no content was changed or summarized. -**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha is built** (`doubao_engine.remove_watermark_reverse_alpha`, `assets/doubao_alpha.png`; see the `doubao_engine.py` section in `docs/module-internals.md`). The captures (`data/doubao_capture/captures/`, now committed) confirmed the alpha-composite model: on black `captured = a*logo`, logo pure white. +**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha was built.** +That historical method, `doubao_engine.remove_watermark_reverse_alpha`, has since +been removed. Its detection silhouette remains at +`src/remove_ai_watermarks/assets/doubao_alpha.png`. The committed captures in +`data/calibration/doubao/` confirmed the alpha-composite model: on black +`captured = a*logo`, logo pure white. **UPDATE 2026-05-31 (issue #13 follow-up): the first build was NOT "exact"** — it left a readable "豆包AI生成" outline on the real sample (the detector was fooled, conf 0.0). The alpha is now rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve shared with Jimeng), removal always-aligns + thin-inpaints, and the locate box was widened; see the `doubao_engine.py` section in `docs/module-internals.md`. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary. **Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.** -The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- a local corpus of pristine originals holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo color) was tried end-to-end and **still leaves a persistent ghost outline.** +Curate same-resolution originals with `DoubaoEngine.detect` and an NCC filter against +a clean glyph template, keeping only aligned marks. Even with aligned inputs, +LaMa-clean `O` plus weighted least squares and per-pixel regression for `α` and logo +color still leaves a persistent ghost outline. Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into color noise. -**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in grey on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. Black/white/gray seeds exist (`data/doubao_capture/seeds/seed_*_1x1_2048x2048.png`); a capture run (feed a black seed through doubao.com edit mode, download the *original*) was requested from the #13 reporter 2026-05-29. With ~2-3 black captures we get `α = capture/255` for free, Gemini-quality. +**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in gray on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. The retained black and gray outputs live in `data/calibration/doubao/`; local solid-color seeds are regenerable and are not committed. **Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.** diff --git a/docs/supported-signals.md b/docs/supported-signals.md new file mode 100644 index 0000000..d893e61 --- /dev/null +++ b/docs/supported-signals.md @@ -0,0 +1,127 @@ +# Supported signals + +This page describes the current support boundary. A check mark means that the +repository contains a corresponding code path. It does not guarantee detection +or removal on every future vendor version. + +## Visible marks + +The `visible` command registers these mark keys: + +| Key | Mark | Expected area | Important limit | +| --- | --- | --- | --- | +| `gemini` | Google Gemini sparkle | Usually bottom right | Detection includes a false positive gate. | +| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. | +| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. | +| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. | +| `kling` | `可灵AI 3.0` | Bottom right | Only calibrated variants are covered. | +| `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. | +| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. | +| `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. | +| `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. | +| `liblib` | `LibLibAI` | Bottom center | Includes a minimum image size gate. | +| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. | + +`--mark auto` evaluates all registered marks and removes every selected match. +Known marks are localized to a mask, then the selected fill backend reconstructs +the masked area. + +Marks from other vendors are not detected automatically. Use `erase --region` +when you can select the affected area yourself. + +## Fill backends + +| Backend | Install | Behavior | +| --- | --- | --- | +| `cv2` | Core package | Classical OpenCV inpainting | +| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime | +| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime | +| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV | + +The learned backends download model files on first use. + +## Metadata and provenance + +The inspection and stripping code handles signals in these groups: + +- C2PA Content Credentials and supported cloud manifest references; +- EXIF and XMP generator fields; +- IPTC AI disclosure fields; +- PNG text chunks and embedded generation parameters; +- China TC260 AIGC labels in supported metadata placements; +- xAI and Grok EXIF signature fields; +- Samsung AI editing markers; +- Hugging Face job metadata; +- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra; +- Adobe TrustMark with the `trustmark` extra. + +`identify` combines detected signals into a `ProvenanceReport`. It reports +unknown when evidence is absent. It never treats missing metadata as proof that +an image is human made. + +## File and container formats + +Pixel based image commands discover these extensions: + +- PNG; +- JPEG; +- WebP; +- HEIC and HEIF; +- AVIF. + +Metadata inspection and removal additionally have container paths for: + +- JPEG XL metadata; +- MP4, MOV, M4V, and M4A; +- WebM, MKV, MKA, MP3, WAV, FLAC, OGG, OGA, Opus, and AAC when ffmpeg is + available. + +JPEG image metadata stripping removes targeted metadata segments without +re-encoding the entropy coded image scan. PNG and WebP removal preserves pixel +values through lossless output paths. HEIC, HEIF, AVIF, and other containers +use their format specific paths. + +## Invisible watermarks + +The `invisible` command uses diffusion regeneration. It targets watermark +patterns by changing the image rather than decoding and deleting a known +payload. + +Current pipeline values: + +- `controlnet`; +- `sdxl`; +- `qwen`; +- `qwen-zimage`; +- legacy alias `default`, which resolves to `sdxl`. + +SynthID does not have a public local pixel decoder in this project. The tool can +infer likely presence from supported provenance metadata, but after that +metadata is removed a local negative result is inconclusive. + +The optional `detect` extra is different: it provides a local decoder for the +open DWT-DCT watermark used by some Stable Diffusion, SDXL, and FLUX workflows. +That signal is carrier and transformation sensitive, so a negative is still +not a universal clean verdict. + +## Provider overview + +| Provider or family | Visible | Invisible path | Metadata or provenance | +| --- | --- | --- | --- | +| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals | +| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance | +| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata | +| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | +| Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder | +| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals | +| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals | +| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC | +| Kling | Kling mark | No registered pixel decoder | TC260 AIGC | +| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC | +| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC | +| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC | +| Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers | + +For detector thresholds, measured limits, and incident history, see +[module internals](module-internals.md) and +[known limitations](known-limitations.md). diff --git a/docs/synthid-robust-identity-research-2026-06-08.md b/docs/synthid-robust-identity-research-2026-06-08.md index 009a1a3..6d16b13 100644 --- a/docs/synthid-robust-identity-research-2026-06-08.md +++ b/docs/synthid-robust-identity-research-2026-06-08.md @@ -1,5 +1,8 @@ # Deep research: SynthID-safe face-identity recovery for SDXL (2026-06-08) +> Research archive. This dated follow-up records evidence available during the +> study. It is not the current command reference. + **Stats:** {"angles": 6, "sourcesFetched": 28, "claimsExtracted": 104, "claimsVerified": 25, "confirmed": 19, "killed": 6, "afterSynthesis": 6, "urlDupes": 1, "budgetDropped": 7, "agentCalls": 111} ## Summary @@ -49,7 +52,7 @@ Arc2Face README verbatim: 'Arc2Face is built upon SD1.5' with stable-diffusion-v **Vote:** 3-0 on the narrow factual claims (SDXL base + CLIP-G encoder) -GitHub README explicitly instructs 'Download the pretrained base models from SDXL-base-1.0 and CLIP-G' (CLIP-ViT-bigG-14-laion2B-39B-b160k). Neither README nor arXiv 2406.07209 mention ArcFace/InsightFace/antelopev2/buffalo_l. Architecturally descended from IP-Adapter (CLIP-image-embedding family), not from FaceID/InstantID/PhotoMaker-V2. Verifier caveat (high confidence on the license-narrow claim, medium on suitability): CLIP-image face-ID accuracy ~80.95% vs specialized face recognition ~87.61% — license-safe but probably not identity-grade for portraits. Confidence is medium because the suitability claim for raiw.cc face-identity use case has not been validated empirically. +GitHub README explicitly instructs 'Download the pretrained base models from SDXL-base-1.0 and CLIP-G' (CLIP-ViT-bigG-14-laion2B-39B-b160k). Neither README nor arXiv 2406.07209 mention ArcFace/InsightFace/antelopev2/buffalo_l. Architecturally descended from IP-Adapter (CLIP-image-embedding family), not from FaceID/InstantID/PhotoMaker-V2. Verifier caveat (high confidence on the license-narrow claim, medium on suitability): CLIP-image face-ID accuracy ~80.95% vs specialized face recognition ~87.61% — license-safe but probably not identity-grade for portraits. Confidence is medium because suitability for portrait and group-photo inputs has not been validated empirically. - https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf - https://github.com/MS-Diffusion/MS-Diffusion @@ -83,7 +86,7 @@ Six claims were refuted in adversarial verification, two of them load-bearing: A ## Open questions -- Does MS-Diffusion (or any CLIP-image-embedding SDXL adapter) achieve usable face-identity fidelity on the raiw.cc input distribution (portraits + group photos), or is the ArcFace gap (~7 pp face-ID accuracy) visually disqualifying — and can a face-specific CLIP fine-tune close it? +- Does MS-Diffusion (or any CLIP-image-embedding SDXL adapter) achieve usable face-identity fidelity on portraits and group photos, or is the ArcFace gap (~7 pp face-ID accuracy) visually disqualifying — and can a face-specific CLIP fine-tune close it? - Has InstantX (or any community fork) actually shipped an InstantID variant retrained on a commercially-licensed face embedder since the maintainer's 2024 commitment, and if so what is its identity-fidelity vs the antelopev2 original? - What is the exact diffusers-0.38 compat status of InstantID, MS-Diffusion, and PuLID-FLUX inference scripts — does any need a fork the way PhotoMaker-V1 did, and if so what specifically breaks? - Is there a single-pipeline multi-subject identity-preservation method (mask-guided regional ID-adapters, multi-subject InstantID, MS-Diffusion multi-subject mode) that handles group photos without the per-face crop+composite patchwork that PhotoMaker-V2 produced? @@ -161,7 +164,7 @@ on Modal A100 in two phases: `ip_adapter_scale=1.0`, `controlnet_scale=1.0` brought identity closer to original but introduced more "SDXL gloss / clean skin" aesthetic. -**Net finding for raiw.cc (load-bearing).** The fundamental issue is structural: +**Net finding for a commercial deployment (load-bearing).** The fundamental issue is structural: ArcFace encodes "this person's general look" (ethnicity, gender, basic facial geometry) at 512 dimensions; SDXL decodes that embedding into pixels with the inherent SDXL aesthetic (smooth skin, symmetric pores, AI-photoreal look). @@ -182,4 +185,4 @@ for embedding-driven regeneration and makes the face read as "AI-generated" rather than "the original person". The `instantid` and `photomaker` extras stay in the library as opt-in for research / personal use where users explicitly want identity regeneration; the CLI flag and module docstrings -state the trade-off at every entry point. \ No newline at end of file +state the trade-off at every entry point. diff --git a/docs/synthid-robust-identity-research.md b/docs/synthid-robust-identity-research.md index d464efa..b49efa9 100644 --- a/docs/synthid-robust-identity-research.md +++ b/docs/synthid-robust-identity-research.md @@ -1,5 +1,10 @@ # SynthID-robust face identity for an SDXL removal pipeline (research) +> Research archive. This document evaluates approaches considered during the +> study. It does not describe the current public interface. The current +> pipelines and limits are documented in `docs/cli.md` and +> `docs/known-limitations.md`. + > **Status (2026-06-08): retired.** Every approach described below was empirically > tested and rejected -- see `docs/synthid-robust-identity-research-2026-06-08.md` > "Empirical follow-up" for the final conclusion. The library no longer ships any @@ -12,7 +17,7 @@ canny-ControlNet watermark-removal pipeline (denoise 0.20-0.30) is BOTH (a) commercial-safe end-to-end and (b) does not re-introduce the SynthID pixel watermark the removal pass just destroyed? -**Constraint.** raiw.cc is a paid service, so every component (adapter weights AND +**Constraint.** A paid deployment requires every component (adapter weights AND the face embedder it conditions on AND any base model) must be Apache-2.0 / MIT / BSD or otherwise clearly commercial-permitted. Non-commercial is disqualifying. @@ -61,7 +66,7 @@ extra). V2 uses a DUAL ID encoder (CLIP image features + ArcFace embedding), which delivers true identity-from-embedding face regeneration. The cost is that the ArcFace embedding comes from InsightFace's `antelopev2`/`buffalo_l` model packs, which are released under a non-commercial / research-only license. **So -the shipped restore path is NON-COMMERCIAL.** raiw.cc and any other monetized +the shipped restore path is NON-COMMERCIAL.** Any monetized deployment must NOT install the `photomaker` extra. The CLI flag and module docstring both call this out at every entry point. @@ -213,9 +218,9 @@ from the test set + this doc). - canny ControlNet conditioning can fight the ID embedding (edges of the ORIGINAL face vs identity of the SAME person regenerated) -- expect to tune `controlnet_conditioning_scale` down a notch on photoreal faces; - - PhotoMaker was trained on a celebrity-skew distribution; real-user faces + - PhotoMaker was trained on a celebrity-skew distribution; representative faces (especially non-white, non-Western, elderly, children) may have lower - fidelity. Measure on the real upload distribution. + fidelity. Measure on a representative evaluation distribution. ## 6. Integration cost (rough) @@ -253,7 +258,7 @@ from the test set + this doc). per-vendor oracles. Expected: SynthID cleared (the regeneration is the same) AND identity recovered (the embedding adds it back). 4. **Honest exit criteria.** Ship only if BOTH oracle reads clean AND a small - user-perception test on real uploads says "looks like me". If identity is + user-perception test on representative inputs says "looks like me". If identity is still too soft on small faces -> add stacked-reference (multiple crops of the same upload at different scales) before reaching for a non-commercial embedder. diff --git a/docs/synthid.md b/docs/synthid.md index 0e74512..86aaa13 100644 --- a/docs/synthid.md +++ b/docs/synthid.md @@ -1,5 +1,10 @@ # SynthID-Image: technical reference +> Technical research reference. Current package behavior is defined by the +> [supported signals](supported-signals.md), [known limitations](known-limitations.md), +> and [module internals](module-internals.md). Dated measurements below are +> historical evidence and should not be read as current CLI defaults. + This document covers how Google SynthID for images works mechanically, what it survives, what removes it, and the current deployment landscape. It is written for engineers working on watermark detection and removal -- specifically to @@ -175,11 +180,11 @@ A controlled study (June 2026, clean v0.8.6 with text/face protection OFF, native resolution on this repo's default SDXL pipeline) measured the minimum img2img strength that removes the SynthID pixel watermark, verified per image on the vendor's own oracle (openai.com/verify for OpenAI, the Gemini app "Verify -with SynthID" for Google). Each subject is archived in `data/synthid_corpus/` as a -pos original plus its minimum-clearing cleaned output (manifest `verified_via` = -`openai-verify` / `gemini-app`), EXCEPT one third-party image from issue #14, which -was oracle-verified but is not committed (third-party content stays out of the -public corpus). +with SynthID" for Google). The reusable originals are stored once in +`data/synthid/originals/`, with their input verification in `manifest.csv`. +Generated cleaned outputs are not committed; the table below is the durable +record of the historical oracle verdicts. One third-party image from issue #14 +was oracle-verified but is not committed. **Oracle validation order: start with OpenAI.** When validating removal across vendors, run the OpenAI arm first. `openai.com/verify` is more accessible than the @@ -431,27 +436,14 @@ conditioning, never by copying original pixels.** OpenAI and Gemini oracles. This is a quality recommendation for the measured content, not broad removal certification; very small text can still degrade. See `docs/known-limitations.md` for the metrics, runtime, and validation scope. -- **Face identity:** canny holds face *structure* but not *identity*. Shipped as the - optional `--restore-faces` GFPGAN post-pass (`face_restore.py`, the `restore` - extra, experimental/opt-in, off by default). It runs GFPGAN on the ORIGINAL - faces and feather-composites the restored face REGIONS into the cleaned image. - **WARNING (oracle-confirmed 2026-06-04): this pass can RE-INTRODUCE SynthID into - the face regions -- the earlier "GFPGAN re-synthesizes from a StyleGAN2 prior -> - scrubs SynthID -> oracle-confirmed clean" claim was WRONG.** At the default fidelity - weight `0.5` GFPGAN blends ~half the ORIGINAL (watermarked) face pixels with the - prior, and SynthID is robust to that partial blend, so the composited face carries - the watermark back in -- over the diffusion-cleaned face. Confirmed by a clean A/B: - `gemini_3` read SynthID-detected after controlnet @ 0.20/0.25 WITH restore, but - NOT-detected after the same controlnet @ 0.20 with `--no-restore-faces` (only - restore differed). Content-dependent (a second face image cleared WITH restore), - which is why a single-image check earlier read "clean". **Fix directions (not yet - done): run GFPGAN on the diffusion-CLEANED image not the original; or drop the - weight well below 0.5; or leave restore OFF for removal -- each needs oracle - re-validation.** Commercial- - safe (GFPGAN Apache-2.0 + RetinaFace MIT); the CodeFormer alternative is - NON-COMMERCIAL and is not shipped. (An IP-Adapter FaceID approach was tried and - REMOVED -- it needs high denoise strength and corrupts faces at removal strength; - see `docs/controlnet-removal-pipeline-research.md`.) +- **Face identity:** canny holds face *structure* but not *identity*. The standard + SDXL and ControlNet profiles do not run a separate face-restoration option. + Earlier GFPGAN, PhotoMaker, and FaceID experiments were removed after they + degraded identity or risked reintroducing source pixels. The separate + `qwen-zimage` profile now provides the only shipped face-specific stage: + YuNet and SAM locate faces, then Z-Image regenerates the selected original + face crops before a feathered composite. See + `docs/controlnet-removal-pipeline-research.md` for the historical experiments. ### 5.2 Strength setting @@ -536,7 +528,7 @@ manifest get `OPENAI_STRENGTH` 0.10, the one without C2PA falls to | image | content type | size | strength | `--auto`/controlnet | `default` | |---|---|---|---|---|---| | typography card | flat text | 1122x1402 | 0.10 | clean | clean | -| raiw.cc poster | flat graphic (logo + flat fills) | 1024x1536 | 0.10 | clean | **detected** | +| Flat poster | flat graphic (logo + flat fills) | 1024x1536 | 0.10 | clean | **detected** | | 9-face grid | photoreal | 1448x1086 | 0.10 | **detected** | clean | | bracelet product photo | photoreal | 1600x1600 | 0.15 | **detected** | clean | @@ -575,16 +567,18 @@ content×pipeline table above conflates a borderline/non-deterministic 0.15 resu with deterministic content behavior -- the photoreal-survives-controlnet effect is solid at 0.10 but at 0.15 it is near-threshold noise; (2) for reliable removal pick a strength with MARGIN above the borderline (controlnet >= 0.20), not exactly on -it; (3) **engineering follow-up for raiw.cc: the controlnet pipeline should use a -HIGHER vendor strength than `default` (it currently shares `resolve_strength`) -- -e.g. controlnet floor 0.20 -- calibrated per vendor/content on the GPU worker where -batches are cheap. The shared 0.10/0.15 is tuned for `default`, not controlnet.** +it; (3) **historical engineering conclusion:** this dated run argued for a +higher ControlNet strength than the then-current default. That proposal was +later superseded. The current resolver intentionally shares the 0.10/0.15 +ladder between SDXL and ControlNet and uses a separate Qwen ladder; see +`noai/watermark_profiles.py`. Source images are private (faces / product shots), not committed; reproduce on any photoreal + flat-graphic gpt-image pair, varying the seed, and re-checking the oracle. **Gemini pass + the face-restore re-introduction (2026-06-04).** Four Gemini -originals via `--auto` (controlnet) at `--max-resolution 1024`, checked on the +originals via the then-current `--auto` ControlNet path at `--max-resolution 1024`, +checked on the Gemini "Verify with SynthID" oracle (Google content needs the Google oracle, not openai.com/verify): - Most cleared at controlnet 0.15-0.25; `gemini_3` (a large central FACE, +restore) @@ -599,23 +593,21 @@ openai.com/verify): robust to downscaling by design, and the study's resolution trend says LOWER processing res needs LESS strength, so 1024 was never the wall.) -**Certified controlnet floors (isolated Modal GPU sweep + oracle, +**Historical controlnet certification, superseded by the current vendor-adaptive +defaults (isolated GPU sweep + oracle, restore OFF, <= 1536, each vendor on its own oracle):** OpenAI **0.20** (2 photoreal x seed {1,2,3} = 6/6 clean; the 0.15-flipper is seed-robust at 0.20) and Gemini **0.30** (0.20 detected -> 0.30 clean on 2/2 seeds). OpenAI 0.20 transfers to prod (resolution-independent); Gemini 0.30 holds only <= 1536 -- Gemini is -resolution-sensitive and raiw.cc runs NATIVE, so cap Gemini <= 1536 + use 0.30 or +resolution-sensitive, so a native-resolution deployment should cap Gemini <= 1536 + use 0.30 or native-calibrate (~0.35+). See `docs/controlnet-removal-pipeline-research.md` for the table. -**Net for raiw.cc:** (1) controlnet needs a higher, per-vendor strength than -`default` -- CERTIFIED OpenAI 0.20 / Gemini 0.30 (above); add a controlnet-specific -schedule to `resolve_strength`, do not reuse the default ladder; (2) the -`--restore-faces` pass is now SynthID-safe by construction (the GFPGAN-on-original -path that re-added SynthID was removed 2026-06-04; the shipped restore is -PhotoMaker-V2, NON-COMMERCIAL, see `photomaker_restore.py`); (3) -removal near threshold is seed-non-deterministic -> FIX the prod seed (kills the -coin-flip; ship a deterministic certified config). +**Current implication:** the old floor table remains evidence about the dated +test set, not the current resolver. The shipped SDXL and ControlNet defaults are +defined in `watermark_profiles.py`, and face restoration is available only +through the separate `qwen-zimage` profile. Removal near a threshold remains +seed dependent, so reproducible verification requires a fixed seed. --- diff --git a/docs/text-protection-research.md b/docs/text-protection-research.md index 5c6ab50..333b1f0 100644 --- a/docs/text-protection-research.md +++ b/docs/text-protection-research.md @@ -1,5 +1,8 @@ # Text protection research: crisp text under a "watermark removed everywhere" constraint +> Research archive. This page records evaluated ideas, including rejected +> prototypes. Current behavior is documented in the user guides and source. + Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources fetched and 3-vote adversarially verified). Not committed automatically — saved as a research note for the next session. diff --git a/docs/verification-plan.md b/docs/verification-plan.md index 62981da..d5f98f5 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -1,50 +1,36 @@ # Full verification plan +> Verification log and historical plan. Dated results describe the code and +> datasets at the time of each run. They are evidence for maintainers, not a +> current CLI contract. Recheck open items against the current code and issue +> tracker before treating them as work that remains. + How we convince ourselves the library actually works, across its whole surface, on real data. This is the pre-release and periodic-audit plan. It is deliberately organized by **oracle strength** rather than by module, because the hard part is never "call the function" -- it is "know what the right answer was". A sweep with no oracle proves only that nothing threw. -Measured throughput on the local corpus (39,430 images, M-series, 2026-07-19): +Performance depends on format, enabled extras, and hardware. Measure it locally with representative inputs; do not publish private dataset sizes or run statistics. -| path | per image | full corpus, 8 procs | -|---|---|---| -| `identify` | 0.58 s | ~0.8 h | -| `detect_marks` | 0.28 s | ~0.4 h | -| visible remove (cv2) | 0.58 s | ~0.8 h | -| diffusion @512px (MPS) | ~50 s | ~23 days -- sample only | +## Public test sources -So every CPU path is affordable at FULL corpus scale; only the diffusion paths need -sampling. Plan accordingly: never sample where a full sweep costs an hour. - -## Data sources - -| source | size | committed | role | -|---|---|---|---| -| `data/spaces/originals/` | 39,430 imgs, 87.5 GB | no (gitignored) | the real-upload corpus | -| `data/spaces/identify/` | 39,314 JSON sidecars | no | **recorded `identify` verdict per image** | -| `data/spaces/_visible_datasets/` | 3,741 imgs, per vendor | no | mark-positive pools | -| `data/synthid_corpus/` | 39 imgs, labelled | yes | pos/neg/cleaned SynthID references | -| `data/samples/` | 11 fixtures | yes | deterministic fixtures | -| `data/*_capture/` | vendor captures | yes | detection silhouettes | -| synthesized | generated | no | constructed ground truth (tier B) | - -**Data safety.** The corpus is user uploads: local analysis only. No run may copy, promote, -or commit corpus images into a tracked path, and no report may embed them. All harness -output goes to gitignored paths under `data/spaces/`. +| source | role | +|---|---| +| `data/synthid/` | labeled SynthID oracle fixtures and evaluation manifest | +| `data/fixtures/provenance/` | deterministic format and provenance fixtures | +| `data/calibration//` | minimal controlled inputs used to derive detection silhouettes | +| synthesized inputs | constructed ground truth for pure and format-level checks | ## Tier A -- self-evident oracles (full corpus, unattended) Properties that are true or false without anyone labelling anything. These are the -backbone: they scale to 39k images and catch regressions with zero human cost. +backbone: they scale to large local datasets and catch regressions with zero human cost. -### A1. Sidecar regression -- the highest-value check we are not running +### A1. Recorded-verdict regression -`data/spaces/identify/` holds 39,314 recorded `identify` verdicts, keyed by the same uid as -the image. Re-running `identify` today and diffing against them turns the corpus into a -**39k-image behavioral regression suite for free**. Any drift in verdict, platform, -confidence or signal set shows up as a diff, bucketed by cause. +A dated local cache of `identify` verdicts can be replayed against the same +inputs to detect drift in verdict, platform, confidence, or signal set. Caveat that makes this honest: a diff is not automatically a bug -- the sidecars were written by older versions, so intended improvements also show up. The output is therefore a @@ -53,86 +39,22 @@ confidence), reviewed once, then re-baselined. Lost detections are the alarm. Implemented as `scripts/sidecar_regression.py` (resumable, ~1.5 h at 8 workers). -#### First full run, 2026-07-19, all 39,314 sidecars +#### Local run protocol -| class | n | share | -|---|---|---| -| unchanged | 37,326 | 94.9% | -| lost_signal | 1,302 | 3.3% | -| platform changed | 922 | 2.3% | -| confidence changed | 899 | 2.3% | -| new_signal | 694 | 1.8% | -| lost_ai | 747 | 1.9% | -| new_ai | 152 | 0.4% | - -Two results worth keeping: - -**No metadata signal regressed anywhere.** Lost families were exclusively visual -(`visible_sparkle` 1,256, `visible_doubao` 45, `visible_jimeng` 4) -- zero c2pa, synthid, -aigc_tc260, iptc, exif_generator or xai_signature losses across the whole corpus. And -`identify` raised on **none** of the 39,314 real uploads. - -**The sparkle losses are mostly corrected false fires, but not entirely.** Sampling 400 of -the 1,256 and checking whether the file still carries Google provenance independently of -the sparkle: 12.5% (95% CI 9.6-16.1%) still do, i.e. ~120-200 corpus-wide are **genuine -misses**; the other ~1,050-1,135 had nothing backing them. Read the split as a trade the -FP-gate tightening made, not as a clean win. - -Caveat on that split: "no Google provenance" is not proof of a false positive -- a -metadata-stripped Gemini screenshot also has none while still carrying the pixel sparkle. -So 87.5% is an **upper bound** on false fires; only the 12.5% genuine-miss figure is solid. - -Doubao moved the other way (-45 / +628 net +583), which is the `scale_basis` landscape fix -showing up at corpus scale. `trustmark` +38 and `open_invisible` +9 are not behavior: those -extras were simply not installed when the sidecars were written. +Re-run `identify` against locally recorded sidecars, classify losses separately from intended new detections, and keep generated reports under `.local-eval/`. Do not commit dataset-derived counts or identifiers. ### A2. Parity: whatever we detect, we must be able to remove For every image where a signal fires: remove, re-scan with the same oracle, assert quiet. -- metadata: `scripts/metadata_removal_audit.py` (exists) -- run full corpus. -- visible: `scripts/visible_removal_audit.py` (exists) -- run **once per backend** - (cv2 / migan / lama). It is single-process, so a full-corpus sweep is ~10 h and three - backends ~30 h. Its expensive half is DETECTION, which does not depend on the backend, - so run `scripts/visible_positives.py` once (parallel, ~40 min) and feed the result to - the audit's `--paths-file` seam: a few thousand images per backend instead of 39k. +- metadata: `scripts/metadata_removal_audit.py`; +- visible: `scripts/visible_removal_audit.py`, once per backend. -#### Metadata parity, first full run, 2026-07-19 (20,153 carriers + 1,500 clean controls) +Detection does not depend on the fill backend. Run `scripts/visible_positives.py` once +and pass its output through the audit's `--paths-file` option. -Zero scan/strip/decode errors. Survival after strip: +#### Local parity protocol -| signal | carriers | survived | -|---|---|---| -| c2pa_manifest / claim_generator | 15,410 | **3** | -| synthid_watermark | 14,985 | 0 | -| aigc_label | 4,414 | 0 | -| the other 12 signal types | - | 0 | - -The no-op control is clean: the strip **added** a signal to 0 of 1,500 clean images. Two -real defects fell out of the run. - -**Defect 1 -- the fail-safe reports success on a file it did not strip.** All 3 parity -failures are Samsung Galaxy S22 camera PNGs (`Galaxy S22 c2pa-rs/0.37.0`) whose `caBX` -chunk survives. Cause: PIL raises `UnidentifiedImageError` on them, so -`remove_ai_metadata`'s fail-safe copies the file through byte-identical -- correct in -intent (never crash a worker on a partial upload) but it returns an output path -indistinguishable from a real strip. User-visible: `metadata --remove` prints -"AI metadata stripped ->", exits 0, and `identify` on the output still reports C2PA. The -warning is logged but the success line contradicts it. Rare here (3 of 20,153) but the -mechanism fires on ANY file PIL cannot decode. The fail-safe should stay; what needs -fixing is that the caller cannot tell a no-op from a strip. - -**Defect 2 -- 16-bit PNGs are silently downconverted to 8-bit.** 5 of the 1,500 clean -controls failed the pixel-identity check; all are 16-bit PNGs, and the PIL re-save halves -their bit depth (one went 9.2 MB -> 2.5 MB). This is the known limitation recorded in -CLAUDE.md, now measured: a byte-level IHDR scan over every corpus PNG puts it at -**42 of 27,018 (0.16%)**. - -Method note worth keeping: the first attempt to reproduce Defect 2 said "pixels -identical" and nearly closed it as a harness bug. That check read both files through -`image_io.imread`, which returns 8-bit -- **the reader destroyed the very property under -test**. The audit was right because it reads via `read_bgr_and_alpha`, which preserves -uint16. When verifying a fidelity property, check that the verification path can still -represent it. +Run detection, removal, and re-detection over a representative local set. Confirm that decoded pixels remain unchanged for metadata-only operations. Keep reports untracked. ### A3. Byte-level invariants @@ -159,20 +81,12 @@ Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invis Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the sweep asserts on stderr, or the codes get split -- the latter is the better fix. -#### Coverage before the extension (measured 2026-07-19, not estimated) +#### Coverage method -Comparing the flags the matrix actually executed against the flags the CLI declares: -**18 of 38 had never been executed even once** -- `--pipeline`, `--strength`, `--steps`, -`--guidance-scale`, `--device`, `--model`, `--upscaler`, `--tile`/`--tile-size`/ -`--tile-overlap`, `--humanize`, `--unsharp`, `--adaptive-polish`, `--controlnet-scale`, -`--min-resolution`, `--hf-token`, `--auto`, `--verbose`. Plus uncovered VALUES of covered -flags: `--backend migan|lama` had never been driven through the CLI at all (only at -library level), `erase --backend` only ever ran `cv2`, `batch --mode all` never ran, and -`--pipeline` only ever ran its default. - -Whole subsystems had unit tests but no real-data run: tiling (27 unit tests, never -processed a real image through the CLI), the region-targeted composite, the ESRGAN -upscaler, and the ffmpeg audio/video strip. The gap is not "logic untested" but +Compare the flags and values exercised by the matrix against the options declared by +the CLI. Include optional backends, batch modes, tiling, region-targeted composition, +the ESRGAN upscaler, and the ffmpeg audio/video strip. The gap to find is not only +"logic untested" but "never executed on real data", which is precisely what this campaign is for. #### Bug found by the extension: `--steps` below ~7 crashes inside torch @@ -198,7 +112,7 @@ evidence of a common cause, not of many faults -- check the shared input first. ## Tier B -- constructed ground truth (automatable, no labelling) Where reality gives no answer key, build one. This is the tier that closes the two biggest -holes: fill quality has **never** been asserted, and recall was measured once at n=240. +holes: fill quality and detector response at the edge of the operating range. ### B1. Fill quality with a true reference @@ -214,210 +128,13 @@ INSIDE the footprint (whole-frame PSNR sits near 60 dB whatever the backend does the MEDIAN (a fill that reproduces a flat background exactly scores PSNR=inf, and one inf makes a mean inf -- the first run reported "+inf" for every flat bucket). -#### First run, 2026-07-19, 80 verified-clean sources, n=720 measurements +#### Local fill-quality protocol -Median dB recovered inside the footprint (filled PSNR minus damaged PSNR): +Construct marked images from clean local references, compare each backend against the known original inside the affected footprint, and keep the report under `.local-eval/`. -| mark | bg | cv2 | migan | lama | -|---|---|---|---|---| -| doubao | flat | +10.79 | **+15.62** | +13.97 | -| doubao | mid | +9.25 | **+14.71** | +14.69 | -| doubao | textured | +2.15 | +0.66 | **+1.64** | -| jimeng | flat | +5.52 | +8.80 | **+10.30** | -| jimeng | mid | +10.78 | +11.05 | **+12.33** | -| jimeng | textured | +2.16 | +1.70 | **+3.03** | +### B2. Detector response curves -**The `auto` order is CONFIRMED.** Median per-image recovery on the textured tercile: - -| mark (textured) | cv2 | migan | lama | -|---|---|---|---| -| doubao | +0.06 | +1.53 | +1.38 | -| gemini | +1.79 | +2.75 | **+5.59** | -| jimeng | +1.24 | +2.69 | **+3.34** | -| jimeng_pill | +4.27 | +3.81 | **+5.16** | - -LaMa > MI-GAN > cv2 holds on 3 of the 4 marks worth filling, and MI-GAN edges LaMa on -doubao. Nothing here argues for changing `--backend auto`. - -**Correction, and the statistic that caused it.** An earlier version of this section -claimed the opposite -- that MI-GAN was the WORST on texture, below cv2 -- and it was -wrong. The report computed recovery as `median(filled) - median(damaged)`: a **difference -of medians**, not the median of the per-image differences. On skewed data those are -different statistics and here they disagreed in SIGN. A paired per-mark sign test settled -it: on the textured tercile cv2 vs MI-GAN is not significant for any mark (p 0.13-1.00; -pooled n=119, cv2 wins 69, p=0.099), while MI-GAN's medians are higher for 3 of 5. - -The wrong statistic nearly shipped a change to `--backend auto`, which is the resolver a -memory-constrained CPU caller depends on. Two lessons: report the median of the per-image -DIFFERENCES when the question is paired, and confirm a ranking with a paired test before -acting on a table of independently-aggregated columns. - -**Every backend collapses on texture**: recovery falls from ~+10-15 dB to ~+1-3 dB and SSIM -from ~0.79-0.95 to ~0.30-0.44. The documented "textured is where fills struggle" is -confirmed, with numbers, for the first time. - -**The invariant held**: 0 violations of "the fill touches nothing outside its mask" across -all 720 measurements and all three backends. - -#### Visible parity, first full run, 2026-07-20 (10,593 image/mark pairs, cv2) - -| mark | n | detector-clean after removal | -|---|---|---| -| jimeng | 298 | 100% (CI 98.7-100) | -| gemini | 4,974 | 95.3% (CI 94.7-95.8) | -| doubao | 2,580 | 91.8% (CI 90.7-92.8) | -| samsung | 3 | 100% (CI 43.8-100) | -| jimeng_pill | 2,738 | 32.4% -- **wrong path, see below** | -| **excluding the pill** | **7,855** | **94.3% (CI 93.8-94.8)** | - -Backend-independence was CHECKED, not assumed: the same 309 pairs through cv2 / MI-GAN / -LaMa agree within one image on doubao (91.9% x3), gemini (91.4 / 92.1 / 91.4) and jimeng -(100% x3). Only the pill diverges (32.5 / 25.0 / 25.0), and 17 of the 18 disagreements are -the pill. So one backend suffices for parity; quality is B1's job, not this sweep's. - -**The residual is NOT one phenomenon -- it splits by mark.** Checking whether a still- -detected mark carries vendor provenance independent of the visual detector, against the -cleaned marks as a control: - -| mark | still detected | cleaned | reading | -|---|---|---|---| -| doubao | 82.9% corroborated | 80.0% | indistinguishable -- the marks are REAL | -| gemini | 39.6% | 62.4% | significantly lower -- much of it is false fires that persist | - -The doubao half turned out to be the front-end mismatch bug (fixed 2026-07-20, see the -CLAUDE.md rule): `remove()` was a silent no-op on 57 of 60 sampled cases because the -binarized mask came back empty. After the fix, 60/60 of those clear, so doubao parity -should now sit near 99%; **the sweep has not been re-run to confirm that**. - -Method note: two intermediate checks were worthless and both looked meaningful. -`MarkDetection.region` for a text mark is the GEOMETRY box derived from frame dimensions, -not a match position, so "the detector's box did not move after the fill" was predetermined, -and "the mask covers 96.5% of the detected region" compared geometry against the same -geometry. What actually answered it: looking at the pixels, and testing whether `remove()` -changed the array at all. - -#### The Jimeng pill: measure the GATED path or the number is meaningless - -The parity audit calls `get_mark(key).remove`, which bypasses `_keep_pill`. On the pill -that path reports "detector still fires after removal" 68-75% of the time and reads as a -broken feature. It is not: the product gates the pill hard, and `--mark auto` behaves -completely differently. - -Measured through the product path over **all 2,738 pill positives** (2026-07-20): - -| | n | | -|---|---|---| -| raw detections | 2,738 | | -| corroborated real (wordmark or TC260) | 346 | 12.6% | -| the gate lets through to removal | 125 | 4.6% | -| **of those, corroborated real** | **125** | **precision 100% (95% CI 97.0-100)** | - -Every single pill the product removed was corroborated. Headline recall is 36.1%, but that -denominator is wrong: **TC260 provenance maps to BOTH ByteDance products**, so a "TC260 -says Jimeng" image may be a Doubao image with no pill at all. Split by evidence strength: - -| corroboration | n | removed | recall | -|---|---|---|---| -| wordmark (names the product) | 49 | 49 | **100% (CI 92.7-100)** | -| TC260 only (cannot separate Doubao) | 297 | 76 | 25.6% (CI 21.0-30.8) | - -So where the evidence actually names Jimeng, the pill is removed every time. The flatness -guard suppresses 186 TC260-only cases -- its measured recall cost, paid to avoid the -smeared textured fills it exists to prevent. - -Two harness lessons, both of which produced a wrong number before being caught: -- **Gated marks must be measured through the gate.** A per-mark audit answers a question - the product never asks. -- **Match mark labels exactly.** A substring test on `AI生成` also matches Doubao's label - (`Doubao 豆包AI生成 text`), counting Doubao removals as pill removals and inflating the - pill's precision. - -#### The finding that was not being looked for: filling a faint mark is net negative - -Samsung came out negative in every cell, which looked like a broken mark. It is not -- the -sign is set by how strongly the mark perturbs the image, not by which mark it is. Per-mark -recovery by damage band (a HIGH damage-PSNR means a FAINT mark): - -| mark | 0-18 dB | 18-22 dB | 22-26 dB | 26+ dB | -|---|---|---|---|---| -| doubao | +9.78 (n=160) | -1.52 (36) | -3.37 (18) | -8.38 (23) | -| jimeng | +7.70 (187) | -2.49 (21) | -2.54 (15) | -2.34 (15) | -| samsung | - | **+7.23** (48) | -3.78 (102) | -10.78 (90) | - -Samsung is POSITIVE where its mark is strong; doubao and jimeng go NEGATIVE where theirs -are faint. Linear fit over all 720: `recovery = -0.861 * damage_psnr + 19.47`, break-even -at **~22.6 dB**. Samsung's alpha map peaks at 0.37 against doubao 0.68 and jimeng 0.93, so -Samsung simply sits on the faint side of that line most of the time. - -So: **below ~22.6 dB of mark damage, inpainting costs more fidelity than the mark did.** -The pipeline currently fills unconditionally once a mark is detected, so this cost is -invisible today. - -Do not read this as "stop removing faint marks". A user who wants the watermark GONE is not -optimizing PSNR, and a faint mark is still a mark. What it says is that the fill has a real -cost, it is now measurable, and for faint marks it exceeds the thing it removes -- which -makes "how faint is too faint" a product decision that can finally be made on evidence. - -### B2. Detector response curves -- RUN 2026-07-20 - -`scripts/detector_response.py`. Stamp marks across a controlled grid (size x contrast, -crossed with real corpus backgrounds and frame aspects) and measure two things per cell: -`detected`, and whether the same call path then yields a non-empty removal mask -(`maskable`). The second column exists because the gap between them is a silent no-op -- -`identify` reports a mark that `visible` skips -- and any detection-only harness scores -that bug as a success. - -**Read the nominal cell, never the aggregate.** The grid deliberately visits sizes and -opacities no engine was calibrated for, so its overall rate is an adversarial score, not -recall. Production recall still comes from the unbiased corpus sample. - -What it found: - -- **The size response is a COMB, not a curve.** `_tophat_score` sweeps exactly three rungs - (0.8, 1.0, 1.25). Doubao scores ~0.99 at each rung and collapses to 0.37-0.48 between - them, against a 0.50 gate -- so a mark ~10% off a rung is missed at FULL contrast. The - `binary` front-end has no ladder at all: jimeng holds one lobe over 0.90-1.20, samsung - only 0.95-1.05, i.e. samsung requires a mark at essentially its exact nominal size. -- **Contrast is nearly irrelevant** on the tophat front-end -- the response is - max-normalized, so a mark at 15 luma levels of contrast still scores 0.984. -- **No detected-but-unmaskable cells** at any grid point, so the front-end/mask parity fix - holds across the whole operating range, not just where the corpus happened to look. - -And the follow-up that stopped a bad change: dead zones only cost recall if real marks land -in them. `scripts/ladder_headroom.py` measured that on the corpus (positives = frames whose -metadata names the vendor, negatives = frames with no metadata signal, deliberately NOT -filtered on the detector's own verdict, which would make its false-fire rate 0 by -construction). A 13-rung dense ladder recovers **28 of 368 misses (7.6%)** while false fire -goes **2.52% -> 3.05%**. So the comb is real and mostly unvisited: the fractions were -calibrated on real captures and real marks cluster at the rungs. **Do not densify the -ladder** on this evidence. - -The residual looked like a clean lead and, on a full 923-positive / 3546-negative run, -turned out not to be. A targeted `plus_one` ladder (one extra rung at ~1.116) recovers 36 -of the 39 marks the 13-rung dense ladder recovers -- so the win really is that one rung, -not density. But **all 36 recoveries AND all 18 of its added false fires are LANDSCAPE at -that same rung** (2.0:1 overall, 1.7:1 even gated to landscape). The rung is not vendor -signal; it is a size shift that helps and hurts equally. - -And the obvious "fix it at the source" -- bump the landscape width fraction so that -subpopulation lands on the nominal rung -- is **falsified by the same data**: the 55 -currently-DETECTED landscape positives already peak at scale 1.0, not 1.116. So there are -two size clusters of landscape doubao marks, one at the calibrated nominal and one ~11% -larger, and moving the nominal would drop the cluster that works to catch the one that does -not. The larger cluster is genuinely a different size and is inseparable from landscape -false fire at the NCC gate -- the same detector-discrimination wall as vendor attribution, -not a geometry constant anyone forgot to set. **Do not add the rung, and do not move the -landscape fraction.** The per-rung scores are stored in `_ladder_headroom_doubao.jsonl` so -this verdict can be re-derived without re-running the sweep. - -Why the misses are not a tuning problem: on the sampled misses the dense-ladder score is -bimodal -- detected marks sit at median 0.938, misses at 0.155, and **the band 0.31-0.52 is -empty**. There is no near-gate cluster, so no threshold recovers them. Eyeballing 26 miss -corners explains it: ~6 are jimeng (TC260 names no vendor, so a "doubao positive" is often -a jimeng frame), ~14 carry no visible mark in that corner at all, 2 belong to **uncovered -vendors** (`千问AI生成`, `百度 AI生成`), and only ~4 are genuine doubao failures -- on -saturated or structurally busy corners, with heterogeneous causes (the saturation gate -explains exactly one of five tested). +Use `scripts/detector_response.py` to sweep mark size, opacity, background, and aspect. Report detection and maskability separately. Generated reports stay under `.local-eval/`. ### B3. Invisible round-trip, positive-control gated @@ -438,15 +155,11 @@ regression here is invisible today and would only surface under load. The machinery exists: `visible_recall_sample.py` -> `visible_sheets.py` -> `visible_groundtruth.py` -> `visible_eval.py`. -- **Recall** is the known weak spot: measured once, unbiased n=240, and that single - measurement is what exposed the landscape miss. Expand per mark, especially jimeng - (n=14) and jimeng_pill (n=6), whose numbers currently rest on almost nothing. -- **Precision** re-runs over the existing 779-cell ground truth; benchmark every detector - change with `--vs `. -- **Coverage**, the largest known gap: ~6% of sampled images carry an uncovered vendor's - mark (百度 / 星绘 / 抖音-class; 千问 was the first of this class and is registered since - 2026-07-21) that no registered detector can fire on. This is a - coverage problem, not a tuning problem, and no threshold work will move it. +- **Recall** needs representative sampling per mark and aspect ratio. +- **Precision** must be benchmarked before and after every detector change with + `--vs `. +- **Coverage** is separate from tuning. A missing vendor detector cannot be repaired by + changing another detector's threshold. Three harness rules are load-bearing and must not be relaxed: score a mark only within its crop's adjudication scope; take provenance from metadata, never from labels; and never @@ -464,11 +177,7 @@ that is all it can do. See `docs/synthid.md`. ### D1. Sampling frame -The sidecars already classify the corpus: **15,000 images whose watermark list mentions -SynthID**, of which 9,071 carry `verify_oracle=openai` and 5,929 `verify_oracle=google`. -Stratify on the two axes that actually move removal efficacy -- vendor (the certified -floors differ: OpenAI 0.10, Gemini 0.15) and content class (photoreal vs flat graphic, -where the pipelines are documented to diverge). +Select a representative local sample without committing images, identifiers, dataset sizes, or oracle results. Stratify by signal family and preserve the sampling method outside the public repository. ### D2. The oracle is the bottleneck, not the GPU @@ -509,7 +218,7 @@ measurement, so it is a hypothesis to test, not a reason to skip the control. ## Tier E -- robustness and adversarial inputs -Malformed and hostile inputs, since ~0.2% of real uploads are already truncated: truncation +Malformed and hostile inputs, including truncated files: at many offsets, corrupt headers, 16-bit and CMYK, absurd dimensions, decompression bombs, zero-byte files, unicode and RTL filenames, symlinks, read-only output dirs, concurrent runs on one file. The bar is never "handles it" but **never raises and never silently degrades**. @@ -517,10 +226,10 @@ on one file. The bar is never "handles it" but **never raises and never silently ## Build order 1. **A1 sidecar regression** -- highest value per hour, unattended, needs no new labels. -2. **A2/A3/A4 parity and invariants** -- full corpus, reuses existing audit scripts. +2. **A2/A3/A4 parity and invariants** -- representative local set, reusing existing audit scripts. 3. **B1 fill quality** -- closes the oldest unmeasured claim in the project. 4. **B2 detector curves** -- cheap, and directly guards the geometry class of bug. -5. **A5 contract sweep at corpus scale**. +5. **A5 contract sweep over a representative local set**. 6. **B4 resource ceilings**, **E robustness**. 7. **C recall expansion** -- gated by labelling appetite. 8. **D oracles** -- manual, per release. @@ -541,558 +250,32 @@ strengthening the strict-path detectors themselves; it is not a gating problem. Per mark, what actually goes away when metadata is stripped: -- **The pill loses an entire arm.** Its TC260 arm is dead without metadata, leaving only - the wordmark arm. Measured: where the wordmark corroborates, pill recall is **100%** - (49/49); on TC260-only evidence it is 25.6%. So on stripped uploads the pill's fate - rests entirely on Jimeng wordmark detection -- whose own recall is **71% on n=14**. - This is the weakest link with the most leverage: every point of wordmark recall pulls - the pill along with it. -- **The sparkle already runs on pixels**, and the FP-gate tightening cost ~120-200 genuine - detections corpus-wide (12.5% of the 1,256 lost). That headroom exists but the - precision trade behind it was deliberate. -- **The largest gap is metadata-independent by nature**: ~6% of sampled images carry an - uncovered vendor's mark (百度 / 星绘 / 抖音-class; 千问 is registered since 2026-07-21) - that no registered detector can fire on at all. +- **The pill loses its metadata-confirmed arm.** Without metadata it depends on the + bottom-right Jimeng wordmark. +- **The sparkle already runs on pixels.** Its threshold remains a deliberate + recall-versus-precision trade. +- **Uncovered vendors are metadata-independent gaps.** They require a detector of their + own. ### Where the evidence points -1. ~~A generic CJK AI-mark detector~~ **superseded for 千问 (2026-07-21).** The AUC ~0.5 - non-separability from Doubao was measured at the WRONG size; at the fitted geometry an - exact-size 6-glyph template separates 千问 from 400 doubao-marked frames with ZERO - cross-fire at the gate, so per-vendor registration won and shipped. The generic - class-detector shape may still be right for the long tail of compliant vendors, but - its motivating measurement is gone. -2. **Port the `tophat` front-end to the remaining marks.** It took Doubao from 89% to 92% - recall at unchanged 99% precision. But the gate is front-end specific and **must be - recalibrated, never ported**: a naive 0.40 produced 8 false fires instead of 1 and - silently halved the pill's recall (because `_keep_pill` suppresses the pill whenever - Doubao fires). -3. **The Jimeng wordmark.** Weak on its own (71%/71%) and it gates the pill. Its - silhouette is also non-discriminative against Doubao's, which was patched with a 0.85 - threshold -- a patch on a detector problem, not a fix. +1. Prefer per-vendor CJK templates when fitted geometry separates similar marks. +2. Recalibrate every detector when changing its front-end. Thresholds do not transfer + between `binary`, `tophat`, and `gray`. +3. Treat the Jimeng wordmark as a load-bearing confirmation path for the pill. ### Measure before improving -Jimeng recall rests on **n=14** and the pill's on **n=6**. Improving what is measured by -six samples means not knowing whether it improved. Tier B2 (detector response curves on -stamped marks) is the instrument to build first: recall as a function of size, contrast -and background texture, with no new hand labelling, and it catches the geometry class of -bug (`scale_basis`) directly. +Use Tier B2 detector-response curves before tuning a detector with sparse labelled +examples. Sweep size, contrast, aspect, and background texture so geometry regressions +are visible without exposing private evaluation statistics. This section records what the measurements imply technically. Prioritization is tracked separately, outside this repo. -## Real-example end-to-end run (2026-07-20) +## Local end-to-end verification -The corpus sweeps all call the library IN-PROCESS. This run drives the actual -`remove-ai-watermarks` entry point as a user would, over real corpus images and the -committed real fixtures, and checks the OUTCOME rather than the exit code -(`scripts/real_examples_e2e.py`). **26 of 28 behaviours passed.** - -| Command | Real examples | Result | -|---|---|---| -| `identify --json` | 6 provenance classes (OpenAI, Adobe, Midjourney, Doubao/TC260, Grok, FLUX) | 6/6 correct verdict + platform | -| `metadata --check` / `--remove` | the same 5 real AI-metadata files | 10/10 detected, and the output re-scans clean | -| `visible --mark auto` | a live positive per mark | doubao / jimeng / gemini removed and re-detect clean; pill correctly DECLINED by the gate; samsung partial (below) | -| `erase --region` | one real image x 3 backends | cv2 / MI-GAN / big-LaMa all wrote output | -| `batch --mode visible` | a real 5-image directory | 5/5 outputs | -| `invisible` (MPS, `--max-resolution 512`) | a real Gemini and a real OpenAI carrier | both wrote a genuinely CHANGED image | -| `all` (MPS) | a real Gemini carrier | one transient exit 1, not reproducible (see below) | - -**Samsung is a real, reproducible partial.** It is the faintest registered mark (peak alpha -~0.38) sitting on a 0.40 gate, so the margin between "detected" and "removed" is razor thin. -Over the entire corpus population (n=3, all of it) the CLI clears 2 of 3 outright -(0.446 and 0.440 -> below gate) and on the weakest one reduces 0.431 -> **0.404**, which is -still fractionally over the gate. The glyph IS filled and the confidence IS reduced; the -residual re-detects. This is the faint-mark residual class on the `binary` front-end, which -has no equivalent of the `tophat` faint-mask fallback. **Not fixed:** with n=3 corpus-wide -there is no way to tell an improvement from noise, which is the same -improving-what-3-samples-measure trap recorded under Open items. - -**The pill "failure" was the harness, not the product.** `detect_marks` fires `jimeng_pill` -but `remove_auto_marks` returns no label -- `_keep_pill` correctly declines an uncorroborated -low-confidence pill, so `visible` writes nothing and exits 2. The harness now asks the -product what it DECIDED and treats a correct decline as a pass. - -**The `all` exit 1 did not reproduce** -- the same invocation ran clean standalone twice and -again in the exact three-run sequence that produced it (all exit 0, step 2 executed, no skip -banner). It stays recorded as a transient because it could not be diagnosed: the harness -discarded the command output, and `cmd_all` has three distinct `SystemExit(1)` paths -(unreadable input, unreadable intermediate, and the deliberate synthid-skipped banner) that -an exit code alone cannot distinguish. The harness now retains the output tail on any -failure, so a recurrence is identifiable. - -## Tier E: robustness (2026-07-20) -- RUN, and it found two real crashes - -`scripts/robustness_suite.py` drives the real CLI over adversarial and degenerate inputs and -scores GRACEFULNESS, not success: a non-zero exit with a readable message is a pass, an -unhandled traceback or a hang is a fail. **33/33 graceful after two fixes; 31/33 before.** - -Covered: truncated and corrupt files, zero-byte, a text file named `.jpg`, 1x1 and -1x4000 slivers, a 729 KB decompression bomb declaring 16000x16000, Unicode and RTL -filenames (read AND write), a mismatched extension, a nonexistent nested output dir, a -read-only output dir, a directory passed as a file, a missing path, 4x concurrent runs -against one input, and batch over both an empty and an undecodable directory. - -**Both defects were invisible to the 849-test suite**, because unit tests feed well-formed -fixtures into writable directories. Both are now regression-guarded by -`tests/test_cli_robustness.py`. - -1. **A failed write crashed on the size report.** `image_io.imwrite` is contractually - non-raising -- it returns `False` when the path cannot be written. But - `write_bgr_with_alpha` discarded that bool and returned `None`, so **no caller could - distinguish a failed write from a successful one**, and all five write sites then ran - `output.stat()` to print the size. A read-only output directory produced a bare - `FileNotFoundError` traceback pointing at the stat rather than at the write. The signal - existed the whole way down and was thrown away by one wrapper. Fixed at that wrapper - (propagate the flag) plus one shared `cli._write_output_or_exit`, so all sites are - covered rather than the one that happened to be caught. -2. **A directory passed as the image crashed the scanner.** `click.Path(exists=True)` - accepts directories unless told otherwise, so `identify ` reached `open()` and - raised `IsADirectoryError`. Fixed by `dir_okay=False` on all six `source` arguments -- - argument parsing now refuses it, which is where it belongs. (`batch` was already correct: - it declares `file_okay=False`.) - -3. **The worst instance was found by the /simplify review, not by the sweep: `batch` into a - read-only directory wrote ZERO files for 2 inputs and exited 0.** No traceback, no - error, a success code and an empty output directory -- a wrapping service would treat - that as a completed run. `graceful()` structurally cannot see this class, because it - scores exit code and traceback markers and this failure has neither. The suite now - carries a `batch_readonly_outdir` case that asserts on the ARTIFACTS (how many files - exist) rather than on the status. **Any check for a silent no-op has to assert on the - output, not the exit code.** - -The fix is NOT uniform, and that matters: the single-image commands exit via -`cli._write_output_or_exit`; `api._write_visible_result` RAISES so a library caller gets an -accurate error rather than a confusing `FileNotFoundError` from the downstream metadata -strip; and the batch sites raise too but must never `SystemExit`, because the batch loop -counts per-image exceptions and aborting would kill the whole run instead of failing one -image. - -The lesson worth keeping: **a non-raising IO contract needs its return value checked at -every call site, and a wrapper that swallows it silently disables the contract for -everyone downstream.** Grep for other `-> None` wrappers over non-raising primitives -- -`invisible_engine.py:346` still discards `imwrite`'s flag and is the same shape. - -## Tier B4: resource ceilings (2026-07-20) -- RUN - -`scripts/resource_ceilings.py`, one FRESH process per cell (peak RSS inside a long-lived -process is contaminated by whatever ran before it, and the question is what a per-request -worker needs). The mask is a fixed, small corner region at every input size, so the thing -under test is whether the learned backends really crop around the MARK rather than -processing the frame. - -| backend | peak RSS 1MP -> 25MP | wall | scaling | -|---|---|---|---| -| cv2 | 74 -> 440 MB | 0.02-0.12 s | **grows 5.9x with input size** | -| migan | 603 -> 775 MB | ~0.6 s | flat | -| lama | 4679 -> 4779 MB | ~3.8 s | flat | - -**The documented claims hold.** CLAUDE.md's "migan ~0.6-0.9 GB regardless of upload size" -and "lama ~4.7 GB peak" both reproduce to the digit, and the crop-around-the-mask design is -confirmed: both learned backends are flat in input size. This is also the measurement -behind the deployment split (free tier `migan` fits a small droplet under 1 GB; paid tier -`lama` needs ~5 GB). - -**New information: cv2 is the only backend that scales with the input.** It inpaints the -full frame rather than a crop, so at 25 MP it costs 440 MB -- still the cheapest tier, but -6x its small-image footprint, which is worth knowing when sizing a worker that accepts -phone-camera uploads. Its wall time stays trivial throughout. - -Caveat on the wall times: each is a COLD process including model load, so migan's ~0.6 s -here is not comparable to the ~0.19 s warm figure quoted elsewhere. Cold is the honest -number for a per-request worker; warm is the honest number for a long-lived one. - -**Two method notes, both cases of the harness corrupting its own measurement.** - -1. The first version called `erase()` with a positional `boxes` argument, which the - keyword-only signature rejects. All 12 cells failed identically and still reported - plausible-looking RSS (60-129 MB) -- the process's numpy footprint with no backend work - at all. Twelve identical failures were one bad call, and only the printed error made - that visible. The child now asserts the output actually differs, so a no-op cannot be - reported as a measurement. -2. That assertion was then itself the contaminant: `(out != img).any()` allocates a boolean - temp the size of the IMAGE before `getrusage` is read -- ~75 MB at 25 MP, and it grows - with the input, so the harness partly manufactured the very "cv2 scales with input size" - conclusion it was measuring. Now it compares only the mask box. **Re-measured: the - conclusion survived, the digits moved.** cv2 6.1x -> 5.9x, migan max 847 -> 775 MB, lama - max 4849 -> 4779 MB; the table above is the corrected run. Worth keeping because the - contaminated numbers were already committed to CLAUDE.md before the check was - questioned -- a verification step is part of the instrument and needs the same scrutiny - as the thing it verifies. - -## Open items (as of 2026-07-20) - -Everything below is known, measured, and deliberately not done yet. Each line says what it -would take, so none of it has to be rediscovered. - -### START HERE next session - -Item 1 from the previous session is **DONE (2026-07-21)**: 千问 is registered -- -see "The 千问 harvest (2026-07-21)" below for the decision and the numbers. In -priority order: - -1. **Decide the exit-code split** (open defect 2). It is a deliberate product call, not - research: it is breaking for existing wrappers, so it needs a yes/no rather than more - measurement. -2. **The two small correctness items** (open defects 3 and 4) -- both are contained, both - have the fix written out below. -3. **The bonus vendors from the harvest** (元宝 n=50, 可灵 n=30, cat-logo n=19) - started with the 千问 playbook: font-rendered silhouette, `--fit-geometry`, gate - calibration against the contamination-guarded clean arm, and crossfire against - doubao/jimeng. 可灵 registered on 2026-07-21; 元宝 registered on 2026-07-25 after - correcting a clipped synthetic render and adding polarity-independent local - contrast. The cat-logo remains evidence-limited to two byte-unique carriers. - 可灵 additionally stamps a second mark bottom-LEFT, which no current text-mark - config expresses (the pill is top-left; a bottom-left CJK mark needs a - `corner="bl"` CJK config -- samsung is `bl` but Latin-script and width-based). - 星绘 is NOT in the corpus in labelable quantity -- verified, do not hunt it - again. (百度 WAS found later via the USCC cohort harvest and is registered since - 2026-07-22 -- see "The 2026-07-22 vendor round" below.) - - **STATUS 2026-07-25: 可灵 and 元宝 REGISTERED.** - * **可灵 (`kling_engine.py`)** -- "可灵AI 3.0" bottom-right, strict-only, gate - 0.35, no rival margin, shared 3-rung ladder (the mark is UNIMODAL at 0.12 of the - short side). Cohort-vs-clean (286 guarded clean frames): clean p99 0.304 / max - 0.320; 9 of ~19 eyeballed visible marks fire = ~47% recall of visible marks, all - 9 true (precision 9/9). The misses are the faint "Omni"-suffix release, the - latin "KlingAI 3.0" release and the version-less "可灵AI" (0.17-0.25, inside the - clean arm's top tail -- unreachable). Crossfire: 1/400 doubao (a 豆包 frame - INSIDE the kling cohort, still below gate), 0/298 jimeng, 0/286 clean. Parity - 9/9 detect->cv2 fill->re-detect clean. A confident kling detection suppresses - the jimeng pill exactly like doubao's/qwen's does. The bottom-LEFT `AI生成` - pill variant was NOT seen in this cohort's contact sheet at registration time - and stays unhandled. - * **元宝 (`yuanbao_engine.py`) -- REGISTERED 2026-07-25.** The earlier measured - negative was invalid because the synthetic renderer applied negative shear - without an x translation. That clipped most of the lower `AI生成` line off the - left edge and left a wide blank tail, which the matcher then squeezed into the - fitted box. The corrected renderer translates before shearing and tightly - crops the result. A polarity-independent local-contrast front-end then covers - both light-on-dark and dark-on-light stamps. Gate 0.38 plus the measured - bottom-right anchor detects 26 of 28 standard two-line marks across 33 - byte-unique cohort frames (92.9%) and fires on 0 of 286 byte-unique clean - controls (clean max 0.348). Detect -> cv2 fill -> re-detect is clean on 26/26. - The separate one-line photographer overlay remains unregistered because only - one example exists. - * **cat-logo -- probe READY, parked on evidence.** The cohort (USCC - 91110108562144110X) is 19 frames but only **2 unique carriers** (byte-unique) -- - the xinghui rule (nothing registered off ~one frame) applies. The mark is an - outline cat-head + bold "AI生成", bottom-right, ~0.25 of the width, very bold. - A drawn synthetic silhouette (`draw_catlogo` in `render_vendor_silhouettes.py`; - a solid filled head scored 0.35, the outline form 0.50 -- iterated against a - real tophat response) separates: mark 0.50 vs a diverse clean arm max 0.333 - (n=29 probe). Registration is a gate pick (~0.42) the moment more unique - carriers arrive; recall across diverse cat-logo generations is unmeasurable at - n=2. Fit-trap that applied here too: the mark is bigger than Doubao's box - (0.25 of width), so the inherited box clipped it exactly like qwen's. - -Do NOT restart the sweeps to "check". Their artifacts are on disk and listed under -"Completed full runs" below; re-running costs hours and answers nothing new. The fast way -to confirm the whole surface still works after a change is -`uv run python scripts/real_examples_e2e.py` (~2 min, real corpus examples through the real -CLI) plus `uv run python scripts/robustness_suite.py` (~3 min, adversarial inputs). - -### The 2026-07-22 vendor round -- 3 REGISTERED (runninghub / baidu / liblib), 2 parked - -A fresh metadata-mining pass over the whole corpus (`data/spaces/_mine_signals.py`) -found NO new metadata signals (the channel is saturated), so the round worked the -visible-mark cohorts (`vendor_cohort_harvest.py`, 4606 TC260 carriers / 46 entities). -Registered, each by the qwen playbook (synthetic silhouette -> measured geometry -> -clean-arm gate -> crossfire -> full-corpus sweep): - -* **RunningHub (`runninghub_engine.py`)** -- "RunningHub AI生成" TOP-LEFT (a new - `corner="tl"`), faint mid-gray text. The white top-hat suppresses it to clean-arm - levels (positives 0.16-0.23 vs clean p99 0.31), so it introduced the third - detection front-end, **`gray`** (raw-grayscale silhouette NCC, contrast-DEPENDENT): - positives 0.38-0.54 vs clean max 0.295 -> gate 0.34, strict-only. The NCC comb is - razor-sharp in size (0.537 on-size, 0.223 at +5.6%), so the ladder is a tight - (0.95, 1.0, 1.05) exactly on the measured 0.32-of-width. Two measured traps with - their fixes: (1) the binary blob under-segments the faint head glyphs, so the - blob-bbox footprint left "Runni" unremoved -- the gray front-end's footprint is - always the detector's own match box; (2) the full-corpus sweep surfaced 37/42009 - outside-cohort false fires at 0.34-0.38 (hair, shelves, CJK banners) with no NCC - separation from the 0.381 positives -- the **anchor gate** (the match must sit at - the measured corner, x<=0.025/y<=0.015 of the frame) rejects all 37 at zero - positive cost. -* **Baidu (`baidu_engine.py`)** -- "百度" white bold text + a white rounded tag - "AI生成", bottom-right. Detection keys on the 百度 TEXT RUN ONLY: a - text+pill template was a measured bright-blob magnet (no separation on either - front-end). Gate history, each step measured: 0.37 from the clean arm (max 0.352); - the 741-frame eval set then fired 14x outside the cohort and 13 were NOT the - vendor (12x 千问 -- 百/千 are near-identical after binarization -- plus one 抖音 - AI创作 at 0.425), so `rivals=("doubao_alpha.png","qwen_alpha.png")` (both margins - load-bearing, zero genuine cost) and the gate moved 0.37 -> 0.43; the full-corpus - sweep then put outside-cohort true carriers at 0.50-0.66 vs the false arm max - 0.47, so the gate settled at **0.48**. Cohort: 7/16 fire (all true). The - footprint is custom: the tag's flat white interior gives no top-hat response, so - a blob bbox leaves the tag as a ghost -- the mask is the match box extended right - to the corner. -* **LibLibAI (`liblib_engine.py`)** -- triangle logo + "LibLibAI" wordmark - **bottom-CENTER** (a new `corner="bc"`). The discriminative lever was the FONT: - STHeiti scored the cohort 0.31-0.47 against a false arm (latin UI text bands) at - 0.50; measured across 7 fonts, **Arial** lifts the cohort to 0.42-0.73 and DROPS - the false arm to max 0.398 (generic latin text matches the wrong font less). Gate - 0.42, strict-only; a per-mark size floor (`_MIN_SHORT_SIDE=480`) backs it (the - one remaining false fire was a 200x200 icon on a 20px template). Custom - footprint: match box extended left by ~1.3 glyph heights for the triangle logo - (the blob bbox both bled into background structure -- ate a shirt's real print -- - and did not own the logo). - -Parked, both as measured negatives with the silhouette kept in -`render_vendor_silhouettes.py` as the starting point: - -* **Zhipu Qingyan (清言·AI生成)** -- 7-frame cohort, white semi-transparent text + - swirl logo. On both front-ends the cohort scores 0.34-0.39 vs clean max - 0.34-0.37 -- no separation at any render/box setting (text-only and - logo-composite templates, two CJK fonts). Unlike the old Yuanbao result, no - malformed-render explanation has been found for this overlap. -* **MiniMax / Hailuo AI** -- only 1 of 6 cohort frames carries a visible mark - (Hailuo is a video product; the mark is a video-frame stamp). The xinghui rule: - nothing registered off a single frame. - -The full-corpus sweep harness is `data/spaces/_sweep_new_marks.py` (read-only, -gitignored); its artifact `_new_marks_sweep.jsonl` records every fire. The sweep -also proved the outside-cohort value of registration: 6 metadata-STRIPPED true -Baidu carriers the TC260 cohort cannot see are now detected and cleaned. - -### The 千问 harvest (2026-07-21) -- RESOLVED, registered the same day - -**The unlock: the TC260 label is not anonymous.** Its `ContentProducer` field carries the -producer's Chinese Unified Social Credit Code (`001191110102MACQD9K64010000` -> USCC -`91110102MACQD9K640`), which names a legal entity. So carriers partition into per-VENDOR -cohorts from METADATA ALONE, owing nothing to any pixel detector -- which is exactly what -broke the previous attempt, whose only way to find 千问 frames was to eyeball the misses of -a detector that cannot see them. A cohort is a LABEL: eyeball one frame, and every frame in -it is a labelled example. (CLAUDE.md's "the generic TC260 label names no specific vendor" is -about the label MARKER; the producer FIELD inside the block is a different thing.) - -New tools, both lint-clean, both **uncommitted**: -- `scripts/vendor_cohort_harvest.py` -- full-corpus metadata scan -> cohorts. Joins which - detectors fired from the completed `_visible_positives.jsonl` rather than re-running the - pixel pass. Artifact `data/spaces/_vendor_cohorts.jsonl` (**4441 carriers, 46 entities**). - `--sheets N` writes full-width top/bottom band crops per cohort for eyeballing. -- `scripts/vendor_mark_calibrate.py` -- scores a cohort against the 432 hand-labelled - `present: []` negatives from the 2026-07-18 round, and writes score-SORTED corner crops so - mark presence and the gate are read in one visual pass. -- `src/.../assets/qwen_alpha.png` + `xinghui_alpha.png` regenerated (they were listed in - `render_vendor_silhouettes.py` but had never been committed). - -**What the corpus actually contains** (this corrects the previous list of targets): - -| Cohort USCC | n | quiet | Visible mark | Verdict | -|---|---|---|---|---| -| 91440101MA9Y9T4H7A | 117 | 112 | `千问AI生成` bottom-right | **the target, confirmed by eye** | -| 91340100MAEB4N8H76 | 73 | 70 | mostly none; one `RunningHub AI生成` | metadata-mostly | -| 913502007378955153 | 113 | 109 | none seen | metadata-only | -| 91440300708461136T | 50 | 46 | `元宝AI生成` (Tencent Yuanbao) | bonus vendor, bold | -| 91441900557262083U | 49 | 45 | none seen | metadata-only | -| 91110108335469089C | 30 | 28 | `可灵AI 3.0` (Kling) + an `AI生成` pill bottom-LEFT | bonus vendor | -| 91110108562144110X | 19 | 19 | cat-logo + `AI生成`, in **19/19** | bonus vendor, very clean | - -**`百度` and the `星绘`/`抖音` class are NOT in this corpus in labelable quantity.** No brand -token for them appears in any AIGC label field, and every remaining cohort is <= 16 frames. -Do not spend another session hunting them here; the previous "one confirmed positive each" -is all there is. The corpus offers 千问 richly plus three DIFFERENT vendors instead. - -Also worth knowing: a cohort is a strong grouping key but names the SIGNING ENTITY, not -always the consumer brand -- the 千问 cohort contains one `造点AI生成` frame. And TC260 -provenance does NOT imply a visible mark, which is why four large cohorts above are -metadata-only. The cohort is the candidate pool; the eye settles mark presence. - -**RESOLVED 2026-07-21: 千问 is registered** (`qwen_engine.py`, strict-only, no rival -margin). The ladder trade-off that was the open decision is settled in favor of a -**per-mark ladder**, not the shared one and not a wider shared one -- and the path there -found two more geometry defects the "single fraction on the shipped ladder" framing had -missed. - -What the final calibration measured, in the order it happened: - -1. **The locate box was clipping the mark.** Scoring with the fitted fractions still - collapsed the cohort (p50 0.209). Frame-level diff against the wide-ladder fit showed - why: the real mark sits ~0.025 of the short side off the right edge, while doubao's - inherited box anchors at 0.004 -- the box's left edge cut into the 千 glyph, and an - exact-size template scored 0.26 where the fit's wider box scored 0.73. So the locate - fractions are as mark-specific as the template size; `--fit-geometry` now records the - absolute match rects and fits the box too (margins ~0.021, width 0.231, height 0.074). -2. **The size distribution is cleanly BIMODAL.** With the box fixed, frac_short clusters - at ~0.124 (13 frames) and ~0.203 (38 frames), nothing between -- two stamp sizes, - ratio 1.64, just over the shared ladder's 1.5625 span. That is why no single fraction - covers the mark. -3. **A per-mark 2-rung ladder beats both alternatives.** Candidates, both arms scored on - the shipped code path: (A) shipped 3-rung @ frac 0.167 -- cohort p50 0.538; (B1) - 2-rung (0.78, 1.27) @ frac 0.160, one rung centred on each mode -- cohort p50 - **0.662**; (B2) 4-rung (0.8, 1.0, 1.25, 1.5625) @ frac 0.155 -- p50 0.449, strictly - worse (its big-mode rung sits 4.6% off the mode, and the extra rungs cover nothing). - B1 also costs one matchTemplate LESS than the shipped 3. `TextMarkConfig.ladder` was - added with the default `(0.8, 1.0, 1.25)`, so every other mark's computation is - byte-identical (the full 876-test suite plus e2e + robustness confirm); a shared - densification was already ruled out by B2's false-fire measurement. The doubao - false-fire check the plan asked for reduces to that equivalence-by-construction -- - doubao's ladder never changed. -4. **`alpha_height_frac` measured, not inherited:** aspect fit at the winning width, p50 - 0.260 (tight, p10-p90 0.250-0.270) -> 0.0416. The silhouette's own aspect (0.2219) - and doubao's ratio were both measurably off. -5. **The clean arm was contaminated, and fixing it flipped the verdict.** The 2026-07-18 - `present: []` labels are in the vocabulary of the REGISTERED marks only -- 146 of the - 432 "clean" frames sit in a TC260 cohort, including 15 qwen-cohort frames VISIBLY - carrying 千问AI生成, and they were the clean arm's entire top tail (clean p99 0.37 -> - 0.69 with the fitted geometry). `load_sets` now drops every frame in ANY cohort. - Final arm: 286 frames, clean p99 0.301 / max 0.316. -6. **Gate 0.45, strict-only, no rival margin.** Every cohort frame >= 0.45 carries a - visible mark (83 of ~96 eyeballed visible marks fire = **86% recall of visible - marks**; the misses are white-on-near-white contrast losses); 0/286 clean fires; - crossfire at the gate: 0/400 on doubao-marked frames, 0/298 on jimeng-marked frames - (the shared `AI生成` tail correlates at p50 0.224, far below gate -- the AUC-0.5 - attribution fear from 2026-07-18 was a mis-sizing artifact). A 0.10 rival margin - would have cost ~10% of genuine qwen detections, so `rivals=()`. The band just below - the gate is dominated by non-qwen banners (夸克 anti-forgery strip 0.274, 造点 mark - 0.253), so a provenance-relaxed arm would be mostly false fills: - `provenance_ncc_factor` is pinned at 1.0 and qwen has NO provenance mapping. -7. **Parity confirmed end to end:** detect -> cv2 fill -> re-detect clean on **83/83** - real cohort marks, no empty masks; `real_examples_e2e.py` now drives a live qwen - positive through the real CLI (qwen bucket = symlinks under the gitignored - `_visible_datasets/`); a confident qwen detection suppresses the jimeng pill exactly - like doubao's does. - -Method note worth keeping: **the wide ladder flattered the clean arm exactly as -predicted, but the trap that actually bit was the LABEL vocabulary.** "present: []" -meant "no registered mark", not "no mark" -- and a calibration clean arm has to be -re-filtered per candidate, or the gate is read off frames that carry the very mark being -calibrated. - -元宝 and 可灵 now have registered font-rendered silhouettes. The cat-logo still needs -more than its two byte-unique carriers before registration; 可灵's separate second -bottom-left mark remains uncovered. - -### Open defects - -| # | Defect | Measured impact | What the fix takes | -|---|---|---|---| -| 1 | 16-bit PNGs are downconverted to 8-bit by a metadata strip | 42 of 27,018 corpus PNGs (0.16%); one went 9.2 MB -> 2.5 MB | a byte-level PNG chunk stripper, so the PIL re-save is skipped entirely | -| 2 | Exit code 2 means three different things (no visible mark / no invisible signal / Click usage error) | any wrapper must parse stderr to tell them apart | split the codes; **breaking for existing wrappers**, so it needs a deliberate call | -| 3 | `visible_removal_audit.py` measures the UNGATED per-mark path | reports the pill at 32% where the product runs at 100% precision | teach it the product path (`remove_auto_marks`) for gated marks, or at minimum say so loudly in its docstring | -| 4 | `invisible_engine.py:346` still discards `imwrite`'s success flag | same shape as the crash fixed 2026-07-20, on the diffusion output path; not yet observed failing | check the bool and raise/report, mirroring `cli._write_output_or_exit` | -| 5 | samsung leaves a residual just over its gate on the weakest of its 3 corpus positives | 0.431 -> 0.404 against a 0.40 gate; the other two clear outright | a faint-mask fallback for the `binary` front-end (samsung has none), but **do not tune on n=3** -- blocked behind item 1 above | - -### Closed 2026-07-20 (kept for the reasoning, not for action) - -- **The visible-parity re-run** confirmed the doubao front-end fix: **91.8% -> 99.3%** - (2562/2580), gemini/jimeng/samsung unchanged. Artifact `_visible_parity_cv2_v2.csv`. -- **The faint-mask fallback filled the whole corner box** instead of the glyph. Fixed with - the detector's own best-match box; full reasoning immediately below, because how it - escaped both parity and its own regression test is the instructive part. -- **Three crash-class defects** found by Tier E and the /simplify review (failed writes - crashing on the size report, batch losing data silently, directories crashing the - scanner) -- see the Tier E section. - -**The faint-mask defect in full, because it is instructive.** The fallback added on -2026-07-19 reads -`np.where(resp >= _FAINT_GLYPH_LEVEL)` with the constant at `0.5`, and its comment says -"thresholded relative to its own peak". But `tophat_response` returns **uint8 0..255**, so -`>= 0.5` selects every pixel with value >= 1 -- the entire non-zero response, not half the -peak. Measured against alternatives on 14 real frames (cv2 fill, detector re-run after): - -| mask | detector clean after | median filled area (% of corner box) | -|---|---|---| -| `thr0.5` (as shipped) | 100% | 120.9% | -| `thr190` | 100% | 68.5% | -| largest connected component at 190 | **21%** | 10.5% | -| **the detector's own best-match box** | 100% | **58.7%** | - -The fix is the last row: the correlation already located the mark at a position and scale, -so thresholding its response was always a weaker proxy for information we had. The -connected-component variant is rejected outright -- it is the tightest but removes the mark -on only 21% of frames, i.e. it does not cover it. - -Two things about how this was found are worth keeping: - -- **Parity could not see it.** Parity asks whether the detector is clean after removal, and - a mask that fills everything passes trivially. The defect was in the COST, and nothing - measured cost on that path. A green parity run is not evidence about mask size. -- **The regression test could not see it either, by construction.** Its fixture is a FLAT - frame, where the top-hat response is non-zero only on the glyph, so every threshold gives - the same bounding box. Mutating the constant to an absurd 99.0 left it green. The fixture - now carries texture, which is the condition under which sizing matters and what real - corner backgrounds look like -- and it reproduces the corpus number exactly (127% of the - corner box, against 120.9% measured). - -### Latent, pre-existing, not fixed this pass - -The detect-fires / mask-empty silent no-op that fix 4 closed on the `tophat` front-end has a -**narrower cousin on the `binary` front-end** (jimeng/samsung), surfaced by the /simplify -altitude review. Binary detection gates on `coverage >= detect_min_coverage` (a FRACTION) -while the mask gates on `xs.size >= _MIN_GLYPH_PIXELS = 20` (an absolute COUNT), both on the -same blob. For samsung (`detect_min_coverage = 0.01`) they disagree in a small-image band -(width ~200-294 px): detection can fire at 10-19 glyph px while the 20-px mask floor returns -None -> the identical observable. It is NOT introduced by this work (the `else: return None` -fall-through predates it), it sits well below real mark sizes (captured positives are -1086-2048 px wide), and fixing it means changing binary detection's gate to match the mask's --- which needs its own per-mark measurement. So the "cannot drift by construction" claim is -scoped to `tophat` (where score and box are one computation); the binary path is coupled but -by a threshold pair that can still disagree at the edges. Fix only alongside a binary-mark -detector change, never on its own. - -### Dependency alert - -RESOLVED 2026-07-21. `GHSA-rrmf-rvhw-rf47` (torch, `torch.jit.script` memory corruption, -alert range `<= 2.12.1`) is closed by the torch **2.13.0** bump (the lock already carried it; -`uv-secure` no longer flags torch). The Dependabot alert itself may still need a manual -close in the GitHub UI if it has not auto-resolved on the lock bump. - -### Where detection work should go next - -This is the EVIDENCE behind "START HERE" item 1, not a competing list -- it records what was -measured and ruled out, so the next session does not re-run any of it. In the order the -evidence supports: - -1. **Not the ladder, not the threshold, not the landscape rung.** All three were measured - to completion and all three are dead ends: the dense ladder buys 7.6% of misses for a - 21% relative rise in false fire; the score band below the gate is empty so no threshold - recovers the misses; and the one targeted rung that helps (1.116, landscape) adds false - fire at 1.7:1 because the recoveries and the false fires are the same landscape size - shift. Moving the landscape width fraction is also out -- detected landscape marks - already sit at the nominal, so it would break more than it fixes. Do not spend here. -2. **Coverage of uncovered vendors is the largest lever.** 千问 was the head of this item - and is now CLOSED (registered 2026-07-21, see the harvest section above): the blocker - turned out to be evidence, and the TC260 producer-USCC cohort trick removed it. The - 元宝 and 可灵 are now CLOSED (registered 2026-07-25 and 2026-07-21 respectively). - The cat-logo remains blocked by only two byte-unique carriers. `百度` was also - registered after a later cohort harvest; the 星绘/抖音 class is NOT in the corpus - in labelable quantity (verified twice; do not hunt it again). Nothing may be - registered off a single frame. -3. **A generic shared-tail template is not a shortcut.** `AI生成` is guaranteed across - compliant vendors by GB 45438-2025, so one template covering all of them is the obvious - idea -- and measured on the tophat front-end it separates a bold 千问 positive from clean - corners by only 0.407 vs a clean p99 of 0.298. A 4-glyph run is simply less specific - than a 6-glyph one. Treat it as a harvesting aid, not a detector. - -### Completed full runs -- do not re-run to "check" - -Each of these took from tens of minutes to hours and its artifact is on disk (gitignored, -under `data/spaces/`). A later session asking "did we actually cover X" should read the -artifact, not relaunch the sweep. Row counts are what the file held when written. - -| Run | What it covered | Rows | Artifact | -|---|---|---|---| -| A1 sidecar regression | the whole corpus re-run through `identify`, diffed against recorded verdicts | 39,314 | `_sidecar_regression.jsonl` | -| A2 visible parity (v1 pre-fix, v2 post-fix) | detect -> remove -> re-detect per mark | 10,594 x2 | `_visible_parity_cv2{,_v2}.csv` | -| metadata removal audit | strip-and-verify, detection/removal parity | 21,654 | `_metadata_removal_audit.csv` | -| B1 fill quality | stamped ground truth, per backend and background | 1,200 | `_fill_quality.jsonl` | -| pill gate audit | the pill through the PRODUCT path, not the ungated one | 2,738 | `_pill_gate_audit.jsonl` | -| B2 / ladder headroom | the scale-ladder and threshold questions, to exhaustion | 4,469 | `_ladder_headroom_doubao.jsonl` | -| E robustness | 34 adversarial CLI cases | -- | rerun is ~3 min, no artifact needed | -| B4 resource ceilings | peak RSS per backend, 1 MP -> 25 MP | 12 cells | rerun is ~2 min | -| real-example E2E | every command over real corpus examples | 28 checks | rerun is ~2 min (+ diffusion) | - -### Verification tiers not run - -- **C recall expansion** -- gated by labelling appetite. This is now the ONLY unrun tier, - and it is blocked on data rather than effort: every remaining detector question needs - labelled positives (30+ per uncovered vendor; jimeng still rests on n=14, the pill on - n=6, samsung on n=3). - -(Tiers E and B4 are now RUN -- see the sections above.) +Run `scripts/real_examples_e2e.py` against representative local inputs before releases that affect image handling. The script must read from `.local-eval/`, write only untracked temporary output, and report behavior without exposing dataset provenance or aggregate private measurements. ## Standing gap diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index c61ea1b..49f7729 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -1,47 +1,69 @@ # Watermarking landscape (research 2026-05-24) -> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded -> context small. Long single-line entries were reformatted into paragraphs; -> no content was changed or summarized. +> Research and signal inventory. Code-facing statements are updated with the +> implementation; dated vendor observations remain snapshots and may change +> independently of the package. Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read. -- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). **Caveat: the `imwatermark` dwtDct decode is carrier-fragile on a broad class of real images, NOT just re-encode-fragile, and it is a POSITIVE-ONLY signal.** A clean encode->decode round-trip (no re-encode at all) recovers 48/48 bits on some carriers (random noise, chatgpt-1.png 48/48, firefly-1.png 45/48) but FAILS on many others — verified 2026-06-19 that a *known-embedded* watermark only round-trips 28-39/48 (below the safe `_MATCH_48` = 44 gate, random baseline ~24) on the FLUX fox sample (28), doubao-1.png (39), a 1024² minimalist-flat FLUX image (28), AND a **clean synthetic bright-flat fill with NO watermark at all (28)**. The failure does NOT track texture (firefly lapvar ~11 passes; the flat FLUX lapvar ~56 fails); it correlates with a degenerate decode where the raw bits read **all-ones (48/48 ones)** — which a clean synthetic image reproduces, so **all-ones is a CARRIER ARTIFACT, NOT a watermark signal** (a double-embed test also showed a pre-existing embed does not corrupt a second embed — no interference). Net: trust a `detect_invisible_watermark` hit, but treat a `None`/no-match as **inconclusive** whenever a positive-control embed on the same carrier does not first recover >=44/48. The 44 gate is a deliberate precision choice (lowering it would admit false positives). +- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (upstream `black-forest-labs/flux2`, file `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). **Caveat: the `imwatermark` dwtDct decode is carrier-fragile on a broad class of real images, NOT just re-encode-fragile, and it is a POSITIVE-ONLY signal.** A clean encode->decode round-trip (no re-encode at all) recovers 48/48 bits on some carriers (random noise, chatgpt-1.png 48/48, firefly-1.png 45/48) but FAILS on many others — verified 2026-06-19 that a *known-embedded* watermark only round-trips 28-39/48 (below the safe `_MATCH_48` = 44 gate, random baseline ~24) on the FLUX fox sample (28), doubao-1.png (39), a 1024² minimalist-flat FLUX image (28), AND a **clean synthetic bright-flat fill with NO watermark at all (28)**. The failure does NOT track texture (firefly lapvar ~11 passes; the flat FLUX lapvar ~56 fails); it correlates with a degenerate decode where the raw bits read **all-ones (48/48 ones)** — which a clean synthetic image reproduces, so **all-ones is a CARRIER ARTIFACT, NOT a watermark signal** (a double-embed test also showed a pre-existing embed does not corrupt a second embed — no interference). Net: trust a `detect_invisible_watermark` hit, but treat a `None`/no-match as **inconclusive** whenever a positive-control embed on the same carrier does not first recover >=44/48. The 44 gate is a deliberate precision choice (lowering it would admit false positives). **Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.) Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback). -- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; found on real production traffic 2026-06-19, which **disproved the earlier assumption** that Canva downloads are re-encoded exports that always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). +- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). -**Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). It ALSO burns a **visible** localized wordmark into the pixels — a sparkle + "generated with AI" string in the bottom-LEFT corner (issue #37; the Italian "✦ Contenuti generati dall'AI" variant is calibrated) — removed by `samsung_engine.py` / `visible --mark samsung` (reverse-alpha, see the engine bullet); detection feeds `identify` as the medium `visible_samsung` signal. The string is locale-specific, so each locale needs its own captured alpha template. +**Samsung Galaxy AI** signs supported edits with C2PA and may carry the +proprietary `genAIType` marker. The registered visible detector covers the +Italian `✦ Contenuti generati dall'AI` bottom-left variant. Removal follows the +same localize-then-fill path as other registered text marks. Other locales and +icon-only variants need separate calibrated silhouettes. **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). -**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the SAME content as **"Byteplus Pte. Ltd."** — the bare `volcengine` needle missed it, so real BytePlus output was mis-attributed to "Adobe Firefly" (an incidental "Adobe XMP" toolkit string in the file's XMP, picked up by the fallback byte-scan once the clean manifest issuer matched nothing). Added issuer `b"Byteplus"` → org "BytePlus (ByteDance)" (platform resolves to the shared "ByteDance (Doubao / Jimeng / Volcano Engine)" label via the common `ByteDance` needle) so the clean manifest issuer attributes it directly; found on real production traffic 2026-06-19. ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **NO `trainedAlgorithmicMedia`** — the generator name is the only AI signal, and the active manifest is frequently a plain `c2pa-tool` transcode with the real `Dreamina` token on an ingredient manifest. Added issuer `b"Dreamina"` → org "ByteDance (Dreamina)" with **`asserts_ai=True`** (see `constants.py`): the caBX / store-JSON byte-scan sees the token across all manifests, and the identity-AI flag lifts the AI verdict without a source-type. Mined from the retained corpus 2026-07 (7 files read `unknown` before, all now ByteDance). Registering the **issuer** `b"Bytedance Pte"` was deliberately AVOIDED — that same Singapore entity also signs non-AI CapCut edits (`CapCut/x.y` generator, `c2pa.created`, no AI marker), which must stay unattributed per the editor-vs-generator line; keying on the `Dreamina` generator token is precise. -- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Three more mined from the retained corpus 2026-06-22, all no-C2PA generator stamps that previously read as no-signal: **NovelAI** (anime SD) writes its stamp in PNG `tEXt` chunks `Software="NovelAI"` / `Source="NovelAI Diffusion V4.5 "` / `Title="NovelAI generated image"` — so `exif_generator` now reads PNG text chunks (`Software`/`Source`/`Title`/`Description`), not just EXIF/XMP; **Reve** (reve.com) writes EXIF `Software` / XMP `CreatorTool` = `reve.com` (token is the full `reve.com`, not bare `reve`, to avoid false-firing on "forever"/"reverie"); **Aphrodite AI** writes EXIF `Make`/`Software` = `Aphrodite AI`. +**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise. +- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`). - **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: ` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)"). -**Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/samples/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). +**Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/fixtures/provenance/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). -**Stripped on removal too:** `remove_ai_metadata` now calls `_scrub_ai_exif` on the JPEG EXIF, which deletes the xAI Signature+UUID-Artist pair **and** any `Software`/`Make`/`Artist`/`ImageDescription` tag holding an `AI_GENERATOR_TOKENS` token (so Ideogram's `Make="Ideogram AI"` is scrubbed too), while keeping genuine camera/editor EXIF. The shared `_is_xai_signature_pair` helper (module-level compiled regexes) is the single source of truth for the pattern, used by both `xai_signature` and `_scrub_ai_exif`. (AVIF/HEIF/JXL still strip only C2PA boxes via `isobmff`, not EXIF — unchanged.) +**Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on +JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported +AI generator values while retaining unrelated camera and editor EXIF. The +shared `_is_xai_signature_pair` helper is the single source of truth for the +pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding +in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair. - **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). -**Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `{...}` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on the corpus 2026-06-10 (`ContentProducer="00119144030008867405X210002"`; 3 files read `unknown` before this form was added). The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). All three generic forms (the PNG chunk, the bare `{"AIGC":...}` object, and the bare `AIGC{...}` blob) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `_TC260_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, mined from the retained corpus 2026-07 (11 files read `unknown` before — the block was found by the raw-JSON scan but rejected because none of its fields were in the producer-only gate; removal already stripped it since it lives in EXIF). In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated). -- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID (3 on the corpus 2026-05-28, no other signal). It marks the *hosting job*, not a model — most commonly diffusion output — so it lifts an Unknown verdict to a tentative AI via `hf_only` (parallel to the visible sparkle) but never overrides a hard metadata signal; `_HF_JOB_CAVEAT` states the limit (job, not model; not proof of AI pixels). Stripped on removal (the PNG save whitelist keeps only `STANDARD_METADATA_KEYS`, so `hf-job-id` and the `AIGC` chunk are both dropped). The exact writer is not authoritatively documented (HF Jobs are generic GPU jobs), hence medium not high. -- **No detectable signal on download (correctly reported `unknown`):** **Recraft** (PNG export is a re-encoded design export — strips everything), **Krea hosting FLUX 2** (no imwatermark despite FLUX — the host omits the encoder, same as Stability's hosted SDXL), and Midjourney (embeds nothing). Lesson: the imwatermark detector only fires on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL), not from re-hosts (Krea/Stability) or re-encoded exports (Recraft/Canva). +**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `{...}` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). All three generic forms (the PNG chunk, the bare `{"AIGC":...}` object, and the bare `AIGC{...}` blob) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `_TC260_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated). +- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist. +- **No detectable signal on some downloads:** Recraft exports and some hosted + FLUX surfaces can arrive without a supported local signal. Midjourney samples + may carry IPTC metadata but no registered C2PA or pixel watermark. The open + DWT-DCT decoder only applies when the producing pipeline actually ran its + encoder and the carrier remains decodable. - **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID. - **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders). -- **Built 2026-05-26 (this batch):** soft-binding `alg` vendor detection; IPTC Photo Metadata 2025.1 AI-disclosure fields (`AISystemUsed` etc.); **video C2PA metadata** detect + strip for MP4/MOV/M4V (free — `isobmff.py` is format-agnostic, MP4 is ISOBMFF); Adobe TrustMark open decoder. NOT done (out of cheap reach, per the feasibility review): visible video-logo removal (needs a video frame pipeline) and audio (SynthID/ElevenLabs/Resemble/Suno all oracle-only or unmarked). +- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo + Metadata AI-disclosure fields, C2PA detection and stripping for supported + ISOBMFF video, and the optional Adobe TrustMark decoder. Visible video-logo + removal and proprietary audio-watermark detection remain outside the package. + Metadata stripping for supported audio containers is a separate implemented + path. **Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`). -**Meta-box XMP removal — now handled (v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (HEIF/AVIF; out of reach of the top-level box stripper) is blanked in place by `isobmff.blank_ai_xmp_packets` — it locates the packet by its `` delimiters and, if it carries an AI marker (`_AI_LABEL_MARKERS`), overwrites it with spaces of the SAME length, so box sizes / `iloc` offsets stay valid and the coded image is untouched (selective: plain non-AI XMP is left alone, mirroring the top-level uuid logic). Wired into `remove_ai_metadata`'s ISOBMFF branch after `strip_c2pa_boxes`. The remaining gap is an `Exif` meta-box *item* (rare; the AI labels are XMP) — still needs `iinf`/`iloc` surgery or exiftool. -- **Regulatory driver (context, not a code change):** AI-content labeling mandates are expanding, which pushes more generators toward exactly the C2PA + watermark signals we read. The full per-jurisdiction table lives in README "## Legal" -- keep it there, not duplicated here. Newly added + primary-source verified 2026-05-26: **EU AI Act Article 50** machine-readable marking applicable **2026-08-02** (verified against the article text); **South Korea AI Framework Act Art. 31(3)** in force since **22 January 2026** (verified via Kim & Chang + FPF/Korea Times; Enforcement Decree accepts an invisible-watermark label); **California AB 853** (amends the CA AI Transparency Act) latent-disclosure duty operative **2026-08-02**, requiring a disclosure "permanent or extraordinarily difficult to remove" (verified against the leginfo bill text -- this is the exact disclosure our tool strips); **India IT Amendment Rules 2026** in force **2026-02-20** (verified via Chambers), which prominently-label + permanent-provenance-id all synthetic media AND **expressly prohibit removing/suppressing the label or metadata** -- the first major all-content removal ban outside China. +**Meta-box XMP and EXIF removal are handled in place:** an AI-label XMP packet +stored as a meta-box `mime` item is blanked by +`isobmff.blank_ai_xmp_packets`. Supported EXIF items are handled by +`blank_ai_exif_tokens`. Both paths preserve box sizes and coded media offsets. -**Removal liability (README "## Legal" disclaimer):** the tool is lawful general-purpose software; liability sits with the remover and is intent-gated -- downstream acts (fraud/deception/IP), plus US DMCA 17 USC 1202 (removing copyright-management info to conceal infringement), plus the removal-as-such bans in China + India. When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries. +For current scope and legal context, see +[scope, safety, and legal notes](legal-and-safety.md). Re-verify legal facts +against primary sources before adding jurisdiction-specific claims. ## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified) @@ -51,11 +73,14 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC* **Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise. -**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. So every major Chinese platform now ships visible "AI生成"-style text marks (we cover Doubao / Jimeng; expect more CJK-text marks under this driver). By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries. +**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries. ## Uncovered visible marks: implementation specs (deep-research 2026-07-18) -Triggered by a corpus finding: **50% of TC260-labelled uploads (1452 of 2896 unique) produced no detection at all**, and hand-inspection showed the bulk are MISSED Doubao marks (a localization defect, now fixed -- see `scale_basis` in `docs/module-internals.md`) plus a minority of genuinely uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads, not ground truth. +Compatibility testing showed that TC260-labelled images can still produce no visible-mark +detection. The main causes were a fixed Doubao localization defect and genuinely +uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads, +not ground truth. **GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label: - 应采用文字提示 (must be a TEXT prompt); @@ -72,12 +97,12 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp **Baidu: RESOLVED 2026-07-22, registered (`baidu_engine.py`).** The mark is a white bold "百度" text run + a separate white rounded tag with dark "AI生成", bottom-right -- settled by the TC260 USCC cohort harvest (16 frames, USCC 91110000802100433B), not by web research. Detection keys on the text run only; details in `docs/module-internals.md`. -**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The corrected synthetic silhouette and corpus calibration are recorded in `docs/module-internals.md`; a separate one-line photographer-overlay variant remains evidence-limited to one example. +**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example. -**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict (bottom-left vs bottom-right) and one claims newer Meta models dropped the visible mark for invisible watermarking; none survived a fetch. Do NOT encode a corner without a corpus sample. Meta also embeds IPTC + invisible watermarks, which `identify` already reads. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`. +**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. `identify` reads the supported IPTC disclosure; it does not decode Meta's proprietary invisible watermark. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`. **Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found. **The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session. -**No vendor publishes typeface, colour, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin. +**No vendor publishes typeface, color, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin. diff --git a/pyproject.toml b/pyproject.toml index 0d4738c..5e56f90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -130,8 +130,8 @@ lama = [ # Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan, # MIT). CPU, no torch. Model (~28 MB) downloaded on first use and cached by # huggingface_hub; never bundled. ~700-950 MB peak RAM / ~0.19 s/call -- the -# droplet-friendly tier (vs big-LaMa's ~4.7 GB) and the preferred inpaint backend -# for the visible-mark fallback when installed. Same runtime as `lama`. +# memory-tight learned tier (vs big-LaMa's ~4.7 GB). Select it explicitly when +# LaMa, the quality-first `auto` choice, is too large. Same runtime as `lama`. migan = [ "onnxruntime>=1.16.0", "huggingface-hub>=0.20.0", @@ -185,10 +185,8 @@ build-backend = "hatchling.build" packages = ["src/remove_ai_watermarks"] [tool.hatch.build.targets.sdist] -# Keep the source distribution small: ship the package + metadata, not the -# committed test corpora / calibration captures under data/ (tens of MB -- -# synthid_corpus images + the visible-mark captures), which pushed the 0.8.0 -# sdist past PyPI's per-project file-size limit (the wheel ships only src/). +# Keep the source distribution small: ship the package and metadata, not +# calibration or evaluation data under data/. The wheel ships only src/. exclude = ["/data"] [tool.pytest.ini_options] diff --git a/scripts/ai_score.py b/scripts/ai_score.py index 539064b..d2f06c2 100644 --- a/scripts/ai_score.py +++ b/scripts/ai_score.py @@ -312,7 +312,7 @@ def cmd_train(pattern: str, model_path: str, schema: FeatureSchema = "v2") -> No from sklearn.metrics import average_precision_score, roc_auc_score # keep the feature vector, not the record: a parsed scan record is an order of - # magnitude larger than the row it collapses to, and the corpus is ~5-digit. + # magnitude larger than the row it collapses to. labeled: dict[str, tuple[list[float], int, str]] = {} missing_hash_index = 0 for r in iter_records(pattern): diff --git a/src/remove_ai_watermarks/assets/catlogo_alpha.png b/scripts/assets/visible-mark-candidates/catlogo_alpha.png similarity index 100% rename from src/remove_ai_watermarks/assets/catlogo_alpha.png rename to scripts/assets/visible-mark-candidates/catlogo_alpha.png diff --git a/src/remove_ai_watermarks/assets/hailuo_alpha.png b/scripts/assets/visible-mark-candidates/hailuo_alpha.png similarity index 100% rename from src/remove_ai_watermarks/assets/hailuo_alpha.png rename to scripts/assets/visible-mark-candidates/hailuo_alpha.png diff --git a/src/remove_ai_watermarks/assets/qingyan_alpha.png b/scripts/assets/visible-mark-candidates/qingyan_alpha.png similarity index 100% rename from src/remove_ai_watermarks/assets/qingyan_alpha.png rename to scripts/assets/visible-mark-candidates/qingyan_alpha.png diff --git a/src/remove_ai_watermarks/assets/xinghui_alpha.png b/scripts/assets/visible-mark-candidates/xinghui_alpha.png similarity index 100% rename from src/remove_ai_watermarks/assets/xinghui_alpha.png rename to scripts/assets/visible-mark-candidates/xinghui_alpha.png diff --git a/scripts/cjk_tail_probe.py b/scripts/cjk_tail_probe.py index f969796..38bc342 100644 --- a/scripts/cjk_tail_probe.py +++ b/scripts/cjk_tail_probe.py @@ -41,8 +41,8 @@ THE MEASUREMENT selects -- the script writes a contact sheet for exactly that. DATA SAFETY - Corpus images are user uploads: read-only, local analysis, gitignored output. The - template is font-rendered synthetic, never cut from a user upload. + Treat input datasets as sensitive and read-only, and keep output gitignored. The + template is font-rendered synthetic, never cut from an input image. uv run python scripts/cjk_tail_probe.py --n 6000 """ @@ -66,12 +66,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).parent)) REPO = Path(__file__).resolve().parents[1] -CORPUS = REPO / "data" / "spaces" / "originals" -OUT = REPO / "data" / "spaces" / "_cjk_tail_probe.jsonl" +CORPUS = REPO / ".local-eval" / "originals" +OUT = REPO / ".local-eval" / "cjk-tail-probe.jsonl" # Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a # product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is # what writes the committed silhouette into src/.../assets/. -TAIL_PNG = REPO / "data" / "spaces" / "_cjk_tail_silhouette.png" +TAIL_PNG = REPO / ".local-eval" / "cjk-tail-silhouette.png" # The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths, # `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely @@ -262,7 +262,7 @@ def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) -> if crop.size: tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA)) if tiles: - dest = REPO / "data" / "spaces" / "_cjk_tail_sheet.png" + dest = REPO / ".local-eval" / "cjk-tail-sheet.png" cv2.imwrite(str(dest), np.vstack(tiles)) print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}") print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)])) diff --git a/scripts/corpus_gap_scan.py b/scripts/corpus_gap_scan.py index d9fd635..f15d3df 100644 --- a/scripts/corpus_gap_scan.py +++ b/scripts/corpus_gap_scan.py @@ -14,12 +14,12 @@ Two jobs in one pass: first audit naively scanned the first megabyte. This is how new detector gaps get found (it is what surfaced the JPEG-EXIF -``{"AIGC":{...}}`` form). Re-run after collecting a fresh corpus batch. +``{"AIGC":{...}}`` form). Re-run after collecting a fresh evaluation batch. Usage: - uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals - uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals \\ - --report data/spaces/detector_report.csv + uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals + uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\ + --report .local-eval/detector-report.csv """ from __future__ import annotations @@ -122,7 +122,7 @@ def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport) @click.option( "--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), - default=Path("data/spaces/originals"), + default=Path(".local-eval/originals"), show_default=True, help="Directory of images to scan (recursively).", ) diff --git a/scripts/detector_response.py b/scripts/detector_response.py index 928c82a..9645c4a 100644 --- a/scripts/detector_response.py +++ b/scripts/detector_response.py @@ -57,8 +57,8 @@ READING `mask_hit` top-left geometry box, so it covers the stamp by definition. Reported, and flagged. DATA SAFETY - Corpus images are user uploads: read-only, local analysis, gitignored output under - data/spaces/. Records source filenames and measurements, never image content. + Treat input datasets as sensitive and read-only. Keep generated reports under + .local-eval/. Reports record source filenames and measurements, never image content. uv run python scripts/detector_response.py --n 12 # trial, measures throughput uv run python scripts/detector_response.py --n 150 # the real run, resumable @@ -84,7 +84,7 @@ sys.path.insert(0, str(Path(__file__).parent)) from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of REPO = Path(__file__).resolve().parents[1] -OUT = REPO / "data" / "spaces" / "_detector_response.jsonl" +OUT = REPO / ".local-eval" / "detector-response.jsonl" # 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below # it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat diff --git a/scripts/fill_quality.py b/scripts/fill_quality.py index 1e6c7b0..d82ab28 100644 --- a/scripts/fill_quality.py +++ b/scripts/fill_quality.py @@ -24,8 +24,8 @@ WHAT IT DOES NOT MEASURE fill numbers -- this isolates the FILL. Detection accuracy is Tier C's job. DATA SAFETY - Corpus images are user uploads: read-only, local analysis, output under a gitignored - data/spaces/ path. No image content is written into the report. + Treat input datasets as sensitive and read-only. Keep output under the gitignored + .local-eval/ path. No image content is written into the report. uv run python scripts/fill_quality.py --n 60 """ @@ -50,8 +50,8 @@ sys.path.insert(0, str(Path(__file__).parent)) from invisible_quality_audit import _ssim # reuse, do not reimplement a third SSIM REPO = Path(__file__).resolve().parents[1] -CORPUS = REPO / "data" / "spaces" / "originals" -OUT = REPO / "data" / "spaces" / "_fill_quality.jsonl" +CORPUS = REPO / ".local-eval" / "originals" +OUT = REPO / ".local-eval" / "fill-quality.jsonl" # Text marks: a bundled alpha PNG plus the engine's own corner geometry. STAMPABLE = ("doubao", "jimeng", "samsung") @@ -216,7 +216,7 @@ def clean_sources(n: int, seed: int = 11) -> list[Path]: continue if any(d.detected for d in detect_marks(img)): continue - except Exception: # noqa: S112 -- a bad corpus file just is not a candidate + except Exception: # noqa: S112 -- an unreadable local file is not a candidate continue out.append(path) return out diff --git a/scripts/invisible_quality_audit.py b/scripts/invisible_quality_audit.py index af6d70f..2776d6b 100644 --- a/scripts/invisible_quality_audit.py +++ b/scripts/invisible_quality_audit.py @@ -9,12 +9,12 @@ SSIM alone does NOT equal "bad": a high-texture image legitimately changes under the SDXL scrub. Use the ranked output to pick candidates, then look at them to name the failure classes (garbled text, deformed faces, over-smoothed detail). -Operates on gitignored data only (data/spaces/...); writes nothing tracked. +Operates on gitignored local data only; writes nothing tracked. uv run python scripts/invisible_quality_audit.py \ - --originals data/spaces/originals/2026-06-03 \ - --cleaned data/spaces/results/2026-06-03 \ - --out data/spaces/_quality_audit.csv --worst 25 + --originals .local-eval/originals \ + --cleaned .local-eval/results \ + --out .local-eval/quality-audit.csv --worst 25 """ from __future__ import annotations @@ -60,7 +60,7 @@ def _stem(name: str) -> str: @click.command() @click.option("--originals", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True) @click.option("--cleaned", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True) -@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_quality_audit.csv")) +@click.option("--out", type=click.Path(path_type=Path), default=Path(".local-eval/quality-audit.csv")) @click.option("--worst", type=int, default=25, help="Print the N lowest-SSIM pairs.") def main(originals: Path, cleaned: Path, out: Path, worst: int) -> None: logging.basicConfig(level=logging.WARNING, format="%(message)s") diff --git a/scripts/ladder_headroom.py b/scripts/ladder_headroom.py index d99e722..30aa9bc 100644 --- a/scripts/ladder_headroom.py +++ b/scripts/ladder_headroom.py @@ -35,7 +35,7 @@ THE MEASUREMENT percentage is an upper bound on what a denser ladder buys. DATA SAFETY - Corpus images are user uploads: read-only, local analysis, gitignored output. + Treat input datasets as sensitive and read-only, and keep output gitignored. uv run python scripts/ladder_headroom.py --mark doubao --n 4000 """ @@ -60,8 +60,8 @@ import numpy as np sys.path.insert(0, str(Path(__file__).parent.parent)) REPO = Path(__file__).resolve().parents[1] -CORPUS = REPO / "data" / "spaces" / "originals" -OUT = REPO / "data" / "spaces" / "_ladder_headroom.jsonl" +CORPUS = REPO / ".local-eval" / "originals" +OUT = REPO / ".local-eval" / "ladder-headroom.jsonl" # The rungs the product ships today, and the dense ladder under evaluation. The dense one # is geometric with a ~6% step, chosen from the measured half-width of a rung's lobe @@ -189,7 +189,7 @@ def main() -> None: # for a mark with rivals, `dense_crosses` would ignore the competitive margin and read # optimistically. Widening the binary front-end is a separate experiment. ap.add_argument("--mark", default="doubao", choices=["doubao"]) - ap.add_argument("--n", type=int, default=4000, help="corpus files to scan") + ap.add_argument("--n", type=int, default=4000, help="local files to scan") ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2)) ap.add_argument("--out", type=Path, default=OUT) ap.add_argument("--report-only", action="store_true") diff --git a/scripts/metadata_removal_audit.py b/scripts/metadata_removal_audit.py index de2a0f4..af7d732 100644 --- a/scripts/metadata_removal_audit.py +++ b/scripts/metadata_removal_audit.py @@ -11,11 +11,11 @@ metadata, never the coded image. A no-op control set (clean images with no AI metadata) verifies the stripper neither ADDS a signal nor corrupts pixels on files it should leave alone. -Operates on gitignored data only (data/spaces/...); writes nothing tracked. +Operates on gitignored local data only; writes nothing tracked. uv run python scripts/metadata_removal_audit.py \ - --corpus data/spaces/originals --identify data/spaces/identify \ - --out data/spaces/_metadata_removal_audit.csv --jobs 8 + --corpus .local-eval/originals --identify .local-eval/identify \ + --out .local-eval/metadata-removal-audit.csv --jobs 8 """ from __future__ import annotations @@ -157,15 +157,21 @@ def _candidate_paths(corpus: Path, identify: Path | None, clean_sample: int) -> @click.command() @click.option( - "--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), default=Path("data/spaces/originals") + "--corpus", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=Path(".local-eval/originals"), ) @click.option( "--identify", type=click.Path(path_type=Path), - default=Path("data/spaces/identify"), + default=Path(".local-eval/identify"), help="identify-JSON dir to pick carriers (skip = scan all).", ) -@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_metadata_removal_audit.csv")) +@click.option( + "--out", + type=click.Path(path_type=Path), + default=Path(".local-eval/metadata-removal-audit.csv"), +) @click.option( "--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op." ) diff --git a/scripts/pill_gate_audit.py b/scripts/pill_gate_audit.py index 297a125..1bc66bc 100644 --- a/scripts/pill_gate_audit.py +++ b/scripts/pill_gate_audit.py @@ -19,7 +19,7 @@ THE CORROBORATION PROXY AND ITS BIAS wordmark -- so measured precision is a LOWER BOUND, not a point estimate. Do not quote it as if it were exact. -Corpus images are user uploads: read-only, local analysis, gitignored output. +Treat input datasets as sensitive and read-only, and keep output gitignored. uv run python scripts/pill_gate_audit.py --jobs 7 """ @@ -40,8 +40,8 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) REPO = Path(__file__).resolve().parents[1] -POSITIVES = REPO / "data" / "spaces" / "_visible_positives.jsonl" -OUT = REPO / "data" / "spaces" / "_pill_gate_audit.jsonl" +POSITIVES = REPO / ".local-eval" / "visible-positives.jsonl" +OUT = REPO / ".local-eval" / "pill-gate-audit.jsonl" def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]: diff --git a/scripts/real_examples_e2e.py b/scripts/real_examples_e2e.py index e9fd3fe..7e07d23 100644 --- a/scripts/real_examples_e2e.py +++ b/scripts/real_examples_e2e.py @@ -1,9 +1,9 @@ -"""End-to-end confidence run: drive the ACTUAL CLI over REAL corpus examples. +"""End-to-end confidence run over local evaluation examples. WHY THIS EXISTS AND WHAT IT IS NOT The 849-test suite and `smoke_matrix.py` prove the code paths behave on fixtures and synthetic inputs. This is the other half: run the real `remove-ai-watermarks` entry point, - as a user would, over real corpus images spanning every command and every provenance + as a user would, over local evaluation images spanning every command and provenance class, and CHECK THE OUTPUT -- not that it exited 0, but that it did the right thing (the mark is actually gone on re-detect, the metadata actually strips, the diffusion actually writes a changed image). A green exit is not evidence the work happened. @@ -22,7 +22,7 @@ WHAT IT COVERS cv2/numpy and fast. DATA SAFETY - Corpus images are user uploads: read-only, local analysis, outputs to a gitignored temp + Treat input datasets as sensitive and read-only. Output stays in a gitignored temp dir. Records example uids and pass/fail, never image content. uv run python scripts/real_examples_e2e.py # fast surface (no diffusion) @@ -44,9 +44,9 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) REPO = Path(__file__).resolve().parents[1] -CORPUS = REPO / "data" / "spaces" / "originals" -DATASETS = REPO / "data" / "spaces" / "_visible_datasets" -SAMPLES = REPO / "data" / "samples" +CORPUS = REPO / ".local-eval" / "originals" +DATASETS = REPO / ".local-eval" / "visible-datasets" +SAMPLES = REPO / "data" / "fixtures" / "provenance" _UV = shutil.which("uv") or "uv" # full path avoids the partial-executable lint @@ -64,7 +64,7 @@ def run(args: list[str], timeout: int = 300) -> tuple[int, str]: def find_visible_positive(mark: str) -> Path | None: - """A real corpus image the parity run bucketed as carrying this mark, that the current + """A local evaluation image bucketed as carrying this mark, which the current detector STILL fires on (the bucket was built by an older run; re-confirm live).""" from remove_ai_watermarks.image_io import imread from remove_ai_watermarks.watermark_registry import detect_marks @@ -111,7 +111,7 @@ class Results: for line in out.strip().splitlines()[-12:]: print(f" {line}") if not bad: - print(" every command produced the right result on real corpus examples") + print(" every command produced the right result on local evaluation examples") return 1 if bad else 0 diff --git a/scripts/render_pill_silhouette.py b/scripts/render_pill_silhouette.py index 19c6cf6..0258967 100644 --- a/scripts/render_pill_silhouette.py +++ b/scripts/render_pill_silhouette.py @@ -8,9 +8,8 @@ content), used ONLY to (a) detect the pill by edge-NCC in the top-left corner an inpaint backend (MI-GAN/cv2), so the silhouette need not be pixel-accurate, and the synthetic render keeps corpus/user content out of the tracked repo (data-safety). -Detection was calibrated on the retained local corpus (61 real positives + jimeng -negatives): edge-NCC threshold ~0.22 in the top-left ROI. Re-run to regenerate the -asset: uv run python scripts/render_pill_silhouette.py +The calibrated edge-NCC threshold is 0.22 in the top-left ROI. Re-run to regenerate +the asset: uv run python scripts/render_pill_silhouette.py Requires a CJK font (macOS STHeiti by default); the asset itself is committed, so this script only runs when regenerating it (never in CI). diff --git a/scripts/render_vendor_silhouettes.py b/scripts/render_vendor_silhouettes.py index db1d1c9..237e09d 100644 --- a/scripts/render_vendor_silhouettes.py +++ b/scripts/render_vendor_silhouettes.py @@ -1,95 +1,11 @@ -"""Render SYNTHETIC detection silhouettes for the CJK vendor text marks (data-safe). +"""Render synthetic detection silhouettes for vendor text marks. -Adding a mark needs only a DETECTION silhouette, and it must be font-rendered rather -than derived from user uploads: the corpus is real user content and may never reach a -tracked asset (see the repo CLAUDE.md data-safety rule). Seeing real samples to learn -the glyphs, weight and layout is fine; the committed template stays synthetic. +Committed assets must be font-rendered and contain no source-image pixels. Local +evaluation inputs may be used only to learn glyphs, weight, layout, and detector +thresholds. Candidate assets stay outside the installed package until calibrated. -Covered here: - qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text - xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text - yuanbao "元宝\nAI生成" -- Tencent Yuanbao, bottom-right, two-line italic block - (REGISTERED 2026-07-25 after fixing the negative-shear clipping in this - renderer and matching both light and dark mark polarities) - kling "可灵AI 3.0" -- Kuaishou Kling, bottom-right, spiral logo + text - (REGISTERED 2026-07-21, kling_engine.py) - -The leading LOGO is deliberately NOT rendered. It is the part that varies most between -releases and is hardest to reproduce synthetically, while the CJK run is stable and is -what actually discriminates one vendor from another (the shared `AI生成` tail is exactly -what does NOT discriminate -- see the rival-margin mechanism in _text_mark_engine). - -Regenerate with: uv run python scripts/render_vendor_silhouettes.py - -STATUS 2026-07-21: `qwen_alpha.png` IS registered (`qwen_engine.py`) -- the 2026-07-18 -blocker quoted below turned out to be mis-sized GEOMETRY (two size modes + a locate box -that clipped the first glyph), not segmentation, and was solved by the TC260-producer -cohort harvest + `vendor_mark_calibrate.py` (117 labelled frames; full record in -`docs/verification-plan.md`). `xinghui_alpha.png` is still NOT registered: one confirmed -corpus example is nothing to calibrate a gate against. - ---- the 2026-07-18 record, kept as the history of the failed first attempt --- -Measured on 14 hand-verified 千问 positives from the corpus, -the then-current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED) -could not see this mark AT ALL: - - same pipeline, each mark scored with its OWN template, on real positives - doubao n=40 mean NCC 0.723 median 0.835 >= 0.40 gate: 82% - qwen n=14 mean NCC 0.170 median 0.179 >= 0.40 gate: 0% - -Three checks ruled out the obvious explanations, in order: - 1. NOT the synthetic render. A template cut from an ACTUAL Qwen mark scores the same - as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308) -- and real masks - do not even match EACH OTHER. - 2. NOT the morphology kernel size. Scaling MORPH_OPEN/CLOSE with the box height (they - are fixed 5px, ~9% of a 57px-tall box) gained only +0.014 mean and moved nothing - across the gate. - 3. NOT the appearance thresholds. Sweeping tophat_delta / logo_min_luma / kernel - reached at best mean 0.35 with 4/14 over the gate. - -The blocker was named SEGMENTATION on a faint mark: Doubao is stamped bold and opaque, -so the white top-hat returns a clean glyph blob; the Qwen mark is a thin translucent -overlay that shatters into specks, and no template can match a blob that is not there. -The `tophat` front-end (built later, for doubao) removed that blocker -- and 千问 STILL -did not register, because the real residual was geometry. See the 2026-07-21 status -above. - -星绘 additionally has only ONE confirmed example in the corpus, so even a working -front-end could not have its threshold calibrated yet. - -UPDATE 2026-07-20: the named blocker is GONE, and the retry is still inconclusive. -`detect_frontend="tophat"` (built later, for doubao) is exactly the "grayscale correlation -on the raw top-hat" this note asked for, so the 2026-07-18 ruling rests on a premise that -no longer holds and must not simply be inherited. Two things were measured against it, and -neither settles the question: - - * A GENERIC template of the shared `AI生成` tail -- attractive because GB 45438-2025 - guarantees that run across vendors, so one template would cover 千问 / 百度 / 星绘 and - anything compliant that ships next. Measured on the tophat front-end at the shipped - 3-rung ladder: a bold 千问 positive scores 0.407 against clean corners at p99 0.298 / - max 0.321. It separates on that one frame, but only by a hair, and a 4-glyph template - is inherently less specific than a 6-glyph one -- the shorter the run, the more - arbitrary corner structure correlates with it. - * The FULL 千问 template on the same front-end scores 0.248 against a clean max of 0.537, - i.e. no separation at all -- WORSE than the generic tail, which is the opposite of - what the specificity argument predicts and is itself a reason to distrust n=1. - -The blocker is now EVIDENCE, not architecture: this session found exactly one 千问 and one -百度 positive (both by eyeballing doubao-provenance misses), and the 14 positives quoted -above were not preserved anywhere the current scripts can reach. Nothing should be -registered off a single frame. - -UPDATE 2026-07-21 (the resolution): the evidence arrived via the TC260 producer-USCC -cohort trick (`scripts/vendor_cohort_harvest.py` -- 117 labelled 千问 frames from metadata -alone), and the registration shipped the same day (`qwen_engine.py`). The "no separation -at all" reading above was the MIS-SIZED geometry, not the mark: at the fitted geometry the -full template separates the cohort from clean corners 0.662 vs 0.134 (p50). The traps -below still bind any NEXT vendor: score with `alpha_height_frac`, not the silhouette's own -aspect ratio (the latter inflated the clean p99 from 0.30 to 0.58 and made every -comparison meaningless); keep the ladder at the shipped rungs for gate-setting, since a -wide sweep hands clean corners many extra chances to match; and re-filter the clean arm -per candidate -- the 2026-07-18 `present: []` labels mean "no REGISTERED mark", so qwen --cohort frames visibly carrying 千问AI生成 sat in it (see `vendor_mark_calibrate.load_sets`). +Regenerate with: + uv run python scripts/render_vendor_silhouettes.py """ from __future__ import annotations @@ -101,7 +17,13 @@ from typing import Any import numpy as np from PIL import Image, ImageDraw, ImageFont -_ASSETS = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets" +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "src")) + +from remove_ai_watermarks.watermark_registry import mark_keys # noqa: E402 + +_PACKAGE_ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets" +_CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates" # STHeiti Medium approximates the semibold CJK sans these marks are set in; the exact # family is unpublished for every vendor (GB 45438-2025 only requires a legible face). _FONT = "/System/Library/Fonts/STHeiti Medium.ttc" @@ -110,55 +32,34 @@ MARKS = { "qwen_alpha.png": "千问AI生成", "xinghui_alpha.png": "星绘AI生成", # Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly - # stacked and ITALIC-SLANTED (measured on the 2026-07-21 cohort sheet + real tophat - # responses); a rare one-line variant exists but the stacked block is dominant. + # stacked and ITALIC-SLANTED. A rare one-line variant exists, but the stacked block + # is dominant. "yuanbao_alpha.png": "元宝\nAI生成", # Kling (可灵) stamps a thin light-gray one-line "可灵AI 3.0" bottom-right (an # "Omni" suffix variant and a latin "KlingAI 3.0" variant also exist; the CJK # run without the suffix is the common core). The leading spiral logo is NOT # rendered (logos vary; the text run discriminates). "kling_alpha.png": "可灵AI 3.0", - # The "cat-logo" cohort (USCC 91110108562144110X) stamps an outline cat-head + - # bold "AI生成", bottom-right. PARKED 2026-07-21: the cohort is 19 copies of - # only 2 unique carriers -- nothing to calibrate recall against (the xinghui - # rule). The probe is ready: this silhouette scores 0.50 on the mark vs 0.333 - # max on a diverse clean arm, so registration is a gate pick (0.42) the moment - # more unique carriers arrive. + # The "cat-logo" candidate stamps an outline cat-head plus bold "AI生成", + # bottom-right. It remains unregistered pending sufficient calibration coverage. "catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered - # RunningHub (ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort - # 2026-07-22): white one-line "RunningHub AI生成" text mark. + # RunningHub top-left text mark. "runninghub_alpha.png": "RunningHub AI生成", - # LibLibAI / 哩布哩布AI (USCC 91110105MACJ6K1C8A, 15-frame cohort): white - # "LibLibAI" wordmark with a triangle logo (logo not rendered, logos vary). + # LibLibAI bottom-center wordmark. "liblib_alpha.png": "LibLibAI", - # Zhipu Qingyan (USCC 91110108MA01KP2T5U, 7-frame cohort): white bold - # "清言·AI生成" with a circular logo (logo not rendered). PARKED 2026-07-22 - # as a measured negative: on both front-ends the cohort scores 0.34-0.39 - # against a clean-arm max of 0.34-0.37 -- no separation at any render/box - # setting (text-only and logo-composite templates both plateau ~0.34 raw; - # the white semi-transparent text on variable backgrounds is the wall). - # Silhouette stays as the starting point for a structural/learned lever. + # Zhipu Qingyan candidate text mark. "qingyan_alpha.png": "清言·AI生成", - # MiniMax / Hailuo (6-frame cohort): "MINIMAX" + "Hailuo AI" latin wordmarks. - # PARKED 2026-07-22: only 1 of the 6 cohort frames carries a visible mark -- - # nothing to calibrate recall against (the xinghui rule). Registration is a - # gate pick once more unique carriers arrive. + # MiniMax / Hailuo candidate wordmark. "hailuo_alpha.png": "Hailuo AI", - # Baidu (USCC 91110000802100433B, 16-frame cohort): white bold "百度" text - # + a separate white rounded tag with dark "AI生成", bottom-right. Detection - # keys on the 百度 text run ONLY: a two-component template (text+pill) scored - # at clean-arm levels (pill = bright-blob magnet, clean p95 0.45-0.55 vs cohort - # ~0.5, no separation on either front-end, 2026-07-22); the text-only silhouette - # separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is removed - # with the mark because the fill blob covers both bright components. + # Baidu bottom-right text run. "baidu_alpha.png": "百度", } +_REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys() # Per-mark post-processing for the multi-line / slanted stamps (see render()). MARK_OPTS: dict[str, dict[str, Any]] = { - # Re-fitted 2026-07-25 after the old affine transform was found to clip the - # lower line and retain a large blank right half. Hiragino Sans GB W6, tight - # leading, a 2px dilation, and -0.60 shear match the standard Yuanbao stamp. + # Hiragino Sans GB W6, tight leading, dilation, and negative shear match the + # standard Yuanbao stamp without clipping the lower line. "yuanbao_alpha.png": { "gap_frac": 0.05, "dilate": 2, @@ -166,14 +67,9 @@ MARK_OPTS: dict[str, dict[str, Any]] = { "font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2, }, - # Qingyan's real stamp is a heavier weight than STHeiti Medium -- Hiragino - # Sans GB W6 matches the measured stroke (2026-07-22; with Medium the - # silhouette aspect came out 0.19 vs the real 0.28 and NCC plateaued ~0.3). + # Qingyan uses a heavier weight than STHeiti Medium. "qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2}, - # LibLibAI's wordmark is set in an Arial-class grotesque, not STHeiti: - # measured 2026-07-22 across 7 candidate fonts, Arial lifts the cohort - # positives from 0.31-0.47 to 0.42-0.73 while the full-corpus false-fire arm - # DROPS to max 0.398 (generic latin UI text matches the wrong font less). + # LibLibAI uses an Arial-class grotesque. "liblib_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial.ttf"}, } @@ -237,10 +133,8 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n def draw_catlogo(width: int = 335) -> np.ndarray: """The cat-logo mark: an outline cat-head (integrated pointy ears, two dot eyes) - + a bold "AI生成" run, drawn synthetically from the measured layout (cat ~1.08x - the glyph height, stroke ~9%, gap ~35%). Proportions were iterated against a real - tophat response (2026-07-21): a solid filled head scored 0.35, this outline form - 0.50 -- the parked probe, see MARKS.""" + + a bold "AI生成" run, drawn synthetically from the calibrated layout. The outline + form is the parked candidate described in MARKS.""" probe = Image.new("L", (10, 10)) d0 = ImageDraw.Draw(probe) text = "AI生成" @@ -292,8 +186,11 @@ def main() -> None: try: for name, text in MARKS.items(): sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name)) - Image.fromarray(sil).save(_ASSETS / name) - print(f"wrote {_ASSETS / name} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}") + output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS + output_dir.mkdir(parents=True, exist_ok=True) + output = output_dir / name + Image.fromarray(sil).save(output) + print(f"wrote {output} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}") except OSError as e: print(f"Font not found ({e}); install a CJK font or edit _FONT.", file=sys.stderr) raise SystemExit(1) from e diff --git a/scripts/robustness_suite.py b/scripts/robustness_suite.py index 8798388..0b6748e 100644 --- a/scripts/robustness_suite.py +++ b/scripts/robustness_suite.py @@ -14,10 +14,10 @@ WHAT "PASS" MEANS HERE the exit code looks tidy. WHY THESE INPUTS - Every case is drawn from something real: ~0.2% of corpus uploads are truncated, ~2% carry - a mismatched extension, Unicode filenames were issue #17, and a wrapping service will run + The suite covers truncated files, mismatched extensions, Unicode filenames, and + concurrent access because a wrapping service may run concurrent jobs against one path. Decompression bombs and absurd geometry are the cheap - denial-of-service shapes any tool taking user uploads must survive. + denial-of-service shapes any tool taking untrusted files must survive. DATA SAFETY Builds its own inputs (synthetic, or truncated copies of committed fixtures) inside a @@ -43,7 +43,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) REPO = Path(__file__).resolve().parents[1] -SAMPLES = REPO / "data" / "samples" +SAMPLES = REPO / "data" / "fixtures" / "provenance" _UV = shutil.which("uv") or "uv" # A traceback in the output means the failure escaped the error handling, whatever the @@ -103,7 +103,7 @@ def graceful(res: Results, case: str, cmd: str, args: list[str], timeout: int = def make_inputs(tmp: Path) -> dict[str, Path]: - """Build the adversarial corpus. Each entry is something a real upload can be.""" + """Build the adversarial corpus from representative malformed inputs.""" import numpy as np from remove_ai_watermarks.image_io import imwrite @@ -115,7 +115,7 @@ def make_inputs(tmp: Path) -> dict[str, Path]: imwrite(good, np.full((600, 800, 3), 128, np.uint8)) made["good"] = good - # Truncated: a real PNG cut mid-stream (~0.2% of real uploads). + # Truncated PNG cut mid-stream. src = SAMPLES / "chatgpt-1.png" if src.exists(): raw = src.read_bytes() @@ -159,7 +159,7 @@ def make_inputs(tmp: Path) -> dict[str, Path]: shutil.copy2(good, rtl) made["rtl_filename"] = rtl - # Mismatched extension: PNG content named .jpg (~2% of real uploads). + # Mismatched extension: PNG content named .jpg. mismatch = tmp / "png_named_jpg.jpg" shutil.copy2(good, mismatch) made["mismatched_extension"] = mismatch @@ -280,8 +280,8 @@ def check_batch_silent_loss(res: Results, tmp: Path, inputs: dict[str, Path]) -> """The nastiest shape: NO output files AND a success exit code. `graceful()` cannot see this class -- it scores exit code and traceback markers, and a - run that writes nothing while exiting 0 has neither. Corpus-reproduced 2026-07-20: - `batch --mode visible` into a read-only directory wrote 0 of 2 files and exited 0, so a + run that writes nothing while exiting 0 has neither. A regression case showed that + `batch --mode visible` into a read-only directory could write no files and exit 0, so a wrapping service would treat an empty output directory as a completed run. Any check for a silent no-op must assert on the ARTIFACTS, not on the status. """ diff --git a/scripts/scan_dataset.py b/scripts/scan_dataset.py index ce2e29d..45bb043 100644 --- a/scripts/scan_dataset.py +++ b/scripts/scan_dataset.py @@ -157,7 +157,7 @@ def read_full_exif( for t, v in tags.items(): name = tag_names.get(t, {}).get("name", f"tag_{t}") if name == "MakerNote" and isinstance(v, bytes): - # full hex, no cap: measured on real uploads, Apple is ~2 KB + # Full hex, no cap: some Apple manifests are several kilobytes. # but Canon reaches 28 KB and Sony 38 KB (AF data, serials, # embedded previews) -- a cap would silently drop exactly the # camera-original evidence this scan exists to preserve diff --git a/scripts/sidecar_regression.py b/scripts/sidecar_regression.py index 2e2e352..ee6bc1c 100644 --- a/scripts/sidecar_regression.py +++ b/scripts/sidecar_regression.py @@ -1,8 +1,8 @@ -"""Tier A1: diff today's `identify` against the verdicts recorded in the corpus sidecars. +"""Tier A1: diff today's `identify` against previously recorded verdict sidecars. -`data/spaces/identify//.json` holds the verdict a past run produced for -`data/spaces/originals//_src.`. Re-running identify and diffing turns the -corpus into a ~39k-image behavioral regression suite that needs no new labelling. +An identify-results directory holds the verdict a past run produced for each image in a +local source directory. Re-running identify and diffing provides a behavioral regression +suite without requiring new labels. WHAT A DIFF MEANS -- READ THIS BEFORE PANICKING The sidecars were written by OLDER versions, so an intended improvement shows up as a @@ -27,8 +27,8 @@ WHY FAMILIES, NOT RAW STRINGS so the comparison tracks BEHAVIOR, not phrasing. DATA SAFETY - Corpus images are user uploads: read-only, local analysis. Output goes to a gitignored - path under data/spaces/ and records uids, never image content. + Treat input datasets as sensitive and read-only. Output goes under the gitignored + .local-eval/ directory and records identifiers, never image content. uv run python scripts/sidecar_regression.py --sample 500 # representative trial uv run python scripts/sidecar_regression.py # full corpus, resumable @@ -53,9 +53,9 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parent.parent)) REPO = Path(__file__).resolve().parents[1] -IDENTIFY_DIR = REPO / "data" / "spaces" / "identify" -ORIGINALS = REPO / "data" / "spaces" / "originals" -OUT = REPO / "data" / "spaces" / "_sidecar_regression.jsonl" +IDENTIFY_DIR = REPO / ".local-eval" / "identify" +ORIGINALS = REPO / ".local-eval" / "originals" +OUT = REPO / ".local-eval" / "sidecar-regression.jsonl" # Map a watermark description to a stable behavior family. Order matters: the first # matching pattern wins, so put the specific tokens above the generic ones. diff --git a/scripts/smoke_matrix.py b/scripts/smoke_matrix.py index 580ff07..a4ee0b1 100644 --- a/scripts/smoke_matrix.py +++ b/scripts/smoke_matrix.py @@ -49,8 +49,8 @@ from dataclasses import dataclass, field from pathlib import Path REPO = Path(__file__).resolve().parents[1] -SAMPLES = REPO / "data" / "samples" -CORPUS = REPO / "data" / "spaces" / "originals" +SAMPLES = REPO / "data" / "fixtures" / "provenance" +CORPUS = REPO / ".local-eval" / "originals" EXIT_NO_VISIBLE_MARK = 2 @@ -101,7 +101,7 @@ class Runner: def corpus_pick(n: int, suffixes: tuple[str, ...]) -> list[Path]: - """Real uploads, chosen deterministically so a failure is reproducible.""" + """Local evaluation files, chosen deterministically so a failure is reproducible.""" if not CORPUS.exists(): return [] pool = [p for p in CORPUS.glob("*/*") if p.suffix.lower() in suffixes] @@ -117,7 +117,7 @@ def main() -> None: ) a = ap.parse_args() - tmp = Path(tempfile.mkdtemp(prefix="raiw-smoke-")) + tmp = Path(tempfile.mkdtemp(prefix="remove-ai-watermarks-smoke-")) r = Runner(tmp) doubao = SAMPLES / "doubao-1.png" chatgpt = SAMPLES / "chatgpt-1.png" @@ -312,7 +312,7 @@ def main() -> None: picks += [(label, p) for p in corpus_pick(2, (suf,))] picks += [("png", p) for p in corpus_pick(3, (".png",))] if not picks: - r.skip("real-format rows", "corpus not present (data/spaces/originals)") + r.skip("real-format rows", "local evaluation dataset not present") for label, p in picks: r.run(f"identify real {label}", ["identify", str(p), "--json"]) r.run( diff --git a/scripts/synthid_corpus.py b/scripts/synthid_corpus.py index 5ef26cd..81beeba 100644 --- a/scripts/synthid_corpus.py +++ b/scripts/synthid_corpus.py @@ -1,11 +1,11 @@ """Ingest and inspect the local SynthID reference corpus. -Copies images into ``data/synthid_corpus/images/