
A privacy-first app that strips AI watermarks from content you own.
```
_ _ _ ____ ___ ____ ____ _ _ ____ ____ _ _ ____ ____ ____ _ _ ____ _ _ ____ ____
| | | |__| | |___ |__/ |\/| |__| |__/ |_/ [__ __ |__/ |___ |\/| | | | | |___ |__/
|_|_| | | | |___ | \ | | | | | \ | \_ ___] | \ |___ | | |__| \/ |___ | \
```
# watermarks-remover
<!-- logo: figlet -d .figlet -f cybermedium -w 120 "watermarks-remover" -->
[](https://github.com/guillaumemeyer/watermarks-remover/actions/workflows/ci.yml)
[](https://github.com/guillaumemeyer/watermarks-remover/releases)
[](https://github.com/guillaumemeyer/watermarks-remover/stargazers)
[](https://github.com/guillaumemeyer/watermarks-remover/forks)
Agent skill + stdlib Python service to strip **multi-vendor AI provenance marks** from text and files — for privacy and hygiene on content **you own**. The skill is a thin client: it drives the machinery over HTTP, so the agent host needs no Python.
| Layer | Target | How |
| --- | --- | --- |
| **A** | Invisible Unicode, exotic spaces, bidi, tag chars | Deterministic Python scripts |
| **B** | Statistical (token-sampling) text watermarks | Agent rewrite + optional `rewrite_text.py` hook |
| **Files** | C2PA / EXIF / XMP / doc props | PNG, JPEG, WebP, AVIF, HEIC, BMP, GIF, TIFF, SVG, PDF, DOCX, XLSX, PPTX, EPUB, ODT, HTML, Markdown, MP4/MOV/M4A/M4V, WAV, MP3, FLAC |
Vendors / ecosystems (class-level): **Claude**, **Gemini / SynthID-Text**, **OpenAI** provenance surfaces, **open-LLM** Kirchenbauer-style (green-list) and keyed-Gumbel / EXP (Aaronson) marks.
**Latest release:** [v0.7.0](https://github.com/guillaumemeyer/watermarks-remover/releases/tag/v0.7.0)
Skill path: [`skills/remove-ai-marks/`](https://github.com/guillaumemeyer/watermarks-remover/blob/main/skills/remove-ai-marks)
Service path: [`service/`](https://github.com/guillaumemeyer/watermarks-remover/blob/main/service)
(migration: formerly `remove-claude-marks`; slash alias `/remove-claude-marks` still documented)
## Install (agent skill)
The skill ships **no code** — it calls the service over HTTP. Install the skill (markdown only) and start the service, then set `WATERMARKS_SERVICE_URL` if it is not `http://127.0.0.1:8765`.
In Claude Code, the fastest route is the bundled
[plugin marketplace](#claude-code-plugin-marketplace) — no clone, and it updates
in place. Everywhere else, one installer covers every supported host
(Python 3.10+ stdlib, no dependencies):
```bash
python3 install_skill.py --skill remove-ai-marks --target claude-code
```
| Host | Target | Lands in |
| --- | --- | --- |
| Claude Code (personal) | `--target claude-code` | `~/.claude/skills/<skill>` (honors `CLAUDE_CONFIG_DIR`) |
| Claude Code (project) | `--target claude-project --project-dir PATH` | `PATH/.claude/skills/<skill>` |
| Codex (personal) | `--target codex` | `~/.agents/skills/<skill>` |
| Codex (project) | `--target codex-project --project-dir PATH` | `PATH/.agents/skills/<skill>` |
| Cowork, claude.ai, cloud sessions, routines | `--target cowork` | `dist/<skill>.zip` to upload under **Customize → Skills** |
| Cursor | `--target cursor` (default) | `~/.cursor/skills/<skill>` |
Shipped skills: `remove-ai-marks` (full, service-backed) and
`clean-user-facing-text` (text only, self-contained). `--list` prints them.
Existing installations are preserved unless you pass `--force`; replacement is
staged first and the previous install is kept as a uniquely named backup.
`--link` symlinks this checkout instead of copying, so edits are picked up
live. On Windows, use `py install_skill.py ...`; the `install-skill.sh` wrapper
is provided for macOS/Linux shells.
Before writing anything, the installer validates the skill against the
[Agent Skills](https://agentskills.io) packaging rules that claude.ai uploads
and the Skills API enforce: spec-only frontmatter (`name`, `description`,
`license`, `compatibility`, `metadata`, `allowed-tools`), a lowercase hyphenated
`name` of at most 64 characters matching the directory, a non-empty
`description` of at most 1024 characters. The Cowork bundle additionally has
to fit the 30 MB upload limit, which the packager enforces.
### Codex terminal and desktop
Install either shipped skill for your user, or install one into a particular
project:
```bash
python3 install_skill.py --skill remove-ai-marks --target codex
python3 install_skill.py --skill clean-user-facing-text --target codex
python3 install_skill.py --skill remove-ai-marks --target codex-project --project-dir /path/to/project
```
On Windows, use `py` in place of `python3`. Open the project in Codex. In the
terminal, use `/skills` or type `$` to select a skill; in the desktop app,
select or mention the skill in your prompt. Restart Codex if the new skill does
not appear. The project install is available when Codex runs in that project.
For `remove-ai-marks`, start the existing HTTP service from this repository
with `make serve` before using the skill (or on Windows without `make`, run
`py service/scripts/server.py --host 127.0.0.1 --port 8765`). It uses
`WATERMARKS_SERVICE_URL` (default `http://127.0.0.1:8765`), checks `/health`,
and calls the existing `/inspect` and `/clean` endpoints. If the service
requires an API key, set `WATERMARKS_SERVER_API_KEY` for the client.
`clean-user-facing-text` is
self-contained and does not need the service.
### Automatic cleaning via hook (deterministic)
A skill is an instruction: the model decides whether to invoke it, and the
model is the thing producing the marks. A **hook** is executed by the harness
on every matching tool call, cooperation not required. That makes the hook the
deterministic half of this workflow.
The plugin registers a `PostToolUse` hook on `Write|Edit|MultiEdit|NotebookEdit`
that runs [`service/scripts/hook_written_file.py`](https://github.com/guillaumemeyer/watermarks-remover/blob/main/service/scripts/hook_written_file.py)
against the file the agent just wrote. Two modes, matching the pre-commit
convention of check-by-default:
| Mode | Behaviour |
| --- | --- |
| `check` (default) | Reports provenance marks, leaves the file alone. Findings go to the model (exit 2), so it can offer to clean them. |
| `clean` | Strips the marks in place, then tells the model the file on disk changed. |
Set the mode from the plugin's settings (**Hook mode** in `/plugin manage`,
read by the hook as `CLAUDE_PLUGIN_OPTION_HOOK_MODE`), or with
`WATERMARKS_HOOK_MODE=clean` in the environment. The hook command deliberately
does **not** interpolate `${user_config.hook_mode}`: Claude Code refuses to run
a hook that references an option the user has never opened `/plugin manage` to
set — a declared `default` does not satisfy it — so interpolating it would mean
the hook silently never runs on a fresh install. Detection reuses `audit_lib`'s
`scan_file` / `is_actionable`, so the hook, the pre-commit gate, and the CI
SARIF export agree on what counts as actionable; cleaning shells out to
`clean_file.py`, so no cleaning logic is duplicated. `clean` mode writes to a
sibling temp file and swaps only on a real difference, so files that were
already clean keep their mtime and don't retrigger file watchers.
Without the plugin, wire it in `~/.claude/settings.json` (or a project
`.claude/settings.json`) yourself:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "python3",
"args": ["/path/to/watermarks-remover/service/scripts/hook_written_file.py",
"--mode", "check"],
"timeout": 30
}
]
}
]
}
}
```
On Windows, replace `python3` with `py`.
**What a hook cannot do.** No hook can rewrite the assistant's chat message
before you read it. Claude Code's `Stop` hook receives `last_assistant_message`
read-only, and there is no pre-send filter for final responses — the same limit
this project already documents for Cursor rules. So the deterministic guarantee
covers **files the agent writes**, plus the
[pre-commit gate](#pre-commit-hook) for anything on its way into git. Text that
only ever exists in the chat transcript still depends on the skill workflow,
which is model-instruction-based and therefore best-effort.
### Claude Code plugin (marketplace)
The repository is also a Claude Code **plugin** and a single-plugin
**marketplace** (`.claude-plugin/`), so both skills install and update in two
commands, no clone or script required:
```
/plugin marketplace add guillaumemeyer/watermarks-remover
/plugin install watermarks-remover@watermarks-remover
```
The skills then load namespaced: `/watermarks-remover:remove-ai-marks` and
`/watermarks-remover:clean-user-facing-text` (the bare `/remove-ai-marks` also
works when nothing else claims the name). `/plugin marketplace update
watermarks-remover` pulls later versions. The same works from the CLI with
`claude plugin marketplace add …` / `claude plugin install …`, and from a local
checkout by passing a path instead of `owner/repo`.
Maintainers: `make plugin-validate` runs `claude plugin validate . --strict`
against both manifests; `tests/test_plugin_manifest.py` covers the same files
without needing the CLI.
### Claude Code
```bash
# Personal — available in all your projects
python3 install_skill.py --skill remove-ai-marks --target claude-code
# or: make install-claude-code-skill
# Project — commit .claude/skills/ to share it with the repo
python3 install_skill.py --skill remove-ai-marks --target claude-project \
--project-dir /path/to/project
# or: make install-claude-project-skill PROJECT=/path/to/project
```
Claude Code picks up personal and project skills without a restart; `/skills`
lists what it loaded. Invoke with `/remove-ai-marks` or ask to “strip AI
watermarks / C2PA / Claude marks / SynthID-class text.” A project install is
also what [cloud sessions](https://code.claude.com/docs/en/cloud-environments)
read, since they clone the repository and load its `.claude/skills/`.
### Cowork (and claude.ai, cloud sessions, routines)
Cowork sessions do **not** read `~/.claude/skills` on your machine — they load
the skills enabled for your claude.ai account, synced when the session starts.
So install there by uploading a bundle:
```bash
python3 install_skill.py --skill remove-ai-marks --target cowork
# writes dist/remove-ai-marks.zip (make package-cowork-skill)
```
Then, in the Claude Desktop app, open **Customize → Skills → Add** and upload
the zip (the same skill settings on claude.ai work too). The bundle is
reproducible and contains a single top-level `remove-ai-marks/` directory with
`SKILL.md` at its root, which is the layout the upload expects.
Service reachability matters more here than in a local install: the skill is a
thin HTTP client, so the session must be able to reach `WATERMARKS_SERVICE_URL`.
Cowork sessions that run locally on your machine reach a local `make serve`;
cloud sessions and routines run remotely and need a service URL reachable from
there (and `WATERMARKS_SERVER_API_KEY` set on it). If you want a skill with no
service at all, upload `clean-user-facing-text` instead — it is text-only and
ships its own scripts:
```bash
python3 install_skill.py --skill clean-user-facing-text --target cowork
```
### Grok
```bash
# Grok Build / project-local
mkdir -p .grok/skills
ln -sfn "$(pwd)/skills/remove-ai-marks" .grok/skills/remove-ai-marks
# User-global Grok
mkdir -p ~/.grok/skills
ln -sfn "$(pwd)/skills/remove-ai-marks" ~/.grok/skills/remove-ai-marks
```
### Optional text-only skill
[`skills/clean-user-facing-text/`](https://github.com/guillaumemeyer/watermarks-remover/blob/main/skills/clean-user-facing-text) is a
self-contained skill for authorized manuscripts, documentation, and web
copy. It excludes image, C2PA, service, and external-model tooling, and runs
its own vendored Layer A scripts instead of calling the service.
```bash
python3 install_skill.py --skill clean-user-facing-text --target claude-code
python3 install_skill.py --skill clean-user-facing-text --target cursor
```
Skill invocation is model-selected. Projects that explicitly adopt this
workflow in Cursor can also copy the optional rule:
```bash
mkdir -p /path/to/project/.cursor/rules
cp integrations/cursor/clean-user-facing-text.mdc \
/path/to/project/.cursor/rules/clean-user-facing-text.mdc
```
For all projects, put the same instruction in Cursor **User Rules** instead.
Rules improve consistency but remain model instructions; Cursor does not expose
a deterministic pre-send filter for final chat responses.
### Start the service
The fastest path is a local HTTP server (Python 3.10+ stdlib only — no deps, no Docker):
```bash
make serve # http://127.0.0.1:8765
# or directly:
python3 service/scripts/server.py --host 127.0.0.1 --port 8765
```
### Windows (no Docker)
See [docs/windows-autostart.md](https://github.com/guillaumemeyer/watermarks-remover/blob/main/docs/windows-autostart.md) for auto-starting the service at Windows login without Docker.
For the whole infra (core + optional harness/heavy backends), see [Docker / compose](#docker--compose) below.
Optional system tools (auto-used when present — preinstalled in the core Docker image):
| Tool | Role |
| --- | --- |
| [`c2patool`](https://github.com/contentauth/c2pa-rs/tree/main/cli) | Inspect C2PA manifests |
| [`exiftool`](https://exiftool.org/) | Residual metadata strip (esp. **PDF**) |
| [`qpdf`](https://qpdf.sourceforge.io/) | Structural PDF rebuild — **required** for a real PDF strip (see below) |
Core scripts need **Python 3.10+** stdlib only. Layer B model calls are optional.
## Quick use (scripts)
```bash
SCRIPTS=service/scripts
# Unified inspect / clean
python3 "$SCRIPTS/inspect_file.py" draft.md
python3 "$SCRIPTS/clean_file.py" draft.md -o draft.cleaned.md
python3 "$SCRIPTS/clean_file.py" photo.png -o photo.cleaned.png
python3 "$SCRIPTS/clean_file.py" notes.docx -o notes.cleaned.docx
# Text Layer A
python3 "$SCRIPTS/inspect_text.py" draft.md
python3 "$SCRIPTS/clean_text.py" draft.md -o draft.cleaned.md --stats
# Layer B rewrite hook (default: print prompt only — no model required)
python3 "$SCRIPTS/rewrite_text.py" draft.md --backend print-prompt --tactic paraphrase
# Optional local Ollama (loopback only by default — remote endpoints require
# WATERMARKS_REWRITE_ALLOW_REMOTE=1 or --allow-remote):
# WATERMARKS_REWRITE_BACKEND=ollama WATERMARKS_REWRITE_MODEL=llama3.2 \
# python3 "$SCRIPTS/rewrite_text.py" draft.md -o draft.rewritten.md
# API keys are read from WATERMARKS_REWRITE_API_KEY only (never argv).
# Images
python3 "$SCRIPTS/inspect_image.py" shot.png
python3 "$SCRIPTS/clean_image.py" shot.png -o shot.cleaned.png
```
### Text tools refuse binary input
`inspect_text.py`, `clean_text.py` and `rewrite_text.py` operate on text. Pointed
at a `.docx`, `.pdf` or image they used to decode the compressed bytes and report
whatever codepoints fell out — noise that tracks the compression, not the
content — and `clean_text.py` then wrote those mangled bytes back, destroying the
file. They now refuse binary input and name the tool that handles it:
```bash
python3 "$SCRIPTS/inspect_text.py" report.docx
# refusing to treat report.docx as text: it looks like a ZIP container (DOCX, ODT, …).
# Use inspect_file.py / clean_file.py, which route by format,
# or pass --force-text to scan the raw bytes anyway.
```
Detection is by magic number plus a control-byte ratio, so text in encodings
other than UTF-8 keeps working. `--force-text` overrides it everywhere.
### Unrecognized formats are never auto-cleaned
`classify()` labels bytes that match no supported text, image or container
format as **`unknown`** — it no longer falls back to "text". In auto mode
`clean_file.py` refuses such files (exit 2, no output written) instead of
decoding them as UTF-8 and writing back mangled bytes; `--as text` or
`--force-text` are the explicit opt-ins. `inspect_file.py` reports the file
as `unknown` (exit 0), and the HTTP service answers `/inspect` with
`kind: "unknown"` but rejects `/clean` of unknown formats (400 — send a
filename with a known extension, e.g. `notes.txt`).
## HTTP service
The same machinery runs as a stdlib HTTP service (`service/scripts/server.py`) — the interface the skill uses and the way any web app can integrate without vendoring:
| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| GET | `/health` | — | `{"ok": true, "version": ...}` |
| GET | `/capabilities` | — | optional tools / backends usable (each tool is version-probed, not just found on `PATH`) |
| GET | `/openapi.json` | — | dynamically generated OpenAPI 3.0.3 spec |
| POST | `/inspect` | `{"file": "<base64>", "name": "notes.md"}` | `{"ok", "kind", "suspicious", "report"}` |
| POST | `/detect` | `{"file": "<base64>", "name": "notes.txt"}` | `{"ok", "kind", "detections": [...]}` |
| POST | `/clean` | `{"file": "<base64>", "name": "notes.md", "options": {...}}` | `{"ok", "kind", "cleaned": "<base64>", "report"}` |
| POST | `/watermark` | `{"text": "...", "keys": [118, 504, ...], "options": {...}}` or `{"file": "<base64>", ...}` | `{"ok", "kind", "watermarked_text", "report": {"scheme_used", ...}}` |
| POST | `/inspect/batch` | `{"files": [{"file": "<base64>", "name": "notes.md"}, ...]}` | `{"ok", "results": [{"name", "ok", "kind", "suspicious", "report"}, ...]}` |
| POST | `/detect/batch` | `{"files": [{"file": "<base64>", "name": "notes.txt"}, ...]}` | `{"ok", "results": [{"name", "ok", "kind", "detections", "report"}, ...]}` |
| POST | `/clean/batch` | `{"files": [{"file": "<base64>", "name": "notes.md", "options": {...}}, ...]}` | `{"ok", "results": [{"name", "ok", "kind", "cleaned", "report"}, ...]}` |
| POST | `/watermark/batch` | `{"files": [{"text": "...", "keys": [...]}, {"file": "<base64>"}, ...]}` | `{"ok", "results": [{"name", "ok", "kind", "watermarked_text", "report": {"scheme_used", ...}}, ...]}` |
Batch endpoints loop the same per-file pipeline as `/inspect`, `/detect`, `/clean`, and `/watermark`, capped at `WATERMARKS_MAX_BATCH_FILES` files per request (default 50). A malformed entry (bad base64, unknown option, unrecognized format) surfaces as that entry's `"ok": false` with an `"error"` string — it never aborts the rest of the batch.
```bash
WM="http://127.0.0.1:8765"
curl -s "$WM/health" # {"ok": true, "version": "..."}
curl -s "$WM/openapi.json" # machine-readable OpenAPI 3.0.3 contract
curl -s -X POST "$WM/clean" -H 'Content-Type: application/json' \
-d "{\"file\": \"$(base64 < notes.md | tr -d '\n')\", \"name\": \"notes.md\"}"
```
The service routes by filename extension then magic bytes, so text / image / container are auto-detected. Set `WATERMARKS_SERVER_API_KEY` to require `Authorization: Bearer <key>` on every request. Loopback-only bind by default (`--host` to override); intended for a trusted network.
### Watermark detection (`/detect` and `detect_before` / `detect_after`)
Detection is a separate step from cleaning — the service never calls vendor
APIs unless you ask it to:
- **`POST /detect`** runs the configured watermark detectors on a file.
Text → vendor detectors + stylometry; image → SynthID pixel score.
- **`/inspect`** accepts an opt-in `"detect": true` flag that appends
detector results to the text report (and can flip `suspicious`).
- **`/clean`** accepts `"detect_before"` / `"detect_after"` options to
score the input and the cleaned output, so you can measure what a clean
actually changed.
- **`/clean`** runs the Layer B text rewrite after Layer A **by default** (it
is a required step for text). A **`"strategy"`** option (an ordered
`tactic@intensity` list, e.g. `"[email protected],[email protected]"`) overrides the
default from the strategy config file (see below). When the rewrite
backend/model for a step isn't configured, `/clean` returns a 400.
Text detectors (see `/capabilities` → `text_detectors`):
Text detectors (see `/capabilities` → `text_detectors`):
| Detector | Activated by | Notes |
| --- | --- | --- |
| `markllm` | `MARKLLM_DIR` (host checkout) | Research harness (KGW / SynthID schemes), same-config-only — not a vendor oracle. |
| `gumbel` | `WATERMARKS_GUMBEL_KEY` | Model-free same-key replay of the keyed-Gumbel (Aaronson EXP) scheme (see `detect_gumbel.py`), stdlib-only — self-hosted engines such as arbi-serve; same-key-only, not a vendor oracle. |
| `claude-text` | — (placeholder) | Anthropic has announced a watermark detection API; this seam activates when it ships. |
Image scoring: when `WATERMARKS_SYNTHID_SCORER_URL` is set, the service
scores images through the `wr-synthid-score` sidecar (heavy profile); with a
local `REVERSE_SYNTHID_DIR` it uses the checkout directly. Detection is
fail-soft: unconfigured, timed-out, or errored detectors report
`{"available": false, "error": ...}` and never block cleaning.
### Watermark generation (`/watermark` and `/watermark/batch`)
Generates watermarked text for benchmark evaluation and round-trip testing.
When `WATERMARKS_SYNTHID_TEXT_URL` is set, the service delegates generation to the
`wr-synthid-text` sidecar (harness profile); with a local `MARKLLM_DIR` it uses the
checkout directly. Like detection, generation is fail-soft: an unconfigured generator
reports `{"ok": false, "error": ...}`.
## Docker / compose
Published images (GHCR):
| Image tag | Contents | Published? |
| --- | --- | --- |
| `ghcr.io/guillaumemeyer/watermarks-remover:<tag>` / `:latest` | Core HTTP service + all cleaners + exiftool / qpdf / c2patool | Yes |
| `…:markllm-<tag>` / `:markllm-latest` | MarkLLM text-watermark harness (Apache-2.0 upstream) | Yes |
| `…:markdiffusion-<tag>` / `:markdiffusion-latest` | MarkDiffusion image harness (Apache-2.0 upstream) | Yes |
| `watermarks-remover-ctrlregen:local` | CtrlRegen pixel removal — **never published** (`noai-watermark` ships no LICENSE) | Local build only |
| `watermarks-remover-synthid-scorer:local` | reverse-SynthID scorer — **never published** (non-commercial Research License) | Local build only (CLI scorer + optional `wr-synthid-score` HTTP sidecar under the `heavy` profile) |
Build and run the core service:
```bash
make docker-core-build
docker run --rm -p 127.0.0.1:8765:8765 --read-only --tmpfs /tmp watermarks-remover
# any CLI stays runnable by overriding the command:
docker run --rm -v "$(pwd):/data" watermarks-remover \
/app/scripts/clean_file.py /data/notes.md -o /data/notes.cleaned.md
```
Whole-infra bring-up:
```bash
docker compose up -d # core HTTP service only
docker compose --profile harness up -d # + markllm / markdiffusion / wr-synthid-text sidecar
docker compose --profile heavy up -d # + ctrlregen / synthid (local builds)
docker compose --profile harness --profile heavy up -d # all services
```
The compose stack maps the core service to `127.0.0.1:8765`. Persistent services run as background daemons (`wr-core` and the `wr-synthid-text` sidecar under the harness profile). The remaining harness/heavy services are one-shot CLIs — invoke with `docker compose run --rm <service> …` when you need verification or pixel work.
Validate the running stack (exit code only, no output on success):
```bash
make compose-check # or: ./compose-check.sh
```
Checks `wr-core` via `GET /health` and runs each harness/heavy service with `--help`, requiring exit `0`.
### Configuration (env vars for docker compose)
**Text cleaning requires Layer B configuration** — the Layer B rewrite is a
required step for `POST /clean` on text, so the core service needs the rewrite
backend set up, or text cleaning returns HTTP 400. Image/container metadata
cleaning works out of the box. For text you must configure the Layer B strategy
dependencies: `transformers` + `roberta-large` (for the default `mlm` step) and
the `WATERMARKS_REWRITE_*` LLM config (for the `paraphrase` step):
```bash
echo "Hello\u200bWorld\u00ad!" > /tmp/sample.txt
curl -s -X POST http://127.0.0.1:8765/clean -H 'Content-Type: application/json' \
-d "{\"file\": \"$(base64 < /tmp/sample.txt | tr -d '\n')\", \"name\": \"sample.txt\"}"
```
Languages whose typography relies on a non-breaking space (French `« … »`, the
space before `; : ! ?`) should pass `"options": {"normalize_spaces": false}`, the
HTTP equivalent of `clean_text.py --no-normalize-spaces`. Invisible carriers are
still removed; only the space rewrite is skipped.
Everything else is optional and lives in a `.env` file at the repo root. `docker compose` **auto-loads `.env`** and interpolates the `${VAR}` references in `compose.yaml` from it (shell exports win over `.env` if both are set).
```bash
cp .env.example .env # then edit
docker compose up -d # picks up .env automatically
```
`.env` is **gitignored** (deny-by-default) — never commit it. For host-side CLI runs (`rewrite_text.py`, the skill), export the same file into the environment:
```bash
set -a; . ./.env; set +a; python3 service/scripts/rewrite_text.py /tmp/x.txt -o /tmp/x.rewritten.txt
```
| Var | Reaches | Purpose |
| --- | --- | --- |
| `WATERMARKS_SERVER_API_KEY` | `wr-core` (via compose `environment`) | Require `Authorization: Bearer <key>` on the HTTP API |
| `WATERMARKS_GEMINI_*` | — | Removed Aug 2026: Google retired SynthID text watermarking on the API (see `vendor-notes.md`) |
| `WATERMARKS_SYNTHID_SCORER_URL` | `wr-core` | Point core at the `wr-synthid-score` sidecar for SynthID image scoring (e.g. `http://wr-synthid-score:8766` under the heavy profile) |
| `WATERMARKS_SYNTHID_SCORER_API_KEY` | `wr-core` + `wr-synthid-score` | Shared bearer key for the scorer sidecar (empty = no auth) |
| `WATERMARKS_SYNTHID_TEXT_URL` | `wr-core` | Point core at the `wr-synthid-text` sidecar for SynthID text watermarking (e.g. `http://wr-synthid-text:8767` under the harness profile) |
| `WATERMARKS_SYNTHID_TEXT_API_KEY` | `wr-core` + `wr-synthid-text` | Shared bearer key for the text watermark sidecar (empty = no auth) |
| `WATERMARKS_SYNTHID_TEXT_TIMEOUT` | `wr-core` | Seconds to wait for the `wr-synthid-text` sidecar (default 120) |
| `WATERMARKS_MARKLLM_SCHEME` | `text_detectors.py` (host) | MarkLLM scheme for `/detect`: `kgw` (default) / `synthid` |
| `HF_TOKEN` | harness/heavy services | Hugging Face token for gated models |
| `WATERMARKS_SERVICE_URL` | client only (skill / curl) | Where to reach the service; default `http://127.0.0.1:8765` |
| `WATERMARKS_REWRITE_BACKEND` | `rewrite_text.py` hook | `print-prompt` (default) / `ollama` / `openai-compatible` |
| `WATERMARKS_REWRITE_MODEL` | `rewrite_text.py` hook | Model name (e.g. `deepseek-v4-flash`) |
| `WATERMARKS_REWRITE_BASE_URL` | `rewrite_text.py` hook | API base (e.g. `https://api.deepseek.com`) |
| `WATERMARKS_REWRITE_API_KEY` | `rewrite_text.py` hook | API key — env only, never on argv |
| `WATERMARKS_REWRITE_ALLOW_REMOTE` | `rewrite_text.py` hook | `1` to allow non-loopback endpoints |
| `WATERMARKS_REWRITE_REASONING_EFFORT` | `rewrite_text.py` hook | `none` (default) / `low` / `medium` / `high` / `off` |
| `WATERMARKS_CLEAN_STRATEGY_FILE` | `server.py` `/clean` | Path to the Layer B strategy config JSON (default `config/clean_strategy.json`) |
| `WATERMARKS_GUMBEL_KEY` | `detect_gumbel.py` / `text_detectors.py` | Secret key for keyed-Gumbel (EXP) same-key replay (e.g. `0x…`); preferred over argv — never logged |
**Layer B is required for text cleaning.** `/clean` always applies the default
strategy (from `config/clean_strategy.json`, `{"default_strategy": "[email protected],[email protected]"}`) to a text file after Layer A, unless the request passes its own `"strategy"` option (an ordered `tactic@intensity` list). A strategy step is `tactic@intensity`; the `mlm` step needs `transformers` + `roberta-large`, and any LLM step (`paraphrase`, `humanize`, …) needs the `WATERMARKS_REWRITE_*` config. If the required backend/model isn't configured — or no strategy is available — `/clean` **rejects the request with a 400**. Precedence for the config path: `--strategy-config` CLI flag > `WATERMARKS_CLEAN_STRATEGY_FILE` env var > the default `config/clean_strategy.json`.
Images publish automatically on `v*` tags via [`.github/workflows/release-images.yml`](https://github.com/guillaumemeyer/watermarks-remover/blob/main/.github/workflows/release-images.yml).
## Optional SynthID pixel scoring
`inspect_image.py` and `clean_image.py` can report a pixel-domain SynthID
confidence score when an external checkout of
[`aloshdenny/reverse-SynthID`](https://github.com/aloshdenny/reverse-SynthID)
is available. The scorer is **not bundled**: it is loaded at runtime from your
checkout, and its code remains under the upstream project's non-commercial
Research License.
### Option 1: one-command bootstrap (no Docker)
```bash
SCRIPTS=service/scripts
# Clones upstream, creates a venv, and installs scorer-only dependencies.
"$SCRIPTS/setup_synthid.sh"
# Score an image (default checkout: ~/reverse-SynthID).
REVERSE_SYNTHID_DIR=~/reverse-SynthID \
~/reverse-SynthID/.venv/bin/python "$SCRIPTS/score_synthid.py" shot.png
# Or surface the score from inspect / clean (same venv Python).
REVERSE_SYNTHID_DIR=~/reverse-SynthID \
~/reverse-SynthID/.venv/bin/python "$SCRIPTS/inspect_image.py" shot.png
```
`setup_synthid.sh` accepts `--dir PATH`, `--ref REF`, and `--full` (install the
full upstream `requirements.txt`, which adds `torch`/`diffusers` for the
upstream VAE bypass this project does not use).
On Windows use `setup_synthid.ps1` (`-Dir`, `-Ref`, `-Full`), which creates the
venv at `.venv\Scripts\` — the layout `image_meta.py` already looks for on
`os.name == "nt"`.
### Option 2: local Docker build
```bash
make docker-synthid-build
# Run unprivileged and with a read-only rootfs; the scorer only needs to read
# /data and write to stdout/tmp.
docker run --rm \
--user "$(id -u):$(id -g)" \
--read-only --tmpfs /tmp \
-v "$(pwd):/data" \
watermarks-remover-synthid-scorer /data/shot.png
```
The image is built locally from the upstream source at build time. It is not
published, so it does not redistribute the upstream code.
### Option 3: HTTP scorer sidecar (docker compose)
Under the `heavy` profile the compose stack also runs the scorer as an HTTP
sidecar (`wr-synthid-score`) so the **published core service** can score
images before/after cleaning without bundling the non-commercial upstream
code. Point `wr-core` at it and share a bearer key (see `.env.example`):
```bash
# .env
WATERMARKS_SYNTHID_SCORER_URL=http://wr-synthid-score:8766
WATERMARKS_SYNTHID_SCORER_API_KEY=change-me
docker compose --profile heavy up -d
```
Then `POST /clean` with `{"options": {"detect_before": true,
"detect_after": true}}` returns `synthid_before` / `synthid_after` in the
report, and `POST /detect` on an image returns the SynthID score. Fail-soft:
if the sidecar is down or unconfigured, reports carry
`{"available": false, "error": ...}` and cleaning still succeeds.
V4 scoring uses `artifacts/spectral_codebook_v4.npz` from the upstream checkout
(`220 MB). This is **detection/scoring only** — it does not remove pixel
watermarks.
## Optional CtrlRegen pixel removal
For **pixel-domain** image watermarks (SynthID-class, StegaStamp, Tree-Ring,
StableSignature), an optional external backend runs the CtrlRegen pipeline
(ControlNet + DINOv2 IP-Adapter controllable regeneration). The backend is
[`mertizci/noai-watermark`](https://github.com/mertizci/noai-watermark), a
maintained reimplementation of the ICLR 2025
[CtrlRegen](https://arxiv.org/abs/2410.05470) method with automatic tiling.
The backend is **not bundled** and ships no LICENSE file, so it is treated as
all-rights-reserved: it is cloned at a pinned commit and loaded at runtime.
Its research-era dependency pins (`requirements-ctrlregen.txt` — e.g.
`transformers==4.37.2`, `diffusers==0.27.2`) carry published advisories and
are intentionally not current, so they are only ever installed inside the
dedicated venv this script creates and never into the main service image;
`setup_ctrlregen.sh` also re-verifies the pinned commit on existing
checkouts, not just fresh clones.
### Bootstrap
```bash
SCRIPTS=service/scripts
```
---
[Read more](https://github.com/guillaumemeyer/watermarks-remover)