Compare commits
No files matched your search
@@ -0,0 +1,33 @@
|
||||
---
|
||||
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
|
||||
description: Command contracts, project gate, typing boundaries, and model-adjacent test invariants.
|
||||
---
|
||||
|
||||
# Development invariants
|
||||
|
||||
## Command contracts
|
||||
|
||||
Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard.
|
||||
|
||||
Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it.
|
||||
|
||||
## Local gate
|
||||
|
||||
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
|
||||
|
||||
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
|
||||
|
||||
## Model-adjacent tests
|
||||
|
||||
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
|
||||
|
||||
- target-size selection in `test_invisible_engine.py`;
|
||||
- unsharp and adaptive-polish helpers in `test_humanizer.py`;
|
||||
- mocked device fallback in `test_img2img_runner.py`;
|
||||
- tiling geometry and blending in `test_tiling.py`;
|
||||
- prompt-embedding cache keying, storage round-trip, and the cross-pipeline reuse
|
||||
that lets a stack load without its text encoder, in `test_qwen_zimage_pipeline.py`.
|
||||
|
||||
Use availability checks only for paths that actually load large models.
|
||||
|
||||
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
|
||||
@@ -1,3 +1,3 @@
|
||||
# HuggingFace token (required for invisible watermark removal)
|
||||
# HuggingFace token (optional; only needed for gated/private models)
|
||||
# Get yours at: https://huggingface.co/settings/tokens
|
||||
HF_TOKEN=
|
||||
# HF_TOKEN=
|
||||
@@ -0,0 +1,2 @@
|
||||
# Enables the "Sponsor" button on the repository page.
|
||||
github: wiltodelta
|
||||
@@ -14,6 +14,21 @@ updates:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
# The `all` extra must remain jointly resolvable. These candidates violate
|
||||
# upstream constraints: TrustMark still requires NumPy 1.x, OpenCV 4.12+
|
||||
# requires NumPy 2 on newer Python versions, and this project keeps tokenizers
|
||||
# below 0.23 for the stable Transformers path. Keep Dependabot on the
|
||||
# compatible lines until those constraints move.
|
||||
ignore:
|
||||
- dependency-name: "numpy"
|
||||
versions:
|
||||
- ">=2"
|
||||
- dependency-name: "opencv-python-headless"
|
||||
versions:
|
||||
- ">=4.12"
|
||||
- dependency-name: "tokenizers"
|
||||
versions:
|
||||
- ">=0.23"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
name: Distribute on release
|
||||
|
||||
# Fans a published GitHub Release out to the channels that need a nudge.
|
||||
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
|
||||
# autotick bot. This workflow updates the repository recipe and event-drives
|
||||
# the three 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.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# All 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
|
||||
|
||||
conda-recipe:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
- name: Bump the repository conda recipe
|
||||
env:
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python scripts/sync_conda_recipe.py --version "$VERSION" --sha256 "$SHA"
|
||||
git diff --check
|
||||
if git diff --quiet -- packaging/conda/recipe.yaml; then
|
||||
echo "Conda recipe already current"
|
||||
exit 0
|
||||
fi
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add packaging/conda/recipe.yaml
|
||||
git commit -m "Sync conda recipe with v$VERSION"
|
||||
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
|
||||
|
||||
comfyui:
|
||||
needs: resolve
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Sync and publish the ComfyUI nodes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.COMFYUI_RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=wiltodelta/ComfyUI-remove-ai-watermarks
|
||||
workflow=sync-library.yml
|
||||
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
|
||||
gh workflow run "$workflow" \
|
||||
--repo "$repo" \
|
||||
--ref main \
|
||||
--raw-field library_version="${{ needs.resolve.outputs.version }}"
|
||||
|
||||
run_id=""
|
||||
for attempt in $(seq 1 30); do
|
||||
run_id=$(
|
||||
gh run list \
|
||||
--repo "$repo" \
|
||||
--workflow "$workflow" \
|
||||
--event workflow_dispatch \
|
||||
--limit 10 \
|
||||
--json databaseId,createdAt \
|
||||
| python3 -c '
|
||||
import json
|
||||
import sys
|
||||
|
||||
dispatched_at = sys.argv[1]
|
||||
runs = json.load(sys.stdin)
|
||||
print(next(
|
||||
(run["databaseId"] for run in runs if run["createdAt"] >= dispatched_at),
|
||||
"",
|
||||
))
|
||||
' "$dispatched_at"
|
||||
)
|
||||
if [ -n "$run_id" ]; then
|
||||
break
|
||||
fi
|
||||
echo "ComfyUI sync run not visible yet (attempt $attempt), waiting..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ -z "$run_id" ]; then
|
||||
echo "::error::The dispatched ComfyUI sync run did not appear"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for attempt in $(seq 1 180); do
|
||||
read -r status conclusion <<< "$(
|
||||
gh run view "$run_id" \
|
||||
--repo "$repo" \
|
||||
--json status,conclusion \
|
||||
--jq '[.status, (.conclusion // "")] | @tsv'
|
||||
)"
|
||||
if [ "$status" = "completed" ]; then
|
||||
if [ "$conclusion" = "success" ]; then
|
||||
echo "ComfyUI sync run $run_id succeeded"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::ComfyUI sync run $run_id completed with $conclusion"
|
||||
exit 1
|
||||
fi
|
||||
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
|
||||
sleep 5
|
||||
done
|
||||
echo "::error::ComfyUI sync run $run_id did not finish in time"
|
||||
exit 1
|
||||
@@ -13,12 +13,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Verify release tag matches package version
|
||||
run: |
|
||||
tag="${{ github.event.release.tag_name }}"
|
||||
version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)"/\1/')"
|
||||
if [ "${tag#v}" != "$version" ]; then
|
||||
echo "Release tag '$tag' does not match pyproject.toml version '$version'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release tag '$tag' matches package version '$version'"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
# Publish with `uv publish` (its own uploader, not the twine bundled in the
|
||||
# old pypa action). Trusted publishing is automatic in GitHub Actions: the
|
||||
# `id-token: write` permission + the `pypi` environment supply the OIDC token,
|
||||
# and PyPI's trusted-publisher entry matches on repo + workflow filename +
|
||||
# environment name (all unchanged from the pypa-action setup), so no API token
|
||||
# is needed. uv's uploader also accepts Metadata-Version 2.5 -- the permanent
|
||||
# fix that lets the hatchling pin be relaxed (see pyproject [build-system]).
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
run: uv publish
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Ruff lint
|
||||
run: uv run ruff check
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check
|
||||
|
||||
test:
|
||||
name: test ${{ matrix.os }} py${{ matrix.python-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Cross-OS x Python-floor/recent. The default-install surface only
|
||||
# needs the core deps plus dev tooling (no GPU extra): the model-running
|
||||
# paths are availability-gated and skip when torch/diffusers are absent,
|
||||
# so this matrix exercises metadata, identify, visible, and the cv2
|
||||
# eraser on every OS without pulling the heavy ML stack.
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run tests
|
||||
run: uv run pytest -q
|
||||
|
||||
video-e2e:
|
||||
name: video full-clip end-to-end
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Install ffmpeg
|
||||
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run full-clip video test
|
||||
run: >-
|
||||
uv run pytest -vv -o faulthandler_timeout=60
|
||||
tests/test_video.py::TestVideoVisibleFullClip
|
||||
@@ -4,6 +4,8 @@ __pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
tmp/
|
||||
.sc/
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
@@ -18,11 +20,9 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test results
|
||||
data/results/
|
||||
|
||||
# SynthID corpus images (manifest.csv + README.md stay tracked)
|
||||
data/synthid_corpus/images/
|
||||
# SynthID corpus reference fills (synthetic black/white calibration tiles,
|
||||
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
|
||||
data/synthid_corpus/refs/
|
||||
|
||||
# Reference materials
|
||||
_refs/
|
||||
@@ -33,4 +33,28 @@ yolov8n.pt
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
data/synthid_corpus/refs/
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
|
||||
# committed (content-free: a solid colour + the watermark; the source for
|
||||
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
|
||||
# synthetic seeds (regenerable) and any real-content validation download (a real
|
||||
# generated scene, kept local for privacy) are NOT committed.
|
||||
data/doubao_capture/seeds/
|
||||
data/jimeng_capture/seeds/
|
||||
data/jimeng_capture/captures/jimeng_content_*.png
|
||||
data/gemini_capture/seeds/
|
||||
data/gemini_capture/captures/gemini_content_*.png
|
||||
data/samsung_capture/seeds/
|
||||
data/samsung_capture/captures/samsung_content_*
|
||||
|
||||
# Leftover GFPGAN weights dir from the retired face-restore experiments
|
||||
# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working
|
||||
# dir on first use). Runtime artifact, never committed.
|
||||
gfpgan/
|
||||
|
||||
# Qwen ControlNet experiment outputs (throwaway eval; never the committed corpus)
|
||||
scripts/_qwen_exp_out/
|
||||
|
||||
# Local-only working data for analysis (not a committed corpus; never tracked)
|
||||
.local-eval/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,53 +1,54 @@
|
||||
# Remove-AI-Watermarks
|
||||
# Remove AI Watermarks
|
||||
|
||||
You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI watermarks from images.
|
||||
You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI provenance watermarks.
|
||||
|
||||
## Scope and non-goals
|
||||
|
||||
The project gives users control over provenance marks on content they generated or edited themselves. It does not automatically remove stock-agency, marketplace, classifieds, tiled-preview, or other marks that protect a third party's paid or copyrighted asset.
|
||||
|
||||
- Add visible templates only for AI-generation labels.
|
||||
- Do not add stock, agency, or classifieds marks to `watermark_registry.py`.
|
||||
- Keep `erase --region` generic and user-directed; do not build an automatic stock-watermark remover on it.
|
||||
|
||||
Full boundary and legal context: [`docs/legal-and-safety.md`](docs/legal-and-safety.md).
|
||||
|
||||
## How to run
|
||||
|
||||
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
|
||||
- `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
|
||||
```bash
|
||||
uv run remove-ai-watermarks --help
|
||||
bash maintain.sh
|
||||
```
|
||||
|
||||
## Test and lint
|
||||
|
||||
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
|
||||
- `maintain.sh` may not finish fully green (pre-existing, not per-change): strict pyright carries debt in `remove_ai_metadata` / `cli.py` (untyped piexif/PIL/click/rich). (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.) To gate a change, run `uv run ruff check`, `uv run pyright <changed files>`, `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.
|
||||
- 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, `not-ai-*` = clean); synthetic byte blobs cover the JPEG/ISOBMFF format paths.
|
||||
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/` (`manifest.csv` tracked, `images/` gitignored); see its README for the collection protocol and verification oracles.
|
||||
Run `uv` from the repository root. Command selection, options, defaults, and examples live in [`docs/cli.md`](docs/cli.md). Before changing command routing, no-signal behavior, or exit codes, read the command-line section of [`docs/module-internals.md`](docs/module-internals.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
|
||||
- Tests for ML modules are limited to availability checks (require multi-GB downloads)
|
||||
GPU and ML modules are optional. Guard their imports with `is_available()`.
|
||||
|
||||
## Key modules
|
||||
Optional features and installation groups are documented in [`docs/installation.md`](docs/installation.md). Model-running paths may use availability tests, while pure helpers in ML-adjacent modules must remain unit-tested without downloads.
|
||||
|
||||
- `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.
|
||||
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, and `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI). Add a new issuer here, not inline.
|
||||
- `metadata.py` — `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.
|
||||
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, IPTC "Made with AI", embedded SD/ComfyUI params, SynthID proxy, visible Gemini sparkle) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. Add platform mappings to `_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` and XMP `CreatorTool` generator tags 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. **Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
|
||||
- `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`.
|
||||
- `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.
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
|
||||
## Test and lint
|
||||
|
||||
## Watermarking landscape (research 2026-05-24)
|
||||
`maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory.
|
||||
|
||||
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).
|
||||
- **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).
|
||||
- **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` json-decodes the (HTML-entity-encoded) block; container-agnostic raw-byte scan.
|
||||
- **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.
|
||||
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
|
||||
|
||||
## Known limitations
|
||||
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Keep the source-distribution public allowlist.
|
||||
|
||||
- `invisible` pipeline processes at **native resolution by default** (`max_resolution=0`), matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need the ~1024 downscale. `--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 point (verified 2026-05-25 on a 1254x1254 gpt-image SDXL run, fp32, 20 GB MPS ceiling):** 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. Adding `enable_vae_tiling()` alone does NOT prevent it (the peak is the UNet, not the VAE). The fast Mac workarounds are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The native-vs-downscale decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution)` (returns `None` for native, a clamped target tuple otherwise) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.
|
||||
- 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.
|
||||
- `ultralytics` monkey-patches `PIL.Image.open` and tries to autoload `pi_heif`. When `pi_heif` is missing, opening files raises `ModuleNotFoundError`, not `UnidentifiedImageError`. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
|
||||
- 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). EXIF/XMP boxes inside those containers are read for detection but not yet **scrubbed** on removal.
|
||||
- **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). 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). 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 (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.
|
||||
- **SynthID v2 vs default pipeline:** the SDXL-based default profile (since May 2026) defeats SynthID v2. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain.
|
||||
## Module architecture
|
||||
|
||||
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
|
||||
|
||||
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md).
|
||||
|
||||
## Data safety
|
||||
|
||||
Follow [`data/README.md`](data/README.md) for public fixture, calibration, oracle, and evaluation layout. Store each tracked binary once and keep generated evaluation outputs outside the repository.
|
||||
|
||||
## Rules and conventions
|
||||
|
||||
Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when matching files are touched.
|
||||
|
||||
| File | Covers |
|
||||
|---|---|
|
||||
| `development.md` | Command contracts, project gate, typing boundaries, and model-adjacent tests |
|
||||
@@ -1,21 +1,217 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 wiltodelta
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
1. Definitions.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
|
||||
Copyright 2025-2026 wiltodelta
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,338 +1,438 @@
|
||||
# Remove-AI-Watermarks
|
||||
# Remove AI Watermarks
|
||||
|
||||
Remove **visible** and **invisible** AI watermarks from images generated by Google Gemini (Nano Banana), ChatGPT / DALL-E, Stable Diffusion, Adobe Firefly, Midjourney, and other AI models.
|
||||
Remove AI provenance marks from images and video you generated yourself:
|
||||
|
||||
Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command.
|
||||
- known visible labels such as the Gemini sparkle and vendor text marks;
|
||||
- invisible pixel watermarks through diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
## Features
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
removal.
|
||||
|
||||
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo via reverse alpha blending (fast, offline, deterministic)
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL), XMP DigitalSourceType
|
||||
- **"Made with AI" label removal** — removes the metadata that triggers AI labels on Instagram, Facebook, X (Twitter)
|
||||
- **Analog Humanizer** — film grain and chromatic aberration to bypass AI image classifiers
|
||||
- **Smart Face Protection** — automatic extraction and blending of human faces to prevent AI distortion
|
||||
- **Batch processing** — process entire directories
|
||||
- **Detection** — three-stage NCC watermark detection with confidence scoring
|
||||
- **Provenance detection (`identify`)** — aggregate C2PA issuer, IPTC "Made with AI", embedded SD/ComfyUI params, EXIF/XMP generator tags, the SynthID metadata proxy, the visible sparkle, and the open SD/SDXL/FLUX invisible watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
|
||||
> or run diffusion models locally.
|
||||
|
||||
> **Try it online** — don't want to install anything? Use [raiw.cc](https://raiw.cc), a free web service powered by this library.
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
[](https://pepy.tech/project/remove-ai-watermarks)
|
||||
[](LICENSE)
|
||||
[](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
|
||||
[](https://github.com/sponsors/wiltodelta)
|
||||
|
||||
> This project is for lawful use on content you own. It does not target stock
|
||||
> agency previews or other watermarks that protect third party paid content.
|
||||
> See [scope, safety, and legal notes](docs/legal-and-safety.md).
|
||||
|
||||
## Choose what you want to do
|
||||
|
||||
| Goal | Command | GPU |
|
||||
| --- | --- | --- |
|
||||
| Find provenance signals and watermarks | `identify` | No |
|
||||
| Remove known visible AI marks | `visible` | No |
|
||||
| Erase a region you select | `erase` | No |
|
||||
| Strip AI metadata | `metadata` | No |
|
||||
| Identify supported video provenance | `video identify` | No |
|
||||
| Remove visible marks and AI metadata from video | `video all` | No |
|
||||
| Strip AI metadata from video | `video metadata` | No |
|
||||
| Remove a registered visible AI mark from video | `video visible` | No |
|
||||
| Process a directory of videos | `video batch` | Depends on mode |
|
||||
| Remove video SynthID with the certified VAE profile | `video invisible` | Recommended |
|
||||
| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended |
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
## Installation modes
|
||||
|
||||
| Need | Install |
|
||||
| --- | --- |
|
||||
| Metadata inspection and stripping | `remove-ai-watermarks` |
|
||||
| Visible detection and removal | `remove-ai-watermarks[visible]` |
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Diffusion removal | `remove-ai-watermarks[diffusion]` |
|
||||
| Every production feature | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, `esrgan`, and `qwen-zimage`. The
|
||||
[installation guide](docs/installation.md#feature-extras) documents their exact
|
||||
dependency composition and model requirements.
|
||||
|
||||
## Quick start
|
||||
|
||||
Install the metadata-focused default CLI:
|
||||
|
||||
```bash
|
||||
uv tool install remove-ai-watermarks
|
||||
```
|
||||
|
||||
Inspect an image:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify image.png
|
||||
```
|
||||
|
||||
For visible watermark removal, install the pixel dependencies:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[visible]"
|
||||
```
|
||||
|
||||
Then remove a known visible mark and AI metadata:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
```
|
||||
|
||||
Strip metadata without running visible inpainting or diffusion:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks metadata image.png --remove -o clean.png
|
||||
```
|
||||
|
||||
Inspect or remove AI metadata from an MP4, MOV, M4V, WebM, MKV, AVI, or FLV
|
||||
file:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video metadata input.mp4 --check
|
||||
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
|
||||
```
|
||||
|
||||
The metadata command does not transcode video or audio streams. When `-o` is
|
||||
omitted it writes `<source>_clean` and preserves the original. MP4 and MOV
|
||||
inspection includes the native TC260 `AIGC` tag in
|
||||
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload.
|
||||
MKV and WebM inspection reads the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
|
||||
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
|
||||
copy for removal.
|
||||
|
||||
Use the product-oriented video path to identify or clean a file:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
remove-ai-watermarks video identify input.mp4
|
||||
remove-ai-watermarks video all input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
`video all` removes a stable registered visible mark when present and always
|
||||
strips verified AI metadata. If neither signal is found, it still writes a
|
||||
same-container passthrough, so application callers get one predictable output
|
||||
contract. Proprietary invisible-video removal is excluded by default.
|
||||
`--invisible` opts into the lossy, oracle-certified video SynthID profile.
|
||||
|
||||
Process a directory with the same contract:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video batch ./videos --mode all
|
||||
```
|
||||
|
||||
Remove a supported visible video mark:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video visible input.mp4 -o clean.mp4
|
||||
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
|
||||
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
|
||||
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
|
||||
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
|
||||
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
```
|
||||
|
||||
This path scans the complete sequence before changing pixels. It accepts only a
|
||||
mark that repeats at a stable position across adjacent frames, then reuses the
|
||||
same OpenCV, MI-GAN, or LaMa fill backends as image removal. Audio is copied
|
||||
without re-encoding and is allowed to reach its natural end; the video stream
|
||||
is transcoded because its pixels change. By default, a guarded optical-flow
|
||||
pass motion-aligns the preceding accepted fill and blends it only when the
|
||||
nearby source context agrees; use `--no-temporal-consistency` to disable it.
|
||||
The encoder preserves supported
|
||||
8-bit source chroma sampling, color tags, and MP4/MOV track timescale instead
|
||||
of relying on ffmpeg's implicit raw-BGR defaults. Variable frame intervals are
|
||||
preserved through a timestamped in-memory NUT bridge instead of being flattened
|
||||
to the average frame rate. Non-zero source start timestamps are retained
|
||||
together with the copied audio offset. The default `--mark auto`
|
||||
scans all providers in one decode pass and selects the first stable match in
|
||||
the specificity order shown below. Pass an explicit mark to restrict detection
|
||||
to one provider.
|
||||
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
|
||||
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
|
||||
label with its version suffix. A completed encode is published atomically. No
|
||||
output is written when no stable mark is found.
|
||||
HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather
|
||||
than silently reduced through OpenCV's 8-bit BGR boundary.
|
||||
|
||||
Remove video SynthID:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
This path regenerates the complete sequence with one latent-noise field shared
|
||||
across time, copies complete audio, strips source metadata, and publishes the
|
||||
completed encode atomically. The default `noise_std=0.15` profile passed both
|
||||
the two-carrier calibration and a complete public eight-second Veo oracle
|
||||
check. Google does not publish a local decoder, so a fresh provider check
|
||||
remains useful for unusually important files or after provider changes, but it
|
||||
is not a product result state.
|
||||
|
||||
For invisible watermark removal, install the diffusion dependencies:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png
|
||||
```
|
||||
|
||||
If the local detectors cannot confirm an invisible watermark but you know the
|
||||
image came from an AI generator, add `--force`:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
See the [installation guide](docs/installation.md) for Homebrew, uv, optional
|
||||
features, and development setup.
|
||||
|
||||
## Examples
|
||||
|
||||
| Before (Watermarked) | After (Cleaned) |
|
||||
### Visible Gemini mark
|
||||
|
||||
| Before | After |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Supported models
|
||||
### High quality invisible removal
|
||||
|
||||
| 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 |
|
||||
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
|
||||
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ✅ SynthID + content-specific pixel watermark (since May 2026; no local decoder, openai.com/verify oracle) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
|
||||
| **Stable Diffusion / SDXL (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
|
||||
| **Black Forest Labs FLUX** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ C2PA (FLUX.2 Pro) | Diffusion regeneration + metadata strip |
|
||||
| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip |
|
||||
| **Stability AI** (DreamStudio / Stable Image) | — | — | ✅ C2PA ("Stability AI Ltd") | Metadata strip |
|
||||
| **Microsoft Designer / Bing Image Creator** | — | ✅ SynthID via DALL-E backend (Designer) | ✅ C2PA (Bing runs MAI-Image, signed "Microsoft") | Metadata strip |
|
||||
| **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 | — | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | Metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
The `qwen-zimage` profile is the highest fidelity option for face heavy images.
|
||||
It is CUDA only and uses a much larger model stack than the default ControlNet
|
||||
profile.
|
||||
|
||||
> Visible watermarks (logo overlays) are currently used only by Google Gemini / Nano Banana. Other services rely on invisible watermarks and/or metadata. Our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain.
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline qwen-zimage --force
|
||||
```
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, IPTC "Made with AI", the China TC260 AIGC label, embedded generation params, EXIF/XMP generator tags, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` extra) the open SD/SDXL/FLUX invisible watermark. The SynthID *pixel* watermark has no local decoder, so it is reported as a metadata proxy only.
|
||||
| OpenAI example before | OpenAI example after |
|
||||
| --- | --- |
|
||||
| [](data/synthid/originals/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png) | [](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png) |
|
||||
|
||||
| Gemini example before | Gemini example after |
|
||||
| --- | --- |
|
||||
| [](data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png) | [](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png) |
|
||||
|
||||
These exact output files were checked with the matching provider verifiers. That
|
||||
result applies to these files, not to every seed, image, or future watermark
|
||||
version.
|
||||
|
||||
## Common recipes
|
||||
|
||||
### Remove every detected visible mark
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
```
|
||||
|
||||
The default `--mark auto` checks all registered visible marks and removes every
|
||||
match. If the mark is visible to you but the detector misses it, select its
|
||||
region explicitly:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png \
|
||||
--region 1640,1930,400,100 \
|
||||
-o clean.png
|
||||
```
|
||||
|
||||
`--region` uses `x,y,width,height` and may be repeated.
|
||||
|
||||
### Use a learned fill backend
|
||||
|
||||
The `visible` extra uses OpenCV inpainting when no learned backend is installed.
|
||||
For more difficult backgrounds, the learned-backend extras include the same
|
||||
pixel dependencies automatically:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[migan]"
|
||||
remove-ai-watermarks visible image.png -o clean.png --backend migan
|
||||
```
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[lama]"
|
||||
remove-ai-watermarks visible image.png -o clean.png --backend lama
|
||||
```
|
||||
|
||||
### Reduce CUDA memory use
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--cpu-offload --force
|
||||
```
|
||||
|
||||
CPU offload lowers CUDA memory pressure by moving model components between CPU
|
||||
and GPU. It is slower and has no effect on CPU or MPS.
|
||||
|
||||
### Process a directory
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks batch ./images --mode visible
|
||||
remove-ai-watermarks batch ./images --mode all
|
||||
```
|
||||
|
||||
## What the tool can recognize
|
||||
|
||||
Visible mark support includes:
|
||||
|
||||
- Google Gemini and Nano Banana sparkle;
|
||||
- Doubao, Jimeng, Qwen, Kling, Baidu, LibLibAI, and RunningHub labels;
|
||||
- one calibrated Samsung Galaxy AI label variant.
|
||||
|
||||
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
|
||||
generator parameters, China TC260 AIGC labels, and several vendor specific
|
||||
signals. Optional decoders add support for open DWT-DCT watermarks and Adobe
|
||||
TrustMark.
|
||||
|
||||
The exact support matrix, including important locale and detector limits, lives
|
||||
in [supported signals](docs/supported-signals.md).
|
||||
|
||||
## How it works
|
||||
|
||||
### Removing the Gemini / Nano Banana sparkle watermark
|
||||
Visible removal follows three steps:
|
||||
|
||||
Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo to generated images using alpha blending:
|
||||
1. Detect a registered mark in its expected area.
|
||||
2. Build a mask around the mark.
|
||||
3. Fill only the masked region with OpenCV, MI-GAN, or LaMa.
|
||||
|
||||
```text
|
||||
watermarked = α × logo + (1 − α) × original
|
||||
```
|
||||
Metadata removal uses format aware stripping. JPEG metadata removal preserves
|
||||
the encoded image scan instead of recompressing it. Native MP4/MOV TC260 values
|
||||
are blanked without changing box sizes or media offsets. Other supported
|
||||
containers use their corresponding metadata path.
|
||||
|
||||
We reverse this with a known alpha map (extracted from Gemini / Nano Banana output on a pure-black background):
|
||||
Invisible removal is different. It regenerates the image through a diffusion
|
||||
pipeline to disrupt pixel and frequency domain watermarks. This changes the
|
||||
image and cannot guarantee that a proprietary verifier will reject every
|
||||
output.
|
||||
|
||||
```text
|
||||
original = (watermarked − α × logo) / (1 − α)
|
||||
```
|
||||
See [supported signals](docs/supported-signals.md) and
|
||||
[known limitations](docs/known-limitations.md) for the full technical boundary.
|
||||
|
||||
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.
|
||||
## Python API
|
||||
|
||||
**Speed**: ~0.05s per image. No GPU needed.
|
||||
|
||||
### Removing SynthID and other invisible watermarks
|
||||
|
||||
Google embeds **SynthID** into every image generated by Gemini / Nano Banana. Other AI services use StableSignature, TreeRing, and similar schemes. These imperceptible frequency-domain patterns survive cropping, resizing, and JPEG compression.
|
||||
|
||||
The removal pipeline (default profile, SDXL):
|
||||
|
||||
```text
|
||||
image → encode to latent space (VAE) at native resolution
|
||||
→ add controlled noise (forward diffusion)
|
||||
→ denoise (reverse diffusion, ~50 steps at strength 0.05)
|
||||
→ decode back to pixels (VAE)
|
||||
```
|
||||
|
||||
By default the image is processed at its **native resolution** with no pre-downscale, matching the hosted raiw.cc backend (fal `fast-sdxl`, which is `stabilityai/stable-diffusion-xl-base-1.0` — the same checkpoint the CLI defaults to). At strength ~0.05 SDXL img2img does not need the input shrunk, and the old forced downscale-to-1024 then upscale-back round-trip was the main quality loss. Pass `--max-resolution N` to cap the long side only when a very large image runs out of GPU/MPS memory (it reintroduces that lossy round-trip).
|
||||
|
||||
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2.
|
||||
|
||||
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
|
||||
|
||||
**Analog Humanizer**: optional film grain and chromatic aberration injection that makes the output indistinguishable from a photo of a screen, defeating AI-generated image classifiers.
|
||||
|
||||
### Stripping C2PA, EXIF, and "Made with AI" metadata
|
||||
|
||||
AI tools embed generation metadata that social platforms use to show "Made with AI" labels:
|
||||
|
||||
- **EXIF tags** — prompt, seed, model hash, sampler settings (Stable Diffusion, Midjourney)
|
||||
- **XMP DigitalSourceType** — `trainedAlgorithmicMedia` tag used by Instagram, Facebook, and X (Twitter) to show "Made with AI"
|
||||
- **PNG text chunks** — ComfyUI workflows, AUTOMATIC1111 parameters
|
||||
- **C2PA Content Credentials** — cryptographic provenance manifests from Google Imagen, OpenAI DALL-E, Adobe Firefly
|
||||
|
||||
The cleaner parses each layer, removes AI-related fields, and preserves standard metadata (Author, Copyright, Title).
|
||||
|
||||
## Installation
|
||||
|
||||
### Recommended
|
||||
|
||||
Install as an isolated CLI tool — no need to manage virtual environments:
|
||||
|
||||
```bash
|
||||
# Using pipx (https://pipx.pypa.io)
|
||||
pipx install git+https://github.com/wiltodelta/remove-ai-watermarks.git
|
||||
|
||||
# Or using uv (https://docs.astral.sh/uv)
|
||||
uv tool install git+https://github.com/wiltodelta/remove-ai-watermarks.git
|
||||
```
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
pipx upgrade remove-ai-watermarks
|
||||
|
||||
# or
|
||||
uv tool upgrade remove-ai-watermarks
|
||||
```
|
||||
|
||||
### Install from repository
|
||||
|
||||
**Prerequisites:** Python 3.10+ and `pip` (or [`uv`](https://docs.astral.sh/uv/)).
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/wiltodelta/remove-ai-watermarks.git
|
||||
cd remove-ai-watermarks
|
||||
|
||||
# 2. Install the package in editable mode
|
||||
pip install -e .
|
||||
|
||||
# Or, if you use uv:
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
After installation the `remove-ai-watermarks` command is available system-wide.
|
||||
|
||||
> **Note**: The base install covers visible watermark removal and metadata stripping.
|
||||
> For invisible watermark removal (SynthID etc.), install GPU dependencies:
|
||||
>
|
||||
> ```bash
|
||||
> pip install -e ".[gpu]" # or: uv pip install -e ".[gpu]"
|
||||
> ```
|
||||
>
|
||||
> To let `identify` decode the open Stable Diffusion / SDXL / FLUX invisible
|
||||
> watermarks, install the `detect` extra (adds the `invisible-watermark` decoder):
|
||||
>
|
||||
> ```bash
|
||||
> pip install -e ".[detect]" # or: uv pip install -e ".[detect]"
|
||||
> ```
|
||||
|
||||
#### Invisible watermark removal
|
||||
|
||||
Invisible removal uses diffusion models and a GPU for reasonable speed.
|
||||
|
||||
```bash
|
||||
# On first run, the model (~2 GB) will be downloaded automatically.
|
||||
# Device is auto-detected: CUDA (Linux/Windows) > MPS (macOS) > CPU.
|
||||
# To force a device: --device cuda / --device mps / --device cpu
|
||||
|
||||
# Optional: set a HuggingFace token for gated/private models
|
||||
cp .env.example .env
|
||||
# Edit .env and set HF_TOKEN=hf_your_token_here
|
||||
```
|
||||
|
||||
#### Developer setup
|
||||
|
||||
```bash
|
||||
# Install with dev dependencies (pytest, ruff, pyright)
|
||||
pip install -e ".[dev]"
|
||||
# Or with uv:
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
# Run tests
|
||||
pytest
|
||||
|
||||
# Run linters
|
||||
./maintain.sh
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Remove all watermarks from a single image (visible + invisible + metadata)
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
|
||||
# Process an entire directory
|
||||
remove-ai-watermarks batch ./images/ --mode all
|
||||
```
|
||||
|
||||
#### Individual commands
|
||||
|
||||
```bash
|
||||
# Identify provenance: where an image was made + its watermark inventory.
|
||||
# Aggregates C2PA, IPTC "Made with AI", embedded SD/ComfyUI params, EXIF/XMP
|
||||
# generator tags (incl. inside AVIF/HEIF), the SynthID proxy, the visible Gemini
|
||||
# sparkle, and (with the [detect] extra) the open SD/SDXL/FLUX invisible
|
||||
# watermark into one verdict. Reports "unknown"
|
||||
# (never "clean") when no signal is found, since stripped metadata is not proof
|
||||
# of a clean origin. Add --json for machine-readable output.
|
||||
remove-ai-watermarks identify image.png
|
||||
|
||||
# Visible watermark only (Gemini / Nano Banana sparkle) — fast, offline
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Invisible watermark only (SynthID etc.) — requires GPU
|
||||
remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0
|
||||
# Runs at native resolution by default. On a very large image that OOMs the
|
||||
# GPU/MPS, cap the long side: --max-resolution 2048
|
||||
|
||||
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
|
||||
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
|
||||
# Google or OpenAI implies an invisible SynthID watermark in the pixels
|
||||
# (both vendors pair the two). Adobe Firefly / Microsoft sign C2PA without
|
||||
# SynthID, so they are reported as C2PA only.
|
||||
remove-ai-watermarks metadata image.png --check
|
||||
remove-ai-watermarks metadata image.png --remove
|
||||
|
||||
# Batch with a specific mode
|
||||
remove-ai-watermarks batch ./images/ --mode visible
|
||||
```
|
||||
|
||||
### Python API
|
||||
The visible-removal API requires `remove-ai-watermarks[visible]`.
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
import cv2
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
engine = GeminiEngine()
|
||||
image = cv2.imread("watermarked.png")
|
||||
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
|
||||
print(removed)
|
||||
|
||||
# Detect
|
||||
result = engine.detect_watermark(image)
|
||||
print(f"Detected: {result.detected} (confidence: {result.confidence:.1%})")
|
||||
|
||||
# Remove
|
||||
clean = engine.remove_watermark(image)
|
||||
cv2.imwrite("clean.png", clean)
|
||||
provenance = raiw.identify_video("input.mp4")
|
||||
report = raiw.inspect_video_metadata("input.mp4")
|
||||
complete = raiw.remove_video_all("input.mp4", "clean.mp4")
|
||||
batch = raiw.remove_video_batch("videos", "videos_clean")
|
||||
cleaned = raiw.remove_video_metadata("input.mp4")
|
||||
synthid_cleaned = raiw.remove_video_invisible("input.mp4", "synthid_clean.mp4")
|
||||
visible = raiw.remove_video_visible("input.mp4", "clean.mp4")
|
||||
print(visible.mark)
|
||||
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
|
||||
seedance = raiw.remove_video_visible(
|
||||
"seedance.mp4",
|
||||
"seedance_clean.mp4",
|
||||
mark="seedance",
|
||||
)
|
||||
dola = raiw.remove_video_visible("dola.mp4", "dola_clean.mp4", mark="dola")
|
||||
```
|
||||
|
||||
### Metadata stripping
|
||||
The high level API accepts a file path or a BGR NumPy array. For path inputs it
|
||||
also reads provenance metadata, preserves alpha, and can strip AI metadata from
|
||||
the written result.
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata, remove_ai_metadata
|
||||
from pathlib import Path
|
||||
See the [Python API guide](docs/python-api.md) for visible removal, provenance
|
||||
inspection, metadata stripping, and diffusion usage.
|
||||
|
||||
if has_ai_metadata(Path("image.png")):
|
||||
remove_ai_metadata(Path("image.png"), Path("clean.png"))
|
||||
```
|
||||
## ComfyUI
|
||||
|
||||
## Requirements
|
||||
The separate
|
||||
[ComfyUI Remove AI Watermarks](https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks)
|
||||
package provides nodes for visible removal, detection, region erasing, and
|
||||
invisible removal.
|
||||
|
||||
- Python ≥ 3.10
|
||||
- **Visible removal / metadata**: CPU only, no GPU required
|
||||
- **Invisible removal**: GPU recommended (CUDA or MPS), works on CPU (slow)
|
||||
## Important limitations
|
||||
|
||||
## Troubleshooting
|
||||
- A missing local signal means unknown, not clean. Proprietary pixel
|
||||
watermarks may remain after metadata has been stripped.
|
||||
- Visible removal reconstructs a small region. Results depend on the background
|
||||
and selected fill backend.
|
||||
- Invisible removal changes the whole image and may alter faces, text, or fine
|
||||
detail.
|
||||
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
|
||||
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
|
||||
Dola, Hailuo, and Kling labels. It does not recognize the older Sora Turbo
|
||||
corner swirl or unregistered layouts from those providers.
|
||||
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
|
||||
LaMa when recovery quality matters.
|
||||
- Video SynthID regeneration changes resolution, frame rate, and image detail.
|
||||
The shipped profile is oracle-certified, but no public local decoder can
|
||||
certify an arbitrary output at runtime. Recheck unusually important outputs
|
||||
after provider changes.
|
||||
- `qwen-zimage` requires CUDA. The other diffusion profiles also support the
|
||||
devices listed by `remove-ai-watermarks invisible --help`.
|
||||
- Provider watermark systems can change. Validate important outputs with the
|
||||
provider's own verifier when one is available.
|
||||
|
||||
**SSL certificate error** (`CERTIFICATE_VERIFY_FAILED`):
|
||||
The shipped `video invisible` command uses the certified `noise_std=0.15`
|
||||
profile. The companion `scripts/video_synthid_sweep.py` research harness builds
|
||||
a matched re-encode control plus VAE-regenerated candidates and leaves the
|
||||
verifier verdict blank:
|
||||
|
||||
```bash
|
||||
# Install certifi (the tool auto-detects it)
|
||||
pip install certifi
|
||||
|
||||
# macOS only: run the Python certificate installer
|
||||
/Applications/Python\ 3.*/Install\ Certificates.command
|
||||
uv run --extra video --extra diffusion python scripts/video_synthid_sweep.py input.mp4 -o sweep/
|
||||
```
|
||||
|
||||
**First run is slow** — this is expected. The tool downloads model weights (~2 GB) on first launch. Subsequent runs use cached models.
|
||||
The control must still be SynthID-positive before a negative candidate can
|
||||
count as removal evidence. In the 2026-07-29 two-clip calibration, both matched
|
||||
controls were positive in Gemini's built-in SynthID verifier; the stronger
|
||||
candidate was negative on both carriers, while a weaker candidate was
|
||||
negative on one. A later adversarial follow-up that asked ordinary Gemini to
|
||||
reinterpret the pixel result returned `UNAVAILABLE`; that follow-up was not a
|
||||
verifier rerun and does not invalidate the built-in verdicts. A 2026-07-31
|
||||
full-clip check on a public eight-second Veo sample found `0.10` still detected
|
||||
and `0.15` not detected, so `0.15` is now the certified default. The
|
||||
reproducible hashes and verdicts live in
|
||||
`data/evaluations/video-synthid-oracle.csv`.
|
||||
|
||||
## Credits
|
||||
## Documentation
|
||||
|
||||
- [noai-watermark](https://github.com/mertizci/noai-watermark) by mertizci — invisible watermark removal engine
|
||||
- [GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) by Allen Kuo (MIT) — visible watermark removal algorithm
|
||||
- [CtrlRegen](https://github.com/yepengliu/CtrlRegen) by Liu et al. (ICLR 2025) — controllable regeneration pipeline
|
||||
- NeuralBleach (MIT) — analog humanizer technique
|
||||
Start with the [documentation index](docs/index.md).
|
||||
|
||||
## Roadmap
|
||||
- [Installation](docs/installation.md)
|
||||
- [CLI guide](docs/cli.md)
|
||||
- [Python API](docs/python-api.md)
|
||||
- [Supported signals](docs/supported-signals.md)
|
||||
- [Known limitations](docs/known-limitations.md)
|
||||
- [Scope, safety, and legal notes](docs/legal-and-safety.md)
|
||||
- [Module internals](docs/module-internals.md)
|
||||
- [Release and distribution](docs/release-and-distribution.md)
|
||||
|
||||
Tracked but not yet implemented:
|
||||
Research notes and historical experiments are listed separately in the
|
||||
[documentation index](docs/index.md). They explain past decisions but do not
|
||||
define the current public API.
|
||||
|
||||
- **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.
|
||||
- **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**. Clear strict-pyright debt in `remove_ai_metadata` / `cli.py` (untyped piexif / PIL / click / rich) so `maintain.sh` can finish green. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
|
||||
- **AVIF / HEIF / JPEG-XL detection limits**. Removal strips top-level C2PA `uuid` and JUMBF `jumb` boxes. EXIF/XMP boxes inside these containers are not yet scrubbed (PNG and JPEG are fully covered).
|
||||
- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo.
|
||||
## Contributing
|
||||
|
||||
Won't fix:
|
||||
Install the development environment and run the project gate:
|
||||
|
||||
- **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.
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
bash maintain.sh
|
||||
```
|
||||
|
||||
## Legal
|
||||
|
||||
Watermarking and provenance for AI-generated content is now regulated in several jurisdictions. The table below summarises the May 2026 status. None of this is legal advice.
|
||||
|
||||
| Jurisdiction | Instrument | Status (May 2026) | Relevance |
|
||||
| --- | --- | --- | --- |
|
||||
| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. |
|
||||
| US (federal) | COPIED Act | **Reintroduced April 2025; not enacted** (pending in the Senate). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
|
||||
| US (state) | CA AB 2655, TX SB 751, similar | TX SB 751 in force; **CA AB 2655 struck down** by a federal court (Aug 2025, Section 230 / First Amendment). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
|
||||
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels for AI content; tampering with or removing labels is prohibited. |
|
||||
| UK | Online Safety Act 2023 / Ofcom guidance | In force, but **no statutory AI-provenance or watermarking obligation**. | Ofcom encourages watermarking / provenance metadata as voluntary "attribution measures"; platform duties, not user obligations. |
|
||||
|
||||
## Threat model
|
||||
|
||||
This tool 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.
|
||||
|
||||
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.
|
||||
|
||||
Use cases where the threat model **does not** fit:
|
||||
- Generating an image, expecting that removing the watermark anonymises you to Google. It doesn't.
|
||||
- Distributing AI-generated content while claiming human authorship. The watermark is one of several traceability layers.
|
||||
|
||||
This tool is intended for legitimate purposes such as:
|
||||
|
||||
- Privacy protection (removing metadata that leaks user account identifiers).
|
||||
- Art preservation and fair-use research.
|
||||
- Removing false-positive "Made with AI" labels from human-edited photographs.
|
||||
- Security research and watermark robustness study.
|
||||
|
||||
Removing AI provenance markers to misrepresent AI-generated content as human-created may violate the laws above, the DMCA, and platform terms of service. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations.
|
||||
See [module internals](docs/module-internals.md) before changing a subsystem
|
||||
with documented invariants.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[Apache 2.0](LICENSE). Copyright 2025-2026 wiltodelta.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Repository data
|
||||
|
||||
Tracked data is organized by purpose:
|
||||
|
||||
```text
|
||||
data/
|
||||
fixtures/
|
||||
provenance/ Real format and provenance fixtures used by tests
|
||||
calibration/
|
||||
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
|
||||
synthid/
|
||||
originals/ Canonical provider-oracle fixtures, stored once
|
||||
manifest.csv Provenance and verification record for each original
|
||||
full-pipeline-quality.csv
|
||||
Reusable full-pipeline evaluation selection
|
||||
evaluations/
|
||||
fidelity/ Evaluation instructions and hand-verified ground truth
|
||||
video-synthid-oracle.csv
|
||||
Reproducible full-clip Gemini SynthID verdicts
|
||||
```
|
||||
|
||||
## Storage rules
|
||||
|
||||
1. Store each binary image once. Evaluation manifests and documentation point
|
||||
to its canonical location.
|
||||
2. Put executable test fixtures in `fixtures/`.
|
||||
3. Put only the minimal reproducible detector inputs in `calibration/`.
|
||||
4. Put externally verified SynthID originals in `synthid/originals/` and keep
|
||||
both CSV files synchronized.
|
||||
5. Keep evaluation outputs outside the repository. Record reproducible
|
||||
commands, hashes, and oracle verdicts instead of committing another corpus
|
||||
copy. A small curated before-and-after example may live in `docs/images/`
|
||||
when it is part of the public documentation.
|
||||
6. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
|
||||
Unregistered research candidates belong in
|
||||
`scripts/assets/visible-mark-candidates/` so they are not shipped in the
|
||||
wheel.
|
||||
|
||||
The source distribution excludes `data/`; the wheel contains only package
|
||||
runtime assets.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Doubao visible watermark capture
|
||||
|
||||
> **Status (captured 2026-05-29; asset rebuilt 2026-05-31):** the
|
||||
> black and gray captures are committed here. The generated alpha
|
||||
> asset is now a detection silhouette and mask-shaping input only. Current
|
||||
> removal localizes the mark and sends its footprint to the shared fill backend.
|
||||
> Reverse-alpha recovery was retired. The details below preserve the historical
|
||||
> capture rationale.
|
||||
|
||||
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
|
||||
rebuild and validate the detection silhouette used by
|
||||
`src/remove_ai_watermarks/doubao_engine.py`.
|
||||
|
||||
## Historical findings from the capture work
|
||||
|
||||
- Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`,
|
||||
`logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`.
|
||||
Confirmed by two independent sources (an open-source remover's algorithm doc + aiwatermarkremover.dev,
|
||||
both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray
|
||||
capture below settles it empirically.
|
||||
- Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size.
|
||||
Confirmed by our sample `data/fixtures/provenance/doubao-1.png` (2048x2048) plus three sources.
|
||||
- Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are
|
||||
approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped
|
||||
third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh.
|
||||
- The original implementation used reverse-alpha recovery. That path was later
|
||||
removed in favor of localize then fill because the latter also handles moved
|
||||
and re-rendered marks.
|
||||
|
||||
## Use doubao.com specifically
|
||||
|
||||
The "豆包AI生成" mark is Doubao's. Jimeng / Dreamina use a different mark. Generate on doubao.com so
|
||||
the captured template matches our target.
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each locally generated solid-color seed:
|
||||
|
||||
1. Open Doubao image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
|
||||
(English: `Recreate this image exactly as it is, keep it identical, do not add or change anything`)
|
||||
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
Prior art confirms uploading a pure-black image and letting Doubao stamp it works.
|
||||
|
||||
If edit mode is unavailable and text-to-image refuses a solid color, fall back to generating 10-12
|
||||
normal-content images at one fixed resolution; the mark is the only constant across them and can be
|
||||
extracted by per-pixel min/median.
|
||||
|
||||
## What to capture (priority top to bottom)
|
||||
|
||||
| Aspect | black | white | gray128 | why |
|
||||
|--------|-------|-------|---------|-----|
|
||||
| 1:1 | 3 | 1 | 1 | primary alpha map + confirm the stamp is pixel-identical across runs + settle blend mode |
|
||||
| 16:9 | 2 | 1 | 1 | anchor rule in landscape |
|
||||
| 9:16 | 2 | 1 | 1 | anchor rule in portrait |
|
||||
| 4:3, 3:4 | 1 each | - | - | optional, refines anchor rule |
|
||||
|
||||
- 3 blacks on 1:1: if the first two are byte-identical in the watermark region, the third is optional.
|
||||
- gray128 is the blend-mode test: predict the gray result from the black capture under alpha vs screen;
|
||||
whichever matches the real gray output is the true blend.
|
||||
- If the UI offers multiple output resolutions (1K / 2K / 4K), capture one black per resolution on 1:1 -
|
||||
needed to learn how the watermark scales.
|
||||
- Also grab 3-5 normal-content images on 1:1 for end-to-end removal validation.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Committed inputs
|
||||
|
||||
```
|
||||
doubao_black_1x1_1.png
|
||||
doubao_gray_1x1_1.png
|
||||
```
|
||||
|
||||
The black and gray captures feed `scripts/visible_alpha_solve.py doubao`.
|
||||
|
||||
## Also report back
|
||||
|
||||
1. Which resolutions and aspect ratios the Doubao UI actually offers.
|
||||
2. Whether there is a watermark on/off toggle in the UI.
|
||||
3. Download format (PNG or JPEG).
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 980 KiB |
@@ -0,0 +1,46 @@
|
||||
# Gemini (Nano Banana) visible sparkle capture
|
||||
|
||||
> **Status (captured 2026-05-31):** the required black capture is committed
|
||||
> here. The sparkle-on-black assets
|
||||
> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`.
|
||||
> They support detection and footprint geometry. Current pixel recovery uses the
|
||||
> shared fill backend, not reverse-alpha inversion.
|
||||
|
||||
Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner
|
||||
via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng
|
||||
text marks, the sparkle is captured over a **pure-black** background, where
|
||||
`watermarked = a*255` (logo is near-white), so the alpha reads directly off the capture
|
||||
(`alpha = max(R,G,B)/255`) -- no background fit needed. This is the "golden" capture case.
|
||||
|
||||
## What the captures confirmed (2026-05-31)
|
||||
|
||||
- The sparkle at a 2048-wide image is **96x96 px** (width_frac ~0.047), bottom-right,
|
||||
margins ~0.031, alpha max ~0.51 -- matching the engine's existing 96px asset.
|
||||
- Our own controlled capture matches the previously third-party-sourced
|
||||
`gemini_bg_96.png` to **NCC 0.9998**, so the bundled asset is validated and now
|
||||
reproducible from our own capture.
|
||||
|
||||
## How to capture (image-edit path)
|
||||
|
||||
For each locally generated black, gray, or white 2048 px seed:
|
||||
|
||||
1. Open Gemini image generation, image-edit / reference mode, upload the seed.
|
||||
2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything`
|
||||
3. Download the ORIGINAL output (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check.
|
||||
|
||||
## Committed input
|
||||
|
||||
```
|
||||
gemini_black_2048.png # the key capture (sparkle on black)
|
||||
```
|
||||
|
||||
The gray and white captures were calibration cross-checks and are not needed to
|
||||
rebuild the assets. Synthetic seeds and real-content validation downloads stay
|
||||
local.
|
||||
Rebuild the assets with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py gemini # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 3.7 MiB |
@@ -0,0 +1,68 @@
|
||||
# Jimeng (即梦AI) visible watermark capture
|
||||
|
||||
> **Status (completed 2026-05-30):** solid black and gray Jimeng captures were
|
||||
> obtained and the detection asset was solved. The asset now supports detection
|
||||
> and mask geometry only. Current removal localizes the wordmark and uses the
|
||||
> shared fill backend. The text below records the capture and calibration work.
|
||||
|
||||
Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat
|
||||
backgrounds so we can rebuild and validate the silhouette used by
|
||||
`src/remove_ai_watermarks/jimeng_engine.py`.
|
||||
|
||||
## What we learned (verified from the captures, 2026-05-30)
|
||||
|
||||
- Mark: a four-point sparkle icon followed by the "即梦AI" characters, near-white
|
||||
semi-transparent overlay, bottom-right corner.
|
||||
- Blend model: **alpha compositing with a pure-white logo** `watermarked =
|
||||
a*255 + (1-a)*original`, confirmed in sRGB (a linear-light solve made the
|
||||
black/gray cross-residual much worse, so the compositing is plain sRGB). An
|
||||
L-pair-solve (independent of the L assumption) lands at ~254.6, confirming white.
|
||||
- **Alpha is solved from the GRAY capture**, not black: `a = (I - B)/(255 - B)`
|
||||
with B a per-capture CUBIC background fit over the non-glyph pixels, averaged
|
||||
over channels, at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (bg ~132)
|
||||
is the best proxy for real content (the mark sits on bright photo areas, not on
|
||||
black). This careful build drops the gray self-residual to ~1.3; an earlier
|
||||
max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated
|
||||
least-squares solve) left a visible outline -- the mask quality, not the method,
|
||||
was the limit.
|
||||
- Geometry (fraction of image WIDTH, at the captured 2048): asset width ~0.211,
|
||||
height ~0.068, right margin ~0.023, bottom margin ~0.023. The mark scales with
|
||||
width; a real 1440-wide download matched width_frac ~0.21.
|
||||
- **Per-image render variation:** the alpha maps solved independently from the
|
||||
black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng
|
||||
re-rasterizes the mark per generation and jitters its position a few pixels.
|
||||
This was one reason the old reverse-alpha path needed alignment and residual
|
||||
inpainting. The current path instead detects the silhouette, localizes the
|
||||
glyph footprint, and fills that footprint.
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each solid-color seed:
|
||||
|
||||
1. Open Jimeng image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
|
||||
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
The black capture is the key one (white logo on black -> `captured ~= a*255`); the
|
||||
gray capture refines the alpha at mid-tones; the white capture confirms the logo is
|
||||
pure white (the mark is nearly invisible on white, as expected).
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Committed inputs
|
||||
|
||||
```
|
||||
jimeng_cap_A.png # black seed run through Jimeng
|
||||
jimeng_cap_C.png # gray seed
|
||||
```
|
||||
|
||||
The A and C captures feed
|
||||
`scripts/visible_alpha_solve.py jimeng`. Rebuild the detection asset with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py jimeng # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,61 @@
|
||||
# Samsung Galaxy AI visible watermark capture
|
||||
|
||||
> **Status (built 2026-06-05):** flat black and gray Samsung Galaxy AI captures
|
||||
> were obtained and the detection asset was solved. The asset now supports
|
||||
> detection and mask geometry only. Current removal localizes the wordmark and
|
||||
> uses the shared fill backend. The text below records the capture plan and its
|
||||
> locale and resolution limits.
|
||||
|
||||
Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'AI" visible wordmark
|
||||
over known flat backgrounds so we can rebuild and validate the silhouette used
|
||||
by `src/remove_ai_watermarks/samsung_engine.py`.
|
||||
|
||||
## What we learned (verified from the captures, 2026-06-05)
|
||||
|
||||
- Mark: a sparkle icon followed by the locale string "Contenuti generati dall'AI"
|
||||
(Italian), a light low-opacity (peak alpha ~0.38) semi-transparent **white**
|
||||
overlay, anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right). The string is
|
||||
locale-specific, so the alpha template is per-locale; this build is the Italian
|
||||
variant. Other locales need their own captured template.
|
||||
- Blend model: alpha compositing with a pure-white logo, `watermarked =
|
||||
a*255 + (1-a)*original`, solved from the GRAY capture (same careful recipe as
|
||||
Doubao/Jimeng: cubic-background fit, mean over channels, full halo extent,
|
||||
unblurred). The white capture confirms the logo is white; on white the mark is
|
||||
white-on-white and not detectable (no contrast), which is fine -- there is nothing
|
||||
to recover there.
|
||||
- Geometry (fraction of image WIDTH): asset width ~0.32, height ~0.038, left margin
|
||||
~0.011, bottom margin ~0.006. The mark scales with width: a 1086-wide flat capture
|
||||
and a 2958-wide real photo both measure width_frac ~0.31.
|
||||
- **Resolution caveat (open quality follow-up):** the flat black/gray/white captures
|
||||
arrived at the phone's flat-edit size (1086 wide and a landscape 1920 set), while
|
||||
the real photos are ~3000 wide, so the captured glyph (~334 px) is ~2.7x smaller
|
||||
than on a real photo (~900 px). The alpha is solved at the capture size and
|
||||
width-scaled + NCC-aligned per image, which removes the mark cleanly (verified on a
|
||||
real 2958-wide photo: re-detect 0.79 -> 0.00, no readable text or outline), but a
|
||||
flat capture taken at the real photo resolution (~3000 wide) would let the alpha be
|
||||
pixel-sharp instead of upscaled. Not a blocker; a quality upgrade if a full-res
|
||||
flat capture is provided.
|
||||
|
||||
## Capture protocol (to re-capture or add a locale)
|
||||
|
||||
On a Samsung Galaxy AI device (set the UI language to the target locale):
|
||||
|
||||
1. Run the AI edit (Generative Edit / Sketch to Image) on a solid black image, so
|
||||
the overlay lands on a flat black background. Download the ORIGINAL output file
|
||||
(not a screenshot, no crop or re-save).
|
||||
2. Repeat over solid white and solid gray (those pin the exact glyph color).
|
||||
3. Ideally run all three flat edits at the same resolution as real photos (~3000
|
||||
wide) so the alpha map is pixel-sharp rather than upscaled.
|
||||
4. Plus 3-5 real outputs with the visible mark over normal content for validation.
|
||||
|
||||
## Files
|
||||
|
||||
- `samsung_black_1.png` and `samsung_gray_1.png` are the retained solver inputs.
|
||||
- White, secondary-resolution, and real-content captures were validation
|
||||
material and are not required to rebuild the asset.
|
||||
|
||||
Rebuild the detection asset with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py samsung
|
||||
```
|
||||
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 904 KiB |
@@ -0,0 +1,37 @@
|
||||
# Pipeline fidelity evaluation
|
||||
|
||||
This evaluation compares diffusion pipelines with
|
||||
`scripts/fidelity_metrics.py`. The images themselves have one canonical home
|
||||
in `data/synthid/originals/`; this directory stores only evaluation-specific
|
||||
ground truth and instructions.
|
||||
|
||||
| Original | Provider | Content | Exercises |
|
||||
| --- | --- | --- | --- |
|
||||
| `ChatGPT Image May 31, 2026, 02_03_55 PM.png` | OpenAI | Multilingual typography | Text preservation |
|
||||
| `Gemini_Generated_Image_633uuy633uuy633u.png` | Google | Landscape with a Chinese sign | CJK text preservation |
|
||||
| `Gemini_Generated_Image_y48j3cy48j3cy48j.png` | Google | Portrait grid | Face identity and skin texture |
|
||||
|
||||
## Text ground truth
|
||||
|
||||
`ground-truth.json` contains hand-verified OCR for the two text-bearing
|
||||
originals. To regenerate an OCR seed:
|
||||
|
||||
```bash
|
||||
uv run scripts/fidelity_metrics.py ocr \
|
||||
"data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png" \
|
||||
data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \
|
||||
--langs en,ru,ch \
|
||||
--out data/evaluations/fidelity/ground-truth.json
|
||||
```
|
||||
|
||||
Verify and correct the generated text by hand before using it as ground truth.
|
||||
|
||||
## Compare
|
||||
|
||||
```bash
|
||||
uv run scripts/fidelity_metrics.py compare \
|
||||
--original data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png \
|
||||
--variant controlnet=<out>.png \
|
||||
--variant qwen=<out>.png \
|
||||
--ocr-langs ""
|
||||
```
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
date,source_url,source_sha256,duration_seconds,source_verdict,noise_std,long_side,fps,seed,output_sha256,output_verdict,psnr_db,temporal_residual_ratio
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,8,detected_all_frames,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,26.2932,1.0072
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,8,detected_all_frames,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,25.3911,1.0578
|
||||
|
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 553 KiB |
@@ -1,41 +0,0 @@
|
||||
# OpenAI ChatGPT Images 2.0 sample
|
||||
|
||||
Reference image generated by OpenAI's ChatGPT Images 2.0 (`gpt-image-2`, launched 2026-04-21). Kept in the repo so the C2PA manifest parser and invisible-watermark pipeline can be re-verified against a real production output.
|
||||
|
||||
## `amur-leopard.png`
|
||||
|
||||
- Resolution: 1055 x 1491, PNG, 3.0 MB
|
||||
- Downloaded: 2026-04-22
|
||||
- Content: AI-generated infographic about the Amur leopard (*Panthera pardus orientalis*), chosen to exercise the model's new accurate-text-rendering feature
|
||||
|
||||
### Embedded C2PA manifest (caBX chunk, 23607 bytes)
|
||||
|
||||
Parsed by `remove-ai-watermarks metadata --check`:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| `claim_generator` | GPT-4o |
|
||||
| `c2pa_spec` | 2.2.0 |
|
||||
| `digital_source_type` | http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia |
|
||||
| `c2pa_actions` | created, converted |
|
||||
| Signer | OpenAI OpCo, LLC / OpenAI Media Service API |
|
||||
| Claim signing CA | Trufo C2PA Claim Signing CA (2025) -> Trufo C2PA Root CA (ECC P384) |
|
||||
| Timestamp authority | OpenAI TSA Issuing CA -> OpenAI TSA Root CA |
|
||||
|
||||
The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Instagram, Facebook, and X.
|
||||
|
||||
### Invisible pixel-level watermark
|
||||
|
||||
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. This sample was downloaded 2026-04-22, before OpenAI's 19 May 2026 rollout of Google's SynthID watermark across ChatGPT / Codex / the API, so it likely predates SynthID and carries only the original content-specific watermark. Since that rollout, the openai.com/verify tool (in preview) is the public oracle for both signals; there is still no local decoder, so bypass cannot be verified empirically here without the oracle.
|
||||
|
||||
## Reproducing the removal
|
||||
|
||||
```bash
|
||||
# C2PA strip only
|
||||
remove-ai-watermarks metadata amur-leopard.png --remove -o amur-leopard.clean.png
|
||||
|
||||
# Full pipeline (visible + diffusion regeneration + metadata)
|
||||
remove-ai-watermarks all amur-leopard.png -o amur-leopard.all.png --device mps
|
||||
```
|
||||
|
||||
Note: the diffusion step runs at native resolution by default (no pre-downscale), so fine text on a text-heavy infographic like this one is preserved. Pass `--max-resolution N` only if a very large image OOMs the GPU/MPS (that reintroduces a lossy downscale then upscale round-trip).
|
||||
|
Before Width: | Height: | Size: 3.0 MiB |
@@ -1,11 +1,10 @@
|
||||
# SynthID reference corpus
|
||||
# SynthID oracle fixtures
|
||||
|
||||
A locally-collected, labeled image corpus for SynthID work. Two downstream uses:
|
||||
A compact set of externally verified originals for two uses:
|
||||
|
||||
1. **Per-resolution spectral codebook** for an experimental SynthID detector
|
||||
(carrier frequencies are resolution-dependent, so labels must record the
|
||||
exact native resolution).
|
||||
2. **Removal regression set** — verify that our pipeline turns a SynthID-positive
|
||||
1. **Signal and detector-limit research** with exact native resolutions and
|
||||
recorded verification sources.
|
||||
2. **Removal regression set**: verify that a pipeline turns a SynthID-positive
|
||||
image into a negative one.
|
||||
|
||||
There is no reliable local detector of the SynthID pixel watermark (Google's
|
||||
@@ -15,18 +14,40 @@ external oracle, recorded per image in `verified_via` (see below).
|
||||
## Layout
|
||||
|
||||
```
|
||||
data/synthid_corpus/
|
||||
README.md # this protocol (committed)
|
||||
manifest.csv # labels + provenance (committed, reviewable)
|
||||
images/ # the actual files (gitignored, local-only)
|
||||
pos/ # SynthID present
|
||||
neg/ # SynthID absent
|
||||
cleaned/ # our pipeline output from a pos image
|
||||
data/synthid/
|
||||
README.md # collection and verification protocol
|
||||
manifest.csv # one row per committed original
|
||||
full-pipeline-quality.csv # reusable evaluation selection
|
||||
originals/ # one canonical copy of each verified source
|
||||
```
|
||||
|
||||
Images are gitignored on purpose: the corpus is large, may contain personal or
|
||||
licensed content, and SynthID-positive outputs are best kept local. The
|
||||
`manifest.csv` (sha256 + labels + extracted metadata) is the durable artifact.
|
||||
The originals are committed so the evaluation set is reproducible and its
|
||||
hashes can be checked in CI. `manifest.csv` is kept in sync with
|
||||
`originals/`, one row per file. Generated outputs do not belong here. Store
|
||||
them outside the repository and record only a reproducible verdict in the
|
||||
evaluation manifest or documentation.
|
||||
|
||||
## Reusable removal-quality set
|
||||
|
||||
`full-pipeline-quality.csv` is the canonical reusable input set for
|
||||
full-pipeline visual-quality and watermark-removal tests. Its fixtures are
|
||||
cleared for permanent public test reuse. Tests, benchmarks, and manual
|
||||
evaluation bundles may read these images repeatedly without requesting new
|
||||
permission.
|
||||
|
||||
The CSV preserves each platform's original filename and maps it to the one
|
||||
canonical corpus copy. Use `corpus_path` to read the input, but preserve
|
||||
`source_filename` in generated result names and keep OpenAI and Gemini outputs
|
||||
in separate provider groups. This avoids duplicating large PNGs while retaining
|
||||
the names needed for the corresponding provider oracle.
|
||||
|
||||
Rows with a dated negative `final_clean_oracle` were checked after the complete
|
||||
`visible -> qwen-zimage -> metadata` route with the matching provider oracle.
|
||||
`not-checked-in-final-candidate` means the input is available as an additional
|
||||
quality stress fixture, but a newly generated output still needs the matching
|
||||
oracle before claiming removal. The checked result applies to those exact
|
||||
output bytes; re-run the oracle after changing the pipeline, seed, model
|
||||
versions, or runtime settings.
|
||||
|
||||
## Verification levels (`verified_via`)
|
||||
|
||||
@@ -39,32 +60,25 @@ Ground-truth quality, strongest first:
|
||||
- `third-party` — label asserted by an external dataset, not independently verified.
|
||||
- `none` — unverified.
|
||||
|
||||
Prefer `gemini-app` for any image that will train the codebook or gate a test.
|
||||
Prefer `gemini-app` for any Google image that will gate a test.
|
||||
|
||||
## What to collect
|
||||
|
||||
For the **codebook** (per target resolution, e.g. 1024x1024, 1024x1536, 1536x2816):
|
||||
|
||||
- 30-50+ SynthID-positive outputs per resolution (more is better; ~150-200 per
|
||||
resolution materially improves carrier discovery).
|
||||
- At each target resolution, also a batch of **pure-black (#000000)** and
|
||||
**pure-white (#FFFFFF)** fills generated by the SynthID model — these isolate
|
||||
the content-independent carrier (the watermark is most of the signal there).
|
||||
|
||||
For the **regression set**:
|
||||
|
||||
- A handful of `pos` images, their `cleaned` counterparts (run through our
|
||||
pipeline), and the cleaned re-verified via `gemini-app` (should read negative).
|
||||
- `neg` controls: non-AI photos and outputs from non-SynthID models (SD,
|
||||
Midjourney, Firefly) verified negative.
|
||||
- A small number of positive originals with a recorded provider oracle.
|
||||
- A quality-set row for every original used by repeatable evaluation.
|
||||
- Re-run the provider oracle on each newly generated output. Do not commit the
|
||||
generated output as another corpus copy.
|
||||
|
||||
Avoid personal or identifiable content; the corpus stays local.
|
||||
The corpus is committed to a public repo: review every image before adding it
|
||||
and keep out anything private or identifiable you would not publish.
|
||||
|
||||
## Ingesting
|
||||
|
||||
Use `scripts/synthid_corpus.py` — it copies a file in, records its sha256,
|
||||
resolution, format, and C2PA issuer (via our own detector), and appends a row
|
||||
to `manifest.csv`:
|
||||
Use `scripts/synthid_corpus.py` to copy a file into `originals/`, record its
|
||||
SHA-256, resolution, format, and C2PA issuer through our detector, and append a
|
||||
row to `manifest.csv`:
|
||||
|
||||
```bash
|
||||
uv run python scripts/synthid_corpus.py ingest path/to/*.png \
|
||||
@@ -76,6 +90,10 @@ uv run python scripts/synthid_corpus.py status # counts by label / resolution
|
||||
|
||||
## Autonomous collection via Chrome MCP
|
||||
|
||||
> Historical collection notes. Browser interfaces and download behavior can
|
||||
> change independently of this repository. These steps do not define package
|
||||
> behavior.
|
||||
|
||||
Generation can be driven through the browser (the account must be logged in):
|
||||
|
||||
- **Gemini** (`gemini.google.com`): type `Create an image: <prompt>`, wait, hover the
|
||||
@@ -116,8 +134,8 @@ Download; Leonardo serves the original JPEG in-chat (download button matches).
|
||||
|
||||
## Per-platform watermark map (observed, May 2026)
|
||||
|
||||
What each platform actually embeds, verified by byte-scan (and Gemini-app oracle
|
||||
where noted). The detector's coverage is complementary: metadata catches C2PA /
|
||||
This is a dated observation table, not a live provider compatibility promise.
|
||||
The detector's coverage is complementary: metadata catches C2PA /
|
||||
IPTC; `exif_generator` catches EXIF `Make`/`Software` + XMP `CreatorTool`;
|
||||
`invisible_watermark.py` (imwatermark) catches the open SD/SDXL/FLUX DWT-DCT
|
||||
watermark on pristine files; the visible detector catches the Gemini-family
|
||||
@@ -0,0 +1,8 @@
|
||||
provider,source_filename,corpus_path,sha256,original_signal_verified_via,final_clean_oracle
|
||||
openai,"ChatGPT Image May 30, 2026, 10_31_08 AM.png","originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png",28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,openai-verify,openai-verify-negative-2026-07-25
|
||||
openai,"ChatGPT Image May 31, 2026, 02_02_23 PM.png","originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png",228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,c2pa-metadata,not-checked-in-final-candidate
|
||||
openai,"ChatGPT Image May 31, 2026, 02_03_55 PM.png","originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png",05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,openai-verify,openai-verify-negative-2026-07-25
|
||||
gemini,"Gemini_Generated_Image_3mc4t93mc4t93mc4.png","originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png",2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,gemini-app,gemini-app-negative-2026-07-25
|
||||
gemini,"Gemini_Generated_Image_633uuy633uuy633u.png","originals/Gemini_Generated_Image_633uuy633uuy633u.png",4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,gemini-app,gemini-app-negative-2026-07-25
|
||||
gemini,"Gemini_Generated_Image_akdbeiakdbeiakdb.png","originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png",8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,gemini-app,gemini-app-negative-2026-07-25
|
||||
gemini,"Gemini_Generated_Image_y48j3cy48j3cy48j.png","originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png",45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,gemini-app,gemini-app-negative-2026-07-25
|
||||
|
@@ -0,0 +1,8 @@
|
||||
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
|
||||
05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
|
||||
28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,"ChatGPT Image May 30, 2026, 10_31_08 AM.png",pos,ChatGPT,gpt-image,1448,1086,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
|
||||
4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,Gemini_Generated_Image_633uuy633uuy633u.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
|
||||
8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,Gemini_Generated_Image_akdbeiakdbeiakdb.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
|
||||
45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,Gemini_Generated_Image_y48j3cy48j3cy48j.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
|
||||
2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,Gemini_Generated_Image_3mc4t93mc4t93mc4.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
|
||||
228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,c2pa-metadata,2026-07-25T20:21:33Z,Reusable full-pipeline quality fixture; original filename preserved in quality-set manifest; cleared for public test reuse
|
||||
|
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 9.6 MiB |
|
After Width: | Height: | Size: 8.2 MiB |
|
After Width: | Height: | Size: 8.6 MiB |
|
After Width: | Height: | Size: 9.3 MiB |
@@ -1,119 +0,0 @@
|
||||
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
|
||||
4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e,"4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,openai-verify,2026-05-23T21:48:12Z,fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected
|
||||
d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656,d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-23T21:52:40Z,"user: latest Gemini, SynthID v2"
|
||||
47188e88f956291bd38ab6906e5f21eb273d4a697ddc8b4479deac9f48915e1a,47188e88-disco_synthid_removed.png,cleaned,our pipeline (invisible/SDXL),stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-23T22:06:54Z,cleaned from 4ef377bd disco; openai.com/verify: SynthID NOT detected (defeated)
|
||||
52bb6bd524a74bff1b74ed893784c1c0ee76f48a9b45a009bae95ad4a57b7759,"52bb6bd5-ChatGPT Image May 24, 2026, 11_01_02 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
|
||||
ee75ade07914d6306eae3443b3028782e0ac8c125a31b2d5a141f75aebdafb18,"ee75ade0-ChatGPT Image May 24, 2026, 11_10_04 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
|
||||
9398c74dfad0f030633bd3ac224ba53e56a7ff9711d7b3a4c464e0073ece51b5,"9398c74d-ChatGPT Image May 24, 2026, 11_10_09 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
|
||||
79c3733895e82e3c9e506de0ddd6dfbf20ba09171263d88444520777151868c8,"79c37338-ChatGPT Image May 24, 2026, 11_10_13 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
|
||||
38315a0a83aa0e094a50520ea44e01aa26115927f09a532ff2ab8636de743e0a,"38315a0a-ChatGPT Image May 24, 2026, 11_10_19 AM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,c2pa-metadata,2026-05-24T18:49:35Z,solid black ref (spectral pilot 2026-05-24)
|
||||
f3a1fbc3bc8f768265400724bb9800d322f8e0b1461b2c585540845ea8352c5d,f3a1fbc3-winter_scene_X.png,neg,local (SDXL/processed),sdxl-or-processed,2816,1536,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
|
||||
f07bc0bcad09a5a5687ae312a1298c9ddd110a5e414265efc52ef4d524b36f86,f07bc0bc-api_enterprise_gate_ax.png,neg,local (SDXL/processed),sdxl-or-processed,1080,1350,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
|
||||
89571987e368f1ce82f9dedfa9101584434def842e50d1f4c759de64db5c21d9,89571987-c87bd3c48a4443a68cb84a65604dacd3_clean.png,neg,local (SDXL/processed),sdxl-or-processed,2816,1536,png,,,none,2026-05-24T18:52:23Z,metadata-clean: no C2PA/SynthID source
|
||||
7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a,"7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png",pos,ChatGPT,gpt-image,1602,982,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:20:25Z,content: misty pine forest at dawn
|
||||
fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32,fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:30:25Z,content: elderly fisherman portrait
|
||||
d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae,d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:33:15Z,content: red coffee mug product shot
|
||||
28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe,28f32334-chatgpt_fisherman.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:39:20Z,content: elderly fisherman portrait (fetch+blob dl)
|
||||
88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84,88e61a38-chatgpt_tokyo.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:42:02Z,content: tokyo street night (fetch+blob dl)
|
||||
607087e40da1ac2636f7233c06784295d374d503ab928bb0c0c9b09e0140b3bc,607087e4-IMG_0505.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440,1fa5f77f-IMG_0786.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
ac32346771d3ebb6b9d7d395e1efad995972fb708b76082905d15299d61dcfaa,ac323467-IMG_0114.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067,2ea228ed-IMG_1791.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
eb0e14f7c8dfe88d710804fe0d2e74eb033d4d72db5525a526bb3751f2d52f4f,eb0e14f7-ai_price_software_not_hardware.png,neg,iPhone (me/post_photos),,1200,675,png,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
cd2c0eba3625bf7a90178063b4a769667cf00904db968efff7502734f8456892,cd2c0eba-IMG_3201.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
208b21ccfa8c81d2e75903a6083f2806ac068d856ad12957e6f6ea7870af0096,208b21cc-IMG_0712.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
f9c64806fd2b1ea4cf39a49f67e651f2146e9c892f1a401d9981e9d7f0601062,f9c64806-grep_vs_vector_agentic_search.png,neg,iPhone (me/post_photos),,1600,900,png,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
beaae48cf40c8d471c9c1a49d205cd87cf6c079211847f66ced4d4310aa7cff4,beaae48c-IMG_1699.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
41307ceeb9c981fa766ec4d711fe72f647fce86d21f1026e63c45dcd95063a65,41307cee-IMG_2148.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
3801fbe36a77134336508f888f658deb03657c5b765c8b3205b2d928e1b3a5f8,3801fbe3-IMG_2364.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
3012ff6d1db0535423c4dfb22e8d4d2da77b94b776c38cf06fa06958b7a71860,3012ff6d-IMG_2489.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
b978efbdec5ec96b320e53fb23a1aec306f5a32a796a56a032ae66929c803e7b,b978efbd-IMG_0334.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
1136f4df74e842e4ba95c91731656e4bf125361b0aa83b1fc4e128161668a8fe,1136f4df-IMG_0363.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
6e768835f52e840ec0f6af4a9604bd31ed38b2ce3fa187cfe5cc7daa2acb56e4,6e768835-IMG_3492.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
5470ded7892073b1deb31980b3cc541ce73b4cf025d56a43eb6e80c2db810ff2,5470ded7-IMG_1417.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6,0bb4c176-IMG_1790.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
c1f3267973e33e91c82ab889d21b6be18a6128e2178a85403bf89b04a6ea8c36,c1f32679-IMG_2981.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
efcb985a4ecc055029bea0df82ba161442340b1df6aa54639c2c717f8ebe7c4e,efcb985a-IMG_0841.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
383c6d9f4d74d1d30eeab2bfbe9b83212dcedb52b36b8ac403efa45730563326,383c6d9f-IMG_1616.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
b284d9ad7f58a44dc2b0d2e69b12236cf004d5052fb5484cfa1c21548bfd3821,b284d9ad-IMG_1250.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
85802ce2d2d8a7a155e631ef36a82cb912691fbbab4b2c28bb2438efed0b9225,85802ce2-IMG_2973.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
15b1e4e26340630345fc18e439ec98995ddca2816f38e13d4b2ee9e99ed26bac,15b1e4e2-IMG_1563.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
d8a1eed3cc327b5db7f5fd635fd4c37b29ff24a3360fce57622ea1cf35af284a,d8a1eed3-IMG_1666.heic,neg,iPhone (me/post_photos),,3024,4032,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
505047f592db5fd2ce4fc0fc3580e0b28abc19b6720a98a73530f2cb7158f7c1,505047f5-IMG_2569.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
a499773829f0ed206f9bd8c46740fc01955c1d43f45e8e775582015d9db9956c,a4997738-IMG_1980.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
a2f98b6a8887c7b4909601c46d03fb1f6e5145e714ffe3bc8b7056bc636bfee0,a2f98b6a-IMG_0780.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
9172987b14366231857e74a3f7b554ec0fc8cc23ebb7f88cd8eca3ce95ef8498,9172987b-IMG_3293.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2f21fcd7b1ace88aa368028a7a97ffadd6ebe7cffe6c221d47abbed2a205074b,2f21fcd7-IMG_3144.PNG,neg,iPhone (me/post_photos),,2250,3000,png,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
d95e9af4d343501c4597918b5dfc66a1afc338b126e29c9eba13e7b2a61174d5,d95e9af4-IMG_1390.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
25b72de3a2ba4110c5dea68e82af6d19592eecc7000b2f12f52f8512afd48e73,25b72de3-IMG_1369.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
d18874c51b17ced8f736e24cd12ba54bd6c4f76292cba702a9c8a2d97c72f1c4,d18874c5-IMG_1447.HEIC,neg,iPhone (me/post_photos),,3088,2316,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
bf5abcd01037f44b2418b586c3b864340188a00f95047433832972ab797ae2d5,bf5abcd0-IMG_0743.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
a2782e0e6bb3d611925cace6ca3da10259ae70229aad3f366bc25b9e94538ab4,a2782e0e-IMG_0345.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846,3fc0c425-IMG_1450.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
56023f628e6d8ad0a3fb204b5bd10c9e9182446bad9032b0ab8c8d068a311bc7,56023f62-IMG_1796.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
100a121953049a83a714cd8771c7ac3f5afba69d8f8abb073535e98358395014,100a1219-IMG_0037.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
3e287f9e838f4f1bef5d8417957ea504954e5851de61656bbe32915c90db3a1f,3e287f9e-IMG_2323.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
237ff92d14163216687649f1fe222d5427f8600d8a97cb667386e71466a5d908,237ff92d-IMG_2270.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
a3f4a1a85bbd1cc6236e20c5a7d58bc18ccf695bc471cc02f36c1ad885551df8,a3f4a1a8-IMG_1309.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
be2f8bcb71f4bc5a4e79821bb4691e0f3c817ce916a35b05ffacce003a380c1c,be2f8bcb-IMG_0999.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
d5a4f2f81e86696cd18d38796c6b92ce96dcfe073bfd3a9c5dedf9d574aa66d6,d5a4f2f8-span_vs_flattening.png,neg,iPhone (me/post_photos),,1600,1000,png,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723,2b148db2-IMG_1832.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36,2d02fed0-IMG_1520.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
68bf936a5491478484a295b3d882fef042f77e9784cae1b64a9e6e1b72d0628c,68bf936a-IMG_1107.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
32a9b71a13ecec3c90dcd7b54b90206acbe0f646a2296c955e227baa83823eec,32a9b71a-IMG_1238.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772,8f170c06-IMG_1078.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
f7634a3779812f52de0599736b4c35f55b61027bae191e4ca060b10341661731,f7634a37-IMG_1605.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
e2ae859a96a5076f37b6efc5a1dad4952e29550f93cee4bba4899ef6fda09305,e2ae859a-IMG_3394.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
b4ea1e6f9134dfc91b4334ed723a15c937eee4fe3fd65b210cc31959884e5c4e,b4ea1e6f-IMG_1595.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
bf9d4d9ebb17cbae46407446d440b273fc136ea3e157a648e4959b116fca376d,bf9d4d9e-IMG_0916.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2546689c376f365188a5e5a85e72982ff099b5680608e861c8490bde340e8a29,2546689c-IMG_2481.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
88944f46e3d85ea5c4f6d4559f8da9360f68060f73bb8b8c3344c3fdd3b54476,88944f46-IMG_2157.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e,3fc4c831-IMG_2566.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
af59d9a72a859a72b6ad8e6178992e320934f74eb2a88d74c9d4c64b5bdb89f5,af59d9a7-IMG_2198.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4f9d6807ff42d0bcd9494dc5d4c9d15f7b96691e29bfe2d670b8e3bceba934de,4f9d6807-IMG_0371.HEIC,neg,iPhone (me/post_photos),,3088,2316,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
237492e65e73acacf8135a2a903a2a231ee9c68ddcc2c590e90ec6e54c6a4520,237492e6-IMG_0473.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
d8a339db6b19c71d4ccf1a467c8999c5d2947b5c8cfd07a8b17b343f579e3f26,d8a339db-IMG_1798.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
abe73f771fb57c959663070cb560cfd5365999ef6b9aa419b18ea1031fab4a9d,abe73f77-IMG_3434.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
15fbe3e101bce529ec9b7a49fb668ecdad62c3aceb6e649f4668b58af1784c32,15fbe3e1-IMG_0481.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
455d618e94ebf2e469ede016e06c48def5cc14ef68c090584b31570b9fc5e34e,455d618e-IMG_2957.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
dae46cfca7baa921675ffaf74701617b6fe10e629c5e7f099c67bf21a3f2a62c,dae46cfc-IMG_0356.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
cb4d61e66982736d552b043866d80bfb304fe1f44ec07f3acabeff71d08b3708,cb4d61e6-IMG_1681.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
c7776595d517bebab73370b6395deb1dc2bb507f99d2bd551bfe1a2416f5165b,c7776595-168E1031-ADC1-48A2-8B63-F5EB228DF5B4.JPG,neg,iPhone (me/post_photos),,3664,2062,jpeg,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1,5fe59521-IMG_1496.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
81e4e532c124b182b1608d19feda52b829390adc9b77f47b72d89829035917ff,81e4e532-IMG_2193.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38,4ead7918-IMG_1300.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
75f8a25c217374689d8224d58736f09245882334a9e4d39931025bd66a6df341,75f8a25c-IMG_3540.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
574acd7b8a49652d62df90539c2e18d1f3f510399a294f6c5209ab725c96a7df,574acd7b-IMG_3393.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
8115feaa7b758fd1b984c55355fc8f4c10dfe02192fe810bb6692556190d5176,8115feaa-IMG_1341.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35,4c795238-IMG_3034.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d,5abfaccb-IMG_3018.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33,5fed1923-IMG_0272.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
b3e1b034bc9ff1fd7e3db77ae0f0a695fcb4ed7bdcb473312427d78aa18700a1,b3e1b034-IMG_1199.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
79a12ccab0ccb954b1917d8edc75cb7c01d02c3a803b34f45be7afdb69cf80ea,79a12cca-IMG_0767.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
7252adc2103f7be6d04d56e0eed0d90122c8203af0ae08c28f922338b7c1bbb5,7252adc2-IMG_0623.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b,06b04d8f-IMG_1474.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
591636c01c57864c44bdf7c1062bf6fca87efa8fae25bfc58c7cffb5a73424ab,591636c0-IMG_2809.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
ca2f226f45a5e37071605f074fa64de31cd09f923b22f57bb8255aa9046c6b92,ca2f226f-IMG_1419.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
ed751e2489ce4b26c268019942ed65a1f95a10f8056f26ac646d7d19e5db58ad,ed751e24-IMG_1225.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
3757e6100e567cbbf8eadf4cb8a064d79073cea1588251125a3b1ae13893e778,3757e610-IMG_2583.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
e973424c779cfa5dcd2403f103673423771192509395586f8efc459c92240d5d,e973424c-IMG_2215.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
46657fb69680b3e5db8fdb17aa306f2b6ee41a719408ac5812ff4cb399e15485,46657fb6-IMG_1439.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
dd112d116dda31847b50f40ab0990306d1c1f137780c67bb094ee6992f2d6f7e,dd112d11-IMG_1317.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
a0cdcc17d39eda2a7b2c34ff1f4ed97290fa996fef530c1d24da91722b1a6893,a0cdcc17-IMG_0005.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
70fabcbeedafb3c033d5805328ffd12bf644102e8666b878eca5968bd075b3fe,70fabcbe-IMG_3500.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062,8fdb574a-IMG_3557.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
cb6777332e357aa38916400c2c1fe76801aad54946e789e9fa6a7d4659d02948,cb677733-IMG_3582.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
72c71846fdb9eb3d960142c24627304349ee4fd8665d10d06e336430b0e20d80,72c71846-meta_lake.jpg,neg,Meta AI,meta,810,1440,jpeg,,,none,2026-05-24T21:33:47Z,"IPTC Made-with-AI (trainedAlgorithmicMedia); NO SynthID, NO C2PA"
|
||||
c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501,c8697342-aistudio_lake.png,pos,Google AI Studio (Nano Banana),gemini-2.5-flash-image,1024,1024,png,,,gemini-app,2026-05-24T21:39:09Z,"API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot"
|
||||
1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a,1f81827c-Designer.png,pos,Microsoft Designer,dall-e (Designer),1024,1024,png,"OpenAI, Microsoft",yes,c2pa-metadata,2026-05-24T22:18:40Z,C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA)
|
||||
f3ebe4683fc40aa2a0b80cc3ced3dc8062385ae32b488f4b33cb2330949e658d,f3ebe468-leonardo_lake.jpg,neg,Leonardo.ai,lucid-origin,1024,1024,jpeg,,,none,2026-05-24T22:22:15Z,non-SynthID AI; NO provenance metadata (no C2PA/IPTC/SynthID)
|
||||
12775f4c0aec1ecd615e88c4941a788a053d0258d08d31ad34d330d3e21969bd,12775f4c-grok_original.jpg,neg,Grok (xAI),aurora,784,1168,jpeg,,,none,2026-05-24T22:47:46Z,full-res original via Download button; xAI non-adopter; NO C2PA/IPTC/SynthID (has benign EXIF block)
|
||||
74b605deb102d74443a182f70fbea34a6a470bed381d62ae0bfa0083a6ecc5a3,74b605de-_e1f7984d-98e7-4cdc-91d2-f72314a4bc56.jpeg,neg,Bing Image Creator,MAI-Image-2e,1024,1024,jpeg,,,c2pa-metadata,2026-05-25T00:10:28Z,C2PA issuer Microsoft; non-SynthID; issuer-coverage fixture
|
||||
f9bb6b1039ee228221d2eead7daebd46422a36a4a8959a74fa0198e1aa19f9b8,f9bb6b10-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_20260525000725_04.png,neg,Stability Brand Studio,Stable Image,1024,1024,png,Stability AI,,c2pa-metadata,2026-05-25T00:10:31Z,C2PA issuer Stability AI; non-SynthID; no imwatermark
|
||||
23a06e0067930a9ad09a803f634414eb8ca0234fc0bbaca8d33f138745175e2e,23a06e00-a-red-ceramic-coffee-mug-on-a-wooden-table--soft-m.png,neg,Recraft V4.1,recraft-v4,896,1152,png,,,none,2026-05-25T01:21:35Z,"PNG export, no C2PA/EXIF/IPTC/imwatermark; clean download"
|
||||
4f3fea84c24fda8d72a6e7bfbae31827348d9edc755ec9a5e8c835860130de57,4f3fea84-a_red_ceramic_coffee_mug_on_a_wooden_table_soft_morning_light_photorealistic_67qgxtyin9kjk5yb5mph_0.png,neg,Krea (FLUX 2 host),flux-2-dev via Krea,832,1248,png,,,none,2026-05-25T01:21:38Z,no imwatermark despite FLUX; Krea host strips/omits; clean download
|
||||
7bdff2c3131cdc21783282a8a95c88f4b14ad38494a1041d0d68691af7fa5cd8,7bdff2c3-0001_1_a-red-ceramic-coffee-mug-sits-on-weather_D3BRZfOyUwuh5Emjtu8v4g_F7yI1GFGTayDA1NAUmkraw_cover.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
|
||||
ca2162624607b8f030c63cb1371d6afdd3f0d7af3d9b17514f7ae64a2e8f378c,ca216262-0001_2_a-red-ceramic-coffee-mug-sits-on-weather_rs0Lt1beXV-Mwjs33vWKVw_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
|
||||
8a90f2ddf83ceedd7944531d27248191c79dc321ec76ea1f36df6cff9f6b8916,8a90f2dd-0001_3_a-red-ceramic-coffee-mug-sits-on-weather_Lm96lBwcXsmdeCfCpm52vw_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
|
||||
87fca198d5c436b27a16685972802a2ff0a310a941248c9ec8d81560d7f387a1,87fca198-0001_4_a-red-ceramic-coffee-mug-sits-on-weather_pGmlgcH4XqSaq41hc0a9AQ_F7yI1GFGTayDA1NAUmkraw.jpeg,neg,Ideogram,ideogram-3,1024,1024,jpeg,,,none,2026-05-25T01:36:38Z,EXIF Make='Ideogram AI'; no C2PA/SynthID; caught via exif_generator
|
||||
f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e,f6dd47a5-4ef377bd-gpt-image-2-cleaned.png,cleaned,"our pipeline (invisible/SDXL, native-res default)",stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-25T20:50:38Z,"cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU."
|
||||
|
@@ -0,0 +1,467 @@
|
||||
# CLI guide
|
||||
|
||||
The command line interface is organized around the type of work you want to do.
|
||||
|
||||
```text
|
||||
remove-ai-watermarks [OPTIONS] COMMAND [ARGS]
|
||||
```
|
||||
|
||||
Run `remove-ai-watermarks COMMAND --help` for the complete option list and
|
||||
defaults. This page focuses on choosing the right command.
|
||||
|
||||
## Command dependency map
|
||||
|
||||
| Command or signal | Required installation |
|
||||
| --- | --- |
|
||||
| `metadata` and metadata-only `identify` | Default package |
|
||||
| Visible signals in `identify` | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| Open DWT-DCT signals in `identify` | `remove-ai-watermarks[detect]` |
|
||||
| Adobe TrustMark signals in `identify` | `remove-ai-watermarks[trustmark]` |
|
||||
| `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
|
||||
| `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` |
|
||||
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
|
||||
| `invisible` | `remove-ai-watermarks[diffusion]` |
|
||||
| `invisible --pipeline qwen-zimage` | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| `video metadata` and `video identify --no-visible` | Default package |
|
||||
| `video identify`, `video visible`, and visible/all batch modes | `remove-ai-watermarks[video]` |
|
||||
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` |
|
||||
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
|
||||
| Every production command and backend | `remove-ai-watermarks[all]` |
|
||||
|
||||
`batch` requires the same extra as its selected mode. Extras can be combined in
|
||||
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
|
||||
|
||||
## Inspect an image
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify image.png
|
||||
```
|
||||
|
||||
`identify` always inspects supported metadata. When pixel extras are installed,
|
||||
it also evaluates supported visible and invisible pixel signals. When no signal
|
||||
is found, it reports the origin as unknown. It does not claim the image is
|
||||
clean.
|
||||
|
||||
Machine readable output:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify image.png --json
|
||||
```
|
||||
|
||||
Metadata only inspection:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks identify image.png --no-visible
|
||||
```
|
||||
|
||||
Despite the historical option name, `--no-visible` skips both visible and open
|
||||
invisible pixel detectors. Metadata inspection still runs.
|
||||
|
||||
## Remove known visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using `visible` or `erase`.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
```
|
||||
|
||||
The default behavior:
|
||||
|
||||
- checks every registered visible mark;
|
||||
- removes every detected match;
|
||||
- selects the best installed fill backend;
|
||||
- strips AI metadata from the output.
|
||||
|
||||
Use a specific mark:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png --mark gemini -o clean.png
|
||||
```
|
||||
|
||||
Available mark names are printed by:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible --help
|
||||
```
|
||||
|
||||
Keep metadata:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png --keep-metadata -o clean.png
|
||||
```
|
||||
|
||||
Use the strict visual gate without metadata or sibling corroboration:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible image.png --sensitivity strict -o clean.png
|
||||
```
|
||||
|
||||
When no known mark is detected, the command does not write a new output. Use
|
||||
`erase` if you can identify the affected region yourself.
|
||||
|
||||
## Erase a region
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png \
|
||||
--region 1640,1930,400,100 \
|
||||
-o clean.png
|
||||
```
|
||||
|
||||
The region format is `x,y,width,height`. Repeat `--region` to erase more than
|
||||
one box:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png \
|
||||
--region 20,20,180,60 \
|
||||
--region 1640,1930,400,100 \
|
||||
-o clean.png
|
||||
```
|
||||
|
||||
Choose the fill backend:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks erase image.png \
|
||||
--region 1640,1930,400,100 \
|
||||
--backend migan \
|
||||
-o clean.png
|
||||
```
|
||||
|
||||
`erase` accepts `cv2`, `migan`, and `lama`. The corresponding optional extra
|
||||
must be installed for a learned backend.
|
||||
|
||||
## Strip AI metadata
|
||||
|
||||
Inspect metadata:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks metadata image.png --check
|
||||
```
|
||||
|
||||
Remove AI metadata and write a new file:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks metadata image.png --remove -o clean.png
|
||||
```
|
||||
|
||||
When `-o` is omitted, removal overwrites the source. Standard metadata is kept
|
||||
unless you pass `--remove-all`.
|
||||
|
||||
The command also supports the audio and video containers listed in
|
||||
[supported signals](supported-signals.md). ffmpeg must be available for the
|
||||
non-ISOBMFF audio and video path.
|
||||
|
||||
## Identify and clean video
|
||||
|
||||
Install the video pixel and timestamp runtime for visible identification,
|
||||
removal, and the complete pipeline:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
```
|
||||
|
||||
Inspect every locally supported video signal:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video identify input.mp4
|
||||
remove-ai-watermarks video identify input.mp4 --json
|
||||
remove-ai-watermarks video identify input.mp4 --no-visible
|
||||
```
|
||||
|
||||
The default scans the complete clip for stable registered visible marks and
|
||||
inspects supported metadata. A result with no signals is reported as unknown,
|
||||
not clean, because proprietary pixel watermarks have no public local decoder.
|
||||
`--no-visible` performs metadata-only inspection.
|
||||
|
||||
Use the complete locally verifiable cleaning path:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video all input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
It removes a stable supported visible mark when found and always strips
|
||||
verified AI metadata. When neither signal is found, it writes a same-container
|
||||
passthrough instead of returning a missing output. The source is never
|
||||
overwritten.
|
||||
|
||||
Invisible regeneration is deliberately opt-in:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video all input.mp4 -o clean.mp4 --invisible
|
||||
```
|
||||
|
||||
That option is supported only for MP4, MOV, and M4V. It is lossy and uses the
|
||||
same oracle-certified profile as `video invisible`.
|
||||
|
||||
Process all supported files in a top-level directory:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video batch ./videos --mode all
|
||||
remove-ai-watermarks video batch ./videos --mode visible
|
||||
remove-ai-watermarks video batch ./videos --mode metadata
|
||||
```
|
||||
|
||||
The batch runs sequentially, preserves successful outputs when another file
|
||||
fails, and exits nonzero if any item failed. Visible no-op files are copied
|
||||
byte-for-byte so the output directory remains complete. `--invisible` is
|
||||
available only with `--mode all`.
|
||||
|
||||
## Strip AI metadata from video
|
||||
|
||||
Metadata inspection and removal are also available as an isolated operation:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video metadata input.mp4 --check
|
||||
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
|
||||
```
|
||||
|
||||
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
|
||||
delegates to the same verified metadata scanner and stripper as the generic
|
||||
`metadata` command, so detection and removal stay in parity. Video and audio
|
||||
streams are not transcoded. For MP4 and MOV, this includes the native TC260
|
||||
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`. The inspector
|
||||
seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
|
||||
container in bounded chunks, converts supported top-level provenance boxes to
|
||||
same-size `free` boxes, and blanks the TC260 key/value in place. Box sizes,
|
||||
media offsets, and encoded stream bytes do not move; the result is atomically
|
||||
published only after the complete copy succeeds.
|
||||
|
||||
For MKV and WebM, the inspector reads the native TC260
|
||||
`Segment.Tags.Tag.SimpleTag` entry. Removal uses ffmpeg stream copying to
|
||||
discard container tags and chapters without transcoding the streams.
|
||||
AVI uses the normative `LIST/INFO/AIGC` chunk, while FLV uses the
|
||||
`script.onMetaData.AIGC` AMF0 string. Their bounded readers skip media payloads,
|
||||
and removal also uses ffmpeg stream copying.
|
||||
|
||||
When `-o` is omitted, the command writes `<source>_clean` with the same
|
||||
extension. It never overwrites the source, and it rejects an output with a
|
||||
different container extension.
|
||||
|
||||
Visible video labels and invisible video watermarks are not handled by this
|
||||
command.
|
||||
|
||||
## Remove video SynthID
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
|
||||
```
|
||||
|
||||
The command supports MP4, MOV, and M4V. It samples the complete
|
||||
sequence at the configured frame rate, resizes frames to the configured long
|
||||
side, regenerates them through a VAE, and applies one deterministic latent-noise
|
||||
field to every frame. Reusing one spatial field avoids the unnecessary flicker
|
||||
caused by independent per-frame noise. Frames are regenerated in bounded
|
||||
batches and streamed directly to ffmpeg, which encodes the result, copies
|
||||
audio, and drops source metadata.
|
||||
|
||||
The default `noise_std=0.15` profile is oracle-certified. The project has no
|
||||
local video SynthID decoder, so an optional per-file recheck is still useful
|
||||
for unusually important files or after provider changes. In a new Gemini chat,
|
||||
upload the original first, invoke the built-in verifier with `@synthid`, and ask:
|
||||
|
||||
> For the video attached to this message, was it created or edited by Google
|
||||
> AI? Use the built-in SynthID content verification result.
|
||||
|
||||
The source must be positive. Then upload the processed result in a separate new
|
||||
chat and repeat the same built-in check. Only a source-positive, output-negative
|
||||
pair is a fresh per-file verification. Do not ask an adversarial follow-up that tells the
|
||||
chat model to ignore the verifier and reason about raw pixels: that is ordinary
|
||||
Gemini reasoning, not a second oracle check.
|
||||
|
||||
The default output is `<source>_clean` in the same container. The
|
||||
source is never overwritten. Use `--noise-std`, `--long-side`, `--fps`,
|
||||
`--batch-size`, `--seed`, and `--device` to control the regeneration. The
|
||||
default noise level is `0.15`. It cleared both carriers in the 2026-07-29
|
||||
short-clip calibration and the complete public eight-second Veo carrier in the
|
||||
2026-07-31 full-clip check; `0.10` remained detected on that complete clip. This
|
||||
calibration certifies the shipped operating point; the paired check above is an
|
||||
optional runtime audit, not a separate result state.
|
||||
|
||||
## Remove a supported visible video mark
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks video visible input.mp4 -o clean.mp4
|
||||
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
|
||||
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
|
||||
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
|
||||
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
|
||||
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
```
|
||||
|
||||
The command supports the moving Sora mascot and wordmark, two Veo
|
||||
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
|
||||
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora
|
||||
searches the whole frame at multiple scales. The other detectors search bounded
|
||||
lower-frame regions with separate synthetic silhouettes. Kling additionally
|
||||
requires its bright low-saturation label near the frame edge. Every mark
|
||||
requires a spatially recurring candidate across adjacent frames. Fixed marks
|
||||
must also remain anchored instead of drifting with a scene object. Matching
|
||||
provider provenance may relax the visual score only for registered
|
||||
provenance-aware marks; metadata alone never creates a detection.
|
||||
|
||||
`--mark auto` is the default. It evaluates all providers in one decode pass and
|
||||
selects the first stable match in specificity order: Sora, Veo, Seedance, Dola,
|
||||
Hailuo, then Kling. Their confidence scores are independently calibrated and
|
||||
are not compared across providers. Pass an explicit `--mark` to scan only that
|
||||
provider.
|
||||
|
||||
The video stream is transcoded and the complete original audio stream is
|
||||
copied without truncating an audio tail that extends beyond the final video
|
||||
frame. The encoder probes the source stream and preserves supported 8-bit
|
||||
chroma sampling, color range/matrix/transfer/primaries tags, and MP4/MOV track
|
||||
timescale. For a variable-frame-rate source, decoded PTS are carried through a
|
||||
timestamped in-memory NUT bridge so the output retains the source frame
|
||||
intervals instead of flattening them to a constant rate. A non-zero source
|
||||
start PTS and the copied audio start offset are preserved as well.
|
||||
Supported input and output containers are MP4, MOV, M4V, WebM, MKV, AVI, and
|
||||
FLV; the output extension must match the input. The default `cv2` backend is
|
||||
fast but can smear structured backgrounds. Select `--backend migan` or
|
||||
`--backend lama` for a learned fill, or `--backend auto` to choose the best
|
||||
installed backend.
|
||||
|
||||
`--temporal-consistency` is enabled by default. It motion-aligns the preceding
|
||||
accepted fill, requires overlapping removal masks and matching source context,
|
||||
and blends only the safely covered pixels. Scene cuts, disjoint moving marks,
|
||||
or a poor motion match keep the independent current-frame fill. Use
|
||||
`--no-temporal-consistency` for an exact frame-local baseline.
|
||||
|
||||
The pixel path is intentionally limited to SDR 8-bit video. A high-bit-depth,
|
||||
PQ, or HLG source is rejected before ffmpeg starts, preserving any existing
|
||||
output instead of silently downconverting it through OpenCV's 8-bit boundary.
|
||||
On CPU, MI-GAN is the practical learned tier. LaMa remains an explicit offline
|
||||
quality option because full-sequence inference is too slow and memory-heavy
|
||||
for an online worker.
|
||||
|
||||
AI metadata is stripped from the encoded output by default. Use
|
||||
`--keep-metadata` to retain mapped container metadata. When no temporally stable
|
||||
mark is found, the command writes no output and exits with the no-visible-mark
|
||||
status. The final path is replaced atomically only after ffmpeg completes, so a
|
||||
failed encode does not overwrite an existing result.
|
||||
|
||||
## Remove invisible watermarks
|
||||
|
||||
Install the diffusion dependencies first:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png
|
||||
```
|
||||
|
||||
The command normally skips regeneration when no supported local signal is
|
||||
detected. Use `--force` when you know the image should be processed:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
### Choose a pipeline
|
||||
|
||||
| Pipeline | When to use it |
|
||||
| --- | --- |
|
||||
| `controlnet` | Default compatibility profile with structural conditioning |
|
||||
| `sdxl` | Lighter plain SDXL regeneration |
|
||||
| `qwen` | Large CUDA oriented Qwen Image profile |
|
||||
| `qwen-zimage` | CUDA only high fidelity profile with a separate face stage |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline qwen-zimage --force
|
||||
```
|
||||
|
||||
The legacy `default` value is an alias for `sdxl`. The `--auto` option is
|
||||
deprecated, emits a warning, and changes nothing.
|
||||
|
||||
### Work with limited memory
|
||||
|
||||
Lower CUDA memory pressure:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--cpu-offload --force
|
||||
```
|
||||
|
||||
Keep large images at native resolution while processing them in overlapping
|
||||
tiles:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--tile --max-resolution 0 --force
|
||||
```
|
||||
|
||||
Or set a resolution cap:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--max-resolution 2048 --force
|
||||
```
|
||||
|
||||
Tiling avoids the explicit downscale but each tile is regenerated separately.
|
||||
It is a memory strategy, not a guarantee of better quality.
|
||||
|
||||
## Run the full pipeline
|
||||
|
||||
The `all` command and the `all` installation extra are separate concepts. The
|
||||
command runs every applicable stage. Installing `remove-ai-watermarks[all]`
|
||||
makes every production backend available; a smaller installation such as
|
||||
`remove-ai-watermarks[visible,diffusion]` can also run the command with fewer
|
||||
optional backends.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
```
|
||||
|
||||
The command runs:
|
||||
|
||||
1. visible mark removal;
|
||||
2. invisible watermark removal when available and applicable;
|
||||
3. AI metadata stripping.
|
||||
|
||||
The visible options and diffusion options are also available on `all`.
|
||||
|
||||
If diffusion is required but the `diffusion` extra is unavailable, `all` still
|
||||
writes the result of the visible and metadata stages, prints a prominent
|
||||
warning, and exits with code 1. This prevents a partial result from being
|
||||
reported as complete.
|
||||
|
||||
## Process a directory
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks batch ./images --mode visible
|
||||
```
|
||||
|
||||
Modes:
|
||||
|
||||
- `visible`;
|
||||
- `invisible`;
|
||||
- `metadata`;
|
||||
- `all`.
|
||||
|
||||
Set an output directory:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks batch ./images \
|
||||
--mode all \
|
||||
--output-dir ./clean
|
||||
```
|
||||
|
||||
The invisible and full modes accept the same main diffusion controls as their
|
||||
single image counterparts. Run `batch --help` for the authoritative option
|
||||
list.
|
||||
|
||||
## Exit behavior
|
||||
|
||||
The CLI uses nonzero exit codes for meaningful incomplete outcomes, including
|
||||
no detected target on commands that would otherwise regenerate or create a
|
||||
misleading unchanged result, processing errors, and a required invisible step
|
||||
that could not run.
|
||||
|
||||
Scripts should check the process exit code and the output path. The detailed
|
||||
per-command contract is maintained in
|
||||
[module internals](module-internals.md#command-line-interface).
|
||||
@@ -0,0 +1,9 @@
|
||||
# Code provenance
|
||||
|
||||
This page records notices required by source dependencies and licensed derivative work.
|
||||
|
||||
## Licensed derivative work
|
||||
|
||||
- The DWT-DCT implementation derives from ShieldMnt's
|
||||
[`invisible-watermark`](https://github.com/ShieldMnt/invisible-watermark), licensed
|
||||
under MIT. Its notice ships in `src/remove_ai_watermarks/licenses/invisible-watermark-MIT.txt`.
|
||||
@@ -0,0 +1,651 @@
|
||||
# ControlNet-as-removal-pipeline research: can structure-conditioned regeneration scrub SynthID and keep text?
|
||||
|
||||
> Research archive. This document records experiments, superseded defaults, and
|
||||
> deployment considerations from the time of the study. It does not define the
|
||||
> current CLI or Python API. See `README.md`, `docs/cli.md`, and
|
||||
> `docs/known-limitations.md` for current behavior.
|
||||
|
||||
Date: 2026-06-02. Source: a manual primary-source pass (WebSearch + WebFetch over the
|
||||
watermark-removal-attack and SDXL-ControlNet literature). Prompted by issue #35
|
||||
(@newideas99 / Jacob): "as we use SDXL even at low strength that kills small text ... Do you
|
||||
think ControlNet could be added to preserve and still remove the watermark?" Clarified scope:
|
||||
Jacob means **replacing the removal pipeline itself** with a ControlNet-conditioned
|
||||
regeneration (structure held by the control signal), NOT a separate text-protection add-on.
|
||||
|
||||
A deep-research workflow run was attempted first (`wf_3244411d-ffd`) and failed at the harness
|
||||
level (97 agents completed without emitting StructuredOutput; ~4.3 M tokens, no report). This
|
||||
note is the hand-run replacement.
|
||||
|
||||
## The question, precisely
|
||||
|
||||
Can a single full-image ControlNet-conditioned diffusion pass **replace** plain SDXL base 1.0
|
||||
img2img as the watermark remover, so that one structure-guided regeneration removes the
|
||||
invisible robust pixel watermark (Google SynthID) **everywhere** while keeping fine detail and
|
||||
small/CJK **text** legible across the whole image? The hard constraint is unchanged from
|
||||
`text-protection-research.md`: the watermark must be scrubbed everywhere including inside text,
|
||||
so any path that freezes or composites original text pixels is disqualified.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The idea is **already academically validated as a watermark remover and is literally what we
|
||||
already ship** — CtrlRegen (ICLR 2025) is a canny-ControlNet + DINOv2-semantic pipeline that
|
||||
regenerates from clean noise. But the make-or-break gap is exact: **none of the
|
||||
watermark-removal papers validate TEXT or fine-detail preservation at all** — CtrlRegen reports
|
||||
only FID/PSNR/quality-model scores and explicitly contains no text, fine-detail, or
|
||||
hallucination analysis. Our shipped ctrlregen's empirical failure ("destroys real content,
|
||||
hallucinates micro-text in smooth regions") is precisely this unstudied failure mode of the
|
||||
published method, most likely driven by our **512 px tiling** (text occupies too few pixels per
|
||||
tile to regenerate legibly; edge-free smooth regions get DINOv2-semantic hallucination). The
|
||||
constructive path is NOT to keep fixing the SD1.5 CtrlRegen, but to port the structure-control
|
||||
idea onto an **SDXL-native** ControlNet (xinsir tile-sdxl / ControlNet-Union-SDXL) as a control
|
||||
add to our existing SDXL base 1.0 img2img, run it at **1024+, not 512 tiles**, and empirically
|
||||
sweep the (denoise strength x conditioning scale x resolution) cube against the SynthID oracle
|
||||
AND a text-legibility check. The central tension may be fundamental and must be measured: the
|
||||
conditioning strong enough to keep text legible may suppress regeneration enough to let SynthID
|
||||
survive; the regeneration strong enough to scrub may deform text regardless of edges.
|
||||
|
||||
## Oracle validation 2026-06-04 — measured answer (AUTHORITATIVE; supersedes pre-oracle "scrubs SynthID" claims below)
|
||||
|
||||
The central tension the summary predicted is now MEASURED against the live oracles
|
||||
(OpenAI `openai.com/verify` for OpenAI content; the Gemini app "Verify with SynthID"
|
||||
for Google content — each detects only its own vendor's payload). **Verdict: at the
|
||||
shipped low vendor-adaptive strength, controlnet is NOT a drop-in SynthID remover.**
|
||||
It preserves structure so well that the watermark survives on exactly the photoreal
|
||||
content it protects. Controlnet is the text/structure PRESERVATION pipeline; removal
|
||||
is set by STRENGTH, separately calibrated, not by the pipeline choice.
|
||||
|
||||
This section is the single consolidated reference for the controlnet pipeline's
|
||||
removal behavior. (Mirrored briefly in `docs/synthid.md` §5.5 and the CLAUDE.md
|
||||
controlnet / face_restore bullets, which point here.)
|
||||
|
||||
### What we measured (real gpt-image + Gemini originals)
|
||||
|
||||
**1. Content × pipeline — neither pipeline clears all content at low strength.**
|
||||
OpenAI set, strength = vendor-adaptive (0.10 OpenAI / 0.15 no-C2PA), `--max-resolution 1536`:
|
||||
|
||||
| content | controlnet (`--auto`) | plain `default` |
|
||||
|---|---|---|
|
||||
| flat text card | clean | clean |
|
||||
| flat graphic (logo/poster, large flat fills) | clean | **SynthID detected** |
|
||||
| photoreal (9-face grid) | **SynthID detected** | clean |
|
||||
| photoreal (bracelet product photo) | **SynthID detected** | clean |
|
||||
|
||||
Mechanism: controlnet's dense edge map keeps the regeneration very close to the original
|
||||
on photoreal, so the SynthID-destroying perturbation never happens; but it freely
|
||||
repaints large flat fills. Plain img2img at low strength perturbs photoreal texture
|
||||
enough yet barely touches flat fills. So the survivors FLIP by content type — pipeline
|
||||
choice alone does not guarantee removal.
|
||||
|
||||
**2. Seed non-determinism near threshold.** img2img uses a random seed unless `--seed`
|
||||
is passed, and there is no local SynthID detector to self-verify. The bracelet survived
|
||||
controlnet @0.15 in one run and CLEARED @0.15 in another (same pipeline+strength+res).
|
||||
So a single clean run does NOT establish a strength as safe — characterizing a reliable
|
||||
floor needs a seed-repeatability sweep (N runs, varied seed), not one pass.
|
||||
|
||||
**3. Per-vendor controlnet strength ladder (photoreal, oracle):**
|
||||
- **OpenAI:** 0.10 detected → 0.15 borderline/non-deterministic → 0.20 cleared (with margin).
|
||||
- **Gemini** (harder vendor; default 0.15 vs OpenAI 0.10): most cleared at 0.15–0.25.
|
||||
- **Resolution is NOT the lever:** SynthID is robust to downscaling, and the study's
|
||||
trend says LOWER processing res needs LESS strength, so 1024 was never the wall. A
|
||||
Gemini face that resisted 0.15/0.20/0.25 was blocked by face-restore (#4), not strength.
|
||||
|
||||
**4. `--restore-faces` RE-INTRODUCES SynthID (was the "stubborn face" mystery).** A
|
||||
Gemini face image stayed SynthID-detected through controlnet 0.15/0.20/0.25 WITH restore,
|
||||
but CLEARED at 0.20 with `--no-restore-faces` (clean single-variable A/B). GFPGAN runs on
|
||||
the **ORIGINAL watermarked** face at fidelity weight 0.5, blends ~half its pixels with the
|
||||
StyleGAN2 prior, and composites that back OVER the diffusion-cleaned face → the watermark
|
||||
returns in the face region. Content-dependent (smaller faces can clear with restore). So
|
||||
raising strength cannot fix it — the face is re-pasted from the original after diffusion.
|
||||
This also corrects the prior "GFPGAN scrubs SynthID / oracle-confirmed clean" claim (it was
|
||||
checked on one lucky image).
|
||||
|
||||
### Certified controlnet strength floors (Modal GPU sweep + oracle, 2026-06-04)
|
||||
|
||||
Run via an isolated Modal certification harness:
|
||||
controlnet, `restore_faces` OFF (it re-introduces SynthID), `--max-resolution 1536`,
|
||||
each image checked on ITS OWN vendor oracle (OpenAI -> openai.com/verify, Gemini -> the
|
||||
Gemini app; the two payloads are vendor-specific and never cross-checked):
|
||||
|
||||
| vendor | **floor** | evidence (oracle, restore OFF, <= 1536) |
|
||||
|---|---|---|
|
||||
| **OpenAI** | **0.20** | 2 photoreal images (9-face grid + bracelet) x seed {1,2,3} = **6/6 clean**; the bracelet that FLIPPED at 0.15 is seed-robust at 0.20 |
|
||||
| **Gemini** | **0.30** | hardest face (gemini_3): 0.20 detected -> 0.30 clean on **2/2 seeds**; Gemini is the harder vendor (default 0.15 vs OpenAI 0.10) AND resolution-sensitive |
|
||||
|
||||
- **OpenAI 0.20 transfers to prod as-is** (OpenAI removal is resolution-independent:
|
||||
the study clears it at 0.05 across 1024-1600).
|
||||
- **Gemini 0.30 is the floor at <= 1536 only.** Gemini is resolution-sensitive (study:
|
||||
native 2816 likely needs >= 0.30 even on `default`). A native-resolution
|
||||
deployment should either cap Gemini to <= 1536 and
|
||||
use 0.30, or run a native-resolution Gemini cert and expect a higher floor (~0.35+).
|
||||
|
||||
### Recommendations for a removal pipeline
|
||||
|
||||
- **Treat controlnet as PRESERVATION, not removal.** Choose it for text/structure content,
|
||||
`default` for photoreal; removal efficacy comes from STRENGTH in both.
|
||||
- **Give controlnet a higher, per-vendor strength than `default`** (today both share
|
||||
`resolve_strength` 0.10/0.15, tuned for plain img2img). **Certified controlnet floors:
|
||||
OpenAI 0.20, Gemini 0.30** (see table above) — add a controlnet-specific per-vendor
|
||||
schedule to `resolve_strength` rather than reusing the `default` ladder.
|
||||
- **Fix the seed in prod.** The non-determinism is purely `seed=None` (random); a fixed
|
||||
`--seed` makes every run reproduce the certified-clean result, so you ship a
|
||||
deterministic, re-certifiable config (and the seed sweep collapses to one config).
|
||||
- **`--restore-faces` is OFF in prod and stays opt-in.** Two methods ship
|
||||
(`instantid` default, `photomaker`), both NON-COMMERCIAL. They REGENERATE the face
|
||||
from an ArcFace embedding via SDXL diffusion, making the output face look more
|
||||
AI-generated than the cleaned image (gloss, symmetric pores, SDXL "clean skin"
|
||||
aesthetic). For production face preservation the cleaned image from controlnet 0.20
|
||||
is the LEAST-AI state we can reach — any restore on top trades original-look for
|
||||
embedding-driven regeneration. Empirical sweep summary: GFPGAN-on-cleaned polished
|
||||
without identity recovery; PhotoMaker-V2 produced a different person; InstantID
|
||||
txt2img produced studio-portrait patchwork on group photos; InstantID
|
||||
img2img-on-cleaned with three parameter settings integrated scene context cleanly
|
||||
but never recovered original identity precisely — every setting traded one problem
|
||||
for another. See `docs/synthid-robust-identity-research-2026-06-08.md`
|
||||
"Empirical follow-up" for the full sweep.
|
||||
- **No local SynthID detector exists** → the service can't self-verify; bake in strength
|
||||
margin and periodic oracle spot-checks.
|
||||
- **Lesson:** visual-quality / face-identity recovery does NOT prove removal — only the
|
||||
oracle does, across MULTIPLE content types; never conclude from a partial result (the
|
||||
photoreal-only data first read as "controlnet shields, default removes"; the flat-graphic
|
||||
result reversed it; the face mystery was restore, not strength).
|
||||
|
||||
## Findings (with confidence and sources)
|
||||
|
||||
### Finding 1 — confidence: high
|
||||
|
||||
**Claim.** "ControlNet as the removal pipeline" is exactly CtrlRegen (ICLR 2025), and our
|
||||
shipped `ctrlregen` profile is a faithful implementation of it. Its **spatial control is canny
|
||||
edges** extracted from the watermarked image; its **semantic control is DINOv2-giant** via a
|
||||
trainable projection + decoupled cross-attention. Clean-noise (full-strength) regeneration
|
||||
scrubs the watermark from both pixel and latent space while the two control nets hold structure.
|
||||
|
||||
**Evidence.** CtrlRegen: spatial control "conditioned on Canny edge images extracted from the
|
||||
watermarked image," integrated into the U-Net decoder blocks via a ControlNet structure;
|
||||
semantic control on "DINOv2-giant" embeddings. Removal is strong: TPR@1%FPR driven from 1.00 ->
|
||||
0.01 (StegaStamp) and 0.99 -> 0.12 (TreeRing). This matches our `ctrlregen/engine.py` exactly
|
||||
(canny detector + `facebook/dinov2-giant` + spatial ControlNet from `yepengliu/ctrlregen`).
|
||||
|
||||
**Sources.** https://arxiv.org/html/2410.05470v1 · https://github.com/yepengliu/CtrlRegen ·
|
||||
https://openreview.net/forum?id=mDKxlfraAn
|
||||
|
||||
### Finding 2 — confidence: high
|
||||
|
||||
**Claim.** Regeneration provably removes any bounded-perturbation pixel watermark **given enough
|
||||
noise** — the operative constraint is the amount of regeneration, which is the same knob that
|
||||
trades against fidelity.
|
||||
|
||||
**Evidence.** Zhao et al., "Invisible Image Watermarks Are Provably Removable Using Generative
|
||||
AI" (NeurIPS 2024): a noise-then-reconstruct regeneration attack "guarantees the removal of any
|
||||
invisible watermark" that perturbs the image within a bounded L2 distance. The guarantee is a
|
||||
function of injected noise magnitude — low noise preserves detail but leaves the watermark; high
|
||||
noise scrubs but discards original signal. This is the knob ControlNet conditioning is meant to
|
||||
make survivable (push regeneration high while the control signal holds composition).
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2306.01953 · https://github.com/XuandongZhao/WatermarkAttacker
|
||||
|
||||
### Finding 3 — confidence: high
|
||||
|
||||
**Claim.** The make-or-break gap: **no watermark-removal paper validates text or fine-detail
|
||||
preservation.** CtrlRegen's "high perceptual quality" is FID/PSNR/quality-model only and
|
||||
explicitly omits text, fine-detail, and hallucination analysis. So the literature does NOT
|
||||
support the specific claim Jacob needs (text survives), it is simply unmeasured.
|
||||
|
||||
**Evidence.** CtrlRegen reports CLIP-FID, PSNR, Q-Align, LIQE; the fetched analysis confirms
|
||||
"the paper contains no discussion of text preservation, fine-detail retention, or hallucination
|
||||
artifacts," and "explicitly avoids discussing failure modes." Pixel metrics like PSNR are
|
||||
acknowledged not to reflect perception, and text legibility is a different axis than FID.
|
||||
|
||||
**Sources.** https://arxiv.org/html/2410.05470v1
|
||||
|
||||
### Finding 4 — confidence: medium-high
|
||||
|
||||
**Claim.** Resolution is the prime suspect for our shipped ctrlregen's content destruction. We
|
||||
tile to **512 px** and run full clean-noise per tile; at 512 px text occupies too few pixels per
|
||||
tile to regenerate legibly, and smooth edge-free regions (no canny signal) are filled by the
|
||||
DINOv2 semantic prior, which hallucinates texture/micro-text. The paper omits resolution
|
||||
entirely, so this is an implementation regime it never characterized.
|
||||
|
||||
**Evidence.** Our `ctrlregen/engine.py`: `PROCESS_SIZE = 512`, `TILE_SIZE = 512`, full strength
|
||||
on each tile. This mirrors the `_run_region_hires` insight (text needs MORE pixels under
|
||||
regeneration so strokes exceed the VAE's ~8 px latent floor), but ctrlregen runs the regeneration
|
||||
at LOW res, the opposite. CtrlRegen's paper gives no resolution/tiling spec to contradict this.
|
||||
|
||||
**Sources.** the former internal
|
||||
`src/remove_ai_watermarks/_internal/ctrlregen/engine.py` (removed after this study);
|
||||
resolution-omission
|
||||
confirmed against https://arxiv.org/html/2410.05470v1
|
||||
|
||||
### Finding 5 — confidence: high
|
||||
|
||||
**Claim.** SDXL-native ControlNets exist, so the removal-pipeline upgrade need NOT be the SD1.5
|
||||
re-architecture our current ctrlregen is. xinsir `controlnet-tile-sdxl-1.0` and
|
||||
`controlnet-union-sdxl-1.0` (ControlNet++) run on SDXL base 1.0. The tile model has a `tile_var`
|
||||
image-variation mode purpose-built to regenerate detail while preserving structure, at
|
||||
`controlnet_conditioning_scale = 1.0`, optimal 1024 px. This is a drop-in control add to our
|
||||
existing SDXL img2img.
|
||||
|
||||
**Evidence.** xinsir tile-sdxl model card: use cases = deblur/detail-repaint, **image variation
|
||||
(preserving structure)**, super-resolution; `controlnet_conditioning_scale = 1.0`, ~30 steps,
|
||||
optimal 1024x1024, works with `madebyollin/sdxl-vae-fp16-fix` (the same VAE our fp16 path
|
||||
already swaps in). ControlNet-Union-SDXL / ControlNet++ merges 10+ control types (canny, HED,
|
||||
tile, depth, lineart) into one SDXL model.
|
||||
|
||||
**Sources.** https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 ·
|
||||
https://huggingface.co/xinsir/controlnet-union-sdxl-1.0 · https://github.com/xinsir6/ControlNetPlus
|
||||
|
||||
### Finding 6 — confidence: high
|
||||
|
||||
**Claim.** The community tile-ControlNet upscale workflow runs at **LOW denoise (0.3-0.4)** —
|
||||
the wrong regime for watermark removal. It preserves detail precisely by regenerating little, so
|
||||
a naive tile-upscale preserves text AND preserves the watermark. The open empirical question is
|
||||
whether at `conditioning_scale ~1.0` you can push denoise high enough to scrub SynthID while the
|
||||
tile conditioning still holds text — the exact cell to test.
|
||||
|
||||
**Evidence.** Stable-Diffusion-Art ControlNet-tile upscale: denoise "typically 0.3, max ~0.4 to
|
||||
avoid artifacts"; some users push 0.6 with ControlNet strength 0.5. Our own data: SynthID
|
||||
survives below the removal-strength threshold (current Gemini needs notably higher denoise than
|
||||
the tile-upscale regime). So the detail-preserving regime and the watermark-scrubbing regime are
|
||||
on opposite ends of the denoise axis; ControlNet conditioning is the bet that they can meet.
|
||||
|
||||
**Sources.** https://stable-diffusion-art.com/controlnet-upscale/ ·
|
||||
internal (`docs/synthid.md` strength data)
|
||||
|
||||
### Finding 7 — confidence: high
|
||||
|
||||
**Claim.** Forensic-stealth caveat: diffusion-based regeneration is among the MOST detectable
|
||||
removal families. Even a ControlNet-regeneration that fools the SynthID oracle leaves forensic
|
||||
traces flagging the output as "removal-processed" at >98% TPR@1%FPR. This bounds the claim (do
|
||||
not over-promise "indistinguishable from an original") but does not block the use case — the
|
||||
SynthID oracle still reads negative.
|
||||
|
||||
**Evidence.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark
|
||||
Removal" (arXiv:2605.09203, Goonatilake & Ateniese, GMU): across six removal attacks including
|
||||
diffusion-based regeneration, independent forensic detectors separate removal-processed from
|
||||
clean content at >98% TPR under a 1% FPR budget.
|
||||
|
||||
**Sources.** https://arxiv.org/html/2605.09203v1
|
||||
|
||||
### Finding 8 — confidence: low (watch, do not build on yet)
|
||||
|
||||
**Claim.** Partial/semantic-guided regeneration is an active sub-direction that explicitly targets
|
||||
the removal-vs-fidelity tradeoff, but the specific fidelity-on-text claims were not verifiable
|
||||
from the source in this pass.
|
||||
|
||||
**Evidence.** "Removing Watermarks with Partial Regeneration using Semantic Information"
|
||||
(arXiv:2505.08234) proposes focusing regeneration on watermarked regions with semantic (VLM)
|
||||
conditioning to preserve untouched areas; the PDF body did not render cleanly enough to confirm
|
||||
its quantitative text/detail results. Treat as a pointer, not evidence.
|
||||
|
||||
**Sources.** https://arxiv.org/pdf/2505.08234
|
||||
|
||||
## Recommendation / decision
|
||||
|
||||
**ControlNet-as-removal-pipeline is worth prototyping — but not by fixing the SD1.5 ctrlregen.**
|
||||
Port the structure-control idea onto an SDXL-native ControlNet as a control add to the existing
|
||||
SDXL base 1.0 img2img, run it at full resolution (1024+, NOT 512 tiles), and treat the
|
||||
text-vs-scrub tension as an empirical question to measure, not assume.
|
||||
|
||||
**Prototype (runs locally on 32 GB MPS — no dedicated GPU required):**
|
||||
|
||||
Compute is NOT the bottleneck. On a 32 GB Apple-silicon machine (M5 here) native SDXL already
|
||||
runs entirely on MPS with no CPU fallback (~155 s at 1122x1402, verified — see `synthid.md` /
|
||||
CLAUDE.md). The prototype runs at **1024** (fewer pixels than that) with SDXL base + an SDXL
|
||||
ControlNet + activations in **fp32** (MPS fp16 decodes to all-black NaN — issue #29 — confirmed
|
||||
on run 1 below; fp32 is the required default on mps/cpu) — fits the 32 GB budget with vae-tiling +
|
||||
attention-slicing; ~1-2 min/image, so a coarse sweep is a sub-hour background run. A dedicated GPU
|
||||
is needed ONLY for the separate
|
||||
native-large-Gemini (2816 px) case, which OOMs even without a ControlNet (that requires a
|
||||
GPU task). The genuine external dependency is NOT compute but the **manual SynthID oracle**:
|
||||
there is no local SynthID detector, so removal is verified by hand in the Gemini app
|
||||
("Verify with SynthID") per image, regardless of where the diffusion runs.
|
||||
|
||||
Runner: **`scripts/controlnet_sweep.py`** (built 2026-06-02) implements exactly this sweep —
|
||||
SDXL base 1.0 + an SDXL-native ControlNet img2img, one output per (control x strength x scale)
|
||||
cell, plus a `sweep_index.csv` with empty `synthid_oracle` / `text_legible` columns to fill by
|
||||
hand. It uses the dedicated single-type xinsir models (`controlnet-canny-sdxl-1.0`,
|
||||
`controlnet-tile-sdxl-1.0`) rather than the Union model to keep the diffusers API path robust.
|
||||
|
||||
uv run python scripts/controlnet_sweep.py watermarked.png -o sweep_out
|
||||
|
||||
1. SDXL base 1.0 img2img + `xinsir/controlnet-canny-sdxl-1.0` / `controlnet-tile-sdxl-1.0`
|
||||
(sweep both `tile` and `canny` control), full image at 1024, `sdxl-vae-fp16-fix`.
|
||||
2. Sweep the cube on fresh Gemini + gpt-image inputs that contain small/CJK text:
|
||||
- denoise strength {0.15, 0.3, 0.5, 0.7, 1.0}
|
||||
- `controlnet_conditioning_scale` {0.5, 0.8, 1.0}
|
||||
- control type {tile, canny}
|
||||
3. Per cell, measure BOTH axes:
|
||||
- **removal**: Gemini app "Verify with SynthID" oracle (the only valid SynthID oracle; for
|
||||
gpt-image also openai.com/verify for provenance) — must read clean.
|
||||
- **text**: OCR round-trip / visual legibility of the small text.
|
||||
- secondary: SSIM/FID vs original for global fidelity.
|
||||
4. Find the Pareto cell where the oracle is clean AND text stays legible.
|
||||
|
||||
**The honest fork the prototype resolves:**
|
||||
- If such a cell exists -> the answer to Jacob is YES, ship an SDXL-native ControlNet removal
|
||||
profile (replacing the SD1.5 ctrlregen) tuned to that cell.
|
||||
- If no cell clears both (the tension is fundamental: scrub-strength always deforms text, or
|
||||
text-preserving conditioning always spares the watermark) -> the canny/tile-ControlNet middle
|
||||
path is dead for text, and the standing answer reverts to `text-protection-research.md`: a full
|
||||
**glyph-conditioned re-render** (EasyText / TextSR on a FLUX-DiT base) is required, which is a
|
||||
base-model migration, not a control add.
|
||||
|
||||
**Do not:** keep tuning the 512 px SD1.5 ctrlregen for text (wrong resolution, wrong base model);
|
||||
run tile-ControlNet at the community 0.3-0.4 upscale denoise and expect watermark removal (that
|
||||
regime preserves the watermark); over-claim forensic invisibility (Finding 7).
|
||||
|
||||
## Prototype run 1 — 2026-06-02 (text axis measured; watermark axis pending the oracle)
|
||||
|
||||
First sweep on a real, SynthID-positive, text-dense input: the corpus tokyo-street-night
|
||||
gpt-image (`88e61a38-chatgpt_tokyo.png`, 1023x1537 -> 680x1024, dense small CJK + Latin neon
|
||||
signage; SynthID + C2PA confirmed, so its valid oracle is openai.com/verify). Grid: control
|
||||
{canny, tile} x strength {0.3, 0.5, 0.7, 1.0} x `conditioning_scale` 1.0, fp32 on MPS. Outputs +
|
||||
`sweep_index.csv` (text verdicts filled by visual inspection; `synthid_oracle` left for the
|
||||
manual run) are under `/tmp/cnsweep/` (not committed — derived regenerations of corpus content).
|
||||
|
||||
**Measured — PSNR vs input (proxy for how much was regenerated):**
|
||||
- canny: 0.3 -> 16.91, 0.5 -> 15.91, 0.7 -> 14.82, 1.0 -> 13.22 (monotonic drop = progressively
|
||||
more regeneration as strength rises; canny only pins edges, so flat regions change).
|
||||
- tile: 0.3 -> 17.89, 0.5 -> 17.84, 0.7 -> 17.83, 1.0 -> 17.74 (**flat and high — near-identity
|
||||
even at strength 1.0**; tile@scale1.0 pins the whole image to the input and barely regenerates).
|
||||
|
||||
**Measured — text legibility (visual, focused on SMALL text; large high-contrast glyphs survive
|
||||
everything because canny/tile hold their edges):**
|
||||
- canny: legible at 0.3, softening at 0.5 (partial), garbling at 0.7, hallucinated pseudo-glyphs
|
||||
at 1.0 ("NEC" -> "NWENES"). Same plain-img2img small-text deformation, only big text protected.
|
||||
- tile: near-identity through 0.7, only tiny alterations at 1.0 — small text preserved throughout.
|
||||
|
||||
**Reading (the make-or-break tension, now visible in the data):**
|
||||
- **tile@scale1.0 does not actually regenerate** (flat PSNR), so it preserves all text but almost
|
||||
certainly leaves the watermark intact — it is a near-identity pass, exactly the community
|
||||
"tile-upscale preserves detail by not regenerating" regime (Finding 6), confirmed.
|
||||
- **canny@scale1.0 regenerates progressively** (PSNR drops) and so could scrub — but small text
|
||||
breaks at exactly the strength where scrubbing would start to bite. canny saves big edges, not
|
||||
sub-stroke small text.
|
||||
- Net on the text axis: neither cell at scale 1.0 cleanly gives "high regeneration + legible small
|
||||
text." This is the literature prior (Findings 3, 6) reproduced empirically. Lowering
|
||||
`conditioning_scale` to force small-text regeneration is the same tradeoff knob, not an escape.
|
||||
|
||||
**Still pending (the decisive half, cannot be done locally):** run the 8 cells through the SynthID
|
||||
oracle and fill `synthid_oracle`. The most informative cells: canny 1.0 (text dead — does it at
|
||||
least scrub? if not, the canny path is dead outright), canny 0.5 (text partial — does it scrub?),
|
||||
tile 1.0 (text perfect — predicted to still read present). If no cell is `oracle=clean` AND
|
||||
`text=yes`, the fork resolves to the glyph-re-render path (`text-protection-research.md`).
|
||||
|
||||
**Incidental bug caught:** the first run used fp16 on MPS (the script's original default) and
|
||||
produced **all-black** outputs across every cell (2 KB PNGs, PSNR 9.22 flat) — the issue #29
|
||||
fp16-VAE-NaN failure, and the fp16-fix VAE did not save it on MPS. Fixed `scripts/controlnet_sweep.py`
|
||||
to default fp32 on mps/cpu (fp16 only on cuda/xpu), matching the production pipeline.
|
||||
|
||||
## Tuning ControlNet for text preservation across image types (research 2026-06-03)
|
||||
|
||||
Goal: how to configure the canny-ControlNet path to best preserve text (and faces) on diverse
|
||||
images. Primary sources: diffusers ControlNet doc, the ControlNet paper (arXiv:2302.05543),
|
||||
xinsir model cards, practitioner guides. The **critical reframe**: almost all community ControlNet
|
||||
advice optimizes a txt2img *generation* tradeoff (control vs creative freedom). OUR context is
|
||||
img2img *watermark removal*, where the objective is the opposite -- maximum faithful preservation
|
||||
while regenerating just enough to scrub. So several common recommendations INVERT here.
|
||||
|
||||
**Removal is `strength`; everything below is preservation and does not change removal efficacy**
|
||||
(only the watermark-shielding risk -- see the caveat). Set `strength` by the oracle/vendor need;
|
||||
tune these to keep text/faces intact at that strength.
|
||||
|
||||
Knobs, ranked by impact for text:
|
||||
|
||||
1. **Canny edge density (the per-image lever, currently hardcoded `_CANNY_LOW=100`/`_CANNY_HIGH=200`).**
|
||||
Lower thresholds capture more/finer edges; higher thresholds keep only major outlines (diffusers
|
||||
doc + practitioner guides; ControlNet paper uses 100/200 as the default). Small-text strokes and
|
||||
fine facial features fall below the default 100/200 and are missed. **For dense small text
|
||||
(infographics, signage) lower the thresholds (~50/120, even 30/100 for facial likeness per
|
||||
practitioner tests); for high-contrast large text 100/200 already suffices.** Denser canny is
|
||||
still a BINARY thresholded edge map, so it does not carry the low-amplitude SynthID pixel pattern
|
||||
-- it passes more shape, not the watermark (still oracle-verify). This is the single highest-value
|
||||
unexplored lever and should become a CLI knob.
|
||||
|
||||
2. **`controlnet_conditioning_scale` -> keep at 1.0 (max structure hold).** Community defaults to 0.5
|
||||
for creative balance; we want maximum preservation, so 1.0 (xinsir canny/tile cards also recommend
|
||||
1.0). We measured text on a clean high-contrast image surviving across strength 0.1-0.5 at scale
|
||||
1.0 (PSNR ~26 flat), so scale 1.0 is the right default; only lower it if a specific image needs
|
||||
more regeneration to scrub (raises shielding risk the other way).
|
||||
|
||||
3. **`control_guidance_start=0.0`, `control_guidance_end=1.0` (full window) -- KEEP, do not shorten.**
|
||||
The common "end=0.5: establish structure early then let the model render detail freely" is a
|
||||
creative-generation recipe; for text it is HARMFUL -- the late free steps re-render and deform the
|
||||
glyphs. We want the edge control active through ALL denoise steps so text stays pinned. (Our
|
||||
pipeline already uses the 0->1 default; the point is to NOT adopt the shorten-the-window advice.)
|
||||
|
||||
4. **Control type, per image type:**
|
||||
- **Text / graphics / high-contrast -> canny** (the literature's reliable choice for defined edges
|
||||
and text; what we ship).
|
||||
- **Faces / smooth tonal content -> soft-edge / HED is a candidate worth testing.** Canny's hard
|
||||
binary threshold fractures smooth skin gradients; HED/soft-edge gives gradual edges that may hold
|
||||
faces better. UNVERIFIED for removal (softer edges may carry slightly more original signal ->
|
||||
oracle-check). A face-heavy image is the test (gemini group photos).
|
||||
- **tile -> NOT for removal.** It is near-identity (detail-enhancement at low denoise); it shields
|
||||
the watermark (measured flat PSNR ~17.8 across strength on the tokyo sweep). Do not use it as the
|
||||
removal control.
|
||||
|
||||
5. **Resolution** -- higher long-side = strokes span more VAE latent cells = less softening, while
|
||||
still fully regenerating. Already a knob (`--max-resolution`); for tiny text prefer native/large.
|
||||
|
||||
**Multi-ControlNet (canny + soft-edge), list scales e.g. `[1.0, 0.8]`** (diffusers MultiControlNet):
|
||||
could hold text edges AND face geometry at once, but doubles ControlNet memory/latency and raises the
|
||||
shielding risk; defer to a v2 after the single-canny path is dialed in.
|
||||
|
||||
**Image-type playbook (proposed, to validate with the oracle):**
|
||||
- Clean high-contrast text (openai_1-style): canny 100/200, scale 1.0, full window -- already optimal.
|
||||
- Dense small text / infographics (big_pic3, neon signage): canny **lower thresholds (~50/120)**,
|
||||
scale 1.0, full window, larger resolution.
|
||||
- Faces / portraits: try **soft-edge/HED** control, scale 1.0; or multi-ControlNet canny+softedge.
|
||||
|
||||
**Hard caveat:** every change that increases preservation (higher scale, denser canny, fuller window,
|
||||
softer edges) marginally REDUCES effective regeneration and so raises the chance the watermark
|
||||
survives -- exactly the shielding failure mode. There is no local SynthID detector, so each tuning
|
||||
change must be re-confirmed on the oracle. These are img2img-context recommendations derived from
|
||||
generation-context sources plus our own measurements; treat the playbook as hypotheses to verify, not
|
||||
settled defaults.
|
||||
|
||||
**Sources.** https://huggingface.co/docs/diffusers/en/using-diffusers/controlnet ·
|
||||
https://arxiv.org/pdf/2302.05543 · https://huggingface.co/xinsir/controlnet-canny-sdxl-1.0 ·
|
||||
https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 · https://blog.cephalon.ai/canny-and-softedge/
|
||||
|
||||
## FaceID research: identity-preserving face conditioning (research 2026-06-03)
|
||||
|
||||
Motivation: canny alone preserves face STRUCTURE/position better than plain SDXL but does NOT hold
|
||||
IDENTITY -- verified on a real Gemini group photo (gemini_3, s015): faces drift in expression and
|
||||
likeness (the smile/mouth and eyes change), they are "a similar person," not the same one. Canny
|
||||
carries edges, not identity, so the regenerated face is identity-drifted. To hold identity WITHOUT
|
||||
copying original pixels (the hard constraint -- copied pixels carry SynthID), the conditioning must
|
||||
be an identity EMBEDDING, not pixels. Primary sources: diffusers IP-Adapter doc, InstantID
|
||||
(arXiv:2401.07519), IP-Adapter (arXiv:2308.06721), practitioner comparisons.
|
||||
|
||||
### Findings
|
||||
|
||||
**1. IP-Adapter FaceID conditions on an ArcFace identity VECTOR, not pixels (confidence: high).**
|
||||
FaceID extracts `insightface` ArcFace `normed_embedding` (a ~512-d identity vector) via
|
||||
`FaceAnalysis`, and passes it as `ip_adapter_image_embeds` -- NOT a CLIP image embedding, NOT the
|
||||
original pixels. So it is constraint-compatible: the watermark (a pixel-amplitude pattern) is not in
|
||||
the identity vector, and the img2img still regenerates the pixels (removal via `strength` unchanged).
|
||||
It loads on any SDXL via `load_ip_adapter` (~100 MB), is fast/low-VRAM, but identity fidelity on SDXL
|
||||
is ~5-10% lower than the SD1.5 line / dedicated methods.
|
||||
|
||||
**2. Multiple distinct faces ARE handled, via regional attention masks (confidence: high -- THE key
|
||||
unlock).** This is the make-or-break for group photos (our hardest case). diffusers supports a LIST
|
||||
of IP-Adapter face images each with its own binary region mask: `IPAdapterMaskProcessor` builds the
|
||||
masks, `set_ip_adapter_scale([[s1, s2, ...]])`, and `cross_attention_kwargs={"ip_adapter_masks":
|
||||
masks}`. So you detect each face, extract its own ArcFace embedding, assign it a region mask, and one
|
||||
pass preserves N different identities simultaneously. (InstantID, by contrast, is single-subject --
|
||||
it averages embeddings for multiple refs, which is wrong for distinct people -- so for group photos
|
||||
**IP-Adapter FaceID + masks beats InstantID**.)
|
||||
|
||||
**3. IP-Adapter + ControlNet + img2img compose (confidence: high).** The doc shows IP-Adapter +
|
||||
ControlNet (depth) in one pipeline and IP-Adapter + img2img (`strength`). Our target stack is the
|
||||
union: `StableDiffusionXLControlNetImg2ImgPipeline` (canny = structure) + `load_ip_adapter` (FaceID =
|
||||
identity) + `strength` (removal). `set_ip_adapter_scale` (1.0 = image-only, 0.5 = balanced) is the
|
||||
identity-hold knob. API friction to verify in implementation: that `ip_adapter_masks` via
|
||||
`cross_attention_kwargs` works on the *ControlNet img2img* pipeline (the masking is an attention-
|
||||
processor feature, so it should be pipeline-agnostic, but confirm).
|
||||
|
||||
**4. InstantID / PuLID positioning (confidence: medium).** InstantID does not train the UNet so it
|
||||
composes with canny/depth ControlNets, and gives better single-face fidelity than FaceID -- but it is
|
||||
single-subject (needs its own landmark ControlNet + dedicated weights). PuLID has the best identity
|
||||
fidelity but is heaviest and Flux-leaning. For our multi-face, constraint-bound, SDXL-canny case,
|
||||
IP-Adapter FaceID + masks is the right first build; InstantID/PuLID are single-portrait upgrades.
|
||||
|
||||
### Architecture (proposed)
|
||||
|
||||
```
|
||||
detect faces (insightface) -> per face: ArcFace embed + region mask
|
||||
one img2img pass:
|
||||
image=init, control_image=canny(init), # structure (existing)
|
||||
ip_adapter_image_embeds=[face_embeds], # identity per face
|
||||
cross_attention_kwargs={"ip_adapter_masks": face_masks}, # each face -> its region
|
||||
controlnet_conditioning_scale=1.0, set_ip_adapter_scale(~0.6),
|
||||
strength=vendor-adaptive # removal (unchanged)
|
||||
```
|
||||
Pixels are regenerated (SynthID removed by `strength`), structure held by canny, each face's identity
|
||||
held by its masked ArcFace vector -- no original pixel copied.
|
||||
|
||||
### Risks / honest costs
|
||||
|
||||
- **Shielding risk (same wall):** FaceID conditioning, like canny, reduces effective regeneration ->
|
||||
higher `set_ip_adapter_scale` raises the chance SynthID survives in the face region (echo of why the
|
||||
old region-hires failed). MUST oracle-verify removal at the chosen FaceID scale; keep `strength` at
|
||||
the vendor threshold.
|
||||
- **New heavy dependency:** `insightface` + `onnxruntime` + the `buffalo_l` model (~300 MB, downloaded
|
||||
on first use). Detection + embedding is CPU/ONNX, separate from the diffusion.
|
||||
- **Detection floor:** insightface needs faces large enough (det_size ~640); tiny faces in a dense
|
||||
group may not be detected -> not preserved (falls back to canny-only for those).
|
||||
- **Identity ceiling:** SDXL FaceID is ~5-10% off true identity -- a meaningful boost over canny-only
|
||||
drift, NOT a perfect face swap. Set expectations; PuLID/InstantID are the higher-fidelity (heavier)
|
||||
paths if needed.
|
||||
- **Value scales with strength:** at low strength (OpenAI 0.10) faces barely drift, so FaceID is
|
||||
marginal; at the higher strength a hard vendor (Google 0.30) needs, FaceID earns its keep.
|
||||
|
||||
### Build plan (staged)
|
||||
|
||||
- v1: optional `--face-id` flag on `--pipeline controlnet`. Detect faces; if any, run the masked
|
||||
FaceID pass (works for 1 or N faces -- masks generalize). If none detected, fall through to plain
|
||||
canny. Oracle-verify SynthID removal is preserved at the default FaceID scale on a face image.
|
||||
- v2 (if identity still short): InstantID for single-portrait, or PuLID, as a higher-fidelity opt-in.
|
||||
|
||||
**Sources.** https://huggingface.co/docs/diffusers/main/en/using-diffusers/ip_adapter ·
|
||||
https://huggingface.co/h94/IP-Adapter-FaceID · https://arxiv.org/pdf/2401.07519 (InstantID) ·
|
||||
https://instantid.github.io/ · https://arxiv.org/abs/2308.06721 (IP-Adapter)
|
||||
|
||||
### FaceID prototype run 1 -- 2026-06-03 (NEGATIVE on dense small-face groups)
|
||||
|
||||
Built and shipped the masked multi-face FaceID layer (`--face-id`, `face_id.py`, `faceid` extra).
|
||||
First real run on the gemini_3 group photo (Google, s015, scale 0.6, native 2816 via cap 1536):
|
||||
insightface detected **17 faces**, the masked multi-face pass composed and ran end-to-end (non-black
|
||||
output), so the API is correct. **At s015 the result is a clear FAILURE: every face corrupted --
|
||||
melted/discolored/psychedelic, materially WORSE than canny-only.**
|
||||
|
||||
**ROOT CAUSE FOUND (confirmed by ablation, not speculation) -- it is STRENGTH, not scale/masks/faces.**
|
||||
Investigated the real data: masks are fine (max overlap depth 2, 33% coverage, only 0.2% of pixels
|
||||
double-covered -- NOT an overlap problem), embeddings are fine (`normed_embedding` norm 1.000), the
|
||||
FaceID LoRA is not required for SDXL (h94 model card), and faces span 34-181 px (7 medium + 10 tiny).
|
||||
None of those is the cause. The decisive test: the SAME image + FaceID at **strength 0.5** produces
|
||||
**clean, coherent faces across the whole group** (no psychedelic artifacts). So FaceID needs
|
||||
substantial regeneration: the h94 usage is full generation (txt2img, 30 steps); at our removal
|
||||
strength (0.10-0.15 = ~7 effective steps) the strong identity cross-attention cannot reconcile with
|
||||
a latent that is ~85% the untouched original, so it smears identity-colored noise onto the faces.
|
||||
|
||||
**This is a FUNDAMENTAL tension, not a tuning bug:** watermark removal wants LOW strength (minimal
|
||||
degradation, just enough to scrub), FaceID wants HIGH strength (regenerate the face to impose
|
||||
identity). They are opposed. At strength 0.5 FaceID works AND removes the watermark, but the whole
|
||||
image regenerates much more (canny still holds text/edge structure, but texture/detail drifts well
|
||||
beyond the 0.15 "minimal degradation" target). So `--face-id` is a HIGH-STRENGTH option: it trades
|
||||
whole-image fidelity for face identity, and is a footgun at the low default strength (guaranteed
|
||||
garbage). Required follow-up code guard: when `--face-id` is set, floor `strength` at ~0.5 (or refuse
|
||||
+ warn) -- never run FaceID at the vendor-adaptive removal strength. Open question: whether
|
||||
high-strength FaceID's whole-image drift is acceptable for face-centric images, or whether identity
|
||||
preservation at LOW strength needs a different mechanism entirely (FaceID structurally cannot do it). (Infra lesson: the `faceid` extra must
|
||||
stay numpy<2.0 -- pin `onnx<1.18` + `scipy<1.18`; pinning numpy UP, as the first build did, leaves a
|
||||
numpy-1.26 env with a numpy-2-only scipy that crashes the diffusers import via `np.long`.)
|
||||
|
||||
## Face preservation, done properly (research 2026-06-03, after the FaceID failure)
|
||||
|
||||
The FaceID run failed and I wrongly concluded "faces can't be preserved." Re-research corrected the
|
||||
understanding. The hard constraint is unchanged: to remove the watermark FROM a face the face MUST
|
||||
be regenerated (freezing it leaves SynthID), so the goal is identity-preserving REGENERATION of the
|
||||
face, at minimal overall image degradation. Three things I got wrong and the corrected picture:
|
||||
|
||||
**What I got wrong:** (1) I applied FaceID at GLOBAL high strength -- the literature is clear the
|
||||
architecture must be REGION-ADAPTIVE (face region handled separately, background stays low-strength);
|
||||
(2) I used IP-Adapter FaceID, the WEAKEST identity tool -- InstantID uses an ArcFace encoder and hits
|
||||
82-86% face-recognition similarity vs FaceID's weak CLIP-ish signal; (3) I missed the entire
|
||||
face-restoration class (CodeFormer / GFPGAN), which is purpose-built for "regenerate a face, keep
|
||||
identity."
|
||||
|
||||
**The most promising mechanism -- CodeFormer face-restoration post-pass (confidence: high on the
|
||||
mechanism, unverified on our watermark).** CodeFormer is a VQ-VAE: a frozen discrete CODEBOOK of HQ
|
||||
facial priors + a Transformer that predicts code *tokens* from the input, and a frozen decoder that
|
||||
regenerates the face FROM THE CODEBOOK ENTRIES -- "does not depend on feature fusion with low-quality
|
||||
cues." So the output face pixels come from a finite learned codebook, NOT from the input pixels:
|
||||
**the SynthID pixel-amplitude pattern physically cannot survive a codebook re-synthesis** -- a
|
||||
stronger scrub than low-strength img2img (which keeps ~85% of the latent). Fidelity knob `w` in
|
||||
[0,1]: higher w preserves identity but fuses MORE low-quality (input) cues (more watermark risk),
|
||||
lower w leans on the codebook (cleaner scrub, identity drift) -- the same scrub-vs-fidelity tension,
|
||||
settled per-image by the oracle; there is likely a `w` that holds identity AND clears the oracle.
|
||||
|
||||
**Constraint-compatible architecture:** run the normal canny low-strength controlnet removal globally
|
||||
(minimal degradation everywhere), then detect+align each face, run CodeFormer on the **ORIGINAL** face
|
||||
crop (to capture true identity AND re-synthesize from the codebook = scrub), and composite the
|
||||
CodeFormer output (codebook-generated, not original pixels -> no copy, no watermark) into the cleaned
|
||||
image. Decouples whole-image minimal-degradation from face identity -- no high GLOBAL strength needed.
|
||||
|
||||
**Honest costs/caveats:** (a) **License -- CodeFormer is NTU S-Lab 1.0 (non-commercial/research)**, so
|
||||
it cannot be bundled in this MIT tool for general use; the license-clean alternative is **GFPGAN
|
||||
(Apache-2.0)**, slightly lower quality. (b) Deps (basicsr/facexlib) are heavy and numpy-version-finicky
|
||||
(same class of conflict as insightface). (c) CodeFormer is a *restoration* model -- it can subtly
|
||||
alter expression/asymmetry; identity is held but not pixel-identical. (d) **The watermark-scrub is
|
||||
mechanistically strong but UNVERIFIED -- must oracle-check.** InstantID + region-adaptive strength is
|
||||
the alternative if the restoration route disappoints, but it is more complex (differential strength).
|
||||
Prototype plan: validate CodeFormer on a real face in a THROWAWAY env (identity held? oracle clean?)
|
||||
before any project-env integration or the license/GFPGAN decision.
|
||||
|
||||
### CodeFormer prototype -- VALIDATED end-to-end 2026-06-03 (oracle-confirmed)
|
||||
|
||||
Prototyped the CodeFormer face-restoration post-pass (codeformer-pip in a throwaway venv, forced CPU
|
||||
-- the pip wrapper has an MPS device-mismatch bug) on the gemini_3 group photo (18 faces). Pipeline:
|
||||
`all --pipeline controlnet --strength 0.15` (sparkle + SynthID removed from the whole image, minimal
|
||||
degradation) -> CodeFormer on the ORIGINAL faces -> feather-composite the CodeFormer faces into the
|
||||
all-cleaned image. Oracle results (Gemini app "Verify with SynthID"), isolating each part:
|
||||
- pure controlnet-0.15 background (no faces): **clean** -> the background scrub works at 0.15 (no
|
||||
ControlNet-shielding problem for Google on this image).
|
||||
- composite with CodeFormer faces at **w0.7**: **SynthID DETECTED** -> high fidelity fuses too much of
|
||||
the original face signal (the watermark) through.
|
||||
- composite at **w0.5**: **clean**. composite at **w0.3**: **clean**.
|
||||
So the scrub-vs-fidelity threshold is between 0.5 and 0.7; **w=0.5 is the sweet spot** (highest
|
||||
fidelity / best identity that still clears the oracle). Identity at w0.3-0.7 all looks like the same
|
||||
person (the face is large enough), so the lower w costs little.
|
||||
|
||||
**This VALIDATES the corrected face-preservation approach** (and refutes my earlier "faces can't be
|
||||
preserved" / FaceID conclusion): controlnet low-strength background scrub + CodeFormer-codebook face
|
||||
re-synthesis at w~0.5 + feather composite = oracle-clean SynthID removal everywhere (background AND
|
||||
faces), identity preserved, minimal overall degradation, zero original-pixel copying (CodeFormer faces
|
||||
are codebook-generated). CodeFormer's discrete-codebook re-synthesis DOES scrub the pixel watermark,
|
||||
but only when w is low enough that the decoder leans on the codebook rather than fusing the input
|
||||
(watermark-carrying) features -- exactly the predicted fidelity-vs-scrub tension, with an empirical
|
||||
clean threshold at w<=0.5.
|
||||
|
||||
**Production TODO (not built -- still a throwaway prototype):** (1) license -- CodeFormer is NTU S-Lab
|
||||
(non-commercial); decide CodeFormer-as-user-installed-extra vs GFPGAN (Apache-2.0, re-verify it scrubs
|
||||
at its fidelity setting); (2) wire a `--restore-faces` post-pass (detect -> restore w~0.5 -> feather
|
||||
composite) onto the controlnet pipeline; (3) handle the MPS device bug (force CPU for the face model
|
||||
or fix); (4) re-verify the w threshold on more images / vendors (w=0.5 confirmed on one Gemini group
|
||||
photo only).
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2206.11253 (CodeFormer) · https://github.com/sczhou/CodeFormer ·
|
||||
https://arxiv.org/pdf/2401.07519 (InstantID) ·
|
||||
https://openaccess.thecvf.com/content/WACV2024/papers/Suin_Diffuse_and_Restore... (region-adaptive) ·
|
||||
https://arxiv.org/pdf/2504.12809 (saliency-aware watermark removal)
|
||||
|
||||
## Provenance
|
||||
|
||||
Hand-run primary-source pass, 2026-06-02. Sources fetched and quoted above; the central
|
||||
make-or-break claim (structure-conditioned high-strength regeneration scrubs the watermark while
|
||||
keeping text) is **unverified and explicitly flagged as the thing the local prototype must
|
||||
measure** (against the manual Gemini SynthID oracle) — the literature supports removal (Findings 1, 2) and supports structure-preserving
|
||||
regeneration (Finding 5) but never jointly validated text (Finding 3). No code change implied
|
||||
until the prototype validates a Pareto cell on the SynthID oracle.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Development
|
||||
|
||||
Read this reference for environment setup, dependency recovery, CI behavior, and fixture policy. The always-loaded invariants remain in [`.claude/rules/development.md`](../.claude/rules/development.md).
|
||||
|
||||
## Local environment
|
||||
|
||||
- Use `uv sync --frozen --extra dev` and add only the feature extras needed for the task.
|
||||
- Do not use `uv pip install` for development tools. It can re-resolve `uv.lock` outside the compatible ML dependency set.
|
||||
- A default-only sync removes every pixel and model package by design. Package imports remain light through lazy exports.
|
||||
- On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend.
|
||||
- Run `uv` from the repository root or it may create a bare environment without the project dependencies.
|
||||
|
||||
The optional TrustMark decoder downloads weights into its installed package directory. After pruning that extra, a leftover weights directory can make availability checks see an empty namespace package. If Pyright reports an unknown `TrustMark` import and `find_spec("trustmark")` returns a loader-less spec, remove that regenerable remnant from the active virtual environment and resync.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/test.yml` runs Ruff and a cross-platform supported-Python test matrix with default plus development dependencies. Diffusion and model-running tests skip in that matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the OpenCV eraser remain covered across operating systems.
|
||||
|
||||
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
|
||||
|
||||
Release and distribution behavior is canonical in [`release-and-distribution.md`](release-and-distribution.md).
|
||||
|
||||
## Fixture and data policy
|
||||
|
||||
[`../data/README.md`](../data/README.md) is the source of truth:
|
||||
|
||||
- executable provenance fixtures live under `data/fixtures/`;
|
||||
- minimal controlled detector inputs live under `data/calibration/`;
|
||||
- canonical provider-oracle originals and their manifests live under `data/synthid/`;
|
||||
- evaluation-only ground truth lives under `data/evaluations/`;
|
||||
- runtime detector assets live in the package; unregistered research candidates remain outside the shipped wheel.
|
||||
|
||||
Store each binary once. Point tests and manifests at its canonical path. Keep generated and cleaned outputs outside the repository and retain only reproducible public records allowed by the data policy.
|
||||
|
||||
Use synthetic byte blobs for unsupported format paths and deterministic generated negatives where a real negative fixture is unnecessary. Detection and removal tests must preserve their format-specific invariants.
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
@@ -0,0 +1,39 @@
|
||||
# Documentation
|
||||
|
||||
This documentation is split by purpose. Start with the user guides if you want
|
||||
to run the tool. Use the maintainer references only when changing the code.
|
||||
|
||||
## User guides
|
||||
|
||||
| Page | Use it when |
|
||||
| --- | --- |
|
||||
| [Installation](installation.md) | You need the CLI, an optional model backend, or a development environment. |
|
||||
| [CLI guide](cli.md) | You want a command for an image, video metadata or visible marks, a directory, or a specific watermark type. |
|
||||
| [Python API](python-api.md) | You want to call the package from Python. |
|
||||
| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. |
|
||||
| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. |
|
||||
| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. |
|
||||
|
||||
## Maintainer references
|
||||
|
||||
| Page | Purpose |
|
||||
| --- | --- |
|
||||
| [Module internals](module-internals.md) | Current architecture, invariants, and regression guards by module. |
|
||||
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
|
||||
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
|
||||
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, and release workflow. |
|
||||
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
|
||||
| [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. |
|
||||
|
||||
## Research archive
|
||||
|
||||
These pages record experiments and the evidence behind past decisions. They are
|
||||
not command references and may describe prototypes that were later removed.
|
||||
The current behavior is defined by the code, tests, README, and user guides.
|
||||
|
||||
- [ControlNet removal research](controlnet-removal-pipeline-research.md)
|
||||
- [Qwen improvement research](qwen-improvement-research.md)
|
||||
- [Doubao reverse-alpha research](research-doubao-distillation.md)
|
||||
- [SynthID identity research](synthid-robust-identity-research.md)
|
||||
- [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md)
|
||||
- [Text protection research](text-protection-research.md)
|
||||
@@ -0,0 +1,220 @@
|
||||
# Installation
|
||||
|
||||
Python 3.10.1 or newer is required.
|
||||
|
||||
## Default metadata mode
|
||||
|
||||
The default package provides:
|
||||
|
||||
- provenance inspection;
|
||||
- AI metadata inspection and removal.
|
||||
|
||||
It installs Pillow, piexif, and c2pa-python for reading metadata directly from
|
||||
files. It does not install NumPy, OpenCV, pillow-heif, Torch, diffusion models,
|
||||
or invisible-watermark decoders.
|
||||
|
||||
Install it as an isolated command with uv:
|
||||
|
||||
```bash
|
||||
uv tool install remove-ai-watermarks
|
||||
```
|
||||
|
||||
Or with pipx:
|
||||
|
||||
```bash
|
||||
pipx install remove-ai-watermarks
|
||||
```
|
||||
|
||||
You can also install the Homebrew package on macOS or Linux:
|
||||
|
||||
```bash
|
||||
brew install wiltodelta/tap/remove-ai-watermarks
|
||||
```
|
||||
|
||||
## Visible watermark removal
|
||||
|
||||
Visible mark detection, OpenCV inpainting, and manual region erasing need the
|
||||
`visible` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[visible]"
|
||||
```
|
||||
|
||||
Add `heif` only when the pixel path must decode HEIC, HEIF, or AVIF:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[visible,heif]"
|
||||
```
|
||||
|
||||
## Video processing
|
||||
|
||||
Video metadata inspection and stripping work with the default package. Stable
|
||||
visible-mark identification and removal, full video cleaning, and visible/all
|
||||
batch modes need the `video` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
```
|
||||
|
||||
The extra includes the visible pixel runtime and PyAV for preserving variable
|
||||
frame timestamps. Video SynthID regeneration also needs the diffusion stack:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[video,diffusion]"
|
||||
```
|
||||
|
||||
## Invisible watermark removal
|
||||
|
||||
Diffusion based removal needs the `diffusion` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[diffusion]"
|
||||
```
|
||||
|
||||
The code supports CUDA, XPU, MPS, and CPU devices. A GPU is recommended because
|
||||
CPU inference is slow.
|
||||
|
||||
For the CUDA only Qwen Image plus Z-Image profile:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra includes the normal `diffusion` dependencies.
|
||||
|
||||
## Feature extras
|
||||
|
||||
Extras are composable. Install only the capabilities and file formats the
|
||||
application actually uses:
|
||||
|
||||
| Extra | Capability | Automatically includes | Torch or model download |
|
||||
| --- | --- | --- | --- |
|
||||
| `pixels` | Shared BGR array and image-processing runtime | NumPy, headless OpenCV | No |
|
||||
| `heif` | HEIC, HEIF, and AVIF pixel decoding | pillow-heif | No |
|
||||
| `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No |
|
||||
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
|
||||
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
|
||||
| `trustmark` | Adobe TrustMark detection | trustmark | Yes |
|
||||
| `diffusion` | Diffusion-based invisible watermark removal | `pixels`, Torch, Diffusers | Yes |
|
||||
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `esrgan` | Real-ESRGAN upscaling before diffusion | `pixels`, spandrel | Yes |
|
||||
| `qwen-zimage` | CUDA-only Qwen Image plus Z-Image pipeline | `diffusion`, DiffSynth | Yes |
|
||||
| `all` | Every production feature | All rows above | Yes |
|
||||
| `dev` | Tests, linting, typing, and upstream parity checks | `visible`, `detect`, upstream invisible-watermark | Yes, for parity tests |
|
||||
|
||||
Dependency composition:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
visible --> pixels
|
||||
video --> visible
|
||||
detect --> pixels
|
||||
diffusion --> pixels
|
||||
migan --> visible
|
||||
lama --> visible
|
||||
esrgan --> pixels
|
||||
qwen["qwen-zimage"] --> diffusion
|
||||
heif
|
||||
trustmark
|
||||
```
|
||||
|
||||
`heif` and `trustmark` are independent branches. Combine them explicitly with
|
||||
another feature when required. The `all` bundle contains every production
|
||||
branch but never includes `dev`.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Metadata plus torch-free DWT-DCT detection
|
||||
uv tool install --force "remove-ai-watermarks[detect]"
|
||||
|
||||
# Visible removal with HEIC/AVIF support and MI-GAN
|
||||
uv tool install --force "remove-ai-watermarks[migan,heif]"
|
||||
|
||||
# Visible video removal with preserved timestamps
|
||||
uv tool install --force "remove-ai-watermarks[video]"
|
||||
|
||||
# DWT-DCT and TrustMark detection without diffusion removal
|
||||
uv tool install --force "remove-ai-watermarks[detect,trustmark]"
|
||||
|
||||
# Every production capability
|
||||
uv tool install --force "remove-ai-watermarks[all]"
|
||||
|
||||
# An arbitrary minimal combination
|
||||
uv tool install --force "remove-ai-watermarks[migan,detect]"
|
||||
```
|
||||
|
||||
`heif` stays independent so applications that only process PNG, JPEG, or WebP
|
||||
do not install libheif. `detect` uses the in-tree torch-free decoder and does
|
||||
not install the upstream `invisible-watermark` package. Optional models download
|
||||
their weights on first use.
|
||||
|
||||
The old `gpu` and `remove` aliases are intentionally not provided. Use
|
||||
`diffusion` and `visible` respectively.
|
||||
|
||||
## Install from the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/wiltodelta/remove-ai-watermarks.git
|
||||
cd remove-ai-watermarks
|
||||
uv sync --frozen
|
||||
```
|
||||
|
||||
Add the feature groups required for your work:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
uv sync --frozen --extra dev --extra diffusion
|
||||
```
|
||||
|
||||
Run commands from the repository root:
|
||||
|
||||
```bash
|
||||
uv run remove-ai-watermarks --help
|
||||
```
|
||||
|
||||
## Development setup
|
||||
|
||||
Install development dependencies:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
```
|
||||
|
||||
Run the complete project gate:
|
||||
|
||||
```bash
|
||||
bash maintain.sh
|
||||
```
|
||||
|
||||
The script runs dependency checks, linting, formatting checks, type checking,
|
||||
and the test suite.
|
||||
|
||||
## Hugging Face authentication
|
||||
|
||||
Pass a Hugging Face token directly when the selected model or account requires
|
||||
one:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png --hf-token "$HF_TOKEN"
|
||||
```
|
||||
|
||||
The CLI also loads `HF_TOKEN` from the environment and from a local `.env`
|
||||
file. The same name is documented in `.env.example`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The first model run is slow
|
||||
|
||||
Diffusion and learned fill backends may download model weights on first use.
|
||||
Later runs reuse their caches.
|
||||
|
||||
### The command skips invisible removal
|
||||
|
||||
The normal behavior is to skip diffusion when no supported local signal is
|
||||
found. A missing signal does not prove that the image is clean. If you know the
|
||||
image came from a relevant generator, use `--force`.
|
||||
|
||||
If the CLI reports that diffusion dependencies are unavailable, install the
|
||||
`diffusion` extra. Video SynthID removal needs both `video` and `diffusion`.
|
||||
@@ -0,0 +1,343 @@
|
||||
# Known limitations
|
||||
|
||||
This page describes current product limits. Historical measurements and
|
||||
superseded experiments live in the research archive listed in
|
||||
[the documentation index](index.md).
|
||||
|
||||
## Visible removal
|
||||
|
||||
### Fill quality depends on the background
|
||||
|
||||
Visible removal changes only the selected mask, but the hidden pixels still
|
||||
have to be reconstructed.
|
||||
|
||||
- OpenCV is fast and requires no model download. It works well on flat
|
||||
backgrounds but
|
||||
can smear texture or repeated structure.
|
||||
- MI-GAN is a lighter learned backend. It can improve natural texture but may
|
||||
ghost or invent structure.
|
||||
- LaMa is the heaviest learned backend and is generally the strongest option
|
||||
for difficult backgrounds.
|
||||
|
||||
`--backend auto` selects LaMa when available, then MI-GAN, then OpenCV.
|
||||
|
||||
No backend can recover detail that is completely hidden by an opaque mark. A
|
||||
successful detection therefore does not guarantee a visually perfect fill.
|
||||
|
||||
### Automatic detection covers registered variants only
|
||||
|
||||
The registry contains vendor and locale specific templates. A redesigned mark,
|
||||
an unsupported locale, a different position, or a crop may be missed.
|
||||
|
||||
Known examples:
|
||||
|
||||
- Samsung detection is calibrated for the Italian
|
||||
`Contenuti generati dall'AI` text variant.
|
||||
- The Jimeng top-left pill has a weak visual detector and is intentionally
|
||||
subject to additional product and background checks.
|
||||
- Kling support covers the calibrated variants rather than every Kling label.
|
||||
|
||||
Use `erase --region` when you can see and select an unsupported or missed mark.
|
||||
|
||||
### Strict and automatic sensitivity trade recall for precision
|
||||
|
||||
`--sensitivity strict` uses the visual gate alone. The default `auto` mode may
|
||||
relax a mark only when metadata or a confidently detected sibling mark
|
||||
corroborates the same product.
|
||||
|
||||
There is no blanket "this image is AI" relaxation. That information does not
|
||||
identify the vendor, mark, or location and caused unacceptable false
|
||||
detections in the removed experimental mode.
|
||||
|
||||
## Invisible removal
|
||||
|
||||
### Regeneration is lossy
|
||||
|
||||
Invisible removal does not decode and delete a payload. It regenerates the
|
||||
image through a diffusion pipeline. Faces, text, colors, and fine detail can
|
||||
change even when the watermark is successfully disrupted.
|
||||
|
||||
ControlNet is the default compatibility profile. It conditions on edges to
|
||||
preserve structure, but edges do not preserve identity or exact texture.
|
||||
|
||||
The CUDA only `qwen-zimage` profile adds a separate face stage and is the
|
||||
highest fidelity option in the current implementation. It is larger, slower,
|
||||
and still may alter small text or difficult faces.
|
||||
|
||||
### Removal cannot be verified locally for proprietary SynthID
|
||||
|
||||
The project has no public local SynthID pixel decoder. It can infer likely
|
||||
presence from supported provenance metadata, but a missing metadata proxy is
|
||||
not a negative pixel verdict.
|
||||
|
||||
For important outputs:
|
||||
|
||||
1. preserve the original;
|
||||
2. process a copy;
|
||||
3. verify with the matching provider tool when available;
|
||||
4. do not assume one provider's verifier covers another provider's payload.
|
||||
|
||||
Provider systems can change, so a result verified on one file, seed, or version
|
||||
is not a permanent certification.
|
||||
|
||||
### Video SynthID removal is lossy and content-dependent
|
||||
|
||||
The `video invisible` command and `remove_video_invisible` API regenerate video
|
||||
pixels through a VAE. The shipped `noise_std=0.15` profile is oracle-certified,
|
||||
but Google does not publish a local decoder for arbitrary runtime outputs. A
|
||||
quiet metadata scan, paired PSNR, and the temporal-residual metric are fidelity
|
||||
measurements, not independent SynthID verdicts.
|
||||
|
||||
The control must use the same clip, frame rate, dimensions, and final codec as
|
||||
the candidates. The separate `scripts/video_synthid_sweep.py` harness produces
|
||||
that matched control. If the control is not detected by the matching provider
|
||||
oracle, the experiment cannot attribute a quiet candidate to regeneration.
|
||||
|
||||
The 2026-07-29 two-clip calibration used Gemini's built-in content verifier:
|
||||
both matched controls were SynthID-positive, the stronger candidate was
|
||||
negative on both carriers, and a weaker candidate was negative on one. A
|
||||
2026-07-30 adversarial follow-up incorrectly asked the ordinary chat model to
|
||||
reinterpret the verifier while excluding every other input; its `UNAVAILABLE`
|
||||
answer was not another detector run and does not invalidate the original
|
||||
built-in results. The calibrated default remains content-dependent; a fresh
|
||||
source-positive, output-negative pair is an optional audit for unusually
|
||||
important files or after provider changes.
|
||||
|
||||
The 2026-07-31 full-clip check used the public eight-second Veo off-road sample.
|
||||
The source was detected across the full clip, the complete product path at
|
||||
`noise_std=0.10` remained detected, and `0.15` returned no SynthID detection.
|
||||
The default was raised to `0.15`. At 512 px / 12 fps, the accepted candidate
|
||||
measured 25.39 dB paired PSNR and a 1.058 motion-compensated temporal-residual
|
||||
ratio. This is one carrier, not a universal guarantee; hashes and exact verdicts
|
||||
are tracked in `data/evaluations/video-synthid-oracle.csv`.
|
||||
|
||||
The shipped engine streams sampled frames in bounded batches, computes its
|
||||
fidelity metrics incrementally, and pipes regenerated pixels directly to
|
||||
ffmpeg. Its frame and latent memory is therefore bounded by `--batch-size`
|
||||
rather than clip duration. Runtime still grows linearly with duration, and the
|
||||
separate multi-candidate research sweep deliberately retains its short sampled
|
||||
prefix so it can reuse identical latents across candidate strengths.
|
||||
|
||||
### Strength is content and seed dependent
|
||||
|
||||
For SDXL and ControlNet, the CLI resolves an unset strength from the detected
|
||||
vendor:
|
||||
|
||||
- OpenAI: `0.10`;
|
||||
- Google: `0.15`;
|
||||
- unknown: `0.15`.
|
||||
|
||||
An explicit `--strength` overrides these defaults. The defaults are operating
|
||||
points, not universal guarantees. Near a removal threshold, different content
|
||||
or a different random seed may change the verifier result.
|
||||
|
||||
The base Qwen and `qwen-zimage` profiles have profile specific strength
|
||||
behavior. Consult `remove-ai-watermarks invisible --help` and the source of
|
||||
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py)
|
||||
for the current resolver.
|
||||
|
||||
### Pipelines have different quality tradeoffs
|
||||
|
||||
| Pipeline | Main limit |
|
||||
| --- | --- |
|
||||
| `controlnet` | Edge conditioning can preserve a watermark carrying region too closely, and faces may drift. |
|
||||
| `sdxl` | Flat graphics and precise structure may receive too little or unhelpful change. |
|
||||
| `qwen` | Large CUDA oriented model; face smoothing can still be significant. |
|
||||
| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. |
|
||||
|
||||
The legacy `default` profile name maps to `sdxl`. The `--auto` flag is
|
||||
deprecated, emits a warning, and changes nothing.
|
||||
|
||||
## Resolution and memory
|
||||
|
||||
### Small images are enlarged before SDXL based diffusion
|
||||
|
||||
The SDXL, ControlNet, and base Qwen paths use a default minimum long side of
|
||||
`1024`. Smaller inputs are enlarged before diffusion and restored to their
|
||||
original dimensions afterward. Set `--min-resolution 0` to disable the floor.
|
||||
|
||||
`qwen-zimage` does not apply this SDXL minimum resolution floor.
|
||||
|
||||
### Large images stay at native resolution unless capped
|
||||
|
||||
`--max-resolution 0` means no explicit downscale cap. A positive value caps the
|
||||
long side before diffusion and restores the result afterward. This reduces
|
||||
memory use but introduces a downscale and upscale round trip.
|
||||
|
||||
`--tile` preserves the input dimensions while running the diffusion stage in
|
||||
overlapping tiles. It avoids the explicit downscale, but it is not pixel
|
||||
lossless: each tile is independently regenerated. With `qwen-zimage`, only the
|
||||
global Qwen stage is tiled; the face stage runs after tile blending.
|
||||
|
||||
### CPU offload is CUDA only
|
||||
|
||||
`--cpu-offload` moves Diffusers model components between CPU and CUDA instead of
|
||||
keeping the complete standard pipeline in GPU memory. For `qwen-zimage`, it
|
||||
forces the face stack to use its offload path.
|
||||
|
||||
The option reduces CUDA memory pressure at the cost of speed. It has no effect
|
||||
on CPU or MPS and fails loudly when a CUDA Diffusers pipeline does not expose
|
||||
the required offload method.
|
||||
|
||||
### MPS may fall back to CPU
|
||||
|
||||
The SDXL paths include an MPS out-of-memory fallback that reloads on CPU. A run
|
||||
that appears much slower after an MPS failure may be continuing on CPU.
|
||||
|
||||
Memory needs depend on the pipeline, input size, dtype, and machine. Use tiling,
|
||||
a resolution cap, or a lighter pipeline when necessary.
|
||||
|
||||
## Metadata and formats
|
||||
|
||||
### Missing metadata does not mean clean
|
||||
|
||||
Screenshots, social platforms, and re-encoding can remove metadata while a
|
||||
pixel watermark remains. `identify` therefore reports unknown rather than
|
||||
clean when no supported signal is found.
|
||||
|
||||
### JPEG XL is metadata only
|
||||
|
||||
The metadata path recognizes JPEG XL containers, but the visible and diffusion
|
||||
image paths do not list `.jxl` as a supported pixel format because the package
|
||||
does not include a JPEG XL pixel decoder.
|
||||
|
||||
### HEIC, HEIF, and AVIF pixel decoding uses an optional Pillow fallback
|
||||
|
||||
OpenCV does not decode these formats in the project. `image_io.imread` falls
|
||||
back to Pillow with `pillow-heif` when the `heif` extra is installed alongside
|
||||
a pixel feature. The
|
||||
default metadata path scans these containers without that plugin. A corrupt or
|
||||
truncated file may still fail to decode.
|
||||
|
||||
### Some metadata removal requires ffmpeg
|
||||
|
||||
WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is stripped
|
||||
through ffmpeg with stream copying. The operation fails if ffmpeg is absent or
|
||||
cannot parse the input.
|
||||
|
||||
### Video pixel removal is provider-specific
|
||||
|
||||
The `video metadata` command and high level video API inspect and strip
|
||||
supported AI provenance metadata without transcoding streams.
|
||||
|
||||
`video visible` and `remove_video_visible` additionally support the moving
|
||||
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
|
||||
`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo
|
||||
MINIMAX/Hailuo composite label, and the bottom-right Kling label with its
|
||||
version suffix. Detection requires a recurring visual candidate across
|
||||
adjacent frames. Fixed-mark candidates must remain anchored rather than
|
||||
drifting with a scene object. Kling also requires a bright low-saturation
|
||||
candidate near the expected frame edge. Provider provenance can recover
|
||||
low-contrast runs only after visual evidence exists for the marks that define a
|
||||
provenance prior, so metadata alone does not erase a clean API export.
|
||||
The default auto-router evaluates all detectors in one decode pass but does not
|
||||
rank their raw confidence values. Those scores are provider-specific and known
|
||||
to cross-match in some layouts, so the router applies the independent temporal
|
||||
policies and selects the first stable result in specificity order. Use an
|
||||
explicit mark when the provider is already known.
|
||||
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
|
||||
than the moving mascot-and-wordmark design; that earlier variant is not
|
||||
detected by the `sora` video mark. Hailuo and Kling coverage is specific to the
|
||||
verified lower-edge layouts; a new provider layout needs a separate calibrated
|
||||
silhouette. Other provider video labels are not supported yet. Google video
|
||||
SynthID has an oracle-certified VAE removal path, while other proprietary
|
||||
invisible video watermarks have no registered attack.
|
||||
|
||||
Visible removal transcodes the video stream and copies the complete audio
|
||||
stream without shortening an audio tail. Completed visible and invisible
|
||||
encodes are published atomically, so an encode failure preserves an existing
|
||||
output. Visible removal now applies a guarded motion-compensated blend after
|
||||
the per-frame fill. It uses adjacent optical flow only when the warped prior
|
||||
mask covers the current mask and a source-context ring agrees; scene cuts and
|
||||
disjoint marks keep the independent fill. This reduces measured paired
|
||||
temporal error, but it is not a generative video-inpainting model and cannot
|
||||
recover structure that no frame exposes. OpenCV can still leave a visible
|
||||
smear where the mark overlaps a hard edge or structured texture. MI-GAN
|
||||
improves difficult individual frames and is the practical learned CPU tier.
|
||||
LaMa remains an offline quality option: a full real sequence confirmed its
|
||||
multi-GB memory use and CPU throughput unsuitable for an online worker. The Veo diamond
|
||||
uses a shape mask to limit damage outside the symbol. Seedance fills the full
|
||||
localized box because a synthetic outline mask left part of the real
|
||||
translucent border visible in an end-to-end check. OpenCV may therefore soften
|
||||
texture inside that small box; use MI-GAN or LaMa when reconstruction quality
|
||||
matters. Relative variable-frame timestamps are preserved through a timestamped
|
||||
NUT bridge. A non-zero absolute video start PTS and the corresponding copied
|
||||
audio offset are preserved through ffmpeg timestamp passthrough.
|
||||
The encoder is source-aware for common 8-bit inputs: it probes and preserves
|
||||
supported chroma sampling, recognized color metadata, encoder time base, and
|
||||
MP4/MOV track timescale instead of accepting ffmpeg's implicit `yuv444p`
|
||||
raw-BGR output. OpenCV still decodes through 8-bit BGR, so HDR/high-bit-depth
|
||||
inputs are rejected before encoding rather than silently falling back to
|
||||
`yuv420p`. The synthetic
|
||||
Sora/OpenCV full-clip CI gate covers complete removal, untouched-region PSNR,
|
||||
frame count, frame rate, duration, copied-audio identity, source stream
|
||||
properties, paired temporal deltas against an independently encoded
|
||||
frame-local baseline inside the filled region, and metadata
|
||||
stripping through real ffmpeg. A second synthetic VFR clip verifies all display
|
||||
timestamps to the source time-base tick, including a non-zero source start,
|
||||
and checks both video and audio stream offsets. A separate constant-rate case
|
||||
guards the non-zero-start routing independently of VFR detection. A local
|
||||
full-sequence audit over all six providers passed both OpenCV and MI-GAN for
|
||||
complete-frame removal, quiet second detection, stream starts, duration, and
|
||||
copied audio. A full LaMa sequence passed the same checks but established that
|
||||
the backend belongs in the offline tier on CPU. These bounded local checks are
|
||||
still not universal evidence for every provider layout or source.
|
||||
|
||||
Native TC260 metadata in MP4/MOV is supported at its normative
|
||||
`moov.udta.meta.keys/ilst` placement, including non-faststart files whose
|
||||
`moov` follows a large media payload. MKV/WebM is supported at the normative
|
||||
`Segment.Tags.Tag.SimpleTag` placement and uses ffmpeg for stream-copy removal.
|
||||
AVI is supported at `LIST/INFO/AIGC`, and FLV at
|
||||
`script.onMetaData.AIGC`; both use ffmpeg stream-copy removal. Stock ffmpeg can
|
||||
write and strip the FLV form, but writing the nonstandard AVI child for fixture
|
||||
generation requires dedicated muxer support, so the AVI reader is verified
|
||||
against an exact synthetic RIFF structure.
|
||||
|
||||
MP4/MOV/M4V metadata removal now stream-copies the container in bounded chunks,
|
||||
keeps every box size and media offset fixed, and publishes atomically. The
|
||||
large-`mdat` regression rejects a full-source `read_bytes()` call and verifies
|
||||
that the encoded payload is byte-identical. HEIF/AVIF/JPEG-XL image metadata
|
||||
still uses the in-memory path because their XMP/EXIF items may live inside
|
||||
`mdat`/`idat` and require bounded format-item parsing before that path can
|
||||
stream safely.
|
||||
|
||||
### Metadata transformation is fail safe
|
||||
|
||||
`remove_ai_metadata` may copy an undecodable file through unchanged instead of
|
||||
raising. User facing callers must use `strip_and_verify` and inspect its
|
||||
surviving marker mapping before reporting success. `strip_and_verify` recovers
|
||||
when `image_io` can still decode the raster by normalizing the container and
|
||||
checking again. A truly undecodable file still reports the surviving markers.
|
||||
The CLI uses this verified path.
|
||||
|
||||
### Sixteen bit PNG output is not preserved
|
||||
|
||||
The Pillow based PNG metadata rewrite uses the normal image save path and may
|
||||
reduce a sixteen bit PNG to eight bits. A byte-level PNG metadata stripper
|
||||
would be required to preserve that bit depth.
|
||||
|
||||
## Detection extras
|
||||
|
||||
The `detect` extra decodes an open DWT-DCT watermark used in some Stable
|
||||
Diffusion, SDXL, and FLUX workflows. That decoder is sensitive to the carrier
|
||||
and transformations. A negative result is not a universal negative.
|
||||
|
||||
The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains
|
||||
an additional JPEG re-encode gate because isolated decoder hits can otherwise
|
||||
be content noise.
|
||||
|
||||
External AI versus real image classifiers are out of scope. The project
|
||||
identifies concrete local provenance signals instead of shipping a generic
|
||||
statistical classifier.
|
||||
|
||||
## Output and traceability
|
||||
|
||||
Removing file-local signals does not remove:
|
||||
|
||||
- provider account history;
|
||||
- server side copies or provenance stores;
|
||||
- perceptual fingerprints;
|
||||
- evidence that an image passed through a removal pipeline;
|
||||
- legal disclosure duties.
|
||||
|
||||
See [scope, safety, and legal notes](legal-and-safety.md).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Scope, safety, and legal notes
|
||||
|
||||
This page explains the project's intended boundary. It is not legal advice.
|
||||
Laws and platform rules change, so check the current rules that apply to your
|
||||
location and use case.
|
||||
|
||||
## Intended scope
|
||||
|
||||
The project removes AI provenance marks that a platform adds to content the
|
||||
user generated or edited themselves. Examples include:
|
||||
|
||||
- visible AI generation labels;
|
||||
- invisible provenance watermarks;
|
||||
- C2PA and metadata based AI disclosures.
|
||||
|
||||
The purpose is user control over the user's own output, false positive cleanup,
|
||||
interoperability work, and watermark robustness research.
|
||||
|
||||
## Out of scope
|
||||
|
||||
The project does not provide automatic removal for marks that protect a third
|
||||
party's paid or copyrighted asset, including:
|
||||
|
||||
- stock agency previews;
|
||||
- marketplace and classifieds marks;
|
||||
- tiled overlays used to gate a purchase;
|
||||
- artist protection systems such as Nightshade or Glaze.
|
||||
|
||||
The `erase` command is a generic region tool. Users are responsible for having
|
||||
the right to edit the selected content.
|
||||
|
||||
## What removal does not prove
|
||||
|
||||
Removing a local signal does not:
|
||||
|
||||
- prove that an image is human made;
|
||||
- remove server side generation history;
|
||||
- anonymize the generating account;
|
||||
- defeat every statistical AI detector;
|
||||
- guarantee that a provider's current verifier will reject the result;
|
||||
- make deceptive or unlawful use permissible.
|
||||
|
||||
An original file may remain linked to an account or generation session in a
|
||||
provider's systems even after a local copy is changed.
|
||||
|
||||
## Legal context
|
||||
|
||||
Some jurisdictions and platforms require AI generated content to carry visible
|
||||
or machine readable disclosures. Rules may apply to providers, publishers,
|
||||
users, or a combination of them. Some laws also restrict removing or
|
||||
suppressing provenance information.
|
||||
|
||||
Before removing a mark, consider:
|
||||
|
||||
1. whether you own or are authorized to edit the content;
|
||||
2. whether a disclosure is legally required where the content will be used;
|
||||
3. whether removal would mislead a viewer about authorship or origin;
|
||||
4. whether copyright management information is involved;
|
||||
5. whether a platform's terms prohibit the change.
|
||||
|
||||
The repository does not provide jurisdiction specific legal advice. The user is
|
||||
responsible for checking current law and policy.
|
||||
|
||||
## Appropriate uses
|
||||
|
||||
Examples that fit the project scope include:
|
||||
|
||||
- removing metadata that exposes an account identifier from your own file;
|
||||
- correcting an AI label applied to a human photograph after a limited edit;
|
||||
- publishing your own generated artwork under the disclosure rules that apply
|
||||
to you;
|
||||
- testing the robustness of watermarking and provenance systems;
|
||||
- evaluating image processing pipelines in a controlled environment.
|
||||
|
||||
## Uses the project does not condone
|
||||
|
||||
- fraud or impersonation;
|
||||
- nonconsensual sexual imagery;
|
||||
- hiding copyright infringement;
|
||||
- presenting generated content as human made where that claim is deceptive;
|
||||
- evading a disclosure that the law requires;
|
||||
- removing protection from someone else's paid asset.
|
||||
|
||||
## Reporting security or safety concerns
|
||||
|
||||
Open a GitHub issue when the concern can be discussed publicly without exposing
|
||||
private data. Do not attach confidential images, credentials, or personal
|
||||
information to a public issue.
|
||||
@@ -0,0 +1,756 @@
|
||||
# Module internals
|
||||
|
||||
This page documents the current implementation contract. It intentionally
|
||||
avoids experiment logs, corpus counts, and calibration history. Those records
|
||||
live in [the verification plan](verification-plan.md) and the research archive
|
||||
listed in [the documentation index](index.md).
|
||||
|
||||
Read the relevant section before changing a subsystem. When this page and the
|
||||
code disagree, the code and its tests are authoritative and this page must be
|
||||
updated in the same change.
|
||||
|
||||
## Architecture
|
||||
|
||||
The package has four main paths:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Input[Input file] --> Identify[Identify provenance]
|
||||
Input --> Visible[Visible mark removal]
|
||||
Input --> Invisible[Diffusion regeneration]
|
||||
Input --> Metadata[Metadata stripping]
|
||||
|
||||
Identify --> Report[ProvenanceReport]
|
||||
Visible --> VisibleOutput[Localized and filled image]
|
||||
Invisible --> InvisibleOutput[Regenerated image]
|
||||
Metadata --> MetadataOutput[Container with AI metadata removed]
|
||||
```
|
||||
|
||||
The `all` command runs visible removal, optional invisible regeneration, and
|
||||
metadata stripping in that order.
|
||||
|
||||
## Command line interface
|
||||
|
||||
[`cli.py`](../src/remove_ai_watermarks/cli.py) owns command parsing and
|
||||
user-facing exit behavior.
|
||||
|
||||
Important contracts:
|
||||
|
||||
- Single-image arguments reject directories.
|
||||
- `visible` writes no output when no registered mark is selected and exits with
|
||||
`EXIT_NO_VISIBLE_MARK`.
|
||||
- `invisible` writes no output when no supported local signal is found, unless
|
||||
`--force` is supplied.
|
||||
- The two no-signal conditions currently share exit code `2`.
|
||||
- Hard processing and write failures exit with code `1`.
|
||||
- `all` can still write the completed visible and metadata stages when the
|
||||
diffusion dependencies are unavailable, but exits with code `1` so the
|
||||
partial result is not reported as complete.
|
||||
- `batch` counts per-file failures and exits nonzero if any file failed or an
|
||||
applicable invisible stage was skipped because its dependencies were absent.
|
||||
|
||||
The decorators for diffusion options are shared by `invisible`, `all`, and
|
||||
`batch`. The runtime help generated by Click is the source of truth for option
|
||||
names and defaults.
|
||||
|
||||
The deprecated `--auto` option does not select a pipeline or change adaptive
|
||||
polishing. [`_resolve_auto_polish`](../src/remove_ai_watermarks/cli.py) emits a
|
||||
warning and returns the explicit polish value unchanged.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_cli.py`](../tests/test_cli.py)
|
||||
- [`test_cli_robustness.py`](../tests/test_cli_robustness.py)
|
||||
- [`test_optional_deps.py`](../tests/test_optional_deps.py)
|
||||
|
||||
## High-level Python API
|
||||
|
||||
[`api.py`](../src/remove_ai_watermarks/api.py) provides:
|
||||
|
||||
- `remove_visible`
|
||||
- `visible_provenance`
|
||||
|
||||
The package root exposes both lazily through
|
||||
[`__getattr__`](../src/remove_ai_watermarks/__init__.py), keeping a plain package
|
||||
import free of the heavier image and model imports.
|
||||
|
||||
For path inputs, `remove_visible` reads provenance metadata, preserves alpha,
|
||||
and optionally writes and strips metadata. Array inputs are treated as BGR
|
||||
arrays and have no file provenance or separate alpha plane.
|
||||
|
||||
When no visible mark is removed, a same-format path copy preserves the original
|
||||
bytes. `write_noop=False` leaves the requested output path untouched instead.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_api.py`](../tests/test_api.py)
|
||||
- [`test_image_io.py`](../tests/test_image_io.py)
|
||||
|
||||
[`video.py`](../src/remove_ai_watermarks/video.py) provides the high-level
|
||||
video entry point:
|
||||
|
||||
- `identify_video`
|
||||
- `inspect_video_metadata`
|
||||
- `remove_video_all`
|
||||
- `remove_video_batch`
|
||||
- `remove_video_invisible`
|
||||
- `remove_video_metadata`
|
||||
- `remove_video_visible`
|
||||
|
||||
The video API validates both the supported extension and container signature,
|
||||
then delegates all metadata detection and stripping to `metadata.py`. It
|
||||
requires a separate same-container output, defaulting to `<source>_clean`, so
|
||||
the product path does not overwrite an original. The package root exposes all
|
||||
functions lazily.
|
||||
|
||||
`identify_video` runs the same stable-mark selection helper as
|
||||
`remove_video_visible`, so a provenance report cannot authorize a mark that the
|
||||
removal path would reject. It reports an empty local result as unknown rather
|
||||
than clean. Identification skips the separate per-frame timestamp probe because
|
||||
it never encodes frames. `remove_video_all` is the predictable-output
|
||||
composition: visible removal plus verified metadata stripping by default, with
|
||||
a same-container passthrough when neither signal exists. The lossy invisible
|
||||
removal stage is an explicit opt-in through the oracle-certified profile.
|
||||
`remove_video_batch` applies those contracts sequentially across a top-level
|
||||
directory, returns every per-file failure, and byte-copies visible no-ops so a
|
||||
successful output set has no silent holes. An invisible batch loads one VAE
|
||||
runtime and reuses it across every compatible file; a failed model load is
|
||||
reported per file without retrying the same multi-GB initialization.
|
||||
|
||||
Native MP4/MOV TC260 labels follow TC260-PG-20257A:
|
||||
`moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`.
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py) walks those
|
||||
nested boxes by seeking, so detection reaches a tail `moov` without reading the
|
||||
preceding `mdat`. The MP4/MOV/M4V/M4A removal path first validates the top-level
|
||||
box walk, then copies the source to a sibling temporary file in bounded chunks.
|
||||
Supported C2PA/JUMBF/AI-label boxes become same-size `free` boxes with blank
|
||||
payloads; TC260 removal changes the four-byte key to `free` and blanks only the
|
||||
validated JSON value with same-length spaces. This preserves every box size,
|
||||
`stco`/`co64` offset, encoded stream byte, and source-sized memory bound.
|
||||
Publication is atomic, and a malformed top-level walk is copied unchanged. A
|
||||
generic `AIGC` key whose value has no TC260 field is ignored.
|
||||
|
||||
[`_internal/ebml.py`](../src/remove_ai_watermarks/_internal/ebml.py) provides the
|
||||
corresponding bounded Matroska/WebM reader. It seeks over clusters and accepts
|
||||
only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
|
||||
`TagString` carrying a TC260 field. The existing ffmpeg stream-copy path removes
|
||||
those container tags without transcoding the encoded streams.
|
||||
|
||||
[`_internal/riff.py`](../src/remove_ai_watermarks/_internal/riff.py) and
|
||||
[`_internal/flv.py`](../src/remove_ai_watermarks/_internal/flv.py) implement the remaining
|
||||
normative TC260 video placements. The RIFF walker reads only AVI
|
||||
`LIST/INFO/AIGC` children. The FLV walker skips media tags and parses the AMF0
|
||||
`script.onMetaData.AIGC` string. Both require a recognized TC260 JSON field and
|
||||
use the verified ffmpeg stream-copy path for removal.
|
||||
|
||||
[`video_encoding.py`](../src/remove_ai_watermarks/video_encoding.py) owns the
|
||||
ffmpeg command and pipe lifecycle shared by visible removal and invisible
|
||||
regeneration. It centralizes container codecs, optional audio stream copying,
|
||||
metadata/chapter policy, encode-failure reporting, and atomic same-directory
|
||||
publication. Each mapped stream is allowed to reach its own end, so a copied
|
||||
audio tail is not shortened to the frame-input duration.
|
||||
Both the raw-BGR and timestamped-NUT stdin modes redirect ffmpeg stderr to a
|
||||
temporary file while frames are written. Waiting to read diagnostics until
|
||||
after stdin closed allowed stderr backpressure to stop ffmpeg's frame reads,
|
||||
which in turn blocked the producer before it could close stdin. The file consumes
|
||||
no pipe capacity or RAM while ffmpeg runs; completion reports a bounded head and
|
||||
tail when diagnostics are unusually large. Aborts release it even when ffmpeg
|
||||
has already exited. A real subprocess regression writes diagnostics beyond pipe
|
||||
capacity while streaming frames, checks bounded failure reporting, and the Linux
|
||||
full-clip CI job guards the complete path.
|
||||
Frame encoding and source-audio copying run as two ffmpeg processes in sequence.
|
||||
The streaming encoder has only the frame pipe as input, so input probing or
|
||||
demux queues cannot deadlock the producer against a second input. After that
|
||||
pipe reaches EOF, a finite stream-copy mux combines the encoded video with the
|
||||
source audio and applies the requested metadata/chapter policy. Both stages use
|
||||
sibling temporary files, and only the completed mux is published atomically.
|
||||
The mux also redirects diagnostics to disk and reports only a bounded head and
|
||||
tail. Command regressions assert the single-input encoder and final map targets;
|
||||
failure regressions cover bounded mux diagnostics and atomic cleanup.
|
||||
`probe_video_encode_profile` reads the first source video stream with ffprobe
|
||||
and preserves the supported properties that survive the 8-bit BGR boundary:
|
||||
`yuv420p`/`yuv422p`/`yuv444p` chroma sampling, recognized color tags, encoder
|
||||
time base, MP4/MOV track timescale, source pixel format, and component depth.
|
||||
Both raw-CFR and timestamped-NUT inputs use ffmpeg's passthrough FPS mode. This
|
||||
keeps one encoded frame per supplied frame when an older ffmpeg receives a
|
||||
fine-grained source encoder time base such as `1/90000`; implicit synchronization
|
||||
can otherwise synthesize thousands of duplicate frames between CFR timestamps.
|
||||
HDR transfer functions and component depths above 8 bits are rejected before
|
||||
encoding so the OpenCV boundary cannot silently reduce them to SDR 8-bit.
|
||||
`probe_video_timestamps` reads authoritative per-frame display PTS through
|
||||
ffprobe. OpenCV timestamps are only a count-matched fallback when ffprobe is
|
||||
unavailable or fails; this avoids decoder anomalies such as one spurious
|
||||
negative first-frame timestamp turning a CFR clip into false VFR. A uniform sequence keeps the
|
||||
cheap raw-BGR pipe unless the source starts at a non-zero PTS. A variable or
|
||||
offset sequence is packetized by the lazy PyAV bridge as rawvideo in an
|
||||
in-memory NUT stream with explicit PTS. System ffmpeg reads that stream with
|
||||
`-fps_mode passthrough`; `-copyts` additionally retains a non-zero video start
|
||||
and the corresponding copied-audio offset. No temporary frame sequence or
|
||||
second video encoder is introduced.
|
||||
|
||||
[`video_temporal.py`](../src/remove_ai_watermarks/video_temporal.py) owns the
|
||||
shared optical-flow maps and temporal residual metric. Visible removal uses
|
||||
`stabilize_filled_frame` after the selected image backend: it works on a
|
||||
bounded crop around adjacent masks, backward-warps the prior cleaned frame,
|
||||
requires high warped-mask coverage, and gates blending on an unmasked
|
||||
source-context ring. Only covered current-mask pixels change. Scene cuts,
|
||||
disjoint marks, and poor motion matches therefore retain the independent
|
||||
current-frame fill. The same module supplies the motion-compensated metric used
|
||||
by the invisible-video sweep.
|
||||
|
||||
[`video_invisible.py`](../src/remove_ai_watermarks/video_invisible.py)
|
||||
implements the oracle-certified video SynthID removal engine. It samples frames
|
||||
uniformly, resizes to a VAE-aligned geometry, encodes each frame to latent
|
||||
space, applies one seeded spatial-noise field across the entire sequence, and
|
||||
decodes fresh pixels. Reusing a single noise field avoids independent
|
||||
frame-to-frame noise. The shipped path retains only one configured frame batch,
|
||||
updates PSNR and temporal residuals incrementally, and streams BGR frames
|
||||
directly to the video-only ffmpeg encoder. A separate stream-copy mux then adds
|
||||
optional source audio and drops all source metadata. The result is written
|
||||
through same-directory temporary files and atomically replaced only after both
|
||||
stages succeed.
|
||||
|
||||
The engine returns PSNR and a motion-compensated temporal-residual ratio as
|
||||
quality measurements. Neither is a watermark detector. The high-level result
|
||||
reports completed removal without a separate verification-status flag. The companion
|
||||
`scripts/video_synthid_sweep.py` imports the same engine helpers to build a
|
||||
matched control and candidate grid, preventing research and shipped
|
||||
regeneration paths from drifting. The full-clip oracle floor is
|
||||
`noise_std=0.15`: on the public eight-second Veo carrier, `0.10` remained
|
||||
detected while `0.15` did not.
|
||||
|
||||
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
|
||||
the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. The
|
||||
Sora detector searches a normalized frame with a fully synthetic
|
||||
mascot-and-text silhouette at several scales. The Veo detector uses separate
|
||||
synthetic silhouettes for the current four-point diamond and legacy `Veo`
|
||||
text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses
|
||||
an OpenCV-font `Dola AI` silhouette. Hailuo uses a synthetic waveform,
|
||||
MINIMAX/Hailuo text, separator, and ring. Kling combines synthetic font
|
||||
variants with a ring approximation of its swirl; the logo path rescues
|
||||
wordmarks whose version or font differs, while the edge and white-label gates
|
||||
reject recurring scene texture. All fixed-mark searches are bounded to the
|
||||
expected lower-frame area and calibrated independently. A strong relocated Veo
|
||||
diamond may bypass the known layout anchors, but weak free-corner matches never
|
||||
enter the temporal arbiter.
|
||||
|
||||
The default `auto` route decodes each frame once, shares its grayscale and
|
||||
normalized representations across all detectors, and caches resized synthetic
|
||||
template features for the fixed stream geometry. Provider confidence scales
|
||||
are not comparable: selection applies each provider's temporal arbiter and
|
||||
takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
|
||||
`dola`, `hailuo`, `kling`). An explicit mark uses the same scan path with one
|
||||
candidate. Removal also collects authoritative per-frame timestamps for the
|
||||
encoder, while identification omits that unused ffprobe pass.
|
||||
|
||||
Every per-frame result is untrusted. The provider-specific stabilization
|
||||
wrappers share one recurrence implementation, while retaining separate visual
|
||||
floors and minimum-run policy. Provenance can relax a low-contrast run only
|
||||
after recurring visual evidence exists. Sora transition frames follow the
|
||||
nearest confirmed moving position only with Sora provenance. Veo, Seedance,
|
||||
Dola, Hailuo, and Kling additionally require candidates to remain anchored to
|
||||
the start of a run. This rejects slowly drifting scene details that still have
|
||||
high frame-to-frame overlap. Hailuo and Kling do not infer provenance from
|
||||
technical encoder tags; their confirmed public samples carried no provider
|
||||
metadata.
|
||||
|
||||
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text,
|
||||
Seedance, Hailuo, and Kling use box masks. Seedance deliberately fills the
|
||||
complete localized box: a synthetic outline mask passed repeat detection but
|
||||
left part of the real translucent border visible during visual end-to-end
|
||||
review. Hailuo expands beyond the matched core to cover both provider icons.
|
||||
Kling expands around the wordmark or swirl to include the version and optional
|
||||
`PRO` suffix. The square Veo diamond uses a synthetic shape mask so transparent
|
||||
corners do not erase unrelated pixels. Every mask goes through the shared
|
||||
`watermark_registry.fill` backends. ffmpeg encodes the changed video stream and
|
||||
copies optional audio. The default OpenCV fill is the speed floor; structured
|
||||
backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
|
||||
stages must continue to reuse the image and metadata implementations rather
|
||||
than copying their logic.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_video.py`](../tests/test_video.py), including a real ffmpeg full-clip
|
||||
Sora/OpenCV path that generates a synthetic marked MP4 with AAC audio and
|
||||
C2PA provenance, runs both `remove_video_visible` and the composed
|
||||
`remove_video_all` API without mocks, and verifies complete removal, frame
|
||||
count, frame rate, duration, untouched-region PSNR, paired temporal deltas
|
||||
inside the filled region, byte-identical copied audio packets, source stream
|
||||
properties, metadata stripping, and a large-`mdat` metadata case that rejects
|
||||
any full-source `read_bytes()` call. CI installs ffmpeg explicitly for this
|
||||
test so the integration gate cannot silently skip.
|
||||
|
||||
## Metadata and provenance
|
||||
|
||||
### C2PA
|
||||
|
||||
[`_internal/c2pa.py`](../src/remove_ai_watermarks/_internal/c2pa.py) reads C2PA with the
|
||||
official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback
|
||||
for partial and synthetic fixtures that the official reader rejects.
|
||||
|
||||
Vendor attribution comes from the registry in
|
||||
[`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived
|
||||
issuer and platform maps should not be maintained separately.
|
||||
|
||||
### Metadata scanning and stripping
|
||||
|
||||
[`metadata.py`](../src/remove_ai_watermarks/metadata.py) contains the shared
|
||||
metadata scanners and `remove_ai_metadata`.
|
||||
|
||||
Key contracts:
|
||||
|
||||
- `scan_head` is the shared cached input for bounded byte scans.
|
||||
- JPEG stripping walks metadata segments and preserves the entropy-coded image
|
||||
scan.
|
||||
- ISOBMFF containers use
|
||||
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
|
||||
- Native MP4/MOV TC260 `AIGC` entries are read from
|
||||
`moov.udta.meta.keys/ilst` and blanked without changing box sizes.
|
||||
- Native MKV/WebM TC260 `AIGC` entries are read from
|
||||
`Segment.Tags.Tag.SimpleTag` and removed through the ffmpeg stream-copy path.
|
||||
- Native AVI and FLV TC260 entries are read from `LIST/INFO/AIGC` and
|
||||
`script.onMetaData.AIGC`, respectively, then removed through ffmpeg stream
|
||||
copying.
|
||||
- Supported non-ISOBMFF audio and video containers use ffmpeg stream copying.
|
||||
- The low-level remover is fail-safe and can copy an undecodable file through
|
||||
unchanged.
|
||||
- A caller that reports success must use `strip_and_verify`, which scans the
|
||||
written output for surviving markers. If the metadata-preserving decoder
|
||||
rejected the container but `image_io` can still decode its raster,
|
||||
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
|
||||
file keeps the surviving-marker result.
|
||||
|
||||
Detection and removal must stay in parity. A new marker is incomplete until the
|
||||
scanner can find it, the remover can reach every supported placement, and a
|
||||
test proves that it no longer appears in the output.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_metadata.py`](../tests/test_metadata.py)
|
||||
- [`test_metadata_internals.py`](../tests/test_metadata_internals.py)
|
||||
- [`test_security_clamp.py`](../tests/test_security_clamp.py)
|
||||
|
||||
### Provenance report
|
||||
|
||||
[`identify.py`](../src/remove_ai_watermarks/identify.py) separates file-backed
|
||||
metadata extraction from verdict logic:
|
||||
|
||||
- `extract_provenance_evidence` reads the supported metadata signals into
|
||||
`ProvenanceEvidence`.
|
||||
- `evidence_from_metadata_record` normalizes an externally collected nested
|
||||
metadata record into the same evidence type without file access. Diagnostic
|
||||
values under `error` and `kind` are excluded from evidence while nested raw
|
||||
bytes remain available through encoded binary fields.
|
||||
- `identify_from_evidence` evaluates that evidence without reopening the source.
|
||||
- `identify` preserves the path-based API and adds the optional registered
|
||||
visible-mark and open invisible-watermark decoders after extraction.
|
||||
|
||||
The `detect` extra composes the shared `pixels` runtime with PyWavelets. Its
|
||||
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder preserves
|
||||
the upstream matrix algorithm without installing Torch or non-headless OpenCV.
|
||||
The upstream MIT notice ships inside the wheel under `licenses/`.
|
||||
|
||||
`is_ai_generated` is `True` or `None`; absence of evidence is not reported as a
|
||||
human-made verdict. `ai_source_kind` distinguishes fully generated content from
|
||||
AI-enhanced composites when the source metadata provides that distinction.
|
||||
|
||||
TrustMark is reported as a watermark signal but does not by itself assert AI
|
||||
origin because it can also protect human-authored content.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_identify.py`](../tests/test_identify.py)
|
||||
- [`test_trustmark_detector.py`](../tests/test_trustmark_detector.py)
|
||||
- [`test_invisible_watermark.py`](../tests/test_invisible_watermark.py)
|
||||
|
||||
## Visible mark removal
|
||||
|
||||
### Registry and decision flow
|
||||
|
||||
[`watermark_registry.py`](../src/remove_ai_watermarks/watermark_registry.py) is
|
||||
the only visible-mark registry. `mark_keys()` supplies the CLI choices, so the
|
||||
CLI must not maintain a separate mark list.
|
||||
|
||||
Automatic removal has three distinct stages:
|
||||
|
||||
1. Perception: each registered detector produces strict and relaxed candidates.
|
||||
2. Decision: the pure `decide` arbiter applies sensitivity and corroborating
|
||||
provenance.
|
||||
3. Action: each selected mark is localized to a mask and passed to the shared
|
||||
fill function.
|
||||
|
||||
`sensitivity="strict"` never relaxes a detector. `sensitivity="auto"` can relax
|
||||
one only when metadata or a sufficiently strong same-product sibling confirms
|
||||
that product. The removed blanket `assume_ai` mode is rejected explicitly.
|
||||
|
||||
The Jimeng pill has an additional decision gate because its visual detector is
|
||||
weaker than the other registered marks. Keep that policy in the registry, not
|
||||
inside unrelated detector engines.
|
||||
|
||||
`remove_auto_marks` removes every selected mark, not only the strongest one.
|
||||
This matters for images that carry marks in more than one corner.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_watermark_registry.py`](../tests/test_watermark_registry.py)
|
||||
- [`test_api.py`](../tests/test_api.py)
|
||||
|
||||
### Gemini sparkle
|
||||
|
||||
[`gemini_engine.py`](../src/remove_ai_watermarks/gemini_engine.py) uses a
|
||||
multi-scale shape search and a false-positive gate. Its captured sparkle assets
|
||||
serve detection and mask geometry only. Pixel recovery is performed by the
|
||||
shared fill backend.
|
||||
|
||||
`detect_sparkle_confidence` uses a process-wide shared engine because its loaded
|
||||
assets and template ladder are immutable.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_gemini_engine.py`](../tests/test_gemini_engine.py)
|
||||
|
||||
### Text mark engines
|
||||
|
||||
[`_text_mark_engine.py`](../src/remove_ai_watermarks/_text_mark_engine.py)
|
||||
provides common localization, detection front ends, template caching, rival
|
||||
comparison, and footprint construction.
|
||||
|
||||
Each vendor module supplies a `TextMarkConfig` and only the behavior that cannot
|
||||
be represented by the shared base:
|
||||
|
||||
- [`doubao_engine.py`](../src/remove_ai_watermarks/doubao_engine.py)
|
||||
- [`jimeng_engine.py`](../src/remove_ai_watermarks/jimeng_engine.py)
|
||||
- [`qwen_engine.py`](../src/remove_ai_watermarks/qwen_engine.py)
|
||||
- [`kling_engine.py`](../src/remove_ai_watermarks/kling_engine.py)
|
||||
- [`yuanbao_engine.py`](../src/remove_ai_watermarks/yuanbao_engine.py)
|
||||
- [`samsung_engine.py`](../src/remove_ai_watermarks/samsung_engine.py)
|
||||
- [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py)
|
||||
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
|
||||
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)
|
||||
|
||||
The detector and removal mask must use compatible geometry. A detector that
|
||||
fires while producing an empty or misplaced mask is a removal failure even if
|
||||
the detection test passes.
|
||||
|
||||
Yuanbao uses the polarity-independent `contrast` front end because its standard
|
||||
two-line mark can be light on dark scenes or dark on light scenes. Its detector
|
||||
and footprint both use the same best-match box. The separate one-line overlay
|
||||
variant is not covered.
|
||||
|
||||
The capture-less Jimeng pill lives in
|
||||
[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a
|
||||
synthetic silhouette for detection and a fixed top-left footprint.
|
||||
|
||||
Each engine has a corresponding test module under [`tests/`](../tests/).
|
||||
Shared behavior is covered by:
|
||||
|
||||
- [`test_text_mark_engine.py`](../tests/test_text_mark_engine.py)
|
||||
- [`test_text_mark_faint_mask.py`](../tests/test_text_mark_faint_mask.py)
|
||||
- [`test_text_mark_memory.py`](../tests/test_text_mark_memory.py)
|
||||
|
||||
### Fill backends and region erasing
|
||||
|
||||
[`region_eraser.py`](../src/remove_ai_watermarks/region_eraser.py) implements the
|
||||
same backends used by visible removal and the user-directed `erase` command:
|
||||
|
||||
- `cv2`
|
||||
- `migan`
|
||||
- `lama`
|
||||
|
||||
`watermark_registry.resolve_backend` selects LaMa first, then MI-GAN, then
|
||||
OpenCV for `auto`. A memory-constrained caller should explicitly select MI-GAN
|
||||
or OpenCV instead of relying on `auto`.
|
||||
|
||||
MI-GAN and LaMa crop around the mask before model inference and paste back only
|
||||
masked pixels. Their model sessions are loaded lazily. MI-GAN uses the inverse
|
||||
mask polarity expected by its ONNX model.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_region_eraser.py`](../tests/test_region_eraser.py)
|
||||
- [`test_inpaint_fallback.py`](../tests/test_inpaint_fallback.py)
|
||||
|
||||
## Invisible watermark regeneration
|
||||
|
||||
### Profiles and strength
|
||||
|
||||
[`_internal/watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py)
|
||||
is the source of truth for:
|
||||
|
||||
- profile aliases;
|
||||
- default model identifiers;
|
||||
- default steps and seeds;
|
||||
- vendor-adaptive strength resolution;
|
||||
- the minimum viable step calculation.
|
||||
|
||||
The current profiles are `controlnet`, `sdxl`, `qwen`, and `qwen-zimage`.
|
||||
For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
|
||||
mandatory Qwen stage and YuNet while leaving the optional Z-Image and SAM face
|
||||
stack lazy until a face is detected. The default `preload()` still loads every
|
||||
stage.
|
||||
`default` is a legacy alias for `sdxl`. There is no content-dependent automatic
|
||||
router.
|
||||
|
||||
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) handles
|
||||
image sizing, optional pre-upscaling, postprocessing, and the public engine
|
||||
interface. It delegates model execution to
|
||||
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).
|
||||
|
||||
The Python engine and CLI do not have identical defaults for every optional
|
||||
postprocessing argument. Integrations that require reproducibility should pass
|
||||
the relevant values explicitly.
|
||||
|
||||
The standard Qwen and ControlNet prompts are calibrated model inputs, and the
|
||||
ControlNet edge map uses fixed Canny thresholds of 100 and 200. Treat those
|
||||
values as behavioral compatibility contracts: a refactor must preserve them,
|
||||
and any deliberate change requires image-quality evaluation rather than only a
|
||||
unit-test pass. Exact prompt and edge-map regression guards live in
|
||||
`test_platform.py` and `test_invisible_engine.py`.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_watermark_profiles.py`](../tests/test_watermark_profiles.py)
|
||||
- [`test_invisible_engine.py`](../tests/test_invisible_engine.py)
|
||||
- [`test_img2img_runner.py`](../tests/test_img2img_runner.py)
|
||||
- [`test_platform.py`](../tests/test_platform.py)
|
||||
|
||||
### CPU offload
|
||||
|
||||
CPU offload is enabled only when requested on CUDA. The standard Diffusers
|
||||
profiles call `enable_model_cpu_offload`. The `qwen-zimage` profile uses the
|
||||
same flag to force **both** its stacks out of automatic device residency.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM, once per stack:
|
||||
`resolve_global_model_residency` gates the mandatory Qwen stack at
|
||||
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB` and `resolve_face_model_residency` gates the
|
||||
optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.
|
||||
|
||||
Below the global floor, `_qwen_vram_config` streams the stack from disk, which is
|
||||
what makes a 20B model runnable on a consumer card. At or above it, streaming is
|
||||
pure waste and the weights stay on the GPU. The difference is not marginal:
|
||||
DiffSynth offloads by dropping the weights to the meta device and re-reading every
|
||||
parameter through its `DiskMap` on the next onload, and the pipeline moves between
|
||||
text encoder, transformer and VAE on each pass. Measured on an H100 (80 GiB) in
|
||||
August 2026, a warm global pass took 37.3 s at 0.8 GiB resident with the streaming
|
||||
config, against 2.2 s at 28.7 GiB with the stack resident; both stacks resident
|
||||
peaked at 48.0 GiB. Faster storage cannot close that gap, because the cost is the
|
||||
reload itself rather than the read.
|
||||
|
||||
The resident config deliberately passes no `"disk"` value anywhere. DiffSynth latches
|
||||
`disk_offload` once, from `offload_dtype`, so leaving the sentinel in place while
|
||||
pointing every device at CUDA would keep the meta-drop and re-read.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
|
||||
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
|
||||
|
||||
### Qwen plus Z-Image
|
||||
|
||||
[`_internal/qwen_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py)
|
||||
implements the fixed CUDA-only two-stage profile:
|
||||
|
||||
1. Qwen Image with Canny conditioning regenerates the frame.
|
||||
2. YuNet locates faces, SAM builds masks, and Z-Image regenerates the selected
|
||||
face regions.
|
||||
|
||||
The profile rejects a custom model identifier. Its global and face model stack
|
||||
is fixed by the implementation. When tiling is enabled, only the global stage
|
||||
is tiled; the face stage runs once after the tiles are blended.
|
||||
|
||||
#### How the face stage actually composites
|
||||
|
||||
The mechanism is easy to misread from the parameter names, so state it plainly.
|
||||
`_run_faces` crops the expanded box from the **original** image, resizes it toward the
|
||||
768 px face guide, runs Z-Image over **the entire crop**, resizes back, and only then
|
||||
merges through `composite_face`, which cross-fades on a Gaussian-blurred SAM mask with
|
||||
`feather=10`. The base it merges into is the global Qwen result.
|
||||
|
||||
Two consequences follow, and both matter when tuning:
|
||||
|
||||
- **Everything inside the crop is regenerated, including the pixels the mask later
|
||||
discards.** The generation is therefore conditioned on a fully noised neighbourhood,
|
||||
not on an intact one. An alternative design passes the mask into the sampler as a
|
||||
latent noise mask, so only masked pixels are ever denoised and the edge transition
|
||||
happens inside the generation rather than as a post-hoc blend. That approach has
|
||||
never been tried in this runtime and is an open lever, particularly since the face
|
||||
stage is the largest measured quality contributor: removing it costs 3.5 dB inside
|
||||
the face boxes on one fixture and 6.1 dB on another.
|
||||
- **`FACE_DENOISE_SCALE = 0.5` is best understood as compensation for the above.**
|
||||
Regenerating a whole crop and blending is a stronger operation than denoising only
|
||||
inside a mask, so the halved strength brings the visible result back into range. Read
|
||||
it as coupled to the compositing design rather than as an independently calibrated
|
||||
constant: changing the compositing without revisiting the scale would change output
|
||||
strength by roughly a factor of two.
|
||||
|
||||
The maintained implementation preserves the previously oracle-tested strength,
|
||||
conditioning, crop, and sampler parameters as compatibility contracts. Its Python
|
||||
orchestration, YuNet integration, SAM selection, masks, sizing helpers, and pixel
|
||||
compositing are implemented for this runtime. Changing a calibrated model input
|
||||
requires the same provider-oracle and identity evaluation as a model change.
|
||||
|
||||
### SDXL plus Z-Image
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` subclasses
|
||||
`QwenZImagePipeline` and overrides only `_run_global` and `preload`, so the face stage
|
||||
is inherited rather than copied and cannot drift between the profiles; a test asserts
|
||||
the shared methods are the same objects.
|
||||
|
||||
Four things are architecture-bound and swap with the model: the ControlNet
|
||||
(`xinsir/controlnet-canny-sdxl-1.0`), the four-step distillation LoRA
|
||||
(`ByteDance/SDXL-Lightning` at its documented strength 1.0, not the reference graph's
|
||||
0.8, which belongs to a different LoRA), the sampler (Euler with trailing spacing, no
|
||||
AuraFlow shift), and the latent grid (8 px against Qwen's 16).
|
||||
|
||||
**Strength is architecture-bound too, and that is the easy mistake.** An SDXL global
|
||||
pass leaves SynthID at the strength Qwen needs: verified through the Gemini app on a
|
||||
native 2816x1536 original, 0.154 is FOUND while 0.20, 0.25 and 0.30 are clean. So this
|
||||
profile takes a vendor policy (`SDXL_ZIMAGE_OPENAI_STRENGTH` 0.15,
|
||||
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.25, unknown following Gemini) rather than
|
||||
`resolution_adaptive_denoise`. Flat values are what was measured; no size dependence
|
||||
has been established for this stage, so none is asserted.
|
||||
|
||||
`requested_steps` exists because the two runtimes truncate differently. DiffSynth sets
|
||||
`sigma_start = denoising_strength` and runs every requested step across the shortened
|
||||
sigma range; Diffusers img2img truncates the step *count*
|
||||
(`init_timestep = int(steps * strength)`), so asking it for four steps at 0.15 executes
|
||||
**zero** and returns a bare VAE round-trip.
|
||||
|
||||
This profile is not deployed. Before it could be, it needs the other three Gemini
|
||||
originals, OpenAI re-verified at 0.15, a flat-graphic content class, and a low
|
||||
resolution case -- every verdict so far comes from one fixture and one seed.
|
||||
|
||||
### Measured provider boundaries for qwen-zimage
|
||||
|
||||
Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
|
||||
everything it has been tested at:
|
||||
|
||||
| oracle | fixture size | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
|
||||
| Gemini app | 4.33 MP | 0.08 | 0.10 |
|
||||
| Gemini app | 0.57 MP | -- | 0.0896 (the curve's own value) |
|
||||
| Gemini app | 1.40 MP | -- | 0.1066 (the curve's own value) |
|
||||
|
||||
Read the last two rows before concluding the curve's low end is under-driven. Against
|
||||
the 4.33 MP Gemini boundary the sub-1 MP rungs of 0.084-0.094 look short, but at those
|
||||
sizes the curve's own values verify clean, which is what a resolution-scaled
|
||||
requirement would predict. There is no measured size at which the shipped curve fails,
|
||||
so it is left alone.
|
||||
|
||||
### Static prompt embeddings
|
||||
|
||||
Both stages prompt with module constants, and at CFG 1.0 DiffSynth's
|
||||
`PipelineUnitRunner` reuses the positive embedding for the negative side instead of
|
||||
encoding it. So exactly one embedding per stage is ever computed, from text that
|
||||
cannot vary at runtime, which makes it cacheable across containers rather than only
|
||||
within one pipeline.
|
||||
|
||||
`_cache_static_prompt_embeddings` therefore persists what the text encoder produced
|
||||
under `_model_cache_dir()/prompt-embeddings`, keyed by cache version, model id,
|
||||
pipeline output params, and the exact prompt string. Once that file exists,
|
||||
`_load_qwen` and `_load_zimage` drop the text-encoder `ModelConfig` from the model
|
||||
stack entirely and serve the stored tensors instead. Measured on an H100 volume in
|
||||
August 2026, that removes **15.45 GiB** (Qwen2.5-VL) and **7.49 GiB** (Z-Image) of a
|
||||
**87.6 GiB** per-request read, worth a median **11.76 s** and **4.10 s** of load time
|
||||
(paired within five containers). The output is **byte-identical** -- the stored
|
||||
tensors are the encoder's own -- so this needs no provider-oracle re-verification.
|
||||
|
||||
Three properties are load-bearing:
|
||||
|
||||
- **The key self-heals.** A model bump or a prompt edit changes the key, so the next
|
||||
container recomputes rather than reading a stale embedding. `_PROMPT_CACHE_VERSION`
|
||||
covers a change to the stored shape itself.
|
||||
- **The write is atomic.** A torn write must never be readable as a cache hit, so the
|
||||
payload lands in a temp file and is renamed into place.
|
||||
- **A miss after the encoder was dropped raises.** `require_cache` records that the
|
||||
stack was built without a text encoder on the strength of the file; falling back
|
||||
would call a model that is not loaded, which surfaces as an opaque crash.
|
||||
|
||||
`_model_cache_dir()` prefers `HF_HOME` for the same reason: on a scale-to-zero runner
|
||||
that is the only persistently mounted path, and anything below it is re-derived per
|
||||
request. The YuNet download follows the same root.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
|
||||
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
|
||||
|
||||
### Tiling
|
||||
|
||||
[`_internal/tiling.py`](../src/remove_ai_watermarks/_internal/tiling.py) contains pure
|
||||
tile planning, feather weights, tile orchestration, and region compositing.
|
||||
|
||||
Tiling engages only when requested and the long side exceeds the tile size.
|
||||
It avoids an explicit full-image downscale but does not make diffusion
|
||||
pixel-preserving. Each tile is still regenerated.
|
||||
|
||||
`feather_region_composite` changes only the requested box and leaves pixels
|
||||
outside it unchanged.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_tiling.py`](../tests/test_tiling.py)
|
||||
|
||||
### Upscaling and postprocessing
|
||||
|
||||
[`upscaler.py`](../src/remove_ai_watermarks/upscaler.py) is the optional
|
||||
Real-ESRGAN path used only when enlarging a small image to the minimum
|
||||
resolution floor. Failure or an absent extra falls back to Lanczos.
|
||||
|
||||
[`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit
|
||||
grain, unsharp masking, and adaptive polish helpers.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_upscaler.py`](../tests/test_upscaler.py)
|
||||
- [`test_humanizer.py`](../tests/test_humanizer.py)
|
||||
|
||||
## Image input and output
|
||||
|
||||
[`image_io.py`](../src/remove_ai_watermarks/image_io.py) is the shared image
|
||||
codec boundary.
|
||||
|
||||
Contracts:
|
||||
|
||||
- All package OpenCV file reads and writes use `image_io.imread` and
|
||||
`image_io.imwrite`.
|
||||
- `to_bgr` normalizes grayscale and alpha-bearing arrays.
|
||||
- `read_bgr_and_alpha` and `write_bgr_with_alpha` preserve the alpha plane.
|
||||
- `imwrite` returns a success flag; every caller must check it.
|
||||
- HEIC, HEIF, and AVIF pixel reads fall back to Pillow plus `pillow-heif` from
|
||||
the independent `heif` extra. Metadata scanning does not require that plugin.
|
||||
- A visible no-op can preserve the original file bytes.
|
||||
|
||||
Regression coverage:
|
||||
|
||||
- [`test_image_io.py`](../tests/test_image_io.py)
|
||||
- [`test_cli_robustness.py`](../tests/test_cli_robustness.py)
|
||||
|
||||
## Adding or changing behavior
|
||||
|
||||
For a new visible mark:
|
||||
|
||||
1. create a synthetic detection silhouette;
|
||||
2. add or extend a vendor engine;
|
||||
3. add one registry entry;
|
||||
4. test detection, false positives, localization, and actual pixel change;
|
||||
5. update [supported signals](supported-signals.md).
|
||||
|
||||
For a new metadata signal:
|
||||
|
||||
1. add the scanner;
|
||||
2. add every supported removal placement;
|
||||
3. verify the output through `strip_and_verify`;
|
||||
4. add identification and removal tests;
|
||||
5. update [supported signals](supported-signals.md) and, when relevant,
|
||||
[the watermarking landscape](watermarking-landscape.md).
|
||||
|
||||
For a diffusion change:
|
||||
|
||||
1. keep model-free logic in pure helpers where possible;
|
||||
2. test option propagation and dispatch without downloading models;
|
||||
3. run a real model smoke for the changed model path;
|
||||
4. treat provider-verifier results as specific to the exact checked output;
|
||||
5. update [known limitations](known-limitations.md).
|
||||
@@ -0,0 +1,429 @@
|
||||
# Python API
|
||||
|
||||
Use the high level API for normal application integration. Low level detector
|
||||
and pipeline modules are intended for maintainers and specialized workflows.
|
||||
|
||||
Dependency groups are identical for the CLI and Python API. The default install
|
||||
covers metadata extraction, normalization, verdict logic, and stripping.
|
||||
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
|
||||
uses `detect`; diffusion removal uses `diffusion`; and visible video processing
|
||||
uses `video`. Video SynthID removal combines `video` and `diffusion`. Add `heif`
|
||||
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
|
||||
the complete [feature-extra matrix](installation.md#feature-extras).
|
||||
|
||||
## Remove visible marks
|
||||
|
||||
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result, removed = raiw.remove_visible(
|
||||
"watermarked.png",
|
||||
"clean.png",
|
||||
)
|
||||
```
|
||||
|
||||
The function returns:
|
||||
|
||||
- the result as a BGR NumPy array;
|
||||
- a list of labels that were removed.
|
||||
|
||||
An empty `removed` list means that no registered visible mark was selected. It
|
||||
does not prove the image has no metadata or invisible watermark.
|
||||
|
||||
### Path input
|
||||
|
||||
For a path input, `remove_visible`:
|
||||
|
||||
- reads metadata provenance for the default `auto` sensitivity;
|
||||
- preserves a separate alpha channel;
|
||||
- writes the output when an output path is supplied;
|
||||
- strips AI metadata from the written output by default;
|
||||
- preserves the original bytes for a same-format no-op copy.
|
||||
|
||||
```python
|
||||
result, removed = raiw.remove_visible(
|
||||
"watermarked.png",
|
||||
"clean.png",
|
||||
sensitivity="auto",
|
||||
backend="auto",
|
||||
strip_metadata=True,
|
||||
)
|
||||
```
|
||||
|
||||
Set `write_noop=False` if the output path must remain untouched when nothing is
|
||||
removed:
|
||||
|
||||
```python
|
||||
result, removed = raiw.remove_visible(
|
||||
"input.png",
|
||||
"clean.png",
|
||||
write_noop=False,
|
||||
)
|
||||
```
|
||||
|
||||
### Array input
|
||||
|
||||
Array inputs are BGR NumPy arrays. They do not carry file metadata or a separate
|
||||
alpha plane:
|
||||
|
||||
```python
|
||||
import cv2
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
image = cv2.imread("input.png")
|
||||
result, removed = raiw.remove_visible(image, backend="cv2")
|
||||
```
|
||||
|
||||
## Inspect provenance
|
||||
|
||||
The default installation evaluates file metadata. Add `visible`, `detect`, or
|
||||
`trustmark` to enable the corresponding optional pixel signals.
|
||||
|
||||
Get the vendor keys used by visible removal:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
vendors = raiw.visible_provenance("input.png")
|
||||
```
|
||||
|
||||
Get the full provenance report:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
report = identify(Path("input.png"))
|
||||
print(report.platform)
|
||||
print(report.signals)
|
||||
```
|
||||
|
||||
Use `check_visible=False` and `check_invisible=False` for metadata-only
|
||||
inspection through the compatible path-based API:
|
||||
|
||||
```python
|
||||
report = identify(
|
||||
Path("input.png"),
|
||||
check_visible=False,
|
||||
check_invisible=False,
|
||||
)
|
||||
```
|
||||
|
||||
Extraction and detection are also available as separate steps. This is useful
|
||||
when a file-reading worker collects the metadata once and another component
|
||||
evaluates the resulting evidence:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import (
|
||||
extract_provenance_evidence,
|
||||
identify_from_evidence,
|
||||
)
|
||||
|
||||
evidence = extract_provenance_evidence(Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
If metadata was collected by another component, normalize its nested record
|
||||
without reopening the original file:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import (
|
||||
evidence_from_metadata_record,
|
||||
identify_from_evidence,
|
||||
)
|
||||
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {"0th": {"Software": "Stable Diffusion"}},
|
||||
}
|
||||
evidence = evidence_from_metadata_record(record, path=Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
The normalizer recursively preserves text and byte values. It also decodes
|
||||
strings prefixed with `hex:` and fields named `base64` or ending in
|
||||
`_base64`. Diagnostic values under `error` and `kind` are ignored because they
|
||||
describe the collector rather than the source file. Pass a C2PA manifest-store
|
||||
dictionary in `record["c2pa_store"]`, or through the explicit
|
||||
`c2pa_manifest_store` argument.
|
||||
|
||||
`identify_from_evidence` does not reopen the source file. It evaluates metadata
|
||||
only; registered visible marks and pixel-backed invisible watermarks remain in
|
||||
the path-based `identify` call.
|
||||
|
||||
## Strip metadata
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata, strip_and_verify
|
||||
|
||||
source = Path("input.png")
|
||||
output = Path("clean.png")
|
||||
|
||||
if has_ai_metadata(source):
|
||||
output_path, surviving_markers = strip_and_verify(source, output)
|
||||
if surviving_markers:
|
||||
raise RuntimeError(
|
||||
f"AI metadata remains in {output_path}: {surviving_markers}"
|
||||
)
|
||||
```
|
||||
|
||||
Use `strip_and_verify` when your application reports that stripping succeeded.
|
||||
It checks the written output and returns `(output_path, surviving_markers)`.
|
||||
When the first strip leaves markers in a malformed but raster-decodable image,
|
||||
it normalizes the container through `image_io` and checks again. That recovery
|
||||
path preserves the pixels but drops standard metadata. Treat a nonempty
|
||||
`surviving_markers` mapping as a failure.
|
||||
|
||||
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
|
||||
undecodable input through unchanged, so its return alone must not be presented
|
||||
as proof that metadata was removed.
|
||||
|
||||
## Identify and clean video
|
||||
|
||||
The high level video API supports MP4, MOV, M4V, WebM, MKV, AVI, and FLV:
|
||||
metadata-only calls work with the default install, while visible identification,
|
||||
removal, and the complete pipeline require `remove-ai-watermarks[video]`.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
report = raiw.identify_video("input.mp4")
|
||||
print(report.is_ai_generated)
|
||||
print(report.platform)
|
||||
print(report.visible_mark)
|
||||
print(report.metadata_markers)
|
||||
```
|
||||
|
||||
`identify_video` uses the same full-clip temporal arbiter as visible removal.
|
||||
It reports a recurring registered mark and supported AI metadata as positive
|
||||
signals. When neither is present, `is_ai_generated` is `None`, never `False`.
|
||||
The absence of a public local video SynthID decoder is included in `caveats`.
|
||||
Pass `check_visible=False` for a bounded metadata-only inspection.
|
||||
|
||||
For normal product integration, use the complete locally verifiable pipeline:
|
||||
|
||||
```python
|
||||
result = raiw.remove_video_all("input.mp4", "clean.mp4")
|
||||
if result.remaining_metadata:
|
||||
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
|
||||
```
|
||||
|
||||
The default removes one stable supported visible provider mark when present,
|
||||
always strips verified AI metadata, and writes a same-container output even
|
||||
when neither signal is found. This gives callers one predictable output path.
|
||||
It does not run lossy invisible regeneration by default.
|
||||
|
||||
`include_invisible=True` explicitly adds VAE regeneration for MP4, MOV, or M4V.
|
||||
`VideoAllResult.invisible_removed` reports whether the oracle-certified SynthID
|
||||
stage ran.
|
||||
|
||||
Process a top-level directory sequentially:
|
||||
|
||||
```python
|
||||
batch = raiw.remove_video_batch("videos", "videos_clean", mode="all")
|
||||
if batch.failed:
|
||||
for item in batch.items:
|
||||
if item.error:
|
||||
print(item.source, item.error)
|
||||
```
|
||||
|
||||
Batch modes are `all`, `visible`, and `metadata`. Successful visible no-ops are
|
||||
copied byte-for-byte, keeping the output directory complete. Per-file failures
|
||||
are returned in `VideoBatchItem.error`; they do not discard successful outputs.
|
||||
The invisible stage is available only as an explicit opt-in in `all` mode and
|
||||
reuses one loaded VAE runtime across the batch.
|
||||
|
||||
## Inspect and strip video metadata
|
||||
|
||||
Metadata inspection and removal use the same supported video containers:
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
report = raiw.inspect_video_metadata("input.mp4")
|
||||
if report.has_ai_metadata:
|
||||
result = raiw.remove_video_metadata("input.mp4")
|
||||
if result.remaining:
|
||||
raise RuntimeError(f"AI metadata remains: {result.remaining}")
|
||||
```
|
||||
|
||||
`remove_video_metadata` does not transcode video or audio streams. Its default
|
||||
output is `input_clean.mp4`, leaving the source untouched. An explicit output
|
||||
must use the same container extension as the source.
|
||||
|
||||
The returned `VideoMetadataResult` records the source, output, metadata detected
|
||||
before removal, and any markers remaining after the verified strip. MP4/MOV
|
||||
inspection recognizes the native TC260 `AIGC` entry in
|
||||
`moov.udta.meta.keys/ilst`; its removal preserves container size and encoded
|
||||
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
|
||||
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
|
||||
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
|
||||
stream-copy remux. AVI inspection reads `LIST/INFO/AIGC`, and FLV inspection
|
||||
reads `script.onMetaData.AIGC`; both use the same verified ffmpeg stream-copy
|
||||
removal path.
|
||||
|
||||
## Remove video SynthID
|
||||
|
||||
Install `remove-ai-watermarks[video,diffusion]` before using the video SynthID
|
||||
API.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.remove_video_invisible(
|
||||
"input.mp4",
|
||||
"clean.mp4",
|
||||
device="auto",
|
||||
)
|
||||
if result.remaining_metadata:
|
||||
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
|
||||
```
|
||||
|
||||
`remove_video_invisible` supports MP4, MOV, and M4V. It regenerates the complete
|
||||
video through a VAE in bounded batches, shares one seeded latent-noise field
|
||||
across all frames, streams pixels to ffmpeg, copies complete audio, strips
|
||||
source metadata, and publishes atomically. The default output is
|
||||
`input_clean.mp4`; a distinct same-container output is required.
|
||||
|
||||
The returned `VideoInvisibleResult` includes output geometry, frame rate, frame
|
||||
count, paired PSNR, and the motion-compensated temporal-residual ratio. Those
|
||||
fields measure fidelity and flicker only. They are not a SynthID detector.
|
||||
The default `noise_std=0.15` is the current full-clip oracle floor; `0.10`
|
||||
remained detected on the public eight-second Veo calibration carrier.
|
||||
The default profile is oracle-certified. Google does not publish a local
|
||||
decoder for this video payload, so a fresh source-positive, output-negative
|
||||
pair from Gemini's built-in SynthID verifier remains an optional per-file audit.
|
||||
A response inferred from a visible logo or metadata is not such a verdict, and
|
||||
an adversarial follow-up asking ordinary Gemini to reinterpret the verifier is
|
||||
not a second oracle run.
|
||||
|
||||
## Remove a supported visible video mark
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.remove_video_visible(
|
||||
"input.mp4",
|
||||
"clean.mp4",
|
||||
backend="cv2",
|
||||
strip_metadata=True,
|
||||
temporal_consistency=True,
|
||||
)
|
||||
if result.output is None:
|
||||
print("No temporally stable supported mark was found")
|
||||
else:
|
||||
print(result.mark)
|
||||
|
||||
veo_result = raiw.remove_video_visible(
|
||||
"veo.mp4",
|
||||
"veo_clean.mp4",
|
||||
mark="veo",
|
||||
)
|
||||
seedance_result = raiw.remove_video_visible(
|
||||
"seedance.mp4",
|
||||
"seedance_clean.mp4",
|
||||
mark="seedance",
|
||||
)
|
||||
dola_result = raiw.remove_video_visible(
|
||||
"dola.mp4",
|
||||
"dola_clean.mp4",
|
||||
mark="dola",
|
||||
)
|
||||
hailuo_result = raiw.remove_video_visible(
|
||||
"hailuo.mp4",
|
||||
"hailuo_clean.mp4",
|
||||
mark="hailuo",
|
||||
)
|
||||
kling_result = raiw.remove_video_visible(
|
||||
"kling.mp4",
|
||||
"kling_clean.mp4",
|
||||
mark="kling",
|
||||
)
|
||||
```
|
||||
|
||||
`remove_video_visible` scans the complete video before writing output. It
|
||||
combines synthetic multi-scale visual matching with temporal consistency, so an
|
||||
isolated lookalike in one frame is not enough to authorize inpainting.
|
||||
`mark="auto"` is the default: it evaluates all providers in one decode pass and
|
||||
selects the first stable match in specificity order (`sora`, `veo`, `seedance`,
|
||||
`dola`, `hailuo`, `kling`). Provider confidence values are calibrated
|
||||
independently and are not compared across detectors. Pass one of those explicit
|
||||
values to restrict the scan to a single provider. The Veo detector recognizes
|
||||
the current four-point diamond and the
|
||||
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
|
||||
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
|
||||
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
|
||||
variant has an independent synthetic silhouette and calibrated temporal policy.
|
||||
After each accepted frame is filled, `temporal_consistency=True` motion-aligns
|
||||
the preceding accepted fill and blends it only when the warped prior mask
|
||||
covers the current mask and a surrounding source-context ring agrees. Scene
|
||||
cuts and disjoint masks keep the independent current fill. Pass
|
||||
`temporal_consistency=False` for the frame-local baseline.
|
||||
|
||||
The returned `VideoVisibleResult` records the selected `mark`, the total,
|
||||
detected, and removed frame counts, plus any AI metadata that survived the
|
||||
output encode. The function returns `output=None` and writes no file when no
|
||||
stable mark is selected. Video pixels are transcoded through ffmpeg while the
|
||||
complete source audio stream is copied. The encoder preserves supported 8-bit
|
||||
source chroma sampling, color tags, MP4/MOV track timescale, and relative
|
||||
variable-frame timestamps. It also retains a non-zero source start PTS and the
|
||||
copied audio offset. A failed encode preserves any existing output; only a
|
||||
completed result is published atomically.
|
||||
SDR 8-bit video is the supported pixel contract. High-bit-depth, PQ, and HLG
|
||||
sources raise `RuntimeError` before encoding instead of being silently reduced
|
||||
to 8-bit SDR.
|
||||
|
||||
## Remove invisible watermarks
|
||||
|
||||
Install `remove-ai-watermarks[diffusion]` for the standard pipelines or
|
||||
`remove-ai-watermarks[qwen-zimage]` for the CUDA-only high-fidelity profile.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
engine = InvisibleEngine(
|
||||
pipeline="controlnet",
|
||||
device=None,
|
||||
cpu_offload=False,
|
||||
)
|
||||
|
||||
engine.remove_watermark(
|
||||
Path("watermarked.png"),
|
||||
Path("clean.png"),
|
||||
)
|
||||
```
|
||||
|
||||
`device=None` selects the device automatically. Supported explicit values are
|
||||
defined by the CLI and runtime device resolver.
|
||||
|
||||
For limited CUDA memory:
|
||||
|
||||
```python
|
||||
engine = InvisibleEngine(
|
||||
pipeline="controlnet",
|
||||
cpu_offload=True,
|
||||
)
|
||||
```
|
||||
|
||||
For the CUDA only high fidelity profile:
|
||||
|
||||
```python
|
||||
engine = InvisibleEngine(pipeline="qwen-zimage")
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra must be installed for that profile.
|
||||
|
||||
The full `remove_watermark` signature includes strength, steps, guidance,
|
||||
seeding, tiling, resolution, upscaling, and postprocessing controls. Read the
|
||||
method signature in
|
||||
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use
|
||||
the CLI guide for the concepts.
|
||||
Defaults can differ between the Python method and CLI profile resolution, so
|
||||
pass values explicitly when reproducibility matters.
|
||||
@@ -0,0 +1,270 @@
|
||||
# Qwen-Image improvement research (2026-06-20)
|
||||
|
||||
> Research archive. This page records experiments and decisions from the date
|
||||
> above. It may mention prototypes or defaults that were later changed. Use the
|
||||
> user guides and current source code for the supported interface.
|
||||
|
||||
Cited research behind the decision **"ship the `qwen` pipeline as-is, or improve it
|
||||
first?"** Produced by the multi-source deep-research harness (5 search angles, 22
|
||||
sources fetched, 85 claims extracted, 25 verified by a 3-vote adversarial check, 20
|
||||
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. At research time none of the improvements had measured face-fidelity
|
||||
numbers at our scrub floors. The later `qwen-zimage` follow-up below adds a crowded
|
||||
fixture, two direct upstream comparisons, and a provider-oracle-negative final candidate.
|
||||
A broader seeded text/face matrix is still needed for general certification.
|
||||
|
||||
## 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 broad text fidelity
|
||||
remain unmeasured. The later `qwen-zimage` follow-up provides direct face metrics.
|
||||
- **[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 single-pass `qwen` pipeline:** faces stay on SDXL+controlnet; Canny alone is not
|
||||
a Qwen face fix. The next distinct architecture was Z-Image-Turbo on original masked face
|
||||
crops, not another Qwen geometry conditioner.
|
||||
|
||||
**Implementation follow-up (2026-07-24, revised 2026-07-31):** an early experimental
|
||||
`qwen-zimage` prototype reproduced a broad two-stage shape demonstrated by a public
|
||||
experiment: structure-guided Qwen regeneration followed by masked Z-Image face
|
||||
refinement. The maintained profile was subsequently
|
||||
rewritten with project-owned prompts, adaptive strength and sizing policies, YuNet face
|
||||
detection, SAM selection, masks, and compositing. It does not include the upstream workflow
|
||||
JSON or source code. The upstream result supplied by the user was Gemini-oracle negative.
|
||||
|
||||
The first exact-path Modal run completed without SAM fallback or face seams. On one crowded
|
||||
18-face `gemini_3` fixture, ArcFace identity improved materially over controlnet
|
||||
(0.795 vs 0.587/0.588 raw/polished), while face LPIPS was 0.082 vs 0.087/0.078.
|
||||
Two official upstream before/after pairs then reproduced the same identity advantage:
|
||||
local `qwen-zimage` scored 0.950/0.947 ArcFace identity versus polished ControlNet's
|
||||
0.701/0.548. The published upstream outputs remained slightly higher at 0.976/0.976.
|
||||
On the matched-size group example, local face LPIPS essentially matched upstream
|
||||
(0.015 vs 0.014) while whole-image LPIPS and SSIM were better (0.085/0.896 vs
|
||||
0.111/0.777). ControlNet retained more texture but changed the identities.
|
||||
|
||||
That comparison also caught a real /16 alignment defect: DiffSynth dimensions were floored
|
||||
without resizing the corresponding PIL input. The official non-grid input failed with a
|
||||
VAE/noise-grid shape mismatch. Regression assertions were observed failing before global
|
||||
and face pixels were aligned to the same grid as their explicit dimensions.
|
||||
|
||||
The final verifier follow-up is positive but exact-output scoped. On 2026-07-25 the user
|
||||
checked all six current outputs in the provider-separated
|
||||
`full-clean-final-candidate-2026-07-25-by-oracle` bundle with the corresponding provider
|
||||
oracles and confirmed that none retained SynthID or the provider generation signal. The
|
||||
checked bytes used the complete `visible -> qwen-zimage -> metadata` route, the calibrated
|
||||
YuNet 0.5 gate, and the shipped prompt-cache/model-residency optimizations. This supersedes
|
||||
the earlier prototype batch check as the release-candidate result, but it is not a
|
||||
certification across seeds, resolutions, and content classes. YuNet's threshold was
|
||||
calibrated independently from upstream YOLO: 0.5 retained the visible faces in the
|
||||
comparison fixtures while removing the false and duplicate boxes admitted by the copied
|
||||
0.2 gate. The remaining questions are now narrower: how well does text hold across a
|
||||
broader set and how stable removal is across seeds and Google content types. See
|
||||
`docs/known-limitations.md`.
|
||||
|
||||
The shipped profile therefore resolves an omitted seed to `0`; explicit seeds still
|
||||
override it, and every other pipeline keeps its existing random default. This makes the
|
||||
documented `--pipeline qwen-zimage` command reproduce the seed condition used for the
|
||||
release-candidate oracle evidence without claiming cross-seed certification.
|
||||
|
||||
**Product recommendation:** keep ControlNet as the default because it is much cheaper and
|
||||
supports CUDA, XPU, MPS, and CPU. Treat it as the compatibility baseline, not the
|
||||
highest-fidelity result. When CUDA is available and visual quality matters more than
|
||||
latency or cost, recommend `qwen-zimage`, especially for face-heavy content. This
|
||||
recommendation is based on the measured identity advantage and the oracle-negative exact
|
||||
candidate; it does not upgrade that exact-output result into broad certification.
|
||||
|
||||
**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 external claim provides measured face-fidelity numbers (ArcFace/LPIPS/Laplacian) for
|
||||
any recommended intervention at the project's scrub floors. The later direct
|
||||
`qwen-zimage` comparisons are the project's own measurements, not external evidence or
|
||||
a certification.
|
||||
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.
|
||||
- Head-to-head: does `qwen-zimage` retain its measured ArcFace gain across more portraits
|
||||
and mixed scenes, match Qwen's text advantage (CJK+Cyrillic CER), and clear SynthID
|
||||
robustly across content types and seeds?
|
||||
- Can YuNet's serial face workload be bounded to foreground/relevant faces without losing the
|
||||
small faces the upstream YOLO path would process?
|
||||
|
||||
## 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,108 @@
|
||||
# Release and distribution
|
||||
|
||||
This page describes the release behavior defined in this repository. External
|
||||
registry state can change independently, so verify it during a release.
|
||||
|
||||
## Release sources of truth
|
||||
|
||||
The package version appears in:
|
||||
|
||||
- `pyproject.toml`;
|
||||
- `src/remove_ai_watermarks/__init__.py`;
|
||||
- the root package entry generated in `uv.lock`.
|
||||
|
||||
Update the first two, then refresh the lock file with uv. Do not edit a
|
||||
line-number-specific location in `uv.lock`; its package order changes.
|
||||
|
||||
## Publish flow
|
||||
|
||||
PyPI publishing is triggered by a published GitHub Release, not by a tag push
|
||||
alone.
|
||||
|
||||
The expected sequence:
|
||||
|
||||
1. update the version sources and lock file;
|
||||
2. run the complete project gate;
|
||||
3. commit the release change;
|
||||
4. create an annotated `vX.Y.Z` tag;
|
||||
5. push the commit and tag;
|
||||
6. publish the GitHub Release.
|
||||
|
||||
`.github/workflows/publish.yml` then:
|
||||
|
||||
1. checks that the release tag matches `pyproject.toml`;
|
||||
2. builds the package with uv;
|
||||
3. publishes with `uv publish` through PyPI trusted publishing.
|
||||
|
||||
The workflow uses GitHub OIDC through the `pypi` environment. It does not read a
|
||||
PyPI API token from the repository.
|
||||
|
||||
## Post-release distribution
|
||||
|
||||
`.github/workflows/distribute.yml` runs on the same published-release event. It
|
||||
waits for the matching source distribution to appear on PyPI, then:
|
||||
|
||||
- updates the Homebrew tap formula URL and SHA-256;
|
||||
- updates the repository conda recipe version and source-distribution SHA-256;
|
||||
- triggers a factory rebuild of the Hugging Face Space.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
Conda-forge updates are outside this workflow.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
|
||||
manual Homebrew formula update is the fallback when its automation is blocked.
|
||||
|
||||
The conda job uses the published artifact rather than a locally built archive
|
||||
as the hash source and commits the resulting recipe change to `main`. Runtime
|
||||
dependency mapping remains review-controlled: keep it aligned with the default
|
||||
metadata dependencies in `pyproject.toml`, do not copy optional pixel extras
|
||||
into the default recipe, and document any conda-forge package that is
|
||||
unavailable and must be omitted.
|
||||
The optional `video` extra carries PyAV with Python-version-specific bounds; it
|
||||
does not belong in the default metadata-focused conda recipe.
|
||||
|
||||
## Source distribution boundary
|
||||
|
||||
The wheel includes the package under `src/`.
|
||||
|
||||
The source distribution uses an explicit allowlist for `/src`, `/LICENSE`,
|
||||
`/README.md`, and `/pyproject.toml` through
|
||||
`[tool.hatch.build.targets.sdist]` in `pyproject.toml`. It also defensively
|
||||
excludes `/data`, `/tmp`, and `/.sc`. Keep both controls: calibration captures,
|
||||
test corpora, generated research outputs, and local session state do not belong
|
||||
in the published package archive. The matching local-root entries in
|
||||
`.gitignore` prevent accidental commits, but are not a substitute for the build
|
||||
boundary because hatchling may include untracked files.
|
||||
|
||||
## Build backend
|
||||
|
||||
The package uses hatchling through the unpinned `hatchling` build requirement in
|
||||
`pyproject.toml`. Uploading uses uv rather than the older twine-based action.
|
||||
|
||||
## Other channels
|
||||
|
||||
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
|
||||
|
||||
The ComfyUI nodes are maintained and versioned in their own repository. After
|
||||
the matching source distribution appears on PyPI, `distribute.yml` dispatches
|
||||
that repository's sync workflow with the exact library version and waits for it
|
||||
to finish. The sync updates the dependency floor, runs compatibility tests,
|
||||
bumps the node patch version, and publishes to the ComfyUI Registry only when
|
||||
those tests pass. Its daily schedule remains as a recovery path if a release
|
||||
dispatch is interrupted. The `COMFYUI_RELEASE_TOKEN` repository secret is a
|
||||
fine-grained token limited to the ComfyUI node repository, with Actions read and
|
||||
write access.
|
||||
|
||||
## Release verification
|
||||
|
||||
After publication, verify:
|
||||
|
||||
- both wheel and source distribution exist on PyPI;
|
||||
- the package version matches the tag;
|
||||
- the Homebrew formula points to the new source distribution;
|
||||
- the distribution workflow completed successfully;
|
||||
- the repository's conda recipe matches the published version and source
|
||||
distribution;
|
||||
- the ComfyUI Registry node requires the new library version;
|
||||
- a clean install can run `remove-ai-watermarks --version`.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
|
||||
|
||||
> Research archive. Reverse-alpha pixel recovery is no longer part of the
|
||||
> current visible-removal pipeline. The current implementation uses
|
||||
> localize-then-fill; see `docs/module-internals.md`.
|
||||
|
||||
> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded
|
||||
> context small. Long single-line entries were reformatted into paragraphs;
|
||||
> no content was changed or summarized.
|
||||
|
||||
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha was built.**
|
||||
That historical method, `doubao_engine.remove_watermark_reverse_alpha`, has since
|
||||
been removed. Its detection silhouette remains at
|
||||
`src/remove_ai_watermarks/assets/doubao_alpha.png`. The committed captures in
|
||||
`data/calibration/doubao/` confirmed the alpha-composite model: on black
|
||||
`captured = a*logo`, logo pure white.
|
||||
|
||||
**UPDATE 2026-05-31 (issue #13 follow-up): the first build was NOT "exact"** — it left a readable "豆包AI生成" outline on the real sample (the detector was fooled, conf 0.0). The alpha is now rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve shared with Jimeng), removal always-aligns + thin-inpaints, and the locate box was widened; see the `doubao_engine.py` section in `docs/module-internals.md`. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary.
|
||||
|
||||
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.**
|
||||
|
||||
Curate same-resolution originals with `DoubaoEngine.detect` and an NCC filter against
|
||||
a clean glyph template, keeping only aligned marks. Even with aligned inputs,
|
||||
LaMa-clean `O` plus weighted least squares and per-pixel regression for `α` and logo
|
||||
color still leaves a persistent ghost outline.
|
||||
|
||||
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into color noise.
|
||||
|
||||
**Why Gemini's engine is clean: its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in gray on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. The retained black and gray outputs live in `data/calibration/doubao/`; local solid-color seeds are regenerable and are not committed.
|
||||
|
||||
**Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.**
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Supported signals
|
||||
|
||||
This page describes the current support boundary. A check mark means that the
|
||||
repository contains a corresponding code path. It does not guarantee detection
|
||||
or removal on every future vendor version.
|
||||
|
||||
## Visible marks
|
||||
|
||||
The `visible` command registers these mark keys:
|
||||
|
||||
| Key | Mark | Expected area | Important limit |
|
||||
| --- | --- | --- | --- |
|
||||
| `gemini` | Google Gemini sparkle | Usually bottom right | Detection includes a false positive gate. |
|
||||
| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. |
|
||||
| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. |
|
||||
| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. |
|
||||
| `kling` | `可灵AI 3.0` | Bottom right | Only calibrated variants are covered. |
|
||||
| `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. |
|
||||
| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. |
|
||||
| `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. |
|
||||
| `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. |
|
||||
| `liblib` | `LibLibAI` | Bottom center | Includes a minimum image size gate. |
|
||||
| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. |
|
||||
|
||||
`--mark auto` evaluates all registered marks and removes every selected match.
|
||||
Known marks are localized to a mask, then the selected fill backend reconstructs
|
||||
the masked area.
|
||||
|
||||
Marks from other vendors are not detected automatically. Use `erase --region`
|
||||
when you can select the affected area yourself.
|
||||
|
||||
### Visible video marks
|
||||
|
||||
| Key | Mark | Motion | Important limit |
|
||||
| --- | --- | --- | --- |
|
||||
| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. |
|
||||
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
|
||||
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
|
||||
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
|
||||
| `hailuo` | `MINIMAX | hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. |
|
||||
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
|
||||
|
||||
`video identify`, `video visible`, and `video all` share this registry and the
|
||||
same temporal arbiter. It is separate from the image registry because selection
|
||||
is made over a sequence rather than one raster. The default `auto` mode scans
|
||||
all six entries in one decode pass and selects the first temporally stable
|
||||
match in table order; an explicit mark restricts the scan to that row.
|
||||
Accepted fills are motion-aligned across adjacent frames by default. The prior
|
||||
fill contributes only where its warped mask covers the current removal mask and
|
||||
nearby source context agrees. Scene cuts or disjoint marks retain the
|
||||
independent frame fill.
|
||||
|
||||
## Fill backends
|
||||
|
||||
| Backend | Install | Behavior |
|
||||
| --- | --- | --- |
|
||||
| `cv2` | `remove-ai-watermarks[visible]` | Classical OpenCV inpainting |
|
||||
| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime; practical learned CPU video tier |
|
||||
| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime; offline video quality tier |
|
||||
| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV |
|
||||
|
||||
The learned backends download model files on first use.
|
||||
|
||||
## Metadata and provenance
|
||||
|
||||
The inspection and stripping code handles signals in these groups:
|
||||
|
||||
- C2PA Content Credentials and supported cloud manifest references;
|
||||
- EXIF and XMP generator fields;
|
||||
- IPTC AI disclosure fields;
|
||||
- PNG text chunks and embedded generation parameters;
|
||||
- China TC260 AIGC labels in supported image placements and the normative
|
||||
MP4/MOV `moov.udta.meta.keys/ilst`, MKV/WebM
|
||||
`Segment.Tags.Tag.SimpleTag`, AVI `LIST/INFO/AIGC`, and FLV
|
||||
`script.onMetaData.AIGC` placements;
|
||||
- xAI and Grok EXIF signature fields;
|
||||
- Samsung AI editing markers;
|
||||
- Hugging Face job metadata;
|
||||
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
|
||||
- Adobe TrustMark with the `trustmark` extra.
|
||||
|
||||
`identify` combines detected signals into a `ProvenanceReport`. It reports
|
||||
unknown when evidence is absent. It never treats missing metadata as proof that
|
||||
an image is human made.
|
||||
|
||||
## File and container formats
|
||||
|
||||
Pixel based image commands discover these extensions:
|
||||
|
||||
- PNG;
|
||||
- JPEG;
|
||||
- WebP;
|
||||
- HEIC and HEIF;
|
||||
- AVIF.
|
||||
|
||||
HEIC, HEIF, and AVIF pixel decoding requires the independent `heif` extra in
|
||||
addition to the selected pixel feature. Metadata scanning does not.
|
||||
|
||||
Metadata inspection and removal additionally have container paths for:
|
||||
|
||||
- JPEG XL metadata;
|
||||
- MP4, MOV, M4V, and M4A;
|
||||
- WebM, MKV, MKA, AVI, FLV, MP3, WAV, FLAC, OGG, OGA, Opus, and AAC when
|
||||
ffmpeg is available.
|
||||
|
||||
JPEG image metadata stripping removes targeted metadata segments without
|
||||
re-encoding the entropy coded image scan. PNG and WebP removal preserves pixel
|
||||
values through lossless output paths. HEIC, HEIF, AVIF, and other containers
|
||||
use their format specific paths.
|
||||
|
||||
## Invisible watermarks
|
||||
|
||||
The `invisible` command uses diffusion regeneration. It targets watermark
|
||||
patterns by changing the image rather than decoding and deleting a known
|
||||
payload.
|
||||
|
||||
Current pipeline values:
|
||||
|
||||
- `controlnet`;
|
||||
- `sdxl`;
|
||||
- `qwen`;
|
||||
- `qwen-zimage`;
|
||||
- legacy alias `default`, which resolves to `sdxl`.
|
||||
|
||||
SynthID does not have a public local pixel decoder in this project. The tool can
|
||||
infer likely presence from supported provenance metadata, but after that
|
||||
metadata is removed a local negative result is inconclusive.
|
||||
|
||||
For MP4, MOV, and M4V, `video invisible` or the explicit
|
||||
`video all --invisible` option can regenerate the video through a VAE and strip
|
||||
source metadata. The shipped profile is oracle-certified, but it is not a local
|
||||
decoder. A fresh source-positive, output-negative pair from Gemini's built-in
|
||||
SynthID verifier is an optional per-file audit. A normal Gemini answer may instead
|
||||
infer from a visible logo or metadata; asking it to reinterpret a completed
|
||||
verifier result is not a second oracle run.
|
||||
|
||||
The optional `detect` extra is different: it provides a local decoder for the
|
||||
open DWT-DCT watermark used by some Stable Diffusion, SDXL, and FLUX workflows.
|
||||
That signal is carrier and transformation sensitive, so a negative is still
|
||||
not a universal clean verdict.
|
||||
|
||||
## Provider overview
|
||||
|
||||
| Provider or family | Visible | Invisible path | Metadata or provenance |
|
||||
| --- | --- | --- | --- |
|
||||
| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals |
|
||||
| Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals |
|
||||
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
|
||||
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
|
||||
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
|
||||
| Adobe Firefly | None registered | No proprietary local decoder | C2PA; optional TrustMark decoder |
|
||||
| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC and supported C2PA signals |
|
||||
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
|
||||
| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC |
|
||||
| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC |
|
||||
| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers |
|
||||
|
||||
For detector thresholds, measured limits, and incident history, see
|
||||
[module internals](module-internals.md) and
|
||||
[known limitations](known-limitations.md).
|
||||
@@ -0,0 +1,188 @@
|
||||
# Deep research: SynthID-safe face-identity recovery for SDXL (2026-06-08)
|
||||
|
||||
> Research archive. This dated follow-up records evidence available during the
|
||||
> study. It is not the current command reference.
|
||||
|
||||
**Stats:** {"angles": 6, "sourcesFetched": 28, "claimsExtracted": 104, "claimsVerified": 25, "confirmed": 19, "killed": 6, "afterSynthesis": 6, "urlDupes": 1, "budgetDropped": 7, "agentCalls": 111}
|
||||
|
||||
## Summary
|
||||
|
||||
For SDXL-based SynthID-removal pipelines, the entire ArcFace-class identity-adapter ecosystem (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) is blocked from commercial use by a single chokepoint: InsightFace's pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are explicitly non-commercial research-only, regardless of the adapter weights' Apache-2.0/MIT license. The InstantX maintainers themselves acknowledged this on HuggingFace ("cannot be Apache 2.0 if it is using Insight Face") and stated intent to retrain on commercially-licensed embedders — work that as of the verified sources has not shipped. The only verified commercial-safe SDXL adapter surfaced is MS-Diffusion (ICLR 2025), which uses CLIP-ViT-bigG-14 instead of ArcFace and runs on SDXL-base-1.0, but it is an IP-Adapter-class subject-similarity method, not a face-identity method — CLIP image embeddings are empirically weaker for face identity than ArcFace (~81% vs ~88% face-ID accuracy), so it solves the license problem but likely not the identity-fidelity problem. Arc2Face is SD1.5-only and so not a drop-in regardless of license; StyleGAN2-based methods (e4e) terminate in a GAN generator with no SDXL wiring. Net: no fully commercial-safe SynthID-robust SDXL identity-preservation stack with ArcFace-grade fidelity exists today — the space is genuinely blocked by InsightFace's grip on commercial ArcFace embeddings, and AdaFace as a permissive ArcFace alternative was REFUTED in verification.
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. The InsightFace pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are non-commercial research-only and this restriction propagates at runtime to any adapter that calls FaceAnalysis(), regardless of the adapter's own license tag.
|
||||
|
||||
**Confidence:** high
|
||||
**Vote:** 3-0 unanimous across 4 supporting claims
|
||||
|
||||
|
||||
InsightFace upstream: code is MIT but 'The training data containing the annotation (and the models trained with these data) are available for non-commercial research purposes only.' Licensing page enumerates buffalo_l, antelopev2, buffalo_s, buffalo_m as needing separate commercial licensing. InstantX maintainer on HF: 'cannot be Apache 2.0 if it is using Insight Face... we plan to train on other face encoders that support commercial license.' Mechanical propagation: the runtime FaceAnalysis() call pulls these packs.
|
||||
|
||||
- https://github.com/deepinsight/insightface
|
||||
- https://www.insightface.ai/solutions/face-recognition-licensing
|
||||
- https://huggingface.co/InstantX/InstantID/discussions/2
|
||||
|
||||
### 2. InstantID is Apache-2.0 on the adapter weights and architecturally a clean plugin to SDXL-base-1.0 (no UNet training, semantic IdentityNet conditioning + landmark weak-spatial, no pixel copying) — but at runtime it instantiates FaceAnalysis(name='antelopev2'), inheriting the InsightFace non-commercial restriction.
|
||||
|
||||
**Confidence:** high
|
||||
**Vote:** 3-0 across 5 claims
|
||||
|
||||
|
||||
HF model card: 'License: apache-2.0' AND 'For face encoder, you need to manutally download via this URL to models/antelopev2' with 'from insightface.app import FaceAnalysis' in usage. Diffusers community pipeline shows literal `app = FaceAnalysis(name='antelopev2', ...)` then `face_emb = face_info['embedding']`. Paper: 'IdentityNet by imposing strong semantic and weak spatial conditions, integrating facial and landmark images with textual prompts' + 'seamlessly integrates with SD1.5 and SDXL'. So mechanism is semantic (good for SynthID-no-pixel-leak) but license is blocked.
|
||||
|
||||
- https://huggingface.co/InstantX/InstantID
|
||||
- https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py
|
||||
- https://arxiv.org/pdf/2401.07519
|
||||
- https://instantid.github.io/
|
||||
|
||||
### 3. Arc2Face is SD1.5-only (not SDXL) AND requires InsightFace antelopev2 with arcface.onnx replacing glintr100.onnx — so it is doubly blocked: not a drop-in for the SDXL pipeline AND non-commercial via the model pack.
|
||||
|
||||
**Confidence:** high
|
||||
**Vote:** 3-0 across 3 claims
|
||||
|
||||
|
||||
Arc2Face README verbatim: 'Arc2Face is built upon SD1.5' with stable-diffusion-v1-5 checkpoint as base. Same README: 'manually download the antelopev2 package and place the checkpoints under models/antelopev2', 'Download arcface.onnx from HuggingFace', 'delete glintr100.onnx (the default backbone from insightface)'. Code is MIT but the runtime antelopev2 dependency carries the non-commercial restriction. No SDXL adaptation exists in the official repo.
|
||||
|
||||
- https://github.com/foivospar/Arc2Face
|
||||
|
||||
### 4. MS-Diffusion (ICLR 2025) is the one verified candidate that combines SDXL-base-1.0 as its foundation with a non-ArcFace image encoder (CLIP-ViT-bigG-14), so it avoids the InsightFace non-commercial blocker — but it is an IP-Adapter-class multi-subject personalization method, NOT a face-identity-recognition method, so license safety does not equal face-identity fidelity.
|
||||
|
||||
**Confidence:** medium
|
||||
**Vote:** 3-0 on the narrow factual claims (SDXL base + CLIP-G encoder)
|
||||
|
||||
|
||||
GitHub README explicitly instructs 'Download the pretrained base models from SDXL-base-1.0 and CLIP-G' (CLIP-ViT-bigG-14-laion2B-39B-b160k). Neither README nor arXiv 2406.07209 mention ArcFace/InsightFace/antelopev2/buffalo_l. Architecturally descended from IP-Adapter (CLIP-image-embedding family), not from FaceID/InstantID/PhotoMaker-V2. Verifier caveat (high confidence on the license-narrow claim, medium on suitability): CLIP-image face-ID accuracy ~80.95% vs specialized face recognition ~87.61% — license-safe but probably not identity-grade for portraits. Confidence is medium because suitability for portrait and group-photo inputs has not been validated empirically.
|
||||
|
||||
- https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf
|
||||
- https://github.com/MS-Diffusion/MS-Diffusion
|
||||
|
||||
### 5. ID-Aligner and the StyleGAN2/e4e-based identity method (arXiv 2510.25084) do not solve the problem: the former does not disclose embedder/license in the primary source; the latter terminates identity in a StyleGAN2 generator with no SDXL adapter, so it cannot be wired into the SDXL+canny ControlNet pipeline.
|
||||
|
||||
**Confidence:** high
|
||||
**Vote:** 3-0 across 3 claims
|
||||
|
||||
|
||||
ID-Aligner paper: no license terms, no weight-release status, no specific face embedder named in the primary source — commercial-safety unresolved. arXiv 2510.25084: 'facial identity features... mapped into the W+ latent space of StyleGAN2 using the e4e encoder' — identity pixels come from StyleGAN2's generator, not from any SDXL-compatible adapter, so architecturally incompatible with our SDXL+canny ControlNet pipeline. Abstract also does not name the face embedder or disclose weights license.
|
||||
|
||||
- https://arxiv.org/pdf/2404.15449
|
||||
- https://arxiv.org/pdf/2510.25084
|
||||
|
||||
### 6. The honest verdict: no fully commercial-safe SynthID-robust ArcFace-grade face-identity stack for SDXL exists today; the space is blocked by InsightFace's grip on ArcFace-class pretrained packs, and the verified permissive alternative (AdaFace as a drop-in replacement) was REFUTED in this verification round.
|
||||
|
||||
**Confidence:** high
|
||||
**Vote:** 3-0 on the chokepoint claim, 0-3 against the AdaFace escape hatch
|
||||
|
||||
|
||||
Every ArcFace-grade SDXL adapter audited (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) instantiates an InsightFace FaceAnalysis pack at runtime. The maintainer-acknowledged commercial-retrain path (InstantX) has not shipped. AdaFace as a permissive ArcFace alternative was refuted 0-3 by the adversarial verifier (MIT-licensed claim and drop-in-replacement claim both failed). Outcome: commercial-safe option for SDXL today is CLIP-image-embedding-based (MS-Diffusion class), which is weaker for face identity than ArcFace. For non-commercial / research-only deployments, InstantID on SDXL is the strongest semantic-only (no-pixel-leak) candidate.
|
||||
|
||||
- https://github.com/deepinsight/insightface
|
||||
- https://huggingface.co/InstantX/InstantID/discussions/2
|
||||
- https://github.com/mk-minchul/AdaFace
|
||||
|
||||
## Caveats
|
||||
|
||||
Six claims were refuted in adversarial verification, two of them load-bearing: AdaFace as a permissive ArcFace drop-in (both the MIT license and the drop-in characterization failed 0-3) — so the most attractive escape hatch from the InsightFace chokepoint did not survive verification. PuLID's superiority claim and ID-Aligner's embedder claim also failed, leaving those methods uncharacterized at the mechanism level. None of the verified claims directly answered the diffusers-0.38 compat question — InstantID's compat with our shipped diffusers version is unverified by primary source. MS-Diffusion's identity-fidelity for portraits is not empirically validated for the SynthID-removal use case; it is verified as a CLIP-G/SDXL adapter, not as a face-identity method. No 2025-2026 candidate other than MS-Diffusion was both surfaced AND verified as license-safe and SDXL-compatible — the verification round did not produce a confirmed CCSR-V2/ConsistencyID/OmniGen-V2/ConsistentID/MagicID candidate. The multi-face scenario (group photos) was not addressed by any verified claim — MS-Diffusion is the only multi-subject candidate but its face-identity strength is unmeasured. Time-sensitivity: InstantX's stated intent to retrain on commercial embedders ("We agree, we plan to train on other face encoders that support commercial license") is dated and unfulfilled per the verified sources; this could change.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does MS-Diffusion (or any CLIP-image-embedding SDXL adapter) achieve usable face-identity fidelity on portraits and group photos, or is the ArcFace gap (~7 pp face-ID accuracy) visually disqualifying — and can a face-specific CLIP fine-tune close it?
|
||||
- Has InstantX (or any community fork) actually shipped an InstantID variant retrained on a commercially-licensed face embedder since the maintainer's 2024 commitment, and if so what is its identity-fidelity vs the antelopev2 original?
|
||||
- What is the exact diffusers-0.38 compat status of InstantID, MS-Diffusion, and PuLID-FLUX inference scripts — does any need a fork the way PhotoMaker-V1 did, and if so what specifically breaks?
|
||||
- Is there a single-pipeline multi-subject identity-preservation method (mask-guided regional ID-adapters, multi-subject InstantID, MS-Diffusion multi-subject mode) that handles group photos without the per-face crop+composite patchwork that PhotoMaker-V2 produced?
|
||||
|
||||
## Refuted claims
|
||||
|
||||
- **AdaFace code is released under the MIT license, making it permissively licensed for commercial use (in contrast to InsightFace's research-only model packs).** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace
|
||||
- **AdaFace produces 512-dim face recognition embeddings comparable to ArcFace, positioning it as a drop-in alternative for ID-conditioning adapters that currently depend on InsightFace ArcFace.** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace
|
||||
- **The paper claims PuLID achieves superior performance in both ID fidelity and editability compared to prior ID-customization methods.** — vote 0-3 — source: https://arxiv.org/pdf/2404.16022
|
||||
- **Identity consistency in ID-Aligner is enforced by reward feedback from face detection and recognition models, implying dependency on an external face-recognition embedder (typically ArcFace/InsightFace-class) rather than a CLIP-only path.** — vote 1-2 — source: https://arxiv.org/pdf/2404.15449
|
||||
- **The pipeline decouples face detection from inference by accepting a pre-computed embedding via image_embeds, so any ArcFace-class embedder could be swapped in if a commercially-licensed equivalent existed.** — vote 1-2 — source: https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py
|
||||
- **The model card does not explicitly prohibit commercial use, but the license of the required InsightFace antelopev2 embedder is not specified on this page.** — vote 1-2 — source: https://huggingface.co/InstantX/InstantID
|
||||
|
||||
## Sources
|
||||
|
||||
- [source](https://huggingface.co/InstantX/InstantID/discussions/2)
|
||||
- [source](https://www.insightface.ai/solutions/face-recognition-licensing)
|
||||
- [source](https://github.com/mk-minchul/AdaFace)
|
||||
- [source](https://github.com/foivospar/Arc2Face)
|
||||
- [source](https://apatero.com/blog/instantid-vs-pulid-vs-faceid-ultimate-face-swap-comparison-2025)
|
||||
- [source](https://arxiv.org/pdf/2404.16022)
|
||||
- [source](https://instantid.github.io/)
|
||||
- [source](https://arxiv.org/pdf/2404.15449)
|
||||
- [source](https://arxiv.org/pdf/2511.11989)
|
||||
- [source](https://arxiv.org/pdf/2510.25084)
|
||||
- [source](https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py)
|
||||
- [source](https://huggingface.co/InstantX/InstantID)
|
||||
- [source](https://github.com/huggingface/diffusers/issues/9158)
|
||||
- [source](https://github.com/huggingface/diffusers/issues/5904)
|
||||
- [source](https://github.com/Mikubill/sd-webui-controlnet/discussions/2589)
|
||||
- [source](https://arxiv.org/pdf/2401.07519)
|
||||
- [source](https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf)
|
||||
- [source](https://github.com/huggingface/diffusers/issues/8626)
|
||||
- [source](https://arxiv.org/pdf/2404.04243)
|
||||
- [source](https://huggingface.co/OmniGen2/OmniGen2)
|
||||
- [source](https://github.com/VectorSpaceLab/OmniGen)
|
||||
- [source](https://github.com/ToTheBeginning/PuLID)
|
||||
- [source](https://arc2face.github.io/)
|
||||
- [source](https://github.com/mk-minchul/AdaFace/blob/master/LICENSE)
|
||||
- [source](https://github.com/IrvingMeng/MagFace/blob/main/LICENSE)
|
||||
- [source](https://github.com/askerlee/AdaFace-dev)
|
||||
- [source](https://openreview.net/forum?id=Hc2ZwCYgmB)
|
||||
- [source](https://github.com/tencent-ailab/IP-Adapter/wiki/IP%E2%80%90Adapter%E2%80%90Face)
|
||||
|
||||
## Empirical follow-up (2026-06-08, end of session)
|
||||
|
||||
After the research synthesis above, InstantID was integrated end-to-end and cert-swept
|
||||
on Modal A100 in two phases:
|
||||
|
||||
1. **Phase 1: InstantID txt2img per-face crop + composite.** Per-face InstantID
|
||||
txt2img with the upstream `pipeline_stable_diffusion_xl_instantid`, ArcFace
|
||||
embedding from the original face, landmark stick figure. Three composite
|
||||
iterations:
|
||||
- v1 (rectangular Gaussian alpha on the 2x square_box around each face):
|
||||
visible patchwork on group photos, generated 1024 backgrounds clashing.
|
||||
- v2 (tight crop on YuNet-detected face in the generated 1024 + elliptical
|
||||
alpha 0.45*bw x 0.55*bh + soft feather): ellipse axis exceeded bbox
|
||||
vertically, clipped forehead/chin on single portrait, group still had
|
||||
visible elliptical seams + cool-vs-warm tone clash with scene.
|
||||
- v3 (tighter ellipse 0.32*bw x 0.42*bh + per-channel mean color match to
|
||||
local cleaned canvas + softer feather): patchwork visually softened; faces
|
||||
still read as studio portraits inserted into the scene, not as people
|
||||
shot in the scene. Single portrait identity drifted (tatsunari -> "round
|
||||
Asian male" vs original's thin face).
|
||||
2. **Phase 2: InstantID img2img on cleaned crop.** Switched to the upstream
|
||||
`pipeline_stable_diffusion_xl_instantid_img2img` (downloaded at first use
|
||||
from raw.githubusercontent.com; requires `trust_remote_code=True`). Same
|
||||
ArcFace + landmark conditioning but the SDXL diffusion source is the
|
||||
CLEANED face crop, so the diffusion sees scene lighting / shoulders /
|
||||
shadow direction directly. Multi-face composition jumped substantially:
|
||||
faces sit in the bar scene with matching warm tone, no more elliptical
|
||||
seams. Single-portrait identity at the default (`strength=0.55`,
|
||||
`ip_adapter_scale=0.8`, `controlnet_conditioning_scale=0.8`) was "similar
|
||||
person, not exactly the original"; raising to `strength=0.7`,
|
||||
`ip_adapter_scale=1.0`, `controlnet_scale=1.0` brought identity closer to
|
||||
original but introduced more "SDXL gloss / clean skin" aesthetic.
|
||||
|
||||
**Net finding for a commercial deployment (load-bearing).** The fundamental issue is structural:
|
||||
ArcFace encodes "this person's general look" (ethnicity, gender, basic facial
|
||||
geometry) at 512 dimensions; SDXL decodes that embedding into pixels with the
|
||||
inherent SDXL aesthetic (smooth skin, symmetric pores, AI-photoreal look).
|
||||
Stronger identity push (higher strength / IP-Adapter scale) makes the face
|
||||
CLOSER to the embedded identity but MORE AI-looking; weaker push leaves
|
||||
identity to drift but face looks less AI-generated. There is no parameter
|
||||
setting that simultaneously recovers original identity AND looks less AI than
|
||||
the cleaned image, because the cleaned image is itself a controlnet-light
|
||||
denoise of the original (closer to original pixels) while a restore pass is a
|
||||
full SDXL regeneration (further from original pixels).
|
||||
|
||||
**Operational conclusion.** Do not ship `--restore-faces` in any monetized
|
||||
deployment. The cleaned image from the main controlnet 0.20 pass is the
|
||||
LEAST-AI state we can reach without re-introducing SynthID; every restore
|
||||
method tested (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img,
|
||||
InstantID img2img-on-cleaned at three parameter sweeps) trades original-look
|
||||
for embedding-driven regeneration and makes the face read as "AI-generated"
|
||||
rather than "the original person". The `instantid` and `photomaker` extras
|
||||
stay in the library as opt-in for research / personal use where users
|
||||
explicitly want identity regeneration; the CLI flag and module docstrings
|
||||
state the trade-off at every entry point.
|
||||
@@ -0,0 +1,287 @@
|
||||
# SynthID-robust face identity for an SDXL removal pipeline (research)
|
||||
|
||||
> Research archive. This document evaluates approaches considered during the
|
||||
> study. It does not describe the current public interface. The current
|
||||
> pipelines and limits are documented in `docs/cli.md` and
|
||||
> `docs/known-limitations.md`.
|
||||
|
||||
> **Status (2026-06-08): retired.** Every approach described below was empirically
|
||||
> tested and rejected -- see `docs/synthid-robust-identity-research-2026-06-08.md`
|
||||
> "Empirical follow-up" for the final conclusion. The library no longer ships any
|
||||
> face-restore extra: the cleaned image from the main controlnet 0.20 pass is the
|
||||
> least-AI face state we can reach without re-introducing SynthID. This document
|
||||
> is kept as historical record of the exploration.
|
||||
|
||||
**Question.** Which face identity-preservation mechanism for an SDXL img2img +
|
||||
canny-ControlNet watermark-removal pipeline (denoise 0.20-0.30) is BOTH (a)
|
||||
commercial-safe end-to-end and (b) does not re-introduce the SynthID pixel
|
||||
watermark the removal pass just destroyed?
|
||||
|
||||
**Constraint.** A paid deployment requires every component (adapter weights AND
|
||||
the face embedder it conditions on AND any base model) must be Apache-2.0 / MIT /
|
||||
BSD or otherwise clearly commercial-permitted. Non-commercial is disqualifying.
|
||||
|
||||
**One-line verdict.** Today there is **ONE** SDXL identity-conditioning stack that
|
||||
is commercial-safe end-to-end: **PhotoMaker-V1** (Apache-2.0, identity encoded as a
|
||||
fine-tuned OpenCLIP-ViT-H/14 image embedding -- NO InsightFace). Every other
|
||||
candidate -- **including PhotoMaker-V2**, IP-Adapter FaceID, InstantID, PuLID,
|
||||
Arc2Face -- inherits InsightFace's non-commercial model-pack license through an
|
||||
ArcFace-class embedder and is therefore blocked for paid services, regardless of
|
||||
the adapter's own license header. Below is the evidence per component and the
|
||||
integration plan.
|
||||
|
||||
**Correction notice (2026-06-04).** An earlier version of this doc claimed
|
||||
PhotoMaker-V2 was commercial-safe end-to-end. That was WRONG -- the V2 model card
|
||||
phrase *"id_encoder includes finetuned OpenCLIP-ViT-H-14 and a few fuse layers"*
|
||||
described one of TWO ID branches; the V2 source (model_v2.py) defines
|
||||
`PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` whose forward takes an
|
||||
ArcFace `id_embeds` from `insightface.app.FaceAnalysis`, and the upstream package
|
||||
`__init__.py` imports InsightFace at module load. A Modal cert sweep caught this
|
||||
empirically (`No module named 'insightface'` from `restore_faces_photomaker`). V1
|
||||
is the correct commercial-safe target: its `PhotoMakerIDEncoder` (model.py)
|
||||
forward takes only `(id_pixel_values, prompt_embeds, class_tokens_mask)` -- no
|
||||
ArcFace branch -- so identity is CLIP-only.
|
||||
|
||||
**Status notice (2026-06-04, end of session).** Two commercial-safe paths were
|
||||
tried and abandoned:
|
||||
|
||||
1. **PhotoMaker-V1** (commercial-safe by license but blocked by upstream compat).
|
||||
The cert sweep hit a cascade of upstream compatibility issues with the diffusers
|
||||
version we ship (0.38): missing `einops` declaration, missing `peft` declaration,
|
||||
default `pm_version='v2'` that mis-loads V1 weights into the V2 encoder, custom
|
||||
`id_encoder` left on CPU after `pipe.to(device)`, and a CFG-batch tensor-shape
|
||||
mismatch in the denoising loop (`Expected size 2 but got size 1`). 7 cascading
|
||||
fixes did not get the pipeline running end-to-end. The PhotoMaker `pipeline.py`
|
||||
header notes it was forked from diffusers v0.29.1; SDXL prompt-encoder handling
|
||||
changed significantly between 0.29 and 0.38.
|
||||
2. **GFPGAN on the diffusion-CLEANED image** (commercial-safe, but no identity
|
||||
recovery). A one-line change made it SynthID-safe (input pixels are already
|
||||
clean, so the partial blend cannot transport the watermark), but visual
|
||||
inspection of the cert output showed it only polished the already-drifted face
|
||||
without actually restoring identity. Trade-off was real and the value too low
|
||||
to keep.
|
||||
|
||||
**The shipped path is PhotoMaker-V2** (`photomaker_restore.py`, the `photomaker`
|
||||
extra). V2 uses a DUAL ID encoder (CLIP image features + ArcFace embedding),
|
||||
which delivers true identity-from-embedding face regeneration. The cost is that
|
||||
the ArcFace embedding comes from InsightFace's `antelopev2`/`buffalo_l` model
|
||||
packs, which are released under a non-commercial / research-only license. **So
|
||||
the shipped restore path is NON-COMMERCIAL.** Any monetized
|
||||
deployment must NOT install the `photomaker` extra. The CLI flag and module
|
||||
docstring both call this out at every entry point.
|
||||
|
||||
A future commercial-safe path would need either (a) the PhotoMaker upstream to
|
||||
land its diffusers 0.38 compat fix so V1 can run, or (b) an equally good
|
||||
ArcFace-class face-recognition model released under a permissive license that
|
||||
PhotoMaker-V2 can be retargeted to. Neither is on a near-term horizon as of
|
||||
this writing.
|
||||
|
||||
## 1. Why identity-by-embedding (not by pixel) is the only SynthID-robust path
|
||||
|
||||
The pipeline regenerates pixels to destroy SynthID. Any identity-restoration that
|
||||
is "faithful to the input pixels" (GFPGAN, CodeFormer, face-swap-by-blending, our
|
||||
previous restore-on-original pass) reproduces the watermark, because SynthID is
|
||||
engineered to be robust to fidelity-preserving transforms (resize, JPEG, partial
|
||||
blend). Oracle-confirmed on a real Gemini face: controlnet @ 0.20/0.25 WITH the
|
||||
GFPGAN restore pass left SynthID detected; the SAME controlnet @ 0.20 with
|
||||
`--no-restore-faces` cleared it (clean A/B, see `docs/synthid.md` 5.5 and
|
||||
`docs/controlnet-removal-pipeline-research.md`).
|
||||
|
||||
The only mechanism that can preserve identity AND not re-introduce SynthID is to
|
||||
carry identity in a SEMANTIC EMBEDDING (a vector that encodes "who is in this
|
||||
picture") and use it to CONDITION a fresh generation -- the pixels are new, so
|
||||
the watermark is not transported. Two embedding families exist in practice:
|
||||
|
||||
- **ArcFace-class face-recognition embeddings** (the InsightFace family). Used by
|
||||
IP-Adapter FaceID, InstantID, PuLID, Arc2Face. Highest identity fidelity, but
|
||||
the embedder weights are non-commercial.
|
||||
- **CLIP image embeddings of a face crop**. Used by PhotoMaker (and the original
|
||||
IP-Adapter image variant). Lower identity fidelity at small scale than ArcFace,
|
||||
but the encoder (OpenCLIP-ViT-H/14, MIT) is commercial-safe.
|
||||
|
||||
## 2. License table (verified against primary sources, 2026-06-04)
|
||||
|
||||
| stack | adapter weights | identity encoder | end-to-end commercial-safe? |
|
||||
|---|---|---|---|
|
||||
| **PhotoMaker-V1** | **Apache-2.0** ([HF][pmhf]) | **OpenCLIP-ViT-H/14 (MIT)** finetuned, identity from `PhotoMakerIDEncoder` (`model.py`); forward takes only ``(id_pixel_values, prompt_embeds, class_tokens_mask)`` -- no ArcFace branch | **YES** |
|
||||
| PhotoMaker-V2 | Apache-2.0 (adapter) ([HF][pm2hf]) | DUAL encoder: OpenCLIP-ViT-H/14 AND InsightFace antelopev2/buffalo_l -- `PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` (`model_v2.py`) forward takes `id_embeds` from `insightface.app.FaceAnalysis`, and `photomaker/__init__.py` imports InsightFace at module load | NO -- InsightFace pack is non-commercial |
|
||||
| IP-Adapter FaceID | non-commercial per model card: *"AS InsightFace pretrained models are available for non-commercial research purposes, IP-Adapter-FaceID models are released exclusively for research purposes and is not intended for commercial use"* ([HF][ipafhf]) | InsightFace antelopev2 (non-commercial for the model pack) | NO -- both layers block |
|
||||
| InstantID | Apache-2.0 (adapter only) ([HF][insthf]) | requires InsightFace antelopev2 face-analysis at runtime (`FaceAnalysis(name='antelopev2', ...)` per the README usage snippet, [HF][insthf]) | NO -- embedder pack is non-commercial |
|
||||
| PuLID | apache-2.0 (HF model metadata, [HF][pulidhf]) | depends on InsightFace face-analysis for ArcFace embedding (per the upstream README; PuLID's own card is sparse and the GitHub README documents the InsightFace install step) | NO -- same embedder issue as IP-Adapter FaceID |
|
||||
| Arc2Face | MIT (HF model metadata, [HF][arc2hf]) | uses `insightface.app.FaceAnalysis` to extract the ArcFace embedding ([HF][arc2hf]); also based on SD-v1-5 (NOT SDXL) | NO -- non-commercial embedder + not SDXL |
|
||||
|
||||
**The crux is InsightFace.** InsightFace explicitly splits its license: *"Code is
|
||||
MIT licensed; models require separate commercial licensing"* and frames the
|
||||
pretrained packs as *"Commercial licensing for InsightFace's open-source model
|
||||
packages"* requiring users to *"obtain commercial usage rights for model
|
||||
packages"* ([insightface.ai][iflic]). antelopev2 and buffalo_l fall under the
|
||||
model-pack license, not MIT. So any stack that calls
|
||||
`insightface.app.FaceAnalysis(name='antelopev2', ...)` to compute its ArcFace
|
||||
embedding is blocked by default, REGARDLESS of the adapter's own Apache header
|
||||
above it. This is the same reason IP-Adapter FaceID's card flags itself
|
||||
non-commercial.
|
||||
|
||||
(Note on PuLID's HF metadata: the model card declares apache-2.0 for the adapter
|
||||
weights but the upstream repo's quickstart requires the InsightFace package to
|
||||
extract the ID embedding. So PuLID's adapter license is permissive; the BLOCKER
|
||||
is the embedder it expects at runtime. This is the same trap as InstantID.)
|
||||
|
||||
[pmhf]: https://huggingface.co/TencentARC/PhotoMaker
|
||||
[pm2hf]: https://huggingface.co/TencentARC/PhotoMaker-V2
|
||||
[ipafhf]: https://huggingface.co/h94/IP-Adapter-FaceID
|
||||
[insthf]: https://huggingface.co/InstantX/InstantID
|
||||
[pulidhf]: https://huggingface.co/guozinan/PuLID
|
||||
[arc2hf]: https://huggingface.co/FoivosPar/Arc2Face
|
||||
[iflic]: https://www.insightface.ai/solutions/face-recognition-licensing
|
||||
|
||||
## 3. Is there a commercial-safe ArcFace replacement?
|
||||
|
||||
Short answer: **no clean drop-in**. The widely deployed pretrained ArcFace packs
|
||||
(antelopev2, buffalo_l, glint360k) come from InsightFace and are non-commercial.
|
||||
ArcFace as an ARCHITECTURE is published in a paper, so retraining is legally fine,
|
||||
but you would need:
|
||||
|
||||
- a commercial-licensed training dataset (the big public ones -- MS-Celeb-1M,
|
||||
Glint360K, WebFace -- carry research-only or licensing-uncertain restrictions);
|
||||
- compute + time to train an ArcFace-class model on the legal dataset;
|
||||
- the result would be a one-off effort, not a maintained dependency.
|
||||
|
||||
For a removal service this is a multi-month side project that delivers what
|
||||
PhotoMaker already gives us with one pip install. So the practical answer is to
|
||||
take the CLIP-embedding path (PhotoMaker-V1; V2 adds InsightFace and is non-commercial), accept the identity-fidelity
|
||||
trade-off, and revisit ArcFace later if quality is insufficient.
|
||||
|
||||
## 4. Does an identity embedding leak SynthID?
|
||||
|
||||
This is the load-bearing assumption of the whole approach. The argument:
|
||||
|
||||
- SynthID is a low-amplitude, perceptually-invisible pixel watermark engineered
|
||||
to be robust to "fidelity-preserving" transforms (it survives JPEG, resize,
|
||||
crop, color, noise at >=99% TPR -- see arXiv:2510.09263 referenced in
|
||||
`docs/synthid.md`).
|
||||
- A face-recognition / CLIP-image embedding is by design INVARIANT to such low-
|
||||
amplitude pixel changes (compression, brightness, small noise should not change
|
||||
"who is in the photo"). That is the whole training objective.
|
||||
- Therefore the embedding extracted from a watermarked face vs. the same face
|
||||
cleaned should be ~identical -- the embedding cannot CARRY the watermark
|
||||
pattern, only the identity, because the watermark sits in exactly the
|
||||
dimensions the embedding learned to discard.
|
||||
|
||||
**MEASURED 2026-06-04 — hypothesis confirmed.** Ran a low-amplitude
|
||||
perturbation sweep on 31 face crops (3 photoreal originals: gemini_3, gemini_4,
|
||||
openai_3 grid), comparing `cos(embedding(orig), embedding(perturbed))` for OpenCLIP-
|
||||
ViT-H/14 (laion2B-s32B-b79K, the same OpenCLIP-ViT-H/14 encoder PhotoMaker V1 and V2 both finetune for CLIP-side identity):
|
||||
|
||||
| perturbation | mean cos | min | max |
|
||||
|---|---|---|---|
|
||||
| **synthid_proxy** (±2 LSB low-freq noise, σ=4 px Gaussian carrier — same regime SynthID hides in) | **0.9977** | 0.9937 | 0.9996 |
|
||||
| noise3 (Gaussian σ=3, full-spectrum) | 0.9541 | 0.9055 | 0.9825 |
|
||||
| jpeg90 (SynthID survives this) | 0.9280 | 0.8806 | 0.9566 |
|
||||
| blur1 (Gaussian σ=1) | 0.9139 | 0.8103 | 0.9875 |
|
||||
| jpeg70 | 0.8945 | 0.8125 | 0.9603 |
|
||||
| (self check: identical crop) | 1.0000 | 1.0000 | 1.0000 |
|
||||
|
||||
The SynthID-magnitude perturbation moves the embedding by **0.002** (cosine 0.9977),
|
||||
an order of magnitude less than JPEG90 — which SynthID survives at >=99% TPR by
|
||||
design. So the embedding cannot carry the watermark pattern: its discriminative
|
||||
signal is in dimensions the SynthID payload does not occupy. PhotoMaker-V1
|
||||
conditioned on a watermarked face will see ~the same identity vector as if
|
||||
conditioned on a clean face of the same person, so the freshly generated face
|
||||
inherits the identity, not the watermark.
|
||||
|
||||
A first, naive smoke run measured `cos(orig, SDXL-cleaned)` instead — that test is
|
||||
about diffusion drift, not watermark invariance (diffusion at strength 0.20-0.30 is a
|
||||
much larger perturbation than SynthID), so its 0.56-0.93 spread is the identity
|
||||
drift the PhotoMaker pipeline is meant to fix in the first place. The
|
||||
synthid_proxy result above is the one that actually answers the load-bearing
|
||||
question. Script: `/tmp/identity_smoke/test2_proxy.py` (not committed; reproducible
|
||||
from the test set + this doc).
|
||||
|
||||
## 5. PhotoMaker-V1 properties for our pipeline
|
||||
|
||||
- **SDXL-native.** PhotoMaker v1 and v2 target Stable Diffusion XL; the pipeline
|
||||
is a stacked-ID embedding fused into SDXL's cross-attention via the fuse layers
|
||||
bundled in the released weights.
|
||||
- **Identity from a SINGLE reference image works** but the method was designed
|
||||
for "stacked" multi-reference; with one image identity fidelity is lower than
|
||||
with 3-4, and a service has only one (the upload). This is the failure mode to
|
||||
guard.
|
||||
- **Compatibility with img2img + canny ControlNet.** PhotoMaker is typically
|
||||
exposed in txt2img workflows in the upstream demo. SDXL img2img + ControlNet
|
||||
is the same denoising backbone, so the cross-attention injection works the same
|
||||
way; community examples on Diffusers and ComfyUI confirm PhotoMaker stacks with
|
||||
ControlNet. Validate this on a representative image before adopting.
|
||||
- **Failure modes to expect:**
|
||||
- identity drift on small / multi-face groups (the 9-face grid case);
|
||||
- "plastic" / over-smoothed faces if PhotoMaker's identity weighting is high
|
||||
while the img2img strength is low;
|
||||
- canny ControlNet conditioning can fight the ID embedding (edges of the
|
||||
ORIGINAL face vs identity of the SAME person regenerated) -- expect to tune
|
||||
`controlnet_conditioning_scale` down a notch on photoreal faces;
|
||||
- PhotoMaker was trained on a celebrity-skew distribution; representative faces
|
||||
(especially non-white, non-Western, elderly, children) may have lower
|
||||
fidelity. Measure on a representative evaluation distribution.
|
||||
|
||||
## 6. Integration cost (rough)
|
||||
|
||||
- New deps: `diffusers` already in the diffusion extra; PhotoMaker ships as a `.bin`
|
||||
loaded via `pipeline.load_photomaker_adapter(...)`. The OpenCLIP encoder is the
|
||||
same one diffusers already pulls. No new heavy pip dep.
|
||||
- Weight download: PhotoMaker-V1 weights are ~3 GB. Add to the Modal HF volume
|
||||
alongside SDXL.
|
||||
- VRAM: SDXL + canny ControlNet + PhotoMaker-V1 fits comfortably in A100-40GB.
|
||||
- Latency: a few extra seconds on cold start (load PhotoMaker), negligible per
|
||||
request after warm-up.
|
||||
- No InsightFace install: huge win for `restore` extra's basicsr/numpy hell --
|
||||
this path simply does not touch that ecosystem.
|
||||
|
||||
## 7. Recommended path
|
||||
|
||||
1. **Embedding-invariance smoke test FIRST** (one afternoon, no codegen):
|
||||
- For ~10 OpenAI / Gemini watermarked faces, compute OpenCLIP-ViT-H/14
|
||||
embeddings; for the same images after our SDXL `default` pass at the
|
||||
certified strength, compute the embeddings again; assert mean cosine
|
||||
similarity > ~0.95.
|
||||
- If yes -> the embedding does not carry SynthID, proceed.
|
||||
- If no -> the assumption is wrong; PhotoMaker would re-introduce the
|
||||
watermark. Stop and reconsider.
|
||||
2. **PhotoMaker-V1 prototype** in the existing `controlnet` pipeline:
|
||||
- Mirror the `_load_controlnet_pipeline` path: add a PhotoMaker variant that
|
||||
loads SDXL + canny ControlNet + PhotoMaker adapter on the same engine.
|
||||
- Extract the OpenCLIP face embedding from the watermarked face crops (use
|
||||
OpenCV YuNet, already bundled for `auto`, to find the face boxes).
|
||||
- Pass the embedding as PhotoMaker's `id_embeds` to the SDXL pipeline; run
|
||||
img2img at the certified strength (0.20 OpenAI, 0.30 Gemini-capped-1536)
|
||||
with the canny edge map.
|
||||
3. **Oracle validation** on the cert sweep: run the new PhotoMaker variant
|
||||
through the isolated Modal certification harness over the same 6 image set, certify on the
|
||||
per-vendor oracles. Expected: SynthID cleared (the regeneration is the same)
|
||||
AND identity recovered (the embedding adds it back).
|
||||
4. **Honest exit criteria.** Ship only if BOTH oracle reads clean AND a small
|
||||
user-perception test on representative inputs says "looks like me". If identity is
|
||||
still too soft on small faces -> add stacked-reference (multiple crops of the
|
||||
same upload at different scales) before reaching for a non-commercial
|
||||
embedder.
|
||||
|
||||
## 8. What we are NOT doing, and why
|
||||
|
||||
- **No InsightFace.** Non-commercial for model packs (see License table).
|
||||
- **No CodeFormer.** Non-commercial.
|
||||
- **No GFPGAN on the original image.** It re-introduces SynthID
|
||||
(oracle-confirmed).
|
||||
- **No GFPGAN on the cleaned image.** It cannot RECOVER identity that the
|
||||
diffusion pass already drifted -- it can only smooth/sharpen whatever face is
|
||||
already there. Useful as cosmetic polish, not as identity restoration.
|
||||
- **No retraining of an in-house ArcFace.** Out of scope for a removal service.
|
||||
|
||||
---
|
||||
|
||||
## Process note
|
||||
|
||||
The deep-research harness was run but its verifier subagents failed to call
|
||||
`StructuredOutput` (same harness bug as the prior 2026-05-XX run), so its synthesis
|
||||
was unusable. The license claims above were verified by directly fetching the HF
|
||||
model cards and the InsightFace licensing page and quoting them; the
|
||||
embedding-invariance argument is mechanistic and explicitly flagged as not yet
|
||||
measured (it is the first integration step). Do not treat the deep-research
|
||||
output as ground truth for this file.
|
||||
@@ -0,0 +1,744 @@
|
||||
# SynthID: technical reference
|
||||
|
||||
> Technical research reference. Current package behavior is defined by the
|
||||
> [supported signals](supported-signals.md), [known limitations](known-limitations.md),
|
||||
> and [module internals](module-internals.md). Dated measurements below are
|
||||
> historical evidence and should not be read as current CLI defaults.
|
||||
|
||||
This document covers how Google SynthID for images works mechanically, what it
|
||||
survives, what removes it, the external video-verification workflow, and the
|
||||
current deployment landscape. It is written for engineers working on watermark
|
||||
detection and removal -- specifically to inform decisions about strength
|
||||
settings, test methodology, and what oracle results mean.
|
||||
|
||||
Primary sources are cited inline. Marketing-only claims are flagged separately
|
||||
from independently-verified results.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mechanism
|
||||
|
||||
### 1.1 Post-hoc, model-independent design
|
||||
|
||||
SynthID-Image is **not** baked into a diffusion model's weights. It is a
|
||||
post-hoc, model-independent system: a separate encoder `f` is applied to an
|
||||
already-generated image, and a separate decoder `g` reads it back.
|
||||
|
||||
> "We deliberately designed SynthID-Image as a post-hoc, model-independent
|
||||
> approach, a choice largely based on deployment considerations."
|
||||
> -- Gowal et al., arXiv:2510.09263
|
||||
|
||||
The formal definition from the paper:
|
||||
|
||||
> "A post-hoc watermarking scheme is a pair f, g consisting of an encoder
|
||||
> function f: X -> X, which adds an identification mark, and a decoder
|
||||
> function g: X -> {+-1}, which tries to detect if the mark is present."
|
||||
|
||||
This is the key architectural fact: **the generative model (Imagen, Gemini's
|
||||
image model) is not modified**. The watermark is stamped onto the pixel output
|
||||
after generation, by a separate neural network. This means:
|
||||
|
||||
- The watermark is in **pixel space**, not in the model's latent activations.
|
||||
- Replacing the generative model does not remove the watermarking capability.
|
||||
- The encoder/decoder pair can be updated independently of the generative model.
|
||||
|
||||
The paper does not disclose the internal architecture of the encoder/decoder
|
||||
networks (layer types, capacity). The external variant SynthID-O is available
|
||||
to partners; the production internal variant is not published.
|
||||
|
||||
### 1.2 How it differs from classical DWT-DCT watermarks
|
||||
|
||||
The open watermarks used by Stable Diffusion / SDXL / FLUX (via the
|
||||
`imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a
|
||||
fixed bit pattern is added to specific frequency coefficients of the image's
|
||||
wavelet transform. This is fast, key-free, and locally detectable with a public
|
||||
decoder.
|
||||
|
||||
SynthID-Image uses **jointly-trained deep learning models**:
|
||||
|
||||
> "SynthID uses two deep learning models -- for watermarking and identifying --
|
||||
> that have been trained together on a diverse set of images. The combined model
|
||||
> is optimised on a range of objectives, including correctly identifying
|
||||
> watermarked content and improving imperceptibility by visually aligning the
|
||||
> watermark to the original content."
|
||||
> -- Google DeepMind blog, 2023
|
||||
|
||||
The practical difference for robustness: the deep learning encoder learns to
|
||||
spread the signal across the image in a way that is optimized to survive a
|
||||
specific perturbation distribution seen during training. Classical DWT-DCT
|
||||
embeds in fixed, predictable frequency bins, making it brittle to any
|
||||
operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at
|
||||
quality <= 90).
|
||||
|
||||
### 1.3 Payload capacity
|
||||
|
||||
SynthID-O (the external/partnership variant) encodes:
|
||||
|
||||
- **136 bits** within a **512x512 pixel image**
|
||||
|
||||
For comparison (from the same paper):
|
||||
|
||||
| Method | Bits | Resolution |
|
||||
|-------------|------|------------|
|
||||
| SynthID-O | 136 | 512x512 |
|
||||
| StegaStamp | 100 | 400x400 |
|
||||
| TrustMark | 100 | 256x256 |
|
||||
| WAM | 32 | 256x256 |
|
||||
|
||||
The payload carries an identification mark (not a user-readable secret). The
|
||||
paper separates watermark **detection** (is this watermarked?) from payload
|
||||
**recovery** (what does the payload say?): the detection path is what oracles
|
||||
like the Gemini app's "Verify with SynthID" exercise.
|
||||
|
||||
### 1.4 Where in the pipeline it lives
|
||||
|
||||
```
|
||||
[Diffusion model]
|
||||
|
|
||||
raw pixel output
|
||||
|
|
||||
[SynthID encoder f] <-- separate neural net, stamps the watermark
|
||||
|
|
||||
watermarked image
|
||||
|
|
||||
[served / downloaded]
|
||||
|
|
||||
[SynthID decoder g] <-- separate neural net, run by Google's verifier only
|
||||
|
|
||||
present / not present
|
||||
```
|
||||
|
||||
The VAE decoder of the diffusion model is **not** involved in watermarking.
|
||||
Some in-generation watermark approaches (like the research method "Tree Ring")
|
||||
inject the signal into the initial noise latent so it propagates through the
|
||||
diffusion process and appears in the final image; SynthID-Image does not do
|
||||
this -- it is applied after the VAE has already decoded latents to pixels.
|
||||
|
||||
---
|
||||
|
||||
## 2. Robustness
|
||||
|
||||
### 2.1 What the paper claims it survives (primary-source verified)
|
||||
|
||||
The SynthID-Image paper (arXiv:2510.09263) evaluates SynthID-O against **30
|
||||
image transformations** grouped into 6 categories:
|
||||
|
||||
| Category | Examples |
|
||||
|-------------|-----------------------------------------------|
|
||||
| Color | brightness, contrast, saturation, hue shifts |
|
||||
| Combination | combinations of multiple transforms |
|
||||
| Noise | Gaussian noise, impulse noise, median filter |
|
||||
| Overlay | text overlays, logos, stickers |
|
||||
| Quality | JPEG compression, WebP, format conversion |
|
||||
| Spatial | crop, resize, rotate, flip, padding |
|
||||
|
||||
**TPR at 0.1% FPR -- SynthID-O vs. baselines (resized to 512x512):**
|
||||
|
||||
| Category | SynthID-O | Best baseline (WAM) | Worst baseline (StegaStamp spatial) |
|
||||
|------------------|-----------|---------------------|--------------------------------------|
|
||||
| Identity (none) | 100.00% | 100.00% | 100.00% |
|
||||
| Aggregated | 99.98% | 90.62% | ~70% |
|
||||
| Color | 100.00% | 81.29% | ~75% |
|
||||
| Combination | 99.96% | 96.08% | ~22% |
|
||||
| Noise | 99.98% | 100.00% | ~92% |
|
||||
| Overlay | 100.00% | 100.00% | 100.00% |
|
||||
| Quality | 99.99% | -- | ~89% |
|
||||
| Spatial (worst) | 99.97% | 76.04% | 15.25% |
|
||||
|
||||
The "Spatial worst" row is the hardest case (aggressive crop + resize).
|
||||
SynthID-O retains 99.97% TPR; StegaStamp collapses to 15.25%. This is where
|
||||
the deep-learning approach gains the most over classical methods.
|
||||
|
||||
Google's marketing page states the watermark is:
|
||||
|
||||
> "designed to stand up to modifications like cropping, adding filters, changing
|
||||
> frame rates, or lossy compression."
|
||||
> -- deepmind.google/models/synthid/
|
||||
|
||||
The marketing claim is broadly consistent with the paper's numbers for these
|
||||
specific categories.
|
||||
|
||||
**JPEG and format conversion specifically** fall under the "Quality" category,
|
||||
where SynthID-O achieves 99.99% TPR. This is the empirical basis for the fact
|
||||
that **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
|
||||
subjects**: the re-encoding does not remove the pixel watermark.
|
||||
|
||||
### 2.2 Stated limits (vendor claim, not independently verified)
|
||||
|
||||
> "SynthID isn't foolproof against extreme image manipulations."
|
||||
> -- Google DeepMind blog, 2023
|
||||
|
||||
This is the only public failure-mode statement Google has made. No specific
|
||||
perturbation type, threshold, or quantitative boundary is named. The
|
||||
Limitations section of the paper (Section 10) was not recoverable from the
|
||||
public HTML version of arXiv:2510.09263v1 due to a rendering failure in the
|
||||
conversion (the body text of Section 10 is absent from the HTML).
|
||||
|
||||
**What is known empirically from our own oracle-verified testing.**
|
||||
|
||||
A controlled study (June 2026, clean v0.8.6 with text/face protection OFF,
|
||||
native resolution on this repo's default SDXL pipeline) measured the minimum
|
||||
img2img strength that removes the SynthID pixel watermark, verified per image on
|
||||
the vendor's own oracle (openai.com/verify for OpenAI, the Gemini app "Verify
|
||||
with SynthID" for Google). The reusable originals are stored once in
|
||||
`data/synthid/originals/`, with their input verification in `manifest.csv`.
|
||||
Generated cleaned outputs are not committed; the table below is the durable
|
||||
record of the historical oracle verdicts. One third-party image from issue #14
|
||||
was oracle-verified but is not committed.
|
||||
|
||||
**Oracle validation order: start with OpenAI.** When validating removal across
|
||||
vendors, run the OpenAI arm first. `openai.com/verify` is more accessible than the
|
||||
Gemini app -- fewer per-check restrictions, so it gives the fastest signal and is
|
||||
the strongest candidate for automation (Playwright / Chrome MCP driving
|
||||
`openai.com/verify`); the Gemini "Verify with SynthID" flow is more manual. This is
|
||||
an ordering/throughput choice, not a substitution: each oracle only reads its own
|
||||
vendor's SynthID (`openai.com/verify` is OpenAI-scoped), so Google content still
|
||||
needs the Gemini app.
|
||||
|
||||
| Vendor | Images | Resolution(s) | Pipeline | Removed at |
|
||||
|--------|--------|---------------|----------|------------|
|
||||
| OpenAI (gpt-image) | n=4 (3 archived + 1 external-only) | 1024x1536 .. 1600x1600 | native | **0.05** |
|
||||
| Google (Gemini) | n=4 | 2816x1536 -> capped 1536 | `--max-resolution 1536` | **0.15** (0.05 and 0.10 do NOT clear) |
|
||||
|
||||
**Two findings, both oracle-verified:**
|
||||
|
||||
1. **Vendor is the dominant factor, not resolution.** Google's SynthID is
|
||||
roughly 3x more robust than OpenAI's: at a comparable (small) working
|
||||
resolution, OpenAI clears at 0.05 while Google needs 0.15. This matches
|
||||
Google having hardened SynthID more aggressively over time.
|
||||
|
||||
2. **OpenAI SynthID removal is resolution-independent in the tested range.**
|
||||
All four OpenAI images (including a 1600x1600) cleared at 0.05.
|
||||
|
||||
**CORRECTION (supersedes the earlier "resolution dependence" claim).** A prior
|
||||
version of this doc and CLAUDE.md stated that strength 0.30 failed to remove
|
||||
SynthID on 1600x1600 gpt-image and that removal was resolution-dependent. That
|
||||
was a **measurement artifact of a since-removed per-region re-scrub step** (issue
|
||||
#14): on the dense-text infographics tested, that step could reconstitute SynthID
|
||||
in text regions. Re-running the *same* 1600x1600 image on the clean current
|
||||
pipeline removes SynthID at **0.05**. The "large images resist removal" conclusion
|
||||
was false; the resistance was that region-rescrub shielding, since removed.
|
||||
|
||||
**Open / not locally testable:**
|
||||
|
||||
- **Native large Gemini (2816x1536, ~4.3 MP).** The Gemini floor of 0.15 was
|
||||
measured on the *capped* (`--max-resolution 1536`) path, which is the
|
||||
practical local route on Apple-Silicon (native 2816 OOMs / falls back to slow
|
||||
CPU on a 32 GB M-series). Native large Gemini was not measured here; the
|
||||
vendor and resolution effects would stack, so it plausibly needs >= 0.30 or a
|
||||
discrete GPU. Confirm on a CUDA box if needed.
|
||||
- **Heavy JPEG compression** (quality < ~50-60): not oracle-tested; the DL
|
||||
approach is more robust than DWT-DCT but Google acknowledges limits at
|
||||
"extreme" manipulation.
|
||||
|
||||
### 2.3 Removal attacks and forensic detectability
|
||||
|
||||
The paper arXiv:2605.09203 ("Removing the Watermark Is Not Enough",
|
||||
Goonatilake & Ateniese, 2026) evaluates 6 removal attacks against a ResNet-50
|
||||
forensic detector. All attacks defeat the watermark verifier but are detected
|
||||
by the forensic classifier:
|
||||
|
||||
| Attack | Family | AUROC | TPR @ 1% FPR | TPR @ 0.1% FPR |
|
||||
|-----------------|------------------|--------|--------------|----------------|
|
||||
| UnMarker | Distortion | 0.9994 | 99.81% | 98.28% |
|
||||
| WatermarkAttacker| Regeneration | 0.9997 | 99.95% | 99.38% |
|
||||
| CtrlRegen+ | Regeneration | 0.9999 | 99.97% | 99.64% |
|
||||
| NFPA | Inversion/Pert. | 0.9984 | 99.24% | 62.10% |
|
||||
| Boundary Leak. | Inversion/Pert. | 0.9991 | 99.24% | 88.34% |
|
||||
| WiTS | Erosion | 0.9999 | 99.80% | 99.55% |
|
||||
|
||||
The forensic detector is a standard ResNet-50 fine-tuned end-to-end; no exotic
|
||||
architecture needed. The key finding:
|
||||
|
||||
> "These removers do not return images to a clean forensic state. They often
|
||||
> trade an explicit watermark for an implicit watermark: a detectable artifact
|
||||
> introduced by the removal process itself."
|
||||
|
||||
This means: even when our SDXL img2img pass defeats the SynthID pixel
|
||||
watermark (oracle reads negative), the output may still be classifiable as
|
||||
"an image that went through a removal pipeline" by an independent detector --
|
||||
even if that detector is not trained on SynthID specifically. **Defeating the
|
||||
verifier does not restore forensic deniability.**
|
||||
|
||||
CtrlRegen+ is the most detectable removal method (AUROC 0.9999), which is
|
||||
notable because it is also the most powerful removal attack. The paper notes
|
||||
that diffusion regeneration "leaves a strong reconstruction signature from the
|
||||
diffusion prior."
|
||||
|
||||
---
|
||||
|
||||
## 3. Detectability and verifier access
|
||||
|
||||
### 3.1 No public local detector
|
||||
|
||||
The SynthID decoder is proprietary and not released:
|
||||
|
||||
> "SynthID-Image has been used to watermark over ten billion images and video
|
||||
> frames across Google's services and its corresponding verification service is
|
||||
> available to trusted testers."
|
||||
> -- Gowal et al., arXiv:2510.09263
|
||||
|
||||
There is no public API, no released decoder weights, and no reproducible
|
||||
algorithm for local detection. The verification service (SynthID Detector) is:
|
||||
|
||||
> "a verification portal" in early testing with "journalists and media
|
||||
> professionals" on a waitlist
|
||||
> -- deepmind.google/models/synthid/
|
||||
|
||||
The external variant SynthID-O is available "through partnerships" only. Our
|
||||
tool cannot locally detect SynthID presence or absence -- this is by design,
|
||||
not a gap we can fill.
|
||||
|
||||
### 3.2 How our tool detects SynthID (metadata proxy)
|
||||
|
||||
We detect SynthID indirectly: if the image's C2PA manifest is signed by a
|
||||
known SynthID-using issuer (Google, OpenAI), we infer SynthID is present. This
|
||||
is a **metadata proxy**, not a pixel watermark decode. It works while the C2PA
|
||||
manifest is intact, and is silent once the manifest is stripped or the image
|
||||
is re-encoded without C2PA (e.g., a screenshot, a social-media re-upload, or
|
||||
after `metadata --remove`).
|
||||
|
||||
This is why:
|
||||
- `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is
|
||||
gone) even though the pixel SynthID is still present and detectable by
|
||||
openai.com/verify.
|
||||
- A quiet `identify` output is not proof that SynthID was removed -- it only
|
||||
means the metadata signal is gone.
|
||||
|
||||
### 3.3 Oracle scope: each vendor detects only their own
|
||||
|
||||
From openai.com/research/verify (verbatim, verified 2026-05-31):
|
||||
|
||||
> "OpenAI generation signals will only be detected if the image was generated
|
||||
> with our tools."
|
||||
> "Content could also still be AI-generated by another company's model, which
|
||||
> the tool currently does not detect."
|
||||
|
||||
SynthID technology is used by multiple vendors, but each verifier is keyed to
|
||||
its own payload:
|
||||
|
||||
| Oracle | Detects | Does NOT detect |
|
||||
|-------------------------------|------------------|-------------------------|
|
||||
| Gemini app "Verify with SynthID" | Google SynthID | OpenAI SynthID |
|
||||
| openai.com/research/verify | OpenAI SynthID | Google SynthID |
|
||||
|
||||
A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads
|
||||
clean in the Gemini oracle. They are different payloads within the same
|
||||
framework.
|
||||
|
||||
### 3.4 Video verification and attack harness
|
||||
|
||||
Gemini's built-in verification flow reports whether and where it detects Google
|
||||
SynthID in a video. This remains a proprietary oracle: invoke `@synthid`, use
|
||||
the supported content-verification question, and keep every file in a separate
|
||||
new chat. A normal Gemini answer that discusses visual clues or metadata is not
|
||||
an oracle verdict. Nor is an adversarial follow-up that asks the chat model to
|
||||
ignore and reinterpret a completed verifier result.
|
||||
|
||||
The research harness `scripts/video_synthid_sweep.py` tests a VAE regeneration
|
||||
attack without pretending to detect success locally. It emits:
|
||||
|
||||
1. a re-encode control using the same sampled frames, dimensions, frame rate,
|
||||
and codec as the candidates;
|
||||
2. VAE round-trip candidates with one spatial latent-noise field shared across
|
||||
time;
|
||||
3. paired PSNR and motion-compensated temporal-residual metrics;
|
||||
4. an empty oracle column for the external verdict.
|
||||
|
||||
The control is the first oracle submission. If it is not SynthID-positive, stop:
|
||||
the surrounding transcode already changed the verifier result. Only a
|
||||
control-positive, candidate-negative pair is evidence about the regeneration
|
||||
attack. PSNR and temporal residual measure fidelity and flicker, never watermark
|
||||
presence.
|
||||
|
||||
The shipped `video invisible` command and `remove_video_invisible` API reuse the
|
||||
same VAE regeneration mechanism for a complete input sequence. The shipped
|
||||
default is oracle-certified and does not expose a separate verification-status
|
||||
flag. In the 2026-07-29
|
||||
two-carrier calibration, both matched controls were positive in the built-in
|
||||
verifier; the stronger candidate was negative on both, while a weaker
|
||||
candidate was negative on one. A 2026-07-30 `UNAVAILABLE` response came from an
|
||||
ordinary-model follow-up that asked Gemini to reinterpret the already returned
|
||||
verdict and therefore did not invalidate it. The default is a calibrated,
|
||||
content-dependent operating point. A per-file provider check remains an
|
||||
optional audit after provider changes or for unusually important files.
|
||||
|
||||
The 2026-07-31 full-clip calibration used Google's public eight-second Veo
|
||||
off-road sample through the complete product command. The original was detected
|
||||
across 00:00-00:07, the `noise_std=0.10` output remained detected, and the
|
||||
`0.15` output was not detected. The positive `0.10` result proves that the
|
||||
surrounding 512 px / 12 fps / H.264 path did not create the negative result by
|
||||
itself. `0.15` is therefore the shipped default. The tracked manifest
|
||||
`data/evaluations/video-synthid-oracle.csv` records the public source URL,
|
||||
hashes, fidelity metrics, and verdicts without committing generated videos.
|
||||
|
||||
The VAE perturbation follows the general regeneration-attack construction from
|
||||
Zhao et al. The video-specific control and temporal metric are local additions.
|
||||
VideoMarkBench motivates testing frame aggregation and matched perturbations,
|
||||
but it does not evaluate Google's proprietary SynthID, so its findings cannot
|
||||
stand in for the Gemini oracle.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adoption and current state (as of June 2026)
|
||||
|
||||
### 4.1 Google products
|
||||
|
||||
Google has watermarked **over 10 billion** images and video frames. The
|
||||
deployment split by surface matters for our tool:
|
||||
|
||||
| Surface | SynthID pixel | C2PA metadata | Visible sparkle |
|
||||
|--------------------------------------|---------------|---------------|-----------------|
|
||||
| Gemini app (generated images) | YES | YES (Google) | YES |
|
||||
| Gemini API / AI Studio / Nano Banana | YES | NO | YES |
|
||||
|
||||
The Gemini API surface is a key blind spot: it embeds the pixel watermark and
|
||||
the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns
|
||||
Unknown on API-generated images unless the visible sparkle is detected (via
|
||||
`check_visible=True`) or the user runs the Gemini app oracle.
|
||||
|
||||
### 4.2 OpenAI
|
||||
|
||||
OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21):
|
||||
|
||||
> "ChatGPT images include both C2PA metadata and SynthID watermarks."
|
||||
|
||||
This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without
|
||||
SynthID. Our C2PA proxy therefore over-reports SynthID presence on old images
|
||||
(hence the `_OPENAI_CAVEAT` hedging flag in the codebase).
|
||||
|
||||
### 4.3 Other vendors
|
||||
|
||||
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
|
||||
- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline)
|
||||
- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead
|
||||
|
||||
### 4.4 Version evolution (v1 vs v2 hardening)
|
||||
|
||||
Google has not publicly documented version numbers for the SynthID image
|
||||
watermark in a way that maps to our testing observations. What is known
|
||||
empirically from oracle tests:
|
||||
|
||||
- **Before May 2026 (Gemini)**: strength 0.05 removed the watermark
|
||||
- **May 2026 (Gemini)**: strength 0.05 insufficient; 0.10 required
|
||||
- **Current (Gemini, June 2026)**: on the capped 1536 path, 0.05 and 0.10 do
|
||||
NOT clear; 0.15 clears (n=4, Gemini app oracle). See section 2.2.
|
||||
- **OpenAI (June 2026)**: clears at 0.05 across 1024-1600 (n=4, clean v0.8.6).
|
||||
The earlier "0.30 still detected on 1600x1600" report (issue #14) was the
|
||||
text-protection bug, not a hardening of the watermark -- see the correction in
|
||||
section 2.2.
|
||||
|
||||
Google has hardened SynthID relative to OpenAI's (vendor gap measured at ~3x
|
||||
strength), but the year-over-year "0.05 -> 0.10 -> 0.30" progression above
|
||||
conflates a real hardening trend with the now-debunked region-rescrub artifact;
|
||||
treat only the section 2.2 controlled numbers as authoritative.
|
||||
|
||||
---
|
||||
|
||||
## 5. Practical implications for this tool
|
||||
|
||||
### 5.1 Preserving content means regenerating it, never copying it
|
||||
|
||||
**Core rule:** SynthID is a pixel-amplitude pattern, so any approach that FREEZES
|
||||
or RESTORES original pixels in a region re-introduces the watermark there. Early
|
||||
region-based text/face "protection" (since removed) proved this: restoring the
|
||||
original face pixels guaranteed SynthID survived in faces, and even a per-region
|
||||
high-resolution re-scrub from an upscaled crop could be insufficient to destroy
|
||||
the payload, reconstituting SynthID in text. The lesson held and shaped the
|
||||
current design: **content is preserved by REGENERATING it under structural
|
||||
conditioning, never by copying original pixels.**
|
||||
|
||||
- **Text + structure:** `--pipeline controlnet` (SDXL img2img + a canny ControlNet) is
|
||||
**THE DEFAULT pipeline since 2026-06-09** (`--pipeline default` opts down to plain
|
||||
SDXL img2img for inputs without text/faces). It conditions the regeneration on the
|
||||
edge map, so text and structure stay sharp while every pixel is still regenerated. Text legibility is
|
||||
better than plain img2img at the same strength (text stays readable where plain
|
||||
garbles it). **BUT removal efficacy at the low vendor-adaptive strength is CONTENT ×
|
||||
PIPELINE dependent and NEITHER pipeline clears all content -- oracle-validated
|
||||
2026-06-04 (8 OpenAI images, strength 0.10/0.15, max-res 1536).** The survivors FLIP
|
||||
by content type: **photoreal** (a 9-face grid, a bracelet product photo) SURVIVES
|
||||
controlnet but CLEARS `default`; **flat graphic** (a logo/poster with large flat
|
||||
color fills) SURVIVES `default` but CLEARS controlnet; a flat **text** card cleared
|
||||
under both. Why: controlnet's dense edge map keeps the regen too close to the
|
||||
original on photoreal (so SynthID survives) but freely repaints flat fills (so it
|
||||
clears them); plain img2img at low strength perturbs photoreal texture enough but
|
||||
barely touches flat fills. **Root cause = insufficient STRENGTH, not the pipeline:
|
||||
the vendor-adaptive 0.10 is NOT universally sufficient (the June numbers below held
|
||||
for the content they were measured on). The robust fix is a HIGHER strength,
|
||||
oracle-revalidated per content type (controlnet can be cranked harder without losing
|
||||
structure; a lower `controlnet_conditioning_scale` also frees the regen on
|
||||
photoreal).** So neither `--pipeline controlnet` nor plain `default` is a drop-in
|
||||
removal guarantee at today's strength -- pick by what you must PRESERVE (controlnet
|
||||
for text/structure), then raise strength until the oracle reads clean. (The earlier
|
||||
"reads clean on the oracle" claim held only for the one flat/text-background case it
|
||||
was checked on; it does not generalize.) **UPDATE 2026-06-09: the default strengths
|
||||
were raised and made pipeline-aware (controlnet ladder = the certified
|
||||
0.20/0.30/0.30 floors, applied to BOTH pipelines as a single ladder -- see §5.2 for
|
||||
why one ladder covers plain `sdxl` too) and controlnet is now the default pipeline.
|
||||
The plain-SDXL profile was also renamed `default` -> `sdxl` (`default` stays as an
|
||||
alias). The 0.10/0.15 numbers in this analysis are the PRE-raise values it was
|
||||
measured at. See §5.2.**
|
||||
- **Highest-fidelity CUDA option:** `--pipeline qwen-zimage` is the recommended
|
||||
quality mode when preserving face identity matters more than latency, model size,
|
||||
and GPU cost. ControlNet remains the default because it is much cheaper and supports
|
||||
CUDA, XPU, MPS, and CPU, but canny conditioning preserves edges rather than identity.
|
||||
On two direct upstream comparisons, `qwen-zimage` retained substantially more
|
||||
ArcFace identity than polished ControlNet. On 2026-07-25 the exact six-output
|
||||
`visible -> qwen-zimage -> metadata` candidate was negative in the corresponding
|
||||
OpenAI and Gemini oracles. This is a quality recommendation for the measured content,
|
||||
not broad removal certification; very small text can still degrade.
|
||||
See `docs/known-limitations.md` for the metrics, runtime, and validation scope.
|
||||
- **Face identity:** canny holds face *structure* but not *identity*. The standard
|
||||
SDXL and ControlNet profiles do not run a separate face-restoration option.
|
||||
Earlier GFPGAN, PhotoMaker, and FaceID experiments were removed after they
|
||||
degraded identity or risked reintroducing source pixels. The separate
|
||||
`qwen-zimage` profile now provides the only shipped face-specific stage:
|
||||
YuNet and SAM locate faces, then Z-Image regenerates the selected original
|
||||
face crops before a feathered composite. See
|
||||
`docs/controlnet-removal-pipeline-research.md` for the historical experiments.
|
||||
|
||||
### 5.2 Strength setting
|
||||
|
||||
There is no single permanent correct strength, but the controlled June 2026
|
||||
study (section 2.2) gives empirical floors:
|
||||
|
||||
- **OpenAI**: 0.05 clears across 1024-1600 (n=4) -- **but content-dependent, NOT
|
||||
universal.** The follow-up oracle pass (2026-06-04, 8 images) found a flat-graphic
|
||||
OpenAI logo/poster still SynthID-detected after `default` at 0.10, and photoreal
|
||||
images still detected after controlnet at 0.10/0.15: at low strength the
|
||||
low-change regions (large flat fills under `default`, dense edges under controlnet)
|
||||
are not perturbed enough. So the 0.05 floor held only for the n=4 content it was
|
||||
measured on; treat it as a lower bound, not a guarantee, and raise + oracle-recheck
|
||||
per content type (see §5.1 controlnet bullet).
|
||||
- **Google (capped 1536)**: 0.15 (n=4); 0.05 and 0.10 do not clear.
|
||||
- **Google native 2816**: 0.15 clears (n=2, deployed controlnet worker, 2026-06-14) --
|
||||
the same rung as capped 1536, so no resolution penalty was observed.
|
||||
|
||||
The default is **vendor-adaptive** (`watermark_profiles.resolve_strength` +
|
||||
`vendor_for_strength`): the tool reads the C2PA issuer on the original input and picks
|
||||
`OPENAI_STRENGTH` 0.10 / `GEMINI_STRENGTH` 0.15 / `UNKNOWN_STRENGTH` 0.15 **(LOWERED
|
||||
2026-06-14 from the 2026-06-04 cert floors 0.20/0.30/0.30)**. **The SAME ladder applies
|
||||
to both pipelines** (`sdxl` and `controlnet`). The 2026-06-14 re-test on the deployed
|
||||
Modal controlnet worker (v0.10.0) cleared SynthID on the oracle at OpenAI 0.10 (2
|
||||
photoreal) and Google 0.15 (2 NATIVE 2816x1536, contradicting the "native >= 0.30" guess
|
||||
on line above), and a pixel sweep showed 0.20/0.30 over-regenerated for no efficacy gain.
|
||||
**This re-opens a genuine tension with the 2026-06-04 pass, which found photoreal STILL
|
||||
detected after controlnet at 0.10/0.15 (lines above):** either the v0.10.0 controlnet
|
||||
default improved the floor, or n=2 landed on the lucky side of the seed-non-determinism
|
||||
(§5.5). So a SERVICE on this ladder MUST pin a fixed, oracle-verified seed (not random),
|
||||
and flat-graphic hard cases (NOT in the n=2 re-test) still need a per-content oracle
|
||||
recheck -- raise `--strength` there. The prior cert floors are the §5.5 record. Why one ladder
|
||||
covers plain `sdxl` too: the certification was run on controlnet and does NOT transfer
|
||||
by symmetry (the two pipelines have OPPOSITE hard cases -- controlnet leaves SynthID on
|
||||
photoreal, `sdxl` on flat graphics, the §5.1 content-x-pipeline table), BUT on its own
|
||||
hard case (flat fills) `sdxl` is the WEAKER remover (plain img2img barely perturbs a
|
||||
flat region at low strength), so it needs AT LEAST controlnet's strength -- the
|
||||
certified floor is therefore the right floor for `sdxl` too. This is a MARGIN argument
|
||||
for `sdxl`, not a separate certification (no local SynthID detector to self-verify).
|
||||
The higher strength costs little quality where it matters, because `controlnet` is now
|
||||
the default pipeline, so `sdxl` is reached only via an explicit `--pipeline sdxl` (a
|
||||
deliberate opt-down), where over-regeneration has no faces/text to damage.
|
||||
This uses the vendor signal we DO have locally (the C2PA SynthID proxy) to avoid the
|
||||
overkill of a single high default on OpenAI images, without needing a local pixel
|
||||
detector. An explicit `--strength` always wins. If the watermark still survives (e.g. a
|
||||
large native Gemini beyond the capped-1536 validation), raise toward 0.35-0.40 (0.40
|
||||
visibly corrupts dense text), using the lowest value that reads clean on the oracle.
|
||||
|
||||
**qwen-zimage global denoise, Gemini boundary bracketed (2026-08-02).** The profile does
|
||||
not use the vendor ladder above; `resolution_adaptive_denoise` maps megapixels onto
|
||||
roughly 0.084 (sub-0.3 MP) to 0.154 (>= 3.7 MP). A ladder on one native 2816x1536 Gemini
|
||||
original, seed 0, everything else at profile defaults, verified through the Gemini app:
|
||||
|
||||
| global denoise | Gemini app | whole-image PSNR | face-box PSNR | edge IoU |
|
||||
|---|---|---|---|---|
|
||||
| 0.154 (profile top) | clean | 24.72 | 31.19 | 0.188 |
|
||||
| 0.12 | clean | 25.65 | 31.82 | 0.202 |
|
||||
| 0.10 | **clean** | 26.26 | 32.17 | 0.212 |
|
||||
| 0.08 | **SynthID FOUND** | 26.95 | 32.51 | 0.227 |
|
||||
|
||||
So the boundary sits between 0.08 and 0.10 for this image, and the profile's shipped
|
||||
0.154 carries roughly half a rung more strength than that content needed. Fidelity rises
|
||||
monotonically all the way down - dropping to 0.10 buys **+1.54 dB whole-image and
|
||||
+0.98 dB inside the face boxes** - which is exactly why the temptation is to move the
|
||||
ceiling, and exactly why one fixture is not enough to do it.
|
||||
|
||||
Two constraints on reading this:
|
||||
|
||||
- **It brackets, it does not calibrate.** One image, one seed. Shipping the lowest clean
|
||||
rung means shipping at the measured cliff edge; another sample, seed, or content class
|
||||
can sit on the other side of it. Note §5.2's flat-graphic hard cases were not in this
|
||||
set at all.
|
||||
- **The bottom of the curve was the untested end. It has now been measured, and it
|
||||
holds.** Every Gemini oracle fixture is 2816x1536, so the Google-side certification
|
||||
only ever covered 0.154, while `resolution_adaptive_denoise` sends sub-1 MP images to
|
||||
0.084-0.094 - at or below the rung that failed at 4.33 MP. Downscaling a Gemini
|
||||
original (valid test material: SynthID survives it by design) and running the deployed
|
||||
worker on it gives, through the Gemini app:
|
||||
|
||||
| processing size | profile denoise | Gemini app |
|
||||
|---|---|---|
|
||||
| 1024x559 (0.57 MP) | 0.0896 | clean |
|
||||
| 1600x873 (1.40 MP) | 0.1066 | clean |
|
||||
|
||||
So production's own low end clears Google, and the "small images are under-processed"
|
||||
failure mode is ruled out at these two sizes. **Read this as validation of the shipped
|
||||
curve, not as proof that the boundary moves with resolution.** 0.0896 sits inside the
|
||||
untested gap at 4.33 MP, where only 0.08 (found) and 0.10 (clean) were probed, so it
|
||||
may well clear at both sizes. The direction of any resolution dependence remains
|
||||
unproven (§5.5). Also note these are downscales of a 2816x1536 original rather than
|
||||
natively small Gemini outputs, which have still never been tested.
|
||||
|
||||
### 5.3 Test methodology
|
||||
|
||||
- **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
|
||||
subjects.** JPEG re-encoding removes C2PA metadata but does NOT remove the
|
||||
SynthID pixel watermark (verified June 2026 on issue #14 pic3). Do not
|
||||
dismiss these as "not faithful originals" for SynthID-removal tests.
|
||||
- **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini
|
||||
app. The two oracles detect different payloads.
|
||||
- **A quiet `identify` output after processing is not proof of removal.** It
|
||||
means the metadata proxy is gone. The pixel watermark state is unknown without
|
||||
an oracle check.
|
||||
- **After removal, the output may carry forensic artifacts** detectable by an
|
||||
independent classifier even if the vendor oracle reads negative. Defeating the
|
||||
verifier is not the same as being forensically indistinguishable from clean
|
||||
content (arXiv:2605.09203).
|
||||
|
||||
### 5.4 Strength vs forensic detectability: the tradeoff
|
||||
|
||||
Higher img2img strength removes the watermark but introduces detectable
|
||||
regeneration artifacts. The Goonatilake & Ateniese paper shows the strongest
|
||||
diffusion-based removers are simultaneously the most forensically detectable
|
||||
(AUROC up to 0.9999). The tradeoff is unavoidable with current diffusion-based
|
||||
approaches: defeating the vendor's verifier is not the same as being clean.
|
||||
|
||||
### 5.5 Oracle validation log -- 2026-06-04 OpenAI pass
|
||||
|
||||
Eight OpenAI `gpt-image` originals run through both pipelines and checked on
|
||||
openai.com/verify (the OpenAI SynthID oracle). `--max-resolution 1536`; strength
|
||||
is the vendor-adaptive default (`vendor_for_strength`): images with an OpenAI C2PA
|
||||
manifest get `OPENAI_STRENGTH` 0.10, the one without C2PA falls to
|
||||
`UNKNOWN_STRENGTH` 0.15. "detected" = SynthID still found (removal FAILED);
|
||||
"clean" = SynthID not detected.
|
||||
|
||||
| image | content type | size | strength | `--auto`/controlnet | `default` |
|
||||
|---|---|---|---|---|---|
|
||||
| typography card | flat text | 1122x1402 | 0.10 | clean | clean |
|
||||
| Flat poster | flat graphic (logo + flat fills) | 1024x1536 | 0.10 | clean | **detected** |
|
||||
| 9-face grid | photoreal | 1448x1086 | 0.10 | **detected** | clean |
|
||||
| bracelet product photo | photoreal | 1600x1600 | 0.15 | **detected** | clean |
|
||||
|
||||
(The other four cleared under both and are omitted.) **Reading:** at this strength
|
||||
NEITHER pipeline removes SynthID on all content -- the survivors flip by content
|
||||
type. Photoreal survives controlnet / clears `default`; flat graphic survives
|
||||
`default` / clears controlnet; flat text clears both.
|
||||
|
||||
**Follow-up: removal near the threshold is NON-DETERMINISTIC (seed-dependent).**
|
||||
Re-running the two photoreal survivors through controlnet at an explicit
|
||||
`--strength 0.15` (`--auto`, same `--max-resolution 1536`) cleared BOTH on the
|
||||
oracle (SynthID not detected). But the bracelet had SURVIVED controlnet at the
|
||||
SAME 0.15 in the first pass (it was the no-C2PA image, so its vendor-adaptive
|
||||
strength was already 0.15) -- same pipeline + strength + resolution, only the
|
||||
random (unset) seed differed between runs. So **0.15 is the borderline floor for
|
||||
controlnet photoreal, not a robust guarantee**: at the threshold the same
|
||||
image+settings can pass or fail run-to-run. img2img runs with `seed=None` (random)
|
||||
unless `--seed` is passed, so a removal SERVICE gets a coin-flip near threshold and
|
||||
has no local SynthID detector to self-verify.
|
||||
|
||||
**Controlnet strength ladder on the two photoreal images (oracle, `--auto`,
|
||||
`--max-resolution 1536`):**
|
||||
|
||||
| controlnet strength | 9-face grid | bracelet photo |
|
||||
|---|---|---|
|
||||
| 0.10 | detected | (was 0.15) |
|
||||
| 0.15 | clean | **non-deterministic** (survived pass 1, clean pass 2) |
|
||||
| **0.20** | **clean** | **clean** |
|
||||
|
||||
**Recommended robust controlnet strength = 0.20** (0.05 of margin above the 0.15
|
||||
non-deterministic borderline); both photoreal survivors cleared at 0.20. Honest
|
||||
caveat: 0.20 is one confirming run WITH margin, not an N-run repeatability proof --
|
||||
for a removal service, add a little more margin or validate repeatability, since
|
||||
there is no local SynthID detector to self-check. **Implications:** (1) the
|
||||
content×pipeline table above conflates a borderline/non-deterministic 0.15 result
|
||||
with deterministic content behavior -- the photoreal-survives-controlnet effect is
|
||||
solid at 0.10 but at 0.15 it is near-threshold noise; (2) for reliable removal pick
|
||||
a strength with MARGIN above the borderline (controlnet >= 0.20), not exactly on
|
||||
it; (3) **historical engineering conclusion:** this dated run argued for a
|
||||
higher ControlNet strength than the then-current default. That proposal was
|
||||
later superseded. The current resolver intentionally shares the 0.10/0.15
|
||||
ladder between SDXL and ControlNet and uses a separate Qwen ladder; see
|
||||
`_internal/watermark_profiles.py`.
|
||||
Source images are private (faces / product shots), not committed; reproduce on any
|
||||
photoreal + flat-graphic gpt-image pair, varying the seed, and re-checking the
|
||||
oracle.
|
||||
|
||||
**Gemini pass + the face-restore re-introduction (2026-06-04).** Four Gemini
|
||||
originals via the then-current `--auto` ControlNet path at `--max-resolution 1024`,
|
||||
checked on the
|
||||
Gemini "Verify with SynthID" oracle (Google content needs the Google oracle, not
|
||||
openai.com/verify):
|
||||
- Most cleared at controlnet 0.15-0.25; `gemini_3` (a large central FACE, +restore)
|
||||
stayed **SynthID-detected at controlnet 0.15, 0.20 AND 0.25** -- raising strength
|
||||
did not crack it.
|
||||
- **Root cause was the face-restore pass, not strength/resolution.** `gemini_3` at
|
||||
controlnet 0.20 with `--no-restore-faces` read **SynthID-NOT-detected** (clean
|
||||
A/B, only restore differed). GFPGAN runs on the ORIGINAL watermarked face and at
|
||||
weight 0.5 blends ~half its pixels back, re-introducing SynthID into the
|
||||
composited face over the diffusion-cleaned result (see §5.1 face-identity bullet).
|
||||
- (Side note: reducing the processing resolution does NOT weaken SynthID -- it is
|
||||
robust to downscaling by design, so 1024 was never the wall. Whether a lower
|
||||
processing resolution then needs more or less removal strength is NOT established;
|
||||
see the note below.)
|
||||
|
||||
**Historical controlnet certification, superseded by the current vendor-adaptive
|
||||
defaults (isolated GPU sweep + oracle,
|
||||
restore OFF, <= 1536, each vendor on its own oracle):** OpenAI **0.20** (2 photoreal x
|
||||
seed {1,2,3} = 6/6 clean; the 0.15-flipper is seed-robust at 0.20) and Gemini **0.30**
|
||||
(0.20 detected -> 0.30 clean on 2/2 seeds). Both were measured at <= 1536 only. See
|
||||
`docs/controlnet-removal-pipeline-research.md` for the table.
|
||||
|
||||
**Whether Gemini removal is resolution-sensitive is UNPROVEN, in either direction.**
|
||||
This document previously asserted it was, and recommended capping Gemini at 1536 with
|
||||
0.30 or "native-calibrating" to ~0.35+. Nothing measured that. The one relevant
|
||||
measurement points the other way: the 2026-06-14 deployed-worker re-test cleared Gemini
|
||||
at **0.15 on two NATIVE 2816x1536 images**, the same rung as capped 1536. So there is no
|
||||
observed native-resolution penalty, and no observed benefit either -- the low-resolution
|
||||
end has simply never been through the Gemini oracle on any pipeline. Do not reason from
|
||||
a resolution trend here; measure it.
|
||||
|
||||
**Current implication:** the old floor table remains evidence about the dated
|
||||
test set, not the current resolver. The shipped SDXL and ControlNet defaults are
|
||||
defined in `watermark_profiles.py`, and face restoration is available only
|
||||
through the separate `qwen-zimage` profile. Removal near a threshold remains
|
||||
seed dependent, so reproducible verification requires a fixed seed.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. Gowal et al. (2025). **SynthID-Image: Image watermarking at internet scale.**
|
||||
arXiv:2510.09263. https://arxiv.org/abs/2510.09263
|
||||
|
||||
2. Google DeepMind. **Identifying AI-generated images with SynthID.** Blog post,
|
||||
2023. https://deepmind.google/blog/identifying-ai-generated-images-with-synthid/
|
||||
|
||||
3. Google DeepMind. **SynthID.** Product page.
|
||||
https://deepmind.google/models/synthid/
|
||||
|
||||
4. Goonatilake & Ateniese (2026). **Removing the Watermark Is Not Enough:
|
||||
Forensic Stealth in Generative-AI Watermark Removal.** arXiv:2605.09203.
|
||||
https://arxiv.org/abs/2605.09203
|
||||
|
||||
5. OpenAI. **Verify tool for AI-generated images.** openai.com/research/verify.
|
||||
Accessed 2026-05-31.
|
||||
|
||||
6. Google. **Verify AI-generated images, videos, and audio.**
|
||||
https://support.google.com/gemini/answer/16722517
|
||||
|
||||
7. Zhao et al. (2024). **Invisible Image Watermarks Are Provably Removable
|
||||
Using Generative AI.** NeurIPS 2024, arXiv:2306.01953.
|
||||
https://arxiv.org/abs/2306.01953
|
||||
|
||||
8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video
|
||||
Watermarking.** arXiv:2505.21620.
|
||||
https://arxiv.org/abs/2505.21620
|
||||
@@ -0,0 +1,143 @@
|
||||
# Text protection research: crisp text under a "watermark removed everywhere" constraint
|
||||
|
||||
> Research archive. This page records evaluated ideas, including rejected
|
||||
> prototypes. Current behavior is documented in the user guides and source.
|
||||
|
||||
Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources
|
||||
fetched and 3-vote adversarially verified). Not committed automatically — saved as a
|
||||
research note for the next session.
|
||||
|
||||
## The constraint that frames everything
|
||||
|
||||
The invisible watermark (Google SynthID) must be removed **everywhere, including inside
|
||||
text regions**. Therefore any technique that keeps or composites the **original
|
||||
(watermarked) text pixels** is disqualified — the text must be *regenerated / freshly
|
||||
synthesized* enough to scrub the watermark, yet rendered crisply. This single rule is the
|
||||
filter applied to every candidate below.
|
||||
|
||||
## Problem recap
|
||||
|
||||
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
|
||||
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
|
||||
current threshold for fresh Gemini output); higher strength deforms text more, which is
|
||||
exactly why text protection matters. Text is protected via Differential Diffusion with a
|
||||
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
|
||||
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
|
||||
garbles** (issue #14, confirmed on real content).
|
||||
|
||||
## Executive summary
|
||||
|
||||
The fine-text softening is an **architectural consequence of latent-space processing, not
|
||||
a tuning problem**: SDXL's 4-channel VAE (~48x compression) discards high-frequency signal
|
||||
on encode, and Differential Diffusion blends in latent space with the change map
|
||||
downsampled by 8x, so any stroke under ~8 px sits inside one latent cell and cannot be
|
||||
preserved or edited cleanly **regardless of `preserve`** (the Differential Diffusion
|
||||
authors state this limit explicitly). Two structurally sound directions keep the
|
||||
"watermark removed everywhere" guarantee because they **synthesize fresh glyph pixels**
|
||||
rather than compositing originals: (1) glyph/text-conditioned diffusion re-render of
|
||||
detected text (AnyText2, EasyText), and (2) a two-stage architecture — global scrub, then
|
||||
a dedicated text-restoration / text-aware super-resolution pass over detected regions
|
||||
(TIGER, TextSR, TeReDiff/TAIR). **EasyText** and **TextSR** are the most promising for this
|
||||
CJK-first pipeline (both multilingual via DiT/ByT5, both regenerate from glyph or
|
||||
character-shape priors). The deepest fix — a 16-channel (SD3/FLUX) VAE — materially reduces
|
||||
the softening but means switching the base model, not a drop-in VAE swap.
|
||||
|
||||
## Constraint reconciliation (important)
|
||||
|
||||
The generic research "quick win: bump `preserve` toward 1.0" is **invalid under our hard
|
||||
constraint**: raising `preserve` freezes the text region, so SynthID there is **not
|
||||
scrubbed**. Likewise, pixel paste-back of the original text is disqualified. The only
|
||||
constraint-compatible quick win is **higher resolution / tiled diffusion** (strokes span
|
||||
more latent cells, less VAE softening, while the text is still fully regenerated and thus
|
||||
scrubbed). The real answer is **regenerate text crisply**, not freeze it.
|
||||
|
||||
## Findings (with confidence and sources)
|
||||
|
||||
### Finding 1 — confidence: high
|
||||
|
||||
**Claim.** The small-text softening is an architectural latent-space limit, not a tuning issue. SDXL's VAE compressively encodes (losing exact color and fine detail on every round-trip), and Differential Diffusion blends in latent space with the change map downsampled to latent resolution (8x), so the method explicitly caps edit/preserve granularity at ~8 px under SD settings. Text strokes below one latent cell cannot be cleanly preserved even at preserve ~0.9.
|
||||
|
||||
**Evidence.** Differential Diffusion's paper states a "cap on the resolution of the change map ... can limit the ability to precisely edit small objects (less than 8 pixels for Stable-Diffusion's settings)"; the official SDXL pipeline downsamples the map by `vae_scale_factor=8` and blends `latents = original*mask + latents*(1-mask)` in latent space. The VAE encode is "compressive ... exact color qualities and exact visual fine-details are lost." arXiv:2512.05198 confirms "resizing the pixel mask to latent resolution discards fine structure ... downsamples by 1/8" and that linear latent blending "cannot be pixel-equivalent." Higher compression = more high-frequency loss (arXiv:2305.02541).
|
||||
|
||||
**Sources.** https://onlinelibrary.wiley.com/doi/10.1111/cgf.70040 · https://differential-diffusion.github.io/ · https://github.com/exx8/differential-diffusion · https://arxiv.org/abs/2512.05198 · https://omriavrahami.com/blended-latent-diffusion-page/ · https://arxiv.org/pdf/2305.02541
|
||||
|
||||
### Finding 2 — confidence: low (do not build on it yet)
|
||||
|
||||
**Claim.** Pixel-space differential / blended-latent variants exist as a research direction, but the specific full-resolution-mask solution (PELC/DecFormer, arXiv:2512.05198) was NOT verified to deliver its claimed seam/edge improvements.
|
||||
|
||||
**Evidence.** arXiv:2512.05198 argues linear latent blending is not pixel-equivalent and proposes decoder-equivariant compositing; PixPerfect (arXiv:2512.03247) does pixel-space refinement of chromatic shifts at edit boundaries. But the specific PELC full-resolution-mask and DecFormer "53% error reduction" claims were **refuted on adversarial vote (0-3 and 1-2)**. Treat pixel-equivalent latent compositing as an emerging idea to watch, not a production fix.
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2512.05198 · https://arxiv.org/abs/2512.03247
|
||||
|
||||
### Finding 3 — confidence: high
|
||||
|
||||
**Claim.** Glyph/text-conditioned diffusion can re-render detected text as freshly synthesized pixels (not copied), which inherently scrubs any watermark in the text region while rendering glyphs crisply. AnyText/AnyText2 inject text-rendering into a pretrained T2I model and support generation AND editing of existing scene images; multilingual including CJK and English.
|
||||
|
||||
**Evidence.** AnyText2 "enables precise control over multilingual text attributes in natural scene image generation and editing" (WriteNet+AttnX); +3.3% (Chinese) / +9.3% (English) accuracy over AnyText v1. AnyText "can be plugged into existing diffusion models ... for rendering or editing text" and synthesizes text latent features through diffusion (fresh pixels), supporting zh/en/ja/ko/ar/bn/hi. **Caveat:** both are SD1.5-based, so NOT a drop-in into the SDXL scrub (separate base model); AnyText's own limitation: "the inpainting manner ... impedes editing quality on small text," and it ranks weak on STRICT (EMNLP 2025) — small-text crispness not guaranteed.
|
||||
|
||||
**Sources.** https://github.com/tyxsspa/AnyText2 · https://arxiv.org/abs/2411.15245 · https://arxiv.org/abs/2311.03054
|
||||
|
||||
### Finding 4 — confidence: high
|
||||
|
||||
**Claim.** EasyText is a strong glyph-conditioned re-render candidate: built on the FLUX-dev DiT framework with LoRA tuning, renders compact per-character glyph patches (64px-high adaptive for alphabetic, 64x64 for logographic) concatenated in latent space, supports 10+ languages including Chinese, Japanese, Korean, Thai, Vietnamese, Greek, and Latin.
|
||||
|
||||
**Evidence.** AAAI 2025 + arXiv:2505.24417: "implemented based on the open-source FLUX-dev framework with LoRA-based parameter-efficient tuning," VAE and text encoder frozen, two-stage 512->1024 training. Glyph conditioning via "64-pixel-high images ... adaptive widths for alphabetic; fixed 64x64 for logographic," VAE-encoded and concatenated with denoised latents, "less than one-tenth the spatial size of layout-matching methods." FLUX-based (16-channel VAE, DiT) also sidesteps the SDXL 4-channel wall. Fresh-pixel generation preserves the watermark-removal guarantee. Cyrillic/Arabic crispness not separately benchmarked.
|
||||
|
||||
**Sources.** https://arxiv.org/html/2505.24417 · https://ojs.aaai.org/index.php/AAAI/article/view/37697
|
||||
|
||||
### Finding 5 — confidence: high
|
||||
|
||||
**Claim.** A two-stage "global watermark scrub then text-restoration pass" architecture is validated by recent literature, and the restoration stage can synthesize glyph pixels from priors (no original-pixel reintroduction). TIGER reconstructs stroke geometry then injects it as guidance into full-image super-resolution; TextSR uses a detector + multilingual OCR to regenerate text from character-shape priors; TeReDiff/TAIR couples a jointly-trained text-spotter with diffusion.
|
||||
|
||||
**Evidence.** TIGER (arXiv:2510.21590): "a diffusion-based local text refiner ... reconstructing fine-grained stroke geometry ... injected as conditional guidance into the subsequent full-image restoration." TextSR (arXiv:2505.23119, Google): "leverages a text detector ... then employs OCR to extract multilingual text," regenerating from "multilingual character-to-shape diffusion priors" that "produce character shapes solely based on text prompts, even without visual input" — fresh pixels. TAIR/TeReDiff (ICLR 2026): standard restoration "frequently generates plausible but incorrect textures"; TeReDiff feeds text-spotter outputs back as prompts. **Caveat:** TIGER orders text-first then global (reverse of scrub-then-text); these target degraded-input super-resolution, not watermark removal, so the SynthID-scrub of the restoration stage must be verified empirically (the stages are themselves diffusion-based, so fresh-pixel = no SynthID is plausible but unproven here).
|
||||
|
||||
**Sources.** https://arxiv.org/html/2510.21590v1 · https://arxiv.org/html/2505.23119v1 · https://cvlab-kaist.github.io/TAIR/ · https://arxiv.org/abs/2506.09993
|
||||
|
||||
### Finding 6 — confidence: high
|
||||
|
||||
**Claim.** Switching to a 16-channel VAE (SD3/FLUX class) materially reduces small-text/latent softening vs SDXL's 4-channel VAE, but it requires switching the base model — not a drop-in latent swap into an SDXL UNet img2img pipeline. RAE approaches are DiT-native and likewise not drop-in.
|
||||
|
||||
**Evidence.** SD3/FLUX moved from 4-channel (48x) to 16-channel (12x) VAEs specifically to preserve fine detail (diffusers Discussion #8713; madebyollin VAE notes; arXiv:2305.02541). RAE (arXiv:2510.11690) "should be the new default for diffusion transformer training" but produces high-dimensional latents needing a DiT wide-DDT head — NOT compatible with an SDXL 4-channel UNet. EasyText shows the practical path: adopt a FLUX-DiT base rather than retrofit SDXL. The VAE upgrade couples to a base-model migration.
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2510.11690 · https://arxiv.org/pdf/2305.02541 · https://arxiv.org/html/2505.24417
|
||||
|
||||
## Recommendation
|
||||
|
||||
Under the hard constraint, the correct architecture is **not "protect text during the
|
||||
scrub" (Differential Diffusion)** but **"scrub everywhere, then restore text crisply by
|
||||
regeneration"**:
|
||||
|
||||
1. Global SDXL scrub with text protection OFF (text region is scrubbed too).
|
||||
2. On detected text regions, a **glyph-conditioned restoration** that re-renders the same
|
||||
glyphs as fresh pixels (no original reused).
|
||||
|
||||
This is the only path that delivers both "watermark everywhere" and crisp text.
|
||||
|
||||
**Top-2 to prototype:**
|
||||
- **TextSR** — detector + multilingual OCR + character-shape diffusion priors; closest to
|
||||
the existing detector-driven pipeline.
|
||||
- **EasyText** — FLUX-DiT glyph re-render, multilingual incl. CJK; also gets the 16-channel
|
||||
VAE for free.
|
||||
|
||||
**Honest costs / unknowns:** this is a re-architecture, not a quick fix. It needs a new
|
||||
**OCR-recognition** step (we currently only detect text; we must know *what* to re-render).
|
||||
Models are FLUX/DiT-class (heavy) -> serverless GPU. Maturity is research-grade; CJK is
|
||||
covered, Cyrillic/Arabic crispness is not separately benchmarked -> a prototype must
|
||||
measure real fidelity. The restoration stage being diffusion-based makes "fresh pixels =
|
||||
no SynthID" plausible but **must be verified empirically** (run the SynthID oracle on the
|
||||
restored output).
|
||||
|
||||
**Constraint-compatible quick win to try first:** run the global scrub at **higher
|
||||
resolution / tiled** so strokes exceed the latent cell — less softening, full scrub, no
|
||||
freezing. Cheap to test; quantify recall/quality vs cost.
|
||||
|
||||
**Do not pursue:** raising `preserve` toward 1.0 or pixel paste-back (both leave original
|
||||
watermarked pixels in text); PELC/DecFormer pixel-equivalent latent compositing (refuted,
|
||||
not production-ready).
|
||||
|
||||
## Provenance
|
||||
|
||||
Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversarially verified
|
||||
(2/3 refutes required to kill a claim). This note records research only; no code change is
|
||||
implied until a prototype validates fidelity and the SynthID-scrub guarantee on the
|
||||
restored output.
|
||||
@@ -0,0 +1,359 @@
|
||||
# Full verification plan
|
||||
|
||||
> Verification log and historical plan. Dated results describe the code and
|
||||
> datasets at the time of each run. They are evidence for maintainers, not a
|
||||
> current CLI contract. Recheck open items against the current code and issue
|
||||
> tracker before treating them as work that remains.
|
||||
|
||||
How we convince ourselves the library actually works, across its whole surface, on real data.
|
||||
|
||||
This is the pre-release and periodic-audit plan. It is deliberately organized by **oracle
|
||||
strength** rather than by module, because the hard part is never "call the function" -- it
|
||||
is "know what the right answer was". A sweep with no oracle proves only that nothing threw.
|
||||
|
||||
Performance depends on format, enabled extras, and hardware. Measure it locally with representative inputs; do not publish private dataset sizes or run statistics.
|
||||
|
||||
## Public test sources
|
||||
|
||||
| source | role |
|
||||
|---|---|
|
||||
| `data/synthid/` | labeled SynthID oracle fixtures and evaluation manifest |
|
||||
| `data/fixtures/provenance/` | deterministic format and provenance fixtures |
|
||||
| `data/calibration/<vendor>/` | minimal controlled inputs used to derive detection silhouettes |
|
||||
| synthesized inputs | constructed ground truth for pure and format-level checks |
|
||||
|
||||
## Tier A -- self-evident oracles (full corpus, unattended)
|
||||
|
||||
Properties that are true or false without anyone labelling anything. These are the
|
||||
backbone: they scale to large local datasets and catch regressions with zero human cost.
|
||||
|
||||
### A1. Recorded-verdict regression
|
||||
|
||||
A dated local cache of `identify` verdicts can be replayed against the same
|
||||
inputs to detect drift in verdict, platform, confidence, or signal set.
|
||||
|
||||
Caveat that makes this honest: a diff is not automatically a bug -- the sidecars were
|
||||
written by older versions, so intended improvements also show up. The output is therefore a
|
||||
**classified diff** (new detections / lost detections / changed platform / changed
|
||||
confidence), reviewed once, then re-baselined. Lost detections are the alarm.
|
||||
|
||||
Implemented as `scripts/sidecar_regression.py` (resumable, ~1.5 h at 8 workers).
|
||||
|
||||
#### Local run protocol
|
||||
|
||||
Re-run `identify` against locally recorded sidecars, classify losses separately from intended new detections, and keep generated reports under `.local-eval/`. Do not commit dataset-derived counts or identifiers.
|
||||
|
||||
### A2. Parity: whatever we detect, we must be able to remove
|
||||
|
||||
For every image where a signal fires: remove, re-scan with the same oracle, assert quiet.
|
||||
- metadata: `scripts/metadata_removal_audit.py`;
|
||||
- visible: `scripts/visible_removal_audit.py`, once per backend.
|
||||
|
||||
Detection does not depend on the fill backend. Run `scripts/visible_positives.py` once
|
||||
and pass its output through the audit's `--paths-file` option.
|
||||
|
||||
#### Local parity protocol
|
||||
|
||||
Run detection, removal, and re-detection over a representative local set. Confirm that decoded pixels remain unchanged for metadata-only operations. Keep reports untracked.
|
||||
|
||||
### A3. Byte-level invariants
|
||||
|
||||
- no-op `remove_visible` returns the ORIGINAL bytes (not a re-encode)
|
||||
- pixels outside the fill mask are bit-identical to the input
|
||||
- JPEG metadata strip is pixel-lossless on the DEFAULT path (`--remove-all` re-encodes by
|
||||
design -- see `metadata.py`; assert the split, not losslessness everywhere)
|
||||
- lossless source formats survive a misnamed extension
|
||||
|
||||
### A4. Idempotence and order-independence
|
||||
|
||||
- `remove_visible(remove_visible(x)) == remove_visible(x)`
|
||||
- `strip(remove(x)) == remove(strip(x))` in signal terms
|
||||
- a second `identify` on a cleaned output reports no metadata signals
|
||||
|
||||
### A5. Contract sweep across every parameter choice
|
||||
|
||||
`scripts/smoke_matrix.py` (exists, 68 rows, 0 skipped with `--diffusion`) covers every
|
||||
choice-valued flag on fixtures. Extend from fixtures to a stratified corpus slice
|
||||
(~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
|
||||
rather than just absence of crash.
|
||||
|
||||
Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal,
|
||||
Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
|
||||
sweep asserts on stderr, or the codes get split -- the latter is the better fix.
|
||||
|
||||
#### Coverage method
|
||||
|
||||
Compare the flags and values exercised by the matrix against the options declared by
|
||||
the CLI. Include optional backends, batch modes, tiling, region-targeted composition,
|
||||
the ESRGAN upscaler, and the ffmpeg audio/video strip. The gap to find is not only
|
||||
"logic untested" but
|
||||
"never executed on real data", which is precisely what this campaign is for.
|
||||
|
||||
#### Bug found by the extension: `--steps` below ~7 crashes inside torch
|
||||
|
||||
Effective timesteps are `int(steps * strength)`. At the vendor-adaptive default strength
|
||||
(0.15, or 0.10 for OpenAI) any `--steps` under 7 rounds to **zero**, and the pipeline dies
|
||||
with a raw traceback:
|
||||
|
||||
```
|
||||
$ remove-ai-watermarks invisible img.png --steps 5
|
||||
RuntimeError: cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]
|
||||
```
|
||||
|
||||
Fully valid CLI arguments, no special flags, no `--force`. The value is accepted, the
|
||||
crash is a torch internal, and nothing tells the user that steps and strength interact.
|
||||
Fix is either a clamp to >=1 effective step or an up-front validation naming both values.
|
||||
|
||||
Method note: the first run of the knob rows failed 12 times with this identical error,
|
||||
which read like twelve broken features. It was one bad harness parameter (`--steps 4`)
|
||||
sitting on top of one real bug. An error that is IDENTICAL across unrelated rows is
|
||||
evidence of a common cause, not of many faults -- check the shared input first.
|
||||
|
||||
## Tier B -- constructed ground truth (automatable, no labelling)
|
||||
|
||||
Where reality gives no answer key, build one. This is the tier that closes the two biggest
|
||||
holes: fill quality and detector response at the edge of the operating range.
|
||||
|
||||
### B1. Fill quality with a true reference
|
||||
|
||||
Real marks have no clean counterpart, so quality has only ever been eyeballed. Construct it
|
||||
instead: take a clean corpus image, stamp a known mark at a known position (the captured
|
||||
alpha maps make this exact), remove it, and compare against the **true original**.
|
||||
|
||||
Yields PSNR / SSIM per `--backend` (cv2 / migan / lama), sliced by background class, which
|
||||
is exactly the axis where the docs say quality varies but no number exists.
|
||||
|
||||
Implemented as `scripts/fill_quality.py`. Two reporting rules are load-bearing: score
|
||||
INSIDE the footprint (whole-frame PSNR sits near 60 dB whatever the backend does), and use
|
||||
the MEDIAN (a fill that reproduces a flat background exactly scores PSNR=inf, and one inf
|
||||
makes a mean inf -- the first run reported "+inf" for every flat bucket).
|
||||
|
||||
#### Local fill-quality protocol
|
||||
|
||||
Construct marked images from clean local references, compare each backend against the known original inside the affected footprint, and keep the report under `.local-eval/`.
|
||||
|
||||
### B2. Detector response curves
|
||||
|
||||
Use `scripts/detector_response.py` to sweep mark size, opacity, background, and aspect. Report detection and maskability separately. Generated reports stay under `.local-eval/`.
|
||||
|
||||
### B3. Invisible round-trip, positive-control gated
|
||||
|
||||
The open DWT-DCT detector is positive-only and carrier-fragile: "not found" on a fragile
|
||||
carrier proves nothing (measured: `chatgpt-1.png` recovers 114/128, below the 118 gate).
|
||||
Every invisible assertion must first embed on the SAME carrier and confirm recovery, and
|
||||
**degrade to a skip rather than a pass** when the control fails. Already implemented in
|
||||
`smoke_matrix.py`; apply the same discipline anywhere else this detector is used.
|
||||
|
||||
### B4. Resource ceilings
|
||||
|
||||
Peak RSS and wall time per backend x input size, up to 25 MP. The memory-constrained CPU
|
||||
tier is a real constraint (MI-GAN must stay ~0.6-0.9 GB by cropping around the mask); a
|
||||
regression here is invisible today and would only surface under load.
|
||||
|
||||
## Tier C -- human-labelled accuracy (bounded by labelling effort)
|
||||
|
||||
The machinery exists: `visible_recall_sample.py` -> `visible_sheets.py` ->
|
||||
`visible_groundtruth.py` -> `visible_eval.py`.
|
||||
|
||||
- **Recall** needs representative sampling per mark and aspect ratio.
|
||||
- **Precision** must be benchmarked before and after every detector change with
|
||||
`--vs <snapshot>`.
|
||||
- **Coverage** is separate from tuning. A missing vendor detector cannot be repaired by
|
||||
changing another detector's threshold.
|
||||
|
||||
Three harness rules are load-bearing and must not be relaxed: score a mark only within its
|
||||
crop's adjudication scope; take provenance from metadata, never from labels; and never
|
||||
report recall from the detector-sampled set.
|
||||
|
||||
## Tier D -- external oracles (manual, not automatable here)
|
||||
|
||||
SynthID removal cannot be verified locally by design -- no public decoder exists. Each
|
||||
vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for
|
||||
OpenAI (more accessible, the automation candidate), the Gemini app for Google (manual,
|
||||
rate-limited). A quiet metadata proxy is **not** proof the pixel watermark is gone.
|
||||
|
||||
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
|
||||
that is all it can do. See `docs/synthid.md`.
|
||||
|
||||
### D1. Sampling frame
|
||||
|
||||
Select a representative local sample without committing images, identifiers, dataset sizes, or oracle results. Stratify by signal family and preserve the sampling method outside the public repository.
|
||||
|
||||
### D2. The oracle is the bottleneck, not the GPU
|
||||
|
||||
Measured on MPS (2026-07-19), single invocation of `invisible`:
|
||||
|
||||
| `--max-resolution` | 256 | 384 | 512 | 768 | 1024 |
|
||||
|---|---|---|---|---|---|
|
||||
| wall time | 37.9 s | 58.9 s | 59.4 s | 65.2 s | 118.3 s |
|
||||
|
||||
384/512/768 are indistinguishable, so below ~768 the cost is dominated by **fixed model
|
||||
load, not diffusion**. Confirmed by batching: 4 images in one process took 105.5 s
|
||||
(26.4 s/image) against 59.4 s/image one at a time -- roughly 40 s fixed overhead per
|
||||
invocation and ~15 s marginal per image at 512 (rough: run-to-run variance is large).
|
||||
|
||||
Two consequences for the harness:
|
||||
- **Amortize the fixed cost**: one long-lived process over many images, never one
|
||||
invocation per image. That is a 2-4x win. Shrinking below 512 is not.
|
||||
- The binding constraint is the **external oracle's throughput**, which is manual and rate
|
||||
limited. So do not run a uniform grid; spend each oracle check where the answer is
|
||||
uncertain -- **bisect strength per content class** to certify a floor in ~10 checks
|
||||
instead of ~100.
|
||||
|
||||
### D3. Mandatory control before trusting any reduced-size run
|
||||
|
||||
`--max-resolution` is downscale -> diffuse -> Lanczos upscale. If the **resize round trip
|
||||
alone** damages SynthID, the oracle goes quiet for a reason unrelated to removal and the
|
||||
result does not transfer to production at native resolution.
|
||||
|
||||
Before any reduced-size sweep, run the resize round trip with **no diffusion** and put the
|
||||
result through the vendor oracle. If the watermark survives, the reduced size is a valid
|
||||
test bed; if it does not, reduced-size results are measuring the resizer. This is the same
|
||||
failure shape as the imwatermark carrier-fragility trap in B3: an oracle that falls silent
|
||||
for the wrong reason reads exactly like success.
|
||||
|
||||
`docs/synthid.md` cites ~99.98% TPR across 30 transforms including resize, which predicts
|
||||
the control passes -- but that is Google's claim about their own decoder, not our
|
||||
measurement, so it is a hypothesis to test, not a reason to skip the control.
|
||||
|
||||
### D4. Video candidates require a matched transcode control
|
||||
|
||||
Video experiments add frame sampling, resizing, frame-rate conversion, and a
|
||||
final video codec around the actual attack. `scripts/video_synthid_sweep.py`
|
||||
therefore emits `control.mp4` from the same selected frames and encoder settings
|
||||
as every VAE candidate.
|
||||
|
||||
Verify the control first in a new Gemini chat by invoking `@synthid` and using
|
||||
the supported built-in content-verification question. Continue only when the
|
||||
provider oracle still detects SynthID in it. Verify each candidate in its own
|
||||
new chat. A generic response that discusses visual clues or metadata is not an
|
||||
oracle result. Do not ask the chat model to reinterpret or second-guess the
|
||||
built-in verdict; that follow-up is ordinary model reasoning. Record only the
|
||||
explicit built-in SynthID verification verdict in the generated CSV.
|
||||
|
||||
The harness shares one latent-noise field across the sequence to avoid adding
|
||||
independent frame noise. Its temporal-residual metric is a fidelity check, not a
|
||||
watermark detector.
|
||||
|
||||
The 2026-07-29 two-carrier calibration produced genuine built-in verifier
|
||||
results: both matched controls were positive, the stronger candidate
|
||||
was negative on both carriers, and a weaker candidate was negative on one. On
|
||||
2026-07-30, adversarial follow-up prompts returned `UNAVAILABLE` after asking
|
||||
ordinary Gemini to ignore and reinterpret the detector result. Those follow-ups
|
||||
were mistakenly treated as a stricter oracle; they were not detector reruns.
|
||||
The implementation remains exposed as `video invisible` and
|
||||
`remove_video_invisible`. Its oracle-certified default is the product operating
|
||||
point. A fresh control-positive, candidate-negative pair remains an optional
|
||||
per-file audit after provider changes or for unusually important files.
|
||||
|
||||
The 2026-07-31 full-clip check added a public eight-second Veo carrier. The
|
||||
source and the complete `0.10` product output were both detected, proving the
|
||||
surrounding resize / frame-rate / codec path had not silenced the oracle. The
|
||||
complete `0.15` product output was not detected, so `0.15` became the default.
|
||||
The tracked source and output hashes, fidelity metrics, and verdicts are in
|
||||
`data/evaluations/video-synthid-oracle.csv`; generated media remains untracked.
|
||||
|
||||
## Tier E -- robustness and adversarial inputs
|
||||
|
||||
Malformed and hostile inputs, including truncated files:
|
||||
at many offsets, corrupt headers, 16-bit and CMYK, absurd dimensions, decompression bombs,
|
||||
zero-byte files, unicode and RTL filenames, symlinks, read-only output dirs, concurrent runs
|
||||
on one file. The bar is never "handles it" but **never raises and never silently degrades**.
|
||||
|
||||
## Build order
|
||||
|
||||
1. **A1 sidecar regression** -- highest value per hour, unattended, needs no new labels.
|
||||
2. **A2/A3/A4 parity and invariants** -- representative local set, reusing existing audit scripts.
|
||||
3. **B1 fill quality** -- closes the oldest unmeasured claim in the project.
|
||||
4. **B2 detector curves** -- cheap, and directly guards the geometry class of bug.
|
||||
5. **A5 contract sweep over a representative local set**.
|
||||
6. **B4 resource ceilings**, **E robustness**.
|
||||
7. **C recall expansion** -- gated by labelling appetite.
|
||||
8. **D oracles** -- manual, per release.
|
||||
|
||||
Every tier writes a versioned snapshot so runs are comparable over time; a run that cannot
|
||||
be diffed against the last one is a one-off, not a regression suite.
|
||||
|
||||
## What the measurements imply for detection work
|
||||
|
||||
Recorded here because each item is grounded in a number from this campaign, not because
|
||||
it is a prioritized plan (that lives elsewhere -- see the note at the end of this section).
|
||||
|
||||
### Metadata absence does not disable the detectors -- it disables the RELAXATION
|
||||
|
||||
The detectors are pixel-based and need no metadata. What metadata does is relax the
|
||||
false-positive gate (`auto` vs `strict`). So "work better without metadata" means
|
||||
strengthening the strict-path detectors themselves; it is not a gating problem.
|
||||
|
||||
Per mark, what actually goes away when metadata is stripped:
|
||||
|
||||
- **The pill loses its metadata-confirmed arm.** Without metadata it depends on the
|
||||
bottom-right Jimeng wordmark.
|
||||
- **The sparkle already runs on pixels.** Its threshold remains a deliberate
|
||||
recall-versus-precision trade.
|
||||
- **Uncovered vendors are metadata-independent gaps.** They require a detector of their
|
||||
own.
|
||||
|
||||
### Where the evidence points
|
||||
|
||||
1. Prefer per-vendor CJK templates when fitted geometry separates similar marks.
|
||||
2. Recalibrate every detector when changing its front-end. Thresholds do not transfer
|
||||
between `binary`, `tophat`, and `gray`.
|
||||
3. Treat the Jimeng wordmark as a load-bearing confirmation path for the pill.
|
||||
|
||||
### Measure before improving
|
||||
|
||||
Use Tier B2 detector-response curves before tuning a detector with sparse labelled
|
||||
examples. Sweep size, contrast, aspect, and background texture so geometry regressions
|
||||
are visible without exposing private evaluation statistics.
|
||||
|
||||
This section records what the measurements imply technically. Prioritization is tracked
|
||||
separately, outside this repo.
|
||||
|
||||
## Local end-to-end verification
|
||||
|
||||
Run `scripts/real_examples_e2e.py` against representative local inputs before releases that affect image handling. The script must read from `.local-eval/`, write only untracked temporary output, and report behavior without exposing dataset provenance or aggregate private measurements.
|
||||
|
||||
The cheap video seam is covered automatically by
|
||||
`TestVideoVisibleFullClip::test_removes_complete_clip_and_preserves_sequence_and_audio`.
|
||||
It constructs a full synthetic Sora-like MP4 with AAC audio and C2PA
|
||||
provenance, then exercises detection, temporal arbitration, OpenCV fill, real
|
||||
ffmpeg encoding, metadata stripping, audio stream copy, and atomic publication
|
||||
through the public API. A separately encoded clean control supplies the paired
|
||||
frame-to-frame deltas inside the filled region, so the gate detects temporal
|
||||
flicker rather than treating all output motion as an error. The default
|
||||
motion-compensated fill must score strictly below a separately encoded
|
||||
frame-local opt-out on the median paired error, while its high-percentile error
|
||||
cannot regress. The dedicated Linux CI job installs ffmpeg explicitly. Keep real-provider and learned-backend
|
||||
sequence evaluation local because those inputs or model downloads do not
|
||||
belong in the core matrix.
|
||||
|
||||
The local real-provider audit runs one complete clip for every registered video
|
||||
mark. OpenCV and MI-GAN must remove every accepted frame, leave the second-pass
|
||||
detector quiet, preserve source stream starts and duration, and copy AAC
|
||||
packets exactly when present. Run one complete LaMa clip to verify wiring and
|
||||
resource tier; its CPU throughput makes a six-provider online matrix
|
||||
counterproductive. Store generated outputs and the detailed CSV only under
|
||||
`.local-eval/`.
|
||||
|
||||
The same full-clip gate runs the public metadata-only path against its real MP4
|
||||
and verifies unchanged file size, decoded frames, stream properties, and AAC
|
||||
packets. A separate synthetic large-`mdat` test rejects full-source
|
||||
`read_bytes()`, hashes the copied media payload, and mutation-checks both C2PA
|
||||
and TC260 survival.
|
||||
|
||||
A companion full-clip VFR case alternates three frame durations, runs the
|
||||
public visible-removal API, and compares every output display timestamp to the
|
||||
source within one source time-base tick. Its source starts at a non-zero PTS,
|
||||
so the test also verifies retained video/audio stream offsets, container
|
||||
duration, and copied AAC identity. Mutations that disable the timestamped NUT
|
||||
bridge or reset its start PTS must fail this gate.
|
||||
A separate constant-rate clip with the same non-zero start guards the
|
||||
start-offset routing without relying on the VFR branch.
|
||||
|
||||
## Standing gap
|
||||
|
||||
None of this is in `maintain.sh`, and it should not all be -- the sweeps take hours. But
|
||||
that means **no detector-accuracy or CLI-contract regression is caught automatically**
|
||||
today. The endpoint of this plan is a cheap subset (fixtures-only smoke + a sidecar diff on
|
||||
a fixed 500-image slice) that CI can run, with the full sweeps staying pre-release.
|
||||
@@ -0,0 +1,162 @@
|
||||
# Watermarking landscape (research 2026-05-24)
|
||||
|
||||
> Research and signal inventory. Code-facing statements are updated with the
|
||||
> implementation; dated vendor observations remain snapshots and may change
|
||||
> independently of the package.
|
||||
|
||||
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (upstream `black-forest-labs/flux2`, file `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). **Caveat: the `imwatermark` dwtDct decode is carrier-fragile on a broad class of real images, NOT just re-encode-fragile, and it is a POSITIVE-ONLY signal.** A clean encode->decode round-trip (no re-encode at all) recovers 48/48 bits on some carriers (random noise, chatgpt-1.png 48/48, firefly-1.png 45/48) but FAILS on many others — verified 2026-06-19 that a *known-embedded* watermark only round-trips 28-39/48 (below the safe `_MATCH_48` = 44 gate, random baseline ~24) on the FLUX fox sample (28), doubao-1.png (39), a 1024² minimalist-flat FLUX image (28), AND a **clean synthetic bright-flat fill with NO watermark at all (28)**. The failure does NOT track texture (firefly lapvar ~11 passes; the flat FLUX lapvar ~56 fails); it correlates with a degenerate decode where the raw bits read **all-ones (48/48 ones)** — which a clean synthetic image reproduces, so **all-ones is a CARRIER ARTIFACT, NOT a watermark signal** (a double-embed test also showed a pre-existing embed does not corrupt a second embed — no interference). Net: trust a `detect_invisible_watermark` hit, but treat a `None`/no-match as **inconclusive** whenever a positive-control embed on the same carrier does not first recover >=44/48. The 44 gate is a deliberate precision choice (lowering it would admit false positives).
|
||||
|
||||
**Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.)
|
||||
|
||||
Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
|
||||
**Samsung Galaxy AI** signs supported edits with C2PA and may carry the
|
||||
proprietary `genAIType` marker. The registered visible detector covers the
|
||||
Italian `✦ Contenuti generati dall'AI` bottom-left variant. Removal follows the
|
||||
same localize-then-fill path as other registered text marks. Other locales and
|
||||
icon-only variants need separate calibrated silhouettes.
|
||||
|
||||
**ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker.
|
||||
|
||||
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
|
||||
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
|
||||
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
|
||||
|
||||
Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <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/fixtures/provenance/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public).
|
||||
|
||||
**Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on
|
||||
JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported
|
||||
AI generator values while retaining unrelated camera and editor EXIF. The
|
||||
shared `xai_signature_pair` helper is the single source of truth for the
|
||||
pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
|
||||
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
|
||||
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<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 a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image 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 compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item. The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
|
||||
Native MKV/WebM is a sixth serialization. TC260-PG-20257A stores
|
||||
`TagName=AIGC` and the raw JSON `TagString` in
|
||||
`Segment.Tags.Tag.SimpleTag`. The bounded EBML reader skips cluster payloads;
|
||||
the existing ffmpeg stream-copy path removes the tags without transcoding.
|
||||
|
||||
The same [TC260 video guide](https://www.tc260.org.cn/portal/article/303/4061772dcf684d8a96f395a4298e9e53)
|
||||
defines two more native serializations. AVI stores an `AIGC` child in
|
||||
`LIST/INFO`; FLV stores an AMF0 `AIGC` string under `script.onMetaData`. The
|
||||
bounded RIFF and FLV readers validate the JSON field set and skip media
|
||||
payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
|
||||
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist.
|
||||
- **No detectable signal on some downloads:** Recraft exports and some hosted
|
||||
FLUX surfaces can arrive without a supported local signal. Midjourney samples
|
||||
may carry IPTC metadata but no registered C2PA or pixel watermark. The open
|
||||
DWT-DCT decoder only applies when the producing pipeline actually ran its
|
||||
encoder and the carrier remains decodable.
|
||||
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
|
||||
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
|
||||
Metadata AI-disclosure fields, C2PA detection and stripping for supported
|
||||
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
|
||||
visible Sora, Veo, Seedance, Dola, Hailuo, and Kling removal. Other visible
|
||||
video logos and proprietary audio-watermark detection remain outside the
|
||||
package.
|
||||
Metadata stripping for supported audio containers is a separate implemented
|
||||
path.
|
||||
|
||||
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`).
|
||||
|
||||
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
|
||||
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so
|
||||
the normative tag is also found when a large `mdat` precedes `moov`.
|
||||
|
||||
**Meta-box XMP and EXIF removal are handled in place:** an AI-label XMP packet
|
||||
stored as a meta-box `mime` item is blanked by
|
||||
`isobmff.blank_ai_xmp_packets`. Supported EXIF items are handled by
|
||||
`blank_ai_exif_tokens`. Both paths preserve box sizes and coded media offsets.
|
||||
|
||||
For current scope and legal context, see
|
||||
[scope, safety, and legal notes](legal-and-safety.md). Re-verify legal facts
|
||||
against primary sources before adding jurisdiction-specific claims.
|
||||
|
||||
## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified)
|
||||
|
||||
**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).
|
||||
|
||||
**Google Veo video marks use two incompatible visible designs.** Public raw
|
||||
clips verify the current four-point diamond and the legacy bottom-right `Veo`
|
||||
text. The independent
|
||||
[VeoWatermarkRemover](https://github.com/allenk/VeoWatermarkRemover) project
|
||||
reports the same current-versus-legacy split, multiple output layouts, and the
|
||||
need for cross-frame position agreement. Our implementation copies no logo
|
||||
pixels or alpha maps from that project: it uses two synthetic silhouettes,
|
||||
known-layout searches plus a strong relocated-diamond fallback, and a separate
|
||||
temporal arbiter calibrated against raw watermarked clips and clean API exports.
|
||||
|
||||
**ByteDance video surfaces use distinct visible labels.** Public Seedance
|
||||
showcase clips contain a fixed rounded box with `AI`, while the Dola sample in
|
||||
[issue #16](https://github.com/wiltodelta/remove-ai-watermarks/issues/16) uses
|
||||
fixed `Dola AI` text. The independent
|
||||
[Seedance remover](https://github.com/SamurAIGPT/seedance-2.0-watermark-remover)
|
||||
estimates a static corner from a temporal mean frame and edge density. Our
|
||||
implementation instead matches provider-specific synthetic silhouettes on
|
||||
every frame, then requires an anchored temporal run. This extra anchor check
|
||||
was necessary because a moving clean scene detail could retain enough adjacent
|
||||
overlap to pass a recurrence-only gate.
|
||||
|
||||
**Hailuo and Kling use larger fixed composite labels.** Verified Hailuo exports
|
||||
carry a lower-edge waveform, `MINIMAX`, separator, Hailuo ring, and
|
||||
`hailuo AI` text. Verified Kling exports carry a bottom-right swirl,
|
||||
`KLING AI`, a changing version suffix, and sometimes `PRO`. The detectors use
|
||||
only synthetic primitives and fonts. Hailuo expands the matched core to cover
|
||||
the complete composite. Kling combines a version-independent text core with a
|
||||
synthetic ring rescue, then requires the recurring candidate to reach the
|
||||
expected frame edge and contain enough bright low-saturation pixels. Those
|
||||
extra gates were added after clean Luma and PixVerse scene details passed shape
|
||||
and temporal recurrence alone. The generic
|
||||
[WatermarkRemover-AI](https://github.com/D-Ogi/WatermarkRemover-AI) project
|
||||
instead uses Florence-2 to identify arbitrary watermarks before LaMa
|
||||
inpainting. That is broader, but it carries a much heavier model and a less
|
||||
auditable detection boundary than the provider-specific synthetic path here.
|
||||
|
||||
**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. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries.
|
||||
|
||||
## Uncovered visible marks: implementation specs (deep-research 2026-07-18)
|
||||
|
||||
Compatibility testing showed that TC260-labelled images can still produce no visible-mark
|
||||
detection. The main causes were a fixed Doubao localization defect and genuinely
|
||||
uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads,
|
||||
not ground truth.
|
||||
|
||||
**GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label:
|
||||
- 应采用文字提示 (must be a TEXT prompt);
|
||||
- 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`.
|
||||
|
||||
**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example.
|
||||
|
||||
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. `identify` reads the supported IPTC disclosure; it does not decode Meta's proprietary invisible watermark. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
|
||||
|
||||
**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found.
|
||||
|
||||
**The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session.
|
||||
|
||||
**No vendor publishes typeface, color, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin.
|
||||
@@ -3,9 +3,27 @@
|
||||
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 uv.lock 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
|
||||
uv run pyright
|
||||
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
|
||||
# repo (see CLAUDE.md "Test and lint"); src/ is the authoritative strict gate.
|
||||
uv run pyright src/
|
||||
uv run pytest -n auto
|
||||
@@ -0,0 +1,62 @@
|
||||
schema_version: 1
|
||||
|
||||
context:
|
||||
version: "0.22.0"
|
||||
python_min: "3.10"
|
||||
|
||||
package:
|
||||
name: remove-ai-watermarks
|
||||
version: ${{ version }}
|
||||
|
||||
source:
|
||||
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
|
||||
sha256: 00549927083c2b60444dd8404830c7333899a9345aacc41d43b21a77c63b8dd0
|
||||
|
||||
build:
|
||||
noarch: python
|
||||
number: 0
|
||||
script: python -m pip install . -vv --no-deps --no-build-isolation
|
||||
|
||||
requirements:
|
||||
host:
|
||||
- python ${{ python_min }}.*
|
||||
- pip
|
||||
- hatchling
|
||||
run:
|
||||
- python >=${{ python_min }}
|
||||
- pillow >=10.0.0
|
||||
- piexif >=1.1.3
|
||||
- click >=8.0.0
|
||||
- python-dotenv >=1.0.0
|
||||
# c2pa-python is a default PyPI dependency but is not packaged on conda-forge.
|
||||
# The guarded import falls back to the built-in C2PA byte scanner when it is
|
||||
# absent. Add it here once a c2pa-python feedstock exists.
|
||||
|
||||
tests:
|
||||
- python:
|
||||
imports:
|
||||
- remove_ai_watermarks
|
||||
- remove_ai_watermarks.identify
|
||||
- remove_ai_watermarks.metadata
|
||||
python_version: ${{ python_min }}.*
|
||||
# pip_check defaults to true; disable it explicitly. `pip check` fails on
|
||||
# the conda-forge piexif `py_2` build, whose stale 2019 metadata pip reads
|
||||
# as "piexif 1.1.3 is not supported on this platform" -- even though the
|
||||
# package installs, imports, and works. The imports test verifies loading.
|
||||
pip_check: false
|
||||
|
||||
about:
|
||||
homepage: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
summary: Inspect and strip AI provenance metadata from media
|
||||
description: |
|
||||
Inspect and strip AI-provenance metadata (C2PA, EXIF, IPTC, and PNG text
|
||||
chunks) from images and supported video containers. Optional pip extras add
|
||||
visible watermark removal, video processing, SynthID diffusion removal, and
|
||||
additional invisible-watermark detectors.
|
||||
license: Apache-2.0
|
||||
license_file: LICENSE
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
|
||||
extra:
|
||||
recipe-maintainers:
|
||||
- wiltodelta
|
||||
@@ -1,56 +1,193 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.5.4"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
version = "0.23.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
requires-python = ">=3.10.1"
|
||||
license = {text = "Apache-2.0"}
|
||||
keywords = [
|
||||
"ai-watermark",
|
||||
"ai-watermark-remover",
|
||||
"watermark-remover",
|
||||
"watermark-removal",
|
||||
"remove-watermark",
|
||||
"synthid",
|
||||
"c2pa",
|
||||
"content-credentials",
|
||||
"nano-banana",
|
||||
"gemini",
|
||||
"gemini-watermark",
|
||||
"ai-metadata",
|
||||
"metadata-removal",
|
||||
"provenance",
|
||||
"exif",
|
||||
"stable-diffusion",
|
||||
"comfyui",
|
||||
"ai-detection",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
"Topic :: Scientific/Engineering :: Image Processing",
|
||||
"Topic :: Security",
|
||||
"Topic :: Utilities",
|
||||
]
|
||||
dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
"piexif>=1.1.3",
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
"click>=8.0.0",
|
||||
"rich>=13.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 _internal/c2pa.py is kept only as a fallback for
|
||||
# synthetic/partial blobs the validator rejects. Binary wheel (Rust), but the
|
||||
# import is light (no torch/numpy) so it fits the dependency-light identify
|
||||
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
|
||||
"c2pa-python>=0.35.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gpu = [
|
||||
"torch>=2.0.0",
|
||||
"diffusers>=0.38.0",
|
||||
"transformers>=4.35.0",
|
||||
"accelerate>=0.25.0",
|
||||
"controlnet-aux>=0.0.9",
|
||||
"safetensors",
|
||||
"ultralytics>=8.0.0",
|
||||
"color-matcher>=0.5.0",
|
||||
pixels = [
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# Open invisible-watermark (imwatermark) decoder for detecting the DWT-DCT
|
||||
# watermarks embedded by Stable Diffusion / SDXL / FLUX. Optional because it
|
||||
# pulls non-headless opencv AND torch (invisible-watermark declares torch a hard
|
||||
# dependency, and WatermarkDecoder eagerly imports rivaGan -> torch at import
|
||||
# time, so the dwtDct-only detect path still needs torch present even though it
|
||||
# never runs on GPU). So `detect` alone pulls torch -- no need to add `gpu` for
|
||||
# detection. identify() guards the import and skips the signal when absent.
|
||||
# Optional HEIC/AVIF pixel decode. Metadata scanning handles these containers
|
||||
# without this plugin; combine `heif` with any pixel feature only when needed.
|
||||
heif = [
|
||||
"pillow-heif>=0.13.0",
|
||||
]
|
||||
visible = ["remove-ai-watermarks[pixels]"]
|
||||
# Video visible removal uses the shared pixel runtime. PyAV packetizes processed
|
||||
# VFR frames with explicit PTS before system ffmpeg encodes them. PyAV 18 requires
|
||||
# Python 3.11; the 16.x wheel line still covers Python 3.10.
|
||||
video = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"av>=16,<17; python_version < '3.11'",
|
||||
"av>=18,<19; python_version >= '3.11'",
|
||||
]
|
||||
# Open DWT-DCT watermarks used by Stable Diffusion / SDXL / FLUX. The in-tree
|
||||
# decoder avoids the upstream invisible-watermark package's mandatory torch and
|
||||
# non-headless OpenCV dependencies.
|
||||
detect = [
|
||||
"invisible-watermark>=0.2.0",
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"PyWavelets>=1.1.1",
|
||||
]
|
||||
diffusion = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"torch>=2.0.0",
|
||||
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
|
||||
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
|
||||
# from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS
|
||||
# XPU build). Install that build first, then this extra (torch is then
|
||||
# already satisfied and won't be re-pulled):
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
# pip install 'remove-ai-watermarks[diffusion]'
|
||||
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
|
||||
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
"diffusers>=0.38.0",
|
||||
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
|
||||
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
|
||||
# transformers 5.x -- so ``from diffusers import AutoPipelineForImage2Image``
|
||||
# fails on transformers 4.x. The real SDXL-loading break was NOT transformers
|
||||
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
|
||||
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
|
||||
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
|
||||
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
|
||||
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
|
||||
"transformers>=5,<6",
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"accelerate>=0.25.0",
|
||||
"safetensors",
|
||||
]
|
||||
# Full two-stage high-fidelity profile: Qwen-Image-2512 Lightning + DiffSynth
|
||||
# Canny ControlNet for the frame, then SAM-masked Z-Image Turbo face repair.
|
||||
# CUDA-only and intentionally separate from the normal diffusion extra because the
|
||||
# additional model stack and DiffSynth runtime are large.
|
||||
qwen-zimage = [
|
||||
"remove-ai-watermarks[diffusion]",
|
||||
"diffsynth>=2.0.17,<3",
|
||||
"torchvision>=0.20.0",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
# because it pulls torch and downloads model weights on first use. identify()
|
||||
# guards the import and skips the TrustMark signal when absent.
|
||||
trustmark = [
|
||||
"trustmark>=0.8.0",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
# eraser backend needs none of this.
|
||||
lama = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
# ONNX Runtime 1.24 dropped CPython 3.10 wheels; keep the project's
|
||||
# supported 3.10 line on the last compatible release series.
|
||||
"onnxruntime>=1.16.0,<1.24; python_version < '3.11'",
|
||||
"onnxruntime>=1.16.0; python_version >= '3.11'",
|
||||
"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
|
||||
# memory-tight learned tier (vs big-LaMa's ~4.7 GB). Select it explicitly when
|
||||
# LaMa, the quality-first `auto` choice, is too large. Same runtime as `lama`.
|
||||
migan = [
|
||||
"remove-ai-watermarks[visible]",
|
||||
"onnxruntime>=1.16.0,<1.24; python_version < '3.11'",
|
||||
"onnxruntime>=1.16.0; python_version >= '3.11'",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN). Loaded via
|
||||
# spandrel (MIT) -- a pure model-loader with NO basicsr dependency (it pulls only
|
||||
# torch / torchvision / safetensors / numpy / einops).
|
||||
# The Real-ESRGAN weights (BSD-3-Clause) download on first use and are cached; they
|
||||
# are never bundled. CPU works but is slow on large inputs -- it is meant for the
|
||||
# pre-diffusion upscale of SMALL inputs (and the GPU worker). Guarded by
|
||||
# upscaler.is_available(); the default upscaler stays Lanczos (cv2, no deps). The
|
||||
# weights are fetched with torch.hub (bundled with spandrel's torch), so no extra
|
||||
# download dependency is needed.
|
||||
esrgan = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"spandrel>=0.3.0",
|
||||
]
|
||||
dev = [
|
||||
"remove-ai-watermarks[video]",
|
||||
"remove-ai-watermarks[detect]",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-xdist>=3.5.0",
|
||||
"packaging>=24.0",
|
||||
"ruff>=0.4.0",
|
||||
"pyright>=1.1.0",
|
||||
"invisible-watermark>=0.2.0",
|
||||
# maintain.sh helpers; they only support newer Pythons, so gate them by
|
||||
# marker to keep the py3.10 resolution (and CI matrix) solvable.
|
||||
"uv-outdated>=0.1.0; python_version >= '3.12'",
|
||||
"uv-secure>=0.12.0; python_version >= '3.12'",
|
||||
]
|
||||
all = ["remove-ai-watermarks[gpu,detect,dev]"]
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,diffusion,qwen-zimage,lama,migan,esrgan]"]
|
||||
|
||||
# diffusers 0.38.0 (security fix for GHSA-98h9-4798-4q5v) declares a dependency
|
||||
# on safetensors>=0.8.0rc0 — a pre-release. Allow pre-releases globally so the
|
||||
# resolver can satisfy that. Drop once diffusers publishes a release with a
|
||||
# stable safetensors pin (or once safetensors 0.8.0 stable is out).
|
||||
[tool.uv]
|
||||
prerelease = "allow"
|
||||
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
|
||||
# the default CPU/CUDA install: uv consults it only when a torch install
|
||||
# explicitly targets it (see the ``diffusion`` extra comment), so it does not alter
|
||||
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-xpu"
|
||||
url = "https://download.pytorch.org/whl/xpu"
|
||||
explicit = true
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
@@ -65,6 +202,22 @@ build-backend = "hatchling.build"
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/remove_ai_watermarks"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
# Keep the source distribution small and public-safe: ship tracked source and
|
||||
# metadata, not corpora or local research/session artifacts. The wheel ships
|
||||
# only src/.
|
||||
include = [
|
||||
"/src",
|
||||
"/LICENSE",
|
||||
"/README.md",
|
||||
"/pyproject.toml",
|
||||
]
|
||||
exclude = [
|
||||
"/data",
|
||||
"/tmp",
|
||||
"/.sc",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -74,6 +227,7 @@ addopts = "-v --tb=short"
|
||||
target-version = "py310"
|
||||
line-length = 120
|
||||
exclude = ["_refs"]
|
||||
extend-exclude = ["*.md"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "B", "I", "S", "UP", "SIM", "RET", "COM", "C4", "G", "PT", "PIE", "T20", "DTZ", "ICN", "TCH", "RUF", "ANN"]
|
||||
@@ -83,10 +237,10 @@ 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
|
||||
"src/remove_ai_watermarks/noai/ctrlregen/engine.py" = ["S101", "S603"] # assert for loaded state, subprocess for auto-install
|
||||
"src/remove_ai_watermarks/_internal/watermark_remover.py" = ["S603", "S606", "S607"] # nvidia-smi capability probe
|
||||
"src/remove_ai_watermarks/_internal/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Minimal plain-text stand-ins for the rich Console/Table API.
|
||||
|
||||
rich was dropped as a project dependency (see the CLI plain-text refactor), but
|
||||
the analysis scripts still printed through it. These shims keep the scripts
|
||||
runnable without rich: ``[bold]``/``[/]``-style markup is stripped and tables
|
||||
render as aligned plain text. Output goes through ``click.echo`` to match the
|
||||
package CLI (no bare ``print`` in tooling).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
# Matches rich style tags: the bare close ``[/]`` and named open/close tags such
|
||||
# as ``[yellow]``, ``[bold yellow]``, ``[/green]``. Anchored to lowercase-letter
|
||||
# starts so numeric/data brackets (``[1024]``, ``[file.png]``) are left intact.
|
||||
_MARKUP = re.compile(r"\[(?:/|/?[a-z][a-z ]*)\]")
|
||||
|
||||
|
||||
def _strip(obj: Any) -> str:
|
||||
return _MARKUP.sub("", str(obj))
|
||||
|
||||
|
||||
class Table:
|
||||
"""Drop-in for ``rich.table.Table`` covering add_column/add_row + render."""
|
||||
|
||||
def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None:
|
||||
self.title = title
|
||||
self._headers: list[str] = []
|
||||
self._rows: list[list[str]] = []
|
||||
|
||||
def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None:
|
||||
self._headers.append(_strip(header))
|
||||
|
||||
def add_row(self, *cells: Any) -> None:
|
||||
self._rows.append([_strip(c) for c in cells])
|
||||
|
||||
def render(self) -> str:
|
||||
all_rows = ([self._headers] if self._headers else []) + self._rows
|
||||
cols = max((len(r) for r in all_rows), default=0)
|
||||
widths = [0] * cols
|
||||
for row in all_rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(cell))
|
||||
lines: list[str] = []
|
||||
if self.title:
|
||||
lines.append(_strip(self.title))
|
||||
if self._headers:
|
||||
lines.append(" ".join(h.ljust(widths[i]) for i, h in enumerate(self._headers)))
|
||||
lines.extend(" ".join(c.ljust(widths[i]) for i, c in enumerate(row)) for row in self._rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class Console:
|
||||
"""Drop-in for ``rich.console.Console`` covering ``print`` (with Table)."""
|
||||
|
||||
def print(self, *objects: Any, **kwargs: Any) -> None:
|
||||
click.echo(" ".join(o.render() if isinstance(o, Table) else _strip(o) for o in objects))
|
||||
@@ -0,0 +1,436 @@
|
||||
"""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.
|
||||
The model is distribution-specific by design; evaluate it again before
|
||||
using it on an unrelated target distribution.
|
||||
|
||||
Modes:
|
||||
uv run --with scikit-learn python scripts/ai_score.py train <scan_glob> <model.pkl> [v1|v2]
|
||||
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 math
|
||||
import pickle
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
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"),
|
||||
]
|
||||
FeatureSchema = Literal["v1", "v2"]
|
||||
_FFT_BANDS = 8
|
||||
_GRADIENT_BINS = 10
|
||||
_COLOR_BINS = 64
|
||||
_DCT_AC_POSITIONS = 8
|
||||
_DCT_AC_BINS = 21
|
||||
_JPEG_QUANT_TABLES = 2
|
||||
_JPEG_QUANT_VALUES = 64
|
||||
_SCORE_BATCH = 4096
|
||||
|
||||
_V1_FEATURE_NAMES = (
|
||||
*(key for _, key in _FEAT_SCALAR),
|
||||
*(f"fft_band_energy_{index}" for index in range(_FFT_BANDS)),
|
||||
*(f"gradient_hist_{index}" for index in range(_GRADIENT_BINS)),
|
||||
*(f"color_hist_4x4x4_{index}" for index in range(_COLOR_BINS)),
|
||||
"jpeg_444",
|
||||
"jpeg_progressive",
|
||||
"megapixels",
|
||||
"aspect_ratio",
|
||||
"format_jpeg",
|
||||
"format_png",
|
||||
)
|
||||
_V2_EXTRA_FEATURE_NAMES = (
|
||||
"cfa_peak_0",
|
||||
"cfa_peak_1",
|
||||
*(f"dct_ac_{position}_{bin_index}" for position in range(_DCT_AC_POSITIONS) for bin_index in range(_DCT_AC_BINS)),
|
||||
*(f"jpeg_quant_{table}_{index}" for table in range(_JPEG_QUANT_TABLES) for index in range(_JPEG_QUANT_VALUES)),
|
||||
"jpeg_quant_table_count",
|
||||
"jpeg_huffman_table_count",
|
||||
"jpeg_huffman_total_bytes",
|
||||
"jpeg_scan_count",
|
||||
"jpeg_restart_interval",
|
||||
"jpeg_precision_bits",
|
||||
"jpeg_adobe_transform",
|
||||
"jpeg_jfif_present",
|
||||
"format_webp",
|
||||
"format_isobmff",
|
||||
"format_other",
|
||||
)
|
||||
_SCHEMA_FEATURE_NAMES: dict[str, tuple[str, ...]] = {
|
||||
"v1": _V1_FEATURE_NAMES,
|
||||
"v2": _V1_FEATURE_NAMES + _V2_EXTRA_FEATURE_NAMES,
|
||||
}
|
||||
|
||||
|
||||
def parse_schema(value: Any) -> FeatureSchema:
|
||||
"""Validate a feature-schema token (CLI argument or model-bundle field)."""
|
||||
if value == "v1" or value == "v2":
|
||||
return value
|
||||
raise ValueError(f"Unknown feature schema: {value!r}")
|
||||
|
||||
|
||||
def feature_names(schema: FeatureSchema = "v1") -> tuple[str, ...]:
|
||||
"""Return the stable ordered feature names for a model schema."""
|
||||
return _SCHEMA_FEATURE_NAMES[parse_schema(schema)]
|
||||
|
||||
|
||||
def _fixed_values(values: Any, count: int, *, normalize: bool = False) -> list[float]:
|
||||
"""Return a fixed-width float vector, padding absent values with NaN."""
|
||||
if not isinstance(values, (list, tuple)):
|
||||
return [float("nan")] * count
|
||||
result = [float(value) for value in values[:count]]
|
||||
if normalize and result:
|
||||
total = sum(value for value in result if math.isfinite(value))
|
||||
if total > 0:
|
||||
result = [value / total for value in result]
|
||||
return result + [float("nan")] * (count - len(result))
|
||||
|
||||
|
||||
def _optional_float(mapping: dict[str, Any], key: str) -> float:
|
||||
value = mapping.get(key)
|
||||
return float(value) if isinstance(value, (int, float)) else float("nan")
|
||||
|
||||
|
||||
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], *, schema: FeatureSchema = "v1") -> 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]
|
||||
fft = record.get("fft") or {}
|
||||
gradient = record.get("gradient") or {}
|
||||
color = record.get("color") or {}
|
||||
v.extend(_fixed_values(fft.get("fft_band_energy"), _FFT_BANDS))
|
||||
v.extend(_fixed_values(gradient.get("gradient_hist"), _GRADIENT_BINS))
|
||||
v.extend(_fixed_values(color.get("color_hist_4x4x4"), _COLOR_BINS))
|
||||
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]
|
||||
if schema == "v2":
|
||||
v.extend(_fixed_values(fft.get("cfa_peaks"), 2))
|
||||
dct_hist = (record.get("dct") or {}).get("dct_ac_hist")
|
||||
for position in range(_DCT_AC_POSITIONS):
|
||||
values = dct_hist[position] if isinstance(dct_hist, list) and position < len(dct_hist) else None
|
||||
v.extend(_fixed_values(values, _DCT_AC_BINS, normalize=True))
|
||||
quant_tables = jf.get("quant_tables")
|
||||
for table in range(_JPEG_QUANT_TABLES):
|
||||
values = quant_tables.get(str(table)) if isinstance(quant_tables, dict) else None
|
||||
v.extend(_fixed_values(values, _JPEG_QUANT_VALUES))
|
||||
is_jpeg = fmt == "jpeg"
|
||||
huffman_tables = jf.get("huffman_tables_hex")
|
||||
huffman_values = huffman_tables if isinstance(huffman_tables, list) else []
|
||||
v.extend(
|
||||
[
|
||||
float(len(quant_tables)) if isinstance(quant_tables, dict) else (0.0 if is_jpeg else float("nan")),
|
||||
float(len(huffman_values)) if is_jpeg else float("nan"),
|
||||
(
|
||||
float(sum(len(value) // 2 for value in huffman_values if isinstance(value, str)))
|
||||
if is_jpeg
|
||||
else float("nan")
|
||||
),
|
||||
_optional_float(jf, "scan_count"),
|
||||
_optional_float(jf, "restart_interval"),
|
||||
_optional_float(jf, "precision_bits"),
|
||||
_optional_float(jf, "adobe_transform"),
|
||||
1.0 if jf.get("jfif") else (0.0 if is_jpeg else float("nan")),
|
||||
1.0 if fmt == "webp" else 0.0,
|
||||
1.0 if isinstance(fmt, str) and fmt.startswith("isobmff:") else 0.0,
|
||||
(
|
||||
1.0
|
||||
if fmt is not None and fmt not in {"jpeg", "png", "webp"} and not str(fmt).startswith("isobmff:")
|
||||
else 0.0
|
||||
),
|
||||
]
|
||||
)
|
||||
elif schema != "v1":
|
||||
raise ValueError(f"Unknown feature schema: {schema}")
|
||||
expected = len(feature_names(schema))
|
||||
if len(v) != expected:
|
||||
raise ValueError(f"Feature schema {schema} produced {len(v)} values; expected {expected}")
|
||||
return v
|
||||
|
||||
|
||||
def grouped_stratified_split(
|
||||
labels: np.ndarray,
|
||||
hashes: np.ndarray,
|
||||
*,
|
||||
test_size: float = 0.3,
|
||||
random_state: int = 0,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Split records by hash while preserving label balance across hash groups."""
|
||||
if len(labels) != len(hashes):
|
||||
raise ValueError("labels and hashes must have equal length")
|
||||
if not 0 < test_size < 1:
|
||||
raise ValueError("test_size must be between 0 and 1")
|
||||
groups: dict[str, list[int]] = defaultdict(list)
|
||||
group_labels: dict[str, int] = {}
|
||||
for index, (label, digest) in enumerate(zip(labels, hashes, strict=True)):
|
||||
key = str(digest) if digest else f"__row_{index}"
|
||||
int_label = int(label)
|
||||
if key in group_labels and group_labels[key] != int_label:
|
||||
raise ValueError(f"Hash {key!r} has conflicting labels")
|
||||
group_labels[key] = int_label
|
||||
groups[key].append(index)
|
||||
keys_by_label: dict[int, list[str]] = defaultdict(list)
|
||||
for key, label in group_labels.items():
|
||||
keys_by_label[label].append(key)
|
||||
rng = np.random.default_rng(random_state)
|
||||
test_set: set[str] = set()
|
||||
for class_keys in keys_by_label.values():
|
||||
if len(class_keys) < 2:
|
||||
raise ValueError("Each label needs at least two distinct hash groups")
|
||||
shuffled = np.asarray(class_keys)
|
||||
rng.shuffle(shuffled)
|
||||
test_count = min(max(round(len(shuffled) * test_size), 1), len(shuffled) - 1)
|
||||
test_set.update(shuffled[:test_count].tolist())
|
||||
train_set = set(groups) - test_set
|
||||
train = np.asarray([index for key, indices in groups.items() if key in train_set for index in indices])
|
||||
test = np.asarray([index for key, indices in groups.items() if key in test_set for index in indices])
|
||||
return np.sort(train), np.sort(test)
|
||||
|
||||
|
||||
def temporal_holdout_split(
|
||||
dates: np.ndarray,
|
||||
hashes: np.ndarray,
|
||||
*,
|
||||
train_fraction: float = 0.7,
|
||||
) -> tuple[np.ndarray, np.ndarray, str]:
|
||||
"""Split chronologically and remove later copies of training hashes."""
|
||||
if len(dates) != len(hashes):
|
||||
raise ValueError("dates and hashes must have equal length")
|
||||
if not 0 < train_fraction < 1:
|
||||
raise ValueError("train_fraction must be between 0 and 1")
|
||||
unique_dates = sorted({str(date) for date in dates})
|
||||
if len(unique_dates) < 2:
|
||||
raise ValueError("Temporal holdout needs at least two distinct dates")
|
||||
cutoff_index = min(int(len(unique_dates) * train_fraction), len(unique_dates) - 1)
|
||||
cutoff = unique_dates[cutoff_index]
|
||||
train = np.flatnonzero(dates < cutoff)
|
||||
test = np.flatnonzero((dates >= cutoff) & ~np.isin(hashes, hashes[train]))
|
||||
return train, test, cutoff
|
||||
|
||||
|
||||
def model_schema(bundle: dict[str, Any]) -> FeatureSchema:
|
||||
"""Read a model's feature schema, defaulting legacy bundles to v1."""
|
||||
return parse_schema(bundle.get("feature_schema", "v1"))
|
||||
|
||||
|
||||
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, schema: FeatureSchema = "v2") -> None:
|
||||
from sklearn.ensemble import HistGradientBoostingClassifier
|
||||
from sklearn.impute import SimpleImputer
|
||||
from sklearn.metrics import average_precision_score, roc_auc_score
|
||||
|
||||
# keep the feature vector, not the record: a parsed scan record is an order of
|
||||
# magnitude larger than the row it collapses to.
|
||||
labeled: dict[str, tuple[list[float], int, str]] = {}
|
||||
missing_hash_index = 0
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
lab = label_of(r)
|
||||
if lab is None:
|
||||
continue
|
||||
digest = r.get("sha256")
|
||||
if not digest:
|
||||
digest = f"__missing_{missing_hash_index}"
|
||||
missing_hash_index += 1
|
||||
date = Path(r["file"]).parent.name
|
||||
previous = labeled.get(str(digest))
|
||||
if previous is not None and previous[1] != lab:
|
||||
raise ValueError(f"Hash {digest!r} has conflicting labels")
|
||||
if previous is None or date < previous[2]:
|
||||
labeled[str(digest)] = (features_of(r, schema=schema), lab, date)
|
||||
|
||||
rows, labels, row_dates = zip(*labeled.values(), strict=True)
|
||||
X = np.asarray(rows)
|
||||
y = np.asarray(labels)
|
||||
dates = np.asarray(row_dates)
|
||||
hashes = np.asarray(list(labeled))
|
||||
print(
|
||||
f"unique labeled: {len(y)} "
|
||||
f"(pos {int(y.sum())}, neg {int((1 - y).sum())}, features {X.shape[1]}, schema {schema})"
|
||||
)
|
||||
|
||||
def fit(train: np.ndarray) -> tuple[Any, Any]:
|
||||
rows = X[train]
|
||||
imputer = SimpleImputer(strategy="median")
|
||||
classifier = HistGradientBoostingClassifier(
|
||||
random_state=0,
|
||||
max_iter=300,
|
||||
l2_regularization=5.0,
|
||||
early_stopping=False,
|
||||
)
|
||||
classifier.fit(imputer.fit_transform(rows), y[train])
|
||||
return imputer, classifier
|
||||
|
||||
train, test = grouped_stratified_split(y, hashes, test_size=0.3, random_state=0)
|
||||
imp, clf = fit(train)
|
||||
p = clf.predict_proba(imp.transform(X[test]))[:, 1]
|
||||
print(f"SHA-grouped holdout: AUC {roc_auc_score(y[test], p):.4f} | AP {average_precision_score(y[test], p):.4f}")
|
||||
|
||||
temporal_train, temporal_test, cutoff = temporal_holdout_split(dates, hashes)
|
||||
if len(temporal_train) > 100 and len(temporal_test) > 100:
|
||||
imp_t, clf_t = fit(temporal_train)
|
||||
pt = clf_t.predict_proba(imp_t.transform(X[temporal_test]))[:, 1]
|
||||
auc_t = roc_auc_score(y[temporal_test], pt)
|
||||
ap_t = average_precision_score(y[temporal_test], pt)
|
||||
print(f"temporal (<{cutoff}): AUC {auc_t:.4f} | AP {ap_t:.4f}")
|
||||
|
||||
all_indices = np.arange(len(y))
|
||||
imp, clf = fit(all_indices)
|
||||
bundle = {
|
||||
"imputer": imp,
|
||||
"clf": clf,
|
||||
"feature_schema": schema,
|
||||
"feature_names": feature_names(schema),
|
||||
}
|
||||
with open(model_path, "wb") as f:
|
||||
pickle.dump(bundle, 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"]
|
||||
schema = model_schema(bundle)
|
||||
stored_names = bundle.get("feature_names")
|
||||
if stored_names is not None and tuple(stored_names) != feature_names(schema):
|
||||
raise ValueError(f"Model feature names do not match schema {schema}")
|
||||
n = 0
|
||||
batch_rows: list[list[float]] = []
|
||||
batch_meta: list[dict[str, Any]] = []
|
||||
|
||||
def flush(out: Any) -> None:
|
||||
"""Score one batch: per-record predict_proba is dominated by call overhead."""
|
||||
nonlocal n
|
||||
if not batch_rows:
|
||||
return
|
||||
scores = clf.predict_proba(imp.transform(np.asarray(batch_rows)))[:, 1]
|
||||
for meta, score in zip(batch_meta, scores, strict=True):
|
||||
out.write(json.dumps({**meta, "ai_score": round(float(score), 4)}) + "\n")
|
||||
n += len(batch_rows)
|
||||
batch_rows.clear()
|
||||
batch_meta.clear()
|
||||
print(f" {n}", flush=True)
|
||||
|
||||
with open(out_path, "w") as out:
|
||||
for r in iter_records(pattern):
|
||||
if "noise" not in r:
|
||||
continue
|
||||
batch_rows.append(features_of(r, schema=schema))
|
||||
batch_meta.append({"file": r["file"], "sha256": r.get("sha256"), "metadata_label": label_of(r)})
|
||||
if len(batch_rows) >= _SCORE_BATCH:
|
||||
flush(out)
|
||||
flush(out)
|
||||
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) in (4, 5):
|
||||
schema = parse_schema(sys.argv[4]) if len(sys.argv) == 5 else "v2"
|
||||
cmd_train(sys.argv[2], sys.argv[3], schema)
|
||||
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()
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -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
|
||||
Treat input datasets as sensitive and read-only, and keep output gitignored. The
|
||||
template is font-rendered synthetic, never cut from an input image.
|
||||
|
||||
uv run python scripts/cjk_tail_probe.py --n 6000
|
||||
"""
|
||||
|
||||
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 / ".local-eval" / "originals"
|
||||
OUT = REPO / ".local-eval" / "cjk-tail-probe.jsonl"
|
||||
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
|
||||
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
|
||||
# what writes the committed silhouette into src/.../assets/.
|
||||
TAIL_PNG = REPO / ".local-eval" / "cjk-tail-silhouette.png"
|
||||
|
||||
# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
|
||||
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
|
||||
# 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 / ".local-eval" / "cjk-tail-sheet.png"
|
||||
cv2.imwrite(str(dest), np.vstack(tiles))
|
||||
print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
|
||||
print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,348 @@
|
||||
"""ControlNet-as-removal-pipeline prototype sweep (issue #35 / Jacob).
|
||||
|
||||
Research prototype, NOT a shipped pipeline. It tests whether a full-image
|
||||
SDXL-native ControlNet-conditioned img2img can REPLACE plain SDXL img2img as the
|
||||
watermark remover: a single structure-guided regeneration that scrubs an invisible
|
||||
robust watermark (SynthID) everywhere while keeping fine detail and small/CJK text
|
||||
legible. See docs/controlnet-removal-pipeline-research.md for the full rationale.
|
||||
|
||||
The make-or-break tension (from the watermark-removal-attack literature): the
|
||||
denoise strength high enough to scrub the watermark deforms text, while the
|
||||
conditioning strong enough to keep text may spare the watermark. There is no local
|
||||
SynthID detector, so this script CANNOT decide removal on its own -- it produces
|
||||
one output per (control, strength, conditioning-scale) cell plus an index, and YOU
|
||||
verify each output by hand in the Gemini app ("Verify with SynthID") and judge text
|
||||
legibility visually. Fill the verdict columns in the emitted index, then read off
|
||||
the Pareto cell (oracle clean AND text legible).
|
||||
|
||||
Pipeline: stabilityai/stable-diffusion-xl-base-1.0 +
|
||||
- canny: xinsir/controlnet-canny-sdxl-1.0 (control = cv2.Canny(gray, 100, 200))
|
||||
- tile: xinsir/controlnet-tile-sdxl-1.0 (control = the resized original, no preproc)
|
||||
StableDiffusionXLControlNetImg2ImgPipeline (image=init, control_image=control).
|
||||
|
||||
Needs the gpu extra (torch + diffusers) and cv2. Runs locally on 32 GB MPS in
|
||||
fp32 (MPS fp16 decodes to all-black NaN -- issue #29 -- so fp32 is the default on
|
||||
mps/cpu, fp16 only on cuda/xpu); a dedicated GPU is not required for 1024 px. Run:
|
||||
|
||||
uv run python scripts/controlnet_sweep.py path/to/watermarked.png -o sweep_out
|
||||
uv run python scripts/controlnet_sweep.py img.png --control canny tile \\
|
||||
--strength 0.3 0.5 0.7 1.0 --scale 0.6 1.0 --size 1024
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# torch/diffusers/cv2 ship no usable types; relax the unknown-type + private-import
|
||||
# rules for this boundary script (mirrors scripts/visible_alpha_solve.py and the
|
||||
# cv2/torch engine modules). Pure-logic helpers here stay correct regardless.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportPrivateImportUsage=false
|
||||
import argparse
|
||||
import contextlib
|
||||
import csv
|
||||
import importlib.util
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from _plain_console import Console, Table
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PIL import Image
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
BASE_MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
FP16_VAE = "madebyollin/sdxl-vae-fp16-fix"
|
||||
CONTROLNETS = {
|
||||
"canny": "xinsir/controlnet-canny-sdxl-1.0",
|
||||
"tile": "xinsir/controlnet-tile-sdxl-1.0",
|
||||
}
|
||||
|
||||
# A neutral quality prompt: the goal is faithful regeneration, not creative edits.
|
||||
PROMPT = "best quality, high quality, sharp, detailed, photographic"
|
||||
NEGATIVE_PROMPT = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts"
|
||||
|
||||
|
||||
def pick_device(requested: str) -> str:
|
||||
"""Resolve the inference device without the CUDA-reinstaller side effect.
|
||||
|
||||
Deliberately does NOT call the package ``get_device`` (which can trigger a
|
||||
torch-CUDA reinstall+restart). A research script should never do that.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if requested != "auto":
|
||||
return requested
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
return "cpu"
|
||||
|
||||
|
||||
def resolve_dtype(device: str, requested: str) -> Any:
|
||||
"""fp16 only on cuda/xpu; fp32 on cpu AND mps, unless overridden.
|
||||
|
||||
MPS fp16 produces all-black NaN output here (the SDXL UNet/VAE overflows on
|
||||
the Metal backend -- issue #29; even the fp16-fix VAE does not save it), so the
|
||||
production pipeline runs fp32 on MPS and so do we. fp32 SDXL + an SDXL ControlNet
|
||||
at 1024 fits 32 GB unified memory with vae-tiling + attention-slicing.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if requested == "fp16":
|
||||
return torch.float16
|
||||
if requested == "fp32":
|
||||
return torch.float32
|
||||
return torch.float16 if device in {"cuda", "xpu"} else torch.float32
|
||||
|
||||
|
||||
def fit_size(image: Image.Image, long_side: int) -> Image.Image:
|
||||
"""Resize so the long side is ``long_side``, each dim a multiple of 8 (SDXL)."""
|
||||
from PIL import Image as PILImage
|
||||
|
||||
w, h = image.size
|
||||
scale = long_side / max(w, h)
|
||||
nw = max(8, round(w * scale) // 8 * 8)
|
||||
nh = max(8, round(h * scale) // 8 * 8)
|
||||
if (nw, nh) == (w, h):
|
||||
return image
|
||||
return image.resize((nw, nh), PILImage.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def make_control_image(init: Image.Image, control: str) -> Image.Image:
|
||||
"""Build the ControlNet conditioning image for the given control type.
|
||||
|
||||
canny: cv2.Canny(gray, 100, 200) -> 3-channel edge map (xinsir canny recipe).
|
||||
tile: the init image itself, no preprocessing (xinsir tile recipe).
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
|
||||
if control == "tile":
|
||||
return init
|
||||
rgb = np.array(init.convert("RGB"))
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
edges = cv2.Canny(gray, 100, 200)
|
||||
edges_rgb = np.stack([edges, edges, edges], axis=-1)
|
||||
return PILImage.fromarray(edges_rgb)
|
||||
|
||||
|
||||
def psnr(a: Image.Image, b: Image.Image) -> float:
|
||||
"""Coarse global fidelity proxy vs the original (NOT a text or watermark metric)."""
|
||||
import numpy as np
|
||||
|
||||
x = np.asarray(a.convert("RGB"), dtype=np.float64)
|
||||
y = np.asarray(b.convert("RGB").resize(a.size), dtype=np.float64)
|
||||
mse = float(np.mean((x - y) ** 2))
|
||||
if mse == 0.0:
|
||||
return 99.0
|
||||
return float(10.0 * np.log10((255.0**2) / mse))
|
||||
|
||||
|
||||
def load_pipeline(control: str, device: str, dtype: Any) -> Any:
|
||||
"""Load SDXL base + the chosen SDXL ControlNet as an img2img pipeline."""
|
||||
import torch
|
||||
from diffusers import (
|
||||
AutoencoderKL,
|
||||
ControlNetModel,
|
||||
StableDiffusionXLControlNetImg2ImgPipeline,
|
||||
)
|
||||
|
||||
console.print(f"Loading {CONTROLNETS[control]} ({control}) ...")
|
||||
controlnet = ControlNetModel.from_pretrained(CONTROLNETS[control], torch_dtype=dtype)
|
||||
load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": dtype}
|
||||
if dtype == torch.float16:
|
||||
# The stock SDXL VAE decodes to NaN/black in fp16; the fp16-fix VAE is the
|
||||
# same swap the production pipeline uses (_SDXL_FP16_VAE_ID).
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(FP16_VAE, torch_dtype=dtype)
|
||||
pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(BASE_MODEL, **load_kwargs)
|
||||
pipe = pipe.to(device)
|
||||
pipe.set_progress_bar_config(disable=True)
|
||||
if device != "cpu":
|
||||
# Keep the 1024 px + extra-ControlNet peak inside 32 GB unified memory.
|
||||
with contextlib.suppress(Exception):
|
||||
pipe.enable_vae_tiling()
|
||||
with contextlib.suppress(Exception):
|
||||
pipe.enable_attention_slicing()
|
||||
return pipe
|
||||
|
||||
|
||||
def run_cell(
|
||||
pipe: Any,
|
||||
init: Image.Image,
|
||||
control_image: Image.Image,
|
||||
strength: float,
|
||||
scale: float,
|
||||
steps: int,
|
||||
guidance: float,
|
||||
seed: int,
|
||||
) -> Image.Image:
|
||||
"""Run one ControlNet img2img cell and return the regenerated image.
|
||||
|
||||
The generator is created on CPU intentionally: a CPU generator is portable
|
||||
across the mps/cuda/cpu backends (diffusers rejects a device-mismatched one),
|
||||
matching the production runner's fallback behavior.
|
||||
"""
|
||||
import torch
|
||||
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
result = pipe(
|
||||
prompt=PROMPT,
|
||||
negative_prompt=NEGATIVE_PROMPT,
|
||||
image=init,
|
||||
control_image=control_image,
|
||||
controlnet_conditioning_scale=float(scale),
|
||||
strength=float(strength),
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=guidance,
|
||||
generator=generator,
|
||||
)
|
||||
return result.images[0]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="ControlNet-as-removal-pipeline prototype sweep.")
|
||||
p.add_argument("image", type=Path, help="Watermarked input image.")
|
||||
p.add_argument("-o", "--out", type=Path, default=Path("controlnet_sweep_out"), help="Output directory.")
|
||||
p.add_argument("--control", nargs="+", choices=list(CONTROLNETS), default=["canny", "tile"])
|
||||
p.add_argument("--strength", nargs="+", type=float, default=[0.3, 0.5, 0.7, 1.0])
|
||||
p.add_argument("--scale", nargs="+", type=float, default=[0.6, 1.0], help="controlnet_conditioning_scale values.")
|
||||
p.add_argument("--size", type=int, default=1024, help="Long-side resolution (multiple of 8).")
|
||||
p.add_argument("--steps", type=int, default=30)
|
||||
p.add_argument("--guidance", type=float, default=7.5)
|
||||
p.add_argument("--seed", type=int, default=0)
|
||||
p.add_argument("--device", default="auto", choices=["auto", "mps", "cuda", "cpu"])
|
||||
p.add_argument("--dtype", default="auto", choices=["auto", "fp16", "fp32"])
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not args.image.exists():
|
||||
log.error("Input image not found: %s", args.image)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
except ImportError:
|
||||
log.error("Pillow is required. Install the gpu extra: uv sync --extra gpu --extra dev")
|
||||
return 1
|
||||
if importlib.util.find_spec("diffusers") is None or importlib.util.find_spec("torch") is None:
|
||||
log.error("diffusers/torch are required. Install: uv sync --extra gpu --extra dev")
|
||||
return 1
|
||||
|
||||
device = pick_device(args.device)
|
||||
dtype = resolve_dtype(device, args.dtype)
|
||||
console.print(f"Device: {device} | dtype: {str(dtype).split('.')[-1]}")
|
||||
|
||||
init_full = PILImage.open(args.image).convert("RGB")
|
||||
init = fit_size(init_full, args.size)
|
||||
console.print(f"Input: {args.image.name} {init_full.size[0]}x{init_full.size[1]} -> {init.size[0]}x{init.size[1]}")
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
stem = args.image.stem
|
||||
init_path = args.out / f"{stem}__INPUT.png"
|
||||
init.save(init_path)
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
table = Table(title="ControlNet sweep")
|
||||
for col in ("control", "strength", "scale", "psnr_vs_input", "file"):
|
||||
table.add_column(col)
|
||||
|
||||
# Group by control so SDXL + the ControlNet load once per control type.
|
||||
for control in args.control:
|
||||
pipe = load_pipeline(control, device, dtype)
|
||||
control_image = make_control_image(init, control)
|
||||
if control == "canny":
|
||||
control_image.save(args.out / f"{stem}__canny_edges.png")
|
||||
for strength in args.strength:
|
||||
for scale in args.scale:
|
||||
tag = f"{control}_s{strength:g}_c{scale:g}"
|
||||
console.print(f"Running {tag} ...")
|
||||
try:
|
||||
out = run_cell(
|
||||
pipe,
|
||||
init,
|
||||
control_image,
|
||||
strength,
|
||||
scale,
|
||||
args.steps,
|
||||
args.guidance,
|
||||
args.seed,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Cell %s failed: %s", tag, exc)
|
||||
continue
|
||||
fname = f"{stem}__{tag}.png"
|
||||
out.save(args.out / fname)
|
||||
quality = psnr(init, out)
|
||||
rows.append(
|
||||
{
|
||||
"control": control,
|
||||
"strength": strength,
|
||||
"scale": scale,
|
||||
"psnr_vs_input": round(quality, 2),
|
||||
"file": fname,
|
||||
"synthid_oracle": "", # fill: clean / present
|
||||
"text_legible": "", # fill: yes / no / partial
|
||||
}
|
||||
)
|
||||
table.add_row(control, f"{strength:g}", f"{scale:g}", f"{quality:.2f}", fname)
|
||||
del pipe
|
||||
_free_memory(device)
|
||||
|
||||
_write_index(args.out, stem, rows, init_path.name)
|
||||
console.print(table)
|
||||
console.print(f"\nWrote {len(rows)} cells to {args.out}/")
|
||||
console.print(f"Next: open {args.out}/sweep_index.csv, run each PNG through the Gemini SynthID oracle,")
|
||||
console.print("fill synthid_oracle (clean/present) + text_legible (yes/no/partial), find the Pareto cell.")
|
||||
return 0
|
||||
|
||||
|
||||
def _free_memory(device: str) -> None:
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
with contextlib.suppress(Exception):
|
||||
import torch
|
||||
|
||||
if device == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
elif device == "mps" and hasattr(torch, "mps"):
|
||||
torch.mps.empty_cache()
|
||||
|
||||
|
||||
def _write_index(out: Path, stem: str, rows: list[dict[str, Any]], input_name: str) -> None:
|
||||
"""Write the CSV index (with empty verdict columns) and a README."""
|
||||
fields = ["control", "strength", "scale", "psnr_vs_input", "file", "synthid_oracle", "text_legible"]
|
||||
with (out / "sweep_index.csv").open("w", newline="", encoding="utf-8") as fh:
|
||||
writer = csv.DictWriter(fh, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
readme = (
|
||||
f"# ControlNet sweep for {stem}\n\n"
|
||||
f"Input (resized): {input_name}\n\n"
|
||||
"Each row in sweep_index.csv is one (control, strength, scale) cell. PSNR vs the\n"
|
||||
"resized input is a COARSE global-fidelity proxy only -- it does NOT measure text\n"
|
||||
"legibility or watermark presence. Decide those two by hand:\n\n"
|
||||
"1. synthid_oracle: open the PNG in the Gemini app, 'Verify with SynthID'. Mark\n"
|
||||
" 'clean' if no SynthID is detected, 'present' if it still is. (No local detector\n"
|
||||
" exists; this manual check is the only valid SynthID oracle.)\n"
|
||||
"2. text_legible: eyeball the small/CJK text. Mark yes / partial / no.\n\n"
|
||||
"The Pareto cell is the one where synthid_oracle=clean AND text_legible=yes at the\n"
|
||||
"lowest strength. If no cell satisfies both, the canny/tile-ControlNet middle path\n"
|
||||
"is dead for text and a glyph re-render is required (see\n"
|
||||
"docs/text-protection-research.md). Record the outcome in\n"
|
||||
"docs/controlnet-removal-pipeline-research.md.\n"
|
||||
)
|
||||
(out / "sweep_README.md").write_text(readme, encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Audit a local image corpus against the library's own ``identify`` detector.
|
||||
|
||||
Two jobs in one pass:
|
||||
|
||||
1. **Report** -- run ``identify`` over every image and write one CSV row per file
|
||||
(verdict, platform, confidence, watermarks, signals, integrity clashes).
|
||||
2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata
|
||||
region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file
|
||||
head for other containers) for known provenance markers. A marker found there
|
||||
on a file the detector calls ``unknown`` is a concrete lib gap: a serialization
|
||||
or generator we do not yet parse. Scanning the metadata region -- not the whole
|
||||
file -- is deliberate: short tokens collide randomly inside compressed PNG
|
||||
``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the
|
||||
first audit naively scanned the first megabyte.
|
||||
|
||||
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
|
||||
``{"AIGC":{...}}`` form). Re-run after collecting a fresh evaluation batch.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals
|
||||
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\
|
||||
--report .local-eval/detector-report.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from _plain_console import Console, Table
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.metadata import _png_late_metadata
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
# Distinctive, multi-byte provenance markers worth flagging when they appear in a
|
||||
# file the detector calls `unknown`. Kept long enough that a random collision in a
|
||||
# (non-scanned) compressed stream is implausible; the metadata-region restriction
|
||||
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
|
||||
# AI source-type / labeling schemes, and distinctive generator name strings.
|
||||
MARKERS: tuple[bytes, ...] = (
|
||||
# C2PA / JUMBF infrastructure and AI source-type / labeling schemes.
|
||||
b"c2pa",
|
||||
b"jumbf",
|
||||
b"contentauth",
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"digitalSourceType",
|
||||
b'"AIGC"',
|
||||
b"<TC260:AIGC>",
|
||||
b"TC260:AIGC",
|
||||
b"tc260.org.cn",
|
||||
b"AISystemUsed",
|
||||
b"SynthID",
|
||||
b"hf-job-id",
|
||||
b"genAIType",
|
||||
b"PhotoEditor_Re_Edit",
|
||||
b"Signature:",
|
||||
# Distinctive multi-word generator strings only. Bare single words (Luma,
|
||||
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
|
||||
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
|
||||
b"Midjourney",
|
||||
b"Stable Diffusion",
|
||||
b"StableDiffusion",
|
||||
b"ComfyUI",
|
||||
b"Automatic1111",
|
||||
b"DALL-E",
|
||||
b"Ideogram AI",
|
||||
b"Adobe Firefly",
|
||||
b"Black Forest",
|
||||
b"volcengine",
|
||||
b"Doubao",
|
||||
b"\xe8\xb1\x86\xe5\x8c\x85",
|
||||
b"Nano Banana",
|
||||
b"Stability AI",
|
||||
b"Samsung Galaxy",
|
||||
)
|
||||
|
||||
|
||||
def _metadata_region(path: Path) -> bytes:
|
||||
"""Return only the bytes where provenance metadata can live, never the
|
||||
compressed pixel stream (which produces random short-token collisions)."""
|
||||
try:
|
||||
head = path.read_bytes()
|
||||
except OSError:
|
||||
return b""
|
||||
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
# All ancillary metadata chunks (window=0), via the library's own walker.
|
||||
return _png_late_metadata(path, 0)
|
||||
if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan
|
||||
out = bytearray()
|
||||
p = 2
|
||||
n = len(head)
|
||||
while p + 4 <= n and head[p] == 0xFF:
|
||||
marker = head[p + 1]
|
||||
if marker == 0xDA: # SOS -> compressed scan data follows
|
||||
break
|
||||
seg_len = (head[p + 2] << 8) | head[p + 3]
|
||||
out += head[p + 4 : p + 2 + seg_len]
|
||||
p += 2 + seg_len
|
||||
return bytes(out)
|
||||
return head[:65536] # webp/avif/heif/jxl: metadata sits near the head
|
||||
|
||||
|
||||
def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
|
||||
return {
|
||||
"path": "", # filled by caller (relative)
|
||||
"is_ai": str(rep.is_ai_generated),
|
||||
"platform": rep.platform or "",
|
||||
"confidence": rep.confidence,
|
||||
"watermarks": "|".join(rep.watermarks),
|
||||
"signals": "|".join(s.name for s in rep.signals),
|
||||
"integrity_clashes": "|".join(rep.integrity_clashes),
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--corpus",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=Path(".local-eval/originals"),
|
||||
show_default=True,
|
||||
help="Directory of images to scan (recursively).",
|
||||
)
|
||||
@click.option(
|
||||
"--report",
|
||||
type=click.Path(path_type=Path),
|
||||
default=None,
|
||||
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
||||
def main(corpus: Path, report: Path | None, limit: int) -> None:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
report = report or corpus.parent / "detector_report.csv"
|
||||
|
||||
files = sorted(p for p in corpus.rglob("*") if p.is_file())
|
||||
if limit:
|
||||
files = files[:limit]
|
||||
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
|
||||
|
||||
verdicts: Counter[str] = Counter()
|
||||
platforms: Counter[str] = Counter()
|
||||
gap_tokens: Counter[str] = Counter()
|
||||
gaps: list[tuple[str, list[str]]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
errors = 0
|
||||
|
||||
with click.progressbar(files, label="identify") as bar:
|
||||
for p in bar:
|
||||
rel = str(p.relative_to(corpus))
|
||||
try:
|
||||
rep = identify(p)
|
||||
except Exception as exc:
|
||||
log.warning("identify failed on %s: %s", rel, exc)
|
||||
errors += 1
|
||||
continue
|
||||
row = _row(rep)
|
||||
row["path"] = rel
|
||||
rows.append(row)
|
||||
if rep.is_ai_generated:
|
||||
verdicts["ai"] += 1
|
||||
platforms[rep.platform or "?"] += 1
|
||||
continue
|
||||
verdicts["unknown"] += 1
|
||||
# A gap candidate is a file identify is *blind* to (no signal at all)
|
||||
# yet whose metadata carries a known marker. A file that produced a
|
||||
# signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we
|
||||
# attribute but do not call AI) is handled correctly -- not a gap.
|
||||
if rep.signals:
|
||||
continue
|
||||
region = _metadata_region(p)
|
||||
hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region})
|
||||
if hits:
|
||||
gaps.append((rel, hits))
|
||||
gap_tokens.update(hits)
|
||||
|
||||
with report.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
|
||||
|
||||
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
|
||||
plat = Table(title="AI platforms", show_header=False)
|
||||
for name, n in platforms.most_common():
|
||||
plat.add_row(str(n), name)
|
||||
console.print(plat)
|
||||
|
||||
if gaps:
|
||||
console.print(
|
||||
f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known "
|
||||
f"marker in their metadata region (potential undetected serialization/generator):"
|
||||
)
|
||||
tok = Table(title="markers seen in unknown files")
|
||||
tok.add_column("count", justify="right")
|
||||
tok.add_column("marker")
|
||||
for name, n in gap_tokens.most_common():
|
||||
tok.add_row(str(n), name)
|
||||
console.print(tok)
|
||||
for rel, hits in gaps:
|
||||
console.print(f" {rel} -> {', '.join(hits)}")
|
||||
else:
|
||||
console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
Treat input datasets as sensitive and read-only. Keep generated reports under
|
||||
.local-eval/. Reports record source filenames and measurements, never image content.
|
||||
|
||||
uv run python scripts/detector_response.py --n 12 # trial, measures throughput
|
||||
uv run python scripts/detector_response.py --n 150 # the real run, resumable
|
||||
"""
|
||||
|
||||
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 / ".local-eval" / "detector-response.jsonl"
|
||||
|
||||
# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
|
||||
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
|
||||
# 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
|
||||
Treat input datasets as sensitive and read-only. Keep output under the gitignored
|
||||
.local-eval/ path. No image content is written into the report.
|
||||
|
||||
uv run python scripts/fill_quality.py --n 60
|
||||
"""
|
||||
|
||||
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 / ".local-eval" / "originals"
|
||||
OUT = REPO / ".local-eval" / "fill-quality.jsonl"
|
||||
|
||||
# Text marks: a bundled alpha PNG plus the engine's own corner geometry.
|
||||
STAMPABLE = ("doubao", "jimeng", "samsung")
|
||||
# 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 -- an unreadable local file 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,117 @@
|
||||
"""Audit invisible-removal output quality by pairing originals with cleaned outputs.
|
||||
|
||||
The spaces routine writes ``<hash>_src.<ext>`` originals and ``<hash>_clean.<ext>``
|
||||
cleaned outputs. For each pair this computes a structural-similarity score plus
|
||||
cheap content proxies (detail via Laplacian variance, resolution, aspect), so the
|
||||
WORST-preserved images can be surfaced and then visually classified.
|
||||
|
||||
SSIM alone does NOT equal "bad": a high-texture image legitimately changes under
|
||||
the SDXL scrub. Use the ranked output to pick candidates, then look at them to
|
||||
name the failure classes (garbled text, deformed faces, over-smoothed detail).
|
||||
|
||||
Operates on gitignored local data only; writes nothing tracked.
|
||||
|
||||
uv run python scripts/invisible_quality_audit.py \
|
||||
--originals .local-eval/originals \
|
||||
--cleaned .local-eval/results \
|
||||
--out .local-eval/quality-audit.csv --worst 25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ssim(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Grayscale SSIM (single-window Gaussian, the Wang et al. formulation)."""
|
||||
a = a.astype(np.float64)
|
||||
b = b.astype(np.float64)
|
||||
c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
|
||||
k = (11, 11)
|
||||
mu_a = cv2.GaussianBlur(a, k, 1.5)
|
||||
mu_b = cv2.GaussianBlur(b, k, 1.5)
|
||||
mu_a2, mu_b2, mu_ab = mu_a * mu_a, mu_b * mu_b, mu_a * mu_b
|
||||
sa = cv2.GaussianBlur(a * a, k, 1.5) - mu_a2
|
||||
sb = cv2.GaussianBlur(b * b, k, 1.5) - mu_b2
|
||||
sab = cv2.GaussianBlur(a * b, k, 1.5) - mu_ab
|
||||
ssim_map = ((2 * mu_ab + c1) * (2 * sab + c2)) / ((mu_a2 + mu_b2 + c1) * (sa + sb + c2))
|
||||
return float(ssim_map.mean())
|
||||
|
||||
|
||||
def _stem(name: str) -> str:
|
||||
"""Strip the _src/_clean suffix and extension to get the pairing key."""
|
||||
base = name.rsplit(".", 1)[0]
|
||||
for suf in ("_src", "_clean"):
|
||||
if base.endswith(suf):
|
||||
return base[: -len(suf)]
|
||||
return base
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--originals", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
|
||||
@click.option("--cleaned", type=click.Path(exists=True, file_okay=False, path_type=Path), required=True)
|
||||
@click.option("--out", type=click.Path(path_type=Path), default=Path(".local-eval/quality-audit.csv"))
|
||||
@click.option("--worst", type=int, default=25, help="Print the N lowest-SSIM pairs.")
|
||||
def main(originals: Path, cleaned: Path, out: Path, worst: int) -> None:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
src_by_key = {_stem(p.name): p for p in originals.iterdir() if p.is_file()}
|
||||
clean_by_key = {_stem(p.name): p for p in cleaned.iterdir() if p.is_file()}
|
||||
keys = sorted(src_by_key.keys() & clean_by_key.keys())
|
||||
click.echo(f"Pairs: {len(keys)} ({len(src_by_key)} src, {len(clean_by_key)} clean)")
|
||||
|
||||
rows: list[dict[str, str]] = []
|
||||
with click.progressbar(keys, label="ssim") as bar:
|
||||
for key in bar:
|
||||
a = image_io.imread(src_by_key[key], cv2.IMREAD_COLOR)
|
||||
b = image_io.imread(clean_by_key[key], cv2.IMREAD_COLOR)
|
||||
if a is None or b is None:
|
||||
continue
|
||||
if a.shape[:2] != b.shape[:2]:
|
||||
b = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_LANCZOS4)
|
||||
ga = cv2.cvtColor(a, cv2.COLOR_BGR2GRAY)
|
||||
gb = cv2.cvtColor(b, cv2.COLOR_BGR2GRAY)
|
||||
h, w = ga.shape
|
||||
rows.append(
|
||||
{
|
||||
"key": key,
|
||||
"ssim": f"{_ssim(ga, gb):.4f}",
|
||||
"laplacian_var": f"{cv2.Laplacian(ga, cv2.CV_64F).var():.1f}",
|
||||
"width": str(w),
|
||||
"height": str(h),
|
||||
"megapixels": f"{w * h / 1e6:.2f}",
|
||||
"aspect": f"{w / h:.2f}",
|
||||
}
|
||||
)
|
||||
|
||||
rows.sort(key=lambda r: float(r["ssim"]))
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with out.open("w", newline="") as f:
|
||||
wtr = csv.DictWriter(f, fieldnames=["key", "ssim", "laplacian_var", "width", "height", "megapixels", "aspect"])
|
||||
wtr.writeheader()
|
||||
wtr.writerows(rows)
|
||||
|
||||
ssims = [float(r["ssim"]) for r in rows]
|
||||
if ssims:
|
||||
arr = np.array(ssims)
|
||||
click.echo(f"\nSSIM mean={arr.mean():.3f} p10={np.percentile(arr, 10):.3f} min={arr.min():.3f}")
|
||||
click.echo(f"Pairs with SSIM < 0.70: {(arr < 0.70).sum()} | < 0.60: {(arr < 0.60).sum()}")
|
||||
click.echo(f"\nWorst {worst} (lowest SSIM):")
|
||||
for r in rows[:worst]:
|
||||
click.echo(
|
||||
f" ssim={r['ssim']} lap={r['laplacian_var']:>8} {r['megapixels']}MP {r['aspect']} {r['key']}"
|
||||
)
|
||||
click.echo(f"\nReport: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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
|
||||
Treat input datasets as sensitive and read-only, and keep output gitignored.
|
||||
|
||||
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 / ".local-eval" / "originals"
|
||||
OUT = REPO / ".local-eval" / "ladder-headroom.jsonl"
|
||||
|
||||
# The rungs the product ships today, and the dense ladder under evaluation. The dense one
|
||||
# is geometric with a ~6% step, chosen from the measured half-width of a rung's lobe
|
||||
# (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="local files to scan")
|
||||
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--report-only", action="store_true")
|
||||
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,262 @@
|
||||
"""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 local data only; writes nothing tracked.
|
||||
|
||||
uv run python scripts/metadata_removal_audit.py \
|
||||
--corpus .local-eval/originals --identify .local-eval/identify \
|
||||
--out .local-eval/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._internal.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(".local-eval/originals"),
|
||||
)
|
||||
@click.option(
|
||||
"--identify",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path(".local-eval/identify"),
|
||||
help="identify-JSON dir to pick carriers (skip = scan all).",
|
||||
)
|
||||
@click.option(
|
||||
"--out",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path(".local-eval/metadata-removal-audit.csv"),
|
||||
)
|
||||
@click.option(
|
||||
"--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op."
|
||||
)
|
||||
@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.
|
||||
|
||||
Treat input datasets as sensitive and read-only, and keep output gitignored.
|
||||
|
||||
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 / ".local-eval" / "visible-positives.jsonl"
|
||||
OUT = REPO / ".local-eval" / "pill-gate-audit.jsonl"
|
||||
|
||||
|
||||
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
||||
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 over local evaluation examples.
|
||||
|
||||
WHY THIS EXISTS AND WHAT IT IS NOT
|
||||
The 849-test suite and `smoke_matrix.py` prove the code paths behave on fixtures and
|
||||
synthetic inputs. This is the other half: run the real `remove-ai-watermarks` entry point,
|
||||
as a user would, over local evaluation images spanning every command and provenance
|
||||
class, and CHECK THE OUTPUT -- not that it exited 0, but that it did the right thing (the
|
||||
mark is actually gone on re-detect, the metadata actually strips, the diffusion actually
|
||||
writes a changed image). A green exit is not evidence the work happened.
|
||||
|
||||
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
|
||||
Treat input datasets as sensitive and read-only. Output stays in a gitignored temp
|
||||
dir. Records example uids and pass/fail, never image content.
|
||||
|
||||
uv run python scripts/real_examples_e2e.py # fast surface (no diffusion)
|
||||
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 / ".local-eval" / "originals"
|
||||
DATASETS = REPO / ".local-eval" / "visible-datasets"
|
||||
SAMPLES = REPO / "data" / "fixtures" / "provenance"
|
||||
_UV = shutil.which("uv") or "uv" # full path avoids the partial-executable lint
|
||||
|
||||
|
||||
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 local evaluation image bucketed as carrying this mark, which the current
|
||||
detector STILL fires on (the bucket was built by an older run; re-confirm live)."""
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
|
||||
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 local evaluation 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,59 @@
|
||||
"""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).
|
||||
|
||||
The calibrated edge-NCC threshold is 0.22 in the top-left ROI. Re-run to regenerate
|
||||
the asset: uv run python scripts/render_pill_silhouette.py
|
||||
|
||||
Requires a CJK font (macOS STHeiti by default); the asset itself is committed, so this
|
||||
script only runs when regenerating it (never in CI).
|
||||
"""
|
||||
|
||||
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,200 @@
|
||||
"""Render synthetic detection silhouettes for vendor text marks.
|
||||
|
||||
Committed assets must be font-rendered and contain no source-image pixels. Local
|
||||
evaluation inputs may be used only to learn glyphs, weight, layout, and detector
|
||||
thresholds. Candidate assets stay outside the installed package until calibrated.
|
||||
|
||||
Regenerate with:
|
||||
uv run python scripts/render_vendor_silhouettes.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import mark_keys # noqa: E402
|
||||
|
||||
_PACKAGE_ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets"
|
||||
_CANDIDATE_ASSETS = _ROOT / "scripts" / "assets" / "visible-mark-candidates"
|
||||
# STHeiti Medium approximates the semibold CJK sans these marks are set in; the exact
|
||||
# family is unpublished for every vendor (GB 45438-2025 only requires a legible face).
|
||||
_FONT = "/System/Library/Fonts/STHeiti Medium.ttc"
|
||||
|
||||
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. 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" candidate stamps an outline cat-head plus bold "AI生成",
|
||||
# bottom-right. It remains unregistered pending sufficient calibration coverage.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
# RunningHub top-left text mark.
|
||||
"runninghub_alpha.png": "RunningHub AI生成",
|
||||
# LibLibAI bottom-center wordmark.
|
||||
"liblib_alpha.png": "LibLibAI",
|
||||
# Zhipu Qingyan candidate text mark.
|
||||
"qingyan_alpha.png": "清言·AI生成",
|
||||
# MiniMax / Hailuo candidate wordmark.
|
||||
"hailuo_alpha.png": "Hailuo AI",
|
||||
# Baidu bottom-right text run.
|
||||
"baidu_alpha.png": "百度",
|
||||
}
|
||||
_REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys()
|
||||
|
||||
# Per-mark post-processing for the multi-line / slanted stamps (see render()).
|
||||
MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
# Hiragino Sans GB W6, tight leading, dilation, and negative shear match the
|
||||
# standard Yuanbao stamp without clipping the lower line.
|
||||
"yuanbao_alpha.png": {
|
||||
"gap_frac": 0.05,
|
||||
"dilate": 2,
|
||||
"shear": -0.60,
|
||||
"font": "/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||
"font_index": 2,
|
||||
},
|
||||
# Qingyan uses a heavier weight than STHeiti Medium.
|
||||
"qingyan_alpha.png": {"font": "/System/Library/Fonts/Hiragino Sans GB.ttc", "font_index": 2},
|
||||
# LibLibAI uses an Arial-class grotesque.
|
||||
"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
|
||||
extra = int(abs(shear_k) * hh)
|
||||
offset = extra if shear_k < 0 else 0
|
||||
sil = cv2.warpAffine(
|
||||
sil,
|
||||
np.float32([[1, shear_k, offset], [0, 1, 0]]),
|
||||
(ww + extra, hh),
|
||||
)
|
||||
ys, xs = np.where(sil > 0)
|
||||
if xs.size:
|
||||
sil = sil[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
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 calibrated layout. The outline
|
||||
form is the parked candidate described in 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))
|
||||
output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = output_dir / name
|
||||
Image.fromarray(sil).save(output)
|
||||
print(f"wrote {output} ({sil.shape[1]}x{sil.shape[0]}) text={text!r}")
|
||||
except OSError as e:
|
||||
print(f"Font not found ({e}); install a CJK font or edit _FONT.", file=sys.stderr)
|
||||
raise SystemExit(1) from e
|
||||
|
||||
|
||||
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
|
||||
The suite covers truncated files, mismatched extensions, Unicode filenames, and
|
||||
concurrent access because a wrapping service may run
|
||||
concurrent jobs against one path. Decompression bombs and absurd geometry are the cheap
|
||||
denial-of-service shapes any tool taking untrusted files 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" / "fixtures" / "provenance"
|
||||
_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 from representative malformed inputs."""
|
||||
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 PNG cut mid-stream.
|
||||
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.
|
||||
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. A regression case showed that
|
||||
`batch --mode visible` into a read-only directory could write no files and exit 0, so a
|
||||
wrapping service would treat an empty output directory as a completed run. Any check
|
||||
for a silent no-op must assert on the ARTIFACTS, not on the status.
|
||||
"""
|
||||
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()
|
||||