Compare commits

...
8 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 4.8 d763581ed3 chore(release): v0.10.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:53:50 -07:00
Victor KuznetsovandClaude Opus 4.8 0d99f403fb ci: auto-distribute releases to Homebrew tap + HF Space
distribute.yml fans a published GitHub Release out to the channels that
would otherwise be manual: it waits for the sdist on PyPI, bumps the
Homebrew formula (HOMEBREW_TAP_TOKEN) and factory-rebuilds the HF Space
(HF_TOKEN). PyPI stays on publish.yml; conda-forge on its autotick bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:47:03 -07:00
Victor KuznetsovandClaude Opus 4.8 e78e5f1154 docs: address HN feedback in README (scope, limitations, honest use case)
From the HN front-page discussion (news.ycombinator.com/item?id=48200569):
- Threat model: drop the 'third-party classifiers' overclaim. State scope
  honestly: it removes SynthID / visible marks / provenance metadata, does NOT
  defeat trained AI-vs-real classifiers (Hive), and watermarks are a weak trust
  signal to begin with.
- Replace the 'preserving art / historical record' use case (criticized as not
  holding) with the defensible one: clearing an overstated AI label from your
  own lightly-AI-edited photo.
- Add a Limitations section: lossless visible/metadata vs lossy content-dependent
  SynthID path, no local self-verify, large images not tiled yet, out-of-scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:41:39 -07:00
Victor KuznetsovandClaude Opus 4.8 0a77d3198e chore(release): v0.10.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:38:50 -07:00
Victor KuznetsovandClaude Opus 4.8 9aea5f240f chore: improve discoverability (PyPI keywords/classifiers, README badges)
Research-informed metadata for organic dev discovery:
- pyproject: add a keywords field (was absent; biggest PyPI search gap) and
  expand classifiers (audience, console, security, AI, utilities); rewrite the
  summary noun-first, naming Nano Banana / SynthID / C2PA verbatim.
- README: add PyPI version, Python versions, downloads, and license badges.

GitHub topics (comfyui, watermark-remover) and the repo description were
updated out of band. PyPI metadata ships on the next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:34:43 -07:00
Victor KuznetsovandClaude Opus 4.8 c3ddf8a801 docs: document Homebrew, conda-forge, and ComfyUI distribution channels
- README: add Homebrew install, conda (conda-forge, in review), and a
  ComfyUI custom-nodes section.
- CLAUDE.md: per-channel release/bump cadence (Homebrew formula, conda-forge
  autotick bot, ComfyUI Registry); note pip_check: false on the conda recipe.
- Add packaging/conda/recipe.yaml (v1, noarch core-only), verified green on
  conda-forge/staged-recipes PR #33674.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:29:40 -07:00
