_ _ _ ____ ___ ____ ____ _ _ ____ ____ _ _ ____ ____ ____ _ _ ____ _ _ ____ ____
| | | |__| | |___ |__/ |\/| |__| |__/ |_/ [__ __ |__/ |___ |\/| | | | | |___ |__/
|_|_| | | | |___ | \ | | | | | \ | \_ ___] | \ |___ | | |__| \/ |___ | \
Fork. Extended from
guillaumemeyer/watermarks-remover(MIT) with a self-hosted operating model: local-only Layer B, hardware capability tiers, a zero-GPU pixel tier, and local watermark verification. Upstream holds copyright on the original work — see Credits.
Agent skill + stdlib Python scripts to strip multi-vendor AI provenance marks from text and files — for privacy and hygiene on content you own.
| Layer | Target | How |
|---|---|---|
| A | Invisible Unicode, exotic spaces, bidi, tag chars | Deterministic Python scripts |
| B | Statistical (token-sampling) text watermarks | rewrite_text.py against a local open-weight model |
| Files | C2PA / EXIF / XMP / doc props | PNG, JPEG, SVG, PDF, DOCX, ODT, HTML, Markdown |
Vendors / ecosystems (class-level): Claude, Gemini / SynthID-Text, OpenAI provenance surfaces, open-LLM Kirchenbauer-style marks.
Runs entirely on self-hosted open weights — no metered inference API. Start
with doctor.py, which reports your capability tier and names the command to fix
anything missing. See Self-hosted, no API cost.
Latest release: v0.5.0 — self-hosted operation (no metered inference API)
Skill path: skills/remove-ai-marks/
(migration: formerly remove-claude-marks; slash alias /remove-claude-marks still documented)
# 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-marksInvoke with /remove-ai-marks or ask to “strip AI watermarks / C2PA / Claude marks / SynthID-class text.”
Optional system tools (auto-used when present):
| Tool | Role |
|---|---|
c2patool |
Inspect C2PA manifests |
exiftool |
Residual metadata strip (esp. PDF) |
Core scripts need Python 3.10+ stdlib only. Layer B model calls are optional.
Every model this project uses is open-weight and runs on your own machine. Downloading weights over the network is expected; sending your content to a metered inference API is not, and takes two explicit opt-ins to do.
python3 skills/remove-ai-marks/scripts/doctor.py # human report
python3 skills/remove-ai-marks/scripts/doctor.py --json # for scripts/agentsdoctor.py detects your device and VRAM, picks a capability tier, and reports
every capability as ready / degraded / unavailable with the exact command
that fixes it — including whether the disk can hold CtrlRegen's ~10 GB of
weights before you start the download.
| Tier | VRAM | Local text model | Image removal path |
|---|---|---|---|
tier0-cpu |
none / <4 GB | 3B GGUF Q4 | lite only |
tier1-low |
4–8 GB | 7–8B GGUF Q4 | lite, ctrlregen (fp16 + sequential offload) |
tier2-mid |
8–16 GB | 8–14B Q4/Q5 | lite, ctrlregen (fp16 + model offload) |
tier3-high |
16 GB+ | 14B+ | lite, ctrlregen (fp16, no offload) |
# Ollama; --model auto picks one that fits the tier and is already pulled
python3 "$SCRIPTS/rewrite_text.py" draft.md -o draft.rewritten.md \
--backend ollama --model auto --strength paraphrase
# llama.cpp server
python3 "$SCRIPTS/rewrite_text.py" draft.md --backend llamacpp --model auto
# Avoid re-stamping: never rewrite Gemini text with a Google-family model
python3 "$SCRIPTS/rewrite_text.py" draft.md --backend ollama --model auto --origin geminiLong documents are chunked to the tier's context budget automatically, so a 7B with an 8k window does not silently truncate your document.
Two independent endpoint gates. Non-loopback endpoints need
--allow-remote (self-hosting on another box on your LAN needs only this).
Known metered vendors — api.openai.com, api.anthropic.com,
generativelanguage.googleapis.com, and friends — are refused on top of that
and need WATERMARKS_ALLOW_PAID_API=1 as a separate opt-in, because reaching one
costs money per token and can re-stamp the text with that vendor's own mark.
python3 "$SCRIPTS/clean_image.py" shot.png -o shot.cleaned.png --remove-pixel lite
python3 "$SCRIPTS/clean_pixel_lite.py" shot.png --preset medium --jsonPillow + numpy only, sub-second, no external checkout. Presets light /
medium / heavy, each with its own PSNR floor so a run that would visibly
wreck the image refuses instead.
What this tier is not. These are the classic cheap attacks. They degrade fragile pixel marks. Robust modern schemes — SynthID, StableSignature, Tree-Ring — are trained specifically to survive resampling, filtering and recompression, and usually do. Use it as a free first pass or when no GPU is available; it is not equivalent to CtrlRegen regeneration.
python3 "$SCRIPTS/detect_text_watermark.py" draft.md # before
python3 "$SCRIPTS/detect_text_watermark.py" draft.rewritten.md # afterReports a Kirchenbauer green-list z-score and p-value on CPU in milliseconds. Measured on a synthetic marked sequence, rewriting 50% of tokens still scores z=20 against a threshold of 4 — the quantitative version of this README's disclaimer that light editing does not remove a statistical mark.
Scope limit, stated plainly: this detects the open-LLM Kirchenbauer class with a known key. It cannot detect Claude, Gemini/SynthID-Text or OpenAI marks, which use secret keys and undisclosed schemes. A low score here is not evidence that a vendor detector would fail.
SCRIPTS=skills/remove-ai-marks/scripts
# Preflight: tier, capabilities, and the fix for anything missing
python3 "$SCRIPTS/doctor.py"
# 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 --strength 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.pnginspect_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:
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.
inspect_image.py and clean_image.py can report a pixel-domain SynthID
confidence score when an external checkout of
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.
SCRIPTS=skills/remove-ai-marks/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.pngsetup_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).
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.pngThe image is built locally from the upstream source at build time. It is not published, so it does not redistribute the upstream code.
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.
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, a
maintained reimplementation of the ICLR 2025
CtrlRegen 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.
SCRIPTS=skills/remove-ai-marks/scripts
# Clones upstream (pinned commit), creates a venv, installs torch + deps.
"$SCRIPTS/setup_ctrlregen.sh"
# Standalone removal (default checkout: ~/noai-watermark).
NOAI_WATERMARK_DIR=~/noai-watermark \
~/noai-watermark/.venv/bin/python "$SCRIPTS/clean_ctrlregen.py" shot.png -o shot.ctrlregen.pngNOAI_WATERMARK_DIR=~/noai-watermark \
~/noai-watermark/.venv/bin/python "$SCRIPTS/clean_image.py" shot.png \
-o shot.cleaned.png --remove-pixel ctrlregenOrder of operations: metadata strip first, then CtrlRegen pixel removal, then
an optional reverse-SynthID before/after score (when REVERSE_SYNTHID_DIR is
also set).
Strength is conservative by default (--ctrlregen-strength 0.25), because
higher strength removes more watermark but regenerates more of the image.
Documented presets: 0.15 minimal / 0.25 default / 0.35 balanced /
0.5 aggressive / 0.7 max (backend default is 0.5). --ctrlregen-steps
defaults to 50 (effective denoising steps ≈ steps × strength).
CtrlRegen is a 512×512 Stable Diffusion 1.5 ControlNet. The backend resolves this for arbitrary inputs, so no extra tiling is exposed here:
- ≤512 px: single pass — center-crop/resize to 512, regenerate, resize back.
- >512 px: automatic overlapping tiling (512 px tiles, 192 px overlap), width/height aligned to multiples of 8, then cosine-blended seams.
- Either path: output is resized to the original size and color-matched to the original image.
Very large images (e.g. 4K) produce many tiles, so runs scale with tile count (slower and higher VRAM). Pre-downscale large inputs when practical; tile size and overlap are hardcoded upstream and are not exposed as flags.
The backend loads in fp16 on GPU by default (it picks the dtype itself when none is passed), so the pipeline is already half-precision. What it does not expose is the diffusers memory machinery, which this adapter now applies to the loaded pipeline:
# 4-8 GB card: slicing + tiling + per-layer offload
python3 "$SCRIPTS/clean_ctrlregen.py" shot.png --low-vram --offload sequential
# 8-16 GB: submodule offload is much faster than per-layer
python3 "$SCRIPTS/clean_ctrlregen.py" shot.png --low-vram --offload model
# explicit precision (bf16 on Ampere+, fp32 to debug numerics)
python3 "$SCRIPTS/clean_ctrlregen.py" shot.png --dtype bf16| Flag | Effect |
|---|---|
--low-vram |
Attention slicing + VAE slicing/tiling. Slower, much smaller peak |
--offload model |
Moves submodules on/off GPU between steps (CUDA only) |
--offload sequential |
Per-layer offload; smallest footprint, slowest (CUDA only) |
--dtype |
auto (fp16 on GPU, fp32 on CPU) / fp16 / bf16 / fp32 |
--max-pixels |
Refuse oversized inputs before they become ~100 tiles |
--skip-vram-check |
Run anyway when the preflight says it will not fit |
CPU offload is CUDA/accelerate territory. On MPS it is skipped with a notice and the slicing knobs carry the load instead.
A VRAM preflight runs before any weights are pulled. If the configuration cannot fit, it exits 3 and names the flags that would make it fit — rather than downloading ~10 GB and then OOMing on the first denoising step.
Expect ~10 GB of model downloads; a GPU is strongly recommended and CPU runs
are slow. Some upstream models are gated, so export HF_TOKEN (env only —
never argv). clean_ctrlregen.py refuses to auto-install dependencies; run
setup_ctrlregen.sh first.
No GPU at all? Use --remove-pixel lite instead — see
Pixel removal without a GPU.
There is no local detector for StegaStamp/Tree-Ring/StableSignature, so the
only local signal is the reverse-SynthID score (a surrogate). When available,
clean_image.py --remove-pixel ctrlregen reports that score before/after; the
official Google SynthID check remains the final authority.
make docker-ctrlregen-build
docker run --rm -e HF_TOKEN="$HF_TOKEN" \
--user "$(id -u):$(id -g)" \
-v "$(pwd):/data" \
watermarks-remover-ctrlregen /data/shot.png -o /data/shot.ctrlregen.png| Channel | Claude | Gemini/SynthID | OpenAI | Open-LLM |
|---|---|---|---|---|
| Unicode / edit-based text | Layer A | Layer A | Layer A | Layer A |
| Statistical sampling text | Layer B best-effort | Layer B best-effort | Layer B if present | Layer B best-effort |
| C2PA / file metadata | Yes (listed formats) | Yes when present | Yes when present | Yes when present |
| Pixel image marks | Out of scope | lite (fragile only) + optional SynthID score + CtrlRegen removal |
Out of scope | lite (fragile only) + optional CtrlRegen removal |
| Local verification | No (secret key) | No (secret key) | No (secret key) | Yes — detect_text_watermark.py z-score |
| Training backdoors | Out of scope | Out of scope | Out of scope | Out of scope |
Details: skills/remove-ai-marks/references/vendor-notes.md, mark-classes.md.
Modern LLM watermarks often hide a signal in which tokens are chosen (generative / sampling bias), not only in invisible characters. Edit-based schemes inject Unicode or synonym rules. File schemes attach C2PA or generator metadata.
- Layer A removes edit-based Unicode carriers (testable).
- Layer B attacks sampling watermarks via heavy rewrite (best-effort; literature-standard attacks such as paraphrase / back-translation).
- File cleaners strip C2PA/XMP/props from supported containers.
Until vendors ship public detectors and keys, no tool can honestly certify “this fails the official check.” Reports must separate verifiable vs best-effort work.
Prefer a non-origin model for Layer B (do not rewrite Claude text with Claude if you are trying to avoid re-stamping).
Text watermarks live in the wording itself: the signal is spread across token choices, so nearly every sentence carries a little of it. Two consequences follow, and they are why Layer B is honestly described as best-effort rather than a magic eraser.
-
Removal means rewording, not restructuring. Shuffling paragraphs, changing headings, or light touch-ups barely move the signal. Stripping a statistical mark requires rewriting a substantial fraction of the text — sentence by sentence, not section by section.
-
Rewording degrades the copy. Any rewrite replaces the original word choices with the rewriting model's, which flattens tone, voice, and precision. On production copy (SEO, marketing, client work) that degradation is real and often visible to the people who care most about the writing. It is like taking text from a top-tier model and asking a less capable model to rewrite it from scratch: the result cannot exceed the rewrite model's ceiling.
Which leads to the honest full-circle question:
If the plan is to rewrite the text with a cheaper model anyway, why pay for a premium model in the first place? Generating directly with the cheaper model is simpler, cheaper, and produces the same — or better — end result.
Layer B makes sense when you specifically want the premium model's thinking and drafting and accept a rewrite pass to satisfy a hygiene or privacy requirement — not as a cheap route to mark-free text.
When to skip Layer B:
- Quality matters more than hygiene: use the lossless path — Layer A Unicode scrub plus the file metadata cleaners — and keep the original prose.
- Rewriting anyway: use a non-origin model (rewriting with the origin model can re-stamp the text), and remember residual risk remains — no tool can certify a vendor detector will fail.
| Format | Inspect | Clean |
|---|---|---|
| PNG / JPEG | C2PA chunks / APP11, AI XMP hints | Drop metadata segments |
| SVG | <metadata>, XMP |
Strip blocks |
| Byte/XMP + optional tools | exiftool preferred; degraded without it | |
| DOCX | docProps / customXml | Scrub props, drop customXml |
| ODT | meta.xml | Drop generator / AI-ish meta |
| HTML | meta, JSON-LD, data-ai* | Strip tags/attrs |
| Markdown | YAML frontmatter AI keys | Drop keys + Layer A body |
Pixel-domain watermark removal is now available as an optional external CtrlRegen backend (see above); it is a regenerating remover, not a guarantee. C2PA soft binding (in-content watermark that can re-link a remote Content Credentials manifest after metadata is stripped) remains out of scope. Stripping hard-bound C2PA does not clear those channels.
This tool reports verifiable removals (Unicode counts, metadata actions) and best-effort Layer B rewrites. It cannot certify that vendor detectors will fail.
To check residual signals yourself (optional, external):
| Channel | What we remove | What may remain | External check (examples) |
|---|---|---|---|
| Hard-bound C2PA / EXIF / XMP | Yes | Soft-bound / pixel marks | c2patool, Content Credentials verify |
| SynthID-class media | Optional pixel removal (external CtrlRegen); local score otherwise | Audio/video watermark; residual pixel watermark after removal | Provider tools (e.g. Google SynthID / Vertex detector where offered); optional local reverse-SynthID scorer |
| Statistical text | Best-effort rewrite | Strong marks after light edit | No public universal detector; vendor tools when available |
Industry two-layer context (C2PA + imperceptible watermark): Institute of AI PM guide.
| Option | Removes | Notes |
|---|---|---|
| Unicode scrub (Layer A) | ZWSP, bidi, tags, exotic spaces, … | Safe default for text |
| Rewrite (Layer B) | Statistical token marks (best-effort) | Always offered by skill; runs on a local model; costs style — see Disclaimer |
| Container/metadata strip | File provenance | See format table |
| Pixel-lite removal | Fragile pixel marks | No GPU, no checkout, sub-second; robust marks survive |
| CtrlRegen pixel removal (optional) | Pixel-domain image marks (SynthID-class, StegaStamp, Tree-Ring, StableSignature) | External backend; heavy compute; conservative strength default; --low-vram / --offload for small cards |
| Open-weight local models | Avoid re-stamping with origin model | The default; --origin enforces family exclusion |
| Local z-score detector | — (measures, does not remove) | Open-LLM Kirchenbauer class only; not vendor marks |
Matrix: skills/remove-ai-marks/references/removal-matrix.md.
See skills/remove-ai-marks/references/ethics.md. For privacy and research on your content — not academic fraud or false “human-written” claims.
Responsible use: This project is for content you own or are authorized to process. Users must adhere to local regulations and use it responsibly. The developers disclaim any liability for potential misuse by users.
python3 -m venv .venv && .venv/bin/pip install pytest
.venv/bin/python -m pytest # or: make test
make smoke # quick CLI smoke on fixturesEntries from v0.4.0 down are upstream
(guillaumemeyer/watermarks-remover)
and link to upstream's release tags.
v0.5.0 — self-hosted operation: local Layer B, capability tiers, zero-GPU pixel tier, local verification
Removes every metered inference API from the default paths. All models are open-weight and run on the operator's machine; the network is still used to download weights, but no per-token API is required at any point. Layer A and the container/metadata cleaners remain pure-stdlib and behaviourally unchanged.
Capability preflight and tier policy
- New
scripts/local_models.py: hardware probe (detect_hardware), four-tier policy (pick_tier/tier_policy), an open-weight model catalog with per-tier VRAM budgets, and the sharedresolve_device/resolve_dtypehelpers (the latter moved out ofclean_ctrlregen.pyso device resolution has one owner). No module-scope torch import: a machine with no GPU stack degrades tocpuinstead of raising - New
scripts/doctor.py: reports device, VRAM/RAM/free disk, selected tier, and every capability asready/degraded/unavailablewith the command that fixes it;--jsonfor agent consumption. Flags insufficient free disk before CtrlRegen's ~10 GB download starts - Tiers:
tier0-cpu(<4 GB — lite only) →tier1-low(4–8 GB — fp16 + sequential offload) →tier2-mid(8–16 GB — fp16 + model offload) →tier3-high(16 GB+) make doctor;doctor.py --jsonadded tomake smoke
Layer B now runs locally by default
SKILL.mddefault inverted. The agent no longer rewrites text itself when the hook is unconfigured — that path sent the user's document to a metered vendor API and risked re-stamping the output with the rewriting vendor's own watermark. It is now an explicit last resort that must be offered with both costs stated- New
llamacppbackend targeting a localllama.cppllama-server; reuses the OpenAI-compatible transport but never forwards an API key to it - Per-backend loopback defaults (
default_base_url): Ollama:11434, llama-server:8080, generic OpenAI-compatible:8000— a single shared default silently pointed one runtime at the other's socket --model autoresolves a model that fits the tier and is actually pulled, via/api/tags(Ollama) or/v1/models(llama-server), failing with the exactollama pull …command instead of a mid-run backend error._model_matchesdistinguishes bare-name from tagged requests, so asking for:3bnever silently runs:1b- Paragraph-boundary chunking (
split_chunks), sized from the tier's context budget. Previously the entire document went into one prompt, which silently truncates on any local 7–8B with an 8k window — the practical blocker for running Layer B locally at all. Oversized paragraphs fall back to sentence boundaries, then to a hard split. Chunking is skipped forbacktranslateandstructural, which reason over the whole document by design --origin <vendor>excludes the suspected origin's model family from--model auto, making the long-standing "prefer a non-origin model" guidance mechanically enforced rather than prose
Endpoint cost gate
_check_remotegained a second, independent gate. Non-loopback endpoints still require--allow-remote; known metered inference hosts (api.openai.com,api.anthropic.com,generativelanguage.googleapis.com,openrouter.ai, and others) are refused on top of that and requireWATERMARKS_ALLOW_PAID_API=1. Self-hosting on another machine on the LAN needs only the first opt-in- Matching is anchored on registrable domains, so
my-resource.openai.azure.comis caught whileopenai.com.evil.testis not misclassified as the vendor - The gate runs before the model preflight, so a refused host is never contacted
Zero-GPU pixel removal
- New
scripts/clean_pixel_lite.pyandclean_image.py --remove-pixel lite: crop-and-rescale, sub-degree rotation round-trip, Gaussian blur + unsharp mask, median filter, wavelet detail-band noise, optional palette quantization with dithering, and successive JPEG recompression cycles. Pillow + numpy only, sub-second, no external checkout and no GPU — closing the gap where the only removal path was a ~10 GB diffusion pipeline or nothing - Presets
light/medium/heavy, each with its own PSNR floor (30 / 26 / 22 dB). A single global floor was wrong at both ends: it either rejectedheavy(which the operator explicitly asked for) or failed to catch alightrun that wrecked the image. Deterministic under--seed - When PyWavelets is absent the frequency-domain stage degrades to mild spatial noise rather than silently skipping, so a preset never quietly does less than its name claims
- Documented as fragile-mark-only. These are the classic distortion attacks catalogued by Petitcolas et al. and Voloshynovskiy et al.; robust modern schemes are trained specifically to survive them, as the WAVES benchmark quantifies. Reported as a free first pass and a no-GPU fallback, never as equivalent to regeneration
CtrlRegen on small GPUs
--low-vramapplies attention slicing and VAE slicing/tiling;--offload model|sequentialadds accelerate CPU offload. These arediffuserspipeline methods that upstream'sCtrlRegenEnginedoes not expose, so the adapter now calls the engine's public, idempotentload()and applies them to the loaded pipeline. Every call isgetattr-guarded and degrades to a warning — the backend is third-party code at a pinned commit- CPU offload is applied on CUDA only; on MPS it is skipped with a notice and the slicing knobs carry the load
--dtype auto|fp16|bf16|fp32. Note: the backend already selects fp16 on GPU when no dtype is passed, so this adds explicit control (bf16 on Ampere+, fp32 for numerical debugging) rather than changing the default precision- VRAM preflight before any weight download (
estimate_ctrlregen_vram_gb): exits 3 naming the flags that would make the configuration fit, instead of pulling ~10 GB and then OOMing on the first denoising step.--assume-vram-gbfor testing,--skip-vram-checkto override --max-pixelsguard plus a tile-count warning (estimate_tiles), since a 4K input becomes ~84 sequential 512 px passes at upstream's 512/192 tiling geometry_torch_dtypereturnsNonewhen torch is unavailable, keeping the adapter runnable and testable outside the backend venv
Local watermark verification
- New
scripts/detect_text_watermark.py: Kirchenbauer green-list z-score and p-value, CPU-only, milliseconds. Green lists are cached per preceding token, turning an O(V) permutation per token into one per distinct prefix. Tokenizes via a local Hugging Face tokenizer or a runningllama-server, or accepts pre-tokenized ids with--token-ids - Measured on a synthetic marked sequence (2000 tokens, γ=0.25, threshold z=4): 77.4 unmodified → 43.0 at 25% of tokens rewritten → 20.2 at 50% → 4.1 at 75% → −1.6 at 100%. This is the quantitative form of the existing disclaimer that light editing does not remove a statistical mark; the table now appears in
references/removal-matrix.md - Scope stated in the tool, the report and the docs: it covers the open-LLM Kirchenbauer class with a known key. It cannot detect Claude, Gemini/SynthID-Text or OpenAI marks — the same marked text scored with the wrong key gives z = 0.7. A low score is never evidence that a vendor detector would fail. Exits 3 rather than fabricating a score when no tokenizer is available
Docs and tests
- README: new "Self-hosted, no API cost" section, tier table, low-VRAM flag table, and honest-limits callouts for both the lite tier and the detector. Coverage matrix gains a "Local verification" row;
references/removal-matrix.mdgains the lite tier and the rewrite-fraction table SKILL.md: step 0 preflight, inverted Layer B default, new step 4b local verification, and an explicit instruction not to present a lite run as equivalent to CtrlRegen- 175 new tests (145 → 320), all mock-based: CI needs no GPU, no torch, no model weights and no network. New
make smoke-litetarget
v0.4.0 — pixel removal, finding confidence, Windows & false-positive fixes
Optional CtrlRegen pixel removal (external backend)
- Optional pixel-domain watermark removal via an external
mertizci/noai-watermarkcheckout:clean_ctrlregen.pyadapter +setup_ctrlregen.shbootstrap (pinned commit, sparse checkout, venv, SHA verification), plusDockerfile.ctrlregenandmake bootstrap-ctrlregen/docker-ctrlregen-build/smoke-ctrlregen clean_image.py --remove-pixel ctrlregenruns metadata strip → CtrlRegen removal → optional reverse-SynthID before/after score;inspect_image.pyhints at the flag on a high SynthID score- Conservative default strength
0.25(presets 0.15/0.25/0.35/0.5/0.7); the 512×512-native pipeline is auto-tiled by the backend for larger images; the torch subprocess gets higher env-overridable resource caps - Backend is never bundled:
noai-watermarkships no LICENSE file (treated as all-rights-reserved), and its auto-install/restart code paths are bypassed by usingCtrlRegenEnginedirectly
Finding confidence and aggregate audits
- Findings are now classified
confirmed/probable/informational/likely_false_positive, exposed in text/image/container JSON and human reports - New
audit_dir.py(recursive tree) andaudit_website.py(sitemap discovery + crawl) aggregate reports; documented in SKILL.md
False-positive fixes
- DOCX: scan only
docProps/customXml, not the visible body (#14) - Text Layer A: preserve emoji
VS16/ZWJafter an emoji base; new--strip-emoji-glueparanoid flag (#22) - HTML: treat CMS generator tags as informational, not AI metadata (#13)
- PDF: exclude stream payloads from the AI-marker byte scan (#13)
- Inspect reports note unsupported/best-effort paths
Windows support
- Gate POSIX-only
preexec_fnandos.fchmodso writes and optional tools run on Windows (#15, #23) - Reconfigure stdio to UTF-8 so redirected Windows streams no longer raise on invisible Unicode; Windows CI leg + CLI smoke run (#23)
Docs and supply chain
- README CtrlRegen section + research references (CtrlRegen, UnMarker, forensic-stealth caveat), responsible-use disclaimer; SKILL/matrix/vendor-notes/ethics updates
- Dependabot config + security-path CODEOWNERS; bump scipy/numpy/opencv-python/scikit-learn/pywavelets and the base image to Python 3.14-slim
- Mock-based CtrlRegen tests (no torch in CI)
v0.3.2 — security hardening (safe writes, HTTP client, CI supply chain)
- Safe, atomic output writes: every cleaner now writes via temp-file + atomic rename (
safe_write_bytes/safe_write_text), refuses symlinked destinations, and creates.bakbackups through the same safe path — pre-placed symlinks (e.g. in/tmpor download dirs) can no longer redirect a clean write onto an arbitrary file rewrite_text.pyHTTP client hardening: redirects are refused outright, so an API key in theAuthorizationheader can never be re-sent to an unvalidated host; non-loopback endpoints are denied by default (opt in with--allow-remoteorWATERMARKS_REWRITE_ALLOW_REMOTE=1); only http(s) schemes are accepted;--api-keywas removed — keys are env-only viaWATERMARKS_REWRITE_API_KEY- Resource caps: default max input 1 GiB → 256 MiB, new 64 MiB stdin cap, DOCX/ODT zip budget 512 MiB → 128 MiB, and
RLIMIT_AS/RLIMIT_FSIZEapplied to exiftool/c2patool/SynthID subprocesses (all caps env-overridable) - Supply chain: CI actions SHA-pinned with
permissions: contents: read, pinned dev deps (requirements-dev.txt), apip-auditstep, and a new CodeQL workflow; the Docker image now runs as an unprivileged user with pip pinned - Scorer deps: Pillow bumped 10.4.0 → 12.3.0 (24 known CVEs); API usage verified against the pinned upstream commit
- Tests: 18 new security regression tests (60 total, all passing)
v0.3.1 — stronger Layer B statistical-watermark rewrite
rewrite_text.pydefault paraphrase now performs an explicit word-choice + syntax attack (clause order, connectors, transition words, sentence boundaries, function words) rather than a generic rewrite- New
--strength humanize: zero-shot "write like a human" pass targeting formulaic AI-style phrasing - New
--strength code: rewrites comments, docstrings, and string literals, and renames local identifiers while preserving behavior and public API names - Structural pass now emits "natural, varied human prose" instead of AI-typical "clear professional style"
- New
--temperature(default0.9) for both Ollama and OpenAI-compatible backends - New
--candidates N: generates N rewrites and selects the most lexically diverged (bigram Jaccard distance) with a length-drift guard - Stronger model hygiene: prefer local open-weight models and avoid any known-watermarked vendor, not just the suspected origin
- Residual-risk reporting now distinguishes short/highly predictable text (lower risk) from long, high-entropy prose (higher risk)
- Docs updated in
SKILL.md,removal-matrix.md, andvendor-notes.md; tests cover new prompts, divergence scoring, and candidate selection
v0.3.0 — optional SynthID pixel scoring
- Optional pixel-domain SynthID scorer via an external
aloshdenny/reverse-SynthIDcheckout (score_synthid.py); surfaced ininspect_image.py/clean_image.pywithREVERSE_SYNTHID_DIRor--synthid-dir setup_synthid.shbootstrap (scorer-only dependencies;--fullinstalls upstream requirements);Dockerfile.synthidplusmake docker-synthid-build/docker-synthid-help- Makefile
smoke-synthidandbootstrap-synthidtargets - Tests for the scorer adapter, CLI unavailable path, JSON parsing, and runtime errors
- Docs: detection/scoring only (no pixel removal); upstream code is not bundled and remains under its non-commercial Research License
v0.2.0 — c2patool false-positive fix
image_meta.py:has_manifestno longer flagsError: No claim found/No JUMBF data foundas a manifest (operator-precedence bug: the negative markers now veto every positive branch)- New
tests/test_c2patool_report.py(4 cases: no claim, no JUMBF, genuine manifest, tool absent) - Docs: fixed
c2patoollinks (repo moved tocontentauth/c2pa-rs); added a disclaimer on the quality cost of text-watermark removal
v0.1.0 — packaging polish + provenance honesty
Makefile(test/smoke/install-skill) andpytest.ini- Fixture samples for Markdown, HTML, SVG; PDF degraded-clean test
- Docs: industry two-layer model (hard-bound C2PA vs soft binding / SynthID-media)
- README residual-risk table + links to external verify tools
- Reference: Institute of AI PM C2PA/SynthID guide
- Soft-binding and pixel/audio/video watermarks explicitly out of scope in skill/matrix/ethics
v0.0.1 — initial multi-vendor release
- Agent skill
remove-ai-marks(replaces Claude-onlyremove-claude-marks) - Layer A: invisible Unicode / bidi / tag chars / space homoglyphs (
inspect_text/clean_text) - Layer B: rewrite guidance + optional
rewrite_text.py(print-prompt, Ollama, OpenAI-compatible) - Files: C2PA/AI metadata strip for PNG, JPEG, SVG, PDF, DOCX, ODT, HTML, Markdown
- Unified
inspect_file.py/clean_file.py - Multi-vendor docs (Claude, Gemini/SynthID-class, OpenAI, open-LLM)
- Stdlib-first scripts; optional
c2patool/exiftool
This project is a fork of
guillaumemeyer/watermarks-remover,
which contributed the original agent skill, the Layer A Unicode engine, the
container/metadata cleaners, the audit tooling, and the security hardening
through v0.4.0. That work is MIT-licensed and its copyright notice is retained
in LICENSE; changelog entries for v0.4.0 and earlier are upstream's.
Work in this fork (v0.5.0) is described in the changelog and covers the self-hosted operating model: capability tiers, local-only Layer B, the zero-GPU pixel tier, low-VRAM CtrlRegen operation, and local verification.
External backends are not bundled and remain under their own terms:
mertizci/noai-watermark (no
LICENSE file — treated as all-rights-reserved) and
aloshdenny/reverse-SynthID
(non-commercial Research License). Both are cloned at pinned commits and loaded
at runtime.
MIT — see LICENSE.
- How Claude marks AI-generated content (Anthropic)
- Dathathri et al., Scalable watermarking for identifying large language model outputs (SynthID-Text, Nature 2024)
- Google AI for Developers, SynthID safeguards (Gemini API docs)
- Kirchenbauer et al., A Watermark for Large Language Models — the green-list scheme
detect_text_watermark.pyscores against jwkirchenbauer/lm-watermarking— reference implementation; the detector follows its "lefthash" partition and default γ / hash-key values- C2PA / c2patool
- google-deepmind/synthid-text (research reference; not used for detection here)
- Institute of AI PM, AI Content Provenance and Watermarking: The PM's Guide to C2PA and SynthID (two-layer industry model: C2PA + imperceptible watermark / soft binding; SB 942 / EU AI Act Art. 50 context)
- Zhang et al., Watermarks in the Sand: Impossibility of Strong Watermarking for Generative Models (ICML 2024)
- Petitcolas, Anderson & Kuhn, Attacks on Copyright Marking Systems (Information Hiding 1998; StirMark) — the classic taxonomy of geometric and filtering attacks that
clean_pixel_lite.pyimplements - Voloshynovskiy et al., Attacks on Digital Watermarks: Classification, Estimation-Based Attacks, and Benchmarks (IEEE Communications Magazine, 2001) — attack classification underpinning the preset design
- An et al., WAVES: Benchmarking the Robustness of Image Watermarks (ICML 2024) — why the lite tier is documented as fragile-mark-only: robust schemes are tuned to survive exactly these distortions
- Zhao et al., Invisible Image Watermarks Are Provably Removable Using Generative AI — the regeneration argument motivating CtrlRegen over distortion attacks
- Liu et al., Image Watermarks are Removable Using Controllable Regeneration from Clean Noise (ICLR 2025) — the pixel-regeneration method the optional CtrlRegen backend implements — code
- Kassis & Hengartner, UnMarker: A Universal Attack on Defensive Image Watermarking (arXiv:2405.08363; IEEE S&P 2025) — a universal watermark attack compared on a different metric than CtrlRegen
- Goonatilake & Ateniese, Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal (arXiv:2605.09203) — motivates the conservative-strength default: removal can still leave forensic traces
aloshdenny/reverse-SynthID(research reference)mertizci/noai-watermark(CLI/Python toolkit for SynthID/StableSignature/TreeRing removal and AI metadata stripping)0xROOTPLS/DeSynth(SynthID removal for OpenAI/Google images)
- Rombach et al., High-Resolution Image Synthesis with Latent Diffusion Models (CVPR 2022) — the Stable Diffusion 1.5 base the 512×512 CtrlRegen pipeline builds on
- Zhang, Rao & Agrawala, Adding Conditional Control to Text-to-Image Diffusion Models (ControlNet, ICCV 2023)
- Ye et al., IP-Adapter: Text Compatible Image Prompt Adapter for Text-to-Image Diffusion Models
- Oquab et al., DINOv2: Learning Robust Visual Features without Supervision — the image encoder in the CtrlRegen pipeline
- Hugging Face, Diffusers — reduce memory usage — attention slicing, VAE slicing/tiling, and model/sequential CPU offload, as applied by
--low-vram/--offload ggml-org/llama.cpp— GGUF quantized inference behind thellamacppbackendollama/ollama— local model serving behind theollamabackend