Compare commits
No files matched your search
@@ -0,0 +1,130 @@
|
||||
---
|
||||
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
|
||||
description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, and the detection-path measurement rule.
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it.
|
||||
|
||||
`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. It is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds.
|
||||
|
||||
The same rule applies to install hints: name the extra that actually makes the command work (`qwen-zimage`, not `diffusion`), and keep the printed command shell-quoted -- bare `pkg[extra]` is a glob in zsh.
|
||||
|
||||
## 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.
|
||||
|
||||
From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself.
|
||||
|
||||
## 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`;
|
||||
- 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`;
|
||||
- the face stack's dtype, in `test_qwen_zimage_pipeline.py`. A subclass that changes
|
||||
the pipeline dtype for its own global model must not change the inherited face
|
||||
stage's; `sdxl-zimage` shipped doing exactly that and crashed on every image with a
|
||||
face. When one profile inherits another's stage, guard the invariants that stage
|
||||
relies on, not just the code path.
|
||||
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
|
||||
mirror another, compare them field by field rather than pinning the values you happen
|
||||
to know about, so the next field added on one side and not the other fails at the
|
||||
seam. Two of these defaults drifted in practice and neither needed a GPU to catch;
|
||||
the incident is recorded in `docs/module-internals.md`. Keep the comparison free of
|
||||
an exception table: a field that needs one is a field that belongs elsewhere, which
|
||||
is what `force` turned out to be.
|
||||
|
||||
A defaults comparison is not a forwarding test, and the two fail differently. Pin the
|
||||
VALUE at the seam, not just the name -- `_run_invisible` passed the whole suite with
|
||||
`controlnet_conditioning_scale` hardcoded, because nothing asserted the caller's value
|
||||
arrived. `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the
|
||||
real seam with every field set off its default, so one test covers the whole bag
|
||||
instead of one assertion per knob.
|
||||
|
||||
Count the seams before believing a knob is covered. Each of `force` and
|
||||
`controlnet_conditioning_scale` reaches the engine through TWO paths -- `remove_all`
|
||||
versus `remove_batch(mode="all")` for the first, `_run_invisible` versus `_batch_engine`
|
||||
for the second -- and in both cases guarding one path left the other free to hardcode a
|
||||
constant with a green suite. The mode-parametrized guards in
|
||||
`TestRemoveBatchLibrary::test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
|
||||
`TestBatchCommand::test_batch_controlnet_scale_flows_to_the_cached_engine` exist because
|
||||
that is what actually happened.
|
||||
|
||||
Use availability checks only for paths that actually load large models.
|
||||
|
||||
## One measurement, one gate seam
|
||||
|
||||
A detector is split into a trust-level-blind scan and a verdict that applies the
|
||||
threshold, so `detect` and `detect_both` reach the same numbers by construction. Two
|
||||
rules follow, and both were broken in practice before they were written down:
|
||||
|
||||
- A per-mark demotion goes in the `_post_gate` hook (or, for a whole-scan precondition
|
||||
like LibLibAI's size floor, in `_scan`) -- never in a `detect` override. An override
|
||||
is invisible to `detect_both`, so the RunningHub and Yuanbao anchor gates silently
|
||||
stopped applying on the arbiter's perception path. `TestSinglePassPerception` is the
|
||||
guard: it asserts `detect_both` equals two `detect` calls field for field.
|
||||
- Detection and the removal mask must read ONE sweep. The winning box travels on
|
||||
`TextMarkDetection.match_box` and the registry threads the detection into the mask
|
||||
builder; a mask path that re-runs its own sweep is how the two drift apart.
|
||||
|
||||
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
|
||||
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
|
||||
|
||||
A third seam reaches the same verdict: `collect_metadata_record` ->
|
||||
`evidence_from_metadata_record` -> `identify_from_evidence`, the path a caller uses when
|
||||
collection and verdict run on different machines. Its contract is equality with
|
||||
`identify(path, check_visible=False, check_invisible=False)` on the same image, and it
|
||||
can break from EITHER side -- a region the collector stops walking, or a placement the
|
||||
file path learns to read and the record does not. `tests/test_metadata_record.py` pins
|
||||
it over the tracked fixtures; a separate local evaluation corpus catches placements the
|
||||
fixtures do not cover. Change either side and re-run both.
|
||||
|
||||
Before changing anything in the detection path, record the detectors' exact verdicts
|
||||
over a local sample first and diff them after. A refactor here is only correct if that
|
||||
record is byte-identical, and a green test suite does not establish that on its own. A
|
||||
change that is meant to FIX detection is the exception that proves the rule: the diff
|
||||
must then be exactly the files you intended to change, named in advance.
|
||||
|
||||
## A certified operating point is data, not a constant
|
||||
|
||||
The video SynthID default is only meaningful as a row in
|
||||
`data/evaluations/video-synthid-oracle.csv`, so
|
||||
`test_shipped_defaults_match_a_certified_manifest_row` derives the pin from that
|
||||
manifest instead of restating literals. Pinning `noise_std` alone had let `long_side`
|
||||
and `fps` -- two thirds of what the oracle was actually shown -- move with a green
|
||||
suite.
|
||||
|
||||
The certified profile is a perturbation-to-signal ratio, not a bare `noise_std`, so
|
||||
the latent scaling factor is gated in `load_video_vae_runtime`, carried on
|
||||
`VideoVaeRuntime`, and passed into encode and decode: the validated value and the
|
||||
applied value are one measurement. Anything that produces oracle evidence loads
|
||||
through that function. `video_synthid_sweep.py` hand-rolled the load and was the one
|
||||
path exempt from the gate it exists to feed, which is exactly backwards.
|
||||
|
||||
Prove a video-path refactor the same way the detection path is proven, and without
|
||||
needing an oracle carrier: build a clip from a tracked fixture with ffmpeg, run the
|
||||
engine before and after, and require an identical output sha256. Keep the generated
|
||||
media outside the repository.
|
||||
|
||||
Frame sampling is compared as `timestamp + 1e-9 >= next_sample_time`, so mutating
|
||||
that `>=` to `>` changes nothing and a green suite proves nothing. Mutate the phase
|
||||
or the period instead -- starting the accumulator half a period late shifts the
|
||||
selection by one frame while keeping the count identical, which is the drift a
|
||||
frame-count check cannot see and what
|
||||
`test_pairing_follows_the_engine_sampling_rule_not_just_the_frame_count` exists to
|
||||
catch.
|
||||
|
||||
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
|
||||
@@ -1,3 +1,3 @@
|
||||
# HuggingFace token (optional; only needed for gated/private models)
|
||||
# Get yours at: https://huggingface.co/settings/tokens
|
||||
HF_TOKEN=
|
||||
# HF_TOKEN=
|
||||
@@ -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,179 @@
|
||||
name: Distribute on release
|
||||
|
||||
# Fans a published GitHub Release out to the channels that need a nudge.
|
||||
# PyPI is handled by publish.yml. This workflow 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
|
||||
|
||||
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,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -30,5 +30,12 @@ jobs:
|
||||
- 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
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@@ -44,3 +44,43 @@ jobs:
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run tests
|
||||
run: uv run pytest -q
|
||||
|
||||
transport-contract-python-314:
|
||||
name: versioned transport contracts py3.14
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Create clean runtime contract environment
|
||||
run: uv venv --python 3.14 .venv-contract
|
||||
- name: Install runtime contract dependencies
|
||||
run: >-
|
||||
uv pip install --python .venv-contract/bin/python
|
||||
".[pixels,heif]" "numpy>=2,<3" "pytest>=8"
|
||||
- name: Run versioned transport contract tests
|
||||
run: >-
|
||||
.venv-contract/bin/python -m pytest -q
|
||||
tests/test_forensic_metadata.py
|
||||
tests/test_metadata_record.py
|
||||
tests/test_pixel_evidence.py
|
||||
|
||||
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
|
||||
tests/test_video_fidelity_probe.py
|
||||
@@ -4,6 +4,8 @@ __pycache__/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
tmp/
|
||||
.sc/
|
||||
|
||||
# Environment secrets
|
||||
.env
|
||||
@@ -18,11 +20,7 @@ Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Test results
|
||||
data/results/
|
||||
|
||||
# SynthID corpus reference fills (synthetic black/white calibration tiles,
|
||||
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
|
||||
# Generated calibration inputs
|
||||
data/synthid_corpus/refs/
|
||||
|
||||
# Reference materials
|
||||
@@ -36,13 +34,17 @@ yolov8n.pt
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
|
||||
# committed (content-free: a solid colour + the watermark; the source for
|
||||
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
|
||||
# synthetic seeds (regenerable) and any real-content validation download (a real
|
||||
# generated scene, kept local for privacy) are NOT committed.
|
||||
# Generated calibration inputs and local evaluation artifacts
|
||||
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_*
|
||||
|
||||
# Runtime model artifacts
|
||||
gfpgan/
|
||||
|
||||
# Local evaluation data
|
||||
.local-eval/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -1,88 +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 visible <image.png> -o <out.png>` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark; `--mark gemini` / `--mark doubao` / `--mark jimeng` force one. Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng adds an always-on residual inpaint over the glyph footprint** (its mark re-rasterizes per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`.
|
||||
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable.
|
||||
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the same `--strength`/`--steps`/`--pipeline`/`--device`/`--max-resolution`/`--seed`/`--hf-token` knobs as `invisible`, `--inpaint/--no-inpaint` for the visible pass, and `--humanize` for the Analog Humanizer
|
||||
```bash
|
||||
uv run remove-ai-watermarks --help
|
||||
bash maintain.sh
|
||||
```
|
||||
|
||||
## Test and lint
|
||||
|
||||
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building. **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry, ~line 2868), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (synthid_corpus images ~65 MB + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. **A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch. **Build backend is pinned `hatchling<1.28`** (`[build-system] requires`): hatchling 1.28+ emits **Metadata-Version 2.5** (PEP 639), which the twine bundled in `pypa/gh-action-pypi-publish@release/v1` rejects (`"'2.5' is not a valid Metadata-Version"`) — this **failed the v0.8.3 PyPI upload on 2026-06-01** (tag-match + build passed, the upload step failed; nothing was uploaded, so the version stayed empty on PyPI). 1.27.x emits 2.4, which uploads fine (0.8.2 shipped on it). The pin is unpinned `requires = ["hatchling"]` no longer safe because `uv build` pulls the latest hatchling. Lift the pin only once the publish action's twine is ≥ 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
|
||||
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
|
||||
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `face_protector`, `humanizer`, `invisible_engine`, `noai/watermark_remover`, and the whole `noai/ctrlregen/` subpackage) carry a documented per-file `# pyright:` relax pragma (or, for `ctrlregen`, a `tool.pyright.executionEnvironments` entry) that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.)
|
||||
- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly.
|
||||
- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
|
||||
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand.
|
||||
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `<TC260:AIGC>` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC); synthetic byte blobs cover the JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent).
|
||||
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles.
|
||||
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
|
||||
- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent.
|
||||
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`), the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover), and the tiling math `tile_positions` (now raises `ValueError` when not `0 <= overlap < tile`)/`make_blend_weight`/`resize_center_crop` (`test_tiling.py`; `pytest.importorskip("torch")` since `tiling.py` imports torch at module top). Don't skip these as "ML, needs a model" — only `run_tiled`/`remove_watermark`/the diffusion bodies do.
|
||||
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, and `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both the PNG parser and the non-PNG binary path). PNG/caBX chunk reads are clamped to the remaining file size (`safe_length = min(length, remaining)`; skipped chunks use seek) so a malformed huge `length` cannot drive a multi-GB allocation (shared safety discipline matching `isobmff.scan_c2pa_region`).
|
||||
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new issuer/binding here, not inline.
|
||||
- `metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus the payloads of any provenance metadata found beyond that window — for ISOBMFF, the late provenance boxes from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); for **PNG**, the late `tEXt`/`iTXt`/`zTXt`/`eXIf`/`iCCP` chunks from `_png_late_metadata` (catches an XMP/EXIF packet appended after a large `IDAT`, e.g. a TC260 AIGC label at ~2.7 MB). Behavior-neutral (`f.read(size)`) for non-ISOBMFF inputs and for any file that fits within `size`. Use it instead of `open().read(1MB)` for any new marker scan. `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output. `strip_c2pa_boxes` is **fail-safe** on a malformed box: it returns the original bytes unchanged with a logged warning instead of truncating the tail to EOF (detection-only `scan_c2pa_region` still stops at a malformed box). `_png_late_metadata` clamps each late-chunk read to the remaining file size (`safe_length = min(length, remaining)`) so a malformed `length` cannot drive a multi-GB allocation.
|
||||
- `identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng reuse the registry detectors (`_visible_text_marks` → `watermark_registry`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOLine truncated
|
||||
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao and Jimeng both add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), and `jimeng` (bottom-right "★ 即梦AI"). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection. **Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold), so `auto` picks the right one on a Doubao vs Jimeng image. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove`/`_jimeng_remove` apply reverse-alpha only when the mark is detected AND `reverse_alpha_available`; outside that, removal is **skipped** (not inpainted). Add a new visible mark = one `KnownMark` entry + its engine (with a captured alpha map); do not re-add per-mark `if` branches in the CLI. **Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. Reverse-alpha (and `erase` inpaint) recover real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
|
||||
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`. The public entry points normalize a grayscale (2D) or RGBA (4-channel) input to BGR up front so a non-BGR image does not crash the cv2 pipeline. **Removal is reverse-alpha with NO inpaint** (`remove_watermark` → `_reverse_alpha_blend`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, which are PURE-BLACK so the alpha is near-exact — re-verified clean on `demo_banana_before.png` 2026-05-31 (the registry's optional `inpaint_residual` is a no-op on a clean removal; an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact). **The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
|
||||
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). **Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing. **Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`. **Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048. **The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual). **Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.** Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark). **Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
|
||||
- `jimeng_engine.py` — visible Jimeng / Dreamina "★ 即梦AI" remover/detector (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Mirrors `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`. **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual). **Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black), and the careful build drops the gray self-residual to ~1.3. **The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused. **Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` normalizes grayscale (2D) and RGBA (4-channel) inputs up front (`erase_cv2` splits off any alpha plane and re-attaches it on the result): `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
|
||||
- `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs.
|
||||
- `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton guarded by a double-checked `threading.Lock` so concurrent callers do not double-download the weights, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise.
|
||||
- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by `_REGION_HIRES_SCALE` 3.0 (applied as an integer factor via int(...), capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (the shift is applied only on a confident, small correlation -- `response > 0.3` and `|shift| < 4` -- so a spurious large offset on a flat crop no longer garbles the composite; and after a CPU fallback the generator is dropped before the per-region passes to avoid an MPS-vs-CPU generator device mismatch) (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI off-switch `--no-protect-text` on `invisible`/`all`. `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **MUST still be confirmed by the SynthID oracle** (openai.com/verify / Gemini app) that a region-rescrubbed text zone reads watermark-free before trusting it in prod. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested.
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features. The expensive extract+blend already runs only when a face is found, but the YOLO detector itself always loads+runs to decide; CLI off-switch `--no-protect-faces` on `invisible`/`all` skips it entirely (mirrors `--no-protect-text`), for images known to have no people.
|
||||
- `humanizer.py` — optional post-process "humanize" effects (cv2/numpy). The chromatic-shift step replicates the border instead of wrapping opposite-edge pixels, so a shifted channel no longer bleeds the far edge into the near one.
|
||||
- `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** `imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
|
||||
## Test and lint
|
||||
|
||||
### Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
|
||||
`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.
|
||||
|
||||
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha is built** (`doubao_engine.remove_watermark_reverse_alpha`, `assets/doubao_alpha.png`; see the `doubao_engine.py` bullet above). The captures (`data/doubao_capture/captures/`, now committed) confirmed the alpha-composite model: on black `captured = a*logo`, logo pure white. **UPDATE 2026-05-31 (issue #13 follow-up): the first build was NOT "exact"** — it left a readable "豆包AI生成" outline on the real sample (the detector was fooled, conf 0.0). The alpha is now rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve shared with Jimeng), removal always-aligns + thin-inpaints, and the locate box was widened; see the `doubao_engine.py` bullet. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary.
|
||||
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).
|
||||
|
||||
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.** The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- `data/spaces/originals/` holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo colour) was tried end-to-end and **still leaves a persistent ghost outline.**
|
||||
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist.
|
||||
|
||||
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into colour noise.
|
||||
## Module architecture
|
||||
|
||||
**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in grey on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. Black/white/gray seeds exist (`data/doubao_capture/seeds/seed_*_1x1_2048x2048.png`); a capture run (feed a black seed through doubao.com edit mode, download the *original*) was requested from the #13 reporter 2026-05-29. With ~2-3 black captures we get `α = capture/255` for free, Gemini-quality.
|
||||
[`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.
|
||||
|
||||
**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.
|
||||
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).
|
||||
|
||||
## Watermarking landscape (research 2026-05-24)
|
||||
## Data safety
|
||||
|
||||
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). Caveat: fragile to re-encoding.
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). **Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). **ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29.
|
||||
- **EXIF/XMP generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)"). **Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/<uuid>.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/samples/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). **Stripped on removal too:** `remove_ai_metadata` now calls `_scrub_ai_exif` on the JPEG EXIF, which deletes the xAI Signature+UUID-Artist pair **and** any `Software`/`Make`/`Artist`/`ImageDescription` tag holding an `AI_GENERATOR_TOKENS` token (so Ideogram's `Make="Ideogram AI"` is scrubbed too), while keeping genuine camera/editor EXIF. The shared `_is_xai_signature_pair` helper (module-level compiled regexes) is the single source of truth for the pattern, used by both `xai_signature` and `_scrub_ai_exif`. (AVIF/HEIF/JXL still strip only C2PA boxes via `isobmff`, not EXIF — unchanged.)
|
||||
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). **Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **three serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), **and** a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`). Both generic forms (the PNG chunk and the bare `{"AIGC":...}` object) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID (3 on the corpus 2026-05-28, no other signal). It marks the *hosting job*, not a model — most commonly diffusion output — so it lifts an Unknown verdict to a tentative AI via `hf_only` (parallel to the visible sparkle) but never overrides a hard metadata signal; `_HF_JOB_CAVEAT` states the limit (job, not model; not proof of AI pixels). Stripped on removal (the PNG save whitelist keeps only `STANDARD_METADATA_KEYS`, so `hf-job-id` and the `AIGC` chunk are both dropped). The exact writer is not authoritatively documented (HF Jobs are generic GPU jobs), hence medium not high.
|
||||
- **No detectable signal on download (correctly reported `unknown`):** **Recraft** (PNG export is a re-encoded design export — strips everything), **Krea hosting FLUX 2** (no imwatermark despite FLUX — the host omits the encoder, same as Stability's hosted SDXL), and Midjourney (embeds nothing). Lesson: the imwatermark detector only fires on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL), not from re-hosts (Krea/Stability) or re-encoded exports (Recraft/Canva).
|
||||
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
|
||||
- **Built 2026-05-26 (this batch):** soft-binding `alg` vendor detection; IPTC Photo Metadata 2025.1 AI-disclosure fields (`AISystemUsed` etc.); **video C2PA metadata** detect + strip for MP4/MOV/M4V (free — `isobmff.py` is format-agnostic, MP4 is ISOBMFF); Adobe TrustMark open decoder. NOT done (out of cheap reach, per the feasibility review): visible video-logo removal (needs a video frame pipeline) and audio (SynthID/ElevenLabs/Resemble/Suno all oracle-only or unmarked). **Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`). **Meta-box XMP removal — now handled (v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (HEIF/AVIF; out of reach of the top-level box stripper) is blanked in place by `isobmff.blank_ai_xmp_packets` — it locates the packet by its `<?xpacket begin … end?>` delimiters and, if it carries an AI marker (`_AI_LABEL_MARKERS`), overwrites it with spaces of the SAME length, so box sizes / `iloc` offsets stay valid and the coded image is untouched (selective: plain non-AI XMP is left alone, mirroring the top-level uuid logic). Wired into `remove_ai_metadata`'s ISOBMFF branch after `strip_c2pa_boxes`. The remaining gap is an `Exif` meta-box *item* (rare; the AI labels are XMP) — still needs `iinf`/`iloc` surgery or exiftool.
|
||||
- **Regulatory driver (context, not a code change):** AI-content labeling mandates are expanding, which pushes more generators toward exactly the C2PA + watermark signals we read. The full per-jurisdiction table lives in README "## Legal" -- keep it there, not duplicated here. Newly added + primary-source verified 2026-05-26: **EU AI Act Article 50** machine-readable marking applicable **2026-08-02** (verified against the article text); **South Korea AI Framework Act Art. 31(3)** in force since **22 January 2026** (verified via Kim & Chang + FPF/Korea Times; Enforcement Decree accepts an invisible-watermark label); **California AB 853** (amends the CA AI Transparency Act) latent-disclosure duty operative **2026-08-02**, requiring a disclosure "permanent or extraordinarily difficult to remove" (verified against the leginfo bill text -- this is the exact disclosure our tool strips); **India IT Amendment Rules 2026** in force **2026-02-20** (verified via Chambers), which prominently-label + permanent-provenance-id all synthetic media AND **expressly prohibit removing/suppressing the label or metadata** -- the first major all-content removal ban outside China. **Removal liability (README "## Legal" disclaimer):** the tool is lawful general-purpose software; liability sits with the remover and is intent-gated -- downstream acts (fraud/deception/IP), plus US DMCA 17 USC 1202 (removing copyright-management info to conceal infringement), plus the removal-as-such bans in China + India. When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries.
|
||||
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.
|
||||
|
||||
## Known limitations
|
||||
## Rules and conventions
|
||||
|
||||
- `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 points (the OOM is memory-tier-dependent, NOT a hard MPS limit):** on a ~24 GB unified-memory machine (verified 2026-05-25, 1254x1254 gpt-image SDXL, fp32) native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. On a **32 GB** unified-memory machine the same default SDXL pass runs entirely on MPS with **no CPU fallback** (verified 2026-05-31, 1122x1402 gpt-image, `all`/default, ~155 s end-to-end), so 32 GB clears the native-res UNet peak that 24 GB could not. Adding `enable_vae_tiling()` alone does NOT prevent the 24 GB OOM (the peak is the UNet, not the VAE). The fast Mac workarounds for memory-constrained machines are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. **ctrlregen is compute-bound, not memory-bound, on MPS:** the clean-noise profile tiles to 512px (e.g. ~12 tiles for a 1122x1402 image) and runs the full step count per tile (strength 1.0 -> ~50 effective steps/tile), so on a base-tier Apple-GPU laptop it is ~25-30 min/image after the one-time DINOv2-giant download, regardless of having 32 GB -- a discrete CUDA GPU (fp16, no tiling) is the right place for ctrlregen, while the default SDXL pass is comfortable on a 32 GB Mac. 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.
|
||||
- **fp16 VAE black-output fix (issue #29, 2026-05-30):** on a **CUDA/XPU fp16** backend the stock SDXL VAE overflows to NaN and the *plain* img2img path decodes to an **all-black** image (reproduced on the raiw.cc result: a 1086x1448 input -> a uniformly black 4.6 KB PNG, mean 0). `watermark_remover._load_pipeline` now swaps in the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix` = `_SDXL_FP16_VAE_ID`) when `_needs_fp16_vae_fix(model_id, DEFAULT_MODEL_ID, is_fp16)` is true -- only the default SDXL checkpoint on fp16. **cpu/mps run fp32** (the stock VAE is fine there, which is why the bug never reproduces on Mac), and the **differential / region-hires** pipeline already upcasts the VAE itself (see the `text_protector` bullet). A custom non-SDXL `model_id` keeps its own VAE (the fp16-fix VAE is SDXL-architecture-specific). The decision is a pure helper, unit-tested without a download (`tests/test_platform.py::TestFp16VaeFix`); the actual black->clean recovery needs a CUDA GPU and was NOT verifiable on this MPS machine -- confirm on the backend / an NVIDIA box.
|
||||
- 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`.
|
||||
- **rich was dropped (CLI + scripts print plain text via `click.echo`).** `cli.py` renders through small `_Console`/`_Table`/`_Progress` shims; the analysis scripts (`scripts/synthid_corpus.py`, `synthid_pixel_probe.py`, `text_detection_benchmark.py`, `corpus_gap_scan.py`) import `Console`/`Table` from the shared `scripts/_plain_console.py` shim (markup like `[bold]`/`[/]` is stripped, tables render aligned). Consequences: (1) `rich` is NOT a dependency, so anything that imports it breaks a clean `uv sync --frozen` (CI installs core+dev only) — this exact gap red-failed CI after the refactor when those 4 scripts still imported rich; if you add a script, use the `_plain_console` shim, not rich. (2) The old `[gpu]`-bracket-eaten bug (#19) is gone — plain `click.echo` prints `pip install 'remove-ai-watermarks[gpu]'` verbatim, no escaping needed (regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`). (3) No Unicode glyphs / colors / progress bars in CLI output by design.
|
||||
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
|
||||
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). **Each vendor's oracle detects only its OWN content (verified on the page 2026-05-31):** `openai.com/research/verify` states verbatim "OpenAI generation signals will only be detected if the image was generated with our tools" and "Content could also still be AI-generated by another company's model, which the tool currently does not detect" -- SynthID is shared tech but the verifier is keyed to its own vendor's payload, so a Google-SynthID image reads clean on OpenAI's verifier and vice-versa. **This explains the recurring "oracle says clean but `identify` still flags SynthID" report (#14):** the oracle reads the *pixel* watermark (gone after our SDXL pass), while `identify` reads the *C2PA-metadata proxy* (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (`metadata --remove` / `all`) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone. 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" Line truncated
|
||||
- **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.
|
||||
- **SynthID v2 vs default pipeline:** **CORRECTION (2026-05-31, oracle-verified GPU study, SUPERSEDES the 0.10 claim below): the current Gemini SynthID survives 0.10/0.15/0.2 and is REMOVED only at strength 0.3** (Modal A100, native res, Gemini-app "Verify with SynthID", n=3 FRESH Gemini images, `protect_text`/`faces` OFF; 0.2 still present, 0.3 removed). `DEFAULT_STRENGTH` was raised **0.10 -> 0.30** to match. The "0.10 removes it" finding below was n=1 and is now stale -- the threshold has climbed 0.05 -> 0.10 -> ~0.3 as Google hardens SynthID, so re-test against fresh Gemini periodically (moving target). 0.3 costs SSIM ~0.97 vs original (modest) but softens dense/fine typography, and is **overkill for non-SynthID sources** (OpenAI/ChatGPT carry C2PA, NOT Google SynthID -- their pixels read negative on the Gemini verifier at every strength, so 0.10 is plenty there; a per-source strength via `identify` was considered and deferred in favour of the simpler single 0.3 default). **`protect_text` does NOT block SynthID removal and is recommended ON (corrected 2026-05-31 by A/B tests at 0.3, reversing an earlier wrong claim):** verified on a GENUINE Gemini-SynthID image (`gemini_633uuy`, a photo with a Chinese-text sign) AND an OpenAI infographic (`qw1212ss_pic3`), both with protect_text ON and OFF -- the Gemini-subject run is the load-bearing one (Gemini-oracle verified; the OpenAI image is not a valid SynthID subject). SynthID is a GLOBAL watermark, so regenerating the non-text regions at 0.3 destroys the payload whether protect_text is on or off -- it clears in BOTH. protect_text additionally SALVAGES text fidelity (medium headings/body stay readable; with it off the same text garbles), at ~3x runtime. Only the very smallest text still softens at 0.3 (0.3 is inherently aggressive). **Recommended SynthID config: strength ~0.3 + protect_text ON (the default).** The earlier "protect_text shields the watermark, use `--no-protect-text`" claim was wrong -- it mistook a strength failure (0.10 too weak) for a protection effect. **Oracle scope (load-bearing):** the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (it detects Google's mark on any image); `openai.com/verify` is scoped to OpenAI provenance (its own C2PA) and is **NOT** a SynthID oracle, so a negative there is meaningless for SynthID and the older "OpenAI cleared at 0.05 on openai.com/verify" notes below are about provenance, not a pixel-SynthID measurement. **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). **Resolution dependence confirmed by a user report (#14, qw1212ss, 2026-05-31):** on 1600x1600 gpt-image outputs checked via openai.com/verify, 0.05 left SynthID detected on **7/8** images, while small images (376x429) cleared at ~100% -- so "OpenAI cleared at 0.05" was a low-resolution result; a larger canvas carries a stronger watermark and needs more strength. **Policy (do NOT chase a single magic number or build resolution/vendor-adaptive defaults): 0.10 is the default because it is what clears the watermark today; if the oracle still reads SynthID, the guidance is simply to raise `--strength` (0.12, then 0.15), using the lowest value that verifies clean.** There is no local SynthID detector, so the tool cannot self-check and auto-tune; both vendors tighten the watermark over time, so any fixed value is a moving target. README "Removing SynthID" documents the strength-ladder guidance for users. The original claim below (0.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **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 defaultLine truncated
|
||||
- **CtrlRegen profile uses a clean-noise default strength, NOT the SDXL 0.10 (fixed 2026-05-31).** **CORRECTION (2026-05-31, same oracle-verified GPU study): ctrlregen at its clean-noise strength DESTROYS real images** -- smooth/background regions fill with hallucinated micro-text garbage; the pipeline is binary (low strength = no-op, high = destroy, no usable middle) and heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL). So the literature's "clean-noise is the lever" (detailed below) did NOT survive empirical testing on real content. ctrlregen is now flagged **EXPERIMENTAL** (CLI `--pipeline` help, README, and the `watermark_profiles` comment) and is **NOT for production** -- SDXL img2img at ~0.3 is the shippable path. The clean-noise-default plumbing below is kept (so the profile at least does real work if anyone opts in), but do not recommend ctrlregen. `--pipeline ctrlregen` no longer inherits the SDXL img2img `--strength` default. `resolve_strength(strength, profile)` (`watermark_profiles.py`, pure + unit-tested in `test_platform.py::TestResolveStrength`) resolves an unset `--strength` to `CTRLREGEN_DEFAULT_STRENGTH` (**1.0**) for ctrlregen and `DEFAULT_STRENGTH` (0.10) for the SDXL default; an explicit `--strength` always wins (including `0.0` -- the resolver checks `is None`, not falsiness, so it does not repeat the old `strength or DEFAULT` bug). CLI `--strength` for `invisible`/`all` now defaults to **None** (batch already did); the display (`cli.py`) and the engine (`watermark_remover.remove_watermark`) both route through `resolve_strength` so they never disagree. **Why (deep-research pass 2026-05-31, primary sources):** CtrlRegen's removal power comes from regenerating from (near) clean Gaussian noise, not the light partial-noise img2img the SDXL pass uses. CtrlRegen (ICLR 2025, arXiv:2410.05470) diagnoses verbatim that prior partial-noise regeneration "struggles with high-perturbation watermarks" because a small noise step "retains" watermark info that diffuses back into the output; the fix is a clean-noise start, which with `StableDiffusionControlNetImg2ImgPipeline` maps to strength ~1.0 (image structure held by the canny ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). **Before the fix `--pipeline ctrlregen` ran at 0.10** -- a near-identity pass that loaded ControlNet + DINOv2-giant and then barely changed the image (a removal no-op). **NOT yet oracle-verified** that clean-noise ctrlregen clears the stubborn high-texture gpt-image class that 0.20 SDXL img2img could not (issue #14, qw1212ss: pic3/6/7 survived SynthID through 0.05->0.20); that is the pending controlled test (via openai.com/verify with the IP-country rate-limit bypass). **Forensic-stealth caveat applies harder here:** regeneration-family removal is the MOST detectable as "an image that went through a removal pipeline" (CtrlRegen+ 99.97% TPR@1%FPR, arXiv:2605.09203). **Two #14-investigation hypotheses the literature did NOT confirm:** (1) our "VAE round-trip drives removal, denoising strength does not" framing is only PARTIALLY supported -- arXiv:2510.09263 confirms SynthID was hardened against *weak* VAE re-generation (explaining survival) but does not name the VAE round-trip as the removal vector; (2) our "survival correlates with high-frequency CONTENT texture (Laplacian 466 vs 236)" is unconfirmed by any primary source -- the literature establishes *watermark-perturbation-strength* dependence (a different axis), so the texture correlation stays our own unverified observation, not a literature-backed fact.
|
||||
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, model-adjacent tests, and the detection-path measurement rule |
|
||||
@@ -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,408 +1,452 @@
|
||||
# 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.
|
||||
|
||||
> ## Try it online: [raiw.cc](https://raiw.cc)
|
||||
>
|
||||
> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee.
|
||||
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.
|
||||
|
||||
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
|
||||
> or run diffusion models locally.
|
||||
|
||||
[](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)
|
||||
|
||||
If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta).
|
||||
> This project is for lawful use on content you own. It does not target stock
|
||||
> agency previews or other watermarks that protect third party paid content.
|
||||
> See [scope, safety, and legal notes](docs/legal-and-safety.md).
|
||||
|
||||
> **Intended for lawful use only.** Publishing and running this software is lawful; responsibility for any downstream use, and for compliance with local law, rests entirely with the user. Some jurisdictions restrict removing an AI label as such (see [Legal](#legal)). The authors do not condone use for deception, fraud, or any unlawful activity.
|
||||
## Choose what you want to do
|
||||
|
||||
## Features
|
||||
| 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` | Required (CUDA) |
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark. Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own; the Doubao and Jimeng text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
|
||||
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
|
||||
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
|
||||
- **Analog Humanizer** — optional film grain and chromatic aberration post-processing
|
||||
- **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, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
## 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]` |
|
||||
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| Every production feature | `remove-ai-watermarks[all]` |
|
||||
|
||||
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
|
||||
`migan`, `lama`, and `diffusion`. 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
|
||||
```
|
||||
|
||||
Without `-o` this command overwrites the source in place.
|
||||
|
||||
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 `video metadata` command does not transcode video or audio streams. Unlike
|
||||
the image command above, 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 `qwen-zimage` extra. **An NVIDIA GPU
|
||||
is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
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 |
|
||||
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
|
||||
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
|
||||
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Reverse-alpha (captured α map) + residual inpaint over the glyph footprint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | — | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Detected (`identify`) + metadata strip |
|
||||
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
`qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny
|
||||
ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
|
||||
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
|
||||
stage. Both are CUDA only.
|
||||
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo) and by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng adds a residual inpaint over the glyph footprint, since its mark re-rasterizes per image). Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
| 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, at the cost of speed.
|
||||
|
||||
### 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, Yuanbao, 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 the Doubao "豆包AI生成" text watermark
|
||||
|
||||
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. It is a fixed semi-transparent white overlay, so it is removed by **reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map is solved from controlled black/gray captures (rebuildable with `scripts/visible_alpha_solve.py`). Like the Jimeng mark, Doubao re-rasterizes its text slightly per image, so reverse-alpha is followed by a thin residual inpaint over the glyph footprint to clear the leftover edges, and the α template is NCC-aligned to the actual mark (handling per-image scale/position jitter). Detection matches the same glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners.
|
||||
|
||||
**Speed**: ~0.05s, no GPU needed.
|
||||
|
||||
### Removing the Jimeng "★ 即梦AI" wordmark
|
||||
|
||||
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. It is a fixed semi-transparent **pure-white** overlay, solved from controlled black / gray / white captures the same way as Doubao. `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). One difference from Doubao: Jimeng re-rasterizes its mark slightly differently per image, so a single alpha map does not cancel it pixel-for-pixel — reverse-alpha knocks the mark down and a residual inpaint over the glyph footprint clears the remaining outline. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng
|
||||
remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png
|
||||
```
|
||||
|
||||
### Universal region eraser
|
||||
|
||||
For any visible mark the dedicated engines do not cover — a logo anywhere, any colour — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
|
||||
|
||||
### 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.30)
|
||||
→ decode back to pixels (VAE)
|
||||
```
|
||||
|
||||
- Native resolution avoids shrinking the input to 1024 px first; that down-then-up round-trip was the main quality loss (issue #10). Use `--max-resolution N` only to cap GPU/MPS memory on very large inputs.
|
||||
|
||||
> **Default strength is `0.30`, tuned to remove the current Google SynthID.** An oracle-verified study (fresh Gemini images, "Verify with SynthID") found the current SynthID survives `0.10`/`0.15`/`0.20` and clears only at `0.30`. SynthID is a moving target (the threshold has climbed `0.05` → `0.10` → `~0.30` as Google hardens it), and there is no local SynthID detector, so the tool cannot self-check and auto-tune. If the oracle still reads SynthID, raise `--strength` further; if you care more about preserving fine text, lower it. `0.30` softens dense typography somewhat, so use the lowest value that comes back clean on the oracle.
|
||||
>
|
||||
> **Keep text protection on (the default) — it does not block SynthID removal.** SynthID is a global watermark, so strength `0.30` clears it whether or not text is protected, and text protection keeps headings and body text readable through the pass (only the very finest print still softens at `0.30`). You do not need to disable it for removal; `--no-protect-text` only trades text quality for a faster run.
|
||||
>
|
||||
> **OpenAI / ChatGPT images do not carry Google SynthID** (they use C2PA metadata, stripped by the metadata step), so `0.30` is overkill there; `--strength 0.10` preserves quality and the metadata strip is what matters.
|
||||
>
|
||||
> **`--pipeline ctrlregen` is experimental and not recommended.** On paper CtrlRegen ([ICLR 2025](https://github.com/yepengliu/CtrlRegen)) regenerates from near-clean Gaussian noise to defeat robust watermarks, but in testing on real images it **destroys content** — smooth and background regions fill with hallucinated micro-text — and it is heavy (several GB of extra models, minutes per image). It has no usable middle setting (too low removes nothing, high enough to remove wrecks the image), so the shippable path is the default SDXL pipeline at `~0.30`. CtrlRegen stays available for experimentation only.
|
||||
|
||||
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
|
||||
|
||||
> **Oracle vs `identify` can disagree, and that is expected.** An online verifier reads the actual SynthID *pixel* watermark and detects only its own vendor's content — [openai.com/research/verify](https://openai.com/research/verify/) states "OpenAI generation signals will only be detected if the image was generated with our tools". Our `identify` cannot decode the pixel watermark (no vendor ships a local decoder), so it infers SynthID from the **C2PA metadata** instead. So after the SDXL pass the oracle can read "no SynthID" (pixel watermark gone) while `identify` still reports SynthID from a surviving C2PA manifest. They measure different signals. Run `metadata --remove` (or `all`) to also strip the manifest; note that a quiet metadata proxy is not proof the pixel watermark itself is gone.
|
||||
|
||||
**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. Pass `--no-protect-faces` to skip the detector entirely (useful when the image has no people).
|
||||
|
||||
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
|
||||
|
||||
**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the whole image is scrubbed normally and then each text block is **re-scrubbed at high resolution** and composited back. Because the text is regenerated (not copied), the watermark is still removed inside it, while running the text region at higher resolution keeps small strokes crisp through the regeneration. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only.
|
||||
|
||||
### 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]"
|
||||
> ```
|
||||
>
|
||||
> To also decode the open **Adobe TrustMark** watermark (behind Adobe Durable
|
||||
> Content Credentials), install the `trustmark` extra (pulls torch and downloads
|
||||
> model weights on first use):
|
||||
>
|
||||
> ```bash
|
||||
> pip install -e ".[trustmark]" # or: uv pip install -e ".[trustmark]"
|
||||
> ```
|
||||
|
||||
#### 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 — fast, offline, CPU. --mark auto (default) finds the
|
||||
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI"); force
|
||||
# one with --mark gemini / doubao / jimeng. Removed by reverse-alpha (true-pixel recovery).
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
|
||||
# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama').
|
||||
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
|
||||
# Invisible watermark only (SynthID etc.) — requires GPU
|
||||
remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0
|
||||
# Runs at native resolution by default. On a very large image that OOMs the
|
||||
# GPU/MPS, cap the long side: --max-resolution 2048
|
||||
# Text / CJK glyphs are preserved automatically; disable with --no-protect-text
|
||||
|
||||
# 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, the full
|
||||
`remove_all` and `remove_batch` pipeline, 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.
|
||||
- Invisible-watermark removal requires CUDA. Both profiles refuse any other
|
||||
device at construction rather than falling back to one that cannot run them.
|
||||
Visible removal, metadata stripping and `identify` still run anywhere.
|
||||
- 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.
|
||||
To score what an output actually cost, use `scripts/video_fidelity_probe.py`:
|
||||
the engine's own PSNR is measured before the resize and the encode, so only the
|
||||
probe sees the delivered picture.
|
||||
|
||||
## Credits
|
||||
```bash
|
||||
uv run --extra video python scripts/video_fidelity_probe.py input.mp4 input_clean.mp4
|
||||
```
|
||||
|
||||
- [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
|
||||
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`.
|
||||
|
||||
## Roadmap
|
||||
## Documentation
|
||||
|
||||
Tracked but not yet implemented:
|
||||
Start with the [documentation index](docs/index.md).
|
||||
|
||||
- **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**. Strict pyright is now clean across `src/` (0 errors): pure-logic files are fully typed, the cv2 / torch / diffusers boundary files carry a documented per-file relax pragma, and a local `typings/piexif` stub covers piexif. Remaining: full-project `pyright` (no path) still OOMs node on this ML-heavy repo, so it must be scoped to `src/`; narrowing the boundary pragmas back toward full strict (as upstream stubs improve) is the long tail. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
|
||||
- **AVIF / HEIF `Exif` item inside the `meta` box**. An AI-label *XMP* packet in a `meta`-box item is now blanked in place (v0.6.9), but EXIF stored as a `meta`-box `Exif` *item* is still not removed — it needs full `iinf`/`iloc` surgery (offset rewrite, corruption risk) or `exiftool` (a non-bundled binary dependency). Low priority: the AI labels we target are XMP, not EXIF, so an EXIF-only meta-box case is rare.
|
||||
- **More C2PA device signers**. Leica, Nikon, Google Pixel, Sony, and Truepic capture cameras are mapped (each verified against a real signed file); **Samsung Galaxy AI**, **Black Forest Labs (FLUX)**, and **ByteDance Volcano Engine** (Doubao / Jimeng) are now attributed too (verified on real signed files). Canon is still deferred until a real signed sample surfaces — no public direct-download C2PA file exists for it today (upload-to-verify / news-agency-licensed only).
|
||||
- **Resemble PerTh audio detection** — evaluated, not feasible with the public API: `get_watermark()` returns a raw bit array with no presence/confidence flag, so watermarked vs. clean audio can't be reliably separated without Resemble's fixed payload or a confidence service. Same wall as the SynthID pixel detector.
|
||||
- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo.
|
||||
- [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)
|
||||
|
||||
Won't fix:
|
||||
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.
|
||||
|
||||
- **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.
|
||||
## Contributing
|
||||
|
||||
## Legal
|
||||
Install the development environment and run the project gate:
|
||||
|
||||
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.
|
||||
```bash
|
||||
uv sync --frozen --extra dev
|
||||
bash maintain.sh
|
||||
```
|
||||
|
||||
| Jurisdiction | Instrument | Status (May 2026) | Relevance |
|
||||
| --- | --- | --- | --- |
|
||||
| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. |
|
||||
| US (federal) | COPIED Act (S. 1396, 119th Cong.) | **Reintroduced April 2025; not enacted** (referred to Senate Commerce Committee). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
|
||||
| US (state) | CA AB 2655, TX SB 751 (2019), similar | TX SB 751 (2019) in force; **CA AB 2655 struck down** by a federal court (E.D. Cal., Aug 2025, *Kohls v. Bonta*) as preempted by **Section 230**; the court did not reach the First Amendment (the companion law AB 2839 was separately enjoined on First Amendment grounds). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
|
||||
| US (state) | CA AB 853 (amends the California AI Transparency Act) | Core provider duties operative **2 August 2026** (delayed from 1 January 2026); large platforms 1 January 2027; capture devices 1 January 2028. | Covered providers (1M+ monthly users) must embed a latent disclosure that is "permanent or extraordinarily difficult to remove" and offer a free detection tool. Removing that disclosure is what this tool does. |
|
||||
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **22 January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. |
|
||||
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels across image / audio / video; tampering with, forging, or removing labels is prohibited. |
|
||||
| India | IT (Intermediary Guidelines and Digital Media Ethics Code) Amendment Rules, 2026 | In force since **20 February 2026** (notified 10 February 2026). | All "synthetically generated information" must be **prominently labelled** and carry **permanent metadata / a provenance identifier**; the rules expressly **prohibit modifying, suppressing, or removing** that label or metadata. Covers image, audio, and audio-visual content. |
|
||||
| UK | Online Safety Act 2023 / Ofcom guidance | In force, but **no statutory AI-provenance or watermarking obligation**. | Ofcom encourages watermarking / provenance metadata as voluntary "attribution measures"; platform duties, not user obligations. |
|
||||
|
||||
## Threat model
|
||||
|
||||
This tool 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.
|
||||
|
||||
**Who bears the liability.** This is general-purpose software and is itself lawful to publish and run; legal responsibility attaches to the person who removes a marker and to how the result is then used, and the hinge is intent. Removing AI provenance to pass AI-generated content off as human-made, to commit fraud, to produce non-consensual deepfakes, or to conceal copyright infringement can expose the remover to liability. Two kinds of exposure are worth knowing:
|
||||
|
||||
- **The downstream act.** Deception, fraud, defamation, IP infringement, or breaking a platform's terms — judged by intent and harm, not by the act of editing metadata itself. In the US, the **DMCA (17 U.S.C. § 1202)** specifically bars removing "copyright management information" *with intent to conceal or enable infringement*.
|
||||
- **The removal itself.** Some jurisdictions penalise tampering with the label/metadata as such, regardless of downstream use — notably **China** (Labeling Measures) and **India** (IT Amendment Rules 2026), which expressly prohibit removing or suppressing the AI label and provenance metadata. The US **COPIED Act** would do the same if enacted.
|
||||
|
||||
Legitimate uses — publishing your own work, privacy (stripping metadata that leaks an account identifier), security / robustness research, or removing a false-positive "Made with AI" label from a human-edited photograph — are generally lawful. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations. None of this is legal advice.
|
||||
See [module internals](docs/module-internals.md) before changing a subsystem
|
||||
with documented invariants.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
[Apache 2.0](LICENSE). Copyright 2025-2026 wiltodelta.
|
||||
@@ -0,0 +1,77 @@
|
||||
# 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.
|
||||
|
||||
## Video SynthID oracle manifest
|
||||
|
||||
`evaluations/video-synthid-oracle.csv` is the only evidence that the shipped
|
||||
video removal profile works, so it is also the source of truth for three shipped
|
||||
defaults: `tests/test_video_invisible.py` asserts that `noise_std`, `long_side`,
|
||||
and `fps` together match a row this manifest records as certified. Changing one
|
||||
of those three without adding the row that certifies it fails the suite. `vae` is
|
||||
deliberately outside that check because neither tracked row records one; add it
|
||||
to the assertion in the same commit as the first row that does.
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `date`, `source_url`, `source_sha256` | Identify the carrier. |
|
||||
| `source_width`, `source_height`, `source_fps` | Carrier geometry. Without it the actual downscale factor of a row cannot be recovered later. |
|
||||
| `duration_seconds`, `source_verdict` | Clip length submitted and the verifier's reading of the untouched carrier. |
|
||||
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The full run configuration. |
|
||||
| `output_sha256` | Identifies the exact submitted file. |
|
||||
| `output_verdict` | One of `detected`, `not_detected`, `indeterminate`, `refused`. |
|
||||
| `output_verdict_text` | The verifier's wording, verbatim. |
|
||||
| `output_detected_range` | Time range the verifier reported for the output. |
|
||||
| `track` | `visual`, `audio`, `both`, or empty. The verifier scores tracks separately and this path copies source audio unchanged. |
|
||||
| `session_id` | Groups rows submitted in one oracle session, so per-session drift stays visible. |
|
||||
| `stratum` | Content class of the carrier, for stratified certification. |
|
||||
| `psnr_db`, `temporal_residual_ratio` | Fidelity measurements. Neither is a watermark verdict. |
|
||||
|
||||
Record `indeterminate` when the verifier answers with its unclear state rather
|
||||
than a negative: an unclear reading logged as `not_detected` is exactly the
|
||||
silent regression this manifest exists to prevent. Leave a field empty when it
|
||||
was not recorded, and never backfill it with a plausible value.
|
||||
|
||||
`psnr_db` is measured against the already-resized frame and before the encode,
|
||||
so it excludes the downscale, the decimation, and the codec. Rows stay
|
||||
comparable to each other only while that definition holds.
|
||||
|
||||
The two 2026-07-31 rows predate this schema; their empty fields were never
|
||||
recorded and are not recoverable from the row.
|
||||
@@ -1,17 +1,17 @@
|
||||
# Doubao visible watermark capture
|
||||
|
||||
> **Status (captured 2026-05-29; alpha rebuilt 2026-05-31):** the black/gray/white captures were
|
||||
> taken and are now **committed** in `captures/` (solid colour + watermark, content-free). The alpha
|
||||
> map is rebuilt by `scripts/visible_alpha_solve.py doubao` (the careful gray-self solve shared with
|
||||
> Jimeng). The first build claimed "pixel-exact" but left a readable outline on the real sample (issue
|
||||
> #13 follow-up); removal now reverse-alphas, NCC-aligns, and applies a thin residual inpaint. See the
|
||||
> `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the historical capture plan.
|
||||
> **Status (captured 2026-05-29; asset rebuilt 2026-05-31):** the
|
||||
> black and gray captures are committed here. The generated alpha
|
||||
> asset is now a detection silhouette and mask-shaping input only. Current
|
||||
> removal localizes the mark and sends its footprint to the shared fill backend.
|
||||
> Reverse-alpha recovery was retired. The details below preserve the historical
|
||||
> capture rationale.
|
||||
|
||||
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
|
||||
build a per-pixel alpha map and a reverse-alpha-blend remover, the same way the Gemini sparkle
|
||||
engine works (`src/remove_ai_watermarks/gemini_engine.py`).
|
||||
rebuild and validate the detection silhouette used by
|
||||
`src/remove_ai_watermarks/doubao_engine.py`.
|
||||
|
||||
## What we already know (verified from prior art, 2026-05-26)
|
||||
## Historical findings from the capture work
|
||||
|
||||
- Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`,
|
||||
`logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`.
|
||||
@@ -19,14 +19,13 @@ engine works (`src/remove_ai_watermarks/gemini_engine.py`).
|
||||
both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray
|
||||
capture below settles it empirically.
|
||||
- Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size.
|
||||
Confirmed by our sample `data/samples/doubao-1.png` (2048x2048) plus three sources.
|
||||
Confirmed by our sample `data/fixtures/provenance/doubao-1.png` (2048x2048) plus three sources.
|
||||
- Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are
|
||||
approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped
|
||||
third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh.
|
||||
- The planning assumption was that clean inversion leaves residue on textured backgrounds, so the
|
||||
remover would pair the alpha map with inpainting. After the capture this turned out unnecessary at
|
||||
the native width (recovery is pixel-exact there and inpaint is off); the shipped remover is
|
||||
reverse-alpha only, with a residual inpaint applied off-native only.
|
||||
- The original implementation used reverse-alpha recovery. That path was later
|
||||
removed in favor of localize then fill because the latter also handles moved
|
||||
and re-rendered marks.
|
||||
|
||||
## Use doubao.com specifically
|
||||
|
||||
@@ -35,7 +34,7 @@ the captured template matches our target.
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each seed in `seeds/`:
|
||||
For each locally generated solid-color seed:
|
||||
|
||||
1. Open Doubao image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
@@ -70,16 +69,15 @@ extracted by per-pixel min/median.
|
||||
- Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
## Committed inputs
|
||||
|
||||
```
|
||||
doubao_black_1x1_1.png
|
||||
doubao_white_1x1_1.png
|
||||
doubao_gray128_1x1_1.png
|
||||
doubao_black_16x9_1.png
|
||||
doubao_content_1x1_1.png
|
||||
doubao_gray_1x1_1.png
|
||||
```
|
||||
|
||||
The black and gray captures feed `scripts/visible_alpha_solve.py doubao`.
|
||||
|
||||
## Also report back
|
||||
|
||||
1. Which resolutions and aspect ratios the Doubao UI actually offers.
|
||||
@@ -1,8 +1,10 @@
|
||||
# Gemini (Nano Banana) visible sparkle capture
|
||||
|
||||
> **Status (captured 2026-05-31):** black/gray/white captures taken and **committed**
|
||||
> in `captures/` (solid colour + the sparkle, content-free). The sparkle-on-black assets
|
||||
> **Status (captured 2026-05-31):** the required black capture is committed
|
||||
> here. The sparkle-on-black assets
|
||||
> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`.
|
||||
> They support detection and footprint geometry. Current pixel recovery uses the
|
||||
> shared fill backend, not reverse-alpha inversion.
|
||||
|
||||
Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner
|
||||
via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng
|
||||
@@ -20,7 +22,7 @@ text marks, the sparkle is captured over a **pure-black** background, where
|
||||
|
||||
## How to capture (image-edit path)
|
||||
|
||||
For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored):
|
||||
For each locally generated black, gray, or white 2048 px seed:
|
||||
|
||||
1. Open Gemini image generation, image-edit / reference mode, upload the seed.
|
||||
2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything`
|
||||
@@ -28,16 +30,15 @@ For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored
|
||||
|
||||
Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
## Committed input
|
||||
|
||||
```
|
||||
gemini_black_2048.png # the key capture (sparkle on black)
|
||||
gemini_gray_2048.png
|
||||
gemini_white_2048.png
|
||||
```
|
||||
|
||||
The solid captures are **committed** (content-free). The synthetic `seeds/` and any
|
||||
real-content `gemini_content_*.png` validation download are gitignored (local-only).
|
||||
The gray and white captures were calibration cross-checks and are not needed to
|
||||
rebuild the assets. Synthetic seeds and real-content validation downloads stay
|
||||
local.
|
||||
Rebuild the assets with:
|
||||
|
||||
```
|
||||
@@ -1,15 +1,13 @@
|
||||
# Jimeng (即梦AI) visible watermark capture
|
||||
|
||||
> **Status (completed 2026-05-30):** solid black/gray/white Jimeng captures were
|
||||
> obtained (issue #13, from @powersee) and the alpha map was solved. Removal is
|
||||
> reverse-alpha plus a residual inpaint over the glyph footprint; see the
|
||||
> `jimeng_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the
|
||||
> capture plan.
|
||||
> **Status (completed 2026-05-30):** solid black and gray Jimeng captures were
|
||||
> obtained and the detection asset was solved. The asset now supports detection
|
||||
> and mask geometry only. Current removal localizes the wordmark and uses the
|
||||
> shared fill backend. The text below records the capture and calibration work.
|
||||
|
||||
Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat
|
||||
backgrounds so we can build a per-pixel alpha map and a reverse-alpha remover, the
|
||||
same way the Gemini sparkle and Doubao strip engines work
|
||||
(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`).
|
||||
backgrounds so we can rebuild and validate the silhouette used by
|
||||
`src/remove_ai_watermarks/jimeng_engine.py`.
|
||||
|
||||
## What we learned (verified from the captures, 2026-05-30)
|
||||
|
||||
@@ -32,11 +30,10 @@ same way the Gemini sparkle and Doubao strip engines work
|
||||
width; a real 1440-wide download matched width_frac ~0.21.
|
||||
- **Per-image render variation:** the alpha maps solved independently from the
|
||||
black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng
|
||||
re-rasterizes the mark per generation AND jitters its position a few px, so a
|
||||
single alpha map does NOT pixel-cancel the mark the way Doubao's deterministic
|
||||
overlay does. Removal therefore: NCC-aligns the alpha to the actual mark (always,
|
||||
not only off-native), reverse-alphas, then clears the residual with a THIN inpaint
|
||||
over the glyph footprint (a wide full-footprint pass smeared the texture/edges).
|
||||
re-rasterizes the mark per generation and jitters its position a few pixels.
|
||||
This was one reason the old reverse-alpha path needed alignment and residual
|
||||
inpainting. The current path instead detects the silhouette, localizes the
|
||||
glyph footprint, and fills that footprint.
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
@@ -56,19 +53,15 @@ pure white (the mark is nearly invisible on white, as expected).
|
||||
- Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
## Committed inputs
|
||||
|
||||
```
|
||||
jimeng_cap_A.png # black seed run through Jimeng
|
||||
jimeng_cap_B.png # white seed
|
||||
jimeng_cap_C.png # gray seed
|
||||
jimeng_content_1.png # a normal-content download, for end-to-end validation
|
||||
```
|
||||
|
||||
The solid `jimeng_cap_{A,B,C}.png` captures are **committed** (content-free: a solid
|
||||
colour + the watermark; the source for `scripts/visible_alpha_solve.py jimeng`). The
|
||||
synthetic `seeds/` and the real-content `jimeng_content_*.png` validation download are
|
||||
gitignored (local-only). Rebuild the alpha asset with:
|
||||
The A and C captures feed
|
||||
`scripts/visible_alpha_solve.py jimeng`. Rebuild the detection asset with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py jimeng # or: all
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -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,source_width,source_height,source_fps,duration_seconds,source_verdict,vae,noise_std,long_side,fps,seed,output_sha256,output_verdict,output_verdict_text,output_detected_range,track,session_id,stratum,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 |
|
Before Width: | Height: | Size: 715 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 373 KiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -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,23 +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; one row per tracked image)
|
||||
images/ # the labeled corpus (committed)
|
||||
pos/ # SynthID present
|
||||
neg/ # SynthID absent (incl. reviewed real photos)
|
||||
cleaned/ # our pipeline output from a pos image
|
||||
refs/ # synthetic black/white calibration fills (gitignored, regenerable)
|
||||
data/synthid/
|
||||
README.md # collection and verification protocol
|
||||
manifest.csv # one row per committed original
|
||||
full-pipeline-quality.csv # reusable evaluation selection
|
||||
originals/ # one canonical copy of each verified source
|
||||
```
|
||||
|
||||
The labeled images are committed so the corpus is reproducible and the removal
|
||||
regression set runs in CI. `manifest.csv` is kept in sync with the files on
|
||||
disk (one row per tracked image; dangling rows are pruned when files are
|
||||
removed). Before adding any image, confirm it carries no private or
|
||||
identifiable content you would not publish -- this is a public repo and git
|
||||
history is permanent. The synthetic `refs/` fills stay gitignored (regenerable,
|
||||
not part of the labeled set).
|
||||
The originals are committed so the evaluation set is reproducible and its
|
||||
hashes can be checked in CI. `manifest.csv` is kept in sync with
|
||||
`originals/`, one row per file. Generated outputs do not belong here. Store
|
||||
them outside the repository and record only a reproducible verdict in the
|
||||
evaluation manifest or documentation.
|
||||
|
||||
## Reusable removal-quality set
|
||||
|
||||
`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`)
|
||||
|
||||
@@ -40,37 +56,31 @@ Ground-truth quality, strongest first:
|
||||
- `gemini-app` — checked via the Gemini app "Verify with SynthID" feature. Gold standard for the pixel watermark (Google models).
|
||||
- `openai-verify` — checked via openai.com/verify (gold standard for OpenAI ChatGPT/Codex/API images).
|
||||
- `synthid-portal` — checked via Google's SynthID Detector portal.
|
||||
- `c2pa-metadata` — issuer-only proxy (Google/OpenAI C2PA manifest present). Weaker: the C2PA can be stripped while the pixel watermark remains.
|
||||
- `c2pa-metadata` — supported provenance evidence (Google AI C2PA, or OpenAI
|
||||
C2PA with an explicit `c2pa.watermarked.*` action). Weaker than a provider
|
||||
oracle: C2PA can be stripped while the pixel watermark remains.
|
||||
- `third-party` — label asserted by an external dataset, not independently verified.
|
||||
- `none` — unverified.
|
||||
|
||||
Prefer `gemini-app` for any image that will train the codebook or gate a test.
|
||||
Prefer `gemini-app` for any Google image that will gate a test.
|
||||
|
||||
## What to collect
|
||||
|
||||
For the **codebook** (per target resolution, e.g. 1024x1024, 1024x1536, 1536x2816):
|
||||
|
||||
- 30-50+ SynthID-positive outputs per resolution (more is better; ~150-200 per
|
||||
resolution materially improves carrier discovery).
|
||||
- At each target resolution, also a batch of **pure-black (#000000)** and
|
||||
**pure-white (#FFFFFF)** fills generated by the SynthID model — these isolate
|
||||
the content-independent carrier (the watermark is most of the signal there).
|
||||
|
||||
For the **regression set**:
|
||||
|
||||
- A handful of `pos` images, their `cleaned` counterparts (run through our
|
||||
pipeline), and the cleaned re-verified via `gemini-app` (should read negative).
|
||||
- `neg` controls: non-AI photos and outputs from non-SynthID models (SD,
|
||||
Midjourney, Firefly) verified negative.
|
||||
- A small number of positive originals with a recorded provider oracle.
|
||||
- A quality-set row for every original used by repeatable evaluation.
|
||||
- Re-run the provider oracle on each newly generated output. Do not commit the
|
||||
generated output as another corpus copy.
|
||||
|
||||
The corpus is committed to a public repo: review every image before adding it
|
||||
and keep out anything private or identifiable you would not publish.
|
||||
|
||||
## Ingesting
|
||||
|
||||
Use `scripts/synthid_corpus.py` — it copies a file in, records its sha256,
|
||||
resolution, format, and C2PA issuer (via our own detector), and appends a row
|
||||
to `manifest.csv`:
|
||||
Use `scripts/synthid_corpus.py` to copy a file into `originals/`, record its
|
||||
SHA-256, resolution, format, and C2PA issuer through our detector, and append a
|
||||
row to `manifest.csv`:
|
||||
|
||||
```bash
|
||||
uv run python scripts/synthid_corpus.py ingest path/to/*.png \
|
||||
@@ -82,6 +92,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
|
||||
@@ -122,8 +136,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: 9.3 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.7 MiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 7.8 MiB |
@@ -1,26 +0,0 @@
|
||||
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
|
||||
4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e,"4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,openai-verify,2026-05-23T21:48:12Z,fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected
|
||||
d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656,d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-23T21:52:40Z,"user: latest Gemini, SynthID v2"
|
||||
7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a,"7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png",pos,ChatGPT,gpt-image,1602,982,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:20:25Z,content: misty pine forest at dawn
|
||||
fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32,fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:30:25Z,content: elderly fisherman portrait
|
||||
d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae,d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:33:15Z,content: red coffee mug product shot
|
||||
28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe,28f32334-chatgpt_fisherman.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:39:20Z,content: elderly fisherman portrait (fetch+blob dl)
|
||||
88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84,88e61a38-chatgpt_tokyo.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:42:02Z,content: tokyo street night (fetch+blob dl)
|
||||
1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440,1fa5f77f-IMG_0786.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067,2ea228ed-IMG_1791.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6,0bb4c176-IMG_1790.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846,3fc0c425-IMG_1450.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723,2b148db2-IMG_1832.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36,2d02fed0-IMG_1520.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772,8f170c06-IMG_1078.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e,3fc4c831-IMG_2566.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1,5fe59521-IMG_1496.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38,4ead7918-IMG_1300.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35,4c795238-IMG_3034.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d,5abfaccb-IMG_3018.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33,5fed1923-IMG_0272.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b,06b04d8f-IMG_1474.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062,8fdb574a-IMG_3557.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501,c8697342-aistudio_lake.png,pos,Google AI Studio (Nano Banana),gemini-2.5-flash-image,1024,1024,png,,,gemini-app,2026-05-24T21:39:09Z,"API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot"
|
||||
1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a,1f81827c-Designer.png,pos,Microsoft Designer,dall-e (Designer),1024,1024,png,"OpenAI, Microsoft",yes,c2pa-metadata,2026-05-24T22:18:40Z,C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA)
|
||||
f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e,f6dd47a5-4ef377bd-gpt-image-2-cleaned.png,cleaned,"our pipeline (invisible/SDXL, native-res default)",stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-25T20:50:38Z,"cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU."
|
||||
|
@@ -0,0 +1,490 @@
|
||||
# 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` and `all` (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| `video metadata` and `video identify --no-visible` | Default package |
|
||||
| `video identify` | `remove-ai-watermarks[video]` |
|
||||
| `video visible`, `video all`, and visible/all batch modes | `remove-ai-watermarks[video]` plus ffmpeg on PATH |
|
||||
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
|
||||
| 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, except the weakly detected Jimeng label pill,
|
||||
which needs corroboration (see [supported signals](supported-signals.md));
|
||||
- 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.
|
||||
|
||||
Two more knobs tune the fill. `--dilate N` (default 3) grows every box by `N`
|
||||
pixels before inpainting, which helps when a mark has a soft edge or a drop
|
||||
shadow just outside the box you measured; it applies to every backend because it
|
||||
shapes the mask. `--inpaint-method telea|ns` selects the classical algorithm and
|
||||
only affects the `cv2` backend. Like `visible`, `erase` strips AI metadata from
|
||||
the output by default; pass `--keep-metadata` to retain it.
|
||||
|
||||
## 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 removal dependencies first. Both profiles are CUDA-only and both
|
||||
run the DiffSynth Z-Image face stage, so this is the extra either one needs:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
```
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| `qwen-zimage` | Default. Qwen-Image-2512 global pass plus a SAM-masked Z-Image face stage |
|
||||
| `sdxl-zimage` | The same recipe and face stage on an SDXL global pass, at a higher denoise |
|
||||
|
||||
**Both are CUDA-only.** There is no CPU or MPS profile for invisible-watermark
|
||||
removal. The former `controlnet`, `sdxl`, `qwen` and `default` profiles were removed
|
||||
rather than kept as a CPU path: none of them matched this recipe's face preservation,
|
||||
so offering them implied a quality the library no longer delivers. Passing a retired
|
||||
name is rejected at parse time rather than remapped. Visible-mark removal and every
|
||||
identify path still run anywhere.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline qwen-zimage --force
|
||||
```
|
||||
|
||||
There is no `--model`, `--steps`, `--guidance-scale` or `--device` option, and the
|
||||
deprecated `--auto` is gone. Each profile pins its model stack, its per-stage
|
||||
schedule, CFG 1.0 and CUDA, so every one of those flags existed only to be refused
|
||||
several layers down. They are not parsed at all now, which fails at the point the
|
||||
user can act on rather than after a model load.
|
||||
|
||||
### 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,qwen-zimage]` 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`.
|
||||
|
||||
When the `qwen-zimage` extra is unavailable, `all` still writes the result of
|
||||
the visible and metadata stages, prints a prominent warning, and exits with code
|
||||
1. That happens on every run without the extra: the skipped stage is what would
|
||||
have decided whether a signal was there. This prevents a partial result from
|
||||
being reported as complete.
|
||||
|
||||
If the extra is installed but the machine has no CUDA, which is the usual macOS
|
||||
case, the run fails at engine construction instead: `all` prints
|
||||
`Error: Invisible-watermark removal is CUDA-only ...`, writes no output at all,
|
||||
and exits with code 1.
|
||||
|
||||
## 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,654 @@
|
||||
# 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. Everything it names on the
|
||||
> face-restoration path was removed after this study: the `--restore-faces` and
|
||||
> `--face-id` flags, the `instantid`, `photomaker` and `faceid` extras, and their
|
||||
> modules. So were the `controlnet`, `sdxl`, `qwen` and `default` profiles.
|
||||
|
||||
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, which points 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 2026-05-31).
|
||||
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;
|
||||
all three were removed after this run and never reached the tracked tree).
|
||||
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, a cross-platform test matrix over the supported-Python floor and the latest supported minor with default plus development dependencies, and a separate job that installs ffmpeg on Ubuntu to run the full-clip video test. 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,41 @@
|
||||
# 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. |
|
||||
| [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. |
|
||||
| [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, ComfyUI Registry, and the 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)
|
||||
- [Video SynthID quality research](video-synthid-quality-research.md)
|
||||
- [Text protection research](text-protection-research.md)
|
||||
@@ -0,0 +1,227 @@
|
||||
# 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 works with the default package, and MP4 and MOV
|
||||
stripping uses the in-tree ISOBMFF box walker. Stripping the non-ISOBMFF
|
||||
containers (MKV, WebM, AVI, FLV, and the audio formats) and writing any cleaned
|
||||
video need ffmpeg on PATH, for example `brew install ffmpeg`. 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
|
||||
|
||||
Install the `qwen-zimage` extra:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
```
|
||||
|
||||
Both remaining profiles run a Z-Image face stage on the DiffSynth runtime, so
|
||||
both need this extra. It includes the `diffusion` dependencies; `diffusion` on its
|
||||
own covers the torch and diffusers imports but not the face stage, so it is not
|
||||
enough to run a removal.
|
||||
|
||||
**An NVIDIA GPU is required.** `qwen-zimage` and `sdxl-zimage` are CUDA-only, and
|
||||
construction refuses any other device rather than falling back to a slow or broken
|
||||
one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark
|
||||
removal, metadata stripping and every `identify` command still run anywhere.
|
||||
|
||||
Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS;
|
||||
it needs the `diffusion` extra, not this one.
|
||||
|
||||
## 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` | Torch and Diffusers runtime; video SynthID regeneration | `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 |
|
||||
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
|
||||
| `all` | Every production feature | All rows above | Yes |
|
||||
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `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
|
||||
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 syncs every optional backend on top of the `dev` environment above,
|
||||
then runs dependency checks, linting, formatting, type checking, and the test
|
||||
suite. It applies Ruff fixes and formatting in place rather than only reporting
|
||||
them.
|
||||
|
||||
## 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 the removal dependencies are unavailable, install the
|
||||
`qwen-zimage` extra. `diffusion` alone covers Torch and Diffusers but not the
|
||||
DiffSynth face stage that both profiles run. Video SynthID removal is a separate
|
||||
path and needs `video` and `diffusion`.
|
||||
@@ -0,0 +1,364 @@
|
||||
# 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.
|
||||
|
||||
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
|
||||
Both are CUDA only and differ only in the global regeneration model: each
|
||||
conditions that stage on a canny edge map, which preserves structure but not
|
||||
identity or exact texture, and each then runs the same face stage.
|
||||
`qwen-zimage` is the higher fidelity of the two. Both are large, slow, and may
|
||||
still alter small text or difficult faces.
|
||||
|
||||
### Removal cannot be verified locally for proprietary SynthID
|
||||
|
||||
The project has no public local SynthID pixel decoder. It recognizes presence
|
||||
from supported provenance metadata: Google AI C2PA under Google's all-media
|
||||
policy, or current OpenAI C2PA with an explicit watermark action. A missing
|
||||
provenance signal 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.
|
||||
|
||||
The reported `psnr_db` is measured against the already-resized frame and before
|
||||
the H.264 encode, so it excludes the downscale, the frame decimation, and the
|
||||
encoder. It measures the VAE round trip plus latent noise at the working geometry.
|
||||
[`video-synthid-quality-research.md`](video-synthid-quality-research.md) records
|
||||
what the manifest rows constrain, what a higher-resolution or higher-frame-rate
|
||||
profile would need in order to be certified, and the audio-track question this
|
||||
path has not yet answered.
|
||||
|
||||
### Strength is content and seed dependent
|
||||
|
||||
The two profiles resolve an unset strength differently, because different things
|
||||
were measured for each.
|
||||
|
||||
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
|
||||
curve. The vendor is deliberately ignored: the curve, not the issuer, is what was
|
||||
calibrated.
|
||||
|
||||
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
|
||||
|
||||
- OpenAI: `0.15`;
|
||||
- Google: `0.25`;
|
||||
- unknown: `0.25`, following the stricter of the two.
|
||||
|
||||
An SDXL global pass needs more denoise than Qwen at the same fidelity, and the
|
||||
values are flat rather than a curve because flat values are what was measured: each
|
||||
verdict came from a fixed strength at one size, and no size dependence has been
|
||||
established for that stage.
|
||||
|
||||
An explicit `--strength` overrides both. The defaults are operating points, not
|
||||
universal guarantees. Near a removal threshold, different content or a different
|
||||
random seed may change the verifier result, which is why both profiles are
|
||||
certified at a fixed seed. The live resolver is
|
||||
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py).
|
||||
|
||||
### Pipelines have different quality tradeoffs
|
||||
|
||||
| Pipeline | Main limit |
|
||||
| --- | --- |
|
||||
| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. |
|
||||
| `sdxl-zimage` | CUDA only. Its strength ladder is flat per vendor, not a resolution curve, because flat values are what was measured. |
|
||||
|
||||
The `controlnet`, `sdxl`, `qwen` and `default` profiles were removed, not aliased
|
||||
onward: a retired name is rejected at parse time rather than routed into a profile
|
||||
the caller never chose. There is no `--model`, `--steps`, `--guidance-scale`,
|
||||
`--device` or `--auto` option either; each profile pins its model stack, its
|
||||
per-stage schedule, CFG 1.0 and CUDA.
|
||||
|
||||
## Resolution and memory
|
||||
|
||||
### Small images are processed at their native size
|
||||
|
||||
There is no minimum-resolution floor. It existed to enlarge small inputs toward
|
||||
SDXL's ~1024 training resolution and was removed with the SDXL profiles, which
|
||||
never applied it anyway. Both surviving profiles run at native geometry, so a
|
||||
small input is neither enlarged before diffusion nor restored afterward.
|
||||
|
||||
`--max-resolution` still caps very large inputs, and only ever scales down.
|
||||
|
||||
### 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 trades speed for VRAM
|
||||
|
||||
`--cpu-offload` forces both stacks of the two-stage profile out of automatic
|
||||
device residency, streaming weights instead of pinning them. It reduces CUDA
|
||||
memory pressure at the cost of speed.
|
||||
|
||||
Residency is otherwise chosen from the card's total VRAM. On a card large enough
|
||||
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
|
||||
device and re-reads every parameter from disk on each stage transition.
|
||||
|
||||
### There is no CPU or MPS fallback
|
||||
|
||||
Invisible-watermark removal refuses any device but CUDA at construction. There is
|
||||
no MPS out-of-memory fallback that continues on CPU, and no lighter profile to
|
||||
drop to: both remaining profiles need an NVIDIA GPU.
|
||||
|
||||
When a run does not fit, the levers are `--tile` (native geometry, tiled
|
||||
diffusion), `--max-resolution` (an explicit downscale), and `--cpu-offload`.
|
||||
Memory needs depend on the profile, input size, dtype, and card.
|
||||
|
||||
## 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,598 @@
|
||||
# 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`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
||||
visible video processing uses `video`. Video SynthID removal is a separate VAE
|
||||
path that still runs on CPU and 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")
|
||||
```
|
||||
|
||||
## Run the full pipeline
|
||||
|
||||
`remove_all` is the library form of the `all` command: visible marks, then the
|
||||
invisible watermark, then AI metadata. Stages are chained through a file in the
|
||||
system temp directory, so a partial result never appears at the output path.
|
||||
|
||||
```python
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
result = raiw.remove_all("input.png", "clean.png") # -> RemoveAllResult
|
||||
print(result.output) # the path written
|
||||
print(result.visible_label) # the marks removed, or None
|
||||
print(result.invisible) # "removed" | "no-signal" | "unavailable"
|
||||
```
|
||||
|
||||
`invisible` is the field to check. `"unavailable"` means the GPU extra is not
|
||||
installed, so the output *looks* processed but still carries the watermark;
|
||||
`"no-signal"` means the scrub was deliberately skipped because nothing was
|
||||
locally detectable, which is a successful run.
|
||||
|
||||
Pass `InvisibleOptions` to tune the diffusion stage, and `engine` to reuse one
|
||||
loaded model across many calls:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks import InvisibleOptions
|
||||
|
||||
raiw.remove_all(
|
||||
"input.png",
|
||||
"clean.png",
|
||||
invisible=InvisibleOptions(strength=0.35),
|
||||
force=True,
|
||||
progress=print,
|
||||
)
|
||||
```
|
||||
|
||||
`InvisibleOptions` carries only what `InvisibleEngine` itself takes, and uses the
|
||||
engine's own parameter names and defaults. `force`, which decides whether the
|
||||
engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside
|
||||
`backend` and `sensitivity`.
|
||||
|
||||
If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete`
|
||||
**before** writing anything: an AI-readable output is worse than no output.
|
||||
|
||||
`remove_batch` runs one mode over a directory and never lets a single bad file
|
||||
end the run:
|
||||
|
||||
```python
|
||||
summary = raiw.remove_batch("in_dir", "out_dir", mode="visible") # -> BatchSummary
|
||||
print(summary.processed, summary.failed, summary.errors)
|
||||
print(summary.invisible_unavailable) # outputs that still carry the watermark
|
||||
```
|
||||
|
||||
`mode` is `all`, `visible`, `invisible`, or `metadata`. Pass a constructed
|
||||
`InvisibleEngine` as `engine` to load the model once for the whole directory.
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
### Collect once, judge elsewhere
|
||||
|
||||
`collect_metadata_record` splits the two halves apart: it is the only step that
|
||||
touches the file, and it returns a JSON-serializable record the verdict can be
|
||||
built from on another machine, in another process, or later.
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
from remove_ai_watermarks.identify import identify_metadata_record
|
||||
from remove_ai_watermarks.metadata_record import collect_metadata_record
|
||||
|
||||
record = collect_metadata_record(Path("input.png"), schema_version=1) # reads the file
|
||||
blob = json.dumps(record) # ship it anywhere
|
||||
|
||||
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
|
||||
payload = report.to_dict(schema_version=1) # versioned JSON contract
|
||||
```
|
||||
|
||||
The collection record has `record_type="provenance_metadata"`,
|
||||
`schema_version=1`, and a `status`. A vanished or unreadable source produces an
|
||||
`error` record with structured `issues`; `identify_metadata_record` rejects that
|
||||
record instead of turning a collection failure into an unknown-image verdict.
|
||||
Unknown schema versions, non-integer aliases, and native records without a
|
||||
`complete` collection status are rejected explicitly.
|
||||
|
||||
The verdict is the same one `identify(path, check_visible=False,
|
||||
check_invisible=False)` returns for that file. That equality is the record's whole
|
||||
contract and is verified over the tracked provenance fixtures and a separate local
|
||||
evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary: it
|
||||
adds a `schema_version`, contains only JSON-safe values, and deliberately omits the
|
||||
local source path.
|
||||
|
||||
Package and transport versions evolve independently. Long-lived consumers should
|
||||
request the schema they implement, as above, instead of assuming the installed
|
||||
package's latest schema. Within schema 1, existing fields, types, meanings,
|
||||
`signals[].name` values, and `watermarks[]` labels remain compatible; releases may
|
||||
add fields that consumers must ignore. A breaking change requires a new schema while
|
||||
the schema 1 serializer remains available for rolling upgrades. Asking a release for
|
||||
an unsupported schema raises `ValueError` rather than silently returning another
|
||||
shape.
|
||||
|
||||
A record carries metadata regions, not the primary coded-pixel stream: marker
|
||||
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
|
||||
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
|
||||
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size
|
||||
is bounded by those metadata regions and trailers; images with large embedded
|
||||
manifests naturally produce larger records.
|
||||
|
||||
The `path` argument is metadata: it labels the report and is never opened by
|
||||
either function, so a record collected elsewhere can be judged against a path that
|
||||
does not exist locally.
|
||||
|
||||
If metadata was collected by another component instead, normalize its nested
|
||||
record the same way:
|
||||
|
||||
```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)
|
||||
```
|
||||
|
||||
Unversioned third-party records are normalized recursively for compatibility. The
|
||||
normalizer preserves text and byte values. It also decodes
|
||||
strings prefixed with `hex:` and fields named `base64` or ending in
|
||||
`_base64`. Diagnostic, transport, timing, hash, provenance-result, and pixel-result
|
||||
subtrees are ignored because they describe the collector or a derived result rather
|
||||
than the source file. A versioned portable record is stricter still: only
|
||||
`metadata_base64`, `tail_base64`, `pil`, `exif`, and `c2pa_store` are accepted as
|
||||
source evidence. Other `record_type` values are rejected, so do not pass a broad
|
||||
forensic inspection record to this API. Pass a C2PA manifest-store dictionary in
|
||||
`record["c2pa_store"]`, or through the explicit
|
||||
`c2pa_manifest_store` argument.
|
||||
|
||||
### Broad metadata inspection
|
||||
|
||||
`collect_forensic_metadata` provides the wide metadata-only record used by forensic
|
||||
inspection and migration adapters. It preserves hashes and timestamps, full EXIF and
|
||||
IPTC, C2PA, container inventories, bounded raw metadata payloads, and embedded
|
||||
thumbnail forensics. It does not calculate a provenance verdict or pixel statistics.
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.forensic_metadata import collect_forensic_metadata
|
||||
|
||||
record = collect_forensic_metadata(Path("input.png"), schema_version=1)
|
||||
assert record["record_type"] == "forensic_metadata"
|
||||
```
|
||||
|
||||
This record is intentionally not accepted by `identify_metadata_record`. Collect the
|
||||
small strict provenance record separately and publish the resulting
|
||||
`ProvenanceReport.to_dict()` as the detector contract.
|
||||
|
||||
### Pixel evidence
|
||||
|
||||
`extract_pixel_evidence` decodes once and calculates the DCT, FFT, residual, ELA,
|
||||
gradient, and color families. Its versioned `to_dict()` result has a semantic
|
||||
`status`: `complete`, `partial` when an individual family failed, or `error` when the
|
||||
source could not be decoded. Transported errors contain only the exception class, so
|
||||
local paths stay in the caller's logs rather than crossing the service boundary.
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.pixel_evidence import extract_pixel_evidence
|
||||
|
||||
pixels = extract_pixel_evidence(Path("input.png"), artifacts=False, timings=True)
|
||||
payload = pixels.to_dict(schema_version=1)
|
||||
```
|
||||
|
||||
Timings and spatial artifacts are opt-in. Artifacts include image-identifying data
|
||||
such as a thumbnail and perceptual hash; aggregate feature families do not.
|
||||
|
||||
`identify_from_evidence` does not reopen the source file by default: it evaluates
|
||||
metadata only, and registered visible marks and pixel-backed invisible watermarks
|
||||
remain in the path-based `identify` call.
|
||||
|
||||
Pass `image_path` together with `check_visible` or `check_invisible` to add those
|
||||
pixel detectors on top of the SAME evidence. That is how a caller asking one file
|
||||
two provenance questions — which vendor is confirmed, and is there an invisible
|
||||
target — pays for the metadata extraction once:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
|
||||
|
||||
evidence = extract_provenance_evidence(source)
|
||||
metadata_only = identify_from_evidence(evidence)
|
||||
with_pixels = identify_from_evidence(evidence, image_path=source, check_invisible=True)
|
||||
```
|
||||
|
||||
## 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[qwen-zimage]`. Both profiles need it, and both
|
||||
need an NVIDIA GPU.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
engine = InvisibleEngine(
|
||||
pipeline="qwen-zimage", # the default; the only other value is "sdxl-zimage"
|
||||
device=None,
|
||||
cpu_offload=False,
|
||||
)
|
||||
|
||||
engine.remove_watermark(
|
||||
Path("watermarked.png"),
|
||||
Path("clean.png"),
|
||||
)
|
||||
```
|
||||
|
||||
`device=None` and `device="auto"` both run detection. `"cuda"` pins it without
|
||||
detecting. Every other value raises at construction rather than deferring a
|
||||
guaranteed failure to model-load time.
|
||||
|
||||
For limited CUDA memory:
|
||||
|
||||
```python
|
||||
engine = InvisibleEngine(
|
||||
pipeline="qwen-zimage",
|
||||
cpu_offload=True,
|
||||
)
|
||||
```
|
||||
|
||||
Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
|
||||
resolves to `cpu` and construction raises. For the SDXL global stage instead of
|
||||
Qwen:
|
||||
|
||||
```python
|
||||
engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra is required for both profiles: each runs the same
|
||||
DiffSynth Z-Image face stage.
|
||||
|
||||
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
|
||||
controls. It takes no model id, step count or guidance scale, and neither does the
|
||||
constructor: each profile pins its model stack, its per-stage schedule and CFG
|
||||
1.0, so passing one raises `TypeError` at the call rather than being accepted and
|
||||
refused several layers down. 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,119 @@
|
||||
# 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.
|
||||
|
||||
## Published surfaces
|
||||
|
||||
A release is complete only after all four published surfaces are verified:
|
||||
|
||||
| Surface | Published result | Automation |
|
||||
|---|---|---|
|
||||
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
|
||||
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
|
||||
| Hugging Face Space | A factory rebuild of `wiltodelta/remove-ai-watermarks` against the new PyPI release | `distribute.yml` |
|
||||
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
|
||||
|
||||
The GitHub Release is the trigger and release record for this flow. Conda is
|
||||
not a supported publishing surface: this repository has no conda recipe or
|
||||
conda publication job.
|
||||
|
||||
## 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;
|
||||
- triggers a factory rebuild of the Hugging Face Space;
|
||||
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
|
||||
manual Homebrew formula update is the fallback when its automation is blocked.
|
||||
|
||||
## Source distribution boundary
|
||||
|
||||
The wheel includes the package under `src/`.
|
||||
|
||||
The source distribution 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. Hatchling always adds the root `.gitignore` to
|
||||
the sdist, so keep its comments generic and free of local operational context.
|
||||
Ignore rules are not the build boundary: `data/` is deliberately tracked, while
|
||||
the sdist configuration keeps it and the other excluded paths out of the archive.
|
||||
|
||||
## Build backend
|
||||
|
||||
The package uses hatchling through the unpinned `hatchling` build requirement in
|
||||
`pyproject.toml`. Uploading uses uv rather than the older twine-based action.
|
||||
|
||||
## Other channels
|
||||
|
||||
The 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
|
||||
|
||||
Forensic transports are versioned independently from the package. Before publishing
|
||||
a change to provenance metadata, provenance reports, broad forensic metadata, or
|
||||
pixel evidence, run their schema 1 contract tests. Additive fields are compatible;
|
||||
renaming a field, changing its type or meaning, changing a signal name or watermark
|
||||
label, or removing a field requires a new output schema. Add the new serializer
|
||||
without removing schema 1 so long-lived consumers can update separately. A package
|
||||
release must never silently substitute its latest schema when a caller explicitly
|
||||
requests an older supported one.
|
||||
|
||||
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 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,170 @@
|
||||
# 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;
|
||||
- exact app-export provenance and AIGC disclosures from supported
|
||||
ByteDance-family products, with product-only provenance excluded from the
|
||||
generated-image verdict;
|
||||
- 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, both CUDA-only:
|
||||
|
||||
- `qwen-zimage`, the default;
|
||||
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass.
|
||||
|
||||
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
|
||||
is rejected at parse time rather than remapped onto a surviving profile.
|
||||
|
||||
SynthID does not have a public local pixel decoder in this project. The tool
|
||||
recognizes presence from supported provenance: Google AI C2PA under Google's
|
||||
all-media watermark policy, and current OpenAI C2PA carrying an explicit
|
||||
`c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action does not
|
||||
assert SynthID. After provenance metadata is removed, a local negative result
|
||||
is still 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, supported C2PA, and exact app-export AIGC disclosures |
|
||||
| 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,191 @@
|
||||
# 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. The `instantid` and `photomaker`
|
||||
> extras it discusses, the `--restore-faces` flag, and their modules were all removed
|
||||
> from the library afterwards.
|
||||
|
||||
**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 (the single-portrait
|
||||
fixture regenerated as a generic "round Asian male" vs the 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,290 @@
|
||||
# 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`. The path it calls shipped -- `photomaker_restore.py`
|
||||
> and the `photomaker` extra -- was removed from the library afterwards.
|
||||
|
||||
> **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 (removed in 0.24.0 with the
|
||||
controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_sdxl`):
|
||||
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,771 @@
|
||||
# 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 recognizes SynthID from provenance
|
||||
|
||||
We recognize SynthID indirectly from supported C2PA evidence; this is not a
|
||||
pixel watermark decode. Google states that all media generated by its tools is
|
||||
watermarked, so Google AI C2PA establishes SynthID. OpenAI C2PA predates its
|
||||
SynthID rollout, but current manifests add the signed
|
||||
`c2pa.watermarked.unbound` action; OpenAI provenance establishes SynthID only
|
||||
when that action is present. Legacy OpenAI C2PA without it remains valid origin
|
||||
evidence but does not assert a pixel watermark.
|
||||
|
||||
This 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.
|
||||
|
||||
What that calibration does and does not constrain, the ranked experiment program
|
||||
for trading less quality for the same removal, and the change candidates that were
|
||||
refuted along the way are recorded in
|
||||
[`video-synthid-quality-research.md`](video-synthid-quality-research.md).
|
||||
|
||||
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. Current OpenAI manifests distinguish the watermarked output with the
|
||||
signed `c2pa.watermarked.unbound` action. The detector requires that action, so
|
||||
old OpenAI C2PA remains an origin signal without becoming a SynthID claim.
|
||||
|
||||
### 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.**
|
||||
|
||||
> **Superseded in 0.24.0.** The `controlnet`, `sdxl`, `qwen` and `default` profiles
|
||||
> were removed, and a retired name is now rejected at parse time rather than routed
|
||||
> onward. `--pipeline` accepts only `qwen-zimage` (the default) and `sdxl-zimage`;
|
||||
> both are CUDA-only, both condition their global stage on a canny edge map, and both
|
||||
> run the same Z-Image face stage. The bullets below are a record of what was measured
|
||||
> on the removed profiles, kept because the oracle verdicts are still the evidence the
|
||||
> current design rests on.
|
||||
|
||||
- **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 was then the default, because it was much cheaper and ran on
|
||||
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/qwen-improvement-research.md` for the identity and text metrics and the
|
||||
validation scope of those comparisons.
|
||||
- **Face identity:** canny holds face *structure* but not *identity*. The removed
|
||||
SDXL and ControlNet profiles did not run a separate face-restoration stage, and
|
||||
earlier GFPGAN, PhotoMaker, and FaceID experiments were dropped after they
|
||||
degraded identity or risked reintroducing source pixels. Both shipped profiles
|
||||
now run the same 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.
|
||||
|
||||
> **Superseded in 0.24.0.** The `sdxl`, `controlnet`, `qwen` and `default` profiles
|
||||
> were removed, and `OPENAI_STRENGTH` / `GEMINI_STRENGTH` / `UNKNOWN_STRENGTH` went
|
||||
> with them. Everything from here to the end of this section is a record of what was
|
||||
> measured on those profiles, kept because the oracle verdicts are still the evidence
|
||||
> base. For the strength policy in force now see `module-internals.md`: `qwen-zimage`
|
||||
> uses `resolution_adaptive_denoise`, `sdxl-zimage` a flat vendor ladder.
|
||||
|
||||
The default was **vendor-adaptive** (`watermark_profiles.resolve_strength` +
|
||||
`vendor_for_strength`): the tool read the C2PA issuer on the original input and picked
|
||||
`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 applied
|
||||
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 provenance) 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 provenance evidence 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 SDXL and ControlNet profiles it measured
|
||||
no longer exist; the shipped defaults are defined in `watermark_profiles.py`, and
|
||||
both surviving profiles run face repair as a built-in second stage rather than as
|
||||
an optional restore. 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
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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.
|
||||
> The `text_protector.py` module it describes was removed from the library
|
||||
> afterwards.
|
||||
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# 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` covers every choice-valued flag on fixtures. Its knob rows
|
||||
were rewritten when the CPU/MPS profiles and the ESRGAN chain were removed: most now
|
||||
assert a knob is REJECTED, and the accepted-knob rows skip without a CUDA device, so
|
||||
the row count is host-dependent rather than the fixed 68 recorded here before. 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, and the ffmpeg audio/video
|
||||
strip. (Region-targeted diffusion composition was on this list until the parameter
|
||||
was deleted: nothing but a caller-less convenience wrapper ever reached it, and the
|
||||
user-facing region path is `erase`, which inpaints instead.) 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 crashed inside torch
|
||||
|
||||
**Fixed by deletion.** `--steps` no longer exists, on the CLI or in the Python API,
|
||||
so this class of failure is unreachable. Kept as a record of why.
|
||||
|
||||
Effective timesteps were `int(steps * strength)`. At the vendor-adaptive default
|
||||
strength (0.15, or 0.10 for OpenAI) any `--steps` under 7 rounded to **zero**, and the
|
||||
pipeline died with a raw traceback:
|
||||
|
||||
```
|
||||
RuntimeError: cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]
|
||||
```
|
||||
|
||||
Fully valid CLI arguments, no special flags, no `--force`. The value was accepted, the
|
||||
crash was a torch internal, and nothing told the user that steps and strength interact.
|
||||
The considered fixes were a clamp to >=1 effective step or an up-front validation
|
||||
naming both values; what shipped instead is that each stage owns its own distilled
|
||||
schedule and no caller can set it. The general lesson stands: a knob whose valid range
|
||||
depends on another knob's value needs the interaction validated where both are known,
|
||||
or it needs to not be a knob.
|
||||
|
||||
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,493 @@
|
||||
# Video SynthID quality research (2026-08-05)
|
||||
|
||||
> 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 question **"can the video SynthID path keep removing the
|
||||
mark while giving up far less quality than the shipped 512 px / 12 fps profile?"**
|
||||
Produced by a 13-agent workflow: 6 parallel scouts (pipeline ablation, SynthID
|
||||
internals, attack literature, autoencoder landscape, perceptual masking, experiment
|
||||
design), 11 change proposals from 3 independent design angles, and 3 adversarial
|
||||
critics (signal theory, repository engineering, verifiability). No experiment was
|
||||
run against the provider oracle for this page; every claim is labeled MEASURED,
|
||||
REPORTED, or INFERRED.
|
||||
|
||||
Repository claims below were re-verified against source after the workflow
|
||||
returned: the PSNR reference frame, the audio stream copy, the missing `_fit_size`
|
||||
clamp, the absent `enable_tiling` call, the crf asymmetry, the test pins, and the
|
||||
absent `scaling_factor` key in the cached checkpoint config.
|
||||
|
||||
## Context
|
||||
|
||||
`remove_video_invisible` regenerates video pixels through `sd-vae-ft-mse`: frames
|
||||
are decimated to 12 fps, resized to a 512 px long side, encoded to latents with
|
||||
`latent_dist.mode()`, perturbed by one seeded spatial noise field shared across
|
||||
every frame, decoded, and streamed to an H.264 encoder at crf 18. A separate
|
||||
stream-copy mux re-adds source audio and strips metadata. The shipped
|
||||
`noise_std=0.15` is certified by one oracle row on one carrier.
|
||||
|
||||
The quality complaint is real: a 1080p source is delivered at roughly a quarter of
|
||||
its linear resolution and half its frame rate. This page asks what part of that
|
||||
cost is buying removal and what part is buying nothing.
|
||||
|
||||
## What the two oracle rows do and do not prove
|
||||
|
||||
`data/evaluations/video-synthid-oracle.csv`, one carrier (Veo 3 off-road,
|
||||
sha256 `79a552b9...`), one seed, one geometry:
|
||||
|
||||
| noise_std | long_side | fps | verdict | psnr_db | temporal_residual_ratio |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 0.10 | 512 | 12 | detected | 26.2932 | 1.0072 |
|
||||
| 0.15 | 512 | 12 | not_detected | 25.3911 | 1.0578 |
|
||||
|
||||
They prove exactly one thing: the envelope of 512 px, 12 fps, crf 18, and a full
|
||||
VAE round trip does **not** silence the oracle on its own. The verdict flip is
|
||||
bought by the last 0.05 of latent noise.
|
||||
|
||||
They cannot decompose that conjunction. No row varies `long_side` or `fps`, so the
|
||||
contribution of the downscale is unmeasured. The frequent reading "the 512 px
|
||||
downscale was probably doing the removal work" is not supported, and neither is its
|
||||
opposite.
|
||||
|
||||
## Measured facts from the repository
|
||||
|
||||
- **The fidelity metric is blind to the expensive steps.** `_iter_sampled_frames`
|
||||
applies `cv2.resize` at [`video_invisible.py:236`](../src/remove_ai_watermarks/video_invisible.py);
|
||||
the same generator feeds the loop at `:401-407`; the accumulator at `:429-435`
|
||||
zips those already-resized frames against `regenerated` captured at `:423`, which
|
||||
is before `frame_pipe.write` at `:430`. `psnr_db` therefore excludes the
|
||||
downscale, the decimation, and the encoder. It measures the VAE round trip plus
|
||||
latent noise at the working geometry, nothing else.
|
||||
- **Frame decimation cannot destroy the carrier.** `_iter_sampled_frames`
|
||||
(`:224-238`) is pure subset selection: the only statement inside the threshold
|
||||
test is `yield cv2.resize(...)`, with no arithmetic across frames. The
|
||||
perturbation is equally time-blind: `shared_noise.expand(latents.shape[0], -1, -1, -1)`
|
||||
at `:295` broadcasts one CHW field across the batch and never mixes across time.
|
||||
Any fps effect on the verdict is a detection-probability effect, not carrier
|
||||
destruction.
|
||||
- **`_fit_size` has no clamp at 1.0.** `scale = long_side / max(width, height)` at
|
||||
`:88` upscales any source whose long side is below 512, and both axes are floored
|
||||
independently to a multiple of 8 at `:89-96`, which introduces an anamorphic
|
||||
shift at scale 1.0 (1366x768 becomes 1360x768).
|
||||
- **`enable_tiling` is never called** anywhere in `src/`, `scripts/`, or `tests/`.
|
||||
`enable_slicing()` at `:135` already splits both encode and decode to single
|
||||
frames, so activation VRAM is set by one frame and does not scale with
|
||||
`--batch-size`.
|
||||
- **crf asymmetry:** 18 on the invisible path (`:321`, `:386`) against 14 on the
|
||||
visible path ([`video_visible.py:1343`](../src/remove_ai_watermarks/video_visible.py))
|
||||
through the same encoder.
|
||||
- **No HDR guard on the invisible path.** `_HDR_TRANSFERS` (`video_visible.py:97`)
|
||||
and the `component_depth > 8` rejection (`:1322`) exist only for visible removal.
|
||||
- **Audio is byte-copied.** `-map 0:v:0`, `-map 1:a?`, `-c copy` at
|
||||
[`video_encoding.py:413`](../src/remove_ai_watermarks/video_encoding.py).
|
||||
- **Only `noise_std` is pinned.** `tests/test_video_invisible.py:285` asserts
|
||||
`DEFAULT_VIDEO_SYNTHID_NOISE_STD == 0.15`. Neither `long_side` nor `fps` is
|
||||
pinned anywhere, so changing either breaks no test.
|
||||
- **`scaling_factor` 0.18215 is a class default, not a checkpoint fact.** The
|
||||
cached `config.json` for `stabilityai/sd-vae-ft-mse` has no `scaling_factor` key
|
||||
at all (verified locally: `_class_name`, `latent_channels`, `block_out_channels`,
|
||||
`sample_size`, and block types only). The value comes from the `AutoencoderKL`
|
||||
class default under `diffusers>=0.38.0` with no upper bound (`pyproject.toml:94`,
|
||||
`uv.lock` resolves 0.39.0), while `maintain.sh` runs `uv-outdated`. A dependency
|
||||
bump can move the certified operating point with a fully green test suite.
|
||||
- **The manifest schema cannot record what a real program needs:** no source
|
||||
geometry, no control row, no track column, no verbatim verdict, no indeterminate
|
||||
state.
|
||||
|
||||
## Measured facts from external sources
|
||||
|
||||
- Gemini video verification quota: 10 checks per rolling 24 hours, up to 5 minutes
|
||||
of video total, under 90 seconds and 100 MB per file. The verifier reports which
|
||||
parts of the video carry the mark, and it has a **third** state beyond detected
|
||||
and not detected: unclear, with documented causes including "not enough details
|
||||
to watermark".
|
||||
- The verifier scores **audio and visual tracks separately**. Google's published
|
||||
example verdict reads as SynthID detected in the audio over a time range with no
|
||||
SynthID detected in the visuals.
|
||||
- SynthID-Image (arXiv:2510.09263) is a post-hoc, model-independent pixel-space
|
||||
watermark: a separate encoder network stamps an already-decoded image rather than
|
||||
being injected into the generator's latents. Its published payload figure is 136
|
||||
bits within a 512x512 image, and its product setup runs at 1536x1536.
|
||||
|
||||
## Inferred, with the reasoning that makes them weak
|
||||
|
||||
- **The noise axis is nearly exhausted.** Fitting `MSE = A + B * noise_std^2` to the
|
||||
two measured rows gives A = 124.5 and B = 2815, so a pure round trip
|
||||
(`noise_std = 0`) lands near 27.2 dB. The entire noise budget is worth at most
|
||||
**+1.79 dB**; everything else is the autoencoder. This is a two-parameter fit to
|
||||
two points with zero degrees of freedom, and the assumptions that MSE is
|
||||
quadratic in `noise_std` and that reconstruction and noise errors are orthogonal
|
||||
are untested. One local run at `noise_std=0` replaces it with a measurement.
|
||||
- **Statistical power of the current certification.** With zero failures at n = 1,
|
||||
the exact one-sided Clopper-Pearson bound `1 - 0.05^(1/n)` is 95%: the data are
|
||||
compatible with removal failing almost always. n = 15 gives 18.1%, n = 30 gives
|
||||
9.5%.
|
||||
- **Size of the geometry prize.** At a 1280x720 source, `_fit_size(1280, 720, 512)`
|
||||
is `(512, 288)` (pinned in `tests/test_video_synthid_sweep.py:30`), so 6.25x of
|
||||
the pixels are discarded by the downscale and another 2x by decimation from a
|
||||
24 fps source. Ladder rungs: 768 gives 2.25x the current area, 1024 gives 4.0x.
|
||||
The carrier's own geometry is recorded nowhere, so this is conditional.
|
||||
- **The direction of the resolution effect is disputed, and both sides are
|
||||
inference.** Against raising it: the absolute frequency ceiling below which an f8
|
||||
VAE reconstructs faithfully is tied to the latent pitch, so 512x288 (a 64x36
|
||||
latent) preserves roughly up to 32 cycles per frame width while 1920 (a 240x135
|
||||
latent) preserves up to about 120. Raising resolution moves the carrier band out
|
||||
of the regime the decoder synthesizes and into the regime it reproduces
|
||||
faithfully, handing the detector more evidence. For raising it: Google's product
|
||||
operating point is 1536x1536, so native is closer to the distribution the
|
||||
watermark encoder targets. Note that the second argument cuts against the
|
||||
proposal rather than for it.
|
||||
|
||||
## First local measurements (2026-08-05)
|
||||
|
||||
Run without the oracle on a locally built carrier: a 6-second 1280x720 24 fps
|
||||
clip panning across `data/fixtures/provenance/doubao-1.png`, processed by the
|
||||
shipped path at 512 px / 12 fps on MPS. Generated media stayed outside the
|
||||
repository.
|
||||
|
||||
| noise_std | engine `psnr_db` | end-to-end PSNR | end-to-end SSIM | bitrate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 0.00 | 27.8049 | 27.6935 | 0.6955 | 2564 kbps |
|
||||
| 0.15 | 25.8853 | 25.8541 | 0.6703 | 2716 kbps |
|
||||
|
||||
Three readings, all MEASURED, none of them about SynthID:
|
||||
|
||||
1. **The two-point fit's shape survives contact with independent content.** The
|
||||
pure round trip lands at 27.80 dB against the 27.2 dB the carrier fit
|
||||
predicted, and the whole noise budget costs 1.92 dB against the predicted
|
||||
1.79 dB. These are not the carrier's numbers, but the decomposition holds:
|
||||
the autoencoder is the floor and the entire `noise_std` axis is worth about
|
||||
2 dB.
|
||||
2. **On this content the downscale is nearly free in PSNR terms.** End-to-end
|
||||
PSNR sits within 0.11 dB of the in-loop number, meaning the 512 px geometry
|
||||
cost almost nothing next to the VAE damage. This clip is a pan over a smooth
|
||||
generated image with little high-frequency detail, so it is the friendly
|
||||
case: real camera texture should widen that gap. Run the probe across content
|
||||
types before trusting any estimate of the geometry prize.
|
||||
3. **`temporal_residual_ratio` is not meaningful on a near-static shot.** It read
|
||||
1.82 at `noise_std=0` and 2.47 at 0.15, far outside the [1.0072, 1.0578] band
|
||||
ever observed before. A slow pan gives the source almost no motion residual,
|
||||
so the `max(temporal_baseline, 1e-6)` denominator collapses and the ratio
|
||||
inflates. This is the predicted defect, now observed rather than argued.
|
||||
|
||||
## The single most important unknown
|
||||
|
||||
**How much the 512 px downscale contributes to removal.** Every quality gain routes
|
||||
through this question, and the critics moved it from "probably free" to "direction
|
||||
unknown", which is exactly what makes it the highest-information experiment
|
||||
available.
|
||||
|
||||
## Ranked experiment program
|
||||
|
||||
Ranked by information per oracle query. The budget is roughly 10 checks per 24
|
||||
hours (MEASURED), so the program is paced by calendar, not by money.
|
||||
|
||||
### E0. The zero-oracle tier (0 submissions) - do this first
|
||||
|
||||
Not an experiment on the oracle; the precondition that makes everything else
|
||||
interpretable.
|
||||
|
||||
- `ffprobe` the carrier and record `source_width`, `source_height`, `source_fps`.
|
||||
The actual downscale factor of the existing rows is currently unrecoverable.
|
||||
- Extend the manifest schema: variant/control, source geometry, `vae`, `track`
|
||||
(audio|visual), verdict state in {DETECTED, NOT_DETECTED, INDETERMINATE,
|
||||
REFUSED}, verbatim verdict text, detected time range for the output,
|
||||
`session_id`, content stratum.
|
||||
- **Measure the `noise_std = 0.0` round trip locally** on the same carrier. This
|
||||
converts the inferred 27.2 dB ceiling into a measurement and bounds the whole
|
||||
noise axis for one GPU pass and zero oracle cost.
|
||||
- Freeze the current defaults' metrics on a fixed clip set, matching the
|
||||
record-then-diff rule in `.claude/rules/development.md`.
|
||||
- Determine whether the carrier has an audio track and whether it carries SynthID.
|
||||
|
||||
### E1. Instrument validation: multiplexing and a session anchor (1-2 submissions)
|
||||
|
||||
The verifier reports time ranges, so one file can carry several doses. A 24-second
|
||||
file of `[control 8 s | certified 0.15 8 s | control 8 s]` should read detected on
|
||||
the outer segments and not detected on the middle one, both halves already known
|
||||
from the manifest.
|
||||
|
||||
`encode_video_frames` requires matching dimensions and one frame rate, so
|
||||
multiplexing works **only along the `noise_std` axis**, not across geometries: one
|
||||
multiplexed file per geometric envelope.
|
||||
|
||||
Separately, submitting the existing detected file first in each session costs 10%
|
||||
of the quota, turns "the oracle may have changed" into a per-session gate, and
|
||||
measures the flip rate on a byte-identical file, a quantity every plan silently
|
||||
assumes is zero.
|
||||
|
||||
### E2. Resolution ladder at fixed dose - the decisive axis (2 submissions per rung)
|
||||
|
||||
A fixed value, not a ceiling. Rung 1 is `long_side = 768` with fps held at 12 so
|
||||
exactly one destruction axis moves. Jumping straight to 1920 is a bad first rung:
|
||||
a 3.75x jump makes a detected verdict uninformative about where the boundary lies.
|
||||
|
||||
- **Matched control:** 768 / 12 / `noise_std = 0.10`, expected DETECTED. It is
|
||||
strictly stronger than a plain re-encode control because it discharges the
|
||||
envelope, the VAE round trip, and a nonzero dose at once, and it diffs directly
|
||||
against the existing 512 row.
|
||||
- **Candidate:** 768 / 12 / 0.15.
|
||||
- **Positive:** the resolution axis is open; next rungs 1024 and then native with a
|
||||
clamp, 2 submissions each.
|
||||
- **Negative:** the downscale contributes to removal. This is the most valuable
|
||||
available negative: it kills the native-resolution proposals outright and turns
|
||||
the question into how much dose must be spent to buy resolution back, at the
|
||||
known exchange rate B = 2815.
|
||||
- Watch for a monotonicity violation: 768 / 0.10 reading NOT_DETECTED would
|
||||
overturn the critics' spectral argument.
|
||||
|
||||
### E3. Frame rate at fixed geometry and dose (2 submissions)
|
||||
|
||||
Control 512 / 24 / 0.10 expected DETECTED; candidate 512 / 24 / 0.15 expected
|
||||
not detected. The mechanism is provable from source, so the prior is high, but the
|
||||
only counter-mechanism - per-frame count aggregation - is probabilistic and n = 1
|
||||
on an 8-second clip does not measure it. Certify on the longest clip that fits the
|
||||
90 s / 100 MB limits, because the risk compounds with frame count.
|
||||
|
||||
Ranked below E2 because the outcome is nearly predetermined and the prize (judder)
|
||||
is smaller than the geometry prize. It is the safest bet if a guaranteed win is
|
||||
wanted.
|
||||
|
||||
### E4. `noise_std = 0.0` as an oracle probe (1 submission) - deliberately low rank
|
||||
|
||||
The local half of this probe is the cheapest high-information measurement in the
|
||||
program and already sits in E0 at zero oracle cost. The **oracle** half ranks low:
|
||||
it buys no quality by construction, and its likely DETECTED outcome is nearly
|
||||
deducible from the 0.10 row under monotonicity. Its one real value is that
|
||||
NOT_DETECTED here would be non-monotone against 0.10 and would refute the
|
||||
assumption underneath every ladder and bisection in this program. Worth one query,
|
||||
after E2 and E3.
|
||||
|
||||
### E5. Stratified certification of the surviving operating point (15-30 submissions)
|
||||
|
||||
Only after E2 and E3 produce a winner. Zero detections across at least 15
|
||||
stratified carriers (at least 3 per stratum: face closeup, fast motion, flat sky,
|
||||
dark night, text overlay) bounds the failure rate at 18.1%; 30 carriers bound it at
|
||||
9.5%. Add the project's 1.5x margin convention, at least 2 seeds, at least 2
|
||||
sessions, and per-track verdict recording.
|
||||
|
||||
For the flat-sky stratum, check the source reads detected first: Google documents a
|
||||
"not enough details to watermark" state, so a clean reading there may say nothing
|
||||
about removal.
|
||||
|
||||
## Surviving change candidates
|
||||
|
||||
**S1. Fix the metric's reference frame (0 oracle submissions).** Add
|
||||
`source_psnr_db` (candidate upscaled back to native against the untouched source
|
||||
frame) **and** a separate post-mux pass that decodes the delivered file. The
|
||||
critics killed the original claim that this would expose crf 18: the candidate is
|
||||
captured at `:423`, before `frame_pipe.write` at `:430`, so no in-loop metric can
|
||||
see the codec. Only the post-mux pass covers resize, decimation, crf, and mux
|
||||
together. Use `INTER_AREA` or a fixed analytic size for the upscale so the CPU
|
||||
resize does not dominate the loop, and update both other consumers of the
|
||||
generator in the same change (`read_sampled_frames` at `:188-209`, and
|
||||
`scripts/video_synthid_sweep.py:147` where `np.stack(frames)` must keep receiving
|
||||
resized frames). Do not redefine the existing `psnr_db`: the two manifest rows stay
|
||||
comparable to each other only while that field keeps its exact current meaning.
|
||||
|
||||
**S2. Frame rate as a fixed certified value (after E3).**
|
||||
`DEFAULT_VIDEO_SYNTHID_FPS` 12.0 to 24.0, keeping `min(fps, source_fps)`. A
|
||||
ceiling of 60.0 was **rejected**: it makes the delivered operating point a function
|
||||
of the user's source, so one sample from a family gets certified while the rest
|
||||
ship uncertified, and the risk direction is unfavorable. Port the VFR/PTS bridge
|
||||
(`probe_video_timestamps`, `timestamped_input`, currently only in
|
||||
`video_visible.py`): at native frame rate the output stops reading as a proxy, and
|
||||
silent CFR-ification becomes a master-quality defect. Add `fps` and `long_side`
|
||||
pins next to `tests/test_video_invisible.py:285`, tied to the certifying manifest
|
||||
row.
|
||||
|
||||
**S3. Clamp and align `_fit_size` (0 oracle submissions, with a caveat).**
|
||||
`scale = min(1.0, long_side / max(width, height))` at `:88`; align the long side
|
||||
and derive the short side from the true aspect, rounding to the nearest multiple of
|
||||
8. The pinned `_fit_size(1280, 720, 512) == (512, 288)` survives. The caveat: the
|
||||
clamp is an **uncertified operator change for a whole class of users**, since a
|
||||
320x240 source is upscaled to 512x384 today and would run at 320x240 afterwards,
|
||||
and neither has been tested. Ship it as its own documented change, not as a
|
||||
drive-by fix.
|
||||
|
||||
**S4. Extend the manifest schema and oracle protocol (0 submissions).** A
|
||||
precondition for the whole program. Record the verbatim verdict text, because an
|
||||
unclear state logged as not detected is exactly the silent regression the protocol
|
||||
exists to prevent.
|
||||
|
||||
**S5. A sigma normalization contract (0 submissions, applies to the current path).**
|
||||
No VAE swap survived, but two elements are real risks today: `vae.config.scaling_factor`
|
||||
cannot be trusted, so log `latents.std()` at `:276` and assert the effective
|
||||
scaling factor at load; and any cross-model sigma transfer must match on the **RMS
|
||||
of the decoded pixel perturbation**, not on latent units.
|
||||
|
||||
**S6. Additive texture masking - contingency on an E2 negative only.** Not energy
|
||||
preserving: the certified sigma stays as a floor everywhere and the mask only adds
|
||||
on top in textured regions. The original energy-preserving form was refuted - the
|
||||
mechanism normalizes and then clips, which breaks the claimed invariant by a
|
||||
content-dependent amount, and the proposed unit test asserted the invariant before
|
||||
the clip and would have stayed green. Energy preservation is also unsafe in
|
||||
principle: the detector does not need a full frame, and the verifier reports
|
||||
per-segment, so a flat region perturbed only at the floor is a crop carrying nearly
|
||||
the full carrier. The salvaged version has **no standalone PSNR win**; its only
|
||||
value is freeing distortion budget to spend on resolution if E2 shows the dose must
|
||||
rise. It additionally needs motion compensation for the mask (flow is computed at
|
||||
`:442`, after the decode at `:423`, so the loop must be restructured) and one
|
||||
deliberately bad candidate so `temporal_residual_ratio` acquires a known failing
|
||||
value: it has only ever been observed in [1.0072, 1.0578], so "the ratio looks
|
||||
fine" is currently an unfalsified claim.
|
||||
|
||||
## Refuted proposals - do not resurrect
|
||||
|
||||
- **Carrier-scale, resolution-invariant noise field.** "Carrier scale" is set by
|
||||
our VAE's latent pitch, not the carrier's. The motivating arithmetic treats a
|
||||
field on the latent grid as white at pixel resolution and is wrong by exactly the
|
||||
square of the scale factor: white noise on a 240-wide latent already spans
|
||||
0-120 cycles per frame, entirely inside the band the decoder reproduces. The
|
||||
construction actually band-limits the perturbation from 0-120 to 0-32 cycles,
|
||||
making it smoother - the first thing any spread-spectrum extractor's content
|
||||
suppression removes - while moving its energy toward the peak of the contrast
|
||||
sensitivity function, making it more visible. Bilinear upsampling is also
|
||||
heteroscedastic, and global renormalization by `noise.std()` stamps a visible
|
||||
amplitude lattice rather than fixing it.
|
||||
- **Native resolution plus tiling plus a carrier-scale field.** Inherits the
|
||||
arithmetic above, concedes that native processing destroys less carrier without
|
||||
proposing a replacement mechanism, and bundles four destruction axes into one
|
||||
candidate on a 4-query budget so a detected verdict is undecomposable. Its cost
|
||||
model was also wrong by roughly 10x. The salvageable parts moved into S1 and S3.
|
||||
- **`AutoencoderKLTemporalDecoder`.** Refuted independently by three critics.
|
||||
Removal is a property of the encode-decode composition, so a strictly more
|
||||
faithful decoder preserves more carrier at the same sigma. The class implements
|
||||
neither slicing nor tiling, so `enable_slicing()` at `:135` raises
|
||||
`NotImplementedError` and the documented bounded-memory property dies. Its
|
||||
temporal receptive field needs windows of tens of frames, its license carries a
|
||||
revenue restriction incompatible with an Apache-2.0 default, and its own
|
||||
published table shows a **worse** FID (9.17 against 7.61).
|
||||
- **Wan or another temporal video VAE.** Hard refusal in code: `AutoencoderKLWan._encode`
|
||||
uses `iter_ = 1 + (num_frame - 1) // 4`, so at the shipped `batch_size = 4` only
|
||||
frame 0 is encoded and a 4-frame batch decodes back to 1, which makes
|
||||
`zip(..., strict=True)` at `:429` raise. The proposed remedy of carrying the
|
||||
causal feature cache across chunks is impossible through the public API, since
|
||||
`clear_cache()` runs on both entry and exit of `_encode` and `_decode`.
|
||||
`float(vae.config.scaling_factor)` at `:270` and `:292` also raises, because Wan
|
||||
exposes `latents_mean`/`latents_std` instead. Logically, feeding 4:1 temporal
|
||||
compression frames 83 ms apart either reconstructs well (carrier preserved, no
|
||||
removal gain) or hallucinates (no quality prize); the two claims are mutually
|
||||
exclusive.
|
||||
- **A 16-channel f8 VAE.** Self-refuted and confirmed by the critics. Quadrupling
|
||||
latent channels roughly doubles L in Zhao's theorem, so sigma must roughly double
|
||||
to hold removal, and the VAE frontier in the UnMarker results is monotone with no
|
||||
published point where a more faithful autoencoder removes **more**. Predicted net
|
||||
loss of 2-7.5 dB by its own arithmetic. Its config also carries
|
||||
`scaling_factor = 0.2614`, so an unchanged `noise_std = 0.15` is about 30% weaker
|
||||
in raw latent units. Only the normalization contract survived, as S5.
|
||||
- **A per-channel luma/chroma probe.** The arms match in absolute latent units but
|
||||
not in relative dose per channel, since `scaling_factor` normalizes the aggregate
|
||||
latent, so the very reading the probe exists for is confounded with dose. The
|
||||
chroma arm is additionally subsampled by the yuv420p encode, which no current
|
||||
metric sees. Maximum prize under 1 dB by its own fit.
|
||||
- **Post-processing with unsharp plus grain, as a shipped stage.** The
|
||||
carrier-reimport argument is sound and was verified line by line: `unsharp_mask`
|
||||
reads only its own argument ([`humanizer.py:79`](../src/remove_ai_watermarks/humanizer.py)),
|
||||
and `adaptive_polish` touches the source only through one float. But reimport was
|
||||
never the binding risk:
|
||||
`cv2.addWeighted(img_f, 1.0 + amount, blurred, -amount, 0.0)` at
|
||||
`_ADAPTIVE_MAX_UNSHARP = 1.0` multiplies the **surviving carrier residue** by up
|
||||
to 2x as the terminal operation before encoding, and whether that re-arms a given
|
||||
file depends on that file's unobservable margin. With no local decoder and only a
|
||||
sampled oracle the stage is structurally uncertifiable, not merely expensive to
|
||||
certify. The only rescue is moving sharpening **above** the VAE stage so the
|
||||
removal operator stays terminal. Grain, which monotonically lowers detector SNR,
|
||||
is the safe half and can be separated.
|
||||
|
||||
## Measuring quality properly
|
||||
|
||||
The current `psnr_db` cannot show the improvement this work exists to produce.
|
||||
Minimum upgrade:
|
||||
|
||||
1. **`source_psnr_db`** - candidate upscaled back to native against the untouched
|
||||
source frame. Good for ranking configurations against a common reference,
|
||||
dominated by unrecoverable high-frequency content, so not a measure of what the
|
||||
VAE costs.
|
||||
2. **A post-mux end-to-end pass** - decode the delivered file after
|
||||
`mux_encoded_video` (`:456`) and compare against the source. The only measure
|
||||
covering resize, decimation, crf, and mux together. Keep it out of the streaming
|
||||
loop so `test_stream_batches_consumes_only_one_batch_ahead` stays valid.
|
||||
Implemented as `scripts/video_fidelity_probe.py`, which streams, reports the
|
||||
delivered file's bitrate, and shares the engine's frame-selection rule rather
|
||||
than copying it.
|
||||
3. **DISTS** (arXiv:2004.07728), built to tolerate texture resampling - exactly
|
||||
what the VAE does to foliage, skin, and grass, and exactly what PSNR and LPIPS
|
||||
punish even when the result is perceptually equivalent. It separates "the VAE
|
||||
resampled the grass" from "the VAE destroyed an edge".
|
||||
4. **VMAF** through `ffmpeg` `libvmaf`, cheapest to add, whose ADM/DLM feature
|
||||
names this exact complaint. Its temporal term is only a mean absolute luma
|
||||
difference between neighboring frames, so it is not a flicker detector: keep the
|
||||
motion-compensated ratio.
|
||||
5. **Encoded file size as a third axis.** Every current metric is taken before the
|
||||
pipe, so a bitrate explosion currently reads as "quality did not suffer". This
|
||||
matters most for any perturbation that varies frame to frame: at fixed crf it
|
||||
raises bitrate rather than lowering quality.
|
||||
6. **`temporal_residual_ratio` repairs**: add a p95 across frames next to the mean
|
||||
so localized flicker stops being averaged away, add a long-horizon term against
|
||||
the first frame, and replace `max(temporal_baseline, 1e-6)` with an explicit
|
||||
undefined result on static shots.
|
||||
7. **A no-reference metric** (DOVER or FAST-VQA) for the case where no
|
||||
full-reference measure can compare a 512/12 output against a 1024/24 output on
|
||||
one scale. Relative ranking within a sweep only; absolute values are
|
||||
uncalibrated for this artifact class.
|
||||
|
||||
Nothing local measures removal. `remove_video_invisible` checks `get_ai_metadata`
|
||||
on the output, which is metadata, not pixels. Only a manifest row counts.
|
||||
|
||||
## Risks
|
||||
|
||||
**Error asymmetry is the governing constraint.** Shipping a leak means a user
|
||||
receives a watermarked file believing it is clean, with no local pixel decoder to
|
||||
catch it and no feedback path that would surface it. Staying conservative means a
|
||||
user receives 512 px / 12 fps, a cost that is visible, bounded, and reversible with
|
||||
an explicit flag. A symmetric test is therefore inadmissible, and the burden of
|
||||
proof sits entirely on the new default.
|
||||
|
||||
**n = 1.** The current default is certified by one success on one carrier with one
|
||||
seed. Any quality change inherits that weakness and must not deepen it.
|
||||
|
||||
**Content and seed dependence** is MEASURED on this project's image branch:
|
||||
survivors switch by content type, and near the threshold the same input flipped
|
||||
between runs on seed alone.
|
||||
|
||||
**Oracle instability.** Three verdict states, not two. Per-segment reporting.
|
||||
Separate audio and visual tracks - and the pipeline byte-copies audio, so a Veo 3
|
||||
clip with generated sound leaves with its audio SynthID intact, and the manifest
|
||||
cannot even record which track the 2026-07-31 negative referred to. That is
|
||||
potentially a shipped product hole, not only an experimental confound. Session
|
||||
drift is currently unfalsifiable.
|
||||
|
||||
**Observability narrows exactly where quality rises.** At fixed crf the bitrate
|
||||
grows with pixels per second, so the maximum duration fitting under 100 MB falls as
|
||||
fps and resolution rise. The configuration users actually receive becomes **less**
|
||||
observable than the one it replaces. That is a permanent property, not an
|
||||
inconvenience.
|
||||
|
||||
**Ceilings instead of fixed values.** Any ceiling makes the delivered operating
|
||||
point a function of the user's source, and with it the perturbation's cycles per
|
||||
frame - the quantity carrying the certified margin. Ship fixed values.
|
||||
|
||||
**Documentation and test surface.** Only `DEFAULT_VIDEO_SYNTHID_NOISE_STD` is
|
||||
pinned. Moving `long_side` or `fps` touches hardcoded numbers in `README.md`,
|
||||
`docs/known-limitations.md`, `docs/cli.md`, `docs/python-api.md`,
|
||||
`docs/module-internals.md`, `docs/synthid.md`, and `docs/verification-plan.md`.
|
||||
Drift-prone operational numbers should not live in seven places of prose; their
|
||||
source of truth is the manifest.
|
||||
|
||||
**Silent configuration drift.** `sd-vae-ft-mse/config.json` carries no
|
||||
`scaling_factor`, so 0.18215 is a class default under an upper-unbounded
|
||||
`diffusers>=0.38.0` while `maintain.sh` runs `uv-outdated`. A library bump can move
|
||||
the certified operating point with a green suite.
|
||||
|
||||
**Missing guards that get worse at native resolution.** There is no HDR or >8-bit
|
||||
rejection and no VFR/PTS bridge on the invisible path. A 512 px output plainly
|
||||
reads as a proxy; a native-resolution output reads as a master, and the cost of
|
||||
silently flattening a 10-bit PQ source to 8-bit SDR rises accordingly.
|
||||
|
||||
**Do not bundle axes.** Raising `long_side`, raising `fps`, and lowering crf all
|
||||
reduce total destruction and all require recertification. The metric reference-frame
|
||||
fix is the only exception, because it changes the measurement rather than the
|
||||
pixels. Do not put a crf 18 to 14 change in the same candidate as a resolution
|
||||
change.
|
||||
@@ -0,0 +1,169 @@
|
||||
# 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)").
|
||||
|
||||
Some applications sign C2PA through an upstream model or infrastructure
|
||||
provider. For AI claims, exact product mappings in `claim_generator` therefore
|
||||
take precedence over issuer attribution. Supported mappings include Higgsfield
|
||||
AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim
|
||||
generator still falls back to the certificate issuer.
|
||||
|
||||
**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`).
|
||||
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
|
||||
- **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`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes the detector of the vendor it actually carries rather than a guessed pair; an unmapped or absent producer falls back to ByteDance's two products. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
|
||||
|
||||
**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`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for WebP it appends the `EXIF` / `XMP ` / `ICCP` / `C2PA` chunks past the window (`_riff_late_metadata`, stepping over the coded image), which is how an IPTC "Made with AI" tag stored after the pixels is caught; and for a file at least `size` bytes long it finally appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`), which covers a compressed PNG `zTXt` packet no byte scan can spell; for any file that fits inside `size`, it is 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
|
||||
@@ -1,41 +1,97 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.8.4"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
version = "0.26.2"
|
||||
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 = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"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",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"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",
|
||||
"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 = [
|
||||
pixels = [
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
]
|
||||
# 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 = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
"PyWavelets>=1.1.1",
|
||||
]
|
||||
diffusion = [
|
||||
"remove-ai-watermarks[pixels]",
|
||||
# A CUDA-enabled torch build is required: invisible-watermark removal has no
|
||||
# CPU, MPS or XPU path. The default PyPI wheel carries CUDA on Linux/Windows;
|
||||
# on macOS there is no CUDA build and this extra installs only for the
|
||||
# non-diffusion imports it shares.
|
||||
"torch>=2.0.0",
|
||||
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
|
||||
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
|
||||
# from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS
|
||||
# XPU build). Install that build first, then this extra (torch is then
|
||||
# already satisfied and won't be re-pulled):
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
# pip install 'remove-ai-watermarks[gpu]'
|
||||
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
|
||||
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
"diffusers>=0.38.0",
|
||||
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
|
||||
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
|
||||
@@ -49,20 +105,16 @@ gpu = [
|
||||
"transformers>=5,<6",
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"accelerate>=0.25.0",
|
||||
"controlnet-aux>=0.0.9",
|
||||
"safetensors",
|
||||
"ultralytics>=8.0.0",
|
||||
"color-matcher>=0.5.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.
|
||||
detect = [
|
||||
"invisible-watermark>=0.2.0",
|
||||
# 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
|
||||
@@ -76,33 +128,42 @@ trustmark = [
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
# eraser backend needs none of this.
|
||||
lama = [
|
||||
"onnxruntime>=1.16.0",
|
||||
"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",
|
||||
]
|
||||
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,trustmark,lama,dev]"]
|
||||
|
||||
# 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 ``gpu`` extra comment), so it does not alter
|
||||
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-xpu"
|
||||
url = "https://download.pytorch.org/whl/xpu"
|
||||
explicit = true
|
||||
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
|
||||
# independently sufficient for a removal, which it is not.
|
||||
all = ["remove-ai-watermarks[video,heif,detect,trustmark,qwen-zimage,lama,migan]"]
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
@@ -111,23 +172,27 @@ remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
Repository = "https://github.com/wiltodelta/remove-ai-watermarks"
|
||||
|
||||
[build-system]
|
||||
# Pin hatchling < 1.28: 1.28+ emits Metadata-Version 2.5 (PEP 639), which the twine
|
||||
# bundled in pypa/gh-action-pypi-publish@release/v1 rejects ("'2.5' is not a valid
|
||||
# Metadata-Version"), failing the PyPI upload (v0.8.3, 2026-06-01). 1.27.x emits 2.4,
|
||||
# which uploads fine (0.8.2 shipped on it). Lift this pin once the publish action's
|
||||
# twine is upgraded to >= 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
|
||||
requires = ["hatchling<1.28"]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/remove_ai_watermarks"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
# Keep the source distribution small: ship the package + metadata, not the
|
||||
# committed test corpora / calibration captures under data/ (tens of MB --
|
||||
# synthid_corpus images + the visible-mark captures), which pushed the 0.8.0
|
||||
# sdist past PyPI's per-project file-size limit (the wheel ships only src/).
|
||||
exclude = ["/data"]
|
||||
# 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"]
|
||||
@@ -138,6 +203,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"]
|
||||
@@ -147,10 +213,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"
|
||||
@@ -172,33 +238,3 @@ reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportMissingTypeArgument = false
|
||||
|
||||
# CtrlRegen is a torch/diffusers/controlnet-aux boundary: those libs ship no
|
||||
# usable types, so strict pyright cannot know the tensor element types. Relax the
|
||||
# unknown-type rules for this subpackage only (mirrors the per-file pragmas used
|
||||
# for the cv2 engines); the rest of the codebase stays strict.
|
||||
[[tool.pyright.executionEnvironments]]
|
||||
root = "src/remove_ai_watermarks/noai/ctrlregen"
|
||||
reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportUnknownParameterType = false
|
||||
reportMissingTypeArgument = false
|
||||
reportMissingTypeStubs = false
|
||||
reportMissingImports = false
|
||||
reportArgumentType = false
|
||||
reportAssignmentType = false
|
||||
reportReturnType = false
|
||||
reportCallIssue = false
|
||||
reportIndexIssue = false
|
||||
reportOperatorIssue = false
|
||||
reportOptionalMemberAccess = false
|
||||
reportOptionalCall = false
|
||||
reportOptionalSubscript = false
|
||||
reportOptionalOperand = false
|
||||
reportAttributeAccessIssue = false
|
||||
reportPrivateImportUsage = false
|
||||
reportPrivateUsage = false
|
||||
reportInvalidTypeForm = false
|
||||
reportConstantRedefinition = false
|
||||
reportUnnecessaryComparison = false
|
||||