Victor KuznetsovandClaude Fable 5 5777458296 chore(release): v0.10.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:08:44 -07:00
Victor KuznetsovandClaude Fable 5 295e7ada2b chore: project review (dev tools in extras, dep upgrades, optional-deps guard, stale cleanup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:03:17 -07:00
19 changed files with 1044 additions and 310 deletions

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml; conda-forge is handled by its autotick bot.
# This workflow event-drives the two channels that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# Both wait for the freshly published sdist to appear on PyPI first, since the
# Release event fires in parallel with publish.yml's upload.
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to distribute (no leading v), e.g. 0.10.3. Blank = latest on PyPI."
required: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
url: ${{ steps.sdist.outputs.url }}
sha: ${{ steps.sdist.outputs.sha }}
steps:
- name: Resolve target version
id: v
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "release" ]; then
v="${GITHUB_REF_NAME#v}"
elif [ -n "${{ github.event.inputs.version }}" ]; then
v="${{ github.event.inputs.version }}"
else
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Target version: $v"
- name: Wait for the sdist on PyPI, capture url + sha256
id: sdist
run: |
set -euo pipefail
v="${{ steps.v.outputs.version }}"
for i in $(seq 1 30); do
if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
if [ -n "$url" ] && [ -n "$sha" ]; then
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Found sdist for $v"
exit 0
fi
fi
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
sleep 20
done
echo "::error::sdist for $v did not appear on PyPI in time"
exit 1
homebrew:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Checkout the tap
uses: actions/checkout@v4
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
+3 -5
View File
@@ -18,9 +18,6 @@ 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)
data/synthid_corpus/refs/
@@ -49,6 +46,7 @@ data/gemini_capture/captures/gemini_content_*.png
data/samsung_capture/seeds/
data/samsung_capture/captures/samsung_content_*
# GFPGAN downloads its RetinaFace/parsing weights to a CWD ./gfpgan/weights/
# working dir on first use (the restore extra). Runtime artifact, never committed.
# Leftover GFPGAN weights dir from the retired face-restore experiments
# (GFPGAN wrote RetinaFace/parsing weights to a CWD ./gfpgan/weights/ working
# dir on first use). Runtime artifact, never committed.
gfpgan/
+4 -3
View File
@@ -15,11 +15,12 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
## Test and lint
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building, then uploads via `uv publish` (PyPI trusted publishing over OIDC, no token — replaced the `pypa/gh-action-pypi-publish` action so the upload no longer depends on that action's bundled twine accepting the Metadata-Version; the `id-token: write` permission + `pypi` environment + workflow filename are unchanged, so PyPI's trusted-publisher entry still matches). **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry — find it with `grep -n 'name = "remove-ai-watermarks"' uv.lock`, the `version =` line right below it, ~line 2246), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (the multi-hundred-MB synthid_corpus images + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. **A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch. **Build backend is pinned `hatchling<1.31`** (`[build-system] requires`): hatchling **1.30.0** made **Metadata-Version 2.5** (PEP 794) the default, 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), when unpinned `requires = ["hatchling"]` pulled 1.30.0. **hatchling 1.30.1 reverted the default back to 2.4** ("kept at 2.4 until more tools support 2.5"), and 1.27-1.29 always emitted 2.4 — so `<1.31` keeps `uv build` on a 2.4-emitting hatchling (it resolves to the latest allowed, **1.30.1**, which uploads fine). (The earlier "1.28+ emits 2.5" note was imprecise: the 2.5 default landed only in 1.30.0, verified against hatch's changelog.) The publish workflow **now uses `uv publish`** (its uploader handles 2.5), so this pin is no longer load-bearing — it stays as belt-and-suspenders so the first uv-publish release ships 2.4 metadata (isolating the uploader swap from the metadata-version bump); drop it to `requires = ["hatchling"]` once that release confirms the path.
- `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`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx, and aiohttp 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8. (The basicsr Dependabot alert (GHSA-86w8-vhw6-q9qq, command injection, no patch, <= 1.4.2): accepted, not fixable -- basicsr is the optional `restore` extra pinned to 1.4.2 as the only buildable version, experimental and off by default.)
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building, then uploads via `uv publish` (PyPI trusted publishing over OIDC, no token — replaced the `pypa/gh-action-pypi-publish` action so the upload no longer depends on that action's bundled twine accepting the Metadata-Version; the `id-token: write` permission + `pypi` environment + workflow filename are unchanged, so PyPI's trusted-publisher entry still matches). **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry — find it with `grep -n 'name = "remove-ai-watermarks"' uv.lock`, the `version =` line right below it, ~line 2246), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **After the PyPI sdist is live, bump the Homebrew formula** in the separate public tap repo `wiltodelta/homebrew-tap` (`Formula/remove-ai-watermarks.rb`): update `url` to the new sdist URL (from `https://pypi.org/pypi/remove-ai-watermarks/<version>/json`, the `sdist` entry's `url`) and `sha256` to its hash, commit + push there — otherwise `brew install wiltodelta/tap/remove-ai-watermarks` keeps installing the old version. The formula is a core-only venv that pip-installs the sdist (no vendored resources, so pip pulls the binary numpy/opencv wheels per platform at install time); only those two lines change per release. **This is now AUTOMATED:** the main repo's `.github/workflows/distribute.yml` fires on the GitHub Release `published` event, waits for the sdist to appear on PyPI (poll loop, the Release event races publish.yml's upload), rewrites the formula's `url`+`sha256`, and pushes to the tap using the `HOMEBREW_TAP_TOKEN` repo secret (a fine-grained PAT with Contents:write on `homebrew-tap`). The SAME workflow also factory-rebuilds the HF Space (`HfApi.restart_space(..., factory_reboot=True)`, `HF_TOKEN` secret) so the Space reinstalls the new sdist (it pins `remove-ai-watermarks>=...` and only re-resolves on a rebuild). The manual Homebrew steps above are the fallback / what the workflow automates — a normal release needs no Homebrew or HF action. **Other distribution channels:** (1) **conda-forge** — recipe source of truth committed at `packaging/conda/recipe.yaml` (v1 `recipe.yaml`, noarch core-only: pillow/piexif/numpy/py-opencv/click/python-dotenv); the initial submission is `conda-forge/staged-recipes` PR #33674 (went green only after **`pip_check: false`** in the python test — rattler-build's `pip check` defaults to ON and fails on the ancient conda-forge `piexif py_2` build's stale metadata with "piexif 1.1.3 is not supported on this platform", though the package installs/imports/works; keep it disabled). Once that merges and the `remove-ai-watermarks-feedstock` exists, the `regro-cf-autotick-bot` auto-opens a version-bump PR on the feedstock when each new PyPI sdist is detected — just review + merge it (hand-edit only if run-deps changed; keep `packaging/conda/recipe.yaml` in sync as the reference copy). (2) **ComfyUI Registry** — the node package is a SEPARATE repo `wiltodelta/ComfyUI-remove-ai-watermarks` with its OWN `pyproject.toml` `version` (independent of the library version). Publish a new node version by bumping that `version` in the node repo's `pyproject.toml` and pushing to `main` — the node repo's `.github/workflows/publish.yml` (`Comfy-Org/publish-node-action@main`, triggered on a push that touches `pyproject.toml`, secret `COMFY_REGISTRY_TOKEN`) **auto-publishes** it; `comfy node publish --token <registry-PAT>` is the manual/local fallback. It is NOT auto-published on a library release (the node has its own version), so only bump it when the node code or its `remove-ai-watermarks>=` dependency floor changes. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (the multi-hundred-MB synthid_corpus images + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shiLine truncated
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright (scoped `src/`, see the OOM note below), pytest -n auto. The helper tools live in the `dev` extra (`pytest-xdist`, plus `uv-outdated`/`uv-secure` marker-gated to py3.12+ so the py3.10 resolution stays solvable) — a bare env without `--extra dev` does not have them.
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `humanizer`, `invisible_engine`, `noai/watermark_remover`) carry a documented per-file `# pyright:` relax pragma that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx, and aiohttp 3.13.5 -> 3.14.0 via `uv lock --upgrade-package aiohttp`, fixing GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8. (The old basicsr Dependabot alert (GHSA-86w8-vhw6-q9qq) is resolved by removal: the experimental `restore` extra was retired and basicsr is no longer anywhere in the dependency tree.)
- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `uv run pytest` directly.
- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
- **Stale `trustmark` remnant in site-packages after an extras change:** the `trustmark` package downloads model weights INTO its own package dir, so when a narrower `uv sync` prunes the package, a `trustmark/models/` directory survives as an empty namespace package. Symptom: pyright `"TrustMark" is unknown import symbol` on `trustmark_detector.py` and `find_spec("trustmark")` returning a loader-less spec (so `is_available()` lies True). Fix: `rm -rf .venv/lib/python3.12/site-packages/trustmark` (regenerable weights cache).
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand.
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `<TC260:AIGC>` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC); synthetic byte blobs cover the JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent).
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles. **`cleaned/` examples must be produced by a CURRENT shipped removal method** -- the default SDXL img2img pass (optionally `--max-resolution`). Do NOT archive cleaned outputs from methods that are no longer in the pipeline (ctrlregen, the old text/face-protection, IP-Adapter FaceID, CodeFormer) or from the experimental opt-in paths (controlnet, face restore) as corpus examples; a cleaned reference should represent the canonical removal, and a removed method's output is not a reproducible example. Keep those experiment outputs in a local working dir, never in the committed corpus.
+58 -2
View File
@@ -8,6 +8,10 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
>
> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee.
[![PyPI](https://img.shields.io/pypi/v/remove-ai-watermarks?logo=pypi&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/)
[![Python](https://img.shields.io/pypi/pyversions/remove-ai-watermarks?logo=python&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/)
[![Downloads](https://static.pepy.tech/badge/remove-ai-watermarks/month)](https://pepy.tech/project/remove-ai-watermarks)
[![License](https://img.shields.io/pypi/l/remove-ai-watermarks?color=blue)](LICENSE)
[![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta)
@@ -154,6 +158,34 @@ The cleaner parses each layer, removes AI-related fields, and preserves standard
## Installation
### Homebrew (macOS / Linux)
```bash
brew install wiltodelta/tap/remove-ai-watermarks
```
This installs the core command surface (`identify`, `metadata`, `visible`,
`erase`) as a self-contained CLI. The diffusion-based `invisible` / `all`
pipeline needs heavy ML dependencies (torch, diffusers, multi-GB) and is kept
out of the Homebrew build; add it with the `gpu` extra via pip if you need it:
```bash
pip install "remove-ai-watermarks[gpu]"
```
### conda
A conda-forge recipe is under review
([staged-recipes PR](https://github.com/conda-forge/staged-recipes/pull/33674)).
Once it merges, the core package installs with:
```bash
conda install -c conda-forge remove-ai-watermarks
```
Like the Homebrew build, this is the core command surface; add the diffusion
`invisible` / `all` pipeline with the pip `gpu` extra.
### Recommended
Install as an isolated CLI tool — no need to manage virtual environments:
@@ -252,6 +284,23 @@ pytest
./maintain.sh
```
## ComfyUI
Custom nodes are available so the watermark tools run inside a ComfyUI graph:
[ComfyUI-remove-ai-watermarks](https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks)
([registry](https://registry.comfy.org/nodes/remove-ai-watermarks)).
Install via ComfyUI Manager (search "Remove AI Watermarks") or manually:
```bash
cd ComfyUI/custom_nodes
git clone https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks
pip install -r ComfyUI-remove-ai-watermarks/requirements.txt
```
Nodes: Remove Visible Watermark, Detect Visible Watermark, Erase Region (by
mask), and Remove Invisible Watermark / SynthID (needs the `gpu` extra).
## Usage
### CLI
@@ -416,6 +465,13 @@ Won't fix:
- **Nightshade / Glaze / PhotoGuard removal**. These are defensive perturbations used by artists to protect their work from being scraped into AI training sets. Removing them attacks artists, not AI provenance. Out of scope.
## Limitations
- **Visible-mark and metadata removal is lossless.** Reverse-alpha recovers the original pixels under the mark; metadata stripping never touches image data.
- **The invisible (SynthID) path is lossy and not guaranteed.** It runs a low-strength SDXL img2img regeneration, so it softens fine detail and is content-dependent. There is no public SynthID decoder, so the tool cannot verify removal locally; confirm with the Gemini app's "Verify with SynthID" oracle and raise `--strength` if it still detects. A vendor can change the scheme at any time, so treat this as an arms race, not a permanent fix.
- **Large images are not tiled yet.** The SynthID path runs at the diffusion model's native resolution and caps or upscales larger inputs instead of tiling them, so very large images lose detail. (Nano Banana 2 is natively 1024px; GPT Image 2 supports 4K experimentally.) Tiled diffusion is the planned fix.
- **Out of scope:** defeating trained AI-vs-real classifiers like Hive (see [Threat model](#threat-model)), visible-logo removal from video, and any guarantee that a stripped copy is untraceable server-side.
## Legal
Watermarking and provenance for AI-generated content is now regulated in several jurisdictions. The table below summarises the May 2026 status. None of this is legal advice.
@@ -433,14 +489,14 @@ Watermarking and provenance for AI-generated content is now regulated in several
## Threat model
This tool defends already-distributed AI imagery against automatic detection systems (social-platform "Made with AI" labels, third-party classifiers, content-policy filters). It does **not** retroactively anonymise generation.
This tool removes specific, known signals: the embedded SynthID pixel watermark, the visible vendor marks, and the C2PA / EXIF / IPTC provenance metadata that platforms read to apply automatic "Made with AI" labels. It is **not** a general detector-evasion tool. It does **not** defeat trained statistical AI-vs-real classifiers (for example Hive Moderation), and a light diffusion pass will not reliably fool those, so a clean classifier hit after removal is expected, not a bug. It also does **not** retroactively anonymise generation. And watermarking is a weak trust signal in the first place: a marker that is almost always present yet trivially removable can make a cleaned forgery look more trustworthy, not less, which is why durable provenance more likely comes from signing genuine content than from watermarking synthetic content.
In particular, **SynthID** (Google DeepMind) is embedded across Google's generative media stack — Imagen (images), Veo (video), Lyria (audio) — and Gemini app image outputs (Nano Banana / Gemini 3 Pro, which we verified positive via the Gemini app's SynthID oracle); Google reported over 10 billion items watermarked by December 2025. It carries a **multi-bit payload** — the research paper's SynthID-O variant encodes 136-bit payloads in 512x512 images ([arxiv 2510.09263](https://arxiv.org/abs/2510.09263)). The payload is believed to encode a user / session identifier. If the original watermarked file ever passed through a system controlled by the prompt originator (a saved Gemini account history, a screenshot uploaded to a Google product, a backup), Google retains the ability to link that original to the generating account. Stripping the watermark from a copy you possess does not erase Google's server-side record.
Use cases where the threat model fits:
- You generated the image yourself, want to publish it as your own work, and accept the consequences if Google ever publishes their detector logs.
- You are running a security / robustness evaluation.
- You are preserving art or historical record against false-positive "AI-generated" labels.
- A real photo of yours was lightly AI-edited (a retouch in Gemini or ChatGPT, say) and now carries a SynthID or C2PA label that overstates how AI-generated it is, and you want to clear that label from your own copy.
Use cases where the threat model **does not** fit:
- Generating an image, expecting that removing the watermark anonymises you to Google. It doesn't.
+3 -1
View File
@@ -7,5 +7,7 @@ uv run uv-outdated
uv run uv-secure --ignore-unfixed
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
+63
View File
@@ -0,0 +1,63 @@
schema_version: 1
context:
version: "0.10.1"
python_min: "3.10"
package:
name: remove-ai-watermarks
version: ${{ version }}
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: ec92b450363d947cd897f0b75c18e75c2988ff79314ea247333ba892470b5a77
build:
noarch: python
number: 0
script: python -m pip install . -vv --no-deps --no-build-isolation
requirements:
host:
- python ${{ python_min }}.*
- pip
- hatchling
run:
- python >=${{ python_min }}
- pillow >=10.0.0
- piexif >=1.1.3
- numpy >=1.24.0
- py-opencv >=4.8.0
- click >=8.0.0
- python-dotenv >=1.0.0
tests:
- python:
imports:
- remove_ai_watermarks
- remove_ai_watermarks.identify
- remove_ai_watermarks.metadata
python_version: ${{ python_min }}.*
# pip_check defaults to true; disable it explicitly. `pip check` fails on
# the conda-forge piexif `py_2` build, whose stale 2019 metadata pip reads
# as "piexif 1.1.3 is not supported on this platform" -- even though the
# package installs, imports, and works. The imports test verifies loading.
pip_check: false
about:
homepage: https://github.com/wiltodelta/remove-ai-watermarks
summary: Remove visible and invisible AI watermarks from images
description: |
Detect and remove visible AI watermarks (Gemini / Nano Banana sparkle,
ByteDance Doubao and Jimeng, Samsung Galaxy AI) and strip AI-provenance
metadata (C2PA, EXIF, IPTC, PNG text chunks) from images. The core package
covers the identify / metadata / visible / erase command surface; optional
pip extras add SynthID diffusion removal and additional invisible-watermark
detectors.
license: Apache-2.0
license_file: LICENSE
repository: https://github.com/wiltodelta/remove-ai-watermarks
extra:
recipe-maintainers:
- wiltodelta
+37 -21
View File
@@ -1,19 +1,48 @@
[project]
name = "remove-ai-watermarks"
version = "0.10.0"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
version = "0.10.3"
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "Apache-2.0"}
keywords = [
"ai-watermark",
"ai-watermark-remover",
"watermark-remover",
"watermark-removal",
"remove-watermark",
"synthid",
"c2pa",
"content-credentials",
"nano-banana",
"gemini",
"gemini-watermark",
"ai-metadata",
"metadata-removal",
"provenance",
"exif",
"stable-diffusion",
"comfyui",
"ai-detection",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Multimedia :: Graphics",
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Scientific/Engineering :: Image Processing",
"Topic :: Security",
"Topic :: Utilities",
]
dependencies = [
"pillow>=10.0.0",
@@ -91,19 +120,17 @@ esrgan = [
dev = [
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
"pytest-xdist>=3.5.0",
"ruff>=0.4.0",
"pyright>=1.1.0",
"invisible-watermark>=0.2.0",
# maintain.sh helpers; they only support newer Pythons, so gate them by
# marker to keep the py3.10 resolution (and CI matrix) solvable.
"uv-outdated>=0.1.0; python_version >= '3.12'",
"uv-secure>=0.12.0; python_version >= '3.12'",
]
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,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
@@ -120,18 +147,7 @@ remove-ai-watermarks = "remove_ai_watermarks.cli:main"
Repository = "https://github.com/wiltodelta/remove-ai-watermarks"
[build-system]
# Pin hatchling < 1.31. hatchling 1.30.0 made Metadata-Version 2.5 (PEP 794) the
# default, which the twine bundled in pypa/gh-action-pypi-publish@release/v1 rejects
# ("'2.5' is not a valid Metadata-Version"), failing the v0.8.3 PyPI upload
# (2026-06-01) when unpinned requires = ["hatchling"] pulled 1.30.0. hatchling 1.30.1
# reverted the default to 2.4 ("kept at 2.4 until more tools support 2.5"), and
# 1.27-1.29 were always 2.4 -- so < 1.31 keeps `uv build` on a 2.4-emitting hatchling
# (it resolves to the latest allowed, 1.30.1). The publish workflow now uses
# `uv publish`, whose uploader accepts 2.5, so this pin is belt-and-suspenders, not
# load-bearing: keeping it makes the first uv-publish release ship 2.4 metadata
# (isolating the uploader swap from the metadata-version bump). Drop to
# `requires = ["hatchling"]` once that release confirms the path.
requires = ["hatchling<1.31"]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
+1 -1
View File
@@ -11,4 +11,4 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.10.0"
__version__ = "0.10.3"
+1 -2
View File
@@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any, Literal
import click
from remove_ai_watermarks import __version__, watermark_registry
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
from remove_ai_watermarks.noai.watermark_profiles import (
resolve_strength,
strength_default_help,
@@ -106,8 +107,6 @@ Progress = _Progress
SpinnerColumn = BarColumn = TextColumn = TimeElapsedColumn = _column
console = _Console()
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
def _setup_logging(verbose: bool) -> None:
level = logging.DEBUG if verbose else logging.WARNING
+3 -3
View File
@@ -2,8 +2,8 @@
``apply_analog_humanizer`` injects film grain and chromatic aberration to defeat
digital AI-perfection classifiers (ported from NeuralBleach); ``unsharp_mask``
counters the soft, over-smoothed look that diffusion + face-restoration leave
behind (itself a common "this is AI" tell).
counters the soft, over-smoothed look that the diffusion pass leaves behind
(itself a common "this is AI" tell).
"""
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
@@ -63,7 +63,7 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
def unsharp_mask(image: NDArray, amount: float = 0.5, sigma: float = 1.0) -> NDArray:
"""Sharpen via unsharp masking: ``out = image + amount * (image - blur(image))``.
Counters the soft, over-smoothed look of the diffusion + GFPGAN passes, which
Counters the soft, over-smoothed look of the diffusion pass, which
reads as an AI tell. ``amount`` 0 = no-op (returns an unchanged copy); ~0.5-0.8
is a safe range -- higher risks bright edge halos that are their own artifact.
``sigma`` is the Gaussian radius of the unsharp kernel.
+6 -4
View File
@@ -19,6 +19,8 @@ import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any
from .noai.watermark_profiles import DEFAULT_MODEL_ID as DEFAULT_SDXL_MODEL_ID
if TYPE_CHECKING:
from collections.abc import Callable
@@ -37,9 +39,9 @@ logger = logging.getLogger(__name__)
def is_available() -> bool:
"""Check if invisible watermark removal dependencies are installed."""
import importlib.util
from .optional_deps import module_available
return importlib.util.find_spec("diffusers") is not None and importlib.util.find_spec("torch") is not None
return module_available("diffusers", "torch")
def _target_size(width: int, height: int, max_resolution: int, min_resolution: int = 0) -> tuple[int, int] | None:
@@ -83,7 +85,7 @@ class InvisibleEngine:
# SDXL base is the default since May 2026; the vendor-adaptive strength
# removes the current SynthID (see watermark_profiles + docs/synthid.md).
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
DEFAULT_MODEL_ID = DEFAULT_SDXL_MODEL_ID
def __init__(
self,
@@ -176,7 +178,7 @@ class InvisibleEngine:
output_path: Output path (None = overwrite source).
strength: Denoising strength (0.0-1.0). None -> the vendor-adaptive
default.
steps: Number of denoising steps.
num_inference_steps: Number of denoising steps.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
humanize: Intensity of Analog Humanizer film grain (0 = off).
@@ -50,9 +50,9 @@ _MATCH_SD1_FRAC = 0.92 # fraction of the 136 string bits that must match
def is_available() -> bool:
"""True if the optional imwatermark decoder is installed."""
import importlib.util
from .optional_deps import module_available
return importlib.util.find_spec("imwatermark") is not None
return module_available("imwatermark")
def _bits_match(value: int, ref: int, width: int = 48) -> int:
+7 -2
View File
@@ -20,6 +20,11 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Smaller scan_head window for the cheap marker checks (has_ai_metadata,
# samsung_genai); the full-detail scans use scan_head's 1 MB default. Sharing
# one constant also keeps both call sites on the same memoized cache entry.
_QUICK_SCAN_BYTES = 512 * 1024
# ── Known AI metadata keys ──────────────────────────────────────────
AI_METADATA_KEYS: frozenset[str] = frozenset(
@@ -306,7 +311,7 @@ def has_ai_metadata(image_path: Path) -> bool:
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
data = scan_head(image_path, 512 * 1024)
data = scan_head(image_path, _QUICK_SCAN_BYTES)
if c2pa_marker_in(data):
return True
if any(marker in data for marker in AIGC_MARKERS):
@@ -453,7 +458,7 @@ def samsung_genai(image_path: Path) -> int | None:
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
``genAIType`` token cannot false-positive.
"""
head = scan_head(image_path, 512 * 1024)
head = scan_head(image_path, _QUICK_SCAN_BYTES)
if _SAMSUNG_EDITOR_MARKER not in head:
return None
m = _SAMSUNG_GENAI_RE.search(head)
+28
View File
@@ -0,0 +1,28 @@
"""Shared availability guard for optional dependencies.
A bare ``importlib.util.find_spec(name) is not None`` check lies when only a
leftover data directory exists in site-packages: e.g. ``trustmark`` downloads
model weights into its own package dir, so after the package is uninstalled
(``uv sync`` pruning an extra) a ``trustmark/models/`` remnant survives and
``find_spec`` resolves it to a namespace-package spec (``loader is None``)
while the actual import fails. Every ``is_available()`` guard routes through
``module_available`` so a pure namespace package counts as absent.
"""
from __future__ import annotations
import importlib.util
def module_available(*names: str) -> bool:
"""True when every named module resolves to a real, importable package.
A spec with ``loader is None`` is a pure namespace package -- for our
optional deps that means a stale directory remnant, not an installed
package -- so it is treated as not available.
"""
for name in names:
spec = importlib.util.find_spec(name)
if spec is None or spec.loader is None:
return False
return True
+2 -2
View File
@@ -82,9 +82,9 @@ def erase_cv2(
def lama_available() -> bool:
"""True when the optional LaMa-ONNX backend can run (onnxruntime installed)."""
import importlib.util
from .optional_deps import module_available
return importlib.util.find_spec("onnxruntime") is not None
return module_available("onnxruntime")
def _get_lama_session() -> object:
@@ -40,9 +40,9 @@ _tm_lock = threading.Lock()
def is_available() -> bool:
"""True if the optional ``trustmark`` package is installed."""
import importlib.util
from .optional_deps import module_available
return importlib.util.find_spec("trustmark") is not None
return module_available("trustmark")
def _decoder() -> Any:
+5 -4
View File
@@ -8,8 +8,8 @@ The DEFAULT upscaler stays Lanczos (cv2, no deps); this is opt-in via the ``esrg
extra and feeds the ``--upscaler esrgan`` path. ``spandrel`` is a pure model-loader
(MIT) with NO basicsr dependency -- it pulls only torch/torchvision/safetensors/numpy/
einops -- so it sidesteps the basicsr / ``torchvision.transforms.functional_tensor``
breakage that the ``restore`` (GFPGAN) extra has to shim. Real-ESRGAN weights are
BSD-3-Clause.
breakage that the retired ``restore`` (GFPGAN) extra had to shim. Real-ESRGAN weights
are BSD-3-Clause.
CPU works but is slow on large inputs, so this is meant for the pre-diffusion upscale of
SMALL inputs (and the GPU worker). On a memory-constrained host it is a no-op (the extra
@@ -21,7 +21,6 @@ is absent), and the caller falls back to Lanczos.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false
from __future__ import annotations
import importlib.util
import logging
import threading
from pathlib import Path
@@ -45,7 +44,9 @@ _lock = threading.Lock()
def is_available() -> bool:
"""True if the ``esrgan`` extra (spandrel + torch) is importable."""
return importlib.util.find_spec("spandrel") is not None and importlib.util.find_spec("torch") is not None
from .optional_deps import module_available
return module_available("spandrel", "torch")
def _model_cache_path() -> Path:
+58
View File
@@ -0,0 +1,58 @@
"""Tests for the shared optional-dependency availability guard."""
from __future__ import annotations
import importlib.machinery
import importlib.util
from remove_ai_watermarks import optional_deps
def _fake_find_spec(specs: dict[str, importlib.machinery.ModuleSpec | None]):
def find_spec(name: str) -> importlib.machinery.ModuleSpec | None:
return specs[name]
return find_spec
def _real_spec(name: str) -> importlib.machinery.ModuleSpec:
spec = importlib.util.find_spec(name)
assert spec is not None
assert spec.loader is not None
return spec
class TestModuleAvailable:
def test_installed_module_is_available(self):
assert optional_deps.module_available("json") is True
def test_missing_module_is_not_available(self, monkeypatch):
monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec({"ghost": None}))
assert optional_deps.module_available("ghost") is False
def test_namespace_package_remnant_is_not_available(self, monkeypatch):
# A leftover data dir in site-packages (e.g. trustmark/models/ surviving
# an uninstall) resolves to a namespace-package spec with loader=None;
# the guard must not report it as installed.
ns_spec = importlib.machinery.ModuleSpec("trustmark", loader=None, is_package=True)
assert ns_spec.loader is None
monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec({"trustmark": ns_spec}))
assert optional_deps.module_available("trustmark") is False
def test_any_namespace_member_fails_the_conjunction(self, monkeypatch):
ns_spec = importlib.machinery.ModuleSpec("spandrel", loader=None, is_package=True)
specs = {"spandrel": ns_spec, "torch": _real_spec("json")}
monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec(specs))
assert optional_deps.module_available("torch", "spandrel") is False
def test_all_real_members_are_available(self):
assert optional_deps.module_available("json", "logging") is True
class TestGuardsUseSharedHelper:
def test_trustmark_is_available_rejects_namespace_remnant(self, monkeypatch):
from remove_ai_watermarks import trustmark_detector
ns_spec = importlib.machinery.ModuleSpec("trustmark", loader=None, is_package=True)
monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec({"trustmark": ns_spec}))
assert trustmark_detector.is_available() is False
Generated
+655 -256
View File
File diff suppressed because it is too large. Load diff