commit 6f330990b5abc2d7db5b80ead3ab7b7f3dc7fb80 Author: wehub-resource-sync Date: Mon Jul 13 12:35:25 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1fb8139 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch" + ] + }, + "enabledPlugins": { + "pyright-lsp@claude-plugins-official": true, + "context7@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "claude-md-management@claude-plugins-official": true + } +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2431293 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# HuggingFace token (optional; only needed for gated/private models) +# Get yours at: https://huggingface.co/settings/tokens +HF_TOKEN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3aae989 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.pt binary diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..3ef416e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +# Enables the "Sponsor" button on the repository page. +github: wiltodelta diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..402d72c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 + +updates: + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml new file mode 100644 index 0000000..3b778fa --- /dev/null +++ b/.github/workflows/distribute.yml @@ -0,0 +1,106 @@ +name: Distribute on release + +# Fans a published GitHub Release out to the channels that need a nudge. +# PyPI is handled by publish.yml; conda-forge is handled by its autotick bot. +# This workflow event-drives the two channels that would otherwise be manual: +# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist. +# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI. +# Both wait for the freshly published sdist to appear on PyPI first, since the +# Release event fires in parallel with publish.yml's upload. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to distribute (no leading v), e.g. 0.10.3. Blank = latest on PyPI." + required: false + +jobs: + resolve: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + url: ${{ steps.sdist.outputs.url }} + sha: ${{ steps.sdist.outputs.sha }} + steps: + - name: Resolve target version + id: v + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "release" ]; then + v="${GITHUB_REF_NAME#v}" + elif [ -n "${{ github.event.inputs.version }}" ]; then + v="${{ github.event.inputs.version }}" + else + v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])") + fi + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "Target version: $v" + + - name: Wait for the sdist on PyPI, capture url + sha256 + id: sdist + run: | + set -euo pipefail + v="${{ steps.v.outputs.version }}" + for i in $(seq 1 30); do + if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then + url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))") + sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))") + if [ -n "$url" ] && [ -n "$sha" ]; then + echo "url=$url" >> "$GITHUB_OUTPUT" + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "Found sdist for $v" + exit 0 + fi + fi + echo "sdist for $v not on PyPI yet (attempt $i), waiting..." + sleep 20 + done + echo "::error::sdist for $v did not appear on PyPI in time" + exit 1 + + homebrew: + needs: resolve + runs-on: ubuntu-latest + steps: + - name: Checkout the tap + uses: actions/checkout@v7 + with: + repository: wiltodelta/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + - name: Bump the formula + env: + URL: ${{ needs.resolve.outputs.url }} + SHA: ${{ needs.resolve.outputs.sha }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + F=Formula/remove-ai-watermarks.rb + sed -i -E "s|url \"[^\"]*\"|url \"$URL\"|" "$F" + sed -i -E "s|sha256 \"[^\"]*\"|sha256 \"$SHA\"|" "$F" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add "$F" + git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; } + git push + + hf-space: + needs: resolve + runs-on: ubuntu-latest + steps: + - name: Factory-rebuild the HuggingFace Space + env: + HF_TOKEN: ${{ secrets.HF_TOKEN }} + run: | + python3 -m pip install --quiet huggingface_hub + python3 - <<'PY' + import os + from huggingface_hub import HfApi + HfApi(token=os.environ["HF_TOKEN"]).restart_space( + "wiltodelta/remove-ai-watermarks", factory_reboot=True + ) + print("HF Space factory rebuild triggered") + PY diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7da14ac --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +permissions: + id-token: write + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + environment: pypi + steps: + - uses: actions/checkout@v7 + + - uses: astral-sh/setup-uv@v7 + + - name: Verify release tag matches package version + run: | + tag="${{ github.event.release.tag_name }}" + version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)"/\1/')" + if [ "${tag#v}" != "$version" ]; then + echo "Release tag '$tag' does not match pyproject.toml version '$version'" >&2 + exit 1 + fi + echo "Release tag '$tag' matches package version '$version'" + + - name: Build package + run: uv build + + # Publish with `uv publish` (its own uploader, not the twine bundled in the + # old pypa action). Trusted publishing is automatic in GitHub Actions: the + # `id-token: write` permission + the `pypi` environment supply the OIDC token, + # and PyPI's trusted-publisher entry matches on repo + workflow filename + + # environment name (all unchanged from the pypa-action setup), so no API token + # is needed. uv's uploader also accepts Metadata-Version 2.5 -- the permanent + # fix that lets the hatchling pin be relaxed (see pyproject [build-system]). + - name: Publish to PyPI + run: uv publish \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..719ecde --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,46 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + - name: Sync dev environment + run: uv sync --frozen --extra dev + - name: Ruff lint + run: uv run ruff check + - name: Ruff format check + run: uv run ruff format --check + + test: + name: test ${{ matrix.os }} py${{ matrix.python-version }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + # Cross-OS x Python-floor/recent. The default-install surface only + # needs the core deps plus dev tooling (no GPU extra): the model-running + # paths are availability-gated and skip when torch/diffusers are absent, + # so this matrix exercises metadata, identify, visible, and the cv2 + # eraser on every OS without pulling the heavy ML stack. + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + - name: Sync dev environment + run: uv sync --frozen --extra dev + - name: Run tests + run: uv run pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5372352 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Dependencies +.venv/ +__pycache__/ +*.egg-info/ +dist/ +build/ + +# Environment secrets +.env + +# OS files +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# SynthID corpus reference fills (synthetic black/white calibration tiles, +# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README) +data/synthid_corpus/refs/ + +# Reference materials +_refs/ + +# Downloaded model weights +yolov8n.pt +.coverage + +# Claude Code local settings +.claude/settings.local.json +.claude/scheduled_tasks.lock + +# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are +# committed (content-free: a solid colour + the watermark; the source for +# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The +# synthetic seeds (regenerable) and any real-content validation download (a real +# generated scene, kept local for privacy) are NOT committed. +data/doubao_capture/seeds/ +data/jimeng_capture/seeds/ +data/jimeng_capture/captures/jimeng_content_*.png +data/gemini_capture/seeds/ +data/gemini_capture/captures/gemini_content_*.png +data/samsung_capture/seeds/ +data/samsung_capture/captures/samsung_content_* + +# Leftover GFPGAN weights dir from the retired face-restore experiments +# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working +# dir on first use). Runtime artifact, never committed. +gfpgan/ + +# Qwen ControlNet experiment outputs (throwaway eval; never the committed corpus) +scripts/_qwen_exp_out/ + +# Local-only working data for analysis (not a committed corpus; never tracked) +data/spaces/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..09d79c4 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# Remove-AI-Watermarks + +You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI watermarks from images. + +## Scope and non-goals + +The mission is removing **AI-provenance watermarks** that a platform stamps onto content the user generated themselves — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is user autonomy over their own generated output. + +It deliberately does **not** remove watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled / diagonal "preview" watermark whose job is to gate a purchase. Stripping those makes a paid resource free off someone else's work; out of scope **by principle, not by technical difficulty**. The line: a visible mark is in scope when it labels the user's **own** AI generation, and out of scope when it protects a **third party's paid asset**. + +Consequences for contributors (do not drift back into the stock niche just because it is technically feasible): +- Do not add stock / agency / classifieds watermark removal to `watermark_registry.py` or the eraser, and do not build tiled-overlay or multi-image watermark-estimation features aimed at them. +- `erase --region` stays a generic **user-driven** tool (the user points at their own object); do not ship an *automatic* stock-watermark detector/remover on top of it. +- New visible-mark templates are for **AI-generation labels only**. + +(Established 2026-06-13 by user instruction: "Я пытаюсь сделать платные ресурсы бесплатными — это не то, против чего мы боремся.") + +## How to run + +Per-command exit-code semantics (the no-signal / GPU-missing skip branches), test traps, and regression-guard paths live in `docs/module-internals.md` (section "CLI commands (`cli.py`)") — read it before changing any command's skip/exit behavior. + +- `uv run remove-ai-watermarks all -o ` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) and `--sensitivity auto|strict|assume-ai` (default `auto`) for the localize -> fill visible removal (see the `visible` bullet). Skips step 2 (invisible/SynthID) when the `[gpu]` extra is absent or no invisible signal is detectable; see the module doc for the distinct exit codes. +- `uv run remove-ai-watermarks invisible -o ` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default), `--steps`, `--guidance-scale` (CFG, default 7.5), `--pipeline sdxl|controlnet|qwen` (default `controlnet`; `qwen` is a manual opt-in only — see the qwen note in the module map), `--controlnet-scale`, `--model` (HF model id, default SDXL base), `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize` (Analog Humanizer grain), `--unsharp` (final sharpen), `--adaptive-polish/--no-adaptive-polish` (**ON by default**), `--tile/--no-tile` + `--tile-size`/`--tile-overlap` (**OFF by default**), `--force/--no-force` (default skip = ON, runs the scrub even with no detected signal). `--auto` is deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable (the no-signal gate); see the module doc. +- `uv run remove-ai-watermarks visible -o ` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Samsung Galaxy AI "✦ Contenuti generati dall'AI", and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one. `--sensitivity auto|strict|assume-ai` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes; `assume-ai` relaxes every mark (the caller asserts the image is AI, e.g. a metadata-stripped screenshot uploaded to a remover — corpus-measured Gemini recall ~46% -> ~92%, at the cost of a small fill on some clean corners). Metadata provenance is read automatically and feeds `auto`; the library cannot infer AI from a stripped image, so only `assume-ai` reaches the high recall there. For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. +- `uv run remove-ai-watermarks erase --region x,y,w,h -o ` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps), `--backend migan` (MI-GAN via onnxruntime, extra `migan`; ~28 MB, ~1 GB RAM, near-LaMa), or `--backend lama` (big-LaMa, extra `lama`; best quality but ~4.7 GB RAM); `--region` is repeatable. +- `uv run remove-ai-watermarks identify ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector +- `uv run remove-ai-watermarks metadata --check` — inspect AI metadata (C2PA, EXIF, PNG chunks) +- `uv run remove-ai-watermarks metadata --remove -o ` — strip all AI metadata +- `uv run remove-ai-watermarks batch ` — process every supported image in a directory (output defaults to `_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. + +## Test and lint + +- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. +- **Release flow + distribution channels** (PyPI publish via `publish.yml`/`uv publish`, the automated Homebrew-tap + HF-Space bumps in `distribute.yml`, conda-forge, ComfyUI Registry, the sdist `data/` exclusion, hatchling pin history): see `docs/release-and-distribution.md` before cutting a release. +- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scoped `src/`, see the OOM note below), pytest -n auto. The helper tools live in the `dev` extra (`pytest-xdist`, plus `uv-outdated`/`uv-secure` marker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without `--extra dev` does not have them. +- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. The `uv-secure` CVE-resolution history (idna/aiohttp bumps, retired basicsr, the dismissed torch `GHSA-rrmf-rvhw-rf47`) lives in `docs/release-and-distribution.md` — read it before re-triaging a dependency alert. +- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly. +- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch. +- **Stale `trustmark` remnant in site-packages after an extras change:** the `trustmark` package downloads model weights INTO its own package dir, so when a narrower `uv sync` prunes the package, a `trustmark/models/` directory survives as an empty namespace package. Symptom: pyright `"TrustMark" is unknown import symbol` on `trustmark_detector.py` and `find_spec("trustmark")` returning a loader-less spec (so `is_available()` lies True). Fix: `rm -rf .venv/lib/python3.12/site-packages/trustmark` (regenerable weights cache). +- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand. +- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC; `flux-1.png` / `flux-1.jpg` = real Black Forest Labs FLUX.2 Playground output, signed C2PA (issuer "Black Forest Labs" + `trainedAlgorithmicMedia`) -- `flux-1.jpg` is the first committed **JPEG-with-C2PA** fixture, exercising the c2pa-python non-PNG reader path end to end; whether BFL hosted output also embeds the open DWT-DCT pixel watermark is UNRESOLVED -- our detector returns None on these fox samples, but they are high-texture carriers where even a known-embedded watermark fails the round-trip, see the content-fragility caveat in `docs/watermarking-landscape.md`); synthetic byte blobs cover the remaining JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent). +- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles. **`cleaned/` examples must be produced by a CURRENT shipped removal method** -- the default SDXL img2img pass (optionally `--max-resolution`). Do NOT archive cleaned outputs from methods that are no longer in the pipeline (ctrlregen, the old text/face-protection, IP-Adapter FaceID, CodeFormer) or from the experimental opt-in paths (controlnet, face restore) as corpus examples; a cleaned reference should represent the canonical removal, and a removed method's output is not a reproducible example. Keep those experiment outputs in a local working dir, never in the committed corpus. + +## Configuration + +- GPU/ML modules (invisible_engine, watermark_remover) are optional — guard imports with `is_available()` checks +- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent. +- Optional `esrgan` extra (spandrel only): Real-ESRGAN pre-diffusion super-resolution for small inputs (`upscaler.py`, CLI `--upscaler esrgan` on `invisible`/`all`/`batch`). Guarded by `upscaler.is_available()`; the default upscaler stays Lanczos (cv2, no deps) and the engine falls back to Lanczos when the extra is absent or the model errors. spandrel is MIT and pulls NO basicsr (only torch/torchvision/safetensors/numpy/einops); Real-ESRGAN weights are BSD-3-Clause and download on first use via `torch.hub` (never bundled). Kept OUT of `all` (heavy + model download). +- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale-cap-vs-upscale-floor, `test_invisible_engine.py`), `humanizer.unsharp_mask`/`adaptive_polish` (`test_humanizer.py`), and the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover). Don't skip these as "ML, needs a model" — only `remove_watermark`/the diffusion bodies do. + +## Key modules + +Compact map. The full per-module detail (design decisions, tuned thresholds, calibration history, incident records, and the regression-guard map) lives in `docs/module-internals.md` — **read the relevant section there before changing any module below.** + +- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use). +- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`. +- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11, AI XMP APP1, IPTC APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). This is what lets a `--strip-metadata` on a q100 removal output NOT crush it back to q75. PNG/WebP re-saves are already pixel-lossless. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_jpeg_metadata_strip_is_pixel_lossless` (real grok/flux fixtures). `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`. Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). +- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`/`assume_ai`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_relax` + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How auto/assume decide (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under `strict`/metadata-free `auto` and ~92% under `assume_ai` (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's `assume_ai` reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes `assume_ai` acceptable. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao** detection, and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard holds even under `assume_ai`. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark still needs a real detection template: a new alpha-solved silhouette from `scripts/visible_alpha_solve.py` (solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under `data/_capture/captures/`) for a captured mark, or a synthetic font-rendered silhouette for a capture-less one like the pill. Do NOT synthesize a captured mark's detection template by font-rendering the wordmark (the user rejected synthetic reconstruction as below the quality bar, 2026-06-22), and do NOT derive one from user uploads (data-safety: no corpus-derived committed assets). So if no flat capture exists for a mark, the work is parked — do not propose synthetic, do not derive from user uploads. (Meta AI, more Samsung locales, and any Grok visible mark are all parked on this; Grok additionally needs confirming it even HAS a visible mark — its known signal is EXIF-only `xai_signature`.) +- `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. +- `_text_mark_engine.py` — shared base for the three text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). +- `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates. +- `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own captured detection template. +- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged). +- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) **carrier-fragile on a broad class of pristine images** -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the `_MATCH_48`=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate **all-ones decode that is a CARRIER ARTIFACT, not a watermark** (synthetic clean image reproduces it). So `detect_invisible_watermark` is **positive-only**: trust a hit; a `None` is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in `docs/watermarking-landscape.md`. +- `trustmark_detector.py` — Adobe TrustMark open decoder (extra `trustmark`). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise. +- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one). +- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). `WatermarkRemover.remove_watermark` branches to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner. +- `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit). +- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text. +- `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches. `read_bgr_and_alpha`/`write_bgr_with_alpha` (+ `ALPHA_FORMATS`) are the alpha-preserving IO helpers shared by the CLI and the library `api` (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). cv2/numpy import lazily, so importing `image_io` is cheap. **`imread` has a Pillow fallback (`_pil_read`) for HEIC/AVIF**: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core `pillow-heif` dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). **`imwrite` PRESERVES the input format at max quality** ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (`_pil_write`) since cv2 has NO encoder for them (writing `.heic` via cv2 RAISES — a HEIC input used to crash on save). `imwrite` never raises (catches `cv2.error`). **`api.remove_visible` copies the original bytes verbatim on a no-op** (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. `noai/constants.SUPPORTED_FORMATS` now includes `.heic`/`.heif`/`.avif` alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so `batch` discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). **The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top:** `watermark_remover` saves the regenerated output through `image_io.imwrite` (not raw `PIL.save`, which defaults to JPEG q75), `invisible_engine` writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level `metadata.remove_ai_metadata` (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical. +- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`. + +For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`. + +## Watermarking landscape + +Who embeds what (C2PA / IPTC / EXIF / TC260 AIGC / xAI signature / open and proprietary invisible watermarks), whether each is locally detectable, the C2PA 2.4 durable-credentials implications, and the regulatory driver table live in `docs/watermarking-landscape.md` (research 2026-05-24, updated through 2026-06-10). Read it before adding a new `identify` signal, vendor token, or metadata marker. See `identify.py` for what we read today. + +## Known limitations + +Compact list. Full measurements, incident history, and oracle-validation runs live in `docs/known-limitations.md` — **read the relevant section there before changing the diffusion pipelines, strength defaults, resolution handling, or metadata coverage.** + +- **Visible-mark fill quality is background/backend-dependent.** The fill only touches the mark footprint (no outside-box damage) and whether the mark is removed is fill-independent — cv2/MI-GAN/LaMa all strip the shape; only the recovered region's *quality* differs. Flat backgrounds: all clean (cv2 often crispest). Textured/regular-structured (fabric, grid): cv2 smears, MI-GAN can ghost/hallucinate, LaMa best. The old reverse-alpha recovered true pixels so it was sometimes cleaner on structure, but localize -> fill trades that for robustness (moved/re-rendered marks, no per-mark capture); `auto` = LaMa > MI-GAN > cv2 with a one-time cv2-fallback warning. Head-to-head vs v0.12.1 on the full visible set: doubao/jimeng identical (100%/100%), gemini strict coverage a few points lower (the metadata-stripped faint ones now mostly recovered by the default white-core rescue in the gemini FP gate; the residual via `assume-ai`), clearance ~98% both. Detail in `docs/known-limitations.md`. +- `invisible` processes at native resolution for inputs >= 1024px long side and auto-upscales smaller inputs to a 1024px floor (`--min-resolution 0` disables; `--max-resolution N` is an opt-in cap to bound GPU/MPS memory). MPS OOM is memory-tier dependent, not a hard limit: ~24 GB unified memory falls back to CPU (slow but weight-identical output), 32 GB runs native on MPS. The native-vs-cap-vs-floor decision lives in the pure helper `invisible_engine._target_size` — keep the logic there, unit-tested without the model. For large inputs that OOM, `--tile` is the **lossless** alternative to `--max-resolution`: sliding-window diffusion at native resolution, each tile near SDXL's 1024 training size, feather-blended over the overlap (`noai/tiling.py`). It only engages when the long side exceeds `--tile-size`; the geometry (`plan_tiles`) and the blend window (`feather_weights`) are pure and unit-tested (`tests/test_tiling.py`). Caveat: each tile is an independent low-strength regeneration, so at the certified removal strengths (0.20-0.30) tile drift is minimal but not zero; tiling is a memory workaround, not a quality upgrade over a single native pass. +- fp16 VAE black-output (issues #29/#41): the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix`) is swapped in for the default SDXL checkpoint on cuda/xpu fp16, plus a model-agnostic backstop that detects a degenerate (all-black) fp16 output and re-runs once in fp32. cpu/mps run fp32 and never reproduce the bug. +- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files. +- A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (`ModuleNotFoundError`), not `UnidentifiedImageError`, when opening a file. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`. +- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design. +- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an `Exif` meta-box *item* (bytes in `mdat`/`idat`) is now blanked **in place** by `isobmff.blank_ai_exif_tokens` (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`); it scrubs the AI-token value only, leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists). non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an `Exif` meta-box *item* (bytes in `mdat`/`idat`) is now blanked **in place** by `isobmff.blank_ai_exif_tokens` (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`); it scrubs the AI-token value only, leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists). +- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here. +- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content. +- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based. +- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.20** / Gemini **0.30** / unknown **0.30** when `--strength` is unset; explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type. Certified controlnet floors (Modal cert 2026-06-04): OpenAI 0.20 (resolution-independent), Gemini 0.30 (only <= 1536px; native large Gemini needs ~0.35+ or a cap). +- **`controlnet` is the default pipeline**; `--pipeline sdxl` is the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b4e4b66 --- /dev/null +++ b/LICENSE @@ -0,0 +1,217 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + Copyright 2025-2026 wiltodelta + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..def35db --- /dev/null +++ b/README.md @@ -0,0 +1,554 @@ +# 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. + +Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command. + +> ## 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. + +[![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/) +[![Downloads](https://static.pepy.tech/badge/remove-ai-watermarks/month)](https://pepy.tech/project/remove-ai-watermarks) +[![License](https://img.shields.io/pypi/l/remove-ai-watermarks?color=blue)](LICENSE) +[![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). + +> **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. + +## Scope + +This tool removes **AI-provenance watermarks** that a platform stamps onto content **you generated yourself** — SynthID, the Gemini / Nano Banana sparkle, the Doubao / Jimeng / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is your autonomy over your own output. + +It does **not** target watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled "preview" watermark whose job is to gate a purchase. Removing those is out of scope by design. `erase` is a generic, user-driven region tool for your own objects, not an automatic stock-watermark remover. + +## Features + +- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `--sensitivity assume-ai` relaxes every mark on the assertion that the image is AI, recovering the moved or re-rendered marks the conservative gate skips on a metadata-stripped screenshot (`strict` never relaxes). `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.) +- **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 +- **Text and face preservation (default)** — the default pipeline is a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Use `--pipeline sdxl` for plain SDXL img2img (lighter, no extra model download) on inputs without text or faces. An experimental `--pipeline qwen` runs Qwen-Image (20B, Apache-2.0) img2img, which preserves **text** (including CJK and small text) better than SDXL at equal strength; it is CUDA/cloud-class (does not fit MPS), and its strength floors are not yet certified (pass an explicit `--strength`, especially for Gemini content). Note: measured fidelity (`scripts/fidelity_metrics.py`) shows Qwen wins on text but controlnet preserves **faces** better (Qwen smooths skin more), so Qwen is not a universal upgrade. Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness). The library does not ship a face-restore extra: every approach evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned) regenerated the face via SDXL and made the output look more AI-generated than the cleaned image. The cleaned controlnet output is the least-AI face state achievable without re-introducing SynthID. +- **Batch processing** — process entire directories +- **Detection** — three-stage NCC watermark detection with confidence scoring +- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output) + +## Examples + +| Before (Watermarked) | After (Cleaned) | +| --- | --- | +| ![Before](demo_banana_before.png) | ![After](demo_banana_after.png) | + +## Supported models + +| 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 | +| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip | +| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip | +| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration | +| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration | + +> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command. + +> **Detection:** `remove-ai-watermarks identify ` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only. + +## How it works + +### Removing the Gemini / Nano Banana sparkle watermark + +Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo to generated images using alpha blending: + +```text +watermarked = α × logo + (1 − α) × original +``` + +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). + +**Speed**: fast on CPU with the default cv2 fill. No GPU needed. + +### 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.20 OpenAI / 0.30 Google / 0.30 unknown, same for both pipelines; + 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 (each near SDXL's 1024 px size) 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`. 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. + +> **Default strength is vendor-adaptive (no flag needed).** The tool reads the C2PA issuer to detect which vendor's SynthID is present and picks the strength accordingly: **OpenAI gpt-image → `0.20`**, **Google Gemini → `0.30`**, **unknown source → `0.30`**. The **same ladder applies to both pipelines** — these are the oracle-certified `controlnet` floors (June 2026 Modal cert, multi-seed). They also cover plain `sdxl`: the two pipelines have opposite hard cases (controlnet leaves SynthID on photoreal, sdxl on flat graphics), but on its own hard case sdxl is the weaker remover, so it needs at least controlnet's strength — using one certified ladder is the safe choice (margin-based for sdxl, not separately certified). The dominant factor is the vendor (Google's SynthID is ~3x more robust). There is no local SynthID detector, so if the oracle still reads SynthID, raise `--strength`; if you care more about preserving fine detail, lower it. (Caveat: Google's `0.30` was validated only at `--max-resolution 1536`; a very large native Gemini image may need ~`0.35`+.) +> +> **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 default strength ladder (OpenAI `0.20` / Google `0.30`) is the oracle-certified controlnet floor. `--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. +> +> **No face-restore extra in the library.** Every ArcFace-based regeneration approach we evaluated (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned at three parameter sweeps, 2026-06-04 - 2026-06-08 Modal cert sweeps) regenerated the face via SDXL diffusion — the output face pixels were diffusion-fresh (SynthID not re-introduced), but the face inherently looked more AI-generated than the cleaned image (SDXL "clean skin" gloss, lost original identity precision). The cleaned image from the main controlnet 0.20 pass is the least-AI face state we can reach without re-introducing SynthID. Empirical conclusion in `docs/synthid-robust-identity-research-2026-06-08.md`. + +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 pipeline; `--pipeline sdxl` opts down to plain SDXL): a canny ControlNet keeps text and face *structure* sharp through the removal pass, without copying or freezing any original pixels (so SynthID is still removed). Tune the preservation strength with `--controlnet-scale`. Canny preserves structure but not face *identity*: the regenerated face drifts in likeness. The library does not ship a face-restore extra (see the callout above). + +**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]" +``` + +### 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+ 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]" +> ``` +> +> 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" / +# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with +# --mark gemini / doubao / jimeng / 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|assume-ai (default auto) sets how hard a borderline +# mark is trusted; use assume-ai on a metadata-stripped screenshot to recover a +# moved or faint mark the conservative gate would skip. +# 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: assert it is AI to relax detection and recover +# a moved/faint mark (a wrong guess just fills a small corner near-losslessly). +remove-ai-watermarks visible screenshot.png --sensitivity assume-ai -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 +# --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 (OpenAI 0.20 / Google 0.30, same +# for both pipelines); override with --strength. 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) restores the input's detail level (sparing +# text) to counter the over-smoothed look; it self-limits to a no-op where +# there is no detail deficit. Disable with --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/...); --adaptive-polish is on by default (--no-adaptive-polish to disable) +remove-ai-watermarks batch ./images/ --mode all +``` + +### Python API + +One high-level call removes every detected visible mark (Gemini sparkle, Doubao / Jimeng / Samsung text, the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use). + +```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="assume_ai") + +# 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") +``` + +#### Invisible removal (diffusion) + +```python +from pathlib import Path +from remove_ai_watermarks.invisible_engine import InvisibleEngine + +# pipeline: "controlnet" (default, preserves text/face structure) or "sdxl" (plain). +# model_id=None uses the SDXL base; controlnet_conditioning_scale tunes preservation. +engine = InvisibleEngine(pipeline="controlnet") + +engine.remove_watermark( + Path("watermarked.png"), + Path("clean.png"), + strength=None, # None = vendor-adaptive default (OpenAI 0.20 / Google 0.30) + num_inference_steps=50, + guidance_scale=None, # None = the library default (7.5) + seed=None, # set for reproducible output + 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) +) +``` + +### Metadata stripping + +```python +from remove_ai_watermarks.metadata import has_ai_metadata, remove_ai_metadata +from pathlib import Path + +if has_ai_metadata(Path("image.png")): + remove_ai_metadata(Path("image.png"), Path("clean.png")) +``` + +## Requirements + +- Python ≥ 3.10 +- **Visible removal / metadata**: CPU only, no GPU required +- **Invisible removal**: GPU recommended (CUDA or MPS), works on CPU (slow) + +## Troubleshooting + +**SSL certificate error** (`CERTIFICATE_VERIFY_FAILED`): + +```bash +# Install certifi (the tool auto-detects it) +pip install certifi + +# macOS only: run the Python certificate installer +/Applications/Python\ 3.*/Install\ Certificates.command +``` + +**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.** It runs a low-strength SDXL img2img regeneration, so it softens fine detail and is content-dependent. There is no public SynthID decoder, so the tool cannot verify removal locally; confirm with the Gemini app's "Verify with SynthID" oracle and raise `--strength` if it still detects. 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. (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. + +## License + +[Apache 2.0](LICENSE). Copyright 2025-2026 wiltodelta. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..bb839dc --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`wiltodelta/remove-ai-watermarks` +- 原始仓库:https://github.com/wiltodelta/remove-ai-watermarks +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/data/doubao_capture/README.md b/data/doubao_capture/README.md new file mode 100644 index 0000000..b17d7d8 --- /dev/null +++ b/data/doubao_capture/README.md @@ -0,0 +1,87 @@ +# 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. + +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`). + +## What we already know (verified from prior art, 2026-05-26) + +- 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)`. + Confirmed by two independent sources (an open-source remover's algorithm doc + aiwatermarkremover.dev, + 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. +- 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. + +## Use doubao.com specifically + +The "豆包AI生成" mark is Doubao's. Jimeng / Dreamina use a different mark. Generate on doubao.com so +the captured template matches our target. + +## How to capture (image-edit path, most reliable) + +For each seed in `seeds/`: + +1. Open Doubao image generation, use the image-edit / reference mode, upload the seed. +2. Prompt (Chinese preferred): + `请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容` + (English: `Recreate this image exactly as it is, keep it identical, do not add or change anything`) +3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save. + +Prior art confirms uploading a pure-black image and letting Doubao stamp it works. + +If edit mode is unavailable and text-to-image refuses a solid color, fall back to generating 10-12 +normal-content images at one fixed resolution; the mark is the only constant across them and can be +extracted by per-pixel min/median. + +## What to capture (priority top to bottom) + +| Aspect | black | white | gray128 | why | +|--------|-------|-------|---------|-----| +| 1:1 | 3 | 1 | 1 | primary alpha map + confirm the stamp is pixel-identical across runs + settle blend mode | +| 16:9 | 2 | 1 | 1 | anchor rule in landscape | +| 9:16 | 2 | 1 | 1 | anchor rule in portrait | +| 4:3, 3:4 | 1 each | - | - | optional, refines anchor rule | + +- 3 blacks on 1:1: if the first two are byte-identical in the watermark region, the third is optional. +- gray128 is the blend-mode test: predict the gray result from the black capture under alpha vs screen; + whichever matches the real gray output is the true blend. +- If the UI offers multiple output resolutions (1K / 2K / 4K), capture one black per resolution on 1:1 - + needed to learn how the watermark scales. +- Also grab 3-5 normal-content images on 1:1 for end-to-end removal validation. + +## Hygiene + +- 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/` + +``` +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 +``` + +## Also report back + +1. Which resolutions and aspect ratios the Doubao UI actually offers. +2. Whether there is a watermark on/off toggle in the UI. +3. Download format (PNG or JPEG). diff --git a/data/doubao_capture/captures/doubao_black_1x1_1.png b/data/doubao_capture/captures/doubao_black_1x1_1.png new file mode 100644 index 0000000..44ed258 Binary files /dev/null and b/data/doubao_capture/captures/doubao_black_1x1_1.png differ diff --git a/data/doubao_capture/captures/doubao_gray_1x1_1.png b/data/doubao_capture/captures/doubao_gray_1x1_1.png new file mode 100644 index 0000000..c59b7f8 Binary files /dev/null and b/data/doubao_capture/captures/doubao_gray_1x1_1.png differ diff --git a/data/doubao_capture/captures/doubao_white_1x1_1.png b/data/doubao_capture/captures/doubao_white_1x1_1.png new file mode 100644 index 0000000..e1f6abc Binary files /dev/null and b/data/doubao_capture/captures/doubao_white_1x1_1.png differ diff --git a/data/gemini_capture/README.md b/data/gemini_capture/README.md new file mode 100644 index 0000000..26c1e83 --- /dev/null +++ b/data/gemini_capture/README.md @@ -0,0 +1,45 @@ +# 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 +> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`. + +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 +text marks, the sparkle is captured over a **pure-black** background, where +`watermarked = a*255` (logo is near-white), so the alpha reads directly off the capture +(`alpha = max(R,G,B)/255`) -- no background fit needed. This is the "golden" capture case. + +## What the captures confirmed (2026-05-31) + +- The sparkle at a 2048-wide image is **96x96 px** (width_frac ~0.047), bottom-right, + margins ~0.031, alpha max ~0.51 -- matching the engine's existing 96px asset. +- Our own controlled capture matches the previously third-party-sourced + `gemini_bg_96.png` to **NCC 0.9998**, so the bundled asset is validated and now + reproducible from our own capture. + +## How to capture (image-edit path) + +For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored): + +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` +3. Download the ORIGINAL output (not a screenshot). Do not crop / edit / re-save. + +Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check. + +## Naming, drop into `captures/` + +``` +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). +Rebuild the assets with: + +``` +uv run python scripts/visible_alpha_solve.py gemini # or: all +``` diff --git a/data/gemini_capture/captures/gemini_black_2048.png b/data/gemini_capture/captures/gemini_black_2048.png new file mode 100644 index 0000000..8661674 Binary files /dev/null and b/data/gemini_capture/captures/gemini_black_2048.png differ diff --git a/data/gemini_capture/captures/gemini_gray_2048.png b/data/gemini_capture/captures/gemini_gray_2048.png new file mode 100644 index 0000000..2c2db4a Binary files /dev/null and b/data/gemini_capture/captures/gemini_gray_2048.png differ diff --git a/data/gemini_capture/captures/gemini_white_2048.png b/data/gemini_capture/captures/gemini_white_2048.png new file mode 100644 index 0000000..79018cd Binary files /dev/null and b/data/gemini_capture/captures/gemini_white_2048.png differ diff --git a/data/jimeng_capture/README.md b/data/jimeng_capture/README.md new file mode 100644 index 0000000..1ca6cfa --- /dev/null +++ b/data/jimeng_capture/README.md @@ -0,0 +1,75 @@ +# 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. + +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`). + +## What we learned (verified from the captures, 2026-05-30) + +- Mark: a four-point sparkle icon followed by the "即梦AI" characters, near-white + semi-transparent overlay, bottom-right corner. +- Blend model: **alpha compositing with a pure-white logo** `watermarked = + a*255 + (1-a)*original`, confirmed in sRGB (a linear-light solve made the + black/gray cross-residual much worse, so the compositing is plain sRGB). An + L-pair-solve (independent of the L assumption) lands at ~254.6, confirming white. +- **Alpha is solved from the GRAY capture**, not black: `a = (I - B)/(255 - B)` + with B a per-capture CUBIC background fit over the non-glyph pixels, averaged + over channels, at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (bg ~132) + is the best proxy for real content (the mark sits on bright photo areas, not on + black). This careful build drops the gray self-residual to ~1.3; an earlier + max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated + least-squares solve) left a visible outline -- the mask quality, not the method, + was the limit. +- Geometry (fraction of image WIDTH, at the captured 2048): asset width ~0.211, + height ~0.068, right margin ~0.023, bottom margin ~0.023. The mark scales with + 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). + +## How to capture (image-edit path, most reliable) + +For each solid-color seed: + +1. Open Jimeng image generation, use the image-edit / reference mode, upload the seed. +2. Prompt (Chinese preferred): + `请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容` +3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save. + +The black capture is the key one (white logo on black -> `captured ~= a*255`); the +gray capture refines the alpha at mid-tones; the white capture confirms the logo is +pure white (the mark is nearly invisible on white, as expected). + +## Hygiene + +- 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/` + +``` +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: + +``` +uv run python scripts/visible_alpha_solve.py jimeng # or: all +``` diff --git a/data/jimeng_capture/captures/jimeng_cap_A.png b/data/jimeng_capture/captures/jimeng_cap_A.png new file mode 100644 index 0000000..7ef335d Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_A.png differ diff --git a/data/jimeng_capture/captures/jimeng_cap_B.png b/data/jimeng_capture/captures/jimeng_cap_B.png new file mode 100644 index 0000000..25d1232 Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_B.png differ diff --git a/data/jimeng_capture/captures/jimeng_cap_C.png b/data/jimeng_capture/captures/jimeng_cap_C.png new file mode 100644 index 0000000..7a825ab Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_C.png differ diff --git a/data/qwen_in/README.md b/data/qwen_in/README.md new file mode 100644 index 0000000..6fcc603 --- /dev/null +++ b/data/qwen_in/README.md @@ -0,0 +1,35 @@ +# 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 new file mode 100644 index 0000000..229f943 Binary files /dev/null and b/data/qwen_in/gemini_1_original.png differ diff --git a/data/qwen_in/gemini_3_original.png b/data/qwen_in/gemini_3_original.png new file mode 100644 index 0000000..7ffcbf4 Binary files /dev/null and b/data/qwen_in/gemini_3_original.png differ diff --git a/data/qwen_in/ground_truth.json b/data/qwen_in/ground_truth.json new file mode 100644 index 0000000..4d84aa7 --- /dev/null +++ b/data/qwen_in/ground_truth.json @@ -0,0 +1,5 @@ +{ + "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_1_original.png b/data/qwen_in/openai_1_original.png new file mode 100644 index 0000000..c1325f7 Binary files /dev/null and b/data/qwen_in/openai_1_original.png differ diff --git a/data/qwen_in/openai_2_original.png b/data/qwen_in/openai_2_original.png new file mode 100644 index 0000000..e3b01fb Binary files /dev/null and b/data/qwen_in/openai_2_original.png differ diff --git a/data/samples/chatgpt-1.png b/data/samples/chatgpt-1.png new file mode 100644 index 0000000..2d284cb Binary files /dev/null and b/data/samples/chatgpt-1.png differ diff --git a/data/samples/chatgpt-2.png b/data/samples/chatgpt-2.png new file mode 100644 index 0000000..eb41401 Binary files /dev/null and b/data/samples/chatgpt-2.png differ diff --git a/data/samples/doubao-1.png b/data/samples/doubao-1.png new file mode 100644 index 0000000..8fff015 Binary files /dev/null and b/data/samples/doubao-1.png differ diff --git a/data/samples/firefly-1.png b/data/samples/firefly-1.png new file mode 100644 index 0000000..4a0da9f Binary files /dev/null and b/data/samples/firefly-1.png differ diff --git a/data/samples/flux-1.jpg b/data/samples/flux-1.jpg new file mode 100644 index 0000000..fbba0cd Binary files /dev/null and b/data/samples/flux-1.jpg differ diff --git a/data/samples/flux-1.png b/data/samples/flux-1.png new file mode 100644 index 0000000..c52f113 Binary files /dev/null and b/data/samples/flux-1.png differ diff --git a/data/samples/grok-1.jpg b/data/samples/grok-1.jpg new file mode 100644 index 0000000..04b7524 Binary files /dev/null and b/data/samples/grok-1.jpg differ diff --git a/data/samples/mj-1.png b/data/samples/mj-1.png new file mode 100644 index 0000000..9f0690b Binary files /dev/null and b/data/samples/mj-1.png differ diff --git a/data/samples/mj-2.png b/data/samples/mj-2.png new file mode 100644 index 0000000..b4ff1c6 Binary files /dev/null and b/data/samples/mj-2.png differ diff --git a/data/samples/openai-images-2/README.md b/data/samples/openai-images-2/README.md new file mode 100644 index 0000000..2f492a3 --- /dev/null +++ b/data/samples/openai-images-2/README.md @@ -0,0 +1,41 @@ +# 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 new file mode 100644 index 0000000..17757c9 Binary files /dev/null and b/data/samples/openai-images-2/amur-leopard.png differ diff --git a/data/samsung_capture/README.md b/data/samsung_capture/README.md new file mode 100644 index 0000000..47dcb3c --- /dev/null +++ b/data/samsung_capture/README.md @@ -0,0 +1,66 @@ +# 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. + +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`). + +## What we learned (verified from the captures, 2026-06-05) + +- Mark: a sparkle icon followed by the locale string "Contenuti generati dall'AI" + (Italian), a light low-opacity (peak alpha ~0.38) semi-transparent **white** + overlay, anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right). The string is + locale-specific, so the alpha template is per-locale; this build is the Italian + variant. Other locales need their own captured template. +- Blend model: alpha compositing with a pure-white logo, `watermarked = + a*255 + (1-a)*original`, solved from the GRAY capture (same careful recipe as + Doubao/Jimeng: cubic-background fit, mean over channels, full halo extent, + unblurred). The white capture confirms the logo is white; on white the mark is + white-on-white and not detectable (no contrast), which is fine -- there is nothing + to recover there. +- Geometry (fraction of image WIDTH): asset width ~0.32, height ~0.038, left margin + ~0.011, bottom margin ~0.006. The mark scales with width: a 1086-wide flat capture + and a 2958-wide real photo both measure width_frac ~0.31. +- **Resolution caveat (open quality follow-up):** the flat black/gray/white captures + arrived at the phone's flat-edit size (1086 wide and a landscape 1920 set), while + the real photos are ~3000 wide, so the captured glyph (~334 px) is ~2.7x smaller + than on a real photo (~900 px). The alpha is solved at the capture size and + width-scaled + NCC-aligned per image, which removes the mark cleanly (verified on a + real 2958-wide photo: re-detect 0.79 -> 0.00, no readable text or outline), but a + flat capture taken at the real photo resolution (~3000 wide) would let the alpha be + pixel-sharp instead of upscaled. Not a blocker; a quality upgrade if a full-res + flat capture is provided. + +## Capture protocol (to re-capture or add a locale) + +On a Samsung Galaxy AI device (set the UI language to the target locale): + +1. Run the AI edit (Generative Edit / Sketch to Image) on a solid black image, so + the overlay lands on a flat black background. Download the ORIGINAL output file + (not a screenshot, no crop or re-save). +2. Repeat over solid white and solid gray (those pin the exact glyph color). +3. Ideally run all three flat edits at the same resolution as real photos (~3000 + wide) so the alpha map is pixel-sharp rather than upscaled. +4. Plus 3-5 real outputs with the visible mark over normal content for validation. + +## 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). + +Rebuild the alpha asset with: + +``` +uv run python scripts/visible_alpha_solve.py samsung +``` diff --git a/data/samsung_capture/captures/samsung_black_1.png b/data/samsung_capture/captures/samsung_black_1.png new file mode 100644 index 0000000..2fee517 Binary files /dev/null and b/data/samsung_capture/captures/samsung_black_1.png differ diff --git a/data/samsung_capture/captures/samsung_black_2.png b/data/samsung_capture/captures/samsung_black_2.png new file mode 100644 index 0000000..4d92ccf Binary files /dev/null and b/data/samsung_capture/captures/samsung_black_2.png differ diff --git a/data/samsung_capture/captures/samsung_gray_1.png b/data/samsung_capture/captures/samsung_gray_1.png new file mode 100644 index 0000000..c9a856f Binary files /dev/null and b/data/samsung_capture/captures/samsung_gray_1.png differ diff --git a/data/samsung_capture/captures/samsung_gray_2.png b/data/samsung_capture/captures/samsung_gray_2.png new file mode 100644 index 0000000..55003aa Binary files /dev/null and b/data/samsung_capture/captures/samsung_gray_2.png differ diff --git a/data/samsung_capture/captures/samsung_white_1.png b/data/samsung_capture/captures/samsung_white_1.png new file mode 100644 index 0000000..af041de Binary files /dev/null and b/data/samsung_capture/captures/samsung_white_1.png differ diff --git a/data/samsung_capture/captures/samsung_white_2.png b/data/samsung_capture/captures/samsung_white_2.png new file mode 100644 index 0000000..d3e5eba Binary files /dev/null and b/data/samsung_capture/captures/samsung_white_2.png differ diff --git a/data/synthid_corpus/README.md b/data/synthid_corpus/README.md new file mode 100644 index 0000000..71a11ed --- /dev/null +++ b/data/synthid_corpus/README.md @@ -0,0 +1,152 @@ +# SynthID reference corpus + +A locally-collected, labeled image corpus for SynthID work. Two downstream 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 + image into a negative one. + +There is no reliable local detector of the SynthID pixel watermark (Google's +decoder is proprietary). The ground-truth label therefore comes from an +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) + 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) +``` + +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). + +## Verification levels (`verified_via`) + +Ground-truth quality, strongest first: + +- `gemini-app` — checked via the Gemini app "Verify with SynthID" feature. Gold standard for the pixel watermark (Google models). +- `openai-verify` — checked via openai.com/verify (gold standard for OpenAI ChatGPT/Codex/API images). +- `synthid-portal` — checked via Google's SynthID Detector portal. +- `c2pa-metadata` — issuer-only proxy (Google/OpenAI C2PA manifest present). Weaker: the C2PA can be stripped while the pixel watermark remains. +- `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. + +## 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. + +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`: + +```bash +uv run python scripts/synthid_corpus.py ingest path/to/*.png \ + --label pos --source "Gemini app" --model gemini-3-pro \ + --verified-via gemini-app --notes "1024x1024 batch" + +uv run python scripts/synthid_corpus.py status # counts by label / resolution / verification +``` + +## Autonomous collection via Chrome MCP + +Generation can be driven through the browser (the account must be logged in): + +- **Gemini** (`gemini.google.com`): type `Create an image: `, wait, hover the + result, click the download icon (top-right). Single, reliable click. Outputs + carry Google C2PA + SynthID. Occasionally the composer stalls in a + "generating" state -> start a New chat to reset. +- **ChatGPT** (`chatgpt.com`): the UI download is flaky (the fullscreen viewer + races and can grab the previous image; the share-modal path works but is + multi-step). Reliable path is an in-page fetch of the rendered image, which + preserves the original bytes (C2PA intact, unlike a canvas re-encode): + + ```js + // run in the ChatGPT tab via the browser MCP javascript tool + (async () => { + const imgs = [...document.querySelectorAll('img')].filter(i => i.naturalWidth >= 400); + const img = imgs[imgs.length - 1]; // newest large image + const b = await (await fetch(img.currentSrc || img.src)).blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(b); a.download = 'dl.png'; + document.body.appendChild(a); a.click(); a.remove(); + return 'size=' + b.size; // do NOT return the src (privacy guard blocks query strings) + })() + ``` + + Gotcha: confirm the returned `size` differs from the previous image before + ingesting -- if the new image has not finished rendering, the script grabs the + prior one (the corpus dedups by sha256, but the notes would mislabel it). + ChatGPT also shows an A/B "which is better?" picker; click Skip first. + +**Originals, not previews.** Some platforms render a low-res preview in the chat +(Grok serves a ~20KB 1024px JPEG/PNG; the in-page `` fetch grabs *that*, not +the original). Previews are re-encoded and **strip metadata**, so a "clean" +preview is not proof the original is clean. Always pull the original via the +platform's native Download / lightbox button and sanity-check the file size (a +20KB "1024x1024" is a preview). ChatGPT's in-chat `` *is* the full-res +oaiusercontent original, so fetch+blob is fine there; Grok needs the lightbox +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 / +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 +sparkle; the SynthID *pixel* itself has no local detector (oracle only). + +| Platform | C2PA issuer | SynthID pixel | IPTC "Made with AI" | Visible sparkle | imwatermark | Corpus label | +|---|---|---|---|---|---|---| +| Gemini app | Google | yes | - | yes | - | pos | +| ChatGPT / gpt-image | OpenAI | yes | - | - | - | pos | +| Microsoft Designer | OpenAI + Microsoft | yes (via OpenAI) | - | - | - | pos | +| Bing Image Creator | Microsoft (MAI-Image) | no | - | - | - | pos (C2PA "Microsoft", not OpenAI) | +| Google AI Studio (Nano Banana) | **none** | yes (oracle-confirmed) | - | yes | - | pos (metadata blind spot) | +| Stability AI (Brand Studio) | Stability AI Ltd | no | - | - | no | pos (C2PA only) | +| Ideogram | none | no | - | - | no | pos (EXIF `Make="Ideogram AI"` only) | +| Meta AI | none | no | **yes** | - | - | neg (for SynthID) | +| Leonardo.ai | none | no | no | - | no | neg | +| Recraft | none (export strips) | no | no | - | no | neg (re-encoded export, no signal) | +| Krea (FLUX 2 host) | none | no | no | - | no | neg (host omits the imwatermark encoder) | +| Grok (xAI) | none (non-adopter) | no | no | - | no | neg (captured: clean low-res preview) | + +Key takeaways: +- The same model differs by *surface*: Gemini app wraps C2PA, AI Studio (API/playground) emits none -- only the pixel + sparkle survive. +- Microsoft Designer's DALL-E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL-E). +- Meta uses the IPTC `digitalSourceType` marker, not C2PA or SynthID. +- The open imwatermark fires only on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL) -- not from re-hosts (Krea, Stability hosted SDXL) or re-encoded design exports (Recraft, Canva). Ideogram's only signal is the EXIF `Make` tag. +- Bing and Grok web UIs are uncooperative for autonomous capture (no document_idle for screenshots; blob downloads intermittently no-op; low-res in-chat previews). Use their native download button manually if a full-res sample is needed. 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 new file mode 100644 index 0000000..e52c76b Binary files /dev/null and b/data/synthid_corpus/images/cleaned/2e4ce41c-openai_1_clean_s005.png 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 new file mode 100644 index 0000000..c6f183a Binary files /dev/null and b/data/synthid_corpus/images/cleaned/356196dd-gemini_3_clean_s015_max1536.png 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 new file mode 100644 index 0000000..4306c2b Binary files /dev/null and b/data/synthid_corpus/images/cleaned/37b34274-openai_2_clean_s010.png 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 new file mode 100644 index 0000000..f95254a Binary files /dev/null and b/data/synthid_corpus/images/cleaned/4aa5f61c-gemini_2_clean_s015_max1536.png 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 new file mode 100644 index 0000000..75a9030 Binary files /dev/null and b/data/synthid_corpus/images/cleaned/9e4160bb-gemini_4_clean_s015_max1536.png 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 new file mode 100644 index 0000000..a054fe0 Binary files /dev/null and b/data/synthid_corpus/images/cleaned/f6dd47a5-4ef377bd-gpt-image-2-cleaned.png 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 new file mode 100644 index 0000000..a7c7d12 Binary files /dev/null and b/data/synthid_corpus/images/cleaned/f7c52cdf-openai_3_clean_s005.png 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 new file mode 100644 index 0000000..45f3136 Binary files /dev/null and b/data/synthid_corpus/images/cleaned/f99bd9a5-gemini_1_clean_s015_max1536.png differ diff --git a/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC b/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC new file mode 100644 index 0000000..927c126 Binary files /dev/null and b/data/synthid_corpus/images/neg/06b04d8f-IMG_1474.HEIC differ diff --git a/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC b/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC new file mode 100644 index 0000000..39b1b63 Binary files /dev/null and b/data/synthid_corpus/images/neg/0bb4c176-IMG_1790.HEIC differ diff --git a/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC b/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC new file mode 100644 index 0000000..56beaaa Binary files /dev/null and b/data/synthid_corpus/images/neg/1fa5f77f-IMG_0786.HEIC differ diff --git a/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC b/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC new file mode 100644 index 0000000..2898b12 Binary files /dev/null and b/data/synthid_corpus/images/neg/2b148db2-IMG_1832.HEIC differ diff --git a/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC b/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC new file mode 100644 index 0000000..d46484c Binary files /dev/null and b/data/synthid_corpus/images/neg/2d02fed0-IMG_1520.HEIC differ diff --git a/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC b/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC new file mode 100644 index 0000000..a8c38f2 Binary files /dev/null and b/data/synthid_corpus/images/neg/2ea228ed-IMG_1791.HEIC differ diff --git a/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC b/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC new file mode 100644 index 0000000..0903ae4 Binary files /dev/null and b/data/synthid_corpus/images/neg/3fc0c425-IMG_1450.HEIC differ diff --git a/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC b/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC new file mode 100644 index 0000000..c4dd62a Binary files /dev/null and b/data/synthid_corpus/images/neg/3fc4c831-IMG_2566.HEIC differ diff --git a/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC b/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC new file mode 100644 index 0000000..1039d28 Binary files /dev/null and b/data/synthid_corpus/images/neg/4c795238-IMG_3034.HEIC differ diff --git a/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC b/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC new file mode 100644 index 0000000..54cb5ba Binary files /dev/null and b/data/synthid_corpus/images/neg/4ead7918-IMG_1300.HEIC differ diff --git a/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC b/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC new file mode 100644 index 0000000..951430a Binary files /dev/null and b/data/synthid_corpus/images/neg/5abfaccb-IMG_3018.HEIC differ diff --git a/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC b/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC new file mode 100644 index 0000000..cd529c9 Binary files /dev/null and b/data/synthid_corpus/images/neg/5fe59521-IMG_1496.HEIC differ diff --git a/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC b/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC new file mode 100644 index 0000000..f7cb2cf Binary files /dev/null and b/data/synthid_corpus/images/neg/5fed1923-IMG_0272.HEIC differ diff --git a/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC b/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC new file mode 100644 index 0000000..b6c47ea Binary files /dev/null and b/data/synthid_corpus/images/neg/8f170c06-IMG_1078.HEIC differ diff --git a/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC b/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC new file mode 100644 index 0000000..8b44209 Binary files /dev/null and b/data/synthid_corpus/images/neg/8fdb574a-IMG_3557.HEIC 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 new file mode 100644 index 0000000..c1325f7 Binary files /dev/null and b/data/synthid_corpus/images/pos/05b836ec-openai_1_original.png differ diff --git a/data/synthid_corpus/images/pos/1f81827c-Designer.png b/data/synthid_corpus/images/pos/1f81827c-Designer.png new file mode 100644 index 0000000..c800a3e Binary files /dev/null and b/data/synthid_corpus/images/pos/1f81827c-Designer.png differ diff --git a/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png b/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png new file mode 100644 index 0000000..b69afed Binary files /dev/null and b/data/synthid_corpus/images/pos/28f32334-chatgpt_fisherman.png 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 new file mode 100644 index 0000000..59365a1 Binary files /dev/null and b/data/synthid_corpus/images/pos/28ff8732-openai_3_original.png differ diff --git a/data/synthid_corpus/images/pos/2c33e75a-gemini_4_original.png b/data/synthid_corpus/images/pos/2c33e75a-gemini_4_original.png new file mode 100644 index 0000000..c79bc40 Binary files /dev/null and b/data/synthid_corpus/images/pos/2c33e75a-gemini_4_original.png 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 new file mode 100644 index 0000000..7ffcbf4 Binary files /dev/null and b/data/synthid_corpus/images/pos/45d79a68-gemini_3_original.png 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 new file mode 100644 index 0000000..229f943 Binary files /dev/null and b/data/synthid_corpus/images/pos/4affd7f2-gemini_1_original.png 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 new file mode 100644 index 0000000..d10d79e Binary files /dev/null and b/data/synthid_corpus/images/pos/4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png 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 new file mode 100644 index 0000000..e3b01fb Binary files /dev/null and b/data/synthid_corpus/images/pos/794e023e-openai_2_original.png 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 new file mode 100644 index 0000000..d1a4f95 Binary files /dev/null and b/data/synthid_corpus/images/pos/7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png differ diff --git a/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png b/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png new file mode 100644 index 0000000..976739f Binary files /dev/null and b/data/synthid_corpus/images/pos/88e61a38-chatgpt_tokyo.png differ diff --git a/data/synthid_corpus/images/pos/8c1a6fb0-gemini_2_original.png b/data/synthid_corpus/images/pos/8c1a6fb0-gemini_2_original.png new file mode 100644 index 0000000..3526c8c Binary files /dev/null and b/data/synthid_corpus/images/pos/8c1a6fb0-gemini_2_original.png differ diff --git a/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png b/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png new file mode 100644 index 0000000..205945f Binary files /dev/null and b/data/synthid_corpus/images/pos/c8697342-aistudio_lake.png 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 new file mode 100644 index 0000000..04e7337 Binary files /dev/null and b/data/synthid_corpus/images/pos/d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png 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 new file mode 100644 index 0000000..459d9f3 Binary files /dev/null and b/data/synthid_corpus/images/pos/d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png 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 new file mode 100644 index 0000000..0af81f0 Binary files /dev/null and b/data/synthid_corpus/images/pos/fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png differ diff --git a/data/synthid_corpus/manifest.csv b/data/synthid_corpus/manifest.csv new file mode 100644 index 0000000..14074c8 --- /dev/null +++ b/data/synthid_corpus/manifest.csv @@ -0,0 +1,40 @@ +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) diff --git a/demo_banana_after.png b/demo_banana_after.png new file mode 100644 index 0000000..771c3ec Binary files /dev/null and b/demo_banana_after.png differ diff --git a/demo_banana_before.png b/demo_banana_before.png new file mode 100644 index 0000000..d58443c Binary files /dev/null and b/demo_banana_before.png differ diff --git a/docs/controlnet-removal-pipeline-research.md b/docs/controlnet-removal-pipeline-research.md new file mode 100644 index 0000000..53ca02d --- /dev/null +++ b/docs/controlnet-removal-pipeline-research.md @@ -0,0 +1,644 @@ +# ControlNet-as-removal-pipeline research: can structure-conditioned regeneration scrub SynthID and keep text? + +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 +think ControlNet could be added to preserve and still remove the watermark?" Clarified scope: +Jacob means **replacing the removal pipeline itself** with a ControlNet-conditioned +regeneration (structure held by the control signal), NOT a separate text-protection add-on. + +A deep-research workflow run was attempted first (`wf_3244411d-ffd`) and failed at the harness +level (97 agents completed without emitting StructuredOutput; ~4.3 M tokens, no report). This +note is the hand-run replacement. + +## The question, precisely + +Can a single full-image ControlNet-conditioned diffusion pass **replace** plain SDXL base 1.0 +img2img as the watermark remover, so that one structure-guided regeneration removes the +invisible robust pixel watermark (Google SynthID) **everywhere** while keeping fine detail and +small/CJK **text** legible across the whole image? The hard constraint is unchanged from +`text-protection-research.md`: the watermark must be scrubbed everywhere including inside text, +so any path that freezes or composites original text pixels is disqualified. + +## Executive summary + +The idea is **already academically validated as a watermark remover and is literally what we +already ship** — CtrlRegen (ICLR 2025) is a canny-ControlNet + DINOv2-semantic pipeline that +regenerates from clean noise. But the make-or-break gap is exact: **none of the +watermark-removal papers validate TEXT or fine-detail preservation at all** — CtrlRegen reports +only FID/PSNR/quality-model scores and explicitly contains no text, fine-detail, or +hallucination analysis. Our shipped ctrlregen's empirical failure ("destroys real content, +hallucinates micro-text in smooth regions") is precisely this unstudied failure mode of the +published method, most likely driven by our **512 px tiling** (text occupies too few pixels per +tile to regenerate legibly; edge-free smooth regions get DINOv2-semantic hallucination). The +constructive path is NOT to keep fixing the SD1.5 CtrlRegen, but to port the structure-control +idea onto an **SDXL-native** ControlNet (xinsir tile-sdxl / ControlNet-Union-SDXL) as a control +add to our existing SDXL base 1.0 img2img, run it at **1024+, not 512 tiles**, and empirically +sweep the (denoise strength x conditioning scale x resolution) cube against the SynthID oracle +AND a text-legibility check. The central tension may be fundamental and must be measured: the +conditioning strong enough to keep text legible may suppress regeneration enough to let SynthID +survive; the regeneration strong enough to scrub may deform text regardless of edges. + +## Oracle validation 2026-06-04 — measured answer (AUTHORITATIVE; supersedes pre-oracle "scrubs SynthID" claims below) + +The central tension the summary predicted is now MEASURED against the live oracles +(OpenAI `openai.com/verify` for OpenAI content; the Gemini app "Verify with SynthID" +for Google content — each detects only its own vendor's payload). **Verdict: at the +shipped low vendor-adaptive strength, controlnet is NOT a drop-in SynthID remover.** +It preserves structure so well that the watermark survives on exactly the photoreal +content it protects. Controlnet is the text/structure PRESERVATION pipeline; removal +is set by STRENGTH, separately calibrated, not by the pipeline choice. + +This section is the single consolidated reference for the controlnet pipeline's +removal behavior. (Mirrored briefly in `docs/synthid.md` §5.5 and the CLAUDE.md +controlnet / face_restore bullets, which point here.) + +### What we measured (real gpt-image + Gemini originals) + +**1. Content × pipeline — neither pipeline clears all content at low strength.** +OpenAI set, strength = vendor-adaptive (0.10 OpenAI / 0.15 no-C2PA), `--max-resolution 1536`: + +| content | controlnet (`--auto`) | plain `default` | +|---|---|---| +| flat text card | clean | clean | +| flat graphic (logo/poster, large flat fills) | clean | **SynthID detected** | +| photoreal (9-face grid) | **SynthID detected** | clean | +| photoreal (bracelet product photo) | **SynthID detected** | clean | + +Mechanism: controlnet's dense edge map keeps the regeneration very close to the original +on photoreal, so the SynthID-destroying perturbation never happens; but it freely +repaints large flat fills. Plain img2img at low strength perturbs photoreal texture +enough yet barely touches flat fills. So the survivors FLIP by content type — pipeline +choice alone does not guarantee removal. + +**2. Seed non-determinism near threshold.** img2img uses a random seed unless `--seed` +is passed, and there is no local SynthID detector to self-verify. The bracelet survived +controlnet @0.15 in one run and CLEARED @0.15 in another (same pipeline+strength+res). +So a single clean run does NOT establish a strength as safe — characterizing a reliable +floor needs a seed-repeatability sweep (N runs, varied seed), not one pass. + +**3. Per-vendor controlnet strength ladder (photoreal, oracle):** +- **OpenAI:** 0.10 detected → 0.15 borderline/non-deterministic → 0.20 cleared (with margin). +- **Gemini** (harder vendor; default 0.15 vs OpenAI 0.10): most cleared at 0.15–0.25. +- **Resolution is NOT the lever:** SynthID is robust to downscaling, and the study's + trend says LOWER processing res needs LESS strength, so 1024 was never the wall. A + Gemini face that resisted 0.15/0.20/0.25 was blocked by face-restore (#4), not strength. + +**4. `--restore-faces` RE-INTRODUCES SynthID (was the "stubborn face" mystery).** A +Gemini face image stayed SynthID-detected through controlnet 0.15/0.20/0.25 WITH restore, +but CLEARED at 0.20 with `--no-restore-faces` (clean single-variable A/B). GFPGAN runs on +the **ORIGINAL watermarked** face at fidelity weight 0.5, blends ~half its pixels with the +StyleGAN2 prior, and composites that back OVER the diffusion-cleaned face → the watermark +returns in the face region. Content-dependent (smaller faces can clear with restore). So +raising strength cannot fix it — the face is re-pasted from the original after diffusion. +This also corrects the prior "GFPGAN scrubs SynthID / oracle-confirmed clean" claim (it was +checked on one lucky image). + +### Certified controlnet strength floors (Modal GPU sweep + oracle, 2026-06-04) + +Run via the isolated `raiw-controlnet-cert` Modal app (`raiw-app/modal_cert.py`): +controlnet, `restore_faces` OFF (it re-introduces SynthID), `--max-resolution 1536`, +each image checked on ITS OWN vendor oracle (OpenAI -> openai.com/verify, Gemini -> the +Gemini app; the two payloads are vendor-specific and never cross-checked): + +| vendor | **floor** | evidence (oracle, restore OFF, <= 1536) | +|---|---|---| +| **OpenAI** | **0.20** | 2 photoreal images (9-face grid + bracelet) x seed {1,2,3} = **6/6 clean**; the bracelet that FLIPPED at 0.15 is seed-robust at 0.20 | +| **Gemini** | **0.30** | hardest face (gemini_3): 0.20 detected -> 0.30 clean on **2/2 seeds**; Gemini is the harder vendor (default 0.15 vs OpenAI 0.10) AND resolution-sensitive | + +- **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 + use 0.30, or run a native-resolution Gemini cert and expect a higher floor (~0.35+). + +### Recommendations for a removal pipeline (raiw.cc) + +- **Treat controlnet as PRESERVATION, not removal.** Choose it for text/structure content, + `default` for photoreal; removal efficacy comes from STRENGTH in both. +- **Give controlnet a higher, per-vendor strength than `default`** (today both share + `resolve_strength` 0.10/0.15, tuned for plain img2img). **Certified controlnet floors: + OpenAI 0.20, Gemini 0.30** (see table above) — add a controlnet-specific per-vendor + schedule to `resolve_strength` rather than reusing the `default` ladder. +- **Fix the seed in prod.** The non-determinism is purely `seed=None` (random); a fixed + `--seed` makes every run reproduce the certified-clean result, so you ship a + deterministic, re-certifiable config (and the seed sweep collapses to one config). +- **`--restore-faces` is OFF in prod and stays opt-in.** Two methods ship + (`instantid` default, `photomaker`), both NON-COMMERCIAL. They REGENERATE the face + from an ArcFace embedding via SDXL diffusion, making the output face look more + AI-generated than the cleaned image (gloss, symmetric pores, SDXL "clean skin" + aesthetic). For production face preservation the cleaned image from controlnet 0.20 + is the LEAST-AI state we can reach — any restore on top trades original-look for + embedding-driven regeneration. Empirical sweep summary: GFPGAN-on-cleaned polished + without identity recovery; PhotoMaker-V2 produced a different person; InstantID + txt2img produced studio-portrait patchwork on group photos; InstantID + img2img-on-cleaned with three parameter settings integrated scene context cleanly + but never recovered original identity precisely — every setting traded one problem + for another. See `docs/synthid-robust-identity-research-2026-06-08.md` + "Empirical follow-up" for the full sweep. +- **No local SynthID detector exists** → the service can't self-verify; bake in strength + margin and periodic oracle spot-checks. +- **Lesson:** visual-quality / face-identity recovery does NOT prove removal — only the + oracle does, across MULTIPLE content types; never conclude from a partial result (the + photoreal-only data first read as "controlnet shields, default removes"; the flat-graphic + result reversed it; the face mystery was restore, not strength). + +## Findings (with confidence and sources) + +### Finding 1 — confidence: high + +**Claim.** "ControlNet as the removal pipeline" is exactly CtrlRegen (ICLR 2025), and our +shipped `ctrlregen` profile is a faithful implementation of it. Its **spatial control is canny +edges** extracted from the watermarked image; its **semantic control is DINOv2-giant** via a +trainable projection + decoupled cross-attention. Clean-noise (full-strength) regeneration +scrubs the watermark from both pixel and latent space while the two control nets hold structure. + +**Evidence.** CtrlRegen: spatial control "conditioned on Canny edge images extracted from the +watermarked image," integrated into the U-Net decoder blocks via a ControlNet structure; +semantic control on "DINOv2-giant" embeddings. Removal is strong: TPR@1%FPR driven from 1.00 -> +0.01 (StegaStamp) and 0.99 -> 0.12 (TreeRing). This matches our `ctrlregen/engine.py` exactly +(canny detector + `facebook/dinov2-giant` + spatial ControlNet from `yepengliu/ctrlregen`). + +**Sources.** https://arxiv.org/html/2410.05470v1 · https://github.com/yepengliu/CtrlRegen · +https://openreview.net/forum?id=mDKxlfraAn + +### Finding 2 — confidence: high + +**Claim.** Regeneration provably removes any bounded-perturbation pixel watermark **given enough +noise** — the operative constraint is the amount of regeneration, which is the same knob that +trades against fidelity. + +**Evidence.** Zhao et al., "Invisible Image Watermarks Are Provably Removable Using Generative +AI" (NeurIPS 2024): a noise-then-reconstruct regeneration attack "guarantees the removal of any +invisible watermark" that perturbs the image within a bounded L2 distance. The guarantee is a +function of injected noise magnitude — low noise preserves detail but leaves the watermark; high +noise scrubs but discards original signal. This is the knob ControlNet conditioning is meant to +make survivable (push regeneration high while the control signal holds composition). + +**Sources.** https://arxiv.org/abs/2306.01953 · https://github.com/XuandongZhao/WatermarkAttacker + +### Finding 3 — confidence: high + +**Claim.** The make-or-break gap: **no watermark-removal paper validates text or fine-detail +preservation.** CtrlRegen's "high perceptual quality" is FID/PSNR/quality-model only and +explicitly omits text, fine-detail, and hallucination analysis. So the literature does NOT +support the specific claim Jacob needs (text survives), it is simply unmeasured. + +**Evidence.** CtrlRegen reports CLIP-FID, PSNR, Q-Align, LIQE; the fetched analysis confirms +"the paper contains no discussion of text preservation, fine-detail retention, or hallucination +artifacts," and "explicitly avoids discussing failure modes." Pixel metrics like PSNR are +acknowledged not to reflect perception, and text legibility is a different axis than FID. + +**Sources.** https://arxiv.org/html/2410.05470v1 + +### Finding 4 — confidence: medium-high + +**Claim.** Resolution is the prime suspect for our shipped ctrlregen's content destruction. We +tile to **512 px** and run full clean-noise per tile; at 512 px text occupies too few pixels per +tile to regenerate legibly, and smooth edge-free regions (no canny signal) are filled by the +DINOv2 semantic prior, which hallucinates texture/micro-text. The paper omits resolution +entirely, so this is an implementation regime it never characterized. + +**Evidence.** Our `ctrlregen/engine.py`: `PROCESS_SIZE = 512`, `TILE_SIZE = 512`, full strength +on each tile. This mirrors the `_run_region_hires` insight (text needs MORE pixels under +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 +confirmed against https://arxiv.org/html/2410.05470v1 + +### Finding 5 — confidence: high + +**Claim.** SDXL-native ControlNets exist, so the removal-pipeline upgrade need NOT be the SD1.5 +re-architecture our current ctrlregen is. xinsir `controlnet-tile-sdxl-1.0` and +`controlnet-union-sdxl-1.0` (ControlNet++) run on SDXL base 1.0. The tile model has a `tile_var` +image-variation mode purpose-built to regenerate detail while preserving structure, at +`controlnet_conditioning_scale = 1.0`, optimal 1024 px. This is a drop-in control add to our +existing SDXL img2img. + +**Evidence.** xinsir tile-sdxl model card: use cases = deblur/detail-repaint, **image variation +(preserving structure)**, super-resolution; `controlnet_conditioning_scale = 1.0`, ~30 steps, +optimal 1024x1024, works with `madebyollin/sdxl-vae-fp16-fix` (the same VAE our fp16 path +already swaps in). ControlNet-Union-SDXL / ControlNet++ merges 10+ control types (canny, HED, +tile, depth, lineart) into one SDXL model. + +**Sources.** https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 · +https://huggingface.co/xinsir/controlnet-union-sdxl-1.0 · https://github.com/xinsir6/ControlNetPlus + +### Finding 6 — confidence: high + +**Claim.** The community tile-ControlNet upscale workflow runs at **LOW denoise (0.3-0.4)** — +the wrong regime for watermark removal. It preserves detail precisely by regenerating little, so +a naive tile-upscale preserves text AND preserves the watermark. The open empirical question is +whether at `conditioning_scale ~1.0` you can push denoise high enough to scrub SynthID while the +tile conditioning still holds text — the exact cell to test. + +**Evidence.** Stable-Diffusion-Art ControlNet-tile upscale: denoise "typically 0.3, max ~0.4 to +avoid artifacts"; some users push 0.6 with ControlNet strength 0.5. Our own data: SynthID +survives below the removal-strength threshold (current Gemini needs notably higher denoise than +the tile-upscale regime). So the detail-preserving regime and the watermark-scrubbing regime are +on opposite ends of the denoise axis; ControlNet conditioning is the bet that they can meet. + +**Sources.** https://stable-diffusion-art.com/controlnet-upscale/ · +internal (`docs/synthid.md` strength data) + +### Finding 7 — confidence: high + +**Claim.** Forensic-stealth caveat: diffusion-based regeneration is among the MOST detectable +removal families. Even a ControlNet-regeneration that fools the SynthID oracle leaves forensic +traces flagging the output as "removal-processed" at >98% TPR@1%FPR. This bounds the claim (do +not over-promise "indistinguishable from an original") but does not block the use case — the +SynthID oracle still reads negative. + +**Evidence.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark +Removal" (arXiv:2605.09203, Goonatilake & Ateniese, GMU): across six removal attacks including +diffusion-based regeneration, independent forensic detectors separate removal-processed from +clean content at >98% TPR under a 1% FPR budget. + +**Sources.** https://arxiv.org/html/2605.09203v1 + +### Finding 8 — confidence: low (watch, do not build on yet) + +**Claim.** Partial/semantic-guided regeneration is an active sub-direction that explicitly targets +the removal-vs-fidelity tradeoff, but the specific fidelity-on-text claims were not verifiable +from the source in this pass. + +**Evidence.** "Removing Watermarks with Partial Regeneration using Semantic Information" +(arXiv:2505.08234) proposes focusing regeneration on watermarked regions with semantic (VLM) +conditioning to preserve untouched areas; the PDF body did not render cleanly enough to confirm +its quantitative text/detail results. Treat as a pointer, not evidence. + +**Sources.** https://arxiv.org/pdf/2505.08234 + +## Recommendation / decision + +**ControlNet-as-removal-pipeline is worth prototyping — but not by fixing the SD1.5 ctrlregen.** +Port the structure-control idea onto an SDXL-native ControlNet as a control add to the existing +SDXL base 1.0 img2img, run it at full resolution (1024+, NOT 512 tiles), and treat the +text-vs-scrub tension as an empirical question to measure, not assume. + +**Prototype (runs locally on 32 GB MPS — no dedicated GPU required):** + +Compute is NOT the bottleneck. On a 32 GB Apple-silicon machine (M5 here) native SDXL already +runs entirely on MPS with no CPU fallback (~155 s at 1122x1402, verified — see `synthid.md` / +CLAUDE.md). The prototype runs at **1024** (fewer pixels than that) with SDXL base + an SDXL +ControlNet + activations in **fp32** (MPS fp16 decodes to all-black NaN — issue #29 — confirmed +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 +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. + +Runner: **`scripts/controlnet_sweep.py`** (built 2026-06-02) implements exactly this sweep — +SDXL base 1.0 + an SDXL-native ControlNet img2img, one output per (control x strength x scale) +cell, plus a `sweep_index.csv` with empty `synthid_oracle` / `text_legible` columns to fill by +hand. It uses the dedicated single-type xinsir models (`controlnet-canny-sdxl-1.0`, +`controlnet-tile-sdxl-1.0`) rather than the Union model to keep the diffusers API path robust. + + uv run python scripts/controlnet_sweep.py watermarked.png -o sweep_out + +1. SDXL base 1.0 img2img + `xinsir/controlnet-canny-sdxl-1.0` / `controlnet-tile-sdxl-1.0` + (sweep both `tile` and `canny` control), full image at 1024, `sdxl-vae-fp16-fix`. +2. Sweep the cube on fresh Gemini + gpt-image inputs that contain small/CJK text: + - denoise strength {0.15, 0.3, 0.5, 0.7, 1.0} + - `controlnet_conditioning_scale` {0.5, 0.8, 1.0} + - control type {tile, canny} +3. Per cell, measure BOTH axes: + - **removal**: Gemini app "Verify with SynthID" oracle (the only valid SynthID oracle; for + gpt-image also openai.com/verify for provenance) — must read clean. + - **text**: OCR round-trip / visual legibility of the small text. + - secondary: SSIM/FID vs original for global fidelity. +4. Find the Pareto cell where the oracle is clean AND text stays legible. + +**The honest fork the prototype resolves:** +- If such a cell exists -> the answer to Jacob is YES, ship an SDXL-native ControlNet removal + profile (replacing the SD1.5 ctrlregen) tuned to that cell. +- If no cell clears both (the tension is fundamental: scrub-strength always deforms text, or + text-preserving conditioning always spares the watermark) -> the canny/tile-ControlNet middle + path is dead for text, and the standing answer reverts to `text-protection-research.md`: a full + **glyph-conditioned re-render** (EasyText / TextSR on a FLUX-DiT base) is required, which is a + base-model migration, not a control add. + +**Do not:** keep tuning the 512 px SD1.5 ctrlregen for text (wrong resolution, wrong base model); +run tile-ControlNet at the community 0.3-0.4 upscale denoise and expect watermark removal (that +regime preserves the watermark); over-claim forensic invisibility (Finding 7). + +## Prototype run 1 — 2026-06-02 (text axis measured; watermark axis pending the oracle) + +First sweep on a real, SynthID-positive, text-dense input: the corpus tokyo-street-night +gpt-image (`88e61a38-chatgpt_tokyo.png`, 1023x1537 -> 680x1024, dense small CJK + Latin neon +signage; SynthID + C2PA confirmed, so its valid oracle is openai.com/verify). Grid: control +{canny, tile} x strength {0.3, 0.5, 0.7, 1.0} x `conditioning_scale` 1.0, fp32 on MPS. Outputs + +`sweep_index.csv` (text verdicts filled by visual inspection; `synthid_oracle` left for the +manual run) are under `/tmp/cnsweep/` (not committed — derived regenerations of corpus content). + +**Measured — PSNR vs input (proxy for how much was regenerated):** +- canny: 0.3 -> 16.91, 0.5 -> 15.91, 0.7 -> 14.82, 1.0 -> 13.22 (monotonic drop = progressively + more regeneration as strength rises; canny only pins edges, so flat regions change). +- tile: 0.3 -> 17.89, 0.5 -> 17.84, 0.7 -> 17.83, 1.0 -> 17.74 (**flat and high — near-identity + even at strength 1.0**; tile@scale1.0 pins the whole image to the input and barely regenerates). + +**Measured — text legibility (visual, focused on SMALL text; large high-contrast glyphs survive +everything because canny/tile hold their edges):** +- canny: legible at 0.3, softening at 0.5 (partial), garbling at 0.7, hallucinated pseudo-glyphs + at 1.0 ("NEC" -> "NWENES"). Same plain-img2img small-text deformation, only big text protected. +- tile: near-identity through 0.7, only tiny alterations at 1.0 — small text preserved throughout. + +**Reading (the make-or-break tension, now visible in the data):** +- **tile@scale1.0 does not actually regenerate** (flat PSNR), so it preserves all text but almost + certainly leaves the watermark intact — it is a near-identity pass, exactly the community + "tile-upscale preserves detail by not regenerating" regime (Finding 6), confirmed. +- **canny@scale1.0 regenerates progressively** (PSNR drops) and so could scrub — but small text + breaks at exactly the strength where scrubbing would start to bite. canny saves big edges, not + sub-stroke small text. +- Net on the text axis: neither cell at scale 1.0 cleanly gives "high regeneration + legible small + text." This is the literature prior (Findings 3, 6) reproduced empirically. Lowering + `conditioning_scale` to force small-text regeneration is the same tradeoff knob, not an escape. + +**Still pending (the decisive half, cannot be done locally):** run the 8 cells through the SynthID +oracle and fill `synthid_oracle`. The most informative cells: canny 1.0 (text dead — does it at +least scrub? if not, the canny path is dead outright), canny 0.5 (text partial — does it scrub?), +tile 1.0 (text perfect — predicted to still read present). If no cell is `oracle=clean` AND +`text=yes`, the fork resolves to the glyph-re-render path (`text-protection-research.md`). + +**Incidental bug caught:** the first run used fp16 on MPS (the script's original default) and +produced **all-black** outputs across every cell (2 KB PNGs, PSNR 9.22 flat) — the issue #29 +fp16-VAE-NaN failure, and the fp16-fix VAE did not save it on MPS. Fixed `scripts/controlnet_sweep.py` +to default fp32 on mps/cpu (fp16 only on cuda/xpu), matching the production pipeline. + +## Tuning ControlNet for text preservation across image types (research 2026-06-03) + +Goal: how to configure the canny-ControlNet path to best preserve text (and faces) on diverse +images. Primary sources: diffusers ControlNet doc, the ControlNet paper (arXiv:2302.05543), +xinsir model cards, practitioner guides. The **critical reframe**: almost all community ControlNet +advice optimizes a txt2img *generation* tradeoff (control vs creative freedom). OUR context is +img2img *watermark removal*, where the objective is the opposite -- maximum faithful preservation +while regenerating just enough to scrub. So several common recommendations INVERT here. + +**Removal is `strength`; everything below is preservation and does not change removal efficacy** +(only the watermark-shielding risk -- see the caveat). Set `strength` by the oracle/vendor need; +tune these to keep text/faces intact at that strength. + +Knobs, ranked by impact for text: + +1. **Canny edge density (the per-image lever, currently hardcoded `_CANNY_LOW=100`/`_CANNY_HIGH=200`).** + Lower thresholds capture more/finer edges; higher thresholds keep only major outlines (diffusers + doc + practitioner guides; ControlNet paper uses 100/200 as the default). Small-text strokes and + fine facial features fall below the default 100/200 and are missed. **For dense small text + (infographics, signage) lower the thresholds (~50/120, even 30/100 for facial likeness per + practitioner tests); for high-contrast large text 100/200 already suffices.** Denser canny is + still a BINARY thresholded edge map, so it does not carry the low-amplitude SynthID pixel pattern + -- it passes more shape, not the watermark (still oracle-verify). This is the single highest-value + unexplored lever and should become a CLI knob. + +2. **`controlnet_conditioning_scale` -> keep at 1.0 (max structure hold).** Community defaults to 0.5 + for creative balance; we want maximum preservation, so 1.0 (xinsir canny/tile cards also recommend + 1.0). We measured text on a clean high-contrast image surviving across strength 0.1-0.5 at scale + 1.0 (PSNR ~26 flat), so scale 1.0 is the right default; only lower it if a specific image needs + more regeneration to scrub (raises shielding risk the other way). + +3. **`control_guidance_start=0.0`, `control_guidance_end=1.0` (full window) -- KEEP, do not shorten.** + The common "end=0.5: establish structure early then let the model render detail freely" is a + creative-generation recipe; for text it is HARMFUL -- the late free steps re-render and deform the + glyphs. We want the edge control active through ALL denoise steps so text stays pinned. (Our + pipeline already uses the 0->1 default; the point is to NOT adopt the shorten-the-window advice.) + +4. **Control type, per image type:** + - **Text / graphics / high-contrast -> canny** (the literature's reliable choice for defined edges + and text; what we ship). + - **Faces / smooth tonal content -> soft-edge / HED is a candidate worth testing.** Canny's hard + binary threshold fractures smooth skin gradients; HED/soft-edge gives gradual edges that may hold + faces better. UNVERIFIED for removal (softer edges may carry slightly more original signal -> + oracle-check). A face-heavy image is the test (gemini group photos). + - **tile -> NOT for removal.** It is near-identity (detail-enhancement at low denoise); it shields + the watermark (measured flat PSNR ~17.8 across strength on the tokyo sweep). Do not use it as the + removal control. + +5. **Resolution** -- higher long-side = strokes span more VAE latent cells = less softening, while + still fully regenerating. Already a knob (`--max-resolution`); for tiny text prefer native/large. + +**Multi-ControlNet (canny + soft-edge), list scales e.g. `[1.0, 0.8]`** (diffusers MultiControlNet): +could hold text edges AND face geometry at once, but doubles ControlNet memory/latency and raises the +shielding risk; defer to a v2 after the single-canny path is dialed in. + +**Image-type playbook (proposed, to validate with the oracle):** +- Clean high-contrast text (openai_1-style): canny 100/200, scale 1.0, full window -- already optimal. +- Dense small text / infographics (big_pic3, neon signage): canny **lower thresholds (~50/120)**, + scale 1.0, full window, larger resolution. +- Faces / portraits: try **soft-edge/HED** control, scale 1.0; or multi-ControlNet canny+softedge. + +**Hard caveat:** every change that increases preservation (higher scale, denser canny, fuller window, +softer edges) marginally REDUCES effective regeneration and so raises the chance the watermark +survives -- exactly the shielding failure mode. There is no local SynthID detector, so each tuning +change must be re-confirmed on the oracle. These are img2img-context recommendations derived from +generation-context sources plus our own measurements; treat the playbook as hypotheses to verify, not +settled defaults. + +**Sources.** https://huggingface.co/docs/diffusers/en/using-diffusers/controlnet · +https://arxiv.org/pdf/2302.05543 · https://huggingface.co/xinsir/controlnet-canny-sdxl-1.0 · +https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 · https://blog.cephalon.ai/canny-and-softedge/ + +## FaceID research: identity-preserving face conditioning (research 2026-06-03) + +Motivation: canny alone preserves face STRUCTURE/position better than plain SDXL but does NOT hold +IDENTITY -- verified on a real Gemini group photo (gemini_3, s015): faces drift in expression and +likeness (the smile/mouth and eyes change), they are "a similar person," not the same one. Canny +carries edges, not identity, so the regenerated face is identity-drifted. To hold identity WITHOUT +copying original pixels (the hard constraint -- copied pixels carry SynthID), the conditioning must +be an identity EMBEDDING, not pixels. Primary sources: diffusers IP-Adapter doc, InstantID +(arXiv:2401.07519), IP-Adapter (arXiv:2308.06721), practitioner comparisons. + +### Findings + +**1. IP-Adapter FaceID conditions on an ArcFace identity VECTOR, not pixels (confidence: high).** +FaceID extracts `insightface` ArcFace `normed_embedding` (a ~512-d identity vector) via +`FaceAnalysis`, and passes it as `ip_adapter_image_embeds` -- NOT a CLIP image embedding, NOT the +original pixels. So it is constraint-compatible: the watermark (a pixel-amplitude pattern) is not in +the identity vector, and the img2img still regenerates the pixels (removal via `strength` unchanged). +It loads on any SDXL via `load_ip_adapter` (~100 MB), is fast/low-VRAM, but identity fidelity on SDXL +is ~5-10% lower than the SD1.5 line / dedicated methods. + +**2. Multiple distinct faces ARE handled, via regional attention masks (confidence: high -- THE key +unlock).** This is the make-or-break for group photos (our hardest case). diffusers supports a LIST +of IP-Adapter face images each with its own binary region mask: `IPAdapterMaskProcessor` builds the +masks, `set_ip_adapter_scale([[s1, s2, ...]])`, and `cross_attention_kwargs={"ip_adapter_masks": +masks}`. So you detect each face, extract its own ArcFace embedding, assign it a region mask, and one +pass preserves N different identities simultaneously. (InstantID, by contrast, is single-subject -- +it averages embeddings for multiple refs, which is wrong for distinct people -- so for group photos +**IP-Adapter FaceID + masks beats InstantID**.) + +**3. IP-Adapter + ControlNet + img2img compose (confidence: high).** The doc shows IP-Adapter + +ControlNet (depth) in one pipeline and IP-Adapter + img2img (`strength`). Our target stack is the +union: `StableDiffusionXLControlNetImg2ImgPipeline` (canny = structure) + `load_ip_adapter` (FaceID = +identity) + `strength` (removal). `set_ip_adapter_scale` (1.0 = image-only, 0.5 = balanced) is the +identity-hold knob. API friction to verify in implementation: that `ip_adapter_masks` via +`cross_attention_kwargs` works on the *ControlNet img2img* pipeline (the masking is an attention- +processor feature, so it should be pipeline-agnostic, but confirm). + +**4. InstantID / PuLID positioning (confidence: medium).** InstantID does not train the UNet so it +composes with canny/depth ControlNets, and gives better single-face fidelity than FaceID -- but it is +single-subject (needs its own landmark ControlNet + dedicated weights). PuLID has the best identity +fidelity but is heaviest and Flux-leaning. For our multi-face, constraint-bound, SDXL-canny case, +IP-Adapter FaceID + masks is the right first build; InstantID/PuLID are single-portrait upgrades. + +### Architecture (proposed) + +``` +detect faces (insightface) -> per face: ArcFace embed + region mask +one img2img pass: + image=init, control_image=canny(init), # structure (existing) + ip_adapter_image_embeds=[face_embeds], # identity per face + cross_attention_kwargs={"ip_adapter_masks": face_masks}, # each face -> its region + controlnet_conditioning_scale=1.0, set_ip_adapter_scale(~0.6), + strength=vendor-adaptive # removal (unchanged) +``` +Pixels are regenerated (SynthID removed by `strength`), structure held by canny, each face's identity +held by its masked ArcFace vector -- no original pixel copied. + +### Risks / honest costs + +- **Shielding risk (same wall):** FaceID conditioning, like canny, reduces effective regeneration -> + higher `set_ip_adapter_scale` raises the chance SynthID survives in the face region (echo of why the + old region-hires failed). MUST oracle-verify removal at the chosen FaceID scale; keep `strength` at + the vendor threshold. +- **New heavy dependency:** `insightface` + `onnxruntime` + the `buffalo_l` model (~300 MB, downloaded + on first use). Detection + embedding is CPU/ONNX, separate from the diffusion. +- **Detection floor:** insightface needs faces large enough (det_size ~640); tiny faces in a dense + group may not be detected -> not preserved (falls back to canny-only for those). +- **Identity ceiling:** SDXL FaceID is ~5-10% off true identity -- a meaningful boost over canny-only + drift, NOT a perfect face swap. Set expectations; PuLID/InstantID are the higher-fidelity (heavier) + paths if needed. +- **Value scales with strength:** at low strength (OpenAI 0.10) faces barely drift, so FaceID is + marginal; at the higher strength a hard vendor (Google 0.30) needs, FaceID earns its keep. + +### Build plan (staged) + +- v1: optional `--face-id` flag on `--pipeline controlnet`. Detect faces; if any, run the masked + FaceID pass (works for 1 or N faces -- masks generalize). If none detected, fall through to plain + canny. Oracle-verify SynthID removal is preserved at the default FaceID scale on a face image. +- v2 (if identity still short): InstantID for single-portrait, or PuLID, as a higher-fidelity opt-in. + +**Sources.** https://huggingface.co/docs/diffusers/main/en/using-diffusers/ip_adapter · +https://huggingface.co/h94/IP-Adapter-FaceID · https://arxiv.org/pdf/2401.07519 (InstantID) · +https://instantid.github.io/ · https://arxiv.org/abs/2308.06721 (IP-Adapter) + +### FaceID prototype run 1 -- 2026-06-03 (NEGATIVE on dense small-face groups) + +Built and shipped the masked multi-face FaceID layer (`--face-id`, `face_id.py`, `faceid` extra). +First real run on the gemini_3 group photo (Google, s015, scale 0.6, native 2816 via cap 1536): +insightface detected **17 faces**, the masked multi-face pass composed and ran end-to-end (non-black +output), so the API is correct. **At s015 the result is a clear FAILURE: every face corrupted -- +melted/discolored/psychedelic, materially WORSE than canny-only.** + +**ROOT CAUSE FOUND (confirmed by ablation, not speculation) -- it is STRENGTH, not scale/masks/faces.** +Investigated the real data: masks are fine (max overlap depth 2, 33% coverage, only 0.2% of pixels +double-covered -- NOT an overlap problem), embeddings are fine (`normed_embedding` norm 1.000), the +FaceID LoRA is not required for SDXL (h94 model card), and faces span 34-181 px (7 medium + 10 tiny). +None of those is the cause. The decisive test: the SAME image + FaceID at **strength 0.5** produces +**clean, coherent faces across the whole group** (no psychedelic artifacts). So FaceID needs +substantial regeneration: the h94 usage is full generation (txt2img, 30 steps); at our removal +strength (0.10-0.15 = ~7 effective steps) the strong identity cross-attention cannot reconcile with +a latent that is ~85% the untouched original, so it smears identity-colored noise onto the faces. + +**This is a FUNDAMENTAL tension, not a tuning bug:** watermark removal wants LOW strength (minimal +degradation, just enough to scrub), FaceID wants HIGH strength (regenerate the face to impose +identity). They are opposed. At strength 0.5 FaceID works AND removes the watermark, but the whole +image regenerates much more (canny still holds text/edge structure, but texture/detail drifts well +beyond the 0.15 "minimal degradation" target). So `--face-id` is a HIGH-STRENGTH option: it trades +whole-image fidelity for face identity, and is a footgun at the low default strength (guaranteed +garbage). Required follow-up code guard: when `--face-id` is set, floor `strength` at ~0.5 (or refuse ++ warn) -- never run FaceID at the vendor-adaptive removal strength. Open question: whether +high-strength FaceID's whole-image drift is acceptable for face-centric images, or whether identity +preservation at LOW strength needs a different mechanism entirely (FaceID structurally cannot do it). (Infra lesson: the `faceid` extra must +stay numpy<2.0 -- pin `onnx<1.18` + `scipy<1.18`; pinning numpy UP, as the first build did, leaves a +numpy-1.26 env with a numpy-2-only scipy that crashes the diffusers import via `np.long`.) + +## Face preservation, done properly (research 2026-06-03, after the FaceID failure) + +The FaceID run failed and I wrongly concluded "faces can't be preserved." Re-research corrected the +understanding. The hard constraint is unchanged: to remove the watermark FROM a face the face MUST +be regenerated (freezing it leaves SynthID), so the goal is identity-preserving REGENERATION of the +face, at minimal overall image degradation. Three things I got wrong and the corrected picture: + +**What I got wrong:** (1) I applied FaceID at GLOBAL high strength -- the literature is clear the +architecture must be REGION-ADAPTIVE (face region handled separately, background stays low-strength); +(2) I used IP-Adapter FaceID, the WEAKEST identity tool -- InstantID uses an ArcFace encoder and hits +82-86% face-recognition similarity vs FaceID's weak CLIP-ish signal; (3) I missed the entire +face-restoration class (CodeFormer / GFPGAN), which is purpose-built for "regenerate a face, keep +identity." + +**The most promising mechanism -- CodeFormer face-restoration post-pass (confidence: high on the +mechanism, unverified on our watermark).** CodeFormer is a VQ-VAE: a frozen discrete CODEBOOK of HQ +facial priors + a Transformer that predicts code *tokens* from the input, and a frozen decoder that +regenerates the face FROM THE CODEBOOK ENTRIES -- "does not depend on feature fusion with low-quality +cues." So the output face pixels come from a finite learned codebook, NOT from the input pixels: +**the SynthID pixel-amplitude pattern physically cannot survive a codebook re-synthesis** -- a +stronger scrub than low-strength img2img (which keeps ~85% of the latent). Fidelity knob `w` in +[0,1]: higher w preserves identity but fuses MORE low-quality (input) cues (more watermark risk), +lower w leans on the codebook (cleaner scrub, identity drift) -- the same scrub-vs-fidelity tension, +settled per-image by the oracle; there is likely a `w` that holds identity AND clears the oracle. + +**Constraint-compatible architecture:** run the normal canny low-strength controlnet removal globally +(minimal degradation everywhere), then detect+align each face, run CodeFormer on the **ORIGINAL** face +crop (to capture true identity AND re-synthesize from the codebook = scrub), and composite the +CodeFormer output (codebook-generated, not original pixels -> no copy, no watermark) into the cleaned +image. Decouples whole-image minimal-degradation from face identity -- no high GLOBAL strength needed. + +**Honest costs/caveats:** (a) **License -- CodeFormer is NTU S-Lab 1.0 (non-commercial/research)**, so +it cannot be bundled in this MIT tool for general use; the license-clean alternative is **GFPGAN +(Apache-2.0)**, slightly lower quality. (b) Deps (basicsr/facexlib) are heavy and numpy-version-finicky +(same class of conflict as insightface). (c) CodeFormer is a *restoration* model -- it can subtly +alter expression/asymmetry; identity is held but not pixel-identical. (d) **The watermark-scrub is +mechanistically strong but UNVERIFIED -- must oracle-check.** InstantID + region-adaptive strength is +the alternative if the restoration route disappoints, but it is more complex (differential strength). +Prototype plan: validate CodeFormer on a real face in a THROWAWAY env (identity held? oracle clean?) +before any project-env integration or the license/GFPGAN decision. + +### CodeFormer prototype -- VALIDATED end-to-end 2026-06-03 (oracle-confirmed) + +Prototyped the CodeFormer face-restoration post-pass (codeformer-pip in a throwaway venv, forced CPU +-- the pip wrapper has an MPS device-mismatch bug) on the gemini_3 group photo (18 faces). Pipeline: +`all --pipeline controlnet --strength 0.15` (sparkle + SynthID removed from the whole image, minimal +degradation) -> CodeFormer on the ORIGINAL faces -> feather-composite the CodeFormer faces into the +all-cleaned image. Oracle results (Gemini app "Verify with SynthID"), isolating each part: +- pure controlnet-0.15 background (no faces): **clean** -> the background scrub works at 0.15 (no + ControlNet-shielding problem for Google on this image). +- composite with CodeFormer faces at **w0.7**: **SynthID DETECTED** -> high fidelity fuses too much of + the original face signal (the watermark) through. +- composite at **w0.5**: **clean**. composite at **w0.3**: **clean**. +So the scrub-vs-fidelity threshold is between 0.5 and 0.7; **w=0.5 is the sweet spot** (highest +fidelity / best identity that still clears the oracle). Identity at w0.3-0.7 all looks like the same +person (the face is large enough), so the lower w costs little. + +**This VALIDATES the corrected face-preservation approach** (and refutes my earlier "faces can't be +preserved" / FaceID conclusion): controlnet low-strength background scrub + CodeFormer-codebook face +re-synthesis at w~0.5 + feather composite = oracle-clean SynthID removal everywhere (background AND +faces), identity preserved, minimal overall degradation, zero original-pixel copying (CodeFormer faces +are codebook-generated). CodeFormer's discrete-codebook re-synthesis DOES scrub the pixel watermark, +but only when w is low enough that the decoder leans on the codebook rather than fusing the input +(watermark-carrying) features -- exactly the predicted fidelity-vs-scrub tension, with an empirical +clean threshold at w<=0.5. + +**Production TODO (not built -- still a throwaway prototype):** (1) license -- CodeFormer is NTU S-Lab +(non-commercial); decide CodeFormer-as-user-installed-extra vs GFPGAN (Apache-2.0, re-verify it scrubs +at its fidelity setting); (2) wire a `--restore-faces` post-pass (detect -> restore w~0.5 -> feather +composite) onto the controlnet pipeline; (3) handle the MPS device bug (force CPU for the face model +or fix); (4) re-verify the w threshold on more images / vendors (w=0.5 confirmed on one Gemini group +photo only). + +**Sources.** https://arxiv.org/abs/2206.11253 (CodeFormer) · https://github.com/sczhou/CodeFormer · +https://arxiv.org/pdf/2401.07519 (InstantID) · +https://openaccess.thecvf.com/content/WACV2024/papers/Suin_Diffuse_and_Restore... (region-adaptive) · +https://arxiv.org/pdf/2504.12809 (saliency-aware watermark removal) + +## Provenance + +Hand-run primary-source pass, 2026-06-02. Sources fetched and quoted above; the central +make-or-break claim (structure-conditioned high-strength regeneration scrubs the watermark while +keeping text) is **unverified and explicitly flagged as the thing the local prototype must +measure** (against the manual Gemini SynthID oracle) — the literature supports removal (Findings 1, 2) and supports structure-preserving +regeneration (Finding 5) but never jointly validated text (Finding 3). No code change implied +until the prototype validates a Pareto cell on the SynthID oracle. diff --git a/docs/known-limitations.md b/docs/known-limitations.md new file mode 100644 index 0000000..0e18081 --- /dev/null +++ b/docs/known-limitations.md @@ -0,0 +1,177 @@ +# Known limitations: full detail + +> 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. + +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-mark fill quality is background- and backend-dependent + +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: + +- **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. + +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. + +## Invisible-pipeline resolution handling (native / 1024 floor / `--max-resolution`; MPS memory tiers) + +`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. + +**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. + +### Tiled diffusion for large inputs (`--tile`, issue #10) + +`--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. `WatermarkRemover.remove_watermark` refactors 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 routes it through `noai.tiling.run_tiled` when tiling is active. The geometry and blend math are pure helpers, unit-tested without the model (`tests/test_tiling.py`): + +- `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`. + +CAVEAT: each tile is an **independent** low-strength regeneration. At the certified removal strengths (0.20-0.30) 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. + +**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. + +## fp16 VAE black-output fix (issue #29) + degenerate-output fp32 backstop (issue #41) + +**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. + +**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. + +**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.) + +**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. + +## rich was dropped (plain-text CLI and scripts) + +**rich was dropped (CLI + scripts print plain text via `click.echo`).** + +`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. + +## AVIF/HEIF/JPEG-XL metadata, ISOBMFF/ffmpeg removal, audio watermark detection + +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. + +**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). + +**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). + +**`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-token value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Limitation: covers the AI-generator-token case (the realistic one); a future xAI-signature-in-meta-box-EXIF (Grok is JPEG-only today) is not separately handled. **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists). + +**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.) + +## SynthID detection is metadata-only (no local pixel detector) + +**SynthID detection is metadata-only.** + +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. + +**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). + +**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. + +**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. + +**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. + +**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. + +**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.) + +**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. + +## External AI-vs-real classifier models are out of scope + +**External AI-vs-real classifier models are out of scope (decided 2026-05-24).** + +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. + +## Default strength is vendor-adaptive, one ladder for both pipelines + +**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).** + +`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. + +**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. + +**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`. + +**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. + +**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.** + +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`. + +**Everything below in this bullet about a fixed 0.10/0.30 default is HISTORICAL; trust the vendor-adaptive constants + docs/synthid.md.** + +## SynthID removal: strength + oracle scope + +**SynthID removal: strength + oracle scope.** + +Default strength is vendor-adaptive (see the bullet above); `docs/synthid.md` §2.2 is authoritative for the numbers. + +**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. Only the `sdxl` (plain SDXL img2img; `default` is a back-compat alias) and `controlnet` (SDXL + canny ControlNet) profiles exist; the local `invisible` default is weight-for-weight identical to raiw.cc prod (`fal-ai/fast-sdxl` = `stabilityai/stable-diffusion-xl-base-1.0`, runtime-downloaded, not bundled). + +**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". + +## `controlnet` pipeline: content x pipeline removal, certified floors, no face-restore + +**`controlnet` pipeline (text/face STRUCTURE preservation, THE DEFAULT since 2026-06-09; `--pipeline default` opts down to plain SDXL).** + +SDXL + the canny ControlNet `xinsir/controlnet-canny-sdxl-1.0` via `StableDiffusionXLControlNetImg2ImgPipeline` (`watermark_remover._run_controlnet` / `_load_controlnet_pipeline`). + +**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 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. + +**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).** + +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. + +**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). + +**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). raiw.cc still needs its own per-vendor/content calibration on the GPU worker for native resolution. The Gemini-native resolution caveat stands: controlnet 0.30 is certified only <=1536.** **CERTIFIED 2026-06-04 via the isolated `raiw-controlnet-cert` Modal app (`raiw-app/modal_cert.py`), 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 prod (resolution-independent); Gemini 0.30 holds only ≤1536 — Gemini is resolution-sensitive and raiw.cc runs NATIVE (`max_resolution=0`), so cap Gemini ≤1536 + use 0.30, or native-calibrate (~0.35+). Prod recipe: controlnet + per-vendor floor in `resolve_strength` (not the default ladder) + FIXED seed (kills the non-determinism). + +**No face-restore in the library:** every approach evaluated (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 drifted face from controlnet 0.20 is the least-AI state we can reach; for a paid service that's the prod output. See `docs/synthid-robust-identity-research-2026-06-08.md` "Empirical follow-up".** + +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 **faces stay on SDXL+controlnet; Qwen is the text lane, not a face fix.** The strongest remaining lead is **Z-Image-Turbo** (6B, Apache-2.0, `ZImageImg2ImgPipeline`, scrub mechanism preserved) -- its own SynthID floor and face/text fidelity are UNMEASURED; that is the next experiment. 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. Always validate any improvement at the certified floors with `scripts/fidelity_metrics.py` first. + +**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. diff --git a/docs/module-internals.md b/docs/module-internals.md new file mode 100644 index 0000000..491f404 --- /dev/null +++ b/docs/module-internals.md @@ -0,0 +1,237 @@ +# 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. + +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. + +## `noai/c2pa.py` + +`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}`. + +## `noai/constants.py` + +`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. + +## `metadata.py` + +`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. + +**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. + +## `identify.py` + +`identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, **C2PA cloud-manifest reference** via `metadata.c2pa_cloud_manifest` — signal `c2pa_cloud`, **medium**, provenance-only (does NOT set `is_ai`, excluded from `ai_from_metadata` + clash vendors): a C2PA 2.4 Durable-Content-Credentials case where the embedded manifest is stripped but an XMP `dcterms:provenance` pointer to the vendor's cloud manifest store (`_C2PA_MANIFEST_REPOSITORIES`, today `cai-manifests.adobe.com` → "Adobe Content Authenticity") survives, so the credentials stay recoverable server-side; only emitted when no embedded manifest already attributed the file — surfaced on 2 corpus PNGs 2026-06-10 that read fully `unknown` before, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI / Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. + +**AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)". + +**Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. + +**`import identify` is deliberately light** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. + +**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`). + +**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. + +**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. + +**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`. + +**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): 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). + +**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). + +**Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read. + +**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**. + +**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`). + +**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). + +**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** (the latter can be a by-product of our own SDXL removal pass). 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`). + +**`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}`. + +## `watermark_registry.py` + +`watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. + +**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not color-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the color just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a color-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged). + +**Head-to-head validation (v0.12.1 reverse-alpha vs the current localize -> fill):** run over the full labelled visible-mark set, with the cv2 / MI-GAN / LaMa fills each compared against the old reverse-alpha. **doubao and jimeng are identical** across every backend -- 100% coverage and 100% clearance either way. **gemini** strict coverage is a few points below reverse-alpha's (the deliberate false-positive tightening), but the metadata-stripped faint ones are now mostly recovered by the DEFAULT white-core rescue in the FP gate (`gemini_engine`: a bright near-WHITE core distinguishes a real faint sparkle from a colored bright corner -- ~14/20 recovered at ~1.25% clean false-fire; a learned classifier on the same features measured worse, 2026-07 tier-1), the residual under `assume_ai`; clearance is equal (~98% both), and neither version touches pixels outside the mark box (outside-box PSNR ~99). **Clearance is fill-independent** -- cv2, MI-GAN and LaMa all strip the mark's shape equally, so the re-detect metric does not separate them; the difference is purely the *visual fill quality* on the recovered region, and it is background-dependent. reverse-alpha recovered textured and especially regular/structured backgrounds (a lattice, a grid) more cleanly than any inpaint; **LaMa closes most of that gap** (the best learned backend), **MI-GAN can ghost or hallucinate structure**, and **cv2 smears** (the last-resort floor). This is why `auto` resolves `LaMa > MI-GAN > cv2` (`preferred_inpaint_backend`) and warns once on the cv2 fallback; on flat backgrounds every backend is clean. + +**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller. + +**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()`. + +**`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/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. + +**Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` 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. + +## `gemini_engine.py` + +`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. + +**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`. + +**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`. + +**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. + +**Conclusion: keep the 0.85 corner gate; do NOT add a margin/chroma-gated lower promote.** + +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. + +**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. + +**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`). + +**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. + +**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). + +## `_text_mark_engine.py` + +`_text_mark_engine.py` — **shared base for the three text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the `locate → extract_mask → detect` detection pipeline plus the removal that localizes the glyph blob to a footprint mask and hands it to the shared `watermark_registry.fill` (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Detection still matches the glyph silhouette (NCC against the captured template); the removal MASK is TEMPLATE-FREE — it is the bounding box of the top-hat glyph blob from `extract_mask`, filled solid + dilated, so a re-rendered or differently-placed mark is still masked. This dropped the fixed alpha-template placement; the captured alpha maps are now used only for the detection silhouette, not for removal. Each engine module is a thin subclass supplying only its `TextMarkConfig` (the tuned constants, the bundled asset, and the bounded structural deltas — `corner` br/bl, `margin_floor` 4/2, `morph_open_size` 5/3, `min_gw` 8/16) plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`. + +**Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. + +**The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap. + +## `doubao_engine.py` + +`doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" detector + localizer (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). + +**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). + +**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. + +**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).** + +## `jimeng_engine.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`. + +**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. + +**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`. + +**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. + +## `samsung_engine.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). + +**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. + +**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`. + +**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 captured detection template. This is a pre-existing limit, unchanged by the localize -> fill refactor. + +**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). + +## `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 three 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`). + +**`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 (abba) 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). Faces stay on controlnet; the next improvement lead is Z-Image-Turbo (Apache-2.0, unmeasured floor). Full record + the deep-research sweep in `docs/qwen-improvement-research.md`. + +**`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. 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. 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` (the SDXL training 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** — it self-gates to a no-op where there is no detail deficit, so default-on is safe; uses the full-res original as the detail reference) — 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). + +### `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`. `--adaptive-polish` is a detail-targeted polish that self-gates to a no-op where there is no deficit. `--auto` is deprecated and now a no-op that only warns (the polish it used to enable is ON by default). **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}` 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 — the only path to high recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — ~27% precise, its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark ` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0). + +### `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`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default; `--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. diff --git a/docs/qwen-improvement-research.md b/docs/qwen-improvement-research.md new file mode 100644 index 0000000..868c325 --- /dev/null +++ b/docs/qwen-improvement-research.md @@ -0,0 +1,220 @@ +# Qwen-Image improvement research (2026-06-20) + +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 +confirmed / 5 killed, 104 agent calls). Findings carry their confidence and vote. + +## Context + +The `qwen` pipeline runs base Qwen-Image (20B MMDiT, Apache-2.0) as a low-strength +img2img scrub (removal comes from the denoising `strength`). Certified oracle scrub +floors: OpenAI 0.10 (seed-robust), Gemini 0.25 (pinned seed). Measured against the +SDXL + canny-ControlNet pipeline (`scripts/fidelity_metrics.py`): Qwen preserves +**text** markedly better (incl. CJK and Cyrillic, lower OCR CER) but preserves +**faces** worse, smoothing skin (Laplacian-variance retention 0.40 vs 0.62, face +LPIPS 0.17 vs 0.09, ArcFace identity 0.38 vs 0.55 at the scrub floors). The goal of +the research: keep Qwen's text advantage while fixing the face-smoothing, and judge +production-readiness. + +## Verdict + +Base Qwen-Image is **shippable now as an opt-in text-content lane** (Apache-2.0 on +code and weights, scrub lever confirmed), but it is not a universal upgrade (it loses +faces). The strongest verified improvement path is to **add structure conditioning** +(a Qwen-Image ControlNet) to the existing base pass, the direct analog of the SDXL + +canny conditioning that wins on faces. Separately, **Z-Image / Z-Image-Turbo** (6B, +Apache-2.0) is the best-verified lighter alternative to evaluate before committing to +the 20B cost. None of the improvements has measured face-fidelity numbers at our +scrub floors yet, so each must be validated with `scripts/fidelity_metrics.py` plus +the oracle before shipping. + +## Follow-up: ControlNet experiment + deeper research (2026-06-20) + +The verdict's strongest lead -- adding a Qwen-Image ControlNet -- was **built, measured, and +CLOSED**. + +**Experiment** (Modal A100-80GB; DiffSynth-Studio `QwenImagePipeline` + the Apache-2.0 +`DiffSynth-Studio/Qwen-Image-Blockwise-ControlNet-Canny` -- the only framework exposing +Qwen-Image + canny ControlNet + img2img `denoising_strength` in ONE call; diffusers ships no +`QwenImageControlNetImg2ImgPipeline`, its three Qwen ControlNet pipelines are txt2img only). +Measured on `gemini_3` (18 faces) at the Gemini scrub floor 0.25 vs base-Qwen 0.25 with +`scripts/fidelity_metrics.py`: +- **The actual failure mode (face skin texture) was NOT restored:** Laplacian-variance + retention stayed flat (base 0.40 -> qwen+canny 0.40; per-face 13/16 within +-0.02 after a + one-to-one face match, sd 0.016 -- not an averaging artifact). The SDXL+canny target 0.62 + was not approached. +- Identity rose modestly and broadly (ArcFace 0.346 -> 0.415, 12/16 faces improved) but the + absolute stays ~0.42 ("a different person, slightly closer"). +- Mechanism (verified, not inferred): canny conditioning was applied fully (scale 1.0, full + denoise schedule); the canny edge map is clean facial geometry with BLANK skin (4.83% edge + density) -- canny carries edges, not skin grain. Root cause: Qwen's Gemini floor (0.25) is + higher than SDXL+canny's (0.15), forcing more denoising -> more smoothing; structure + conditioning cannot compensate for that. + +**Deeper research** (deep-research harness, 103 agents, 3-vote adversarial): +- **[high, unanimous] No permissively-licensed Qwen-Image tile / detail / realism / skin + ControlNet exists anywhere** -- DiffSynth first-party is Canny/Depth/Inpaint only, InstantX + Union is canny/soft-edge/depth/pose, the official QwenLM repo ships none. Every Qwen + conditioning is GEOMETRY, the same class as the tested canny. **The "add a Qwen ControlNet to + fix faces" lead is closed for good.** +- **[high, unanimous] Z-Image / Z-Image-Turbo (6B, Apache-2.0 on code AND weights, ~1/3 of + Qwen 20B)** ships a documented `ZImageImg2ImgPipeline` with standard strength denoising, so + it preserves the scrub mechanism. Its own SynthID scrub floor and face/text fidelity are + UNMEASURED -- this is the strongest concrete NEXT experiment. +- **[medium] Lowering Qwen's scrub floor has no off-the-shelf SynthID answer:** the "partial + img2img ~0.3 breaks robust watermarks" literature tests open schemes + (StegaStamp/TrustMark/VINE), NEVER SynthID (proprietary decoder) -- analogy, not proof. No + minimal-strength SynthID attack under a named permissive license was found. +- **REFUTED [0-3]:** "re-injecting high-frequency detail from a clean diffusion output would + not carry the watermark back." So non-regenerative detail transfer is NOT safe by + assumption -- the transferred high-frequency band must be gated against the SynthID oracle. + +**Net for the pipeline:** **faces stay on SDXL+controlnet**; there is no Qwen face-fix. +The live frontier is Z-Image-Turbo (next experiment) and oracle-gated non-regenerative detail +re-injection. + +**Follow-up (2026-06-20) — the content-routed lane / mixed dual-pass was tested and DROPPED.** +A `--pipeline auto` router (Haar+MSER → text→qwen / faces→controlnet / both→mixed) and a +faces+text mixed dual-pass (scrub the whole frame on both, graft qwen text regions onto the +controlnet base) were built and run on Modal (the abba poster: faces + display text). On that +canonical faces+text case **controlnet won EVERY metric, including text** (CER 0.114 vs qwen +0.379; ID 0.64 vs 0.36) — canny holds existing letter shapes, qwen re-renders display text and +garbles it, so grafting qwen text only hurts. Qwen beats controlnet on text ONLY for clean body +text on a plain background with no faces (openai_1/2), a niche `--pipeline qwen` alone covers; +the faces+clean-body-text intersection is near-empty, and "text→qwen" is undecidable cheaply +(body-vs-display text is what matters). So the router + mixed modules were removed and **`qwen` +is a manual `--pipeline qwen` opt-in only.** KEPT (independently valid): the qwen geometry fix +(it squished non-square inputs to 1024²), the pipeline-aware `resolve_strength` Qwen ladder, and +the `fidelity_metrics.py` one-to-one face matcher below. + +**Tooling fix surfaced by this run:** `scripts/fidelity_metrics.py` face matching was changed +from per-face nearest-center to a collision-free one-to-one assignment +(`assign_faces_one_to_one`, gated by face size), after the 18-face `gemini_3` exposed +collisions (the regenerated variants detected 17 faces, so two originals mapped to the same +variant face, corrupting the identity metric). lapvar/LPIPS were always anchored to the +original bbox and stayed collision-immune. Regression-guarded by +`tests/test_fidelity_matching.py`. + +## Findings + +1. **[high, 3-0] A permissively-licensed Qwen-Image ControlNet exists today and is + CUDA/diffusers-runnable.** InstantX Qwen-Image-ControlNet-Union supports + canny/soft-edge/depth/pose; DiffSynth-Studio maintains blockwise Canny/Depth/Inpaint + plus an In-Context-Control-Union; diffusers exposes `QwenImageControlNetPipeline` + and `QwenImageMultiControlNetModel` with `controlnet_conditioning_scale` (default + 1.0) and `control_guidance_start`/`end`. This is the direct analog of the certified + SDXL+canny structure conditioning that wins on faces. Caveat: canny/depth preserve + geometric structure, not face identity per se, and none is a **tile**-ControlNet + (the variant most tied to fine-detail/skin retention in the SDXL world). + Sources: InstantX/Qwen-Image-ControlNet-Union, InstantX/Qwen-Image-ControlNet-Inpainting, + DiffSynth-Studio Qwen-Image docs, diffusers qwenimage pipeline docs. + +2. **[high, 3-0] The scrub mechanism is preserved, and the license is clean.** + `QwenImageImg2ImgPipeline.strength` (default 0.6, range 0-1; DiffSynth names it + `denoising_strength`) keeps the partial-regeneration scrub the project relies on, + lower values staying closer to the input. Qwen-Image and Qwen-Image-Edit-2509 are + Apache-2.0 on both code and weights. + +3. **[medium, mixed 2-1 / 3-0] Qwen-Image-Edit improves identity consistency, but that + is not proof it fixes our metric.** The instruction-edit pipeline (2511 better than + 2509) improves identity/character consistency, but only for identity *through edits* + of an input portrait, which is not the same as measured face-skin Laplacian/LPIPS + fidelity at a low scrub strength. Architecture: 20B base + Qwen2.5-VL (semantic + control) + VAE Encoder (appearance control). Several stronger edit-model face claims + were refuted (see below). + +4. **[high, 3-0] Z-Image / Z-Image-Turbo is the best-verified lighter alternative.** A + 6B model (~1/3 of Qwen-Image's 20B), Apache-2.0 on code and weights, strong bilingual + (Chinese + English) native text rendering, with an official diffusers + `ZImageImg2ImgPipeline` exposing the same 0-1 denoising-strength scrub lever; Turbo + runs at ~8 steps (guidance_scale=0.0) vs ~40. A material cost/footprint reduction vs + 20B/A100-80GB (but see caveat 4 on the refuted consumer-GPU claim). + +5. **[high, 3-0] EliGen-V2 is NOT relevant** to the face-smoothing problem. It is an + entity-level/regional control model (LoRA + regional attention placing entities via + text + mask maps, plus entity-level inpainting); it provides no + ControlNet/canny/depth/tile structure conditioning or face-skin-detail retention. + +6. **[medium, 2-1] flymy-ai/qwen-image-realism-lora** is Apache-2.0 (code+weights) on + base Qwen-Image, so it is permissively usable with the existing base img2img pass, + but it is NOT verified to specifically fix the face/skin-smoothing failure mode. + +## Caveats + +1. The research did NOT surface verified evidence for two things specifically asked: + (a) a Qwen-Image **tile**-ControlNet (the variant most tied to fine-detail/skin + retention; only canny/soft-edge/depth/pose/inpaint were confirmed), and (b) any + **non-regenerative detail-restoration** technique (high-frequency residual transfer, + guided filtering) that recovers smoothed faces without re-introducing the watermark. + Research angle 4 produced zero surviving claims, so it is unanswered. +2. No claim provides measured face-fidelity numbers (ArcFace/LPIPS/Laplacian) for ANY + recommended intervention at the project's scrub floors. All fidelity evidence is the + project's own internal measurement. The improvements are mechanistically sound but + unproven for this exact metric, so validate with `scripts/fidelity_metrics.py` + before shipping. +3. Several vendor model cards are marketing-register primary sources (Qwen blog, + Z-Image card). Load-bearing facts (license, params, API levers) are independently + corroborated, but comparative quality framings are author glosses. +4. Z-Image's "sub-second" figure is H800-specific and author-benchmarked; consumer-GPU + third-party benchmarks are still limited (seconds, not sub-second, though within the + <16GB envelope). +5. Time-sensitivity: Qwen-Image-Edit-2511 and Z-Image are late-2025/2026 releases; the + diffusers pipelines cited are on the main/dev branch, so confirm released-version + availability before pinning. +6. Five claims were refuted (below), clustering on over-strong edit-model face-fidelity + and one over-strong Z-Image cost claim. + +## Open questions + +- Does a Qwen-Image **tile-ControlNet** (or equivalent high-resolution detail + conditioning) exist under a permissive license? +- What **non-regenerative detail-restoration** method recovers smoothed faces WITHOUT + re-introducing SynthID? Note: residual transfer from the ORIGINAL risks copying back + watermark-carrying high frequencies, so it must be verified against the SynthID oracle. +- Does adding Qwen-Image-ControlNet (canny/depth) at the certified floors (OpenAI 0.10, + Gemini 0.25) actually raise face Laplacian/LPIPS toward the SDXL+ControlNet numbers + (0.62 / 0.09) WITHOUT re-introducing SynthID, or does the structure constraint + preserve the watermark the way ControlNet can on photoreal content (the existing + "SynthID CAN survive controlnet at low strength" caveat)? +- Head-to-head: does Z-Image-Turbo at its scrub floor match Qwen's text advantage + (CJK+Cyrillic CER) while not worsening faces, and what are Z-Image's own SynthID + scrub floors and seed-robustness (none exist yet)? + +## Refuted claims (do NOT rely on these) + +- [0-3] "Qwen-Image-Edit-2511 specifically targets/mitigates image drift, the same + failure mode as face-detail loss in a low-strength scrub." (qwen.ai/blog, 2511) +- [0-3] "Qwen-Image-Edit-2509 explicitly improves facial identity preservation and + supports portrait styles and pose transformations." (HF Qwen-Image-Edit-2509) +- [0-3] "Qwen-Image-Edit-2509 has native built-in ControlNet support (depth/edge/ + keypoint)." (HF Qwen-Image-Edit-2509) +- [1-2] "flymy realism LoRA specifically targets facial and skin detail, the exact + failure mode." (HF flymy-ai/qwen-image-realism-lora) +- [0-3] "Z-Image-Turbo runs on consumer 16GB-VRAM hardware, far below the A100-80GB of + Qwen-Image 20B, materially lowering per-image cost." (HF Tongyi-MAI/Z-Image-Turbo) + +## Sources + +1. https://qwen.ai/blog?id=qwen-image-edit-2511 +2. https://qwenlm.github.io/blog/qwen-image-edit/ +3. https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit +4. https://github.com/FurkanGozukara/Stable-Diffusion/wiki/Qwen-Image-Edit-2511-Free-and-Open-Source-Crushes-Qwen-Image-Edit-2509-and-Challenges-Nano-Banana-Pro +5. https://myaiforce.com/qie-2511/ +6. https://huggingface.co/Qwen/Qwen-Image-Edit-2509 +7. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Union +8. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Inpainting +9. https://huggingface.co/DiffSynth-Studio/Qwen-Image-EliGen-V2 +10. https://github.com/modelscope/DiffSynth-Studio/blob/main/docs/en/Model_Details/Qwen-Image.md +11. https://blog.comfy.org/p/day-1-support-of-qwen-image-instantx +12. https://learn.thinkdiffusion.com/how-to-use-qwen-image-with-instantx-union-controlnet-in-comfyui-guide-workflow/ +13. https://huggingface.co/flymy-ai/qwen-image-realism-lora +14. https://huggingface.co/lightx2v/Qwen-Image-Lightning/discussions/4 +15. https://huggingface.co/docs/diffusers/main/en/api/pipelines/qwenimage +16. https://www.diyphotography.net/skin-retouching-technique-frequency-separation/ +17. https://link.springer.com/content/pdf/10.1007/978-3-642-15549-9_1.pdf +18. https://github.com/ShieldMnt/invisible-watermark/wiki/Frequency-Methods +19. https://huggingface.co/Tongyi-MAI/Z-Image-Turbo +20. https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/z_image/pipeline_z_image_img2img.py +21. https://arxiv.org/pdf/2511.22699 +22. https://github.com/ModelTC/LightX2V-Qwen-Image-Lightning diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md new file mode 100644 index 0000000..634494d --- /dev/null +++ b/docs/release-and-distribution.md @@ -0,0 +1,36 @@ +# 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. + +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. + +`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). + +**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. + +**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. + +**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. + +**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). + +**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. + +**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. + +**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. + +**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. + +## Dependency CVE-resolution history (`uv-secure`) + +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. + +- **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, vulnerable `<= 2.12.0`) is **dismissed as `not_used`** (2026-06-10): torch is a transitive dep of the optional `gpu` extra only, the codebase never calls `torch.jit` (grep-verified), and **no patched torch version exists** (`first_patched_version` is null), so it cannot be closed by an upgrade — do not re-triage it. diff --git a/docs/research-doubao-distillation.md b/docs/research-doubao-distillation.md new file mode 100644 index 0000000..918eccd --- /dev/null +++ b/docs/research-doubao-distillation.md @@ -0,0 +1,21 @@ +# Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29) + +> 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. + +**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.** + +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. + +**Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.** + +The consensus glyph silhouette across the aligned marks distills cleanly (proto: a tight "豆包AI生成" strip, width ≈ 0.156 × image-width) and is good both as an exact inpaint mask and as an NCC localiser -- the latter also fixes the #23 detector false-positives (match the real glyph shape, not any bright low-saturation corner). Do **not** retry content-image reverse-alpha: it is data-limited by physics (no dark-background observations), not by effort. diff --git a/docs/synthid-robust-identity-research-2026-06-08.md b/docs/synthid-robust-identity-research-2026-06-08.md new file mode 100644 index 0000000..009a1a3 --- /dev/null +++ b/docs/synthid-robust-identity-research-2026-06-08.md @@ -0,0 +1,185 @@ +# Deep research: SynthID-safe face-identity recovery for SDXL (2026-06-08) + +**Stats:** {"angles": 6, "sourcesFetched": 28, "claimsExtracted": 104, "claimsVerified": 25, "confirmed": 19, "killed": 6, "afterSynthesis": 6, "urlDupes": 1, "budgetDropped": 7, "agentCalls": 111} + +## Summary + +For SDXL-based SynthID-removal pipelines, the entire ArcFace-class identity-adapter ecosystem (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) is blocked from commercial use by a single chokepoint: InsightFace's pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are explicitly non-commercial research-only, regardless of the adapter weights' Apache-2.0/MIT license. The InstantX maintainers themselves acknowledged this on HuggingFace ("cannot be Apache 2.0 if it is using Insight Face") and stated intent to retrain on commercially-licensed embedders — work that as of the verified sources has not shipped. The only verified commercial-safe SDXL adapter surfaced is MS-Diffusion (ICLR 2025), which uses CLIP-ViT-bigG-14 instead of ArcFace and runs on SDXL-base-1.0, but it is an IP-Adapter-class subject-similarity method, not a face-identity method — CLIP image embeddings are empirically weaker for face identity than ArcFace (~81% vs ~88% face-ID accuracy), so it solves the license problem but likely not the identity-fidelity problem. Arc2Face is SD1.5-only and so not a drop-in regardless of license; StyleGAN2-based methods (e4e) terminate in a GAN generator with no SDXL wiring. Net: no fully commercial-safe SynthID-robust SDXL identity-preservation stack with ArcFace-grade fidelity exists today — the space is genuinely blocked by InsightFace's grip on commercial ArcFace embeddings, and AdaFace as a permissive ArcFace alternative was REFUTED in verification. + +## Findings + +### 1. The InsightFace pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are non-commercial research-only and this restriction propagates at runtime to any adapter that calls FaceAnalysis(), regardless of the adapter's own license tag. + +**Confidence:** high +**Vote:** 3-0 unanimous across 4 supporting claims + + +InsightFace upstream: code is MIT but 'The training data containing the annotation (and the models trained with these data) are available for non-commercial research purposes only.' Licensing page enumerates buffalo_l, antelopev2, buffalo_s, buffalo_m as needing separate commercial licensing. InstantX maintainer on HF: 'cannot be Apache 2.0 if it is using Insight Face... we plan to train on other face encoders that support commercial license.' Mechanical propagation: the runtime FaceAnalysis() call pulls these packs. + +- https://github.com/deepinsight/insightface +- https://www.insightface.ai/solutions/face-recognition-licensing +- https://huggingface.co/InstantX/InstantID/discussions/2 + +### 2. InstantID is Apache-2.0 on the adapter weights and architecturally a clean plugin to SDXL-base-1.0 (no UNet training, semantic IdentityNet conditioning + landmark weak-spatial, no pixel copying) — but at runtime it instantiates FaceAnalysis(name='antelopev2'), inheriting the InsightFace non-commercial restriction. + +**Confidence:** high +**Vote:** 3-0 across 5 claims + + +HF model card: 'License: apache-2.0' AND 'For face encoder, you need to manutally download via this URL to models/antelopev2' with 'from insightface.app import FaceAnalysis' in usage. Diffusers community pipeline shows literal `app = FaceAnalysis(name='antelopev2', ...)` then `face_emb = face_info['embedding']`. Paper: 'IdentityNet by imposing strong semantic and weak spatial conditions, integrating facial and landmark images with textual prompts' + 'seamlessly integrates with SD1.5 and SDXL'. So mechanism is semantic (good for SynthID-no-pixel-leak) but license is blocked. + +- https://huggingface.co/InstantX/InstantID +- https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py +- https://arxiv.org/pdf/2401.07519 +- https://instantid.github.io/ + +### 3. Arc2Face is SD1.5-only (not SDXL) AND requires InsightFace antelopev2 with arcface.onnx replacing glintr100.onnx — so it is doubly blocked: not a drop-in for the SDXL pipeline AND non-commercial via the model pack. + +**Confidence:** high +**Vote:** 3-0 across 3 claims + + +Arc2Face README verbatim: 'Arc2Face is built upon SD1.5' with stable-diffusion-v1-5 checkpoint as base. Same README: 'manually download the antelopev2 package and place the checkpoints under models/antelopev2', 'Download arcface.onnx from HuggingFace', 'delete glintr100.onnx (the default backbone from insightface)'. Code is MIT but the runtime antelopev2 dependency carries the non-commercial restriction. No SDXL adaptation exists in the official repo. + +- https://github.com/foivospar/Arc2Face + +### 4. MS-Diffusion (ICLR 2025) is the one verified candidate that combines SDXL-base-1.0 as its foundation with a non-ArcFace image encoder (CLIP-ViT-bigG-14), so it avoids the InsightFace non-commercial blocker — but it is an IP-Adapter-class multi-subject personalization method, NOT a face-identity-recognition method, so license safety does not equal face-identity fidelity. + +**Confidence:** medium +**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. + +- https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf +- https://github.com/MS-Diffusion/MS-Diffusion + +### 5. ID-Aligner and the StyleGAN2/e4e-based identity method (arXiv 2510.25084) do not solve the problem: the former does not disclose embedder/license in the primary source; the latter terminates identity in a StyleGAN2 generator with no SDXL adapter, so it cannot be wired into the SDXL+canny ControlNet pipeline. + +**Confidence:** high +**Vote:** 3-0 across 3 claims + + +ID-Aligner paper: no license terms, no weight-release status, no specific face embedder named in the primary source — commercial-safety unresolved. arXiv 2510.25084: 'facial identity features... mapped into the W+ latent space of StyleGAN2 using the e4e encoder' — identity pixels come from StyleGAN2's generator, not from any SDXL-compatible adapter, so architecturally incompatible with our SDXL+canny ControlNet pipeline. Abstract also does not name the face embedder or disclose weights license. + +- https://arxiv.org/pdf/2404.15449 +- https://arxiv.org/pdf/2510.25084 + +### 6. The honest verdict: no fully commercial-safe SynthID-robust ArcFace-grade face-identity stack for SDXL exists today; the space is blocked by InsightFace's grip on ArcFace-class pretrained packs, and the verified permissive alternative (AdaFace as a drop-in replacement) was REFUTED in this verification round. + +**Confidence:** high +**Vote:** 3-0 on the chokepoint claim, 0-3 against the AdaFace escape hatch + + +Every ArcFace-grade SDXL adapter audited (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) instantiates an InsightFace FaceAnalysis pack at runtime. The maintainer-acknowledged commercial-retrain path (InstantX) has not shipped. AdaFace as a permissive ArcFace alternative was refuted 0-3 by the adversarial verifier (MIT-licensed claim and drop-in-replacement claim both failed). Outcome: commercial-safe option for SDXL today is CLIP-image-embedding-based (MS-Diffusion class), which is weaker for face identity than ArcFace. For non-commercial / research-only deployments, InstantID on SDXL is the strongest semantic-only (no-pixel-leak) candidate. + +- https://github.com/deepinsight/insightface +- https://huggingface.co/InstantX/InstantID/discussions/2 +- https://github.com/mk-minchul/AdaFace + +## Caveats + +Six claims were refuted in adversarial verification, two of them load-bearing: AdaFace as a permissive ArcFace drop-in (both the MIT license and the drop-in characterization failed 0-3) — so the most attractive escape hatch from the InsightFace chokepoint did not survive verification. PuLID's superiority claim and ID-Aligner's embedder claim also failed, leaving those methods uncharacterized at the mechanism level. None of the verified claims directly answered the diffusers-0.38 compat question — InstantID's compat with our shipped diffusers version is unverified by primary source. MS-Diffusion's identity-fidelity for portraits is not empirically validated for the SynthID-removal use case; it is verified as a CLIP-G/SDXL adapter, not as a face-identity method. No 2025-2026 candidate other than MS-Diffusion was both surfaced AND verified as license-safe and SDXL-compatible — the verification round did not produce a confirmed CCSR-V2/ConsistencyID/OmniGen-V2/ConsistentID/MagicID candidate. The multi-face scenario (group photos) was not addressed by any verified claim — MS-Diffusion is the only multi-subject candidate but its face-identity strength is unmeasured. Time-sensitivity: InstantX's stated intent to retrain on commercial embedders ("We agree, we plan to train on other face encoders that support commercial license") is dated and unfulfilled per the verified sources; this could change. + +## 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? +- 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? + +## Refuted claims + +- **AdaFace code is released under the MIT license, making it permissively licensed for commercial use (in contrast to InsightFace's research-only model packs).** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace +- **AdaFace produces 512-dim face recognition embeddings comparable to ArcFace, positioning it as a drop-in alternative for ID-conditioning adapters that currently depend on InsightFace ArcFace.** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace +- **The paper claims PuLID achieves superior performance in both ID fidelity and editability compared to prior ID-customization methods.** — vote 0-3 — source: https://arxiv.org/pdf/2404.16022 +- **Identity consistency in ID-Aligner is enforced by reward feedback from face detection and recognition models, implying dependency on an external face-recognition embedder (typically ArcFace/InsightFace-class) rather than a CLIP-only path.** — vote 1-2 — source: https://arxiv.org/pdf/2404.15449 +- **The pipeline decouples face detection from inference by accepting a pre-computed embedding via image_embeds, so any ArcFace-class embedder could be swapped in if a commercially-licensed equivalent existed.** — vote 1-2 — source: https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py +- **The model card does not explicitly prohibit commercial use, but the license of the required InsightFace antelopev2 embedder is not specified on this page.** — vote 1-2 — source: https://huggingface.co/InstantX/InstantID + +## Sources + +- [source](https://huggingface.co/InstantX/InstantID/discussions/2) +- [source](https://www.insightface.ai/solutions/face-recognition-licensing) +- [source](https://github.com/mk-minchul/AdaFace) +- [source](https://github.com/foivospar/Arc2Face) +- [source](https://apatero.com/blog/instantid-vs-pulid-vs-faceid-ultimate-face-swap-comparison-2025) +- [source](https://arxiv.org/pdf/2404.16022) +- [source](https://instantid.github.io/) +- [source](https://arxiv.org/pdf/2404.15449) +- [source](https://arxiv.org/pdf/2511.11989) +- [source](https://arxiv.org/pdf/2510.25084) +- [source](https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py) +- [source](https://huggingface.co/InstantX/InstantID) +- [source](https://github.com/huggingface/diffusers/issues/9158) +- [source](https://github.com/huggingface/diffusers/issues/5904) +- [source](https://github.com/Mikubill/sd-webui-controlnet/discussions/2589) +- [source](https://arxiv.org/pdf/2401.07519) +- [source](https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf) +- [source](https://github.com/huggingface/diffusers/issues/8626) +- [source](https://arxiv.org/pdf/2404.04243) +- [source](https://huggingface.co/OmniGen2/OmniGen2) +- [source](https://github.com/VectorSpaceLab/OmniGen) +- [source](https://github.com/ToTheBeginning/PuLID) +- [source](https://arc2face.github.io/) +- [source](https://github.com/mk-minchul/AdaFace/blob/master/LICENSE) +- [source](https://github.com/IrvingMeng/MagFace/blob/main/LICENSE) +- [source](https://github.com/askerlee/AdaFace-dev) +- [source](https://openreview.net/forum?id=Hc2ZwCYgmB) +- [source](https://github.com/tencent-ailab/IP-Adapter/wiki/IP%E2%80%90Adapter%E2%80%90Face) + +## Empirical follow-up (2026-06-08, end of session) + +After the research synthesis above, InstantID was integrated end-to-end and cert-swept +on Modal A100 in two phases: + +1. **Phase 1: InstantID txt2img per-face crop + composite.** Per-face InstantID + txt2img with the upstream `pipeline_stable_diffusion_xl_instantid`, ArcFace + embedding from the original face, landmark stick figure. Three composite + iterations: + - v1 (rectangular Gaussian alpha on the 2x square_box around each face): + visible patchwork on group photos, generated 1024 backgrounds clashing. + - v2 (tight crop on YuNet-detected face in the generated 1024 + elliptical + alpha 0.45*bw x 0.55*bh + soft feather): ellipse axis exceeded bbox + vertically, clipped forehead/chin on single portrait, group still had + visible elliptical seams + cool-vs-warm tone clash with scene. + - v3 (tighter ellipse 0.32*bw x 0.42*bh + per-channel mean color match to + local cleaned canvas + softer feather): patchwork visually softened; faces + still read as studio portraits inserted into the scene, not as people + shot in the scene. Single portrait identity drifted (tatsunari -> "round + Asian male" vs original's thin face). +2. **Phase 2: InstantID img2img on cleaned crop.** Switched to the upstream + `pipeline_stable_diffusion_xl_instantid_img2img` (downloaded at first use + from raw.githubusercontent.com; requires `trust_remote_code=True`). Same + ArcFace + landmark conditioning but the SDXL diffusion source is the + CLEANED face crop, so the diffusion sees scene lighting / shoulders / + shadow direction directly. Multi-face composition jumped substantially: + faces sit in the bar scene with matching warm tone, no more elliptical + seams. Single-portrait identity at the default (`strength=0.55`, + `ip_adapter_scale=0.8`, `controlnet_conditioning_scale=0.8`) was "similar + person, not exactly the original"; raising to `strength=0.7`, + `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: +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). +Stronger identity push (higher strength / IP-Adapter scale) makes the face +CLOSER to the embedded identity but MORE AI-looking; weaker push leaves +identity to drift but face looks less AI-generated. There is no parameter +setting that simultaneously recovers original identity AND looks less AI than +the cleaned image, because the cleaned image is itself a controlnet-light +denoise of the original (closer to original pixels) while a restore pass is a +full SDXL regeneration (further from original pixels). + +**Operational conclusion.** Do not ship `--restore-faces` in any monetized +deployment. The cleaned image from the main controlnet 0.20 pass is the +LEAST-AI state we can reach without re-introducing SynthID; every restore +method tested (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img, +InstantID img2img-on-cleaned at three parameter sweeps) trades original-look +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 diff --git a/docs/synthid-robust-identity-research.md b/docs/synthid-robust-identity-research.md new file mode 100644 index 0000000..bf28770 --- /dev/null +++ b/docs/synthid-robust-identity-research.md @@ -0,0 +1,282 @@ +# SynthID-robust face identity for an SDXL removal pipeline (research) + +> **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 +> face-restore extra: the cleaned image from the main controlnet 0.20 pass is the +> least-AI face state we can reach without re-introducing SynthID. This document +> is kept as historical record of the exploration. + +**Question.** Which face identity-preservation mechanism for an SDXL img2img + +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 +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. + +**One-line verdict.** Today there is **ONE** SDXL identity-conditioning stack that +is commercial-safe end-to-end: **PhotoMaker-V1** (Apache-2.0, identity encoded as a +fine-tuned OpenCLIP-ViT-H/14 image embedding -- NO InsightFace). Every other +candidate -- **including PhotoMaker-V2**, IP-Adapter FaceID, InstantID, PuLID, +Arc2Face -- inherits InsightFace's non-commercial model-pack license through an +ArcFace-class embedder and is therefore blocked for paid services, regardless of +the adapter's own license header. Below is the evidence per component and the +integration plan. + +**Correction notice (2026-06-04).** An earlier version of this doc claimed +PhotoMaker-V2 was commercial-safe end-to-end. That was WRONG -- the V2 model card +phrase *"id_encoder includes finetuned OpenCLIP-ViT-H-14 and a few fuse layers"* +described one of TWO ID branches; the V2 source (model_v2.py) defines +`PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` whose forward takes an +ArcFace `id_embeds` from `insightface.app.FaceAnalysis`, and the upstream package +`__init__.py` imports InsightFace at module load. A Modal cert sweep caught this +empirically (`No module named 'insightface'` from `restore_faces_photomaker`). V1 +is the correct commercial-safe target: its `PhotoMakerIDEncoder` (model.py) +forward takes only `(id_pixel_values, prompt_embeds, class_tokens_mask)` -- no +ArcFace branch -- so identity is CLIP-only. + +**Status notice (2026-06-04, end of session).** Two commercial-safe paths were +tried and abandoned: + +1. **PhotoMaker-V1** (commercial-safe by license but blocked by upstream compat). + The cert sweep hit a cascade of upstream compatibility issues with the diffusers + version we ship (0.38): missing `einops` declaration, missing `peft` declaration, + default `pm_version='v2'` that mis-loads V1 weights into the V2 encoder, custom + `id_encoder` left on CPU after `pipe.to(device)`, and a CFG-batch tensor-shape + mismatch in the denoising loop (`Expected size 2 but got size 1`). 7 cascading + fixes did not get the pipeline running end-to-end. The PhotoMaker `pipeline.py` + header notes it was forked from diffusers v0.29.1; SDXL prompt-encoder handling + changed significantly between 0.29 and 0.38. +2. **GFPGAN on the diffusion-CLEANED image** (commercial-safe, but no identity + recovery). A one-line change made it SynthID-safe (input pixels are already + clean, so the partial blend cannot transport the watermark), but visual + inspection of the cert output showed it only polished the already-drifted face + without actually restoring identity. Trade-off was real and the value too low + to keep. + +**The shipped path is PhotoMaker-V2** (`photomaker_restore.py`, the `photomaker` +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 +deployment must NOT install the `photomaker` extra. The CLI flag and module +docstring both call this out at every entry point. + +A future commercial-safe path would need either (a) the PhotoMaker upstream to +land its diffusers 0.38 compat fix so V1 can run, or (b) an equally good +ArcFace-class face-recognition model released under a permissive license that +PhotoMaker-V2 can be retargeted to. Neither is on a near-term horizon as of +this writing. + +## 1. Why identity-by-embedding (not by pixel) is the only SynthID-robust path + +The pipeline regenerates pixels to destroy SynthID. Any identity-restoration that +is "faithful to the input pixels" (GFPGAN, CodeFormer, face-swap-by-blending, our +previous restore-on-original pass) reproduces the watermark, because SynthID is +engineered to be robust to fidelity-preserving transforms (resize, JPEG, partial +blend). Oracle-confirmed on a real Gemini face: controlnet @ 0.20/0.25 WITH the +GFPGAN restore pass left SynthID detected; the SAME controlnet @ 0.20 with +`--no-restore-faces` cleared it (clean A/B, see `docs/synthid.md` 5.5 and +`docs/controlnet-removal-pipeline-research.md`). + +The only mechanism that can preserve identity AND not re-introduce SynthID is to +carry identity in a SEMANTIC EMBEDDING (a vector that encodes "who is in this +picture") and use it to CONDITION a fresh generation -- the pixels are new, so +the watermark is not transported. Two embedding families exist in practice: + +- **ArcFace-class face-recognition embeddings** (the InsightFace family). Used by + IP-Adapter FaceID, InstantID, PuLID, Arc2Face. Highest identity fidelity, but + the embedder weights are non-commercial. +- **CLIP image embeddings of a face crop**. Used by PhotoMaker (and the original + IP-Adapter image variant). Lower identity fidelity at small scale than ArcFace, + but the encoder (OpenCLIP-ViT-H/14, MIT) is commercial-safe. + +## 2. License table (verified against primary sources, 2026-06-04) + +| stack | adapter weights | identity encoder | end-to-end commercial-safe? | +|---|---|---|---| +| **PhotoMaker-V1** | **Apache-2.0** ([HF][pmhf]) | **OpenCLIP-ViT-H/14 (MIT)** finetuned, identity from `PhotoMakerIDEncoder` (`model.py`); forward takes only ``(id_pixel_values, prompt_embeds, class_tokens_mask)`` -- no ArcFace branch | **YES** | +| PhotoMaker-V2 | Apache-2.0 (adapter) ([HF][pm2hf]) | DUAL encoder: OpenCLIP-ViT-H/14 AND InsightFace antelopev2/buffalo_l -- `PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` (`model_v2.py`) forward takes `id_embeds` from `insightface.app.FaceAnalysis`, and `photomaker/__init__.py` imports InsightFace at module load | NO -- InsightFace pack is non-commercial | +| IP-Adapter FaceID | non-commercial per model card: *"AS InsightFace pretrained models are available for non-commercial research purposes, IP-Adapter-FaceID models are released exclusively for research purposes and is not intended for commercial use"* ([HF][ipafhf]) | InsightFace antelopev2 (non-commercial for the model pack) | NO -- both layers block | +| InstantID | Apache-2.0 (adapter only) ([HF][insthf]) | requires InsightFace antelopev2 face-analysis at runtime (`FaceAnalysis(name='antelopev2', ...)` per the README usage snippet, [HF][insthf]) | NO -- embedder pack is non-commercial | +| PuLID | apache-2.0 (HF model metadata, [HF][pulidhf]) | depends on InsightFace face-analysis for ArcFace embedding (per the upstream README; PuLID's own card is sparse and the GitHub README documents the InsightFace install step) | NO -- same embedder issue as IP-Adapter FaceID | +| Arc2Face | MIT (HF model metadata, [HF][arc2hf]) | uses `insightface.app.FaceAnalysis` to extract the ArcFace embedding ([HF][arc2hf]); also based on SD-v1-5 (NOT SDXL) | NO -- non-commercial embedder + not SDXL | + +**The crux is InsightFace.** InsightFace explicitly splits its license: *"Code is +MIT licensed; models require separate commercial licensing"* and frames the +pretrained packs as *"Commercial licensing for InsightFace's open-source model +packages"* requiring users to *"obtain commercial usage rights for model +packages"* ([insightface.ai][iflic]). antelopev2 and buffalo_l fall under the +model-pack license, not MIT. So any stack that calls +`insightface.app.FaceAnalysis(name='antelopev2', ...)` to compute its ArcFace +embedding is blocked by default, REGARDLESS of the adapter's own Apache header +above it. This is the same reason IP-Adapter FaceID's card flags itself +non-commercial. + +(Note on PuLID's HF metadata: the model card declares apache-2.0 for the adapter +weights but the upstream repo's quickstart requires the InsightFace package to +extract the ID embedding. So PuLID's adapter license is permissive; the BLOCKER +is the embedder it expects at runtime. This is the same trap as InstantID.) + +[pmhf]: https://huggingface.co/TencentARC/PhotoMaker +[pm2hf]: https://huggingface.co/TencentARC/PhotoMaker-V2 +[ipafhf]: https://huggingface.co/h94/IP-Adapter-FaceID +[insthf]: https://huggingface.co/InstantX/InstantID +[pulidhf]: https://huggingface.co/guozinan/PuLID +[arc2hf]: https://huggingface.co/FoivosPar/Arc2Face +[iflic]: https://www.insightface.ai/solutions/face-recognition-licensing + +## 3. Is there a commercial-safe ArcFace replacement? + +Short answer: **no clean drop-in**. The widely deployed pretrained ArcFace packs +(antelopev2, buffalo_l, glint360k) come from InsightFace and are non-commercial. +ArcFace as an ARCHITECTURE is published in a paper, so retraining is legally fine, +but you would need: + +- a commercial-licensed training dataset (the big public ones -- MS-Celeb-1M, + Glint360K, WebFace -- carry research-only or licensing-uncertain restrictions); +- compute + time to train an ArcFace-class model on the legal dataset; +- the result would be a one-off effort, not a maintained dependency. + +For a removal service this is a multi-month side project that delivers what +PhotoMaker already gives us with one pip install. So the practical answer is to +take the CLIP-embedding path (PhotoMaker-V1; V2 adds InsightFace and is non-commercial), accept the identity-fidelity +trade-off, and revisit ArcFace later if quality is insufficient. + +## 4. Does an identity embedding leak SynthID? + +This is the load-bearing assumption of the whole approach. The argument: + +- SynthID is a low-amplitude, perceptually-invisible pixel watermark engineered + to be robust to "fidelity-preserving" transforms (it survives JPEG, resize, + crop, color, noise at >=99% TPR -- see arXiv:2510.09263 referenced in + `docs/synthid.md`). +- A face-recognition / CLIP-image embedding is by design INVARIANT to such low- + amplitude pixel changes (compression, brightness, small noise should not change + "who is in the photo"). That is the whole training objective. +- Therefore the embedding extracted from a watermarked face vs. the same face + cleaned should be ~identical -- the embedding cannot CARRY the watermark + pattern, only the identity, because the watermark sits in exactly the + dimensions the embedding learned to discard. + +**MEASURED 2026-06-04 — hypothesis confirmed.** Ran a low-amplitude +perturbation sweep on 31 face crops (3 photoreal originals: gemini_3, gemini_4, +openai_3 grid), comparing `cos(embedding(orig), embedding(perturbed))` for OpenCLIP- +ViT-H/14 (laion2B-s32B-b79K, the same OpenCLIP-ViT-H/14 encoder PhotoMaker V1 and V2 both finetune for CLIP-side identity): + +| perturbation | mean cos | min | max | +|---|---|---|---| +| **synthid_proxy** (±2 LSB low-freq noise, σ=4 px Gaussian carrier — same regime SynthID hides in) | **0.9977** | 0.9937 | 0.9996 | +| noise3 (Gaussian σ=3, full-spectrum) | 0.9541 | 0.9055 | 0.9825 | +| jpeg90 (SynthID survives this) | 0.9280 | 0.8806 | 0.9566 | +| blur1 (Gaussian σ=1) | 0.9139 | 0.8103 | 0.9875 | +| jpeg70 | 0.8945 | 0.8125 | 0.9603 | +| (self check: identical crop) | 1.0000 | 1.0000 | 1.0000 | + +The SynthID-magnitude perturbation moves the embedding by **0.002** (cosine 0.9977), +an order of magnitude less than JPEG90 — which SynthID survives at >=99% TPR by +design. So the embedding cannot carry the watermark pattern: its discriminative +signal is in dimensions the SynthID payload does not occupy. PhotoMaker-V1 +conditioned on a watermarked face will see ~the same identity vector as if +conditioned on a clean face of the same person, so the freshly generated face +inherits the identity, not the watermark. + +A first, naive smoke run measured `cos(orig, SDXL-cleaned)` instead — that test is +about diffusion drift, not watermark invariance (diffusion at strength 0.20-0.30 is a +much larger perturbation than SynthID), so its 0.56-0.93 spread is the identity +drift the PhotoMaker pipeline is meant to fix in the first place. The +synthid_proxy result above is the one that actually answers the load-bearing +question. Script: `/tmp/identity_smoke/test2_proxy.py` (not committed; reproducible +from the test set + this doc). + +## 5. PhotoMaker-V1 properties for our pipeline + +- **SDXL-native.** PhotoMaker v1 and v2 target Stable Diffusion XL; the pipeline + is a stacked-ID embedding fused into SDXL's cross-attention via the fuse layers + bundled in the released weights. +- **Identity from a SINGLE reference image works** but the method was designed + for "stacked" multi-reference; with one image identity fidelity is lower than + with 3-4, and a service has only one (the upload). This is the failure mode to + guard. +- **Compatibility with img2img + canny ControlNet.** PhotoMaker is typically + exposed in txt2img workflows in the upstream demo. SDXL img2img + ControlNet + is the same denoising backbone, so the cross-attention injection works the same + way; community examples on Diffusers and ComfyUI confirm PhotoMaker stacks with + ControlNet. Validate this on a representative image before adopting. +- **Failure modes to expect:** + - identity drift on small / multi-face groups (the 9-face grid case); + - "plastic" / over-smoothed faces if PhotoMaker's identity weighting is high + while the img2img strength is low; + - 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 + (especially non-white, non-Western, elderly, children) may have lower + fidelity. Measure on the real upload distribution. + +## 6. Integration cost (rough) + +- New deps: `diffusers` already in the gpu extra; PhotoMaker ships as a `.bin` + loaded via `pipeline.load_photomaker_adapter(...)`. The OpenCLIP encoder is the + same one diffusers already pulls. No new heavy pip dep. +- Weight download: PhotoMaker-V1 weights are ~3 GB. Add to the Modal HF volume + alongside SDXL. +- VRAM: SDXL + canny ControlNet + PhotoMaker-V1 fits comfortably in A100-40GB. +- Latency: a few extra seconds on cold start (load PhotoMaker), negligible per + request after warm-up. +- No InsightFace install: huge win for `restore` extra's basicsr/numpy hell -- + this path simply does not touch that ecosystem. + +## 7. Recommended path + +1. **Embedding-invariance smoke test FIRST** (one afternoon, no codegen): + - For ~10 OpenAI / Gemini watermarked faces, compute OpenCLIP-ViT-H/14 + embeddings; for the same images after our SDXL `default` pass at the + certified strength, compute the embeddings again; assert mean cosine + similarity > ~0.95. + - If yes -> the embedding does not carry SynthID, proceed. + - If no -> the assumption is wrong; PhotoMaker would re-introduce the + watermark. Stop and reconsider. +2. **PhotoMaker-V1 prototype** in the existing `controlnet` pipeline: + - Mirror the `_load_controlnet_pipeline` path: add a PhotoMaker variant that + loads SDXL + canny ControlNet + PhotoMaker adapter on the same engine. + - Extract the OpenCLIP face embedding from the watermarked face crops (use + OpenCV YuNet, already bundled for `auto`, to find the face boxes). + - Pass the embedding as PhotoMaker's `id_embeds` to the SDXL pipeline; run + img2img at the certified strength (0.20 OpenAI, 0.30 Gemini-capped-1536) + with the canny edge map. +3. **Oracle validation** on the cert sweep: run the new PhotoMaker variant + through `raiw-app/modal_cert.py` over the same 6 image set, certify on the + 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 + 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. + +## 8. What we are NOT doing, and why + +- **No InsightFace.** Non-commercial for model packs (see License table). +- **No CodeFormer.** Non-commercial. +- **No GFPGAN on the original image.** It re-introduces SynthID + (oracle-confirmed). +- **No GFPGAN on the cleaned image.** It cannot RECOVER identity that the + diffusion pass already drifted -- it can only smooth/sharpen whatever face is + already there. Useful as cosmetic polish, not as identity restoration. +- **No retraining of an in-house ArcFace.** Out of scope for a removal service. + +--- + +## Process note + +The deep-research harness was run but its verifier subagents failed to call +`StructuredOutput` (same harness bug as the prior 2026-05-XX run), so its synthesis +was unusable. The license claims above were verified by directly fetching the HF +model cards and the InsightFace licensing page and quoting them; the +embedding-invariance argument is mechanistic and explicitly flagged as not yet +measured (it is the first integration step). Do not treat the deep-research +output as ground truth for this file. diff --git a/docs/synthid.md b/docs/synthid.md new file mode 100644 index 0000000..05981ef --- /dev/null +++ b/docs/synthid.md @@ -0,0 +1,628 @@ +# SynthID-Image: technical reference + +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 +inform decisions about strength settings, test methodology, and what oracle +results mean. + +Primary sources are cited inline. Marketing-only claims are flagged separately +from independently-verified results. + +--- + +## 1. Mechanism + +### 1.1 Post-hoc, model-independent design + +SynthID-Image is **not** baked into a diffusion model's weights. It is a +post-hoc, model-independent system: a separate encoder `f` is applied to an +already-generated image, and a separate decoder `g` reads it back. + +> "We deliberately designed SynthID-Image as a post-hoc, model-independent +> approach, a choice largely based on deployment considerations." +> -- Gowal et al., arXiv:2510.09263 + +The formal definition from the paper: + +> "A post-hoc watermarking scheme is a pair f, g consisting of an encoder +> function f: X -> X, which adds an identification mark, and a decoder +> function g: X -> {+-1}, which tries to detect if the mark is present." + +This is the key architectural fact: **the generative model (Imagen, Gemini's +image model) is not modified**. The watermark is stamped onto the pixel output +after generation, by a separate neural network. This means: + +- The watermark is in **pixel space**, not in the model's latent activations. +- Replacing the generative model does not remove the watermarking capability. +- The encoder/decoder pair can be updated independently of the generative model. + +The paper does not disclose the internal architecture of the encoder/decoder +networks (layer types, capacity). The external variant SynthID-O is available +to partners; the production internal variant is not published. + +### 1.2 How it differs from classical DWT-DCT watermarks + +The open watermarks used by Stable Diffusion / SDXL / FLUX (via the +`imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a +fixed bit pattern is added to specific frequency coefficients of the image's +wavelet transform. This is fast, key-free, and locally detectable with a public +decoder. + +SynthID-Image uses **jointly-trained deep learning models**: + +> "SynthID uses two deep learning models -- for watermarking and identifying -- +> that have been trained together on a diverse set of images. The combined model +> is optimised on a range of objectives, including correctly identifying +> watermarked content and improving imperceptibility by visually aligning the +> watermark to the original content." +> -- Google DeepMind blog, 2023 + +The practical difference for robustness: the deep learning encoder learns to +spread the signal across the image in a way that is optimized to survive a +specific perturbation distribution seen during training. Classical DWT-DCT +embeds in fixed, predictable frequency bins, making it brittle to any +operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at +quality <= 90). + +### 1.3 Payload capacity + +SynthID-O (the external/partnership variant) encodes: + +- **136 bits** within a **512x512 pixel image** + +For comparison (from the same paper): + +| Method | Bits | Resolution | +|-------------|------|------------| +| SynthID-O | 136 | 512x512 | +| StegaStamp | 100 | 400x400 | +| TrustMark | 100 | 256x256 | +| WAM | 32 | 256x256 | + +The payload carries an identification mark (not a user-readable secret). The +paper separates watermark **detection** (is this watermarked?) from payload +**recovery** (what does the payload say?): the detection path is what oracles +like the Gemini app's "Verify with SynthID" exercise. + +### 1.4 Where in the pipeline it lives + +``` +[Diffusion model] + | + raw pixel output + | + [SynthID encoder f] <-- separate neural net, stamps the watermark + | + watermarked image + | + [served / downloaded] + | + [SynthID decoder g] <-- separate neural net, run by Google's verifier only + | + present / not present +``` + +The VAE decoder of the diffusion model is **not** involved in watermarking. +Some in-generation watermark approaches (like the research method "Tree Ring") +inject the signal into the initial noise latent so it propagates through the +diffusion process and appears in the final image; SynthID-Image does not do +this -- it is applied after the VAE has already decoded latents to pixels. + +--- + +## 2. Robustness + +### 2.1 What the paper claims it survives (primary-source verified) + +The SynthID-Image paper (arXiv:2510.09263) evaluates SynthID-O against **30 +image transformations** grouped into 6 categories: + +| Category | Examples | +|-------------|-----------------------------------------------| +| Color | brightness, contrast, saturation, hue shifts | +| Combination | combinations of multiple transforms | +| Noise | Gaussian noise, impulse noise, median filter | +| Overlay | text overlays, logos, stickers | +| Quality | JPEG compression, WebP, format conversion | +| Spatial | crop, resize, rotate, flip, padding | + +**TPR at 0.1% FPR -- SynthID-O vs. baselines (resized to 512x512):** + +| Category | SynthID-O | Best baseline (WAM) | Worst baseline (StegaStamp spatial) | +|------------------|-----------|---------------------|--------------------------------------| +| Identity (none) | 100.00% | 100.00% | 100.00% | +| Aggregated | 99.98% | 90.62% | ~70% | +| Color | 100.00% | 81.29% | ~75% | +| Combination | 99.96% | 96.08% | ~22% | +| Noise | 99.98% | 100.00% | ~92% | +| Overlay | 100.00% | 100.00% | 100.00% | +| Quality | 99.99% | -- | ~89% | +| Spatial (worst) | 99.97% | 76.04% | 15.25% | + +The "Spatial worst" row is the hardest case (aggressive crop + resize). +SynthID-O retains 99.97% TPR; StegaStamp collapses to 15.25%. This is where +the deep-learning approach gains the most over classical methods. + +Google's marketing page states the watermark is: + +> "designed to stand up to modifications like cropping, adding filters, changing +> frame rates, or lossy compression." +> -- deepmind.google/models/synthid/ + +The marketing claim is broadly consistent with the paper's numbers for these +specific categories. + +**JPEG and format conversion specifically** fall under the "Quality" category, +where SynthID-O achieves 99.99% TPR. This is the empirical basis for the fact +that **GitHub-recompressed JPEGs from issue attachments are valid SynthID test +subjects**: the re-encoding does not remove the pixel watermark. + +### 2.2 Stated limits (vendor claim, not independently verified) + +> "SynthID isn't foolproof against extreme image manipulations." +> -- Google DeepMind blog, 2023 + +This is the only public failure-mode statement Google has made. No specific +perturbation type, threshold, or quantitative boundary is named. The +Limitations section of the paper (Section 10) was not recoverable from the +public HTML version of arXiv:2510.09263v1 due to a rendering failure in the +conversion (the body text of Section 10 is absent from the HTML). + +**What is known empirically from our own oracle-verified testing.** + +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). + +**Oracle validation order: start with OpenAI.** When validating removal across +vendors, run the OpenAI arm first. `openai.com/verify` is more accessible than the +Gemini app -- fewer per-check restrictions, so it gives the fastest signal and is +the strongest candidate for automation (Playwright / Chrome MCP driving +`openai.com/verify`); the Gemini "Verify with SynthID" flow is more manual. This is +an ordering/throughput choice, not a substitution: each oracle only reads its own +vendor's SynthID (`openai.com/verify` is OpenAI-scoped), so Google content still +needs the Gemini app. + +| Vendor | Images | Resolution(s) | Pipeline | Removed at | +|--------|--------|---------------|----------|------------| +| OpenAI (gpt-image) | n=4 (3 archived + 1 external-only) | 1024x1536 .. 1600x1600 | native | **0.05** | +| Google (Gemini) | n=4 | 2816x1536 -> capped 1536 | `--max-resolution 1536` | **0.15** (0.05 and 0.10 do NOT clear) | + +**Two findings, both oracle-verified:** + +1. **Vendor is the dominant factor, not resolution.** Google's SynthID is + roughly 3x more robust than OpenAI's: at a comparable (small) working + resolution, OpenAI clears at 0.05 while Google needs 0.15. This matches + Google having hardened SynthID more aggressively over time. + +2. **OpenAI SynthID removal is resolution-independent in the tested range.** + All four OpenAI images (including a 1600x1600) cleared at 0.05. + +**CORRECTION (supersedes the earlier "resolution dependence" claim).** A prior +version of this doc and CLAUDE.md stated that strength 0.30 failed to remove +SynthID on 1600x1600 gpt-image and that removal was resolution-dependent. That +was a **measurement artifact of a since-removed per-region re-scrub step** (issue +#14): on the dense-text infographics tested, that step could reconstitute SynthID +in text regions. Re-running the *same* 1600x1600 image on the clean current +pipeline removes SynthID at **0.05**. The "large images resist removal" conclusion +was false; the resistance was that region-rescrub shielding, since removed. + +**Open / not locally testable:** + +- **Native large Gemini (2816x1536, ~4.3 MP).** The Gemini floor of 0.15 was + measured on the *capped* (`--max-resolution 1536`) path, which is the + practical local route on Apple-Silicon (native 2816 OOMs / falls back to slow + CPU on a 32 GB M-series). Native large Gemini was not measured here; the + vendor and resolution effects would stack, so it plausibly needs >= 0.30 or a + discrete GPU. Confirm on a CUDA box if needed. +- **Heavy JPEG compression** (quality < ~50-60): not oracle-tested; the DL + approach is more robust than DWT-DCT but Google acknowledges limits at + "extreme" manipulation. + +### 2.3 Removal attacks and forensic detectability + +The paper arXiv:2605.09203 ("Removing the Watermark Is Not Enough", +Goonatilake & Ateniese, 2026) evaluates 6 removal attacks against a ResNet-50 +forensic detector. All attacks defeat the watermark verifier but are detected +by the forensic classifier: + +| Attack | Family | AUROC | TPR @ 1% FPR | TPR @ 0.1% FPR | +|-----------------|------------------|--------|--------------|----------------| +| UnMarker | Distortion | 0.9994 | 99.81% | 98.28% | +| WatermarkAttacker| Regeneration | 0.9997 | 99.95% | 99.38% | +| CtrlRegen+ | Regeneration | 0.9999 | 99.97% | 99.64% | +| NFPA | Inversion/Pert. | 0.9984 | 99.24% | 62.10% | +| Boundary Leak. | Inversion/Pert. | 0.9991 | 99.24% | 88.34% | +| WiTS | Erosion | 0.9999 | 99.80% | 99.55% | + +The forensic detector is a standard ResNet-50 fine-tuned end-to-end; no exotic +architecture needed. The key finding: + +> "These removers do not return images to a clean forensic state. They often +> trade an explicit watermark for an implicit watermark: a detectable artifact +> introduced by the removal process itself." + +This means: even when our SDXL img2img pass defeats the SynthID pixel +watermark (oracle reads negative), the output may still be classifiable as +"an image that went through a removal pipeline" by an independent detector -- +even if that detector is not trained on SynthID specifically. **Defeating the +verifier does not restore forensic deniability.** + +CtrlRegen+ is the most detectable removal method (AUROC 0.9999), which is +notable because it is also the most powerful removal attack. The paper notes +that diffusion regeneration "leaves a strong reconstruction signature from the +diffusion prior." + +--- + +## 3. Detectability and verifier access + +### 3.1 No public local detector + +The SynthID decoder is proprietary and not released: + +> "SynthID-Image has been used to watermark over ten billion images and video +> frames across Google's services and its corresponding verification service is +> available to trusted testers." +> -- Gowal et al., arXiv:2510.09263 + +There is no public API, no released decoder weights, and no reproducible +algorithm for local detection. The verification service (SynthID Detector) is: + +> "a verification portal" in early testing with "journalists and media +> professionals" on a waitlist +> -- deepmind.google/models/synthid/ + +The external variant SynthID-O is available "through partnerships" only. Our +tool cannot locally detect SynthID presence or absence -- this is by design, +not a gap we can fill. + +### 3.2 How our tool detects SynthID (metadata proxy) + +We detect SynthID indirectly: if the image's C2PA manifest is signed by a +known SynthID-using issuer (Google, OpenAI), we infer SynthID is present. This +is a **metadata proxy**, not a pixel watermark decode. It works while the C2PA +manifest is intact, and is silent once the manifest is stripped or the image +is re-encoded without C2PA (e.g., a screenshot, a social-media re-upload, or +after `metadata --remove`). + +This is why: +- `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is + gone) even though the pixel SynthID is still present and detectable by + openai.com/verify. +- A quiet `identify` output is not proof that SynthID was removed -- it only + means the metadata signal is gone. + +### 3.3 Oracle scope: each vendor detects only their own + +From openai.com/research/verify (verbatim, verified 2026-05-31): + +> "OpenAI generation signals will only be detected if the image was generated +> with our tools." +> "Content could also still be AI-generated by another company's model, which +> the tool currently does not detect." + +SynthID technology is used by multiple vendors, but each verifier is keyed to +its own payload: + +| Oracle | Detects | Does NOT detect | +|-------------------------------|------------------|-------------------------| +| Gemini app "Verify with SynthID" | Google SynthID | OpenAI SynthID | +| openai.com/research/verify | OpenAI SynthID | Google SynthID | + +A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads +clean in the Gemini oracle. They are different payloads within the same +framework. + +--- + +## 4. Adoption and current state (as of June 2026) + +### 4.1 Google products + +Google has watermarked **over 10 billion** images and video frames. The +deployment split by surface matters for our tool: + +| Surface | SynthID pixel | C2PA metadata | Visible sparkle | +|--------------------------------------|---------------|---------------|-----------------| +| Gemini app (generated images) | YES | YES (Google) | YES | +| Gemini API / AI Studio / Nano Banana | YES | NO | YES | + +The Gemini API surface is a key blind spot: it embeds the pixel watermark and +the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns +Unknown on API-generated images unless the visible sparkle is detected (via +`check_visible=True`) or the user runs the Gemini app oracle. + +### 4.2 OpenAI + +OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21): + +> "ChatGPT images include both C2PA metadata and SynthID watermarks." + +This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without +SynthID. Our C2PA proxy therefore over-reports SynthID presence on old images +(hence the `_OPENAI_CAVEAT` hedging flag in the codebase). + +### 4.3 Other vendors + +- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement) +- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline) +- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead + +### 4.4 Version evolution (v1 vs v2 hardening) + +Google has not publicly documented version numbers for the SynthID image +watermark in a way that maps to our testing observations. What is known +empirically from oracle tests: + +- **Before May 2026 (Gemini)**: strength 0.05 removed the watermark +- **May 2026 (Gemini)**: strength 0.05 insufficient; 0.10 required +- **Current (Gemini, June 2026)**: on the capped 1536 path, 0.05 and 0.10 do + NOT clear; 0.15 clears (n=4, Gemini app oracle). See section 2.2. +- **OpenAI (June 2026)**: clears at 0.05 across 1024-1600 (n=4, clean v0.8.6). + The earlier "0.30 still detected on 1600x1600" report (issue #14) was the + text-protection bug, not a hardening of the watermark -- see the correction in + section 2.2. + +Google has hardened SynthID relative to OpenAI's (vendor gap measured at ~3x +strength), but the year-over-year "0.05 -> 0.10 -> 0.30" progression above +conflates a real hardening trend with the now-debunked region-rescrub artifact; +treat only the section 2.2 controlled numbers as authoritative. + +--- + +## 5. Practical implications for this tool + +### 5.1 Preserving content means regenerating it, never copying it + +**Core rule:** SynthID is a pixel-amplitude pattern, so any approach that FREEZES +or RESTORES original pixels in a region re-introduces the watermark there. Early +region-based text/face "protection" (since removed) proved this: restoring the +original face pixels guaranteed SynthID survived in faces, and even a per-region +high-resolution re-scrub from an upscaled crop could be insufficient to destroy +the payload, reconstituting SynthID in text. The lesson held and shaped the +current design: **content is preserved by REGENERATING it under structural +conditioning, never by copying original pixels.** + +- **Text + structure:** `--pipeline controlnet` (SDXL img2img + a canny ControlNet) is + **THE DEFAULT pipeline since 2026-06-09** (`--pipeline default` opts down to plain + SDXL img2img for inputs without text/faces). It conditions the regeneration on the + edge map, so text and structure stay sharp while every pixel is still regenerated. Text legibility is + better than plain img2img at the same strength (text stays readable where plain + garbles it). **BUT removal efficacy at the low vendor-adaptive strength is CONTENT × + PIPELINE dependent and NEITHER pipeline clears all content -- oracle-validated + 2026-06-04 (8 OpenAI images, strength 0.10/0.15, max-res 1536).** The survivors FLIP + by content type: **photoreal** (a 9-face grid, a bracelet product photo) SURVIVES + controlnet but CLEARS `default`; **flat graphic** (a logo/poster with large flat + color fills) SURVIVES `default` but CLEARS controlnet; a flat **text** card cleared + under both. Why: controlnet's dense edge map keeps the regen too close to the + original on photoreal (so SynthID survives) but freely repaints flat fills (so it + clears them); plain img2img at low strength perturbs photoreal texture enough but + barely touches flat fills. **Root cause = insufficient STRENGTH, not the pipeline: + the vendor-adaptive 0.10 is NOT universally sufficient (the June numbers below held + for the content they were measured on). 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 neither `--pipeline controlnet` nor plain `default` is a drop-in + removal guarantee at today's strength -- pick by what you must PRESERVE (controlnet + for text/structure), then raise strength until the oracle reads clean. (The earlier + "reads clean on the oracle" claim held only for the one flat/text-background case it + was checked on; it does not generalize.) **UPDATE 2026-06-09: the default strengths + were raised and made pipeline-aware (controlnet ladder = the certified + 0.20/0.30/0.30 floors, applied to BOTH pipelines as a single ladder -- see §5.2 for + why one ladder covers plain `sdxl` too) and controlnet is now the default pipeline. + The plain-SDXL profile was also renamed `default` -> `sdxl` (`default` stays as an + alias). The 0.10/0.15 numbers in this analysis are the PRE-raise values it was + measured at. See §5.2.** +- **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`.) + +### 5.2 Strength setting + +There is no single permanent correct strength, but the controlled June 2026 +study (section 2.2) gives empirical floors: + +- **OpenAI**: 0.05 clears across 1024-1600 (n=4) -- **but content-dependent, NOT + universal.** The follow-up oracle pass (2026-06-04, 8 images) found a flat-graphic + OpenAI logo/poster still SynthID-detected after `default` at 0.10, and photoreal + images still detected after controlnet at 0.10/0.15: at low strength the + low-change regions (large flat fills under `default`, dense edges under controlnet) + are not perturbed enough. So the 0.05 floor held only for the n=4 content it was + measured on; treat it as a lower bound, not a guarantee, and raise + oracle-recheck + per content type (see §5.1 controlnet bullet). +- **Google (capped 1536)**: 0.15 (n=4); 0.05 and 0.10 do not clear. +- **Google native 2816**: not locally measured; likely needs >= 0.30 (vendor + + resolution stack). Use a GPU or `--max-resolution 1536`. + +The default is **vendor-adaptive** (`watermark_profiles.resolve_strength` + +`vendor_for_strength`): the tool reads the C2PA issuer on the original input and picks +`OPENAI_STRENGTH` 0.10 / `GEMINI_STRENGTH` 0.15 / `UNKNOWN_STRENGTH` 0.15 **(LOWERED +2026-06-14 from the 2026-06-04 cert floors 0.20/0.30/0.30)**. **The SAME ladder applies +to both pipelines** (`sdxl` and `controlnet`). The 2026-06-14 re-test on the deployed +Modal controlnet worker (v0.10.0) cleared SynthID on the oracle at OpenAI 0.10 (2 +photoreal) and Google 0.15 (2 NATIVE 2816x1536, contradicting the "native >= 0.30" guess +on line above), and a pixel sweep showed 0.20/0.30 over-regenerated for no efficacy gain. +**This re-opens a genuine tension with the 2026-06-04 pass, which found photoreal STILL +detected after controlnet at 0.10/0.15 (lines above):** either the v0.10.0 controlnet +default improved the floor, or n=2 landed on the lucky side of the seed-non-determinism +(§5.5). So a SERVICE on this ladder MUST pin a fixed, oracle-verified seed (not random), +and flat-graphic hard cases (NOT in the n=2 re-test) still need a per-content oracle +recheck -- raise `--strength` there. The prior cert floors are the §5.5 record. Why one ladder +covers plain `sdxl` too: the certification was run on controlnet and does NOT transfer +by symmetry (the two pipelines have OPPOSITE hard cases -- controlnet leaves SynthID on +photoreal, `sdxl` on flat graphics, the §5.1 content-x-pipeline table), 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 -- the +certified floor is therefore the right floor for `sdxl` too. This is a MARGIN argument +for `sdxl`, not a separate certification (no local SynthID detector to self-verify). +The higher strength costs little quality where it matters, because `controlnet` is now +the default pipeline, so `sdxl` is reached only via an explicit `--pipeline sdxl` (a +deliberate opt-down), where over-regeneration has no faces/text to damage. +This uses the vendor signal we DO have locally (the C2PA SynthID proxy) to avoid the +overkill of a single high default on OpenAI images, without needing a local pixel +detector. An explicit `--strength` always wins. If the watermark still survives (e.g. a +large native Gemini beyond the capped-1536 validation), raise toward 0.35-0.40 (0.40 +visibly corrupts dense text), using the lowest value that reads clean on the oracle. + +### 5.3 Test methodology + +- **GitHub-recompressed JPEGs from issue attachments are valid SynthID test + subjects.** JPEG re-encoding removes C2PA metadata but does NOT remove the + SynthID pixel watermark (verified June 2026 on issue #14 pic3). Do not + dismiss these as "not faithful originals" for SynthID-removal tests. +- **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini + app. The two oracles detect different payloads. +- **A quiet `identify` output after processing is not proof of removal.** It + means the metadata proxy is gone. The pixel watermark state is unknown without + an oracle check. +- **After removal, the output may carry forensic artifacts** detectable by an + independent classifier even if the vendor oracle reads negative. Defeating the + verifier is not the same as being forensically indistinguishable from clean + content (arXiv:2605.09203). + +### 5.4 Strength vs forensic detectability: the tradeoff + +Higher img2img strength removes the watermark but introduces detectable +regeneration artifacts. The Goonatilake & Ateniese paper shows the strongest +diffusion-based removers are simultaneously the most forensically detectable +(AUROC up to 0.9999). The tradeoff is unavoidable with current diffusion-based +approaches: defeating the vendor's verifier is not the same as being clean. + +### 5.5 Oracle validation log -- 2026-06-04 OpenAI pass + +Eight OpenAI `gpt-image` originals run through both pipelines and checked on +openai.com/verify (the OpenAI SynthID oracle). `--max-resolution 1536`; strength +is the vendor-adaptive default (`vendor_for_strength`): images with an OpenAI C2PA +manifest get `OPENAI_STRENGTH` 0.10, the one without C2PA falls to +`UNKNOWN_STRENGTH` 0.15. "detected" = SynthID still found (removal FAILED); +"clean" = SynthID not detected. + +| 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** | +| 9-face grid | photoreal | 1448x1086 | 0.10 | **detected** | clean | +| bracelet product photo | photoreal | 1600x1600 | 0.15 | **detected** | clean | + +(The other four cleared under both and are omitted.) **Reading:** at this strength +NEITHER pipeline removes SynthID on all content -- the survivors flip by content +type. Photoreal survives controlnet / clears `default`; flat graphic survives +`default` / clears controlnet; flat text clears both. + +**Follow-up: removal near the threshold is NON-DETERMINISTIC (seed-dependent).** +Re-running the two photoreal survivors through controlnet at an explicit +`--strength 0.15` (`--auto`, same `--max-resolution 1536`) cleared BOTH on the +oracle (SynthID not detected). But the bracelet had SURVIVED controlnet at the +SAME 0.15 in the first pass (it was the no-C2PA image, so its vendor-adaptive +strength was already 0.15) -- same pipeline + strength + resolution, only the +random (unset) seed differed between runs. So **0.15 is the borderline floor for +controlnet photoreal, not a robust guarantee**: at the threshold the same +image+settings can pass or fail run-to-run. img2img runs with `seed=None` (random) +unless `--seed` is passed, so a removal SERVICE gets a coin-flip near threshold and +has no local SynthID detector to self-verify. + +**Controlnet strength ladder on the two photoreal images (oracle, `--auto`, +`--max-resolution 1536`):** + +| controlnet strength | 9-face grid | bracelet photo | +|---|---|---| +| 0.10 | detected | (was 0.15) | +| 0.15 | clean | **non-deterministic** (survived pass 1, clean pass 2) | +| **0.20** | **clean** | **clean** | + +**Recommended robust controlnet strength = 0.20** (0.05 of margin above the 0.15 +non-deterministic borderline); both photoreal survivors cleared at 0.20. Honest +caveat: 0.20 is one confirming run WITH margin, not an N-run repeatability proof -- +for a removal service, add a little more margin or validate repeatability, since +there is no local SynthID detector to self-check. **Implications:** (1) the +content×pipeline table above conflates a borderline/non-deterministic 0.15 result +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.** +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 +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) + stayed **SynthID-detected at controlnet 0.15, 0.20 AND 0.25** -- raising strength + did not crack it. +- **Root cause was the face-restore pass, not strength/resolution.** `gemini_3` at + controlnet 0.20 with `--no-restore-faces` read **SynthID-NOT-detected** (clean + A/B, only restore differed). GFPGAN runs on the ORIGINAL watermarked face and at + weight 0.5 blends ~half its pixels back, re-introducing SynthID into the + composited face over the diffusion-cleaned result (see §5.1 face-identity bullet). +- (Side note: reducing the processing resolution does NOT weaken SynthID -- it is + 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 (Modal GPU sweep `raiw-app/modal_cert.py` + 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 +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). + +--- + +## References + +1. Gowal et al. (2025). **SynthID-Image: Image watermarking at internet scale.** + arXiv:2510.09263. https://arxiv.org/abs/2510.09263 + +2. Google DeepMind. **Identifying AI-generated images with SynthID.** Blog post, + 2023. https://deepmind.google/blog/identifying-ai-generated-images-with-synthid/ + +3. Google DeepMind. **SynthID.** Product page. + https://deepmind.google/models/synthid/ + +4. Goonatilake & Ateniese (2026). **Removing the Watermark Is Not Enough: + Forensic Stealth in Generative-AI Watermark Removal.** arXiv:2605.09203. + https://arxiv.org/abs/2605.09203 + +5. OpenAI. **Verify tool for AI-generated images.** openai.com/research/verify. + Accessed 2026-05-31. diff --git a/docs/text-protection-research.md b/docs/text-protection-research.md new file mode 100644 index 0000000..5c6ab50 --- /dev/null +++ b/docs/text-protection-research.md @@ -0,0 +1,140 @@ +# Text protection research: crisp text under a "watermark removed everywhere" constraint + +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. + +## The constraint that frames everything + +The invisible watermark (Google SynthID) must be removed **everywhere, including inside +text regions**. Therefore any technique that keeps or composites the **original +(watermarked) text pixels** is disqualified — the text must be *regenerated / freshly +synthesized* enough to scrub the watermark, yet rendered crisply. This single rule is the +filter applied to every candidate below. + +## Problem recap + +The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default +strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the +current threshold for fresh Gemini output); higher strength deforms text more, which is +exactly why text protection matters. Text is protected via Differential Diffusion with a +per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector +(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or +garbles** (issue #14, confirmed on real content). + +## Executive summary + +The fine-text softening is an **architectural consequence of latent-space processing, not +a tuning problem**: SDXL's 4-channel VAE (~48x compression) discards high-frequency signal +on encode, and Differential Diffusion blends in latent space with the change map +downsampled by 8x, so any stroke under ~8 px sits inside one latent cell and cannot be +preserved or edited cleanly **regardless of `preserve`** (the Differential Diffusion +authors state this limit explicitly). Two structurally sound directions keep the +"watermark removed everywhere" guarantee because they **synthesize fresh glyph pixels** +rather than compositing originals: (1) glyph/text-conditioned diffusion re-render of +detected text (AnyText2, EasyText), and (2) a two-stage architecture — global scrub, then +a dedicated text-restoration / text-aware super-resolution pass over detected regions +(TIGER, TextSR, TeReDiff/TAIR). **EasyText** and **TextSR** are the most promising for this +CJK-first pipeline (both multilingual via DiT/ByT5, both regenerate from glyph or +character-shape priors). The deepest fix — a 16-channel (SD3/FLUX) VAE — materially reduces +the softening but means switching the base model, not a drop-in VAE swap. + +## Constraint reconciliation (important) + +The generic research "quick win: bump `preserve` toward 1.0" is **invalid under our hard +constraint**: raising `preserve` freezes the text region, so SynthID there is **not +scrubbed**. Likewise, pixel paste-back of the original text is disqualified. The only +constraint-compatible quick win is **higher resolution / tiled diffusion** (strokes span +more latent cells, less VAE softening, while the text is still fully regenerated and thus +scrubbed). The real answer is **regenerate text crisply**, not freeze it. + +## Findings (with confidence and sources) + +### Finding 1 — confidence: high + +**Claim.** The small-text softening is an architectural latent-space limit, not a tuning issue. SDXL's VAE compressively encodes (losing exact color and fine detail on every round-trip), and Differential Diffusion blends in latent space with the change map downsampled to latent resolution (8x), so the method explicitly caps edit/preserve granularity at ~8 px under SD settings. Text strokes below one latent cell cannot be cleanly preserved even at preserve ~0.9. + +**Evidence.** Differential Diffusion's paper states a "cap on the resolution of the change map ... can limit the ability to precisely edit small objects (less than 8 pixels for Stable-Diffusion's settings)"; the official SDXL pipeline downsamples the map by `vae_scale_factor=8` and blends `latents = original*mask + latents*(1-mask)` in latent space. The VAE encode is "compressive ... exact color qualities and exact visual fine-details are lost." arXiv:2512.05198 confirms "resizing the pixel mask to latent resolution discards fine structure ... downsamples by 1/8" and that linear latent blending "cannot be pixel-equivalent." Higher compression = more high-frequency loss (arXiv:2305.02541). + +**Sources.** https://onlinelibrary.wiley.com/doi/10.1111/cgf.70040 · https://differential-diffusion.github.io/ · https://github.com/exx8/differential-diffusion · https://arxiv.org/abs/2512.05198 · https://omriavrahami.com/blended-latent-diffusion-page/ · https://arxiv.org/pdf/2305.02541 + +### Finding 2 — confidence: low (do not build on it yet) + +**Claim.** Pixel-space differential / blended-latent variants exist as a research direction, but the specific full-resolution-mask solution (PELC/DecFormer, arXiv:2512.05198) was NOT verified to deliver its claimed seam/edge improvements. + +**Evidence.** arXiv:2512.05198 argues linear latent blending is not pixel-equivalent and proposes decoder-equivariant compositing; PixPerfect (arXiv:2512.03247) does pixel-space refinement of chromatic shifts at edit boundaries. But the specific PELC full-resolution-mask and DecFormer "53% error reduction" claims were **refuted on adversarial vote (0-3 and 1-2)**. Treat pixel-equivalent latent compositing as an emerging idea to watch, not a production fix. + +**Sources.** https://arxiv.org/abs/2512.05198 · https://arxiv.org/abs/2512.03247 + +### Finding 3 — confidence: high + +**Claim.** Glyph/text-conditioned diffusion can re-render detected text as freshly synthesized pixels (not copied), which inherently scrubs any watermark in the text region while rendering glyphs crisply. AnyText/AnyText2 inject text-rendering into a pretrained T2I model and support generation AND editing of existing scene images; multilingual including CJK and English. + +**Evidence.** AnyText2 "enables precise control over multilingual text attributes in natural scene image generation and editing" (WriteNet+AttnX); +3.3% (Chinese) / +9.3% (English) accuracy over AnyText v1. AnyText "can be plugged into existing diffusion models ... for rendering or editing text" and synthesizes text latent features through diffusion (fresh pixels), supporting zh/en/ja/ko/ar/bn/hi. **Caveat:** both are SD1.5-based, so NOT a drop-in into the SDXL scrub (separate base model); AnyText's own limitation: "the inpainting manner ... impedes editing quality on small text," and it ranks weak on STRICT (EMNLP 2025) — small-text crispness not guaranteed. + +**Sources.** https://github.com/tyxsspa/AnyText2 · https://arxiv.org/abs/2411.15245 · https://arxiv.org/abs/2311.03054 + +### Finding 4 — confidence: high + +**Claim.** EasyText is a strong glyph-conditioned re-render candidate: built on the FLUX-dev DiT framework with LoRA tuning, renders compact per-character glyph patches (64px-high adaptive for alphabetic, 64x64 for logographic) concatenated in latent space, supports 10+ languages including Chinese, Japanese, Korean, Thai, Vietnamese, Greek, and Latin. + +**Evidence.** AAAI 2025 + arXiv:2505.24417: "implemented based on the open-source FLUX-dev framework with LoRA-based parameter-efficient tuning," VAE and text encoder frozen, two-stage 512->1024 training. Glyph conditioning via "64-pixel-high images ... adaptive widths for alphabetic; fixed 64x64 for logographic," VAE-encoded and concatenated with denoised latents, "less than one-tenth the spatial size of layout-matching methods." FLUX-based (16-channel VAE, DiT) also sidesteps the SDXL 4-channel wall. Fresh-pixel generation preserves the watermark-removal guarantee. Cyrillic/Arabic crispness not separately benchmarked. + +**Sources.** https://arxiv.org/html/2505.24417 · https://ojs.aaai.org/index.php/AAAI/article/view/37697 + +### Finding 5 — confidence: high + +**Claim.** A two-stage "global watermark scrub then text-restoration pass" architecture is validated by recent literature, and the restoration stage can synthesize glyph pixels from priors (no original-pixel reintroduction). TIGER reconstructs stroke geometry then injects it as guidance into full-image super-resolution; TextSR uses a detector + multilingual OCR to regenerate text from character-shape priors; TeReDiff/TAIR couples a jointly-trained text-spotter with diffusion. + +**Evidence.** TIGER (arXiv:2510.21590): "a diffusion-based local text refiner ... reconstructing fine-grained stroke geometry ... injected as conditional guidance into the subsequent full-image restoration." TextSR (arXiv:2505.23119, Google): "leverages a text detector ... then employs OCR to extract multilingual text," regenerating from "multilingual character-to-shape diffusion priors" that "produce character shapes solely based on text prompts, even without visual input" — fresh pixels. TAIR/TeReDiff (ICLR 2026): standard restoration "frequently generates plausible but incorrect textures"; TeReDiff feeds text-spotter outputs back as prompts. **Caveat:** TIGER orders text-first then global (reverse of scrub-then-text); these target degraded-input super-resolution, not watermark removal, so the SynthID-scrub of the restoration stage must be verified empirically (the stages are themselves diffusion-based, so fresh-pixel = no SynthID is plausible but unproven here). + +**Sources.** https://arxiv.org/html/2510.21590v1 · https://arxiv.org/html/2505.23119v1 · https://cvlab-kaist.github.io/TAIR/ · https://arxiv.org/abs/2506.09993 + +### Finding 6 — confidence: high + +**Claim.** Switching to a 16-channel VAE (SD3/FLUX class) materially reduces small-text/latent softening vs SDXL's 4-channel VAE, but it requires switching the base model — not a drop-in latent swap into an SDXL UNet img2img pipeline. RAE approaches are DiT-native and likewise not drop-in. + +**Evidence.** SD3/FLUX moved from 4-channel (48x) to 16-channel (12x) VAEs specifically to preserve fine detail (diffusers Discussion #8713; madebyollin VAE notes; arXiv:2305.02541). RAE (arXiv:2510.11690) "should be the new default for diffusion transformer training" but produces high-dimensional latents needing a DiT wide-DDT head — NOT compatible with an SDXL 4-channel UNet. EasyText shows the practical path: adopt a FLUX-DiT base rather than retrofit SDXL. The VAE upgrade couples to a base-model migration. + +**Sources.** https://arxiv.org/abs/2510.11690 · https://arxiv.org/pdf/2305.02541 · https://arxiv.org/html/2505.24417 + +## Recommendation + +Under the hard constraint, the correct architecture is **not "protect text during the +scrub" (Differential Diffusion)** but **"scrub everywhere, then restore text crisply by +regeneration"**: + +1. Global SDXL scrub with text protection OFF (text region is scrubbed too). +2. On detected text regions, a **glyph-conditioned restoration** that re-renders the same + glyphs as fresh pixels (no original reused). + +This is the only path that delivers both "watermark everywhere" and crisp text. + +**Top-2 to prototype:** +- **TextSR** — detector + multilingual OCR + character-shape diffusion priors; closest to + the existing detector-driven pipeline. +- **EasyText** — FLUX-DiT glyph re-render, multilingual incl. CJK; also gets the 16-channel + VAE for free. + +**Honest costs / unknowns:** this is a re-architecture, not a quick fix. It needs a new +**OCR-recognition** step (we currently only detect text; we must know *what* to re-render). +Models are FLUX/DiT-class (heavy) -> serverless GPU. Maturity is research-grade; CJK is +covered, Cyrillic/Arabic crispness is not separately benchmarked -> a prototype must +measure real fidelity. The restoration stage being diffusion-based makes "fresh pixels = +no SynthID" plausible but **must be verified empirically** (run the SynthID oracle on the +restored output). + +**Constraint-compatible quick win to try first:** run the global scrub at **higher +resolution / tiled** so strokes exceed the latent cell — less softening, full scrub, no +freezing. Cheap to test; quantify recall/quality vs cost. + +**Do not pursue:** raising `preserve` toward 1.0 or pixel paste-back (both leave original +watermarked pixels in text); PELC/DecFormer pixel-equivalent latent compositing (refuted, +not production-ready). + +## Provenance + +Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversarially verified +(2/3 refutes required to kill a claim). This note records research only; no code change is +implied until a prototype validates fidelity and the SynthID-scrub guarantee on the +restored output. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md new file mode 100644 index 0000000..0fb75a4 --- /dev/null +++ b/docs/watermarking-landscape.md @@ -0,0 +1,54 @@ +# 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. + +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). + + **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). + +**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. + +**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`. +- **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). + +**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.) +- **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). +- **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). + +**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. + +**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. + +## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified) + +**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. So a Google-C2PA image with NO visible sparkle is expected (Ultra / API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow (upload to the Gemini app, ask if it is AI-generated), which reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec). + +**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms. + +**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. diff --git a/maintain.sh b/maintain.sh new file mode 100755 index 0000000..61c3da9 --- /dev/null +++ b/maintain.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +uv sync --all-extras +# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them +# inside the project env crashes (uv-secure -> "annotated-doc raised exception") and, +# with set -e, aborts the whole gate before ruff/pyright/tests. uvx sidesteps the +# in-project dependency conflict (see CLAUDE.md "Test and lint"). +uvx uv-outdated +uvx uv-secure --ignore-unfixed +uv run ruff check --fix +uv run ruff format +# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy +# repo (see CLAUDE.md "Test and lint"); src/ is the authoritative strict gate. +uv run pyright src/ +uv run pytest -n auto diff --git a/packaging/conda/recipe.yaml b/packaging/conda/recipe.yaml new file mode 100644 index 0000000..dd4236f --- /dev/null +++ b/packaging/conda/recipe.yaml @@ -0,0 +1,63 @@ +schema_version: 1 + +context: + version: "0.10.1" + python_min: "3.10" + +package: + name: remove-ai-watermarks + version: ${{ version }} + +source: + url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz + sha256: ec92b450363d947cd897f0b75c18e75c2988ff79314ea247333ba892470b5a77 + +build: + noarch: python + number: 0 + script: python -m pip install . -vv --no-deps --no-build-isolation + +requirements: + host: + - python ${{ python_min }}.* + - pip + - hatchling + run: + - python >=${{ python_min }} + - pillow >=10.0.0 + - piexif >=1.1.3 + - numpy >=1.24.0 + - py-opencv >=4.8.0 + - click >=8.0.0 + - python-dotenv >=1.0.0 + +tests: + - python: + imports: + - remove_ai_watermarks + - remove_ai_watermarks.identify + - remove_ai_watermarks.metadata + python_version: ${{ python_min }}.* + # pip_check defaults to true; disable it explicitly. `pip check` fails on + # the conda-forge piexif `py_2` build, whose stale 2019 metadata pip reads + # as "piexif 1.1.3 is not supported on this platform" -- even though the + # package installs, imports, and works. The imports test verifies loading. + pip_check: false + +about: + homepage: https://github.com/wiltodelta/remove-ai-watermarks + summary: Remove visible and invisible AI watermarks from images + description: | + Detect and remove visible AI watermarks (Gemini / Nano Banana sparkle, + ByteDance Doubao and Jimeng, Samsung Galaxy AI) and strip AI-provenance + metadata (C2PA, EXIF, IPTC, PNG text chunks) from images. The core package + covers the identify / metadata / visible / erase command surface; optional + pip extras add SynthID diffusion removal and additional invisible-watermark + detectors. + license: Apache-2.0 + license_file: LICENSE + repository: https://github.com/wiltodelta/remove-ai-watermarks + +extra: + recipe-maintainers: + - wiltodelta diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..79921f9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,227 @@ +[project] +name = "remove-ai-watermarks" +version = "0.14.1" +description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images" +readme = "README.md" +requires-python = ">=3.10" +license = {text = "Apache-2.0"} +keywords = [ + "ai-watermark", + "ai-watermark-remover", + "watermark-remover", + "watermark-removal", + "remove-watermark", + "synthid", + "c2pa", + "content-credentials", + "nano-banana", + "gemini", + "gemini-watermark", + "ai-metadata", + "metadata-removal", + "provenance", + "exif", + "stable-diffusion", + "comfyui", + "ai-detection", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Multimedia :: Graphics", + "Topic :: Multimedia :: Graphics :: Graphics Conversion", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Topic :: Scientific/Engineering :: Image Processing", + "Topic :: Security", + "Topic :: Utilities", +] +dependencies = [ + "pillow>=10.0.0", + # HEIC/AVIF pixel decode for the removal path (iPhone photos, modern exports): + # OpenCV cannot decode these containers, so image_io.imread falls back to Pillow + # and pillow-heif (bundled libheif, prebuilt wheels) registers the HEIF+AVIF + # openers. The metadata path already handles them via a plugin-free binary scan; + # this closes the same gap for the pixel path so `visible`/`all` work on them. + "pillow-heif>=0.13.0", + "piexif>=1.1.3", + "numpy>=1.24.0", + "opencv-python-headless>=4.8.0", + "click>=8.0.0", + "python-dotenv>=1.0.0", + # Official C2PA reader (Content Authenticity Initiative, MIT/Apache-2.0). The + # primary, spec-tracking manifest parser for the identify/metadata path; the + # hand-rolled caBX/CBOR scanner in noai/c2pa.py is kept only as a fallback for + # synthetic/partial blobs the validator rejects. Binary wheel (Rust), but the + # import is light (no torch/numpy) so it fits the dependency-light identify + # host. Prebuilt wheels cover the full CI matrix (linux/macos/windows). + "c2pa-python>=0.35.0", +] + +[project.optional-dependencies] +gpu = [ + "torch>=2.0.0", + # The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU + # (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch + # from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS + # XPU build). Install that build first, then this extra (torch is then + # already satisfied and won't be re-pulled): + # pip install torch --index-url https://download.pytorch.org/whl/xpu + # pip install 'remove-ai-watermarks[gpu]' + # uv users can target the ``pytorch-xpu`` index declared under [tool.uv]: + # uv pip install torch --index-url https://download.pytorch.org/whl/xpu + "diffusers>=0.38.0", + # diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional + # Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in + # transformers 5.x -- so ``from diffusers import AutoPipelineForImage2Image`` + # fails on transformers 4.x. The real SDXL-loading break was NOT transformers + # 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global + # ``prerelease = "allow"`` drags in: its CLIP tokenizer raises + # ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``. + # Cap tokenizers to the stable 0.22 line (transformers 5.x accepts + # >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads. + "transformers>=5,<6", + "tokenizers>=0.22,<0.23", + "accelerate>=0.25.0", + "safetensors", +] +# Open invisible-watermark (imwatermark) decoder for detecting the DWT-DCT +# watermarks embedded by Stable Diffusion / SDXL / FLUX. Optional because it +# pulls non-headless opencv AND torch (invisible-watermark declares torch a hard +# dependency, and WatermarkDecoder eagerly imports rivaGan -> torch at import +# time, so the dwtDct-only detect path still needs torch present even though it +# never runs on GPU). So `detect` alone pulls torch -- no need to add `gpu` for +# detection. identify() guards the import and skips the signal when absent. +detect = [ + "invisible-watermark>=0.2.0", +] +# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable +# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional +# because it pulls torch and downloads model weights on first use. identify() +# guards the import and skips the TrustMark signal when absent. +trustmark = [ + "trustmark>=0.8.0", +] +# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX, +# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and +# cached by huggingface_hub; it is never bundled in this repo. The default cv2 +# eraser backend needs none of this. +lama = [ + "onnxruntime>=1.16.0", + "huggingface-hub>=0.20.0", +] +# 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`. +migan = [ + "onnxruntime>=1.16.0", + "huggingface-hub>=0.20.0", +] +# Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN). Loaded via +# spandrel (MIT) -- a pure model-loader with NO basicsr dependency (it pulls only +# torch / torchvision / safetensors / numpy / einops). +# The Real-ESRGAN weights (BSD-3-Clause) download on first use and are cached; they +# are never bundled. CPU works but is slow on large inputs -- it is meant for the +# pre-diffusion upscale of SMALL inputs (and the GPU worker). Guarded by +# upscaler.is_available(); the default upscaler stays Lanczos (cv2, no deps). The +# weights are fetched with torch.hub (bundled with spandrel's torch), so no extra +# download dependency is needed. +esrgan = [ + "spandrel>=0.3.0", +] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.1.0", + "pytest-xdist>=3.5.0", + "ruff>=0.4.0", + "pyright>=1.1.0", + "invisible-watermark>=0.2.0", + # maintain.sh helpers; they only support newer Pythons, so gate them by + # marker to keep the py3.10 resolution (and CI matrix) solvable. + "uv-outdated>=0.1.0; python_version >= '3.12'", + "uv-secure>=0.12.0; python_version >= '3.12'", +] +all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,migan,dev]"] + +# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for +# the default CPU/CUDA install: uv consults it only when a torch install +# explicitly targets it (see the ``gpu`` extra comment), so it does not alter +# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build. +[[tool.uv.index]] +name = "pytorch-xpu" +url = "https://download.pytorch.org/whl/xpu" +explicit = true + +[project.scripts] +remove-ai-watermarks = "remove_ai_watermarks.cli:main" + +[project.urls] +Repository = "https://github.com/wiltodelta/remove-ai-watermarks" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +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/). +exclude = ["/data"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py310" +line-length = 120 +exclude = ["_refs"] + +[tool.ruff.lint] +select = ["E", "F", "B", "I", "S", "UP", "SIM", "RET", "COM", "C4", "G", "PT", "PIE", "T20", "DTZ", "ICN", "TCH", "RUF", "ANN"] +ignore = [ + "COM812", # missing trailing comma (conflicts with ruff formatter) + "ANN401", # typing.Any — sometimes unavoidable with third-party libs +] + +[tool.ruff.lint.per-file-ignores] +"scripts/*.py" = ["G004", "S108", "S310", "T20"] +"tests/*.py" = ["ANN", "S101", "S105", "S106", "S108"] +"src/remove_ai_watermarks/noai/watermark_remover.py" = ["S603", "S606", "S607", "T201"] # subprocess calls for auto-install/CUDA fix +"src/remove_ai_watermarks/noai/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "strict" +exclude = ["_refs"] + +[[tool.pyright.executionEnvironments]] +root = "tests" +extraPaths = ["."] +reportAttributeAccessIssue = false +reportOptionalSubscript = false +reportOptionalMemberAccess = false +reportArgumentType = false +reportUnknownMemberType = false +reportUnknownArgumentType = false +reportUnknownVariableType = false +reportMissingTypeArgument = false diff --git a/scripts/_plain_console.py b/scripts/_plain_console.py new file mode 100644 index 0000000..fe740ce --- /dev/null +++ b/scripts/_plain_console.py @@ -0,0 +1,61 @@ +"""Minimal plain-text stand-ins for the rich Console/Table API. + +rich was dropped as a project dependency (see the CLI plain-text refactor), but +the analysis scripts still printed through it. These shims keep the scripts +runnable without rich: ``[bold]``/``[/]``-style markup is stripped and tables +render as aligned plain text. Output goes through ``click.echo`` to match the +package CLI (no bare ``print`` in tooling). +""" + +from __future__ import annotations + +import re +from typing import Any + +import click + +# Matches rich style tags: the bare close ``[/]`` and named open/close tags such +# as ``[yellow]``, ``[bold yellow]``, ``[/green]``. Anchored to lowercase-letter +# starts so numeric/data brackets (``[1024]``, ``[file.png]``) are left intact. +_MARKUP = re.compile(r"\[(?:/|/?[a-z][a-z ]*)\]") + + +def _strip(obj: Any) -> str: + return _MARKUP.sub("", str(obj)) + + +class Table: + """Drop-in for ``rich.table.Table`` covering add_column/add_row + render.""" + + def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None: + self.title = title + self._headers: list[str] = [] + self._rows: list[list[str]] = [] + + def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None: + self._headers.append(_strip(header)) + + def add_row(self, *cells: Any) -> None: + self._rows.append([_strip(c) for c in cells]) + + def render(self) -> str: + all_rows = ([self._headers] if self._headers else []) + self._rows + cols = max((len(r) for r in all_rows), default=0) + widths = [0] * cols + for row in all_rows: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(cell)) + lines: list[str] = [] + if self.title: + lines.append(_strip(self.title)) + if self._headers: + lines.append(" ".join(h.ljust(widths[i]) for i, h in enumerate(self._headers))) + lines.extend(" ".join(c.ljust(widths[i]) for i, c in enumerate(row)) for row in self._rows) + return "\n".join(lines) + + +class Console: + """Drop-in for ``rich.console.Console`` covering ``print`` (with Table).""" + + def print(self, *objects: Any, **kwargs: Any) -> None: + click.echo(" ".join(o.render() if isinstance(o, Table) else _strip(o) for o in objects)) diff --git a/scripts/controlnet_sweep.py b/scripts/controlnet_sweep.py new file mode 100644 index 0000000..0ea5912 --- /dev/null +++ b/scripts/controlnet_sweep.py @@ -0,0 +1,348 @@ +"""ControlNet-as-removal-pipeline prototype sweep (issue #35 / Jacob). + +Research prototype, NOT a shipped pipeline. It tests whether a full-image +SDXL-native ControlNet-conditioned img2img can REPLACE plain SDXL img2img as the +watermark remover: a single structure-guided regeneration that scrubs an invisible +robust watermark (SynthID) everywhere while keeping fine detail and small/CJK text +legible. See docs/controlnet-removal-pipeline-research.md for the full rationale. + +The make-or-break tension (from the watermark-removal-attack literature): the +denoise strength high enough to scrub the watermark deforms text, while the +conditioning strong enough to keep text may spare the watermark. There is no local +SynthID detector, so this script CANNOT decide removal on its own -- it produces +one output per (control, strength, conditioning-scale) cell plus an index, and YOU +verify each output by hand in the Gemini app ("Verify with SynthID") and judge text +legibility visually. Fill the verdict columns in the emitted index, then read off +the Pareto cell (oracle clean AND text legible). + +Pipeline: stabilityai/stable-diffusion-xl-base-1.0 + + - canny: xinsir/controlnet-canny-sdxl-1.0 (control = cv2.Canny(gray, 100, 200)) + - tile: xinsir/controlnet-tile-sdxl-1.0 (control = the resized original, no preproc) +StableDiffusionXLControlNetImg2ImgPipeline (image=init, control_image=control). + +Needs the gpu extra (torch + diffusers) and cv2. Runs locally on 32 GB MPS in +fp32 (MPS fp16 decodes to all-black NaN -- issue #29 -- so fp32 is the default on +mps/cpu, fp16 only on cuda/xpu); a dedicated GPU is not required for 1024 px. Run: + + uv run python scripts/controlnet_sweep.py path/to/watermarked.png -o sweep_out + uv run python scripts/controlnet_sweep.py img.png --control canny tile \\ + --strength 0.3 0.5 0.7 1.0 --scale 0.6 1.0 --size 1024 +""" + +from __future__ import annotations + +# torch/diffusers/cv2 ship no usable types; relax the unknown-type + private-import +# rules for this boundary script (mirrors scripts/visible_alpha_solve.py and the +# cv2/torch engine modules). Pure-logic helpers here stay correct regardless. +# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportPrivateImportUsage=false +import argparse +import contextlib +import csv +import importlib.util +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from _plain_console import Console, Table + +if TYPE_CHECKING: + from PIL import Image + +logging.basicConfig(level=logging.INFO, format="%(message)s") +log = logging.getLogger(__name__) +console = Console() + +BASE_MODEL = "stabilityai/stable-diffusion-xl-base-1.0" +FP16_VAE = "madebyollin/sdxl-vae-fp16-fix" +CONTROLNETS = { + "canny": "xinsir/controlnet-canny-sdxl-1.0", + "tile": "xinsir/controlnet-tile-sdxl-1.0", +} + +# A neutral quality prompt: the goal is faithful regeneration, not creative edits. +PROMPT = "best quality, high quality, sharp, detailed, photographic" +NEGATIVE_PROMPT = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts" + + +def pick_device(requested: str) -> str: + """Resolve the inference device without the CUDA-reinstaller side effect. + + Deliberately does NOT call the package ``get_device`` (which can trigger a + torch-CUDA reinstall+restart). A research script should never do that. + """ + import torch + + if requested != "auto": + return requested + if torch.cuda.is_available(): + return "cuda" + if hasattr(torch.backends, "mps") and torch.backends.mps.is_available(): + return "mps" + return "cpu" + + +def resolve_dtype(device: str, requested: str) -> Any: + """fp16 only on cuda/xpu; fp32 on cpu AND mps, unless overridden. + + MPS fp16 produces all-black NaN output here (the SDXL UNet/VAE overflows on + the Metal backend -- issue #29; even the fp16-fix VAE does not save it), so the + production pipeline runs fp32 on MPS and so do we. fp32 SDXL + an SDXL ControlNet + at 1024 fits 32 GB unified memory with vae-tiling + attention-slicing. + """ + import torch + + if requested == "fp16": + return torch.float16 + if requested == "fp32": + return torch.float32 + return torch.float16 if device in {"cuda", "xpu"} else torch.float32 + + +def fit_size(image: Image.Image, long_side: int) -> Image.Image: + """Resize so the long side is ``long_side``, each dim a multiple of 8 (SDXL).""" + from PIL import Image as PILImage + + w, h = image.size + scale = long_side / max(w, h) + nw = max(8, round(w * scale) // 8 * 8) + nh = max(8, round(h * scale) // 8 * 8) + if (nw, nh) == (w, h): + return image + return image.resize((nw, nh), PILImage.Resampling.LANCZOS) + + +def make_control_image(init: Image.Image, control: str) -> Image.Image: + """Build the ControlNet conditioning image for the given control type. + + canny: cv2.Canny(gray, 100, 200) -> 3-channel edge map (xinsir canny recipe). + tile: the init image itself, no preprocessing (xinsir tile recipe). + """ + import cv2 + import numpy as np + from PIL import Image as PILImage + + if control == "tile": + return init + rgb = np.array(init.convert("RGB")) + gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY) + edges = cv2.Canny(gray, 100, 200) + edges_rgb = np.stack([edges, edges, edges], axis=-1) + return PILImage.fromarray(edges_rgb) + + +def psnr(a: Image.Image, b: Image.Image) -> float: + """Coarse global fidelity proxy vs the original (NOT a text or watermark metric).""" + import numpy as np + + x = np.asarray(a.convert("RGB"), dtype=np.float64) + y = np.asarray(b.convert("RGB").resize(a.size), dtype=np.float64) + mse = float(np.mean((x - y) ** 2)) + if mse == 0.0: + return 99.0 + return float(10.0 * np.log10((255.0**2) / mse)) + + +def load_pipeline(control: str, device: str, dtype: Any) -> Any: + """Load SDXL base + the chosen SDXL ControlNet as an img2img pipeline.""" + import torch + from diffusers import ( + AutoencoderKL, + ControlNetModel, + StableDiffusionXLControlNetImg2ImgPipeline, + ) + + console.print(f"Loading {CONTROLNETS[control]} ({control}) ...") + controlnet = ControlNetModel.from_pretrained(CONTROLNETS[control], torch_dtype=dtype) + load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": dtype} + if dtype == torch.float16: + # The stock SDXL VAE decodes to NaN/black in fp16; the fp16-fix VAE is the + # same swap the production pipeline uses (_SDXL_FP16_VAE_ID). + load_kwargs["vae"] = AutoencoderKL.from_pretrained(FP16_VAE, torch_dtype=dtype) + pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(BASE_MODEL, **load_kwargs) + pipe = pipe.to(device) + pipe.set_progress_bar_config(disable=True) + if device != "cpu": + # Keep the 1024 px + extra-ControlNet peak inside 32 GB unified memory. + with contextlib.suppress(Exception): + pipe.enable_vae_tiling() + with contextlib.suppress(Exception): + pipe.enable_attention_slicing() + return pipe + + +def run_cell( + pipe: Any, + init: Image.Image, + control_image: Image.Image, + strength: float, + scale: float, + steps: int, + guidance: float, + seed: int, +) -> Image.Image: + """Run one ControlNet img2img cell and return the regenerated image. + + The generator is created on CPU intentionally: a CPU generator is portable + across the mps/cuda/cpu backends (diffusers rejects a device-mismatched one), + matching the production runner's fallback behavior. + """ + import torch + + generator = torch.Generator(device="cpu").manual_seed(seed) + result = pipe( + prompt=PROMPT, + negative_prompt=NEGATIVE_PROMPT, + image=init, + control_image=control_image, + controlnet_conditioning_scale=float(scale), + strength=float(strength), + num_inference_steps=steps, + guidance_scale=guidance, + generator=generator, + ) + return result.images[0] + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="ControlNet-as-removal-pipeline prototype sweep.") + p.add_argument("image", type=Path, help="Watermarked input image.") + p.add_argument("-o", "--out", type=Path, default=Path("controlnet_sweep_out"), help="Output directory.") + p.add_argument("--control", nargs="+", choices=list(CONTROLNETS), default=["canny", "tile"]) + p.add_argument("--strength", nargs="+", type=float, default=[0.3, 0.5, 0.7, 1.0]) + p.add_argument("--scale", nargs="+", type=float, default=[0.6, 1.0], help="controlnet_conditioning_scale values.") + p.add_argument("--size", type=int, default=1024, help="Long-side resolution (multiple of 8).") + p.add_argument("--steps", type=int, default=30) + p.add_argument("--guidance", type=float, default=7.5) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--device", default="auto", choices=["auto", "mps", "cuda", "cpu"]) + p.add_argument("--dtype", default="auto", choices=["auto", "fp16", "fp32"]) + return p.parse_args() + + +def main() -> int: + args = parse_args() + if not args.image.exists(): + log.error("Input image not found: %s", args.image) + return 1 + + try: + from PIL import Image as PILImage + except ImportError: + log.error("Pillow is required. Install the gpu extra: uv sync --extra gpu --extra dev") + return 1 + if importlib.util.find_spec("diffusers") is None or importlib.util.find_spec("torch") is None: + log.error("diffusers/torch are required. Install: uv sync --extra gpu --extra dev") + return 1 + + device = pick_device(args.device) + dtype = resolve_dtype(device, args.dtype) + console.print(f"Device: {device} | dtype: {str(dtype).split('.')[-1]}") + + init_full = PILImage.open(args.image).convert("RGB") + init = fit_size(init_full, args.size) + console.print(f"Input: {args.image.name} {init_full.size[0]}x{init_full.size[1]} -> {init.size[0]}x{init.size[1]}") + + args.out.mkdir(parents=True, exist_ok=True) + stem = args.image.stem + init_path = args.out / f"{stem}__INPUT.png" + init.save(init_path) + + rows: list[dict[str, Any]] = [] + table = Table(title="ControlNet sweep") + for col in ("control", "strength", "scale", "psnr_vs_input", "file"): + table.add_column(col) + + # Group by control so SDXL + the ControlNet load once per control type. + for control in args.control: + pipe = load_pipeline(control, device, dtype) + control_image = make_control_image(init, control) + if control == "canny": + control_image.save(args.out / f"{stem}__canny_edges.png") + for strength in args.strength: + for scale in args.scale: + tag = f"{control}_s{strength:g}_c{scale:g}" + console.print(f"Running {tag} ...") + try: + out = run_cell( + pipe, + init, + control_image, + strength, + scale, + args.steps, + args.guidance, + args.seed, + ) + except Exception as exc: + log.warning("Cell %s failed: %s", tag, exc) + continue + fname = f"{stem}__{tag}.png" + out.save(args.out / fname) + quality = psnr(init, out) + rows.append( + { + "control": control, + "strength": strength, + "scale": scale, + "psnr_vs_input": round(quality, 2), + "file": fname, + "synthid_oracle": "", # fill: clean / present + "text_legible": "", # fill: yes / no / partial + } + ) + table.add_row(control, f"{strength:g}", f"{scale:g}", f"{quality:.2f}", fname) + del pipe + _free_memory(device) + + _write_index(args.out, stem, rows, init_path.name) + console.print(table) + console.print(f"\nWrote {len(rows)} cells to {args.out}/") + console.print(f"Next: open {args.out}/sweep_index.csv, run each PNG through the Gemini SynthID oracle,") + console.print("fill synthid_oracle (clean/present) + text_legible (yes/no/partial), find the Pareto cell.") + return 0 + + +def _free_memory(device: str) -> None: + import gc + + gc.collect() + with contextlib.suppress(Exception): + import torch + + if device == "cuda": + torch.cuda.empty_cache() + elif device == "mps" and hasattr(torch, "mps"): + torch.mps.empty_cache() + + +def _write_index(out: Path, stem: str, rows: list[dict[str, Any]], input_name: str) -> None: + """Write the CSV index (with empty verdict columns) and a README.""" + fields = ["control", "strength", "scale", "psnr_vs_input", "file", "synthid_oracle", "text_legible"] + with (out / "sweep_index.csv").open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + readme = ( + f"# ControlNet sweep for {stem}\n\n" + f"Input (resized): {input_name}\n\n" + "Each row in sweep_index.csv is one (control, strength, scale) cell. PSNR vs the\n" + "resized input is a COARSE global-fidelity proxy only -- it does NOT measure text\n" + "legibility or watermark presence. Decide those two by hand:\n\n" + "1. synthid_oracle: open the PNG in the Gemini app, 'Verify with SynthID'. Mark\n" + " 'clean' if no SynthID is detected, 'present' if it still is. (No local detector\n" + " exists; this manual check is the only valid SynthID oracle.)\n" + "2. text_legible: eyeball the small/CJK text. Mark yes / partial / no.\n\n" + "The Pareto cell is the one where synthid_oracle=clean AND text_legible=yes at the\n" + "lowest strength. If no cell satisfies both, the canny/tile-ControlNet middle path\n" + "is dead for text and a glyph re-render is required (see\n" + "docs/text-protection-research.md). Record the outcome in\n" + "docs/controlnet-removal-pipeline-research.md.\n" + ) + (out / "sweep_README.md").write_text(readme, encoding="utf-8") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/corpus_gap_scan.py b/scripts/corpus_gap_scan.py new file mode 100644 index 0000000..d9fd635 --- /dev/null +++ b/scripts/corpus_gap_scan.py @@ -0,0 +1,214 @@ +"""Audit a local image corpus against the library's own ``identify`` detector. + +Two jobs in one pass: + +1. **Report** -- run ``identify`` over every image and write one CSV row per file + (verdict, platform, confidence, watermarks, signals, integrity clashes). +2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata + region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file + head for other containers) for known provenance markers. A marker found there + on a file the detector calls ``unknown`` is a concrete lib gap: a serialization + or generator we do not yet parse. Scanning the metadata region -- not the whole + file -- is deliberate: short tokens collide randomly inside compressed PNG + ``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the + 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. + +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 +""" + +from __future__ import annotations + +import csv +import logging +from collections import Counter +from pathlib import Path + +import click +from _plain_console import Console, Table + +from remove_ai_watermarks.identify import identify +from remove_ai_watermarks.metadata import _png_late_metadata + +log = logging.getLogger(__name__) +console = Console() + +# Distinctive, multi-byte provenance markers worth flagging when they appear in a +# file the detector calls `unknown`. Kept long enough that a random collision in a +# (non-scanned) compressed stream is implausible; the metadata-region restriction +# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra, +# AI source-type / labeling schemes, and distinctive generator name strings. +MARKERS: tuple[bytes, ...] = ( + # C2PA / JUMBF infrastructure and AI source-type / labeling schemes. + b"c2pa", + b"jumbf", + b"contentauth", + b"trainedAlgorithmicMedia", + b"digitalSourceType", + b'"AIGC"', + b"", + b"TC260:AIGC", + b"tc260.org.cn", + b"AISystemUsed", + b"SynthID", + b"hf-job-id", + b"genAIType", + b"PhotoEditor_Re_Edit", + b"Signature:", + # Distinctive multi-word generator strings only. Bare single words (Luma, + # Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose + # (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision. + b"Midjourney", + b"Stable Diffusion", + b"StableDiffusion", + b"ComfyUI", + b"Automatic1111", + b"DALL-E", + b"Ideogram AI", + b"Adobe Firefly", + b"Black Forest", + b"volcengine", + b"Doubao", + b"\xe8\xb1\x86\xe5\x8c\x85", + b"Nano Banana", + b"Stability AI", + b"Samsung Galaxy", +) + + +def _metadata_region(path: Path) -> bytes: + """Return only the bytes where provenance metadata can live, never the + compressed pixel stream (which produces random short-token collisions).""" + try: + head = path.read_bytes() + except OSError: + return b"" + if head[:8] == b"\x89PNG\r\n\x1a\n": + # All ancillary metadata chunks (window=0), via the library's own walker. + return _png_late_metadata(path, 0) + if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan + out = bytearray() + p = 2 + n = len(head) + while p + 4 <= n and head[p] == 0xFF: + marker = head[p + 1] + if marker == 0xDA: # SOS -> compressed scan data follows + break + seg_len = (head[p + 2] << 8) | head[p + 3] + out += head[p + 4 : p + 2 + seg_len] + p += 2 + seg_len + return bytes(out) + return head[:65536] # webp/avif/heif/jxl: metadata sits near the head + + +def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport) + return { + "path": "", # filled by caller (relative) + "is_ai": str(rep.is_ai_generated), + "platform": rep.platform or "", + "confidence": rep.confidence, + "watermarks": "|".join(rep.watermarks), + "signals": "|".join(s.name for s in rep.signals), + "integrity_clashes": "|".join(rep.integrity_clashes), + } + + +@click.command() +@click.option( + "--corpus", + type=click.Path(exists=True, file_okay=False, path_type=Path), + default=Path("data/spaces/originals"), + show_default=True, + help="Directory of images to scan (recursively).", +) +@click.option( + "--report", + type=click.Path(path_type=Path), + default=None, + help="Write the per-file CSV here (default: /../detector_report.csv).", +) +@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).") +def main(corpus: Path, report: Path | None, limit: int) -> None: + logging.basicConfig(level=logging.WARNING, format="%(message)s") + report = report or corpus.parent / "detector_report.csv" + + files = sorted(p for p in corpus.rglob("*") if p.is_file()) + if limit: + files = files[:limit] + console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...") + + verdicts: Counter[str] = Counter() + platforms: Counter[str] = Counter() + gap_tokens: Counter[str] = Counter() + gaps: list[tuple[str, list[str]]] = [] + rows: list[dict[str, str]] = [] + errors = 0 + + with click.progressbar(files, label="identify") as bar: + for p in bar: + rel = str(p.relative_to(corpus)) + try: + rep = identify(p) + except Exception as exc: + log.warning("identify failed on %s: %s", rel, exc) + errors += 1 + continue + row = _row(rep) + row["path"] = rel + rows.append(row) + if rep.is_ai_generated: + verdicts["ai"] += 1 + platforms[rep.platform or "?"] += 1 + continue + verdicts["unknown"] += 1 + # A gap candidate is a file identify is *blind* to (no signal at all) + # yet whose metadata carries a known marker. A file that produced a + # signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we + # attribute but do not call AI) is handled correctly -- not a gap. + if rep.signals: + continue + region = _metadata_region(p) + hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region}) + if hits: + gaps.append((rel, hits)) + gap_tokens.update(hits) + + with report.open("w", newline="") as f: + writer = csv.DictWriter( + f, + fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"], + ) + writer.writeheader() + writer.writerows(rows) + console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}") + + console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}") + plat = Table(title="AI platforms", show_header=False) + for name, n in platforms.most_common(): + plat.add_row(str(n), name) + console.print(plat) + + if gaps: + console.print( + f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known " + f"marker in their metadata region (potential undetected serialization/generator):" + ) + tok = Table(title="markers seen in unknown files") + tok.add_column("count", justify="right") + tok.add_column("marker") + for name, n in gap_tokens.most_common(): + tok.add_row(str(n), name) + console.print(tok) + for rel, hits in gaps: + console.print(f" {rel} -> {', '.join(hits)}") + else: + console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]") + + +if __name__ == "__main__": + main() diff --git a/scripts/fidelity_metrics.py b/scripts/fidelity_metrics.py new file mode 100644 index 0000000..5578010 --- /dev/null +++ b/scripts/fidelity_metrics.py @@ -0,0 +1,418 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "click", +# "numpy", +# "opencv-python-headless", +# "pillow", +# "scikit-image", +# "rapidfuzz", +# "torch", +# "lpips", +# "paddleocr", +# "paddlepaddle", +# "insightface", +# "onnxruntime", +# ] +# /// +"""Objective fidelity metrics for comparing watermark-removal outputs. + +Given an ORIGINAL (the reference) and one or more cleaned VARIANTS that have all +ALREADY passed the scrub oracle, this scores how much real detail each variant +preserved -- so "closer to the original" is the right axis here (between two +equally-scrubbed outputs, the one that deviates less from the original wins). + +It is a standalone eval tool, NOT part of the package: PEP 723 inline deps let +``uv run`` build a throwaway env so the heavy models (PaddleOCR, insightface, +LPIPS) never touch uv.lock or the shipped library. Metrics self-gate: face +metrics run only where faces are detected, text metrics only where text is. + +Two subcommands: + + ocr -- OCR images (PaddleOCR PP-OCRv6) into a JSON {basename: text} file. + Run this on the ORIGINALS, hand-verify/correct the file, and it + becomes the ground truth for ``compare --ground-truth`` -- the clean + way to score text, since OCR-vs-OCR is doubly noisy (errors on both + images + reading-order differences inflate CER even on identical text). + + compare -- Score each VARIANT against the ORIGINAL across four groups: + 1. Text -- character error rate (CER) of the variant's OCR vs the + verified ground truth (or the original's OCR if no --ground-truth). + 2. Face identity -- insightface (buffalo_l) ArcFace cosine similarity. + 3. Face texture -- LPIPS + Laplacian-variance ratio on face crops + (catches "plastication": ratio < 1 = smoother than the original). + 4. Whole image -- LPIPS / SSIM / PSNR vs the original. + +Usage: + uv run scripts/fidelity_metrics.py ocr O1.png O2.png --langs en,ru,ch --out gt.json + # (edit gt.json by hand to fix any OCR slips, then:) + uv run scripts/fidelity_metrics.py compare --original O1.png \ + --variant controlnet=C.png --variant qwen=Q.png \ + --ocr-langs en,ru,ch --ground-truth gt.json +""" + +from __future__ import annotations + +import json +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import click +import cv2 +import numpy as np +from _plain_console import Console, Table + +console = Console() + + +# ── helpers ────────────────────────────────────────────────────────── + + +def _load_bgr(path: str) -> np.ndarray: + img = cv2.imread(path, cv2.IMREAD_COLOR) + if img is None: + raise click.ClickException(f"cannot read image: {path}") + return img + + +def _match_size(variant: np.ndarray, ref: np.ndarray) -> np.ndarray: + """Resize a variant to the reference size (outputs differ by a grid-round).""" + if variant.shape[:2] != ref.shape[:2]: + variant = cv2.resize(variant, (ref.shape[1], ref.shape[0]), interpolation=cv2.INTER_LANCZOS4) + return variant + + +def _norm(text: str) -> str: + """Normalize for CER: NFC + drop ALL whitespace (segmentation-order agnostic).""" + return "".join(unicodedata.normalize("NFC", text).split()) + + +# ── text: PaddleOCR (PP-OCRv6) ─────────────────────────────────────── + +# Our lang codes -> PaddleOCR lang. The 'ch' model also reads Latin; 'ru' reads +# Cyrillic + Latin. Multiple langs in one image -> run each model, union detections. +_PADDLE_LANG = {"en": "en", "ru": "ru", "ch": "ch", "ch_sim": "ch", "latin": "latin"} +_paddle_cache: dict[str, Any] = {} + + +def _paddle(lang: str) -> Any: + if lang not in _paddle_cache: + from paddleocr import PaddleOCR + + _paddle_cache[lang] = PaddleOCR( + lang=lang, + use_doc_orientation_classify=False, + use_doc_unwarping=False, + use_textline_orientation=False, + ) + return _paddle_cache[lang] + + +def _box_xyxy(box: Any) -> tuple[float, float, float, float]: + """Axis-aligned (x1, y1, x2, y2) of a PaddleOCR rec box ([x1,y1,x2,y2]) or poly (4x2).""" + arr = np.asarray(box, dtype=np.float32).reshape(-1) + if arr.size == 4: + return float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3]) + pts = arr.reshape(-1, 2) + return float(pts[:, 0].min()), float(pts[:, 1].min()), float(pts[:, 0].max()), float(pts[:, 1].max()) + + +def _iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float: + ix1, iy1 = max(a[0], b[0]), max(a[1], b[1]) + ix2, iy2 = min(a[2], b[2]), min(a[3], b[3]) + iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1) + inter = iw * ih + if inter <= 0: + return 0.0 + area_a = (a[2] - a[0]) * (a[3] - a[1]) + area_b = (b[2] - b[0]) * (b[3] - b[1]) + return inter / (area_a + area_b - inter + 1e-9) + + +def _ocr_lines(bgr: np.ndarray, langs: list[str], min_score: float = 0.5) -> list[str]: + """Detected text lines in reading order, unioned across lang models with spatial NMS. + + Several language models over one image re-detect the same lines -- and crucially the + WRONG-script models read e.g. Cyrillic as confident Latin gibberish. So instead of a + naive union, keep the HIGHEST-score detection per physical location (greedy IoU NMS): + the model that actually fits a line wins it (the 'ru' model takes the Cyrillic, 'ch' + the CJK, 'en' the Latin), and the cross-script garbage is dropped. + """ + raw: list[tuple[float, tuple[float, float, float, float], str]] = [] + for lang in langs: + plang = _PADDLE_LANG.get(lang, lang) + for page in _paddle(plang).predict(bgr): + texts = page.get("rec_texts", []) + scores = page.get("rec_scores", []) + boxes = page.get("rec_boxes", None) + if boxes is None or len(boxes) == 0: + boxes = page.get("rec_polys", []) + for text, score, box in zip(texts, scores, boxes, strict=False): + if score < min_score or not text.strip(): + continue + raw.append((float(score), _box_xyxy(box), text.strip())) + + raw.sort(key=lambda d: d[0], reverse=True) + kept: list[tuple[tuple[float, float, float, float], str]] = [] + for _score, box, text in raw: + if any(_iou(box, kbox) > 0.3 for kbox, _ in kept): + continue + kept.append((box, text)) + kept.sort(key=lambda d: (round(d[0][1] / 20.0), d[0][0])) # reading order: y then x + return [t for _, t in kept] + + +def _cer(ref: str, hyp: str) -> float: + from rapidfuzz.distance import Levenshtein + + return Levenshtein.normalized_distance(_norm(ref), _norm(hyp)) + + +# ── face: detection + ArcFace + texture ────────────────────────────── + + +@dataclass +class FaceStats: + n_faces: int = 0 + identity: list[float] = field(default_factory=list) + lpips: list[float] = field(default_factory=list) + lapvar_ratio: list[float] = field(default_factory=list) + + +def _lap_var(bgr: np.ndarray) -> float: + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) + return float(cv2.Laplacian(gray, cv2.CV_64F).var()) + + +def _bbox_center(bbox: Any) -> tuple[float, float]: + return (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2 + + +def _bbox_diag(bbox: Any) -> float: + return float(((bbox[2] - bbox[0]) ** 2 + (bbox[3] - bbox[1]) ** 2) ** 0.5) + + +def assign_faces_one_to_one( + ref_centers: list[tuple[float, float]], + var_centers: list[tuple[float, float]], + ref_diags: list[float], + max_frac: float = 0.6, +) -> dict[int, int]: + """One-to-one nearest-center face assignment (pure; unit-tested without insightface). + + Per-face nearest matching collides on multi-face images -- two original faces can both + pick the SAME variant face (e.g. when regeneration drops a face, so the variant has fewer + detections), corrupting the identity metric (the lapvar/LPIPS metrics are immune: they are + anchored to the ORIGINAL bbox on both images). This greedy-by-distance assignment is + collision-free: it walks candidate pairs nearest-first and never reuses a ref or a variant + face. Faces are spatially well-separated, so greedy equals the optimal (Hungarian) result + here without the scipy dependency. A pair is dropped when the center distance exceeds + ``max_frac`` of the original face diagonal (no plausible match -- the face was lost). + + Returns a dict mapping ref-face index -> variant-face index for matched faces only. + """ + pairs: list[tuple[float, int, int]] = [] + for i, (rx, ry) in enumerate(ref_centers): + for j, (vx, vy) in enumerate(var_centers): + pairs.append((((rx - vx) ** 2 + (ry - vy) ** 2) ** 0.5, i, j)) + pairs.sort() + used_ref: set[int] = set() + used_var: set[int] = set() + matched: dict[int, int] = {} + for dist, i, j in pairs: + if i in used_ref or j in used_var: + continue + if dist > max_frac * ref_diags[i]: + continue + matched[i] = j + used_ref.add(i) + used_var.add(j) + return matched + + +def _cosine(a: np.ndarray, b: np.ndarray) -> float: + return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9)) + + +def _crop(bgr: np.ndarray, bbox: Any) -> np.ndarray: + h, w = bgr.shape[:2] + x1, y1, x2, y2 = (int(max(0, bbox[0])), int(max(0, bbox[1])), int(min(w, bbox[2])), int(min(h, bbox[3]))) + return bgr[y1:y2, x1:x2] + + +# ── whole image: LPIPS / SSIM / PSNR ───────────────────────────────── + + +def _lpips_model() -> tuple[Any, Any]: + import lpips + import torch + + model = lpips.LPIPS(net="alex", verbose=False) + model.eval() + return model, torch + + +def _lpips_distance(model_torch: tuple[Any, Any], a_bgr: np.ndarray, b_bgr: np.ndarray) -> float: + model, torch = model_torch + + def _t(img: np.ndarray) -> Any: + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 127.5 - 1.0 + return torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0) + + with torch.no_grad(): + return float(model(_t(a_bgr), _t(b_bgr)).item()) + + +def _ssim_psnr(a_bgr: np.ndarray, b_bgr: np.ndarray) -> tuple[float, float]: + from skimage.metrics import peak_signal_noise_ratio, structural_similarity + + a = cv2.cvtColor(a_bgr, cv2.COLOR_BGR2GRAY) + b = cv2.cvtColor(b_bgr, cv2.COLOR_BGR2GRAY) + return float(structural_similarity(a, b)), float(peak_signal_noise_ratio(a, b)) + + +# ── reporting ──────────────────────────────────────────────────────── + + +def _mean(xs: list[float]) -> float | None: + return sum(xs) / len(xs) if xs else None + + +def _fmt(v: float | None, nd: int = 3) -> str: + return "-" if v is None else f"{v:.{nd}f}" + + +# ── CLI ────────────────────────────────────────────────────────────── + + +@click.group() +def cli() -> None: + """Objective fidelity metrics for watermark-removal outputs.""" + + +@cli.command("ocr") +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True)) +@click.option("--langs", default="en", help="Comma list of OCR langs (en,ru,ch).") +@click.option("--out", type=click.Path(), default=None, help="Write {basename: text} JSON here (for ground truth).") +def ocr_cmd(images: tuple[str, ...], langs: str, out: str | None) -> None: + """OCR images into a ground-truth seed -- hand-verify the result before using it.""" + lang_list = [x.strip() for x in langs.split(",") if x.strip()] + result: dict[str, str] = {} + for path in images: + lines = _ocr_lines(_load_bgr(path), lang_list) + text = "\n".join(lines) + result[Path(path).name] = text + console.print(f"\n=== {Path(path).name} ===") + console.print(text or "(no text detected)") + if out: + Path(out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + console.print(f"\n Wrote {out} -- verify/correct it by hand, then pass it to `compare --ground-truth`.") + + +@cli.command("compare") +@click.option("--original", required=True, type=click.Path(exists=True), help="Reference (unprocessed) image.") +@click.option( + "--variant", "variants", multiple=True, required=True, help="LABEL=PATH of a cleaned output (repeatable)." +) +@click.option("--ocr-langs", default="en", help="Comma list of OCR langs (en,ru,ch). Empty = skip text.") +@click.option("--ground-truth", type=click.Path(exists=True), default=None, help="Verified {basename: text} JSON.") +@click.option("--no-faces", is_flag=True, help="Skip face metrics.") +def compare(original: str, variants: tuple[str, ...], ocr_langs: str, ground_truth: str | None, no_faces: bool) -> None: + """Score each VARIANT against ORIGINAL across the four fidelity groups.""" + ref = _load_bgr(original) + parsed: list[tuple[str, np.ndarray]] = [] + for spec in variants: + if "=" not in spec: + raise click.ClickException(f"--variant must be LABEL=PATH, got {spec!r}") + label, path = spec.split("=", 1) + parsed.append((label, _match_size(_load_bgr(path), ref))) + + langs = [x.strip() for x in ocr_langs.split(",") if x.strip()] + lp = _lpips_model() # AlexNet LPIPS, loaded once and reused for face crops + whole image + + # ── text ── + ocr_cer: dict[str, float | None] = {label: None for label, _ in parsed} + if langs: + ref_text: str | None = None + if ground_truth: + gt = json.loads(Path(ground_truth).read_text(encoding="utf-8")) + ref_text = gt.get(Path(original).name) + if ref_text is None: + console.print(f" (no ground-truth entry for {Path(original).name}; skipping text)") + else: + console.print(f" OCR original ({','.join(langs)})...") + ref_text = "\n".join(_ocr_lines(ref, langs)) + if ref_text: + console.print(f" OCR variants ({','.join(langs)})...") + for label, img in parsed: + ocr_cer[label] = _cer(ref_text, "\n".join(_ocr_lines(img, langs))) + + # ── faces ── + face_stats: dict[str, FaceStats] = {label: FaceStats() for label, _ in parsed} + if not no_faces: + console.print(" Faces (insightface buffalo_l)...") + from insightface.app import FaceAnalysis + + app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"]) + app.prepare(ctx_id=-1, det_size=(640, 640)) + ref_faces = app.get(ref) + if ref_faces: + ref_centers = [_bbox_center(of.bbox) for of in ref_faces] + ref_diags = [_bbox_diag(of.bbox) for of in ref_faces] + for label, img in parsed: + vfaces = app.get(img) + st = face_stats[label] + # One-to-one assignment for identity (collision-free); lapvar/LPIPS stay + # anchored to the original bbox below, so they need no match. + matched = assign_faces_one_to_one(ref_centers, [_bbox_center(vf.bbox) for vf in vfaces], ref_diags) + for oi, of in enumerate(ref_faces): + st.n_faces += 1 + vf = vfaces[matched[oi]] if oi in matched else None + if vf is not None: + st.identity.append(_cosine(of.normed_embedding, vf.normed_embedding)) + oc, vc = _crop(ref, of.bbox), _crop(img, of.bbox) + if oc.size == 0 or vc.size == 0: + continue + vc_r = cv2.resize(vc, (oc.shape[1], oc.shape[0]), interpolation=cv2.INTER_LANCZOS4) + st.lpips.append(_lpips_distance(lp, oc, vc_r)) + ov = _lap_var(oc) + st.lapvar_ratio.append(_lap_var(vc_r) / ov if ov > 1e-6 else 0.0) + else: + console.print(" (no faces detected in the original; skipping face metrics)") + + # ── whole image ── + console.print(" Whole-image LPIPS/SSIM/PSNR...") + whole: dict[str, tuple[float, float, float]] = {} + for label, img in parsed: + ssim, psnr = _ssim_psnr(ref, img) + whole[label] = (_lpips_distance(lp, ref, img), ssim, psnr) + + # ── report ── + table = Table(title=f"Fidelity vs {Path(original).name} (reference)") + for col in ("variant", "text CER↓", "faces", "ID cos↑", "face LPIPS↓", "lapvar↑", "img LPIPS↓", "SSIM↑", "PSNR↑"): + table.add_column(col) + for label, _ in parsed: + st = face_stats[label] + wl, ws, wp = whole[label] + table.add_row( + label, + _fmt(ocr_cer[label]), + str(st.n_faces), + _fmt(_mean(st.identity)), + _fmt(_mean(st.lpips)), + _fmt(_mean(st.lapvar_ratio)), + _fmt(wl), + _fmt(ws), + _fmt(wp, 1), + ) + console.print(table) + console.print( + " Legend: CER lower=better; ID cos higher=better; face LPIPS lower=better; " + "lapvar ratio ~1=detail kept, <1=smoothed/plastic; img LPIPS lower=better; SSIM/PSNR higher=closer." + ) + + +if __name__ == "__main__": + cli() diff --git a/scripts/invisible_quality_audit.py b/scripts/invisible_quality_audit.py new file mode 100644 index 0000000..af6d70f --- /dev/null +++ b/scripts/invisible_quality_audit.py @@ -0,0 +1,117 @@ +"""Audit invisible-removal output quality by pairing originals with cleaned outputs. + +The spaces routine writes ``_src.`` originals and ``_clean.`` +cleaned outputs. For each pair this computes a structural-similarity score plus +cheap content proxies (detail via Laplacian variance, resolution, aspect), so the +WORST-preserved images can be surfaced and then visually classified. + +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. + + 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 +""" + +from __future__ import annotations + +import csv +import logging +from pathlib import Path + +import click +import cv2 +import numpy as np + +from remove_ai_watermarks import image_io + +log = logging.getLogger(__name__) + + +def _ssim(a: np.ndarray, b: np.ndarray) -> float: + """Grayscale SSIM (single-window Gaussian, the Wang et al. formulation).""" + a = a.astype(np.float64) + b = b.astype(np.float64) + c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2 + k = (11, 11) + mu_a = cv2.GaussianBlur(a, k, 1.5) + mu_b = cv2.GaussianBlur(b, k, 1.5) + mu_a2, mu_b2, mu_ab = mu_a * mu_a, mu_b * mu_b, mu_a * mu_b + sa = cv2.GaussianBlur(a * a, k, 1.5) - mu_a2 + sb = cv2.GaussianBlur(b * b, k, 1.5) - mu_b2 + sab = cv2.GaussianBlur(a * b, k, 1.5) - mu_ab + ssim_map = ((2 * mu_ab + c1) * (2 * sab + c2)) / ((mu_a2 + mu_b2 + c1) * (sa + sb + c2)) + return float(ssim_map.mean()) + + +def _stem(name: str) -> str: + """Strip the _src/_clean suffix and extension to get the pairing key.""" + base = name.rsplit(".", 1)[0] + for suf in ("_src", "_clean"): + if base.endswith(suf): + return base[: -len(suf)] + return base + + +@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("--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") + src_by_key = {_stem(p.name): p for p in originals.iterdir() if p.is_file()} + clean_by_key = {_stem(p.name): p for p in cleaned.iterdir() if p.is_file()} + keys = sorted(src_by_key.keys() & clean_by_key.keys()) + click.echo(f"Pairs: {len(keys)} ({len(src_by_key)} src, {len(clean_by_key)} clean)") + + rows: list[dict[str, str]] = [] + with click.progressbar(keys, label="ssim") as bar: + for key in bar: + a = image_io.imread(src_by_key[key], cv2.IMREAD_COLOR) + b = image_io.imread(clean_by_key[key], cv2.IMREAD_COLOR) + if a is None or b is None: + continue + if a.shape[:2] != b.shape[:2]: + b = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_LANCZOS4) + ga = cv2.cvtColor(a, cv2.COLOR_BGR2GRAY) + gb = cv2.cvtColor(b, cv2.COLOR_BGR2GRAY) + h, w = ga.shape + rows.append( + { + "key": key, + "ssim": f"{_ssim(ga, gb):.4f}", + "laplacian_var": f"{cv2.Laplacian(ga, cv2.CV_64F).var():.1f}", + "width": str(w), + "height": str(h), + "megapixels": f"{w * h / 1e6:.2f}", + "aspect": f"{w / h:.2f}", + } + ) + + rows.sort(key=lambda r: float(r["ssim"])) + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w", newline="") as f: + wtr = csv.DictWriter(f, fieldnames=["key", "ssim", "laplacian_var", "width", "height", "megapixels", "aspect"]) + wtr.writeheader() + wtr.writerows(rows) + + ssims = [float(r["ssim"]) for r in rows] + if ssims: + arr = np.array(ssims) + click.echo(f"\nSSIM mean={arr.mean():.3f} p10={np.percentile(arr, 10):.3f} min={arr.min():.3f}") + click.echo(f"Pairs with SSIM < 0.70: {(arr < 0.70).sum()} | < 0.60: {(arr < 0.60).sum()}") + click.echo(f"\nWorst {worst} (lowest SSIM):") + for r in rows[:worst]: + click.echo( + f" ssim={r['ssim']} lap={r['laplacian_var']:>8} {r['megapixels']}MP {r['aspect']} {r['key']}" + ) + click.echo(f"\nReport: {out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/qwen_scrub_prototype.py b/scripts/qwen_scrub_prototype.py new file mode 100644 index 0000000..5b6a86e --- /dev/null +++ b/scripts/qwen_scrub_prototype.py @@ -0,0 +1,128 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "diffusers>=0.35.0", +# "transformers>=4.51.0", +# "torch", +# "accelerate", +# "pillow", +# "click", +# ] +# /// +"""Isolated GPU prototype: does a low-strength Qwen-Image img2img pass scrub the +invisible watermark while keeping text/structure legible? + +This is the oracle-gated experiment behind Library roadmap P1#5 (migrate the +invisible pipeline onto Qwen-Image-Edit). It is DELIBERATELY standalone: + + * It is NOT imported by the package and NOT in ``uv.lock``. Qwen-Image needs a + newer ``diffusers``/``transformers`` (Qwen2.5-VL text encoder) than the SDXL + pipeline is pinned to, so wiring it into the locked env would risk the + certified SDXL/ControlNet pipeline (the ``cannot import Qwen3VL...`` trap). + PEP 723 inline metadata lets ``uv run`` build a throwaway env for it instead. + * Qwen-Image is ~20B, so it needs a real GPU (CUDA) -- it will not fit on MPS. + +Run (on a GPU box / Modal), then eyeball the outputs AND submit them to the +matching oracle (openai.com/verify for OpenAI, the Gemini app for Google): + + uv run scripts/qwen_scrub_prototype.py INPUT.png -o out/ --strengths 0.1,0.2,0.3,0.4 + +What to look for: + * SCRUB: the oracle no longer reports the watermark at some strength. + * FIDELITY: text stays legible and faces/structure stay faithful at that same + strength -- the whole point of trying Qwen over SDXL (which garbles text). +The smallest strength that clears the oracle while keeping fidelity is the result +to compare against the SDXL/ControlNet floors (OpenAI 0.10 / Google 0.15). +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import click + +log = logging.getLogger("qwen_proto") + +# A neutral, faithful-regeneration prompt (we want to scrub, not restyle); mirrors +# the intent of the SDXL controlnet prompt. Qwen renders text natively, so a light +# pass should keep captions legible where SDXL would garble them. +_PROMPT = "high quality, sharp, detailed, faithful to the original" +_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts" + + +def _pick_device(requested: str) -> tuple[str, object]: + import torch + + if requested != "auto": + device = requested + elif torch.cuda.is_available(): + device = "cuda" + elif getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available(): + device = "mps" + else: + device = "cpu" + # bf16 on CUDA (Qwen's reference dtype); fp32 elsewhere for numerical safety. + dtype = torch.bfloat16 if device == "cuda" else torch.float32 + return device, dtype + + +@click.command() +@click.argument("source", type=click.Path(exists=True, path_type=Path)) +@click.option("-o", "--output-dir", type=click.Path(path_type=Path), default=Path("qwen_out")) +@click.option("--strengths", default="0.1,0.2,0.3,0.4", help="Comma-separated img2img strengths to sweep.") +@click.option("--steps", type=int, default=40, help="Inference steps.") +@click.option("--cfg", type=float, default=4.0, help="true_cfg_scale (Qwen's CFG; reference default 4.0).") +@click.option("--model", default="Qwen/Qwen-Image", help="HF model id (Qwen-Image img2img base).") +@click.option("--device", default="auto", type=click.Choice(["auto", "cuda", "mps", "cpu"])) +@click.option("--seed", type=int, default=0, help="Reproducible seed.") +def main( + source: Path, + output_dir: Path, + strengths: str, + steps: int, + cfg: float, + model: str, + device: str, + seed: int, +) -> None: + """Sweep Qwen-Image img2img strength over SOURCE and save one output per strength.""" + logging.basicConfig(level=logging.INFO, format="%(message)s") + import torch + from diffusers import QwenImageImg2ImgPipeline + from PIL import Image + + dev, dtype = _pick_device(device) + log.info("Loading %s on %s (%s)...", model, dev, dtype) + pipe = QwenImageImg2ImgPipeline.from_pretrained(model, torch_dtype=dtype) + pipe = pipe.to(dev) + + init_image = Image.open(source).convert("RGB") + output_dir.mkdir(parents=True, exist_ok=True) + values = [float(s) for s in strengths.split(",") if s.strip()] + + for strength in values: + generator = torch.Generator(device="cpu").manual_seed(seed) + log.info("Generating strength=%.2f ...", strength) + result = pipe( + prompt=_PROMPT, + negative_prompt=_NEGATIVE, + image=init_image, + strength=strength, + num_inference_steps=steps, + true_cfg_scale=cfg, + generator=generator, + ) + out_path = output_dir / f"{source.stem}_qwen_s{strength:.2f}.png" + result.images[0].save(out_path) + log.info(" saved %s", out_path) + + log.info( + "\nDone. Eyeball text/face fidelity, then submit each output to the matching oracle " + "(openai.com/verify / Gemini app). The smallest strength that clears the oracle while " + "keeping fidelity is the number to compare against the SDXL floors (OpenAI 0.10 / Google 0.15)." + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_pill_silhouette.py b/scripts/render_pill_silhouette.py new file mode 100644 index 0000000..19c6cf6 --- /dev/null +++ b/scripts/render_pill_silhouette.py @@ -0,0 +1,60 @@ +"""Render the SYNTHETIC 'AI生成' pill silhouette asset (data-safe, font-rendered). + +The Jimeng-basic TC260 visible label is a rounded pill with 'AI生成' in the TOP-LEFT +corner (issue #54). Unlike the reverse-alpha marks, this is a CAPTURE-LESS mark: +the committed asset is a font-rendered binary SILHOUETTE (mark shape only, zero photo +content), used ONLY to (a) detect the pill by edge-NCC in the top-left corner and +(b) build the inpaint mask. It is NOT an alpha map -- removal quality comes from the +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 + +Requires a CJK font (macOS STHeiti by default); the asset itself is committed, so this +script only runs when regenerating it (never in CI). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +_ASSET = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets" / "jimeng_pill.png" +_FONT = "/System/Library/Fonts/STHeiti Medium.ttc" + + +def render_silhouette(w: int = 320) -> np.ndarray: + """Rounded-pill outline + 'AI生成' text as a binary silhouette (255 = mark).""" + h = int(w * 0.5) + im = Image.new("L", (w, h), 0) + d = ImageDraw.Draw(im) + pad = int(w * 0.03) + r = (h - 2 * pad) // 3 + d.rounded_rectangle([pad, pad, w - pad, h - pad], radius=r, outline=255, width=max(2, w // 90)) + fsz = int(h * 0.42) + font = ImageFont.truetype(_FONT, fsz) + txt = "AI生成" + tb = d.textbbox((0, 0), txt, font=font) + tw, th = tb[2] - tb[0], tb[3] - tb[1] + d.text(((w - tw) // 2 - tb[0], (h - th) // 2 - tb[1]), txt, font=font, fill=255) + return np.array(im) + + +def main() -> None: + try: + sil = render_silhouette() + 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 + _ASSET.parent.mkdir(parents=True, exist_ok=True) + Image.fromarray(sil).save(_ASSET) + print(f"wrote {_ASSET} ({sil.shape[1]}x{sil.shape[0]})") + + +if __name__ == "__main__": + main() diff --git a/scripts/synthid_corpus.py b/scripts/synthid_corpus.py new file mode 100644 index 0000000..5ef26cd --- /dev/null +++ b/scripts/synthid_corpus.py @@ -0,0 +1,239 @@ +"""Ingest and inspect the local SynthID reference corpus. + +Copies images into ``data/synthid_corpus/images/