Compare commits
No files matched your search
@@ -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
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -50,3 +50,9 @@ data/samsung_capture/captures/samsung_content_*
|
||||
# (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/
|
||||
@@ -2,27 +2,43 @@
|
||||
|
||||
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 / Qwen / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is user autonomy over their own generated output.
|
||||
|
||||
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
|
||||
|
||||
- `uv run remove-ai-watermarks all <image.png> -o <output.png>` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible` below, plus the visible-pass `--inpaint/--no-inpaint`/`--inpaint-method`.
|
||||
- `uv run remove-ai-watermarks invisible <image.png> -o <out.png>` — 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` (default `controlnet`), `--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), and `--adaptive-polish/--no-adaptive-polish` (**ON by default**; 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).
|
||||
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, locale-specific — Italian variant calibrated); `--mark gemini` / `--mark doubao` / `--mark jimeng` / `--mark samsung` force one (choices come from the registry). Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`.
|
||||
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable.
|
||||
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. Every single-image command's `source` argument declares `dir_okay=False`: without it `click.Path(exists=True)` accepts a directory, which then reached `open()` and raised `IsADirectoryError` (Tier E, 2026-07-20; `batch`'s `directory` already declared `file_okay=False`). Regression: `tests/test_cli_robustness.py::TestDirectoryInputIsRejected`.
|
||||
|
||||
- `uv run remove-ai-watermarks all <image.png> -o <output.png>` — full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) and `--sensitivity auto|strict` (default `auto`) for the localize -> fill visible removal (see the `visible` bullet). Skips step 2 (invisible/SynthID) when the `[gpu]` extra is absent or no invisible signal is detectable; see the module doc for the distinct exit codes.
|
||||
- `uv run remove-ai-watermarks invisible <image.png> -o <out.png>` — diffusion SynthID removal. **Full knob set** (kept identical across `invisible`/`all`/`batch`): `--strength` (vendor-adaptive default), `--steps` (**interacts with `--strength`**: diffusers derives its timesteps as `int(steps * strength)`, so a low `--steps` used to crash inside torch with `cannot reshape tensor of 0 elements` -- at the default 0.15 that was every value below 7. `noai/watermark_profiles.viable_steps` now raises the count to the minimum that denoises and logs the adjustment; keep the guard where it is, above `_generate_one`, so all three pipelines and the tiled path inherit it), `--guidance-scale` (CFG, default 7.5), `--pipeline sdxl|controlnet|qwen` (default `controlnet`; `qwen` is a manual opt-in only — see the qwen note in the module map), `--controlnet-scale`, `--model` (HF model id, default SDXL base), `--device`, `--seed`, `--hf-token`, `--max-resolution`/`--min-resolution`, `--upscaler lanczos|esrgan`, `--humanize` (Analog Humanizer grain), `--unsharp` (final sharpen), `--adaptive-polish/--no-adaptive-polish` (**ON by default**), `--tile/--no-tile` + `--tile-size`/`--tile-overlap` (**OFF by default**), `--force/--no-force` (default skip = ON, runs the scrub even with no detected signal). `--auto` is deprecated and a no-op that only warns. Skips the diffusion when no invisible signal is detectable (the no-signal gate); see the module doc.
|
||||
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal by **localize -> fill**: each detected mark is localized to a binary full-frame footprint mask, then one shared, swappable fill inpaints that mask. `--backend auto|cv2|migan|lama` (default `auto`) picks the fill: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. `--mark auto` (default) removes EVERY detected mark in one pass (a Jimeng-basic image carries the top-left "AI生成" pill AND the bottom-right "★ 即梦AI" wordmark) from: Gemini sparkle, Doubao "豆包AI生成", Jimeng "★ 即梦AI", Qwen "千问AI生成", Samsung Galaxy AI "✦ Contenuti generati dall'AI", Baidu "百度 AI生成", LibLibAI wordmark (bottom-center), RunningHub "RunningHub AI生成" (top-left), and the capture-less Jimeng "AI生成" pill (top-left, metadata-gated); `--mark gemini|doubao|jimeng|qwen|kling|samsung|baidu|liblib|runninghub|jimeng_pill` forces one. `--sensitivity auto|strict` (default `auto`) sets how hard a borderline mark is trusted: `auto` relaxes a mark's gate only on same-product evidence (metadata provenance for that vendor, or a confidently detected sibling mark of the same product — clean images stay untouched); `strict` never relaxes. Metadata provenance is read automatically and feeds `auto`. (`assume-ai` was REMOVED in 0.16 — see the registry bullet; a user who can SEE a missed mark should point at it with `erase --region`, or name it with `--mark <text-mark> --no-detect`.) For arbitrary logos/objects use `erase`. When no known mark is detected the command writes no output and exits with the no-visible-mark code instead of re-serving the input; `--no-detect` forces the gemini fallback and proceeds. See the module doc for the routing/exit detail. `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`.
|
||||
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — 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 <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set above** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`), plus `--inpaint/--no-inpaint` for the visible pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. One engine cached per pipeline; the polish is resolved once before the loop.
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
|
||||
|
||||
## Test and lint
|
||||
|
||||
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `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/<version>/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. **Other distribution channels (each has its own bump cadence, none is wired to the PyPI publish):** (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` and running `comfy node publish --token <registry-PAT>` from the node repo; it is NOT auto-published on a library release, 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 defaLine truncated
|
||||
- **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. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx, and aiohttp 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8. (The old 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.)
|
||||
- **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 `<TC260:AIGC>` 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); synthetic byte blobs cover the 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).
|
||||
- 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 `<TC260:AIGC>` 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
|
||||
@@ -34,63 +50,51 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
|
||||
## Key modules
|
||||
|
||||
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor, and `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 the PNG parser and the non-PNG binary path). 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`).
|
||||
- `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.
|
||||
- `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: <base64>` + 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` — 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, 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. **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` 0.5; 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** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `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. 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` (scaLine truncated
|
||||
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao, Jimeng, and Samsung all add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), and `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection. **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 — 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` → `best_auto_mark`, `--mark <key>` → 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 (reverse-alpha only when detected/forced AND `reverse_alpha_available`, else skipped — no inpaint); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map); 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. Reverse-alpha (and `erase` inpaint) recover 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 (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
|
||||
- `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`. **Removal is reverse-alpha with an over-subtraction guard** (`remove_watermark` → `_reverse_alpha_blend`, else `_inpaint_footprint`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png` (the capture max is ~130, NOT 255 — the sparkle is a ~51%-opaque white overlay, so `alpha` maxes at ~0.51, which is CORRECT for the capture, not under-exposed). The alpha is near-exact only when the real mark's effective opacity matches the capture, which holds on bright/flat backgrounds — re-verified clean on `demo_banana_before.png` 2026-05-31. **Issue #30 (dark-background black pit):** on a dark/textured background (e.g. grass, ~73) the real sparkle's effective opacity is LOWER than the captured 0.51, so the fixed-alpha reverse blend OVER-subtracts (`watermarked - a*logo` goes negative) and drives the footprint to black — the white sparkle becomes a black diamond. `remove_watermark` now detects this via `_reverse_alpha_oversubtracts` (fraction of footprint pixels with `alpha >= _FOOTPRINT_ALPHA` 0.1 whose numerator < 0 exceeds `_OVERSUB_FOOTPRINT_FRAC` 0.05) and **inpaints the footprint** (`_inpaint_footprint`, cv2 NS over the dilated alpha mask) from the surrounding pixels instead. **Behavior-neutral on the working case:** a bright background over-subtracts at ~0% so reverse-alpha is used and the output is byte-identical to before (verified: demo_banana 0.0 frac vs issue-#30 grass 0.61 frac; regression-guarded by `test_gemini_engine.py::TestOverSubtractionGuard`, which composites the sparkle at a reduced effective alpha to reproduce the mismatch). **Under-subtraction (the symmetric case, fixed 2026-06-03):** some real Gemini sparkles are rendered MORE opaque than the captured ~0.51, so the fixed-alpha reverse blend UNDER-subtracts and leaves a bright sparkle residual the detector still fires on (measured on the spaces corpus: a visible-removal audit through the registry path left a detectable sparkle on a meaningful fraction of marks, all under-removals, NOT a background-brightness class — failures and successes had the same input confidence and the same background-luma distribution; the discriminator was the removal delta itself). `remove_watermark` now estimates a per-image alpha gain (`_estimate_alpha_gain`: effective sparkle opacity at the bright core vs the local background ring, `a_eff/a_cap`, clamped `[1.0, _ALPHA_GAIN_MAX` 1.94`]`) and scales the alpha to match before the over-sub/blend branch. The gain cleanly separates on the corpus (under-removed marks ~1.47, cleanly-removed ~1.00), and a deadband (`_ALPHA_GAIN_DEADBAND` 1.05) keeps a matching sparkle **byte-identical** to the pre-fix output, so the fix is purely additive (0 regressions on the audit set; the over-sub guard still runs on the scaled alpha as the safety net for an over-shooting estimate). Regression-guarded by `test_gemini_engine.py::TestUnderSubtractionGain` (composites a more-opaque-than-captLine truncated
|
||||
- `_text_mark_engine.py` — **shared base for the three reverse-alpha text-mark engines (Doubao/Jimeng/Samsung), extracted 2026-06-09** (they were ~90% byte-identical clones). `TextMarkEngine(config: TextMarkConfig)` owns the whole `locate → extract_mask → detect → _fixed/_aligned_alpha_map → _apply_reverse_alpha → remove_watermark_reverse_alpha` pipeline (+ the asset-keyed `load_alpha_template`/`glyph_silhouette`/`template_match_score` caches). Each engine module is now a thin subclass: it supplies 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). Behavior is byte-exact vs the old per-engine code (the three engine test suites pass unchanged). 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.
|
||||
- `doubao_engine.py` — **a thin `_text_mark_engine.TextMarkEngine` subclass (config only) since 2026-06-09.** visible Doubao "豆包AI生成" remover/detector (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 alpha 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 = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing. **Alpha 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/`. **Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048. **The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual). **Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.** Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark). **Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
|
||||
- `jimeng_engine.py` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Jimeng / Dreamina "★ 即梦AI" remover/detector (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`. **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual). **Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now 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), and the careful build drops the gray self-residual to ~1.3. **The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused. **Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-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` — **a thin `TextMarkEngine` subclass (config only) since 2026-06-09.** visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" remover/detector (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). **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; white capture confirms). **Alpha 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. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, same `_RESIDUAL_*` recipe as Jimeng) with **always-try fixed AND `_aligned_alpha_map` NCC scale+position search, keep the lower-residual** (`_ALPHA_ALIGN_SEARCH` widened to (0.85, 1.18, 23) because the flat captures are far off the real-photo width). **Resolution caveat:** the flat captures arrived at 1086 wide while real photos are ~2958 wide (the mark scales with width, so the captured glyph ~334px is ~2.7x smaller than the ~903px real-photo glyph); width-scale + NCC-align still removes it cleanly (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, per the Gemini self-verify lesson), but a flat capture at the real photo resolution would make the alpha pixel-sharp instead of upscaled (open quality upgrade, noted in `data/samsung_capture/README.md`). **The mark is locale-specific** (text differs per language); this build is the Italian "Contenuti generati dall'AI" variant — other locales need their own captured template. `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real photo stays gitignored; only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled alpha asset (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the align-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` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **both** backends (`erase_cv2` and `erase_lama` each split off any alpha plane and re-attach it unchanged, and promote grayscale to BGR for processing — LaMa would otherwise crash on grayscale and drop alpha on BGRA): `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **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` — `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` — `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` — the `WatermarkRemover` class has two diffusion pipelines, selected by the explicit `pipeline` ctor arg (NOT inferred from `model_id` -- both use the same SDXL base, `DEFAULT_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). **`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).
|
||||
- **`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"]`).
|
||||
- `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` — 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.
|
||||
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.**
|
||||
|
||||
### Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
|
||||
- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use).
|
||||
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`.
|
||||
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; corpus-observed on real Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the common real-corpus placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): ~2% of real uploads are misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals" (corpus-measured ~0.9% of files). A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; ~0.2% of real uploads) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI Line truncated
|
||||
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. Corpus-validated: adding the vendors introduced 0 new clashes on 5000 carriers.
|
||||
- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. On its first form that left only the raw 0.35 detector threshold and it fired on **59.8% of 256 genuine camera captures**, filling a phantom sparkle on ~6 of every 10 CLEAN photos; a confidence floor made it tolerable, and the mode was removed outright in 0.16. Corpus-measured 2026-07-16 before removal (400 Google-C2PA positives with metadata hidden; 256 camera-capture negatives): recall strict 55.0% / auto 55.2% / assume_ai 62.8%, false fire 0.0% / 0.0% / 2.3% -- the extra recall was never free. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao**, **Qwen**, **Kling**, **RunningHub** or **Baidu** detection (each of those marks names its own product, and none is Jimeng-basic), and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadaLine truncated
|
||||
- `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list.
|
||||
- `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray` (`gray` = raw-grayscale NCC for the faint mid-gray runninghub mark, added 2026-07-22; contrast-DEPENDENT, so its gates never port). The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2).
|
||||
- `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates.
|
||||
- `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture.
|
||||
- `qwen_engine.py` — the Qwen (Alibaba Tongyi Qianwen) "千问AI生成" text mark (bottom-right), registered 2026-07-21 off the 117-frame TC260-producer cohort (`scripts/vendor_cohort_harvest.py` + `scripts/vendor_mark_calibrate.py`; the full calibration record is in `docs/module-internals.md`). Same `TextMarkEngine` subclass shape, but EVERY tuned number is measured on the cohort, not inherited from doubao: `scale_basis=short`, a **per-mark 2-rung `ladder=(0.78, 1.27)`** (the mark sits in two size modes ~0.124/~0.203 of the short side, ratio 1.64 -- wider than the shared ladder's 1.5625 span, so the best single fraction covers only 74.5% and the small mode falls into the comb's collapse zone; `TextMarkConfig.ladder` was added for this, default `(0.8, 1.0, 1.25)` unchanged for every other mark), a fitted locate box (the real mark sits ~0.025 of short off the right edge and doubao's 0.004-margin box clipped the first glyph, 0.73 -> 0.26 NCC), `alpha_height_frac` from the measured 0.260 aspect (not the silhouette's, not doubao's), gate **0.45** (clean p99 0.301; 83 of ~96 eyeballed visible marks fire = 86% recall of visible marks). **STRICT ONLY** (`provenance_ncc_factor` 1.0, no provenance mapping): the sub-gate band is dominated by non-Qwen banners (夸克 strip 0.274, 造点 mark 0.253), so a relaxed arm would be mostly false fills. **No rival margin** (0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames at the gate; a 0.10 margin would cost ~10% of genuine qwen detections). A confident qwen detection suppresses the jimeng pill like doubao's does. Parity: 83/83 real marks detector-clean after cv2 fill. **Calibration trap for the next vendor:** the 2026-07-18 `present: []` clean labels are in the vocabulary of the REGISTERED marks only -- qwen-cohort frames visibly carrying 千问AI生成 sat in the "clean" arm and were its whole top tail (p99 0.69); `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort.
|
||||
- `kling_engine.py` — the Kling (Kuaishou 可灵) "可灵AI 3.0" text mark (bottom-right), registered 2026-07-21 off the 30-frame TC260-producer cohort, same playbook as qwen. UNIMODAL at 0.12 of the short side, so it keeps the shared 3-rung ladder and gets a fitted locate box (margins ~0.03/0.023); gate **0.35** (clean p99 0.304 / max 0.320), STRICT ONLY (the faint "Omni"-suffix / latin "KlingAI" / version-less variants score 0.17-0.25, inside the clean arm's top tail -- unreachable, capping recall at ~47% of visible marks, precision 9/9), no rival margin (crossfire 1/400 doubao below gate, 0 jimeng, 0 clean). Parity 9/9. Suppresses the jimeng pill like doubao/qwen. **元宝 (Tencent Yuanbao) was attempted the same day and PARKED as a measured negative** -- its two-line italic-slanted block does not separate from clean corners on either front-end at any render/box setting (full record in `docs/verification-plan.md`); the fitted render recipe stays in `render_vendor_silhouettes.py` `MARK_OPTS` as the starting point for a future structural/learned lever. The **cat-logo** cohort was also probed and parked: 19 frames but only 2 unique carriers, so nothing to calibrate recall against; the `draw_catlogo` silhouette already separates (mark 0.50 vs clean max 0.333) and registration is a gate pick (~0.42) once more unique carriers arrive.
|
||||
- `runninghub_engine.py` — the RunningHub "RunningHub AI生成" mark (**top-left**, the first `corner="tl"`), registered 2026-07-22 off the 73-frame TC260 cohort. Faint mid-gray text that the top-hat suppresses to clean-arm levels, so it introduced the **third detection front-end `gray`** (raw-grayscale silhouette NCC, contrast-DEPENDENT, one `_gray_best` method serving detection and mask): positives 0.38-0.54 vs clean max 0.295, gate **0.34**, STRICT ONLY. Two measured traps: the tight ladder `(0.95, 1.0, 1.05)` sits exactly on the measured 0.32-of-width (the comb is razor-sharp: 0.537 on-size vs 0.223 at +5.6%), and an **anchor gate** in `detect` (match must sit at x<=0.025 / y<=0.015 of the frame) kills the 37/42009 full-corpus false fires (hair, shelves, CJK banners) that no NCC threshold separates from the 0.381 positives. Footprint is always the detector's match box (the binary blob under-segments the faint head glyphs and left "Runni" unremoved on the first try).
|
||||
- `baidu_engine.py` — the Baidu "百度 AI生成" mark (bottom-right), registered 2026-07-22 off the 16-frame cohort (USCC names Baidu). Detection keys on the 百度 **text run ONLY** (a text+pill template was a measured bright-blob magnet with no separation); the white "AI生成" tag is still removed because the custom `footprint_mask` extends the match box right to the corner (the tag's flat white interior gives no top-hat response, so a blob bbox left it as a ghost). Gate settled at **0.48** after three measured steps (0.37 clean-arm; the 741-eval set fired on 12 Qwen marks + one 抖音 mark, so `rivals=("doubao_alpha.png","qwen_alpha.png")` with load-bearing margins; the full-corpus sweep put true outside-cohort carriers at 0.50-0.66 vs the false arm max 0.47). Cohort 7/16, all true, plus 6 metadata-stripped true carriers the cohort cannot see. STRICT ONLY.
|
||||
- `liblib_engine.py` — the LibLibAI (哩布哩布AI) "LibLibAI" wordmark (**bottom-center**, the first `corner="bc"`), registered 2026-07-22 off the 15-frame cohort. The discriminative lever is the silhouette **font**: STHeiti scored the cohort at the false arm's level (latin UI text bands); measured across 7 fonts, **Arial** lifts the cohort to 0.42-0.73 and drops the false arm to max 0.398. Gate **0.42**, STRICT ONLY, plus a per-mark size floor (`_MIN_SHORT_SIDE=480`: the one remaining false fire was a 200x200 icon on a 20px template). Custom footprint = match box extended left ~1.3 glyph heights for the triangle logo (the blob bbox bled into background structure and never owned the logo). **Zhipu Qingyan (清言·AI生成) and MiniMax/Hailuo were attempted the same day and PARKED** as measured negatives (qingyan: cohort 0.34-0.39 vs clean max 0.34-0.37, no separation at any render/box; minimax: 1 of 6 frames visibly marked, the xinghui rule) -- record in `docs/verification-plan.md` ("The 2026-07-22 vendor round").
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **Measured end to end 2026-07-20** (`scripts/resource_ceilings.py`, fresh process per cell, 1 MP → 25 MP): `migan` 603 → 775 MB and `lama` 4679 → 4779 MB, both **flat in input size** — the crop-around-the-mask design holds and both documented figures reproduce. **`cv2` is the only backend that GROWS with the input** (74 → 440 MB, 5.9x) because it inpaints the full frame rather than a crop; still the cheapest tier, but size it for the largest upload accepted. Cold wall time 0.02-0.12 s (cv2) / ~0.6 s (migan) / ~3.8 s (lama), model load included. (The harness's own no-op check originally allocated a full-frame temp before reading peak RSS and inflated these by up to 17% at 25 MP; it now compares only the mask box. The conclusion survived re-measurement, the digits moved.) **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged).
|
||||
- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) **carrier-fragile on a broad class of pristine images** -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the `_MATCH_48`=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate **all-ones decode that is a CARRIER ARTIFACT, not a watermark** (synthetic clean image reproduces it). So `detect_invisible_watermark` is **positive-only**: trust a hit; a `None` is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in `docs/watermarking-landscape.md`.
|
||||
- `trustmark_detector.py` — Adobe TrustMark open decoder (extra `trustmark`). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.
|
||||
- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. **Both SDXL loaders (`_load_pipeline`, `_load_controlnet_pipeline`) pass `add_watermarker=False`** — diffusers otherwise embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on EVERY SDXL output whenever `invisible-watermark` is installed (the `detect` extra), so a watermark REMOVER would re-stamp a detectable AI watermark and the cleaned output re-reads as AI (`identify` → "Open invisible watermark: Stable Diffusion XL"; corpus-observed on the SynthID sample before the fix). Only the pipeline accepts the kwarg, not the `ControlNetModel` sub-model; qwen is not SDXL and has no such watermarker. Regression: `tests/test_platform.py::TestNoReembeddedWatermark`. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one).
|
||||
- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). `WatermarkRemover.remove_watermark` branches to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner.
|
||||
- `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit).
|
||||
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
|
||||
- `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches. `read_bgr_and_alpha`/`write_bgr_with_alpha` (+ `ALPHA_FORMATS`) are the alpha-preserving IO helpers shared by the CLI and the library `api` (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). **`write_bgr_with_alpha` RETURNS `imwrite`'s success flag and every caller must check it** — `imwrite` is contractually non-raising, so that bool is the ONLY signal the file was not created. The wrapper used to return `None` and swallow it, so a write that a read-only directory silently prevented ran on to `output.stat()` and died with a bare `FileNotFoundError` traceback pointing at the stat rather than the write (Tier E, 2026-07-20). **Three different layers each needed their own handling, and the right one is NOT the same everywhere:** the single-image commands (`visible --mark <name>`, `erase`, `all`) write through the shared `cli._write_output_or_exit`, which exits; `api._write_visible_result` (behind `visible --mark auto`) RAISES `OSError` so a library caller gets an accurate error instead of a confusing `FileNotFoundError` from the downstream metadata strip; and the **batch** sites raise too, never `SystemExit` — the batch loop catches per-image exceptions, counts them and exits non-zero, so aborting there would kill the whole run. Discarding the flag in batch made a read-only output directory produce **zero files and still exit 0** — silent data loss contradicting the documented batch contract. Regression: `tests/test_cli_robustness.py`. cv2/numpy import lazily, so importing `image_io` is cheap. **`imread` has a Pillow fallback (`_pil_read`) for HEIC/AVIF**: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core `pillow-heif` dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). **`imwrite` PRESERVES the input format at max quality** ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (`_pil_write`) since cv2 has NO encoder for them (writing `.heic` via cv2 RAISES — a HEIC input used to crash on save). `imwrite` never raises (catches `cv2.error`). **`api.remove_visible` copies the original bytes verbatim on a no-op** (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. `noai/constants.SUPPORTED_FORMATS` now includes `.heic`/`.heif`/`.avif` alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so `batch` discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). **The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top:** `watermark_remover` saves the regenerated output through `image_io.imwrite` (not raw `PIL.save`, which defaults to JPEG q75), `invisible_engine` writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level `metadata.remove_ai_metadata` (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical.
|
||||
- `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`.
|
||||
|
||||
**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` bullet above). 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` bullet. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary.
|
||||
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`.
|
||||
|
||||
**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 -- `data/spaces/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 colour) was tried end-to-end and **still leaves a persistent ghost outline.**
|
||||
## Watermarking landscape
|
||||
|
||||
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 colour 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.
|
||||
|
||||
## Watermarking landscape (research 2026-05-24)
|
||||
|
||||
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: fragile to re-encoding.
|
||||
- **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), and **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`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), 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.
|
||||
- **EXIF/XMP 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).
|
||||
- **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: <base64>` (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/<uuid>.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 `<TC260:AIGC>{"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 **three serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (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"`), **and** 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"`). Both generic forms (the PNG chunk and the bare `{"AIGC":...}` object) 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. 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 `<?xpacket begin … end?>` 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.
|
||||
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
|
||||
|
||||
- `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-based diffusion is still the proper long-term fix. **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, 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.
|
||||
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), 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 (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.
|
||||
- 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). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **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.)
|
||||
- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design.
|
||||
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here.
|
||||
|
||||
- **SynthID detection is metadata-only.** 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. **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 Line truncated
|
||||
- **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 (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.20** / `GEMINI_STRENGTH` **0.30** / `UNKNOWN_STRENGTH` **0.30** when `--strength` is unset; explicit `--strength` always wins. **The SAME ladder applies to BOTH pipelines** (`sdxl` and `controlnet`) -- these are the 2026-06-04 Modal-cert controlnet floors. **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.** 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 (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 `_noLine truncated
|
||||
- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content.
|
||||
- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based.
|
||||
- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.10** / Gemini **0.15** / unknown **0.15** when `--strength` is unset (the 2026-06-14 lowering from the 2026-06-04 cert floors of 0.20/0.30 — the single source of truth is `watermark_profiles.py`, and the full cert/lowering history is in `docs/known-limitations.md`); explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type.
|
||||
- **`controlnet` is the default pipeline**; `--pipeline sdxl` is the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR.
|
||||
@@ -19,18 +19,24 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
|
||||
> **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 / Qwen / Kling / Baidu / LibLibAI / RunningHub / Samsung visible AI labels, the Chinese TC260 "由…AI生成" label, and C2PA / IPTC / EXIF "Made with AI" metadata. The point is your autonomy over your own output.
|
||||
|
||||
It does **not** target watermarks that protect someone else's paid or copyrighted content — stock-agency overlays (Shutterstock, Getty, iStock, Adobe Stock), classifieds-site marks, or any tiled "preview" watermark whose job is to gate a purchase. Removing those is out of scope by design. `erase` is a generic, user-driven region tool for your own objects, not an automatic stock-watermark remover.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds; it adapts the alpha to each image's sparkle opacity, so a more-opaque-than-captured sparkle is still fully removed (and on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao, Jimeng, and Samsung text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Qwen "千问AI生成" and Kling "可灵AI 3.0" text strips, the Baidu "百度 AI生成" text + tag, the LibLibAI wordmark (bottom-center), the RunningHub "RunningHub AI生成" text (top-left), and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each mark is **localized to a footprint mask, then filled**: the engine finds the mark, builds a binary mask over its footprint, and one shared, swappable fill inpaints that region. Choose the fill with `--backend`: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN, light, the memory-tight pick where LaMa will not fit), or `lama` (big-LaMa, best quality, heavier, auto-preferred when a learned backend is available); the default `auto` uses LaMa > MI-GAN > cv2, best available. The localizer is cheap CPU (cv2/numpy), so it runs anywhere; the heavier MI-GAN/LaMa fill is opt-in. Detection keys on each mark's own shape (NCC against a captured silhouette; the alpha captures rebuilt by `scripts/visible_alpha_solve.py` are used to detect and to shape the mask, not for pixel recovery). The visual detector needs no metadata, but a borderline (faint or moved) mark is only trusted with corroboration: `--sensitivity` (default `auto`) relaxes a mark's gate when local metadata confirms the vendor or a same-product sibling mark is found; `strict` never relaxes. There is deliberately no "assume this is AI" mode: asserting that an image is AI says nothing about *which* vendor made it or *where* the mark is, which is exactly what a gate bypass needs. If you can SEE a mark the detector missed, point at it with `erase --region x,y,w,h`, or force a known text mark by name with `--mark <name> --no-detect` -- both act on what you actually see instead of relaxing every detector at once. `visible --mark auto` finds and removes every detected mark in one pass. Fast, offline, no GPU. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or color. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
|
||||
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
|
||||
- **Analog Humanizer** — optional film grain and chromatic aberration post-processing
|
||||
- **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. 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.
|
||||
- **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, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, 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)
|
||||
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -42,7 +48,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
|
||||
| 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 | Alpha reversal + diffusion + metadata strip |
|
||||
| **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 |
|
||||
@@ -53,16 +59,21 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
| **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 (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Reverse-alpha (captured α map) + residual inpaint over the glyph footprint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Localize glyph footprint + fill + metadata strip |
|
||||
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Localize glyph footprint + fill + metadata strip |
|
||||
| **Qwen / Tongyi Qianwen** (千问, Alibaba) | ✅ "千问AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Kling** (可灵, Kuaishou) | ✅ "可灵AI 3.0" text strip (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Baidu** | ✅ "百度 AI生成" text + tag (bottom-right) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **LibLibAI** (哩布哩布AI) | ✅ "LibLibAI" wordmark (bottom-center) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **RunningHub** | ✅ "RunningHub AI生成" text (top-left) | — | ✅ TC260 AIGC label | Localize glyph footprint + fill + metadata strip |
|
||||
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, Italian-locale detection) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Localize glyph footprint + fill + metadata strip |
|
||||
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng and Samsung add a thin residual inpaint over the glyph footprint, since their marks re-rasterize per image). 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 colour), use the universal `erase --region` command.
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), by Alibaba's Qwen / Tongyi Qianwen ("千问AI生成" corner text), by Kuaishou's Kling ("可灵AI 3.0" corner text), by Baidu ("百度 AI生成" text + tag), by LibLibAI ("LibLibAI" wordmark, bottom-center), by RunningHub ("RunningHub AI生成", top-left), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed by localizing the mark to a footprint mask and inpainting it with one shared fill (cv2 by default, MI-GAN or big-LaMa via `--backend`); the localizer is CPU-cheap and the heavier fills are opt-in. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any color), use the universal `erase --region` command.
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` 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, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, 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.
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, EXIF, or JPEG segment), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the C2PA cloud-manifest reference (Adobe Durable Content Credentials, when the embedded manifest is stripped), the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI / RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -74,25 +85,19 @@ Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo
|
||||
watermarked = α × logo + (1 − α) × original
|
||||
```
|
||||
|
||||
We reverse this with a known alpha map (extracted from Gemini / Nano Banana output on a pure-black background):
|
||||
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).
|
||||
|
||||
```text
|
||||
original = (watermarked − α × logo) / (1 − α)
|
||||
```
|
||||
|
||||
A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark position and scale dynamically, so it works even if the image was resized or cropped. After removal, residual sparkle-edge artifacts are cleaned via gradient-masked inpainting.
|
||||
|
||||
**Speed**: ~0.05s per image. No GPU needed.
|
||||
**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. It is a fixed semi-transparent white overlay, so it is removed by **reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map is solved from controlled black/gray captures (rebuildable with `scripts/visible_alpha_solve.py`). Like the Jimeng mark, Doubao re-rasterizes its text slightly per image, so reverse-alpha is followed by a thin residual inpaint over the glyph footprint to clear the leftover edges, and the α template is NCC-aligned to the actual mark (handling per-image scale/position jitter). Detection matches the same glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners.
|
||||
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**: ~0.05s, no GPU needed.
|
||||
**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. It is a fixed semi-transparent **pure-white** overlay, solved from controlled black / gray / white captures the same way as Doubao. `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). One difference from Doubao: Jimeng re-rasterizes its mark slightly differently per image, so a single alpha map does not cancel it pixel-for-pixel — reverse-alpha knocks the mark down and a residual inpaint over the glyph footprint clears the remaining outline. 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).
|
||||
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
|
||||
@@ -101,7 +106,7 @@ 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. It is solved from controlled black / gray / white captures the same way as Jimeng and removed by reverse-alpha plus a thin residual inpaint over the glyph footprint (the mark re-rasterizes per image, and the flat captures are smaller than real photos, so the alpha template is NCC-aligned and width-scaled to the actual mark). `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. The string is **locale-specific** — this build is calibrated for the Italian "Contenuti generati dall'AI" variant; other locales need their own captured template (open a sample on issue #37).
|
||||
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
|
||||
@@ -110,7 +115,7 @@ 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 colour — `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.
|
||||
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
|
||||
|
||||
@@ -127,7 +132,7 @@ image → encode to latent space (VAE) at native resolution
|
||||
→ 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. 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.
|
||||
- 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`+.)
|
||||
>
|
||||
@@ -232,6 +237,10 @@ After installation the `remove-ai-watermarks` command is available system-wide.
|
||||
> 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):
|
||||
>
|
||||
@@ -325,12 +334,26 @@ remove-ai-watermarks batch ./images/ --mode all
|
||||
# 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) finds the
|
||||
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
|
||||
# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with
|
||||
# --mark gemini / doubao / jimeng / samsung. Removed by reverse-alpha (true-pixel recovery).
|
||||
# Visible watermark only — fast, offline, CPU. --mark auto (default) removes every
|
||||
# detected known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" /
|
||||
# Qwen "千问AI生成" / Kling "可灵AI 3.0" / Baidu "百度 AI生成" / LibLibAI /
|
||||
# RunningHub "RunningHub AI生成" / Samsung Galaxy AI "Contenuti generati dall'AI");
|
||||
# force one with --mark gemini / doubao / jimeng / qwen / kling / baidu / liblib /
|
||||
# runninghub / samsung. Removal localizes each mark to a
|
||||
# footprint mask and inpaints it with a shared fill; --backend auto|cv2|migan|lama
|
||||
# (default auto) picks the fill (auto = LaMa > MI-GAN > cv2, best available).
|
||||
# --sensitivity auto|strict (default auto) sets how hard a borderline mark is
|
||||
# trusted; there is no "assume this is AI" mode -- to act on a mark you can see
|
||||
# but the detector missed, point at it with `erase --region` (below).
|
||||
# If no known visible mark is found, it writes no output and exits 2 (not 0),
|
||||
# pointing you to `all` (for an invisible/metadata mark) or `erase` (for an
|
||||
# arbitrary logo) instead of handing back the unchanged image.
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Metadata-stripped screenshot where you can SEE the mark but detection missed it:
|
||||
# point at it. This executes what you see instead of relaxing every detector at once.
|
||||
remove-ai-watermarks erase screenshot.png --region 812,1180,190,44 -o clean.png
|
||||
|
||||
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
|
||||
# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama').
|
||||
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
@@ -341,7 +364,8 @@ remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0 --unsharp 0
|
||||
# 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, cap the long side: --max-resolution 2048
|
||||
# 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
|
||||
@@ -351,6 +375,12 @@ remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0 --unsharp 0
|
||||
# --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
|
||||
@@ -370,20 +400,30 @@ remove-ai-watermarks batch ./images/ --mode all
|
||||
|
||||
### Python API
|
||||
|
||||
One high-level call removes every detected visible mark (Gemini sparkle, Doubao / Jimeng / Qwen / Samsung text, the Jimeng pill) by localize then fill. For a file it reads metadata provenance automatically and preserves the alpha channel; `import remove_ai_watermarks` stays cheap (the heavy deps load lazily on first use).
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
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")
|
||||
|
||||
engine = GeminiEngine()
|
||||
image = cv2.imread("watermarked.png")
|
||||
# Metadata-stripped screenshot you know is AI-generated: relax detection to recover a
|
||||
# moved or faint mark (a wrong guess just fills a small corner near-losslessly).
|
||||
raiw.remove_visible("screenshot.png", "clean.png", sensitivity="strict")
|
||||
|
||||
# Detect
|
||||
result = engine.detect_watermark(image)
|
||||
print(f"Detected: {result.detected} (confidence: {result.confidence:.1%})")
|
||||
# What the file's metadata confirms (drives the default `auto` sensitivity):
|
||||
raiw.visible_provenance("in.png") # e.g. frozenset({'gemini'})
|
||||
|
||||
# Remove
|
||||
clean = engine.remove_watermark(image)
|
||||
cv2.imwrite("clean.png", clean)
|
||||
# Full provenance verdict (platform + watermark inventory + confidence):
|
||||
from remove_ai_watermarks.identify import identify
|
||||
report = identify("in.png")
|
||||
```
|
||||
|
||||
#### Invisible removal (diffusion)
|
||||
@@ -452,7 +492,7 @@ pip install certifi
|
||||
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 CLAUDE.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.
|
||||
- **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.)
|
||||
@@ -465,6 +505,13 @@ 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.
|
||||
@@ -482,14 +529,14 @@ Watermarking and provenance for AI-generated content is now regulated in several
|
||||
|
||||
## Threat model
|
||||
|
||||
This tool defends already-distributed AI imagery against automatic detection systems (social-platform "Made with AI" labels, third-party classifiers, content-policy filters). It does **not** retroactively anonymise generation.
|
||||
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.
|
||||
- You are preserving art or historical record against false-positive "AI-generated" labels.
|
||||
- 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.
|
||||
|
||||
@@ -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=<out>.png --variant qwen=<out>.png --ocr-langs ""
|
||||
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 9.3 MiB |
@@ -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世界也会向你微笑。"
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
@@ -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 value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Because the ISOBMFF branch never runs the JPEG `_scrub_ai_exif`, this is the ONLY EXIF scrubber on that path and must stay in PARITY with it: it now also blanks the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value` — Doubao producer + Tencent service-provider schemas) and the xAI/Grok `Signature:` + UUID-`Artist` pair, not just `AI_GENERATOR_TOKENS` in `Software`/`Make`/`Artist`/`ImageDescription` (regression `test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`). **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
|
||||
**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.
|
||||
@@ -0,0 +1,401 @@
|
||||
# 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: <base64>` + 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_qwen` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. Corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Qwen/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.50, Jimeng 0.45, Qwen 0.45, Samsung 0.40). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here.
|
||||
|
||||
**`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; Galaxy AI appends it as a trailer AFTER the JPEG EOI, so `samsung_genai` reads the file TAIL when the 512 KB quick-scan head misses it — else a multi-MB photo's trailer past the window went undetected while removal, which reads the whole file, would still strip it; removal truncates the post-EOI Samsung trailer via `metadata._strip_samsung_trailer`, pixels bit-identical): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
|
||||
|
||||
**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). `_AI_VENDOR_TOKENS` covers ByteDance (all brands: bytedance/doubao/jimeng/dreamina/volcengine), Canva, ElevenLabs, and Black Forest Labs in addition to the OpenAI/Google/Adobe/... set — without them a transplanted ByteDance/Canva/BFL C2PA manifest next to an independent conflicting stamp was silently missed. **The generic `China AIGC (TC260)` label names no SPECIFIC vendor** (any Chinese generator applies it), so it cannot vendor-conflict in the spoofing sense: when a Chinese TC260-applying vendor (`_TC260_VENDORS`, today `{ByteDance}`) is co-attributed, Rule 1 attributes the label to that vendor (a legit Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and must not clash); against a NON-TC260 vendor (OpenAI etc.) the label stays generic and still clashes as a laundering tell (`test_bytedance_c2pa_plus_own_aigc_no_clash`, `test_foreign_vendor_plus_aigc_still_clashes`, `test_bytedance_c2pa_plus_foreign_generator_clashes`). Corpus-validated: 0 new clashes on 5000 ByteDance/AIGC/Canva/FLUX carriers.
|
||||
|
||||
**High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (both are low-precision/positive-only signals; the open watermark was also historically a by-product of our own SDXL removal pass, until `watermark_remover` was fixed to load the SDXL pipelines with `add_watermarker=False` — it stays excluded as a fuzzy signal regardless). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`).
|
||||
|
||||
**`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"), `qwen` (bottom-right "千问AI生成", Alibaba Tongyi Qianwen), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_trust` turns the policy + evidence into the per-mark trust level the engines consume as `provenance = level != "strict"` — `strict` never relaxes; `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI, e.g. a metadata-stripped screenshot). **Three levels, not two: `strict` / `assumed` / `confirmed`.** Relaxing bypasses the engine's false-positive gate outright, and that bypass is contracted to mean the vendor is CONFIRMED (`GeminiEngine.detect_watermark`'s `trust_provenance`: "external metadata already proves this is a Google generation"). An `assume_ai` caller asserts the image is AI, which says nothing about WHICH vendor, so a mark relaxed on assumption alone must also clear `_ASSUMED_CONF_FLOOR` (`assumed_floor_ok`; gemini 0.50) — see "Assumed-trust confidence floor" below. **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_trust` cross-mark corroboration + the assumed-trust floor + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior was byte-identical to the pre-arbiter two-pass when the arbiter landed; `assume_ai` has since gained the assumed-trust confidence floor (see below), which deliberately changes its verdict on weak gate-bypassed matches.
|
||||
|
||||
**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.
|
||||
|
||||
**`assume_ai` removed (2026-07-19).** It relaxed EVERY mark's false-positive gate on the caller's bare assertion that an image is AI. That assertion says nothing about WHICH vendor or WHERE the mark is, which is exactly what a gate bypass is contracted to require -- before it carried a confidence floor it filled a phantom sparkle on 59.8% of genuine camera photos, and even with the floor it was a statistical gamble rather than an instruction. It also had no place in the product's model: detector finds a mark -> remove it; detector finds nothing -> leave the image alone; the USER sees a mark and says so -> act on that.
|
||||
|
||||
Removing it collapsed the trust ladder from three levels to two (`strict` / `confirmed`) and took `_ASSUMED_CONF_FLOOR`, `assumed_floor_ok` and the `assumed` level with it. `_keep_pill` lost its `sensitivity` parameter (its assume-arm is gone; the metadata arm and its flatness guard are unchanged). Verified on the 240-image unbiased recall sample: doubao 92%/99%, gemini 96%/80%, jimeng 71%/71%, pill 50%/60% -- identical before and after, so nothing on the default path moved.
|
||||
|
||||
**The replacement advice is PER MARK, because the forced paths are not equally reliable** (measured 2026-07-19):
|
||||
|
||||
| path | reliability |
|
||||
|---|---|
|
||||
| `erase --region x,y,w,h` | sound by construction -- the user supplies the coordinates |
|
||||
| `--mark <text-mark> --no-detect` | reasonable: the forced mask is the real glyph blob, non-empty on 13/13 missed doubao marks |
|
||||
| `--mark gemini --no-detect` | **NOT recommended** -- falls back to a fixed default sparkle slot, which covered the true sparkle on only **31% of 97** sparkles the strict gate missed (median offset 63px up-and-left). The other 69% fill a clean corner AND report a removal that did not happen. |
|
||||
|
||||
`cli._no_visible_mark_exit` therefore recommends `erase --region` first and a named text mark second, and never suggests forcing gemini. It previously recommended `--sensitivity assume-ai`, i.e. the product's own hint contradicted its model.
|
||||
|
||||
**Migration is LOUD, not silent.** `Sensitivity` is a `Literal` and unenforced at runtime, so a 0.15 caller passing `sensitivity="assume_ai"` would otherwise get `auto` behaviour in silence -- a quiet semantic change on exactly the release where they need telling. `validate_sensitivity` (called by `api.remove_visible` and by `Context.__post_init__`) raises a `ValueError` naming the replacement. Regression: `tests/test_watermark_registry.py::TestNoBlanketRelaxation`.
|
||||
|
||||
**Continuous top-hat detection front-end (`detect_frontend` / `tophat_response`, 2026-07-18).** `extract_mask` thresholds the white top-hat into a 0/255 glyph blob and correlates a binary silhouette against it. That is fine for a mark stamped bold and opaque, and destructive for a faint one: a thin translucent overlay shatters into specks under the threshold, and no template can match a blob that is not there (千问 measured 0.170 mean NCC, **0%** over its gate, against doubao's 0.723 / 82% -- same pipeline, each with its own template). The `tophat` front-end never binarizes: the saturation and absolute-luma gates become WEIGHTS instead of hard cuts, so a faint stroke contributes in proportion to its strength, and the response is max-normalized, which makes the score contrast-invariant.
|
||||
|
||||
Doubao is switched to it; jimeng and samsung stay `binary` until measured, because a front-end change must be measured per mark before it ships. Corpus effect on the 240-image unbiased recall sample:
|
||||
|
||||
| mark | recall before | recall after | precision before | precision after |
|
||||
|---|---|---|---|---|
|
||||
| doubao | 89% | **92%** | 99% | **99%** |
|
||||
| jimeng / gemini / pill | unchanged | unchanged | unchanged | unchanged |
|
||||
|
||||
**The gate is FRONT-END SPECIFIC and must be re-calibrated, not ported.** The continuous response scores higher overall (mean 0.809 vs 0.723 on the same 90 positives), so the binary-era 0.40 left the provenance-relaxed gate (x0.7) far too low: at 0.40 the arm ran 96% recall / 91% precision (8 false fires), at 0.50 it runs 92% / 99% (1 false fire). 0.50 was chosen because it beats the binary front-end on recall at IDENTICAL precision -- a front-end that only trades one for the other would not have been worth shipping. A first pass at 0.40 also silently depressed the PILL (recall 50% -> 33%), because `_keep_pill` suppresses the pill whenever doubao fires; a coupling worth remembering when tuning any bottom-right mark.
|
||||
|
||||
**The removal MASK must ride the same front-end, and how it does so was fixed twice.** `tophat` detection does not binarize, but `extract_mask` (which bounds the fill) still does, so a mark faint enough to be found only by the continuous response produced an EMPTY binary blob: `localize` returned `mask=None`, `remove()` was a silent no-op, and `identify` reported `visible_doubao` while `visible` said "no visible mark" on the same file (corpus-measured 2026-07-20: 57 of 60 sampled still-detected Doubao marks untouched, ~8% of its detections). The FIRST fallback (2026-07-19) thresholded the continuous response and took the bounding box of everything above the level -- but the level was `0.5` compared against the max-normalized **uint8 0..255** response, so it selected every non-zero pixel and filled ~120% of the corner box on textured frames (a padded whole-ROI box). It passed parity (a mask that fills everything is trivially detector-clean) and its regression test (a FLAT fixture, where the response is non-zero only on the glyph, so every threshold yields the same box). The SECOND fallback (2026-07-20) uses the detector's OWN best-match box instead: `_tophat_score` was split into **`_tophat_best(image, loc) -> (score, box)`**, the single method whose score gates detection and whose argmax box bounds the mask -- so the two cannot drift by construction, which is how the mismatch arose in the first place. Measured over 14 real faint-path frames (cv2 fill, detector re-run after): the match box fills a **58.7%**-median corner box vs the threshold's **120.9%**, both 100% detector-clean. The largest-connected-component alternative was tighter (10.5%) but removed the mark on only 21% of frames, so it does not cover it and was rejected. Regression: `tests/test_text_mark_faint_mask.py`, whose fixture now carries texture (the flatness of the old one is exactly why it could not see the threshold bug -- mutating the constant to 99.0 left it green). **Any future front-end change must move both the detection and the mask path, or re-check this.**
|
||||
|
||||
**Vendor ATTRIBUTION for the shared-suffix marks -- solved for 千问 by exact sizing, not by a generic template.** The 2026-07-18 worry was that "千问AI生成" and "豆包AI生成" share the `AI生成` tail (four of six glyph cells), so their templates would cross-fire. That held while Qwen was scored at the WRONG SIZE (AUC 0.41-0.59, a coin flip). Re-measured 2026-07-21 at the fitted geometry on real pools (`scripts/vendor_mark_calibrate.py --crossfire`): Qwen's template scores p50 0.224 / p90 0.242 on 400 Doubao-marked frames against a 0.45 gate -- **0 cross-fires**, and 0 on 298 Jimeng-marked frames and 286 clean frames -- because an exact-size 6-glyph template is specific enough that the 2-glyph prefix mismatch dominates the shared tail. Qwen was therefore registered WITHOUT a rival margin (a 0.10 margin would have suppressed ~10% of genuine Qwen detections, whose margin p10 sits at 0.00). A GENERIC shared-tail template remains a harvesting aid, not a detector (measured: 0.407 on a bold positive vs clean p99 0.298).
|
||||
|
||||
**千问 is registered since 2026-07-21; 星绘 is NOT (still one confirmed example).** The 2026-07-18 measurement below is why 千问 was originally held out -- it is kept because it records the failure CLASS that the registration then had to solve. Measured back then on 14 hand-verified corpus positives, same pipeline, each mark scored with its OWN template:
|
||||
|
||||
| mark | n | mean NCC | median | above the 0.40 gate |
|
||||
|---|---|---|---|---|
|
||||
| doubao | 40 | 0.723 | 0.835 | **82%** |
|
||||
| qwen | 14 | 0.170 | 0.179 | **0%** |
|
||||
|
||||
Three candidate explanations were ruled out in order, each by measurement:
|
||||
1. **Not the synthetic render.** A template cut from an ACTUAL Qwen mark scores the same as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308), and real masks do not match EACH OTHER.
|
||||
2. **Not the morphology kernel.** `MORPH_OPEN`/`MORPH_CLOSE` use fixed 5px kernels regardless of mark size (~9% of a 57px-tall box, ~2.7% of a 188px one). Scaling them with the box height gained +0.014 mean and moved nothing across the gate.
|
||||
3. **Not the appearance thresholds.** Sweeping `tophat_delta` / `logo_min_luma` / kernel size peaked at mean 0.35 with 4/14 over the gate.
|
||||
|
||||
The blocker was named as SEGMENTATION on a faint mark -- and the `tophat` front-end (built for Doubao the same week) removed exactly that blocker, yet 千问 still did not register, because the real residual was never segmentation alone: it was **mis-sized geometry** (two size modes the shared 3-rung ladder cannot straddle, plus a locate box that clipped the first glyph). The 2026-07-21 cohort harvest (117 labelled frames) and the full calibration chain are in the `qwen_engine.py` section below and `docs/verification-plan.md`. 星绘 remains where it was: ONE confirmed corpus example, so nothing to calibrate a gate against -- do not register it off a single frame. The synthetic renderer and the evidence chain are kept in `scripts/render_vendor_silhouettes.py`; researched vendor specs are in `docs/watermarking-landscape.md`.
|
||||
|
||||
**RECALL, measured at last (unbiased random sample, 2026-07-18).** Every earlier round sampled where detectors FIRED, so recall was structurally unmeasurable. This round draws 240 images at RANDOM within each provenance class (160 TC260, 80 Google-C2PA) and labels them EXHAUSTIVELY -- both corners shown at native scale, so a missed mark is visible as a miss rather than absent from the data. Build it with `scripts/visible_recall_sample.py`; labels live in the gitignored research dir.
|
||||
|
||||
| mark | present | recall | 95% CI | precision | 95% CI |
|
||||
|---|---|---|---|---|---|
|
||||
| doubao | 90 | **89%** | 81-94% | **99%** | 93-100% |
|
||||
| gemini | 46 | **96%** | 85-99% | **80%** | 68-88% |
|
||||
| jimeng | 14 | 71% | 45-88% | 71% | 45-88% |
|
||||
| jimeng_pill | 6 | 50% | 19-81% | 60% | 23-88% |
|
||||
|
||||
Effect of the `scale_basis` fix on the same sample (strict verdicts recorded before it): **doubao recall 71% -> 89%**, gemini 91% -> 96%, jimeng 64% -> 71%. Part of the doubao gain is the provenance relaxation rather than the basis alone, since the after-numbers run the full `auto` path.
|
||||
|
||||
**Three corrections this forced to earlier numbers:**
|
||||
* **Gemini precision is 80% on an unbiased sample, not the 41% the addition-sampled harness reports.** The 41% is precision restricted to relaxation ADDITIONS, which are by construction the marginal cases; production sees mostly strict fires, which are near-perfect. Quote 80% for the product and 41% only when discussing the relaxation arm.
|
||||
* Doubao is in excellent shape (89/99) and is no longer the problem it looked like before the basis fix.
|
||||
* Landscape is improved but NOT solved: doubao recall by aspect is portrait 92% / square 92% / **landscape 79%**, so a residual geometry gap remains beyond the basis.
|
||||
|
||||
**Where the remaining loss actually is:** jimeng and the pill, both at small n with intervals too wide to tune against (a 14-positive and a 6-positive sample), plus **uncovered vendors at 6% of all sampled images** (千问/百度/星绘/抖音-class marks that no registered detector can ever fire on). Adding those vendors is now a larger win than any further tuning of the covered four, and `docs/watermarking-landscape.md` carries their researched specs.
|
||||
|
||||
**Per-mark geometry scaling (`scale_basis` / `scale_base`, 2026-07-18) -- the largest single recall defect found so far.** Every tuned fraction in `TextMarkConfig` was calibrated on PORTRAIT captures, where the width and the short side coincide, so the scaling basis was never exercised until landscape inputs were measured. Corpus-measured on 2572 unique TC260 carriers, BEFORE the fix:
|
||||
|
||||
| aspect ratio | detected | missed | miss rate |
|
||||
|---|---|---|---|
|
||||
| tall portrait <0.70 | 323 | 212 | 40% |
|
||||
| portrait 0.70-0.95 | 607 | 533 | 47% |
|
||||
| square ~1.0 | 190 | 272 | 59% |
|
||||
| landscape 1.15-1.6 | 0 | 143 | **100%** |
|
||||
| wide >1.6 | 0 | 292 | **100%** |
|
||||
|
||||
**Not one landscape image in the corpus ever produced a detection** -- 435 of them, zero. A width-scaled box is inflated by the aspect ratio on a wide image, so the glyph never lands inside it and the blob never gets scored: of the 1452 no-detection TC260 images the median doubao NCC was **0.057**, with 49% at ~zero. This is a LOCALIZATION failure, not a threshold one -- only 2.7% of those images sat in the band a threshold change could reach, which is why a day of threshold tuning could never have found it. Re-running the previously-undetected set with a short-side basis recovers **56% of landscape** (12% square, 4% portrait; 20% overall).
|
||||
|
||||
**The basis is PER MARK because the vendors genuinely differ.** The same switch took jimeng's labelled landscape positives from 13/13 to **0/13**: the Jimeng wordmark tracks the WIDTH while the Doubao strip tracks the short side, even though both are ByteDance and share a corner. So doubao is `short`, jimeng is `width`, and samsung stays `width` because there is no corpus evidence either way (1 addition corpus-wide) and an unmeasured change is not an improvement. China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the shortest side", which is why short-side is the natural prior -- but jimeng's measured behaviour overrides the prior. Regression: `tests/test_text_mark_engine.py::TestScaleBasis`.
|
||||
|
||||
**How this was missed for so long:** precision was measured repeatedly and recall never was. The eval harness now reports a `missed` column for exactly this reason, and it is what caught the jimeng regression the short-side switch introduced.
|
||||
|
||||
**Competitive detection among same-corner marks (`rivals` / `_rival_margin_ok`, 2026-07-18).** Detection was purely ABSOLUTE -- every engine scored its own template against its own threshold, so nothing ever asked the discriminative question "does this blob match the NEIGHBOUR's mark better than mine?". Doubao "豆包AI生成" and Jimeng "★ 即梦AI" both sit bottom-right in near-white CJK and survive the top-hat binarization as very similar blobs, so no absolute gate can separate them. Measured on hand-labelled examples, scoring BOTH templates against the SAME glyph blob (n=40 jimeng / 75 doubao / 20 other-vendor labels / 89 clean):
|
||||
|
||||
| feature | separability (0.5 = useless, 1.0 = perfect) |
|
||||
|---|---|
|
||||
| absolute `ncc_jimeng` | 0.96 |
|
||||
| `ncc_jimeng` MINUS `ncc_doubao` | **0.99** |
|
||||
|
||||
At a 0.10 margin: real Jimeng wordmarks pass **100%**, Doubao strips 8%, other vendors' AI labels (千问/百度/星绘/抖音) 55%, no-mark corners 12%. Corpus effect (`scripts/visible_eval.py`, 741 labelled images): **jimeng precision 38% -> 63%, genuine detections unchanged at 40, false fires 65 -> 23.** Because real marks pass at 100% this is a pure precision gain, unlike raising a threshold -- so the earlier 0.85 relaxation patch was REVERTED to 0.70 and the recall it had sacrificed came back. **The gate is deliberately asymmetric:** doubao declares no rival, because the symmetric gate cost it 7 genuine detections to prevent 5 false ones (1.4:1 against) while jimeng gained 25pp for free -- doubao's absolute detector is already 86% precise and has nothing to buy. A rival's config is looked up lazily by asset name (`_rival_config`) so its template is scored at ITS own geometry; scoring it at the host mark's geometry would compare a correctly-sized template against a mis-sized one and hand the margin a free win. Regression: `tests/test_text_mark_engine.py::TestRivalMargin`.
|
||||
|
||||
**Evaluation harness (`scripts/visible_eval.py` + `scripts/visible_groundtruth.py`, 2026-07-18).** Run before AND after any detector change; `--save NAME` snapshots, `--vs NAME` diffs. Ground truth is 741 blind-labelled corpus images (779 cells across two rounds, two-sided control each). Three properties of the harness are load-bearing and were each added after the naive version produced a wrong number:
|
||||
|
||||
* **Adjudication scope.** A crop centred on one mark only lets the labeller rule on marks visible IN THAT CROP. Scoring jimeng against a pill-round image (top-left crop) books real bottom-right detections as false fires -- ~61% of pills carry a wordmark. Each image records which marks its crop could rule on; bottom-right marks co-adjudicate each other.
|
||||
* **Provenance must come from METADATA, never from the labels.** A relaxation arm only fires when provenance names the vendor, so label-derived provenance hands the detector the answer: it scored gemini at 99% instead of the true 41%.
|
||||
* **Recall is NOT reported.** The labelled set was sampled where detectors fired, so images every detector missed are absent by construction; a recall computed here would divide by a denominator that excludes exactly the failures recall exists to expose. The `missed` column catches a change LOSING marks it used to find, nothing more. True recall needs a random corpus sample labelled exhaustively -- not yet done.
|
||||
|
||||
Baseline at the time of writing (sensitivity `auto`, provenance from metadata): gemini 41% (321 fires), doubao 86% (77), jimeng 63% (63), jimeng_pill 64% (83), samsung unmeasurable (1 addition corpus-wide).
|
||||
|
||||
**Per-mark provenance NCC relaxation + the corroboration gate (2026-07-18).** Two defects, both on the DEFAULT `auto` path (no flag, driven by TC260 metadata), found by blind hand-labelling the ADDITIONS (accepted with provenance, rejected without) over 4417 unique TC260 carriers. Two-sided control: labeller sensitivity 100% (doubao) / 96% (jimeng), specificity 100% / 100% — the controls are what make the low numbers trustworthy, and the "clean" stratum is structural (another vendor's C2PA image, where a ByteDance mark cannot exist) rather than detector-defined, so it is not circular.
|
||||
|
||||
(1) **One shared `_PROVENANCE_NCC_FACTOR = 0.7` meant two different things per mark:**
|
||||
|
||||
| mark | band | precision | 95% CI | n |
|
||||
|---|---|---|---|---|
|
||||
| doubao | whole arm | 76% | 61-87% | 42 |
|
||||
| | [0.280,0.340) | 58% | 36-77% | 19 |
|
||||
| | [0.340,0.400) | 91% | 73-98% | 23 |
|
||||
| jimeng | whole arm | 17% | 10-27% | 82 |
|
||||
| | [0.315,0.383) | 12% | 6-22% | 68 |
|
||||
| | [0.383,0.450) | 43% | 21-67% | 14 |
|
||||
|
||||
The factor is now a per-mark `TextMarkConfig.provenance_ncc_factor`. Doubao stays 0.70 — both bands return more true marks than false fills, so tightening would cost 11 genuine recoveries to prevent 8. Jimeng moves to 0.85 (gate 0.3825), dropping the 12% band: −8 genuine recoveries, −60 false fills (7.5:1), arm precision 17% → 43%. **Why jimeng fails is a detector problem, not a threshold one:** of its 68 false additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问 / 百度 / 星绘 / 抖音) — relaxed, the silhouette keys on "some text in the bottom-right corner", not on "★ 即梦AI". Damage was scored separately because doubao and jimeng share a corner: 45 of the 68 fill a corner nothing else would touch, the other 23 are harmless (doubao fires strictly there and fills the same box anyway). A better silhouette, not a lower factor, is the real fix.
|
||||
|
||||
(2) **A weak detector must not corroborate a sibling (`_CANNOT_CORROBORATE`).** `resolve_trust` grants `confirmed` on a strict-detected sibling of the same `_PRODUCT_OF`, and `confirmed` bypasses the sibling's FP gate outright. The pill (~7% documented raw false-fire; 5.5% on 578 vendor negatives) maps to product "jimeng", so it could hand that bypass to the wordmark — a closed loop: pill false-fires on clean non-ByteDance content → jimeng relaxes 0.45 → 0.3825 and false-fires → `_keep_pill` sees "jimeng" in keys and takes the WORDMARK arm, removing the pill **unrestricted**, skipping the flatness guard written to stop exactly that smear. 3 of 578 negatives ran the full loop, one with `footprint_flat=0`. The fix costs nothing: negatives 3 → 0, TC260 carriers unchanged (jimeng 398 → 398, pill 117 → 117). `_keep_pill` already encoded this distrust for the pill's ACTION; the gap was that its TESTIMONY was ungated.
|
||||
|
||||
**Pill arms, re-measured on the same corpus** (149 blind-labelled TC260-arm fires, 35 wordmark, 33 unconfirmed): wordmark **94%** (CI 81-98%, confirming the original claim), TC260-metadata-only **21%** raw (CI 16-29%, consistent with the original ~27%) — **29%** (CI 20-40%) among the flat footprints the guard PASSES vs 14% among those it blocks. The guard works directionally but weakly: the shipped arm still runs at ~2.4 false fills per genuine one. Whether an arm that inaccurate belongs on the default path is a product call, not a tuning one.
|
||||
|
||||
**Samsung's relaxation is UNMEASURED and not measurable here:** the corpus holds 14 `samsung_genai` carriers and 3 visible Samsung detections total. Any precision estimate would carry a Wilson interval spanning most of [0,1]. Likely structural — detection is calibrated to the Italian locale string only.
|
||||
|
||||
|
||||
**Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). That gain is why the bypass exists, and it is conditional on the metadata actually naming the vendor — a caller merely ASSUMING the image is AI does not get it unconditionally (see the assumed-trust confidence floor above). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller.
|
||||
|
||||
**Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag (lowered to 0.35 under the Google/Gemini provenance prior) — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto` → `remove_auto_marks` (removes every detected mark), `--mark <key>` → 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/Qwen/Samsung text marks, not just the Gemini sparkle (regression-guarded by `test_all_visible_step_uses_registry`). The three text-mark adapters were consolidated 2026-06-09: a single `_text_mark(key, label, location)` builds the registry row from one parameterized `_text_mark_detect`/`_text_mark_remove` pair (the remove adapter localizes the glyph footprint and hands it to the shared `fill` only when detected/forced, else skipped); the gemini adapters stay bespoke. Add a new visible mark = one `_text_mark(...)` row + its `TextMarkConfig` (with a captured alpha map for the detection silhouette); do not re-add per-mark `if` branches or copy-paste adapters.
|
||||
|
||||
**Alpha-on-save policy (issue #30):** `image_io.write_bgr_with_alpha` (it lives in `image_io`, not `cli` — moved so the CLI and the library `api` share ONE implementation) rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. The fill reconstructs real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region, but the CLI no longer uses it to clear alpha. **It returns `imwrite`'s success flag and callers must check it** (2026-07-20): `imwrite` is contractually non-raising, so that bool is the only signal the file was not created. The wrapper previously returned `None` and swallowed it, so every CLI write site ran `output.stat()` to report the size and a read-only destination died with a bare `FileNotFoundError` traceback pointing at the stat instead of the write. The CLI now writes through the shared `cli._write_output_or_exit`. Regression: `tests/test_cli_robustness.py::TestFailedWriteIsReported`.
|
||||
|
||||
## `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, and since 2026-07-21 `ladder` — the scale rungs `_tophat_best` sweeps, per-mark because 千问's two size modes do not fit the shared 3-rung comb (default `(0.8, 1.0, 1.25)`, unchanged for every other mark; densifying the SHARED ladder was measured and rejected -- see the verification plan's B2). plus the test-facing module shims (`_alpha_template`/`_glyph_silhouette`/`_template_match_score` + the constants). Gemini stays a SEPARATE engine (its multi-size fixed-slot sparkle model is genuinely different). Add a new text mark = a new `TextMarkConfig` + a thin subclass + one registry `_text_mark(...)` row. The engine bullets below describe each mark's calibration history; the LOGIC lives here. **Small-image detection guard (`_MIN_DETECT_SHORT_SIDE` 200, added 2026-06-26):** `detect` returns not-detected when the image short side is below 200px. Below that the glyph template degrades to the `min_gw` floor (~8px) and `TM_CCOEFF_NORMED` on a few pixels is noise, so an unrelated small geometric shape can spuriously correlate with the CJK silhouette — a 48×48 app-icon chevron scored Doubao 0.41 / Jimeng 0.47 (both above their thresholds), a pure small-size artifact (the same icon upscaled collapses to ~0.06–0.10 NCC at ≥256px). A real AI-generation label is stamped on a full-resolution render (the captured samples are 1086–2048px wide, the smallest positive test image is 1086px), so the floor sits far below any genuine mark while killing the icon/thumbnail band (≤96px); `identify` falls back to "unknown" (the safe default) and removal, gated on detection, is suppressed too. Regression-guarded by `test_{doubao,jimeng,samsung}_engine.py::TestDetect::test_small_image_guarded_from_false_positive`.
|
||||
|
||||
**Removal is localize -> fill.** The engine localizes the glyph blob (`extract_mask` over the located box) into a solid, dilated footprint mask and hands it to the shared `watermark_registry.fill` (cv2 / MI-GAN / big-LaMa). The template-free mask (bounding box of the glyph blob, not the fixed alpha template) means a re-rendered or moved mark is still covered, and the fill reconstructs the box from its surroundings. On corpus images doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal.
|
||||
|
||||
**The reverse-alpha removal machinery is retired.** The old per-glyph reverse-alpha blend (`_apply_reverse_alpha`), the fixed/aligned alpha-map helpers, the over-subtraction guard (`_reverse_alpha_oversubtracts` → `_inpaint_footprint`, the dark-pit fix on dark/mid-tone backgrounds), and the always-align placement search are all gone — the fill reconstructs the footprint from its surroundings rather than inverting the captured alpha, so the dark-pit and color-shift failure modes those guards patched around no longer arise. `extract_mask` still returns a box-sized (`(loc.h, loc.w)`) mask rather than a full frame, which keeps the memory-tight `identify` detect path cheap.
|
||||
|
||||
## `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 detection silhouette — the locale string font-rendered and calibrated on real positives (the pill's `scripts/render_pill_silhouette.py` pattern), NOT an app capture (the solid/gray/white capture workflow retired with reverse-alpha). This is a pre-existing limit, unchanged by the localize -> fill refactor.
|
||||
|
||||
**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).
|
||||
|
||||
## `qwen_engine.py`
|
||||
|
||||
`qwen_engine.py` — **a thin `TextMarkEngine` subclass (config only), registered 2026-07-21.** visible Qwen (Alibaba Tongyi Qianwen) "千问AI生成" detector + localizer (cv2/numpy, no GPU), bottom-right, the same GB 45438-2025 6-glyph house style as Doubao (2-glyph vendor prefix + mandated `AI生成` tail; the vendor's tri-lobe logo precedes the text and is deliberately NOT in the silhouette -- logos vary between releases, the CJK run is what discriminates). The detection silhouette `assets/qwen_alpha.png` is font-rendered synthetic (`scripts/render_vendor_silhouettes.py`), never cut from an upload. Feeds `identify` as the medium-confidence `visible_qwen` signal via the registry.
|
||||
|
||||
**Why this registration took two attempts:** the 2026-07-18 attempt died at n=1 positives; the unlock was the TC260 label's `ContentProducer` field, whose USCC names the signing entity and partitions carriers into per-vendor cohorts from metadata alone (`scripts/vendor_cohort_harvest.py` -- 117 labelled Qwen frames, owing nothing to any pixel detector). Every constant was then MEASURED on that cohort against 286 hand-labelled clean frames (`scripts/vendor_mark_calibrate.py`), not inherited from Doubao:
|
||||
|
||||
* **Basis `short`** (frac_short CV 0.189 vs width 0.273).
|
||||
* **Per-mark 2-rung `ladder=(0.78, 1.27)`** against `alpha_width_frac` 0.160: the mark sits in TWO size modes (~0.124 and ~0.203 of the short side, ratio 1.64, both clusters tight) -- wider than the shared 3-rung ladder's 1.5625 span, so the best single fraction covers only 74.5% and the small mode lands in the comb's collapse zone (real marks at the exact rung score ~0.94 vs ~0.67 on the shared ladder). A 4-rung variant scored strictly worse (its big-mode rung sits 4.6% off the mode). The shared default is untouched for every other mark.
|
||||
* **Fitted locate box** (`width_frac` 0.231, `height_frac` 0.074, margins ~0.021): the real mark sits ~0.025 of the short side off the right edge, and Doubao's box (0.004 margin) clipped the first glyph -- an exact-size template collapsed 0.73 -> 0.26 on a real frame.
|
||||
* **`alpha_height_frac` 0.0416 from the aspect fit** (p50 aspect 0.260 at the winning width) -- not the silhouette's own aspect (0.2219) and not Doubao's ratio.
|
||||
* **Gate 0.45**: clean p99 0.301 / max 0.316 (286 frames), and every cohort frame >= 0.45 carries a visible mark (83 of ~96 eyeballed visible marks fire = 86% recall of visible marks; the misses are white-on-near-white contrast losses). 0.45 was picked over 0.32 for margin against unseen clean content at zero measured recall cost.
|
||||
* **STRICT ONLY (`provenance_ncc_factor` 1.0):** the score band just below the gate is dominated by non-Qwen banners on same-cohort frames (a 夸克 anti-forgery strip at 0.274, a 造点 mark at 0.253), so a provenance-relaxed arm would be mostly false fills. There is no provenance mapping for qwen and no relaxed arm.
|
||||
* **No rival margin:** 0 cross-fires on 400 Doubao-marked / 298 Jimeng-marked / 286 clean frames at the gate, while a 0.10 margin would have cost ~10% of genuine Qwen detections. A confident Qwen detection suppresses the Jimeng pill exactly like Doubao's does (`_keep_pill` -- a Qwen frame is TC260 too but is not Jimeng-basic).
|
||||
|
||||
**Parity:** detect -> cv2 fill -> re-detect is clean on **83/83** real cohort marks, no empty masks (the `tophat` faint-mask fallback rides the same `_tophat_best`); the e2e suite drives one live cohort positive through the real CLI (`scripts/real_examples_e2e.py`, qwen bucket = symlinks under the gitignored `_visible_datasets/`). Regression: `tests/test_qwen_engine.py` (pins the ladder, the strict-only factor, the box anchor, and both size modes at a score floor that discriminates the shared-ladder and Doubao-margin mutations).
|
||||
|
||||
**The clean-arm contamination trap (load-bearing for any future calibration):** the 2026-07-18 `present: []` labels are in the vocabulary of the REGISTERED marks only, so 146 of the 432 "clean" frames sit in a TC260 cohort -- including Qwen-cohort frames visibly carrying 千问AI生成. They made up the clean arm's entire top tail (clean p99 0.37 -> 0.69). `vendor_mark_calibrate.load_sets` now excludes every frame in ANY TC260 cohort from the clean arm; a gate read off the unguarded arm is meaningless.
|
||||
|
||||
## `runninghub_engine.py`
|
||||
|
||||
`runninghub_engine.py` — **thin `TextMarkEngine` subclass, registered 2026-07-22.** RunningHub (hosted ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort) "RunningHub AI生成" detector + localizer, **top-left** (the first `corner="tl"` mark), faint mid-gray latin+CJK text. Feeds `identify` as `visible_runninghub`.
|
||||
|
||||
* **`gray` front-end (the third one, added for this mark):** the mark's faint gray is suppressed by the white top-hat to clean-arm levels (positives 0.16-0.23 vs clean p99 0.31), while raw-grayscale silhouette NCC separates (positives 0.38-0.54 vs clean p99 0.264 / max 0.304 on 283 guarded clean frames). It is contrast-DEPENDENT, unlike tophat -- one method `_gray_best` serves both detection and the mask, same one-method parity contract as `_tophat_best`.
|
||||
* **Tight ladder (0.95, 1.0, 1.05) exactly on the measured 0.32-of-width:** the NCC comb is razor-sharp in size (0.537 on-size, 0.223 at +5.6% off -- Qwen's comb behaviour, measured again here), so the shared 3 rungs (nearest rung 5.6% off) collapsed the match to 0.22 and the first calibration showed no separation at all.
|
||||
* **Anchor gate in `detect`:** the full-corpus sweep (`data/spaces/_sweep_new_marks.py`, 42009 files) fired on 37 outside-cohort frames at 0.34-0.38 (hair tops, shelves, window frames, CJK banners) -- no NCC threshold separates them from the 0.381 positives. Every positive sits at the measured corner (x 0.008-0.014, y 0.005-0.007 of the frame) and every false fire off it (x 0.013-0.150, y 0.009-0.045), so detection additionally requires the match box inside x<=0.025 / y<=0.015 of the frame. 0/37 false, 4/4 positives kept.
|
||||
* **Footprint is always the detector's match box** (never the binary blob): the blob under-segments the faint head glyphs and left "Runni" unremoved (caught visually on the first removal). Gate 0.34, STRICT ONLY. Regression: `tests/test_runninghub_engine.py`.
|
||||
|
||||
## `baidu_engine.py`
|
||||
|
||||
`baidu_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** Baidu (USCC 91110000802100433B, 16-frame cohort) "百度 AI生成" detector + localizer, bottom-right: a white bold 百度 text run + a separate white rounded tag with dark "AI生成". Feeds `identify` as `visible_baidu`.
|
||||
|
||||
* **Detection keys on the 百度 text run ONLY.** A two-component (text+pill) template was measured and rejected: the solid white pill is a bright-blob magnet and both front-ends scored the clean arm at cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5). The text-only silhouette separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is still removed: the footprint extends to the corner (below).
|
||||
* **Two load-bearing rival margins** (`rivals=("doubao_alpha.png","qwen_alpha.png")`): 百度 vs 豆包 share their second glyph, and 百度 vs 千问 are near-identical after binarization -- at the 0.37 gate the template fired on 45.8% of 400 Doubao-marked frames and, on the 741-frame blind-labelled eval set, on 12 Qwen-marked frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks, Qwen's by 0.17-0.35 on Qwen marks; the 0.10 margin suppresses all crossfire at zero genuine-Baidu cost (cohort fire+m == fire).
|
||||
* **Gate history, each step measured:** 0.37 from the clean arm (max 0.352) -> 0.43 after the eval-set crossfires (the one 抖音 AI创作 fire at 0.425 named no registered rival) -> **0.48** after the full-corpus sweep put outside-cohort TRUE carriers at 0.50-0.66 vs the false arm max 0.47 (大众点评 UI, a math blackboard, an 80s banner). Cohort keeps 7/16 (all true); the sweep also found 6 metadata-STRIPPED true Baidu carriers the TC260 cohort cannot see -- the direct evidence that registration pays beyond the cohort.
|
||||
* **Custom `footprint_mask`:** the tag's flat white interior gives no top-hat response (a top-hat answers edges, not flats), so the base blob bbox ended at the text run and the fill left the tag as a ghost. The mask is the detector's match box extended RIGHT to the corner. STRICT ONLY. Regression: `tests/test_baidu_engine.py`.
|
||||
|
||||
## `liblib_engine.py`
|
||||
|
||||
`liblib_engine.py` — **thin `TextMarkEngine` subclass with a custom `footprint_mask`, registered 2026-07-22.** LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A, 15-frame cohort) triangle logo + "LibLibAI" wordmark detector + localizer, **bottom-CENTER** (the first `corner="bc"` mark; the locate box is horizontally centered). Feeds `identify` as `visible_liblib`.
|
||||
|
||||
* **The discriminative lever is the silhouette FONT.** With the CJK house font (STHeiti) the cohort scored 0.31-0.47 against a full-corpus false arm (latin UI text bands, website screenshots) at 0.50 -- no separation at any gate. Measured across 7 candidate fonts, **Arial** lifts the cohort to 0.42-0.73 and DROPS the false arm to max 0.398: generic latin text matches the wrong font less, which is where the discrimination comes from. Gate 0.42 keeps all 8 marked cohort frames (0.43-0.59).
|
||||
* **Per-mark size floor (`_MIN_SHORT_SIDE=480`):** the one false fire with the final template was a 200x200 icon (0.444, on a 20px template). The shared `_MIN_DETECT_SHORT_SIDE` (200) is a crash guard, not a discrimination floor; the template needs ~48px to discriminate.
|
||||
* **Custom `footprint_mask`:** the base blob bbox was wrong in both directions -- it bled UP into background structure (ate a shirt's real print on the 768x1024 cohort frame) and never owned the triangle logo. The mask is the detector's match box extended LEFT by ~1.3 glyph heights (the logo is ~1.0x the glyph height, gap ~0.3x, measured on the cohort zoom). STRICT ONLY. Regression: `tests/test_liblib_engine.py`.
|
||||
|
||||
## `region_eraser.py`
|
||||
|
||||
`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
|
||||
- `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).
|
||||
|
||||
**Every single-image command's `source` argument declares `dir_okay=False`** (2026-07-20). `click.Path(exists=True)` accepts a directory unless told otherwise, so `identify <dir>` sailed past argument parsing and raised `IsADirectoryError` out of `metadata.scan_head`'s `open()` — a traceback, not a usage error. Refusing it at the argument layer is the right place: every command gets it, and none needs its own check. (`batch`'s `directory` argument was already correct with `file_okay=False`.) Found by the Tier E adversarial sweep; regression: `tests/test_cli_robustness.py::TestDirectoryInputIsRejected`.
|
||||
|
||||
### `all`
|
||||
|
||||
Full pipeline (visible + invisible + metadata). Same diffusion knobs as `invisible`, plus the visible-pass `--backend auto|cv2|migan|lama` (default `auto`) that picks the fill for the localize -> fill visible removal. **When the `[gpu]` extra is absent, step 2 (invisible/SynthID) is skipped** — `all` still writes an output (visible mark + metadata stripped) but prints a prominent end-of-run banner ("the invisible (SynthID) watermark was NOT removed") AND exits **non-zero** (1), so a skipped SynthID pass is not mistaken for a clean result (the recurring #14/#47 trap, where the old quiet inline warning was missed). `invisible` already hard-errors without the extra; only `all` continued, hence the loud end-banner. Regression-guarded by `tests/test_cli.py::TestAllCommand::test_all_loud_warning_and_nonzero_exit_when_gpu_missing`. **No-signal skip (P0#5):** step 2 also runs the same `has_invisible_target` gate (see `invisible` below) — when no invisible watermark is detectable and `--force` is not set, step 2 is skipped and the pixels are left intact, but unlike the GPU-missing skip this is a **SUCCESS (exit 0)**: the visible pass + metadata strip still ran and a file is written (the message says so without claiming the image is clean). Distinct exit semantics by design: GPU-missing = couldn't do the work (non-zero); no-signal = nothing to do (zero). Regression-guarded by `test_all_skips_invisible_on_no_signal_but_succeeds`. **Test trap:** any `all` test that exercises the full pipeline MUST `patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True)` — CI installs core+dev only (no `[gpu]`), so an unpatched `all` test takes the skip branch and now hits the non-zero exit. This passed locally (gpu present → `is_available()` True) but red-failed every matrix cell on the v0.11.0 commit (`test_all_basic`/`test_all_visible_step_uses_registry` asserted exit 0); both now patch `is_available` True.
|
||||
|
||||
### `invisible`
|
||||
|
||||
Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alternative to a `--max-resolution` downscale for large inputs that OOM on MPS/GPU: it engages only when the long side exceeds `--tile-size` (default 1024); tiles are feather-blended over `--tile-overlap` px (default 128); pair with `--max-resolution 0`. `--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, subject to the assumed-trust confidence floor where the vendor is unconfirmed — the only path to higher recall on a metadata-stripped screenshot). `--backend` and `--sensitivity` are shared across `visible`/`all`/`batch`. Detection keys on each mark's own shape, and under `auto` the trust gate is relaxed when local metadata confirms the vendor (a Google/Gemini C2PA issuer relaxes gemini, a China-AIGC label relaxes doubao/jimeng, `samsung_genai` relaxes samsung), so a moved or re-rendered mark is still caught. `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Qwen "千问AI生成" text strip, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian-locale detection), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao or Qwen, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata confirms Jimeng (`"jimeng" in provenance`, from `cli._visible_provenance`) OR the caller asserts AI (`sensitivity == "assume_ai"`), no wordmark — **re-measured 2026-07-18 on 149 blind-labelled pill fires: 21% precise raw (CI 16-29%), 29% (CI 20-40%) among the flat footprints the guard actually PASSES, 14% among those it blocks** — its false fires are textured ceilings/walls that the fill visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible fill (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`; the flatness guard holds even under `assume_ai`). No confirmation → never removed. `--mark gemini|doubao|jimeng|qwen|samsung|jimeng_pill` forces one (choices come from the registry). Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. For arbitrary logos/objects use `erase`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` 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 `<directory>_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. **Exit code (`batch` used to always exit 0, hiding failures):** `cmd_batch` raises `SystemExit(1)` when any image errored, OR when a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent so its SynthID scrub was skipped — mirroring single `all`, it emits a loud "the invisible watermark was NOT removed on N image(s)" warning and (invisible mode) copies the input through so the output dir stays complete, rather than silently dropping the signal-bearing files that most needed processing. `_process_batch_image` returns that skipped-scrub flag; the loop tallies it. Regression-guarded by `tests/test_cli.py::TestBatchCommand::{test_batch_errors_exit_nonzero, test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero}`.
|
||||
@@ -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
|
||||
@@ -0,0 +1,39 @@
|
||||
# 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/<version>/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.
|
||||
|
||||
**Where the HF Space's own source lives (its demo code, NOT this library):** the private repo **`wiltodelta/raiw-hf-space`** (locally `~/Documents/GitHub/raiw-hf-space`) — `app.py` (CPU-core Gradio demo), `requirements.txt`, `README.md` (the Space card), `assets/`, `examples/`. **Deploying it is a plain `git push` to that repo's `main`:** its `.github/workflows/sync-to-hf.yml` mirrors the files onto the Space via the Hub API (`HfApi.upload_folder`, secret `HF_TOKEN` = a **write**-role token), which adds a commit on top of the Space's own history — deliberately NOT a `git push --force` to the Space, which would clobber it. Do NOT edit the Space through the huggingface.co web UI any more: that was the pre-2026-07-16 workflow (it is why every Space commit before then is authored `@users.noreply.huggingface.co` and why no local write token ever existed), and a web edit now silently diverges from the GitHub source of truth. Note the demo tracks the library's **CPU-core** surface only (`identify` / `visible` / `metadata`); the invisible/SynthID path needs a GPU and stays out. Two distinct automations touch the Space and must not be confused: `sync-to-hf.yml` (in `raiw-hf-space`) ships **demo code changes**; `distribute.yml` (in this repo) factory-rebuilds the Space on a **library release** so its `remove-ai-watermarks>=...` pin re-resolves to the new version.
|
||||
|
||||
**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 <run-id> --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 <registry-PAT>` 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, alert range `<= 2.12.1`) was dismissed `not_used` on 2026-06-10 (torch is a transitive dep of the optional `gpu` extra only and the codebase never calls `torch.jit`) and **resolved by upgrade on 2026-07-21**: the lock carries torch **2.13.0**, above the patched floor, so `uv-secure` is clean. If the GitHub alert has not auto-closed on the lock bump, close it manually as fixed.
|
||||
- **setuptools** bumped 81.0.0 -> 83.0.0 (2026-07-21), fixing PYSEC-2026-3447.
|
||||
@@ -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.
|
||||
@@ -181,6 +181,15 @@ pos original plus its minimum-clearing cleaned output (manifest `verified_via` =
|
||||
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** |
|
||||
@@ -453,9 +462,18 @@ study (section 2.2) gives empirical floors:
|
||||
|
||||
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.20 / `GEMINI_STRENGTH` 0.30 / `UNKNOWN_STRENGTH` 0.30. **The SAME
|
||||
ladder applies to both pipelines** (`sdxl` and `controlnet`) -- these are the
|
||||
oracle-certified controlnet floors (§5.5, the 2026-06-04 Modal cert). Why one ladder
|
||||
`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
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# 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 <hash>"` / `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: <base64>` (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/<uuid>.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 `<TC260:AIGC>{"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 `<TC260:AIGC>{...}</TC260:AIGC>` (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 `<?xpacket begin … end?>` 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.
|
||||
|
||||
## Uncovered visible marks: implementation specs (deep-research 2026-07-18)
|
||||
|
||||
Triggered by a corpus finding: **50% of TC260-labelled uploads (1452 of 2896 unique) produced no detection at all**, and hand-inspection showed the bulk are MISSED Doubao marks (a localization defect, now fixed -- see `scale_basis` in `docs/module-internals.md`) plus a minority of genuinely uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads, not ground truth.
|
||||
|
||||
**GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label:
|
||||
- 应采用文字提示 (must be a TEXT prompt);
|
||||
- must contain BOTH an AI element (`人工智能` or `AI`) AND a generation element (`生成` and/or `合成`);
|
||||
- 应位于图片的边或角 -- **edge OR corner**, so bottom-right is NOT mandated (Annex C.2's bottom-right figure is a non-normative example);
|
||||
- 字型应清晰可辨 (legible typeface -- no family named);
|
||||
- **文字高度不应低于画面最短边长度的 5%** -- glyph HEIGHT >= 5% of the frame's SHORTEST side, with a note defining the shortest side for non-rectangular images.
|
||||
|
||||
Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner).
|
||||
|
||||
**Alibaba Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
|
||||
|
||||
**星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred.
|
||||
|
||||
**Baidu: RESOLVED 2026-07-22, registered (`baidu_engine.py`).** The mark is a white bold "百度" text run + a separate white rounded tag with dark "AI生成", bottom-right -- settled by the TC260 USCC cohort harvest (16 frames, USCC 91110000802100433B), not by web research. Detection keys on the text run only; details in `docs/module-internals.md`.
|
||||
|
||||
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict (bottom-left vs bottom-right) and one claims newer Meta models dropped the visible mark for invisible watermarking; none survived a fetch. Do NOT encode a corner without a corpus sample. Meta also embeds IPTC + invisible watermarks, which `identify` already reads. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
|
||||
|
||||
**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found.
|
||||
|
||||
**The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session.
|
||||
|
||||
**No vendor publishes typeface, colour, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin.
|
||||
@@ -3,8 +3,24 @@
|
||||
set -euo pipefail
|
||||
|
||||
uv sync --all-extras
|
||||
uv run uv-outdated
|
||||
uv run uv-secure --ignore-unfixed
|
||||
# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them
|
||||
# inside the project env crashes and, with set -e, aborts the whole gate before
|
||||
# ruff/pyright/tests (see CLAUDE.md "Test and lint").
|
||||
uvx uv-outdated
|
||||
# uv-secure prints its verdict but can then crash in an internal teardown with a
|
||||
# NON-ZERO exit -- observed as "annotated-doc raised exception" and later "anyio raised
|
||||
# exception"; both are bugs in uv-secure's OWN uvx env, not a project vulnerability. With
|
||||
# set -e that teardown crash aborts the whole gate before ruff/pyright/tests. So gate on
|
||||
# the VERDICT, not the exit code: capture the output, accept the run when uv-secure
|
||||
# reported all-safe (even if it then crashed), but still FAIL on a real finding (no
|
||||
# all-safe line) so a genuine CVE is never masked, and fail loud if it never got a
|
||||
# verdict at all (so a broken run is never silently skipped).
|
||||
secure_out="$(uvx uv-secure --ignore-unfixed 2>&1)" || true
|
||||
printf '%s\n' "$secure_out"
|
||||
if ! grep -qE "No vulnerabilities or maintenance issues detected|All dependencies appear safe" <<<"$secure_out"; then
|
||||
echo "maintain.sh: uv-secure reported a finding or failed before its verdict -- triage before committing." >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run ruff check --fix
|
||||
uv run ruff format
|
||||
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.10.2"
|
||||
version = "0.19.0"
|
||||
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"
|
||||
@@ -46,11 +46,24 @@ classifiers = [
|
||||
]
|
||||
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]
|
||||
@@ -105,6 +118,15 @@ 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).
|
||||
@@ -129,7 +151,7 @@ dev = [
|
||||
"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,dev]"]
|
||||
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
|
||||
@@ -178,6 +200,7 @@ ignore = [
|
||||
]
|
||||
|
||||
[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
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""AI-generation scorer for scanned datasets.
|
||||
|
||||
Trains a gradient-boosted classifier on the metadata-derived labels of a
|
||||
scan_dataset.py output and scores every file, WITHOUT needing the
|
||||
metadata to be present at scoring time: the features are pixel and
|
||||
container statistics, so a metadata-stripped file still gets a score.
|
||||
|
||||
Measured on the production corpus (temporal holdout, honest for drift):
|
||||
structural-feature GBM reaches AUC ~0.94-0.96 on-distribution. The model
|
||||
is distribution-specific by design (it is trained on this service's
|
||||
traffic); do not expect the same numbers on unrelated data. CLIP-ViT-L/14
|
||||
features were evaluated as an alternative and scored lower (0.82) while
|
||||
being ~30x slower, so the shipped model uses the structural features the
|
||||
scanner already collects.
|
||||
|
||||
Modes:
|
||||
uv run --with scikit-learn python scripts/ai_score.py train <scan_glob> <model.pkl>
|
||||
uv run --with scikit-learn python scripts/ai_score.py score <scan_glob> <model.pkl> <out.jsonl>
|
||||
|
||||
<scan_glob> is a glob of scan_dataset shards, e.g. 'data/scan/part_*.jsonl'.
|
||||
score output: one JSON line per file with file, sha256, ai_score, and the
|
||||
label evidence when a metadata label existed (for monitoring drift).
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import pickle
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
# AI-generator names in C2PA claim_generator_info (metadata-level truth).
|
||||
AI_GENERATORS = (
|
||||
"openai",
|
||||
"adobe firefly",
|
||||
"adobe_firefly",
|
||||
"microsoft responsible ai",
|
||||
"microsoft_designer",
|
||||
"black forest labs",
|
||||
"fal-ai",
|
||||
"bria",
|
||||
"chatgpt",
|
||||
"stability",
|
||||
"dreamina",
|
||||
"canva",
|
||||
)
|
||||
# Human-origin software (negative evidence, NOT proof by itself).
|
||||
HUMAN_SOFTWARE = (
|
||||
"photoshop",
|
||||
"lightroom",
|
||||
"picsart",
|
||||
"snapseed",
|
||||
"paint.net",
|
||||
"gimp",
|
||||
"capture one",
|
||||
"meitu",
|
||||
"xingtu",
|
||||
"snow",
|
||||
)
|
||||
|
||||
_FEAT_SCALAR = [
|
||||
("noise", "noise_std"),
|
||||
("noise", "noise_kurtosis"),
|
||||
("fft", "cfa_peak"),
|
||||
("ela", "ela_mean"),
|
||||
("ela", "ela_p95"),
|
||||
("gradient", "laplacian_var"),
|
||||
("color", "saturation_mean"),
|
||||
("color", "value_mean"),
|
||||
("dct", "benford_mad"),
|
||||
]
|
||||
|
||||
|
||||
def label_of(record: dict[str, Any]) -> int | None:
|
||||
"""1 = strong metadata AI, 0 = human-origin, None = unlabeled."""
|
||||
store = record.get("c2pa_store") or {}
|
||||
for m in (store.get("manifests") or {}).values():
|
||||
for cgi in m.get("claim_generator_info") or []:
|
||||
if any(g in str(cgi.get("name", "")).lower() for g in AI_GENERATORS):
|
||||
return 1
|
||||
for a in m.get("assertions") or []:
|
||||
d = a.get("data") if isinstance(a.get("data"), dict) else {}
|
||||
if "trainedAlgorithmicMedia" in str(d.get("digitalSourceType", "")):
|
||||
return 1
|
||||
for c in record.get("png_chunks", []):
|
||||
t = c.get("text", "")
|
||||
kw = t.split("\x00")[0]
|
||||
if kw in ("prompt", "workflow", "parameters") or "AIGC" in t[:80]:
|
||||
return 1
|
||||
exif = record.get("exif", {})
|
||||
z = exif.get("0th") or {}
|
||||
e = exif.get("Exif") or {}
|
||||
if z.get("Make") and z.get("Model") and (e.get("MakerNote") or e.get("LensModel") or e.get("LensMake")):
|
||||
return 0
|
||||
for c in record.get("png_chunks", []):
|
||||
if c.get("apple_screenshot_marker"):
|
||||
return 0
|
||||
for v in (record.get("iptc") or {}).values():
|
||||
if str(v).strip() == "Screenshot":
|
||||
return 0
|
||||
if any(t in str(z.get("Software", "")).lower() for t in HUMAN_SOFTWARE):
|
||||
return 0
|
||||
return None
|
||||
|
||||
|
||||
def features_of(record: dict[str, Any]) -> list[float]:
|
||||
"""The structural feature vector (pixel + container stats, no metadata)."""
|
||||
v = [float((record.get(s) or {}).get(k, np.nan)) for s, k in _FEAT_SCALAR]
|
||||
for b in (record.get("fft") or {}).get("fft_band_energy", []):
|
||||
v.append(float(b))
|
||||
for h in (record.get("gradient") or {}).get("gradient_hist", []):
|
||||
v.append(float(h))
|
||||
for h in (record.get("color") or {}).get("color_hist_4x4x4", []):
|
||||
v.append(float(h))
|
||||
jf = record.get("jpeg_forensics") or {}
|
||||
v.append(1.0 if jf.get("subsampling") == "4:4:4" else 0.0)
|
||||
v.append(1.0 if jf.get("progressive") else 0.0)
|
||||
pil = record.get("pil") or {}
|
||||
w, h = pil.get("width") or 0, pil.get("height") or 0
|
||||
v += [float(w * h) / 1e6, float(w) / max(h, 1)]
|
||||
fmt = record.get("content_format")
|
||||
v += [1.0 if fmt == "jpeg" else 0.0, 1.0 if fmt == "png" else 0.0]
|
||||
return v
|
||||
|
||||
|
||||
def iter_records(pattern: str) -> Any:
|
||||
for path in sorted(glob.glob(pattern)):
|
||||
with open(path) as fh:
|
||||
for line in fh:
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
def cmd_train(pattern: str, model_path: str) -> None:
|
||||
from sklearn.ensemble import HistGradientBoostingClassifier
|
||||
from sklearn.impute import SimpleImputer
|
||||
from sklearn.metrics import average_precision_score, roc_auc_score
|
||||
from sklearn.model_selection import train_test_split
|
||||
|
||||
X, y, dates = [], [], []
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
lab = label_of(r)
|
||||
if lab is None:
|
||||
continue
|
||||
X.append(features_of(r))
|
||||
y.append(lab)
|
||||
dates.append(Path(r["file"]).parent.name)
|
||||
X, y = np.array(X), np.array(y)
|
||||
print(f"labeled: {len(y)} (pos {int(y.sum())}, neg {int((1 - y).sum())}, features {X.shape[1]})")
|
||||
|
||||
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.3, random_state=0, stratify=y)
|
||||
imp = SimpleImputer(strategy="median").fit(Xtr)
|
||||
clf = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
clf.fit(imp.transform(Xtr), ytr)
|
||||
p = clf.predict_proba(imp.transform(Xte))[:, 1]
|
||||
print(f"holdout: AUC {roc_auc_score(yte, p):.4f} | AP {average_precision_score(yte, p):.4f}")
|
||||
|
||||
uniq = sorted(set(dates))
|
||||
cutoff = uniq[int(len(uniq) * 0.7)]
|
||||
tr = np.array([d < cutoff for d in dates])
|
||||
if tr.sum() > 100 and (~tr).sum() > 100:
|
||||
clf_t = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
imp_t = SimpleImputer(strategy="median").fit(X[tr])
|
||||
clf_t.fit(imp_t.transform(X[tr]), y[tr])
|
||||
pt = clf_t.predict_proba(imp_t.transform(X[~tr]))[:, 1]
|
||||
auc_t = roc_auc_score(y[~tr], pt)
|
||||
ap_t = average_precision_score(y[~tr], pt)
|
||||
print(f"temporal (<{cutoff}): AUC {auc_t:.4f} | AP {ap_t:.4f}")
|
||||
|
||||
imp = SimpleImputer(strategy="median").fit(X)
|
||||
clf = HistGradientBoostingClassifier(random_state=0, max_iter=300)
|
||||
clf.fit(imp.transform(X), y)
|
||||
with open(model_path, "wb") as f:
|
||||
pickle.dump({"imputer": imp, "clf": clf}, f)
|
||||
print(f"model written: {model_path}")
|
||||
|
||||
|
||||
def cmd_score(pattern: str, model_path: str, out_path: str) -> None:
|
||||
# the model file is produced locally by `train`; do not load third-party pickles
|
||||
with open(model_path, "rb") as f:
|
||||
bundle = pickle.load(f) # noqa: S301
|
||||
imp, clf = bundle["imputer"], bundle["clf"]
|
||||
n = 0
|
||||
with open(out_path, "w") as out:
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
score = float(clf.predict_proba(imp.transform([features_of(r)]))[0, 1])
|
||||
lab = label_of(r)
|
||||
out.write(
|
||||
json.dumps(
|
||||
{
|
||||
"file": r["file"],
|
||||
"sha256": r.get("sha256"),
|
||||
"ai_score": round(score, 4),
|
||||
"metadata_label": lab,
|
||||
}
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
n += 1
|
||||
if n % 5000 == 0:
|
||||
print(f" {n}", flush=True)
|
||||
print(f"scored {n} -> {out_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("train", "score"):
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
if sys.argv[1] == "train" and len(sys.argv) == 4:
|
||||
cmd_train(sys.argv[2], sys.argv[3])
|
||||
elif sys.argv[1] == "score" and len(sys.argv) == 5:
|
||||
cmd_score(sys.argv[2], sys.argv[3], sys.argv[4])
|
||||
else:
|
||||
print(__doc__)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on?
|
||||
|
||||
THE OPPORTUNITY
|
||||
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and
|
||||
`百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover,
|
||||
bold and plainly legible, with no detector able to fire on either. `docs/...landscape`
|
||||
puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in
|
||||
the covered ones (a dense scale ladder recovers 1 miss in 26; see ladder_headroom.py).
|
||||
|
||||
WHY A SHARED TEMPLATE IS EVEN PLAUSIBLE
|
||||
China's GB 45438-2025 mandates the label, and every compliant vendor ends it with the
|
||||
same run: `AI生成`. The vendor PREFIX varies (豆包 / 千问 / 百度 / 星绘) and is what makes
|
||||
per-vendor silhouettes expensive; the TAIL is guaranteed. So the tail is the part worth
|
||||
detecting, and vendor attribution -- which the shared tail cannot provide anyway
|
||||
(measured 千问-vs-doubao AUC ~0.5) -- moves to metadata, where it is reliable.
|
||||
|
||||
WHY IT IS WORTH RETRYING NOW
|
||||
`render_vendor_silhouettes.py` records 千问 as ruled out, and its reasoning was sound:
|
||||
the then-current front-end binarized the glyph, and a thin translucent overlay shatters
|
||||
into specks that no template can match. It closes by naming what would be needed --
|
||||
"a detection front-end that does not depend on binarizing the glyph (grayscale/edge
|
||||
correlation on the raw top-hat)". That front-end now EXISTS: `detect_frontend="tophat"`
|
||||
was built for doubao and correlates a soft template against the continuous response.
|
||||
The blocker was removed by unrelated work, so the ruling deserves re-measurement rather
|
||||
than inheritance.
|
||||
|
||||
THE MEASUREMENT
|
||||
Three arms, one scorer (the tail template on the tophat response, swept over a dense
|
||||
size ladder because an unregistered vendor's glyph size is genuinely unknown -- the one
|
||||
place a dense ladder earns its cost):
|
||||
|
||||
covered a registered mark already fires -- a sanity arm; the tail is inside those
|
||||
marks too, so a tail template that cannot score THESE is simply broken
|
||||
uncovered TC260 provenance but no detector fires -- the target population
|
||||
clean no metadata signal at all -- the precision arm
|
||||
|
||||
The decision rests on whether `uncovered` carries a high-scoring subpopulation that
|
||||
`clean` does not. It does NOT rest on the raw rate: TC260 provenance does not imply a
|
||||
visible mark, so a modest fire rate on that arm is expected even if the detector is
|
||||
perfect. Any threshold this suggests must then be confirmed by eyeballing the crops it
|
||||
selects -- the script writes a contact sheet for exactly that.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis, gitignored output. The
|
||||
template is font-rendered synthetic, never cut from a user upload.
|
||||
|
||||
uv run python scripts/cjk_tail_probe.py --n 6000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_cjk_tail_probe.jsonl"
|
||||
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
|
||||
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
|
||||
# what writes the committed silhouette into src/.../assets/.
|
||||
TAIL_PNG = REPO / "data" / "spaces" / "_cjk_tail_silhouette.png"
|
||||
|
||||
# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
|
||||
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
|
||||
# unknown here -- unlike a registered mark, whose fraction is calibrated. Hence a wide,
|
||||
# dense ladder: this is the case a dense ladder is actually for.
|
||||
TAIL_SCALES = tuple(round(0.30 * (1.08**i), 4) for i in range(18)) # ~0.30 .. ~1.15
|
||||
|
||||
|
||||
def build_tail_silhouette() -> np.ndarray:
|
||||
"""Font-rendered `AI生成` silhouette, cached next to this script (data-safe)."""
|
||||
if TAIL_PNG.exists():
|
||||
img = cv2.imread(str(TAIL_PNG), cv2.IMREAD_GRAYSCALE)
|
||||
if img is not None:
|
||||
return img
|
||||
from render_vendor_silhouettes import render
|
||||
|
||||
sil = render("AI生成", width=200)
|
||||
cv2.imwrite(str(TAIL_PNG), sil)
|
||||
return sil
|
||||
|
||||
|
||||
def tail_score(engine: Any, image: np.ndarray, sil: np.ndarray) -> tuple[float, float]:
|
||||
"""Best tail correlation over the size ladder; returns (score, winning_scale)."""
|
||||
loc = engine.locate(image)
|
||||
resp = engine.tophat_response(image, loc)
|
||||
if resp is None:
|
||||
return (0.0, 0.0)
|
||||
c = engine.config
|
||||
base = engine.scale_base(image)
|
||||
ar = sil.shape[0] / max(1, sil.shape[1])
|
||||
best, best_s = 0.0, 0.0
|
||||
for s in TAIL_SCALES:
|
||||
gw = max(12, int(c.alpha_width_frac * base * s))
|
||||
gh = max(6, int(gw * ar))
|
||||
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
|
||||
if v > best:
|
||||
best, best_s = v, s
|
||||
return (best, best_s)
|
||||
|
||||
|
||||
def _one(path_str: str) -> dict[str, Any] | None:
|
||||
from remove_ai_watermarks.api import visible_provenance
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
|
||||
path = Path(path_str)
|
||||
try:
|
||||
prov = visible_provenance(path)
|
||||
except Exception:
|
||||
return None
|
||||
tc260 = bool({"doubao", "jimeng"} & prov)
|
||||
|
||||
if not tc260:
|
||||
try:
|
||||
if identify(path, check_visible=False).signals:
|
||||
return None # some other provenance: neither target nor clean control
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 200:
|
||||
return None
|
||||
try:
|
||||
fired = [d.key for d in detect_marks(img) if d.detected]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if fired:
|
||||
arm = "covered"
|
||||
elif tc260:
|
||||
arm = "uncovered"
|
||||
else:
|
||||
arm = "clean"
|
||||
|
||||
score, scale = tail_score(DoubaoEngine(), img, build_tail_silhouette())
|
||||
return {
|
||||
"src": path.name,
|
||||
"arm": arm,
|
||||
"fired": fired,
|
||||
"tail_score": round(score, 4),
|
||||
"tail_scale": scale,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--n", type=int, default=6000)
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--report-only", action="store_true")
|
||||
ap.add_argument(
|
||||
"--sheet-at", type=float, default=0.0, help="write a contact sheet of `uncovered` crops scoring >= this"
|
||||
)
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.report_only:
|
||||
rows = [json.loads(x) for x in a.out.read_text(encoding="utf-8").splitlines() if x.strip()]
|
||||
report(rows)
|
||||
if a.sheet_at:
|
||||
contact_sheet(rows, a.sheet_at)
|
||||
return
|
||||
|
||||
build_tail_silhouette()
|
||||
pool = glob.glob(str(CORPUS / "*" / "*"))
|
||||
random.Random(19).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto
|
||||
pool = pool[: a.n]
|
||||
print(f"scanning {len(pool)} corpus files workers={a.workers}")
|
||||
print(f"tail ladder {TAIL_SCALES[0]} .. {TAIL_SCALES[-1]} ({len(TAIL_SCALES)} rungs)\n", flush=True)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
a.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(a.out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
||||
futures = [ex.submit(_one, p) for p in pool]
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
try:
|
||||
rec = fut.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the sweep
|
||||
continue
|
||||
if rec is None:
|
||||
continue
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
rows.append(rec)
|
||||
if i % 500 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(pool)} usable={len(rows)}", flush=True)
|
||||
report(rows)
|
||||
if a.sheet_at:
|
||||
contact_sheet(rows, a.sheet_at)
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]]) -> None:
|
||||
arms = {k: [r for r in rows if r["arm"] == k] for k in ("covered", "uncovered", "clean")}
|
||||
print(f"\n{'=' * 80}\nGENERIC CJK TAIL PROBE ({', '.join(f'{k}={len(v)}' for k, v in arms.items())})\n{'=' * 80}")
|
||||
print("\nScore distribution of the shared `AI生成` tail on the tophat response\n")
|
||||
print(f"{'arm':11s} {'n':>6s} {'p50':>7s} {'p75':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
|
||||
for k, v in arms.items():
|
||||
if not v:
|
||||
continue
|
||||
s = np.array([r["tail_score"] for r in v])
|
||||
qs = [np.percentile(s, q) for q in (50, 75, 90, 95, 99)]
|
||||
print(f"{k:11s} {len(v):6d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {s.max():7.3f}")
|
||||
|
||||
if arms["clean"] and arms["uncovered"]:
|
||||
clean = np.array([r["tail_score"] for r in arms["clean"]])
|
||||
unc = np.array([r["tail_score"] for r in arms["uncovered"]])
|
||||
print("\n\nOPERATING POINTS -- threshold set on the CLEAN arm, yield read on `uncovered`")
|
||||
print("`clean` fires are the cost; `uncovered` fires are candidate recoveries that")
|
||||
print("still need eyeballing, since TC260 provenance does not imply a visible mark.\n")
|
||||
print(f"{'threshold':>10s} {'clean fire':>12s} {'uncovered fire':>16s} {'candidates':>12s}")
|
||||
for q in (95, 97.5, 99, 99.5, 99.9):
|
||||
t = float(np.percentile(clean, q))
|
||||
cf = float((clean >= t).mean())
|
||||
uf = float((unc >= t).mean())
|
||||
print(f"{t:10.3f} {100 * cf:11.2f}% {100 * uf:15.1f}% {int((unc >= t).sum()):12d}")
|
||||
|
||||
cov = arms["covered"]
|
||||
if cov:
|
||||
s = np.array([r["tail_score"] for r in cov])
|
||||
print(f"\n\nSANITY: on frames where a registered mark fires, the tail scores median {np.median(s):.3f}.")
|
||||
print("A tail template that cannot score these is broken, whatever it does elsewhere.")
|
||||
|
||||
|
||||
def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) -> None:
|
||||
"""Crop the corner of the top-scoring `uncovered` frames so the fires can be judged."""
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
eng = DoubaoEngine()
|
||||
picks = sorted(
|
||||
(r for r in rows if r["arm"] == "uncovered" and r["tail_score"] >= thresh),
|
||||
key=lambda r: -r["tail_score"],
|
||||
)[:limit]
|
||||
tiles = []
|
||||
for r in picks:
|
||||
hits = glob.glob(str(CORPUS / "*" / r["src"]))
|
||||
if not hits:
|
||||
continue
|
||||
img = imread(hits[0])
|
||||
if img is None:
|
||||
continue
|
||||
loc = eng.locate(img)
|
||||
crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
|
||||
if crop.size:
|
||||
tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA))
|
||||
if tiles:
|
||||
dest = REPO / "data" / "spaces" / "_cjk_tail_sheet.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
|
||||
print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Tier B2: detector response curves -- recall vs mark size, contrast, background and aspect.
|
||||
|
||||
WHY THIS EXISTS
|
||||
Every recall number the project quotes rests on found positives: doubao 89% on n=240,
|
||||
jimeng 71% on n=14, the pill 50% on n=6. Those tell you how the detectors do on the
|
||||
marks a corpus happened to contain, and nothing about WHERE they fall over. Two bugs
|
||||
have now shipped in exactly that blind spot:
|
||||
|
||||
* `scale_basis` -- doubao detected 0 of 435 LANDSCAPE marks, a 100% miss, because
|
||||
every fraction was calibrated on portrait captures where width == short side.
|
||||
* the front-end mismatch -- detection moved to the continuous `tophat` response while
|
||||
the removal mask still came from the BINARIZED blob, so ~8% of doubao detections
|
||||
produced an empty mask and removal was a silent no-op.
|
||||
|
||||
Both are geometry/plumbing failures at the edge of the operating range, and both were
|
||||
invisible to a corpus sweep because the corpus is concentrated in the middle of it.
|
||||
This harness constructs the edges instead of waiting for them to turn up.
|
||||
|
||||
THE AGGREGATE HERE IS NOT RECALL
|
||||
The grid deliberately visits sizes and opacities the engines were never calibrated for,
|
||||
so a mark counted as missed at size=0.6 is not evidence of a weak detector -- it is the
|
||||
point of the sweep. Quote the NOMINAL cell (size=1.0, alpha=1.0) as the reference, and
|
||||
read the per-axis tables for shape. Production recall comes from an unbiased corpus
|
||||
sample (`scripts/visible_recall_sample.py`), never from here.
|
||||
|
||||
WHAT IT MEASURES -- TWO NUMBERS, NOT ONE
|
||||
`detected` the detector fires.
|
||||
`maskable` the same call path then yields a NON-EMPTY removal mask.
|
||||
|
||||
The second is the one that has no natural home anywhere else. A mark that is detected
|
||||
but not maskable is reported by `identify` and silently skipped by `visible` -- the user
|
||||
sees a mark the tool says it removed. Anything that measures only detection scores that
|
||||
bug as a success. They are reported side by side and their GAP is the headline.
|
||||
|
||||
THE CONSTRUCTION
|
||||
Take a verified-clean corpus image, stamp a real mark onto it with the forward model the
|
||||
reverse-alpha work established (`stamped = (1-a)*bg + a*white`), and sweep two knobs the
|
||||
engine's own geometry normally pins:
|
||||
|
||||
size multiplies the glyph box the engine would use (a re-rendered or rescaled mark)
|
||||
alpha multiplies the captured opacity (bold stamp -> faint translucent overlay)
|
||||
|
||||
Background texture and frame aspect are not swept -- they come from the corpus and are
|
||||
recorded, so the grid is crossed with real backgrounds rather than synthetic flats.
|
||||
|
||||
WHY EVERY SOURCE ALSO RUNS UNSTAMPED
|
||||
A recall curve with no false-fire baseline is uninterpretable in the same way a fill
|
||||
PSNR with no damage baseline is: "detected on 70% of faint marks" is a triumph or a
|
||||
scandal depending on whether the detector also fires on 70% of the clean frames those
|
||||
marks were stamped onto. The control runs the identical call path on the identical
|
||||
image with no stamp.
|
||||
|
||||
READING `mask_hit`
|
||||
Fraction of the stamped glyph box the removal mask covers -- real localization accuracy
|
||||
for the text marks and gemini, because the mask has to be placed from a detection.
|
||||
For `jimeng_pill` it is CONSTRUCTED and means nothing: the pill's footprint is a fixed
|
||||
top-left geometry box, so it covers the stamp by definition. Reported, and flagged.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis, gitignored output under
|
||||
data/spaces/. Records source filenames and measurements, never image content.
|
||||
|
||||
uv run python scripts/detector_response.py --n 12 # trial, measures throughput
|
||||
uv run python scripts/detector_response.py --n 150 # the real run, resumable
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
OUT = REPO / "data" / "spaces" / "_detector_response.jsonl"
|
||||
|
||||
# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
|
||||
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
|
||||
# front-end was introduced for) and above it (a larger re-render, or an upscaled upload).
|
||||
SIZES = (0.6, 0.8, 1.0, 1.3)
|
||||
ALPHAS = (0.3, 0.5, 0.75, 1.0)
|
||||
MARKS = (*STAMPABLE, *SLOT_STAMPABLE)
|
||||
|
||||
|
||||
def aspect_of(image: np.ndarray) -> str:
|
||||
h, w = image.shape[:2]
|
||||
r = w / max(1, h)
|
||||
return "portrait" if r < 0.9 else ("landscape" if r > 1.1 else "square")
|
||||
|
||||
|
||||
def _probe(image: np.ndarray, mark_key: str, box: tuple[int, int, int, int] | None) -> dict[str, Any]:
|
||||
"""Run the product detect -> localize path once and score it.
|
||||
|
||||
``box`` is the stamped glyph box, or None for the unstamped control.
|
||||
"""
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
|
||||
mark = get_mark(mark_key)
|
||||
loc = mark.localize(image, force=False)
|
||||
mask_px = 0 if loc.mask is None else int(np.count_nonzero(loc.mask))
|
||||
hit = None
|
||||
if box is not None and loc.mask is not None:
|
||||
x, y, w, h = box
|
||||
inside = loc.mask[y : y + h, x : x + w]
|
||||
hit = round(float(np.count_nonzero(inside)) / max(1, w * h), 3)
|
||||
return {
|
||||
"detected": bool(loc.detected),
|
||||
"confidence": round(float(loc.confidence), 4),
|
||||
"maskable": mask_px > 0,
|
||||
"mask_px": mask_px,
|
||||
"mask_hit": hit,
|
||||
}
|
||||
|
||||
|
||||
def _one_source(path_str: str) -> list[dict[str, Any]]:
|
||||
"""Every (mark, size, alpha) cell for one clean background, plus its controls."""
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
base = imread(path_str)
|
||||
if base is None:
|
||||
return []
|
||||
name = Path(path_str).name
|
||||
asp = aspect_of(base)
|
||||
h, w = base.shape[:2]
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for key in MARKS:
|
||||
common = {"src": name, "mark": key, "aspect": asp, "px": w * h}
|
||||
# Control first: the same call path on the same frame with nothing stamped.
|
||||
rows.append({**common, "cell": "control", "size": None, "alpha": None, **_probe(base, key, None)})
|
||||
for size in SIZES:
|
||||
for alpha in ALPHAS:
|
||||
st = stamp_any(base, key, size_mult=size, alpha_mult=alpha)
|
||||
if st is None:
|
||||
continue
|
||||
stamped, box = st
|
||||
# Texture of the region the mark sits on, from the CLEAN frame -- the
|
||||
# same median-Sobel proxy the pill's flatness gate uses.
|
||||
x, y, gw, gh = box
|
||||
rows.append(
|
||||
{
|
||||
**common,
|
||||
"cell": "stamped",
|
||||
"size": size,
|
||||
"alpha": alpha,
|
||||
"texture": round(texture_of(base[y : y + gh, x : x + gw]), 2),
|
||||
**_probe(stamped, key, box),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
||||
if n == 0:
|
||||
return (0.0, 0.0)
|
||||
p, d = k / n, 1 + z * z / n
|
||||
c = (p + z * z / (2 * n)) / d
|
||||
hw = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
|
||||
return (100 * (c - hw), 100 * (c + hw))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--n", type=int, default=150, help="verified-clean source images")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true")
|
||||
ap.add_argument("--report-only", action="store_true", help="re-read --out and print the report")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.report_only:
|
||||
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
report(rows)
|
||||
return
|
||||
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink()
|
||||
done: set[str] = set()
|
||||
if a.out.exists():
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["src"])
|
||||
except Exception: # noqa: S112 -- tolerate a torn last line
|
||||
continue
|
||||
|
||||
print(f"selecting {a.n} verified-clean sources (already measured: {len(done)})...", flush=True)
|
||||
sources = [p for p in clean_sources(a.n) if p.name not in done]
|
||||
print(f"to do {len(sources)} workers {a.workers} grid {len(MARKS)}x{len(SIZES)}x{len(ALPHAS)}\n", flush=True)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
a.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
||||
futures = {ex.submit(_one_source, str(p)): p for p in sources}
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
try:
|
||||
got = fut.result()
|
||||
except Exception as e: # a crashed source must not kill the sweep
|
||||
print(f" source failed: {type(e).__name__}: {e}", flush=True)
|
||||
continue
|
||||
for r in got:
|
||||
fh.write(json.dumps(r) + "\n")
|
||||
rows += got
|
||||
if i % 10 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(sources)}", flush=True)
|
||||
|
||||
if done: # merge the resumed rows so the report covers the whole file
|
||||
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
report(rows)
|
||||
|
||||
|
||||
def _rate(rs: list[dict[str, Any]], field: str) -> tuple[float, float, float, int]:
|
||||
k, n = sum(bool(r[field]) for r in rs), len(rs)
|
||||
lo, hi = wilson(k, n)
|
||||
return (100 * k / n if n else 0.0, lo, hi, n)
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]]) -> None:
|
||||
stamped = [r for r in rows if r["cell"] == "stamped"]
|
||||
control = [r for r in rows if r["cell"] == "control"]
|
||||
if not stamped:
|
||||
print("no measurements")
|
||||
return
|
||||
srcs = len({r["src"] for r in rows})
|
||||
print(f"\n{'=' * 84}\nDETECTOR RESPONSE {len(stamped)} stamped cells over {srcs} clean backgrounds\n{'=' * 84}")
|
||||
|
||||
# Lead with the nominal cell. The grid deliberately includes sizes and opacities the
|
||||
# engines were never calibrated for, so an aggregate over the whole grid is an
|
||||
# adversarial score, NOT production recall -- quoting it as recall would understate
|
||||
# the detectors badly. The nominal cell is the honest reference point: the mark at the
|
||||
# exact geometry and opacity the engine assumes, on a real clean background.
|
||||
print("\nNOMINAL CELL (size=1.0, alpha=1.0) -- a mark exactly as the engine expects it.")
|
||||
print("Read every other number against THIS, not as production recall: the grid is")
|
||||
print("adversarial by design and its aggregate is not a recall figure.\n")
|
||||
print(f"{'mark':13s} {'n':>5s} {'detected':>10s} {'maskable':>10s}")
|
||||
for key in MARKS:
|
||||
rs = [r for r in stamped if r["mark"] == key and r["size"] == 1.0 and r["alpha"] == 1.0]
|
||||
if rs:
|
||||
print(f"{key:13s} {len(rs):5d} {_rate(rs, 'detected')[0]:9.1f}% {_rate(rs, 'maskable')[0]:9.1f}%")
|
||||
|
||||
print("\n\nFALSE FIRE on the unstamped controls (the baseline every recall below is read against)\n")
|
||||
print(f"{'mark':13s} {'n':>5s} {'fires':>8s} {'95% CI':>16s}")
|
||||
for key in MARKS:
|
||||
rs = [r for r in control if r["mark"] == key]
|
||||
if rs:
|
||||
pct, lo, hi, n = _rate(rs, "detected")
|
||||
print(f"{key:13s} {n:5d} {pct:7.1f}% {f'{lo:.1f}-{hi:.1f}':>16s}")
|
||||
|
||||
print("\n\nDETECTED vs MASKABLE by mark. The GAP is the silent-no-op rate:")
|
||||
print("a mark counted here as detected-but-not-maskable is reported by `identify` and")
|
||||
print("skipped by `visible`, so the user is told it was removed when it was not.\n")
|
||||
print(f"{'mark':13s} {'n':>6s} {'detected':>10s} {'maskable':>10s} {'gap':>7s} {'mask_hit':>9s}")
|
||||
for key in MARKS:
|
||||
rs = [r for r in stamped if r["mark"] == key]
|
||||
if not rs:
|
||||
continue
|
||||
det = [r for r in rs if r["detected"]]
|
||||
d, m = _rate(rs, "detected")[0], _rate(rs, "maskable")[0]
|
||||
gap = (100 * sum(1 for r in det if not r["maskable"]) / len(det)) if det else 0.0
|
||||
hits = [r["mask_hit"] for r in det if r["mask_hit"] is not None]
|
||||
note = " (constructed)" if key == "jimeng_pill" else ""
|
||||
hv = f"{float(np.median(hits)):.2f}" if hits else "-"
|
||||
print(f"{key:13s} {len(rs):6d} {d:9.1f}% {m:9.1f}% {gap:6.1f}% {hv:>9s}{note}")
|
||||
|
||||
for axis, label in (("alpha", "CONTRAST (alpha multiplier)"), ("size", "SIZE (glyph box multiplier)")):
|
||||
print(f"\n\nRECALL BY {label} -- 1.0 is the geometry/opacity the engine assumes\n")
|
||||
vals = sorted({r[axis] for r in stamped})
|
||||
print(f"{'mark':13s}" + "".join(f"{v:>12}" for v in vals))
|
||||
for key in MARKS:
|
||||
cells = []
|
||||
for v in vals:
|
||||
rs = [r for r in stamped if r["mark"] == key and r[axis] == v]
|
||||
cells.append(f"{_rate(rs, 'detected')[0]:10.0f}%" if rs else f"{'-':>11s}")
|
||||
print(f"{key:13s}" + "".join(f"{c:>12}" for c in cells))
|
||||
|
||||
print("\n\nRECALL BY FRAME ASPECT -- the axis the `scale_basis` bug lived on\n")
|
||||
asps = ["portrait", "square", "landscape"]
|
||||
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in asps))
|
||||
for key in MARKS:
|
||||
cells = []
|
||||
for asp in asps:
|
||||
rs = [r for r in stamped if r["mark"] == key and r["aspect"] == asp]
|
||||
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
|
||||
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
|
||||
|
||||
tex = sorted(r["texture"] for r in stamped if "texture" in r)
|
||||
if tex:
|
||||
t1, t2 = tex[len(tex) // 3], tex[2 * len(tex) // 3]
|
||||
print(f"\n\nRECALL BY BACKGROUND TEXTURE (terciles at {t1:.1f} / {t2:.1f} median-Sobel)\n")
|
||||
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in ("flat", "mid", "textured")))
|
||||
for key in MARKS:
|
||||
cells = []
|
||||
for lo, hi in ((-1, t1), (t1, t2), (t2, 1e9)):
|
||||
rs = [r for r in stamped if r["mark"] == key and lo < r.get("texture", -2) <= hi]
|
||||
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
|
||||
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
|
||||
|
||||
worst = collections.Counter((r["mark"], r["size"], r["alpha"]) for r in stamped if not r["detected"]).most_common(8)
|
||||
if worst:
|
||||
print("\n\nWORST CELLS (most misses)\n")
|
||||
for (mark, size, alpha), n in worst:
|
||||
print(f" {mark:13s} size={size} alpha={alpha} {n} misses")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Tier B1: measure visible-mark FILL quality against a constructed ground truth.
|
||||
|
||||
THE PROBLEM THIS SOLVES
|
||||
A real watermarked image has no clean counterpart, so fill quality has only ever been
|
||||
eyeballed ("cv2 smears on texture, LaMa is best") and never carried a number. The docs
|
||||
state a preference order for `--backend auto` that rests on no measurement.
|
||||
|
||||
THE CONSTRUCTION
|
||||
Take a VERIFIED-CLEAN corpus image, stamp a known mark onto it using the mark's own
|
||||
captured alpha map (the forward model the reverse-alpha work established:
|
||||
`stamped = (1-a)*bg + a*white`), then remove it and compare against the TRUE original.
|
||||
The original is the answer key that reality never provides.
|
||||
|
||||
TWO MEASUREMENT DECISIONS THAT MAKE OR BREAK THIS
|
||||
* Score INSIDE the footprint only. The fill touches a tiny corner, so whole-frame PSNR
|
||||
sits near 60 dB whatever the backend does and would rank them all "excellent".
|
||||
* Report the DAMAGE baseline (stamped vs original) next to the recovery (filled vs
|
||||
original). Without it a PSNR is uninterpretable: 30 dB is a triumph if the mark cost
|
||||
12 dB and a failure if it cost 29 dB. The honest metric is how much of the gap the
|
||||
fill closes.
|
||||
|
||||
WHAT IT DOES NOT MEASURE
|
||||
Detection. The mark is localized with `force=True`, so a miss cannot contaminate the
|
||||
fill numbers -- this isolates the FILL. Detection accuracy is Tier C's job.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis, output under a gitignored
|
||||
data/spaces/ path. No image content is written into the report.
|
||||
|
||||
uv run python scripts/fill_quality.py --n 60
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from invisible_quality_audit import _ssim # reuse, do not reimplement a third SSIM
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_fill_quality.jsonl"
|
||||
|
||||
# Text marks: a bundled alpha PNG plus the engine's own corner geometry.
|
||||
STAMPABLE = ("doubao", "jimeng", "samsung")
|
||||
# Marks with no bundled alpha asset. Both expose a default footprint via
|
||||
# `footprint_mask(force=True)`, so they are stamped by fitting their own alpha source
|
||||
# into that slot: Gemini's alpha is derived from its background captures, the pill's
|
||||
# from its synthetic font-rendered silhouette.
|
||||
SLOT_STAMPABLE = ("gemini", "jimeng_pill")
|
||||
|
||||
|
||||
def psnr(a: np.ndarray, b: np.ndarray) -> float:
|
||||
mse = float(np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2))
|
||||
return float("inf") if mse == 0 else 10 * float(np.log10(255.0**2 / mse))
|
||||
|
||||
|
||||
def texture_of(box: np.ndarray) -> float:
|
||||
"""Median Sobel magnitude -- the same texture proxy the pill's flatness gate uses."""
|
||||
g = cv2.cvtColor(box, cv2.COLOR_BGR2GRAY) if box.ndim == 3 else box
|
||||
gx = cv2.Sobel(g, cv2.CV_32F, 1, 0, ksize=3)
|
||||
gy = cv2.Sobel(g, cv2.CV_32F, 0, 1, ksize=3)
|
||||
return float(np.median(cv2.magnitude(gx, gy)))
|
||||
|
||||
|
||||
def engine_for(mark_key: str) -> Any:
|
||||
"""The TextMarkEngine instance for a mark key (the registry does not expose one)."""
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module(f"remove_ai_watermarks.{mark_key}_engine")
|
||||
cls = next(
|
||||
obj
|
||||
for name, obj in vars(mod).items()
|
||||
# Exclude the imported base class: it takes a `config` arg, the subclasses do not.
|
||||
if isinstance(obj, type) and name.endswith("Engine") and name != "TextMarkEngine"
|
||||
)
|
||||
return cls()
|
||||
|
||||
|
||||
def composite(image: np.ndarray, alpha: np.ndarray, x: int, y: int) -> np.ndarray:
|
||||
"""The single forward model every stamp uses: ``stamped = (1-a)*bg + a*white``.
|
||||
|
||||
``alpha`` is float and clipped to [0,1] here, so a caller may pre-multiply it by an
|
||||
opacity factor without worrying about overflow. Shared by ``stamp``/``stamp_slot``
|
||||
here and by ``scripts/detector_response.py`` (which imports it) so the model is
|
||||
written ONCE.
|
||||
"""
|
||||
gh, gw = alpha.shape[:2]
|
||||
out = image.copy()
|
||||
roi = out[y : y + gh, x : x + gw].astype(np.float32)
|
||||
a3 = np.clip(alpha, 0.0, 1.0)[..., None]
|
||||
out[y : y + gh, x : x + gw] = np.clip(roi * (1 - a3) + 255.0 * a3, 0, 255).astype(np.uint8)
|
||||
return out
|
||||
|
||||
|
||||
def stamp(
|
||||
image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0
|
||||
) -> tuple[np.ndarray, tuple[int, int, int, int]] | None:
|
||||
"""Composite a mark's alpha glyph into its canonical corner. Returns (stamped, bbox).
|
||||
|
||||
``size_mult`` scales the glyph box off the engine's nominal geometry and ``alpha_mult``
|
||||
its opacity -- both default to 1.0 (the geometry the engine assumes), and
|
||||
`scripts/detector_response.py` sweeps them to build response curves.
|
||||
"""
|
||||
from remove_ai_watermarks._text_mark_engine import load_alpha_template
|
||||
|
||||
engine = engine_for(mark_key)
|
||||
cfg = engine.config
|
||||
alpha = load_alpha_template(cfg.asset_name)
|
||||
if alpha is None:
|
||||
return None
|
||||
|
||||
loc = engine.locate(image)
|
||||
base = engine.scale_base(image)
|
||||
gw = max(cfg.min_gw, int(cfg.alpha_width_frac * base * size_mult))
|
||||
gh = max(4, int(cfg.alpha_height_frac * base * size_mult))
|
||||
if gw < 8 or gh < 4 or gw > loc.w or gh > loc.h:
|
||||
return None
|
||||
|
||||
a = cv2.resize(alpha, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha_mult
|
||||
x = loc.x + (loc.w - gw) // 2
|
||||
y = loc.y + (loc.h - gh) // 2
|
||||
return composite(image, a, x, y), (x, y, gw, gh)
|
||||
|
||||
|
||||
def _slot_alpha(mark_key: str) -> np.ndarray | None:
|
||||
"""The alpha source for a mark that ships no bundled alpha PNG."""
|
||||
if mark_key == "gemini":
|
||||
from remove_ai_watermarks.gemini_engine import _shared_engine
|
||||
|
||||
# _shared_engine is the package's own lru_cache singleton: constructing
|
||||
# GeminiEngine() per image reloads its captures, recomputes both alpha maps and
|
||||
# rebuilds the whole template ladder -- the exact cost that singleton exists to
|
||||
# avoid. Private attribute access is deliberate (no accessor exists); a
|
||||
# measurement script may reach in, product code must not.
|
||||
return np.asarray(_shared_engine()._alpha_large, dtype=np.float32)
|
||||
if mark_key == "jimeng_pill":
|
||||
from remove_ai_watermarks._text_mark_engine import load_alpha_template
|
||||
|
||||
return load_alpha_template("jimeng_pill.png")
|
||||
return None
|
||||
|
||||
|
||||
def stamp_slot(
|
||||
image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0
|
||||
) -> tuple[np.ndarray, tuple[int, int, int, int]] | None:
|
||||
"""Stamp a mark into its OWN default footprint slot (the `--no-detect` geometry).
|
||||
|
||||
Used for gemini and the pill, which have no bundled alpha asset with corner
|
||||
fractions. The mark is fitted into the middle of its slot so the fill has to
|
||||
recover the same kind of region it would in production. ``size_mult``/``alpha_mult``
|
||||
match ``stamp`` (both default to 1.0).
|
||||
"""
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
|
||||
alpha = _slot_alpha(mark_key)
|
||||
if alpha is None:
|
||||
return None
|
||||
loc = get_mark(mark_key).localize(image, force=True)
|
||||
if loc.mask is None:
|
||||
return None
|
||||
ys, xs = np.nonzero(loc.mask)
|
||||
if ys.size == 0:
|
||||
return None
|
||||
y0, y1, x0, x1 = int(ys.min()), int(ys.max()), int(xs.min()), int(xs.max())
|
||||
bw, bh = x1 - x0 + 1, y1 - y0 + 1
|
||||
gw, gh = max(8, int(bw * 0.7 * size_mult)), max(8, int(bh * 0.7 * size_mult))
|
||||
a = cv2.resize(alpha, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32) * alpha_mult
|
||||
x, y = x0 + (bw - gw) // 2, y0 + (bh - gh) // 2
|
||||
# A large size_mult can grow the glyph past its slot and push x/y negative; guard so
|
||||
# composite never writes out of bounds (numpy would wrap a negative index silently).
|
||||
if x < 0 or y < 0 or x + gw > image.shape[1] or y + gh > image.shape[0]:
|
||||
return None
|
||||
return composite(image, a, x, y), (x, y, gw, gh)
|
||||
|
||||
|
||||
def stamp_any(
|
||||
image: np.ndarray, mark_key: str, *, size_mult: float = 1.0, alpha_mult: float = 1.0
|
||||
) -> tuple[np.ndarray, tuple[int, int, int, int]] | None:
|
||||
fn = stamp if mark_key in STAMPABLE else stamp_slot
|
||||
return fn(image, mark_key, size_mult=size_mult, alpha_mult=alpha_mult)
|
||||
|
||||
|
||||
def clean_sources(n: int, seed: int = 11) -> list[Path]:
|
||||
"""Corpus images with NO metadata signal and NO mark detection -- verified clean."""
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
|
||||
pool = glob.glob(str(CORPUS / "*" / "*"))
|
||||
random.Random(seed).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto
|
||||
out: list[Path] = []
|
||||
for p in pool:
|
||||
if len(out) >= n:
|
||||
break
|
||||
path = Path(p)
|
||||
try:
|
||||
img = imread(str(path))
|
||||
if img is None or min(img.shape[:2]) < 400:
|
||||
continue
|
||||
# check_visible=False: identify would otherwise decode the file a second
|
||||
# time and run the very same visible detectors as detect_marks below.
|
||||
if identify(path, check_visible=False).signals:
|
||||
continue
|
||||
if any(d.detected for d in detect_marks(img)):
|
||||
continue
|
||||
except Exception: # noqa: S112 -- a bad corpus file just is not a candidate
|
||||
continue
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--n", type=int, default=60, help="clean source images")
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
a = ap.parse_args()
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.region_eraser import lama_available, migan_available
|
||||
from remove_ai_watermarks.watermark_registry import fill, get_mark
|
||||
|
||||
backends = ["cv2"] + (["migan"] if migan_available() else []) + (["lama"] if lama_available() else [])
|
||||
print(f"backends under test: {backends}")
|
||||
print(f"selecting {a.n} verified-clean sources...", flush=True)
|
||||
sources = clean_sources(a.n)
|
||||
print(f"got {len(sources)}\n", flush=True)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
with open(a.out, "w", encoding="utf-8") as fh:
|
||||
for i, src in enumerate(sources, 1):
|
||||
base = imread(str(src))
|
||||
if base is None:
|
||||
continue
|
||||
for key in (*STAMPABLE, *SLOT_STAMPABLE):
|
||||
st = stamp_any(base, key)
|
||||
if st is None:
|
||||
continue
|
||||
stamped, (x, y, w, h) = st
|
||||
# Score a slightly padded box: the fill dilates, so a tight glyph box
|
||||
# would miss damage the backend does just outside the glyph.
|
||||
pad = 8
|
||||
y0, y1 = max(0, y - pad), min(base.shape[0], y + h + pad)
|
||||
x0, x1 = max(0, x - pad), min(base.shape[1], x + w + pad)
|
||||
truth = base[y0:y1, x0:x1]
|
||||
dmg_psnr = psnr(stamped[y0:y1, x0:x1], truth)
|
||||
dmg_ssim = _ssim(
|
||||
cv2.cvtColor(stamped[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY), cv2.cvtColor(truth, cv2.COLOR_BGR2GRAY)
|
||||
)
|
||||
tex = texture_of(truth)
|
||||
|
||||
mark = get_mark(key)
|
||||
loc = mark.localize(stamped, force=True)
|
||||
if loc.mask is None:
|
||||
continue
|
||||
for backend in backends:
|
||||
try:
|
||||
filled = fill(stamped, loc.mask, backend=backend)
|
||||
except Exception as e:
|
||||
rows.append({"src": src.name, "mark": key, "backend": backend, "error": str(e)[:150]})
|
||||
continue
|
||||
# Invariant: the fill must not touch anything outside its mask.
|
||||
outside = loc.mask == 0
|
||||
untouched = bool(np.array_equal(filled[outside], stamped[outside]))
|
||||
rec = {
|
||||
"src": src.name,
|
||||
"mark": key,
|
||||
"backend": backend,
|
||||
"texture": round(tex, 2),
|
||||
"damage_psnr": round(dmg_psnr, 2),
|
||||
"damage_ssim": round(dmg_ssim, 4),
|
||||
"filled_psnr": round(psnr(filled[y0:y1, x0:x1], truth), 2),
|
||||
"filled_ssim": round(
|
||||
_ssim(
|
||||
cv2.cvtColor(filled[y0:y1, x0:x1], cv2.COLOR_BGR2GRAY),
|
||||
cv2.cvtColor(truth, cv2.COLOR_BGR2GRAY),
|
||||
),
|
||||
4,
|
||||
),
|
||||
"outside_mask_untouched": untouched,
|
||||
}
|
||||
rows.append(rec)
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
if i % 10 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(sources)}", flush=True)
|
||||
|
||||
report(rows, a.out)
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]], out: Path) -> None:
|
||||
good = [r for r in rows if "error" not in r]
|
||||
if not good:
|
||||
print("no measurements")
|
||||
return
|
||||
tex = sorted(r["texture"] for r in good)
|
||||
t1, t2 = tex[len(tex) // 3], tex[2 * len(tex) // 3]
|
||||
|
||||
def bucket(t: float) -> str:
|
||||
return "flat" if t <= t1 else ("mid" if t <= t2 else "textured")
|
||||
|
||||
print(f"\n{'=' * 78}\nFILL QUALITY n={len(good)} measurements (texture terciles at {t1:.1f} / {t2:.1f})")
|
||||
print(f"{'=' * 78}")
|
||||
print("recovered = filled PSNR minus damaged PSNR; how much of the mark's damage the fill undoes\n")
|
||||
hdr = f"{'mark':9s} {'backend':8s} {'bg':9s} {'n':>4s} {'damaged':>9s} {'filled':>9s}"
|
||||
print(f"{hdr} {'recovered':>10s} {'ssim':>7s}")
|
||||
agg: dict[tuple[str, str, str], list[dict[str, Any]]] = collections.defaultdict(list)
|
||||
for r in good:
|
||||
agg[(r["mark"], r["backend"], bucket(r["texture"]))].append(r)
|
||||
for (mark, backend, bg), rs in sorted(agg.items()):
|
||||
# MEDIAN, not mean: a fill that reproduces a flat background EXACTLY scores
|
||||
# PSNR=inf, and a single inf makes the mean inf -- which reported "+inf" for
|
||||
# every flat bucket on the first run and hid the real numbers.
|
||||
finite = [r for r in rs if np.isfinite(r["filled_psnr"])]
|
||||
perfect = len(rs) - len(finite)
|
||||
d = float(np.median([r["damage_psnr"] for r in rs]))
|
||||
f = float(np.median([r["filled_psnr"] for r in finite])) if finite else float("inf")
|
||||
sv = float(np.median([r["filled_ssim"] for r in rs]))
|
||||
# Recovery is the MEDIAN OF THE PER-IMAGE DIFFERENCES, not the difference of the
|
||||
# two medians. Those are not the same statistic on skewed data and they can even
|
||||
# disagree in SIGN: the first version of this report subtracted medians and
|
||||
# produced a backend ranking that a paired comparison did not support.
|
||||
rec = float(np.median([r["filled_psnr"] - r["damage_psnr"] for r in finite])) if finite else float("inf")
|
||||
tail = f" ({perfect} exact)" if perfect else ""
|
||||
print(f"{mark:9s} {backend:8s} {bg:9s} {len(rs):4d} {d:9.2f} {f:9.2f} {rec:+10.2f} {sv:7.4f}{tail}")
|
||||
|
||||
bad = [r for r in good if not r["outside_mask_untouched"]]
|
||||
print(f"\noutside-mask invariant violations: {len(bad)}" + (" <-- BUG" if bad else " (none)"))
|
||||
print(f"records: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""How much recall is the coarse scale ladder costing, and what would a denser one cost?
|
||||
|
||||
THE FINDING THIS MEASURES
|
||||
`_tophat_score` sweeps three rungs -- (0.8, 1.0, 1.25) -- and the `binary` front-end
|
||||
sweeps none at all. Measured on stamped marks over controlled backgrounds
|
||||
(`scripts/detector_response.py`), the response is a COMB: doubao scores 0.99 exactly at
|
||||
each rung and collapses to 0.37-0.48 between them, against a 0.50 gate. So a mark whose
|
||||
rendered size lands mid-gap is missed at FULL contrast. Jimeng (binary, one nominal
|
||||
size) holds a single 0.90-1.20 lobe; samsung only 0.95-1.05.
|
||||
|
||||
WHY A SYNTHETIC SWEEP IS NOT ENOUGH TO JUSTIFY A FIX
|
||||
Dead zones only cost recall if real marks land in them. The fractions were calibrated on
|
||||
real captures, so it is entirely possible that real marks cluster at ratio 1.0 and the
|
||||
gaps are never visited. That question is not answerable by stamping -- it needs the
|
||||
corpus.
|
||||
|
||||
THE MEASUREMENT
|
||||
Positives are images carrying INDEPENDENT vendor provenance (TC260 / C2PA metadata
|
||||
naming the vendor), which is evidence that does not come from the pixel detector we are
|
||||
grading -- the same discriminator that settled the pill gate. For every such image the
|
||||
detector currently MISSES, rescore at a dense ladder and ask whether it would now cross
|
||||
the gate, and at which scale.
|
||||
|
||||
Negatives are images with NO metadata signal at all. That is deliberately NOT "and no
|
||||
mark was detected": defining the set by the detector's own verdict would make its
|
||||
false-fire rate 0 by construction and the comparison vacuous. The cost is that a
|
||||
metadata-stripped screenshot of a marked image sits in the negative set and its correct
|
||||
detection is counted as a false fire -- but that impurity is identical under both
|
||||
ladders, so the DELTA between them, which is what the fix is judged on, stays sound.
|
||||
|
||||
Two more impurities to keep in view when reading the positive arm. Provenance says the
|
||||
vendor produced the file, not that a visible mark is on it. And the TC260 label names no
|
||||
specific vendor, so `visible_provenance` maps it to BOTH doubao and jimeng -- a "doubao
|
||||
positive" may carry a jimeng mark or none. Both inflate the miss count, so the recovery
|
||||
percentage is an upper bound on what a denser ladder buys.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis, gitignored output.
|
||||
|
||||
uv run python scripts/ladder_headroom.py --mark doubao --n 4000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_ladder_headroom.jsonl"
|
||||
|
||||
# The rungs the product ships today, and the dense ladder under evaluation. The dense one
|
||||
# is geometric with a ~6% step, chosen from the measured half-width of a rung's lobe
|
||||
# (doubao holds >=0.5 for about +/-6% around each peak), so no gap is left uncovered.
|
||||
SHIPPED = (0.8, 1.0, 1.25)
|
||||
LADDERS: dict[str, tuple[float, ...]] = {
|
||||
# A geometric ~6% step across the whole plausible range. Measures the CEILING of what
|
||||
# any ladder change can buy, and the worst case of what it costs in false fires.
|
||||
"dense": tuple(round(0.70 * (1.06**i), 4) for i in range(13)), # 0.70 .. ~1.49
|
||||
# The targeted alternative. On the full run, 26 of 28 recoveries came from ONE rung
|
||||
# (~1.116) and 22 of 28 were LANDSCAPE frames -- that is a geometry gap, not a density
|
||||
# gap, so the honest comparison is one extra rung against thirteen.
|
||||
"plus_one": (0.8, 1.0, 1.1157, 1.25),
|
||||
"shipped": SHIPPED,
|
||||
}
|
||||
DENSE = LADDERS["dense"]
|
||||
# Score every rung any candidate ladder might use and store them all, so a new candidate
|
||||
# is evaluated by re-reading the JSONL instead of re-running the hour-long sweep.
|
||||
PROBE_SCALES: tuple[float, ...] = tuple(sorted({s for rungs in LADDERS.values() for s in rungs}))
|
||||
|
||||
|
||||
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
||||
if n == 0:
|
||||
return (0.0, 0.0)
|
||||
p, d = k / n, 1 + z * z / n
|
||||
c = (p + z * z / (2 * n)) / d
|
||||
hw = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
|
||||
return (100 * (c - hw), 100 * (c + hw))
|
||||
|
||||
|
||||
def score_at_scales(engine: Any, image: np.ndarray, scales: tuple[float, ...]) -> dict[float, float]:
|
||||
"""`_tophat_score`'s inner loop, opened up so the ladder is a parameter.
|
||||
|
||||
Deliberately reaches into the engine (`tophat_response`, `_glyph_silhouette`): a
|
||||
measurement script may, product code may not. Kept a faithful copy of the shipped
|
||||
scoring -- if it drifts, the numbers below stop describing the product.
|
||||
"""
|
||||
c = engine.config
|
||||
loc = engine.locate(image)
|
||||
resp = engine.tophat_response(image, loc)
|
||||
sil = engine._glyph_silhouette()
|
||||
if resp is None or sil is None:
|
||||
return {}
|
||||
base = engine.scale_base(image)
|
||||
out: dict[float, float] = {}
|
||||
for scale in scales:
|
||||
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
|
||||
gh = max(4, int(c.alpha_height_frac * base * scale))
|
||||
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
||||
continue
|
||||
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32)
|
||||
if c.template_blur > 0:
|
||||
tmpl = cv2.GaussianBlur(tmpl, (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur)
|
||||
out[scale] = float(cv2.matchTemplate(resp, tmpl.astype(np.uint8), cv2.TM_CCOEFF_NORMED).max())
|
||||
return out
|
||||
|
||||
|
||||
def _one(args: tuple[str, str]) -> dict[str, Any] | None:
|
||||
path_str, mark_key = args
|
||||
from remove_ai_watermarks.api import visible_provenance
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
|
||||
path = Path(path_str)
|
||||
try:
|
||||
prov = visible_provenance(path)
|
||||
except Exception:
|
||||
return None
|
||||
positive = mark_key in prov
|
||||
|
||||
if not positive:
|
||||
# A negative is a frame with NO metadata signal at all -- evidence independent of
|
||||
# the detector being graded. Do NOT also require that no mark was detected: that
|
||||
# defines the negative set by the very verdict under test, and "false fire today"
|
||||
# would then be 0 by construction on every run.
|
||||
try:
|
||||
if identify(path, check_visible=False).signals:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 200:
|
||||
return None
|
||||
|
||||
mark = get_mark(mark_key)
|
||||
det = mark.detect(img)
|
||||
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module(f"remove_ai_watermarks.{mark_key}_engine")
|
||||
cls = next(
|
||||
o for n, o in vars(mod).items() if isinstance(o, type) and n.endswith("Engine") and n != "TextMarkEngine"
|
||||
)
|
||||
engine = cls()
|
||||
scores = score_at_scales(engine, img, PROBE_SCALES)
|
||||
if not scores:
|
||||
return None
|
||||
gate = engine.config.detect_ncc_threshold
|
||||
dense = {s: v for s, v in scores.items() if s in LADDERS["dense"]}
|
||||
best_scale = max(dense, key=lambda s: dense[s]) if dense else 0.0
|
||||
h, w = img.shape[:2]
|
||||
return {
|
||||
"src": path.name,
|
||||
"arm": "positive" if positive else "negative",
|
||||
"detected_now": bool(det.detected),
|
||||
"conf_now": round(float(det.confidence), 4),
|
||||
"gate": gate,
|
||||
# Every rung, so a new candidate ladder is evaluated by re-reading this file.
|
||||
"scores": {str(s): round(v, 4) for s, v in scores.items()},
|
||||
"dense_best": round(dense[best_scale], 4) if dense else 0.0,
|
||||
"dense_best_scale": best_scale,
|
||||
"dense_crosses": bool(dense) and dense[best_scale] >= gate,
|
||||
"aspect": "portrait" if w / h < 0.9 else ("landscape" if w / h > 1.1 else "square"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
# doubao only, and that is not laziness. `score_at_scales` reproduces the TOPHAT
|
||||
# front-end, which is the only one with a ladder to widen; scoring it for jimeng or
|
||||
# samsung would measure a code path their detectors never run. Doubao is also the one
|
||||
# mark that declares NO rival, so crossing its NCC gate really is the whole verdict --
|
||||
# for a mark with rivals, `dense_crosses` would ignore the competitive margin and read
|
||||
# optimistically. Widening the binary front-end is a separate experiment.
|
||||
ap.add_argument("--mark", default="doubao", choices=["doubao"])
|
||||
ap.add_argument("--n", type=int, default=4000, help="corpus files to scan")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--report-only", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
out = a.out.with_name(f"{a.out.stem}_{a.mark}{a.out.suffix}")
|
||||
if a.report_only:
|
||||
report([json.loads(x) for x in out.read_text(encoding="utf-8").splitlines() if x.strip()], a.mark)
|
||||
return
|
||||
|
||||
pool = glob.glob(str(CORPUS / "*" / "*"))
|
||||
random.Random(19).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto
|
||||
pool = pool[: a.n]
|
||||
print(f"mark={a.mark} scanning {len(pool)} corpus files workers={a.workers}")
|
||||
print(f"shipped ladder {SHIPPED}\ndense ladder {DENSE}\n", flush=True)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
||||
futures = [ex.submit(_one, (p, a.mark)) for p in pool]
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
try:
|
||||
rec = fut.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the sweep
|
||||
continue
|
||||
if rec is None:
|
||||
continue
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
rows.append(rec)
|
||||
if i % 500 == 0:
|
||||
fh.flush()
|
||||
pos = sum(1 for r in rows if r["arm"] == "positive")
|
||||
print(f" {i}/{len(pool)} usable={len(rows)} (pos={pos})", flush=True)
|
||||
report(rows, a.mark)
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]], mark: str) -> None:
|
||||
pos = [r for r in rows if r["arm"] == "positive"]
|
||||
neg = [r for r in rows if r["arm"] == "negative"]
|
||||
print(f"\n{'=' * 82}\nLADDER HEADROOM mark={mark} positives={len(pos)} verified-clean negatives={len(neg)}")
|
||||
print(f"{'=' * 82}")
|
||||
if not pos and not neg:
|
||||
print("no usable rows")
|
||||
return
|
||||
|
||||
if pos:
|
||||
miss = [r for r in pos if not r["detected_now"]]
|
||||
rec = [r for r in miss if r["dense_crosses"]]
|
||||
now = len(pos) - len(miss)
|
||||
lo, hi = wilson(len(rec), len(miss)) if miss else (0.0, 0.0)
|
||||
print("\nPOSITIVE ARM (images whose METADATA names this vendor -- an upper bound,")
|
||||
print("provenance says the vendor produced the file, not that a mark is visible on it)\n")
|
||||
print(f" detected today {now:5d} / {len(pos)} ({100 * now / len(pos):.1f}%)")
|
||||
print(f" of the {len(miss)} misses, the dense ladder crosses the gate on {len(rec)}")
|
||||
if miss:
|
||||
print(f" ({100 * len(rec) / len(miss):.1f}%, 95% CI {lo:.1f}-{hi:.1f})")
|
||||
after = now + len(rec)
|
||||
print(f" detection would go {100 * now / len(pos):.1f}% -> {100 * after / len(pos):.1f}%")
|
||||
if rec:
|
||||
by_scale = collections.Counter(r["dense_best_scale"] for r in rec)
|
||||
print("\n which rung recovers them (a rung near a SHIPPED one recovering many means")
|
||||
print(" the gain is the finer STEP, not the wider RANGE):")
|
||||
for s, n in sorted(by_scale.items()):
|
||||
near = " <- shipped" if any(abs(s - x) < 0.02 for x in SHIPPED) else ""
|
||||
print(f" scale {s:<6} {n:4d}{near}")
|
||||
by_asp = collections.Counter(r["aspect"] for r in rec)
|
||||
print(f"\n by aspect: {dict(by_asp)}")
|
||||
|
||||
if neg:
|
||||
fires_now = sum(1 for r in neg if r["detected_now"])
|
||||
fires_dense = sum(1 for r in neg if r["dense_crosses"])
|
||||
lo0, hi0 = wilson(fires_now, len(neg))
|
||||
lo1, hi1 = wilson(fires_dense, len(neg))
|
||||
print("\n\nNEGATIVE ARM (no metadata signal; NOT filtered on the detector's own verdict)\n")
|
||||
p0, p1 = 100 * fires_now / len(neg), 100 * fires_dense / len(neg)
|
||||
print(f" false fire today {fires_now:4d} / {len(neg)} {p0:.2f}% (CI {lo0:.2f}-{hi0:.2f})")
|
||||
print(f" false fire, dense ladder {fires_dense:4d} / {len(neg)} {p1:.2f}% (CI {lo1:.2f}-{hi1:.2f})")
|
||||
print("\n This is the price. A denser ladder gives a spurious blob more chances to")
|
||||
print(" match at SOME scale, so the gain above is only real if this line barely moves.")
|
||||
|
||||
ladder_table(pos, neg)
|
||||
|
||||
|
||||
def _crosses(row: dict[str, Any], rungs: tuple[float, ...]) -> bool:
|
||||
"""Would this frame cross its gate on the given ladder, from the stored per-rung scores."""
|
||||
sc = row.get("scores") or {}
|
||||
gate = row["gate"]
|
||||
return any(v >= gate for k, v in sc.items() if float(k) in rungs)
|
||||
|
||||
|
||||
def ladder_table(pos: list[dict[str, Any]], neg: list[dict[str, Any]]) -> None:
|
||||
"""Every candidate ladder side by side: what it recovers against what it costs."""
|
||||
if not (pos and neg) or "scores" not in pos[0]:
|
||||
return # older run without per-rung scores
|
||||
miss = [r for r in pos if not r["detected_now"]]
|
||||
base_fire = sum(1 for r in neg if r["detected_now"])
|
||||
print("\n\nLADDER CANDIDATES -- recovered marks against the false fires they cost\n")
|
||||
print("A candidate is only worth shipping if the recovered column beats the added-fires")
|
||||
print("column by enough to survive the base rates: clean frames vastly outnumber marked")
|
||||
print("ones in real traffic, so a small percentage on the negative arm is a large count.\n")
|
||||
hdr = f"{'ladder':10s} {'rungs':>6s} {'recovered':>10s} {'of misses':>10s}"
|
||||
print(f"{hdr} {'false fire':>11s} {'added':>7s} {'ratio':>7s}")
|
||||
for name, rungs in LADDERS.items():
|
||||
rec = sum(1 for r in miss if _crosses(r, rungs))
|
||||
fire = sum(1 for r in neg if _crosses(r, rungs) or r["detected_now"])
|
||||
added = fire - base_fire
|
||||
ratio = f"{rec / added:.1f}:1" if added > 0 else ("inf" if rec else "-")
|
||||
pct = 100 * rec / len(miss) if miss else 0.0
|
||||
print(f"{name:10s} {len(rungs):6d} {rec:10d} {pct:9.1f}% {100 * fire / len(neg):10.2f}% {added:7d} {ratio:>7s}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Audit AI-metadata REMOVAL over a local image corpus (detection<->removal parity).
|
||||
|
||||
`corpus_gap_scan.py` proves the DETECTOR sees a marker; this proves the STRIPPER
|
||||
reaches it. For every file that carries an AI-metadata signal, run
|
||||
``remove_ai_metadata`` and re-scan the output with the SAME oracle
|
||||
(``get_ai_metadata``). Any signal that survives is a real parity bug: a re-served
|
||||
file still reads as AI. Also assert the strip is lossless -- decoded pixels
|
||||
(and alpha) bit-identical before/after -- since the removal must only touch
|
||||
metadata, never the coded image.
|
||||
|
||||
A no-op control set (clean images with no AI metadata) verifies the stripper
|
||||
neither ADDS a signal nor corrupts pixels on files it should leave alone.
|
||||
|
||||
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
|
||||
|
||||
uv run python scripts/metadata_removal_audit.py \
|
||||
--corpus data/spaces/originals --identify data/spaces/identify \
|
||||
--out data/spaces/_metadata_removal_audit.csv --jobs 8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# identify-JSON watermark substrings that imply a METADATA-borne signal (as
|
||||
# opposed to a purely visual sparkle/text mark). Used only to pick the candidate
|
||||
# population fast; get_ai_metadata is the per-file ground truth.
|
||||
_META_HINTS = (
|
||||
"C2PA",
|
||||
"Content Credentials",
|
||||
"IPTC",
|
||||
"Made with AI",
|
||||
"AIGC",
|
||||
"TC260",
|
||||
"EXIF",
|
||||
"Signature",
|
||||
"SynthID",
|
||||
"hf-job",
|
||||
"HuggingFace",
|
||||
"Samsung",
|
||||
"soft-binding",
|
||||
"metadata",
|
||||
)
|
||||
|
||||
|
||||
def _pixels(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
"""Decoded BGR + alpha, for the lossless-strip integrity check."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
return image_io.read_bgr_and_alpha(path)
|
||||
|
||||
|
||||
def _same_pixels(a: Path, b: Path) -> bool | None:
|
||||
"""True/False if both decode; None if either is undecodable (can't compare)."""
|
||||
try:
|
||||
bgr_a, al_a = _pixels(a)
|
||||
bgr_b, al_b = _pixels(b)
|
||||
except Exception:
|
||||
return None
|
||||
if bgr_a is None or bgr_b is None:
|
||||
return None
|
||||
if bgr_a.shape != bgr_b.shape or not np.array_equal(bgr_a, bgr_b):
|
||||
return False
|
||||
if (al_a is None) != (al_b is None):
|
||||
return False
|
||||
return al_a is None or al_b is None or np.array_equal(al_a, al_b)
|
||||
|
||||
|
||||
def _audit_one(path_str: str) -> dict[str, object]:
|
||||
"""Worker: detect -> strip -> re-detect + pixel-integrity for one file."""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, remove_ai_metadata
|
||||
|
||||
path = Path(path_str)
|
||||
row: dict[str, object] = {
|
||||
"path": path.name,
|
||||
"ext": path.suffix.lower(),
|
||||
"carrier": False,
|
||||
"before": "",
|
||||
"after": "",
|
||||
"parity_ok": "",
|
||||
"pixels_identical": "",
|
||||
"status": "ok",
|
||||
}
|
||||
try:
|
||||
before = get_ai_metadata(path)
|
||||
except Exception as exc:
|
||||
row["status"] = f"scan_error:{type(exc).__name__}"
|
||||
return row
|
||||
row["before"] = "|".join(sorted(before))
|
||||
row["carrier"] = bool(before)
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = Path(td) / f"clean{path.suffix.lower()}"
|
||||
try:
|
||||
remove_ai_metadata(path, out)
|
||||
except Exception as exc:
|
||||
row["status"] = f"strip_error:{type(exc).__name__}"
|
||||
return row
|
||||
if not out.exists():
|
||||
row["status"] = "no_output"
|
||||
return row
|
||||
try:
|
||||
after = get_ai_metadata(out)
|
||||
except Exception as exc:
|
||||
row["status"] = f"rescan_error:{type(exc).__name__}"
|
||||
return row
|
||||
row["after"] = "|".join(sorted(after))
|
||||
row["parity_ok"] = not after # every AI signal must be gone
|
||||
same = _same_pixels(path, out)
|
||||
row["pixels_identical"] = "" if same is None else same
|
||||
return row
|
||||
|
||||
|
||||
def _candidate_paths(corpus: Path, identify: Path | None, clean_sample: int) -> tuple[list[Path], list[Path]]:
|
||||
"""Return (carriers, clean_controls). Uses identify JSONs when present to pick
|
||||
metadata carriers fast; falls back to scanning every file."""
|
||||
if identify is None or not identify.exists():
|
||||
files = sorted(p for p in corpus.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED_FORMATS)
|
||||
return files, []
|
||||
|
||||
carriers: list[Path] = []
|
||||
clean: list[Path] = []
|
||||
for jf in identify.rglob("*.json"):
|
||||
try:
|
||||
d = json.loads(jf.read_text())
|
||||
except Exception: # noqa: S112 -- skip an unreadable identify JSON, not security-relevant
|
||||
continue
|
||||
src = d.get("src")
|
||||
if not src:
|
||||
continue
|
||||
img = corpus / jf.parent.name / src
|
||||
if not img.exists() or img.suffix.lower() not in SUPPORTED_FORMATS:
|
||||
continue
|
||||
wm = " | ".join(d.get("watermarks") or [])
|
||||
if any(h in wm for h in _META_HINTS):
|
||||
carriers.append(img)
|
||||
elif not d.get("is_ai_generated"):
|
||||
clean.append(img)
|
||||
rng = random.Random(0) # noqa: S311 -- deterministic sampling seed, not cryptographic
|
||||
rng.shuffle(clean)
|
||||
return carriers, clean[:clean_sample]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), default=Path("data/spaces/originals")
|
||||
)
|
||||
@click.option(
|
||||
"--identify",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("data/spaces/identify"),
|
||||
help="identify-JSON dir to pick carriers (skip = scan all).",
|
||||
)
|
||||
@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_metadata_removal_audit.csv"))
|
||||
@click.option(
|
||||
"--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op."
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Cap carriers scanned (0 = all).")
|
||||
@click.option("--jobs", type=int, default=8)
|
||||
def main(corpus: Path, identify: Path | None, out: Path, clean_sample: int, limit: int, jobs: int) -> None:
|
||||
logging.basicConfig(level=logging.ERROR, format="%(message)s")
|
||||
carriers, clean = _candidate_paths(corpus, identify, clean_sample)
|
||||
if limit:
|
||||
carriers = carriers[:limit]
|
||||
tasks = [(p, "carrier") for p in carriers] + [(p, "clean") for p in clean]
|
||||
click.echo(f"Carriers: {len(carriers)} | clean controls: {len(clean)} | jobs {jobs}")
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
with ProcessPoolExecutor(max_workers=jobs) as ex:
|
||||
futs = {ex.submit(_audit_one, str(p)): k for p, k in tasks}
|
||||
for done, fut in enumerate(as_completed(futs), 1):
|
||||
row = fut.result()
|
||||
row["kind"] = futs[fut]
|
||||
rows.append(row)
|
||||
if done % 500 == 0:
|
||||
click.echo(f" {done}/{len(tasks)}")
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
fields = ["kind", "path", "ext", "carrier", "before", "after", "parity_ok", "pixels_identical", "status"]
|
||||
with out.open("w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fields)
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
w.writerow({k: r.get(k, "") for k in fields})
|
||||
|
||||
# ---- Summary ----
|
||||
car = [r for r in rows if r["kind"] == "carrier"]
|
||||
ctl = [r for r in rows if r["kind"] == "clean"]
|
||||
real_carriers = [r for r in car if r["carrier"] and r["status"] == "ok"]
|
||||
parity_fail = [r for r in real_carriers if r["parity_ok"] is False]
|
||||
pixel_fail = [r for r in real_carriers if r["pixels_identical"] is False]
|
||||
errors = [r for r in rows if r["status"] != "ok"]
|
||||
|
||||
click.echo("\n===== METADATA REMOVAL PARITY =====")
|
||||
click.echo(f"Carrier candidates: {len(car)}")
|
||||
click.echo(f"Confirmed carriers: {len(real_carriers)} (get_ai_metadata non-empty)")
|
||||
click.echo(f"Parity FAILS (signal survives strip): {len(parity_fail)}")
|
||||
click.echo(f"Pixel-integrity FAILS (strip altered pixels): {len(pixel_fail)}")
|
||||
click.echo(f"Errors (scan/strip/decode): {len(errors)}")
|
||||
|
||||
# Per-signal parity breakdown.
|
||||
sig_total: Counter[str] = Counter()
|
||||
sig_fail: Counter[str] = Counter()
|
||||
for r in real_carriers:
|
||||
for s in str(r["before"]).split("|"):
|
||||
if s:
|
||||
sig_total[s] += 1
|
||||
if r["parity_ok"] is False:
|
||||
for s in str(r["after"]).split("|"):
|
||||
if s:
|
||||
sig_fail[s] += 1
|
||||
click.echo("\nPer-signal (carriers / surviving-after-strip):")
|
||||
for s, n in sig_total.most_common():
|
||||
click.echo(f" {s:24} {n:6} survived: {sig_fail.get(s, 0)}")
|
||||
|
||||
click.echo("\n===== NO-OP CONTROL (clean images) =====")
|
||||
ctl_ok = [r for r in ctl if r["status"] == "ok"]
|
||||
added = [r for r in ctl_ok if r["after"]] # strip must not ADD a signal
|
||||
corrupted = [r for r in ctl_ok if r["pixels_identical"] is False]
|
||||
click.echo(f"Clean controls scanned: {len(ctl_ok)}")
|
||||
click.echo(f"Strip ADDED a signal: {len(added)}")
|
||||
click.echo(f"Strip corrupted pixels: {len(corrupted)}")
|
||||
|
||||
if parity_fail:
|
||||
click.echo("\n--- Parity failures (first 30) ---")
|
||||
for r in parity_fail[:30]:
|
||||
click.echo(f" {r['ext']:6} survived=[{r['after']}] {r['path']}")
|
||||
if pixel_fail:
|
||||
click.echo("\n--- Pixel-integrity failures (first 30) ---")
|
||||
for r in pixel_fail[:30]:
|
||||
click.echo(f" {r['ext']:6} {r['path']}")
|
||||
if errors:
|
||||
ec: Counter[str] = Counter(str(r["status"]) for r in errors)
|
||||
click.echo("\n--- Errors by kind ---")
|
||||
for k, n in ec.most_common():
|
||||
click.echo(f" {n:5} {k}")
|
||||
|
||||
click.echo(f"\nReport: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,224 @@
|
||||
"""Measure the Jimeng pill on the PRODUCT path, not the raw detector.
|
||||
|
||||
WHY THIS EXISTS
|
||||
`visible_removal_audit.py` calls `get_mark(key).remove` directly, which bypasses
|
||||
`_keep_pill`. For the pill that is the wrong path and it reads as a disaster: on a
|
||||
300-image sample the raw path was "detector still fires after removal" 68-75% of the
|
||||
time. The product does not do that -- `--mark auto` gates the pill hard (never on
|
||||
Doubao; unrestricted only when the bottom-right wordmark fired; otherwise only on a
|
||||
flat footprint). Measured through the gate on the same sample, 7 of 80 raw detections
|
||||
survived to removal and 6 of those were corroborated real.
|
||||
|
||||
That sample was too small to state a precision (95% CI 49-97%). This script runs the
|
||||
gated path over EVERY pill positive in the corpus so the interval is usable.
|
||||
|
||||
THE CORROBORATION PROXY AND ITS BIAS
|
||||
"Real pill" here means: TC260 metadata names Jimeng, OR the bottom-right "★ 即梦AI"
|
||||
wordmark is detected. Both are independent of the pill detector, which is the point.
|
||||
But the proxy MISSES a real pill on a metadata-stripped screenshot with no visible
|
||||
wordmark -- so measured precision is a LOWER BOUND, not a point estimate. Do not quote
|
||||
it as if it were exact.
|
||||
|
||||
Corpus images are user uploads: read-only, local analysis, gitignored output.
|
||||
|
||||
uv run python scripts/pill_gate_audit.py --jobs 7
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
POSITIVES = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
OUT = REPO / "data" / "spaces" / "_pill_gate_audit.jsonl"
|
||||
|
||||
|
||||
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
||||
if n == 0:
|
||||
return (0.0, 0.0)
|
||||
p = k / n
|
||||
d = 1 + z * z / n
|
||||
c = (p + z * z / (2 * n)) / d
|
||||
h = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
|
||||
return (100 * (c - h), 100 * (c + h))
|
||||
|
||||
|
||||
def _one(path_str: str) -> dict[str, object]:
|
||||
from remove_ai_watermarks.api import visible_provenance
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.pill_engine import PillEngine
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks, get_mark, remove_auto_marks
|
||||
|
||||
rec: dict[str, object] = {"path": Path(path_str).name, "status": "ok"}
|
||||
try:
|
||||
img = imread(path_str)
|
||||
if img is None:
|
||||
return {**rec, "status": "unreadable"}
|
||||
prov = visible_provenance(Path(path_str))
|
||||
dets = {d.key: d for d in detect_marks(img) if d.detected}
|
||||
wordmark = "jimeng" in dets
|
||||
tc260 = "jimeng" in prov
|
||||
eng = PillEngine()
|
||||
_, labels = remove_auto_marks(img, sensitivity="auto", provenance=prov, backend="cv2")
|
||||
# Match the EXACT pill label. A substring test on "AI生成" also matches Doubao's
|
||||
# label ("Doubao 豆包AI生成 text"), which counted Doubao removals as pill removals
|
||||
# and inflated the pill's measured precision.
|
||||
pill_label = get_mark("jimeng_pill").label
|
||||
removed = any(str(x) == pill_label for x in labels)
|
||||
# The gate reads the ARBITRATED keys, not strict detection: jimeng can be accepted
|
||||
# on its relaxed (provenance-confirmed) arm, which `detect_marks` never shows. Take
|
||||
# the arm from what was actually removed, or every such case lands in "other".
|
||||
jimeng_decided = any(str(x) == get_mark("jimeng").label for x in labels)
|
||||
return {
|
||||
**rec,
|
||||
"removed_by_product": bool(removed),
|
||||
"corroborated": bool(wordmark or tc260),
|
||||
"wordmark": bool(wordmark),
|
||||
"jimeng_decided": bool(jimeng_decided),
|
||||
"tc260": bool(tc260),
|
||||
"doubao_fired": "doubao" in dets,
|
||||
"footprint_flat": bool(eng.footprint_is_flat(img)),
|
||||
}
|
||||
except Exception as e:
|
||||
return {**rec, "status": f"error:{type(e).__name__}"}
|
||||
|
||||
|
||||
def _batch(paths: list[str], jobs: int, timeout: int) -> list[dict[str, object]]:
|
||||
try:
|
||||
with ProcessPoolExecutor(max_workers=jobs) as ex:
|
||||
futs = [ex.submit(_one, p) for p in paths]
|
||||
return [f.result() for f in as_completed(futs, timeout=timeout)]
|
||||
except (FutureTimeout, BrokenProcessPool, OSError, RuntimeError):
|
||||
out = []
|
||||
for p in paths:
|
||||
try:
|
||||
out.append(_one(p))
|
||||
except BaseException: # a native crash costs one file, not the sweep
|
||||
out.append({"path": Path(p).name, "status": "crashed"})
|
||||
return out
|
||||
|
||||
|
||||
def report(rows: list[dict[str, object]]) -> None:
|
||||
ok = [r for r in rows if r.get("status") == "ok"]
|
||||
if not ok:
|
||||
print("no usable rows")
|
||||
return
|
||||
removed = [r for r in ok if r["removed_by_product"]]
|
||||
corro = [r for r in ok if r["corroborated"]]
|
||||
tp = [r for r in removed if r["corroborated"]]
|
||||
missed = [r for r in corro if not r["removed_by_product"]]
|
||||
|
||||
print(f"\n{'=' * 76}\nJIMENG PILL ON THE PRODUCT PATH n={len(ok)} raw detections\n{'=' * 76}")
|
||||
print(f"raw detections corroborated as real : {len(corro):5d} ({100 * len(corro) / len(ok):.1f}%)")
|
||||
print(f"the gate lets through to removal : {len(removed):5d} ({100 * len(removed) / len(ok):.1f}%)")
|
||||
if removed:
|
||||
lo, hi = wilson(len(tp), len(removed))
|
||||
pct = 100 * len(tp) / len(removed)
|
||||
print(f" of those, corroborated real : {len(tp):5d} -> precision {pct:.1f}% (95% CI {lo:.1f}-{hi:.1f})")
|
||||
if corro:
|
||||
lo, hi = wilson(len(tp), len(corro))
|
||||
pct = 100 * len(tp) / len(corro)
|
||||
head = f"corroborated pills removed : {len(tp):5d}/{len(corro)}"
|
||||
print(f"{head} -> recall {pct:.1f}% (95% CI {lo:.1f}-{hi:.1f})")
|
||||
print(f"corroborated pills the gate suppressed: {len(missed):5d}")
|
||||
print("\nprecision is a LOWER BOUND: the corroboration proxy cannot see a real pill on a")
|
||||
print("metadata-stripped image whose wordmark is absent or missed.\n")
|
||||
|
||||
print("which gate arm let it through:")
|
||||
arms: collections.Counter[str] = collections.Counter()
|
||||
for r in removed:
|
||||
if r.get("jimeng_decided"):
|
||||
arms["jimeng wordmark accepted -> unrestricted"] += 1
|
||||
elif r["tc260"]:
|
||||
arms["tc260 metadata + flat footprint"] += 1
|
||||
else:
|
||||
arms["other (unexpected -- the gate has no third arm)"] += 1
|
||||
for k, v in arms.most_common():
|
||||
print(f" {v:5d} {k}")
|
||||
|
||||
print("\nwhy the rest were suppressed:")
|
||||
sup: collections.Counter[str] = collections.Counter()
|
||||
for r in ok:
|
||||
if r["removed_by_product"]:
|
||||
continue
|
||||
if r["doubao_fired"]:
|
||||
sup["doubao fired (pill never rides on it)"] += 1
|
||||
elif not r["corroborated"]:
|
||||
sup["no corroboration (no wordmark, no TC260)"] += 1
|
||||
elif not r["footprint_flat"]:
|
||||
sup["corroborated but footprint too textured"] += 1
|
||||
else:
|
||||
sup["other"] += 1
|
||||
for k, v in sup.most_common():
|
||||
print(f" {v:5d} {k}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--batch", type=int, default=200)
|
||||
ap.add_argument("--timeout", type=int, default=900)
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
paths = []
|
||||
with open(POSITIVES, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
if "jimeng_pill" in (r.get("keys") or []):
|
||||
paths.append(r["path"])
|
||||
paths.sort()
|
||||
if a.limit:
|
||||
paths = paths[: a.limit]
|
||||
|
||||
done: set[str] = set()
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink() # --restart must TRUNCATE; appending kept the old rows in the report
|
||||
if a.out.exists() and not a.restart:
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["path"])
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
todo = [p for p in paths if Path(p).name not in done]
|
||||
print(f"pill positives {len(paths)} done {len(done)} to do {len(todo)} jobs {a.jobs}", flush=True)
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
with open(a.out, "a", encoding="utf-8") as fh:
|
||||
for i in range(0, len(todo), a.batch):
|
||||
for rec in _batch(todo[i : i + a.batch], a.jobs, a.timeout):
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
rows.append(rec)
|
||||
fh.flush()
|
||||
print(f" {len(rows)}/{len(todo)}", flush=True)
|
||||
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
allrows = []
|
||||
for line in fh:
|
||||
try:
|
||||
allrows.append(json.loads(line))
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
report(allrows)
|
||||
print(f"records -> {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,328 @@
|
||||
"""End-to-end confidence run: drive the ACTUAL CLI over REAL corpus examples.
|
||||
|
||||
WHY THIS EXISTS AND WHAT IT IS NOT
|
||||
The 849-test suite and `smoke_matrix.py` prove the code paths behave on fixtures and
|
||||
synthetic inputs. This is the other half: run the real `remove-ai-watermarks` entry point,
|
||||
as a user would, over real corpus images spanning every command and every provenance
|
||||
class, and CHECK THE OUTPUT -- not that it exited 0, but that it did the right thing (the
|
||||
mark is actually gone on re-detect, the metadata actually strips, the diffusion actually
|
||||
writes a changed image). A green exit is not evidence the work happened.
|
||||
|
||||
WHAT IT COVERS
|
||||
identify one real image per provenance class -> the verdict is right
|
||||
metadata real AI-metadata files -> --check detects, --remove strip-and-verifies clean
|
||||
visible real marked images per mark -> the mark is gone on re-detect, output written
|
||||
erase a real image, each fill backend (cv2 / migan / lama) -> output written
|
||||
invisible a real SynthID image on MPS at reduced resolution -> a CHANGED image is written
|
||||
all a real marked image through the full pipeline -> output written
|
||||
batch a real directory -> every input produces an output
|
||||
|
||||
invisible/all run the diffusion model, so they are gated behind --diffusion and run at a
|
||||
small --max-resolution on MPS (the user's "reduced size on MPS" path). Everything else is
|
||||
cv2/numpy and fast.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis, outputs to a gitignored temp
|
||||
dir. Records example uids and pass/fail, never image content.
|
||||
|
||||
uv run python scripts/real_examples_e2e.py # fast surface (no diffusion)
|
||||
uv run python scripts/real_examples_e2e.py --diffusion # + invisible/all on MPS
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
DATASETS = REPO / "data" / "spaces" / "_visible_datasets"
|
||||
SAMPLES = REPO / "data" / "samples"
|
||||
_UV = shutil.which("uv") or "uv" # full path avoids the partial-executable lint
|
||||
|
||||
|
||||
def run(args: list[str], timeout: int = 300) -> tuple[int, str]:
|
||||
"""Invoke the real installed CLI. Returns (exit_code, combined output)."""
|
||||
proc = subprocess.run( # noqa: S603 -- fixed argv, the whole point is the real entry point
|
||||
[_UV, "run", "remove-ai-watermarks", *args],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
return proc.returncode, (proc.stdout + proc.stderr)
|
||||
|
||||
|
||||
def find_visible_positive(mark: str) -> Path | None:
|
||||
"""A real corpus image the parity run bucketed as carrying this mark, that the current
|
||||
detector STILL fires on (the bucket was built by an older run; re-confirm live)."""
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
|
||||
pool = sorted(glob.glob(str(DATASETS / mark / "*")))
|
||||
random.Random(3).shuffle(pool) # noqa: S311 -- deterministic sampling, not cryptography
|
||||
for p in pool[:60]:
|
||||
img = imread(p)
|
||||
if img is None:
|
||||
continue
|
||||
if any(d.detected and d.key == mark for d in detect_marks(img)):
|
||||
return Path(p)
|
||||
return None
|
||||
|
||||
|
||||
class Results:
|
||||
"""Collects one row per checked behaviour.
|
||||
|
||||
A FAIL keeps the command's OUTPUT. That is not cosmetic: the first run of this harness
|
||||
discarded it, an `all` invocation failed once with exit 1, and because the output was
|
||||
gone there was no way to tell which of that command's three distinct `SystemExit(1)`
|
||||
paths had fired -- the failure was undiagnosable and did not reproduce. Keep the tail
|
||||
so a transient is at least identifiable after the fact.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[tuple[str, str, bool, str, str]] = []
|
||||
|
||||
def add(self, cmd: str, example: str, ok: bool, detail: str, output: str = "") -> None:
|
||||
self.rows.append((cmd, example, ok, detail, "" if ok else output[-800:]))
|
||||
mark = "PASS" if ok else "FAIL"
|
||||
print(f" [{mark}] {cmd:28s} {example:26s} {detail}", flush=True)
|
||||
|
||||
def report(self) -> int:
|
||||
n = len(self.rows)
|
||||
bad = [r for r in self.rows if not r[2]]
|
||||
print(f"\n{'=' * 78}\nREAL-EXAMPLE E2E {n - len(bad)}/{n} passed")
|
||||
print(f"{'=' * 78}")
|
||||
for cmd, ex, ok, detail, out in self.rows:
|
||||
if not ok:
|
||||
print(f" FAIL {cmd} {ex} {detail}")
|
||||
if out.strip():
|
||||
print(" --- command output (tail) ---")
|
||||
for line in out.strip().splitlines()[-12:]:
|
||||
print(f" {line}")
|
||||
if not bad:
|
||||
print(" every command produced the right result on real corpus examples")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def check_identify(res: Results) -> None:
|
||||
"""One real image per provenance class -> the verdict is right (--json must parse)."""
|
||||
print("\nidentify -- real image per provenance class")
|
||||
cases = [
|
||||
("chatgpt-1.png", True),
|
||||
("firefly-1.png", True),
|
||||
("mj-1.png", True),
|
||||
("doubao-1.png", True),
|
||||
("grok-1.jpg", True),
|
||||
("flux-1.jpg", True),
|
||||
]
|
||||
for name, want_ai in cases:
|
||||
p = SAMPLES / name
|
||||
if not p.exists():
|
||||
res.add("identify", name, False, "sample missing")
|
||||
continue
|
||||
code, out = run(["identify", str(p), "--json"])
|
||||
try:
|
||||
data = json.loads(out[out.index("{") : out.rindex("}") + 1])
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
res.add("identify", name, False, f"json did not parse (exit {code})")
|
||||
continue
|
||||
is_ai = data.get("is_ai_generated")
|
||||
plat = (data.get("platform") or "").lower()
|
||||
ok = bool(is_ai) is want_ai
|
||||
res.add("identify", name, ok, f"is_ai={is_ai} platform={plat or '-'}")
|
||||
|
||||
|
||||
def check_metadata(res: Results, tmp: Path) -> None:
|
||||
"""Real AI-metadata files -> --check detects, --remove strip-and-verifies clean."""
|
||||
print("\nmetadata -- real AI-metadata files, detect then strip-and-verify")
|
||||
for name in ("chatgpt-1.png", "doubao-1.png", "mj-1.png", "grok-1.jpg", "flux-1.jpg"):
|
||||
p = SAMPLES / name
|
||||
if not p.exists():
|
||||
res.add("metadata --check", name, False, "sample missing")
|
||||
continue
|
||||
code, out = run(["metadata", str(p), "--check"])
|
||||
detected = "no ai" not in out.lower() and code == 0
|
||||
res.add("metadata --check", name, detected, "AI metadata detected" if detected else "nothing detected")
|
||||
|
||||
outp = tmp / f"stripped_{name}"
|
||||
code, out = run(["metadata", str(p), "--remove", "-o", str(outp)])
|
||||
# strip-and-verify: the command re-scans the OUTPUT and fails loudly on leftovers.
|
||||
clean = outp.exists() and code == 0 and "still" not in out.lower()
|
||||
res.add("metadata --remove", name, clean, "output re-scanned clean" if clean else f"exit {code}")
|
||||
|
||||
|
||||
def check_visible(res: Results, tmp: Path) -> None:
|
||||
"""Real marked images -> the PRODUCT'S DECISION is honoured, and a removed mark clears.
|
||||
|
||||
The success criterion is not "the mark is always gone" -- it is "the product did what it
|
||||
decided, and the decision is right". Two designed behaviours make a blind re-detect
|
||||
misleading:
|
||||
* The pill is GATED (`_keep_pill`): a low-confidence pill with no corroboration is
|
||||
deliberately NOT removed, so `visible` correctly writes nothing and exits 2. That is
|
||||
the gate working, not a miss -- checked by asking `remove_auto_marks` whether it
|
||||
chose to act.
|
||||
* Samsung is the faintest mark (peak alpha ~0.38) at a razor-thin 0.40 gate, so a
|
||||
borderline positive can be reduced yet re-detect just above threshold. Reported as
|
||||
the measured before->after confidence, not a bare pass/fail.
|
||||
"""
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks, get_mark, remove_auto_marks
|
||||
|
||||
print("\nvisible --mark auto -- real marked image per mark, product decision then re-detect")
|
||||
for mark in ("doubao", "jimeng", "qwen", "gemini", "samsung", "jimeng_pill"):
|
||||
src = find_visible_positive(mark)
|
||||
if src is None:
|
||||
res.add("visible", mark, True, "no live positive in bucket (skipped, not a failure)")
|
||||
continue
|
||||
img = imread(str(src))
|
||||
conf_before = next((d.confidence for d in detect_marks(img) if d.detected and d.key == mark), 0.0)
|
||||
# What does the product DECIDE to do? (labels lists the marks it removed.)
|
||||
_out_img, labels = remove_auto_marks(img, sensitivity="auto", provenance=frozenset(), backend="cv2")
|
||||
acted = get_mark(mark).label in labels
|
||||
|
||||
outp = tmp / f"visible_{mark}{src.suffix}"
|
||||
code, out = run(["visible", str(src), "--mark", "auto", "-o", str(outp)])
|
||||
|
||||
if not acted:
|
||||
# The gate declined this mark. The CLI must then write nothing and exit 2 --
|
||||
# that is the correct outcome, so verify the CLI agrees with the decision.
|
||||
ok = code == 2 and not outp.exists()
|
||||
res.add(
|
||||
"visible", mark, ok, f"gate declined (conf {conf_before:.2f}); CLI exit {code}, no output -- correct"
|
||||
)
|
||||
continue
|
||||
|
||||
if not outp.exists():
|
||||
res.add("visible", mark, False, f"product removed it but CLI wrote no output (exit {code})", out)
|
||||
continue
|
||||
cleaned = imread(str(outp))
|
||||
conf_after = next((d.confidence for d in detect_marks(cleaned) if d.detected and d.key == mark), 0.0)
|
||||
clean = conf_after == 0.0
|
||||
if clean:
|
||||
res.add("visible", mark, True, f"removed, re-detect clean ({conf_before:.2f} -> below gate)")
|
||||
else:
|
||||
# Reduced but still over the gate: a real residual. Honest partial, flagged.
|
||||
res.add(
|
||||
"visible",
|
||||
mark,
|
||||
False,
|
||||
f"reduced {conf_before:.2f} -> {conf_after:.2f} but still over gate (faint-mark residual)",
|
||||
)
|
||||
|
||||
|
||||
def check_erase(res: Results, tmp: Path) -> None:
|
||||
"""A real image, each fill backend actually runs and writes an output."""
|
||||
from remove_ai_watermarks.region_eraser import lama_available, migan_available
|
||||
|
||||
print("\nerase --region -- each fill backend on a real image")
|
||||
src = next((Path(p) for p in sorted(glob.glob(str(CORPUS / "*" / "*"))) if Path(p).stat().st_size > 50_000), None)
|
||||
if src is None:
|
||||
res.add("erase", "-", False, "no corpus image")
|
||||
return
|
||||
backends = ["cv2"] + (["migan"] if migan_available() else []) + (["lama"] if lama_available() else [])
|
||||
for backend in backends:
|
||||
outp = tmp / f"erase_{backend}{src.suffix}"
|
||||
code, out = run(["erase", str(src), "--region", "10,10,120,60", "--backend", backend, "-o", str(outp)])
|
||||
ok = outp.exists() and code == 0 and outp.stat().st_size > 0
|
||||
res.add(f"erase --backend {backend}", src.name[:12], ok, "output written" if ok else f"exit {code}", out)
|
||||
|
||||
|
||||
def check_diffusion(res: Results, tmp: Path, gemini_src: str, openai_src: str) -> None:
|
||||
"""The GPU path: invisible + all on MPS at reduced resolution -> a CHANGED image."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
print("\ninvisible / all -- real SynthID image on MPS, reduced resolution")
|
||||
for label, src in (("invisible/gemini", gemini_src), ("invisible/openai", openai_src)):
|
||||
if not src:
|
||||
res.add(label, "-", True, "no real positive found (skipped)")
|
||||
continue
|
||||
sp = Path(src)
|
||||
outp = tmp / f"inv_{sp.stem}.png"
|
||||
code, out = run(
|
||||
["invisible", src, "-o", str(outp), "--device", "mps", "--max-resolution", "512", "--seed", "0"],
|
||||
timeout=1200,
|
||||
)
|
||||
if not outp.exists():
|
||||
res.add(label, sp.name[:12], False, f"no output (exit {code})", out)
|
||||
continue
|
||||
before, after = imread(src), imread(str(outp))
|
||||
# Diffusion regenerates every pixel; the output must actually differ from the input.
|
||||
# A DIFFERENT SHAPE is itself proof it changed (the pipeline resizes), so treat it
|
||||
# as changed rather than comparing arrays that cannot be compared. The first
|
||||
# version wrote `array_equal(after, before if shapes match else after)`, which
|
||||
# compares `after` WITH ITSELF on the mismatch branch and is therefore always
|
||||
# "unchanged" -- it would have reported a genuinely resized output as a no-op.
|
||||
changed = (
|
||||
after is not None
|
||||
and before is not None
|
||||
and (before.shape != after.shape or not np.array_equal(before, after))
|
||||
)
|
||||
res.add(label, sp.name[:12], changed, "diffusion wrote a changed image" if changed else "output == input", out)
|
||||
|
||||
if gemini_src:
|
||||
sp = Path(gemini_src)
|
||||
outp = tmp / f"all_{sp.stem}.png"
|
||||
code, out = run(
|
||||
["all", gemini_src, "-o", str(outp), "--device", "mps", "--max-resolution", "512", "--seed", "0"],
|
||||
timeout=1200,
|
||||
)
|
||||
ok = outp.exists() and outp.stat().st_size > 0
|
||||
res.add("all (full pipeline)", sp.name[:12], ok, "output written" if ok else f"exit {code}", out)
|
||||
|
||||
|
||||
def check_batch(res: Results, tmp: Path) -> None:
|
||||
"""A real directory -> every supported input produces an output."""
|
||||
print("\nbatch -- a real directory")
|
||||
indir = tmp / "batch_in"
|
||||
indir.mkdir(exist_ok=True)
|
||||
picks = sorted(glob.glob(str(DATASETS / "doubao" / "*")))[:5]
|
||||
for p in picks:
|
||||
shutil.copy2(p, indir / Path(p).name)
|
||||
n_in = len(list(indir.glob("*")))
|
||||
if n_in == 0:
|
||||
res.add("batch", "-", False, "no inputs to seed")
|
||||
return
|
||||
outdir = tmp / "batch_out"
|
||||
code, out = run(["batch", str(indir), "-o", str(outdir), "--mode", "visible"], timeout=600)
|
||||
n_out = len(list(outdir.glob("*"))) if outdir.exists() else 0
|
||||
ok = code == 0 and n_out >= n_in
|
||||
res.add("batch --mode visible", f"{n_in} imgs", ok, f"{n_out}/{n_in} outputs (exit {code})", out)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--diffusion", action="store_true", help="also run invisible/all on MPS (slow)")
|
||||
ap.add_argument("--gemini", default="")
|
||||
ap.add_argument("--openai", default="")
|
||||
a = ap.parse_args()
|
||||
|
||||
res = Results()
|
||||
with tempfile.TemporaryDirectory(prefix="raiw_e2e_") as td:
|
||||
tmp = Path(td)
|
||||
check_identify(res)
|
||||
check_metadata(res, tmp)
|
||||
check_visible(res, tmp)
|
||||
check_erase(res, tmp)
|
||||
check_batch(res, tmp)
|
||||
if a.diffusion:
|
||||
check_diffusion(res, tmp, a.gemini, a.openai)
|
||||
else:
|
||||
print("\n(diffusion skipped -- pass --diffusion to run invisible/all on MPS)")
|
||||
raise SystemExit(res.report())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Render SYNTHETIC detection silhouettes for the CJK vendor text marks (data-safe).
|
||||
|
||||
Adding a mark needs only a DETECTION silhouette, and it must be font-rendered rather
|
||||
than derived from user uploads: the corpus is real user content and may never reach a
|
||||
tracked asset (see the repo CLAUDE.md data-safety rule). Seeing real samples to learn
|
||||
the glyphs, weight and layout is fine; the committed template stays synthetic.
|
||||
|
||||
Covered here:
|
||||
qwen "千问AI生成" -- Alibaba Tongyi Qianwen, bottom-right, 3-lobed logo + text
|
||||
xinghui "星绘AI生成" -- ByteDance 星绘, bottom-right, 4-point sparkle + text
|
||||
yuanbao "元宝\nAI生成" -- Tencent Yuanbao, bottom-right, two-line italic block
|
||||
(MEASURED NEGATIVE 2026-07-21, parked: the slanted two-line template does
|
||||
not separate the cohort from clean corners on either front-end; the recipe
|
||||
+ MARK_OPTS stay as the starting point if a structural/learned lever is
|
||||
built -- full record in docs/verification-plan.md)
|
||||
kling "可灵AI 3.0" -- Kuaishou Kling, bottom-right, spiral logo + text
|
||||
(REGISTERED 2026-07-21, kling_engine.py)
|
||||
|
||||
The leading LOGO is deliberately NOT rendered. It is the part that varies most between
|
||||
releases and is hardest to reproduce synthetically, while the CJK run is stable and is
|
||||
what actually discriminates one vendor from another (the shared `AI生成` tail is exactly
|
||||
what does NOT discriminate -- see the rival-margin mechanism in _text_mark_engine).
|
||||
|
||||
Regenerate with: uv run python scripts/render_vendor_silhouettes.py
|
||||
|
||||
STATUS 2026-07-21: `qwen_alpha.png` IS registered (`qwen_engine.py`) -- the 2026-07-18
|
||||
blocker quoted below turned out to be mis-sized GEOMETRY (two size modes + a locate box
|
||||
that clipped the first glyph), not segmentation, and was solved by the TC260-producer
|
||||
cohort harvest + `vendor_mark_calibrate.py` (117 labelled frames; full record in
|
||||
`docs/verification-plan.md`). `xinghui_alpha.png` is still NOT registered: one confirmed
|
||||
corpus example is nothing to calibrate a gate against.
|
||||
|
||||
--- the 2026-07-18 record, kept as the history of the failed first attempt ---
|
||||
Measured on 14 hand-verified 千问 positives from the corpus,
|
||||
the then-current detect architecture (top-hat glyph blob -> binary TM_CCOEFF_NORMED)
|
||||
could not see this mark AT ALL:
|
||||
|
||||
same pipeline, each mark scored with its OWN template, on real positives
|
||||
doubao n=40 mean NCC 0.723 median 0.835 >= 0.40 gate: 82%
|
||||
qwen n=14 mean NCC 0.170 median 0.179 >= 0.40 gate: 0%
|
||||
|
||||
Three checks ruled out the obvious explanations, in order:
|
||||
1. NOT the synthetic render. A template cut from an ACTUAL Qwen mark scores the same
|
||||
as the font-rendered one (real-vs-real 0.307 vs synthetic 0.308) -- and real masks
|
||||
do not even match EACH OTHER.
|
||||
2. NOT the morphology kernel size. Scaling MORPH_OPEN/CLOSE with the box height (they
|
||||
are fixed 5px, ~9% of a 57px-tall box) gained only +0.014 mean and moved nothing
|
||||
across the gate.
|
||||
3. NOT the appearance thresholds. Sweeping tophat_delta / logo_min_luma / kernel
|
||||
reached at best mean 0.35 with 4/14 over the gate.
|
||||
|
||||
The blocker was named SEGMENTATION on a faint mark: Doubao is stamped bold and opaque,
|
||||
so the white top-hat returns a clean glyph blob; the Qwen mark is a thin translucent
|
||||
overlay that shatters into specks, and no template can match a blob that is not there.
|
||||
The `tophat` front-end (built later, for doubao) removed that blocker -- and 千问 STILL
|
||||
did not register, because the real residual was geometry. See the 2026-07-21 status
|
||||
above.
|
||||
|
||||
星绘 additionally has only ONE confirmed example in the corpus, so even a working
|
||||
front-end could not have its threshold calibrated yet.
|
||||
|
||||
UPDATE 2026-07-20: the named blocker is GONE, and the retry is still inconclusive.
|
||||
`detect_frontend="tophat"` (built later, for doubao) is exactly the "grayscale correlation
|
||||
on the raw top-hat" this note asked for, so the 2026-07-18 ruling rests on a premise that
|
||||
no longer holds and must not simply be inherited. Two things were measured against it, and
|
||||
neither settles the question:
|
||||
|
||||
* A GENERIC template of the shared `AI生成` tail -- attractive because GB 45438-2025
|
||||
guarantees that run across vendors, so one template would cover 千问 / 百度 / 星绘 and
|
||||
anything compliant that ships next. Measured on the tophat front-end at the shipped
|
||||
3-rung ladder: a bold 千问 positive scores 0.407 against clean corners at p99 0.298 /
|
||||
max 0.321. It separates on that one frame, but only by a hair, and a 4-glyph template
|
||||
is inherently less specific than a 6-glyph one -- the shorter the run, the more
|
||||
arbitrary corner structure correlates with it.
|
||||
* The FULL 千问 template on the same front-end scores 0.248 against a clean max of 0.537,
|
||||
i.e. no separation at all -- WORSE than the generic tail, which is the opposite of
|
||||
what the specificity argument predicts and is itself a reason to distrust n=1.
|
||||
|
||||
The blocker is now EVIDENCE, not architecture: this session found exactly one 千问 and one
|
||||
百度 positive (both by eyeballing doubao-provenance misses), and the 14 positives quoted
|
||||
above were not preserved anywhere the current scripts can reach. Nothing should be
|
||||
registered off a single frame.
|
||||
|
||||
UPDATE 2026-07-21 (the resolution): the evidence arrived via the TC260 producer-USCC
|
||||
cohort trick (`scripts/vendor_cohort_harvest.py` -- 117 labelled 千问 frames from metadata
|
||||
alone), and the registration shipped the same day (`qwen_engine.py`). The "no separation
|
||||
at all" reading above was the MIS-SIZED geometry, not the mark: at the fitted geometry the
|
||||
full template separates the cohort from clean corners 0.662 vs 0.134 (p50). The traps
|
||||
below still bind any NEXT vendor: score with `alpha_height_frac`, not the silhouette's own
|
||||
aspect ratio (the latter inflated the clean p99 from 0.30 to 0.58 and made every
|
||||
comparison meaningless); keep the ladder at the shipped rungs for gate-setting, since a
|
||||
wide sweep hands clean corners many extra chances to match; and re-filter the clean arm
|
||||
per candidate -- the 2026-07-18 `present: []` labels mean "no REGISTERED mark", so qwen
|
||||
-cohort frames visibly carrying 千问AI生成 sat in it (see `vendor_mark_calibrate.load_sets`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_ASSETS = Path(__file__).resolve().parents[1] / "src" / "remove_ai_watermarks" / "assets"
|
||||
# STHeiti Medium approximates the semibold CJK sans these marks are set in; the exact
|
||||
# family is unpublished for every vendor (GB 45438-2025 only requires a legible face).
|
||||
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
|
||||
|
||||
MARKS = {
|
||||
"qwen_alpha.png": "千问AI生成",
|
||||
"xinghui_alpha.png": "星绘AI生成",
|
||||
# Yuanbao's stamp is a TWO-LINE block (元宝 over AI生成), left-aligned, tightly
|
||||
# stacked and ITALIC-SLANTED (measured on the 2026-07-21 cohort sheet + real tophat
|
||||
# responses); a rare one-line variant exists but the stacked block is dominant.
|
||||
"yuanbao_alpha.png": "元宝\nAI生成",
|
||||
# Kling (可灵) stamps a thin light-gray one-line "可灵AI 3.0" bottom-right (an
|
||||
# "Omni" suffix variant and a latin "KlingAI 3.0" variant also exist; the CJK
|
||||
# run without the suffix is the common core). The leading spiral logo is NOT
|
||||
# rendered (logos vary; the text run discriminates).
|
||||
"kling_alpha.png": "可灵AI 3.0",
|
||||
# The "cat-logo" cohort (USCC 91110108562144110X) stamps an outline cat-head +
|
||||
# bold "AI生成", bottom-right. PARKED 2026-07-21: the cohort is 19 copies of
|
||||
# only 2 unique carriers -- nothing to calibrate recall against (the xinghui
|
||||
# rule). The probe is ready: this silhouette scores 0.50 on the mark vs 0.333
|
||||
# max on a diverse clean arm, so registration is a gate pick (0.42) the moment
|
||||
# more unique carriers arrive.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
# RunningHub (ComfyUI platform, USCC 91340100MAEB4N8H76, 73-frame cohort
|
||||
# 2026-07-22): white one-line "RunningHub AI生成" text mark.
|
||||
"runninghub_alpha.png": "RunningHub AI生成",
|
||||
# LibLibAI / 哩布哩布AI (USCC 91110105MACJ6K1C8A, 15-frame cohort): white
|
||||
# "LibLibAI" wordmark with a triangle logo (logo not rendered, logos vary).
|
||||
"liblib_alpha.png": "LibLibAI",
|
||||
# Zhipu Qingyan (USCC 91110108MA01KP2T5U, 7-frame cohort): white bold
|
||||
# "清言·AI生成" with a circular logo (logo not rendered). PARKED 2026-07-22
|
||||
# as a measured negative: on both front-ends the cohort scores 0.34-0.39
|
||||
# against a clean-arm max of 0.34-0.37 -- no separation at any render/box
|
||||
# setting (text-only and logo-composite templates both plateau ~0.34 raw;
|
||||
# the white semi-transparent text on variable backgrounds is the wall).
|
||||
# Silhouette stays as the starting point for a structural/learned lever.
|
||||
"qingyan_alpha.png": "清言·AI生成",
|
||||
# MiniMax / Hailuo (6-frame cohort): "MINIMAX" + "Hailuo AI" latin wordmarks.
|
||||
# PARKED 2026-07-22: only 1 of the 6 cohort frames carries a visible mark --
|
||||
# nothing to calibrate recall against (the xinghui rule). Registration is a
|
||||
# gate pick once more unique carriers arrive.
|
||||
"hailuo_alpha.png": "Hailuo AI",
|
||||
# Baidu (USCC 91110000802100433B, 16-frame cohort): white bold "百度" text
|
||||
# + a separate white rounded tag with dark "AI生成", bottom-right. Detection
|
||||
# keys on the 百度 text run ONLY: a two-component template (text+pill) scored
|
||||
# at clean-arm levels (pill = bright-blob magnet, clean p95 0.45-0.55 vs cohort
|
||||
# ~0.5, no separation on either front-end, 2026-07-22); the text-only silhouette
|
||||
# separates (cohort 0.39-0.65 vs clean max 0.352). The white tag is removed
|
||||
# with the mark because the fill blob covers both bright components.
|
||||
"baidu_alpha.png": "百度",
|
||||
}
|
||||
|
||||
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
|
||||
MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
# Fitted against real tophat responses on the Yuanbao cohort (2026-07-21): a
|
||||
# right-aligned, gapped, unslanted render plateaued at ~0.34 NCC; left-align +
|
||||
# tight gap + stroke dilation + shear -0.75 reaches 0.65-0.70 on the same frames,
|
||||
# at/above the real-vs-real ceiling (~0.6).
|
||||
"yuanbao_alpha.png": {"gap_frac": 0.05, "dilate": 2, "shear": -0.75},
|
||||
# Qingyan's real stamp is a heavier weight than STHeiti Medium -- Hiragino
|
||||
# Sans GB W6 matches the measured stroke (2026-07-22; with Medium the
|
||||
# silhouette aspect came out 0.19 vs the real 0.28 and NCC plateaued ~0.3).
|
||||
"qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2},
|
||||
# LibLibAI's wordmark is set in an Arial-class grotesque, not STHeiti:
|
||||
# measured 2026-07-22 across 7 candidate fonts, Arial lifts the cohort
|
||||
# positives from 0.31-0.47 to 0.42-0.73 while the full-corpus false-fire arm
|
||||
# DROPS to max 0.398 (generic latin UI text matches the wrong font less).
|
||||
"liblib_alpha.png": {"font": "/System/Library/Fonts/Supplemental/Arial.ttf"},
|
||||
}
|
||||
|
||||
|
||||
def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> np.ndarray:
|
||||
"""Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention.
|
||||
|
||||
Matching doubao's 335px asset width keeps the `alpha_*_frac` numbers transferable,
|
||||
since these marks are the same house style and scale. A "\n" in ``text`` renders a
|
||||
multi-line block: lines drawn left-aligned at one shared font size with a tight
|
||||
gap, then optional stroke dilation and an italic shear (see MARK_OPTS).
|
||||
"""
|
||||
opts = opts or {}
|
||||
gap_frac = float(opts.get("gap_frac", 0.15))
|
||||
dilate = int(opts.get("dilate", 0))
|
||||
shear_k = float(opts.get("shear", 0.0))
|
||||
font_path = str(opts.get("font", _FONT))
|
||||
font_index = int(opts.get("font_index", 0))
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
lines = text.split("\n")
|
||||
size = 8
|
||||
while size < 200: # grow until the LONGEST line fills the target width
|
||||
f = ImageFont.truetype(font_path, size, index=font_index)
|
||||
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(font_path, size, index=font_index)
|
||||
boxes = [d0.textbbox((0, 0), ln, font=font) for ln in lines]
|
||||
line_h = max(bb[3] - bb[1] for bb in boxes)
|
||||
gap = max(1, int(line_h * gap_frac))
|
||||
w = max(bb[2] - bb[0] for bb in boxes)
|
||||
h = line_h * len(lines) + gap * (len(lines) - 1)
|
||||
pad = max(2, int(line_h * 0.12))
|
||||
im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0)
|
||||
draw = ImageDraw.Draw(im)
|
||||
y = pad
|
||||
for ln, bb in zip(lines, boxes, strict=True):
|
||||
draw.text((pad - bb[0], y - bb[1]), ln, font=font, fill=255)
|
||||
y += line_h + gap
|
||||
sil = np.array(im)
|
||||
if dilate or shear_k:
|
||||
import cv2
|
||||
|
||||
if dilate:
|
||||
sil = cv2.dilate(sil, np.ones((dilate, dilate), np.uint8))
|
||||
if shear_k:
|
||||
hh, ww = sil.shape
|
||||
sil = cv2.warpAffine(sil, np.float32([[1, shear_k, 0], [0, 1, 0]]), (ww + int(abs(shear_k) * hh), hh))
|
||||
return sil
|
||||
|
||||
|
||||
def draw_catlogo(width: int = 335) -> np.ndarray:
|
||||
"""The cat-logo mark: an outline cat-head (integrated pointy ears, two dot eyes)
|
||||
+ a bold "AI生成" run, drawn synthetically from the measured layout (cat ~1.08x
|
||||
the glyph height, stroke ~9%, gap ~35%). Proportions were iterated against a real
|
||||
tophat response (2026-07-21): a solid filled head scored 0.35, this outline form
|
||||
0.50 -- the parked probe, see MARKS."""
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
text = "AI生成"
|
||||
size = 8
|
||||
while size < 200:
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
||||
cs = int(th * 1.08)
|
||||
stroke = max(2, int(th * 0.09))
|
||||
gap = int(th * 0.35)
|
||||
|
||||
def head(s: int) -> Image.Image:
|
||||
im = Image.new("L", (s, s), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
f = float(s)
|
||||
pts = [
|
||||
(0.12 * f, 0.95 * f),
|
||||
(0.10 * f, 0.45 * f),
|
||||
(0.12 * f, 0.30 * f),
|
||||
(0.20 * f, 0.05 * f), # left ear tip
|
||||
(0.40 * f, 0.24 * f), # left ear valley
|
||||
(0.60 * f, 0.24 * f), # right ear valley
|
||||
(0.80 * f, 0.05 * f), # right ear tip
|
||||
(0.88 * f, 0.30 * f),
|
||||
(0.90 * f, 0.45 * f),
|
||||
(0.88 * f, 0.95 * f),
|
||||
]
|
||||
d.line([*pts, pts[0]], fill=255, width=stroke, joint="curve")
|
||||
r = max(1.5, stroke * 0.7)
|
||||
d.ellipse([0.35 * f - r, 0.60 * f - r, 0.35 * f + r, 0.60 * f + r], fill=255)
|
||||
d.ellipse([0.65 * f - r, 0.60 * f - r, 0.65 * f + r, 0.60 * f + r], fill=255)
|
||||
return im
|
||||
|
||||
w = cs + gap + tw
|
||||
h = max(th, cs)
|
||||
pad = max(2, int(h * 0.12))
|
||||
im = Image.new("L", (w + 2 * pad, h + 2 * pad), 0)
|
||||
im.paste(head(cs), (pad, pad + (h - cs) // 2))
|
||||
ImageDraw.Draw(im).text((pad + cs + gap - bb[0], pad + (h - th) // 2 - bb[1]), text, font=font, fill=255)
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
for name, text in MARKS.items():
|
||||
sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name))
|
||||
Image.fromarray(sil).save(_ASSETS / name)
|
||||
print(f"wrote {_ASSETS / name} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}")
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Tier B4: peak RSS and wall time per fill backend across input sizes.
|
||||
|
||||
WHY THIS EXISTS
|
||||
The docs describe the backends in CAPABILITY prose -- "MI-GAN is the memory-tight pick",
|
||||
"big-LaMa does not fit a minimal droplet", "~0.6-0.9 GB regardless of upload size". Those
|
||||
numbers were measured once, informally, and are now load-bearing for a real deployment
|
||||
decision (the free tier runs `migan`, the paid tier runs `lama`). This measures them.
|
||||
|
||||
WHAT IT MEASURES
|
||||
For each (backend, input size): peak RSS of a FRESH process doing exactly one erase, and
|
||||
the wall time. A fresh subprocess per measurement is the point -- peak RSS inside a
|
||||
long-lived process is contaminated by whatever ran before it, and the question here is
|
||||
what a per-request worker actually needs.
|
||||
|
||||
The mask is a fixed, small corner region at every size, because the claim under test is
|
||||
that the learned backends crop around the mask and so their memory is bounded by the MARK
|
||||
size, not the image size. If that holds, the curve is flat in input size; if it does not,
|
||||
it climbs and the droplet sizing is wrong.
|
||||
|
||||
READING IT
|
||||
RSS is the peak resident set of the whole worker, which includes the interpreter, numpy,
|
||||
cv2 and (for the learned backends) onnxruntime plus the model. That is the honest number
|
||||
for sizing a container -- not the model tensor alone.
|
||||
|
||||
DATA SAFETY
|
||||
Generates its own synthetic inputs. Reads nothing from the corpus, writes nothing tracked.
|
||||
|
||||
uv run python scripts/resource_ceilings.py # cv2 + whatever is installed
|
||||
uv run python scripts/resource_ceilings.py --max-mp 25 # push to 25 MP
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
_UV = shutil.which("uv") or "uv"
|
||||
|
||||
# (label, width, height) -- 1 MP up to 25 MP, the range a phone photo upload spans.
|
||||
SIZES = (("1MP", 1000, 1000), ("4MP", 2000, 2000), ("12MP", 4000, 3000), ("25MP", 5000, 5000))
|
||||
|
||||
# The child process: build an image, erase one small corner region, report peak RSS.
|
||||
# Kept as a string so each measurement is a genuinely fresh interpreter.
|
||||
_CHILD = """
|
||||
import json, resource, sys, time
|
||||
import numpy as np
|
||||
from remove_ai_watermarks.region_eraser import erase
|
||||
|
||||
w, h, backend = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
|
||||
rng = np.random.default_rng(0)
|
||||
# Textured, not flat: a flat image compresses and can let a backend shortcut work.
|
||||
img = rng.integers(0, 255, (h, w, 3), dtype=np.uint8)
|
||||
box = (w - 320, h - 90, 300, 70) # a mark-sized corner region at every input size
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
out = erase(img, boxes=[box], backend=backend)
|
||||
# Assert the fill actually RAN. A wrong call signature or a silently-declining
|
||||
# backend would otherwise report the process's numpy footprint as if it were the
|
||||
# backend's cost -- the first version of this script did exactly that on all 12
|
||||
# cells and the numbers looked plausible.
|
||||
# Compare ONLY the mask box. A full-frame `(out != img).any()` allocates a boolean
|
||||
# temp the size of the image BEFORE getrusage is read -- 75 MB at 25 MP, ~17% of the
|
||||
# cv2 figure, and it GROWS with the input, so the harness would partly manufacture
|
||||
# the very "cv2 scales with input size" conclusion it is measuring.
|
||||
bx, by, bw_, bh_ = box
|
||||
changed = int((out[by:by+bh_, bx:bx+bw_] != img[by:by+bh_, bx:bx+bw_]).any())
|
||||
err = "" if changed else "NO-OP: backend did not modify the masked region"
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"[:200]
|
||||
elapsed = time.monotonic() - t0
|
||||
peak_kb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
||||
print("RESULT" + json.dumps({"peak_kb": peak_kb, "sec": round(elapsed, 2), "error": err}))
|
||||
"""
|
||||
|
||||
|
||||
def peak_rss_mb(peak_kb: int) -> float:
|
||||
"""ru_maxrss is BYTES on macOS and KILOBYTES on Linux -- normalize to MB.
|
||||
|
||||
Getting this wrong silently reports 1024x off, which would look like a dramatic
|
||||
finding rather than a unit bug.
|
||||
"""
|
||||
return peak_kb / (1024 * 1024) if sys.platform == "darwin" else peak_kb / 1024
|
||||
|
||||
|
||||
def measure(backend: str, w: int, h: int, timeout: int = 900) -> dict[str, object] | None:
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
|
||||
fh.write(_CHILD)
|
||||
child = fh.name
|
||||
try:
|
||||
p = subprocess.run( # noqa: S603 -- fixed argv, our own child script
|
||||
[_UV, "run", "python", child, str(w), str(h), backend],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": f"TIMEOUT >{timeout}s", "sec": float(timeout), "peak_mb": float("nan")}
|
||||
finally:
|
||||
Path(child).unlink(missing_ok=True)
|
||||
|
||||
line = next((x for x in p.stdout.splitlines() if x.startswith("RESULT")), None)
|
||||
if line is None:
|
||||
return {"error": (p.stderr or p.stdout)[-200:], "sec": 0.0, "peak_mb": float("nan")}
|
||||
data = json.loads(line[len("RESULT") :])
|
||||
return {"error": data["error"], "sec": data["sec"], "peak_mb": round(peak_rss_mb(data["peak_kb"]), 1)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--max-mp", type=int, default=25, help="skip sizes above this megapixel count")
|
||||
a = ap.parse_args()
|
||||
|
||||
from remove_ai_watermarks.region_eraser import lama_available, migan_available
|
||||
|
||||
backends = ["cv2"] + (["migan"] if migan_available() else []) + (["lama"] if lama_available() else [])
|
||||
sizes = [s for s in SIZES if (s[1] * s[2]) / 1e6 <= a.max_mp + 0.5]
|
||||
print(f"backends: {backends}\nsizes: {[s[0] for s in sizes]}")
|
||||
print("\nOne FRESH process per cell; the mask is a fixed small corner at every size.")
|
||||
print("If the learned backends really crop around the mask, RSS stays flat in input size.\n")
|
||||
|
||||
print(f"{'backend':8s} {'size':6s} {'peak RSS':>11s} {'wall':>8s} note")
|
||||
rows: list[tuple[str, str, dict[str, object]]] = []
|
||||
for backend in backends:
|
||||
for label, w, h in sizes:
|
||||
r = measure(backend, w, h)
|
||||
if r is None:
|
||||
continue
|
||||
rows.append((backend, label, r))
|
||||
note = str(r["error"])[:48] or "ok"
|
||||
print(f"{backend:8s} {label:6s} {r['peak_mb']:8} MB {r['sec']:7}s {note}", flush=True)
|
||||
|
||||
print(f"\n{'=' * 72}\nRESOURCE CEILINGS\n{'=' * 72}")
|
||||
for backend in backends:
|
||||
cells = [(lbl, r) for b, lbl, r in rows if b == backend and not r["error"]]
|
||||
if not cells:
|
||||
continue
|
||||
peaks = [float(r["peak_mb"]) for _, r in cells] # type: ignore[arg-type]
|
||||
lo, hi = min(peaks), max(peaks)
|
||||
growth = "flat in input size" if hi <= lo * 1.6 else f"GROWS {hi / max(lo, 0.1):.1f}x with input size"
|
||||
print(f" {backend:6s} peak {lo:.0f}-{hi:.0f} MB across {cells[0][0]}..{cells[-1][0]} -- {growth}")
|
||||
print("\nRSS is the whole worker (interpreter + numpy + cv2 + any model), i.e. the")
|
||||
print("number to size a container with, not the model tensor alone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Tier E: does the real CLI fail GRACEFULLY on adversarial and degenerate inputs?
|
||||
|
||||
WHAT "PASS" MEANS HERE
|
||||
Not "it succeeded" -- most of these inputs SHOULD be rejected. A pass is a graceful
|
||||
outcome: a clear message, a sane exit code, and **no unhandled traceback**, within the
|
||||
timeout. The failure modes this is hunting are the ones a user actually hits and that no
|
||||
unit test covers, because unit tests feed well-formed fixtures:
|
||||
|
||||
* an unhandled traceback -- the library crashed instead of reporting
|
||||
* a hang -- worse than a crash for a batch caller
|
||||
* a silent success on garbage -- it "processed" a corrupt file and wrote something
|
||||
|
||||
A non-zero exit with a readable error is a PASS. A Python traceback is a FAIL even when
|
||||
the exit code looks tidy.
|
||||
|
||||
WHY THESE INPUTS
|
||||
Every case is drawn from something real: ~0.2% of corpus uploads are truncated, ~2% carry
|
||||
a mismatched extension, Unicode filenames were issue #17, and a wrapping service will run
|
||||
concurrent jobs against one path. Decompression bombs and absurd geometry are the cheap
|
||||
denial-of-service shapes any tool taking user uploads must survive.
|
||||
|
||||
DATA SAFETY
|
||||
Builds its own inputs (synthetic, or truncated copies of committed fixtures) inside a
|
||||
temp dir. Reads corpus images read-only for the one large-input case. Writes nothing
|
||||
tracked.
|
||||
|
||||
uv run python scripts/robustness_suite.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SAMPLES = REPO / "data" / "samples"
|
||||
_UV = shutil.which("uv") or "uv"
|
||||
|
||||
# A traceback in the output means the failure escaped the error handling, whatever the
|
||||
# exit code says.
|
||||
_CRASH_MARKERS = ("Traceback (most recent call last)", "Fatal Python error", "Segmentation fault")
|
||||
|
||||
|
||||
class Results:
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[tuple[str, str, bool, str, str]] = []
|
||||
|
||||
def add(self, case: str, cmd: str, ok: bool, detail: str, output: str = "") -> None:
|
||||
self.rows.append((case, cmd, ok, detail, "" if ok else output[-600:]))
|
||||
print(f" [{'PASS' if ok else 'FAIL'}] {case:34s} {cmd:10s} {detail}", flush=True)
|
||||
|
||||
def report(self) -> int:
|
||||
bad = [r for r in self.rows if not r[2]]
|
||||
print(f"\n{'=' * 78}\nROBUSTNESS (Tier E) {len(self.rows) - len(bad)}/{len(self.rows)} graceful")
|
||||
print(f"{'=' * 78}")
|
||||
for case, cmd, ok, detail, out in self.rows:
|
||||
if not ok:
|
||||
print(f" FAIL {case} ({cmd}) {detail}")
|
||||
for line in out.strip().splitlines()[-10:]:
|
||||
print(f" {line}")
|
||||
if not bad:
|
||||
print(" every adversarial input was handled without a crash or a hang")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
def run(args: list[str], timeout: int = 120) -> tuple[int, str, bool]:
|
||||
"""Returns (exit_code, output, timed_out)."""
|
||||
try:
|
||||
p = subprocess.run( # noqa: S603 -- fixed argv, driving our own CLI on purpose
|
||||
[_UV, "run", "remove-ai-watermarks", *args],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return (-1, "", True)
|
||||
return (p.returncode, p.stdout + p.stderr, False)
|
||||
|
||||
|
||||
def graceful(res: Results, case: str, cmd: str, args: list[str], timeout: int = 120) -> None:
|
||||
"""Run one adversarial case and score it on gracefulness, not on success."""
|
||||
code, out, timed_out = run(args, timeout=timeout)
|
||||
if timed_out:
|
||||
res.add(case, cmd, False, f"HUNG (> {timeout}s)", out)
|
||||
return
|
||||
crashed = next((m for m in _CRASH_MARKERS if m in out), None)
|
||||
if crashed:
|
||||
res.add(case, cmd, False, f"unhandled {crashed} (exit {code})", out)
|
||||
return
|
||||
res.add(case, cmd, True, f"handled cleanly, exit {code}")
|
||||
|
||||
|
||||
def make_inputs(tmp: Path) -> dict[str, Path]:
|
||||
"""Build the adversarial corpus. Each entry is something a real upload can be."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imwrite
|
||||
|
||||
made: dict[str, Path] = {}
|
||||
|
||||
# A well-formed baseline, so a failure elsewhere is attributable to the input.
|
||||
good = tmp / "good.png"
|
||||
imwrite(good, np.full((600, 800, 3), 128, np.uint8))
|
||||
made["good"] = good
|
||||
|
||||
# Truncated: a real PNG cut mid-stream (~0.2% of real uploads).
|
||||
src = SAMPLES / "chatgpt-1.png"
|
||||
if src.exists():
|
||||
raw = src.read_bytes()
|
||||
trunc = tmp / "truncated.png"
|
||||
trunc.write_bytes(raw[: len(raw) // 3])
|
||||
made["truncated"] = trunc
|
||||
|
||||
# Corrupt: correct magic bytes, garbage body.
|
||||
corrupt = tmp / "corrupt.png"
|
||||
corrupt.write_bytes(b"\x89PNG\r\n\x1a\n" + os.urandom(4096))
|
||||
made["corrupt"] = corrupt
|
||||
|
||||
# Zero-byte file with a valid extension.
|
||||
empty = tmp / "empty.png"
|
||||
empty.write_bytes(b"")
|
||||
made["empty"] = empty
|
||||
|
||||
# Not an image at all, but named like one.
|
||||
text = tmp / "actually_text.jpg"
|
||||
text.write_bytes(b"this is not an image, it is a text file pretending\n" * 50)
|
||||
made["not_an_image"] = text
|
||||
|
||||
# Degenerate geometry: a 1x1, and a 1-pixel-tall sliver (the shape that once faulted
|
||||
# cv2's GaussianBlur natively on Windows).
|
||||
imwrite(tmp / "tiny.png", np.full((1, 1, 3), 200, np.uint8))
|
||||
made["tiny_1x1"] = tmp / "tiny.png"
|
||||
imwrite(tmp / "sliver.png", np.full((1, 4000, 3), 200, np.uint8))
|
||||
made["sliver_1x4000"] = tmp / "sliver.png"
|
||||
|
||||
# Decompression bomb: a tiny file that decodes to a huge canvas. Hand-built so the
|
||||
# on-disk size stays trivial while the declared dimensions are enormous.
|
||||
bomb = tmp / "bomb.png"
|
||||
bomb.write_bytes(_png_bomb(16000, 16000))
|
||||
made["decompression_bomb"] = bomb
|
||||
|
||||
# Unicode + RTL filenames (issue #17 was Unicode-safe IO).
|
||||
uni = tmp / "изображение-测试-🎨.png"
|
||||
shutil.copy2(good, uni)
|
||||
made["unicode_filename"] = uni
|
||||
rtl = tmp / "صورة-اختبار.png"
|
||||
shutil.copy2(good, rtl)
|
||||
made["rtl_filename"] = rtl
|
||||
|
||||
# Mismatched extension: PNG content named .jpg (~2% of real uploads).
|
||||
mismatch = tmp / "png_named_jpg.jpg"
|
||||
shutil.copy2(good, mismatch)
|
||||
made["mismatched_extension"] = mismatch
|
||||
|
||||
return made
|
||||
|
||||
|
||||
def _png_bomb(w: int, h: int) -> bytes:
|
||||
"""A valid PNG header declaring a huge canvas over highly-compressible data."""
|
||||
|
||||
def chunk(tag: bytes, data: bytes) -> bytes:
|
||||
return len(data).to_bytes(4, "big") + tag + data + zlib.crc32(tag + data).to_bytes(4, "big")
|
||||
|
||||
ihdr = w.to_bytes(4, "big") + h.to_bytes(4, "big") + bytes([8, 2, 0, 0, 0]) # 8-bit RGB
|
||||
raw = b"".join(b"\x00" + b"\x00" * (w * 3) for _ in range(h))
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def check_bad_inputs(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
print("\nmalformed and degenerate inputs -- every command must refuse, not crash")
|
||||
out = tmp / "out.png"
|
||||
for case in ("truncated", "corrupt", "empty", "not_an_image", "tiny_1x1", "sliver_1x4000"):
|
||||
src = inputs.get(case)
|
||||
if src is None:
|
||||
continue
|
||||
graceful(res, case, "identify", ["identify", str(src)])
|
||||
graceful(res, case, "visible", ["visible", str(src), "-o", str(out)])
|
||||
graceful(res, case, "metadata", ["metadata", str(src), "--check"])
|
||||
|
||||
|
||||
def check_bomb(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
print("\ndecompression bomb -- must not exhaust memory or hang")
|
||||
bomb = inputs.get("decompression_bomb")
|
||||
if bomb is None:
|
||||
return
|
||||
size_kb = bomb.stat().st_size / 1024
|
||||
print(f" (bomb is {size_kb:.0f} KB on disk, declares 16000x16000)")
|
||||
graceful(res, "decompression_bomb", "identify", ["identify", str(bomb)], timeout=180)
|
||||
graceful(res, "decompression_bomb", "visible", ["visible", str(bomb), "-o", str(tmp / "b.png")], timeout=180)
|
||||
|
||||
|
||||
def check_filenames(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
"""Unicode/RTL paths must round-trip on BOTH read and write (issue #17)."""
|
||||
print("\nunicode / RTL / mismatched-extension paths")
|
||||
for case in ("unicode_filename", "rtl_filename", "mismatched_extension"):
|
||||
src = inputs.get(case)
|
||||
if src is None:
|
||||
continue
|
||||
# Write to a Unicode OUTPUT path too -- the read side alone does not prove the IO.
|
||||
outp = tmp / f"вывод-{case}-📤.png"
|
||||
code, out, timed = run(["identify", str(src)])
|
||||
if timed or any(m in out for m in _CRASH_MARKERS):
|
||||
res.add(case, "identify", False, "crashed or hung", out)
|
||||
else:
|
||||
res.add(case, "identify", True, f"read fine, exit {code}")
|
||||
code, out, timed = run(["erase", str(src), "--region", "5,5,50,30", "-o", str(outp)])
|
||||
wrote = outp.exists() and outp.stat().st_size > 0
|
||||
crash = any(m in out for m in _CRASH_MARKERS)
|
||||
res.add(case, "erase", wrote and not crash and not timed, f"unicode output written={wrote} exit={code}", out)
|
||||
|
||||
|
||||
def check_output_paths(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
print("\nhostile output paths")
|
||||
good = inputs["good"]
|
||||
|
||||
# A nested output dir that does not exist yet -- the CLI should create it.
|
||||
nested = tmp / "a" / "b" / "c" / "out.png"
|
||||
code, out, timed = run(["erase", str(good), "--region", "5,5,40,20", "-o", str(nested)])
|
||||
crash = any(m in out for m in _CRASH_MARKERS)
|
||||
res.add(
|
||||
"nonexistent_nested_outdir",
|
||||
"erase",
|
||||
nested.exists() and not crash and not timed,
|
||||
f"created={nested.exists()} exit={code}",
|
||||
out,
|
||||
)
|
||||
|
||||
# A READ-ONLY output directory -- must report, not traceback.
|
||||
ro = tmp / "readonly"
|
||||
ro.mkdir(exist_ok=True)
|
||||
ro.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
graceful(
|
||||
res, "readonly_output_dir", "erase", ["erase", str(good), "--region", "5,5,40,20", "-o", str(ro / "x.png")]
|
||||
)
|
||||
finally:
|
||||
ro.chmod(stat.S_IRWXU) # restore so the temp dir can be cleaned up
|
||||
|
||||
# A directory where a file is expected.
|
||||
graceful(res, "directory_as_input", "identify", ["identify", str(tmp)])
|
||||
|
||||
# A path that simply is not there.
|
||||
graceful(res, "missing_input", "identify", ["identify", str(tmp / "nope.png")])
|
||||
|
||||
|
||||
def check_concurrency(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
"""A wrapping service will run jobs in parallel against one input path."""
|
||||
print("\nconcurrent runs against a single input")
|
||||
good = inputs["good"]
|
||||
|
||||
def one(i: int) -> tuple[int, str, bool]:
|
||||
return run(["erase", str(good), "--region", "5,5,40,20", "-o", str(tmp / f"conc_{i}.png")])
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as ex:
|
||||
outs = list(ex.map(one, range(4)))
|
||||
crashed = [o for o in outs if any(m in o[1] for m in _CRASH_MARKERS) or o[2]]
|
||||
all_written = all((tmp / f"conc_{i}.png").exists() for i in range(4))
|
||||
res.add(
|
||||
"4x concurrent on one input",
|
||||
"erase",
|
||||
not crashed and all_written,
|
||||
f"{sum(1 for i in range(4) if (tmp / f'conc_{i}.png').exists())}/4 outputs, {len(crashed)} crashed",
|
||||
"".join(o[1] for o in crashed),
|
||||
)
|
||||
|
||||
|
||||
def check_batch_silent_loss(res: Results, tmp: Path, inputs: dict[str, Path]) -> None:
|
||||
"""The nastiest shape: NO output files AND a success exit code.
|
||||
|
||||
`graceful()` cannot see this class -- it scores exit code and traceback markers, and a
|
||||
run that writes nothing while exiting 0 has neither. Corpus-reproduced 2026-07-20:
|
||||
`batch --mode visible` into a read-only directory wrote 0 of 2 files and exited 0, so a
|
||||
wrapping service would treat an empty output directory as a completed run. Any check
|
||||
for a silent no-op must assert on the ARTIFACTS, not on the status.
|
||||
"""
|
||||
print("\nbatch into a read-only output dir -- must NOT exit 0 with nothing written")
|
||||
indir = tmp / "loss_in"
|
||||
indir.mkdir(exist_ok=True)
|
||||
for i in range(2):
|
||||
shutil.copy2(inputs["good"], indir / f"img{i}.png")
|
||||
ro = tmp / "loss_out"
|
||||
ro.mkdir(exist_ok=True)
|
||||
ro.chmod(stat.S_IRUSR | stat.S_IXUSR)
|
||||
try:
|
||||
code, out, timed = run(["batch", str(indir), "-o", str(ro)], timeout=180)
|
||||
written = len(list(ro.glob("*")))
|
||||
finally:
|
||||
ro.chmod(stat.S_IRWXU)
|
||||
ok = not timed and not (code == 0 and written == 0)
|
||||
res.add(
|
||||
"batch_readonly_outdir", "batch", ok, f"exit {code}, {written}/2 written (exit 0 + 0 files = data loss)", out
|
||||
)
|
||||
|
||||
|
||||
def check_batch_edges(res: Results, tmp: Path) -> None:
|
||||
print("\nbatch edge cases")
|
||||
empty_dir = tmp / "empty_dir"
|
||||
empty_dir.mkdir(exist_ok=True)
|
||||
graceful(res, "batch_on_empty_dir", "batch", ["batch", str(empty_dir), "-o", str(tmp / "bo")])
|
||||
|
||||
# A directory of junk: nothing decodable, must not crash the whole run.
|
||||
junk = tmp / "junk_dir"
|
||||
junk.mkdir(exist_ok=True)
|
||||
(junk / "a.png").write_bytes(b"\x89PNG\r\n\x1a\n" + os.urandom(500))
|
||||
(junk / "b.jpg").write_bytes(b"not an image at all")
|
||||
graceful(res, "batch_on_undecodable_dir", "batch", ["batch", str(junk), "-o", str(tmp / "jo")], timeout=180)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.parse_args()
|
||||
res = Results()
|
||||
with tempfile.TemporaryDirectory(prefix="raiw_robust_") as td:
|
||||
tmp = Path(td)
|
||||
print("building adversarial inputs...")
|
||||
inputs = make_inputs(tmp)
|
||||
print(f"built {len(inputs)} inputs\n")
|
||||
check_bad_inputs(res, tmp, inputs)
|
||||
check_bomb(res, tmp, inputs)
|
||||
check_filenames(res, tmp, inputs)
|
||||
check_output_paths(res, tmp, inputs)
|
||||
check_concurrency(res, tmp, inputs)
|
||||
check_batch_silent_loss(res, tmp, inputs)
|
||||
check_batch_edges(res, tmp)
|
||||
raise SystemExit(res.report())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Tier A1: diff today's `identify` against the verdicts recorded in the corpus sidecars.
|
||||
|
||||
`data/spaces/identify/<day>/<uid>.json` holds the verdict a past run produced for
|
||||
`data/spaces/originals/<day>/<uid>_src.<ext>`. Re-running identify and diffing turns the
|
||||
corpus into a ~39k-image behavioral regression suite that needs no new labelling.
|
||||
|
||||
WHAT A DIFF MEANS -- READ THIS BEFORE PANICKING
|
||||
The sidecars were written by OLDER versions, so an intended improvement shows up as a
|
||||
diff exactly like a regression does. The output is therefore CLASSIFIED, not pass/fail:
|
||||
|
||||
lost_ai verdict was AI, now is not <- the alarm; almost always a bug
|
||||
lost_signal a signal family stopped firing <- the alarm
|
||||
new_ai verdict was not AI, now is <- usually an improvement
|
||||
new_signal a signal family started firing <- usually an improvement
|
||||
platform attribution changed
|
||||
confidence confidence level changed
|
||||
unchanged identical on every compared axis
|
||||
|
||||
Only `lost_*` is a regression by default. Everything else needs a look before the
|
||||
baseline is moved.
|
||||
|
||||
WHY FAMILIES, NOT RAW STRINGS
|
||||
Watermark descriptions are human-readable prose and their WORDING has changed between
|
||||
versions ("C2PA Content Credentials (OpenAI)" vs "... (OpenAI, Truepic)"). Diffing raw
|
||||
strings would report every rewording as a lost+new signal pair and bury the real
|
||||
regressions. Signals are normalized to families (c2pa, synthid, visible_sparkle, ...)
|
||||
so the comparison tracks BEHAVIOR, not phrasing.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are user uploads: read-only, local analysis. Output goes to a gitignored
|
||||
path under data/spaces/ and records uids, never image content.
|
||||
|
||||
uv run python scripts/sidecar_regression.py --sample 500 # representative trial
|
||||
uv run python scripts/sidecar_regression.py # full corpus, resumable
|
||||
|
||||
Use `--sample` for a trial, never `--limit`: the sidecar list is sorted by day, so
|
||||
`--limit N` reads only the EARLIEST day and its result does not generalize (the first
|
||||
trial run of this script took 200 files that were all 2026-05-29).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
IDENTIFY_DIR = REPO / "data" / "spaces" / "identify"
|
||||
ORIGINALS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_sidecar_regression.jsonl"
|
||||
|
||||
# Map a watermark description to a stable behavior family. Order matters: the first
|
||||
# matching pattern wins, so put the specific tokens above the generic ones.
|
||||
_FAMILIES: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("synthid", ("synthid",)),
|
||||
("visible_sparkle", ("sparkle",)),
|
||||
("visible_doubao", ("豆包", "doubao")),
|
||||
("visible_jimeng", ("即梦", "jimeng")),
|
||||
("visible_samsung", ("galaxy ai", "generati dall")),
|
||||
("aigc_tc260", ("aigc", "tc260")),
|
||||
("iptc", ("iptc", "digitalsourcetype", "made with ai", "made-with-ai")),
|
||||
("c2pa", ("c2pa", "content credentials")),
|
||||
("trustmark", ("trustmark",)),
|
||||
("open_invisible", ("open invisible", "dwt", "stable diffusion xl")),
|
||||
("xai_signature", ("xai", "grok signature")),
|
||||
("exif_generator", ("exif", "software tag", "png text")),
|
||||
)
|
||||
|
||||
|
||||
def family_of(description: str) -> str:
|
||||
low = description.lower()
|
||||
for name, tokens in _FAMILIES:
|
||||
if any(t in low for t in tokens):
|
||||
return name
|
||||
return "other"
|
||||
|
||||
|
||||
def families(descriptions: list[str]) -> set[str]:
|
||||
return {family_of(d) for d in descriptions or []}
|
||||
|
||||
|
||||
def compare(sidecar: dict, report: object) -> dict:
|
||||
"""Classify the difference between a recorded verdict and a fresh one."""
|
||||
old_ai, new_ai = sidecar.get("is_ai_generated"), getattr(report, "is_ai_generated", None)
|
||||
old_fam = families(sidecar.get("watermarks") or [])
|
||||
new_fam = families(list(getattr(report, "watermarks", []) or []))
|
||||
old_plat, new_plat = sidecar.get("platform"), getattr(report, "platform", None)
|
||||
old_conf, new_conf = sidecar.get("confidence"), getattr(report, "confidence", None)
|
||||
|
||||
classes: list[str] = []
|
||||
# Treat only a real True->not-True transition as lost. None means "unknown", and the
|
||||
# library never asserts False, so None->None is not a change.
|
||||
if bool(old_ai) and not bool(new_ai):
|
||||
classes.append("lost_ai")
|
||||
if not bool(old_ai) and bool(new_ai):
|
||||
classes.append("new_ai")
|
||||
if old_fam - new_fam:
|
||||
classes.append("lost_signal")
|
||||
if new_fam - old_fam:
|
||||
classes.append("new_signal")
|
||||
if old_plat != new_plat:
|
||||
classes.append("platform")
|
||||
if old_conf != new_conf:
|
||||
classes.append("confidence")
|
||||
|
||||
return {
|
||||
"classes": classes or ["unchanged"],
|
||||
"old_ai": old_ai,
|
||||
"new_ai": new_ai,
|
||||
"lost_families": sorted(old_fam - new_fam),
|
||||
"new_families": sorted(new_fam - old_fam),
|
||||
"old_platform": old_plat,
|
||||
"new_platform": new_plat,
|
||||
"old_confidence": old_conf,
|
||||
"new_confidence": new_conf,
|
||||
}
|
||||
|
||||
|
||||
def _one(sidecar_path: str) -> dict:
|
||||
uid = os.path.basename(sidecar_path)[:-5]
|
||||
day = os.path.basename(os.path.dirname(sidecar_path))
|
||||
try:
|
||||
with open(sidecar_path, encoding="utf-8") as fh:
|
||||
sidecar = json.load(fh)
|
||||
except Exception as e: # a corrupt sidecar must not kill the sweep
|
||||
return {"uid": uid, "day": day, "status": "sidecar_unreadable", "error": str(e)[:200]}
|
||||
|
||||
src = sidecar.get("src") or ""
|
||||
image = ORIGINALS / day / src
|
||||
if not src or not image.exists():
|
||||
found = glob.glob(str(ORIGINALS / day / f"{uid}*"))
|
||||
if not found:
|
||||
return {"uid": uid, "day": day, "status": "image_missing"}
|
||||
image = Path(found[0])
|
||||
|
||||
try:
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
report = identify(image)
|
||||
except Exception as e: # record and continue; a crash IS a finding
|
||||
return {"uid": uid, "day": day, "status": "identify_raised", "error": f"{type(e).__name__}: {e}"[:300]}
|
||||
|
||||
return {"uid": uid, "day": day, "status": "ok", **compare(sidecar, report)}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--limit", type=int, default=0, help="only N sidecars (trial run)")
|
||||
ap.add_argument(
|
||||
"--sample",
|
||||
type=int,
|
||||
default=0,
|
||||
help="randomly sample N across ALL days -- use this for a trial, not --limit: "
|
||||
"the sidecar list is sorted, so --limit takes one day and is not representative",
|
||||
)
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true", help="ignore existing output and start over")
|
||||
a = ap.parse_args()
|
||||
|
||||
sidecars = sorted(glob.glob(str(IDENTIFY_DIR / "*" / "*.json")))
|
||||
if a.sample:
|
||||
random.Random(7).shuffle(sidecars) # noqa: S311 -- deterministic sampling, not crypto
|
||||
sidecars = sorted(sidecars[: a.sample])
|
||||
elif a.limit:
|
||||
sidecars = sidecars[: a.limit]
|
||||
if not sidecars:
|
||||
raise SystemExit(f"no sidecars under {IDENTIFY_DIR}")
|
||||
|
||||
# Resume: a full sweep is ~1 h, so never redo work an interrupted run already did.
|
||||
done: set[str] = set()
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink() # --restart must TRUNCATE; the file is reopened in append mode below
|
||||
if a.out.exists() and not a.restart:
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["uid"])
|
||||
except Exception: # noqa: S112 -- tolerate a torn last line from an interrupted run
|
||||
continue
|
||||
todo = [s for s in sidecars if os.path.basename(s)[:-5] not in done]
|
||||
print(f"sidecars {len(sidecars)} already done {len(done)} to do {len(todo)} workers {a.workers}")
|
||||
|
||||
counts: collections.Counter[str] = collections.Counter()
|
||||
a.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
|
||||
futures = {ex.submit(_one, s): s for s in todo}
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
rec = fut.result()
|
||||
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
if rec["status"] != "ok":
|
||||
counts[rec["status"]] += 1
|
||||
else:
|
||||
for c in rec["classes"]:
|
||||
counts[c] += 1
|
||||
if i % 500 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(todo)} " + " ".join(f"{k}={v}" for k, v in counts.most_common(6)), flush=True)
|
||||
|
||||
print(f"\n{'=' * 70}\nSIDECAR REGRESSION processed={len(todo)}\n{'=' * 70}")
|
||||
for k, v in counts.most_common():
|
||||
flag = " <-- REGRESSION" if k.startswith("lost_") or k in ("identify_raised",) else ""
|
||||
print(f" {k:22s} {v:7d}{flag}")
|
||||
print(f"\nfull records: {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,621 @@
|
||||
"""Release smoke matrix: exercise every CLI parameter CHOICE on REAL data.
|
||||
|
||||
Run before a release. This is not a unit-test substitute -- it drives the real CLI as a
|
||||
subprocess, so it covers argument parsing, exit-code semantics and the file-writing
|
||||
contracts that unit tests with fakes cannot.
|
||||
|
||||
WHAT IT COVERS AND WHY THAT SHAPE
|
||||
* Every choice-valued flag at least once (`--backend`, `--sensitivity`, `--mark`,
|
||||
`--mode`, `--inpaint-method`, ...). Full permutation is combinatorially large and
|
||||
mostly meaningless; dead branches and typos hide in the CHOICES, not in their
|
||||
products.
|
||||
* Every input FORMAT and shape edge case through the pixel paths -- PNG/JPEG/WebP/
|
||||
HEIC/AVIF, alpha, unicode names, misnamed extensions, truncated files, tiny and
|
||||
landscape frames. Real-world breakage lives here far more than in flag combos.
|
||||
* CONTRACTS, not just exit codes: `visible` must write NO output and exit 2 when no
|
||||
mark is found (re-serving the input reads as success -- the recurring "it didn't
|
||||
work" report); a no-op must be byte-identical; `metadata --remove` must actually
|
||||
strip; a JPEG strip must not touch the pixels. Note the pixel-lossless contract is
|
||||
the DEFAULT path's -- `--remove-all` deliberately re-encodes (see metadata.py).
|
||||
* The diffusion bodies under `--diffusion`, at `--max-resolution 512` so they fit MPS
|
||||
(~1 min/image on 32 GB unified memory). Not just exit codes: `invisible` must
|
||||
restore the input resolution and must NOT re-stamp SDXL's own open watermark.
|
||||
|
||||
WHAT IT DOES NOT COVER, DELIBERATELY AND LOUDLY
|
||||
* Without `--diffusion`, the model-running bodies are reported as SKIPPED with a
|
||||
reason, never as passes -- a green run that quietly skipped half the surface is
|
||||
worse than a red one.
|
||||
* Removal STRENGTH is never certified here. Whether a watermark is actually gone
|
||||
needs the per-vendor oracles (docs/known-limitations.md); these rows prove the
|
||||
paths run and keep their contracts, nothing more.
|
||||
* The re-embed row is gated on a POSITIVE CONTROL. imwatermark is positive-only and
|
||||
fails to round-trip on some pristine carriers, so "no watermark found" proves
|
||||
nothing there; the row degrades to a skip rather than a false pass.
|
||||
|
||||
uv run python scripts/smoke_matrix.py # corpus + fixtures
|
||||
uv run python scripts/smoke_matrix.py --quick # fixtures only, no corpus
|
||||
uv run python scripts/smoke_matrix.py --diffusion # + the SDXL model paths
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
SAMPLES = REPO / "data" / "samples"
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
EXIT_NO_VISIBLE_MARK = 2
|
||||
|
||||
|
||||
def _capture(args: list[str]) -> str:
|
||||
"""Run the CLI and return stdout (for the rows that inspect output, not exit code)."""
|
||||
exe = shutil.which("uv") or "uv"
|
||||
p = subprocess.run( # noqa: S603
|
||||
[exe, "run", "remove-ai-watermarks", *args], capture_output=True, text=True, cwd=REPO, check=False
|
||||
)
|
||||
return p.stdout
|
||||
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
status: str # pass | FAIL | skip
|
||||
detail: str = ""
|
||||
cmd: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Runner:
|
||||
tmp: Path
|
||||
results: list[Result] = field(default_factory=list)
|
||||
|
||||
def run(self, name: str, args: list[str], *, expect_exit: int | None = 0, timeout: int = 180) -> Result:
|
||||
exe = shutil.which("uv") or "uv"
|
||||
cmd = [exe, "run", "remove-ai-watermarks", *args]
|
||||
try:
|
||||
p = subprocess.run( # noqa: S603
|
||||
cmd, capture_output=True, text=True, timeout=timeout, cwd=REPO, check=False
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
r = Result(name, "FAIL", f"timeout after {timeout}s", " ".join(args))
|
||||
self.results.append(r)
|
||||
return r
|
||||
ok = expect_exit is None or p.returncode == expect_exit
|
||||
detail = "" if ok else f"exit {p.returncode} (want {expect_exit}): {(p.stderr or p.stdout).strip()[-200:]}"
|
||||
r = Result(name, "pass" if ok else "FAIL", detail, " ".join(args))
|
||||
self.results.append(r)
|
||||
return r
|
||||
|
||||
def check(self, name: str, ok: bool, detail: str = "") -> None:
|
||||
self.results.append(Result(name, "pass" if ok else "FAIL", "" if ok else detail))
|
||||
|
||||
def skip(self, name: str, why: str) -> None:
|
||||
self.results.append(Result(name, "skip", why))
|
||||
|
||||
|
||||
def corpus_pick(n: int, suffixes: tuple[str, ...]) -> list[Path]:
|
||||
"""Real uploads, chosen deterministically so a failure is reproducible."""
|
||||
if not CORPUS.exists():
|
||||
return []
|
||||
pool = [p for p in CORPUS.glob("*/*") if p.suffix.lower() in suffixes]
|
||||
random.Random(7).shuffle(pool) # noqa: S311 -- deterministic sampling, not cryptography
|
||||
return pool[:n]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--quick", action="store_true", help="fixtures only; skip the corpus rows")
|
||||
ap.add_argument(
|
||||
"--diffusion", action="store_true", help="also run the model-running paths (SDXL weights, ~1 min/image)"
|
||||
)
|
||||
a = ap.parse_args()
|
||||
|
||||
tmp = Path(tempfile.mkdtemp(prefix="raiw-smoke-"))
|
||||
r = Runner(tmp)
|
||||
doubao = SAMPLES / "doubao-1.png"
|
||||
chatgpt = SAMPLES / "chatgpt-1.png"
|
||||
grok = SAMPLES / "grok-1.jpg"
|
||||
|
||||
# ---- identify: every flag, and JSON must actually parse -------------------
|
||||
r.run("identify plain", ["identify", str(doubao)])
|
||||
r.run("identify --no-visible", ["identify", str(doubao), "--no-visible"])
|
||||
try:
|
||||
json.loads(_capture(["identify", str(doubao), "--json"]))
|
||||
r.check("identify --json parses", True)
|
||||
except Exception as e:
|
||||
r.check("identify --json parses", False, str(e))
|
||||
|
||||
# ---- metadata: check/remove, and the strip must be REAL ------------------
|
||||
r.run("metadata --check", ["metadata", str(doubao), "--check"])
|
||||
out = tmp / "meta.png"
|
||||
r.run("metadata --remove", ["metadata", str(doubao), "--remove", "-o", str(out)])
|
||||
if out.exists():
|
||||
try:
|
||||
rep = json.loads(_capture(["identify", str(out), "--json"]))
|
||||
# Scope the assert to METADATA. doubao-1 also carries a visible pixel mark,
|
||||
# which `metadata --remove` must NOT touch -- demanding an empty watermark
|
||||
# list would fail on correct behavior (and hide a real metadata leak behind
|
||||
# a permanently-red row).
|
||||
leftover = [s for s in rep.get("signals", []) if not s.get("name", "").startswith("visible_")]
|
||||
r.check(
|
||||
"metadata --remove strips every metadata signal",
|
||||
not leftover and not rep.get("ai_from_metadata"),
|
||||
f"still reports metadata signals {[s.get('name') for s in leftover]}",
|
||||
)
|
||||
r.check(
|
||||
"metadata --remove leaves the visible mark alone",
|
||||
any(s.get("name", "").startswith("visible_") for s in rep.get("signals", [])),
|
||||
"the pixel mark vanished -- a metadata strip must not touch pixels",
|
||||
)
|
||||
except Exception as e:
|
||||
r.check("metadata --remove strips every metadata signal", False, str(e))
|
||||
|
||||
# The pixel-lossless contract belongs to the DEFAULT path. `--remove-all`
|
||||
# (keep_standard=False) deliberately falls through to the full PIL re-encode
|
||||
# (metadata.py: the lossless marker walk preserves standard segments, so it cannot
|
||||
# serve a strip-everything caller), which is lossy for JPEG -- measured ~49 dB.
|
||||
# Asserting losslessness there tested a contract the code never made.
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
out2 = tmp / "meta2.jpg"
|
||||
r.run("metadata --remove (jpeg, default)", ["metadata", str(grok), "--remove", "-o", str(out2)])
|
||||
if out2.exists():
|
||||
a_, b_ = imread(str(grok)), imread(str(out2))
|
||||
same = a_ is not None and b_ is not None and a_.shape == b_.shape and bool(np.array_equal(a_, b_))
|
||||
r.check("jpeg metadata strip is pixel-lossless", same, "pixels changed -- the strip re-encoded")
|
||||
out3 = tmp / "meta3.jpg"
|
||||
r.run("metadata --remove --remove-all (jpeg)", ["metadata", str(grok), "--remove", "--remove-all", "-o", str(out3)])
|
||||
if out3.exists():
|
||||
rep3 = json.loads(_capture(["identify", str(out3), "--json"]))
|
||||
r.check(
|
||||
"--remove-all strips the AI metadata too",
|
||||
not [s for s in rep3.get("signals", []) if not s.get("name", "").startswith("visible_")],
|
||||
f"still reports {rep3.get('signals')}",
|
||||
)
|
||||
|
||||
# ---- visible: every --mark choice, every --backend, every --sensitivity ---
|
||||
for mark in ("auto", "gemini", "doubao", "jimeng", "samsung", "jimeng_pill"):
|
||||
# doubao-1 carries only the doubao mark, so every other --mark must exit 2
|
||||
want = 0 if mark in ("auto", "doubao") else EXIT_NO_VISIBLE_MARK
|
||||
r.run(
|
||||
f"visible --mark {mark}",
|
||||
["visible", str(doubao), "--mark", mark, "-o", str(tmp / f"m_{mark}.png")],
|
||||
expect_exit=want,
|
||||
)
|
||||
# Every backend CHOICE, not just the two that need no extra. migan/lama were
|
||||
# measured at library level (scripts/fill_quality.py) but had never been driven
|
||||
# through the CLI, which is a different code path (resolve_backend + the warning).
|
||||
from remove_ai_watermarks.region_eraser import lama_available, migan_available
|
||||
|
||||
for backend in ("auto", "cv2", "migan", "lama"):
|
||||
have = {"migan": migan_available(), "lama": lama_available()}.get(backend, True)
|
||||
if not have:
|
||||
r.skip(f"visible --backend {backend}", f"the `{backend}` extra is not installed")
|
||||
continue
|
||||
r.run(
|
||||
f"visible --backend {backend}",
|
||||
["visible", str(doubao), "--backend", backend, "-o", str(tmp / f"b_{backend}.png")],
|
||||
timeout=600,
|
||||
)
|
||||
for sens in ("auto", "strict"):
|
||||
r.run(
|
||||
f"visible --sensitivity {sens}",
|
||||
["visible", str(doubao), "--sensitivity", sens, "-o", str(tmp / f"s_{sens}.png")],
|
||||
)
|
||||
r.run("visible --keep-metadata", ["visible", str(doubao), "--keep-metadata", "-o", str(tmp / "keep.png")])
|
||||
r.run("visible --no-detect (forced)", ["visible", str(doubao), "--no-detect", "-o", str(tmp / "force.png")])
|
||||
for removed in ("assume-ai", "aggressive"):
|
||||
r.run(
|
||||
f"visible rejects --sensitivity {removed}",
|
||||
["visible", str(doubao), "--sensitivity", removed],
|
||||
expect_exit=2,
|
||||
)
|
||||
|
||||
# CONTRACT: no mark -> no output file, exit 2 (never re-serve the input as success)
|
||||
noout = tmp / "must_not_exist.png"
|
||||
r.run("visible no-mark exits 2", ["visible", str(chatgpt), "-o", str(noout)], expect_exit=EXIT_NO_VISIBLE_MARK)
|
||||
r.check("visible no-mark writes NO output", not noout.exists(), "wrote an output for an undetected mark")
|
||||
|
||||
# ---- erase: backends, methods, repeated regions, dilate ------------------
|
||||
for method in ("telea", "ns"):
|
||||
r.run(
|
||||
f"erase --inpaint-method {method}",
|
||||
[
|
||||
"erase",
|
||||
str(doubao),
|
||||
"--region",
|
||||
"10,10,60,30",
|
||||
"--inpaint-method",
|
||||
method,
|
||||
"-o",
|
||||
str(tmp / f"e_{method}.png"),
|
||||
],
|
||||
)
|
||||
r.run(
|
||||
"erase repeated --region",
|
||||
["erase", str(doubao), "--region", "10,10,40,20", "--region", "80,80,40,20", "-o", str(tmp / "e_multi.png")],
|
||||
)
|
||||
r.run(
|
||||
"erase --dilate",
|
||||
["erase", str(doubao), "--region", "10,10,40,20", "--dilate", "5", "-o", str(tmp / "e_dil.png")],
|
||||
)
|
||||
for backend in ("cv2", "migan", "lama"):
|
||||
have = {"migan": migan_available(), "lama": lama_available()}.get(backend, True)
|
||||
if not have:
|
||||
r.skip(f"erase --backend {backend}", f"the `{backend}` extra is not installed")
|
||||
continue
|
||||
r.run(
|
||||
f"erase --backend {backend}",
|
||||
[
|
||||
"erase",
|
||||
str(doubao),
|
||||
"--region",
|
||||
"10,10,40,20",
|
||||
"--backend",
|
||||
backend,
|
||||
"-o",
|
||||
str(tmp / f"e_{backend}.png"),
|
||||
],
|
||||
timeout=600,
|
||||
)
|
||||
r.run(
|
||||
"erase --dilate 0 (no dilation)",
|
||||
["erase", str(doubao), "--region", "10,10,40,20", "--dilate", "0", "-o", str(tmp / "e_d0.png")],
|
||||
)
|
||||
r.run("erase rejects a malformed --region", ["erase", str(doubao), "--region", "not,a,box"], expect_exit=2)
|
||||
|
||||
# ---- global + explicit-default flags that had never been exercised -------
|
||||
r.run("--verbose", ["--verbose", "identify", str(doubao)])
|
||||
r.run("--version", ["--version"])
|
||||
r.run("metadata --keep-standard (explicit)", ["metadata", str(doubao), "--check", "--keep-standard"])
|
||||
r.run(
|
||||
"visible --strip-metadata (explicit)",
|
||||
["visible", str(doubao), "--strip-metadata", "-o", str(tmp / "sm.png")],
|
||||
)
|
||||
r.run("visible --detect (explicit default)", ["visible", str(doubao), "--detect", "-o", str(tmp / "det.png")])
|
||||
|
||||
# ---- batch: the non-diffusion modes -------------------------------------
|
||||
bd = tmp / "batch_in"
|
||||
bd.mkdir()
|
||||
for f in (doubao, chatgpt):
|
||||
shutil.copy(f, bd / f.name)
|
||||
for mode in ("visible", "metadata"):
|
||||
r.run(
|
||||
f"batch --mode {mode}",
|
||||
["batch", str(bd), "--mode", mode, "-o", str(tmp / f"batch_{mode}")],
|
||||
expect_exit=None,
|
||||
)
|
||||
|
||||
# ---- diffusion: argument handling always; the model body under --diffusion ----
|
||||
r.run("invisible --help parses full knob set", ["invisible", "--help"])
|
||||
r.run("all --help parses full knob set", ["all", "--help"])
|
||||
_media_rows(r, tmp)
|
||||
if a.diffusion:
|
||||
_diffusion_rows(r, tmp, doubao)
|
||||
else:
|
||||
for name in ("invisible", "all", "batch --mode invisible"):
|
||||
r.skip(f"{name} (model-running body)", "pass --diffusion to exercise it (needs the SDXL weights)")
|
||||
|
||||
# ---- real-data formats and shapes ---------------------------------------
|
||||
if not a.quick:
|
||||
picks: list[tuple[str, Path]] = []
|
||||
for suf, label in ((".heic", "heic"), (".avif", "avif"), (".webp", "webp"), (".jpeg", "jpeg")):
|
||||
picks += [(label, p) for p in corpus_pick(2, (suf,))]
|
||||
picks += [("png", p) for p in corpus_pick(3, (".png",))]
|
||||
if not picks:
|
||||
r.skip("real-format rows", "corpus not present (data/spaces/originals)")
|
||||
for label, p in picks:
|
||||
r.run(f"identify real {label}", ["identify", str(p), "--json"])
|
||||
r.run(
|
||||
f"visible auto real {label}",
|
||||
["visible", str(p), "-o", str(tmp / f"r_{label}_{p.stem[:8]}.png")],
|
||||
expect_exit=None,
|
||||
) # 0 or 2 are both correct; a CRASH is not
|
||||
|
||||
# unicode + misnamed extension + truncated: the documented real-world traps
|
||||
if picks:
|
||||
src = picks[0][1]
|
||||
uni = tmp / "тест изображение 测试.png"
|
||||
shutil.copy(src, uni)
|
||||
r.run("unicode filename", ["identify", str(uni), "--json"])
|
||||
mis = tmp / "actually_png.jpg" # content PNG, extension JPEG
|
||||
shutil.copy(SAMPLES / "chatgpt-1.png", mis)
|
||||
r.run("misnamed extension", ["identify", str(mis), "--json"])
|
||||
trunc = tmp / "truncated.png"
|
||||
trunc.write_bytes((SAMPLES / "chatgpt-1.png").read_bytes()[:4096])
|
||||
r.run("truncated file does not crash", ["identify", str(trunc), "--json"], expect_exit=None)
|
||||
|
||||
# ---- report --------------------------------------------------------------
|
||||
bad = [x for x in r.results if x.status == "FAIL"]
|
||||
skipped = [x for x in r.results if x.status == "skip"]
|
||||
ok = [x for x in r.results if x.status == "pass"]
|
||||
print(f"\n{'=' * 74}\nSMOKE MATRIX pass={len(ok)} FAIL={len(bad)} skipped={len(skipped)}\n{'=' * 74}")
|
||||
for x in skipped:
|
||||
print(f" SKIP {x.name:46s} {x.detail}")
|
||||
for x in bad:
|
||||
print(f" FAIL {x.name:46s} {x.detail}")
|
||||
if x.cmd:
|
||||
print(f" cmd: {x.cmd}")
|
||||
if not bad:
|
||||
print(" no failures")
|
||||
print(f"\ntmp artifacts: {tmp}")
|
||||
raise SystemExit(1 if bad else 0)
|
||||
|
||||
|
||||
def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
|
||||
"""Every diffusion knob the matrix never touched.
|
||||
|
||||
Deliberately cheap (`--steps 4`, `--max-resolution 384`): these rows answer "is the
|
||||
knob accepted and does the run complete", NOT "is the output good". Quality per knob
|
||||
needs a per-knob oracle and most of them have none (`--humanize` has no oracle at
|
||||
all), so claiming more here would be dishonest.
|
||||
"""
|
||||
from remove_ai_watermarks import upscaler
|
||||
|
||||
# --steps 20 is the floor that WORKS, not an arbitrary choice: effective timesteps
|
||||
# are int(steps * strength), so at the default strength 0.15 anything below
|
||||
# --steps 7 rounds to ZERO and the pipeline dies inside torch. The first version of
|
||||
# these rows used --steps 4 and every single one failed with the same reshape error.
|
||||
fast = ["--max-resolution", "384", "--min-resolution", "0", "--steps", "20", "--force", "--seed", "0"]
|
||||
|
||||
def run(name: str, extra: list[str], *, tag: str, expect: int | None = 0) -> None:
|
||||
r.run(
|
||||
name,
|
||||
["invisible", str(img), "-o", str(tmp / f"k_{tag}.png"), *fast, *extra],
|
||||
expect_exit=expect,
|
||||
timeout=2400,
|
||||
)
|
||||
|
||||
run("--pipeline sdxl", ["--pipeline", "sdxl"], tag="sdxl")
|
||||
run("--pipeline controlnet", ["--pipeline", "controlnet"], tag="cnet")
|
||||
run("--strength", ["--strength", "0.2"], tag="strength")
|
||||
run("--guidance-scale", ["--guidance-scale", "5.0"], tag="gs")
|
||||
run("--controlnet-scale", ["--controlnet-scale", "0.5"], tag="cns")
|
||||
run("--humanize", ["--humanize", "0.3"], tag="hum")
|
||||
run("--unsharp", ["--unsharp", "0.5"], tag="uns")
|
||||
run("--no-adaptive-polish", ["--no-adaptive-polish"], tag="nap")
|
||||
run("--tile", ["--tile", "--tile-size", "256", "--tile-overlap", "64"], tag="tile")
|
||||
run("--device mps", ["--device", "mps"], tag="mps")
|
||||
run("--upscaler lanczos", ["--upscaler", "lanczos"], tag="lanczos")
|
||||
run("--auto (deprecated no-op)", ["--auto"], tag="auto")
|
||||
|
||||
if upscaler.is_available():
|
||||
run("--upscaler esrgan", ["--upscaler", "esrgan"], tag="esrgan")
|
||||
else:
|
||||
r.skip("--upscaler esrgan", "the `esrgan` extra is not installed")
|
||||
|
||||
# CPU is correctness-relevant (it is the documented MPS-OOM fallback) but slow, so
|
||||
# it gets the smallest possible run rather than being skipped.
|
||||
r.run(
|
||||
"--device cpu",
|
||||
[
|
||||
"invisible",
|
||||
str(img),
|
||||
"-o",
|
||||
str(tmp / "k_cpu.png"),
|
||||
"--device",
|
||||
"cpu",
|
||||
"--max-resolution",
|
||||
"256",
|
||||
"--min-resolution",
|
||||
"0",
|
||||
"--steps",
|
||||
"20",
|
||||
"--force",
|
||||
"--seed",
|
||||
"0",
|
||||
],
|
||||
timeout=3600,
|
||||
)
|
||||
|
||||
# qwen is CUDA-class by design (bf16 MMDiT, no MPS fallback). On this host the
|
||||
# honest outcome is a CLEAN failure, not a crash -- assert it does not hang or
|
||||
# dump a traceback at the user.
|
||||
try:
|
||||
import torch
|
||||
|
||||
cuda = bool(torch.cuda.is_available())
|
||||
except Exception:
|
||||
cuda = False
|
||||
if cuda:
|
||||
run("--pipeline qwen", ["--pipeline", "qwen"], tag="qwen")
|
||||
else:
|
||||
r.skip("--pipeline qwen", "CUDA-class pipeline; no CUDA device on this host")
|
||||
|
||||
# --model and --hf-token are deliberately not exercised: one would download a second
|
||||
# multi-GB checkpoint, the other needs a real credential. Skipped loudly, not passed.
|
||||
r.skip("--model", "would download a second multi-GB checkpoint")
|
||||
r.skip("--hf-token", "needs a real credential; cannot be exercised meaningfully here")
|
||||
|
||||
# CONTRACT, not just execution: the same seed must reproduce the same pixels.
|
||||
a_out, b_out = tmp / "seed_a.png", tmp / "seed_b.png"
|
||||
for out in (a_out, b_out):
|
||||
r.run(
|
||||
f"seed determinism run ({out.name})",
|
||||
["invisible", str(img), "-o", str(out), *fast],
|
||||
timeout=2400,
|
||||
)
|
||||
if a_out.exists() and b_out.exists():
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
x, y = imread(str(a_out)), imread(str(b_out))
|
||||
r.check(
|
||||
"same --seed reproduces identical pixels",
|
||||
x is not None and y is not None and x.shape == y.shape and bool(np.array_equal(x, y)),
|
||||
"two runs with the same seed differed",
|
||||
)
|
||||
|
||||
|
||||
def _media_rows(r: Runner, tmp: Path) -> None:
|
||||
"""Audio/video metadata strip via ffmpeg -- a supported path with no corpus coverage.
|
||||
|
||||
The corpus is images only, so the media is synthesized here with ffmpeg rather than
|
||||
left untested.
|
||||
"""
|
||||
if not shutil.which("ffmpeg"):
|
||||
r.skip("audio/video metadata strip", "ffmpeg not on PATH")
|
||||
return
|
||||
for name, gen in (
|
||||
("mp4", ["-f", "lavfi", "-i", "testsrc=duration=1:size=128x128:rate=8", "-pix_fmt", "yuv420p"]),
|
||||
("mp3", ["-f", "lavfi", "-i", "sine=frequency=440:duration=1"]),
|
||||
):
|
||||
src = tmp / f"media.{name}"
|
||||
ff = shutil.which("ffmpeg") or "ffmpeg"
|
||||
made = subprocess.run( # noqa: S603
|
||||
[ff, "-y", *gen, "-metadata", "comment=Made with AI", str(src)],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if made.returncode != 0 or not src.exists():
|
||||
r.skip(f"{name} metadata strip", "ffmpeg could not synthesize the fixture")
|
||||
continue
|
||||
out = tmp / f"media_clean.{name}"
|
||||
r.run(f"{name} metadata strip runs", ["metadata", str(src), "--remove", "-o", str(out)], expect_exit=None)
|
||||
if out.exists():
|
||||
r.check(f"{name} strip produced a non-empty file", out.stat().st_size > 0, "empty output")
|
||||
|
||||
|
||||
def _sdxl_watermark_bits(img: object) -> float:
|
||||
"""Bits of the open SDXL DWT-DCT watermark recovered from `img` (128 = perfect)."""
|
||||
import numpy as np
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
truth = np.frombuffer(b"StableDiffusionV1"[:16], dtype=np.uint8)
|
||||
rec = WatermarkDecoder("bytes", 128).decode(img, "dwtDct")
|
||||
return float(128 - np.unpackbits(truth ^ np.frombuffer(bytes(rec), dtype=np.uint8)).sum())
|
||||
|
||||
|
||||
def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
|
||||
"""Exercise the model-running bodies at a reduced resolution (MPS-friendly).
|
||||
|
||||
Bounded with `--max-resolution 512` and a fixed seed: the point is that the paths
|
||||
RUN and keep their contracts, not to certify removal strength (that needs the
|
||||
per-vendor oracles, see docs/known-limitations.md).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
small = ["--max-resolution", "512", "--seed", "0"]
|
||||
|
||||
# `invisible` must restore the original resolution and NOT re-stamp SDXL's own
|
||||
# open watermark (add_watermarker=False; a remover that re-marks its output is the
|
||||
# regression this row exists for).
|
||||
#
|
||||
# The carrier matters: imwatermark is positive-only and fails to round-trip on some
|
||||
# pristine images, so an "absent" verdict on a fragile carrier proves NOTHING. mj-1
|
||||
# is used because it round-trips at 128/128; the control is re-checked on the actual
|
||||
# OUTPUT and the row degrades to a skip rather than a false pass if it goes fragile.
|
||||
mj = SAMPLES / "mj-1.png"
|
||||
inv = tmp / "inv_mj.png"
|
||||
res = r.run("invisible runs (mps, 512px)", ["invisible", str(mj), "-o", str(inv), "--force", *small], timeout=1800)
|
||||
if res.status == "pass" and inv.exists():
|
||||
src_img, out_img = imread(str(mj)), imread(str(inv))
|
||||
r.check(
|
||||
"invisible restores the input resolution",
|
||||
src_img is not None and out_img is not None and src_img.shape == out_img.shape,
|
||||
f"{None if src_img is None else src_img.shape} -> {None if out_img is None else out_img.shape}",
|
||||
)
|
||||
try:
|
||||
from imwatermark import WatermarkEncoder
|
||||
|
||||
enc = WatermarkEncoder()
|
||||
enc.set_watermark("bytes", b"StableDiffusionV1"[:16])
|
||||
control = _sdxl_watermark_bits(enc.encode(np.array(out_img).copy(), "dwtDct"))
|
||||
if control < 118:
|
||||
r.skip("invisible does not re-embed an SDXL watermark", f"carrier fragile (control {control:.0f}/128)")
|
||||
else:
|
||||
bits = _sdxl_watermark_bits(out_img)
|
||||
r.check(
|
||||
"invisible does not re-embed an SDXL watermark",
|
||||
bits < 118,
|
||||
f"re-embedded: {bits:.0f}/128 recovered (control {control:.0f}/128)",
|
||||
)
|
||||
except ImportError:
|
||||
r.skip("invisible does not re-embed an SDXL watermark", "imwatermark absent (extra `detect`)")
|
||||
|
||||
# `all`: every stage must land -- the visible mark AND the metadata both gone.
|
||||
allout = tmp / "all_out.png"
|
||||
res = r.run("all runs (mps, 512px)", ["all", str(doubao), "-o", str(allout), *small], timeout=1800)
|
||||
if res.status == "pass" and allout.exists():
|
||||
rep = json.loads(_capture(["identify", str(allout), "--json"]))
|
||||
r.check(
|
||||
"all clears visible + metadata in one pass",
|
||||
not rep.get("signals"),
|
||||
f"still reports {[s.get('name') for s in rep.get('signals', [])]}",
|
||||
)
|
||||
|
||||
_knob_rows(r, tmp, mj)
|
||||
|
||||
# `batch --mode invisible`: every input must produce an output (a silent short
|
||||
# write is the failure this row guards).
|
||||
bd = tmp / "batch_inv"
|
||||
bd.mkdir(exist_ok=True)
|
||||
for f in (SAMPLES / "chatgpt-2.png", mj):
|
||||
shutil.copy(f, bd / f.name)
|
||||
bout = tmp / "batch_inv_out"
|
||||
res = r.run(
|
||||
"batch --mode invisible runs (mps, 512px)",
|
||||
["batch", str(bd), "--mode", "invisible", "-o", str(bout), *small],
|
||||
expect_exit=None,
|
||||
timeout=3600,
|
||||
)
|
||||
if res.status == "pass":
|
||||
produced = len(list(bout.glob("*"))) if bout.exists() else 0
|
||||
r.check("batch invisible writes one output per input", produced == 2, f"{produced} outputs for 2 inputs")
|
||||
|
||||
# `batch --mode all` -- the only --mode value the matrix never ran.
|
||||
aout = tmp / "batch_all_out"
|
||||
r.run(
|
||||
"batch --mode all runs (mps, 512px)",
|
||||
["batch", str(bd), "--mode", "all", "-o", str(aout), *small],
|
||||
expect_exit=None,
|
||||
timeout=3600,
|
||||
)
|
||||
|
||||
# The AI-enhanced composite path: regenerate ONLY a region and feather it back,
|
||||
# leaving everything outside the box pixel-exact. Library-level -- the CLI has no
|
||||
# flag for it, so it would otherwise never be exercised on real data.
|
||||
try:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
src = imread(str(mj))
|
||||
h, w = src.shape[:2]
|
||||
box = (w // 4, h // 4, w // 4, h // 4)
|
||||
rem = WatermarkRemover(pipeline="controlnet")
|
||||
rout = tmp / "region_composite.png"
|
||||
rem.remove_watermark(mj, rout, strength=0.15, num_inference_steps=20, seed=0, region=box)
|
||||
got = imread(str(rout))
|
||||
if got is None or got.shape != src.shape:
|
||||
r.check("region composite keeps the frame outside the box", False, "shape changed or unreadable")
|
||||
else:
|
||||
mask = np.ones(src.shape[:2], dtype=bool)
|
||||
x, y, bw, bh = box
|
||||
# Outside the box PLUS the feather margin must be untouched.
|
||||
pad = 96
|
||||
mask[max(0, y - pad) : y + bh + pad, max(0, x - pad) : x + bw + pad] = False
|
||||
r.check(
|
||||
"region composite keeps the frame outside the box",
|
||||
bool(np.array_equal(src[mask], got[mask])),
|
||||
"pixels changed outside the regenerated region",
|
||||
)
|
||||
except Exception as e:
|
||||
r.skip("region composite (remove_watermark(region=...))", f"{type(e).__name__}: {e}"[:120])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Partition China-AIGC carriers into VENDOR COHORTS by their TC260 producer code.
|
||||
|
||||
THE PROBLEM THIS SOLVES
|
||||
Coverage of uncovered vendors is the largest remaining detection lever
|
||||
(`docs/verification-plan.md`, "Where detection work should go next"), and it is
|
||||
blocked on EVIDENCE: nothing may be registered off a single frame, and the
|
||||
previous session found exactly one confirmed positive each for `千问` and `百度`.
|
||||
Harvesting more by PIXELS is circular -- a detector is what we are trying to
|
||||
build -- and the generic shared-tail probe is too weak to label with (0.407 on a
|
||||
bold positive against a clean p99 of 0.298; see `cjk_tail_probe.py`).
|
||||
|
||||
THE KEY
|
||||
The TC260 label is not anonymous. Its `ContentProducer` field carries the
|
||||
producer's Chinese Unified Social Credit Code (USCC), e.g.
|
||||
``001191110102MACQD9K64010000`` -> USCC ``91110102MACQD9K640``, which names a
|
||||
specific legal entity. So the metadata partitions carriers into per-ENTITY
|
||||
cohorts without looking at a single pixel. A cohort is a LABEL: once one frame
|
||||
in it is eyeballed, every frame in it is a labelled example of that vendor's
|
||||
mark. That is what turns "one confirmed positive" into "30+ per vendor".
|
||||
|
||||
CLAUDE.md's "the generic TC260 label names no specific vendor" is about the
|
||||
label MARKER (the bare presence of `TC260:AIGC`), which indeed names nobody.
|
||||
The producer FIELD inside the block is a different thing and does name one.
|
||||
|
||||
Caveat kept in view: the code names the SIGNING ENTITY, which is not always the
|
||||
consumer brand (an aggregator or a cloud host signs for several apps, and one
|
||||
vendor can hold several codes). So a cohort is a strong grouping key and a
|
||||
hypothesis about the brand -- the brand itself is settled by reading the crop,
|
||||
which is what `--sheets` is for.
|
||||
|
||||
WHAT IT COSTS
|
||||
Metadata only. The expensive pixel pass is NOT re-run: which detectors fired is
|
||||
joined from `_visible_positives.jsonl` (the completed full-corpus artifact), per
|
||||
the standing rule against relaunching finished sweeps to re-check them.
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are real user uploads: read-only, local analysis, gitignored
|
||||
output. Contact sheets stay under `data/spaces/`; nothing here is committed.
|
||||
|
||||
uv run python scripts/vendor_cohort_harvest.py
|
||||
uv run python scripts/vendor_cohort_harvest.py --report-only --sheets 12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_vendor_cohorts.jsonl"
|
||||
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_cohort_sheets"
|
||||
|
||||
# A producer code is `001` + `1` + USCC(18) + a 5-digit app/product suffix, so two
|
||||
# codes sharing the USCC are the same legal entity registering different products.
|
||||
# Slicing is defensive: anything not matching the layout is grouped by its raw value.
|
||||
_USCC_START, _USCC_END = 4, 22
|
||||
|
||||
|
||||
def uscc_of(code: str) -> str:
|
||||
"""The 18-char Unified Social Credit Code embedded in a TC260 producer code."""
|
||||
if len(code) >= _USCC_END and code[:3] == "001":
|
||||
return code[_USCC_START:_USCC_END]
|
||||
return code
|
||||
|
||||
|
||||
def _one(path_str: str) -> dict[str, Any] | None:
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
try:
|
||||
label = aigc_label(Path(path_str))
|
||||
except Exception:
|
||||
return None
|
||||
if not label:
|
||||
return None
|
||||
producer = str(label.get("ContentProducer") or "")
|
||||
return {
|
||||
"path": path_str,
|
||||
"producer": producer,
|
||||
"uscc": uscc_of(producer),
|
||||
"propagator": str(label.get("ContentPropagator") or ""),
|
||||
"service_provider": str(label.get("ServiceProvider") or ""),
|
||||
}
|
||||
|
||||
|
||||
def load_fired() -> dict[str, list[str]]:
|
||||
"""path -> detector keys that fired, from the completed full-corpus artifact."""
|
||||
if not FIRED.exists():
|
||||
print(f"WARNING: {FIRED.name} missing; cohorts will show no detector state")
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for line in FIRED.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
out[rec["path"]] = rec.get("keys") or []
|
||||
return out
|
||||
|
||||
|
||||
def scan(limit: int, workers: int, out_path: Path) -> list[dict[str, Any]]:
|
||||
pool = sorted(glob.glob(str(CORPUS / "*" / "*")))
|
||||
if limit:
|
||||
pool = pool[:limit]
|
||||
print(f"scanning {len(pool)} corpus files for TC260 labels workers={workers}", flush=True)
|
||||
rows: list[dict[str, Any]] = []
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out_path, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
futures = [ex.submit(_one, p) for p in pool]
|
||||
for i, fut in enumerate(as_completed(futures), 1):
|
||||
try:
|
||||
rec = fut.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the scan
|
||||
continue
|
||||
if rec is not None:
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
rows.append(rec)
|
||||
if i % 5000 == 0:
|
||||
fh.flush()
|
||||
print(f" {i}/{len(pool)} carriers={len(rows)}", flush=True)
|
||||
return rows
|
||||
|
||||
|
||||
def report(rows: list[dict[str, Any]], fired: dict[str, list[str]], min_size: int) -> None:
|
||||
by_uscc: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
by_uscc.setdefault(r["uscc"], []).append(r)
|
||||
|
||||
print(f"\n{'=' * 92}\nVENDOR COHORTS ({len(rows)} TC260 carriers, {len(by_uscc)} distinct entities)\n{'=' * 92}")
|
||||
print("\n`fires` = share of the cohort where SOME registered detector fires.")
|
||||
print("A large cohort with a low fire rate is an uncovered vendor -- the harvest target.\n")
|
||||
print(f"{'entity (USCC)':22s} {'n':>6s} {'fires':>7s} {'detectors seen':38s} {'products':>8s}")
|
||||
print("-" * 92)
|
||||
|
||||
cohorts = sorted(by_uscc.items(), key=lambda kv: -len(kv[1]))
|
||||
for uscc, members in cohorts:
|
||||
if len(members) < min_size:
|
||||
continue
|
||||
keys: Counter[str] = Counter()
|
||||
hit = 0
|
||||
for m in members:
|
||||
ks = fired.get(m["path"], [])
|
||||
if ks:
|
||||
hit += 1
|
||||
keys.update(ks)
|
||||
seen = ", ".join(f"{k}:{c}" for k, c in keys.most_common(4)) or "-- none --"
|
||||
products = len({m["producer"] for m in members})
|
||||
print(f"{uscc:22s} {len(members):6d} {100 * hit / len(members):6.1f}% {seen:38s} {products:8d}")
|
||||
|
||||
small = sum(1 for _, m in cohorts if len(m) < min_size)
|
||||
if small:
|
||||
print(f"\n({small} cohorts below --min-size {min_size} not shown)")
|
||||
|
||||
|
||||
def _bands(img: Any, width: int, band: int) -> list[Any]:
|
||||
"""Full-width top and bottom bands, scaled to a readable common width.
|
||||
|
||||
An unregistered vendor's placement is unknown, so cropping the bottom-RIGHT
|
||||
corner (where the marks we already cover happen to sit) would beg the
|
||||
question. Full-width bands catch any horizontal position, and the two bands
|
||||
together cover every corner the standard's implementers actually use.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
h = img.shape[0]
|
||||
strip = max(24, int(h * band))
|
||||
out = []
|
||||
for piece in (img[:strip], img[h - strip :]):
|
||||
scale = width / max(1, piece.shape[1])
|
||||
out.append(cv2.resize(piece, (width, max(12, int(piece.shape[0] * scale))), interpolation=cv2.INTER_AREA))
|
||||
return out
|
||||
|
||||
|
||||
def sheets(rows: list[dict[str, Any]], fired: dict[str, list[str]], per: int, min_size: int) -> None:
|
||||
"""Top/bottom bands per uncovered cohort, so the vendor and mark can be read off."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
by_uscc: dict[str, list[dict[str, Any]]] = {}
|
||||
for r in rows:
|
||||
by_uscc.setdefault(r["uscc"], []).append(r)
|
||||
|
||||
SHEET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"\nwriting contact sheets -> {SHEET_DIR}")
|
||||
|
||||
for uscc, members in sorted(by_uscc.items(), key=lambda kv: -len(kv[1])):
|
||||
if len(members) < min_size:
|
||||
continue
|
||||
quiet = [m for m in members if not fired.get(m["path"])]
|
||||
if not quiet:
|
||||
continue
|
||||
width = 900
|
||||
tiles: list[Any] = []
|
||||
for m in quiet[:per]:
|
||||
img = imread(m["path"])
|
||||
if img is None:
|
||||
continue
|
||||
for b in _bands(img, width, 0.10):
|
||||
tiles.append(b)
|
||||
tiles.append(np.full((3, width, 3), 60, np.uint8))
|
||||
if tiles:
|
||||
dest = SHEET_DIR / f"{uscc}_n{len(members)}_quiet{len(quiet)}.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f" {dest.name} ({len(tiles) // 4} frames)")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--limit", type=int, default=0, help="cap files scanned (0 = whole corpus)")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--report-only", action="store_true")
|
||||
ap.add_argument("--min-size", type=int, default=5, help="hide cohorts smaller than this")
|
||||
ap.add_argument("--sheets", type=int, default=0, help="crops per cohort contact sheet")
|
||||
a = ap.parse_args()
|
||||
|
||||
if a.report_only:
|
||||
rows = [json.loads(x) for x in a.out.read_text(encoding="utf-8").splitlines() if x.strip()]
|
||||
else:
|
||||
rows = scan(a.limit, a.workers, a.out)
|
||||
|
||||
fired = load_fired()
|
||||
report(rows, fired, a.min_size)
|
||||
if a.sheets:
|
||||
sheets(rows, fired, a.sheets, a.min_size)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,620 @@
|
||||
"""Calibrate a candidate text-mark detector for an UNCOVERED vendor, on real positives.
|
||||
|
||||
WHERE THE POSITIVES COME FROM
|
||||
`vendor_cohort_harvest.py` partitions China-AIGC carriers into per-entity cohorts by
|
||||
their TC260 producer code, so a cohort is a vendor LABEL that owes nothing to any
|
||||
pixel detector. That is what makes this calibration non-circular: the previous attempt
|
||||
(`render_vendor_silhouettes.py`, 2026-07-18) died at n=1 because the only way it knew
|
||||
to find 千问 frames was to eyeball the misses of a detector that cannot see them.
|
||||
|
||||
A cohort is NOT automatically a set of visible-mark positives: TC260 provenance is
|
||||
metadata, and a vendor may label a frame without stamping it. So the cohort is the
|
||||
CANDIDATE pool, and mark presence is settled by eye -- `--sheets` writes the corner
|
||||
crops sorted by score, which makes that pass cheap and makes the separation (or its
|
||||
absence) visible directly.
|
||||
|
||||
NEGATIVES
|
||||
The 432 frames hand-labelled `present: []` in the 2026-07-18 round -- already-adjudicated
|
||||
no-visible-mark images, so the false-fire arm rests on human labels rather than on the
|
||||
absence of a detection.
|
||||
|
||||
THE TRAPS, INHERITED FROM THE 2026-07-18 MEASUREMENT
|
||||
Both are encoded below rather than left to the caller:
|
||||
* size the template with `alpha_height_frac`, NOT the silhouette's own aspect ratio
|
||||
(the latter inflated the clean p99 from 0.30 to 0.58 and made comparison meaningless)
|
||||
* keep the ladder at the shipped 3 rungs -- a wide sweep hands clean corners extra
|
||||
chances to match, which flatters the positives and the negatives alike
|
||||
|
||||
DATA SAFETY
|
||||
Corpus images are real user uploads: read-only, local, gitignored output. The template
|
||||
is font-rendered synthetic (`render_vendor_silhouettes.py`), never cut from an upload.
|
||||
|
||||
uv run python scripts/vendor_mark_calibrate.py --cohort 91440101MA9Y9T4H7A \\
|
||||
--asset qwen_alpha.png --sheets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
COHORTS = REPO / "data" / "spaces" / "_vendor_cohorts.jsonl"
|
||||
SHEET_DIR = REPO / "data" / "spaces" / "_vendor_calib_sheets"
|
||||
OUT = REPO / "data" / "spaces" / "_vendor_calibration.jsonl"
|
||||
|
||||
|
||||
def build_config(
|
||||
asset: str,
|
||||
name: str,
|
||||
scale_basis: str = "short",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""A candidate config: doubao's tuned geometry with this vendor's silhouette.
|
||||
|
||||
Transferring doubao's numbers is justified by LAYOUT, not by hope: every one of these
|
||||
marks is the same GB 45438-2025 house style -- a 2-glyph vendor prefix, then the
|
||||
mandated `AI生成` tail, set in a semibold CJK sans in the bottom-right corner. So
|
||||
`豆包AI生成` and `千问AI生成` are the same 6 glyph cells at the same scale, and the
|
||||
width/height fractions carry over. The NCC gate does NOT carry over and is what this
|
||||
script exists to measure. Any tuned value can be overridden with what
|
||||
`--fit-geometry` measured -- inheriting the locate box blindly clipped the big-mode
|
||||
qwen mark, which is exactly the trap this tool exists to avoid.
|
||||
"""
|
||||
import dataclasses
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG
|
||||
|
||||
return dataclasses.replace(
|
||||
TextMarkConfig(**dataclasses.asdict(_CONFIG)),
|
||||
name=name,
|
||||
asset_name=asset,
|
||||
scale_basis=scale_basis,
|
||||
**(overrides or {}),
|
||||
)
|
||||
|
||||
|
||||
ScoreArgs = tuple[str, str, str, str, "dict[str, Any]"]
|
||||
|
||||
|
||||
def _score(args: ScoreArgs) -> dict[str, Any] | None:
|
||||
path_str, asset, name, basis, overrides = args
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 64:
|
||||
return None
|
||||
eng = TextMarkEngine(build_config(asset, name, basis, overrides))
|
||||
loc = eng.locate(img)
|
||||
try:
|
||||
score, box = eng._tophat_best(img, loc)
|
||||
except Exception:
|
||||
return None
|
||||
return {"path": path_str, "score": round(float(score), 4), "box": box}
|
||||
|
||||
|
||||
NEGATIVES = REPO / "data" / "spaces" / "_research_20260718_textmark_relaxation" / "groundtruth.jsonl"
|
||||
|
||||
|
||||
def load_sets(cohort: str) -> tuple[list[str], list[str]]:
|
||||
pos = [
|
||||
json.loads(x)["path"]
|
||||
for x in COHORTS.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip() and json.loads(x)["uscc"] == cohort
|
||||
]
|
||||
# The 2026-07-18 labels are in the vocabulary of the REGISTERED marks only
|
||||
# (gemini/doubao/jimeng/jimeng_pill): `present: []` means "no registered mark", NOT
|
||||
# "no mark at all" -- 146 of the 432 sit in a TC260 cohort, and qwen-cohort frames
|
||||
# visibly carrying 千问AI生成 are labelled `present: []` there (measured 2026-07-21:
|
||||
# they made up the clean arm's whole top tail, clean p99 0.37 -> 0.69). A gate read
|
||||
# off that arm is meaningless, so the clean arm excludes every frame in ANY TC260
|
||||
# cohort -- cohort membership is the cheap proxy for "may carry a CJK AI label".
|
||||
in_any_cohort = {
|
||||
str(Path(json.loads(x)["path"]).resolve())
|
||||
for x in COHORTS.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip()
|
||||
}
|
||||
neg: list[str] = []
|
||||
dropped = 0
|
||||
for line in NEGATIVES.read_text(encoding="utf-8").splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
rec = json.loads(line)
|
||||
if rec.get("present"):
|
||||
continue
|
||||
p = str((REPO / rec["path"]).resolve())
|
||||
if p in in_any_cohort:
|
||||
dropped += 1
|
||||
continue
|
||||
neg.append(p)
|
||||
if dropped:
|
||||
print(f"clean arm: dropped {dropped} negatives that sit in a TC260 cohort (contamination guard)")
|
||||
return pos, neg
|
||||
|
||||
|
||||
def run(
|
||||
paths: list[str],
|
||||
asset: str,
|
||||
name: str,
|
||||
workers: int,
|
||||
basis: str = "short",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
futs = [ex.submit(_score, (p, asset, name, basis, overrides or {})) for p in paths]
|
||||
for f in as_completed(futs):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the sweep
|
||||
continue
|
||||
if r:
|
||||
out.append(r)
|
||||
return out
|
||||
|
||||
|
||||
def report(pos: list[dict[str, Any]], neg: list[dict[str, Any]], name: str) -> None:
|
||||
import numpy as np
|
||||
|
||||
p = np.array([r["score"] for r in pos])
|
||||
n = np.array([r["score"] for r in neg])
|
||||
print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labelled clean\n{'=' * 78}")
|
||||
print(f"\n{'arm':10s} {'n':>5s} {'p10':>7s} {'p50':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
|
||||
for label, arr in (("cohort", p), ("clean", n)):
|
||||
if not len(arr):
|
||||
continue
|
||||
qs = [np.percentile(arr, q) for q in (10, 50, 90, 95, 99)]
|
||||
print(f"{label:10s} {len(arr):5d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {arr.max():7.3f}")
|
||||
|
||||
if len(p) and len(n):
|
||||
print("\n\nOPERATING POINTS -- gate set on the CLEAN arm")
|
||||
print("`cohort fire` is an UPPER BOUND on recall: the cohort also holds")
|
||||
print("metadata-only frames that carry no visible mark to find.\n")
|
||||
print(f"{'gate':>7s} {'clean fire':>12s} {'cohort fire':>13s} {'cohort n':>10s}")
|
||||
for q in (90, 95, 99, 99.5, 100):
|
||||
t = float(np.percentile(n, q))
|
||||
cf, pf = 100 * float((n >= t).mean()), 100 * float((p >= t).mean())
|
||||
print(f"{t:7.3f} {cf:11.2f}% {pf:12.1f}% {int((p >= t).sum()):10d}")
|
||||
|
||||
|
||||
def sheets(pos: list[dict[str, Any]], name: str, per_sheet: int = 24) -> None:
|
||||
"""Corner crops sorted by score, so mark presence and the gate are read in one pass."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
eng = TextMarkEngine(build_config("doubao_alpha.png", "roi"))
|
||||
SHEET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ranked = sorted(pos, key=lambda r: -r["score"])
|
||||
width = 660
|
||||
for start in range(0, len(ranked), per_sheet):
|
||||
chunk = ranked[start : start + per_sheet]
|
||||
tiles: list[Any] = []
|
||||
for rank, r in enumerate(chunk, start + 1):
|
||||
img = imread(r["path"])
|
||||
if img is None:
|
||||
continue
|
||||
loc = eng.locate(img)
|
||||
crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
|
||||
if not crop.size:
|
||||
continue
|
||||
tile = cv2.resize(crop, (width, 96), interpolation=cv2.INTER_AREA)
|
||||
cv2.rectangle(tile, (0, 0), (118, 22), (0, 0, 0), -1)
|
||||
cv2.putText(tile, f"#{rank} {r['score']:.3f}", (4, 16), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
tiles.append(tile)
|
||||
tiles.append(np.full((2, width, 3), 70, np.uint8))
|
||||
if tiles:
|
||||
dest = SHEET_DIR / f"{name}_ranked_{start // per_sheet:02d}.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f" {dest.name} (#{start + 1}..#{start + len(chunk)})")
|
||||
|
||||
|
||||
# Wide and dense, for the geometry FIT only. An unregistered vendor's glyph size is
|
||||
# genuinely unknown, which is the one case a dense ladder earns its cost -- but it also
|
||||
# hands clean corners extra chances to match, so it must never set a gate.
|
||||
_FIT_SCALES = tuple(round(0.4 * (1.03**i), 4) for i in range(80)) # 0.40 .. ~4.1
|
||||
# The ladder the product actually ships (`_text_mark_engine._tophat_best`).
|
||||
_SHIPPED_LADDER = (0.8, 1.0, 1.25)
|
||||
|
||||
|
||||
def _fit_one(args: tuple[str, str, dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Best match over the WIDE ladder, reported as a mark width in pixels.
|
||||
|
||||
Also measures the template ASPECT at the winning width: the mark's true height is
|
||||
fitted by sweeping gh at the winning gw and reading the argmax, because
|
||||
`alpha_height_frac` must be measured, not taken from the silhouette's own aspect
|
||||
(that inflated the clean p99 from 0.30 to 0.58 on the 2026-07-18 attempt) and not
|
||||
inherited from doubao.
|
||||
"""
|
||||
path_str, asset, overrides = args
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
cfg = build_config(asset, "fit", "width", overrides)
|
||||
eng = TextMarkEngine(cfg)
|
||||
img = imread(path_str)
|
||||
if img is None:
|
||||
return None
|
||||
loc = eng.locate(img)
|
||||
resp = eng.tophat_response(img, loc)
|
||||
sil = eng._glyph_silhouette()
|
||||
if resp is None or sil is None:
|
||||
return None
|
||||
w = img.shape[1]
|
||||
best, best_gw = 0.0, 0
|
||||
best_tl = (0, 0)
|
||||
for s in _FIT_SCALES:
|
||||
gw = max(cfg.min_gw, int(cfg.alpha_width_frac * w * s))
|
||||
gh = max(4, int(cfg.alpha_height_frac * w * s))
|
||||
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
res = cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED)
|
||||
_, v, _, tl = cv2.minMaxLoc(res)
|
||||
if v > best:
|
||||
best, best_gw, best_tl = v, gw, (int(tl[0]), int(tl[1]))
|
||||
# Aspect fit at the winning width: sweep gh/gw and keep the argmax. Range covers
|
||||
# everything between samsung's 0.12 and jimeng's 0.29 house styles, plus the
|
||||
# two-line stacked marks (Yuanbao ~0.45), plus slack.
|
||||
best_aspect = 0.0
|
||||
if best_gw > 0:
|
||||
best_gh_score = -1.0
|
||||
# Upper bound raised 0.42 -> 0.62 for two-line marks (Yuanbao's stacked block
|
||||
# has silhouette aspect ~0.45; the old range's 0.12 floor was its own trap --
|
||||
# the fit "won" by squashing the template to a one-line strip).
|
||||
for ratio in np.arange(0.12, 0.62, 0.01):
|
||||
gh = max(4, int(best_gw * float(ratio)))
|
||||
if gh >= resp.shape[0]:
|
||||
continue
|
||||
t = cv2.resize(sil, (best_gw, gh), interpolation=cv2.INTER_AREA)
|
||||
v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
|
||||
if v > best_gh_score:
|
||||
best_gh_score, best_aspect = v, float(ratio)
|
||||
# The ABSOLUTE mark rect, so the LOCATE box fractions can be fitted too: inheriting
|
||||
# doubao's corner box clipped the big-mode qwen mark's first glyph (the qwen mark
|
||||
# sits ~0.025 of the short side off the right edge, doubao's box assumes ~0.004),
|
||||
# which collapsed an exact-size template to 0.26.
|
||||
ax = loc.x + best_tl[0]
|
||||
ay = loc.y + best_tl[1]
|
||||
return {
|
||||
"path": path_str,
|
||||
"best": round(best, 4),
|
||||
"mark_w": best_gw,
|
||||
"aspect": round(best_aspect, 3),
|
||||
"x": ax,
|
||||
"y": ay,
|
||||
"w": w,
|
||||
"h": img.shape[0],
|
||||
}
|
||||
|
||||
|
||||
def fit_geometry(
|
||||
paths: list[str],
|
||||
asset: str,
|
||||
workers: int,
|
||||
floor: float = 0.50,
|
||||
paths_name: str = "cohort",
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Which basis and fraction does this vendor's mark actually scale with?
|
||||
|
||||
Only frames matching above ``floor`` are used: below it the winning size is the
|
||||
ladder's best fit to background texture, not a measurement of the mark.
|
||||
``overrides`` adjusts the LOCATE box for the fit (a two-line mark like Yuanbao's
|
||||
is taller than Doubao's inherited box -- scoring it in the inherited box clips
|
||||
the template to zero overlap).
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
for f in as_completed([ex.submit(_fit_one, (p, asset, overrides or {})) for p in paths]):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the fit
|
||||
continue
|
||||
if r:
|
||||
rows.append(r)
|
||||
|
||||
strong = [r for r in rows if r["best"] >= floor]
|
||||
fit_out = REPO / "data" / "spaces" / f"_vendor_fit_{paths_name}.jsonl"
|
||||
fit_out.write_text("\n".join(json.dumps(r) for r in rows), encoding="utf-8")
|
||||
print(f"\n{'=' * 78}\nGEOMETRY FIT (n={len(rows)}, usable best>={floor}: {len(strong)})\n{'=' * 78}")
|
||||
print(f"rows -> {fit_out}")
|
||||
if len(strong) < 20:
|
||||
print("too few strong frames to fit a basis -- do not ship a fraction off this")
|
||||
return
|
||||
|
||||
mw = np.array([r["mark_w"] for r in strong], float)
|
||||
w = np.array([r["w"] for r in strong], float)
|
||||
h = np.array([r["h"] for r in strong], float)
|
||||
bases = {
|
||||
"width": w,
|
||||
"height": h,
|
||||
"short": np.minimum(w, h),
|
||||
"long": np.maximum(w, h),
|
||||
"sqrt(w*h)": np.sqrt(w * h),
|
||||
"diagonal": np.hypot(w, h),
|
||||
}
|
||||
print(f"\n{'basis':12s} {'mean frac':>10s} {'CV':>8s} {'p10':>8s} {'p90':>8s}")
|
||||
print("-" * 52)
|
||||
for nm, b in sorted(bases.items(), key=lambda kv: float(np.std(mw / kv[1]) / np.mean(mw / kv[1]))):
|
||||
r = mw / b
|
||||
print(
|
||||
f"{nm:12s} {np.mean(r):10.4f} {float(np.std(r) / np.mean(r)):8.3f} "
|
||||
f"{np.percentile(r, 10):8.4f} {np.percentile(r, 90):8.4f}"
|
||||
)
|
||||
|
||||
lo_l, hi_l = _SHIPPED_LADDER[0], _SHIPPED_LADDER[-1]
|
||||
print(f"\nCoverage by a single fraction on the SHIPPED ladder ({lo_l} .. {hi_l}, span {hi_l / lo_l:.3f}x):")
|
||||
print(f"{'basis':12s} {'frac':>7s} {'window':>16s} {'covered':>9s}")
|
||||
for nm in ("short", "sqrt(w*h)", "width"):
|
||||
fs = mw / bases[nm]
|
||||
best_f, best_cov = 0.0, -1.0
|
||||
for f in np.arange(float(fs.min()) * 0.9, float(fs.max()) * 1.1, 0.002):
|
||||
cov = float(((fs >= f * lo_l) & (fs <= f * hi_l)).mean())
|
||||
if cov > best_cov:
|
||||
best_f, best_cov = float(f), cov
|
||||
print(f"{nm:12s} {best_f:7.3f} {best_f * lo_l:7.3f}-{best_f * hi_l:.3f} {100 * best_cov:8.1f}%")
|
||||
|
||||
# The raw distribution behind the coverage number: where the mark actually sits,
|
||||
# so the mode structure (and what a 4th rung would recover) is visible directly.
|
||||
fs = mw / bases["short"]
|
||||
qs = [np.percentile(fs, q) for q in (5, 25, 50, 75, 95)]
|
||||
print(f"\nfrac_short distribution: p5 {qs[0]:.3f} p25 {qs[1]:.3f} p50 {qs[2]:.3f} p75 {qs[3]:.3f} p95 {qs[4]:.3f}")
|
||||
hist, edges = np.histogram(fs, bins=16)
|
||||
for c, e0, e1 in zip(hist, edges[:-1], edges[1:], strict=True):
|
||||
print(f" {e0:.3f}-{e1:.3f} {'#' * c}")
|
||||
|
||||
# Template aspect at the winning width -> the alpha_height_frac recommendation.
|
||||
# Measured, per the standing rule: not the silhouette's own aspect, not doubao's.
|
||||
aspects = np.array([r["aspect"] for r in strong if r["aspect"] > 0], float)
|
||||
if len(aspects) >= 20:
|
||||
med = float(np.median(aspects))
|
||||
print(
|
||||
f"\nASPECT FIT (n={len(aspects)}): p10 {np.percentile(aspects, 10):.3f} "
|
||||
f"p50 {med:.3f} p90 {np.percentile(aspects, 90):.3f}"
|
||||
)
|
||||
print("alpha_height_frac = alpha_width_frac * p50(aspect), per basis:")
|
||||
for nm in ("short", "sqrt(w*h)", "width"):
|
||||
fxs = mw / bases[nm]
|
||||
best_f = max(
|
||||
np.arange(float(fxs.min()) * 0.9, float(fxs.max()) * 1.1, 0.002),
|
||||
key=lambda f: float(((fxs >= f * lo_l) & (fxs <= f * hi_l)).mean()),
|
||||
)
|
||||
print(f" {nm:12s} width {best_f:.3f} -> height {best_f * med:.4f}")
|
||||
|
||||
# LOCATE-box fit. The box fractions are as mark-specific as the template size:
|
||||
# doubao's box clipped qwen's big-mode mark (see _fit_one). Derive the box from the
|
||||
# measured absolute mark rects: margins must not exceed the mark's own (else the
|
||||
# mark exits the anchored box), and the box must cover the mark plus NCC slack.
|
||||
if len(aspects) >= 20:
|
||||
short = np.minimum(w, h).astype(float)
|
||||
mark_h = np.array([r["mark_w"] * r["aspect"] for r in strong], float)
|
||||
ax = np.array([r["x"] for r in strong], float)
|
||||
ay = np.array([r["y"] for r in strong], float)
|
||||
right = (w - (ax + mw)) / short # frame right edge to mark right edge
|
||||
bottom = (h - (ay + mark_h)) / short
|
||||
print(f"\nLOCATE FIT (basis=short, n={len(strong)}):")
|
||||
print(f" right-margin frac p5 {np.percentile(right, 5):.4f} p50 {np.percentile(right, 50):.4f}")
|
||||
print(f" bottom-margin frac p5 {np.percentile(bottom, 5):.4f} p50 {np.percentile(bottom, 50):.4f}")
|
||||
print(
|
||||
f" mark height frac p50 {np.percentile(mark_h / short, 50):.4f} "
|
||||
f"p95 {np.percentile(mark_h / short, 95):.4f}"
|
||||
)
|
||||
mx = max(0.002, float(np.percentile(right, 5)) - 0.004)
|
||||
mb = max(0.002, float(np.percentile(bottom, 5)) - 0.004)
|
||||
need_w = float(np.percentile(mw / short + right, 95)) - mx + 0.02
|
||||
need_h = float(np.percentile(mark_h / short + bottom, 95)) - mb + 0.015
|
||||
print(f" recommended: margin_x_frac={mx:.4f} margin_bottom_frac={mb:.4f}")
|
||||
print(f" width_frac={need_w:.3f} height_frac={need_h:.3f}")
|
||||
|
||||
print("\nThese are DIAGNOSTIC. Re-score both arms on the shipped ladder with the")
|
||||
print("fitted geometry before reading any gate off the clean arm.")
|
||||
|
||||
|
||||
FIRED = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
|
||||
|
||||
def _fired_pool(mark: str, limit: int, seed: int = 7) -> list[str]:
|
||||
"""Paths where ``mark`` fired, from the COMPLETED full-corpus artifact -- the
|
||||
standing rule: detector firings are joined, never re-run."""
|
||||
import random
|
||||
|
||||
pool = [
|
||||
json.loads(x)["path"]
|
||||
for x in FIRED.read_text(encoding="utf-8").splitlines()
|
||||
if x.strip() and mark in (json.loads(x).get("keys") or [])
|
||||
]
|
||||
rng = random.Random(seed) # noqa: S311 -- reproducible sampling, not crypto
|
||||
rng.shuffle(pool)
|
||||
return pool[:limit]
|
||||
|
||||
|
||||
def _cross_score(args: tuple[str, Any, Any]) -> dict[str, Any] | None:
|
||||
"""One frame scored by BOTH the candidate and the doubao production configs."""
|
||||
path_str, cand_cfg, db_cfg = args
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
img = imread(path_str)
|
||||
if img is None or min(img.shape[:2]) < 200:
|
||||
return None
|
||||
out: dict[str, Any] = {"path": path_str}
|
||||
for key, cfg in (("cand", cand_cfg), ("doubao", db_cfg)):
|
||||
eng = TextMarkEngine(cfg)
|
||||
loc = eng.locate(img)
|
||||
try:
|
||||
score, _ = eng._tophat_best(img, loc)
|
||||
except Exception:
|
||||
return None
|
||||
out[key] = round(float(score), 4)
|
||||
return out
|
||||
|
||||
|
||||
def crossfire(
|
||||
pools: dict[str, list[str]],
|
||||
cand_cfg: Any,
|
||||
workers: int,
|
||||
gate: float,
|
||||
margin: float = 0.10,
|
||||
) -> None:
|
||||
"""Score the candidate AND doubao's production template on the same frames.
|
||||
|
||||
The registration question a cohort-vs-clean run cannot answer: the candidate shares
|
||||
the mandated `AI生成` tail with doubao (4 of 6 glyph cells), so its template will
|
||||
correlate with doubao marks too. If the candidate fires on the doubao pool at the
|
||||
candidate gate, registering it double-fills every doubao frame and mislabels it --
|
||||
unless the rival margin suppresses it, which then has to be shown NOT to kill the
|
||||
candidate on its own marks. Measured here in the tophat domain (the gate's domain);
|
||||
production's `_rival_margin_ok` runs the same comparison on the binary blob.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG as db_cfg
|
||||
|
||||
print(f"\n{'=' * 78}\nCROSSFIRE -- candidate vs doubao, same frames, tophat domain\n{'=' * 78}")
|
||||
print(f"candidate gate {gate:.3f} | rival margin {margin:.2f}\n")
|
||||
print(
|
||||
f"{'pool':8s} {'n':>5s} {'cand p50':>9s} {'cand p90':>9s} {'db p50':>7s} {'db p90':>7s} "
|
||||
f"{'m-d p10':>8s} {'m-d p50':>8s} {'fire':>7s} {'fire+m':>7s}"
|
||||
)
|
||||
with ProcessPoolExecutor(max_workers=workers) as ex:
|
||||
for pool_name, paths in pools.items():
|
||||
rows: list[dict[str, Any]] = []
|
||||
futs = [ex.submit(_cross_score, (p, cand_cfg, db_cfg)) for p in paths]
|
||||
for f in as_completed(futs):
|
||||
try:
|
||||
r = f.result()
|
||||
except Exception: # noqa: S112 -- one bad file must not kill the pool
|
||||
continue
|
||||
if r:
|
||||
rows.append(r)
|
||||
if not rows:
|
||||
continue
|
||||
c = np.array([r["cand"] for r in rows])
|
||||
d = np.array([r["doubao"] for r in rows])
|
||||
m = c - d
|
||||
fire = c >= gate
|
||||
fire_m = fire & (m >= margin)
|
||||
print(
|
||||
f"{pool_name:8s} {len(rows):5d} {np.percentile(c, 50):9.3f} {np.percentile(c, 90):9.3f} "
|
||||
f"{np.percentile(d, 50):7.3f} {np.percentile(d, 90):7.3f} "
|
||||
f"{np.percentile(m, 10):8.3f} {np.percentile(m, 50):8.3f} "
|
||||
f"{100 * float(fire.mean()):6.1f}% {100 * float(fire_m.mean()):6.1f}%"
|
||||
)
|
||||
print("\nReading: on `qwen` the margin column must stay high (the candidate keeps its")
|
||||
print("own marks); on `doubao` fire+m must sit near zero (the candidate stays off")
|
||||
print("doubao marks). If fire is high on `doubao` and fire+m is not, the rival margin")
|
||||
print("is load-bearing for registration; if both are high, the mark cannot be")
|
||||
print("registered on this front-end at all.")
|
||||
|
||||
|
||||
def _parse_ladder(raw: str) -> tuple[float, ...] | None:
|
||||
if not raw:
|
||||
return None
|
||||
return tuple(float(x) for x in raw.split(","))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--cohort", required=True, help="cohort USCC from vendor_cohort_harvest.py")
|
||||
ap.add_argument("--asset", required=True, help="silhouette asset name, e.g. qwen_alpha.png")
|
||||
ap.add_argument("--name", default="", help="label for output files (defaults to the asset stem)")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--scale-basis", choices=("short", "width"), default="short")
|
||||
ap.add_argument(
|
||||
"--corner",
|
||||
choices=("br", "bl", "tl"),
|
||||
default=None,
|
||||
help="override the inherited br corner (e.g. tl for RunningHub)",
|
||||
)
|
||||
ap.add_argument("--sheets", action="store_true")
|
||||
ap.add_argument(
|
||||
"--fit-geometry",
|
||||
action="store_true",
|
||||
help="fit the basis + fraction + template aspect the mark scales with",
|
||||
)
|
||||
ap.add_argument("--width-frac", type=float, default=None, help="fitted alpha_width_frac (default: inherit doubao)")
|
||||
ap.add_argument(
|
||||
"--height-frac", type=float, default=None, help="fitted alpha_height_frac (default: inherit doubao)"
|
||||
)
|
||||
ap.add_argument("--ladder", default="", help="comma scale rungs, e.g. 0.8,1.0,1.25,1.6 (default: shipped 3)")
|
||||
ap.add_argument("--gate", type=float, default=0.45, help="candidate gate for the crossfire fire rates")
|
||||
ap.add_argument("--box-width-frac", type=float, default=None, help="fitted locate width_frac")
|
||||
ap.add_argument("--box-height-frac", type=float, default=None, help="fitted locate height_frac")
|
||||
ap.add_argument("--margin-x-frac", type=float, default=None, help="fitted locate margin_x_frac")
|
||||
ap.add_argument("--margin-bottom-frac", type=float, default=None, help="fitted locate margin_bottom_frac")
|
||||
ap.add_argument(
|
||||
"--crossfire",
|
||||
action="store_true",
|
||||
help="score the candidate AND doubao on the cohort, the doubao/jimeng pools and the clean arm",
|
||||
)
|
||||
a = ap.parse_args()
|
||||
name = a.name or a.asset.split("_")[0]
|
||||
ladder = _parse_ladder(a.ladder)
|
||||
overrides: dict[str, Any] = {}
|
||||
for arg, field in (
|
||||
(a.width_frac, "alpha_width_frac"),
|
||||
(a.height_frac, "alpha_height_frac"),
|
||||
(a.box_width_frac, "width_frac"),
|
||||
(a.box_height_frac, "height_frac"),
|
||||
(a.margin_x_frac, "margin_x_frac"),
|
||||
(a.margin_bottom_frac, "margin_bottom_frac"),
|
||||
):
|
||||
if arg is not None:
|
||||
overrides[field] = arg
|
||||
if a.corner is not None:
|
||||
overrides["corner"] = a.corner
|
||||
if ladder is not None:
|
||||
overrides["ladder"] = ladder
|
||||
|
||||
pos_paths, neg_paths = load_sets(a.cohort)
|
||||
if a.fit_geometry:
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates")
|
||||
fit_geometry(pos_paths, a.asset, a.workers, paths_name=name, overrides=overrides)
|
||||
return
|
||||
|
||||
if a.crossfire:
|
||||
cand = build_config(a.asset, name, a.scale_basis, overrides)
|
||||
pools = {
|
||||
"qwen": pos_paths,
|
||||
"doubao": _fired_pool("doubao", 400),
|
||||
"jimeng": _fired_pool("jimeng", 300),
|
||||
"clean": neg_paths,
|
||||
}
|
||||
print("pools: " + ", ".join(f"{k}={len(v)}" for k, v in pools.items()))
|
||||
crossfire(pools, cand, a.workers, a.gate)
|
||||
return
|
||||
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labelled")
|
||||
print(f"scale_basis={a.scale_basis} overrides={overrides}")
|
||||
pos = run(pos_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
neg = run(neg_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
OUT.write_text(
|
||||
"\n".join(json.dumps({**r, "arm": arm}) for arm, rows in (("cohort", pos), ("clean", neg)) for r in rows),
|
||||
encoding="utf-8",
|
||||
)
|
||||
report(pos, neg, name)
|
||||
if a.sheets:
|
||||
print(f"\nsheets -> {SHEET_DIR}")
|
||||
sheets(pos, name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Benchmark harness for the visible-mark detectors.
|
||||
|
||||
Run this before AND after any detector change. It re-runs perception over the
|
||||
hand-labelled ground truth and reports, per mark, how often a fire is correct --
|
||||
with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
uv run python scripts/visible_eval.py # score current code
|
||||
uv run python scripts/visible_eval.py --save baseline # snapshot for comparison
|
||||
uv run python scripts/visible_eval.py --vs baseline # diff against a snapshot
|
||||
|
||||
WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number:
|
||||
|
||||
* PRECISION: sound. Every labelled crop is centred on the region a detector
|
||||
pointed at, so "the detector fired mark K here, was K actually there" is
|
||||
exactly the question the labels answer.
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labelled
|
||||
images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls),
|
||||
so images carrying a mark that every detector missed are absent by construction.
|
||||
Computing recall on this set would divide by a denominator that excludes exactly
|
||||
the failures recall is meant to expose, and would report a flattering number.
|
||||
Recall needs a RANDOM corpus sample laballed exhaustively -- a separate round.
|
||||
|
||||
* `other_ai_label` (千问 / 百度 / 星绘 / 抖音) counts as a FALSE fire for any
|
||||
registered mark, because it is a different vendor's label. It is tracked
|
||||
separately in the confusion output since it is the dominant jimeng failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as wr
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
GT = Path("data/spaces/_research_20260718_textmark_relaxation/groundtruth.jsonl")
|
||||
SNAP = Path("data/spaces/_research_20260718_textmark_relaxation/snapshots")
|
||||
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
|
||||
|
||||
|
||||
def wilson(k: int, n: int) -> tuple[float, float]:
|
||||
if n == 0:
|
||||
return (0.0, 0.0)
|
||||
z, p = 1.96, k / n
|
||||
d = 1 + z * z / n
|
||||
c = p + z * z / (2 * n)
|
||||
s = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
||||
return ((c - s) / d, (c + s) / d)
|
||||
|
||||
|
||||
def provenance_for(rec: dict) -> frozenset[str]:
|
||||
"""The provenance production would have: read from the file's own METADATA.
|
||||
|
||||
Never derive this from the labels. A relaxation arm only fires when provenance
|
||||
names the vendor, so label-derived provenance silently tells the detector the
|
||||
answer and every arm scores near-perfect (observed: gemini 99% instead of 34%).
|
||||
"""
|
||||
return frozenset(rec.get("provenance", []))
|
||||
|
||||
|
||||
def score(sensitivity: str = "auto") -> dict:
|
||||
recs = [json.loads(line) for line in GT.open()]
|
||||
per: dict[str, Counter] = {m: Counter() for m in MARKS}
|
||||
confusion: dict[str, Counter] = {m: Counter() for m in MARKS}
|
||||
missing = 0
|
||||
for rec in recs:
|
||||
img = imread(rec["path"])
|
||||
if img is None:
|
||||
missing += 1
|
||||
continue
|
||||
cands = wr._build_candidates(img)
|
||||
ctx = wr.Context(sensitivity=sensitivity, provenance=provenance_for(rec))
|
||||
fired = {d.candidate.key for d in wr.decide(cands, ctx)}
|
||||
for m in MARKS:
|
||||
# Only score a mark on images whose shown crop could rule on it. Scoring
|
||||
# outside that scope books real detections as false fires -- see the
|
||||
# adjudication note in visible_groundtruth.py.
|
||||
if m not in rec.get("adjudicated", []):
|
||||
continue
|
||||
if m not in fired:
|
||||
per[m]["fn"] += 1 if m in rec["present"] else 0
|
||||
continue
|
||||
if m in rec["present"]:
|
||||
per[m]["tp"] += 1
|
||||
else:
|
||||
per[m]["fp"] += 1
|
||||
for s in rec["seen"]:
|
||||
confusion[m][s] += 1
|
||||
scope = {m: sum(1 for r in recs if m in r.get("adjudicated", [])) for m in MARKS}
|
||||
return {
|
||||
"per": {m: dict(c) for m, c in per.items()},
|
||||
"scope": scope,
|
||||
"confusion": {m: dict(c) for m, c in confusion.items()},
|
||||
"missing": missing,
|
||||
"n": len(recs),
|
||||
"sensitivity": sensitivity,
|
||||
}
|
||||
|
||||
|
||||
def report(res: dict, prev: dict | None = None) -> None:
|
||||
print(f"\nground truth: {res['n']} images ({res['missing']} unreadable) sensitivity={res['sensitivity']}")
|
||||
print("=" * 78)
|
||||
print(
|
||||
f"{'mark':12s} {'scope':>6s} {'fires':>6s} {'correct':>8s} "
|
||||
f"{'precision':>11s} {'95% CI':>13s} {'missed':>7s} delta"
|
||||
)
|
||||
print("-" * 78)
|
||||
for m in MARKS:
|
||||
c = res["per"][m]
|
||||
tp, fp, fn = c.get("tp", 0), c.get("fp", 0), c.get("fn", 0)
|
||||
n = tp + fp
|
||||
scope = res["scope"].get(m, 0)
|
||||
if n == 0:
|
||||
print(f"{m:12s} {scope:6d} {0:6d} {'-':>8s} {'-':>11s} {'-':>13s} {fn:7d}")
|
||||
continue
|
||||
lo, hi = wilson(tp, n)
|
||||
delta = ""
|
||||
if prev:
|
||||
pc = prev["per"][m]
|
||||
pn = pc.get("tp", 0) + pc.get("fp", 0)
|
||||
if pn:
|
||||
d = tp / n - pc.get("tp", 0) / pn
|
||||
delta = f"{d:+.1%} (fires {pn}->{n})"
|
||||
print(f"{m:12s} {scope:6d} {n:6d} {tp:8d} {tp / n:10.0%} {lo:5.0%}-{hi:<6.0%} {fn:7d} {delta}")
|
||||
print("\nwhat the FALSE fires actually were:")
|
||||
for m in MARKS:
|
||||
conf = {k: v for k, v in res["confusion"][m].items() if k != m}
|
||||
if conf:
|
||||
print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.")
|
||||
print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images")
|
||||
print(" every detector missed are absent by construction. Use it only to catch a change")
|
||||
print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--save", metavar="NAME")
|
||||
ap.add_argument("--vs", metavar="NAME")
|
||||
ap.add_argument("--sensitivity", default="auto")
|
||||
a = ap.parse_args()
|
||||
res = score(a.sensitivity)
|
||||
prev = None
|
||||
if a.vs:
|
||||
f = SNAP / f"{a.vs}.json"
|
||||
if f.exists():
|
||||
prev = json.loads(f.read_text())
|
||||
else:
|
||||
print(f"(no snapshot {f}; showing absolute numbers)")
|
||||
report(res, prev)
|
||||
if a.save:
|
||||
SNAP.mkdir(parents=True, exist_ok=True)
|
||||
(SNAP / f"{a.save}.json").write_text(json.dumps(res, indent=1))
|
||||
print(f"\nsaved snapshot -> {SNAP / f'{a.save}.json'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file.
|
||||
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labelled
|
||||
blind against contact sheets with a two-sided control in every round. Rounds so far:
|
||||
|
||||
2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms)
|
||||
2026-07-18 gemini round : 356 cells (gemini relaxation additions)
|
||||
|
||||
DATA SAFETY: the corpus is real user uploads. This script reads the gitignored
|
||||
corpus and writes a gitignored ground-truth file. Neither the images nor this
|
||||
output may be committed; only the harness is. See the repo CLAUDE.md.
|
||||
|
||||
The labels record what the LABELLER SAW in the crop, one of:
|
||||
doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain
|
||||
`other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for
|
||||
(千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is
|
||||
also not "clean" -- it is exactly what the relaxed jimeng detector confuses.
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's
|
||||
honest doubt never becomes a fabricated data point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEEN_TO_MARK = {
|
||||
"doubao": "doubao",
|
||||
"jimeng": "jimeng",
|
||||
"pill": "jimeng_pill",
|
||||
"sparkle": "gemini",
|
||||
}
|
||||
# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible
|
||||
# in the same crop). Doubao and Jimeng share the bottom-right corner.
|
||||
_ADJUDICATES = {
|
||||
"doubao": ("doubao", "jimeng"),
|
||||
"jimeng": ("doubao", "jimeng"),
|
||||
"jimeng_pill": ("jimeng_pill",),
|
||||
"gemini": ("gemini",),
|
||||
"samsung": ("samsung",),
|
||||
}
|
||||
|
||||
ROUNDS = [
|
||||
("textmark", "labels.csv", "manifest.csv"),
|
||||
("gemini", "gemini_labels.csv", "gemini_manifest.csv"),
|
||||
]
|
||||
|
||||
|
||||
def metadata_provenance(path: str) -> list[str]:
|
||||
"""The vendor keys LOCAL METADATA confirms -- what cli._visible_provenance reads.
|
||||
|
||||
Must come from the file's own metadata, never from the labels: deriving it from
|
||||
the ground truth would hand the detector the answer it is being scored on (a
|
||||
relaxation only fires when provenance names the vendor, so label-derived
|
||||
provenance makes every arm look near-perfect). Read from the corpus `identify`
|
||||
sidecar, which is the same signal production computes.
|
||||
"""
|
||||
p = Path(path)
|
||||
sidecar = Path(str(p.parent).replace("/originals/", "/identify/")) / (p.name.split("_src")[0] + ".json")
|
||||
if not sidecar.exists():
|
||||
return []
|
||||
try:
|
||||
with sidecar.open() as fh:
|
||||
wm = " | ".join(json.load(fh).get("watermarks", []))
|
||||
except Exception:
|
||||
return []
|
||||
keys: list[str] = []
|
||||
if "China AIGC label" in wm:
|
||||
keys += ["doubao", "jimeng"]
|
||||
if "C2PA Content Credentials (Google LLC" in wm:
|
||||
keys.append("gemini")
|
||||
if "Samsung Galaxy AI" in wm:
|
||||
keys.append("samsung")
|
||||
return keys
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(sys.argv[1] if len(sys.argv) > 1 else "data/spaces/_research_20260718_textmark_relaxation")
|
||||
out = root / "groundtruth.jsonl"
|
||||
rows: dict[str, dict] = {}
|
||||
stats: dict[str, int] = {}
|
||||
for round_name, labels_file, manifest_file in ROUNDS:
|
||||
with (root / labels_file).open() as fh:
|
||||
labels = {int(r["idx"]): r["seen"] for r in csv.DictReader(fh)}
|
||||
with (root / manifest_file).open() as fh:
|
||||
manifest_rows = list(csv.DictReader(fh))
|
||||
for m in manifest_rows:
|
||||
seen = labels.get(int(m["idx"]))
|
||||
if seen is None:
|
||||
continue
|
||||
stats[seen] = stats.get(seen, 0) + 1
|
||||
if seen == "uncertain":
|
||||
continue # excluded by design -- never coerce a doubt into a label
|
||||
rec = rows.setdefault(
|
||||
m["uid"],
|
||||
{"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []},
|
||||
)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets
|
||||
# the labeller rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# says nothing about a bottom-right wordmark, so scoring jimeng against a
|
||||
# pill-round image would book real detections as false fires (~61% of pills
|
||||
# carry a wordmark). Bottom-right marks co-adjudicate each other: one crop
|
||||
# of that corner shows whichever of Doubao/Jimeng is there.
|
||||
for k in _ADJUDICATES.get(m["key"], (m["key"],)):
|
||||
if k not in rec["adjudicated"]:
|
||||
rec["adjudicated"].append(k)
|
||||
mark = SEEN_TO_MARK.get(seen)
|
||||
if mark and mark not in rec["present"]:
|
||||
rec["present"].append(mark)
|
||||
if seen not in rec["seen"]:
|
||||
rec["seen"].append(seen)
|
||||
if round_name not in rec["rounds"]:
|
||||
rec["rounds"].append(round_name)
|
||||
|
||||
for r in rows.values():
|
||||
r["provenance"] = metadata_provenance(r["path"])
|
||||
with out.open("w") as fh:
|
||||
for r in rows.values():
|
||||
fh.write(json.dumps(r) + "\n")
|
||||
print(f"wrote {out} images={len(rows)}")
|
||||
print("label distribution across all rounds:", dict(sorted(stats.items(), key=lambda kv: -kv[1])))
|
||||
n_pos = sum(1 for r in rows.values() if r["present"])
|
||||
print(f"images with at least one registered mark: {n_pos}; clean-of-registered-marks: {len(rows) - n_pos}")
|
||||
adj: dict[str, int] = {}
|
||||
for r in rows.values():
|
||||
for k in r["adjudicated"]:
|
||||
adj[k] = adj.get(k, 0) + 1
|
||||
print("images each mark can be SCORED on (adjudication scope):", dict(sorted(adj.items())))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Parallel detection pass: list every corpus image carrying a known visible mark.
|
||||
|
||||
Why this exists separately from `visible_removal_audit.py`: that audit is single-process,
|
||||
so a full-corpus sweep costs ~10 h and running it once per backend costs ~30 h. But its
|
||||
expensive half is DETECTION, and detection does not depend on the fill backend. Splitting
|
||||
it out means detecting once in parallel and then feeding the positives to the audit via
|
||||
its `--paths-file` seam, over a few thousand images instead of forty thousand.
|
||||
|
||||
CRASH TOLERANCE IS NOT OPTIONAL AT THIS SCALE
|
||||
cv2/libpng decode native-crash on some real uploads. A plain `ProcessPoolExecutor.map`
|
||||
over 39k files then DEADLOCKS: the worker dies without a Python traceback and the parent
|
||||
waits forever on a result that never arrives (observed 2026-07-19 -- 26 min of work lost
|
||||
because results were only written at the end). So this script:
|
||||
* writes every result to JSONL as it arrives -- a kill never costs more than a batch;
|
||||
* is resumable -- an interrupted run skips what it already recorded;
|
||||
* runs a FRESH pool per batch with a timeout, so one poisoned file costs one batch,
|
||||
and that batch is retried serially to find and record the offender.
|
||||
|
||||
Corpus images are user uploads: read-only, local analysis, gitignored output.
|
||||
|
||||
uv run python scripts/visible_positives.py --jobs 6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from concurrent.futures.process import BrokenProcessPool
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# The package's own format set. An inlined copy here silently skipped .heif, which
|
||||
# CLAUDE.md documents as supported.
|
||||
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS as _EXTS
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / "data" / "spaces" / "originals"
|
||||
OUT = REPO / "data" / "spaces" / "_visible_positives.jsonl"
|
||||
PATHS = REPO / "data" / "spaces" / "_visible_positives.txt"
|
||||
|
||||
|
||||
def _one(path: str) -> dict[str, object]:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
|
||||
try:
|
||||
img = imread(path)
|
||||
if img is None:
|
||||
return {"path": path, "keys": [], "status": "unreadable"}
|
||||
return {"path": path, "keys": [d.key for d in detect_marks(img) if d.detected], "status": "ok"}
|
||||
except Exception as e:
|
||||
return {"path": path, "keys": [], "status": f"error:{type(e).__name__}"}
|
||||
|
||||
|
||||
def _run_batch(batch: list[str], jobs: int, timeout: int) -> list[dict[str, object]]:
|
||||
"""One batch in a fresh pool. On a native worker crash or timeout, retry serially so
|
||||
the poisoned file is identified and recorded instead of stalling the whole sweep."""
|
||||
try:
|
||||
with ProcessPoolExecutor(max_workers=jobs) as ex:
|
||||
futs = {ex.submit(_one, p): p for p in batch}
|
||||
return [f.result() for f in as_completed(futs, timeout=timeout)]
|
||||
except (FutureTimeout, BrokenProcessPool, OSError, RuntimeError):
|
||||
out: list[dict[str, object]] = []
|
||||
for p in batch:
|
||||
try:
|
||||
out.append(_one(p))
|
||||
except BaseException: # a native crash here kills only this file, not the sweep
|
||||
out.append({"path": p, "keys": [], "status": "crashed"})
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--batch", type=int, default=200)
|
||||
ap.add_argument("--timeout", type=int, default=600, help="seconds per batch before falling back to serial")
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true")
|
||||
a = ap.parse_args()
|
||||
|
||||
files = sorted(p for p in glob.glob(str(CORPUS / "*" / "*")) if Path(p).suffix.lower() in _EXTS)
|
||||
if a.limit:
|
||||
files = files[: a.limit]
|
||||
|
||||
done: set[str] = set()
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink() # --restart must TRUNCATE; the file is reopened in append mode below
|
||||
if a.out.exists() and not a.restart:
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["path"])
|
||||
except Exception: # noqa: S112 -- tolerate a torn last line
|
||||
continue
|
||||
todo = [p for p in files if p not in done]
|
||||
print(f"images {len(files)} already done {len(done)} to do {len(todo)} jobs {a.jobs}", flush=True)
|
||||
|
||||
counts: collections.Counter[str] = collections.Counter()
|
||||
status: collections.Counter[str] = collections.Counter()
|
||||
seen = 0
|
||||
with open(a.out, "a", encoding="utf-8") as fh:
|
||||
for start in range(0, len(todo), a.batch):
|
||||
for rec in _run_batch(todo[start : start + a.batch], a.jobs, a.timeout):
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
status[str(rec["status"])] += 1
|
||||
for k in rec["keys"]: # type: ignore[union-attr]
|
||||
counts[str(k)] += 1
|
||||
seen += 1
|
||||
fh.flush()
|
||||
print(f" {seen}/{len(todo)} {dict(counts)} {dict(status)}", flush=True)
|
||||
|
||||
# Rebuild the paths file from the FULL record set, not just this run's slice.
|
||||
hits = []
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
if r.get("keys"):
|
||||
hits.append(r["path"])
|
||||
# Derive the paths file from --out so a trial run with a scratch --out cannot
|
||||
# overwrite the shared list a full sweep produced.
|
||||
paths_out = a.out.with_suffix(".txt")
|
||||
paths_out.write_text("\n".join(sorted(set(hits))) + "\n", encoding="utf-8")
|
||||
print(f"\npositives: {len(set(hits))} images")
|
||||
for k, v in counts.most_common():
|
||||
print(f" {v:6d} {k}")
|
||||
print(f"statuses: {dict(status)}\npaths -> {paths_out}\nrecords -> {a.out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Build an UNBIASED random sample for measuring visible-mark RECALL.
|
||||
|
||||
Every earlier labelling round sampled where detectors FIRED, so images that every
|
||||
detector missed were absent by construction and recall was unmeasurable. This round
|
||||
samples at random within a provenance class and shows the labeller the corners where
|
||||
a mark can physically be, so a MISSED mark is visible as such.
|
||||
|
||||
Design decisions that matter:
|
||||
|
||||
* SAMPLING FRAME is per provenance class, not the whole corpus. Recall is only
|
||||
meaningful against a denominator where the mark CAN occur: TC260 carriers for the
|
||||
ByteDance marks and the pill, Google-C2PA for the sparkle. Reporting one blended
|
||||
recall over all uploads would mostly measure how often each vendor appears.
|
||||
* NATIVE RESOLUTION crops, never a downscaled whole image: a 220px preview destroys a
|
||||
faint mark (measured in an earlier round), which would inflate the miss count with
|
||||
the labeller's own blindness rather than the detector's.
|
||||
* BOTH corners per image (top-left pill, bottom-right wordmark/strip/sparkle), so one
|
||||
pass adjudicates every registered mark instead of one mark per crop.
|
||||
* The detector's verdict is NOT shown and is not in the sheet order -- the manifest
|
||||
holds it and must not be opened until labelling ends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
CELL_W = 300
|
||||
COLS, ROWS = 4, 3
|
||||
PER = COLS * ROWS
|
||||
|
||||
|
||||
def corner_strip(img: NDArray[Any]) -> NDArray[Any] | None:
|
||||
"""Top-left and bottom-right corners stacked, at (near) native scale.
|
||||
|
||||
Crop size is a fraction of the SHORT side because that is what marks scale with
|
||||
(China's GB 45438-2025 sizes the mandated label off the shortest side).
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
short = min(h, w)
|
||||
cw, ch = int(short * 0.46), int(short * 0.17)
|
||||
cw, ch = min(cw, w), min(ch, h)
|
||||
tl = img[0:ch, 0:cw]
|
||||
br = img[h - ch : h, w - cw : w]
|
||||
strip = np.vstack([tl, np.full((6, cw, 3), 90, np.uint8), br])
|
||||
s = min(1.0, CELL_W / strip.shape[1]) # never UPSCALE past native
|
||||
if s < 1.0:
|
||||
strip = cv2.resize(strip, (CELL_W, max(1, int(strip.shape[0] * s))), interpolation=cv2.INTER_AREA)
|
||||
return strip
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scan = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2])
|
||||
n_tc260 = int(sys.argv[3]) if len(sys.argv) > 3 else 160
|
||||
n_google = int(sys.argv[4]) if len(sys.argv) > 4 else 80
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recs = [json.loads(line) for line in scan.open() if '"marks"' in line]
|
||||
seen: dict[tuple, dict] = {}
|
||||
for r in recs: # exact-duplicate uploads share the whole NCC vector
|
||||
seen.setdefault(tuple(r.get("shape", ())) + tuple(sorted((k, m["conf"]) for k, m in r["marks"].items())), r)
|
||||
uniq = list(seen.values())
|
||||
|
||||
tc = [r for r in uniq if r["cls"] == "tc260"]
|
||||
goog = [r for r in uniq if r["cls"] == "neg" and "Google" in r.get("platform", "")]
|
||||
rng = random.Random(2026) # noqa: S311 -- sampling, not cryptography
|
||||
rng.shuffle(tc)
|
||||
rng.shuffle(goog)
|
||||
picked = [("tc260", r) for r in tc[:n_tc260]] + [("google", r) for r in goog[:n_google]]
|
||||
rng.shuffle(picked)
|
||||
|
||||
manifest, cells = [], []
|
||||
for cls, r in picked:
|
||||
img = imread(r["path"])
|
||||
if img is None:
|
||||
continue
|
||||
strip = corner_strip(img)
|
||||
if strip is None:
|
||||
continue
|
||||
cells.append(strip)
|
||||
manifest.append(
|
||||
{
|
||||
"idx": len(cells) - 1,
|
||||
"uid": r["uid"],
|
||||
"cls": cls,
|
||||
"path": r["path"],
|
||||
"fired": "|".join(sorted(k for k, m in r["marks"].items() if m["strict"])),
|
||||
**{f"ncc_{k}": m["conf"] for k, m in r["marks"].items()},
|
||||
}
|
||||
)
|
||||
|
||||
cell_h = max(c.shape[0] for c in cells)
|
||||
for si in range(0, len(cells), PER):
|
||||
chunk = cells[si : si + PER]
|
||||
sheet = np.full((ROWS * (cell_h + 26), COLS * (CELL_W + 8), 3), 40, np.uint8)
|
||||
for i, c in enumerate(chunk):
|
||||
rr, cc = divmod(i, COLS)
|
||||
y0, x0 = rr * (cell_h + 26) + 22, cc * (CELL_W + 8) + 4
|
||||
sheet[y0 : y0 + c.shape[0], x0 : x0 + c.shape[1]] = c
|
||||
cv2.putText(sheet, f"{si + i:03d}", (x0, y0 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
cv2.imwrite(str(out / f"r{si // PER:02d}.png"), sheet)
|
||||
|
||||
with (out / "MANIFEST_DO_NOT_OPEN.csv").open("w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(manifest[0]))
|
||||
w.writeheader()
|
||||
w.writerows(manifest)
|
||||
print(f"cells={len(cells)} sheets={(len(cells) + PER - 1) // PER} -> {out}")
|
||||
print("each cell = TOP-LEFT corner above, BOTTOM-RIGHT corner below, near native scale")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,11 @@ NOT sufficient (their NCC detector is fooled by a thin residual outline -- see
|
||||
CLAUDE.md). Treat a detector-clean Doubao/Jimeng as "detector passes"; visual
|
||||
residual is a separate check.
|
||||
|
||||
Backend: for a REALISTIC quality audit pass ``--backend migan`` (the production
|
||||
fill), not ``cv2``. Removal SUCCESS (detector-clean) is backend-independent, so cv2
|
||||
is fine for a fast pass/fail sweep, but only migan/lama reflect the recovered-region
|
||||
quality a user actually gets. Run migan when validating the visible pipeline for real.
|
||||
|
||||
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
|
||||
|
||||
uv run python scripts/visible_removal_audit.py \
|
||||
@@ -54,7 +59,14 @@ def _rel(p: Path, corpus: Path) -> str:
|
||||
help="Audit only these paths (one per line), skipping the full rglob.",
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
||||
def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, limit: int) -> None:
|
||||
@click.option(
|
||||
"--backend",
|
||||
type=click.Choice(["auto", "cv2", "migan", "lama"]),
|
||||
default="auto",
|
||||
help="Fill backend for removal. Removal SUCCESS (detector-clean) is backend-independent; "
|
||||
"cv2 is fastest for a bulk audit, migan/lama only change recovered-region quality.",
|
||||
)
|
||||
def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, limit: int, backend: str) -> None:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
if paths_file is not None:
|
||||
files = [Path(s) for line in paths_file.read_text().splitlines() if (s := line.strip()) and Path(s).is_file()]
|
||||
@@ -86,7 +98,7 @@ def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, l
|
||||
shutil.copy2(p, ddir / p.name)
|
||||
# Remove, then re-detect with the SAME mark's detector.
|
||||
try:
|
||||
cleaned, _ = mark.remove(img)
|
||||
cleaned, _ = mark.remove(img, backend=backend)
|
||||
after = mark.detect(cleaned)
|
||||
except Exception as exc:
|
||||
log.warning("remove failed on %s (%s): %s", p.name, det.key, exc)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Build BLIND contact sheets for hand-labelling relaxation additions.
|
||||
|
||||
Crops are centered on the DETECTED REGION (not the corner), padded by ~0.9x the mark
|
||||
size, and resized to 240px with INTER_NEAREST -- a downscaled preview destroys a faint
|
||||
mark, so nothing here may smooth. The manifest is written to a separate file that must
|
||||
NOT be read until labelling is finished.
|
||||
|
||||
Each sheet mixes three strata in shuffled order:
|
||||
add - the relaxation additions whose precision we are measuring
|
||||
pos - strict-consistent detections (a mark is really there): labeller sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeller specificity
|
||||
The two control strata are what make a low measured precision trustworthy.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from remove_ai_watermarks import watermark_registry as wr
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
CELL = 240
|
||||
COLS, ROWS = 6, 3
|
||||
PER = COLS * ROWS
|
||||
|
||||
|
||||
def region_for(path: str, key: str) -> tuple[int, int, int, int] | None:
|
||||
"""Re-detect to recover the mark bbox (Candidate carries no region)."""
|
||||
img = imread(path)
|
||||
if img is None:
|
||||
return None
|
||||
mark = next(m for m in wr._REGISTRY if m.key == key)
|
||||
d = mark.detect(img, provenance=True)
|
||||
return d.region if d.region else None
|
||||
|
||||
|
||||
def crop(path: str, region: tuple[int, int, int, int] | None, pad_factor: float = 0.9) -> NDArray[Any] | None:
|
||||
img = imread(path)
|
||||
if img is None:
|
||||
return None
|
||||
h, w = img.shape[:2]
|
||||
if region:
|
||||
x, y, rw, rh = region
|
||||
else:
|
||||
return None
|
||||
px, py = int(rw * pad_factor), int(rh * pad_factor)
|
||||
x0, y0 = max(0, x - px), max(0, y - py)
|
||||
x1, y1 = min(w, x + rw + px), min(h, y + rh + py)
|
||||
c = img[y0:y1, x0:x1]
|
||||
if c.size == 0:
|
||||
return None
|
||||
s = CELL / max(c.shape[0], c.shape[1])
|
||||
return cv2.resize(c, (max(1, int(c.shape[1] * s)), max(1, int(c.shape[0] * s))), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with open(sys.argv[1]) as fh:
|
||||
items = json.load(fh) # [{uid,path,key,stratum,conf}]
|
||||
outdir = Path(sys.argv[2])
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
random.Random(1234).shuffle(items) # noqa: S311 -- sheet ordering, not cryptography
|
||||
|
||||
manifest = []
|
||||
cells = []
|
||||
for it in items:
|
||||
reg = region_for(it["path"], it["key"])
|
||||
c = crop(it["path"], reg)
|
||||
if c is None:
|
||||
continue
|
||||
cells.append((it, c))
|
||||
|
||||
for si in range(0, len(cells), PER):
|
||||
chunk = cells[si : si + PER]
|
||||
sheet = np.full((ROWS * (CELL + 26), COLS * (CELL + 8), 3), 40, np.uint8)
|
||||
for i, (it, c) in enumerate(chunk):
|
||||
r, col = divmod(i, COLS)
|
||||
y0 = r * (CELL + 26) + 22
|
||||
x0 = col * (CELL + 8) + 4
|
||||
sheet[y0 : y0 + c.shape[0], x0 : x0 + c.shape[1]] = c
|
||||
label = f"{si + i:04d}" # index ONLY -- no stratum, no confidence
|
||||
cv2.putText(sheet, label, (x0, y0 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1)
|
||||
manifest.append({"idx": si + i, **it})
|
||||
cv2.imwrite(str(outdir / f"sheet_{si // PER:03d}.png"), sheet)
|
||||
|
||||
with open(outdir / "MANIFEST_DO_NOT_OPEN.csv", "w", newline="") as fh:
|
||||
w = csv.DictWriter(fh, fieldnames=list(manifest[0]))
|
||||
w.writeheader()
|
||||
w.writerows(manifest)
|
||||
print(f"sheets={(len(cells) + PER - 1) // PER} cells={len(cells)} -> {outdir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,21 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks.
|
||||
|
||||
High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
|
||||
import remove_ai_watermarks as raiw
|
||||
raiw.remove_visible("in.png", "out.png") # clean a file (provenance auto)
|
||||
result, removed = raiw.remove_visible(bgr_array) # array -> array
|
||||
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
report = identify("in.png")
|
||||
"""
|
||||
|
||||
import os as _os
|
||||
import warnings as _warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# transformers prints a noisy deprecation for the Siglip2ImageProcessorFast
|
||||
# alias when it is imported (by the optional GPU/ML path). Silence it before
|
||||
@@ -11,4 +25,19 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.10.2"
|
||||
__version__ = "0.19.0"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.api import remove_visible, visible_provenance
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""Lazily resolve the high-level API (PEP 562), so the heavy imports (cv2, the
|
||||
metadata/identify stack) load only when a caller actually reaches for them."""
|
||||
if name in ("remove_visible", "visible_provenance"):
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
return getattr(api, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -1,20 +1,22 @@
|
||||
"""Shared base for the reverse-alpha visible text-mark engines.
|
||||
"""Shared base for the visible text-mark detectors/localizers (localize -> fill).
|
||||
|
||||
The Doubao "豆包AI生成", Jimeng "★ 即梦AI", and Samsung "✦ Contenuti generati
|
||||
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by width-relative
|
||||
geometry, extract the light low-saturation glyph candidate, detect by matching the
|
||||
bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and remove by inverting the
|
||||
alpha blend ``original = (wm - a*logo)/(1-a)`` (always trying fixed AND NCC-aligned
|
||||
placement, keeping the lower-residual one) plus a thin footprint inpaint.
|
||||
dall'AI" marks are the SAME algorithm: anchor a bottom-corner box by geometry
|
||||
relative to the image's SHORT side, extract the light low-saturation glyph candidate (white top-hat), detect
|
||||
by matching the bundled alpha-glyph silhouette via ``TM_CCOEFF_NORMED``, and build a
|
||||
removal MASK from the glyph blob's bounding box (:meth:`footprint_mask`) for the
|
||||
shared fill (region_eraser). The mask is template-FREE -- the top-hat glyph bbox, not
|
||||
a fixed alpha-template placement -- so a re-rendered or differently-placed mark (e.g.
|
||||
a non-Italian Samsung string) is still masked. The old reverse-alpha pixel recovery
|
||||
(``original = (wm - a*logo)/(1-a)``) is gone.
|
||||
|
||||
They differ ONLY in a bounded set of tuned values captured by :class:`TextMarkConfig`:
|
||||
the constants, the bundled asset, the corner (Doubao/Jimeng bottom-right, Samsung
|
||||
bottom-left), and a few structural knobs (the morphology-open kernel size and the
|
||||
minimum glyph width used by the alignment / template-match). Each engine module is a
|
||||
thin :class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
|
||||
the constants, the bundled silhouette asset, the corner (Doubao/Jimeng bottom-right,
|
||||
Samsung bottom-left), and a few structural knobs. Each engine module is a thin
|
||||
:class:`TextMarkEngine` subclass plus the test-facing module constants/helpers.
|
||||
|
||||
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size fixed-slot sparkle
|
||||
model is genuinely different, not a tuned variant of this one.
|
||||
Gemini stays a SEPARATE engine (``gemini_engine``): its multi-size sparkle model is
|
||||
genuinely different, not a tuned variant of this one.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
@@ -37,16 +39,69 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
|
||||
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
|
||||
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
|
||||
# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron --
|
||||
# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely
|
||||
# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256
|
||||
# px and above. A real AI-generation text label is stamped on a full-resolution
|
||||
# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any
|
||||
# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is
|
||||
# skipped (verdict stays "unknown", the safe default) rather than risk a false
|
||||
# positive; removal is gated on detection, so it is suppressed too.
|
||||
_MIN_DETECT_SHORT_SIDE = 200
|
||||
|
||||
# Provenance-confirmed NCC relaxation. When external metadata already confirms the
|
||||
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
|
||||
# glyph that scores just below the standard NCC gate is still trusted. The relaxed
|
||||
# gate is ``detect_ncc_threshold * provenance_ncc_factor``; the coverage gate still
|
||||
# applies on top.
|
||||
#
|
||||
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
|
||||
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
|
||||
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
|
||||
# (accepted with provenance, rejected without) over 4417 unique TC260 carriers,
|
||||
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
|
||||
#
|
||||
# mark band precision 95% CI n
|
||||
# doubao whole arm 76% 61-87% 42
|
||||
# [0.280,0.340) 58% 36-77% 19
|
||||
# [0.340,0.400) 91% 73-98% 23
|
||||
# jimeng whole arm 17% 10-27% 82
|
||||
# [0.315,0.383) 12% 6-22% 68
|
||||
# [0.383,0.450) 43% 21-67% 14
|
||||
#
|
||||
# Doubao stays at 0.70: both its bands return more true marks than false fills, so
|
||||
# tightening would cost 11 genuine recoveries to prevent 8 false ones.
|
||||
#
|
||||
# Jimeng moves to 0.85. Its relaxed detector does not key on the "★ 即梦AI" wordmark
|
||||
# any more -- it keys on "some text in the bottom-right corner": of 68 false
|
||||
# additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问, 百度,
|
||||
# 星绘, 抖音). 45 of those 68 fill a corner nothing else would touch (the other 23
|
||||
# are harmless -- doubao fires strictly there and fills the same box anyway). At
|
||||
# 0.85 the [0.315,0.383) band is dropped: 8 genuine recoveries lost, 60 false fills
|
||||
# prevented (7.5:1). A false fill is the worse error -- it destroys pixels AND makes
|
||||
# the caller report a removal that did not happen, while a miss leaves the image
|
||||
# untouched.
|
||||
#
|
||||
# NOTE: 0.85 is a patch on a detector problem, not a fix. Jimeng's silhouette is not
|
||||
# discriminative against Doubao's (same corner, same script, both ByteDance), and no
|
||||
# threshold repairs that -- it needs a better detection silhouette.
|
||||
_DEFAULT_PROVENANCE_NCC_FACTOR = 0.7
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextMarkConfig:
|
||||
"""All per-mark tuning for a reverse-alpha text-mark engine."""
|
||||
"""All per-mark tuning for a text-mark detector/localizer."""
|
||||
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal["br", "bl"] # bottom-right (Doubao/Jimeng) or bottom-left (Samsung)
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "bc"
|
||||
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of image WIDTH)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
height_frac: float
|
||||
margin_x_frac: float # right margin (br) or left margin (bl)
|
||||
@@ -59,18 +114,44 @@ class TextMarkConfig:
|
||||
# detection
|
||||
detect_min_coverage: float
|
||||
detect_ncc_threshold: float
|
||||
# alpha-map geometry (fraction of WIDTH) emitted by scripts/visible_alpha_solve.py
|
||||
# alpha-map glyph geometry (fraction of scale_base) emitted by
|
||||
# scripts/visible_alpha_solve.py, sizing the detection silhouette for
|
||||
# template_match_score
|
||||
alpha_width_frac: float
|
||||
alpha_height_frac: float
|
||||
alpha_margin_x_frac: float
|
||||
alpha_margin_bottom_frac: float
|
||||
alpha_align_search: tuple[float, float, int] # np.linspace(start, stop, num) scale search
|
||||
min_gw: int # minimum glyph width for the template match / align search (8 br, 16 Samsung)
|
||||
alpha_logo_bgr: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
# residual inpaint over the glyph footprint (thin)
|
||||
residual_alpha_floor: float = 0.05
|
||||
residual_dilate: int = 5
|
||||
residual_inpaint_radius: int = 2
|
||||
min_gw: int # minimum glyph width for the template match (8 br, 16 Samsung)
|
||||
# Asset names of RIVAL marks that occupy the same corner and can therefore be
|
||||
# scored against the same glyph blob. Detection becomes COMPETITIVE: this mark's
|
||||
# template must beat every rival's by `rival_margin`. See _rival_margin_ok.
|
||||
# Detection front-end. "binary" thresholds the top-hat into a glyph blob and
|
||||
# correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
|
||||
# top-hat response against a soft template and never binarizes. See
|
||||
# TextMarkEngine.tophat_response for the measurement that motivated the split.
|
||||
# "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
|
||||
# for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
|
||||
# max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
|
||||
# vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
|
||||
# p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
|
||||
detect_frontend: Literal["binary", "tophat", "gray"] = "binary"
|
||||
# Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
|
||||
template_blur: float = 0.0
|
||||
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
|
||||
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
|
||||
scale_basis: Literal["short", "width"] = "width"
|
||||
# Scale rungs ``_tophat_best`` sweeps (the detection comb). PER-MARK: a vendor
|
||||
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
|
||||
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
|
||||
# on 3 rungs covers only ~75% of them). Densifying the SHARED ladder for everyone
|
||||
# was measured and rejected (false fire 2.52% -> 3.05%; see docs/verification-plan.md
|
||||
# B2), so the default stays the shipped 3 rungs and a deviation must be calibrated
|
||||
# per mark on real positives, never ported.
|
||||
ladder: tuple[float, ...] = (0.8, 1.0, 1.25)
|
||||
rivals: tuple[str, ...] = ()
|
||||
rival_margin: float = 0.10
|
||||
# Multiplier applied to detect_ncc_threshold when provenance confirms the vendor.
|
||||
# Per-mark, NOT shared: see _DEFAULT_PROVENANCE_NCC_FACTOR for the measured
|
||||
# precision that forced the split. Last field so it can carry a default.
|
||||
provenance_ncc_factor: float = _DEFAULT_PROVENANCE_NCC_FACTOR
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -130,18 +211,49 @@ def glyph_silhouette(asset_name: str) -> NDArray[Any] | None:
|
||||
return _silhouette_cache[asset_name]
|
||||
|
||||
|
||||
def template_match_score(box_mask: NDArray[Any], image_width: int, config: TextMarkConfig) -> float:
|
||||
_RIVAL_MODULES = {
|
||||
"doubao_alpha.png": "remove_ai_watermarks.doubao_engine",
|
||||
"jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
|
||||
"samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
|
||||
}
|
||||
|
||||
|
||||
def _rival_config(asset_name: str, fallback: TextMarkConfig) -> TextMarkConfig:
|
||||
"""The rival mark's own config, for scoring its template on a shared blob.
|
||||
|
||||
Looked up LAZILY by asset name: a rival's template geometry
|
||||
(``alpha_*_frac`` / ``min_gw``) is its own, and scoring it with this mark's
|
||||
geometry would compare a correctly-sized template against a mis-sized one and
|
||||
hand the margin a free win. Lazy because the engine modules import this one.
|
||||
"""
|
||||
mod_path = _RIVAL_MODULES.get(asset_name)
|
||||
if mod_path is None:
|
||||
return fallback
|
||||
from importlib import import_module
|
||||
|
||||
try:
|
||||
return import_module(mod_path)._CONFIG
|
||||
except Exception: # a missing/renamed engine must not break detection
|
||||
logger.debug("rival config %s unavailable; skipping its margin check.", asset_name)
|
||||
return fallback
|
||||
|
||||
|
||||
def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig) -> float:
|
||||
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
|
||||
(scaled to the mark's expected size) against the candidate ``box_mask``.
|
||||
|
||||
``TM_CCOEFF_NORMED`` keys on glyph SHAPE, not coverage, so a dense textured
|
||||
corner does not score highly -- only the actual glyph shape does.
|
||||
|
||||
``scale_base`` is the mark's own scaling dimension (:meth:`TextMarkEngine.scale_base`),
|
||||
not always the width: sizing the template on the wrong basis stretches it by the
|
||||
aspect ratio on landscape inputs and the correlation collapses.
|
||||
"""
|
||||
sil = glyph_silhouette(config.asset_name)
|
||||
if sil is None or box_mask.size == 0:
|
||||
return 0.0
|
||||
gw = min(box_mask.shape[1] - 1, max(config.min_gw, int(config.alpha_width_frac * image_width)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * image_width)))
|
||||
gw = min(box_mask.shape[1] - 1, max(config.min_gw, int(config.alpha_width_frac * scale_base)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * scale_base)))
|
||||
if gw < config.min_gw or gh < 4:
|
||||
return 0.0
|
||||
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
@@ -149,7 +261,7 @@ def template_match_score(box_mask: NDArray[Any], image_width: int, config: TextM
|
||||
|
||||
|
||||
class TextMarkEngine:
|
||||
"""Reverse-alpha visible text-mark remover (locate -> mask -> detect -> reverse-alpha)."""
|
||||
"""Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""
|
||||
|
||||
def __init__(self, config: TextMarkConfig) -> None:
|
||||
self.config = config
|
||||
@@ -162,21 +274,216 @@ class TextMarkEngine:
|
||||
def _glyph_silhouette(self) -> NDArray[Any] | None:
|
||||
return glyph_silhouette(self.config.asset_name)
|
||||
|
||||
def _template_match_score(self, box_mask: NDArray[Any], image_width: int) -> float:
|
||||
return template_match_score(box_mask, image_width, self.config)
|
||||
def _template_match_score(self, box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
return template_match_score(box_mask, scale_base, self.config)
|
||||
|
||||
def _rival_margin_ok(self, score: float, box_mask: NDArray[Any], scale_base: int) -> bool:
|
||||
"""Whether this mark's template beats every same-corner RIVAL's on the SAME blob.
|
||||
|
||||
Detection was purely ABSOLUTE -- each engine scored its own template and
|
||||
compared against its own threshold, so nothing ever asked the discriminative
|
||||
question "does this blob look more like the neighbour's mark than like mine?".
|
||||
Two marks sharing a corner and a script (Doubao "豆包AI生成" and Jimeng
|
||||
"★ 即梦AI", both bottom-right, both near-white CJK) survive binarization into
|
||||
very similar blobs, so an absolute gate cannot separate them -- and under the
|
||||
provenance relaxation it stopped trying: 33 of jimeng's 68 false additions
|
||||
were Doubao marks (corpus-measured 2026-07-18).
|
||||
|
||||
Measured separability on hand-labelled examples, scoring BOTH templates
|
||||
against the same glyph blob (n=40 jimeng / 75 doubao / 20 other-vendor labels
|
||||
/ 89 clean):
|
||||
|
||||
feature separability (0.5 = useless, 1.0 = perfect)
|
||||
absolute ncc_jimeng 0.96
|
||||
ncc_jimeng MINUS ncc_doubao 0.99
|
||||
|
||||
At a 0.10 margin: real Jimeng wordmarks pass 100%, Doubao strips 8%, other
|
||||
vendors' AI labels (千问/百度/星绘/抖音) 55%, no-mark corners 12%. Because the
|
||||
real marks pass at 100%, this costs NO recall -- it is a pure precision gain,
|
||||
unlike raising the threshold, which trades recall away.
|
||||
|
||||
Marks with no same-corner rival declare `rivals=()` and are unaffected.
|
||||
"""
|
||||
c = self.config
|
||||
if not c.rivals:
|
||||
return True
|
||||
for rival_asset in c.rivals:
|
||||
rival = _rival_config(rival_asset, c)
|
||||
if score - template_match_score(box_mask, scale_base, rival) < c.rival_margin:
|
||||
logger.debug("%s detect: loses the %s rival margin; rejecting.", c.name, rival_asset)
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Locate ──────────────────────────────────────────────────────────
|
||||
|
||||
def tophat_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
|
||||
"""The CONTINUOUS white top-hat in the located box -- the glyph signal, unbinarized.
|
||||
|
||||
:meth:`extract_mask` thresholds this same response into a 0/255 glyph blob. That
|
||||
is fine for a mark stamped bold and opaque, and destructive for a faint one: a
|
||||
thin translucent overlay shatters into specks under the threshold, and no
|
||||
template can match a blob that is not there.
|
||||
|
||||
Measured 2026-07-18 on hand-verified corpus positives (40 doubao, 14 千问, 60
|
||||
verified-clean), scoring each mark with its own template:
|
||||
|
||||
front-end doubao clean neg AUC doubao/neg
|
||||
binary 0.723 ~0.12 --
|
||||
tophat 0.781 0.122 1.00
|
||||
|
||||
The gates that were hard cuts in the binary path (saturation, absolute luma)
|
||||
become WEIGHTS here, so a faint stroke contributes in proportion to its strength
|
||||
instead of being dropped at a threshold. The response is max-normalized, which
|
||||
makes the score contrast-invariant -- the point of the exercise.
|
||||
|
||||
Kept per-mark (``detect_frontend``) rather than switched globally, because a
|
||||
front-end change must be measured per mark before it ships.
|
||||
"""
|
||||
c = self.config
|
||||
x, y, bw, bh = loc.bbox
|
||||
if bh < 16 or bw < 16:
|
||||
return None
|
||||
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
|
||||
luma = roi.mean(axis=2)
|
||||
sat = roi.max(axis=2) - roi.min(axis=2)
|
||||
sigma = max(4.0, bh * 0.4)
|
||||
tophat = luma - cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
||||
resp = np.clip(tophat, 0, None) * (sat < c.max_saturation)
|
||||
peak = float(resp.max())
|
||||
if peak <= 1e-6:
|
||||
return None
|
||||
return (resp / peak * 255).astype(np.uint8)
|
||||
|
||||
def _tophat_best(
|
||||
self, image: NDArray[Any], loc: TextMarkLocation
|
||||
) -> tuple[float, tuple[int, int, int, int] | None]:
|
||||
"""Best TM_CCOEFF_NORMED of a soft template against the continuous response, and
|
||||
the ROI-local box (x0, y0, x1, y1) where that best match sits.
|
||||
|
||||
Sweeps the mark's scale ladder: the nominal glyph size is derived from the mark's
|
||||
geometry, but a vendor re-rasterization shifts it by a few percent and the
|
||||
continuous response is sharp enough that an exact-size template would miss. The
|
||||
ladder is per-mark (``TextMarkConfig.ladder``), defaulting to the shipped 3 rungs.
|
||||
|
||||
Detection and the removal mask BOTH read this one method -- the score gates
|
||||
detection, the box bounds the fill. Sharing it is deliberate: the standing rule is
|
||||
that detection and the mask use the same front-end, and the way that rule was last
|
||||
broken was a drift between two separate implementations. One method makes the drift
|
||||
impossible instead of merely discouraged.
|
||||
"""
|
||||
c = self.config
|
||||
resp = self.tophat_response(image, loc)
|
||||
sil = self._glyph_silhouette()
|
||||
if resp is None or sil is None:
|
||||
return (0.0, None)
|
||||
base = self.scale_base(image)
|
||||
best_score = 0.0
|
||||
best_box: tuple[int, int, int, int] | None = None
|
||||
for scale in c.ladder:
|
||||
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
|
||||
gh = max(4, int(c.alpha_height_frac * base * scale))
|
||||
if gw >= resp.shape[1] or gh >= resp.shape[0]:
|
||||
continue
|
||||
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA).astype(np.float32)
|
||||
if c.template_blur > 0:
|
||||
tmpl = cv2.GaussianBlur(tmpl, (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur)
|
||||
result = cv2.matchTemplate(resp, tmpl.astype(np.uint8), cv2.TM_CCOEFF_NORMED)
|
||||
_, score, _, top_left = cv2.minMaxLoc(result)
|
||||
if score > best_score:
|
||||
tx, ty = int(top_left[0]), int(top_left[1])
|
||||
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
|
||||
return (best_score, best_box)
|
||||
|
||||
def _tophat_score(self, image: NDArray[Any], loc: TextMarkLocation) -> float:
|
||||
"""The detection score alone -- the box the removal mask needs is discarded here."""
|
||||
return self._tophat_best(image, loc)[0]
|
||||
|
||||
def _gray_best(self, image: NDArray[Any], loc: TextMarkLocation) -> tuple[float, tuple[int, int, int, int] | None]:
|
||||
"""Best TM_CCOEFF_NORMED of the silhouette against the raw GRAYSCALE ROI, and
|
||||
the ROI-local box (x0, y0, x1, y1) of that best match.
|
||||
|
||||
Mirrors :meth:`_tophat_best` (same ladder sweep, same one-method contract so
|
||||
detection and the removal mask can never drift), but skips the top-hat
|
||||
entirely: the RunningHub mark is a faint mid-gray text the top-hat's
|
||||
background subtraction suppresses to clean-arm levels, while raw gray NCC
|
||||
separates (see ``TextMarkConfig.detect_frontend``). Contrast-DEPENDENT by
|
||||
construction, so the gate must be picked against the clean arm, which is
|
||||
what ``scripts/vendor_mark_calibrate.py`` does.
|
||||
"""
|
||||
c = self.config
|
||||
x, y, bw, bh = loc.bbox
|
||||
if bh < 16 or bw < 16:
|
||||
return (0.0, None)
|
||||
roi = cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
|
||||
sil = self._glyph_silhouette()
|
||||
if sil is None:
|
||||
return (0.0, None)
|
||||
base = self.scale_base(image)
|
||||
best_score = 0.0
|
||||
best_box: tuple[int, int, int, int] | None = None
|
||||
for scale in c.ladder:
|
||||
gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
|
||||
gh = max(4, int(c.alpha_height_frac * base * scale))
|
||||
if gw >= roi.shape[1] or gh >= roi.shape[0]:
|
||||
continue
|
||||
tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
|
||||
result = cv2.matchTemplate(roi, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
_, score, _, top_left = cv2.minMaxLoc(result)
|
||||
if score > best_score:
|
||||
tx, ty = int(top_left[0]), int(top_left[1])
|
||||
best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
|
||||
return (best_score, best_box)
|
||||
|
||||
def scale_base(self, image: NDArray[Any]) -> int:
|
||||
"""The image dimension this mark's geometry scales with.
|
||||
|
||||
Per-mark, and MEASURED -- a single shared basis is wrong. The tuned fractions
|
||||
were all calibrated on PORTRAIT captures, where width and short side coincide,
|
||||
so the basis was never exercised until landscape inputs were measured.
|
||||
|
||||
Corpus-measured 2026-07-18 (2572 unique TC260 carriers; the harness is
|
||||
`scripts/visible_eval.py`). Before any fix, doubao detection by aspect ratio:
|
||||
portrait 60%, square 41%, **landscape 0% (0 of 435)** -- the width-scaled box
|
||||
is inflated by the aspect ratio on a wide image and the glyph never lands in
|
||||
it. Re-running the previously-undetected set with a short-side basis recovered
|
||||
**56% of landscape** images (12% square, 4% portrait).
|
||||
|
||||
But the same switch took JIMENG's labelled landscape positives from 13/13 to
|
||||
0/13: its wordmark tracks the WIDTH. Both marks are ByteDance and share a
|
||||
corner, and they still scale differently -- so this is a per-mark measurement,
|
||||
not a house rule to generalize. Samsung keeps ``width`` because there is no
|
||||
corpus evidence either way (1 addition corpus-wide) and an unmeasured change
|
||||
is not an improvement.
|
||||
|
||||
China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the
|
||||
shortest side" for CN marks, which is why a short-side basis is the natural
|
||||
prior -- but Jimeng's measured behaviour overrides the prior, and measurement
|
||||
wins over the standard's wording.
|
||||
"""
|
||||
return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]
|
||||
|
||||
def locate(self, image: NDArray[Any]) -> TextMarkLocation:
|
||||
"""Anchor the watermark box in the configured bottom corner by geometry."""
|
||||
"""Anchor the watermark box in the configured corner, scaled by ``scale_basis``.
|
||||
|
||||
Every fraction is taken against ``scale_base(image)`` -- see
|
||||
:data:`TextMarkConfig.scale_basis`, which is per-mark because the vendors
|
||||
genuinely differ.
|
||||
"""
|
||||
c = self.config
|
||||
h, w = image.shape[:2]
|
||||
wm_w = max(40, int(w * c.width_frac))
|
||||
wm_h = max(16, int(w * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(w * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(w * c.margin_bottom_frac))
|
||||
x = max(0, w - margin_x - wm_w) if c.corner == "br" else min(margin_x, max(0, w - wm_w))
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
base = self.scale_base(image)
|
||||
wm_w = max(40, int(base * c.width_frac))
|
||||
wm_h = max(16, int(base * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
if c.corner == "br":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
@@ -184,20 +491,27 @@ class TextMarkEngine:
|
||||
# ── Mask ────────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any]:
|
||||
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
|
||||
"""Build a box-sized uint8 mask (255 = watermark glyph) for ``loc``.
|
||||
|
||||
Returns just the glyph mask of the located box (shape ``(loc.h, loc.w)``),
|
||||
not a full-frame array: every caller immediately crops to ``loc.bbox``, so
|
||||
allocating a full ``(h, w)`` mask and embedding the box was O(image) work
|
||||
and memory for an O(box) result -- a wasted full-frame uint8 allocation on
|
||||
each detect (~12 MB on a 12 MP frame, recomputed per text-mark detector on
|
||||
the memory-tight identify path). The box mask is byte-identical to the old
|
||||
full-frame mask cropped to ``loc.bbox``.
|
||||
|
||||
Polarity-aware: the mark is a light, low-saturation gray rendered brighter
|
||||
than the local background (white top-hat), so a white-paper document is left
|
||||
untouched (nothing brighter than its surroundings is masked there).
|
||||
"""
|
||||
c = self.config
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
|
||||
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
|
||||
# which can fault native code on some platforms. Skip the cv2 pipeline.
|
||||
if bh < 16 or bw < 16:
|
||||
return np.zeros((h, w), np.uint8)
|
||||
return np.zeros((bh, bw), np.uint8)
|
||||
# Normalize the ROI to 3-channel BGR (grayscale / BGRA would break axis=2).
|
||||
roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
|
||||
|
||||
@@ -216,134 +530,151 @@ class TextMarkEngine:
|
||||
glyph = cand.astype(np.uint8) * 255
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
k = c.morph_open_size
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
|
||||
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y : y + bh, x : x + bw] = glyph
|
||||
return mask
|
||||
return cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))
|
||||
|
||||
# ── Detect ──────────────────────────────────────────────────────────
|
||||
|
||||
def detect(self, image: NDArray[Any]) -> TextMarkDetection:
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
"""Detect the mark by matching the alpha-template glyph silhouette against
|
||||
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage."""
|
||||
the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.
|
||||
|
||||
``provenance`` signals that external metadata already confirms this vendor
|
||||
(China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
|
||||
NCC gate exists to keep a corner texture on an UNRELATED image from matching
|
||||
the glyph silhouette, so when provenance confirms the vendor it is relaxed by
|
||||
the mark's own ``provenance_ncc_factor`` to recover a faint or slightly
|
||||
re-rendered mark (per-mark, not shared -- see _DEFAULT_PROVENANCE_NCC_FACTOR).
|
||||
"""
|
||||
c = self.config
|
||||
det = TextMarkDetection()
|
||||
if image is None or image.size == 0:
|
||||
return det
|
||||
# Guard against the small-image NCC-noise false positive (see
|
||||
# _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
|
||||
# text label, and the degraded few-pixel template spuriously correlates.
|
||||
if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE:
|
||||
logger.debug(
|
||||
"%s detect: image short side %d < %d; too small to carry the mark, skipping.",
|
||||
c.name,
|
||||
min(image.shape[:2]),
|
||||
_MIN_DETECT_SHORT_SIDE,
|
||||
)
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
mask = self.extract_mask(image, loc)
|
||||
x, y, bw, bh = loc.bbox
|
||||
box = mask[y : y + bh, x : x + bw]
|
||||
box = self.extract_mask(image, loc) # box-sized mask (== old full-frame cropped to bbox)
|
||||
_x, _y, bw, bh = loc.bbox
|
||||
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
||||
det.region = loc.bbox
|
||||
det.coverage = coverage
|
||||
if coverage >= c.detect_min_coverage:
|
||||
score = self._template_match_score(box, image.shape[1])
|
||||
if c.detect_frontend == "tophat":
|
||||
# The continuous front-end does not depend on the binarized blob, so the
|
||||
# coverage gate (a blob-area heuristic) does not apply to it.
|
||||
score = self._tophat_score(image, loc)
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= c.detect_ncc_threshold
|
||||
logger.debug("%s detect: coverage=%.3f ncc=%.2f detected=%s", c.name, coverage, score, det.detected)
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (tophat): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if c.detect_frontend == "gray":
|
||||
# Same no-coverage-gate reasoning as tophat: the gray front-end never
|
||||
# binarizes, so a blob-area heuristic does not apply to it either.
|
||||
score = self._gray_best(image, loc)[0]
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug("%s detect (gray): ncc=%.2f thr=%.2f detected=%s", c.name, score, threshold, det.detected)
|
||||
return det
|
||||
if coverage >= c.detect_min_coverage:
|
||||
score = self._template_match_score(box, self.scale_base(image))
|
||||
threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
|
||||
det.confidence = score
|
||||
det.detected = score >= threshold and self._rival_margin_ok(score, box, self.scale_base(image))
|
||||
logger.debug(
|
||||
"%s detect: coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
|
||||
c.name,
|
||||
coverage,
|
||||
score,
|
||||
threshold,
|
||||
det.detected,
|
||||
)
|
||||
return det
|
||||
|
||||
# ── Reverse-alpha (recovery + thin residual inpaint) ────────────────
|
||||
# ── Inpaint footprint (for the inpaint-fallback removal path) ────────
|
||||
|
||||
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
|
||||
"""True if the bundled alpha map is loadable (NCC alignment places it at any
|
||||
resolution; the caller still gates on ``detect`` so a clean corner is untouched)."""
|
||||
return image is not None and image.size > 0 and self._alpha_template() is not None
|
||||
# Minimum glyph pixels for a template-free footprint. Below this the corner has
|
||||
# no real wordmark (a few top-hat specks), so without ``force`` there is nothing
|
||||
# to mask. A real strip covers hundreds of pixels.
|
||||
_MIN_GLYPH_PIXELS = 20
|
||||
|
||||
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Place the template by fixed width-relative geometry (pixel-exact at the
|
||||
captured width)."""
|
||||
c = self.config
|
||||
at = self._alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
# Clamp both dims so a wide/short image cannot overflow the slice assignment.
|
||||
gw = min(w, max(1, int(c.alpha_width_frac * w)))
|
||||
gh = min(h, max(1, int(c.alpha_height_frac * w)))
|
||||
if c.corner == "br":
|
||||
ax = max(0, w - int(c.alpha_margin_x_frac * w) - gw)
|
||||
else: # bottom-left
|
||||
ax = min(max(0, int(c.alpha_margin_x_frac * w)), max(0, w - gw))
|
||||
ay = max(0, h - int(c.alpha_margin_bottom_frac * w) - gh)
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
|
||||
fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.
|
||||
|
||||
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Register the captured template to the actual mark via a TM_CCOEFF_NORMED
|
||||
scale + position search. Returns ``(alpha_map, glyph_bbox)`` or None."""
|
||||
c = self.config
|
||||
at = self._alpha_template()
|
||||
sil = self._glyph_silhouette()
|
||||
if at is None or sil is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
|
||||
expected = c.alpha_width_frac * w
|
||||
best: tuple[float, int, int, int, int] | None = None
|
||||
for scale in np.linspace(*c.alpha_align_search):
|
||||
gw, gh = int(expected * scale), int(c.alpha_height_frac * w * scale)
|
||||
if gw < c.min_gw or gh < 4 or gw >= bw or gh >= bh:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
|
||||
if best is None or score > best[0]:
|
||||
best = (score, gw, gh, top_left[0], top_left[1])
|
||||
if best is None:
|
||||
return None
|
||||
_, gw, gh, ox, oy = best
|
||||
ax, ay = bx + ox, by + oy
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
|
||||
take its bounding box in the corner, and fill that box solid (plus a small
|
||||
margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
|
||||
strokes -- is what makes it robust: the top-hat under-segments individual
|
||||
strokes (which used to leave a "三包"-style residual ghost when the strokes
|
||||
themselves were the mask), but the inpaint reconstructs the whole wordmark
|
||||
rectangle from its surroundings, so a stroke missed by the top-hat is still
|
||||
covered. This drops the fixed alpha-template dependency, so a re-rendered or
|
||||
differently-localized mark (e.g. a non-Italian Samsung string) is still masked.
|
||||
|
||||
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
|
||||
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
|
||||
logo = np.array(self.config.alpha_logo_bgr, np.float32)
|
||||
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
|
||||
|
||||
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
|
||||
"""Recover the original pixels by inverting the alpha blend, then clear the
|
||||
residual outline with a thin inpaint over the glyph footprint.
|
||||
|
||||
Placement: fixed geometry AND the NCC-aligned placement are always tried and
|
||||
the one leaving the least residual mark (lowest re-``detect`` confidence) is
|
||||
kept -- the mark re-rasterizes a few px per image, so fixed geometry alone is
|
||||
not reliable. A single capture cannot pixel-cancel the mark on every image, so
|
||||
a deliberately THIN residual inpaint (``residual_*``) follows: reverse-alpha
|
||||
has already recovered the true background under the mark, so the inpaint only
|
||||
finishes the residual edges instead of smearing the whole footprint. Call only
|
||||
when :meth:`reverse_alpha_available` and the mark is detected.
|
||||
With ``force`` and no glyph found, falls back to the whole geometry box (the
|
||||
``--no-detect`` path). The caller gates on detection.
|
||||
"""
|
||||
c = self.config
|
||||
# Normalize to 3-channel BGR (the reverse-alpha math assumes a 3-channel logo).
|
||||
if image is None or image.size == 0:
|
||||
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
|
||||
image = image_io.to_bgr(image)
|
||||
# An image too small to hold the mark would make the geometry boxes degenerate
|
||||
# and feed cv2.resize a ~1-px-tall target; skip cv2 entirely.
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return image.copy()
|
||||
maps = [m for m in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if m is not None]
|
||||
if not maps:
|
||||
return image.copy()
|
||||
best_out: NDArray[Any] | None = None
|
||||
best_amap: NDArray[Any] | None = None
|
||||
best_residual = float("inf")
|
||||
for amap, _region in maps:
|
||||
out = self._apply_reverse_alpha(image, amap)
|
||||
residual = self.detect(out).confidence
|
||||
if residual < best_residual:
|
||||
best_residual, best_out, best_amap = residual, out, amap
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
if residual_inpaint:
|
||||
kernel = np.ones((c.residual_dilate, c.residual_dilate), np.uint8)
|
||||
rm = cv2.dilate((best_amap > c.residual_alpha_floor).astype(np.uint8) * 255, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, c.residual_inpaint_radius, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
glyph = self.extract_mask(image, loc) # box-sized, 255 = glyph
|
||||
ys, xs = np.where(glyph > 0)
|
||||
box: tuple[int, int, int, int] | None = None
|
||||
if self.config.detect_frontend == "gray" and self.detect(image).detected:
|
||||
# The gray front-end exists for marks the top-hat under-segments, so the
|
||||
# binary blob is NOT authoritative here: trusting it first bounded the
|
||||
# fill by a PARTIAL blob (the faint head glyphs dropped out) and left the
|
||||
# leftmost "Runni" of "RunningHub AI生成" unremoved (2026-07-22). Use the
|
||||
# detector's own best-match box, same as the tophat faint path below.
|
||||
_, box = self._gray_best(image, loc)
|
||||
elif xs.size >= self._MIN_GLYPH_PIXELS:
|
||||
box = (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()))
|
||||
elif self.config.detect_frontend == "tophat" and self.detect(image).detected:
|
||||
# A mark found only by the CONTINUOUS front-end has no binary glyph blob to
|
||||
# bound, so the mask came back empty and removal was a silent no-op while
|
||||
# `identify` still reported the mark (corpus-measured 2026-07-20: 57 of 60
|
||||
# sampled still-detected Doubao marks were untouched, ~8% of its detections).
|
||||
# Use the DETECTOR'S OWN best-match box: the correlation already located the
|
||||
# mark at a position and scale, and thresholding the response was a strictly
|
||||
# worse proxy for that. An earlier fix thresholded the max-normalized uint8
|
||||
# response at 0.5 -- which selects every non-zero pixel, not "half the peak" as
|
||||
# its comment claimed -- and filled ~120% of the corner box on textured frames
|
||||
# (measured: whole corner vs 58.7% for the match box, both detector-clean).
|
||||
# Gated on an actual detection: on a clean corner the box would be spurious.
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is not None:
|
||||
gx0, gy0, gx1, gy1 = box
|
||||
pad = max(4, int(0.10 * bh))
|
||||
rx1 = max(0, bx + gx0 - pad)
|
||||
rx2 = min(w, bx + gx1 + 1 + pad)
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
elif force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
return None
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
# Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
|
||||
# same primitive the shared fill uses); reuse it instead of re-inlining the
|
||||
# zeros/fill/MORPH_ELLIPSE-dilate here.
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""High-level convenience API: clean an image (or array) in one call.
|
||||
|
||||
The low-level building blocks live in ``watermark_registry`` (localize -> fill) and
|
||||
``image_io`` (Unicode-safe, alpha-preserving IO). This module ties them into the two
|
||||
calls a caller usually wants, so a library user does not have to decode images, wire
|
||||
up metadata provenance, or preserve the alpha channel by hand:
|
||||
|
||||
import remove_ai_watermarks as raiw
|
||||
raiw.remove_visible("in.png", "out.png") # path -> file, provenance auto
|
||||
result, removed = raiw.remove_visible(bgr_array) # array -> array
|
||||
raiw.remove_visible("shot.png", "out.png", sensitivity="strict")
|
||||
raiw.visible_provenance("in.png") # -> frozenset({"gemini"})
|
||||
|
||||
Imports stay lazy (inside the functions), so ``import remove_ai_watermarks`` is cheap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import Backend, Sensitivity
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _VisibleInput:
|
||||
"""Normalized visible-removal input with its file-only context."""
|
||||
|
||||
bgr: NDArray[Any]
|
||||
alpha: NDArray[Any] | None = None
|
||||
path: Path | None = None
|
||||
provenance: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def visible_provenance(source: str | Path) -> frozenset[str]:
|
||||
"""Vendor keys that the file's local metadata confirms, the evidence that drives
|
||||
the ``auto`` sensitivity (relaxing a corroborated mark's detection trust gate).
|
||||
|
||||
Mapping: a Google/Gemini C2PA issuer -> ``"gemini"``; a China-AIGC (TC260) label
|
||||
-> ``"doubao"``/``"jimeng"``; a ``samsung_genai`` marker -> ``"samsung"``.
|
||||
Best-effort: any read error yields an empty set (no relaxation). Metadata-only, so
|
||||
it never loads cv2/torch.
|
||||
"""
|
||||
import contextlib
|
||||
|
||||
path = Path(source)
|
||||
with contextlib.suppress(Exception):
|
||||
from remove_ai_watermarks import identify
|
||||
|
||||
rep = identify.identify(path, check_visible=False, check_invisible=False)
|
||||
signal_names = {signal.name for signal in rep.signals}
|
||||
keys: set[str] = set()
|
||||
platform = (rep.platform or "").lower()
|
||||
if "google" in platform or "gemini" in platform:
|
||||
keys.add("gemini")
|
||||
if "aigc" in signal_names:
|
||||
keys |= {"doubao", "jimeng"}
|
||||
if "samsung_genai" in signal_names:
|
||||
keys.add("samsung")
|
||||
return frozenset(keys)
|
||||
return frozenset()
|
||||
|
||||
|
||||
def _load_visible_input(source: str | Path | NDArray[Any]) -> _VisibleInput:
|
||||
"""Normalize a path/array source without making the public operation stateful."""
|
||||
if not isinstance(source, (str, Path)):
|
||||
return _VisibleInput(source)
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
path = Path(source)
|
||||
bgr, alpha = image_io.read_bgr_and_alpha(path)
|
||||
if bgr is None:
|
||||
raise ValueError(f"Could not read image: {source}")
|
||||
return _VisibleInput(bgr=bgr, alpha=alpha, path=path, provenance=visible_provenance(path))
|
||||
|
||||
|
||||
def _write_visible_result(
|
||||
loaded: _VisibleInput,
|
||||
result: NDArray[Any],
|
||||
removed: list[str],
|
||||
output: str | Path,
|
||||
*,
|
||||
strip_metadata: bool,
|
||||
write_noop: bool,
|
||||
) -> None:
|
||||
"""Write one visible-removal result while preserving a true no-op losslessly."""
|
||||
if not removed and not write_noop:
|
||||
return
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
out_path = Path(output)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source_path = loaded.path
|
||||
if not removed and source_path is not None and source_path.suffix.lower() == out_path.suffix.lower():
|
||||
# Copy the ORIGINAL bytes instead of lossily re-encoding a no-op. An in-place
|
||||
# call needs no copy and would otherwise raise shutil.SameFileError.
|
||||
if source_path.resolve() != out_path.resolve():
|
||||
import shutil
|
||||
|
||||
shutil.copyfile(source_path, out_path)
|
||||
else:
|
||||
# imwrite is contractually NON-RAISING, so this bool is the only signal the file
|
||||
# was not created. Unchecked, the metadata strip below ran on a nonexistent path
|
||||
# and surfaced as a confusing "cannot read image <INPUT>" naming the OUTPUT path
|
||||
# (Tier E, 2026-07-20). Raise here so a library caller and the CLI both get an
|
||||
# accurate message about the write.
|
||||
if not image_io.write_bgr_with_alpha(out_path, result, loaded.alpha):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {out_path}")
|
||||
|
||||
if strip_metadata:
|
||||
from remove_ai_watermarks import metadata
|
||||
|
||||
metadata.remove_ai_metadata(out_path, out_path)
|
||||
|
||||
|
||||
def remove_visible(
|
||||
source: str | Path | NDArray[Any],
|
||||
output: str | Path | None = None,
|
||||
*,
|
||||
sensitivity: Sensitivity = "auto",
|
||||
backend: Backend = "auto",
|
||||
strip_metadata: bool = True,
|
||||
write_noop: bool = True,
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/
|
||||
Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr,
|
||||
[labels removed])``.
|
||||
|
||||
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
|
||||
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
|
||||
file still carries its provenance) and the alpha channel is preserved on write; for
|
||||
an ARRAY there is no metadata to read and no separate alpha plane. When ``output``
|
||||
is given the cleaned image is written there (alpha rejoined for a path source); the
|
||||
array is always returned as well, so an empty ``removed`` list tells a caller nothing
|
||||
known was found (e.g. route to the diffusion ``all`` path or ``erase``).
|
||||
|
||||
``sensitivity`` (``auto``/``strict``) and ``backend``
|
||||
(``auto``/``cv2``/``migan``/``lama``) are the same knobs as the CLI. Pass
|
||||
corner if the guess is wrong).
|
||||
|
||||
``strip_metadata`` (default True, matching the CLI ``visible --strip-metadata``)
|
||||
also strips AI provenance metadata (C2PA/EXIF/XMP/IPTC) from the written output via
|
||||
the lossless :func:`metadata.remove_ai_metadata`, so a library call does exactly
|
||||
what the CLI does. Only applies when ``output`` is given.
|
||||
|
||||
``write_noop`` (default True) controls whether ``output`` is written when NOTHING was
|
||||
removed: True writes a clean passthrough copy (an idempotent clean); False leaves the
|
||||
output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI
|
||||
``visible`` no-mark contract) does not clobber a pre-existing file at that path.
|
||||
"""
|
||||
from remove_ai_watermarks import watermark_registry
|
||||
|
||||
# Reject a removed sensitivity loudly; `Sensitivity` is a Literal and not enforced
|
||||
# at runtime, so a 0.15 caller would otherwise get `auto` behaviour in silence.
|
||||
watermark_registry.validate_sensitivity(sensitivity)
|
||||
loaded = _load_visible_input(source)
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
loaded.bgr,
|
||||
sensitivity=sensitivity,
|
||||
provenance=loaded.provenance,
|
||||
backend=backend,
|
||||
)
|
||||
if output is not None:
|
||||
_write_visible_result(
|
||||
loaded,
|
||||
result,
|
||||
removed,
|
||||
output,
|
||||
strip_metadata=strip_metadata,
|
||||
write_noop=write_noop,
|
||||
)
|
||||
return result, removed
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,203 @@
|
||||
"""Baidu visible watermark detector/localizer.
|
||||
|
||||
Baidu stamps its generations with a white bold "百度" text run plus a separate
|
||||
white rounded tag carrying dark "AI生成", bottom-right -- the China TC260
|
||||
explicit AIGC label. Detection keys on the **百度 text run only**: a
|
||||
two-component template (text + pill tag) was measured and REJECTED -- the solid
|
||||
white pill is a bright-blob magnet and both front-ends scored the clean arm at
|
||||
cohort levels (tophat clean p95 0.445 / gray clean p95 0.487 vs cohort ~0.5,
|
||||
2026-07-22). The text-only silhouette separates cleanly (below). The white tag
|
||||
is still removed with the mark: the fill blob covers both bright components in
|
||||
the corner box.
|
||||
|
||||
Removal is the shared **localize -> fill** (:meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only Baidu's tuned
|
||||
:class:`TextMarkConfig` (``assets/baidu_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
Measured on the vendor cohort (16 TC260 carriers whose producer USCC
|
||||
91110000802100433B names Baidu, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The 百度 text run is 0.090 of the SHORT side wide (measured on 720/768/
|
||||
1024-px frames), with its right edge ~0.099 of short off the right edge
|
||||
(the pill tag sits between the text and the corner), bottom margin
|
||||
~0.006; the locate box below covers the whole mark (text + tag).
|
||||
* Gate 0.43 (tophat front-end): on 278 hand-labelled clean frames
|
||||
(cohort-contamination-guarded) the max is 0.352 / p99 0.314, and the
|
||||
visibly-marked cohort frames score 0.386-0.65. Picked over the clean-arm
|
||||
0.37 after a full-corpus check on the 741-frame blind-labelled eval set
|
||||
surfaced 13 cross-fires at 0.38-0.43 (12 Qwen marks + one 抖音 mark) --
|
||||
see DETECT_NCC_THRESHOLD below. At 0.43 the cohort keeps 7 detections
|
||||
(0.61-0.65) and the whole 741 set fires only on the true Baidu frame.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the cohort is small (16) and
|
||||
the sub-gate band is unmeasured, so no provenance relaxation exists.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (measured basis). The
|
||||
# box covers the text run AND the pill tag to its right (tag right edge ~0.002
|
||||
# off the frame edge, text run left edge ~0.19 off).
|
||||
WM_WIDTH_FRAC = 0.25
|
||||
WM_HEIGHT_FRAC = 0.07
|
||||
MARGIN_RIGHT_FRAC = 0.002
|
||||
MARGIN_BOTTOM_FRAC = 0.002
|
||||
|
||||
# Glyph appearance: white bold text on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 278 hand-labelled clean frames
|
||||
# (clean p99 0.314 / max 0.352), THEN raised 0.37 -> 0.43 after a full-corpus
|
||||
# check: on the 741-frame blind-labelled eval set the 0.37 gate fired 14 times
|
||||
# outside the cohort, and only ONE was the vendor -- 12 were 千问AI生成 (Qwen)
|
||||
# marks (the 百/千 first glyphs are near-identical after binarization) and one
|
||||
# was a 抖音 AI创作 mark at 0.425. The Qwen fires are handled by the rival
|
||||
# margin (Qwen scores 0.58-0.76 there, beating Baidu by 0.17-0.35), but the
|
||||
# 抖音 one named no registered rival, so the gate moved above it. Cost: the
|
||||
# cohort's low trio at 0.386 (3 genuine marks) -- precision over recall on a
|
||||
# small cohort. Raised again 0.43 -> 0.48 after the full-corpus sweep
|
||||
# (2026-07-22): outside-cohort true Baidu carriers score 0.50-0.66 while the
|
||||
# false fires (大众点评 UI, a math blackboard, an 80s banner, a checkerboard)
|
||||
# top out at 0.47. Remaining cohort detections: 7 at 0.61-0.65, plus the 6
|
||||
# metadata-stripped true carriers the cohort cannot see.
|
||||
DETECT_NCC_THRESHOLD = 0.48
|
||||
|
||||
# Detection-silhouette geometry (fraction of the short side): the 百度 text run
|
||||
# only, measured 0.090 wide with aspect 0.51.
|
||||
_ALPHA_WIDTH_FRAC = 0.090
|
||||
_ALPHA_HEIGHT_FRAC = 0.046
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine), so the
|
||||
# nominal sits exactly on the measured 0.090 with +-5% rungs.
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Baidu",
|
||||
asset_name="baidu_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# Load-bearing rival margins (crossfire measured 2026-07-22): the 百度 and
|
||||
# 豆包 silhouettes share their second glyph and a similar first, and 百度 vs
|
||||
# 千问 are near-identical after binarization -- at the 0.37 gate this
|
||||
# template fires on 45.8% of 400 Doubao-marked frames AND on Qwen-marked
|
||||
# frames at 0.38-0.43. Doubao's template beats it by ~0.56 on Doubao marks,
|
||||
# Qwen's by 0.17-0.35 on Qwen marks, so the 0.10 margin suppresses all of
|
||||
# that crossfire at zero genuine-Baidu cost (cohort fire+m == fire).
|
||||
rivals=("doubao_alpha.png", "qwen_alpha.png"),
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
BaiduDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Baidu alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "百度" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Baidu glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class BaiduEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Baidu "百度 AI生成" mark (bottom-right; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the WHOLE mark (text run + the pill tag to its right).
|
||||
|
||||
The base class's blob-bbox footprint UNDERCOVERS this mark: the white tag's
|
||||
flat interior gives no top-hat response (a top-hat answers edges, not flats),
|
||||
so the blob ends at the text run and the fill leaves the tag's right half as
|
||||
a ghost (measured 2026-07-22 on the 768x1024 cohort frame: blob bbox x
|
||||
632..746 vs the tag ending ~758). The layout is measured and fixed -- the
|
||||
text run is at the left of the locate box, the tag runs to the corner -- so
|
||||
the footprint is the detector's match box extended RIGHT to the corner.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, _gx1, gy1 = box
|
||||
pad = max(4, int(0.15 * bh))
|
||||
rx1 = max(0, bx + gx0 - pad)
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + bw) # the tag runs to the corner end of the box
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Doubao visible watermark removal engine.
|
||||
"""Doubao visible watermark detector/localizer.
|
||||
|
||||
Doubao (ByteDance) stamps every generated image with a visible "豆包AI生成"
|
||||
(Doubao AI generated) text strip in the bottom-right corner -- the explicit AIGC
|
||||
label mandated by China's TC260 standard, a near-white semi-transparent overlay.
|
||||
|
||||
Removal is **reverse-alpha blending** against a captured alpha map
|
||||
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a
|
||||
thin residual inpaint over the glyph footprint. This is one of the three text-mark
|
||||
engines that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`;
|
||||
this module supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
|
||||
``assets/doubao_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py``). Arbitrary-
|
||||
region inpainting still lives in ``region_eraser`` / the ``erase`` command.
|
||||
Detection matches the bundled glyph silhouette against the corner candidate; removal
|
||||
is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
|
||||
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
|
||||
supplies only Doubao's tuned :class:`TextMarkConfig` (bottom-right corner,
|
||||
``assets/doubao_alpha.png`` -- the detection silhouette, rebuilt by
|
||||
``scripts/visible_alpha_solve.py``). Arbitrary-region inpainting still lives in
|
||||
``region_eraser`` / the ``erase`` command.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
@@ -46,21 +47,28 @@ TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
|
||||
# Shape-consistent detection: match the bundled alpha glyph silhouette against the
|
||||
# corner candidate via TM_CCOEFF_NORMED (keys on glyph SHAPE, not coverage; #23).
|
||||
DETECT_MIN_COVERAGE = 0.04
|
||||
DETECT_NCC_THRESHOLD = 0.4
|
||||
# NOTE: this gate is FRONT-END SPECIFIC. The continuous top-hat front-end scores higher
|
||||
# overall than the binary one (mean 0.809 vs 0.723 on the same 90 positives), so the
|
||||
# binary-era 0.40 left the provenance-relaxed gate (x0.7) far too low and admitted false
|
||||
# fires. Calibrated on the 240-image unbiased recall sample, full auto path:
|
||||
#
|
||||
# gate relaxed recall precision true false
|
||||
# 0.40 0.280 96% 91% 86 8
|
||||
# 0.45 0.315 94% 93% 85 6
|
||||
# 0.50 0.350 92% 99% 83 1 <- chosen
|
||||
# 0.60 0.420 87% 99% 78 1
|
||||
#
|
||||
# 0.50 beats the binary front-end on recall (92% vs 89%) at identical precision (99%),
|
||||
# which is the only reason the front-end switch is worth it. Do not port this number to
|
||||
# a binary-front-end mark; re-calibrate per front-end.
|
||||
DETECT_NCC_THRESHOLD = 0.50
|
||||
|
||||
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py at the captured
|
||||
# width. Removal always tries fixed AND NCC-aligned placement and keeps the lower
|
||||
# residual, then a thin footprint inpaint clears the leftover edges.
|
||||
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py at the
|
||||
# captured width. Sizes the glyph silhouette for the TM_CCOEFF_NORMED detection match
|
||||
# (removal is the template-free glyph-bbox footprint mask, not this template).
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- sizes the detection silhouette
|
||||
_ALPHA_HEIGHT_FRAC = 0.0405
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0132
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0166
|
||||
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 25)
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Doubao",
|
||||
@@ -77,16 +85,15 @@ _CONFIG = TextMarkConfig(
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short", # measured: recovers 56% of landscape misses (see scale_base)
|
||||
# No rival margin: measured 2026-07-18, the symmetric gate cost Doubao 7 genuine
|
||||
# detections to prevent 5 false ones (1.4:1 against). Doubao's absolute detector
|
||||
# is already 86% precise, so it has nothing to buy; Jimeng's is 38% and gains 25pp
|
||||
# for free. The confusion is asymmetric, so the remedy is too.
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
|
||||
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
alpha_align_search=_ALPHA_ALIGN_SEARCH,
|
||||
min_gw=8,
|
||||
alpha_logo_bgr=_ALPHA_LOGO_BGR,
|
||||
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
|
||||
residual_dilate=_RESIDUAL_DILATE,
|
||||
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
|
||||
)
|
||||
|
||||
# Doubao-specific aliases for the shared detection result/engine.
|
||||
@@ -103,13 +110,13 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Doubao glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class DoubaoEngine(TextMarkEngine):
|
||||
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> reverse-alpha)."""
|
||||
"""Detect/localize the visible Doubao "豆包AI生成" watermark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Gemini visible watermark removal engine.
|
||||
"""Gemini visible-sparkle detector and localizer (cv2/numpy, no GPU).
|
||||
|
||||
Port of the GeminiWatermarkTool reverse-alpha-blending algorithm from C++ to Python.
|
||||
Original author: Allen Kuo (allenk) — https://github.com/allenk/GeminiWatermarkTool
|
||||
Locates the Google Gemini / Nano Banana sparkle so the shared fill (region_eraser)
|
||||
can inpaint it. Detection is a multi-scale NCC search against the captured sparkle
|
||||
alpha template (ported from GeminiWatermarkTool's Snap Engine; original author
|
||||
Allen Kuo (allenk), https://github.com/allenk/GeminiWatermarkTool), scored by a
|
||||
spatial + gradient + variance fusion with a false-positive gate. ``footprint_mask``
|
||||
returns the sparkle footprint (captured alpha thresholded low to include the halo,
|
||||
then dilated) as a full-frame mask for the fill.
|
||||
|
||||
The Gemini AI watermark is applied using alpha blending:
|
||||
watermarked = a * logo + (1 - a) * original
|
||||
|
||||
We reverse this to recover the original:
|
||||
original = (watermarked - a * logo) / (1 - a)
|
||||
|
||||
The alpha maps are derived from background captures of the Gemini watermark
|
||||
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
|
||||
The captured alpha maps are background captures of the sparkle on pure-black
|
||||
backgrounds (48x48 for small images, 96x96 for large). NB: they are used here only
|
||||
to DETECT and to shape the removal mask -- the old reverse-alpha pixel recovery
|
||||
(``original = (watermarked - a*logo)/(1-a)``) is gone; removal is localize -> fill.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
|
||||
@@ -19,11 +20,12 @@ on pure-black backgrounds (48x48 for small images, 96x96 for large images).
|
||||
# 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, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -125,39 +127,23 @@ def _load_embedded_asset(name: str) -> NDArray[Any]:
|
||||
return img
|
||||
|
||||
|
||||
class GeminiEngine:
|
||||
"""Engine for removing visible Gemini watermarks via reverse alpha blending.
|
||||
# Single source of truth for the multi-scale template ladder (aggressively downscaled to
|
||||
# slightly upscaled): the precomputed `_tmpl_cache` and the `_scan_scales` loop must use
|
||||
# the SAME scales or a scan scale would miss the cache and KeyError.
|
||||
_TEMPLATE_SCALES: tuple[int, ...] = tuple(range(16, 120, 2))
|
||||
|
||||
This is a Python port of the GeminiWatermarkTool C++ engine.
|
||||
|
||||
class GeminiEngine:
|
||||
"""Detects and localizes the visible Gemini sparkle for the shared fill removal.
|
||||
|
||||
The multi-scale NCC detection is a Python port of the GeminiWatermarkTool C++
|
||||
Snap Engine; ``footprint_mask`` turns a detection into a removal mask.
|
||||
"""
|
||||
|
||||
# Footprint pixels with alpha at/above this are the sparkle body; below it the
|
||||
# mark barely affects the pixel, so those are excluded from both the
|
||||
# over-subtraction test and the inpaint mask.
|
||||
_FOOTPRINT_ALPHA = 0.1
|
||||
# If more than this fraction of footprint pixels over-subtract (numerator < 0),
|
||||
# the fixed alpha does not match this image's sparkle and reverse-alpha would
|
||||
# punch a dark pit -- inpaint instead. demo_banana measures 0.0 (reverse-alpha
|
||||
# kept), the issue #30 dark-grass image measures ~0.61 (inpaint), so the 0.05
|
||||
# gate separates them with a wide margin.
|
||||
_OVERSUB_FOOTPRINT_FRAC = 0.05
|
||||
|
||||
# Per-image alpha gain (under-subtraction fix). The captured alpha peaks ~0.51
|
||||
# (a ~51%-opaque sparkle). Some real Gemini sparkles are rendered MORE opaque,
|
||||
# so the fixed alpha under-subtracts and reverse-alpha leaves a bright residual
|
||||
# the detector still fires on (~11% of marks on the spaces corpus). Estimate
|
||||
# this image's effective sparkle opacity from the bright core vs the local
|
||||
# background and scale the alpha to match, capped so alpha stays < 0.99. The
|
||||
# gain is clamped to >= 1.0 so it only ever STRENGTHENS removal: ~1.0 when the
|
||||
# sparkle matches the capture (working cases unchanged), >1 when more opaque.
|
||||
# On the spaces corpus the gain cleanly separates -- under-removed marks ~1.47,
|
||||
# cleanly-removed ~1.00. 1.94 is the cap that reaches alpha 0.99 from 0.51.
|
||||
_ALPHA_GAIN_MAX = 1.94
|
||||
_ALPHA_GAIN_CORE_FRAC = 0.8 # body pixels at >= this * peak alpha define the core
|
||||
# Deadband: apply the gain only above this, so a sparkle that already matches the
|
||||
# capture (estimated gain ~1.0-1.04 from background noise) stays byte-identical to
|
||||
# the pre-fix output. Under-removed marks estimate >= 1.26, well clear of the band.
|
||||
_ALPHA_GAIN_DEADBAND = 1.05
|
||||
# Body pixels at >= this fraction of the peak captured alpha define the sparkle
|
||||
# "core", sampled by the detection FP-gate's core-vs-ring brightness margin
|
||||
# (:meth:`_core_and_bg`).
|
||||
_CORE_ALPHA_FRAC = 0.8
|
||||
|
||||
# Sparkle false-positive gate. A real Gemini sparkle is a bright WHITE overlay,
|
||||
# so its core sits above the local background; a shape-only NCC match on ornate
|
||||
@@ -172,23 +158,36 @@ class GeminiEngine:
|
||||
# fail to demote.
|
||||
_SPARKLE_FP_CONF = 0.65
|
||||
_SPARKLE_FP_MARGIN = 5.0
|
||||
# Bright-background content false positives (2026-06-26 landing-page FPs: a snow+sky
|
||||
# photo and a white-background product render both scored ~0.51). The margin gate
|
||||
# above cannot catch them -- a bright background gives the "core" a HIGH core-ring
|
||||
# margin (it is genuinely brighter than its surroundings), so the brightness check
|
||||
# reads it as a real overlay. The discriminating signature is the GRADIENT NCC: a
|
||||
# real white sparkle is a crisp star silhouette (grad ~0.97-1.0 on the synthetic
|
||||
# composites, ~0.96 on the real #36 corner sparkle), while a smooth luminance blob
|
||||
# that shape-NCC-matches the rough outline has low gradient fidelity (the two FPs
|
||||
# measured 0.105 and 0.463). So ALSO demote a low-confidence match whose gradient
|
||||
# NCC is below this floor, regardless of margin -- 0.55 sits well above the worst FP
|
||||
# (0.463) and far below every real sparkle (>=0.8). This only ADDS demotions on
|
||||
# bright backgrounds (a real bright-bg sparkle keeps grad ~0.97), so it cannot
|
||||
# regress a dark/mid sparkle (already kept by margin) or a white-bg one (kept by
|
||||
# confidence >= 0.65, above the gate).
|
||||
_SPARKLE_FP_GRAD = 0.55
|
||||
|
||||
# Self-verify fallback. The gain estimate corrects most under-subtractions, but
|
||||
# on the spaces corpus a tail of strong sparkles still survived reverse-alpha
|
||||
# (a few px of position jitter or a gain estimate the [1.0, 1.94] clamp could
|
||||
# not fully reach). After the reverse blend, re-detect; if a sparkle this strong
|
||||
# remains, inpaint the footprint and keep that ONLY when it lowers the re-detect
|
||||
# confidence. Purely additive: the common clean removal re-detects below this and
|
||||
# is returned untouched. Threshold matches the registry's real fail line (0.5),
|
||||
# so it triggers exactly on the cases that would otherwise read as not-removed
|
||||
# (rescued 4 of 15 corpus fails, 0 regressions). An offset+scale alignment search
|
||||
# was prototyped on the remaining 11 but REJECTED: it only lowered the shape-NCC by
|
||||
# moving the reverse-alpha to a different placement that left the sparkle as bright
|
||||
# or brighter (NCC-gaming, not removal), so a brightness sanity check rejected every
|
||||
# one. The footprint inpaint physically reconstructs the slot from its surroundings,
|
||||
# so its rescues are genuine; the survivors are near-white ill-conditioning or
|
||||
# detector false positives that no reverse-alpha placement fixes.
|
||||
_VERIFY_FALLBACK_CONF = 0.5
|
||||
# White-core rescue for the gate above. A real but FAINT sparkle -- a soft white
|
||||
# star on a bright/textured background -- has a high core-ring margin but low
|
||||
# gradient fidelity, the SAME signature the grad gate uses to demote the smooth
|
||||
# colored-corner FP, so faint real sparkles get demoted with it. The separator the
|
||||
# grad gate discards is the CORE COLOR: a real Gemini sparkle core is near-WHITE
|
||||
# (low saturation), while a clean bright corner that shape-matches (sky, sun, a warm
|
||||
# light) is COLORED. So do NOT demote a low-grad match that already clears the trust
|
||||
# confidence (_SPARKLE_KEEP_CONF -- the registry's 0.5 sparkle gate plus a small
|
||||
# margin so the ~0.51 bright-background FPs the grad gate was added for stay demoted)
|
||||
# AND has a bright (margin) near-neutral core (_core_saturation <= _SPARKLE_WHITE_SAT).
|
||||
# Corpus-measured on metadata-stripped faint sparkles: recovers ~14/20 the low-grad
|
||||
# demotion would drop, at ~0.8% clean false-fire vs the ~0.55% baseline.
|
||||
_SPARKLE_KEEP_CONF = 0.52
|
||||
_SPARKLE_WHITE_SAT = 0.20
|
||||
|
||||
# Corner promotion (issue #36): the size weight that suppresses tiny-patch
|
||||
# false positives also buries a small, near-perfect sparkle when a larger,
|
||||
@@ -218,6 +217,16 @@ class GeminiEngine:
|
||||
_CORNER_PROMOTE_MIN = 96
|
||||
_CORNER_PROMOTE_MAX = 384
|
||||
|
||||
# Number of top size-weighted spatial candidates scored by full fusion before one
|
||||
# is selected. The single size-weighted argmax can bury a genuine mid-size sparkle
|
||||
# under a LARGER, lower-fidelity shape match (the 256->512 search-widening
|
||||
# regression: a real corner sparkle at raw ~0.77 lost to a decoy at raw ~0.63).
|
||||
# Scoring the top-K by gradient-bearing fusion rescues it. Top-K (NOT the raw-NCC
|
||||
# argmax) keeps the tiny-patch suppression intact: a coincidental 16 px match never
|
||||
# ranks in the size-weighted top-K, so widening selection cannot add a false
|
||||
# positive on non-Gemini content (verified on the doubao/jimeng visible corpora).
|
||||
_SELECT_TOPK = 3
|
||||
|
||||
def __init__(self, logo_value: float = 255.0) -> None:
|
||||
"""Initialize the engine with embedded alpha maps.
|
||||
|
||||
@@ -240,6 +249,16 @@ class GeminiEngine:
|
||||
self._alpha_small = _calculate_alpha_map(bg_small)
|
||||
self._alpha_large = _calculate_alpha_map(bg_large)
|
||||
|
||||
# Per-scale resized templates are constant (``_alpha_large`` never changes),
|
||||
# so precompute the whole fixed 16..118 ladder once: ``_scan_scales`` runs it on
|
||||
# every image (twice -- global + corner), and re-``resize``-ing the 96x96 source
|
||||
# each time is pure repeated work. Prebuilt (not lazy) so the dict is read-only
|
||||
# after construction and safe to share across threads via the module singleton.
|
||||
self._tmpl_cache: dict[int, NDArray[Any]] = {
|
||||
scale: cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
|
||||
for scale in _TEMPLATE_SCALES
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Alpha maps loaded: small=%s, large=%s",
|
||||
self._alpha_small.shape,
|
||||
@@ -273,11 +292,10 @@ class GeminiEngine:
|
||||
``_alpha_large`` is the high-quality source downscaled per scale; the range
|
||||
covers aggressively downscaled to slightly upscaled logos.
|
||||
"""
|
||||
for scale in range(16, 120, 2):
|
||||
for scale in _TEMPLATE_SCALES:
|
||||
if scale > gray.shape[0] or scale > gray.shape[1]:
|
||||
continue
|
||||
tmpl = cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
|
||||
match_res = cv2.matchTemplate(gray, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
match_res = cv2.matchTemplate(gray, self._tmpl_cache[scale], cv2.TM_CCOEFF_NORMED)
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(match_res)
|
||||
yield scale, float(max_val), max_loc
|
||||
|
||||
@@ -285,8 +303,18 @@ class GeminiEngine:
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
force_size: WatermarkSize | None = None,
|
||||
*,
|
||||
trust_provenance: bool = False,
|
||||
) -> DetectionResult:
|
||||
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
|
||||
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm).
|
||||
|
||||
``trust_provenance`` signals that external metadata already proves this is a
|
||||
Google generation (C2PA issuer "Google"/"Gemini"). The false-positive gate
|
||||
exists only to reject content that shape-matches the sparkle on NON-Google
|
||||
images (Doubao text, ornate corners); when provenance confirms Google, that
|
||||
gate would demote a genuine sparkle the vendor moved/re-rendered (bigger,
|
||||
lighter, shifted), so it is skipped. The caller (registry) still applies the
|
||||
relaxed provenance trust gate to the returned confidence."""
|
||||
result = DetectionResult()
|
||||
|
||||
if image is None or image.size == 0:
|
||||
@@ -316,104 +344,100 @@ class GeminiEngine:
|
||||
|
||||
gray_sr_f = gray_sr.astype(np.float32) / 255.0
|
||||
|
||||
# Phase 1 & 2: multi-scale spatial NCC search, size-weighted argmax.
|
||||
best_scale = 0
|
||||
best_score = -1.0
|
||||
best_raw_ncc = -1.0
|
||||
best_loc = (0, 0)
|
||||
# Phase 1 & 2: multi-scale spatial NCC search. The size weight (mimicking the
|
||||
# C++ vendor weight) overcomes the NCC bias toward tiny patches, but its single
|
||||
# argmax can bury a genuine mid-size sparkle under a LARGER, lower-fidelity
|
||||
# shape match (the 256->512 search-widening regression). So score the top-K
|
||||
# size-weighted candidates by the FULL fusion and keep the highest -- the
|
||||
# gradient term separates a true white sparkle from a shape-only decoy. See
|
||||
# _SELECT_TOPK for why top-K (not the raw-NCC argmax) preserves tiny-patch
|
||||
# suppression and so cannot add a false positive on non-Gemini content.
|
||||
scored: list[tuple[float, int, int, int, float]] = [] # (adj, scale, raw, x, y)
|
||||
for scale, max_val, max_loc in self._scan_scales(gray_sr_f):
|
||||
# Size-adjusted score to overcome NCC bias toward tiny patches (mimics C++ weight)
|
||||
weight = min(1.0, (scale / 96.0) ** 0.5)
|
||||
adj_val = max_val * weight
|
||||
if adj_val > best_score:
|
||||
best_score = adj_val
|
||||
best_scale = scale
|
||||
best_loc = max_loc
|
||||
best_raw_ncc = max_val
|
||||
adj_val = max_val * min(1.0, (scale / 96.0) ** 0.5)
|
||||
scored.append((adj_val, scale, max_val, sx1 + max_loc[0], sy1 + max_loc[1]))
|
||||
scored.sort(reverse=True)
|
||||
|
||||
# Exact dynamic location & size
|
||||
pos_x = sx1 + best_loc[0]
|
||||
pos_y = sy1 + best_loc[1]
|
||||
# Top-K candidates at distinct locations (NMS: drop a lower-ranked match that
|
||||
# overlaps an already-kept one -- the same sparkle matches at adjacent scales).
|
||||
candidates: list[tuple[int, int, int, float]] = []
|
||||
for _adj, scale, raw, x, y in scored:
|
||||
if any(
|
||||
abs(x - px) < 0.5 * max(scale, ps) and abs(y - py) < 0.5 * max(scale, ps)
|
||||
for ps, px, py, _ in candidates
|
||||
):
|
||||
continue
|
||||
candidates.append((scale, x, y, raw))
|
||||
if len(candidates) >= self._SELECT_TOPK:
|
||||
break
|
||||
|
||||
# Corner promotion: a near-perfect but small sparkle in the bottom-right
|
||||
# corner is otherwise outranked by a larger, mediocre size-weighted match
|
||||
# (see _CORNER_PROMOTE_NCC). Override the global pick with it when present.
|
||||
promoted = self._corner_promote(image, best_raw_ncc)
|
||||
# Corner promotion: a near-perfect small bottom-right sparkle the size weight
|
||||
# buries even below the top-K (see _CORNER_PROMOTE_NCC) -- add it as a candidate.
|
||||
promoted = self._corner_promote(image, candidates[0][3] if candidates else -1.0)
|
||||
if promoted is not None:
|
||||
best_scale, pos_x, pos_y, best_raw_ncc = promoted
|
||||
candidates.append(promoted)
|
||||
|
||||
# No candidate at any scale: the search region is smaller than the 16px template
|
||||
# floor (an image whose short side is < 16px), so nothing is detectable. Return
|
||||
# the empty (detected=False) result rather than dereferencing candidates[0].
|
||||
if not candidates:
|
||||
return result
|
||||
|
||||
# Select the candidate with the highest full-fusion confidence (pre-FP-gate).
|
||||
best_scale, pos_x, pos_y, best_raw_ncc = candidates[0]
|
||||
grad_score, var_score, best_fused = 0.0, 0.0, -1.0
|
||||
for c_scale, c_x, c_y, c_raw in candidates:
|
||||
if c_raw < 0.25:
|
||||
c_grad, c_var, c_fused = 0.0, 0.0, max(0.0, c_raw * 0.5)
|
||||
else:
|
||||
c_grad, c_var = self._grad_var_scores(image, c_scale, c_x, c_y)
|
||||
c_fused = c_raw * 0.50 + c_grad * 0.30 + c_var * 0.20
|
||||
if c_fused > best_fused:
|
||||
best_fused = c_fused
|
||||
best_scale, pos_x, pos_y = c_scale, c_x, c_y
|
||||
best_raw_ncc, grad_score, var_score = c_raw, c_grad, c_var
|
||||
|
||||
result.region = (pos_x, pos_y, best_scale, best_scale)
|
||||
result.spatial_score = float(best_raw_ncc)
|
||||
|
||||
# Generate exact alpha map for matched size
|
||||
alpha_region = self.get_interpolated_alpha(best_scale)
|
||||
|
||||
# Extract exactly the matched region for Gradient & Variance analysis
|
||||
x1 = pos_x
|
||||
y1 = pos_y
|
||||
x2 = min(w, x1 + best_scale)
|
||||
y2 = min(h, y1 + best_scale)
|
||||
|
||||
region = image[y1:y2, x1:x2]
|
||||
if len(region.shape) == 3 and region.shape[2] >= 3:
|
||||
gray_region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray_region = region.copy()
|
||||
|
||||
gray_f = gray_region.astype(np.float32) / 255.0
|
||||
|
||||
# Adjust alpha_region if clipped by image boundary (rare, but possible)
|
||||
ay1, ax1 = 0, 0
|
||||
alpha_region = alpha_region[ay1 : ay1 + (y2 - y1), ax1 : ax1 + (x2 - x1)]
|
||||
result.gradient_score = float(grad_score)
|
||||
result.variance_score = float(var_score)
|
||||
|
||||
if result.spatial_score < 0.25:
|
||||
result.confidence = float(max(0.0, result.spatial_score * 0.5))
|
||||
return result
|
||||
|
||||
# ── Stage 2: Gradient NCC ────────────────────────────────────
|
||||
img_gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
|
||||
img_gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
|
||||
img_gmag = cv2.magnitude(img_gx, img_gy)
|
||||
|
||||
alpha_gx = cv2.Sobel(alpha_region, cv2.CV_32F, 1, 0, ksize=3)
|
||||
alpha_gy = cv2.Sobel(alpha_region, cv2.CV_32F, 0, 1, ksize=3)
|
||||
alpha_gmag = cv2.magnitude(alpha_gx, alpha_gy)
|
||||
|
||||
grad_match = cv2.matchTemplate(img_gmag, alpha_gmag, cv2.TM_CCOEFF_NORMED)
|
||||
_, grad_score, _, _ = cv2.minMaxLoc(grad_match)
|
||||
result.gradient_score = float(grad_score)
|
||||
|
||||
# ── Stage 3: Variance Analysis ───────────────────────────────
|
||||
var_score = 0.0
|
||||
ref_h = min(y1, best_scale)
|
||||
|
||||
if ref_h > 8:
|
||||
ref_region = image[y1 - ref_h : y1, x1:x2]
|
||||
gray_ref = cv2.cvtColor(ref_region, cv2.COLOR_BGR2GRAY) if len(ref_region.shape) == 3 else ref_region
|
||||
|
||||
_, s_wm = cv2.meanStdDev(gray_region)
|
||||
_, s_ref = cv2.meanStdDev(gray_ref)
|
||||
|
||||
if s_ref[0][0] > 5.0:
|
||||
var_score = max(0.0, min(1.0, 1.0 - (s_wm[0][0] / s_ref[0][0])))
|
||||
|
||||
result.variance_score = float(var_score)
|
||||
|
||||
# ── Fusion ───────────────────────────────────────────────────
|
||||
confidence = result.spatial_score * 0.50 + result.gradient_score * 0.30 + var_score * 0.20
|
||||
# best_fused is the selected candidate's spatial*0.5 + grad*0.3 + var*0.2.
|
||||
confidence = best_fused
|
||||
|
||||
# False-positive gate: a low-confidence shape match whose core is NOT brighter
|
||||
# than its surroundings is a content false positive, not a white sparkle overlay.
|
||||
if confidence < self._SPARKLE_FP_CONF:
|
||||
margin = self._core_ring_margin(image, self.get_interpolated_alpha(best_scale), (pos_x, pos_y))
|
||||
if margin is not None and margin < self._SPARKLE_FP_MARGIN:
|
||||
logger.debug(
|
||||
"Sparkle FP gate: conf=%.3f, core-ring margin=%.1f < %.1f; demoting.",
|
||||
confidence,
|
||||
margin,
|
||||
self._SPARKLE_FP_MARGIN,
|
||||
)
|
||||
confidence = min(confidence, 0.30)
|
||||
# False-positive gate: a low-confidence match that shows NEITHER real-sparkle
|
||||
# signature is a content false positive, not a white sparkle overlay. A real
|
||||
# sparkle proves itself by a bright core (high core-ring margin, on dark/mid
|
||||
# backgrounds) OR a crisp star silhouette (high gradient NCC, on any background
|
||||
# incl. bright). Demote when both are weak -- this catches the dark/mid no-core
|
||||
# FP (low margin) AND the bright-background smooth-blob FP (high margin but low
|
||||
# gradient), which the margin check alone misses. See _SPARKLE_FP_GRAD.
|
||||
if confidence < self._SPARKLE_FP_CONF and not trust_provenance:
|
||||
alpha = self.get_interpolated_alpha(best_scale)
|
||||
pos = (pos_x, pos_y)
|
||||
margin = self._core_ring_margin(image, alpha, pos)
|
||||
low_margin = margin is not None and margin < self._SPARKLE_FP_MARGIN
|
||||
low_grad = grad_score < self._SPARKLE_FP_GRAD
|
||||
if low_margin or low_grad:
|
||||
# White-core rescue: a real faint sparkle clears the trust confidence,
|
||||
# has a bright core (not low_margin), and a near-WHITE core -- unlike the
|
||||
# colored-corner FP the low-grad demotion targets. See _SPARKLE_WHITE_SAT.
|
||||
core_sat = self._core_saturation(image, alpha, pos)
|
||||
white_core = not low_margin and core_sat is not None and core_sat <= self._SPARKLE_WHITE_SAT
|
||||
if not (confidence >= self._SPARKLE_KEEP_CONF and white_core):
|
||||
logger.debug(
|
||||
"Sparkle FP gate: conf=%.3f, margin=%s, grad=%.3f, core_sat=%s; demoting.",
|
||||
confidence,
|
||||
f"{margin:.1f}" if margin is not None else "n/a",
|
||||
grad_score,
|
||||
f"{core_sat:.2f}" if core_sat is not None else "n/a",
|
||||
)
|
||||
confidence = min(confidence, 0.30)
|
||||
|
||||
result.confidence = float(max(0.0, min(1.0, confidence)))
|
||||
result.detected = result.confidence >= 0.35
|
||||
@@ -429,6 +453,49 @@ class GeminiEngine:
|
||||
|
||||
return result
|
||||
|
||||
def _grad_var_scores(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
scale: int,
|
||||
pos_x: int,
|
||||
pos_y: int,
|
||||
) -> tuple[float, float]:
|
||||
"""Return ``(gradient_score, variance_score)`` for a candidate sparkle.
|
||||
|
||||
Factored out of ``detect_watermark`` so each top-K candidate can be scored by
|
||||
the full fusion before one is selected. The gradient NCC correlates
|
||||
Sobel-magnitude maps (shape fidelity, contrast-robust); the variance score
|
||||
rewards a flat overlay region against the row band above it.
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
x1, y1 = pos_x, pos_y
|
||||
x2, y2 = min(w, x1 + scale), min(h, y1 + scale)
|
||||
region = image[y1:y2, x1:x2]
|
||||
gray_region = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) if region.ndim == 3 and region.shape[2] >= 3 else region
|
||||
gray_f = gray_region.astype(np.float32) / 255.0
|
||||
alpha_region = self.get_interpolated_alpha(scale)[: y2 - y1, : x2 - x1]
|
||||
|
||||
# ── Gradient NCC ──
|
||||
img_gmag = cv2.magnitude(
|
||||
cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
|
||||
)
|
||||
alpha_gmag = cv2.magnitude(
|
||||
cv2.Sobel(alpha_region, cv2.CV_32F, 1, 0, ksize=3), cv2.Sobel(alpha_region, cv2.CV_32F, 0, 1, ksize=3)
|
||||
)
|
||||
_, grad_score, _, _ = cv2.minMaxLoc(cv2.matchTemplate(img_gmag, alpha_gmag, cv2.TM_CCOEFF_NORMED))
|
||||
|
||||
# ── Variance ──
|
||||
var_score = 0.0
|
||||
ref_h = min(y1, scale)
|
||||
if ref_h > 8:
|
||||
ref_region = image[y1 - ref_h : y1, x1:x2]
|
||||
gray_ref = cv2.cvtColor(ref_region, cv2.COLOR_BGR2GRAY) if ref_region.ndim == 3 else ref_region
|
||||
_, s_wm = cv2.meanStdDev(gray_region)
|
||||
_, s_ref = cv2.meanStdDev(gray_ref)
|
||||
if s_ref[0][0] > 5.0:
|
||||
var_score = max(0.0, min(1.0, 1.0 - (s_wm[0][0] / s_ref[0][0])))
|
||||
return float(grad_score), float(var_score)
|
||||
|
||||
def _corner_promote(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
@@ -467,111 +534,67 @@ class GeminiEngine:
|
||||
|
||||
# ── Removal ──────────────────────────────────────────────────────
|
||||
|
||||
def remove_watermark(
|
||||
# Footprint mask for the localize -> fill removal path. The mask must cover the
|
||||
# WHOLE sparkle including its faint semi-transparent halo, not just the bright
|
||||
# core, or the fill leaves a visible ring. Threshold the captured alpha low
|
||||
# (>_MASK_ALPHA catches the halo the core-only 0.10 misses) then dilate by a
|
||||
# sparkle-relative margin so alignment slop and the outermost halo are absorbed.
|
||||
_MASK_ALPHA = 0.04
|
||||
_MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale
|
||||
|
||||
def footprint_mask(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
force_size: WatermarkSize | None = None,
|
||||
) -> NDArray[Any]:
|
||||
"""Remove Gemini visible watermark from an image using reverse alpha blending.
|
||||
*,
|
||||
force: bool = False,
|
||||
dilate: int | None = None,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the
|
||||
shared fill removal path (cv2 / MI-GAN / LaMa), or None.
|
||||
|
||||
No-op when the detector does not find a watermark: returns an unmodified
|
||||
copy. Reverse alpha blending applied where no sparkle exists creates a
|
||||
visible inverse artifact, so we refuse to touch pixels without a positive
|
||||
detection. To bypass detection (e.g. you know the exact region), use
|
||||
``remove_watermark_custom``.
|
||||
The footprint is the interpolated captured alpha at the detected scale,
|
||||
thresholded LOW so the faint halo is included, then dilated by a
|
||||
sparkle-relative margin. When ``force`` and nothing is detected, falls back to
|
||||
the default sparkle slot for the image size (the ``--no-detect`` path).
|
||||
|
||||
Args:
|
||||
image: BGR image as numpy array (will NOT be modified in-place).
|
||||
force_size: Force a specific watermark size (auto-detect if None).
|
||||
|
||||
Returns:
|
||||
Cleaned BGR image as numpy array, or an unmodified copy when no
|
||||
watermark is detected.
|
||||
``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection
|
||||
(the registry passes the decision's provenance-aware region). When given, the
|
||||
mask is built from it directly WITHOUT a second internal detect -- otherwise a
|
||||
provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and
|
||||
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
|
||||
keep the detect-then-force behavior.
|
||||
"""
|
||||
# Normalize to 3-channel BGR up front: 2D grayscale (no channel axis) and
|
||||
# 4-channel BGRA both reach this public entry point and would otherwise
|
||||
# crash on the channel-count checks / downstream 3-channel math.
|
||||
result = image_io.to_bgr(image.copy())
|
||||
|
||||
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
|
||||
|
||||
# Detect dynamic position & size (on the normalized 3-channel image so a
|
||||
# grayscale/BGRA input does not crash the detector).
|
||||
detection = self.detect_watermark(result, force_size=size)
|
||||
|
||||
if not detection.detected:
|
||||
logger.debug(
|
||||
"No watermark detected (conf=%.3f); returning image unchanged.",
|
||||
detection.confidence,
|
||||
)
|
||||
return result
|
||||
|
||||
pos = (detection.region[0], detection.region[1])
|
||||
alpha_map = self.get_interpolated_alpha(detection.region[2])
|
||||
# Match the captured alpha to this image's sparkle opacity (under-subtraction
|
||||
# fix): a more-opaque-than-captured sparkle would otherwise leave a bright
|
||||
# residual. gain == 1.0 leaves the working cases byte-identical.
|
||||
gain = self._estimate_alpha_gain(result, alpha_map, pos)
|
||||
if gain > self._ALPHA_GAIN_DEADBAND:
|
||||
alpha_map = np.clip(alpha_map * gain, 0.0, 0.99)
|
||||
logger.debug(
|
||||
"Removing watermark at (%d, %d) size %dx%d [conf=%.3f]",
|
||||
pos[0],
|
||||
pos[1],
|
||||
detection.region[2],
|
||||
detection.region[3],
|
||||
detection.confidence,
|
||||
)
|
||||
|
||||
# The captured alpha map (max ~0.51 = a ~50%-opaque white sparkle) is exact
|
||||
# only when the real mark's effective opacity matches it. On a dark/textured
|
||||
# background the sparkle's effective alpha is lower than the capture, so the
|
||||
# fixed-alpha reverse blend OVER-subtracts and drives the footprint to black --
|
||||
# the "white sparkle turns into a black pit" bug (issue #30). The signature is
|
||||
# a large fraction of footprint pixels whose numerator (watermarked - a*logo)
|
||||
# goes negative, which is physically impossible under a brightening overlay.
|
||||
# In that case inpaint the small footprint from the surrounding pixels instead;
|
||||
# on a bright background no pixel over-subtracts, so reverse-alpha is used and
|
||||
# the result is byte-identical to before (verified on demo_banana: 0% vs 61%).
|
||||
if self._reverse_alpha_oversubtracts(result, alpha_map, pos):
|
||||
logger.debug("Reverse-alpha over-subtracts on this background; inpainting sparkle footprint.")
|
||||
self._inpaint_footprint(result, alpha_map, pos)
|
||||
if image is None or image.size == 0:
|
||||
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect_watermark
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if region is not None:
|
||||
x, y, scale = region[0], region[1], region[2]
|
||||
else:
|
||||
self._reverse_alpha_blend(result, alpha_map, pos)
|
||||
return self._verify_and_repair(result, alpha_map, pos, size)
|
||||
|
||||
def remove_watermark_custom(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
region: tuple[int, int, int, int],
|
||||
) -> NDArray[Any]:
|
||||
"""Remove watermark from a custom region with interpolated alpha map.
|
||||
|
||||
Args:
|
||||
image: BGR image (will NOT be modified in-place).
|
||||
region: (x, y, width, height) of the watermark region.
|
||||
|
||||
Returns:
|
||||
Cleaned BGR image.
|
||||
"""
|
||||
# Same channel normalization as remove_watermark: the reverse-alpha blend
|
||||
# assumes 3-channel BGR (a grayscale/BGRA input would mis-broadcast).
|
||||
result = image_io.to_bgr(image.copy())
|
||||
x, y, rw, rh = region
|
||||
|
||||
# Check standard sizes
|
||||
if rw == 48 and rh == 48:
|
||||
self._reverse_alpha_blend(result, self._alpha_small, (x, y))
|
||||
return result
|
||||
if rw == 96 and rh == 96:
|
||||
self._reverse_alpha_blend(result, self._alpha_large, (x, y))
|
||||
return result
|
||||
|
||||
# Interpolate alpha map for custom size
|
||||
interp = cv2.INTER_LINEAR if rw > 96 else cv2.INTER_AREA
|
||||
alpha = cv2.resize(self._alpha_large, (rw, rh), interpolation=interp)
|
||||
self._reverse_alpha_blend(result, alpha, (x, y))
|
||||
return result
|
||||
det = self.detect_watermark(image)
|
||||
if det.detected:
|
||||
x, y, scale = det.region[0], det.region[1], det.region[2]
|
||||
elif force:
|
||||
cfg = get_watermark_config(w, h)
|
||||
x, y = cfg.get_position(w, h)
|
||||
scale = cfg.logo_size
|
||||
else:
|
||||
return None
|
||||
alpha = self.get_interpolated_alpha(scale)
|
||||
fp = self._footprint_indices(alpha, (x, y), image.shape)
|
||||
if fp is None:
|
||||
return None
|
||||
aroi, (y1, y2, x1, x2) = fp
|
||||
sil = (aroi > self._MASK_ALPHA).astype(np.uint8) * 255
|
||||
if int((sil > 0).sum()) == 0:
|
||||
return None
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y1:y2, x1:x2] = sil
|
||||
d = dilate if dilate is not None else max(13, int(scale * self._MASK_DILATE_FRAC))
|
||||
if d > 0:
|
||||
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * d + 1, 2 * d + 1)))
|
||||
return mask
|
||||
|
||||
def _footprint_indices(
|
||||
self,
|
||||
@@ -615,19 +638,24 @@ class GeminiEngine:
|
||||
a_cap = float(alpha_roi.max())
|
||||
if a_cap < 0.2:
|
||||
return None
|
||||
gray = image.astype(np.float32).mean(axis=2)
|
||||
core = alpha_roi >= a_cap * self._ALPHA_GAIN_CORE_FRAC
|
||||
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
|
||||
if not bool(core.any()):
|
||||
return None
|
||||
core_obs = float(np.percentile(gray[y1:y2, x1:x2][core], 75))
|
||||
# Local background = a ring just outside the footprint box.
|
||||
# Convert only the footprint+ring crop to gray, not the whole image: every
|
||||
# sample below lives inside the ring box, so a full-image mean is wasted work
|
||||
# that scales with resolution (~70 ms on a 12 MP image, recomputed for both
|
||||
# the alpha-gain estimate and the over-subtraction gate). The crop is sized by
|
||||
# the footprint, so this is O(footprint^2) regardless of image size.
|
||||
ih, iw = image.shape[:2]
|
||||
pad = int((x2 - x1) * 0.7)
|
||||
ry1, ry2 = max(0, y1 - pad), min(ih, y2 + pad)
|
||||
rx1, rx2 = max(0, x1 - pad), min(iw, x2 + pad)
|
||||
ring = gray[ry1:ry2, rx1:rx2]
|
||||
ring = image[ry1:ry2, rx1:rx2].astype(np.float32).mean(axis=2)
|
||||
# Footprint box expressed in ring-crop coordinates.
|
||||
fy1, fy2, fx1, fx2 = y1 - ry1, y2 - ry1, x1 - rx1, x2 - rx1
|
||||
core_obs = float(np.percentile(ring[fy1:fy2, fx1:fx2][core], 75))
|
||||
ring_mask = np.ones(ring.shape, dtype=bool)
|
||||
ring_mask[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False
|
||||
ring_mask[fy1:fy2, fx1:fx2] = False
|
||||
if int(ring_mask.sum()) < 10:
|
||||
return None
|
||||
return core_obs, float(np.median(ring[ring_mask])), a_cap
|
||||
@@ -647,272 +675,63 @@ class GeminiEngine:
|
||||
cb = self._core_and_bg(image, alpha_map, position)
|
||||
return None if cb is None else cb[0] - cb[1]
|
||||
|
||||
def _estimate_alpha_gain(
|
||||
def _core_saturation(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> float:
|
||||
"""Scale factor matching the captured alpha to this image's sparkle opacity.
|
||||
|
||||
The captured alpha (peak ~0.51) under-represents sparkles rendered more
|
||||
opaque; reverse-alpha then leaves a bright residual. Estimate the effective
|
||||
opacity at the sparkle core (observed brightness vs the local background
|
||||
ring) and return ``a_eff / a_capture``, clamped to ``[1.0, _ALPHA_GAIN_MAX]``
|
||||
so it only ever STRENGTHENS removal (1.0 = no change on a matching sparkle).
|
||||
Returns 1.0 when the background cannot be estimated reliably.
|
||||
"""
|
||||
cb = self._core_and_bg(image, alpha_map, position)
|
||||
if cb is None:
|
||||
return 1.0
|
||||
core_obs, bg, a_cap = cb
|
||||
if 255.0 - bg < 5.0:
|
||||
return 1.0
|
||||
a_eff = float(np.clip((core_obs - bg) / (255.0 - bg), 0.0, 0.99))
|
||||
return float(np.clip(a_eff / a_cap, 1.0, self._ALPHA_GAIN_MAX))
|
||||
|
||||
def _reverse_alpha_oversubtracts(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> bool:
|
||||
"""True when reverse-alpha would drive the footprint dark (issue #30).
|
||||
|
||||
Tests the numerator ``watermarked - alpha*logo`` over the sparkle body: a
|
||||
brightening overlay can never make it negative, so a large negative fraction
|
||||
means the fixed alpha over-estimates this image's opacity.
|
||||
) -> float | None:
|
||||
"""Median color saturation of the sparkle core (0 = white/neutral, higher =
|
||||
colored). A real Gemini sparkle is a white star, so its core is near-neutral;
|
||||
a clean bright corner that shape-matches (sky, sun, a warm light) is colored,
|
||||
so a high core saturation flags the false positive the brightness/gradient
|
||||
gates miss. Samples the same high-alpha core pixels as :meth:`_core_and_bg`.
|
||||
None when the footprint cannot be placed or the core is empty.
|
||||
"""
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return False
|
||||
return None
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
body = alpha_roi >= self._FOOTPRINT_ALPHA
|
||||
if not bool(body.any()):
|
||||
return False
|
||||
roi = image[y1:y2, x1:x2].astype(np.float32)
|
||||
numerator = roi.mean(axis=2) - np.clip(alpha_roi, 0.0, 0.99) * self.logo_value
|
||||
frac = float((numerator[body] < 0).sum()) / float(body.sum())
|
||||
return frac > self._OVERSUB_FOOTPRINT_FRAC
|
||||
|
||||
def _inpaint_footprint(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> None:
|
||||
"""Inpaint the sparkle body from surrounding pixels, in-place.
|
||||
|
||||
Fallback for backgrounds where reverse-alpha over-subtracts: a small mask of
|
||||
the footprint (alpha >= threshold, dilated) is reconstructed by cv2 NS inpaint
|
||||
from the continuous surroundings, so the sparkle is replaced by plausible
|
||||
background instead of a black pit.
|
||||
"""
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
# Inpaint only a padded crop around the footprint, not the whole image: the
|
||||
# mask is zero outside a ~96x96 corner, so inpainting the full (multi-MP)
|
||||
# image would be ~hundreds of times more work for an identical result. The
|
||||
# padding gives cv2 enough surrounding context to reconstruct the sparkle.
|
||||
ih, iw = image.shape[:2]
|
||||
pad = 24
|
||||
cy1, cy2 = max(0, y1 - pad), min(ih, y2 + pad)
|
||||
cx1, cx2 = max(0, x1 - pad), min(iw, x2 + pad)
|
||||
crop = image[cy1:cy2, cx1:cx2]
|
||||
mask = np.zeros(crop.shape[:2], dtype=np.uint8)
|
||||
mask[y1 - cy1 : y2 - cy1, x1 - cx1 : x2 - cx1] = (alpha_roi >= self._FOOTPRINT_ALPHA).astype(np.uint8) * 255
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask = cv2.dilate(mask, kernel, iterations=2)
|
||||
image[cy1:cy2, cx1:cx2] = cv2.inpaint(crop, mask, 6, cv2.INPAINT_NS)
|
||||
|
||||
def _verify_and_repair(
|
||||
self,
|
||||
result: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
size: WatermarkSize,
|
||||
) -> NDArray[Any]:
|
||||
"""Inpaint-repair a sparkle that survived reverse-alpha, keeping the better.
|
||||
|
||||
Re-detect on the reverse-alpha output; if a sparkle this strong remains (an
|
||||
alpha mismatch the gain estimate could not fully correct), inpaint the
|
||||
footprint and return that ONLY when it lowers the re-detect confidence. The
|
||||
footprint inpaint reconstructs from the (darker) surroundings, so it physically
|
||||
removes the bright sparkle rather than gaming the shape-NCC. Returns ``result``
|
||||
unchanged when the removal is already clean (the common case) or when the
|
||||
inpaint does not improve it, so it can never regress.
|
||||
"""
|
||||
residual = self.detect_watermark(result, force_size=size).confidence
|
||||
if residual < self._VERIFY_FALLBACK_CONF:
|
||||
return result
|
||||
candidate = result.copy()
|
||||
self._inpaint_footprint(candidate, alpha_map, position)
|
||||
if self.detect_watermark(candidate, force_size=size).confidence < residual:
|
||||
logger.debug("Sparkle survived reverse-alpha (conf=%.3f); footprint inpaint improved it.", residual)
|
||||
return candidate
|
||||
return result
|
||||
|
||||
def _reverse_alpha_blend(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> None:
|
||||
"""Apply reverse alpha blending in-place.
|
||||
|
||||
Formula: original = (watermarked - a * logo) / (1 - a)
|
||||
"""
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
image_roi = image[y1:y2, x1:x2].astype(np.float32)
|
||||
|
||||
alpha_threshold = 0.002
|
||||
max_alpha = 0.99
|
||||
|
||||
# Vectorized reverse alpha blending
|
||||
alpha = alpha_roi.copy()
|
||||
mask = alpha >= alpha_threshold
|
||||
alpha = np.clip(alpha, 0.0, max_alpha)
|
||||
one_minus_alpha = 1.0 - alpha
|
||||
|
||||
# Expand alpha for 3-channel broadcast
|
||||
alpha_3d = alpha[:, :, np.newaxis]
|
||||
one_minus_3d = one_minus_alpha[:, :, np.newaxis]
|
||||
mask_3d = mask[:, :, np.newaxis]
|
||||
|
||||
# original = (watermarked - alpha * logo) / (1 - alpha)
|
||||
restored = (image_roi - alpha_3d * self.logo_value) / one_minus_3d
|
||||
restored = np.clip(restored, 0.0, 255.0)
|
||||
|
||||
# Apply only where alpha is significant
|
||||
image_roi = np.where(mask_3d, restored, image_roi)
|
||||
image[y1:y2, x1:x2] = image_roi.astype(np.uint8)
|
||||
|
||||
# ── Inpainting cleanup ───────────────────────────────────────────
|
||||
|
||||
def inpaint_residual(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
region: tuple[int, int, int, int],
|
||||
strength: float = 0.85,
|
||||
method: Literal["gaussian", "telea", "ns"] = "ns",
|
||||
inpaint_radius: int = 10,
|
||||
padding: int = 32,
|
||||
) -> NDArray[Any]:
|
||||
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
|
||||
|
||||
Uses a sparse mask derived from alpha map gradient to repair only
|
||||
the sparkle-edge pixels where interpolation broke the math.
|
||||
|
||||
Args:
|
||||
image: BGR image (will NOT be modified in-place).
|
||||
region: (x, y, w, h) of the watermark region.
|
||||
strength: Blend strength (0.0 = keep original, 1.0 = fully inpainted).
|
||||
method: Inpaint method ("gaussian", "telea", or "ns").
|
||||
inpaint_radius: Radius for cv2.inpaint.
|
||||
padding: Context padding around region in pixels.
|
||||
|
||||
Returns:
|
||||
Cleaned BGR image.
|
||||
"""
|
||||
result = image.copy()
|
||||
x, y, rw, rh = region
|
||||
|
||||
if rw < 4 or rh < 4:
|
||||
return result
|
||||
|
||||
strength = max(0.0, min(1.0, strength))
|
||||
if strength < 0.001:
|
||||
return result
|
||||
|
||||
# Padded region
|
||||
px1 = max(0, x - padding)
|
||||
py1 = max(0, y - padding)
|
||||
px2 = min(image.shape[1], x + rw + padding)
|
||||
py2 = min(image.shape[0], y + rh + padding)
|
||||
|
||||
if (px2 - px1) < 8 or (py2 - py1) < 8:
|
||||
return result
|
||||
|
||||
# Inner rect relative to padded
|
||||
ix1 = x - px1
|
||||
iy1 = y - py1
|
||||
|
||||
# Get alpha map (interpolated if needed)
|
||||
source_alpha = self._alpha_large
|
||||
interp = cv2.INTER_LINEAR if rw > source_alpha.shape[1] else cv2.INTER_AREA
|
||||
alpha_resized = cv2.resize(source_alpha, (rw, rh), interpolation=interp)
|
||||
|
||||
# Compute gradient mask from alpha
|
||||
grad_x = cv2.Sobel(alpha_resized, cv2.CV_32F, 1, 0, ksize=3)
|
||||
grad_y = cv2.Sobel(alpha_resized, cv2.CV_32F, 0, 1, ksize=3)
|
||||
grad_mag = cv2.magnitude(grad_x, grad_y)
|
||||
|
||||
grad_min, grad_max = grad_mag.min(), grad_mag.max()
|
||||
if grad_max <= grad_min:
|
||||
return result
|
||||
|
||||
# Normalize and apply gamma correction
|
||||
grad_norm = (grad_mag - grad_min) / (grad_max - grad_min)
|
||||
grad_weight = np.sqrt(grad_norm)
|
||||
|
||||
# Dilate the mask
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
grad_weight = cv2.dilate(grad_weight, kernel)
|
||||
|
||||
if method == "gaussian":
|
||||
# Soft blend with Gaussian blur
|
||||
padded_roi = result[py1:py2, px1:px2].copy()
|
||||
blurred = cv2.GaussianBlur(padded_roi, (0, 0), sigmaX=2.0)
|
||||
|
||||
# Create weight mask on padded area (only inner region has weights)
|
||||
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
|
||||
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
|
||||
|
||||
weight_3d = weight_full[:, :, np.newaxis]
|
||||
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + blurred.astype(np.float32) * weight_3d
|
||||
result[py1:py2, px1:px2] = blended.astype(np.uint8)
|
||||
else:
|
||||
# OpenCV inpainting (TELEA or NS)
|
||||
inpaint_flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
|
||||
|
||||
# Create binary mask from gradient weight
|
||||
binary_mask = (grad_weight * 255).astype(np.uint8)
|
||||
_, binary_mask = cv2.threshold(binary_mask, 30, 255, cv2.THRESH_BINARY)
|
||||
|
||||
# Expand mask to padded region
|
||||
mask_full = np.zeros((py2 - py1, px2 - px1), dtype=np.uint8)
|
||||
mask_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = binary_mask
|
||||
|
||||
padded_roi = result[py1:py2, px1:px2].copy()
|
||||
inpainted = cv2.inpaint(padded_roi, mask_full, inpaint_radius, inpaint_flag)
|
||||
|
||||
# Blend with strength
|
||||
weight_full = np.zeros((py2 - py1, px2 - px1), dtype=np.float32)
|
||||
weight_full[iy1 : iy1 + rh, ix1 : ix1 + rw] = grad_weight * strength
|
||||
weight_3d = weight_full[:, :, np.newaxis]
|
||||
|
||||
blended = padded_roi.astype(np.float32) * (1 - weight_3d) + inpainted.astype(np.float32) * weight_3d
|
||||
result[py1:py2, px1:px2] = blended.astype(np.uint8)
|
||||
|
||||
return result
|
||||
a_cap = float(alpha_roi.max())
|
||||
if a_cap < 0.2:
|
||||
return None
|
||||
core = alpha_roi >= a_cap * self._CORE_ALPHA_FRAC
|
||||
box = image[y1:y2, x1:x2]
|
||||
if box.shape[:2] != core.shape or not bool(core.any()):
|
||||
return None
|
||||
px = box[core].astype(np.float32) # (N, 3) BGR core pixels
|
||||
hi = px.max(axis=1)
|
||||
lo = px.min(axis=1)
|
||||
return float(np.median((hi - lo) / (hi + 1.0)))
|
||||
|
||||
|
||||
def detect_sparkle_confidence(image_path: Path) -> float | None:
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _shared_engine() -> GeminiEngine:
|
||||
"""Process-wide default ``GeminiEngine`` singleton.
|
||||
|
||||
The engine holds only constant assets (embedded captures, alpha maps, the
|
||||
precomputed template ladder) and takes the image as a method argument, so one
|
||||
instance is reused across every ``detect_sparkle_confidence`` call instead of
|
||||
reloading assets + recomputing alpha maps + rebuilding the template cache on
|
||||
each of the ~34k images an ``identify`` batch scans. Output is identical."""
|
||||
return GeminiEngine()
|
||||
|
||||
|
||||
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
|
||||
"""Visible-sparkle detection confidence for a file, for provenance use.
|
||||
|
||||
Loads the image with cv2 and runs :meth:`GeminiEngine.detect_watermark`.
|
||||
Returns the NCC confidence in [0, 1], or None if the image cannot be read
|
||||
(cv2 returns None for unsupported containers such as HEIC). Kept here so the
|
||||
cv2 dependency stays in this module; callers apply their own threshold.
|
||||
|
||||
``image`` lets a caller that has already decoded the file (e.g. ``identify``
|
||||
running several visible-mark detectors) pass the BGR array to avoid a second
|
||||
full decode; when None the file is read from ``image_path``.
|
||||
"""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(image_path)
|
||||
img = image if image is not None else image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
return float(GeminiEngine().detect_watermark(img).confidence)
|
||||
return float(_shared_engine().detect_watermark(img).confidence)
|
||||
@@ -41,11 +41,14 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
|
||||
# Shift R channel left, B channel right. np.roll is circular, so it wraps
|
||||
# the opposite edge into a thin colored fringe at the L/R borders; replicate
|
||||
# the original edge columns there to keep the intended offset interior-only.
|
||||
if chromatic_shift > 0:
|
||||
r = np.roll(r, -chromatic_shift, axis=1)
|
||||
r[:, -chromatic_shift:] = r[:, -chromatic_shift - 1 : -chromatic_shift]
|
||||
b = np.roll(b, chromatic_shift, axis=1)
|
||||
b[:, :chromatic_shift] = b[:, chromatic_shift : chromatic_shift + 1]
|
||||
# Clamp so the edge-replication slices below always have a source column: a shift
|
||||
# >= width would leave them empty and crash the broadcast (r[:, -shift:] = (H, 0)).
|
||||
shift = min(chromatic_shift, image.shape[1] - 1)
|
||||
if shift > 0:
|
||||
r = np.roll(r, -shift, axis=1)
|
||||
r[:, -shift:] = r[:, -shift - 1 : -shift]
|
||||
b = np.roll(b, shift, axis=1)
|
||||
b[:, :shift] = b[:, shift : shift + 1]
|
||||
|
||||
merged = cv2.merge((b, g, r))
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
c2pa_cloud_manifest_in,
|
||||
c2pa_marker_in,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
@@ -40,24 +41,36 @@ from remove_ai_watermarks.metadata import (
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_AI_VENDORS, C2PA_ISSUERS
|
||||
from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import MarkDetection
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# How much of a non-PNG container to binary-scan for the C2PA issuer.
|
||||
_SCAN_BYTES = 1024 * 1024
|
||||
|
||||
# Visible-sparkle confidence above which the signal is trusted as provenance.
|
||||
# Stricter than the removal default (0.25): on the corpus, Gemini-family
|
||||
# sparkles score >= 0.56 while non-sparkle images top out at 0.49, so 0.5
|
||||
# cleanly separates them and avoids false positives when sparkle is the only
|
||||
# signal (e.g. an OpenAI image scored 0.37 -- below threshold, correctly dropped).
|
||||
_SPARKLE_THRESHOLD = 0.5
|
||||
# Shared with the removal arbitration (watermark_registry.GEMINI_SPARKLE_TRUST_CONF)
|
||||
# so the provenance "is there a sparkle" verdict and the removal "take the sparkle"
|
||||
# decision can never drift apart -- the detect-vs-remove desync the retained-corpus
|
||||
# mining surfaced (2026-06-20). On the corpus Gemini-family sparkles score >= 0.56
|
||||
# while non-sparkle images top out at 0.49, so 0.5 cleanly separates them and avoids
|
||||
# false positives when the sparkle is the only signal (e.g. an OpenAI image scored
|
||||
# 0.37 -- below threshold, correctly dropped).
|
||||
_SPARKLE_THRESHOLD = GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Issuer (C2PA signer) -> human-readable generating platform, derived from the
|
||||
# single C2PA_AI_VENDORS registry. Ordered: when a manifest names several issuers
|
||||
@@ -96,6 +109,13 @@ _HF_JOB_CAVEAT = (
|
||||
"generation) but names neither the model nor the content type, so it is a "
|
||||
"medium-confidence signal, not proof the pixels are AI-generated."
|
||||
)
|
||||
_C2PA_CLOUD_CAVEAT = (
|
||||
"The embedded C2PA manifest is absent but an XMP provenance pointer to the "
|
||||
"vendor's cloud manifest store survives, so the Content Credentials remain "
|
||||
"recoverable server-side -- stripping the file no longer removes the provenance. "
|
||||
"It marks Content Credentials, not AI origin: the cloud manifest may describe a "
|
||||
"human edit, and reading it needs a network fetch this tool does not make."
|
||||
)
|
||||
_SAMSUNG_GENAI_CAVEAT = (
|
||||
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
|
||||
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
|
||||
@@ -121,6 +141,22 @@ class ProvenanceReport:
|
||||
is_ai_generated: bool | None # True / False is never asserted; None = unknown
|
||||
platform: str | None
|
||||
confidence: str # "high" | "medium" | "none"
|
||||
# Coarse AI-origin kind from the C2PA digital-source-type, so a caller can
|
||||
# branch on full generation vs an AI-touched real photo:
|
||||
# "generated" -- digitalSourceType trainedAlgorithmicMedia (fully AI).
|
||||
# "enhanced" -- compositeWithTrainedAlgorithmicMedia (real content with an
|
||||
# AI-composited region; scrub the AI region, keep the photo).
|
||||
# None -- no C2PA AI source-type (verdict, if AI, came from another
|
||||
# signal: IPTC, AIGC, local gen params, xAI, ...).
|
||||
ai_source_kind: str | None = None
|
||||
# True when the AI verdict rests on a metadata or embedded-invisible signal
|
||||
# (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, or
|
||||
# an open DWT-DCT / TrustMark decode) -- as opposed to a visible mark or a
|
||||
# weak medium-confidence hint (hf-job, Samsung genAIType). It is exactly the
|
||||
# set of signals an invisible/diffusion scrub targets: a visible-only or
|
||||
# no-signal image has it False. Equivalent to ``confidence == "high"``;
|
||||
# surfaced as a field so callers gate on intent, not on the string.
|
||||
ai_from_metadata: bool = False
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
@@ -263,6 +299,22 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("grok", "xAI"),
|
||||
("aurora", "xAI"),
|
||||
("xai", "xAI"),
|
||||
# ByteDance family (all its brands normalize to one origin, mirroring constants.py):
|
||||
# without these a transplanted ByteDance C2PA manifest next to an independent
|
||||
# conflicting stamp went undetected by the clash check.
|
||||
("bytedance", "ByteDance"),
|
||||
("doubao", "ByteDance"),
|
||||
("jimeng", "ByteDance"),
|
||||
("dreamina", "ByteDance"),
|
||||
("volcengine", "ByteDance"),
|
||||
("volcano engine", "ByteDance"),
|
||||
("canva", "Canva"),
|
||||
("elevenlabs", "ElevenLabs"),
|
||||
("eleven labs", "ElevenLabs"),
|
||||
("black forest", "Black Forest Labs"),
|
||||
("fal-ai", "fal.ai"),
|
||||
("bria", "Bria"),
|
||||
("apple photos clean up", "Apple"),
|
||||
)
|
||||
|
||||
|
||||
@@ -285,7 +337,23 @@ def _vendor_of(text: str | None) -> str | None:
|
||||
# chain like Adobe over a Gemini original) legitimately names several vendors in
|
||||
# one valid chain and must not read as spoofing. Families not listed here are each
|
||||
# their own independent source (EXIF/XMP generator, IPTC AISystemUsed, AIGC, ...).
|
||||
_CLASH_SOURCE: dict[str, str] = {"c2pa": "c2pa_manifest", "synthid": "c2pa_manifest"}
|
||||
# The single C2PA-manifest source shared by the issuer attribution and the SynthID
|
||||
# proxy (both inferred from the same embedded manifest). Rule 2 keys off it too:
|
||||
# the camera device label is read from this manifest, so an AI marker is a clash
|
||||
# only when its source differs from this (i.e. it is genuinely independent).
|
||||
_C2PA_MANIFEST_SOURCE = "c2pa_manifest"
|
||||
_CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE}
|
||||
|
||||
# The generic China TC260 AIGC vendor label -- a COUNTRY-LEVEL regulatory "this is AI"
|
||||
# stamp any Chinese generator applies to its own output, naming no specific vendor.
|
||||
_GENERIC_AIGC_VENDOR = "China AIGC (TC260)"
|
||||
# Vendors that apply the TC260 label to their OWN output. When one is co-attributed with
|
||||
# the generic AIGC label, the label is that vendor's own stamp (not an independent
|
||||
# competing origin), so the clash check attributes the AIGC label to it -- else a legit
|
||||
# ByteDance/Doubao image (C2PA "ByteDance" + its own TC260 label) would false-clash once
|
||||
# ByteDance normalizes via _vendor_of. Chinese generators only (Canva/BFL/ElevenLabs,
|
||||
# also added to _vendor_of, are NOT TC260 appliers).
|
||||
_TC260_VENDORS: frozenset[str] = frozenset({"ByteDance"})
|
||||
|
||||
|
||||
def _integrity_clashes(
|
||||
@@ -311,6 +379,18 @@ def _integrity_clashes(
|
||||
# families clash only when they belong to different provenance sources (see
|
||||
# _CLASH_SOURCE) AND name different vendors -- so multiple vendors named within
|
||||
# one C2PA manifest (c2pa issuer + synthid proxy) do not flag.
|
||||
# The generic TC260 AIGC label is a Chinese regulatory "this is AI" stamp. When a
|
||||
# Chinese TC260-applying vendor (ByteDance) is ALSO attributed, the label is that
|
||||
# vendor's own stamp on its own output, so attribute it to that vendor -- a legit
|
||||
# Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and
|
||||
# must not clash. Against a NON-TC260 vendor (OpenAI, Google, ...) the label stays
|
||||
# generic and still clashes as a laundering tell (a foreign-vendor image carrying a
|
||||
# Chinese TC260 label names two different origins).
|
||||
if ai_vendors.get("aigc") == _GENERIC_AIGC_VENDOR:
|
||||
own = next((v for f, v in ai_vendors.items() if f != "aigc" and v in _TC260_VENDORS), None)
|
||||
if own:
|
||||
ai_vendors = {**ai_vendors, "aigc": own} # copy co-located with the relabel
|
||||
|
||||
source = {fam: _CLASH_SOURCE.get(fam, fam) for fam in ai_vendors}
|
||||
independent_conflict = any(
|
||||
source[a] != source[b] and ai_vendors[a] != ai_vendors[b] for a, b in itertools.combinations(ai_vendors, 2)
|
||||
@@ -326,7 +406,16 @@ def _integrity_clashes(
|
||||
+ " -- one provenance set was likely spoofed, transplanted, or laundered."
|
||||
)
|
||||
|
||||
if camera_label and camera_has_ai_marker:
|
||||
# Rule 2: a camera-capture C2PA device next to an AI-generation marker. Only
|
||||
# an AI marker from a source INDEPENDENT of the camera's own C2PA manifest is
|
||||
# a contradiction. 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 manifest, so the AI vendor is named only from that same
|
||||
# manifest (c2pa issuer + synthid proxy) -- a legitimate edit chain, not a
|
||||
# spoof. An EXIF/XMP generator, IPTC field, TC260 AIGC label, or second
|
||||
# manifest naming AI on a camera capture is the real laundering tell.
|
||||
independent_ai_marker = any(grp != _C2PA_MANIFEST_SOURCE for grp in source.values())
|
||||
if camera_label and camera_has_ai_marker and independent_ai_marker:
|
||||
vendors = ", ".join(sorted(set(ai_vendors.values()))) or "present"
|
||||
clashes.append(
|
||||
f"Camera-capture C2PA credentials ({camera_label}) coexist with AI-generation markers "
|
||||
@@ -336,19 +425,21 @@ def _integrity_clashes(
|
||||
return clashes
|
||||
|
||||
|
||||
def _visible_sparkle(image_path: Path) -> float | None:
|
||||
def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
|
||||
"""Visible Gemini-sparkle confidence in [0, 1], or None if unavailable.
|
||||
|
||||
Optional: needs cv2/numpy (no GPU). The cv2 work lives in gemini_engine so
|
||||
this module stays dependency-light; returns None if cv2 or the engine
|
||||
assets are missing, or the image can't be read.
|
||||
assets are missing, or the image can't be read. ``image`` is a pre-decoded
|
||||
BGR array shared across the visible-mark detectors (see ``identify``) so the
|
||||
file is not decoded once per detector.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
|
||||
except Exception as exc: # cv2/engine assets missing
|
||||
log.debug("visible-sparkle detector unavailable: %s", exc)
|
||||
logger.debug("visible-sparkle detector unavailable: %s", exc)
|
||||
return None
|
||||
return detect_sparkle_confidence(image_path)
|
||||
return detect_sparkle_confidence(image_path, image=image)
|
||||
|
||||
|
||||
# Visible text marks (registry keys) -> human-readable platform, mirroring the
|
||||
@@ -358,26 +449,34 @@ def _visible_sparkle(image_path: Path) -> float | None:
|
||||
_VISIBLE_MARK_PLATFORM = {
|
||||
"doubao": "ByteDance Doubao (visible 豆包AI生成 mark detected)",
|
||||
"jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
|
||||
"qwen": "Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
|
||||
"kling": "Kuaishou Kling (visible 可灵AI 3.0 mark detected)",
|
||||
"samsung": "Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)",
|
||||
"runninghub": "RunningHub (visible RunningHub AI生成 mark detected)",
|
||||
"baidu": "Baidu (visible 百度 AI生成 mark detected)",
|
||||
"liblib": "LibLibAI (visible LibLibAI mark detected)",
|
||||
}
|
||||
|
||||
|
||||
def _visible_text_marks(image_path: Path) -> list[MarkDetection]:
|
||||
"""Detected visible Doubao/Jimeng marks (registry ``MarkDetection`` list).
|
||||
def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None) -> list[MarkDetection]:
|
||||
"""Detected visible text marks (registry ``MarkDetection`` list).
|
||||
|
||||
The Gemini sparkle keeps its own ``_visible_sparkle`` path (file-level
|
||||
confidence); these two text marks reuse the registry detectors, which apply
|
||||
each engine's calibrated NCC threshold via ``MarkDetection.detected``.
|
||||
Optional: needs cv2/numpy; returns ``[]`` if the engines/assets are missing
|
||||
or the image can't be read.
|
||||
or the image can't be read. ``image`` is a pre-decoded BGR array shared
|
||||
across the visible-mark detectors (see ``identify``) so the file is not
|
||||
decoded once per detector.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
except Exception as exc: # cv2/engine assets missing
|
||||
log.debug("visible-mark detectors unavailable: %s", exc)
|
||||
logger.debug("visible-mark detectors unavailable: %s", exc)
|
||||
return []
|
||||
image = imread(image_path)
|
||||
if image is None:
|
||||
image = imread(image_path)
|
||||
if image is None:
|
||||
return []
|
||||
detections: list[MarkDetection] = []
|
||||
@@ -385,7 +484,7 @@ def _visible_text_marks(image_path: Path) -> list[MarkDetection]:
|
||||
try:
|
||||
det = get_mark(key).detect(image)
|
||||
except Exception as exc: # one engine failing must not break identify
|
||||
log.debug("visible-mark %s detector failed: %s", key, exc)
|
||||
logger.debug("visible-mark %s detector failed: %s", key, exc)
|
||||
continue
|
||||
if det.detected:
|
||||
detections.append(det)
|
||||
@@ -414,6 +513,42 @@ def _trustmark(image_path: Path) -> str | None:
|
||||
return detect_trustmark(image_path)
|
||||
|
||||
|
||||
def _collect_visible_signals(
|
||||
image_path: Path,
|
||||
signals: list[Signal],
|
||||
watermarks: list[str],
|
||||
platform: str | None,
|
||||
) -> str | None:
|
||||
"""Decode once, append every trusted visible-mark signal, and return platform.
|
||||
|
||||
Keeping this stage separate from metadata aggregation makes the optional cv2
|
||||
boundary explicit and guarantees that all visible detectors share one decoded
|
||||
BGR array. A decode failure preserves the detectors' historical fallback/no-op
|
||||
behavior.
|
||||
"""
|
||||
image: NDArray[Any] | None = None
|
||||
try:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
image = imread(image_path)
|
||||
except Exception as exc: # cv2 missing - detectors fall back / no-op
|
||||
logger.debug("visible-mark decode unavailable: %s", exc)
|
||||
|
||||
sparkle_conf = _visible_sparkle(image_path, image=image)
|
||||
if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD:
|
||||
signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium"))
|
||||
watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})")
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
for detection in _visible_text_marks(image_path, image=image):
|
||||
signals.append(Signal(f"visible_{detection.key}", f"NCC confidence {detection.confidence:.2f}", "medium"))
|
||||
watermarks.append(f"Visible {detection.label} (confidence {detection.confidence:.2f})")
|
||||
if platform is None:
|
||||
platform = _VISIBLE_MARK_PLATFORM[detection.key]
|
||||
return platform
|
||||
|
||||
|
||||
def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport:
|
||||
"""Identify an image's origin platform and watermark inventory.
|
||||
|
||||
@@ -454,9 +589,25 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
|
||||
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
|
||||
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
|
||||
)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
# noai.c2pa._populate_registry_fields (covers PNG + any container the c2pa-python
|
||||
# reader handles); fall back to a raw head scan for the non-PNG raw-blob path
|
||||
# where extract_c2pa_info returns {}. Full generation wins when both appear.
|
||||
c2pa_source_kind = info.get("ai_source_kind")
|
||||
if c2pa_source_kind is None:
|
||||
if b"trainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "generated"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in head:
|
||||
c2pa_source_kind = "enhanced"
|
||||
# An identity-AI issuer (a pure-generator brand like Dreamina) asserts AI even
|
||||
# without a digitalSourceType -- some ByteDance/Dreamina manifests ship no
|
||||
# trainedAlgorithmicMedia, so the registered generator name is the only signal.
|
||||
# Restricted to the ``asserts_ai`` vendors (distinctive brand strings), so it
|
||||
# does not reopen the incidental-mention problem the common-word issuers have.
|
||||
issuer_blob = " ".join(issuers)
|
||||
c2pa_identity_ai = has_c2pa and any(org in issuer_blob for org in C2PA_IDENTITY_AI_ORGS)
|
||||
c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai
|
||||
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
|
||||
# for other containers. Best-effort -- some manifests key it as
|
||||
# `claim_generator_info` (Pixel), so this can be None even when a device is
|
||||
@@ -483,6 +634,21 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if c2pa_is_ai and (v := (_vendor_of(_attribute_platform(issuers, is_ai=True)) or _vendor_of(generator))):
|
||||
ai_vendor_claims["c2pa"] = v
|
||||
|
||||
# ── C2PA cloud-manifest reference (Durable Content Credentials) ─
|
||||
# An XMP dcterms:provenance pointer to a vendor manifest store survives even
|
||||
# when the embedded manifest is stripped, so the credentials stay recoverable
|
||||
# server-side (C2PA 2.4). Provenance only -- it does NOT assert AI (the cloud
|
||||
# manifest may describe a human edit), so it is excluded from ai_from_metadata
|
||||
# and the clash vendors. Skip when an embedded manifest already attributed it.
|
||||
if not has_c2pa and (cloud_vendor := c2pa_cloud_manifest_in(head)):
|
||||
signals.append(Signal("c2pa_cloud", f"cloud manifest store: {cloud_vendor}", "medium"))
|
||||
watermarks.append(
|
||||
f"C2PA Durable Content Credentials (cloud manifest at {cloud_vendor}; embedded manifest absent)"
|
||||
)
|
||||
caveats.append(_C2PA_CLOUD_CAVEAT)
|
||||
if platform is None:
|
||||
platform = f"C2PA signer: {cloud_vendor} (cloud manifest)"
|
||||
|
||||
# ── SynthID metadata proxy ──────────────────────────────────────
|
||||
# get_ai_metadata already sets synthid_watermark for both PNG (caBX parser)
|
||||
# and non-PNG (its own synthid_source fallback), so no extra scan is needed.
|
||||
@@ -510,7 +676,15 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks
|
||||
# the edit with photoshop:Credit / IPTC "Apple Photos Clean Up"
|
||||
# next to compositeWithTrainedAlgorithmicMedia -- corpus-measured
|
||||
# 2026-07-23 (35 files); it was detected but never attributed.
|
||||
platform = (
|
||||
"Apple Photos (Clean Up AI edit)"
|
||||
if b"Apple Photos Clean Up" in head
|
||||
else "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
)
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
@@ -539,7 +713,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append("China AIGC label (TC260 standard)")
|
||||
if platform is None:
|
||||
platform = "China AIGC-labeled generator (TC260; e.g. Doubao)"
|
||||
ai_vendor_claims["aigc"] = "China AIGC (TC260)"
|
||||
ai_vendor_claims["aigc"] = _GENERIC_AIGC_VENDOR
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
|
||||
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
|
||||
@@ -549,13 +723,14 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = "Stable Diffusion / local pipeline (Automatic1111, ComfyUI, InvokeAI)"
|
||||
|
||||
# ── EXIF Software / XMP CreatorTool generator (cross-format) ─────
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL) when there is no C2PA.
|
||||
# ── EXIF Software / XMP CreatorTool / PNG-text generator (cross-format) ─
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL and PNG text chunks)
|
||||
# when there is no C2PA.
|
||||
if generator_tag := exif_generator(image_path):
|
||||
signals.append(Signal("exif_generator", f"EXIF/XMP generator: {generator_tag}", "high"))
|
||||
signals.append(Signal("exif_generator", f"Embedded generator tag: {generator_tag}", "high"))
|
||||
watermarks.append(f"Embedded generator tag: {generator_tag}")
|
||||
if platform is None:
|
||||
platform = f"{generator_tag} (EXIF/XMP generator tag)"
|
||||
platform = f"{generator_tag} (embedded generator tag)"
|
||||
if v := _vendor_of(generator_tag):
|
||||
ai_vendor_claims["exif_generator"] = v
|
||||
|
||||
@@ -632,21 +807,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
or xai_sig
|
||||
)
|
||||
|
||||
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
|
||||
if check_visible and (conf := _visible_sparkle(image_path)) is not None and conf >= _SPARKLE_THRESHOLD:
|
||||
signals.append(Signal("visible_sparkle", f"NCC confidence {conf:.2f}", "medium"))
|
||||
watermarks.append(f"Visible Gemini sparkle (confidence {conf:.2f})")
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
# ── Visible Doubao / Jimeng text marks (registry; same stripped-metadata
|
||||
# fallback role as the Gemini sparkle above) ─
|
||||
if check_visible:
|
||||
for det in _visible_text_marks(image_path):
|
||||
signals.append(Signal(f"visible_{det.key}", f"NCC confidence {det.confidence:.2f}", "medium"))
|
||||
watermarks.append(f"Visible {det.label} (confidence {det.confidence:.2f})")
|
||||
if platform is None:
|
||||
platform = _VISIBLE_MARK_PLATFORM[det.key]
|
||||
platform = _collect_visible_signals(image_path, signals, watermarks, platform)
|
||||
|
||||
visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata
|
||||
hf_only = bool(hf_job) and not ai_from_metadata
|
||||
@@ -674,8 +836,40 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
is_ai_generated=is_ai,
|
||||
platform=platform,
|
||||
confidence=confidence,
|
||||
# Only meaningful when the AI verdict actually came from the C2PA source
|
||||
# type; a non-C2PA AI signal (IPTC/AIGC/local gen) leaves it None.
|
||||
ai_source_kind=c2pa_source_kind if (is_ai and has_c2pa) else None,
|
||||
ai_from_metadata=ai_from_metadata,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
integrity_clashes=clashes,
|
||||
)
|
||||
|
||||
|
||||
def has_invisible_target(image_path: Path) -> bool:
|
||||
"""True when a locally-detectable invisible/metadata AI signal is present.
|
||||
|
||||
The decision gate for the diffusion scrub (``invisible`` / ``all`` / ``batch``):
|
||||
regenerating pixels removes an invisible watermark (SynthID, open DWT-DCT,
|
||||
TrustMark) but degrades a real photo, so it must not run when there is nothing
|
||||
to remove. Runs :func:`identify` with ``check_visible=False`` -- a visible mark
|
||||
is handled by the separate visible pass and is NOT a diffusion target -- and
|
||||
``check_invisible=True`` so an open watermark counts. Returns
|
||||
``report.ai_from_metadata`` (C2PA AI issuer / SynthID proxy, IPTC, AIGC, local
|
||||
gen params, EXIF/xAI, open DWT-DCT / TrustMark).
|
||||
|
||||
IMPORTANT -- this cannot prove a pixel SynthID is absent: SynthID is detectable
|
||||
only through its C2PA proxy, so a metadata-stripped AI image reads as no signal
|
||||
here. A False therefore means "no locally-detectable invisible target", not
|
||||
"clean". Callers must NOT present a skip as a finished clean result.
|
||||
|
||||
Fail-safe: any error resolves to True so the removal still runs -- leaving a
|
||||
watermark on a paid removal is worse than over-regenerating a clean image.
|
||||
"""
|
||||
try:
|
||||
report = identify(image_path, check_visible=False, check_invisible=True)
|
||||
except Exception: # unreadable / detector error -> do not skip the removal
|
||||
logger.debug("has_invisible_target: identify failed, defaulting to run", exc_info=True)
|
||||
return True
|
||||
return report.ai_from_metadata
|
||||
@@ -29,11 +29,17 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
|
||||
"""Unicode-safe ``cv2.imread``.
|
||||
"""Unicode-safe ``cv2.imread`` with a Pillow fallback for HEIC/AVIF.
|
||||
|
||||
``flags`` defaults to ``cv2.IMREAD_COLOR`` (same as ``cv2.imread``). Returns
|
||||
``None`` when the file is missing or cannot be decoded, matching
|
||||
``cv2.imread`` semantics so existing ``if img is None`` checks keep working.
|
||||
``None`` when the file is missing or cannot be decoded, matching ``cv2.imread``
|
||||
semantics so existing ``if img is None`` checks keep working.
|
||||
|
||||
OpenCV cannot decode HEIC/AVIF (and some other containers), so when its decode
|
||||
returns None we fall back to Pillow (:func:`_pil_read`): AVIF is native in modern
|
||||
Pillow, HEIC works when the optional ``pillow-heif`` plugin is installed. This lets
|
||||
the pixel path (visible removal) read the same formats the metadata path already
|
||||
scans; normal PNG/JPEG/WebP never reach the fallback, so they are unaffected.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -46,16 +52,50 @@ def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
|
||||
return None
|
||||
if data.size == 0:
|
||||
return None
|
||||
return cv2.imdecode(data, flags)
|
||||
img = cv2.imdecode(data, flags)
|
||||
# cv2.imdecode returns None on an undecodable container (HEIC/AVIF); the type stub
|
||||
# omits that, hence the ignore.
|
||||
if img is not None: # pyright: ignore[reportUnnecessaryComparison]
|
||||
return img
|
||||
return _pil_read(path, flags)
|
||||
|
||||
|
||||
_heif_registered = False
|
||||
|
||||
|
||||
def _pil_read(path: str | Path, flags: int) -> NDArray[Any] | None:
|
||||
"""Decode via Pillow (HEIC/AVIF and any other Pillow-readable container) into the
|
||||
cv2 layout ``flags`` implies: grayscale, 3-channel BGR, or BGRA when the source has
|
||||
alpha and ``IMREAD_UNCHANGED`` was requested. Returns None if Pillow (with the
|
||||
optional HEIF plugin) still cannot open it. No EXIF auto-rotation, matching cv2."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except Exception:
|
||||
return None
|
||||
_register_heif()
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
im.load()
|
||||
if flags == cv2.IMREAD_GRAYSCALE:
|
||||
return np.asarray(im.convert("L"))
|
||||
has_alpha = im.mode in ("RGBA", "LA", "PA") or "transparency" in im.info
|
||||
if flags == cv2.IMREAD_UNCHANGED and has_alpha:
|
||||
return cv2.cvtColor(np.asarray(im.convert("RGBA")), cv2.COLOR_RGBA2BGRA)
|
||||
return cv2.cvtColor(np.asarray(im.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Return a 3-channel BGR view of ``image``, promoting grayscale and BGRA.
|
||||
|
||||
The cv2-based engines (sparkle + the reverse-alpha text marks) assume a
|
||||
3-channel BGR array for their channel reductions (``mean(axis=2)``, the
|
||||
per-pixel logo subtraction). A 2D grayscale or 4-channel BGRA input -- a real
|
||||
Gemini-app export is opaque RGBA -- would otherwise crash or mis-broadcast.
|
||||
The cv2-based engines (sparkle + the text-mark detectors/localizers) assume a
|
||||
3-channel BGR array for their channel reductions (``mean(axis=2)``, the top-hat
|
||||
glyph extraction). A 2D grayscale or 4-channel BGRA input -- a real Gemini-app
|
||||
export is opaque RGBA -- would otherwise crash or mis-broadcast.
|
||||
Centralizes the shape coercion that was inlined across the engines. A 3-channel
|
||||
input is returned unchanged (no copy).
|
||||
"""
|
||||
@@ -68,18 +108,87 @@ def to_bgr(image: NDArray[Any]) -> NDArray[Any]:
|
||||
return image
|
||||
|
||||
|
||||
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
|
||||
"""Unicode-safe ``cv2.imwrite``.
|
||||
def _register_heif() -> None:
|
||||
"""Register the HEIF+AVIF Pillow opener/saver via libheif (idempotent, best-effort)."""
|
||||
global _heif_registered
|
||||
if _heif_registered:
|
||||
return
|
||||
_heif_registered = True
|
||||
import contextlib
|
||||
|
||||
The output format is taken from the path extension (e.g. ``.png``), exactly
|
||||
like ``cv2.imwrite``. Returns ``True`` on success, ``False`` if the codec
|
||||
rejects the image or the path cannot be written (matching ``cv2.imwrite``,
|
||||
which returns ``False`` rather than raising on an unwritable path).
|
||||
"""
|
||||
with contextlib.suppress(Exception):
|
||||
import pillow_heif # pyright: ignore[reportMissingImports]
|
||||
|
||||
pillow_heif.register_heif_opener()
|
||||
|
||||
|
||||
# Containers cv2 cannot encode -> written via Pillow (pillow-heif).
|
||||
_HEIF_WRITE_EXTS = {".heic", ".heif", ".avif"}
|
||||
|
||||
|
||||
def _encode_params(ext: str) -> list[int]:
|
||||
"""cv2 encode params that PRESERVE quality. The removal only touches the mark's
|
||||
footprint, so the container re-encode must not degrade the untouched pixels:
|
||||
JPEG at quality 100 with 4:4:4 chroma (no subsampling), WebP at max. Lossless
|
||||
containers (PNG/BMP/TIFF) need no params. getattr-guarded so an older OpenCV
|
||||
build without the chroma/subsampling flags still gets quality 100."""
|
||||
import cv2
|
||||
|
||||
ext = Path(path).suffix or ".png"
|
||||
ok, buf = cv2.imencode(ext, img)
|
||||
if ext in (".jpg", ".jpeg"):
|
||||
params = [cv2.IMWRITE_JPEG_QUALITY, 100]
|
||||
cq = getattr(cv2, "IMWRITE_JPEG_CHROMA_QUALITY", None)
|
||||
if cq is not None:
|
||||
params += [cq, 100]
|
||||
sf = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR", None)
|
||||
sf444 = getattr(cv2, "IMWRITE_JPEG_SAMPLING_FACTOR_444", None)
|
||||
if sf is not None and sf444 is not None:
|
||||
params += [sf, sf444]
|
||||
return params
|
||||
if ext == ".webp":
|
||||
# cv2 WebP: quality 1-100 is LOSSY; a value > 100 selects LOSSLESS mode.
|
||||
# "work with originals" requires lossless so a mark-removal re-encode does not
|
||||
# degrade the untouched pixels the fill composites over (regression: q100
|
||||
# round-tripped a random image at maxdiff ~230, q101 at 0).
|
||||
return [cv2.IMWRITE_WEBP_QUALITY, 101]
|
||||
return []
|
||||
|
||||
|
||||
def _pil_write(path: str | Path, img: NDArray[Any]) -> bool:
|
||||
"""Encode HEIC/AVIF via Pillow (+pillow-heif) at high quality -- cv2 has no encoder
|
||||
for them. BGR / BGRA in; returns False if Pillow (with the plugin) cannot save."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
_register_heif()
|
||||
if img.ndim == 3 and img.shape[2] == 4:
|
||||
arr, mode = cv2.cvtColor(img, cv2.COLOR_BGRA2RGBA), "RGBA"
|
||||
else:
|
||||
arr, mode = cv2.cvtColor(to_bgr(img), cv2.COLOR_BGR2RGB), "RGB"
|
||||
try:
|
||||
Image.fromarray(np.ascontiguousarray(arr), mode).save(str(path), quality=100)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
|
||||
"""Unicode-safe image write that PRESERVES the input format at max quality.
|
||||
|
||||
Format is taken from the path extension. HEIC/AVIF (which cv2 cannot encode) go
|
||||
through Pillow; everything else through cv2 with quality-preserving params (see
|
||||
:func:`_encode_params`) so a lossy re-encode of the untouched pixels stays near-
|
||||
lossless. Returns ``True`` on success, ``False`` if the codec rejects the image or
|
||||
the path cannot be written (matching ``cv2.imwrite``, never raising)."""
|
||||
import cv2
|
||||
|
||||
ext = (Path(path).suffix or ".png").lower()
|
||||
if ext in _HEIF_WRITE_EXTS:
|
||||
return _pil_write(path, img)
|
||||
try:
|
||||
ok, buf = cv2.imencode(ext, img, _encode_params(ext))
|
||||
except cv2.error:
|
||||
return False
|
||||
if not ok:
|
||||
return False
|
||||
try:
|
||||
@@ -87,3 +196,49 @@ def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Container extensions that carry an alpha channel (for read/write-with-alpha).
|
||||
ALPHA_FORMATS = {".png", ".webp", ".heic", ".heif", ".avif"}
|
||||
|
||||
|
||||
def read_bgr_and_alpha(path: str | Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
|
||||
"""Read an image preserving its alpha channel separately.
|
||||
|
||||
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
|
||||
source has transparency, else ``None``. Grayscale inputs are promoted to BGR.
|
||||
Returns ``(None, None)`` if the image cannot be decoded.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
image = imread(path, cv2.IMREAD_UNCHANGED)
|
||||
if image is None:
|
||||
return None, None
|
||||
if image.ndim == 2:
|
||||
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
|
||||
if image.shape[2] == 4:
|
||||
return image[:, :, :3].copy(), image[:, :, 3].copy()
|
||||
return image, None
|
||||
|
||||
|
||||
def write_bgr_with_alpha(path: str | Path, bgr: NDArray[Any], alpha: NDArray[Any] | None) -> bool:
|
||||
"""Write BGR (with optional alpha) to ``path``. Returns ``imwrite``'s success flag.
|
||||
|
||||
When ``alpha`` is provided and the output extension supports it, the original
|
||||
alpha plane is rejoined unchanged. The watermark region is NOT made transparent:
|
||||
the fill reconstructs real pixels there, so zeroing alpha would punch a
|
||||
transparent hole that renders as a white box on any non-transparent viewer
|
||||
(issue #30). Preserving the input alpha keeps genuinely transparent backgrounds
|
||||
intact without inventing new holes.
|
||||
|
||||
Returning the flag is load-bearing: :func:`imwrite` is contractually non-raising, so
|
||||
this is the ONLY signal a caller gets that the file was not created. Discarding it let
|
||||
a failed write (read-only directory, full disk) run on to ``output.stat()`` and die
|
||||
with a bare ``FileNotFoundError`` traceback instead of a readable error.
|
||||
Regression: ``tests/test_cli_robustness.py::TestFailedWriteIsReported``.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if alpha is None or Path(path).suffix.lower() not in ALPHA_FORMATS:
|
||||
return imwrite(path, bgr)
|
||||
return imwrite(path, np.dstack([bgr, alpha]))
|
||||
@@ -103,8 +103,9 @@ class InvisibleEngine:
|
||||
device: Device for inference (auto/cpu/mps/cuda/xpu). None = auto.
|
||||
pipeline: Pipeline profile. "controlnet" (DEFAULT; SDXL + canny ControlNet
|
||||
that preserves text/face structure via edge conditioning while removing
|
||||
SynthID) or "sdxl" (plain SDXL img2img, lighter but leaves SynthID on
|
||||
flat-graphic content). "default" is a back-compat alias for "sdxl".
|
||||
SynthID), "sdxl" (plain SDXL img2img, lighter but leaves SynthID on
|
||||
flat-graphic content), or "qwen" (Qwen-Image 20B img2img, best text/
|
||||
structure preservation but CUDA/cloud-class). "default" aliases "sdxl".
|
||||
hf_token: HuggingFace API token.
|
||||
progress_callback: Optional callback for progress messages.
|
||||
controlnet_conditioning_scale: ControlNet structure-preservation
|
||||
@@ -170,6 +171,9 @@ class InvisibleEngine:
|
||||
unsharp: float = 0.0,
|
||||
adaptive_polish: bool = False,
|
||||
upscaler: str = "lanczos",
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
|
||||
@@ -205,6 +209,13 @@ class InvisibleEngine:
|
||||
via the ``esrgan`` extra). Only applies when UPscaling (the floor
|
||||
case); a ``max_resolution`` downscale always uses Lanczos. Falls back
|
||||
to Lanczos if the extra is absent.
|
||||
tile: Process the diffusion pass in overlapping tiles instead of one
|
||||
forward pass -- the lossless alternative to ``max_resolution`` for
|
||||
large inputs that OOM on MPS/GPU. Engages only when the long side
|
||||
exceeds ``tile_size``. Pair with ``max_resolution=0`` (the default)
|
||||
so the input keeps its native resolution.
|
||||
tile_size: Tile dimension in px (default 1024).
|
||||
tile_overlap: Overlap between adjacent tiles in px (default 128).
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -218,6 +229,12 @@ class InvisibleEngine:
|
||||
# SDXL img2img runs near its ~1024 training size instead of distorting on a
|
||||
# tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up).
|
||||
# The output is restored to orig_size below, so the floor is transparent.
|
||||
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT)
|
||||
# decodes here too. The --force skip path bypasses image_io.imread, which is
|
||||
# what would otherwise register it, so a bare Image.open would fail on HEIC.
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image_io._register_heif()
|
||||
image = Image.open(image_path)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
orig_size = image.size # (width, height)
|
||||
@@ -245,10 +262,14 @@ class InvisibleEngine:
|
||||
|
||||
# Always persist to a temp file, even without downscaling: WatermarkRemover
|
||||
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
|
||||
# is lost. Cleaned up in the finally block via _tmp_path.
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
|
||||
# is lost. Written as PNG (lossless) regardless of the input format, so a JPEG
|
||||
# input does not feed a re-compressed copy into the diffusion pass.
|
||||
# Cleaned up in the finally block via _tmp_path.
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=".png")
|
||||
_tmp_path = Path(_tmp_str)
|
||||
image.save(_tmp_path)
|
||||
# Convert to RGB before the PNG temp: the diffusion pass is RGB anyway, and a
|
||||
# non-RGB source mode (e.g. a CMYK JPEG) cannot be written as PNG and would raise.
|
||||
image.convert("RGB").save(_tmp_path)
|
||||
os.close(_tmp_fd)
|
||||
image_path = _tmp_path
|
||||
|
||||
@@ -261,6 +282,9 @@ class InvisibleEngine:
|
||||
guidance_scale=guidance_scale,
|
||||
seed=seed,
|
||||
vendor=vendor,
|
||||
tile=tile,
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
)
|
||||
|
||||
# Post-processing chain: decode the diffusion output ONCE, apply the
|
||||
@@ -269,7 +293,11 @@ class InvisibleEngine:
|
||||
# each stage independently imread/imwrote the full-res output, so a run
|
||||
# with several stages PNG-decoded+re-encoded the same image 2-4 times.
|
||||
# PNG is lossless, so the single-write output is byte-identical.
|
||||
needs_restore = target is not None # the input was resized before diffusion
|
||||
# Diffusers rounds native dimensions down to the latent grid (multiples
|
||||
# of 8), even when our own resolution policy did not resize the input.
|
||||
# Route those outputs through the same final resize so --no-polish does
|
||||
# not silently change e.g. 1448x1086 into 1448x1080.
|
||||
needs_restore = target is not None or any(dimension % 8 for dimension in orig_size)
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish or needs_restore:
|
||||
import cv2
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, cast
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Known 48-bit ``bits`` watermarks (dwtDct, no key), name -> message integer.
|
||||
_BITS_48: dict[str, int] = {
|
||||
@@ -96,7 +96,7 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
if _bits_match(value, ref) >= _MATCH_48:
|
||||
return name
|
||||
except Exception as exc: # decode can fail on tiny images
|
||||
log.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
|
||||
logger.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
|
||||
|
||||
# 136-bit default string watermark (SD 1.x / 2.x).
|
||||
try:
|
||||
@@ -104,6 +104,6 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
|
||||
return "Stable Diffusion 1.x / 2.x"
|
||||
except Exception as exc:
|
||||
log.debug("string watermark decode failed for %s: %s", image_path, exc)
|
||||
logger.debug("string watermark decode failed for %s: %s", image_path, exc)
|
||||
|
||||
return None
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Jimeng / Dreamina visible watermark removal engine.
|
||||
"""Jimeng / Dreamina visible watermark detector/localizer.
|
||||
|
||||
Jimeng (即梦AI, ByteDance) stamps generated images with a visible "★ 即梦AI" wordmark
|
||||
in the bottom-right corner -- a near-white semi-transparent overlay, the same overlay
|
||||
class as the Doubao text strip.
|
||||
|
||||
Removal is **reverse-alpha blending** against a captured alpha map
|
||||
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
|
||||
residual inpaint over the glyph footprint. This is one of the three text-mark engines
|
||||
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
|
||||
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
|
||||
supplies only Jimeng's tuned :class:`TextMarkConfig` (bottom-right corner,
|
||||
``assets/jimeng_alpha.png`` rebuilt by ``scripts/visible_alpha_solve.py`` from the gray
|
||||
capture). Jimeng images are also caught by the China TC260 AIGC metadata label, so this
|
||||
is the visible-mark *removal* path, not a new ``identify`` signal.
|
||||
``assets/jimeng_alpha.png`` -- the detection silhouette, rebuilt by
|
||||
``scripts/visible_alpha_solve.py`` from the gray capture). Jimeng images are also caught
|
||||
by the China TC260 AIGC metadata label, so this is the visible-mark *removal* path, not
|
||||
a new ``identify`` signal.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
@@ -41,21 +42,27 @@ TOPHAT_DELTA = 12
|
||||
|
||||
# Shape-consistent detection. Threshold 0.45 cleanly separates real Jimeng marks
|
||||
# (>=0.81) from the Doubao strip (0.21), so the two ByteDance marks do not cross-fire.
|
||||
#
|
||||
# That separation holds at the STRICT threshold ONLY. Relaxed under provenance it
|
||||
# collapses: at the old shared 0.7 factor (gate 0.315) the arm ran at 17% precision
|
||||
# and 33 of its 68 false additions were Doubao marks (corpus-measured 2026-07-18 --
|
||||
# full table at _DEFAULT_PROVENANCE_NCC_FACTOR). That was patched with a tighter 0.85
|
||||
# factor at the time; the competitive rival margin replaced it -- see below.
|
||||
DETECT_MIN_COVERAGE = 0.02
|
||||
DETECT_NCC_THRESHOLD = 0.45
|
||||
|
||||
# Reverse-alpha geometry, emitted by scripts/visible_alpha_solve.py from the gray
|
||||
# capture at the captured width.
|
||||
# Back to the 0.70 default. The 0.85 patch (2026-07-18) existed only to blunt the
|
||||
# Doubao cross-fire by sacrificing recall; the competitive RIVAL MARGIN below
|
||||
# discriminates directly and passes real wordmarks at 100%, so the recall the patch
|
||||
# gave up is no longer the price of precision. See _rival_margin_ok.
|
||||
PROVENANCE_NCC_FACTOR = 0.7
|
||||
|
||||
# Detection-silhouette geometry, emitted by scripts/visible_alpha_solve.py from the
|
||||
# gray capture at the captured width (sizes the silhouette for the detection match;
|
||||
# removal is the template-free glyph-bbox footprint mask).
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- sizes the detection silhouette
|
||||
_ALPHA_HEIGHT_FRAC = 0.0576
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0288
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0288
|
||||
_ALPHA_ALIGN_SEARCH = (0.90, 1.12, 23)
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Jimeng",
|
||||
@@ -72,16 +79,11 @@ _CONFIG = TextMarkConfig(
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
provenance_ncc_factor=PROVENANCE_NCC_FACTOR,
|
||||
rivals=("doubao_alpha.png",),
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
alpha_margin_x_frac=_ALPHA_MARGIN_RIGHT_FRAC,
|
||||
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
alpha_align_search=_ALPHA_ALIGN_SEARCH,
|
||||
min_gw=8,
|
||||
alpha_logo_bgr=_ALPHA_LOGO_BGR,
|
||||
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
|
||||
residual_dilate=_RESIDUAL_DILATE,
|
||||
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
|
||||
)
|
||||
|
||||
JimengDetection = TextMarkDetection
|
||||
@@ -97,13 +99,13 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Jimeng glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class JimengEngine(TextMarkEngine):
|
||||
"""Remove the visible Jimeng "★ 即梦AI" watermark (locate -> mask -> reverse-alpha)."""
|
||||
"""Detect/localize the visible Jimeng "★ 即梦AI" watermark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Kling (可灵, Kuaishou) visible watermark detector/localizer.
|
||||
|
||||
Kling stamps its generations with a thin, light-gray "可灵AI 3.0" text strip in the
|
||||
bottom-right corner, preceded by the vendor's spiral logo (not part of the detection
|
||||
silhouette -- logos vary between releases, the text run is what discriminates).
|
||||
Known variants: an "Omni" suffix release, a latin "KlingAI 3.0" release, and a
|
||||
version-less "可灵AI" -- the silhouette targets the common "可灵AI 3.0" core, so the
|
||||
suffix variants are only caught when the core run is bold enough (measured below).
|
||||
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This module supplies only Kling's tuned
|
||||
:class:`TextMarkConfig` (``assets/kling_alpha.png`` -- a font-rendered synthetic
|
||||
silhouette from ``scripts/render_vendor_silhouettes.py``, never cut from an
|
||||
upload). It also feeds ``identify`` as the medium-confidence ``visible_kling``
|
||||
signal via the registry.
|
||||
|
||||
EVERY tuned number below was measured on the vendor cohort (30 TC260 carriers whose
|
||||
producer USCC 91110108335469089C names the entity, 2026-07-21; harness
|
||||
``scripts/vendor_mark_calibrate.py``), NOT inherited from Doubao:
|
||||
|
||||
* The mark scales with the SHORT side at ~0.12 of it (mark_w/short measured
|
||||
0.118-0.122 across portrait AND landscape carriers -- unimodal, so the shipped
|
||||
3-rung ladder covers it) and sits ~0.03 off the right/bottom edges; the locate
|
||||
box fractions below are fitted from the measured absolute mark rects.
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.239) at the fitted
|
||||
width, matching the aspect the fit converged on (0.25).
|
||||
* Gate 0.35, one step above the clean arm's max: on the cohort-vs-clean run
|
||||
(cohort-contamination-guarded, 286 hand-labelled clean frames) the clean arm
|
||||
scored p99 0.304 / max 0.320, and every cohort frame >= 0.35 carries a visible
|
||||
可灵AI 3.0 mark (9 of ~19 eyeballed visible marks fire = ~47% recall of visible
|
||||
marks; the misses are the faint "Omni"-suffix release, the latin "KlingAI"
|
||||
release and the version-less "可灵AI", which score 0.17-0.25 and cannot be
|
||||
reached without engulfing the clean arm).
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the sub-gate band holds real Kling
|
||||
variants AND the clean arm's top (clean p90 0.220 vs variant marks at 0.17-0.25
|
||||
-- they overlap), so a provenance-relaxed arm cannot separate them. No
|
||||
provenance relaxation exists for this mark.
|
||||
* No rival margin: at the shipped gate the template fires on 1 of 400
|
||||
Doubao-marked frames (0.2%, a 豆包 frame sitting INSIDE the Kling cohort, still
|
||||
below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled clean
|
||||
frames, and a 0.10 rival margin costs zero genuine Kling detections -- so it is
|
||||
simply unnecessary (same conclusion shape as Qwen).
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (measured basis -- see
|
||||
# scale_base). The box is fitted to the measured mark rects: the mark's right
|
||||
# margin is ~0.034 of the short side and its bottom margin ~0.027; width/height
|
||||
# cover the mark plus NCC slack.
|
||||
WM_WIDTH_FRAC = 0.19
|
||||
WM_HEIGHT_FRAC = 0.05
|
||||
MARGIN_RIGHT_FRAC = 0.03
|
||||
MARGIN_BOTTOM_FRAC = 0.023
|
||||
|
||||
# Glyph appearance: a light, low-saturation gray rendered brighter than the local
|
||||
# background (white top-hat), same overlay class as Doubao -- inherited, and
|
||||
# harmless because the tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# (cohort-contamination-guarded): clean p99 0.304 / max 0.320, and every cohort
|
||||
# frame scoring >= 0.35 carries a visible 可灵AI 3.0 mark. 0.35 was picked over
|
||||
# 0.33 (also zero clean fires) for margin against unseen clean content at a cost
|
||||
# of zero measured cohort detections.
|
||||
DETECT_NCC_THRESHOLD = 0.35
|
||||
|
||||
# Detection-silhouette geometry (fraction of the short side), fitted on the
|
||||
# cohort: the mark's width (0.12, unimodal) and the silhouette aspect (0.239).
|
||||
_ALPHA_WIDTH_FRAC = 0.12
|
||||
_ALPHA_HEIGHT_FRAC = 0.0287
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Kling",
|
||||
asset_name="kling_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short", # measured: mark_w/short 0.118-0.122 across orientations
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: the sub-gate band (real Kling variants at 0.17-0.25) overlaps
|
||||
# the clean arm's top (p90 0.220), so provenance relaxation is disabled
|
||||
# outright (factor 1.0 = never relaxed).
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
KlingDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Kling alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "可灵AI 3.0" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Kling glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class KlingEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Kling "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -0,0 +1,189 @@
|
||||
"""LibLibAI visible watermark detector/localizer.
|
||||
|
||||
LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a
|
||||
white triangle logo + "LibLibAI" latin wordmark at **bottom-center** (not a
|
||||
corner -- the locate box is horizontally centered). Detection matches the
|
||||
bundled font-rendered "LibLibAI" silhouette (the triangle logo is NOT rendered
|
||||
-- logos vary, the wordmark discriminates); removal is the shared **localize ->
|
||||
fill** (the glyph blob covers logo + wordmark, both bright).
|
||||
|
||||
This module supplies only LibLibAI's tuned :class:`TextMarkConfig`
|
||||
(``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``,
|
||||
never cut from an upload).
|
||||
|
||||
Measured on the vendor cohort (15 TC260 carriers, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* The wordmark is ~0.10 of the frame WIDTH wide, centered horizontally, its
|
||||
baseline ~0.94-0.95 of the height; consistent across 768..2240-px frames.
|
||||
* The silhouette font is Arial, NOT the STHeiti the CJK marks use: the real
|
||||
wordmark is a grotesque, and measured across 7 candidate fonts Arial lifts
|
||||
the cohort positives from 0.31-0.47 to 0.42-0.73 while the full-corpus
|
||||
false arm (latin UI text) drops to max 0.398. A 200x200 icon false-fired
|
||||
at 0.444, so a per-mark size floor (``_MIN_SHORT_SIDE``) backs the gate.
|
||||
* Gate 0.42 (tophat front-end): false arm max 0.398, cohort 0.43-0.59.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): small cohort, the relaxed band
|
||||
is unmeasured.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (measured basis). The box is
|
||||
# horizontally centered (corner="bc") and covers the logo + wordmark with NCC
|
||||
# slack around the measured 0.10 width.
|
||||
WM_WIDTH_FRAC = 0.20
|
||||
WM_HEIGHT_FRAC = 0.09
|
||||
MARGIN_BOTTOM_FRAC = 0.02
|
||||
|
||||
# Glyph appearance: white wordmark on a usually-darker background (white
|
||||
# top-hat), same overlay class as Doubao -- inherited, harmless because the
|
||||
# tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# (clean p99 0.315 / max 0.367) and re-measured after the font fix: the wordmark
|
||||
# is set in an Arial-class grotesque, and the Arial silhouette lifts the cohort
|
||||
# positives to 0.43-0.59 while the full-corpus false arm (latin UI text bands,
|
||||
# website screenshots) drops to max 0.398 -- generic latin text matches the
|
||||
# wrong font less, which is exactly where the discrimination comes from. Gate
|
||||
# 0.42 keeps all 8 marked cohort frames with a 0.022 margin over the false arm.
|
||||
DETECT_NCC_THRESHOLD = 0.42
|
||||
|
||||
# Detection-silhouette geometry (fraction of the frame width): the wordmark,
|
||||
# measured 0.10 wide with aspect 0.26.
|
||||
_ALPHA_WIDTH_FRAC = 0.10
|
||||
_ALPHA_HEIGHT_FRAC = 0.026
|
||||
|
||||
# Tight ladder: the NCC comb is sharp in size (see runninghub_engine).
|
||||
_LADDER = (0.9, 1.0, 1.1)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="LibLibAI",
|
||||
asset_name="liblib_alpha.png",
|
||||
corner="bc",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=0.0, # unused for corner="bc" (horizontally centered)
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="width",
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: small cohort, the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
LibLibDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled LibLibAI alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "LibLibAI" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the LibLibAI glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class LibLibEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill)."""
|
||||
|
||||
# Per-mark size floor: the wordmark template is 0.10 of the frame width, so
|
||||
# below ~480px short side it degrades under ~48px -- the one full-corpus
|
||||
# false fire with the final Arial template was a 200x200 icon (0.444, above
|
||||
# the gate, on a 20px template; measured 2026-07-22). The smallest true
|
||||
# carrier in the cohort is 768px.
|
||||
_MIN_SHORT_SIDE = 480
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
def detect(self, image: NDArray[Any] | None, *, provenance: bool = False) -> TextMarkDetection:
|
||||
if image is None or not image.size or min(image.shape[:2]) < self._MIN_SHORT_SIDE:
|
||||
return TextMarkDetection()
|
||||
return super().detect(image, provenance=provenance)
|
||||
|
||||
def footprint_mask(
|
||||
self, image: NDArray[Any] | None, *, force: bool = False, dilate: int | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
"""Full-frame mask of the logo + wordmark, bounded by the detector's match box.
|
||||
|
||||
The base class's blob-bbox footprint is wrong in both directions here: the
|
||||
blob bleeds UP into bright background structure (on the 768x1024 cohort
|
||||
frame it reached y 931 and the fill ate the shirt's own print) and it does
|
||||
not own the triangle logo anyway. The match box bounds the wordmark exactly
|
||||
(that is what the NCC localized); the logo sits its own height to the LEFT
|
||||
of the text (measured on the cohort zoom: logo ~1.0x the glyph height, gap
|
||||
~0.3x), so the footprint is the match box extended left by ~1.3 heights.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
from remove_ai_watermarks import image_io, region_eraser
|
||||
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return None
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
if force:
|
||||
rx1, ry1, rx2, ry2 = bx, by, min(w, bx + bw), min(h, by + bh)
|
||||
else:
|
||||
if not self.detect(image).detected:
|
||||
return None
|
||||
_, box = self._tophat_best(image, loc)
|
||||
if box is None:
|
||||
return None
|
||||
gx0, gy0, gx1, gy1 = box
|
||||
gh = gy1 - gy0 + 1
|
||||
pad = max(3, int(0.25 * gh))
|
||||
rx1 = max(0, bx + gx0 - int(1.3 * gh)) # the triangle logo, left of the text
|
||||
ry1 = max(0, by + gy0 - pad)
|
||||
rx2 = min(w, bx + gx1 + 1 + pad)
|
||||
ry2 = min(h, by + gy1 + 1 + pad)
|
||||
if rx1 >= rx2 or ry1 >= ry2:
|
||||
return None
|
||||
d = dilate if dilate is not None else max(3, int(0.02 * bw))
|
||||
return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -93,9 +93,17 @@ def c2pa_marker_in(data: bytes) -> bool:
|
||||
IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"compositeSynthetic",
|
||||
b"algorithmicMedia",
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
)
|
||||
# NOTE: bare ``algorithmicMedia`` is deliberately NOT here. That IPTC digitalSourceType
|
||||
# means "created purely by an algorithm, NOT from sampled training data" (procedural /
|
||||
# generative-code art) -- it is NOT AI/ML generation. Real "Made with AI" labels
|
||||
# (Meta / Instagram / MidJourney) use ``trainedAlgorithmicMedia``. Including the bare
|
||||
# token flagged clean procedural images as AI (is_ai=high + has_invisible_target=True ->
|
||||
# a diffusion scrub of clean content), contradicting the c2pa layer, which sets
|
||||
# source_type without ai_source for it (tests/test_noai.py::test_plain_algorithmic_media_not_flagged_ai).
|
||||
# It is not a substring of the trained/composite tokens, so its removal does not affect
|
||||
# their detection.
|
||||
|
||||
# IPTC Photo Metadata 2025.1 (published 2025-11-27) added explicit AI-disclosure
|
||||
# XMP properties in the Iptc4xmpExt namespace. Their mere presence is an AI
|
||||
@@ -137,6 +145,7 @@ AIGC_MARKERS: tuple[bytes, ...] = (
|
||||
# label even when the namespaced XMP element is absent.
|
||||
_TC260_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Producer-side schema (Doubao and most China-served generators).
|
||||
"Label",
|
||||
"ContentProducer",
|
||||
"ProduceID",
|
||||
@@ -144,6 +153,12 @@ _TC260_FIELDS: frozenset[str] = frozenset(
|
||||
"PropagateID",
|
||||
"ReservedCode1",
|
||||
"ReservedCode2",
|
||||
# Service-provider schema (Tencent Cloud's AIGC variant, mined from the
|
||||
# retained corpus 2026-07): the same ``{"AIGC":{...}}`` wrapper but keyed
|
||||
# ``ServiceProvider`` / ``ServiceUser`` (+ generic ``Time`` / ``ContentId``,
|
||||
# not gated on), embedded in EXIF ``ImageDescription``.
|
||||
"ServiceProvider",
|
||||
"ServiceUser",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -176,6 +191,19 @@ def _is_ai_key(key: str) -> bool:
|
||||
return any(kw in key_lower for kw in AI_KEYWORDS)
|
||||
|
||||
|
||||
def _is_ai_value(value: str) -> bool:
|
||||
"""True if a metadata VALUE carries a known AI-generator token.
|
||||
|
||||
Mirrors :func:`exif_generator`'s value match so removal stays in parity with
|
||||
detection: NovelAI stamps a generic ``Title``/``Source`` text chunk (an
|
||||
AI-shaped value under a non-AI key) that ``_is_ai_key`` alone would keep.
|
||||
"""
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
value_lower = value.lower()
|
||||
return any(token in value_lower for token in AI_GENERATOR_TOKENS)
|
||||
|
||||
|
||||
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
|
||||
# Never IDAT -- that is the compressed pixel stream.
|
||||
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
|
||||
@@ -297,17 +325,13 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
|
||||
|
||||
# Check C2PA — via the official ``c2pa`` lib if available, otherwise via a
|
||||
# binary scan that also catches AVIF/HEIF/JPEG-XL containers (PIL doesn't
|
||||
# expose their metadata uniformly).
|
||||
try:
|
||||
# optional official lib, not a declared dep -> falls back to the binary scan
|
||||
from c2pa import has_c2pa_metadata # pyright: ignore[reportMissingImports, reportUnknownVariableType]
|
||||
# Check C2PA — via the official c2pa-python reader first (spec-tracking, every
|
||||
# container it supports), then a binary scan that also catches AVIF/HEIF/JPEG-XL
|
||||
# containers and synthetic/partial blobs the validator rejects.
|
||||
from remove_ai_watermarks.noai.c2pa import read_manifest_store_json
|
||||
|
||||
if has_c2pa_metadata(image_path):
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
if read_manifest_store_json(image_path) is not None:
|
||||
return True
|
||||
|
||||
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
|
||||
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
|
||||
@@ -323,7 +347,7 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
return True
|
||||
# China TC260 AIGC label as a PNG text chunk (the byte scan above catches
|
||||
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
|
||||
if aigc_label(image_path):
|
||||
if aigc_label(image_path) is not None:
|
||||
return True
|
||||
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
|
||||
if huggingface_job(image_path):
|
||||
@@ -343,13 +367,16 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
|
||||
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
|
||||
EXIF (UserComment) by some China-served generators, brace-matched from the
|
||||
scan head.
|
||||
scan head; 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 by some China-served generators.
|
||||
|
||||
Returns the decoded JSON (e.g. ``{"Label": "1", "ContentProducer": ...}``)
|
||||
or None. The generic forms (the PNG-chunk key ``AIGC`` and the bare
|
||||
``{"AIGC":...}`` object) are accepted only if they carry at least one known
|
||||
TC260 field (``_TC260_FIELDS``); the namespaced XMP element is unambiguous,
|
||||
so any JSON object is accepted.
|
||||
or None. The generic forms (the PNG-chunk key ``AIGC``, the bare
|
||||
``{"AIGC":...}`` object, and the bare ``AIGC{...}`` blob) are accepted only
|
||||
if they carry at least one known TC260 field (``_TC260_FIELDS``); the
|
||||
namespaced XMP element is unambiguous, so any JSON object is accepted.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
@@ -393,24 +420,76 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
body = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False)
|
||||
|
||||
# Raw-JSON {"AIGC":{...}} block (no namespace), as written into JPEG EXIF
|
||||
# (UserComment) by some China-served generators -- the PNG-chunk and XMP
|
||||
# paths above both miss it. The bytes pre-check keeps the common (no-AIGC)
|
||||
# path off the full-buffer decode; raw_decode then brace-matches the inner
|
||||
# object (respecting nested braces / quoted strings) and `_parse` applies the
|
||||
# same dict coercion + TC260-field gate as the generic PNG-chunk path.
|
||||
if b'"AIGC"' in data:
|
||||
text = data.decode("latin-1")
|
||||
brace = text.find("{", text.find('"AIGC"') + len('"AIGC"'))
|
||||
if brace != -1:
|
||||
try:
|
||||
_, end = json.JSONDecoder().raw_decode(text, brace)
|
||||
except ValueError:
|
||||
return None
|
||||
return _parse(text[brace:end], require_tc260_field=True)
|
||||
# Generic raw-JSON forms the PNG-chunk and XMP paths above both miss, each
|
||||
# gated on a TC260 field: the ``"AIGC":{...}`` key wrapper (as written into
|
||||
# JPEG EXIF UserComment) and the bare ``AIGC{...}`` blob (the label glued
|
||||
# straight to its JSON, no key wrapper, in a JPEG APP segment near the JFIF
|
||||
# header). `raw_decode` brace-matches the inner object (respecting nested
|
||||
# braces / quoted strings); `_parse` applies the same dict coercion + TC260
|
||||
# gate as the PNG-chunk path. A non-matching hit (no TC260 field, or an
|
||||
# undecodable brace) must FALL THROUGH to the next form, never short-circuit:
|
||||
# a quoted ``"AIGC"`` can appear later in an XMP packet while the real label
|
||||
# is a bare ``AIGC{...}`` blob earlier in the file, so an unconditional return
|
||||
# on the quoted form would shadow the bare form.
|
||||
text = data.decode("latin-1")
|
||||
for needle in ('"AIGC"', "AIGC{"):
|
||||
start = text.find(needle)
|
||||
if start == -1:
|
||||
continue
|
||||
# First brace at/after the needle: the object brace for ``"AIGC":{`` and
|
||||
# the glued brace (at start+4) for the bare ``AIGC{`` -- one search covers both.
|
||||
brace = text.find("{", start)
|
||||
if brace == -1:
|
||||
continue
|
||||
try:
|
||||
_, end = json.JSONDecoder().raw_decode(text, brace)
|
||||
except ValueError:
|
||||
continue
|
||||
if result := _parse(text[brace:end], require_tc260_field=True):
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
# C2PA "Durable Content Credentials" manifest repositories (C2PA 2.4). When the
|
||||
# embedded manifest is stripped, an XMP ``dcterms:provenance`` URL can still point
|
||||
# at the vendor's cloud manifest store, from which the credentials are recoverable
|
||||
# server-side via the file's soft binding. Host -> vendor label. Verified on real
|
||||
# files: Adobe's Content Authenticity cloud store.
|
||||
_C2PA_MANIFEST_REPOSITORIES: tuple[tuple[bytes, str], ...] = (
|
||||
(b"cai-manifests.adobe.com", "Adobe Content Authenticity"),
|
||||
)
|
||||
|
||||
|
||||
def c2pa_cloud_manifest_in(data: bytes) -> str | None:
|
||||
"""Return a C2PA cloud-manifest vendor label if ``data`` carries an XMP
|
||||
``dcterms:provenance`` pointer to a known manifest repository, else None.
|
||||
|
||||
The shared byte-scan (mirroring ``soft_binding_vendors_in``), so a caller that
|
||||
already holds the scan head (``identify``) reuses it instead of re-reading.
|
||||
"""
|
||||
if b"dcterms:provenance" not in data:
|
||||
return None
|
||||
for host, vendor in _C2PA_MANIFEST_REPOSITORIES:
|
||||
if host in data:
|
||||
return vendor
|
||||
return None
|
||||
|
||||
|
||||
def c2pa_cloud_manifest(image_path: Path) -> str | None:
|
||||
"""Return a C2PA cloud-manifest vendor label if the file carries only an XMP
|
||||
``dcterms:provenance`` pointer to a manifest repository (C2PA 2.4 Durable
|
||||
Content Credentials), else None.
|
||||
|
||||
This fires on the laundering case where the *embedded* manifest was stripped
|
||||
but the XMP cloud reference survives, so the Content Credentials remain
|
||||
recoverable server-side. It is provenance, NOT an AI assertion: the cloud
|
||||
manifest can describe a human edit as easily as an AI generation, and reading
|
||||
its contents needs a network fetch we do not do. ``identify`` surfaces it as a
|
||||
provenance signal without setting ``is_ai_generated``.
|
||||
"""
|
||||
return c2pa_cloud_manifest_in(scan_head(image_path, _QUICK_SCAN_BYTES))
|
||||
|
||||
|
||||
def huggingface_job(image_path: Path) -> str | None:
|
||||
"""Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG
|
||||
text chunk, else None.
|
||||
@@ -450,18 +529,45 @@ _SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
|
||||
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
|
||||
|
||||
|
||||
def _read_file_tail(image_path: Path, size: int) -> bytes:
|
||||
"""Return the last ``size`` bytes of the file (or the whole file if smaller)."""
|
||||
try:
|
||||
file_size = image_path.stat().st_size
|
||||
with open(image_path, "rb") as f:
|
||||
if file_size > size:
|
||||
f.seek(file_size - size)
|
||||
return f.read()
|
||||
except OSError:
|
||||
return b""
|
||||
|
||||
|
||||
def samsung_genai(image_path: Path) -> int | None:
|
||||
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
|
||||
Galaxy AI editing marker, else None.
|
||||
|
||||
See the module note above ``_SAMSUNG_GENAI_RE``: detection is empirical and
|
||||
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
|
||||
``genAIType`` token cannot false-positive.
|
||||
``genAIType`` token cannot false-positive. Galaxy AI appends the marker as a
|
||||
trailer AFTER the JPEG EOI, so on a multi-MB phone photo it sits past the quick-
|
||||
scan window; when the head misses it, also read the file tail (else detection
|
||||
and removal disagree -- the strip reads the whole file and would drop a marker
|
||||
detection never reported).
|
||||
"""
|
||||
head = scan_head(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in head:
|
||||
data = scan_head(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
# The marker is a post-EOI trailer, so only a file LARGER than the quick-scan
|
||||
# window can hide it past the head (`scan_head` already read a smaller file
|
||||
# whole). Gate the extra tail read on that — `samsung_genai` is on the identify
|
||||
# hot path, so a redundant 512 KB re-read per small image is not free.
|
||||
try:
|
||||
oversize = image_path.stat().st_size > _QUICK_SCAN_BYTES
|
||||
except OSError:
|
||||
oversize = False
|
||||
if oversize:
|
||||
data = _read_file_tail(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
return None
|
||||
m = _SAMSUNG_GENAI_RE.search(head)
|
||||
m = _SAMSUNG_GENAI_RE.search(data)
|
||||
if m is None:
|
||||
return None
|
||||
return int(m.group(1)) or None
|
||||
@@ -528,12 +634,15 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
|
||||
def exif_generator(image_path: Path) -> str | None:
|
||||
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
|
||||
field, if it matches a known generator (see ``AI_GENERATOR_TOKENS``), else None.
|
||||
field (or a PNG text chunk), if it matches a known generator (see
|
||||
``AI_GENERATOR_TOKENS``), else None.
|
||||
|
||||
Cross-format: EXIF is read via PIL + piexif for any container PIL can open
|
||||
(JPEG/WebP/AVIF/PNG); an XMP ``CreatorTool`` raw-byte scan additionally covers
|
||||
HEIF/JPEG-XL that PIL can't open without plugins. Only AI tokens match, so
|
||||
ordinary editors (plain "Adobe Photoshop", "GIMP") are not flagged.
|
||||
HEIF/JPEG-XL that PIL can't open without plugins. PNG ``tEXt`` chunks are read
|
||||
too -- NovelAI stamps its generator in ``Software``/``Source``/``Title`` text
|
||||
chunks rather than EXIF. Only AI tokens match, so ordinary editors (plain
|
||||
"Adobe Photoshop", "GIMP") are not flagged.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -541,13 +650,21 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
|
||||
candidates: list[str] = []
|
||||
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes.
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes,
|
||||
# plus PNG text chunks (NovelAI writes Software/Source/Title there, not EXIF).
|
||||
try:
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif_bytes = img.info.get("exif")
|
||||
info = img.info
|
||||
exif_bytes = info.get("exif")
|
||||
# PNG tEXt/iTXt chunks land in img.info too (same idiom as the other
|
||||
# PNG-text readers in this module); NovelAI stamps Software/Source/Title.
|
||||
for key in ("Software", "Source", "Title", "Description"):
|
||||
value = info.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
candidates.append(value)
|
||||
if exif_bytes:
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
# Make catches camera-style tags AI tools reuse (Ideogram writes
|
||||
@@ -627,47 +744,90 @@ def xai_signature(image_path: Path) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
|
||||
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
|
||||
def _is_aigc_exif_value(raw: object) -> bool:
|
||||
"""Whether an EXIF tag value carries a China TC260 AIGC producer/service block.
|
||||
|
||||
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
|
||||
+ UUID ``Artist``) and (b) any ``Software`` / ``Make`` / ``Artist`` /
|
||||
``ImageDescription`` tag whose value carries an ``AI_GENERATOR_TOKENS`` token
|
||||
(Ideogram's ``Make``, Firefly's ``Software``, etc.). Mirrors the detection in
|
||||
``xai_signature`` / ``exif_generator`` so removal scrubs exactly what
|
||||
``identify`` flags, while leaving genuine camera/editor EXIF intact. Returns
|
||||
the names of the removed tags (for logging).
|
||||
Mirrors ``aigc_label``'s EXIF path: the ``{"AIGC":{...}}`` wrapper embedded in
|
||||
``UserComment`` / ``ImageDescription`` by China-served generators (Doubao's
|
||||
producer schema AND Tencent Cloud's service-provider schema, both keyed under
|
||||
``_TC260_FIELDS``). Gated on both the ``AIGC`` marker and a TC260 field so a
|
||||
coincidental token cannot false-drop a genuine caption/comment. Accepts a ``str``
|
||||
too (a PNG ``tEXt``/``iTXt`` value), not only EXIF bytes.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("latin-1", "ignore")
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
return False
|
||||
if b"AIGC" not in raw:
|
||||
return False
|
||||
text = bytes(raw).decode("latin-1", "ignore")
|
||||
return any(field in text for field in _TC260_FIELDS)
|
||||
|
||||
|
||||
def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]]:
|
||||
"""The SINGLE AI-EXIF rule set, as ``(ifd_key, tag, value_bytes, name)`` entries.
|
||||
|
||||
Shared by both EXIF scrubbers so their coverage cannot drift: the JPEG-path
|
||||
:func:`_scrub_ai_exif` pops each tag, and the ISOBMFF-path
|
||||
``isobmff.blank_ai_exif_tokens`` blanks each value's bytes in place. Covers
|
||||
(a) the xAI/Grok ``Signature:`` + UUID-``Artist`` pair, (b) any ``Software`` /
|
||||
``Make`` / ``Artist`` / ``ImageDescription`` tag carrying an ``AI_GENERATOR_TOKENS``
|
||||
token, and (c) the China TC260 ``{"AIGC":{...}}`` block in ``ImageDescription``
|
||||
(0th) or ``UserComment`` (Exif). De-duplicated by ``(ifd_key, tag)`` so a value
|
||||
flagged by two rules is removed and named once. Mirrors the detection in
|
||||
``xai_signature`` / ``exif_generator`` / ``aigc_label``; adding a new AI EXIF
|
||||
placement here reaches BOTH containers.
|
||||
"""
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
ifd = exif_dict.get("0th")
|
||||
if not ifd:
|
||||
return []
|
||||
ifd0: dict[int, Any] = loaded.get("0th") or {}
|
||||
ifde: dict[int, Any] = loaded.get("Exif") or {}
|
||||
seen: set[tuple[str, int]] = set()
|
||||
targets: list[tuple[str, int, bytes, str]] = []
|
||||
|
||||
drop: dict[int, str] = {}
|
||||
def add(ifd_key: str, ifd: dict[int, Any], tag: int, name: str) -> None:
|
||||
value = ifd.get(tag)
|
||||
if isinstance(value, bytes) and (ifd_key, tag) not in seen:
|
||||
seen.add((ifd_key, tag))
|
||||
targets.append((ifd_key, tag, value, name))
|
||||
|
||||
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
|
||||
if _is_xai_signature_pair(
|
||||
_exif_text(ifd, piexif.ImageIFD.ImageDescription), _exif_text(ifd, piexif.ImageIFD.Artist)
|
||||
_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)
|
||||
):
|
||||
drop[piexif.ImageIFD.ImageDescription] = "ImageDescription"
|
||||
drop[piexif.ImageIFD.Artist] = "Artist"
|
||||
|
||||
# (b) Known AI generator token in any of the text tags.
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
add("0th", ifd0, piexif.ImageIFD.Artist, "Artist")
|
||||
# (b) known AI generator token in a 0th text tag.
|
||||
for tag, name in (
|
||||
(piexif.ImageIFD.Software, "Software"),
|
||||
(piexif.ImageIFD.Make, "Make"),
|
||||
(piexif.ImageIFD.Artist, "Artist"),
|
||||
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
):
|
||||
if any(token in _exif_text(ifd, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
drop[tag] = name
|
||||
if any(token in _exif_text(ifd0, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
add("0th", ifd0, tag, name)
|
||||
# (c) TC260 AIGC block in ImageDescription (0th) or UserComment (Exif sub-IFD).
|
||||
if _is_aigc_exif_value(ifd0.get(piexif.ImageIFD.ImageDescription)):
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)):
|
||||
add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment")
|
||||
|
||||
for tag in drop:
|
||||
ifd.pop(tag, None)
|
||||
return list(drop.values())
|
||||
return targets
|
||||
|
||||
|
||||
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
|
||||
"""Delete the AI-provenance EXIF tags (`_ai_exif_targets`) from a piexif dict's
|
||||
``0th`` / ``Exif`` IFDs in place; return the removed tag names (for logging).
|
||||
Genuine camera/editor EXIF is left intact."""
|
||||
removed: list[str] = []
|
||||
for ifd_key, tag, _value, name in _ai_exif_targets(exif_dict):
|
||||
ifd = exif_dict.get(ifd_key)
|
||||
if ifd is not None:
|
||||
ifd.pop(tag, None)
|
||||
removed.append(name)
|
||||
return removed
|
||||
|
||||
|
||||
def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
@@ -727,7 +887,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
result["soft_binding"] = ", ".join(vendors)
|
||||
|
||||
# China TC260 AI-content label (Doubao and other China-served generators).
|
||||
if aigc := aigc_label(image_path):
|
||||
if (aigc := aigc_label(image_path)) is not None:
|
||||
producer = aigc.get("ContentProducer", "")
|
||||
result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}"
|
||||
|
||||
@@ -789,6 +949,154 @@ def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
|
||||
return output_path
|
||||
|
||||
|
||||
def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
|
||||
"""Whether a JPEG APPn segment carries AI provenance to drop wholesale (C2PA in
|
||||
APP11, an AI XMP packet in APP1, an IPTC "Made with AI" record in APP13). EXIF
|
||||
(APP1 ``Exif``) is NOT dropped here -- it is scrubbed tag-by-tag via piexif so
|
||||
genuine camera EXIF survives."""
|
||||
if not (0xE0 <= marker <= 0xEF): # only APPn segments carry these
|
||||
return False
|
||||
# C2PA / JUMBF manifest (APP11).
|
||||
if marker == 0xEB and (c2pa_marker_in(payload) or b"jumb" in payload[:256].lower()):
|
||||
return True
|
||||
# AI XMP packet (APP1): C2PA, a China-AIGC token, or an IPTC digitalSourceType /
|
||||
# 2025.1 AI-disclosure marker (which live in XMP, not only the APP13 IIM record).
|
||||
if (
|
||||
marker == 0xE1
|
||||
and payload.startswith(b"http://ns.adobe.com/xap/")
|
||||
and (
|
||||
c2pa_marker_in(payload)
|
||||
or any(m in payload for m in AIGC_MARKERS)
|
||||
or any(m in payload for m in IPTC_AI_MARKERS)
|
||||
or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
|
||||
)
|
||||
):
|
||||
return True
|
||||
# IPTC "Made with AI" record (APP13).
|
||||
if marker == 0xED and (
|
||||
any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
|
||||
):
|
||||
return True
|
||||
# A bare / wrapped China TC260 AIGC block (``AIGC{...}`` or ``{"AIGC":{...}}``) glued
|
||||
# into ANY APP segment -- some China gens use APP11, APP1, or a near-JFIF APPn. This
|
||||
# runs for every APP marker the specific checks above did NOT already claim, so a bare
|
||||
# AIGC in APP11 (not a C2PA manifest) is no longer missed by the 0xEB C2PA-only check.
|
||||
# ``aigc_label`` detects it anywhere, so removal must drop the carrying segment too
|
||||
# (detection<->removal parity). Skip APP1-EXIF (0xE1 ``Exif``): its camera tags are
|
||||
# scrubbed tag-by-tag via piexif, not dropped wholesale.
|
||||
if not (marker == 0xE1 and payload.startswith(b"Exif")):
|
||||
return _is_aigc_exif_value(payload)
|
||||
return False
|
||||
|
||||
|
||||
def _strip_samsung_trailer(scan_and_tail: bytes) -> bytes:
|
||||
"""Drop a Samsung Galaxy AI editing trailer appended AFTER the JPEG EOI.
|
||||
|
||||
Galaxy AI records its ``PhotoEditor_Re_Edit_Data`` (``genAIType``) blob as a
|
||||
proprietary trailer past the final ``FFD9`` end-of-image, so the verbatim
|
||||
scan copy in :func:`_strip_jpeg_metadata_lossless` would carry it through. If
|
||||
the marker is present in the post-EOI trailer, truncate at EOI (the coded scan
|
||||
is untouched, pixels stay bit-identical). A JPEG with no such trailer -- or a
|
||||
non-Samsung trailer (e.g. an MPF multi-picture block) -- is returned unchanged.
|
||||
"""
|
||||
if _SAMSUNG_EDITOR_MARKER not in scan_and_tail:
|
||||
return scan_and_tail
|
||||
eoi = scan_and_tail.rfind(b"\xff\xd9")
|
||||
if eoi == -1 or _SAMSUNG_EDITOR_MARKER not in scan_and_tail[eoi:]:
|
||||
return scan_and_tail # marker not in the post-EOI trailer; leave the scan alone
|
||||
return scan_and_tail[: eoi + 2]
|
||||
|
||||
|
||||
def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
"""Remove AI metadata from a JPEG WITHOUT re-encoding the DCT scan, so the pixels
|
||||
stay bit-identical (the point of "work with originals" -- a metadata strip must not
|
||||
degrade the image). Walks the marker segments up to SOS, drops the AI-bearing APP
|
||||
segments (:func:`_jpeg_app_carries_ai`), copies the entropy-coded scan verbatim
|
||||
(minus a Samsung Galaxy AI trailer past EOI, via :func:`_strip_samsung_trailer`),
|
||||
then scrubs AI EXIF tags in place via piexif (which rewrites only the APP1 EXIF,
|
||||
leaving genuine camera EXIF and the scan untouched). Returns False if the bytes are
|
||||
not a parseable JPEG, so the caller falls back to the near-lossless PIL re-save."""
|
||||
import piexif
|
||||
|
||||
data = source_path.read_bytes()
|
||||
if not data.startswith(b"\xff\xd8"):
|
||||
return False
|
||||
out = bytearray(b"\xff\xd8")
|
||||
i, n = 2, len(data)
|
||||
while i + 1 < n:
|
||||
if data[i] != 0xFF:
|
||||
return False # malformed marker boundary: defer to the PIL re-encode fallback
|
||||
marker = data[i + 1]
|
||||
if marker in (0xDA, 0xD9): # SOS / EOI -> the coded scan follows; copy verbatim
|
||||
out += _strip_samsung_trailer(data[i:])
|
||||
break
|
||||
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone markers carry no length
|
||||
out += data[i : i + 2]
|
||||
i += 2
|
||||
continue
|
||||
if i + 4 > n:
|
||||
return False # truncated segment header: defer to the PIL re-encode fallback
|
||||
seg_len = int.from_bytes(data[i + 2 : i + 4], "big")
|
||||
seg_end = i + 2 + seg_len
|
||||
if seg_len < 2 or seg_end > n:
|
||||
return False # malformed segment length: defer to the PIL re-encode fallback
|
||||
if not _jpeg_app_carries_ai(marker, data[i + 4 : seg_end]):
|
||||
out += data[i:seg_end]
|
||||
i = seg_end
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(bytes(out))
|
||||
try:
|
||||
exif = piexif.load(str(output_path))
|
||||
if _scrub_ai_exif(exif):
|
||||
piexif.insert(piexif.dump(exif), str(output_path))
|
||||
except Exception:
|
||||
logger.debug("piexif EXIF scrub skipped on %s", output_path, exc_info=True)
|
||||
return True
|
||||
|
||||
|
||||
# Fallback extension -> PIL save format, used only when the content sniff is
|
||||
# inconclusive (never for JPEG re-encode of lossless content).
|
||||
_EXT_TO_PIL_FORMAT = {".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP", ".png": "PNG"}
|
||||
|
||||
|
||||
def _sniff_image_format(head: bytes) -> str | None:
|
||||
"""Actual raster format from a file's leading magic bytes (>= 12 bytes), as a PIL
|
||||
format name ("JPEG"/"PNG"/"WEBP"), or None when unrecognized. The file EXTENSION is
|
||||
unreliable: ~2% of real uploads carry a mismatched one (a PNG served as ``.jpg`` is
|
||||
common). Choosing the save format by extension re-encodes a lossless PNG/WebP into a
|
||||
real JPEG, silently degrading the pixels -- so the strip routes on content instead.
|
||||
ISOBMFF/GIF are handled before this point or fall through to PNG; only the
|
||||
lossy-vs-lossless distinction that matters here is resolved."""
|
||||
if head[:2] == b"\xff\xd8":
|
||||
return "JPEG"
|
||||
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return "PNG"
|
||||
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
||||
return "WEBP"
|
||||
return None
|
||||
|
||||
|
||||
def strip_and_verify(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
*,
|
||||
keep_standard: bool = True,
|
||||
) -> tuple[Path, dict[str, str]]:
|
||||
"""Strip AI metadata, then RE-SCAN the output and report what survived.
|
||||
|
||||
:func:`remove_ai_metadata` is deliberately fail-safe: a file PIL cannot decode is
|
||||
copied through UNCHANGED rather than crashing a caller, and the path it returns is
|
||||
indistinguishable from a real strip. Any caller that reports an outcome to a user
|
||||
therefore cannot tell a no-op from a success -- corpus-observed on real Samsung
|
||||
Galaxy S22 C2PA PNGs, where `metadata --remove` printed "stripped" and exited 0 while
|
||||
the output still read as AI (2026-07-19 parity audit).
|
||||
|
||||
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
|
||||
"""
|
||||
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
|
||||
return out, get_ai_metadata(out)
|
||||
|
||||
|
||||
def remove_ai_metadata(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
@@ -820,23 +1128,32 @@ def remove_ai_metadata(
|
||||
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
|
||||
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
|
||||
# sniff, so a correctly-shaped container is handled whatever its extension.
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets, is_isobmff, strip_c2pa_boxes
|
||||
from remove_ai_watermarks.noai.isobmff import (
|
||||
blank_ai_exif_tokens,
|
||||
blank_ai_xmp_packets,
|
||||
is_isobmff,
|
||||
strip_c2pa_boxes,
|
||||
)
|
||||
|
||||
with open(source_path, "rb") as f:
|
||||
head = f.read(12)
|
||||
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
|
||||
data = source_path.read_bytes()
|
||||
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then AI-label XMP that
|
||||
# lives inside a meta-box ``mime`` item (HEIF/AVIF) -- blanked in place so
|
||||
# box sizes and iloc offsets stay valid and the coded image is untouched.
|
||||
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then the meta-box items
|
||||
# the top-level stripper can't reach (HEIF/AVIF store them in mdat/idat):
|
||||
# AI-label XMP packets and AI-generator tokens in an Exif item -- both
|
||||
# blanked in place (same length) so box sizes and iloc offsets stay valid
|
||||
# and the coded image is untouched.
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
cleaned, blanked = blank_ai_xmp_packets(cleaned)
|
||||
cleaned, exif_blanked = blank_ai_exif_tokens(cleaned)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(cleaned)
|
||||
logger.info(
|
||||
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
|
||||
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) + %d EXIF token(s) → %s",
|
||||
stripped,
|
||||
blanked,
|
||||
exif_blanked,
|
||||
output_path,
|
||||
)
|
||||
return output_path
|
||||
@@ -847,14 +1164,55 @@ def remove_ai_metadata(
|
||||
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
|
||||
return _strip_with_ffmpeg(source_path, output_path)
|
||||
|
||||
# Route on the ACTUAL content format, not the extension (which lies on ~2% of real
|
||||
# uploads -- a PNG served as .jpg, etc.). Trusting the extension would push a
|
||||
# lossless PNG/WebP through the lossy JPEG re-encode below just because its name
|
||||
# ends .jpg, breaking the "work with originals" invariant.
|
||||
true_fmt = _sniff_image_format(head) # reuse the 12 bytes already read above
|
||||
|
||||
# JPEG: strip AI metadata at the byte level so the DCT scan (the pixels) is NOT
|
||||
# re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that
|
||||
# would undo the quality-preserving writes of the removal pipelines); this keeps a
|
||||
# JPEG bit-identical outside its APP metadata segments. Falls through on a
|
||||
# non-parseable JPEG. Only when keep_standard: the lossless walk drops AI segments
|
||||
# but preserves standard ones, so a keep_standard=False caller (strip EVERYTHING)
|
||||
# must use the full re-encode path below instead.
|
||||
if keep_standard and true_fmt == "JPEG" and _strip_jpeg_metadata_lossless(source_path, output_path):
|
||||
return output_path
|
||||
|
||||
# Fail-safe for a truncated / corrupt image: PIL raises OSError when it decodes a
|
||||
# partial file (`img.copy()` / `img.save()` below), which would crash a direct
|
||||
# library caller (a web worker 500s on a partial upload). Probe decodability first;
|
||||
# if it fails, copy the input through unchanged and return -- we cannot strip what we
|
||||
# cannot parse, but we never raise (mirrors strip_c2pa_boxes' fail-safe).
|
||||
try:
|
||||
with Image.open(source_path) as _probe:
|
||||
_probe.load()
|
||||
except Exception:
|
||||
logger.warning("Could not decode %s to strip metadata (truncated/corrupt); copied through", source_path)
|
||||
if output_path != source_path:
|
||||
import shutil
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source_path, output_path)
|
||||
return output_path
|
||||
|
||||
# Read image and filter metadata
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
fmt = output_path.suffix.lower()
|
||||
# Pick the save format. Honor the caller's output extension (so a deliberate
|
||||
# source.png -> output.jpg conversion still works) UNLESS the SOURCE is misnamed
|
||||
# -- a lossless PNG/WebP whose extension lies (served as .jpg). There the output
|
||||
# extension only inherited the source's wrong name, so re-encoding to JPEG would
|
||||
# silently degrade an original; preserve the true content format instead.
|
||||
source_ext_fmt = _EXT_TO_PIL_FORMAT.get(source_path.suffix.lower())
|
||||
if true_fmt is not None and true_fmt != source_ext_fmt:
|
||||
fmt = true_fmt # misnamed source: never let a lying extension force a re-encode
|
||||
else:
|
||||
fmt = _EXT_TO_PIL_FORMAT.get(output_path.suffix.lower()) or true_fmt or "PNG"
|
||||
|
||||
save_kwargs: dict[str, Any] = {}
|
||||
if fmt in (".jpg", ".jpeg"):
|
||||
save_kwargs["format"] = "JPEG"
|
||||
save_kwargs: dict[str, Any] = {"format": fmt}
|
||||
if fmt == "JPEG":
|
||||
# JPEG output is unavoidably lossy, so minimize the loss: high quality
|
||||
# and no chroma subsampling (4:4:4). Without these PIL defaults to
|
||||
# quality 75 + 4:2:0, which visibly degrades a re-saved image.
|
||||
@@ -862,15 +1220,12 @@ def remove_ai_metadata(
|
||||
save_kwargs["subsampling"] = 0
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
elif fmt == ".webp":
|
||||
elif fmt == "WEBP":
|
||||
# Preserve the WebP container losslessly instead of silently rewriting
|
||||
# it as PNG (which changes the format and bloats the file).
|
||||
save_kwargs["format"] = "WEBP"
|
||||
save_kwargs["lossless"] = True
|
||||
if img.mode == "P": # WebP cannot encode palette mode
|
||||
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
|
||||
else:
|
||||
save_kwargs["format"] = "PNG"
|
||||
|
||||
# Collect non-AI metadata
|
||||
kept_meta: dict[str, str] = {}
|
||||
@@ -881,6 +1236,13 @@ def remove_ai_metadata(
|
||||
continue
|
||||
if _is_ai_key(key):
|
||||
continue
|
||||
# Drop a text chunk whose VALUE names an AI generator (NovelAI writes its
|
||||
# stamp into Title/Source under non-AI keys) OR carries a China TC260 AIGC
|
||||
# block (some China gens put `{"AIGC":{...}}` in a STANDARD chunk like
|
||||
# Description, which _is_ai_key would keep) -- keeps removal in parity with
|
||||
# exif_generator / aigc_label's value-based detection.
|
||||
if isinstance(value, str) and (_is_ai_value(value) or _is_aigc_exif_value(value)):
|
||||
continue
|
||||
if key == "exif":
|
||||
with contextlib.suppress(Exception):
|
||||
exif_data = piexif.load(value)
|
||||
|
||||
@@ -17,7 +17,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
|
||||
|
||||
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
|
||||
@@ -26,7 +26,9 @@ __all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
|
||||
def __getattr__(name: str) -> object:
|
||||
"""Resolve the public API on first access (PEP 562), not at package import."""
|
||||
if name == "remove_ai_metadata":
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
# Re-export the single, robust stripper (byte-level, lossless-for-JPEG, all
|
||||
# containers); the old noai.cleaner implementation is retired.
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
return remove_ai_metadata
|
||||
if name in ("WatermarkRemover", "remove_watermark"):
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""C2PA (Coalition for Content Provenance and Authenticity) metadata handling.
|
||||
|
||||
C2PA metadata is embedded in PNG files as a JUMBF container chunk
|
||||
(``caBX``). This module can detect, extract, and re-inject those
|
||||
chunks. Supported issuers:
|
||||
Reading goes through the official c2pa-python ``Reader`` first (any container it
|
||||
supports), via ``extract_c2pa_info`` / ``read_manifest_store_json``. The
|
||||
hand-rolled PNG ``caBX`` JUMBF-chunk tools below (``has_c2pa_metadata`` /
|
||||
``extract_c2pa_chunk`` / ``inject_c2pa_chunk`` and the ``_extract_c2pa_info_png``
|
||||
fallback) cover raw-chunk extraction, re-injection, and the cases the validator
|
||||
rejects (synthetic/partial blobs, a broken/absent wheel). Known issuers:
|
||||
|
||||
- Google Imagen
|
||||
- Adobe Firefly
|
||||
@@ -10,17 +13,22 @@ chunks. Supported issuers:
|
||||
- OpenAI (ChatGPT, GPT-4o, Sora, DALL-E)
|
||||
- Truepic (signing authority)
|
||||
|
||||
The parser uses byte-level scanning — it does not validate JUMBF/CBOR
|
||||
structure but reliably identifies known signatures, issuers, tools,
|
||||
and actions.
|
||||
The fallback parser uses byte-level scanning — it does not validate JUMBF/CBOR
|
||||
structure but reliably identifies known signatures, issuers, tools, and actions.
|
||||
The vendor / source-type / SynthID / soft-binding registry scan
|
||||
(``_populate_registry_fields``) is shared by both the reader and fallback paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_ACTIONS,
|
||||
@@ -33,6 +41,72 @@ from remove_ai_watermarks.noai.constants import (
|
||||
SYNTHID_C2PA_ISSUERS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Official C2PA reader (c2pa-python, a core dependency). It is the primary,
|
||||
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
|
||||
# a fallback for synthetic/partial blobs the validator rejects. The import is
|
||||
# guarded so a partially-broken install degrades to the byte-scan rather than
|
||||
# crashing the dependency-light identify path.
|
||||
_C2paReader: Any = None
|
||||
with contextlib.suppress(Exception): # broken/absent wheel -> byte-scan fallback
|
||||
from c2pa import Reader as _C2paReader # pyright: ignore[reportMissingTypeStubs]
|
||||
_C2PA_READER_AVAILABLE = _C2paReader is not None
|
||||
|
||||
|
||||
def reader_available() -> bool:
|
||||
"""True when the official c2pa-python Reader imported successfully."""
|
||||
return _C2PA_READER_AVAILABLE
|
||||
|
||||
|
||||
def read_manifest_store_json(image_path: Path) -> str | None:
|
||||
"""Return the full C2PA manifest-store JSON for ``image_path``, or None.
|
||||
|
||||
Uses the official c2pa-python ``Reader`` (any container it supports: PNG,
|
||||
JPEG, WebP, AVIF/HEIF, MP4, ...). Returns None when the reader is unavailable,
|
||||
the file carries no parseable manifest, or parsing fails. The JSON is the
|
||||
WHOLE store (every manifest plus ingredient manifests), matching the
|
||||
whole-chunk semantics of the legacy byte scan -- an AI-source marker in a
|
||||
parent/ingredient manifest (e.g. a ChatGPT edit of a Sora generation) is
|
||||
still seen.
|
||||
|
||||
Memoized per (path, mtime): one identify/get_ai_metadata call invokes the
|
||||
structured parser ~3 times on the same file, so the cache turns the repeated
|
||||
crypto-validating reads into one.
|
||||
"""
|
||||
if not _C2PA_READER_AVAILABLE:
|
||||
return None
|
||||
try:
|
||||
mtime = image_path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return _read_manifest_store_impl(str(image_path))
|
||||
return _read_manifest_store_cached(str(image_path), mtime)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=8)
|
||||
def _read_manifest_store_cached(path_str: str, _mtime_ns: int) -> str | None:
|
||||
"""Cache shim: ``_mtime_ns`` is part of the key only (invalidates on change)."""
|
||||
return _read_manifest_store_impl(path_str)
|
||||
|
||||
|
||||
def _read_manifest_store_impl(path_str: str) -> str | None:
|
||||
# try_create returns None when there is no manifest; a default Reader does no
|
||||
# trust enforcement, so an untrusted signer still yields the manifest content
|
||||
# (we report what is in the file, we do not gate on certificate trust).
|
||||
try:
|
||||
reader = _C2paReader.try_create(path_str)
|
||||
except Exception as exc: # malformed manifest, unsupported container, etc.
|
||||
logger.debug("c2pa Reader could not parse %s: %s", path_str, exc)
|
||||
return None
|
||||
if reader is None:
|
||||
return None
|
||||
try:
|
||||
with reader:
|
||||
return reader.json()
|
||||
except Exception as exc: # pragma: no cover - reader opened but json() failed
|
||||
logger.debug("c2pa Reader.json() failed on %s: %s", path_str, exc)
|
||||
return None
|
||||
|
||||
|
||||
def has_c2pa_metadata(image_path: Path) -> bool:
|
||||
"""
|
||||
@@ -90,16 +164,83 @@ def has_c2pa_metadata(image_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _claim_generator_from_store(store: dict[str, Any]) -> str | None:
|
||||
"""Structured claim-generator name from the active manifest of a store dict.
|
||||
|
||||
Prefers the top-level ``claim_generator`` string (Firefly: "Adobe_Firefly"),
|
||||
falling back to the first ``claim_generator_info[].name`` (ChatGPT keys it
|
||||
only there). isprintable() guards against odd binary-ish values.
|
||||
"""
|
||||
active = _active_manifest(store)
|
||||
generator: Any = active.get("claim_generator")
|
||||
if not (isinstance(generator, str) and generator):
|
||||
info_list: list[Any] = active.get("claim_generator_info") or []
|
||||
if info_list and isinstance(first := info_list[0], dict):
|
||||
generator = cast("dict[str, Any]", first).get("name")
|
||||
return generator if isinstance(generator, str) and generator and generator.isprintable() else None
|
||||
|
||||
|
||||
def _active_manifest(store: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The active manifest dict from a manifest-store dict, or {} when absent."""
|
||||
manifests: Any = store.get("manifests")
|
||||
if not isinstance(manifests, dict):
|
||||
return {}
|
||||
active = cast("dict[str, Any]", manifests).get(store.get("active_manifest", ""))
|
||||
return cast("dict[str, Any]", active) if isinstance(active, dict) else {}
|
||||
|
||||
|
||||
def _info_from_store_json(store_json: str) -> dict[str, Any]:
|
||||
"""Build the C2PA info dict from a c2pa-python manifest-store JSON string."""
|
||||
store_bytes = store_json.encode("utf-8")
|
||||
c2pa_info: dict[str, Any] = {
|
||||
"has_c2pa": True,
|
||||
"type": "C2PA (Coalition for Content Provenance and Authenticity)",
|
||||
"c2pa_manifest": f"C2PA manifest store ({len(store_bytes)} bytes)",
|
||||
}
|
||||
# The whole-store JSON carries every vendor / source-type / SynthID /
|
||||
# soft-binding signature (across active + ingredient manifests), so the same
|
||||
# registry scan that runs on the raw caBX chunk applies unchanged here.
|
||||
_populate_registry_fields(store_bytes, c2pa_info)
|
||||
|
||||
try:
|
||||
parsed: Any = json.loads(store_json)
|
||||
except (ValueError, TypeError):
|
||||
return c2pa_info
|
||||
if not isinstance(parsed, dict):
|
||||
return c2pa_info
|
||||
store = cast("dict[str, Any]", parsed)
|
||||
if generator := _claim_generator_from_store(store):
|
||||
c2pa_info["claim_generator"] = generator
|
||||
sig: Any = _active_manifest(store).get("signature_info")
|
||||
if isinstance(sig, dict) and (time := cast("dict[str, Any]", sig).get("time")):
|
||||
c2pa_info["timestamp"] = str(time)
|
||||
return c2pa_info
|
||||
|
||||
|
||||
def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Extract basic C2PA metadata information from an image.
|
||||
Extract C2PA metadata information from an image.
|
||||
|
||||
Uses the official c2pa-python reader first (any supported container), falling
|
||||
back to the hand-rolled PNG caBX parser when the reader is unavailable or the
|
||||
file carries no parseable manifest (synthetic/partial blobs).
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file.
|
||||
|
||||
Returns:
|
||||
Dictionary containing C2PA metadata info.
|
||||
Dictionary containing C2PA metadata info, or {} when none is found.
|
||||
"""
|
||||
image_path = Path(image_path)
|
||||
|
||||
if (store_json := read_manifest_store_json(image_path)) is not None:
|
||||
return _info_from_store_json(store_json)
|
||||
|
||||
return _extract_c2pa_info_png(image_path)
|
||||
|
||||
|
||||
def _extract_c2pa_info_png(image_path: Path) -> dict[str, Any]:
|
||||
"""Fallback PNG caBX parser, used when the c2pa-python reader finds nothing."""
|
||||
c2pa_info: dict[str, Any] = {}
|
||||
|
||||
if not has_c2pa_metadata(image_path):
|
||||
@@ -199,25 +340,77 @@ def soft_binding_vendors_in(buffer: bytes) -> list[str]:
|
||||
return sorted({name for sig, name in C2PA_SOFT_BINDINGS.items() if sig in buffer})
|
||||
|
||||
|
||||
def _populate_registry_fields(buf: bytes, c2pa_info: dict[str, Any]) -> bool:
|
||||
"""Populate the registry-driven C2PA fields by scanning ``buf``.
|
||||
|
||||
Shared by the legacy caBX-chunk parser and the c2pa-python store-JSON path so
|
||||
both produce an identical dict shape. ``buf`` is the raw manifest bytes for
|
||||
the former and the manifest-store JSON (UTF-8) for the latter; the vendor /
|
||||
tool / action / source-type / SynthID / soft-binding signatures appear in
|
||||
both. Sets ``issuer``, ``ai_tool``, ``actions``, ``source_type``,
|
||||
``synthid_vendors`` / ``synthid_watermark``, ``soft_binding_vendors`` /
|
||||
``soft_binding`` when present and returns whether the source type is AI.
|
||||
"""
|
||||
if issuers := [name for sig, name in C2PA_ISSUERS.items() if sig in buf]:
|
||||
c2pa_info["issuer"] = ", ".join(dict.fromkeys(issuers))
|
||||
|
||||
if ai_tools := [name for sig, name in C2PA_AI_TOOLS.items() if sig in buf]:
|
||||
c2pa_info["ai_tool"] = ", ".join(dict.fromkeys(ai_tools))
|
||||
|
||||
if actions := [name for sig, name in C2PA_ACTIONS.items() if sig in buf]:
|
||||
c2pa_info["actions"] = ", ".join(actions)
|
||||
|
||||
# Digital source type (matched anywhere in the store, including ingredient
|
||||
# manifests -- a ChatGPT edit of a Sora generation carries the AI marker on
|
||||
# the parent, not the active manifest).
|
||||
# ``ai_source_kind`` is the structured generated-vs-enhanced split the caller
|
||||
# branches on (full-frame scrub vs region-targeted clean); ``source_type`` is the
|
||||
# human-readable form. The two byte strings are unambiguous:
|
||||
# "compositeWithTrainedAlgorithmicMedia" capitalizes the inner "Trained", so a
|
||||
# lowercase "trainedAlgorithmicMedia" match is standalone full generation, which
|
||||
# wins when both appear (an edit chain).
|
||||
ai_source = False
|
||||
if b"trainedAlgorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
|
||||
c2pa_info["ai_source_kind"] = "generated"
|
||||
ai_source = True
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in buf:
|
||||
# Checked BEFORE bare ``algorithmicMedia``: a manifest can carry both tokens
|
||||
# (an AI-enhanced composite with a procedural ingredient), and the bare-token
|
||||
# branch would otherwise fire first and misclassify the AI composite as non-AI.
|
||||
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
|
||||
c2pa_info["ai_source_kind"] = "enhanced"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
|
||||
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
|
||||
# vendor (Google/OpenAI) on AI-generated content implies an invisible
|
||||
# SynthID watermark in the pixels (see SYNTHID_C2PA_ISSUERS).
|
||||
synthid_vendors = synthid_vendors_in(buf)
|
||||
if synthid_vendors and ai_source:
|
||||
c2pa_info["synthid_vendors"] = synthid_vendors
|
||||
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
|
||||
|
||||
# Soft-binding: a forensic/third-party watermark vendor named in the
|
||||
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
|
||||
soft_binding_vendors = soft_binding_vendors_in(buf)
|
||||
if soft_binding_vendors:
|
||||
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
|
||||
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
|
||||
|
||||
return ai_source
|
||||
|
||||
|
||||
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
"""Parse C2PA chunk data and populate info dictionary."""
|
||||
"""Parse a raw caBX chunk payload and populate the info dictionary.
|
||||
|
||||
The fallback path, used when the official c2pa-python reader is unavailable
|
||||
or rejects the file (synthetic/partial blobs, broken installs).
|
||||
"""
|
||||
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
|
||||
|
||||
# Find issuers
|
||||
issuers: list[str] = []
|
||||
for sig, name in C2PA_ISSUERS.items():
|
||||
if sig in chunk_data:
|
||||
issuers.append(name)
|
||||
if issuers:
|
||||
c2pa_info["issuer"] = ", ".join(set(issuers))
|
||||
|
||||
# Find AI tools
|
||||
ai_tools: list[str] = []
|
||||
for sig, name in C2PA_AI_TOOLS.items():
|
||||
if sig in chunk_data:
|
||||
ai_tools.append(name)
|
||||
if ai_tools:
|
||||
c2pa_info["ai_tool"] = ", ".join(set(ai_tools))
|
||||
_populate_registry_fields(chunk_data, c2pa_info)
|
||||
|
||||
# Claim generator and spec version: read the CBOR text-string values
|
||||
# directly (regex byte-grabbing produced artifacts like ``fGPT-4o``).
|
||||
@@ -229,14 +422,6 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
if (spec := cbor_text_after(chunk_data, b"specVersion")) and spec.isprintable():
|
||||
c2pa_info["c2pa_spec"] = spec
|
||||
|
||||
# Find actions
|
||||
actions: list[str] = []
|
||||
for sig, name in C2PA_ACTIONS.items():
|
||||
if sig in chunk_data:
|
||||
actions.append(name)
|
||||
if actions:
|
||||
c2pa_info["actions"] = ", ".join(actions)
|
||||
|
||||
# Find timestamps
|
||||
timestamp_matches = re.findall(rb"(\d{14}Z)", chunk_data)
|
||||
if timestamp_matches:
|
||||
@@ -244,32 +429,6 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
if len(timestamp_matches) > 1:
|
||||
c2pa_info["timestamps"] = [t.decode("utf-8") for t in timestamp_matches[:3]]
|
||||
|
||||
# Find digital source type
|
||||
ai_source = False
|
||||
if b"trainedAlgorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
|
||||
ai_source = True
|
||||
|
||||
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
|
||||
# vendor (Google/OpenAI) on AI-generated content implies an invisible
|
||||
# SynthID watermark in the pixels (see SYNTHID_C2PA_ISSUERS).
|
||||
synthid_vendors = synthid_vendors_in(chunk_data)
|
||||
if synthid_vendors and ai_source:
|
||||
c2pa_info["synthid_vendors"] = synthid_vendors
|
||||
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
|
||||
|
||||
# Soft-binding: a forensic/third-party watermark vendor named in the
|
||||
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
|
||||
soft_binding_vendors = soft_binding_vendors_in(chunk_data)
|
||||
if soft_binding_vendors:
|
||||
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
|
||||
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
|
||||
|
||||
|
||||
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
"""
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""AI metadata cleaning and removal.
|
||||
|
||||
Provides functions to identify and strip AI-generation metadata from
|
||||
PNG and JPEG images while optionally preserving standard fields like
|
||||
Author, Title, and Copyright.
|
||||
|
||||
The removal pipeline:
|
||||
1. Opens the image and iterates over all metadata keys.
|
||||
2. Classifies each key as AI-related (using ``constants.AI_METADATA_KEYS``
|
||||
and ``constants.AI_KEYWORDS``) or standard.
|
||||
3. Rebuilds the image with only the desired metadata retained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import piexif
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_KEYWORDS, AI_METADATA_KEYS, PNG_METADATA_KEYS
|
||||
from remove_ai_watermarks.noai.utils import get_image_format
|
||||
|
||||
# Pre-compute a lowercase set for O(1) key lookups.
|
||||
_AI_KEYS_LOWER: frozenset[str] = frozenset(k.lower() for k in AI_METADATA_KEYS)
|
||||
|
||||
|
||||
def remove_ai_metadata(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
keep_standard: bool = True,
|
||||
) -> Path:
|
||||
"""
|
||||
Remove all AI-generated metadata from an image.
|
||||
|
||||
Removes:
|
||||
- AI parameters (Stable Diffusion, ComfyUI, etc.)
|
||||
- C2PA metadata (Google Imagen, OpenAI, etc.)
|
||||
- Any metadata with AI-related keywords
|
||||
|
||||
Args:
|
||||
source_path: Path to the source image file.
|
||||
output_path: Optional output path. If not provided, modifies source in place.
|
||||
keep_standard: If True, keeps standard metadata (Author, Title, etc.).
|
||||
|
||||
Returns:
|
||||
Path to the output file with AI metadata removed.
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = source_path
|
||||
|
||||
cleaned_metadata = _extract_non_ai_metadata(source_path, keep_standard)
|
||||
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
|
||||
output_format = get_image_format(output_path)
|
||||
save_kwargs: dict[str, Any] = {"format": output_format}
|
||||
|
||||
# Handle EXIF data (keep it, just don't include AI-related fields)
|
||||
if "exif" in cleaned_metadata:
|
||||
save_kwargs["exif"] = cleaned_metadata["exif"]
|
||||
|
||||
if output_format == "PNG":
|
||||
save_kwargs = _prepare_clean_png_kwargs(save_kwargs, cleaned_metadata)
|
||||
elif output_format == "JPEG":
|
||||
save_kwargs = _prepare_clean_jpeg_kwargs(save_kwargs, cleaned_metadata)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if output_format == "JPEG" and img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
img.save(output_path, **save_kwargs)
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def _extract_non_ai_metadata(source_path: Path, keep_standard: bool) -> dict[str, Any]:
|
||||
"""Extract metadata excluding AI-related fields."""
|
||||
cleaned_metadata: dict[str, Any] = {}
|
||||
|
||||
with Image.open(source_path) as img:
|
||||
# Handle EXIF data
|
||||
if "exif" in img.info:
|
||||
with contextlib.suppress(Exception):
|
||||
exif_dict = piexif.load(img.info["exif"])
|
||||
cleaned_metadata["exif"] = exif_dict
|
||||
|
||||
# Extract non-AI metadata
|
||||
for key, value in img.info.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if _is_ai_metadata_key(key):
|
||||
continue
|
||||
|
||||
is_standard = keep_standard and key in PNG_METADATA_KEYS
|
||||
is_nonstandard = not keep_standard and key not in ["exif", "dpi", "gamma"] and key not in PNG_METADATA_KEYS
|
||||
if is_standard or is_nonstandard:
|
||||
cleaned_metadata[key] = value
|
||||
|
||||
# Keep DPI and gamma
|
||||
if "dpi" in img.info:
|
||||
cleaned_metadata["dpi"] = img.info["dpi"]
|
||||
if "gamma" in img.info:
|
||||
cleaned_metadata["gamma"] = img.info["gamma"]
|
||||
|
||||
return cleaned_metadata
|
||||
|
||||
|
||||
def _is_ai_metadata_key(key: str) -> bool:
|
||||
"""Return True if *key* is an AI-generation metadata field.
|
||||
|
||||
Detection uses two layers:
|
||||
1. Exact match against the canonical ``AI_METADATA_KEYS`` list.
|
||||
2. Substring match against ``AI_KEYWORDS`` (covers partial hits
|
||||
like ``"stable_diffusion_model"``).
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
if key_lower in _AI_KEYS_LOWER:
|
||||
return True
|
||||
return any(kw in key_lower for kw in AI_KEYWORDS)
|
||||
|
||||
|
||||
def _prepare_clean_png_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare save kwargs for clean PNG."""
|
||||
pnginfo: dict[str, Any] = {}
|
||||
exclude_keys = ["exif", "exif_raw", "dpi", "gamma"]
|
||||
|
||||
for key, value in metadata.items():
|
||||
if key not in exclude_keys:
|
||||
pnginfo[key] = value
|
||||
|
||||
if pnginfo:
|
||||
pnginfo_obj = PngInfo()
|
||||
for key, value in pnginfo.items():
|
||||
if isinstance(value, str):
|
||||
pnginfo_obj.add_text(key, value)
|
||||
elif isinstance(value, bytes):
|
||||
pnginfo_obj.add_text(key, value.decode("utf-8", errors="replace"))
|
||||
save_kwargs["pnginfo"] = pnginfo_obj
|
||||
|
||||
if "dpi" in metadata:
|
||||
save_kwargs["dpi"] = metadata["dpi"]
|
||||
|
||||
return save_kwargs
|
||||
|
||||
|
||||
def _prepare_clean_jpeg_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare save kwargs for clean JPEG."""
|
||||
exif_dict = metadata.get("exif", {"0th": {}, "Exif": {}, "1st": {}, "GPS": {}, "Interop": {}})
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
exif_bytes = piexif.dump(exif_dict)
|
||||
save_kwargs["exif"] = exif_bytes
|
||||
|
||||
if "dpi" in metadata:
|
||||
save_kwargs["dpi"] = metadata["dpi"]
|
||||
|
||||
return save_kwargs
|
||||
|
||||
|
||||
def has_ai_content(image_path: Path) -> bool:
|
||||
"""
|
||||
Check if an image has any AI-generated content or metadata.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image file.
|
||||
|
||||
Returns:
|
||||
True if the image contains AI metadata.
|
||||
"""
|
||||
from remove_ai_watermarks.noai.extractor import has_ai_metadata
|
||||
|
||||
return has_ai_metadata(image_path)
|
||||
@@ -6,8 +6,12 @@ so adding a new AI tool or metadata key requires updating only this file.
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
# Supported image formats
|
||||
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
# Supported image formats for the pixel/removal path (CLI input validation + batch
|
||||
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the core
|
||||
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
|
||||
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
|
||||
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
|
||||
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"}
|
||||
|
||||
# AI-generated image metadata keys (Stable Diffusion, ComfyUI, Midjourney, etc.)
|
||||
AI_METADATA_KEYS = [
|
||||
@@ -96,6 +100,13 @@ class C2paAiVendor(NamedTuple):
|
||||
# (usually a shorter form of org, e.g. "Google" for "Google LLC"); None when platform is.
|
||||
needle: str | None
|
||||
synthid: bool = False # vendor pairs an invisible SynthID pixel watermark with its C2PA manifest
|
||||
# The vendor's mere presence in the manifest asserts AI generation even without
|
||||
# a digitalSourceType (``trainedAlgorithmicMedia``) assertion. Set ONLY for a
|
||||
# pure-generator brand whose issuer/generator byte string is unambiguous (e.g.
|
||||
# "Dreamina"). Do NOT set for common-word issuers (Adobe/Google/OpenAI/Microsoft):
|
||||
# those appear incidentally in unrelated XMP/trust-chain bytes, so they stay
|
||||
# source-type-gated in identify._attribute_platform.
|
||||
asserts_ai: bool = False
|
||||
|
||||
|
||||
# C2PA known vendors, ORDERED for first-match-wins platform attribution: when a
|
||||
@@ -122,15 +133,99 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
C2paAiVendor(
|
||||
b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"
|
||||
),
|
||||
# Some Volcano Engine certs name the signer with the Chinese legal entity
|
||||
# "北京火山引擎科技有限公司" (Beijing Volcano Engine Technology Co., Ltd.) rather
|
||||
# than the latin "volcengine" -- the latin needle misses it entirely, so real
|
||||
# ByteDance output was un-attributed in production traffic. The issuer is the
|
||||
# UTF-8 of the Chinese name (it appears UTF-8-encoded in the manifest-store
|
||||
# JSON and the raw caBX bytes alike); it normalizes to the same "ByteDance"
|
||||
# needle and platform as the volcengine row, so the two collapse together for
|
||||
# clash detection. Verified against the mined retained corpus, 2026-06-20.
|
||||
C2paAiVendor(
|
||||
"北京火山引擎科技有限公司".encode(),
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
# ByteDance's international brand (BytePlus / Seedream / Seededit) signs its
|
||||
# cert as "Byteplus Pte. Ltd." -- the bare ``volcengine`` needle misses it, so
|
||||
# real BytePlus AI output was mis-attributed (an incidental "Adobe XMP" string
|
||||
# in the file's XMP made it read "Adobe Firefly"). Adding the issuer means the
|
||||
# clean manifest issuer matches "BytePlus (ByteDance)" directly. The platform
|
||||
# string mirrors the volcengine row: both share the "ByteDance" needle, so the
|
||||
# earlier row's label wins anyway -- they normalize together for clash
|
||||
# detection. Verified on real signed files in production traffic, 2026-06-19.
|
||||
C2paAiVendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as "Bytedance
|
||||
# Pte. Ltd." with a "Dreamina/x.y" claim generator and, unlike the Volcano
|
||||
# Engine output, NO digitalSourceType assertion -- so the generator name is the
|
||||
# only AI signal. It is registered by that generator token (which the caBX /
|
||||
# store-JSON byte scan sees across active + ingredient manifests, where the
|
||||
# active manifest is often a plain c2pa-tool transcode). ``asserts_ai`` lets the
|
||||
# issuer alone flag AI without trainedAlgorithmicMedia; "Dreamina" is a
|
||||
# distinctive brand string, so it does not risk the incidental-mention problem
|
||||
# the common-word issuers have. Verified on real signed files in the retained
|
||||
# corpus, 2026-07. Normalizes to the same "ByteDance" needle/platform as the
|
||||
# volcengine row (they collapse together for clash detection).
|
||||
C2paAiVendor(
|
||||
b"Dreamina",
|
||||
"ByteDance (Dreamina)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance",
|
||||
asserts_ai=True,
|
||||
),
|
||||
# Canva Magic Media signs AI-generated images as "Canva" with a generic
|
||||
# c2pa-rs claim generator + trainedAlgorithmicMedia; without this entry the
|
||||
# source read AI but no platform was attributed. Verified on real signed files
|
||||
# in production traffic, 2026-06-19. Canva does not use SynthID.
|
||||
C2paAiVendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"),
|
||||
# ElevenLabs is a pure generative-AI company (AI voice / audio, and image /
|
||||
# video via its API); it signs output as "Eleven Labs Inc.", so the C2PA
|
||||
# manifest alone marks AI generation. Verified against the mined retained
|
||||
# corpus, 2026-06-20. ElevenLabs does not use SynthID.
|
||||
C2paAiVendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
|
||||
# fal.ai (generative inference platform, issuer "fal - Features & Labels
|
||||
# Inc." / common name "fal.ai", claim generators like "fal-ai/seedvr",
|
||||
# "fal-ai/gpt-image-2"). Corpus-measured 2026-07-23 on the retained
|
||||
# uploads (17 files): the files carry trainedAlgorithmicMedia, so the
|
||||
# verdict already fired, but the platform stayed unattributed. fal.ai is
|
||||
# a pure generative platform, so ``asserts_ai`` also covers its output
|
||||
# that omits the source-type.
|
||||
C2paAiVendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True),
|
||||
# Bria AI (bria.ai, generative platform) signs as "Bria Artificial
|
||||
# Intelligence" with a "Bria Ai" claim generator and source type
|
||||
# ``empty`` (NOT trainedAlgorithmicMedia), so a real signed file was
|
||||
# completely missed by identify -- corpus-found 2026-07-23. A pure-AI
|
||||
# vendor with distinctive strings, so ``asserts_ai`` is safe here.
|
||||
C2paAiVendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True),
|
||||
# Truepic is a C2PA signing authority, not an AI generator: no platform label,
|
||||
# never asserts is_ai (the verdict comes from the digital-source-type).
|
||||
C2paAiVendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
# Deliberately NOT registered as AI-generation vendors (mined-corpus candidates
|
||||
# evaluated 2026-06-20):
|
||||
# - TikTok Inc.: signs C2PA as a content-provenance / AI-labeling authority on
|
||||
# uploads, not as an image generator. The is_ai verdict keys off the
|
||||
# digitalSourceType (trainedAlgorithmicMedia), which is already honored; a
|
||||
# bare TikTok signer marks distribution provenance, not generation, so adding
|
||||
# it as a generator needle would mis-label human uploads as AI.
|
||||
# - PixelBin.io (issuer "Fynd"): an image transformation / optimization / CDN
|
||||
# service. Its C2PA stamps a transform/upload step, not a generation event.
|
||||
# Both are excluded to avoid false-positive AI attribution; re-evaluate only
|
||||
# against a real signed file whose manifest carries a trainedAlgorithmicMedia
|
||||
# digital-source type produced by the vendor itself.
|
||||
|
||||
# Derived view -- add a vendor to C2PA_AI_VENDORS above, not here.
|
||||
# C2PA issuer signature -> resolved org name, for the manifest byte-scan.
|
||||
C2PA_ISSUERS: dict[bytes, str] = {v.issuer: v.org for v in C2PA_AI_VENDORS}
|
||||
|
||||
# Resolved org names of the vendors whose presence asserts AI generation on its
|
||||
# own (no digitalSourceType needed) -- see the ``asserts_ai`` field. identify uses
|
||||
# this to lift the AI verdict for an identity-AI issuer (e.g. Dreamina) that ships
|
||||
# no trainedAlgorithmicMedia. Derived from the flag -- set it on the vendor, not here.
|
||||
C2PA_IDENTITY_AI_ORGS: frozenset[str] = frozenset(v.org for v in C2PA_AI_VENDORS if v.asserts_ai)
|
||||
|
||||
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
|
||||
# watermark -- a metadata proxy for "SynthID is in the pixels":
|
||||
# - Google (Imagen/Gemini): embeds SynthID, long-standing (DeepMind docs).
|
||||
@@ -216,6 +311,23 @@ AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
|
||||
"leonardo",
|
||||
"flux",
|
||||
"dreamstudio",
|
||||
# Mined from the retained corpus 2026-06-22 (no C2PA -- a plain EXIF/PNG
|
||||
# generator stamp was the only signal and we read none of them):
|
||||
# - NovelAI (anime SD): PNG tEXt Software="NovelAI", Source="NovelAI
|
||||
# Diffusion V4.5 <hash>", Title="NovelAI generated image".
|
||||
# - Reve Image (reve.com): EXIF Software / XMP CreatorTool = "reve.com"
|
||||
# (the bare token "reve" would false-positive on "forever"/"reverie").
|
||||
# - Aphrodite AI: EXIF Make / Software = "Aphrodite AI[ v1.0]".
|
||||
"novelai",
|
||||
"reve.com",
|
||||
"aphrodite ai",
|
||||
# Corpus-mined 2026-07-23:
|
||||
# - Apple Photos Clean Up (Apple Intelligence object removal): XMP
|
||||
# photoshop:Credit / IPTC credit value; composite source-type
|
||||
# covered detection, this token covers removal parity (35 files).
|
||||
# - fal-ai: generative-platform generator string (17 files).
|
||||
"apple photos clean up",
|
||||
"fal-ai",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -33,7 +33,7 @@ from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_MARKERS,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
|
||||
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
|
||||
@@ -157,10 +157,11 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
|
||||
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
|
||||
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
|
||||
(all ISOBMFF). NOTE: this drops only top-level boxes. An AI-label XMP packet
|
||||
stored as an *item inside the ``meta`` box* (typical for AVIF/HEIF) is handled
|
||||
separately by :func:`blank_ai_xmp_packets`; an ``Exif`` meta-box item is still
|
||||
not removed (would need meta-box surgery) and remains a documented limitation.
|
||||
(all ISOBMFF). NOTE: this drops only top-level boxes. AI metadata stored as an
|
||||
*item inside the ``meta`` box* (typical for AVIF/HEIF) is handled separately and
|
||||
in place (same length, no offset rewrite): AI-label XMP by
|
||||
:func:`blank_ai_xmp_packets`, and AI-generator tokens in an ``Exif`` item by
|
||||
:func:`blank_ai_exif_tokens`.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
@@ -187,7 +188,7 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
# would silently truncate the file from the bad box to EOF -- worse than not
|
||||
# stripping. If the walk did not consume the whole input, return it unchanged.
|
||||
if consumed != len(data):
|
||||
log.warning(
|
||||
logger.warning(
|
||||
"ISOBMFF box walk stopped at offset %d of %d (malformed box); "
|
||||
"returning input unchanged to avoid truncation",
|
||||
consumed,
|
||||
@@ -223,3 +224,61 @@ def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
|
||||
return packet
|
||||
|
||||
return _XMP_PACKET_RE.sub(_scrub, data), blanked
|
||||
|
||||
|
||||
# EXIF TIFF byte-order headers: little-endian (II 0x2a 0x00) and big-endian
|
||||
# (MM 0x00 0x2a). A HEIF/AVIF ``Exif`` meta-box item stores its TIFF block in
|
||||
# ``mdat`` / ``idat``, so the block (and these headers) appear in the raw bytes.
|
||||
_TIFF_HEADERS: tuple[bytes, ...] = (b"II\x2a\x00", b"MM\x00\x2a")
|
||||
# How far past a TIFF header an EXIF block plausibly extends; bounds the slice we
|
||||
# hand to piexif and search within (EXIF blocks are small kilobyte-scale).
|
||||
_EXIF_WINDOW = 256 * 1024
|
||||
|
||||
|
||||
def blank_ai_exif_tokens(data: bytes) -> tuple[bytes, int]:
|
||||
"""Overwrite (with spaces, in place) any AI-generator token in an EXIF block
|
||||
stored as an ISOBMFF ``meta``-box ``Exif`` item; return ``(data, blanked_count)``.
|
||||
|
||||
HEIF/AVIF can carry EXIF as a ``meta``-box ``Exif`` item whose TIFF bytes live
|
||||
in ``mdat`` / ``idat`` -- out of reach of the top-level box stripper, and (when
|
||||
no pillow-heif plugin is installed) of the PIL EXIF reader too, so an AI
|
||||
``Software`` / ``Make`` / ``Artist`` / ``ImageDescription`` tag there survived
|
||||
``remove_ai_metadata`` (a documented gap). This locates EXIF TIFF blocks by
|
||||
their byte-order header, **validates each with piexif** (so a coincidental
|
||||
II/MM run in pixel data is ignored -- it will not parse as a TIFF IFD), and
|
||||
overwrites any AI value with spaces of the SAME length. Because the replacement
|
||||
is same-length, every box size and ``iloc`` offset stays valid and the coded image
|
||||
is untouched -- only the AI tag content is destroyed; camera/editor EXIF without an
|
||||
AI token is left intact. This mirrors ``metadata._scrub_ai_exif`` in what it removes
|
||||
-- generator tokens (``Software``/``Make``/``Artist``/``ImageDescription``), the
|
||||
China TC260 ``{"AIGC":{...}}`` block (``ImageDescription``/``UserComment``), and the
|
||||
xAI/Grok ``Signature:`` + UUID-``Artist`` pair -- since on the ISOBMFF path this is
|
||||
the ONLY EXIF scrubber (``_scrub_ai_exif`` never runs there), so without parity a
|
||||
HEIC/AVIF AIGC/xAI tag is detected but not removed.
|
||||
"""
|
||||
import piexif
|
||||
|
||||
# The AI-EXIF rule set is defined ONCE in metadata._ai_exif_targets and shared by both
|
||||
# EXIF scrubbers (the JPEG _scrub_ai_exif pops the tag; here we blank the value bytes),
|
||||
# so their coverage cannot drift. Imported lazily to avoid import-order coupling with
|
||||
# metadata (which imports this module); a deliberate cross-module use, not an API leak.
|
||||
from remove_ai_watermarks.metadata import _ai_exif_targets # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
out = bytearray(data)
|
||||
blanked = 0
|
||||
for header in _TIFF_HEADERS:
|
||||
pos = data.find(header)
|
||||
while pos != -1:
|
||||
window = bytes(out[pos : pos + _EXIF_WINDOW])
|
||||
try:
|
||||
loaded: dict[str, Any] = piexif.load(window)
|
||||
except Exception:
|
||||
loaded = {}
|
||||
for _ifd_key, _tag, value, _name in _ai_exif_targets(loaded):
|
||||
# Blank the value bytes in place, within this EXIF block only.
|
||||
vpos = out.find(value, pos, pos + _EXIF_WINDOW)
|
||||
if vpos != -1:
|
||||
out[vpos : vpos + len(value)] = b" " * len(value)
|
||||
blanked += 1
|
||||
pos = data.find(header, pos + len(header))
|
||||
return bytes(out), blanked
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Sliding-window tiled diffusion for large images.
|
||||
|
||||
The img2img / ControlNet pipeline denoises the WHOLE image in one forward pass,
|
||||
so it OOMs on MPS/GPU above ~2K (issue #10). Tiling splits the image into
|
||||
overlapping tiles -- each kept near SDXL's ~1024 training size -- regenerates
|
||||
each tile independently, and feather-blends the overlaps. The result is processed
|
||||
at NATIVE resolution with no seam: the lossless alternative to the
|
||||
``--max-resolution`` downscale (which trades quality for a smaller forward pass).
|
||||
|
||||
The geometry (``plan_tiles``) and the blend weighting (``feather_weights``) are
|
||||
pure functions, unit-tested without the diffusion model. ``run_tiled`` is the
|
||||
orchestration loop; it takes a ``generate_tile`` callable (one img2img/ControlNet
|
||||
pass on a single PIL tile) so it stays decoupled from the pipeline internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image as PILImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Strictly-positive floor for the accumulated blend weights so a region covered
|
||||
# by a single feathered tile edge (an image corner, no neighbour to blend with)
|
||||
# never divides by zero.
|
||||
_WEIGHT_EPS = 1e-3
|
||||
|
||||
|
||||
class Tile(NamedTuple):
|
||||
"""A tile crop box in the source image: top-left ``(x, y)`` + ``width``/``height``."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
def _axis_positions(length: int, tile: int, overlap: int) -> list[int]:
|
||||
"""Tile start offsets along one axis, last tile flush to the far edge.
|
||||
|
||||
Every interior tile is exactly ``tile`` long; the final tile is pulled back
|
||||
to ``length - tile`` so it ends exactly at the edge (it simply overlaps its
|
||||
predecessor a little more). Keeping all tiles the same size is what lets the
|
||||
diffusion pass run at SDXL's preferred dimension on every tile.
|
||||
"""
|
||||
if tile <= 0:
|
||||
raise ValueError(f"tile must be positive, got {tile}")
|
||||
if length <= tile:
|
||||
return [0]
|
||||
# Guarantee forward progress even on a pathological overlap >= tile.
|
||||
overlap = min(max(overlap, 0), tile - 1)
|
||||
step = tile - overlap
|
||||
positions = list(range(0, length - tile + 1, step))
|
||||
last = length - tile
|
||||
if positions[-1] != last:
|
||||
positions.append(last)
|
||||
return positions
|
||||
|
||||
|
||||
def plan_tiles(width: int, height: int, tile_size: int, overlap: int) -> list[Tile]:
|
||||
"""Lay out a grid of overlapping tiles covering ``width`` x ``height``.
|
||||
|
||||
All tiles are ``min(tile_size, width)`` x ``min(tile_size, height)`` (uniform
|
||||
size; the image itself when it fits in one tile). Returned in row-major order.
|
||||
"""
|
||||
xs = _axis_positions(width, tile_size, overlap)
|
||||
ys = _axis_positions(height, tile_size, overlap)
|
||||
tile_w = min(tile_size, width)
|
||||
tile_h = min(tile_size, height)
|
||||
return [Tile(x, y, tile_w, tile_h) for y in ys for x in xs]
|
||||
|
||||
|
||||
def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
|
||||
"""A 2D blend window: ~1 in the interior, ramping down toward each edge.
|
||||
|
||||
Separable linear taper over ``overlap`` pixels from every edge (capped at
|
||||
half the tile so short tiles still taper symmetrically). Strictly positive
|
||||
everywhere, so the normalized blend is well-defined even at an image corner
|
||||
that only one tile covers.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
def ramp(n: int) -> NDArray[Any]:
|
||||
w = np.ones(n, dtype=np.float32)
|
||||
if overlap > 0 and n > 1:
|
||||
ramp_len = min(overlap, max(1, n // 2))
|
||||
taper = (np.arange(ramp_len, dtype=np.float32) + 1.0) / (ramp_len + 1.0)
|
||||
w[:ramp_len] = taper
|
||||
w[n - ramp_len :] = taper[::-1]
|
||||
return w
|
||||
|
||||
weights = np.outer(ramp(height), ramp(width))
|
||||
np.maximum(weights, _WEIGHT_EPS, out=weights)
|
||||
return weights
|
||||
|
||||
|
||||
def feather_region_composite(
|
||||
base: NDArray[Any],
|
||||
regenerated: NDArray[Any],
|
||||
box: tuple[int, int, int, int],
|
||||
*,
|
||||
feather: int = 64,
|
||||
) -> NDArray[Any]:
|
||||
"""Composite ``regenerated`` over ``base`` inside ``box`` only, feathering the seam.
|
||||
|
||||
For AI-ENHANCED composites (digitalSourceType ``compositeWithTrainedAlgorithmicMedia``):
|
||||
the diffusion remover regenerates the whole frame, but only the AI-composited
|
||||
REGION should change -- the rest is a real photo that must be preserved. This
|
||||
blends the regenerated pixels in over ``box = (x, y, w, h)`` with a separable
|
||||
linear taper of ``feather`` px at the box edges, so the result equals ``base``
|
||||
EXACTLY outside the box and ramps smoothly (no hard seam) at the boundary.
|
||||
|
||||
Pure and model-free (unit-tested): ``base`` and ``regenerated`` must be the same
|
||||
shape (H x W, or H x W x C). The output preserves ``base``'s dtype. ``feather`` is
|
||||
clamped to half the box on each axis, so a small region still tapers symmetrically;
|
||||
``feather=0`` is a hard-edged paste.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if base.shape != regenerated.shape:
|
||||
raise ValueError(f"shape mismatch: base {base.shape} vs regenerated {regenerated.shape}")
|
||||
h, w = base.shape[:2]
|
||||
x, y, bw, bh = box
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(w, x + bw), min(h, y + bh)
|
||||
out = base.copy()
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return out # empty / off-image box -> nothing regenerated
|
||||
|
||||
def taper(n: int) -> NDArray[Any]:
|
||||
win = np.ones(n, dtype=np.float32)
|
||||
f = min(max(feather, 0), n // 2)
|
||||
if f > 0:
|
||||
ramp = (np.arange(f, dtype=np.float32) + 1.0) / (f + 1.0) # in (0, 1), 0 at the edge
|
||||
win[:f] = ramp
|
||||
win[n - f :] = ramp[::-1]
|
||||
return win
|
||||
|
||||
rh, rw = y1 - y0, x1 - x0
|
||||
wmap = np.outer(taper(rh), taper(rw)) # ~0 at the box edge, 1 in the interior
|
||||
if base.ndim == 3:
|
||||
wmap = wmap[:, :, None]
|
||||
roi_base = base[y0:y1, x0:x1].astype(np.float32)
|
||||
roi_gen = regenerated[y0:y1, x0:x1].astype(np.float32)
|
||||
blended = roi_base * (1.0 - wmap) + roi_gen * wmap
|
||||
out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(base.dtype)
|
||||
return out
|
||||
|
||||
|
||||
def run_tiled(
|
||||
generate_tile: Callable[[PILImage.Image], PILImage.Image],
|
||||
image: PILImage.Image,
|
||||
tile_size: int,
|
||||
overlap: int,
|
||||
set_progress: Callable[[str], None] | None = None,
|
||||
) -> PILImage.Image:
|
||||
"""Tile ``image``, run ``generate_tile`` per tile, and feather-blend the result.
|
||||
|
||||
``generate_tile`` runs one diffusion pass on a single RGB PIL tile and returns
|
||||
the regenerated tile (the ControlNet control image is built per tile inside it,
|
||||
so each tile gets its own edge map). A pass that rounds dimensions to the latent
|
||||
grid is resized back to the exact tile size before blending.
|
||||
"""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
width, height = image.size
|
||||
tiles = plan_tiles(width, height, tile_size, overlap)
|
||||
accum = np.zeros((height, width, 3), dtype=np.float32)
|
||||
weight_sum = np.zeros((height, width, 1), dtype=np.float32)
|
||||
|
||||
# All tiles share one size (plan_tiles is uniform), so the feather window is
|
||||
# loop-invariant -- compute it once.
|
||||
weights = feather_weights(tiles[0].width, tiles[0].height, overlap)[:, :, None]
|
||||
|
||||
total = len(tiles)
|
||||
for index, tile in enumerate(tiles, start=1):
|
||||
if set_progress is not None:
|
||||
set_progress(f"Tiled diffusion: tile {index}/{total} at ({tile.x},{tile.y}) {tile.width}x{tile.height}...")
|
||||
crop = image.crop((tile.x, tile.y, tile.x + tile.width, tile.y + tile.height))
|
||||
result = generate_tile(crop)
|
||||
if result.size != (tile.width, tile.height):
|
||||
result = result.resize((tile.width, tile.height), Image.Resampling.LANCZOS)
|
||||
arr = np.asarray(result.convert("RGB"), dtype=np.float32)
|
||||
accum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += arr * weights
|
||||
weight_sum[tile.y : tile.y + tile.height, tile.x : tile.x + tile.width] += weights
|
||||
|
||||
blended = accum / np.maximum(weight_sum, _WEIGHT_EPS)
|
||||
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|
||||
@@ -5,6 +5,7 @@ Pure configuration and lookup functions with no ML dependencies.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,6 +13,20 @@ if TYPE_CHECKING:
|
||||
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
# Qwen-Image (20B MMDiT, Apache-2.0 code AND weights) base for the ``qwen`` pipeline:
|
||||
# an img2img alternative to SDXL with native text rendering (incl. CJK). Loaded only
|
||||
# when ``--pipeline qwen`` is selected; CUDA/cloud-class (does not fit MPS). CERTIFIED
|
||||
# oracle floors (2026-06-20): OpenAI **0.10** (seed-robust -- clean on seeds 0-4) and
|
||||
# Google/Gemini **0.25** (seed 0 verified on 2 images; pin a seed in prod, the Gemini
|
||||
# oracle rate-limits volume seed-repeat). The Gemini floor (0.25) is HIGHER than the
|
||||
# certified controlnet Gemini floor (0.15); ``resolve_strength(..., pipeline="qwen")``
|
||||
# now carries this via ``_QWEN_VENDOR_STRENGTH`` (below), so ``--pipeline qwen`` gets the
|
||||
# right floor automatically -- the old manual "pass --strength 0.25 for Gemini on qwen"
|
||||
# workaround is retired.
|
||||
# (Dispatch uses the bare "qwen" literal, matching the sdxl/controlnet sites, so there
|
||||
# is no QWEN_PROFILE constant -- only the model id is referenced from code.)
|
||||
QWEN_MODEL_ID = "Qwen/Qwen-Image"
|
||||
|
||||
# Canonical pipeline-profile names + the back-compat alias. The plain SDXL img2img
|
||||
# profile is ``sdxl``; ``default`` is kept as an accepted alias (it was the profile's
|
||||
# name before ``controlnet`` became the default-selected pipeline, 2026-06-09).
|
||||
@@ -37,12 +52,22 @@ CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
|
||||
# applies to BOTH pipelines (`sdxl` plain img2img and `controlnet`) -- see "why one
|
||||
# ladder" below.
|
||||
#
|
||||
# Data basis (see docs/synthid.md sections 2.2 / 5.5): the values are the ORACLE-
|
||||
# CERTIFIED controlnet floors (2026-06-04, isolated Modal cert app, each vendor on its
|
||||
# own verifier): OpenAI 0.20 (2 photoreal x 3 seeds = 6/6 clean, resolution-independent),
|
||||
# Google 0.30 (clean on 2/2 seeds, validated ONLY at <= 1536 -- Gemini is resolution-
|
||||
# sensitive, native ~2816 likely needs ~0.35+). Unknown vendor gets the Google (more
|
||||
# robust watermark) value: safe-by-default.
|
||||
# Data basis (see docs/synthid.md sections 2.2 / 5.5): ORACLE-CERTIFIED controlnet floors.
|
||||
# A 2026-06-14 re-test on the deployed Modal worker (the production controlnet pipeline)
|
||||
# LOWERED the ladder back to OpenAI 0.10 / Google 0.15: each output verified on its own
|
||||
# oracle (openai.com/verify for OpenAI, the Google Gemini app for Google), all clean ->
|
||||
# - OpenAI 0.10: 2 photoreal images (1402 / 1448 px), SynthID not found on either.
|
||||
# - Google 0.15: 2 NATIVE-resolution images (both 2816x1536), SynthID not found on
|
||||
# either -- this directly retires the earlier "native ~2816 likely needs ~0.35+"
|
||||
# guess, which was speculative and never oracle-checked at that resolution.
|
||||
# This supersedes the 2026-06-04 cert (OpenAI 0.20 / Google 0.30), whose higher floor a
|
||||
# pixel-fidelity sweep showed was ~2x the removal floor and over-regenerated for no
|
||||
# efficacy gain (Google MAE -20% at 0.15 vs 0.30, no SynthID returning). Unknown vendor
|
||||
# tracks the Google (more robust watermark) value -> 0.15, still safe-by-default and the
|
||||
# floor that real (no-vendor) photos hit, so it also minimizes damage when there is in
|
||||
# fact nothing to remove. CAVEAT: the re-test is n=2 per vendor on photoreal / landscape
|
||||
# content; FLAT-GRAPHIC hard cases (the historical `sdxl` weak spot) were NOT in the
|
||||
# sample, so if an oracle still reads SynthID on a flat output, raise `--strength`.
|
||||
#
|
||||
# Why ONE ladder for both pipelines (2026-06-09): the certification was run on
|
||||
# controlnet, and it does NOT transfer to `sdxl` by symmetry -- the two pipelines have
|
||||
@@ -57,9 +82,9 @@ CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
|
||||
# this is a MARGIN argument for `sdxl`, not a fresh certification -- there is no local
|
||||
# SynthID detector, so if an oracle still reads SynthID on a flat `sdxl` output, raise
|
||||
# `--strength`.
|
||||
OPENAI_STRENGTH = 0.20
|
||||
GEMINI_STRENGTH = 0.30
|
||||
UNKNOWN_STRENGTH = 0.30
|
||||
OPENAI_STRENGTH = 0.10
|
||||
GEMINI_STRENGTH = 0.15
|
||||
UNKNOWN_STRENGTH = 0.15
|
||||
# Backwards-compatible alias: the vendor-unknown value (what a caller gets without a
|
||||
# detected vendor). Kept as DEFAULT_STRENGTH for existing references.
|
||||
DEFAULT_STRENGTH = UNKNOWN_STRENGTH
|
||||
@@ -67,6 +92,18 @@ DEFAULT_STRENGTH = UNKNOWN_STRENGTH
|
||||
# Detected-vendor -> default strength. Vendor strings come from `vendor_for_strength`.
|
||||
_VENDOR_STRENGTH = {"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH}
|
||||
|
||||
# Qwen has its OWN certified floors (Modal A100-80GB, 2026-06-20), DIFFERENT from the
|
||||
# SDXL ladder above: OpenAI 0.10 (seed-robust), Gemini 0.25 (HIGHER than controlnet's
|
||||
# 0.15 -- the 20B MMDiT perturbs less per denoising step, so it needs more strength to
|
||||
# clear Gemini SynthID). Unknown vendor tracks the higher (Gemini) value, safe-by-default.
|
||||
# `resolve_strength(..., pipeline="qwen")` uses this table so `--pipeline qwen` carries the
|
||||
# right floor automatically -- retiring the old manual "pass --strength 0.25 for Gemini on
|
||||
# qwen" workaround.
|
||||
QWEN_OPENAI_STRENGTH = 0.10
|
||||
QWEN_GEMINI_STRENGTH = 0.25
|
||||
QWEN_UNKNOWN_STRENGTH = 0.25
|
||||
_QWEN_VENDOR_STRENGTH = {"openai": QWEN_OPENAI_STRENGTH, "google": QWEN_GEMINI_STRENGTH}
|
||||
|
||||
|
||||
def strength_default_help() -> str:
|
||||
"""One-line description of the vendor-adaptive default, derived from the constants.
|
||||
@@ -80,23 +117,51 @@ def strength_default_help() -> str:
|
||||
)
|
||||
|
||||
|
||||
def resolve_strength(strength: float | None, vendor: str | None = None) -> float:
|
||||
def resolve_strength(strength: float | None, vendor: str | None = None, pipeline: str | None = None) -> float:
|
||||
"""Resolve the denoising strength, applying the vendor default when unset.
|
||||
|
||||
``None`` means "the user did not pass ``--strength``", which resolves
|
||||
**vendor-adaptively**: ``vendor`` (``"openai"`` / ``"google"`` / None, from
|
||||
``vendor_for_strength``) selects ``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
|
||||
``UNKNOWN_STRENGTH``. The same ladder applies to both pipelines (see the module
|
||||
comment for why one ladder is correct). An explicit value always wins (including
|
||||
``vendor_for_strength``) selects the per-vendor floor. The ``sdxl`` and ``controlnet``
|
||||
pipelines share ONE ladder (``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
|
||||
``UNKNOWN_STRENGTH`` -- see the module comment for why); ``qwen`` has its OWN higher
|
||||
ladder (``_QWEN_VENDOR_STRENGTH``, Gemini 0.25 vs controlnet 0.15), selected when
|
||||
``pipeline`` normalizes to ``"qwen"``. An explicit value always wins (including
|
||||
``0.0`` -- the check is ``is None``, not falsiness). Shared by the CLI (for display)
|
||||
and the engine (for execution) so the two never disagree -- both must pass the SAME
|
||||
``vendor``.
|
||||
``vendor`` and ``pipeline``.
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
if pipeline is not None and normalize_profile(pipeline) == "qwen":
|
||||
return _QWEN_VENDOR_STRENGTH.get(vendor or "", QWEN_UNKNOWN_STRENGTH)
|
||||
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
|
||||
|
||||
|
||||
def viable_steps(num_inference_steps: int, strength: float) -> int:
|
||||
"""The smallest step count >= ``num_inference_steps`` that actually denoises.
|
||||
|
||||
diffusers derives its img2img timesteps as ``int(steps * strength)``. When that
|
||||
rounds to ZERO the pipeline builds an empty latent and dies deep inside attention
|
||||
with ``cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]`` -- an opaque
|
||||
torch error for what is really "these two options cannot work together".
|
||||
|
||||
The combination is reachable with entirely valid CLI arguments: at the default
|
||||
strength 0.15 every ``--steps`` below 7 crashed, and nothing told the user that
|
||||
``--steps`` and ``--strength`` interact (found by the release smoke matrix,
|
||||
2026-07-19). Raising the count to the minimum that denoises keeps the caller's intent
|
||||
-- they asked for "few steps", not "zero" -- and the engine logs the adjustment.
|
||||
|
||||
A non-positive ``strength`` cannot denoise at any step count; return the caller's
|
||||
value unchanged rather than dividing by zero.
|
||||
"""
|
||||
if strength <= 0:
|
||||
return num_inference_steps
|
||||
if int(num_inference_steps * strength) >= 1:
|
||||
return num_inference_steps
|
||||
return math.ceil(1 / strength)
|
||||
|
||||
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
|
||||
"""Detect the SynthID vendor for strength selection: ``"openai"`` / ``"google"`` / None.
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"""Watermark removal using diffusion model regeneration attack.
|
||||
|
||||
Two pipelines:
|
||||
Three pipelines (selected by the explicit ``pipeline`` ctor arg):
|
||||
|
||||
0. ``qwen`` -- Qwen-Image (20B MMDiT, Apache-2.0) img2img. The scrub still comes from
|
||||
the img2img ``strength``; Qwen preserves text (incl. CJK) and structure markedly
|
||||
better than SDXL at the scrub floor, so it over-regenerates real photos far less.
|
||||
CUDA/cloud-class (does not fit MPS). See ``watermark_profiles`` for the certified
|
||||
oracle floors. (Near-threshold scrub is seed-non-deterministic, but the prod path
|
||||
pins ONE fixed seed, so a certified floor reproduces run-to-run -- pinning the seed
|
||||
is the release gate, not sweeping seeds.)
|
||||
|
||||
Two SDXL pipelines:
|
||||
1. ``controlnet`` (DEFAULT) -- SDXL img2img with a canny ControlNet. The watermark
|
||||
REMOVAL still comes from the img2img regeneration (``strength``); the ControlNet
|
||||
only PRESERVES structure (text/faces) by conditioning on the edge map. No original
|
||||
@@ -36,8 +46,10 @@ from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
CONTROLNET_CANNY_MODEL,
|
||||
DEFAULT_MODEL_ID,
|
||||
DEFAULT_STRENGTH,
|
||||
QWEN_MODEL_ID,
|
||||
normalize_profile,
|
||||
resolve_strength,
|
||||
viable_steps,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -308,6 +320,45 @@ _CANNY_HIGH = 200
|
||||
_CONTROLNET_PROMPT = "best quality, high quality, sharp, detailed, photographic"
|
||||
_CONTROLNET_NEGATIVE = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts"
|
||||
|
||||
# Neutral prompts for the Qwen-Image img2img pass (faithful regeneration, not an edit).
|
||||
_QWEN_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_QWEN_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
|
||||
def _qwen_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
"""Floor (width, height) to a multiple of 16 for Qwen's VAE/patchifier (>= 16).
|
||||
|
||||
Pure; unit-tested. Without explicit dims the img2img pipeline defaults to a 1024x1024
|
||||
SQUARE and silently distorts any non-square input.
|
||||
"""
|
||||
return max(16, (width // 16) * 16), max(16, (height // 16) * 16)
|
||||
|
||||
|
||||
def _build_qwen_kwargs(
|
||||
image: Image.Image, strength: float, num_inference_steps: int, true_cfg_scale: float, generator: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Build the QwenImageImg2ImgPipeline call kwargs (pure; unit-tested without torch).
|
||||
|
||||
Qwen-Image uses ``true_cfg_scale`` (not SDXL's ``guidance_scale``) and takes an
|
||||
explicit ``negative_prompt``; the scrub still comes from the img2img ``strength``.
|
||||
Passes an explicit ``height``/``width`` derived from the input (floored to /16): the
|
||||
pipeline otherwise defaults to a 1024x1024 SQUARE, squishing any non-square input
|
||||
(the abba mixed-seam test: a 2816x1536 poster came back 1024x1024, distorting the
|
||||
scene and garbling text). So qwen regenerates at the input's own geometry.
|
||||
"""
|
||||
qw, qh = _qwen_target_size(image.width, image.height)
|
||||
return {
|
||||
"prompt": _QWEN_PROMPT,
|
||||
"negative_prompt": _QWEN_NEGATIVE,
|
||||
"image": image,
|
||||
"strength": strength,
|
||||
"num_inference_steps": num_inference_steps,
|
||||
"true_cfg_scale": true_cfg_scale,
|
||||
"generator": generator,
|
||||
"height": qh,
|
||||
"width": qw,
|
||||
}
|
||||
|
||||
|
||||
class WatermarkRemover:
|
||||
"""Remove watermarks from images using diffusion model regeneration.
|
||||
@@ -348,6 +399,11 @@ class WatermarkRemover:
|
||||
if torch_dtype is None:
|
||||
if self.device == "cpu" or self.device == "mps":
|
||||
self.torch_dtype = torch.float32 # type: ignore
|
||||
elif self.model_profile == "qwen":
|
||||
# Qwen-Image is published in bf16; fp16 risks overflow on the 20B MMDiT.
|
||||
# cuda/xpu-only by construction: the cpu/mps guard above already forced
|
||||
# fp32, and the 20B model does not fit MPS anyway.
|
||||
self.torch_dtype = torch.bfloat16 # type: ignore
|
||||
else:
|
||||
self.torch_dtype = torch.float16 # type: ignore
|
||||
else:
|
||||
@@ -355,6 +411,7 @@ class WatermarkRemover:
|
||||
|
||||
self._pipeline: AutoImg2ImgPipeline | None = None
|
||||
self._controlnet_pipeline: Any = None
|
||||
self._qwen_pipeline: Any = None
|
||||
self._progress_callback = progress_callback
|
||||
self.hf_token: str | None = hf_token or os.environ.get("HF_TOKEN")
|
||||
|
||||
@@ -369,7 +426,9 @@ class WatermarkRemover:
|
||||
|
||||
def preload(self) -> None:
|
||||
"""Eagerly load the pipeline so download progress bars are visible."""
|
||||
if self.model_profile == "controlnet":
|
||||
if self.model_profile == "qwen":
|
||||
self._load_qwen_pipeline()
|
||||
elif self.model_profile == "controlnet":
|
||||
self._load_controlnet_pipeline()
|
||||
else:
|
||||
self._load_pipeline()
|
||||
@@ -389,6 +448,19 @@ class WatermarkRemover:
|
||||
self._set_progress("Loading fp16-fixed SDXL VAE (avoids black output)...")
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(_SDXL_FP16_VAE_ID, torch_dtype=torch.float16)
|
||||
|
||||
@staticmethod
|
||||
def _disable_sdxl_watermarker(load_kwargs: dict[str, Any]) -> None:
|
||||
"""Turn off the diffusers default invisible watermarker on an SDXL pipeline.
|
||||
|
||||
diffusers embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on
|
||||
EVERY SDXL output whenever ``invisible-watermark`` is installed (the ``detect``
|
||||
extra). A watermark REMOVER must not re-stamp a detectable AI watermark, or the
|
||||
cleaned output re-reads as AI (``identify`` -> "Open invisible watermark: Stable
|
||||
Diffusion XL"). Shared by both SDXL loaders; the ``ControlNetModel`` sub-model
|
||||
and the Qwen loader never call it (only the pipeline accepts the kwarg).
|
||||
"""
|
||||
load_kwargs["add_watermarker"] = False
|
||||
|
||||
def _move_to_device_and_optimize(self, pipeline: Any) -> Any:
|
||||
"""Move a freshly-loaded pipeline to ``self.device`` + enable memory opts.
|
||||
|
||||
@@ -420,22 +492,52 @@ class WatermarkRemover:
|
||||
|
||||
return pipeline
|
||||
|
||||
def _base_load_kwargs(self) -> dict[str, Any]:
|
||||
"""The ``from_pretrained`` kwargs shared by all three loaders (dtype + token).
|
||||
|
||||
Each loader adds its own extras (SDXL safety_checker + fp16 VAE, the ControlNet
|
||||
model, etc.). Centralizing the dtype/token pair avoids the drift trap of three
|
||||
copies (a token forgotten on one loader silently breaks gated downloads there).
|
||||
"""
|
||||
load_kwargs: dict[str, Any] = {"torch_dtype": self.torch_dtype}
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
return load_kwargs
|
||||
|
||||
def _load_from_pretrained(self, cls: Any, model_id: str, **load_kwargs: Any) -> Any:
|
||||
"""Call ``cls.from_pretrained`` reading the fp16 weight VARIANT when on fp16.
|
||||
|
||||
When ``torch_dtype`` is float16 (the CUDA/XPU SDXL default), pass
|
||||
``variant="fp16"`` so diffusers fetches/reads the half-precision weight files
|
||||
(~half the bytes of the fp32 defaults) instead of reading the full fp32 files
|
||||
and downcasting in memory. On a warm weights cache this roughly halves the
|
||||
cold-start weight read + host->device transfer, which a phase-timed Modal run
|
||||
measured as ~half of the ~25s cold start. Not every checkpoint publishes an
|
||||
fp16 variant (a custom ``--model``, or the canny ControlNet if it ships only
|
||||
default files), so fall back to the default weights if the variant is missing -
|
||||
worst case is the prior behavior (read fp32, downcast). fp32 (cpu/mps) and bf16
|
||||
(qwen) never request the variant.
|
||||
"""
|
||||
if self.torch_dtype == torch.float16:
|
||||
try:
|
||||
return cls.from_pretrained(model_id, variant="fp16", **load_kwargs)
|
||||
except Exception as exc:
|
||||
logger.info("No fp16 weight variant for %s (%s); loading default weights", model_id, exc)
|
||||
return cls.from_pretrained(model_id, **load_kwargs)
|
||||
|
||||
def _load_pipeline(self) -> AutoImg2ImgPipeline:
|
||||
"""Load the plain SDXL img2img pipeline lazily."""
|
||||
if self._pipeline is None:
|
||||
logger.info("Loading model %s on %s...", self.model_id, self.device)
|
||||
self._set_progress(f"Loading model weights: {self.model_id}")
|
||||
|
||||
load_kwargs: dict[str, Any] = {
|
||||
"torch_dtype": self.torch_dtype,
|
||||
"safety_checker": None,
|
||||
"requires_safety_checker": False,
|
||||
}
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
load_kwargs = self._base_load_kwargs()
|
||||
load_kwargs["safety_checker"] = None
|
||||
load_kwargs["requires_safety_checker"] = False
|
||||
self._disable_sdxl_watermarker(load_kwargs)
|
||||
self._maybe_add_fp16_vae(load_kwargs)
|
||||
|
||||
pipeline = AutoImg2ImgPipeline.from_pretrained(self.model_id, **load_kwargs) # type: ignore
|
||||
pipeline = self._load_from_pretrained(AutoImg2ImgPipeline, self.model_id, **load_kwargs) # type: ignore
|
||||
self._pipeline = self._move_to_device_and_optimize(pipeline)
|
||||
|
||||
logger.info("Model loaded successfully")
|
||||
@@ -456,15 +558,19 @@ class WatermarkRemover:
|
||||
|
||||
logger.info("Loading SDXL + ControlNet (%s) on %s...", CONTROLNET_CANNY_MODEL, self.device)
|
||||
self._set_progress(f"Loading ControlNet: {CONTROLNET_CANNY_MODEL}")
|
||||
controlnet = ControlNetModel.from_pretrained(CONTROLNET_CANNY_MODEL, torch_dtype=self.torch_dtype)
|
||||
controlnet = self._load_from_pretrained(
|
||||
ControlNetModel, CONTROLNET_CANNY_MODEL, torch_dtype=self.torch_dtype
|
||||
)
|
||||
|
||||
load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": self.torch_dtype}
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
load_kwargs = self._base_load_kwargs()
|
||||
load_kwargs["controlnet"] = controlnet
|
||||
self._disable_sdxl_watermarker(load_kwargs)
|
||||
self._maybe_add_fp16_vae(load_kwargs)
|
||||
|
||||
self._set_progress(f"Loading model weights: {self.model_id}")
|
||||
pipeline = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(self.model_id, **load_kwargs)
|
||||
pipeline = self._load_from_pretrained(
|
||||
StableDiffusionXLControlNetImg2ImgPipeline, self.model_id, **load_kwargs
|
||||
)
|
||||
pipeline = self._move_to_device_and_optimize(pipeline)
|
||||
with contextlib.suppress(Exception):
|
||||
pipeline.set_progress_bar_config(disable=True)
|
||||
@@ -474,6 +580,37 @@ class WatermarkRemover:
|
||||
|
||||
return self._controlnet_pipeline
|
||||
|
||||
def _load_qwen_pipeline(self) -> Any:
|
||||
"""Load the Qwen-Image img2img pipeline lazily.
|
||||
|
||||
Qwen-Image is its OWN base model (not an SDXL add-on), so it loads
|
||||
``QWEN_MODEL_ID`` unless the caller passed a custom ``--model``. Needs a
|
||||
diffusers build that ships ``QwenImageImg2ImgPipeline``; raises a clear error
|
||||
otherwise. CUDA/cloud-class (the 20B MMDiT does not fit MPS).
|
||||
"""
|
||||
if self._qwen_pipeline is None:
|
||||
try:
|
||||
from diffusers import QwenImageImg2ImgPipeline
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The 'qwen' pipeline needs a diffusers version that ships "
|
||||
"QwenImageImg2ImgPipeline. Upgrade: pip install -U diffusers"
|
||||
) from exc
|
||||
|
||||
# Use the Qwen base unless the user explicitly overrode --model.
|
||||
model = self.model_id if self.model_id != self.DEFAULT_MODEL_ID else QWEN_MODEL_ID
|
||||
logger.info("Loading Qwen-Image (%s) on %s...", model, self.device)
|
||||
self._set_progress(f"Loading model weights: {model}")
|
||||
pipeline = QwenImageImg2ImgPipeline.from_pretrained(model, **self._base_load_kwargs())
|
||||
pipeline = self._move_to_device_and_optimize(pipeline)
|
||||
with contextlib.suppress(Exception):
|
||||
pipeline.set_progress_bar_config(disable=True)
|
||||
|
||||
logger.info("Qwen-Image model loaded successfully")
|
||||
self._qwen_pipeline = pipeline
|
||||
|
||||
return self._qwen_pipeline
|
||||
|
||||
# ── Core removal ─────────────────────────────────────────────────
|
||||
|
||||
def remove_watermark(
|
||||
@@ -485,6 +622,11 @@ class WatermarkRemover:
|
||||
guidance_scale: float | None = None,
|
||||
seed: int | None = None,
|
||||
vendor: str | None = None,
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
region_feather: int = 64,
|
||||
) -> Path:
|
||||
"""Remove watermark from an image using regeneration attack.
|
||||
|
||||
@@ -501,6 +643,22 @@ class WatermarkRemover:
|
||||
input with ``watermark_profiles.vendor_for_strength`` before processing
|
||||
strips the metadata; the caller passes it down so display and execution
|
||||
agree.
|
||||
tile: Process the image in overlapping tiles instead of one forward pass.
|
||||
The lossless alternative to a ``--max-resolution`` downscale for large
|
||||
inputs that OOM on MPS/GPU (issue #10). Only engages when the long side
|
||||
exceeds ``tile_size``; smaller images run a single pass unchanged.
|
||||
tile_size: Tile dimension in px (default 1024, SDXL's training size).
|
||||
tile_overlap: Overlap between adjacent tiles in px (default 128), feather-
|
||||
blended so there is no visible seam.
|
||||
region: Restrict the regeneration to the AI-composited box ``(x, y, w, h)``
|
||||
and feather-composite it back over the ORIGINAL pixels everywhere else.
|
||||
For AI-ENHANCED composites (digitalSourceType
|
||||
``compositeWithTrainedAlgorithmicMedia``, surfaced as
|
||||
``identify.ProvenanceReport.ai_source_kind == "enhanced"``): the real
|
||||
photo outside the box is preserved exactly, only the AI region is
|
||||
scrubbed. The box is supplied by the caller (a C2PA composite manifest
|
||||
does not carry a reliable machine-readable region). None -> whole frame.
|
||||
region_feather: Seam taper in px for ``region`` compositing (default 64).
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -515,7 +673,7 @@ class WatermarkRemover:
|
||||
if output_path is None:
|
||||
output_path = image_path
|
||||
|
||||
strength = resolve_strength(strength, vendor)
|
||||
strength = resolve_strength(strength, vendor, self.model_profile)
|
||||
|
||||
if not 0.0 <= strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
|
||||
@@ -533,6 +691,20 @@ class WatermarkRemover:
|
||||
self._set_progress(f"Setting reproducible seed: {seed}")
|
||||
generator = _make_seed_generator(self.device, seed)
|
||||
|
||||
# A step count whose product with strength rounds to zero kills the pipeline
|
||||
# inside attention with an opaque reshape error, so raise it to the minimum that
|
||||
# denoises. Must be applied to the value HANDED TO THE PIPELINE -- the old
|
||||
# max(1, ...) below only clamped the number in the log line.
|
||||
adjusted = viable_steps(num_inference_steps, strength)
|
||||
if adjusted != num_inference_steps:
|
||||
logger.warning(
|
||||
"steps=%s at strength=%s denoises 0 steps and would crash; using steps=%s (1 effective)",
|
||||
num_inference_steps,
|
||||
strength,
|
||||
adjusted,
|
||||
)
|
||||
num_inference_steps = adjusted
|
||||
|
||||
effective_steps = max(1, int(num_inference_steps * strength))
|
||||
self._set_progress(
|
||||
f"Config: strength={strength}, steps={num_inference_steps} "
|
||||
@@ -541,10 +713,21 @@ class WatermarkRemover:
|
||||
|
||||
_total_start = time.monotonic()
|
||||
|
||||
def _generate() -> Image.Image:
|
||||
def _generate_one(img: Image.Image) -> Image.Image:
|
||||
if self.model_profile == "qwen":
|
||||
return self._run_qwen(img, strength, num_inference_steps, guidance_scale, generator)
|
||||
if self.model_profile == "controlnet":
|
||||
return self._run_controlnet(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
return self._run_controlnet(img, strength, num_inference_steps, guidance_scale, generator)
|
||||
return self._run_img2img(img, strength, num_inference_steps, guidance_scale, generator)
|
||||
|
||||
def _generate() -> Image.Image:
|
||||
# Tile only when asked AND the image is larger than one tile; otherwise a
|
||||
# single full-image pass (tiling a sub-tile image is pure overhead).
|
||||
if tile and max(init_image.size) > tile_size:
|
||||
from remove_ai_watermarks.noai.tiling import run_tiled
|
||||
|
||||
return run_tiled(_generate_one, init_image, tile_size, tile_overlap, self._set_progress)
|
||||
return _generate_one(init_image)
|
||||
|
||||
cleaned_image = _generate()
|
||||
|
||||
@@ -561,6 +744,22 @@ class WatermarkRemover:
|
||||
self._controlnet_pipeline = None
|
||||
cleaned_image = _generate()
|
||||
|
||||
# Region-targeted regeneration for AI-enhanced composites: keep the real photo
|
||||
# outside the AI box pixel-exact, blend only the regenerated AI region back in.
|
||||
if region is not None:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.tiling import feather_region_composite
|
||||
|
||||
gen = cleaned_image.convert("RGB")
|
||||
if gen.size != init_image.size: # a downscaled/tiled pass can resize
|
||||
gen = gen.resize(init_image.size)
|
||||
cleaned_image = gen
|
||||
base_rgb = np.asarray(init_image) # original RGB, untouched outside the box
|
||||
merged = feather_region_composite(base_rgb, np.asarray(gen), region, feather=region_feather)
|
||||
cleaned_image = Image.fromarray(merged)
|
||||
self._set_progress(f"Region-targeted regeneration: AI box {region}, real photo preserved")
|
||||
|
||||
self._set_progress(f"Regeneration complete · Output: {w}x{h}px {cleaned_image.mode}")
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -569,12 +768,24 @@ class WatermarkRemover:
|
||||
self._set_progress(f"Encoding as JPEG → {output_path.name}...")
|
||||
else:
|
||||
self._set_progress(f"Encoding as PNG → {output_path.name}...")
|
||||
cleaned_image.save(output_path)
|
||||
# Encode through image_io so the regenerated image gets the same quality-
|
||||
# preserving write as the visible path (JPEG q100 / 4:4:4, HEIC/AVIF via Pillow)
|
||||
# instead of PIL's default JPEG quality 75, which would crush the diffusion
|
||||
# output further for no reason.
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
rgb = np.asarray(cleaned_image.convert("RGB"))[:, :, ::-1] # PIL RGB -> cv2 BGR
|
||||
if not image_io.imwrite(str(output_path), np.ascontiguousarray(rgb)):
|
||||
cleaned_image.save(output_path) # fallback for a container image_io/cv2 cannot encode
|
||||
|
||||
if output_path.exists():
|
||||
self._set_progress("Stripping AI metadata from output...")
|
||||
try:
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
# The single, robust stripper (byte-level: lossless for JPEG, handles
|
||||
# every container) -- not the legacy PIL-re-encoding noai.cleaner one.
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
||||
except Exception:
|
||||
@@ -706,6 +917,30 @@ class WatermarkRemover:
|
||||
self._controlnet_pipeline = None
|
||||
return self._load_controlnet_pipeline()
|
||||
|
||||
# ── Qwen runner ──────────────────────────────────────────────────
|
||||
|
||||
def _run_qwen(
|
||||
self,
|
||||
init_image: Image.Image,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
) -> Image.Image:
|
||||
"""Run the Qwen-Image img2img pass.
|
||||
|
||||
Removal comes from the img2img ``strength`` (same lever as the SDXL paths);
|
||||
Qwen-Image preserves text/structure markedly better at the scrub floor. The
|
||||
CLI ``guidance_scale`` maps to Qwen's ``true_cfg_scale`` (~4.0 is typical;
|
||||
the SDXL default of 7.5 is high for Qwen). No MPS->CPU fallback: the 20B MMDiT
|
||||
is CUDA/cloud-class and does not run on MPS, so an error here propagates.
|
||||
"""
|
||||
pipeline = self._load_qwen_pipeline()
|
||||
self._set_progress(f"Running Qwen-Image img2img (strength={strength}, true_cfg={guidance_scale})...")
|
||||
kwargs = _build_qwen_kwargs(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
result = pipeline(**kwargs)
|
||||
return result.images[0]
|
||||
|
||||
# ── Batch ────────────────────────────────────────────────────────
|
||||
|
||||
def remove_watermark_batch(
|
||||
@@ -754,12 +989,18 @@ def remove_watermark(
|
||||
model_id: str | None = None,
|
||||
device: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
region: tuple[int, int, int, int] | None = None,
|
||||
) -> Path:
|
||||
"""Convenience function to remove watermark from an image.
|
||||
|
||||
``strength=None`` lets the profile pick its vendor-adaptive SDXL default
|
||||
``strength=None`` lets the profile pick its vendor-adaptive default
|
||||
(0.10 OpenAI / 0.15 Google / 0.15 unknown, from the C2PA SynthID proxy on the
|
||||
input). Pass a value to override.
|
||||
input; same ladder for the controlnet and sdxl pipelines -- the single source of
|
||||
truth is ``watermark_profiles.py``). Pass a value to override.
|
||||
|
||||
``region=(x, y, w, h)`` restricts the regeneration to that box and preserves the
|
||||
real photo elsewhere -- for AI-enhanced composites (see
|
||||
``WatermarkRemover.remove_watermark``).
|
||||
"""
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
@@ -769,4 +1010,5 @@ def remove_watermark(
|
||||
output_path=output_path,
|
||||
strength=strength,
|
||||
vendor=vendor_for_strength(image_path),
|
||||
region=region,
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Jimeng-basic 'AI生成' pill: a CAPTURE-LESS visible mark (issue #54).
|
||||
|
||||
The Jimeng free-tier TC260 label is a rounded pill with 'AI生成' in the TOP-LEFT
|
||||
corner -- distinct from the ``jimeng`` "★ 即梦AI" wordmark (bottom-right). It has no
|
||||
captured alpha map, so unlike the other marks it is detected purely by a synthetic
|
||||
silhouette; like every mark it is then removed by the shared localize -> fill:
|
||||
|
||||
* Detect: edge-NCC of a font-rendered SILHOUETTE (``assets/jimeng_pill.png``,
|
||||
synthetic, data-safe -- see ``scripts/render_pill_silhouette.py``) against the
|
||||
top-left ROI, at the pill's known width fraction. Corpus-calibrated threshold
|
||||
(61 real positives + jimeng negatives): ``_DETECT_THRESHOLD`` 0.22.
|
||||
* Remove: place the pill footprint at the matched location and inpaint it
|
||||
(MI-GAN / cv2 via the registry). Quality comes from the inpaint backend, so the
|
||||
silhouette need not be pixel-accurate -- which is why a synthetic render is
|
||||
sufficient and no corpus-derived asset is committed.
|
||||
|
||||
Geometry measured on 51 real examples (8 resolutions, all 3:4): width ~0.161*W,
|
||||
height ~0.091*W, top-left, margins ~0.02-0.05.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
|
||||
# its array element types. Relax the unknown-type rules for this file only; the
|
||||
# public signatures are still annotated with NDArray[Any].
|
||||
# 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, reportOptionalMemberAccess=false, reportOptionalSubscript=false, reportAttributeAccessIssue=false, reportUnnecessaryComparison=false
|
||||
|
||||
_ASSET = Path(__file__).parent / "assets" / "jimeng_pill.png"
|
||||
|
||||
# Geometry (fractions of image WIDTH unless noted); top-left corner.
|
||||
_WIDTH_FRAC = 0.161
|
||||
_ROI_W_FRAC = 0.34 # search window width (of W)
|
||||
_ROI_H_FRAC = 0.14 # search window height (of H)
|
||||
_DETECT_THRESHOLD = 0.22 # edge-NCC gate, corpus-calibrated
|
||||
# Inpaint mask GEOMETRY (fractions of W unless noted): a generous fixed top-left box
|
||||
# covering the pill (measured ~0.167*W wide, ~0.09*W tall, margin ~0.02-0.05) plus
|
||||
# margin. The mask uses stable geometry, NOT the NCC match position -- the synthetic
|
||||
# silhouette localizes only approximately, and the corner is negative space, so
|
||||
# over-covering is harmless while a match-positioned box leaves outline residue.
|
||||
_MASK_X0, _MASK_Y0 = 0.012, 0.006 # x0 of W, y0 of H
|
||||
_MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W
|
||||
|
||||
# Background-flatness gate for the metadata-only pill arm (see remove_auto_marks).
|
||||
# The pill detector is weak (~7% raw false-fire); metadata confirms the platform,
|
||||
# not pill presence, so its false fires are real Jimeng-class content WITHOUT a pill.
|
||||
# Those false fires cluster on TEXTURED top-left corners (ceiling fixtures, structure)
|
||||
# where inpaint visibly SMEARS, while real pills and harmless false fires sit on FLAT
|
||||
# corners (sky / wall / solid) where inpaint is invisible. So the metadata-only arm
|
||||
# removes the pill only when the footprint background is flat enough for a safe,
|
||||
# invisible inpaint. Threshold = median Sobel magnitude over the footprint box at a
|
||||
# normalized width; corpus-validated on 32k real uploads 2026-07 (real pills median
|
||||
# ~3.2, textured-ceiling false fires median ~8+). The reliable bottom-right wordmark
|
||||
# arm is NOT texture-gated -- a wordmark-confirmed pill is removed regardless.
|
||||
_FLAT_TEXTURE_MAX = 6.0
|
||||
|
||||
_silhouette: NDArray[Any] | None = None
|
||||
|
||||
|
||||
class PillDetection(NamedTuple):
|
||||
detected: bool
|
||||
confidence: float
|
||||
region: tuple[int, int, int, int] # x, y, w, h of the matched pill
|
||||
|
||||
|
||||
def _load_silhouette() -> NDArray[Any] | None:
|
||||
global _silhouette
|
||||
if _silhouette is None:
|
||||
if not _ASSET.exists():
|
||||
return None
|
||||
_silhouette = image_io.imread(str(_ASSET), cv2.IMREAD_GRAYSCALE)
|
||||
return _silhouette
|
||||
|
||||
|
||||
def _grad(gray: NDArray[Any]) -> NDArray[Any]:
|
||||
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
|
||||
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
||||
return cv2.normalize(cv2.magnitude(gx, gy), None, 0, 255, cv2.NORM_MINMAX)
|
||||
|
||||
|
||||
class PillEngine:
|
||||
"""Detect + build the removal mask for the top-left 'AI生成' pill (edge-NCC of a synthetic silhouette)."""
|
||||
|
||||
def _match(self, image: NDArray[Any]) -> tuple[float, tuple[int, int, int, int]] | None:
|
||||
sil = _load_silhouette()
|
||||
if sil is None or image is None or image.size == 0:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
if h < 64 or w < 64:
|
||||
return None
|
||||
gray = cv2.cvtColor(image_io.to_bgr(image), cv2.COLOR_BGR2GRAY)
|
||||
rh, rw = int(h * _ROI_H_FRAC), int(w * _ROI_W_FRAC)
|
||||
roi = gray[0:rh, 0:rw]
|
||||
tw = max(24, int(_WIDTH_FRAC * w))
|
||||
th = max(12, int(tw * sil.shape[0] / sil.shape[1]))
|
||||
if th >= rh or tw >= rw:
|
||||
return None
|
||||
tmpl = cv2.resize(sil, (tw, th))
|
||||
res = cv2.matchTemplate(_grad(roi.astype(np.float32)), _grad(tmpl.astype(np.float32)), cv2.TM_CCOEFF_NORMED)
|
||||
_, score, _, loc = cv2.minMaxLoc(res)
|
||||
return float(score), (int(loc[0]), int(loc[1]), tw, th)
|
||||
|
||||
def detect(self, image: NDArray[Any]) -> PillDetection:
|
||||
m = self._match(image)
|
||||
if m is None:
|
||||
return PillDetection(False, 0.0, (0, 0, 0, 0))
|
||||
score, box = m
|
||||
return PillDetection(score >= _DETECT_THRESHOLD, score, box)
|
||||
|
||||
def _footprint_box(self, image: NDArray[Any]) -> tuple[int, int, int, int] | None:
|
||||
h, w = image.shape[:2]
|
||||
x0, y0 = int(_MASK_X0 * w), int(_MASK_Y0 * h)
|
||||
x1, y1 = min(w, x0 + int(_MASK_W * w)), min(h, y0 + int(_MASK_H * w))
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return None
|
||||
return x0, y0, x1, y1
|
||||
|
||||
def footprint_texture(self, image: NDArray[Any]) -> float:
|
||||
"""Median gradient magnitude over the fixed top-left footprint box at a
|
||||
normalized width. A robust flatness proxy: low = flat (sky / wall / solid,
|
||||
inpaint invisible), high = textured (ceiling fixtures / structure, inpaint
|
||||
smears). Median (not mean) so the pill's own edges -- a minority of the box --
|
||||
do not inflate it. Backs the metadata-only arm's safe-inpaint gate."""
|
||||
if image is None or image.size == 0:
|
||||
return 0.0
|
||||
box = self._footprint_box(image)
|
||||
if box is None:
|
||||
return 0.0
|
||||
x0, y0, x1, y1 = box
|
||||
crop = image[y0:y1, x0:x1]
|
||||
gray = cv2.cvtColor(image_io.to_bgr(crop), cv2.COLOR_BGR2GRAY)
|
||||
tw = 220
|
||||
gray = cv2.resize(gray, (tw, max(1, int(gray.shape[0] * tw / gray.shape[1])))).astype(np.float32)
|
||||
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
|
||||
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
|
||||
return float(np.median(cv2.magnitude(gx, gy)))
|
||||
|
||||
def footprint_is_flat(self, image: NDArray[Any], *, thresh: float = _FLAT_TEXTURE_MAX) -> bool:
|
||||
"""True when the top-left footprint is flat enough for an invisible inpaint."""
|
||||
return self.footprint_texture(image) <= thresh
|
||||
|
||||
def footprint_mask(self, image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
|
||||
"""Full-frame uint8 mask (255 = pill) over the pill's known top-left region.
|
||||
|
||||
Uses stable GEOMETRY (a generous fixed box), not the NCC match position: the
|
||||
synthetic silhouette localizes only approximately, so a match-positioned mask
|
||||
leaves outline residue, while the top-left corner is negative space, so a
|
||||
generous geometric box removes the pill cleanly and harmlessly. The caller
|
||||
gates on :meth:`detect`, so a clean corner is never masked. ``force`` is
|
||||
accepted for a uniform engine signature but ignored (the geometry box is
|
||||
fixed regardless)."""
|
||||
if image is None or image.size == 0:
|
||||
return None
|
||||
box = self._footprint_box(image)
|
||||
if box is None:
|
||||
return None
|
||||
x0, y0, x1, y1 = box
|
||||
h, w = image.shape[:2]
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y0:y1, x0:x1] = 255
|
||||
return mask
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Qwen (Tongyi Qianwen, Alibaba) visible watermark detector/localizer.
|
||||
|
||||
Qwen stamps its generations with a visible "千问AI生成" text strip in the
|
||||
bottom-right corner -- the explicit AIGC label mandated by China's GB 45438-2025
|
||||
(the same 6-glyph house style as Doubao's "豆包AI生成": a 2-glyph vendor prefix
|
||||
plus the shared `AI生成` tail), preceded by the vendor's tri-lobe logo (not part
|
||||
of the detection silhouette -- logos vary between releases, the CJK run is what
|
||||
discriminates).
|
||||
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This module supplies only Qwen's tuned
|
||||
:class:`TextMarkConfig` (``assets/qwen_alpha.png`` -- a font-rendered synthetic
|
||||
silhouette from ``scripts/render_vendor_silhouettes.py``, never cut from an
|
||||
upload). It also feeds ``identify`` as the medium-confidence ``visible_qwen``
|
||||
signal via the registry.
|
||||
|
||||
EVERY tuned number below was measured on the vendor cohort (117 TC260 carriers
|
||||
whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness
|
||||
``scripts/vendor_mark_calibrate.py``), NOT inherited from Doubao:
|
||||
|
||||
* The mark sits in TWO size modes (frac of the short side ~0.124 and ~0.203,
|
||||
ratio 1.64 -- wider than the shared 3-rung ladder's 1.5625 span), so a single
|
||||
fraction on the shared ladder covers ~75% of marks and the rest land in the
|
||||
comb's collapse zone. Qwen therefore carries its OWN 2-rung ladder
|
||||
(``TextMarkConfig.ladder``), one rung centred on each mode; the shared
|
||||
default is untouched for every other mark.
|
||||
* The mark also sits FARTHER off the corner than Doubao's box assumes (right
|
||||
margin ~0.025 vs 0.004 of the short side), so Doubao's locate box clipped the
|
||||
first glyph and collapsed an exact-size template to 0.26; the box fractions
|
||||
below are fitted from the measured absolute mark rects.
|
||||
* ``alpha_height_frac`` comes from the aspect fit at the winning width (p50
|
||||
aspect 0.26), not from the silhouette's own aspect (0.2219) and not from
|
||||
Doubao's ratio.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): the score band just below the
|
||||
gate is dominated by non-Qwen banners on same-cohort frames (a 夸克
|
||||
anti-forgery strip at 0.274, a 造点 mark at 0.253), so a provenance-relaxed
|
||||
arm would be mostly false fills. No provenance relaxation exists for this
|
||||
mark.
|
||||
* No rival margin: at the shipped gate the template fires on 0 of 400
|
||||
Doubao-marked frames, 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled
|
||||
clean frames (the shared tail correlates at ~0.22, far below the gate), while
|
||||
a 0.10 rival margin would have suppressed ~10% of genuine Qwen detections.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image SHORT side (measured basis -- see
|
||||
# scale_base). The box is fitted to the measured mark rects: the mark's right
|
||||
# margin is ~0.025 of the short side (not Doubao's 0.004), so the box anchor is
|
||||
# wider off the corner; width/height cover the big size mode plus NCC slack.
|
||||
WM_WIDTH_FRAC = 0.231
|
||||
WM_HEIGHT_FRAC = 0.074
|
||||
MARGIN_RIGHT_FRAC = 0.0203
|
||||
MARGIN_BOTTOM_FRAC = 0.0218
|
||||
|
||||
# Glyph appearance: a light, low-saturation gray rendered brighter than the local
|
||||
# background (white top-hat), same overlay class as Doubao -- inherited, and
|
||||
# harmless because the tophat front-end turns these gates into weights.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity)
|
||||
# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames
|
||||
# (cohort-contamination-guarded): clean p99 0.301 / max 0.316, and every cohort
|
||||
# frame scoring >= 0.45 carries a visible 千问AI生成 mark (86% of the eyeballed
|
||||
# visible marks fire, the misses being white-on-near-white contrast losses).
|
||||
# 0.45 was picked over 0.32 (identical clean fire) for margin against unseen
|
||||
# clean content at zero measured recall cost.
|
||||
DETECT_NCC_THRESHOLD = 0.45
|
||||
|
||||
# Detection-silhouette geometry (fraction of the short side), fitted on the
|
||||
# cohort: the mark's width modes and its aspect (0.26) at the winning width.
|
||||
_ALPHA_WIDTH_FRAC = 0.160
|
||||
_ALPHA_HEIGHT_FRAC = 0.0416
|
||||
|
||||
# The two measured size modes as scale rungs: 0.124 and 0.203 of the short side,
|
||||
# expressed against the 0.160 nominal. Measured, not rounded: off-mode rungs drop
|
||||
# NCC from ~0.73 to ~0.37 on real marks (the comb), and a 4-rung variant scored
|
||||
# strictly worse (the extra rungs cover nothing and the big mode lands 4.6% off
|
||||
# its nearest rung).
|
||||
_LADDER = (0.78, 1.27)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Qwen",
|
||||
asset_name="qwen_alpha.png",
|
||||
corner="br",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac=MARGIN_BOTTOM_FRAC,
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="tophat",
|
||||
scale_basis="short", # measured: frac_short CV 0.189 vs width 0.273
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: the sub-gate band is dominated by non-Qwen banners, so
|
||||
# provenance relaxation is disabled outright (factor 1.0 = never relaxed).
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
QwenDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled Qwen alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "千问AI生成" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Qwen glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class QwenEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible Qwen "千问AI生成" watermark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -2,17 +2,25 @@
|
||||
|
||||
Position- and content-agnostic. You supply the rectangle(s); the eraser inpaints
|
||||
whatever is inside, so it removes any visible logo / watermark / object regardless
|
||||
of colour, style, or location. Localisation is the user's responsibility (pass the
|
||||
of color, style, or location. Localization is the user's responsibility (pass the
|
||||
box); restoration runs on CPU. This is the universal fallback for marks the
|
||||
deterministic per-generator engines (Gemini sparkle, Doubao) do not cover.
|
||||
|
||||
Backends:
|
||||
- ``cv2`` (default): ``cv2.inpaint`` (Telea / Navier-Stokes). Instant, no extra
|
||||
dependencies, lower quality on large or textured regions.
|
||||
- ``migan`` (optional, extra ``migan``): MI-GAN via onnxruntime
|
||||
(``andraniksargsyan/migan``, MIT). CPU, ~28 MB model, ~0.19 s/call -- the
|
||||
droplet-friendly tier: near-big-LaMa quality on small marks. Model downloaded
|
||||
on first use. Like ``lama`` it crops a padded region around the mask before
|
||||
inference (at native resolution -- MI-GAN takes arbitrary dims), so peak RAM is
|
||||
bounded by the mark size (~0.6-0.9 GB) rather than scaling with the image, which
|
||||
is what lets a memory-tight host run it on a large upload.
|
||||
- ``lama`` (optional, extra ``lama``): big-LaMa via onnxruntime
|
||||
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, much better on
|
||||
texture. The model (~200 MB) is downloaded on first use and cached by
|
||||
huggingface_hub; it is never bundled in this repo.
|
||||
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, best quality on
|
||||
texture but ~200 MB model and ~4.7 GB peak RAM (too heavy for a small host).
|
||||
The model is downloaded on first use and cached by huggingface_hub; it is
|
||||
never bundled in this repo.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
|
||||
@@ -32,13 +40,17 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Backend = Literal["cv2", "lama"]
|
||||
Backend = Literal["cv2", "lama", "migan"]
|
||||
|
||||
_LAMA_REPO = "Carve/LaMa-ONNX"
|
||||
_LAMA_FILE = "lama_fp32.onnx"
|
||||
|
||||
# Cached onnxruntime session (loading is expensive; reuse across calls).
|
||||
_MIGAN_REPO = "andraniksargsyan/migan"
|
||||
_MIGAN_FILE = "migan.onnx"
|
||||
|
||||
# Cached onnxruntime sessions (loading is expensive; reuse across calls).
|
||||
_lama_session: object | None = None
|
||||
_migan_session: object | None = None
|
||||
|
||||
|
||||
def boxes_to_mask(
|
||||
@@ -60,6 +72,26 @@ def boxes_to_mask(
|
||||
return mask
|
||||
|
||||
|
||||
def _padded_crop_box(
|
||||
mask: NDArray[Any], h: int, w: int, *, pad_frac: float, pad_min: int
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""Bounding box of the set mask pixels, padded and clamped to the image.
|
||||
|
||||
Returns ``(x0, y0, x1, y1)`` or ``None`` when the mask is empty. Both learned
|
||||
backends crop to this box so the ONNX working set is bounded by the mark size
|
||||
rather than the whole image; ``pad_frac``/``pad_min`` tune how much surrounding
|
||||
context the inpainter sees (LaMa then resizes the crop to its fixed square,
|
||||
MI-GAN feeds it at native resolution).
|
||||
"""
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
pad = max(pad_min, int(pad_frac * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
|
||||
x0, y0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
x1, y1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def erase_cv2(
|
||||
image_bgr: NDArray[Any],
|
||||
mask: NDArray[Any],
|
||||
@@ -130,19 +162,15 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
size = next((d for d in reversed(dims) if isinstance(d, int) and d > 1), 512)
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) == 0:
|
||||
box = _padded_crop_box(mask, h, w, pad_frac=0.4, pad_min=16)
|
||||
if box is None:
|
||||
return image_bgr.copy()
|
||||
|
||||
# Padded crop around the mask (context for the inpainter).
|
||||
pad = max(16, int(0.4 * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
|
||||
cx0, cy0, cx1, cy1 = box
|
||||
crop = image_bgr[cy0:cy1, cx0:cx1]
|
||||
crop_mask = mask[cy0:cy1, cx0:cx1]
|
||||
ch, cw = crop.shape[:2]
|
||||
|
||||
# Resize crop + mask to the model size, normalise to [0,1] RGB CHW.
|
||||
# Resize crop + mask to the model size, normalize to [0,1] RGB CHW.
|
||||
crop_rs = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
|
||||
mask_rs = cv2.resize(crop_mask, (size, size), interpolation=cv2.INTER_NEAREST)
|
||||
img_in = cv2.cvtColor(crop_rs, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||
@@ -167,6 +195,95 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
return result
|
||||
|
||||
|
||||
def migan_available() -> bool:
|
||||
"""True when the optional MI-GAN backend can run (onnxruntime installed)."""
|
||||
from .optional_deps import module_available
|
||||
|
||||
return module_available("onnxruntime")
|
||||
|
||||
|
||||
def _get_migan_session() -> object:
|
||||
"""Load (once) the MI-GAN ONNX session, downloading the model on first use."""
|
||||
global _migan_session
|
||||
if _migan_session is not None:
|
||||
return _migan_session
|
||||
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
model_path = hf_hub_download(repo_id=_MIGAN_REPO, filename=_MIGAN_FILE)
|
||||
logger.info("Loading MI-GAN ONNX model: %s", model_path)
|
||||
_migan_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
return _migan_session
|
||||
|
||||
|
||||
def erase_migan(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Inpaint ``mask`` (255 = erase) with MI-GAN via onnxruntime (CPU).
|
||||
|
||||
Like ``erase_lama``, we crop a padded region around the mask, feed only that crop
|
||||
to the ONNX model, and paste only the masked pixels back -- so untouched areas stay
|
||||
pixel-exact and the ONNX working set is bounded by the MARK size, not the whole
|
||||
image. MI-GAN accepts arbitrary spatial dims, so (unlike LaMa's fixed 512 square)
|
||||
the crop is fed at NATIVE resolution -- no resize, so the mark is seen at full
|
||||
scale. Feeding the whole frame instead made peak RAM scale with the image
|
||||
(~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds the
|
||||
inpaint working set roughly constant, so a memory-tight host (e.g. a 1-2 GB web
|
||||
worker) can run MI-GAN on a 25 MP upload. Cropping 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).
|
||||
|
||||
Mask polarity: the shipped ``andraniksargsyan/migan`` ONNX expects 0 = hole
|
||||
(inpaint) / 255 = known (keep) -- the INVERSE of this package's 255-erase
|
||||
convention -- so the mask is inverted before feeding the model (corpus-validated
|
||||
2026-07; feeding 255=hole regenerates the whole frame into stripes).
|
||||
|
||||
Accepts 1-channel (grayscale) and 4-channel (BGRA) input.
|
||||
"""
|
||||
if image_bgr.ndim == 2:
|
||||
bgr = erase_migan(cv2.cvtColor(image_bgr, cv2.COLOR_GRAY2BGR), mask)
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
|
||||
if image_bgr.ndim == 3 and image_bgr.shape[2] == 4:
|
||||
bgr = erase_migan(np.ascontiguousarray(image_bgr[:, :, :3]), mask)
|
||||
return np.dstack([bgr, image_bgr[:, :, 3]])
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
# 2x the mark size (min 256 px) gives ample local context while a small corner
|
||||
# mask keeps the crop small regardless of the full image resolution; the crop is
|
||||
# clamped to the image, so a mark already spanning the frame degrades to the
|
||||
# whole image (the old behavior) rather than erroring.
|
||||
box = _padded_crop_box(mask, h, w, pad_frac=2.0, pad_min=256)
|
||||
if box is None:
|
||||
return image_bgr.copy()
|
||||
cx0, cy0, cx1, cy1 = box
|
||||
crop = np.ascontiguousarray(image_bgr[cy0:cy1, cx0:cx1])
|
||||
crop_mask = mask[cy0:cy1, cx0:cx1]
|
||||
ch, cw = crop.shape[:2]
|
||||
|
||||
session = _get_migan_session()
|
||||
inp = session.get_inputs() # type: ignore[attr-defined]
|
||||
img_name, mask_name = inp[0].name, inp[1].name
|
||||
|
||||
rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
|
||||
img_in = np.transpose(rgb, (2, 0, 1))[None].astype(np.uint8) # (1,3,ch,cw)
|
||||
# invert to MI-GAN polarity: 255 where KNOWN (keep), 0 where hole (erase)
|
||||
known = (crop_mask <= 127).astype(np.uint8) * 255
|
||||
mask_in = known[None, None] # (1,1,ch,cw)
|
||||
|
||||
out = session.run(None, {img_name: img_in, mask_name: mask_in})[0] # type: ignore[attr-defined]
|
||||
res = np.transpose(np.asarray(out)[0], (1, 2, 0)).astype(np.uint8) # (ch',cw',3) RGB
|
||||
if res.shape[:2] != (ch, cw):
|
||||
res = cv2.resize(res, (cw, ch), interpolation=cv2.INTER_LINEAR)
|
||||
out_bgr = cv2.cvtColor(res, cv2.COLOR_RGB2BGR)
|
||||
|
||||
result = image_bgr.copy()
|
||||
region = result[cy0:cy1, cx0:cx1]
|
||||
hole = crop_mask > 127
|
||||
region[hole] = out_bgr[hole]
|
||||
result[cy0:cy1, cx0:cx1] = region
|
||||
return result
|
||||
|
||||
|
||||
def erase(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
@@ -191,6 +308,12 @@ def erase(
|
||||
if not mask.any():
|
||||
return image_bgr.copy()
|
||||
|
||||
if backend == "migan":
|
||||
if not migan_available():
|
||||
raise RuntimeError(
|
||||
"MI-GAN backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[migan]'"
|
||||
)
|
||||
return erase_migan(image_bgr, mask)
|
||||
if backend == "lama":
|
||||
if not lama_available():
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""RunningHub visible watermark detector/localizer.
|
||||
|
||||
RunningHub (a hosted ComfyUI platform, USCC 91340100MAEB4N8H76) stamps its
|
||||
generations with a faint light-gray "RunningHub AI生成" text mark in the
|
||||
**top-left** corner -- the China TC260 explicit AIGC label, but placed top-left
|
||||
(unlike the GB 45438-2025 house style bottom-right of Doubao/Qwen/Kling) and
|
||||
rendered in a mid-gray that the white top-hat front-end suppresses to clean-arm
|
||||
levels.
|
||||
|
||||
Detection therefore uses the ``gray`` front-end (raw-grayscale silhouette NCC,
|
||||
see ``TextMarkConfig.detect_frontend``); removal is the shared **localize ->
|
||||
fill** (the detector's best-match box feeds :meth:`footprint_mask` ->
|
||||
``region_eraser``). This module supplies only RunningHub's tuned
|
||||
:class:`TextMarkConfig` (``assets/runninghub_alpha.png`` -- a font-rendered
|
||||
synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut
|
||||
from an upload).
|
||||
|
||||
EVERY tuned number below was measured on the vendor cohort (73 TC260 carriers
|
||||
whose producer USCC names the entity, harvested 2026-07-22 by
|
||||
``scripts/vendor_cohort_harvest.py``), NOT inherited from Doubao:
|
||||
|
||||
* Only ~4 of the 73 cohort frames carry a visible mark (the rest are
|
||||
metadata-only TC260 carriers -- the platform labels frames it does not
|
||||
stamp), so recall of visible marks is 4/4 but the cohort fire rate is not
|
||||
a recall estimate. Positions/geometry are consistent across the positives.
|
||||
* The mark's width is ~0.32 of the frame WIDTH (0.319 measured on 832/1080/
|
||||
1536-wide frames) at ~0.008/0.006 x/y margins; the locate box below covers
|
||||
it with NCC slack.
|
||||
* ``alpha_height_frac`` comes from the silhouette aspect (0.128) at the
|
||||
measured width (0.27 * 1.25 rung ~= 0.3375 >= 0.32), per the standing rule
|
||||
that it is measured, not inherited.
|
||||
* STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is
|
||||
contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is
|
||||
unmeasured beyond the clean arm, so no provenance relaxation exists.
|
||||
* Gate 0.34: on 283 hand-labelled clean frames (cohort-contamination-guarded)
|
||||
corner-anchored gray NCC p99 is 0.264 / max 0.304, while the 4 positives
|
||||
score 0.38-0.54. 0.34 sits above the clean max with a small margin; the
|
||||
positives are few, so the margin is deliberately thin on the recall side.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
# as unused; the use is cross-module.
|
||||
# pyright: reportUnusedFunction=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks import _text_mark_engine
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkDetection, TextMarkEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# Locate geometry as a fraction of the image WIDTH (the measured basis: every
|
||||
# positive is portrait, where width == short side). The mark hugs the top-left
|
||||
# corner (~0.008 of width off the left edge, ~0.006 of height off the top).
|
||||
WM_WIDTH_FRAC = 0.45
|
||||
WM_HEIGHT_FRAC = 0.10
|
||||
MARGIN_LEFT_FRAC = 0.002
|
||||
MARGIN_TOP_FRAC = 0.002
|
||||
|
||||
# Glyph appearance fields are unused by the gray front-end (it never binarizes)
|
||||
# and kept only for config parity with the other text marks.
|
||||
MAX_SATURATION = 55
|
||||
LOGO_MIN_LUMA = 150
|
||||
TOPHAT_DELTA = 12
|
||||
|
||||
DETECT_MIN_COVERAGE = 0.04 # unused by the gray front-end (kept for config parity)
|
||||
# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labelled clean frames:
|
||||
# corner-anchored gray NCC, clean p99 0.264 / max 0.304; positives 0.38-0.54.
|
||||
DETECT_NCC_THRESHOLD = 0.34
|
||||
|
||||
# Detection-silhouette geometry (fraction of the image width), measured on the
|
||||
# positives: mark width is ~0.320 of width on all three frame sizes (266px at 832,
|
||||
# 345px at 1080, 491px at 1536), and the NCC is razor-sharp in size (0.537 on-size,
|
||||
# 0.223 at +5.6% -- the same comb behaviour Qwen measured), so the nominal sits
|
||||
# exactly on the measured size with a TIGHT ladder around it, not the shared 3 rungs
|
||||
# (whose nearest rung landed 5.6% off and collapsed the match to 0.22).
|
||||
_ALPHA_WIDTH_FRAC = 0.32
|
||||
_ALPHA_HEIGHT_FRAC = 0.04
|
||||
_LADDER = (0.95, 1.0, 1.05)
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="RunningHub",
|
||||
asset_name="runninghub_alpha.png",
|
||||
corner="tl",
|
||||
margin_floor=4,
|
||||
width_frac=WM_WIDTH_FRAC,
|
||||
height_frac=WM_HEIGHT_FRAC,
|
||||
margin_x_frac=MARGIN_LEFT_FRAC,
|
||||
margin_bottom_frac=MARGIN_TOP_FRAC, # top margin for corner="tl"
|
||||
max_saturation=MAX_SATURATION,
|
||||
logo_min_luma=LOGO_MIN_LUMA,
|
||||
tophat_delta=TOPHAT_DELTA,
|
||||
morph_open_size=5,
|
||||
detect_min_coverage=DETECT_MIN_COVERAGE,
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
detect_frontend="gray",
|
||||
scale_basis="width", # measured: mark width tracks the frame width (0.32)
|
||||
ladder=_LADDER,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
min_gw=8,
|
||||
# STRICT ONLY: contrast-dependent gray NCC; the relaxed band is unmeasured.
|
||||
provenance_ncc_factor=1.0,
|
||||
)
|
||||
|
||||
RunningHubDetection = TextMarkDetection
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""The bundled RunningHub alpha template (float [0,1]), or None."""
|
||||
return _text_mark_engine.load_alpha_template(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "RunningHub AI生成" silhouette (255 = glyph) from the alpha map, or None."""
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the RunningHub glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class RunningHubEngine(TextMarkEngine):
|
||||
"""Detect/localize the visible RunningHub "RunningHub AI生成" mark (top-left; localize -> fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
|
||||
# Anchor window for the match position, as a fraction of the FRAME: the true
|
||||
# mark hugs the corner (measured x 0.008-0.014, y 0.005-0.007 of the frame on
|
||||
# every cohort positive), while the full-corpus false fires (hair, shelves,
|
||||
# window frames, CJK banners -- 37 of 42009 outside-cohort frames at the 0.34
|
||||
# gate, 2026-07-22 sweep) match off-anchor at x 0.013-0.150 / y 0.009-0.045.
|
||||
# No NCC gate separates them (false max 0.384 vs two positives at 0.381), but
|
||||
# position does: every false fire sits outside this window, every positive
|
||||
# inside. Contrast-dependent raw-gray NCC keys on "some text-like structure
|
||||
# anywhere in the box"; the anchor is what makes it about THIS mark.
|
||||
_ANCHOR_MAX_X = 0.025
|
||||
_ANCHOR_MAX_Y = 0.015
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
|
||||
det = super().detect(image, provenance=provenance)
|
||||
if not det.detected:
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
_, box = self._gray_best(image, loc)
|
||||
if box is None:
|
||||
det.detected = False
|
||||
return det
|
||||
h, w = image.shape[:2]
|
||||
ax = (loc.x + box[0]) / w
|
||||
ay = (loc.y + box[1]) / h
|
||||
if ax > self._ANCHOR_MAX_X or ay > self._ANCHOR_MAX_Y:
|
||||
logger.debug(
|
||||
"RunningHub detect: score %.3f but match off-anchor (x=%.3f y=%.3f); demoting.",
|
||||
det.confidence,
|
||||
ax,
|
||||
ay,
|
||||
)
|
||||
det.detected = False
|
||||
return det
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -1,19 +1,22 @@
|
||||
"""Samsung Galaxy AI visible watermark removal engine.
|
||||
"""Samsung Galaxy AI visible watermark detector/localizer.
|
||||
|
||||
Samsung's on-device Generative AI photo edits burn a visible "✦ Contenuti generati
|
||||
dall'AI" wordmark into the bottom-LEFT corner (the Italian locale variant calibrated
|
||||
here; the string is locale-specific). It is a faint, near-white semi-transparent
|
||||
overlay, the same overlay class as the Doubao/Jimeng marks but bottom-left.
|
||||
here; the string is locale-specific -- DETECTION only matches this locale's silhouette,
|
||||
so other locales are not yet detected, though the fill mask itself is locale-agnostic).
|
||||
It is a faint, near-white semi-transparent overlay, the same overlay class as the
|
||||
Doubao/Jimeng marks but bottom-left.
|
||||
|
||||
Removal is **reverse-alpha blending** against a captured alpha map
|
||||
(``original = (wm - a*logo)/(1-a)``), always NCC-aligned to the actual mark plus a thin
|
||||
residual inpaint over the glyph footprint. This is one of the three text-mark engines
|
||||
that share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
|
||||
Detection matches the bundled glyph silhouette against the corner; removal is the
|
||||
shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds
|
||||
``region_eraser``), NOT reverse-alpha. This is one of the three text-mark engines that
|
||||
share :class:`remove_ai_watermarks._text_mark_engine.TextMarkEngine`; this module
|
||||
supplies only Samsung's tuned :class:`TextMarkConfig` (bottom-LEFT corner, a lower glyph
|
||||
luma since the mark is faint, ``assets/samsung_alpha.png`` solved from the flat captures
|
||||
by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI edits are also caught by C2PA
|
||||
+ the ``genAIType`` marker, so this is the visible-mark *removal* path; it also feeds
|
||||
``identify`` as the medium-confidence ``visible_samsung`` signal via the registry.
|
||||
luma since the mark is faint, ``assets/samsung_alpha.png`` -- the detection silhouette,
|
||||
solved from the flat captures by ``scripts/visible_alpha_solve.py``). Samsung Galaxy AI
|
||||
edits are also caught by C2PA + the ``genAIType`` marker, so this is the visible-mark
|
||||
*removal* path; it also feeds ``identify`` as the medium-confidence ``visible_samsung``
|
||||
signal via the registry.
|
||||
"""
|
||||
# The module-level _alpha_template / _glyph_silhouette / _template_match_score below
|
||||
# are thin test-facing shims (imported by tests/), so pyright's src-only pass sees them
|
||||
@@ -48,21 +51,13 @@ TOPHAT_DELTA = 8
|
||||
DETECT_MIN_COVERAGE = 0.01
|
||||
DETECT_NCC_THRESHOLD = 0.40
|
||||
|
||||
# Reverse-alpha geometry, solved by scripts/visible_alpha_solve.py from the flat gray
|
||||
# capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
|
||||
# upscaled; width-scale + NCC-align removes it cleanly (a flat capture at the real
|
||||
# resolution would make the alpha pixel-sharp -- an open quality upgrade).
|
||||
# Detection-silhouette geometry, solved by scripts/visible_alpha_solve.py from the flat
|
||||
# gray capture (native width 1086). Real photos are ~2958 wide, so the captured glyph is
|
||||
# upscaled; width-scale + NCC-align sizes the silhouette for the detection match (removal
|
||||
# is the template-free glyph-bbox footprint mask).
|
||||
_ALPHA_NATIVE_WIDTH = 1086
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_WIDTH_FRAC = 0.3195 # asset width / image width -- sizes the detection silhouette
|
||||
_ALPHA_HEIGHT_FRAC = 0.0378
|
||||
_ALPHA_MARGIN_LEFT_FRAC = 0.0110
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0064
|
||||
# Wider scale search: the flat capture is far off the real-photo width.
|
||||
_ALPHA_ALIGN_SEARCH = (0.85, 1.18, 23)
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
|
||||
_CONFIG = TextMarkConfig(
|
||||
name="Samsung Galaxy AI",
|
||||
@@ -81,14 +76,7 @@ _CONFIG = TextMarkConfig(
|
||||
detect_ncc_threshold=DETECT_NCC_THRESHOLD,
|
||||
alpha_width_frac=_ALPHA_WIDTH_FRAC,
|
||||
alpha_height_frac=_ALPHA_HEIGHT_FRAC,
|
||||
alpha_margin_x_frac=_ALPHA_MARGIN_LEFT_FRAC,
|
||||
alpha_margin_bottom_frac=_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
alpha_align_search=_ALPHA_ALIGN_SEARCH,
|
||||
min_gw=16,
|
||||
alpha_logo_bgr=_ALPHA_LOGO_BGR,
|
||||
residual_alpha_floor=_RESIDUAL_ALPHA_FLOOR,
|
||||
residual_dilate=_RESIDUAL_DILATE,
|
||||
residual_inpaint_radius=_RESIDUAL_INPAINT_RADIUS,
|
||||
)
|
||||
|
||||
SamsungDetection = TextMarkDetection
|
||||
@@ -104,13 +92,13 @@ def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
return _text_mark_engine.glyph_silhouette(_CONFIG.asset_name)
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
def _template_match_score(box_mask: NDArray[Any], scale_base: int) -> float:
|
||||
"""TM_CCOEFF_NORMED of the Samsung glyph silhouette against ``box_mask``."""
|
||||
return _text_mark_engine.template_match_score(box_mask, image_width, _CONFIG)
|
||||
return _text_mark_engine.template_match_score(box_mask, scale_base, _CONFIG)
|
||||
|
||||
|
||||
class SamsungEngine(TextMarkEngine):
|
||||
"""Remove the visible Samsung Galaxy AI text mark (locate -> mask -> reverse-alpha)."""
|
||||
"""Detect/localize the visible Samsung Galaxy AI text mark (locate -> mask; mask feeds the fill)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_CONFIG)
|
||||
@@ -28,7 +28,7 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Adobe ships Variant P in production (com.adobe.trustmark.P).
|
||||
_MODEL_TYPE = "P"
|
||||
@@ -94,10 +94,10 @@ def detect_trustmark(image_path: Path) -> str | None:
|
||||
if not wm_present:
|
||||
return None
|
||||
if not _survives_reencode(decoder, cover, wm_schema):
|
||||
log.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
|
||||
logger.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
|
||||
return None
|
||||
except Exception as exc: # model download / decode failure / unreadable image
|
||||
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
|
||||
logger.debug("TrustMark decode failed for %s: %s", image_path, exc)
|
||||
return None
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
|
||||
|
||||
|
||||
@@ -5,29 +5,33 @@ A single catalog that ties each known visible mark to (a) where it usually sits,
|
||||
registry detects every known mark in its usual place and removes the ones
|
||||
present.
|
||||
|
||||
**Reverse-alpha based.** A known mark is a fixed semi-transparent overlay, so it
|
||||
is removed by inverting the alpha blend against a captured alpha map
|
||||
(``original = (wm - a*logo)/(1-a)``) -- recovering the true pixels rather than
|
||||
inpainting a guess. Gemini and Doubao recover exactly with no inpaint at native on
|
||||
bright/flat backgrounds (Gemini falls back to inpainting the sparkle footprint when
|
||||
reverse-alpha would over-subtract on a dark background -- issue #30, see gemini_engine);
|
||||
Jimeng adds a thin residual inpaint over the glyph footprint to clear the outline
|
||||
its per-image render variation leaves behind (still seeded by the reverse-alpha
|
||||
recovery, not a blind inpaint). Detection is consistent with that: each mark is
|
||||
recognized by matching its known shape/template (the thing we invert), not by
|
||||
heuristics. A mark is therefore listed here only once a real alpha map has been
|
||||
captured for it; everything else (arbitrary logos/objects) is the user-directed
|
||||
``erase --region`` tool, not this catalog.
|
||||
**Localize -> fill.** A known mark is removed by LOCALIZING it (a template-free,
|
||||
version-robust detector that returns a binary footprint MASK) and then handing
|
||||
that mask to ONE shared, swappable fill backend (``region_eraser``: cv2 Telea/NS,
|
||||
MI-GAN, or big-LaMa). No mark carries a reverse-alpha step any more: the old
|
||||
``original = (wm - a*logo)/(1-a)`` recovery depended on a fixed captured alpha map
|
||||
at a fixed position, broke whenever a vendor re-rendered or moved its mark, and was
|
||||
not color-lossless even with the right map (it amplifies quantization/JPEG-chroma
|
||||
error by ``1/(1-a)`` -- the "the color just changed, not removed" reports). The
|
||||
localizer stays cheap (cv2/numpy, CPU) so a memory-tight caller can run it on a
|
||||
small worker; the heavy fill (MI-GAN / LaMa) is opt-in and chosen by the caller.
|
||||
|
||||
Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
- ``qwen`` -- Alibaba Tongyi Qianwen "千问AI生成" text strip, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
- ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less).
|
||||
- ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end).
|
||||
- ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right.
|
||||
- ``liblib`` -- LibLibAI "LibLibAI" wordmark, bottom-center.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
@@ -36,11 +40,88 @@ if TYPE_CHECKING:
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# cv2 method for the Gemini reverse-alpha edge-residual cleanup (not a standalone
|
||||
# remover): "ns" / "telea".
|
||||
InpaintMethod = Literal["telea", "ns"]
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Region = tuple[int, int, int, int]
|
||||
|
||||
# Fill backend for the shared removal path. ``auto`` resolves best-first to the highest
|
||||
# quality installed model -- LaMa, else MI-GAN, else cv2 (see ``resolve_backend``); the
|
||||
# others force a specific backend (mirrors the ``erase`` command's ``--backend``).
|
||||
Backend = Literal["auto", "cv2", "migan", "lama"]
|
||||
|
||||
# Detection sensitivity for the removal path -- how much to trust a borderline mark.
|
||||
# * ``strict``: high-precision visual gate only; never relaxed, so a clean image is
|
||||
# never touched (the gate demotes a sparkle-shaped content match, so it never fills
|
||||
# a clean corner). Lowest recall on faint/moved marks.
|
||||
# * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product
|
||||
# evidence the mark is there -- metadata provenance for that vendor, or a confidently
|
||||
# detected sibling mark of the same product (see ``resolve_trust``). No evidence ->
|
||||
# stays strict. Safe: it only escalates where the mark is corroborated.
|
||||
#
|
||||
# REMOVED 2026-07-19: ``assume_ai`` relaxed every mark's gate on the caller's bare
|
||||
# assertion that the image is AI. It was a statistical gamble, not an instruction:
|
||||
# "this image is AI" says nothing about WHICH vendor or WHERE, which is exactly what a
|
||||
# gate bypass needs, so it took a confidence floor to be tolerable at all (before that
|
||||
# floor it filled a phantom sparkle on 59.8% of genuine camera photos). It also had no
|
||||
# place in the product's own model -- detector finds a mark, remove it; detector finds
|
||||
# nothing, leave the image alone; the USER sees a mark and says so, act on that. A user
|
||||
# who can see the mark is better served by pointing at it (``erase --region``) or naming
|
||||
# it (``--mark X --no-detect`` for a text mark), both of which execute an instruction
|
||||
# instead of guessing. Removing it also collapsed the trust ladder from three levels to
|
||||
# two. See docs/module-internals.md for the measurements.
|
||||
Sensitivity = Literal["auto", "strict"]
|
||||
|
||||
# The trust level a mark's detection gate is resolved to (see ``resolve_trust``).
|
||||
# ``confirmed`` bypasses the engine's false-positive gate, and that bypass is documented
|
||||
# to require evidence naming THIS vendor (see GeminiEngine.detect_watermark's
|
||||
# ``trust_provenance`` contract) -- so it is only ever reached from same-product evidence.
|
||||
# A third ``assumed`` level existed for ``assume_ai`` and went with it (2026-07-19).
|
||||
Trust = Literal["strict", "confirmed"]
|
||||
|
||||
# Product family per mark, for the ``auto`` cross-mark corroboration: a confidently
|
||||
# detected mark relaxes only OTHER marks of the SAME product (different corners, one
|
||||
# product -- the Jimeng wordmark + the Jimeng pill). Doubao and Jimeng are BOTH ByteDance
|
||||
# but distinct products in the SAME bottom-right corner, so they must NOT cross-relax
|
||||
# (relaxing Doubao on a Jimeng wordmark would spuriously fire Doubao on it).
|
||||
_PRODUCT_OF: dict[str, str] = {
|
||||
"gemini": "gemini",
|
||||
"doubao": "doubao",
|
||||
"jimeng": "jimeng",
|
||||
"jimeng_pill": "jimeng", # same product as the Jimeng wordmark
|
||||
"qwen": "qwen",
|
||||
"kling": "kling",
|
||||
"samsung": "samsung",
|
||||
"runninghub": "runninghub",
|
||||
"baidu": "baidu",
|
||||
"liblib": "liblib",
|
||||
}
|
||||
|
||||
|
||||
# Marks whose own detection is too weak to serve as EVIDENCE for a sibling of the
|
||||
# same product, even though they share one. Sibling corroboration grants ``confirmed``
|
||||
# trust, which bypasses the sibling's false-positive gate outright -- so a detector
|
||||
# that false-fires often must not be able to hand that bypass to anyone.
|
||||
#
|
||||
# The pill qualifies on its own documented numbers: ~7% raw false-fire (5.5% measured
|
||||
# on 578 vendor negatives, 2026-07-18). Letting it corroborate produced a closed loop
|
||||
# on the DEFAULT auto path, no user flag involved:
|
||||
# pill false-fires on a clean non-ByteDance image
|
||||
# -> _PRODUCT_OF maps it to "jimeng", so jimeng resolves to `confirmed`
|
||||
# -> jimeng's NCC gate drops 0.45 -> 0.3825 and it false-fires too
|
||||
# -> _keep_pill now sees "jimeng" in keys and takes the WORDMARK arm, which
|
||||
# removes the pill unrestricted -- skipping the flatness guard that exists
|
||||
# precisely to stop the fill smearing a textured corner.
|
||||
# Measured on the corpus: 3 of 578 negatives ran the full loop, one of them with
|
||||
# footprint_flat=0 (the exact case the guard was written to block). Cutting the pill
|
||||
# out of corroboration removed all 3 and cost NOTHING on 4417 TC260 carriers
|
||||
# (jimeng fires 398 -> 398), so this is a defect fix, not a recall trade.
|
||||
#
|
||||
# `_keep_pill` already encodes the same distrust for the pill's own ACTION; this
|
||||
# closes the gap that its TESTIMONY was never gated.
|
||||
# Regression: tests/test_watermark_registry.py::TestArbiter::
|
||||
# test_weak_pill_detection_does_not_confirm_the_jimeng_wordmark
|
||||
_CANNOT_CORROBORATE: frozenset[str] = frozenset({"jimeng_pill"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkDetection:
|
||||
@@ -54,49 +135,214 @@ class MarkDetection:
|
||||
region: Region
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Localization:
|
||||
"""A located mark: its detection verdict plus the full-frame removal mask.
|
||||
|
||||
``mask`` is a full-frame uint8 array (255 = remove) sized to the image, or None
|
||||
when nothing should be removed (no detection and not forced, or the footprint
|
||||
could not be placed). ``region`` is the mark's bbox (for logging / residual
|
||||
positioning)."""
|
||||
|
||||
detected: bool
|
||||
confidence: float
|
||||
region: Region
|
||||
mask: NDArray[Any] | None
|
||||
|
||||
|
||||
_REMOVED_SENSITIVITIES = {
|
||||
"assume_ai": (
|
||||
"sensitivity='assume_ai' was removed in 0.16: it relaxed EVERY mark's detection "
|
||||
"gate on the bare assertion that an image is AI, which says nothing about which "
|
||||
"vendor made it or where the mark is. If you can see a mark the detector missed, "
|
||||
"act on what you see: erase(image, region=(x, y, w, h)), or the CLI "
|
||||
"`--mark <name> --no-detect` for a known text mark. Use sensitivity='auto' for "
|
||||
"the default evidence-driven behaviour."
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def validate_sensitivity(value: str) -> Sensitivity:
|
||||
"""Reject a removed sensitivity LOUDLY instead of silently falling back to ``auto``.
|
||||
|
||||
``Sensitivity`` is a ``Literal``, which is not enforced at runtime, so a caller
|
||||
upgrading from 0.15 would pass ``"assume_ai"`` and quietly get ``auto`` behaviour --
|
||||
a silent semantic change on the one release where they most need to be told.
|
||||
"""
|
||||
if value in _REMOVED_SENSITIVITIES:
|
||||
raise ValueError(_REMOVED_SENSITIVITIES[value])
|
||||
if value not in ("auto", "strict"):
|
||||
raise ValueError(f"unknown sensitivity {value!r}; expected 'auto' or 'strict'")
|
||||
return value # type: ignore[return-value]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Context:
|
||||
"""The evidence + policy the removal arbiter decides against (perception is
|
||||
kept separate from this decision). ``sensitivity`` is the caller's intent
|
||||
(see :data:`Sensitivity`); ``provenance`` is the vendor keys local metadata
|
||||
confirms, the evidence that drives ``auto``. Bundling them into one object is
|
||||
why the arbiter can be a pure function of ``(candidates, context)``."""
|
||||
|
||||
sensitivity: Sensitivity = "auto"
|
||||
provenance: frozenset[str] = frozenset()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
validate_sensitivity(self.sensitivity)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Candidate:
|
||||
"""One mark's PERCEPTION output -- what the engine sees, with NO policy applied.
|
||||
|
||||
Carries the mark's verdict at BOTH trust levels (``detected_strict`` = the
|
||||
conservative gate, ``detected_relaxed`` = the gate the engine relaxes to under
|
||||
provenance), so the arbiter can pick per mark without re-running detection.
|
||||
|
||||
``features`` is a generic bag of physical measurements a mark's gate may need (the
|
||||
mark owns which it reports via ``KnownMark._features``); e.g. the pill supplies
|
||||
``footprint_flat`` (0/1). Empty for marks whose gate needs no extra evidence."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
detected_strict: bool
|
||||
detected_relaxed: bool
|
||||
features: dict[str, float] # generic; both construction sites always supply it (empty when none)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""The arbiter's verdict for one fired mark: remove it, at the resolved trust
|
||||
level (``relax`` feeds the mark's mask build so the fill footprint matches the
|
||||
level the mark was accepted at)."""
|
||||
|
||||
candidate: Candidate
|
||||
relax: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownMark:
|
||||
"""A known visible watermark: where it lives, how to find and remove it."""
|
||||
"""A known visible watermark: where it lives, how to find and mask it.
|
||||
|
||||
Removal is uniform (:meth:`remove`): localize the mark to a mask, then fill that
|
||||
mask with the chosen backend. Each mark supplies two cheap cv2/numpy callables --
|
||||
``_detect`` (verdict + bbox, no mask; used by the identify scan) and ``_mask``
|
||||
(the full-frame footprint mask; used by removal)."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
location: str # usual place, human-readable ("bottom-right")
|
||||
in_auto: bool # participate in `--mark auto` scanning
|
||||
recovery: str # removal strategy (all reverse-alpha today)
|
||||
_detect: Callable[[NDArray[Any]], MarkDetection]
|
||||
_remove: Callable[..., tuple[NDArray[Any], Region | None]]
|
||||
_detect: Callable[..., MarkDetection]
|
||||
_mask: Callable[..., NDArray[Any] | None]
|
||||
# Optional physical-feature probe: the mark's OWN measurements its gate needs
|
||||
# (e.g. the pill's footprint flatness), so the perception pass stays uniform and
|
||||
# does not special-case any mark. None = the mark's gate needs no extra evidence.
|
||||
_features: Callable[..., dict[str, float]] | None = None
|
||||
|
||||
def detect(self, image: NDArray[Any]) -> MarkDetection:
|
||||
return self._detect(image)
|
||||
def features(self, image: NDArray[Any]) -> dict[str, float]:
|
||||
"""Physical features the mark reports for the arbiter's gate (empty if none)."""
|
||||
return self._features(image) if self._features is not None else {}
|
||||
|
||||
def detect(self, image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
|
||||
"""Detect the mark (verdict + bbox, no mask). ``provenance`` signals that
|
||||
external metadata already confirms this vendor, so the engine may relax its
|
||||
trust threshold (a mark it would otherwise demote as a content false positive
|
||||
is trusted when provenance says the vendor is present)."""
|
||||
return self._detect(image, provenance=provenance)
|
||||
|
||||
def localize(self, image: NDArray[Any], *, provenance: bool = False, force: bool = False) -> Localization:
|
||||
"""Detect and build the removal mask in one call. Returns a
|
||||
:class:`Localization`; ``mask`` is None unless the mark is detected (or
|
||||
``force`` bypasses detection for the mark's usual footprint)."""
|
||||
det = self.detect(image, provenance=provenance)
|
||||
if not (det.detected or force):
|
||||
return Localization(det.detected, det.confidence, det.region, None)
|
||||
# Pass the (provenance-aware) detection to the mask builder so it does NOT
|
||||
# re-detect at a different trust level -- a relaxed sparkle must not be
|
||||
# re-demoted into a None mask (reported-removed-but-unchanged).
|
||||
mask = self._mask(image, force=force, detection=det)
|
||||
return Localization(det.detected, det.confidence, det.region, mask)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
*,
|
||||
inpaint_method: InpaintMethod = "ns",
|
||||
inpaint: bool = True,
|
||||
inpaint_strength: float = 0.85,
|
||||
backend: Backend = "auto",
|
||||
provenance: bool = False,
|
||||
force: bool = False,
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
"""Remove this mark by reverse-alpha; returns ``(result, region)`` where
|
||||
``region`` is the removed mark's bbox (for residual-inpaint positioning),
|
||||
or None if nothing was removed. NB: the CLI does NOT use ``region`` to
|
||||
clear alpha on save -- that zeroing caused the issue-#30 white box.
|
||||
"""Remove this mark by localize -> fill; returns ``(result, region)`` where
|
||||
``region`` is the removed mark's bbox, or None if nothing was removed.
|
||||
|
||||
``inpaint`` / ``inpaint_strength`` / ``inpaint_method`` tune the Gemini
|
||||
reverse-alpha edge-residual cleanup only. ``force`` removes at the mark's
|
||||
usual location even without a positive detection (the ``--no-detect`` path).
|
||||
"""
|
||||
return self._remove(image, inpaint_method, inpaint, inpaint_strength, force)
|
||||
``backend`` picks the fill (``auto`` = LaMa > MI-GAN > cv2, best available; or force
|
||||
``cv2``/``migan``/``lama``). ``provenance`` relaxes the detector's trust gate
|
||||
when metadata already confirms the vendor. ``force`` removes at the mark's
|
||||
usual footprint even without a positive detection (the ``--no-detect`` path).
|
||||
NB: the CLI does NOT use ``region`` to clear alpha on save -- that zeroing
|
||||
caused the issue-#30 white box."""
|
||||
loc = self.localize(image, provenance=provenance, force=force)
|
||||
if loc.mask is None or not loc.mask.any():
|
||||
return image.copy(), None
|
||||
return fill(image, loc.mask, backend=backend), (loc.region if loc.detected else None)
|
||||
|
||||
|
||||
# Gemini-sparkle confidence above which the registry treats it as a confident
|
||||
# detection for arbitration. Matches identify's corpus-validated sparkle
|
||||
# threshold (0.5): the gemini engine's own detect flag uses a looser internal
|
||||
# threshold and weakly fires (~0.36) on unrelated bottom-right text (e.g. the
|
||||
# Doubao mark), which would otherwise let it hijack `--mark auto`. 0.5 gives 0
|
||||
# false positives on the corpus.
|
||||
_GEMINI_AUTO_MIN_CONF = 0.5
|
||||
# Single source of truth for the Gemini-sparkle "trust this as a real mark"
|
||||
# confidence, shared by BOTH the removal arbitration here (`_gemini_detect`) and
|
||||
# the provenance detector in `identify` (which imports it as its sparkle threshold).
|
||||
# Defining it once removes the detect-vs-remove
|
||||
# threshold drift the retained-corpus mining surfaced (2026-06-20): identify
|
||||
# would report a sparkle while removal declined it, or vice versa, whenever the
|
||||
# two independently-maintained 0.5 constants fell out of step. Now they cannot.
|
||||
#
|
||||
# Value 0.5 is corpus-validated: the gemini engine's own `detected` flag uses a
|
||||
# looser internal threshold (0.35) and weakly fires (~0.36-0.42) on unrelated
|
||||
# bottom-right text -- a real Doubao mark scores ~0.40-0.42 as a gemini match,
|
||||
# and its core-ring brightness margin is HIGHER than a genuine faint sparkle's,
|
||||
# so neither confidence nor the brightness gate separates them in the [0.35, 0.5)
|
||||
# band. Lowering this gate to recover faint sparkles was evaluated against that
|
||||
# band (2026-06-20) and REJECTED for the no-provenance case: it cannot be done
|
||||
# without re-admitting the Doubao-text / content false positives. The band below
|
||||
# the gate is therefore left to the metadata-confirmed path below.
|
||||
GEMINI_SPARKLE_TRUST_CONF = 0.5
|
||||
_GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Provenance-confirmed Gemini trust gate. When external metadata already proves the
|
||||
# image is a Google generation (C2PA issuer "Google"/"Gemini"), the [gate, 0.5) band
|
||||
# that the no-provenance gate leaves out is no longer ambiguous with Doubao text: a
|
||||
# Doubao image carries ByteDance provenance, not Google, so it never reaches this
|
||||
# relaxed gate. The vendor moving/re-rendering the sparkle (bigger, lighter, shifted
|
||||
# north-west) drops a real sparkle into this band, and the fixed-slot detector demotes
|
||||
# it -- provenance is exactly the extra evidence that lets us trust it.
|
||||
#
|
||||
# The gate was originally the engine's own `detected` floor (0.35). Raised to 0.42
|
||||
# on 2026-07-18 after measuring what this arm actually admits, because the Doubao
|
||||
# argument above -- while correct -- is not the binding constraint. Google C2PA is
|
||||
# carried by Imagen, API generations and NotebookLM exports, none of which stamp a
|
||||
# visible sparkle at all, so the relaxed gate spends most of its budget on images
|
||||
# that never had a mark rather than on moved ones.
|
||||
#
|
||||
# Measured blind on 954 unique Google-metadata uploads (detector never saw the
|
||||
# metadata), hand-labelled against a two-sided control (labeller sensitivity ~88%,
|
||||
# specificity 100%). "Additions" = accepted with provenance but not without:
|
||||
#
|
||||
# band precision 95% CI population
|
||||
# 0.35-0.42 13% 5-30% 120
|
||||
# 0.42-0.46 35% 19-54% 47
|
||||
# 0.46-0.50 27% 14-46% 44
|
||||
# 0.50-0.54 40% 20-64% 15
|
||||
#
|
||||
# Precision is flat above 0.42 and collapses below it, and that bottom band alone is
|
||||
# half the arm's volume -- so this is a step, not a gradient, and 0.42 is where it
|
||||
# sits. Raising the gate here drops ~16 genuine recoveries to prevent ~104 false
|
||||
# fills (6.5:1), cutting false fills from 18.7% to 7.8% of Google-metadata uploads.
|
||||
# A false fill is the worse error: it destroys pixels AND makes the caller report a
|
||||
# removal that did not happen, while a miss leaves the image untouched.
|
||||
#
|
||||
# NOTE: even at 0.42 this arm runs at ~33% precision (two false fills per genuine
|
||||
# recovery). Whether an arm that inaccurate should exist at all is a product call,
|
||||
# not a tuning one -- do not read this constant as "now correct".
|
||||
_GEMINI_PROVENANCE_MIN_CONF = 0.42
|
||||
|
||||
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
|
||||
|
||||
@@ -117,87 +363,182 @@ def _engine(key: str) -> Any:
|
||||
from remove_ai_watermarks.jimeng_engine import JimengEngine
|
||||
|
||||
_engines[key] = JimengEngine()
|
||||
elif key == "qwen":
|
||||
from remove_ai_watermarks.qwen_engine import QwenEngine
|
||||
|
||||
_engines[key] = QwenEngine()
|
||||
elif key == "kling":
|
||||
from remove_ai_watermarks.kling_engine import KlingEngine
|
||||
|
||||
_engines[key] = KlingEngine()
|
||||
elif key == "samsung":
|
||||
from remove_ai_watermarks.samsung_engine import SamsungEngine
|
||||
|
||||
_engines[key] = SamsungEngine()
|
||||
elif key == "jimeng_pill":
|
||||
from remove_ai_watermarks.pill_engine import PillEngine
|
||||
|
||||
_engines[key] = PillEngine()
|
||||
elif key == "runninghub":
|
||||
from remove_ai_watermarks.runninghub_engine import RunningHubEngine
|
||||
|
||||
_engines[key] = RunningHubEngine()
|
||||
elif key == "baidu":
|
||||
from remove_ai_watermarks.baidu_engine import BaiduEngine
|
||||
|
||||
_engines[key] = BaiduEngine()
|
||||
elif key == "liblib":
|
||||
from remove_ai_watermarks.liblib_engine import LibLibEngine
|
||||
|
||||
_engines[key] = LibLibEngine()
|
||||
else: # pragma: no cover - guarded by the registry keys
|
||||
raise KeyError(key)
|
||||
return _engines[key]
|
||||
|
||||
|
||||
def _gemini_detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine("gemini").detect_watermark(image)
|
||||
detected = bool(d.detected) and d.confidence >= _GEMINI_AUTO_MIN_CONF
|
||||
def inpaint_model_available() -> bool:
|
||||
"""True when any ONNX inpaint-model backend (MI-GAN or big-LaMa) can run."""
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
return region_eraser.migan_available() or region_eraser.lama_available()
|
||||
|
||||
|
||||
_warned_cv2_fallback = False
|
||||
|
||||
|
||||
def preferred_inpaint_backend() -> Literal["lama", "migan", "cv2"]:
|
||||
"""Backend the ``auto`` fill resolves to, best-first: LaMa > MI-GAN > cv2.
|
||||
|
||||
LaMa is the highest-quality inpaint (it recovers the textured/structured backgrounds
|
||||
the classical fill smears), so ``auto`` prefers it whenever a learned backend can run
|
||||
(onnxruntime present). MI-GAN is the lighter learned model; both currently share the
|
||||
SAME onnxruntime availability check, so ``auto`` cannot tell them apart and always
|
||||
prefers the better one -- a memory-tight deployment that cannot afford LaMa's ~4.7 GB
|
||||
peak pins MI-GAN explicitly via ``--backend migan`` / ``backend="migan"`` (that is the
|
||||
deployment's call, not the library's). cv2 is the classical no-deps floor and the last
|
||||
resort: it smears textured/structured backgrounds, so a one-time quality warning fires
|
||||
when ``auto`` falls back to it."""
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
if region_eraser.lama_available():
|
||||
return "lama"
|
||||
if region_eraser.migan_available():
|
||||
return "migan"
|
||||
global _warned_cv2_fallback
|
||||
if not _warned_cv2_fallback:
|
||||
_warned_cv2_fallback = True
|
||||
logger.warning(
|
||||
"No learned-inpaint backend available (onnxruntime not installed); falling back "
|
||||
"to the cv2 classical inpaint, which can smear textured or structured backgrounds. "
|
||||
"Install the 'lama' (best) or 'migan' (lighter) extra for higher-quality fills."
|
||||
)
|
||||
return "cv2"
|
||||
|
||||
|
||||
def resolve_backend(backend: Backend) -> Literal["cv2", "migan", "lama"]:
|
||||
"""Resolve ``auto`` to the preferred installed backend; pass the rest through."""
|
||||
if backend == "auto":
|
||||
return preferred_inpaint_backend()
|
||||
return backend
|
||||
|
||||
|
||||
def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto") -> NDArray[Any]:
|
||||
"""The ONE shared, mark-agnostic removal: erase ``mask`` (255 = remove) via the
|
||||
chosen inpaint backend. Delegates to :func:`region_eraser.erase`; ``auto``
|
||||
resolves to MI-GAN when installed else cv2 (see :func:`resolve_backend`)."""
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
return region_eraser.erase(image, mask=mask, backend=resolve_backend(backend))
|
||||
|
||||
|
||||
# ── Detection adapters (verdict + bbox; no mask work on this path) ──
|
||||
# The identify scan calls `detect_marks`, which must stay cheap (it runs every
|
||||
# detector on the memory-tight identify host), so detection never builds a mask.
|
||||
|
||||
|
||||
def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
|
||||
d = _engine("gemini").detect_watermark(image, trust_provenance=provenance)
|
||||
gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF
|
||||
detected = bool(d.detected) and d.confidence >= gate
|
||||
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _gemini_remove(
|
||||
image: NDArray[Any], inpaint_method: InpaintMethod, inpaint: bool, strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
engine = _engine("gemini")
|
||||
det = engine.detect_watermark(image)
|
||||
if not det.detected:
|
||||
if not force:
|
||||
return image.copy(), None
|
||||
# Forced (--no-detect): remove at the default sparkle slot for the size.
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
h, w = image.shape[:2]
|
||||
cfg = get_watermark_config(w, h)
|
||||
px, py = cfg.get_position(w, h)
|
||||
region = (px, py, cfg.logo_size, cfg.logo_size)
|
||||
result = engine.remove_watermark_custom(image, region)
|
||||
if inpaint:
|
||||
result = engine.inpaint_residual(result, region, strength=strength, method=inpaint_method)
|
||||
return result, region
|
||||
result = engine.remove_watermark(image)
|
||||
# Reverse-alpha leaves a faint residual at the sparkle edge; the engine's
|
||||
# own residual inpaint cleans that seam (part of its reverse-alpha pipeline).
|
||||
if inpaint:
|
||||
result = engine.inpaint_residual(result, det.region, strength=strength, method=inpaint_method)
|
||||
return result, det.region
|
||||
def _gemini_mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# Reuse the decision's provenance-aware region (skip the strict re-detect that would
|
||||
# otherwise re-demote a relaxed sparkle to None); None region -> footprint_mask
|
||||
# falls back to its own detect-then-force path (direct/--no-detect callers).
|
||||
region = detection.region if (detection is not None and detection.detected) else None
|
||||
return _engine("gemini").footprint_mask(image, force=force, region=region)
|
||||
|
||||
|
||||
# The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine
|
||||
# interface, so one parameterized adapter pair drives all of them -- a new
|
||||
# reverse-alpha text mark is one `_text_mark(...)` row below, not another copy-paste
|
||||
# of these bodies. Removal is reverse-alpha only: applied when the mark is detected
|
||||
# (or forced) and the alpha asset loads, otherwise skipped (no hallucination on a
|
||||
# clean corner).
|
||||
def _text_mark_detect(key: str, label: str, location: str) -> Callable[[NDArray[Any]], MarkDetection]:
|
||||
def detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine(key).detect(image)
|
||||
# text mark is one `_text_mark(...)` row below, not another copy-paste of these
|
||||
# bodies. Detection matches the glyph silhouette; the mask is the template-free
|
||||
# glyph-bbox footprint (see TextMarkEngine.footprint_mask).
|
||||
def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., MarkDetection]:
|
||||
def detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
|
||||
d = _engine(key).detect(image, provenance=provenance)
|
||||
return MarkDetection(key, label, location, d.detected, d.confidence, d.region)
|
||||
|
||||
return detect
|
||||
|
||||
|
||||
def _text_mark_remove(key: str) -> Callable[..., tuple[NDArray[Any], Region | None]]:
|
||||
def remove(
|
||||
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
engine = _engine(key)
|
||||
det = engine.detect(image)
|
||||
if (det.detected or force) and engine.reverse_alpha_available(image):
|
||||
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
|
||||
return image.copy(), None
|
||||
def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]:
|
||||
def mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so
|
||||
# the detection is not needed here; accepted for the uniform _mask signature.
|
||||
del detection
|
||||
return _engine(key).footprint_mask(image, force=force)
|
||||
|
||||
return remove
|
||||
return mask
|
||||
|
||||
|
||||
def _text_mark(key: str, label: str, location: str) -> KnownMark:
|
||||
"""A reverse-alpha text-mark registry row (Doubao/Jimeng/Samsung)."""
|
||||
return KnownMark(
|
||||
key, label, location, True, "reverse-alpha", _text_mark_detect(key, label, location), _text_mark_remove(key)
|
||||
)
|
||||
"""A text-mark registry row (Doubao/Jimeng/Samsung): glyph-silhouette detect +
|
||||
template-free glyph-bbox mask."""
|
||||
return KnownMark(key, label, location, True, _text_mark_detect(key, label, location), _text_mark_mask(key))
|
||||
|
||||
|
||||
# ── Capture-less mark: the Jimeng-basic "AI生成" pill (top-left) ──
|
||||
# Detection is edge-NCC of a synthetic silhouette; the mask is a fixed top-left
|
||||
# geometry box (see pill_engine). Removal is the same localize -> fill as the rest.
|
||||
def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection:
|
||||
del provenance # the pill detector is provenance-independent; its relaxation lives entirely in _keep_pill
|
||||
d = _engine("jimeng_pill").detect(image)
|
||||
return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _pill_mask(
|
||||
image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None
|
||||
) -> NDArray[Any] | None:
|
||||
# The pill mask is a fixed top-left geometry box, independent of the detection;
|
||||
# accepted for the uniform _mask signature.
|
||||
del detection
|
||||
return _engine("jimeng_pill").footprint_mask(image, force=force)
|
||||
|
||||
|
||||
def _pill_features(image: NDArray[Any]) -> dict[str, float]:
|
||||
"""The pill's own gate feature: top-left footprint flatness (1.0 = flat enough for
|
||||
an invisible fill), read by the metadata arm of :func:`_keep_pill`."""
|
||||
return {"footprint_flat": float(_engine("jimeng_pill").footprint_is_flat(image))}
|
||||
|
||||
|
||||
_REGISTRY: tuple[KnownMark, ...] = (
|
||||
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
|
||||
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, _gemini_detect, _gemini_mask),
|
||||
_text_mark("doubao", "Doubao 豆包AI生成 text", "bottom-right"),
|
||||
_text_mark("jimeng", "Jimeng 即梦AI wordmark", "bottom-right"),
|
||||
_text_mark("qwen", "Qwen 千问AI生成 text", "bottom-right"),
|
||||
_text_mark("kling", "Kling 可灵AI 3.0 text", "bottom-right"),
|
||||
_text_mark("samsung", "Samsung Galaxy AI text", "bottom-left"),
|
||||
_text_mark("runninghub", "RunningHub AI生成 text", "top-left"),
|
||||
_text_mark("baidu", "Baidu 百度 AI生成 text", "bottom-right"),
|
||||
_text_mark("liblib", "LibLibAI wordmark", "bottom-center"),
|
||||
KnownMark("jimeng_pill", "Jimeng AI生成 pill", "top-left", True, _pill_detect, _pill_mask, _pill_features),
|
||||
)
|
||||
|
||||
|
||||
@@ -219,17 +560,151 @@ def get_mark(key: str) -> KnownMark:
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> list[MarkDetection]:
|
||||
def detect_marks(
|
||||
image: NDArray[Any],
|
||||
*,
|
||||
include_explicit: bool = True,
|
||||
provenance: frozenset[str] = frozenset(),
|
||||
) -> list[MarkDetection]:
|
||||
"""Detect every known mark in its usual place.
|
||||
|
||||
Returns one MarkDetection per scanned mark (``detected`` flags which fired).
|
||||
``include_explicit=False`` scans only the ``in_auto`` marks -- the set used
|
||||
by ``--mark auto``.
|
||||
"""
|
||||
return [m.detect(image) for m in _REGISTRY if include_explicit or m.in_auto]
|
||||
by ``--mark auto``. ``provenance`` names the vendor keys that external metadata
|
||||
already confirms, so each named mark's detector may relax its trust gate."""
|
||||
return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto]
|
||||
|
||||
|
||||
def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
|
||||
"""The highest-confidence detected ``in_auto`` mark, or None if none fired."""
|
||||
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
|
||||
return max(fired, key=lambda d: d.confidence) if fired else None
|
||||
def resolve_trust(
|
||||
key: str,
|
||||
*,
|
||||
sensitivity: Sensitivity,
|
||||
provenance: frozenset[str],
|
||||
strict_keys: set[str],
|
||||
) -> Trust:
|
||||
"""The trust level mark ``key``'s detection gate is resolved to.
|
||||
|
||||
The single place that turns the ``sensitivity`` policy + evidence into a per-mark
|
||||
level (which the engines consume as ``provenance = level != "strict"``). ``strict``
|
||||
never relaxes. A mark is ``confirmed`` only on same-product evidence -- the vendor
|
||||
confirmed by metadata (``key in provenance``) or a confidently strict-detected
|
||||
sibling of the same product (``_PRODUCT_OF``, minus the marks too weak to vouch,
|
||||
:data:`_CANNOT_CORROBORATE`). Without that evidence a mark stays ``strict``: there is
|
||||
no path that relaxes a gate on anything less than same-product evidence."""
|
||||
if sensitivity == "strict":
|
||||
return "strict"
|
||||
product = _PRODUCT_OF[key]
|
||||
confirmed = key in provenance or any(
|
||||
_PRODUCT_OF[k] == product for k in strict_keys if k != key and k not in _CANNOT_CORROBORATE
|
||||
)
|
||||
return "confirmed" if confirmed else "strict"
|
||||
|
||||
|
||||
def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bool) -> bool:
|
||||
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
|
||||
|
||||
Pure decision (the flatness feature is precomputed at perception time and passed
|
||||
in). The pill detector is weak (~7% raw false-fire) and metadata/intent confirms
|
||||
the platform, not pill presence, so a naive confirmation-OR gate over-fires: on a
|
||||
32k real-upload corpus (2026-07) the metadata-only arm was only ~27% precise and
|
||||
its false fires were textured ceilings/walls that the fill visibly SMEARS. Arms:
|
||||
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
|
||||
metadata-STRIPPED uploads: remove the pill unrestricted;
|
||||
* TC260 metadata confirms Jimeng (``"jimeng" in provenance``, no wordmark) -> remove ONLY when the
|
||||
top-left footprint is flat enough for an invisible fill (``footprint_flat``),
|
||||
so real flat-scene pills (and harmless flat false fires) are cleaned while the
|
||||
damaging textured false fires are left untouched.
|
||||
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
|
||||
Doubao detection; a Qwen image likewise (another vendor's bottom-right mark naming
|
||||
its own product), so a confident Qwen detection suppresses the pill the same way.
|
||||
No confirmation at all -> never remove (blocks false fires on non-Jimeng content)."""
|
||||
if "doubao" in keys or "qwen" in keys or "kling" in keys or "runninghub" in keys or "baidu" in keys:
|
||||
return False
|
||||
if "jimeng" in keys:
|
||||
return True
|
||||
if "jimeng" in provenance:
|
||||
return footprint_flat
|
||||
return False
|
||||
|
||||
|
||||
def _build_candidates(image: NDArray[Any]) -> list[Candidate]:
|
||||
"""PERCEPTION pass: run every ``in_auto`` mark's detector at both trust levels and
|
||||
package the raw verdicts + physical features into :class:`Candidate` objects. No
|
||||
policy here -- the arbiter (:func:`decide`) makes every keep/drop call.
|
||||
|
||||
Each mark is detected at the strict AND the relaxed (``provenance=True``) level so
|
||||
:func:`decide` can pick per mark without re-running detection; a relaxed gate is
|
||||
monotonically more permissive, so this reproduces the old strict-then-relax pass
|
||||
exactly. The loop is uniform -- it knows nothing about any specific mark: each mark
|
||||
reports its own gate features via :meth:`KnownMark.features` (computed only when the
|
||||
mark is detected, so a clean image pays nothing extra)."""
|
||||
cands: list[Candidate] = []
|
||||
for m in _REGISTRY:
|
||||
if not m.in_auto:
|
||||
continue
|
||||
strict = m.detect(image, provenance=False)
|
||||
relaxed = m.detect(image, provenance=True)
|
||||
feats = m.features(image) if (strict.detected or relaxed.detected) else {}
|
||||
cands.append(Candidate(m.key, m.label, strict.detected, relaxed.detected, feats))
|
||||
return cands
|
||||
|
||||
|
||||
def decide(candidates: list[Candidate], context: Context) -> list[Decision]:
|
||||
"""The removal ARBITER: a pure function turning perception + context into the
|
||||
ordered list of marks to remove (and the trust level each was accepted at).
|
||||
|
||||
All policy lives here, in one place: per-mark trust resolution (:func:`resolve_trust`,
|
||||
which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and
|
||||
the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and
|
||||
the same decision drives every caller."""
|
||||
strict_keys = {c.key for c in candidates if c.detected_strict}
|
||||
fired: list[Decision] = []
|
||||
for c in candidates:
|
||||
trust = resolve_trust(
|
||||
c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys
|
||||
)
|
||||
relax = trust != "strict"
|
||||
ok = c.detected_relaxed if relax else c.detected_strict
|
||||
if ok:
|
||||
fired.append(Decision(c, relax))
|
||||
keys = {d.candidate.key for d in fired}
|
||||
if "jimeng_pill" in keys:
|
||||
pill = next(d for d in fired if d.candidate.key == "jimeng_pill")
|
||||
flat = bool(pill.candidate.features.get("footprint_flat", 0.0))
|
||||
if not _keep_pill(keys, provenance=context.provenance, footprint_flat=flat):
|
||||
fired = [d for d in fired if d.candidate.key != "jimeng_pill"]
|
||||
return fired
|
||||
|
||||
|
||||
def remove_auto_marks(
|
||||
image: NDArray[Any],
|
||||
*,
|
||||
sensitivity: Sensitivity = "auto",
|
||||
provenance: frozenset[str] = frozenset(),
|
||||
backend: Backend = "auto",
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove EVERY decided ``in_auto`` mark in one pass, chaining the result.
|
||||
|
||||
The three stages are separated: PERCEPTION (:func:`_build_candidates` -- engines
|
||||
report what they see, no policy), DECISION (:func:`decide` -- the pure arbiter over
|
||||
``(candidates, Context)``), ACTION (localize -> :func:`fill` per winner). Marks
|
||||
coexist in different corners -- a Jimeng-basic image carries BOTH the top-left pill
|
||||
AND the bottom-right wordmark -- so every decided mark is removed, chained on the
|
||||
progressively-cleaned image (order does not matter, each re-localizes its corner).
|
||||
|
||||
Three orthogonal knobs: ``sensitivity`` (how hard to trust a borderline mark --
|
||||
see :data:`Sensitivity`), ``provenance`` (vendor keys external metadata confirms,
|
||||
the evidence that drives ``auto``; the TC260 label maps to ``jimeng``/``doubao``),
|
||||
and ``backend`` (the shared fill). Returns ``(result, [labels removed])``; empty
|
||||
means nothing fired."""
|
||||
context = Context(sensitivity=sensitivity, provenance=provenance)
|
||||
result = image
|
||||
labels: list[str] = []
|
||||
for d in decide(_build_candidates(image), context):
|
||||
result, region = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False)
|
||||
# Only report the mark as removed when a fill actually happened: remove() returns
|
||||
# a None region when the localized mask came back empty, and reporting it anyway
|
||||
# would claim a removal that left the pixels unchanged.
|
||||
if region is not None:
|
||||
labels.append(d.candidate.label)
|
||||
return result, labels
|
||||
@@ -0,0 +1,146 @@
|
||||
"""High-level convenience API (remove_visible / visible_provenance) and the lazy
|
||||
top-level re-exports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import remove_ai_watermarks as raiw
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
SAMPLES = Path(__file__).resolve().parents[1] / "data" / "samples"
|
||||
DOUBAO = SAMPLES / "doubao-1.png"
|
||||
CHATGPT = SAMPLES / "chatgpt-1.png"
|
||||
|
||||
|
||||
class TestTopLevelExports:
|
||||
def test_lazy_reexports_resolve(self):
|
||||
assert raiw.remove_visible is api.remove_visible
|
||||
assert raiw.visible_provenance is api.visible_provenance
|
||||
|
||||
def test_unknown_attribute_raises(self):
|
||||
with pytest.raises(AttributeError):
|
||||
_ = raiw.does_not_exist
|
||||
|
||||
def test_bare_import_is_light(self):
|
||||
# importing the package must not pull the heavy cv2/torch stack (PEP 562 lazy).
|
||||
# Checked in a FRESH interpreter -- another test in this process may already
|
||||
# have imported cv2, so an in-process sys.modules check would be flaky.
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
code = "import remove_ai_watermarks, sys; print(int(any(m in sys.modules for m in ('cv2','torch'))))"
|
||||
out = subprocess.run( # noqa: S603 -- fixed sys.executable + literal code, no untrusted input
|
||||
[sys.executable, "-c", code], check=True, capture_output=True, text=True
|
||||
)
|
||||
assert out.stdout.strip() == "0", f"bare import pulled a heavy module: {out.stdout!r}"
|
||||
|
||||
|
||||
class TestRemoveVisibleArray:
|
||||
def test_array_no_mark_is_noop_copy(self):
|
||||
arr = np.zeros((256, 256, 3), np.uint8)
|
||||
result, removed = raiw.remove_visible(arr, backend="cv2")
|
||||
assert removed == []
|
||||
assert result.shape == arr.shape
|
||||
assert np.array_equal(result, arr)
|
||||
|
||||
def test_array_accepts_knobs(self):
|
||||
arr = np.zeros((256, 256, 3), np.uint8)
|
||||
result, removed = raiw.remove_visible(arr, sensitivity="strict", backend="cv2")
|
||||
assert removed == []
|
||||
assert result.shape == arr.shape
|
||||
|
||||
def test_bad_source_raises(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="Could not read image"):
|
||||
raiw.remove_visible(tmp_path / "nope.png")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
||||
class TestRemoveVisiblePath:
|
||||
def test_path_removes_and_writes(self, tmp_path):
|
||||
out = tmp_path / "clean.png"
|
||||
result, removed = raiw.remove_visible(DOUBAO, out, backend="cv2")
|
||||
assert out.exists()
|
||||
assert any("Doubao" in lbl for lbl in removed)
|
||||
assert result.shape[2] == 3
|
||||
|
||||
def test_path_no_output_returns_without_writing(self, tmp_path):
|
||||
# output=None returns the array but writes nothing
|
||||
result, _ = raiw.remove_visible(DOUBAO, backend="cv2")
|
||||
assert result.ndim == 3
|
||||
|
||||
|
||||
class TestNoOpPreservesOriginal:
|
||||
def test_no_mark_copies_original_bytes(self, tmp_path):
|
||||
# A clean image (no mark) same-format-out must be copied VERBATIM, not
|
||||
# re-encoded -- so a no-op never degrades the original ("work with originals").
|
||||
import filecmp
|
||||
|
||||
from PIL import Image
|
||||
|
||||
src = tmp_path / "clean.jpg"
|
||||
Image.fromarray(np.full((40, 40, 3), 120, np.uint8), "RGB").save(src, quality=90)
|
||||
out = tmp_path / "clean_out.jpg"
|
||||
_, removed = raiw.remove_visible(str(src), str(out), sensitivity="strict", backend="cv2")
|
||||
assert removed == []
|
||||
assert filecmp.cmp(str(src), str(out), shallow=False) # byte-identical
|
||||
|
||||
|
||||
class TestVisibleProvenance:
|
||||
@pytest.mark.skipif(not DOUBAO.exists(), reason="doubao sample not present")
|
||||
def test_doubao_tc260_maps_to_bytedance(self):
|
||||
prov = raiw.visible_provenance(DOUBAO)
|
||||
# TC260 label -> ByteDance family (both doubao and jimeng)
|
||||
assert {"doubao", "jimeng"} <= prov
|
||||
|
||||
@pytest.mark.skipif(not CHATGPT.exists(), reason="chatgpt sample not present")
|
||||
def test_openai_image_has_no_visible_vendor(self):
|
||||
# OpenAI C2PA is not one of the visible-mark vendors -> empty provenance
|
||||
assert raiw.visible_provenance(CHATGPT) == frozenset()
|
||||
|
||||
def test_unreadable_path_is_empty(self, tmp_path):
|
||||
assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset()
|
||||
|
||||
def test_uses_report_signals_for_falsy_metadata_values(self, monkeypatch, tmp_path):
|
||||
"""An empty TC260 object and Samsung genAIType=0 are still present signals.
|
||||
|
||||
The report has already normalized those values, so the public API must not
|
||||
re-read the file and accidentally discard them by truthiness.
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from remove_ai_watermarks import identify
|
||||
|
||||
report = SimpleNamespace(
|
||||
platform=None,
|
||||
signals=[SimpleNamespace(name="aigc"), SimpleNamespace(name="samsung_genai")],
|
||||
)
|
||||
monkeypatch.setattr(identify, "identify", lambda *args, **kwargs: report)
|
||||
|
||||
assert raiw.visible_provenance(tmp_path / "synthetic.png") == frozenset({"doubao", "jimeng", "samsung"})
|
||||
|
||||
|
||||
class TestRemoveVisibleOutputPath:
|
||||
"""Output-path robustness: in-place clean (#3) and a missing output dir (#4)."""
|
||||
|
||||
def _write_clean(self, p: Path) -> None:
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8))
|
||||
|
||||
def test_inplace_clean_no_crash(self, tmp_path: Path):
|
||||
p = tmp_path / "clean.png"
|
||||
self._write_clean(p)
|
||||
_, removed = raiw.remove_visible(str(p), str(p), backend="cv2")
|
||||
assert removed == []
|
||||
assert p.exists()
|
||||
|
||||
def test_creates_missing_output_dir(self, tmp_path: Path):
|
||||
src = tmp_path / "in.png"
|
||||
self._write_clean(src)
|
||||
out = tmp_path / "sub" / "out.png"
|
||||
raiw.remove_visible(str(src), str(out), backend="cv2")
|
||||
assert out.exists()
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for the Baidu ("百度 AI生成") visible-watermark engine.
|
||||
|
||||
Every tuned constant in ``baidu_engine`` was measured on the 16-frame vendor
|
||||
cohort (2026-07-22); these tests pin the load-bearing ones: detection keys on
|
||||
the 百度 text run ONLY (the text+pill template was a measured bright-blob
|
||||
magnet), the load-bearing Doubao rival margin, the strict-only gate, and the
|
||||
corner-extended footprint (the tag's flat white interior gives no top-hat
|
||||
response, so a blob-bbox mask leaves the tag as a ghost).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.baidu_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
BaiduEngine,
|
||||
_alpha_template,
|
||||
)
|
||||
|
||||
_TEXT_FRAC = 0.090 # measured 百度 text-run width, fraction of the short side
|
||||
_TEXT_RIGHT = 0.099 # measured right margin of the text run (the tag is right of it)
|
||||
_TAG_FRAC = 0.075 # the white tag's width, approx (text-right to corner)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the 百度 text run + a solid white tag at the measured layout."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
short = min(w, h)
|
||||
gw = int(_TEXT_FRAC * short)
|
||||
gh = max(4, int(_TEXT_FRAC * (_ALPHA_HEIGHT_FRAC / _ALPHA_WIDTH_FRAC) * short))
|
||||
margin_b = int(0.006 * short)
|
||||
ax = w - int(_TEXT_RIGHT * short) - gw
|
||||
ay = h - margin_b - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
# the white rounded tag between the text and the corner
|
||||
tx0 = w - int(0.015 * short) - int(_TAG_FRAC * short)
|
||||
amap[ay - gh // 8 : ay + gh + gh // 8, tx0 : w - int(0.015 * short)] = 1.0
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, (ax, ay, gw, gh, tx0)
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert 2048 - (loc.x + loc.w) < 40
|
||||
assert 2048 - (loc.y + loc.h) < 40
|
||||
|
||||
def test_box_scales_with_short_side(self):
|
||||
eng = BaiduEngine()
|
||||
landscape = eng.locate(np.zeros((640, 1280, 3), np.uint8))
|
||||
wider = eng.locate(np.zeros((640, 2560, 3), np.uint8))
|
||||
assert wider.w == landscape.w
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_tophat_frontend(self):
|
||||
assert BaiduEngine().config.detect_frontend == "tophat"
|
||||
|
||||
def test_doubao_rival_margin(self):
|
||||
# 百度 vs 豆包 share a glyph and a corner: the candidate fires on 45.8% of
|
||||
# Doubao-marked frames at the gate, and the 0.10 margin suppresses ALL of
|
||||
# it at zero genuine-detection cost (crossfire, 2026-07-22).
|
||||
assert "doubao_alpha.png" in BaiduEngine().config.rivals
|
||||
|
||||
def test_strict_only_no_provenance_relaxation(self):
|
||||
assert BaiduEngine().config.provenance_ncc_factor == 1.0
|
||||
|
||||
def test_gate_above_clean_arm_max(self):
|
||||
# Clean arm scored p99 0.314 / max 0.352 on 278 hand-labelled frames;
|
||||
# the 741-frame eval set surfaced cross-fires up to 0.426 (a 抖音
|
||||
# AI创作 mark no rival can suppress), and the full-corpus sweep put the
|
||||
# outside-cohort false arm at 0.47 max vs true carriers at 0.50-0.66,
|
||||
# so the gate sits at 0.48.
|
||||
assert BaiduEngine().config.detect_ncc_threshold >= 0.48
|
||||
|
||||
def test_qwen_is_a_rival(self):
|
||||
# 百度 vs 千问 are near-identical after binarization: 12 of 14 full-corpus
|
||||
# cross-fires at the 0.37 gate were Qwen marks (Qwen's template beats
|
||||
# Baidu's there by 0.17-0.35, so the margin suppresses them).
|
||||
assert "qwen_alpha.png" in BaiduEngine().config.rivals
|
||||
|
||||
def test_registry_row(self):
|
||||
mark = registry.get_mark("baidu")
|
||||
assert mark.location == "bottom-right"
|
||||
assert mark.in_auto
|
||||
|
||||
|
||||
class TestDetectAndMask:
|
||||
def test_detects_composed_mark(self):
|
||||
eng = BaiduEngine()
|
||||
wm, _ = _compose(1024, 1024)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected, f"composed mark missed (conf={det.confidence:.3f})"
|
||||
|
||||
def test_clean_frame_stays_quiet(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.full((1024, 1024, 3), 100, np.uint8)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_mask_extends_to_the_corner_tag(self):
|
||||
"""Regression (2026-07-22): the tag's flat white interior gives no top-hat
|
||||
response, so a blob-bbox mask ended at the text run and the fill left the
|
||||
tag as a ghost. The footprint must extend right to the corner."""
|
||||
eng = BaiduEngine()
|
||||
wm, (ax, _ay, gw, _gh, tx0) = _compose(1024, 1024)
|
||||
mask = eng.footprint_mask(wm)
|
||||
assert mask is not None
|
||||
_ys, xs = np.where(mask > 0)
|
||||
assert xs.min() <= ax + int(0.1 * gw) # covers the text run's left edge
|
||||
assert xs.max() >= tx0 + 10 # covers the white tag right of the text
|
||||
|
||||
def test_no_mask_on_clean_frame(self):
|
||||
eng = BaiduEngine()
|
||||
img = np.full((1024, 1024, 3), 100, np.uint8)
|
||||
assert eng.footprint_mask(img) is None
|
||||
|
||||
|
||||
class TestPillInteraction:
|
||||
def test_confident_baidu_detection_suppresses_the_jimeng_pill(self):
|
||||
# A Baidu image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/
|
||||
# Kling, a confident Baidu detection must veto the pill (``_keep_pill``).
|
||||
from remove_ai_watermarks.watermark_registry import _keep_pill
|
||||
|
||||
assert not _keep_pill({"baidu"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0)
|
||||
@@ -125,6 +125,37 @@ class TestVisibleCommand:
|
||||
assert "visible AI watermark" in result.output
|
||||
assert "--mark" in result.output
|
||||
|
||||
def test_visible_auto_no_mark_exits_two_with_eraser_hint(self, runner, sample_png, tmp_path):
|
||||
# No known visible mark and no AI provenance signal: the command must not
|
||||
# re-serve the input as a finished result. It exits EXIT_NO_VISIBLE_MARK
|
||||
# (2) -- distinct from success (0) and a hard error (1) -- writes no
|
||||
# output file, and points the user at the region eraser.
|
||||
output = tmp_path / "clean.png"
|
||||
result = runner.invoke(main, ["visible", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert not output.exists()
|
||||
assert "erase" in result.output
|
||||
# The "no signal" branch must NOT imply the image is clean: a missing
|
||||
# metadata proxy is not proof an invisible pixel watermark (SynthID) is
|
||||
# absent, so the message preserves that uncertainty and routes to 'all'.
|
||||
assert "SynthID" in result.output
|
||||
assert "all" in result.output
|
||||
|
||||
def test_visible_auto_no_mark_routes_to_all_when_metadata(self, runner, tmp_path):
|
||||
# An image whose only signal is an invisible/metadata watermark (here SD
|
||||
# generation parameters) has no visible mark to remove; the command must
|
||||
# exit 2 and upsell the full 'all' pipeline rather than the eraser.
|
||||
img = Image.fromarray(np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8))
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler, a test landscape")
|
||||
src = tmp_path / "ai.png"
|
||||
img.save(src, pnginfo=pnginfo)
|
||||
output = tmp_path / "clean.png"
|
||||
result = runner.invoke(main, ["visible", str(src), "-o", str(output)])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert not output.exists()
|
||||
assert "all" in result.output
|
||||
|
||||
def test_visible_basic(self, runner, sample_png, tmp_path):
|
||||
output = tmp_path / "clean.png"
|
||||
result = runner.invoke(
|
||||
@@ -141,7 +172,9 @@ class TestVisibleCommand:
|
||||
expected = sample_png.with_stem(sample_png.stem + "_clean")
|
||||
assert expected.exists()
|
||||
|
||||
def test_visible_no_inpaint(self, runner, sample_png, tmp_path):
|
||||
def test_visible_backend_cv2(self, runner, sample_png, tmp_path):
|
||||
# The old --inpaint/--no-inpaint flags are gone; the fill backend is picked
|
||||
# with --backend (localize -> fill). cv2 needs no ONNX model.
|
||||
output = tmp_path / "clean.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
@@ -150,7 +183,8 @@ class TestVisibleCommand:
|
||||
str(sample_png),
|
||||
"-o",
|
||||
str(output),
|
||||
"--no-inpaint",
|
||||
"--backend",
|
||||
"cv2",
|
||||
"--no-detect",
|
||||
],
|
||||
)
|
||||
@@ -259,7 +293,7 @@ class TestInvisibleCommand:
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["invisible", str(sample_png), "-o", str(output)],
|
||||
["invisible", str(sample_png), "-o", str(output), "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
@@ -272,7 +306,7 @@ class TestInvisibleCommand:
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png)])
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
expected = sample_png.with_stem(sample_png.stem + "_clean")
|
||||
assert expected.exists()
|
||||
@@ -284,7 +318,7 @@ class TestInvisibleCommand:
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png)])
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
# adaptive_polish is ON by default (self-gating, so a no-op where not needed).
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
|
||||
@@ -299,7 +333,7 @@ class TestInvisibleCommand:
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--no-adaptive-polish"])
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--no-adaptive-polish", "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
|
||||
|
||||
@@ -312,7 +346,7 @@ class TestInvisibleCommand:
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["invisible", str(sample_png), "--model", "org/custom-sdxl", "--guidance-scale", "5.5"],
|
||||
["invisible", str(sample_png), "--model", "org/custom-sdxl", "--guidance-scale", "5.5", "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_cls.call_args.kwargs["model_id"] == "org/custom-sdxl"
|
||||
@@ -325,7 +359,7 @@ class TestInvisibleCommand:
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "default"])
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "default", "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
# The legacy value warns and is normalized to "sdxl" before the engine is built.
|
||||
assert "deprecated" in result.output.lower()
|
||||
@@ -338,7 +372,7 @@ class TestInvisibleCommand:
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "sdxl"])
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "--pipeline", "sdxl", "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "deprecated" not in result.output.lower()
|
||||
assert mock_cls.call_args.kwargs["pipeline"] == "sdxl"
|
||||
@@ -347,6 +381,57 @@ class TestInvisibleCommand:
|
||||
result = runner.invoke(main, ["invisible", "/nonexistent/file.png"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_invisible_no_signal_skips_and_exits_two(self, runner, sample_png, tmp_path):
|
||||
"""P0#5: when no invisible AI watermark is locally detectable, the diffusion
|
||||
scrub must NOT run (it would only degrade a clean image). Mirrors the visible
|
||||
no-mark contract: write no output, exit 2, and DO NOT imply the image is
|
||||
clean (a stripped SynthID proxy is not proof of absence)."""
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code == 2, result.output
|
||||
assert not output.exists()
|
||||
mock_engine.remove_watermark.assert_not_called()
|
||||
assert "--force" in result.output
|
||||
assert "SynthID" in result.output # the message must preserve removal uncertainty
|
||||
|
||||
def test_invisible_force_runs_scrub_on_no_signal(self, runner, sample_png, tmp_path):
|
||||
"""--force overrides the no-signal skip: the scrub runs regardless."""
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_engine.remove_watermark.assert_called_once()
|
||||
|
||||
def test_invisible_runs_without_force_when_signal_present(self, runner, tmp_path):
|
||||
"""An image carrying an AI metadata signal IS a scrub target, so the run
|
||||
proceeds with no --force needed."""
|
||||
img = Image.fromarray(np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8))
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler, a test landscape")
|
||||
src = tmp_path / "ai.png"
|
||||
img.save(src, pnginfo=pnginfo)
|
||||
output = tmp_path / "clean.png"
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(main, ["invisible", str(src), "-o", str(output)])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_engine.remove_watermark.assert_called_once()
|
||||
|
||||
|
||||
class TestAllCommand:
|
||||
"""Tests for the 'all' subcommand (full pipeline)."""
|
||||
@@ -362,10 +447,11 @@ class TestAllCommand:
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["all", str(sample_png), "-o", str(output)],
|
||||
["all", str(sample_png), "-o", str(output), "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
@@ -376,18 +462,56 @@ class TestAllCommand:
|
||||
|
||||
def test_all_visible_step_uses_registry(self, runner, sample_png, tmp_path):
|
||||
"""Regression (#1): the `all` visible step must route through the registry
|
||||
(best_auto_mark), so Doubao/Jimeng/Samsung text marks are handled -- not just
|
||||
the Gemini sparkle via a hardcoded GeminiEngine."""
|
||||
(remove_auto_marks), so Doubao/Jimeng/Samsung/pill marks are handled -- not
|
||||
just the Gemini sparkle via a hardcoded GeminiEngine."""
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
|
||||
def _fake_remove_auto(image, **kwargs):
|
||||
return image, []
|
||||
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch(
|
||||
"remove_ai_watermarks.watermark_registry.remove_auto_marks", side_effect=_fake_remove_auto
|
||||
) as mock_auto,
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output), "--force"])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_auto.assert_called() # the registry auto-detector drove the visible pass
|
||||
|
||||
def test_all_skips_invisible_on_no_signal_but_succeeds(self, runner, sample_png, tmp_path):
|
||||
"""P0#5: with no detectable invisible watermark and no --force, `all` skips
|
||||
the destructive step 2 (pixels left intact) but STILL succeeds (exit 0) --
|
||||
visible removal + metadata strip ran and a file is written. Distinct from the
|
||||
GPU-missing skip, which is a non-zero failure."""
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.watermark_registry.best_auto_mark", return_value=None) as mock_best,
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_best.assert_called() # the registry auto-detector drove the visible pass
|
||||
assert output.exists()
|
||||
mock_engine.remove_watermark.assert_not_called()
|
||||
assert "Skipped (no invisible" in result.output
|
||||
|
||||
def test_all_loud_warning_and_nonzero_exit_when_gpu_missing(self, runner, sample_png, tmp_path):
|
||||
"""Regression (#14/#47): when the GPU extra is absent the invisible step is
|
||||
skipped, but the output still looks processed -- the run must fail loudly
|
||||
(prominent banner + non-zero exit) so a skipped SynthID pass is not mistaken
|
||||
for a clean result. The output file is still written (visible + metadata)."""
|
||||
output = tmp_path / "clean.png"
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "NOT removed" in result.output
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert output.exists() # visible + metadata still produced a file
|
||||
|
||||
def test_all_preserves_rgba_across_invisible_step(self, runner, tmp_path):
|
||||
"""Regression: ``all`` must keep transparency even when the invisible
|
||||
@@ -407,7 +531,7 @@ class TestAllCommand:
|
||||
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(src), "-o", str(output)])
|
||||
result = runner.invoke(main, ["all", str(src), "-o", str(output), "--force"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
@@ -449,6 +573,41 @@ class TestMetadataCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "stripped" in result.output
|
||||
|
||||
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
|
||||
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
|
||||
|
||||
That is correct (never crash a worker on a partial upload) but the command used
|
||||
to print "AI metadata stripped ->" and exit 0 for it, so a caller could not tell
|
||||
a real strip from a no-op and the output still read as AI. Found on real Samsung
|
||||
Galaxy S22 C2PA PNGs during the corpus parity audit, 2026-07-19.
|
||||
"""
|
||||
# PNG signature + a C2PA (caBX) chunk, then garbage: the byte scanner sees the
|
||||
# marker, PIL cannot decode it.
|
||||
src = tmp_path / "undecodable.png"
|
||||
payload = b"c2pa" + b"\x00" * 32
|
||||
chunk = len(payload).to_bytes(4, "big") + b"caBX" + payload + b"\x00\x00\x00\x00"
|
||||
src.write_bytes(b"\x89PNG\r\n\x1a\n" + chunk + b"NOTAPNG" * 8)
|
||||
out = tmp_path / "cleaned.png"
|
||||
|
||||
result = runner.invoke(main, ["metadata", str(src), "--remove", "-o", str(out)])
|
||||
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
|
||||
if not get_ai_metadata(src):
|
||||
pytest.skip("fixture does not register as an AI-metadata carrier")
|
||||
assert result.exit_code != 0, "a no-op strip must not report success"
|
||||
assert "stripped ->" not in result.output
|
||||
|
||||
def test_metadata_remove_in_place(self, runner, tmp_png_with_ai_metadata):
|
||||
"""With ``-o`` omitted, the strip overwrites the source in place (default
|
||||
output_path=None). Previously every test passed an explicit ``-o``."""
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata
|
||||
|
||||
assert has_ai_metadata(tmp_png_with_ai_metadata) # precondition
|
||||
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--remove"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not has_ai_metadata(tmp_png_with_ai_metadata) # source overwritten, AI metadata gone
|
||||
|
||||
|
||||
class TestIdentifyCommand:
|
||||
"""Tests for the 'identify' subcommand."""
|
||||
@@ -476,6 +635,18 @@ class TestIdentifyCommand:
|
||||
assert "AI-generated" in result.output
|
||||
assert "Stable Diffusion" in result.output
|
||||
|
||||
def test_identify_reports_generated_source_kind(self, runner):
|
||||
"""The C2PA trainedAlgorithmicMedia source type sharpens the verdict to
|
||||
'AI-generated (fully synthetic)' at the CLI (the ai_source_kind branch)."""
|
||||
from pathlib import Path
|
||||
|
||||
sample = Path(__file__).resolve().parent.parent / "data" / "samples" / "chatgpt-1.png"
|
||||
if not sample.exists():
|
||||
pytest.skip("chatgpt sample not present")
|
||||
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI-generated (fully synthetic)" in result.output
|
||||
|
||||
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
|
||||
assert result.exit_code == 0
|
||||
@@ -534,6 +705,26 @@ class TestBatchCommand:
|
||||
input_dir = _make_batch_dir(tmp_path)
|
||||
output_dir = tmp_path / "output"
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "3 processed" in result.output
|
||||
|
||||
def test_batch_invisible_skips_no_signal_and_copies_through(self, runner, tmp_path):
|
||||
"""P0#5: batch invisible mode skips the scrub on signal-less images (no
|
||||
--force) and copies the input through, so the output dir is complete with the
|
||||
pixels left intact and the engine never called."""
|
||||
input_dir = _make_batch_dir(tmp_path)
|
||||
output_dir = tmp_path / "output"
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
@@ -546,6 +737,8 @@ class TestBatchCommand:
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "3 processed" in result.output
|
||||
assert len(list(output_dir.glob("*.png"))) == 3 # inputs copied through
|
||||
mock_engine.remove_watermark.assert_not_called()
|
||||
|
||||
def test_batch_all_mode(self, runner, tmp_path):
|
||||
input_dir = _make_batch_dir(tmp_path)
|
||||
@@ -559,7 +752,7 @@ class TestBatchCommand:
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all"],
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all", "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "3 processed" in result.output
|
||||
@@ -585,7 +778,7 @@ class TestBatchCommand:
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all"],
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all", "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
@@ -609,7 +802,7 @@ class TestBatchCommand:
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--auto"],
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--auto", "--force"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "2 processed" in result.output
|
||||
@@ -628,6 +821,34 @@ class TestBatchCommand:
|
||||
expected_dir = tmp_path / "input_clean"
|
||||
assert expected_dir.exists()
|
||||
|
||||
def test_batch_errors_exit_nonzero(self, runner, tmp_path):
|
||||
"""Regression: batch used to always exit 0 even when every image errored,
|
||||
hiding failure from a wrapping service. A corrupt image must yield a non-zero
|
||||
exit and an error count."""
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
(input_dir / "corrupt.png").write_bytes(b"this is not a PNG at all" * 50)
|
||||
result = runner.invoke(main, ["batch", str(input_dir), "--mode", "visible"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "error" in result.output.lower()
|
||||
|
||||
def test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero(self, runner, tmp_path):
|
||||
"""Regression: batch --mode invisible with a signal-bearing image but no GPU
|
||||
deps used to write NO output for that image and still exit 0, silently dropping
|
||||
the files that most needed processing. It must now copy the input through (so the
|
||||
output dir is complete), warn about the retained SynthID watermark, and exit
|
||||
non-zero -- mirroring the single ``all`` command."""
|
||||
input_dir = _make_batch_dir_with_metadata(tmp_path, count=3) # SD params = invisible signal
|
||||
output_dir = tmp_path / "output"
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible"],
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "NOT removed" in result.output
|
||||
assert len(list(output_dir.glob("*.png"))) == 3 # every input copied through, none dropped
|
||||
|
||||
|
||||
class TestGpuHintMarkup:
|
||||
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
|
||||
@@ -720,3 +941,54 @@ class TestEraseCommand:
|
||||
assert result.exit_code != 0
|
||||
assert "onnxruntime" in result.output.lower()
|
||||
assert not output.exists()
|
||||
|
||||
|
||||
def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypatch):
|
||||
# A backend whose extra is missing raises RuntimeError in region_eraser.erase;
|
||||
# the visible --mark auto path must surface it cleanly, not as a raw traceback (#9).
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks import region_eraser
|
||||
|
||||
doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png"
|
||||
if not doubao.exists():
|
||||
pytest.skip("doubao sample not present")
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise RuntimeError("MI-GAN backend requires onnxruntime. Install the extra: ...")
|
||||
|
||||
monkeypatch.setattr(region_eraser, "erase", boom)
|
||||
out = tmp_path / "out.png"
|
||||
result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"])
|
||||
assert result.exit_code == 1
|
||||
assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content"),
|
||||
[
|
||||
("empty.png", b""),
|
||||
("notimage.jpg", b"plain text, not an image at all " * 20),
|
||||
("truncated.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 40),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
|
||||
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
|
||||
"""Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2%
|
||||
truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata
|
||||
--remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0),
|
||||
a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to
|
||||
remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz."""
|
||||
bad = tmp_path / name
|
||||
bad.write_bytes(content)
|
||||
out = tmp_path / "out.png"
|
||||
result = runner.invoke(main, [cmd[0], str(bad), "-o", str(out), *cmd[1:]])
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit), (
|
||||
f"leaked a raw traceback: {result.exception!r}"
|
||||
)
|
||||
if cmd[0] == "metadata":
|
||||
assert result.exit_code == 0, result.output # fail-safe copy-through
|
||||
assert out.read_bytes() == content # input passed through unchanged
|
||||
else:
|
||||
assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
|
||||
assert "Error" in result.output
|
||||
@@ -0,0 +1,114 @@
|
||||
"""A failed write and a directory input must REPORT, not raise a traceback.
|
||||
|
||||
Both found 2026-07-20 by the Tier E adversarial sweep (`scripts/robustness_suite.py`),
|
||||
which drives the real CLI over degenerate inputs. Neither was reachable from the 849-test
|
||||
suite, because unit tests feed well-formed fixtures into writable directories.
|
||||
|
||||
1. READ-ONLY OUTPUT DIRECTORY. `image_io.imwrite` is contractually non-raising -- it
|
||||
returns False when the codec rejects the image or the path cannot be written. But
|
||||
`write_bgr_with_alpha` discarded that bool and returned None, so no caller could tell a
|
||||
failed write from a successful one. `cmd_erase` then ran `output.stat()` on a file that
|
||||
was never created and died with `FileNotFoundError`. The signal existed the whole way
|
||||
down and was thrown away by the wrapper.
|
||||
|
||||
2. A DIRECTORY PASSED WHERE A FILE IS EXPECTED. `click.Path(exists=True)` accepts
|
||||
directories unless told otherwise, so `identify <dir>` reached the metadata scanner and
|
||||
raised `IsADirectoryError` from `open()`. (`batch` was already correct: it declares
|
||||
`file_okay=False`.)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks.cli import main
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bgr() -> np.ndarray:
|
||||
return np.full((60, 80, 3), 128, np.uint8)
|
||||
|
||||
|
||||
class TestFailedWriteIsReported:
|
||||
def test_write_bgr_with_alpha_reports_failure(self, tmp_path, bgr):
|
||||
"""The wrapper must PROPAGATE imwrite's failure signal, not swallow it.
|
||||
|
||||
Uses a nonexistent directory rather than `chmod`: CI runs windows-latest, where
|
||||
`os.chmod` cannot make a DIRECTORY unwritable, so a chmod-based assertion would be
|
||||
a platform-dependent flake. `write_bgr_with_alpha` does no mkdir of its own, so a
|
||||
missing parent is a genuine write failure on every platform.
|
||||
"""
|
||||
assert image_io.write_bgr_with_alpha(tmp_path / "no-such-dir" / "x.png", bgr, None) is False
|
||||
|
||||
def test_write_bgr_with_alpha_reports_success(self, tmp_path, bgr):
|
||||
"""The other direction, so the assertion above cannot pass by always being False."""
|
||||
assert image_io.write_bgr_with_alpha(tmp_path / "ok.png", bgr, None) is True
|
||||
|
||||
def test_erase_reports_a_failed_write_without_a_traceback(self, tmp_path, bgr, monkeypatch):
|
||||
"""The CLI must exit non-zero with a readable message, not raise FileNotFoundError.
|
||||
|
||||
The write is forced to fail by patching, not by `chmod`: the CLI mkdirs the parent,
|
||||
so a missing directory would not reproduce it, and chmod on a directory is a no-op
|
||||
on Windows. Patching states the condition under test directly -- "the write failed".
|
||||
"""
|
||||
src = tmp_path / "in.png"
|
||||
image_io.imwrite(src, bgr)
|
||||
monkeypatch.setattr(image_io, "write_bgr_with_alpha", lambda *a, **k: False)
|
||||
result = CliRunner().invoke(main, ["erase", str(src), "--region", "5,5,20,10", "-o", str(tmp_path / "x.png")])
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, FileNotFoundError), "write failure escaped as a traceback"
|
||||
assert "write" in result.output.lower() or "failed" in result.output.lower()
|
||||
|
||||
def test_batch_counts_a_failed_write_instead_of_exiting_zero(self, tmp_path, bgr, monkeypatch):
|
||||
"""The worst shape of this bug: no output files AND a success exit code.
|
||||
|
||||
Corpus-reproduced 2026-07-20 -- `batch --mode visible` into a read-only directory
|
||||
wrote ZERO files for 2 inputs and exited 0, so a wrapping service would treat an
|
||||
empty output directory as a completed run. The batch loop counts per-image
|
||||
exceptions, so the write must RAISE there, never `SystemExit` (which would abort
|
||||
the whole run instead of failing one image).
|
||||
"""
|
||||
indir = tmp_path / "in"
|
||||
indir.mkdir()
|
||||
for i in range(2):
|
||||
image_io.imwrite(indir / f"img{i}.png", bgr)
|
||||
monkeypatch.setattr(image_io, "write_bgr_with_alpha", lambda *a, **k: False)
|
||||
result = CliRunner().invoke(main, ["batch", str(indir), "-o", str(tmp_path / "out"), "--mode", "visible"])
|
||||
assert result.exit_code != 0, "a batch that wrote nothing must not exit 0"
|
||||
|
||||
|
||||
class TestApiReportsFailedWrite:
|
||||
"""The same bug lived one layer down, in the library API, with a misleading message.
|
||||
|
||||
`api._write_visible_result` also discarded the write flag, then ran the metadata strip
|
||||
on a file that was never created. The resulting `FileNotFoundError` surfaced through
|
||||
the CLI as `cannot read image <INPUT>: ... <OUTPUT path>` -- it blamed the input while
|
||||
quoting the output. A library caller got the same confusing error with no CLI at all.
|
||||
"""
|
||||
|
||||
def test_remove_visible_raises_a_clear_error_on_unwritable_output(self, tmp_path, bgr, monkeypatch):
|
||||
"""Patched rather than chmod'd: `_write_visible_result` mkdirs the parent, so a
|
||||
missing directory would not reproduce it, and chmod on a directory is a no-op on
|
||||
the Windows CI runner."""
|
||||
from remove_ai_watermarks.api import remove_visible
|
||||
|
||||
monkeypatch.setattr(image_io, "write_bgr_with_alpha", lambda *a, **k: False)
|
||||
with pytest.raises(OSError, match="failed to write output"):
|
||||
remove_visible(bgr, tmp_path / "out.png", strip_metadata=False)
|
||||
|
||||
|
||||
class TestDirectoryInputIsRejected:
|
||||
@pytest.mark.parametrize("cmd", ["identify", "visible", "erase", "metadata", "invisible", "all"])
|
||||
def test_directory_as_source_is_a_clean_usage_error(self, tmp_path, cmd):
|
||||
"""A directory must be refused by argument parsing, never reach the scanners."""
|
||||
args = [cmd, str(tmp_path)]
|
||||
if cmd == "erase":
|
||||
args += ["--region", "1,1,5,5"]
|
||||
if cmd == "metadata":
|
||||
args += ["--check"]
|
||||
result = CliRunner().invoke(main, args)
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, IsADirectoryError), "directory reached the file reader"
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the Doubao visible-watermark engine (reverse-alpha only)."""
|
||||
"""Tests for the Doubao visible-watermark engine (localize -> fill)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,11 +8,9 @@ import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as registry
|
||||
from remove_ai_watermarks.doubao_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_LOGO_BGR,
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
_ALPHA_MARGIN_RIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
@@ -26,6 +24,22 @@ from remove_ai_watermarks.doubao_engine import (
|
||||
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
|
||||
Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
margin = int(0.015 * w)
|
||||
ax = w - margin - gw
|
||||
ay = h - margin - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * 255.0 + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = DoubaoEngine()
|
||||
@@ -76,6 +90,16 @@ class TestDetect:
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_small_image_guarded_from_false_positive(self):
|
||||
"""Below the minimum short side a tiny geometric shape spuriously NCC-matches
|
||||
the CJK silhouette (2026-06-26 FP: a 48x48 app-icon chevron scored 0.41). The
|
||||
size guard suppresses detection there. Bracket it: a real mark is detected at
|
||||
native size, but the same content downscaled below the guard is not."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
eng = DoubaoEngine()
|
||||
assert eng.detect(wm).detected # native: real mark detected
|
||||
assert not eng.detect(cv2.resize(wm, (150, 150))).detected # below guard: suppressed
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
|
||||
class TestRealSample:
|
||||
@@ -84,25 +108,25 @@ class TestRealSample:
|
||||
assert det.detected
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_reverse_alpha_removes_mark(self):
|
||||
eng = DoubaoEngine()
|
||||
def test_fill_lowers_confidence(self):
|
||||
img = load_image_bgr(SAMPLE)
|
||||
assert eng.reverse_alpha_available(img) # sample is at the captured width
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
assert not eng.detect(out).detected # mark gone after recovery
|
||||
before = DoubaoEngine().detect(img)
|
||||
assert before.detected
|
||||
out, region = registry.get_mark("doubao").remove(img, backend="cv2")
|
||||
assert region is not None
|
||||
assert DoubaoEngine().detect(out).confidence < before.confidence
|
||||
|
||||
def test_far_region_untouched(self):
|
||||
eng = DoubaoEngine()
|
||||
img = load_image_bgr(SAMPLE)
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
out, _ = registry.get_mark("doubao").remove(img, backend="cv2")
|
||||
h, w = img.shape[:2]
|
||||
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
|
||||
|
||||
|
||||
# ── Reverse-alpha (exact recovery) ──────────────────────────────────
|
||||
# ── Alpha asset + footprint mask + localize -> fill removal ─────────
|
||||
|
||||
|
||||
class TestReverseAlpha:
|
||||
class TestAlphaAsset:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
@@ -110,104 +134,90 @@ class TestReverseAlpha:
|
||||
assert float(at.min()) >= 0.0
|
||||
assert float(at.max()) <= 1.0
|
||||
|
||||
def test_available_whenever_asset_present(self):
|
||||
# NCC alignment generalizes to any resolution, so availability is just
|
||||
# "asset loadable" (any non-empty image); the caller gates on detect.
|
||||
eng = DoubaoEngine()
|
||||
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
|
||||
assert eng.reverse_alpha_available(np.zeros((1773, 1535, 3), np.uint8))
|
||||
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
|
||||
|
||||
@staticmethod
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
|
||||
Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
class TestFootprintMaskAndRemoval:
|
||||
def test_footprint_mask_in_bottom_right(self):
|
||||
"""A composed mark yields a footprint mask localized to the bottom-right."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
mask = DoubaoEngine().footprint_mask(wm)
|
||||
assert mask is not None
|
||||
assert mask.shape == wm.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
assert ys.mean() > wm.shape[0] / 2
|
||||
assert xs.mean() > wm.shape[1] / 2
|
||||
|
||||
def test_removes_synthetic_mark(self):
|
||||
"""Reverse-alpha + thin residual inpaint clears a mark composed from the
|
||||
real alpha (re-detect no longer fires)."""
|
||||
eng = DoubaoEngine()
|
||||
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert eng.detect(wm).detected
|
||||
out = eng.remove_watermark_reverse_alpha(wm)
|
||||
assert not eng.detect(out).detected
|
||||
"""localize -> cv2 fill clears a mark composed from the real alpha
|
||||
(re-detect no longer fires)."""
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert DoubaoEngine().detect(wm).detected
|
||||
out, region = registry.get_mark("doubao").remove(wm, backend="cv2")
|
||||
assert region is not None
|
||||
assert not DoubaoEngine().detect(out).detected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h", "max_err"),
|
||||
("w", "h"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # captured width
|
||||
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH), # captured width
|
||||
(1773, 2364), # 3:4 portrait -> template-free footprint generalizes
|
||||
],
|
||||
)
|
||||
def test_recovers_flat_background(self, w, h, max_err):
|
||||
"""Recovers the flat background at the captured width AND a non-native
|
||||
resolution (NCC alignment generalizes the single capture)."""
|
||||
eng = DoubaoEngine()
|
||||
wm, mark = self._compose(w, h)
|
||||
def test_fill_removes_and_leaves_far_region(self, w, h):
|
||||
"""The fill lowers re-detect confidence and leaves the far corner exact."""
|
||||
wm, mark = _compose(w, h)
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
|
||||
|
||||
@staticmethod
|
||||
def _textured_bg(w: int, h: int):
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
|
||||
return np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
|
||||
|
||||
def test_recovers_shifted_mark_on_texture(self):
|
||||
"""A real mark is re-rasterized a few px off its fixed slot, so removal
|
||||
must NCC-align to it. Regression guard for the issue-#13 follow-up defect:
|
||||
a too-tight locate box let a corner-ward shift fall outside the alignment
|
||||
search, leaving a readable outline that the detector did not flag. Composes
|
||||
the real alpha SHIFTED on a known texture and asserts the texture is
|
||||
recovered (a misaligned removal would leave the bright glyph outline)."""
|
||||
eng = DoubaoEngine()
|
||||
w = h = _ALPHA_NATIVE_WIDTH
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
bg = self._textured_bg(w, h)
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
|
||||
mark = amap > 0.15
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
|
||||
before = DoubaoEngine().detect(wm)
|
||||
out, _ = registry.get_mark("doubao").remove(wm, backend="cv2")
|
||||
assert DoubaoEngine().detect(out).confidence < before.confidence
|
||||
assert np.array_equal(out[: h // 2, : w // 2], wm[: h // 2, : w // 2])
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
"""footprint_mask must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
|
||||
def test_wide_short_does_not_raise(self, w, h):
|
||||
"""A wide/short image at native width makes the width-derived glyph box
|
||||
taller than the image; the slice assignment must not ValueError."""
|
||||
taller than the image; masking must not ValueError."""
|
||||
eng = DoubaoEngine()
|
||||
img = np.zeros((h, w, 3), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
assert out.shape == img.shape
|
||||
mask = eng.footprint_mask(img, force=True)
|
||||
assert mask is None or mask.shape == (h, w)
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
eng = DoubaoEngine()
|
||||
gray = np.zeros((2048, 2048), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(gray)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
mask = eng.footprint_mask(gray, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
def test_bgra_4channel_does_not_raise(self):
|
||||
eng = DoubaoEngine()
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(bgra)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
mask = eng.footprint_mask(bgra, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
def test_template_match_score_guards_return_zero(self):
|
||||
# Guards return 0.0 (never a false positive) for a mask that cannot hold a
|
||||
# glyph: empty, narrower than min_gw, or shorter than the 4-px floor.
|
||||
assert _template_match_score(np.zeros((0, 5), np.uint8), 1000) == 0.0
|
||||
assert _template_match_score(np.zeros((10, 3), np.uint8), 1000) == 0.0 # width-1 < min_gw
|
||||
assert _template_match_score(np.zeros((3, 200), np.uint8), 1000) == 0.0 # height-1 < 4
|
||||
|
||||
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
|
||||
def test_locate_box_stays_in_bounds(self, shape):
|
||||
"""locate() must clamp its geometry box inside the image for ANY size/aspect --
|
||||
wide-short, tall-narrow, 1x1, huge -- for both bottom corners (br + bl)."""
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
|
||||
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
|
||||
|
||||
h, w = shape[:2]
|
||||
img = np.zeros(shape, np.uint8)
|
||||
for cfg in (BR_CONFIG, BL_CONFIG):
|
||||
loc = TextMarkEngine(cfg).locate(img)
|
||||
assert loc.x >= 0
|
||||
assert loc.y >= 0
|
||||
assert loc.x + loc.w <= w
|
||||
assert loc.y + loc.h <= h
|
||||
assert loc.w > 0
|
||||
assert loc.h > 0
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Regression test for the one-to-one face matcher in ``scripts/fidelity_metrics.py``.
|
||||
|
||||
The shipped per-face nearest matcher collided on multi-face images (two original faces
|
||||
both picking the same variant face when regeneration dropped a face), which inflated/
|
||||
corrupted the identity metric. ``assign_faces_one_to_one`` is the collision-free
|
||||
replacement. The function is pure (centers + diagonals in, index map out), so it is
|
||||
tested here without insightface / the heavy PEP723 env. Caught on the gemini_3 Qwen
|
||||
ControlNet experiment, where the original had 18 faces but the regenerated variants had
|
||||
17, producing two collisions under the old matcher.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
|
||||
|
||||
|
||||
def _load_assign():
|
||||
# fidelity_metrics is a standalone PEP723 script, not an installed module; load it by
|
||||
# path with scripts/ on sys.path so its `_plain_console` shim import resolves.
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("fidelity_metrics", _SCRIPTS / "fidelity_metrics.py")
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod # @dataclass introspection needs the module registered
|
||||
spec.loader.exec_module(mod)
|
||||
except ImportError as exc: # cv2/click absent in a bare env -> skip, not fail
|
||||
pytest.skip(f"fidelity_metrics import deps missing: {exc}")
|
||||
finally:
|
||||
sys.path.remove(str(_SCRIPTS))
|
||||
return mod.assign_faces_one_to_one
|
||||
|
||||
|
||||
def test_distinct_faces_match_nearest() -> None:
|
||||
assign = _load_assign()
|
||||
ref = [(0.0, 0.0), (100.0, 100.0)]
|
||||
var = [(2.0, 1.0), (98.0, 102.0)]
|
||||
diags = [50.0, 50.0]
|
||||
assert assign(ref, var, diags) == {0: 0, 1: 1}
|
||||
|
||||
|
||||
def test_no_collision_when_variant_drops_a_face() -> None:
|
||||
# Two original faces near the SAME single variant face: the old nearest matcher mapped
|
||||
# BOTH to index 0; one-to-one must give the nearer ref the match and drop the other.
|
||||
assign = _load_assign()
|
||||
ref = [(10.0, 10.0), (14.0, 10.0)] # both close to the lone variant
|
||||
var = [(12.0, 10.0)]
|
||||
diags = [50.0, 50.0]
|
||||
matched = assign(ref, var, diags)
|
||||
assert sorted(matched.values()) == [0] # variant 0 used at most once
|
||||
assert len(matched) == 1
|
||||
|
||||
|
||||
def test_gate_drops_implausibly_far_match() -> None:
|
||||
assign = _load_assign()
|
||||
ref = [(0.0, 0.0)]
|
||||
var = [(1000.0, 1000.0)] # far beyond 0.6 * diag
|
||||
diags = [50.0]
|
||||
assert assign(ref, var, diags) == {}
|
||||
|
||||
|
||||
def test_assignment_is_one_to_one_over_many_faces() -> None:
|
||||
assign = _load_assign()
|
||||
ref = [(float(i * 100), 0.0) for i in range(18)]
|
||||
var = [(float(i * 100) + 3.0, 0.0) for i in range(17)] # one fewer, as in the experiment
|
||||
diags = [50.0] * 18
|
||||
matched = assign(ref, var, diags)
|
||||
assert len(matched) == 17
|
||||
assert len(set(matched.values())) == 17 # every variant used at most once
|
||||
@@ -102,28 +102,6 @@ class TestGeminiEngine:
|
||||
assert small.shape == (48, 48)
|
||||
assert large.shape == (96, 96)
|
||||
|
||||
def test_remove_watermark_returns_same_shape(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark(image)
|
||||
assert result.shape == image.shape
|
||||
assert result.dtype == np.uint8
|
||||
|
||||
def test_remove_watermark_does_not_modify_input(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
original = image.copy()
|
||||
self.engine.remove_watermark(image)
|
||||
np.testing.assert_array_equal(image, original)
|
||||
|
||||
def test_remove_watermark_large_image(self, tmp_large_image_path):
|
||||
image = cv2.imread(str(tmp_large_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark(image)
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_remove_watermark_custom_region(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark_custom(image, (10, 10, 48, 48))
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_detect_on_grayscale_does_not_crash(self):
|
||||
# A 2D grayscale array reaching detect_watermark (registry adapter / library
|
||||
# API) must not crash the FP-gate's axis=2 reduction; it is normalized to BGR.
|
||||
@@ -131,26 +109,59 @@ class TestGeminiEngine:
|
||||
result = self.engine.detect_watermark(gray)
|
||||
assert result is not None
|
||||
|
||||
def test_remove_on_bgra_returns_3_channel(self):
|
||||
|
||||
# ── Removal: localize -> fill ───────────────────────────────────────
|
||||
|
||||
|
||||
def _composite_sparkle(engine, bg_value: int = 160, size: int = 1400):
|
||||
"""Composite the captured sparkle at the configured position on a textured
|
||||
mid-tone image (texture so the filled footprint no longer NCC-matches the
|
||||
template). Returns ``(watermarked_uint8, (x, y, w, h))``."""
|
||||
yy, xx = np.mgrid[0:size, 0:size].astype(np.float32)
|
||||
base = bg_value + 18 * np.sin(xx / 40.0) + 14 * np.cos(yy / 55.0)
|
||||
img = np.clip(np.stack([base, base * 0.97, base * 1.03], axis=-1), 0, 255)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = engine.get_interpolated_alpha(config.logo_size)
|
||||
ah, aw = alpha.shape[:2]
|
||||
a = alpha[:, :, None]
|
||||
roi = img[y : y + ah, x : x + aw]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
|
||||
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
|
||||
|
||||
|
||||
class TestRemovalLocalizeFill:
|
||||
"""Removal is localize (footprint_mask) -> fill (shared inpaint backend)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def test_footprint_mask_covers_detected_sparkle(self):
|
||||
img, (x, y, w, h) = _composite_sparkle(self.engine)
|
||||
assert self.engine.detect_watermark(img).detected
|
||||
mask = self.engine.footprint_mask(img)
|
||||
assert mask is not None
|
||||
assert mask.shape == img.shape[:2]
|
||||
# The mask mass overlaps the sparkle footprint box.
|
||||
assert int(mask[y : y + h, x : x + w].sum()) > 0
|
||||
|
||||
def test_fill_lowers_confidence(self):
|
||||
import remove_ai_watermarks.watermark_registry as registry
|
||||
|
||||
img, _ = _composite_sparkle(self.engine)
|
||||
before = self.engine.detect_watermark(img).confidence
|
||||
mask = self.engine.footprint_mask(img)
|
||||
assert mask is not None
|
||||
out = registry.fill(img, mask, backend="cv2")
|
||||
assert self.engine.detect_watermark(out).confidence < before
|
||||
|
||||
def test_footprint_mask_bgra_input(self):
|
||||
# A 4-channel input is normalized before masking; no crash, right shape.
|
||||
bgra = np.zeros((300, 300, 4), dtype=np.uint8)
|
||||
bgra[..., 3] = 255
|
||||
result = self.engine.remove_watermark(bgra)
|
||||
assert result.shape == (300, 300, 3)
|
||||
|
||||
def test_remove_watermark_custom_large_region(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark_custom(image, (10, 10, 96, 96))
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_remove_watermark_custom_arbitrary_region(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark_custom(image, (5, 5, 60, 60))
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_force_size(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.remove_watermark(image, force_size=WatermarkSize.LARGE)
|
||||
assert result.shape == image.shape
|
||||
mask = self.engine.footprint_mask(bgra, force=True)
|
||||
assert mask is None or mask.shape == (300, 300)
|
||||
|
||||
|
||||
# ── Detection ───────────────────────────────────────────────────────
|
||||
@@ -203,217 +214,6 @@ class TestDetectSparkleConfidence:
|
||||
assert detect_sparkle_confidence(bogus) is None
|
||||
|
||||
|
||||
# ── Inpainting ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestInpainting:
|
||||
"""Tests for residual inpainting."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def test_inpaint_ns(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="ns")
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_inpaint_telea(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="telea")
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_inpaint_gaussian(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), method="gaussian")
|
||||
assert result.shape == image.shape
|
||||
|
||||
def test_inpaint_zero_strength(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.inpaint_residual(image, (150, 150, 48, 48), strength=0.0)
|
||||
np.testing.assert_array_equal(result, image)
|
||||
|
||||
def test_inpaint_tiny_region_returns_unchanged(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
result = self.engine.inpaint_residual(image, (10, 10, 2, 2))
|
||||
np.testing.assert_array_equal(result, image)
|
||||
|
||||
def test_inpaint_does_not_modify_input(self, tmp_image_path):
|
||||
image = cv2.imread(str(tmp_image_path), cv2.IMREAD_COLOR)
|
||||
original = image.copy()
|
||||
self.engine.inpaint_residual(image, (150, 150, 48, 48))
|
||||
np.testing.assert_array_equal(image, original)
|
||||
|
||||
|
||||
class TestOverSubtractionGuard:
|
||||
"""Issue #30: reverse-alpha must not turn the sparkle into a black pit.
|
||||
|
||||
On a dark background the captured alpha over-estimates the real sparkle opacity,
|
||||
so the fixed-alpha reverse blend over-subtracts and drives the footprint to black.
|
||||
The engine detects this and inpaints the footprint instead.
|
||||
"""
|
||||
|
||||
# Composite the mark at ~60% of the captured opacity: the engine's alpha maxes at
|
||||
# ~0.51, real dark-background sparkles sit nearer ~0.31, so 0.6x reproduces the
|
||||
# capture-over-estimates-reality mismatch that triggers the bug.
|
||||
_REALISTIC_ALPHA_SCALE = 0.6
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def _composite_sparkle(self, bg_value: int, size: int = 1400, alpha_scale: float = _REALISTIC_ALPHA_SCALE):
|
||||
"""Build a flat BGR image of ``bg_value`` with the sparkle composited in.
|
||||
|
||||
The mark is composited at a LOWER effective opacity than the engine's captured
|
||||
alpha map (``alpha_scale`` < 1), reproducing the real-world mismatch behind
|
||||
issue #30: the captured alpha (~0.51) over-estimates a real sparkle whose
|
||||
effective opacity is lower, so the fixed-alpha reverse blend over-subtracts.
|
||||
Placed at the configured large-image position so the detector locates it.
|
||||
"""
|
||||
img = np.full((size, size, 3), bg_value, dtype=np.float32)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
|
||||
ah, aw = alpha.shape[:2]
|
||||
a = (alpha * alpha_scale)[:, :, None]
|
||||
roi = img[y : y + ah, x : x + aw]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
|
||||
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
|
||||
|
||||
def test_dark_background_does_not_leave_black_pit(self):
|
||||
image, (x, y, w, h) = self._composite_sparkle(bg_value=60)
|
||||
out = self.engine.remove_watermark(image)
|
||||
footprint = out[y : y + h, x : x + w]
|
||||
# The recovered footprint must read like the dark background, not a black hole.
|
||||
assert footprint.min() > 25, f"black pit: min={footprint.min()}"
|
||||
assert abs(float(footprint.mean()) - 60.0) < 25.0
|
||||
|
||||
def test_bright_background_keeps_reverse_alpha(self):
|
||||
"""A bright background does not over-subtract, so reverse-alpha is used."""
|
||||
bright, pos = self._composite_sparkle(bg_value=230)
|
||||
alpha = self.engine.get_interpolated_alpha(pos[2])
|
||||
assert self.engine._reverse_alpha_oversubtracts(bright, alpha, (pos[0], pos[1])) is False
|
||||
dark, dpos = self._composite_sparkle(bg_value=60)
|
||||
dalpha = self.engine.get_interpolated_alpha(dpos[2])
|
||||
assert self.engine._reverse_alpha_oversubtracts(dark, dalpha, (dpos[0], dpos[1])) is True
|
||||
|
||||
|
||||
class TestUnderSubtractionGain:
|
||||
"""Under-subtraction fix: a sparkle MORE opaque than the captured alpha must not
|
||||
survive removal. The captured alpha (~0.51) under-represents such marks, so the
|
||||
fixed-alpha reverse blend leaves a bright residual; the per-image gain scales the
|
||||
alpha up to match this image's opacity. Mirror of TestOverSubtractionGuard.
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def _composite_sparkle(self, bg_value: int, alpha_scale: float, size: int = 1400):
|
||||
"""Flat ``bg_value`` image with the sparkle composited at ``alpha_scale`` opacity.
|
||||
|
||||
``alpha_scale`` > 1 makes the mark MORE opaque than the engine's captured alpha,
|
||||
reproducing the under-subtraction case (real under-removed marks estimate ~1.47).
|
||||
"""
|
||||
img = np.full((size, size, 3), bg_value, dtype=np.float32)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
|
||||
ah, aw = alpha.shape[:2]
|
||||
a = np.clip(alpha * alpha_scale, 0.0, 1.0)[:, :, None]
|
||||
roi = img[y : y + ah, x : x + aw]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
|
||||
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
|
||||
|
||||
def test_more_opaque_sparkle_estimates_gain_above_deadband(self):
|
||||
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
|
||||
alpha = self.engine.get_interpolated_alpha(pos[2])
|
||||
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
|
||||
assert gain > self.engine._ALPHA_GAIN_DEADBAND, f"gain {gain} did not exceed deadband"
|
||||
|
||||
def test_matching_sparkle_estimates_unit_gain(self):
|
||||
"""A sparkle that matches the captured opacity gets ~1.0 (no scaling)."""
|
||||
image, pos = self._composite_sparkle(bg_value=80, alpha_scale=1.0)
|
||||
alpha = self.engine.get_interpolated_alpha(pos[2])
|
||||
gain = self.engine._estimate_alpha_gain(image, alpha, (pos[0], pos[1]))
|
||||
assert gain <= self.engine._ALPHA_GAIN_DEADBAND, f"matching sparkle scaled by {gain}"
|
||||
|
||||
def test_more_opaque_sparkle_is_removed(self):
|
||||
"""The gain-scaled removal clears a more-opaque sparkle without a black pit.
|
||||
|
||||
Asserted on the footprint PIXELS, not the detector: the detector's NCC is
|
||||
degenerate on a perfectly flat synthetic background (zero-variance regions
|
||||
spuriously match), so a re-detect conf is meaningless here -- on real textured
|
||||
images the same removal drops the detector from ~0.80 to ~0.27 (spaces corpus).
|
||||
"""
|
||||
image, (x, y, w, h) = self._composite_sparkle(bg_value=80, alpha_scale=1.3)
|
||||
assert self.engine.detect_watermark(image).detected
|
||||
before_max = int(image[y : y + h, x : x + w].max()) # bright sparkle present
|
||||
assert before_max > 150
|
||||
out = self.engine.remove_watermark(image)
|
||||
footprint = out[y : y + h, x : x + w]
|
||||
# Sparkle gone: no bright residual, no black pit, footprint reads like the bg.
|
||||
assert int(footprint.max()) < 80 + 30, f"bright residual: max={footprint.max()}"
|
||||
assert int(footprint.min()) > 25, f"black pit: min={footprint.min()}"
|
||||
assert abs(float(footprint.mean()) - 80.0) < 20.0
|
||||
|
||||
|
||||
class TestVerifyAndRepair:
|
||||
"""Self-verify fallback: a sparkle that survives reverse-alpha is inpaint-repaired,
|
||||
but only when that lowers the re-detect confidence (so it can never regress).
|
||||
|
||||
The detector NCC is degenerate on flat synthetic backgrounds, so the keep-best
|
||||
control flow is driven through a stubbed ``detect_watermark`` rather than a real
|
||||
re-detect (mirroring the reasoning in TestUnderSubtractionGain).
|
||||
"""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
self.alpha = self.engine.get_interpolated_alpha(96)
|
||||
self.pos = (200, 200)
|
||||
|
||||
def _stub_detect(self, confidences):
|
||||
"""detect_watermark stub yielding the given confidences in order."""
|
||||
seq = iter(confidences)
|
||||
|
||||
def fake(image, force_size=None):
|
||||
return DetectionResult(detected=True, confidence=next(seq))
|
||||
|
||||
return fake
|
||||
|
||||
def _repair(self, result):
|
||||
return self.engine._verify_and_repair(result, self.alpha, self.pos, WatermarkSize.LARGE)
|
||||
|
||||
def test_clean_removal_returned_untouched(self, monkeypatch):
|
||||
"""Below the fallback threshold, the input is returned byte-identical."""
|
||||
img = np.full((600, 600, 3), 90, dtype=np.uint8)
|
||||
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.2]))
|
||||
out = self._repair(img)
|
||||
assert out is img # no copy, no inpaint
|
||||
|
||||
def test_keeps_footprint_inpaint_when_it_helps(self, monkeypatch):
|
||||
"""A surviving sparkle is footprint-inpainted when that re-detects lower; the
|
||||
footprint pixels change."""
|
||||
img = np.full((600, 600, 3), 90, dtype=np.uint8)
|
||||
# Bright residual block over the footprint so the inpaint visibly changes it.
|
||||
img[self.pos[1] : self.pos[1] + 96, self.pos[0] : self.pos[0] + 96] = 240
|
||||
# residual 0.7 (>= 0.5 triggers), candidate re-detects 0.2 (< residual -> keep).
|
||||
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.2]))
|
||||
out = self._repair(img)
|
||||
assert out is not img
|
||||
assert not np.array_equal(out, img) # footprint was inpainted from surroundings
|
||||
|
||||
def test_repair_rejected_when_inpaint_does_not_help(self, monkeypatch):
|
||||
"""When the inpaint does not lower the re-detect confidence, keep the original."""
|
||||
img = np.full((600, 600, 3), 90, dtype=np.uint8)
|
||||
# residual 0.7, candidate re-detects 0.75 (>= residual -> reject the inpaint).
|
||||
monkeypatch.setattr(self.engine, "detect_watermark", self._stub_detect([0.7, 0.75]))
|
||||
out = self._repair(img)
|
||||
assert out is img
|
||||
|
||||
|
||||
class TestSparkleFalsePositiveGate:
|
||||
"""False-positive gate: a low-confidence shape match whose core is NOT brighter
|
||||
than its surroundings (ornate/flat content, not a white sparkle overlay) is
|
||||
@@ -492,6 +292,51 @@ class TestSparkleFalsePositiveGate:
|
||||
assert det.confidence < 0.5
|
||||
assert not det.detected
|
||||
|
||||
def test_bright_white_blurred_sparkle_rescued(self):
|
||||
"""A strong, bright, WHITE blurred sparkle is now KEPT (white-core rescue). A
|
||||
faint / re-compressed real sparkle has soft edges (low gradient) like a smooth
|
||||
blob, but its core is bright AND near-WHITE and its fused confidence clears the
|
||||
trust gate, so the rescue (``_SPARKLE_WHITE_SAT`` / ``_SPARKLE_KEEP_CONF``) keeps
|
||||
it -- recovering the metadata-stripped faint sparkles the grad gate used to drop.
|
||||
The weaker (~0.51) bright-background FPs the grad gate was added for still get
|
||||
demoted, because they fall below ``_SPARKLE_KEEP_CONF`` (0.52); the colored-blob
|
||||
case below is demoted on core saturation.
|
||||
"""
|
||||
size = 1400
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
|
||||
ah, aw = alpha.shape[:2]
|
||||
img = np.full((size, size, 3), 110, dtype=np.float32)
|
||||
a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * img[y : y + ah, x : x + aw]
|
||||
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
|
||||
det = self.engine.detect_watermark(img)
|
||||
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD # soft edges, low gradient
|
||||
assert det.confidence >= self.engine._SPARKLE_KEEP_CONF # strong white-core match, kept
|
||||
assert det.detected
|
||||
|
||||
def test_bright_colored_blob_still_demoted(self):
|
||||
"""The white-core rescue must NOT re-admit a COLORED bright-corner FP (sky, sun,
|
||||
a warm light): its core saturation is high, so the rescue does not apply and the
|
||||
gradient gate still demotes it. Same blurred construction as the white sparkle
|
||||
above, but tinted -- proving the discriminator is core color, not just brightness.
|
||||
"""
|
||||
size = 1400
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
|
||||
ah, aw = alpha.shape[:2]
|
||||
img = np.full((size, size, 3), 110, dtype=np.float32)
|
||||
a = np.clip(alpha * 1.6, 0.0, 1.0)[:, :, None]
|
||||
color = np.array([40.0, 140.0, 235.0]) # BGR warm/orange -> saturated (non-white) core
|
||||
img[y : y + ah, x : x + aw] = a * color + (1.0 - a) * img[y : y + ah, x : x + aw]
|
||||
img = cv2.GaussianBlur(np.clip(img, 0, 255).astype(np.uint8), (39, 39), 0)
|
||||
det = self.engine.detect_watermark(img)
|
||||
assert det.gradient_score < self.engine._SPARKLE_FP_GRAD
|
||||
assert det.confidence < 0.5
|
||||
assert not det.detected
|
||||
|
||||
|
||||
class TestCornerPromotion:
|
||||
"""Issue #36: a small sparkle in the corner must not be lost to a larger decoy.
|
||||
@@ -539,20 +384,72 @@ class TestCornerPromotion:
|
||||
assert abs(det.region[0] - self._CORNER[0]) < 16
|
||||
assert abs(det.region[1] - self._CORNER[1]) < 16
|
||||
|
||||
def test_promotion_is_what_rescues_it(self, monkeypatch):
|
||||
"""Guard the mechanism: disabling the override mislocalizes to the decoy.
|
||||
def test_size_weighted_search_alone_traps_on_the_decoy(self):
|
||||
"""Guard the mechanism: the size-weighted argmax ALONE mislocalizes to the decoy.
|
||||
|
||||
Proves the scene genuinely needs the override (so the localization test above
|
||||
is not a fluke): with the gate set unreachable the larger decoy wins.
|
||||
Proves the scene genuinely needs the dual-candidate fusion selection (so the
|
||||
localization test above is not a fluke): replicating the old size-weighted-only
|
||||
pick lands on the larger left-side decoy, while ``detect_watermark`` -- which
|
||||
scores all top-K candidates by full fusion -- lands on the corner sparkle.
|
||||
"""
|
||||
scene = self._scene()
|
||||
h, w = scene.shape[:2]
|
||||
ss = min(min(w, h), 512)
|
||||
sx1, sy1 = max(0, w - ss), max(0, h - ss)
|
||||
gray = cv2.cvtColor(scene[sy1:h, sx1:w], cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0
|
||||
best = (-1.0, (0, 0))
|
||||
for scale, max_val, max_loc in self.engine._scan_scales(gray):
|
||||
adj = max_val * min(1.0, (scale / 96.0) ** 0.5)
|
||||
if adj > best[0]:
|
||||
best = (adj, max_loc)
|
||||
weighted_region = (sx1 + best[1][0], sy1 + best[1][1])
|
||||
assert not self._in_bottom_right((*weighted_region, 0, 0)), "size-weighted argmax expected to hit the decoy"
|
||||
# The full detector, scoring both candidates by fusion, rescues the corner.
|
||||
assert self._in_bottom_right(self.engine.detect_watermark(scene).region)
|
||||
monkeypatch.setattr(GeminiEngine, "_CORNER_PROMOTE_NCC", 2.0)
|
||||
assert not self._in_bottom_right(self.engine.detect_watermark(scene).region), (
|
||||
"decoy expected to win without the override"
|
||||
)
|
||||
|
||||
def test_no_promotion_on_clean_flat_image(self):
|
||||
"""A flat image with no sparkle yields no corner match to promote."""
|
||||
flat = np.full((self._H, self._W, 3), 40, dtype=np.uint8)
|
||||
assert self.engine._corner_promote(flat, -1.0) is None
|
||||
|
||||
def test_low_gradient_decoy_loses_to_high_gradient_corner_sparkle(self, monkeypatch):
|
||||
"""Regression (reporter 2026-06-12, v0.7.2 detected / v0.8-0.11 missed): the
|
||||
256->512 search widening let a large, low-gradient size-weighted match outrank
|
||||
a smaller, high-gradient corner sparkle whose raw NCC (~0.77) sits BELOW the
|
||||
0.85 corner-promote gate -- so a real Gemini sparkle on light bedding read as
|
||||
clean. The detector now scores all top-K candidates by full fusion and keeps
|
||||
the highest; the gradient term (~0.04 on flat content vs ~1.0 on a true
|
||||
sparkle) rescues the corner. Mirrors the real signature:
|
||||
decoy spatial 0.63 / grad 0.04 vs corner spatial 0.78 / grad 0.96.
|
||||
"""
|
||||
eng = self.engine
|
||||
size = 500
|
||||
img = np.full((size, size, 3), 225, dtype=np.float32)
|
||||
# Low-gradient smooth bright blob = a content false match the size weight favors.
|
||||
blob = np.zeros((size, size), np.float32)
|
||||
cv2.circle(blob, (89, 89), 60, 1.0, -1)
|
||||
blob = cv2.GaussianBlur(blob, (0, 0), 30)
|
||||
img += (blob * 25)[:, :, None]
|
||||
# A real, high-gradient sparkle in the bottom-right corner.
|
||||
cx, cy, cs = 430, 430, 48
|
||||
tmpl = cv2.resize(eng._alpha_large, (cs, cs), interpolation=cv2.INTER_AREA)
|
||||
a = (tmpl * 0.6)[:, :, None]
|
||||
img[cy : cy + cs, cx : cx + cs] = a * 255.0 + (1.0 - a) * img[cy : cy + cs, cx : cx + cs]
|
||||
img = np.clip(img, 0, 255).astype(np.uint8)
|
||||
dx, dy, ds = 40, 40, 98
|
||||
|
||||
# The two locations carry the real-signature gradient split.
|
||||
assert eng._grad_var_scores(img, ds, dx, dy)[0] < 0.1 # decoy: shape-only, ~0.04
|
||||
assert eng._grad_var_scores(img, cs, cx, cy)[0] > 0.8 # corner sparkle: ~1.0
|
||||
|
||||
def fake_scan(_self, _gray):
|
||||
yield ds, 0.66, (dx, dy) # large, low-fidelity: size-weighted adj 0.66 -> winner
|
||||
yield cs, 0.80, (cx, cy) # small sparkle: raw 0.80 but adj 0.57 -> size-weight loses
|
||||
|
||||
monkeypatch.setattr(GeminiEngine, "_scan_scales", fake_scan)
|
||||
det = eng.detect_watermark(img)
|
||||
# Full-fusion selection keeps the high-gradient corner sparkle, not the decoy.
|
||||
assert det.detected
|
||||
assert det.confidence >= 0.5
|
||||
assert abs(det.region[0] - cx) < 4, f"localized to decoy: {det.region}"
|
||||
assert abs(det.region[1] - cy) < 4, f"localized to decoy: {det.region}"
|
||||
@@ -1,4 +1,5 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.humanizer import apply_analog_humanizer, unsharp_mask
|
||||
|
||||
@@ -72,6 +73,15 @@ def test_chromatic_shift_does_not_wrap_opposite_edge():
|
||||
assert result[:, -shift:, 2].min() > 195
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "shift"), [(1, 1), (3, 3), (3, 5), (5, 10)])
|
||||
def test_chromatic_shift_wider_than_image_no_crash(width: int, shift: int):
|
||||
"""Regression: a chromatic_shift >= image width left the edge-replication slices
|
||||
empty and crashed the broadcast (ValueError). The shift must clamp to width-1."""
|
||||
img = np.full((4, width, 3), 120, np.uint8)
|
||||
result = apply_analog_humanizer(img, grain_intensity=0.0, chromatic_shift=shift)
|
||||
assert result.shape == img.shape
|
||||
|
||||
|
||||
def test_unsharp_disabled_returns_unchanged_copy():
|
||||
img = np.full((20, 20, 3), 128, dtype=np.uint8)
|
||||
img[10, 10] = [100, 150, 200]
|
||||
@@ -147,3 +157,19 @@ class TestAdaptivePolish:
|
||||
a = adaptive_polish(soft, reference, seed=7)
|
||||
b = adaptive_polish(soft, reference, seed=7)
|
||||
assert np.array_equal(a, b)
|
||||
|
||||
def test_all_edges_reference_grain_mask_near_zero(self):
|
||||
# An all-high-frequency target: _smooth_grain_mask suppresses edges, so the grain
|
||||
# mask is ~all-zero (grain adds nothing) -- adaptive_polish must still return a
|
||||
# valid same-shape image, not crash on the empty-mask branch.
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks.humanizer import _smooth_grain_mask, adaptive_polish
|
||||
|
||||
rng = np.random.default_rng(5)
|
||||
edges = rng.integers(0, 256, (120, 120, 3), dtype=np.uint8) # all high-frequency
|
||||
assert _smooth_grain_mask(edges).mean() < _smooth_grain_mask(np.full((120, 120, 3), 128, np.uint8)).mean()
|
||||
soft = cv2.GaussianBlur(edges, (0, 0), sigmaX=3.0)
|
||||
out = adaptive_polish(soft, edges, seed=0)
|
||||
assert out.shape == soft.shape
|
||||
assert out.dtype == np.uint8
|
||||
@@ -22,8 +22,10 @@ from remove_ai_watermarks.identify import (
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
has_invisible_target,
|
||||
identify,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
# Where the lazy import inside identify._visible_sparkle resolves the detector.
|
||||
_SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
@@ -66,6 +68,19 @@ class TestAttributePlatform:
|
||||
assert platform
|
||||
assert "Stability AI" in platform
|
||||
|
||||
def test_canva(self):
|
||||
platform = _attribute_platform(["Canva"])
|
||||
assert platform
|
||||
assert "Canva" in platform
|
||||
|
||||
def test_byteplus_attributes_to_bytedance(self):
|
||||
# ByteDance's intl brand signs as "Byteplus Pte. Ltd."; the registry maps
|
||||
# it to the ByteDance platform (was mis-read as Adobe via an incidental
|
||||
# "Adobe XMP" file string before the entry existed).
|
||||
platform = _attribute_platform(["BytePlus (ByteDance)"])
|
||||
assert platform
|
||||
assert "ByteDance" in platform
|
||||
|
||||
def test_empty_is_none(self):
|
||||
assert _attribute_platform([]) is None
|
||||
|
||||
@@ -127,6 +142,52 @@ class TestIdentifyNonPng:
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_bytedance_chinese_legal_name_attributed(self, tmp_path: Path):
|
||||
# Some Volcano Engine certs name the signer with the Chinese legal entity
|
||||
# rather than the latin "volcengine"; the latin needle misses it, so the
|
||||
# Chinese-name registry entry is what attributes real ByteDance output.
|
||||
blob = "北京火山引擎科技有限公司".encode() + b" ... trainedAlgorithmicMedia"
|
||||
path = self._c2pa_jpeg(tmp_path, blob)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_dreamina_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Dreamina (ByteDance's international Jimeng brand) signs C2PA as
|
||||
# "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
|
||||
# digitalSourceType assertion -- the generator name is the only AI signal.
|
||||
# It is an identity-AI vendor (a pure generator), so attribution must not
|
||||
# depend on trainedAlgorithmicMedia the way the incidental-mention-prone
|
||||
# common-word issuers (Adobe/Google/OpenAI) do.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Bytedance Pte. Ltd. Dreamina/7.5.0 c2pa.created")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_elevenlabs_attributed(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Eleven Labs Inc. ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "ElevenLabs"
|
||||
assert not any("SynthID" in w for w in r.watermarks) # ElevenLabs does not use SynthID
|
||||
|
||||
def test_fal_ai_attributed(self, tmp_path: Path):
|
||||
# fal.ai signs as "fal - Features & Labels Inc." with a "fal-ai/<model>"
|
||||
# claim generator; corpus-measured 2026-07-23 (17 files).
|
||||
path = self._c2pa_jpeg(tmp_path, b"fal - Features & Labels Inc. fal-ai/seedvr trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "fal.ai"
|
||||
|
||||
def test_bria_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Bria signs as "Bria Artificial Intelligence" with source type ``empty``
|
||||
# (NO trainedAlgorithmicMedia) -- a pure-generator asserts_ai vendor, so
|
||||
# the issuer/generator strings alone must flag AI. Corpus-found 2026-07-23.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Bria Artificial Intelligence Bria Ai c2pa.created c2pa.edited")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Bria AI"
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
@@ -135,6 +196,21 @@ class TestIdentifyNonPng:
|
||||
assert "Stability AI" in r.platform
|
||||
assert not any("SynthID" in w for w in r.watermarks) # Stability does not use SynthID
|
||||
|
||||
def test_trained_source_is_generated_kind(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind == "generated"
|
||||
|
||||
def test_composite_source_is_enhanced_kind(self, tmp_path: Path):
|
||||
# compositeWithTrainedAlgorithmicMedia: a real photo with an AI-composited
|
||||
# region. Still AI (is_ai True), but the kind must read "enhanced" so a
|
||||
# caller can do region-targeted cleaning instead of a full-frame regen.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Adobe ... compositeWithTrainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind == "enhanced"
|
||||
|
||||
def test_c2pa_without_ai_marker_is_unknown(self, tmp_path: Path):
|
||||
# Adobe signs C2PA on plain Photoshop edits too. Without an AI digital-
|
||||
# source marker, the honest verdict is unknown -- the C2PA watermark is
|
||||
@@ -189,6 +265,16 @@ class TestIdentifySamsungGalaxy:
|
||||
assert r.platform == "ASUS Gallery (C2PA signer)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
def test_galaxy_capture_without_ai_marker_is_not_ai(self, tmp_path: Path):
|
||||
# A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but
|
||||
# NO AI source-type / genAIType. It must stay is_ai=None -- the device cert
|
||||
# is authenticity provenance of a real photo, not an AI-generation signal.
|
||||
path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
@@ -216,6 +302,33 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_apple_clean_up_attributed(self, tmp_path: Path):
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks the
|
||||
# AI edit via photoshop:Credit next to compositeWithTrainedAlgorithmicMedia
|
||||
# -- it must be attributed, not reported as a generic made-with-AI tag.
|
||||
# Corpus-measured 2026-07-23 (35 files).
|
||||
p = tmp_path / "apple_cleanup.jpg"
|
||||
p.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta photoshop:Credit="Apple Photos Clean Up" '
|
||||
b"Iptc4xmpExt:DigitalSourceType=compositeWithTrainedAlgorithmicMedia></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Apple Photos (Clean Up AI edit)"
|
||||
|
||||
def test_flux_bfl_c2pa_png(self):
|
||||
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
|
||||
r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Black Forest Labs (FLUX)"
|
||||
|
||||
def test_flux_bfl_c2pa_jpeg_via_reader(self):
|
||||
# flux-1.jpg: same source as a JPEG -- the real committed JPEG-with-C2PA
|
||||
# fixture that exercises the c2pa-python non-PNG reader path end to end.
|
||||
r = identify(SAMPLES_DIR / "flux-1.jpg", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Black Forest Labs (FLUX)"
|
||||
|
||||
def test_clean_photo_is_unknown_not_clean(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert r.is_ai_generated is None # never asserted False
|
||||
@@ -223,6 +336,19 @@ class TestIdentifyRealSamples:
|
||||
assert r.confidence == "none"
|
||||
assert r.watermarks == []
|
||||
|
||||
def test_has_invisible_target_true_on_metadata_ai(self):
|
||||
# The scrub gate: a C2PA/SynthID image and an IPTC "Made with AI" image are
|
||||
# both invisible/metadata targets, so the diffusion scrub should run.
|
||||
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
|
||||
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
|
||||
# ai_from_metadata mirrors confidence == "high" and backs the helper.
|
||||
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
|
||||
|
||||
def test_has_invisible_target_false_on_clean_photo(self, clean_photo: Path):
|
||||
# No detectable invisible signal -> skip the scrub (do not degrade a clean image).
|
||||
assert has_invisible_target(clean_photo) is False
|
||||
assert identify(clean_photo, check_visible=False).ai_from_metadata is False
|
||||
|
||||
def test_strip_caveat_always_present(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert any("not proof" in c for c in r.caveats)
|
||||
@@ -231,6 +357,30 @@ class TestIdentifyRealSamples:
|
||||
assert isinstance(identify(SAMPLES_DIR / "firefly-1.png", check_visible=False), ProvenanceReport)
|
||||
|
||||
|
||||
class TestHasInvisibleTargetFailSafe:
|
||||
"""The scrub gate fails SAFE: when a detector errors, it runs the removal."""
|
||||
|
||||
def test_detector_error_defaults_to_run(self, tmp_path: Path):
|
||||
# If identify raises (a detector crash), the gate must return True so the
|
||||
# caller still attempts removal -- leaving a watermark on a paid removal is
|
||||
# worse than over-regenerating. (Garbage bytes do NOT raise; identify returns
|
||||
# a clean None verdict there, so that path correctly skips -- see below.)
|
||||
bad = tmp_path / "x.png"
|
||||
bad.write_bytes(b"not image bytes")
|
||||
with patch("remove_ai_watermarks.identify.identify", side_effect=RuntimeError("boom")):
|
||||
assert has_invisible_target(bad) is True
|
||||
|
||||
def test_unreadable_bytes_are_not_a_target(self, tmp_path: Path):
|
||||
# No raise, no signal -> not a scrub target (the CLI rejects undecodable
|
||||
# images earlier anyway; this only documents the gate's own verdict).
|
||||
bad = tmp_path / "x.png"
|
||||
bad.write_bytes(b"not image bytes")
|
||||
assert has_invisible_target(bad) is False
|
||||
|
||||
def test_local_ai_params_are_a_target(self, tmp_png_with_ai_metadata: Path):
|
||||
assert has_invisible_target(tmp_png_with_ai_metadata) is True
|
||||
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ─────────
|
||||
|
||||
|
||||
@@ -251,6 +401,12 @@ class TestIdentifyLocalParams:
|
||||
assert "parameters" in signal.detail
|
||||
assert signal.confidence == "high"
|
||||
|
||||
def test_local_gen_params_have_no_c2pa_source_kind(self, tmp_png_with_ai_metadata: Path):
|
||||
# AI verdict from local SD params (not C2PA) -> ai_source_kind stays None.
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.ai_source_kind is None
|
||||
|
||||
def test_clean_png_is_unknown(self, tmp_clean_png: Path):
|
||||
r = identify(tmp_clean_png, check_visible=False)
|
||||
assert r.is_ai_generated is None
|
||||
@@ -373,6 +529,66 @@ class TestIdentifyVisibleSparkle:
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEMO_BEFORE = REPO_ROOT / "demo_banana_before.png"
|
||||
_DEMO_AFTER = REPO_ROOT / "demo_banana_after.png"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (_DEMO_BEFORE.exists() and _DEMO_AFTER.exists()), reason="demo banana pair not present")
|
||||
class TestSparkleDetectRemoveAlignment:
|
||||
"""Detect (identify) and remove (registry.detect_marks) must agree on the
|
||||
same image -- the retained-corpus desync where identify reported a sparkle the
|
||||
removal arbitration declined (or vice versa). Both gate on the single shared
|
||||
GEMINI_SPARKLE_TRUST_CONF, so a sparkle just over the line is taken by BOTH
|
||||
and one just under is declined by BOTH. Fixtures composite the real captured
|
||||
sparkle (before-minus-after) back at reduced opacity to land on either side.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _faint_sparkle(tmp_path: Path, opacity: float) -> Path:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
before = image_io.imread(_DEMO_BEFORE).astype("float32")
|
||||
after = image_io.imread(_DEMO_AFTER).astype("float32")
|
||||
faint = np.clip(after + opacity * (before - after), 0, 255).astype("uint8")
|
||||
out = tmp_path / f"sparkle_{int(opacity * 100)}.png"
|
||||
image_io.imwrite(out, faint)
|
||||
return out
|
||||
|
||||
def _detect_remove(self, path: Path) -> tuple[bool, bool, float]:
|
||||
from remove_ai_watermarks import image_io, watermark_registry
|
||||
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
|
||||
|
||||
conf = detect_sparkle_confidence(path) or 0.0
|
||||
identify_fires = conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
dets = watermark_registry.detect_marks(image_io.imread(path), include_explicit=False)
|
||||
remove_takes_gemini = any(d.key == "gemini" and d.detected for d in dets)
|
||||
return identify_fires, remove_takes_gemini, conf
|
||||
|
||||
def test_above_threshold_both_fire(self, tmp_path: Path):
|
||||
path = self._faint_sparkle(tmp_path, 0.7) # ~0.55 conf, just over the line
|
||||
identify_fires, remove_takes, conf = self._detect_remove(path)
|
||||
assert conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
assert identify_fires, f"identify declined a sparkle above threshold (conf={conf:.3f})"
|
||||
assert remove_takes, f"removal declined a sparkle above threshold (conf={conf:.3f})"
|
||||
|
||||
def test_below_threshold_both_decline(self, tmp_path: Path):
|
||||
path = self._faint_sparkle(tmp_path, 0.5) # ~0.37 conf, just under the line
|
||||
identify_fires, remove_takes, conf = self._detect_remove(path)
|
||||
assert conf < GEMINI_SPARKLE_TRUST_CONF
|
||||
assert not identify_fires, f"identify fired below threshold (conf={conf:.3f})"
|
||||
assert not remove_takes, f"removal fired below threshold (conf={conf:.3f})"
|
||||
|
||||
def test_full_strength_both_fire(self):
|
||||
# The shipped demo sparkle at full strength: unambiguous agreement.
|
||||
identify_fires, remove_takes, conf = self._detect_remove(_DEMO_BEFORE)
|
||||
assert conf >= GEMINI_SPARKLE_TRUST_CONF
|
||||
assert identify_fires
|
||||
assert remove_takes
|
||||
|
||||
|
||||
class TestIdentifyImportIsLight:
|
||||
"""`import identify` must stay torch-free (lazy noai/__init__): the package
|
||||
is deployed on a 512 MB host where eagerly pulling torch/diffusers OOMs."""
|
||||
@@ -434,6 +650,18 @@ class TestIdentifyVisibleTextMarks:
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=True)
|
||||
assert r.confidence == "high"
|
||||
|
||||
def test_visible_path_decodes_file_once(self, tmp_clean_png: Path):
|
||||
"""The web path identify(check_visible=True, check_invisible=False) must
|
||||
decode the image exactly once and share the array across the sparkle +
|
||||
text-mark detectors. Two decodes of the same bitmap spiked memory on the
|
||||
small web worker (the OOM the decode-once refactor addresses)."""
|
||||
import remove_ai_watermarks.image_io as image_io
|
||||
|
||||
real_imread = image_io.imread
|
||||
with patch.object(image_io, "imread", side_effect=real_imread) as mock_imread:
|
||||
identify(tmp_clean_png, check_visible=True, check_invisible=False)
|
||||
assert mock_imread.call_count == 1
|
||||
|
||||
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
@@ -502,6 +730,16 @@ class TestOpenAiCaveatVendorScoped:
|
||||
assert any("SynthID watermark, inferred from C2PA metadata (likely present (OpenAI" in w for w in r.watermarks)
|
||||
assert any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
def test_dreamina_png_cabx_without_source_type(self, tmp_path: Path):
|
||||
# Real Dreamina PNGs carry the "Dreamina/x.y" generator in an ingredient
|
||||
# manifest (the active one is often a c2pa-tool transcode), so the caBX
|
||||
# byte-scan -- which sees the whole store -- is the reliable detector, and
|
||||
# there is no trainedAlgorithmicMedia to lean on.
|
||||
png = self._png(tmp_path, "dreamina.png", self._png_chunk(b"caBX", b"jumbc2pa Dreamina/7.5.0 c2pa.created"))
|
||||
r = identify(png, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
|
||||
class TestReportSerializable:
|
||||
def test_report_is_json_serializable(self, tmp_png_with_ai_metadata: Path):
|
||||
@@ -719,6 +957,15 @@ class TestVendorOf:
|
||||
assert _vendor_of("a regular photo") is None
|
||||
assert _vendor_of(None) is None
|
||||
|
||||
def test_registered_vendors_normalize(self):
|
||||
# Regression: these registered C2PA vendors returned None, so their claims never
|
||||
# entered clash detection (a coverage hole). They now normalize to one origin.
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Dreamina/1.2") == "ByteDance"
|
||||
assert _vendor_of("Canva (Magic Media)") == "Canva"
|
||||
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
|
||||
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_two_ai_vendors_clash(self):
|
||||
@@ -742,6 +989,31 @@ class TestIntegrityClashesHelper:
|
||||
== []
|
||||
)
|
||||
|
||||
def test_bytedance_c2pa_plus_own_aigc_no_clash(self):
|
||||
# A legit ByteDance/Doubao image carries BOTH a ByteDance C2PA manifest and its
|
||||
# own China TC260 AIGC label. The label is ByteDance's own regulatory stamp, so
|
||||
# it must be attributed to ByteDance and NOT read as a competing origin.
|
||||
assert (
|
||||
_integrity_clashes({"c2pa": "ByteDance", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_foreign_vendor_plus_aigc_still_clashes(self):
|
||||
# But a NON-Chinese vendor's C2PA next to a China TC260 label names two different
|
||||
# origins -- a laundering tell that must still fire (the generic label stays generic).
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "Conflicting AI-origin" in clashes[0]
|
||||
|
||||
def test_bytedance_c2pa_plus_foreign_generator_clashes(self):
|
||||
# Coverage win: a transplanted ByteDance C2PA manifest next to an independent
|
||||
# foreign generator stamp is a laundering tell that went undetected before
|
||||
# ByteDance was added to _vendor_of.
|
||||
clashes = _integrity_clashes({"c2pa": "ByteDance", "exif_generator": "OpenAI"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "ByteDance" in clashes[0]
|
||||
assert "OpenAI" in clashes[0]
|
||||
|
||||
def test_manifest_vendor_vs_independent_signal_clashes(self):
|
||||
# A vendor named only inside the manifest still clashes with a genuinely
|
||||
# independent stamp (here an EXIF/XMP generator tag) naming a third vendor.
|
||||
@@ -772,6 +1044,31 @@ class TestIntegrityClashesHelper:
|
||||
# must NOT raise a clash.
|
||||
assert _integrity_clashes({}, "Leica (camera, C2PA capture)", camera_has_ai_marker=False) == []
|
||||
|
||||
def test_pixel_generative_edit_same_manifest_no_clash(self):
|
||||
# A Google Pixel that BOTH captures and runs on-device generative AI
|
||||
# (Magic Editor / Pixel Studio) records the capture and the AI edit in
|
||||
# ONE C2PA manifest -- the AI vendor is named only from that same
|
||||
# manifest (c2pa / synthid), independent of nothing. That is a legitimate
|
||||
# edit chain, NOT a camera-vs-AI contradiction, so rule 2 must stay quiet.
|
||||
assert (
|
||||
_integrity_clashes(
|
||||
{"c2pa": "Google", "synthid": "Google"},
|
||||
"Google Pixel (camera, C2PA capture)",
|
||||
camera_has_ai_marker=True,
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_camera_plus_independent_ai_marker_still_clashes(self):
|
||||
# But a camera capture next to an AI marker from a genuinely INDEPENDENT
|
||||
# source (EXIF/XMP generator, TC260 AIGC, ...) is still a laundering tell.
|
||||
clashes = _integrity_clashes(
|
||||
{"c2pa": "Google", "aigc": "China AIGC (TC260)"},
|
||||
"Google Pixel (camera, C2PA capture)",
|
||||
camera_has_ai_marker=True,
|
||||
)
|
||||
assert any("Camera-capture" in c for c in clashes)
|
||||
|
||||
|
||||
class TestIntegrityClashEndToEnd:
|
||||
def _c2pa_jpeg(self, tmp_path: Path, blob: bytes) -> Path:
|
||||
@@ -806,6 +1103,22 @@ class TestIntegrityClashEndToEnd:
|
||||
assert r.platform == "Google Pixel (camera, C2PA capture)"
|
||||
assert any("Camera-capture C2PA credentials" in c and "AI-generation markers" in c for c in r.integrity_clashes)
|
||||
|
||||
def test_pixel_generative_edit_no_clash(self, tmp_path: Path):
|
||||
# A real Google Pixel generative edit (Magic Editor / Pixel Studio) signs
|
||||
# ONE manifest carrying both the Pixel Camera capture and a Google
|
||||
# Generative AI edit (trainedAlgorithmicMedia + "Applied imperceptible
|
||||
# SynthID watermark"). The AI marker lives in the SAME manifest as the
|
||||
# device, so it is an edit chain, not a camera-vs-AI contradiction.
|
||||
path = self._c2pa_jpeg(
|
||||
tmp_path,
|
||||
b"Pixel Camera ... Created by Pixel Camera ... computationalCapture ... "
|
||||
b"Created by Google Generative AI ... trainedAlgorithmicMedia ... "
|
||||
b"Applied imperceptible SynthID watermark",
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.integrity_clashes == []
|
||||
|
||||
def test_clash_serializes_to_json(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||