mirror of
https://github.com/guillaumemeyer/watermarks-remover.git
synced 2026-08-22 13:11:57 +02:00
Add optional CtrlRegen pixel removal (external noai-watermark backend)
Wires a standalone clean_ctrlregen.py adapter plus setup_ctrlregen.sh bootstrap, Dockerfile, Makefile targets, and clean_image.py --remove-pixel ctrlregen. The backend is cloned at a pinned commit and never bundled (noai-watermark ships no LICENSE file). Includes mock-based tests and docs with research references.
This commit is contained in:
parent
396c83dbae
commit
8d8fe7ad84
@@ -0,0 +1,63 @@
|
||||
# Optional local Docker image for the CtrlRegen pixel-watermark remover.
|
||||
#
|
||||
# Build from the repository root:
|
||||
# docker build -f Dockerfile.ctrlregen -t watermarks-remover-ctrlregen .
|
||||
#
|
||||
# The upstream code is fetched from source at build time and is NOT
|
||||
# redistributed by this repository. mertizci/noai-watermark ships no LICENSE
|
||||
# file, so it is treated as all-rights-reserved; review its terms before use.
|
||||
#
|
||||
# Vendored fork hardening:
|
||||
# - base image pinned by digest (no moving tag drift)
|
||||
# - upstream checkout pinned to a commit SHA (no moving branch)
|
||||
# - deps pinned exactly in requirements-ctrlregen.txt
|
||||
# - pip itself pinned (no unpinned bootstrap step)
|
||||
# - runs as an unprivileged user (a crafted image can no longer write
|
||||
# files as root inside the container)
|
||||
|
||||
# Pinned upstream commit (2026-08-13). Keep in sync with setup_ctrlregen.sh.
|
||||
ARG NOAI_WATERMARK_REF=b642ae45d20eded52c96d570985eb4e3e427aac8
|
||||
|
||||
# python:3.14-slim linux/amd64 digest.
|
||||
FROM python:3.14-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4
|
||||
|
||||
ARG NOAI_WATERMARK_REF
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
libgl1 \
|
||||
libglib2.0-0 \
|
||||
passwd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN git clone --depth 1 --filter=blob:none --sparse \
|
||||
https://github.com/mertizci/noai-watermark.git /opt/noai-watermark \
|
||||
&& cd /opt/noai-watermark \
|
||||
&& git fetch --depth 1 origin "${NOAI_WATERMARK_REF}" \
|
||||
&& git checkout --detach "${NOAI_WATERMARK_REF}" \
|
||||
&& git sparse-checkout set --no-cone '/src/'
|
||||
|
||||
COPY skills/remove-ai-marks/scripts/requirements-ctrlregen.txt /app/requirements-ctrlregen.txt
|
||||
COPY skills/remove-ai-marks/scripts/clean_ctrlregen.py /app/clean_ctrlregen.py
|
||||
COPY skills/remove-ai-marks/scripts/common.py /app/common.py
|
||||
|
||||
# torch is installed first (CPU wheels on linux) so the pinned ML deps can
|
||||
# resolve against it. Model weights are downloaded at runtime, not build time.
|
||||
RUN python3 -m pip install --no-cache-dir "pip==26.2.1" \
|
||||
&& python3 -m pip install --no-cache-dir torch \
|
||||
&& python3 -m pip install --no-cache-dir -r /app/requirements-ctrlregen.txt
|
||||
|
||||
# Unprivileged runtime user. The remover reads input files from a mounted
|
||||
# volume and writes the output next to them, so nothing under /opt or /app
|
||||
# needs root.
|
||||
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin remover
|
||||
USER remover
|
||||
|
||||
ENV NOAI_WATERMARK_DIR=/opt/noai-watermark \
|
||||
HOME=/home/remover \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["python3", "/app/clean_ctrlregen.py"]
|
||||
@@ -1,4 +1,5 @@
|
||||
.PHONY: test smoke smoke-synthid bootstrap-synthid docker-synthid-build docker-synthid-help install-skill clean
|
||||
.PHONY: test smoke smoke-synthid bootstrap-synthid docker-synthid-build docker-synthid-help \
|
||||
smoke-ctrlregen bootstrap-ctrlregen docker-ctrlregen-build docker-ctrlregen-help install-skill clean
|
||||
|
||||
SCRIPTS := skills/remove-ai-marks/scripts
|
||||
PYTHON ?= $(shell if [ -x .venv/bin/python ]; then echo .venv/bin/python; else echo python3; fi)
|
||||
@@ -32,6 +33,22 @@ docker-synthid-build:
|
||||
docker-synthid-help:
|
||||
docker run --rm watermarks-remover-synthid-scorer --help
|
||||
|
||||
smoke-ctrlregen:
|
||||
@if [ -z "$(NOAI_WATERMARK_DIR)" ]; then \
|
||||
echo "smoke-ctrlregen skipped (set NOAI_WATERMARK_DIR)"; \
|
||||
else \
|
||||
$(PYTHON) $(SCRIPTS)/clean_ctrlregen.py --help >/dev/null && echo "clean_ctrlregen adapter present"; \
|
||||
fi
|
||||
|
||||
bootstrap-ctrlregen:
|
||||
./skills/remove-ai-marks/scripts/setup_ctrlregen.sh
|
||||
|
||||
docker-ctrlregen-build:
|
||||
docker build -f Dockerfile.ctrlregen -t watermarks-remover-ctrlregen .
|
||||
|
||||
docker-ctrlregen-help:
|
||||
docker run --rm watermarks-remover-ctrlregen --help
|
||||
|
||||
install-skill:
|
||||
mkdir -p $(HOME)/.grok/skills
|
||||
ln -sfn $(CURDIR)/skills/remove-ai-marks $(HOME)/.grok/skills/remove-ai-marks
|
||||
|
||||
@@ -127,6 +127,86 @@ 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.
|
||||
|
||||
### Bootstrap
|
||||
|
||||
```bash
|
||||
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.png
|
||||
```
|
||||
|
||||
### From `clean_image.py`
|
||||
|
||||
```bash
|
||||
NOAI_WATERMARK_DIR=~/noai-watermark \
|
||||
~/noai-watermark/.venv/bin/python "$SCRIPTS/clean_image.py" shot.png \
|
||||
-o shot.cleaned.png --remove-pixel ctrlregen
|
||||
```
|
||||
|
||||
Order 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).
|
||||
|
||||
### Image size (512×512 native limit)
|
||||
|
||||
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.
|
||||
|
||||
### Compute, gated models, and verification
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Channel | Claude | Gemini/SynthID | OpenAI | Open-LLM |
|
||||
@@ -134,7 +214,7 @@ watermarks.
|
||||
| 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 | Optional SynthID score (external); removal out of scope | Out of scope | Out of scope |
|
||||
| Pixel image marks | Out of scope | Optional SynthID score + CtrlRegen removal (external) | Out of scope | Optional CtrlRegen removal (external) |
|
||||
| Training backdoors | Out of scope | Out of scope | Out of scope | Out of scope |
|
||||
|
||||
Details: [`skills/remove-ai-marks/references/vendor-notes.md`](skills/remove-ai-marks/references/vendor-notes.md), [`mark-classes.md`](skills/remove-ai-marks/references/mark-classes.md).
|
||||
@@ -188,7 +268,7 @@ Layer B makes sense when you specifically want the premium model's **thinking an
|
||||
| HTML | meta, JSON-LD, data-ai* | Strip tags/attrs |
|
||||
| Markdown | YAML frontmatter AI keys | Drop keys + Layer A body |
|
||||
|
||||
Pixel-domain watermark **removal** and **C2PA soft binding** (in-content watermark that can re-link a remote Content Credentials manifest after metadata is stripped) remain **out of scope**. Stripping hard-bound C2PA does **not** clear those channels. An optional local SynthID scorer is available for detection only (see above).
|
||||
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.
|
||||
|
||||
### Residual risk after a clean
|
||||
|
||||
@@ -199,7 +279,7 @@ 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](https://github.com/contentauth/c2pa-rs/tree/main/cli), [Content Credentials verify](https://contentcredentials.org/verify) |
|
||||
| SynthID-class media | No (optional local score only) | Pixel/audio/video watermark | Provider tools (e.g. [Google SynthID](https://deepmind.google/science/synthid/) / Vertex detector where offered); optional local [reverse-SynthID](https://github.com/aloshdenny/reverse-SynthID) scorer |
|
||||
| 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](https://deepmind.google/science/synthid/) / Vertex detector where offered); optional local [reverse-SynthID](https://github.com/aloshdenny/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](https://www.institutepm.com/knowledge-hub/ai-content-provenance-watermarking).
|
||||
@@ -213,6 +293,7 @@ Industry two-layer context (C2PA + imperceptible watermark): [Institute of AI PM
|
||||
| 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; costs style — see [Disclaimer](#disclaimer-what-removing-a-text-watermark-costs) |
|
||||
| Container/metadata strip | File provenance | See format table |
|
||||
| CtrlRegen pixel removal (optional) | Pixel-domain image marks (SynthID-class, StegaStamp, Tree-Ring, StableSignature) | External backend; heavy compute; conservative strength default |
|
||||
| Open-weight local models | Avoid re-stamping with origin model | Operational alternative |
|
||||
|
||||
Matrix: [`skills/remove-ai-marks/references/removal-matrix.md`](skills/remove-ai-marks/references/removal-matrix.md).
|
||||
@@ -233,6 +314,14 @@ make smoke # quick CLI smoke on fixtures
|
||||
|
||||
## Changelog
|
||||
|
||||
### v0.4.0 (unreleased) — optional CtrlRegen pixel removal (external)
|
||||
|
||||
- Optional pixel-domain watermark removal via an external `mertizci/noai-watermark` checkout: `clean_ctrlregen.py` adapter + `setup_ctrlregen.sh` bootstrap (pinned commit, sparse checkout, venv, SHA verification), plus `Dockerfile.ctrlregen` and `make bootstrap-ctrlregen` / `docker-ctrlregen-build` / `smoke-ctrlregen`
|
||||
- `clean_image.py --remove-pixel ctrlregen` runs metadata strip → CtrlRegen removal → optional reverse-SynthID before/after score; `inspect_image.py` hints 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-watermark` ships no LICENSE file (treated as all-rights-reserved), and its auto-install/restart code paths are bypassed by using `CtrlRegenEngine` directly
|
||||
- Docs: README section + research references (CtrlRegen, UnMarker, forensic-stealth caveat), SKILL/matrix/vendor-notes/ethics updates; mock-based tests (no torch in CI)
|
||||
|
||||
### [v0.3.2](https://github.com/guillaumemeyer/watermarks-remover/releases/tag/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 `.bak` backups through the same safe path — pre-placed symlinks (e.g. in `/tmp` or download dirs) can no longer redirect a clean write onto an arbitrary file
|
||||
@@ -311,9 +400,9 @@ MIT — see [LICENSE](LICENSE).
|
||||
- Zhang et al., [*Watermarks in the Sand: Impossibility of Strong Watermarking for Generative Models*](https://arxiv.org/abs/2311.04378) (ICML 2024)
|
||||
- [google-deepmind/synthid-text](https://github.com/google-deepmind/synthid-text) (research reference; not used for detection here)
|
||||
- [aloshdenny/reverse-SynthID](https://github.com/aloshdenny/reverse-SynthID) (research reference)
|
||||
- Liu et al., [*Image Watermarks are Removable Using Controllable Regeneration from Clean Noise*](https://arxiv.org/abs/2410.05470) (ICLR 2025) — [code](https://github.com/yepengliu/CtrlRegen)
|
||||
- Kassis & Hengartner, [*UnMarker: A Universal Attack on Defensive Image Watermarking*](https://ieeexplore.ieee.org/document/11023303) (IEEE S&P 2025)
|
||||
- Goonatilake & Ateniese, [*Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal*](https://arxiv.org/abs/2605.09203) (arXiv:2605.09203)
|
||||
- Liu et al., [*Image Watermarks are Removable Using Controllable Regeneration from Clean Noise*](https://arxiv.org/abs/2410.05470) (ICLR 2025) — the pixel-regeneration method the optional CtrlRegen backend implements — [code](https://github.com/yepengliu/CtrlRegen)
|
||||
- Kassis & Hengartner, [*UnMarker: A Universal Attack on Defensive Image Watermarking*](https://arxiv.org/abs/2405.08363) (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*](https://arxiv.org/abs/2605.09203) (arXiv:2605.09203) — motivates the conservative-strength default: removal can still leave forensic traces
|
||||
- [mertizci/noai-watermark](https://github.com/mertizci/noai-watermark) (CLI/Python toolkit for SynthID/StableSignature/TreeRing removal and AI metadata stripping)
|
||||
- [0xROOTPLS/DeSynth](https://github.com/0xROOTPLS/DeSynth) (SynthID removal for OpenAI/Google images)
|
||||
- Institute of AI PM, [*AI Content Provenance and Watermarking: The PM's Guide to C2PA and SynthID*](https://www.institutepm.com/knowledge-hub/ai-content-provenance-watermarking) (two-layer industry model: C2PA + imperceptible watermark / soft binding; SB 942 / EU AI Act Art. 50 context)
|
||||
|
||||
@@ -32,6 +32,8 @@ python3 "$SCRIPTS/inspect_text.py" ...
|
||||
python3 "$SCRIPTS/clean_text.py" ...
|
||||
python3 "$SCRIPTS/inspect_image.py" ...
|
||||
python3 "$SCRIPTS/clean_image.py" ...
|
||||
python3 "$SCRIPTS/clean_ctrlregen.py" ... # optional external pixel removal (bootstrap first)
|
||||
"$SCRIPTS/setup_ctrlregen.sh" # one-command bootstrap for the above
|
||||
python3 "$SCRIPTS/rewrite_text.py" ...
|
||||
python3 "$SCRIPTS/audit_dir.py" ...
|
||||
python3 "$SCRIPTS/audit_website.py" ...
|
||||
@@ -73,6 +75,11 @@ external reverse-SynthID scorer. That is **detection only**, not removal.
|
||||
Bootstrap the external checkout with `scripts/setup_synthid.sh`, or build a
|
||||
local image with `make docker-synthid-build`.
|
||||
|
||||
For pixel-domain **removal**, bootstrap the CtrlRegen backend with
|
||||
`scripts/setup_ctrlregen.sh` (or `make docker-ctrlregen-build`), then use
|
||||
`clean_image.py --remove-pixel ctrlregen`. See the README "Optional CtrlRegen
|
||||
pixel removal" section for strength presets and the 512×512 size handling.
|
||||
|
||||
### Aggregate audits and confidence
|
||||
|
||||
Findings are classified as **confirmed**, **probable**, **informational**, or
|
||||
@@ -112,6 +119,9 @@ python3 "$SCRIPTS/inspect_file.py" OUTPUT # verify
|
||||
|
||||
Optional tools if installed: `c2patool`, `exiftool` (auto-used when present; PDF strongly prefers exiftool).
|
||||
|
||||
**Images — optional pixel removal (external):** after the metadata clean, add
|
||||
`--remove-pixel ctrlregen` to `clean_image.py` (bootstrap the backend first).
|
||||
|
||||
### 4. Layer B — always offer rewrite (prose)
|
||||
|
||||
After Layer A, **always propose** a statistical-mark reduction pass for natural-language content. Do not skip this step silently.
|
||||
@@ -227,7 +237,8 @@ Always state:
|
||||
- Layer A does **not** remove token-sampling watermarks.
|
||||
- Layer B cannot be gold-verified without vendor detectors / keys.
|
||||
- PDF strip is best-effort without `exiftool`.
|
||||
- Pixel-domain image/audio/video watermarks (SynthID-media, etc.) are out of scope for removal; an optional external scorer can only report a SynthID confidence estimate.
|
||||
- Pixel-domain **image** watermarks can be removed optionally via the external CtrlRegen backend (`clean_image.py --remove-pixel ctrlregen`); audio/video watermarks remain out of scope for removal.
|
||||
- The CtrlRegen backend is external, all-rights-reserved (no LICENSE file), never bundled, heavy (~10 GB model downloads), and a regenerating remover — no local detector certifies StegaStamp/Tree-Ring/StableSignature removal.
|
||||
- The reverse-SynthID scorer is external, best-effort, and under a non-commercial Research License; it is not bundled and is not an official Google detector.
|
||||
- **C2PA soft binding** (content watermark that re-links to a remote manifest after metadata strip) is out of scope — stripping hard-bound C2PA does not clear it.
|
||||
- Data-driven / backdoor model marks (trigger phrases) are out of scope.
|
||||
@@ -250,6 +261,12 @@ python3 scripts/rewrite_text.py notes.md --backend print-prompt --strength parap
|
||||
python3 scripts/inspect_image.py shot.png
|
||||
python3 scripts/clean_image.py shot.png -o shot.cleaned.png
|
||||
|
||||
# Optional pixel removal (external backend; bootstrap first)
|
||||
scripts/setup_ctrlregen.sh
|
||||
NOAI_WATERMARK_DIR=~/noai-watermark \
|
||||
~/noai-watermark/.venv/bin/python scripts/clean_image.py shot.png \
|
||||
-o shot.cleaned.png --remove-pixel ctrlregen
|
||||
|
||||
# Aggregate audits
|
||||
python3 scripts/audit_dir.py ./src --json
|
||||
python3 scripts/audit_website.py --sitemap https://example.com/sitemap.xml --json
|
||||
|
||||
@@ -23,7 +23,7 @@ Always separate:
|
||||
|
||||
1. **Verifiable** removals (Unicode counts, metadata actions)
|
||||
2. **Best-effort** statistical rewrite (no gold undetection claim)
|
||||
3. **Out of scope** channels (pixel/audio/video watermarks, **C2PA soft binding**, secret-key detectors, training backdoors)
|
||||
3. **Optional / out-of-scope** channels (optional external pixel removal via CtrlRegen; audio/video watermarks, **C2PA soft binding**, secret-key detectors, and training backdoors are out of scope)
|
||||
|
||||
Do not imply that a successful C2PA/metadata strip means “no AI provenance left.” Soft-bound and SynthID-class media signals can survive. Point users at vendor verify tools when they need residual checks (see README *Residual risk after a clean*).
|
||||
|
||||
|
||||
@@ -11,14 +11,15 @@
|
||||
| ODT meta:generator | Scrub `meta.xml` | `clean_file.py` | Loses generator tag | Yes |
|
||||
| HTML generator / JSON-LD provenance | Strip tags | `clean_file.py` | Loses meta | Yes |
|
||||
| Markdown AI frontmatter keys | Drop keys | `clean_file.py` | Loses YAML keys | Yes |
|
||||
| Pixel / audio / video watermarks (SynthID-media) | — | Out of scope | — | — |
|
||||
| Pixel image watermark (SynthID-media / StegaStamp / Tree-Ring / StableSignature) | CtrlRegen regeneration (external backend) | `clean_ctrlregen.py` / `clean_image.py --remove-pixel ctrlregen` | Regenerates pixels; heavy compute; detail drift at higher strength | No without official detector; reverse-SynthID score is a local surrogate |
|
||||
| Audio / video watermarks (SynthID-media) | — | Out of scope | — | — |
|
||||
| C2PA soft binding (in-content link to manifest) | — | Out of scope (survives our metadata strip) | — | Vendor detector only |
|
||||
| Data-driven model backdoors | — | Out of scope | — | — |
|
||||
|
||||
## Default pipeline
|
||||
|
||||
1. **Inspect** (`inspect_file.py` or specific inspect_*).
|
||||
2. **Deterministic clean** — Layer A text and/or container/image metadata.
|
||||
2. **Deterministic clean** — Layer A text and/or container/image metadata; for images, optionally add pixel removal (`--remove-pixel ctrlregen`) after the metadata strip.
|
||||
3. **Always offer Layer B** rewrite for prose (paraphrase → optional strong pass: `humanize` / back-translate / structural).
|
||||
4. Prefer a **non-origin, open-weight** rewrite model when available (avoid re-stamping).
|
||||
5. Layer A again after rewrite.
|
||||
|
||||
@@ -34,6 +34,7 @@ Source: [How Claude marks AI-generated content](https://support.claude.com/en/ar
|
||||
- Productionized in Gemini-scale systems; open research code exists, but **production keys are not public** — this skill does **not** ship a SynthID detector.
|
||||
- Current frontier production watermarks are **token-by-token** (streaming constraint); paragraph-level robust methods (SemStamp / PostMark) are not deployed yet, which keeps paraphrase-class attacks effective today.
|
||||
- Optional external reference: [`aloshdenny/reverse-SynthID`](https://github.com/aloshdenny/reverse-SynthID) provides a reverse-engineered pixel-domain scorer. It is **not bundled** here, is best-effort, and is under a non-commercial Research License; it is not the official Google detector.
|
||||
- Optional pixel-domain removal: [`mertizci/noai-watermark`](https://github.com/mertizci/noai-watermark)'s CtrlRegen profile is wired through `clean_image.py --remove-pixel ctrlregen` / `clean_ctrlregen.py`. It is **not bundled** (no LICENSE file → all-rights-reserved), and no local detector certifies the result; the official Google check is the final authority.
|
||||
|
||||
**Skill mapping:** same Layer B rewrite attacks (paraphrase / back-translate / structural) used in the literature against sampling watermarks.
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Optional CtrlRegen pixel-watermark remover backed by an external noai-watermark checkout.
|
||||
|
||||
This script does NOT vendor upstream code. It imports ``CtrlRegenEngine`` from
|
||||
a user-provided checkout of ``mertizci/noai-watermark`` at runtime, using that
|
||||
environment's optional dependencies (torch, diffusers, transformers, etc.).
|
||||
|
||||
Exit codes:
|
||||
0 removed successfully
|
||||
1 remover runtime error
|
||||
2 bad input (missing/unreadable image, bad args)
|
||||
3 remover unavailable (not configured / missing checkout / missing deps)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from common import cleaned_path, safe_write_bytes # noqa: E402
|
||||
|
||||
# Backend default guidance scale (CtrlRegenEngine.run() default). Kept
|
||||
# internal: strength is the user-facing knob, not the CFG scale.
|
||||
DEFAULT_GUIDANCE_SCALE = 2.0
|
||||
|
||||
|
||||
def resolve_upstream(raw: str | None) -> Path | None:
|
||||
if not raw:
|
||||
return None
|
||||
upstream = Path(raw).expanduser().resolve()
|
||||
if not upstream.is_dir():
|
||||
return None
|
||||
return upstream
|
||||
|
||||
|
||||
def resolve_device(raw: str | None) -> str:
|
||||
"""Resolve the ``auto`` device hint to a concrete torch device."""
|
||||
if raw and raw != "auto":
|
||||
return raw
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
mps = getattr(torch.backends, "mps", None)
|
||||
if mps is not None and mps.is_available():
|
||||
return "mps"
|
||||
except Exception:
|
||||
pass
|
||||
return "cpu"
|
||||
|
||||
|
||||
def save_image_bytes(image, output: Path) -> bytes:
|
||||
"""Encode the regenerated image, honoring a JPEG output suffix."""
|
||||
ext = output.suffix.lower()
|
||||
buf = io.BytesIO()
|
||||
if ext in (".jpg", ".jpeg", ".jpe", ".jfif"):
|
||||
image = image.convert("RGB")
|
||||
image.save(buf, format="JPEG", quality=95)
|
||||
else:
|
||||
image.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _progress(message: str) -> None:
|
||||
print(f"[ctrlregen] {message}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("path", type=Path, help="Input image (PNG/JPEG/etc.)")
|
||||
p.add_argument("-o", "--output", type=Path, help="Output path (default: *.ctrlregen.*)")
|
||||
p.add_argument(
|
||||
"--upstream-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="noai-watermark checkout root (default: $NOAI_WATERMARK_DIR)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--strength",
|
||||
type=float,
|
||||
default=0.25,
|
||||
help="Regeneration strength in (0, 1]; lower preserves more detail (default: 0.25)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--steps",
|
||||
type=int,
|
||||
default=50,
|
||||
help="Diffusion inference steps (default: 50; effective steps ~= steps * strength)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--device",
|
||||
type=str,
|
||||
default="auto",
|
||||
help="auto|cpu|cuda|mps (default: auto)",
|
||||
)
|
||||
p.add_argument("--seed", type=int, default=None, help="Optional RNG seed")
|
||||
p.add_argument("--json", action="store_true", help="Emit JSON on stdout")
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.path.is_file():
|
||||
print(f"not a file: {args.path}", file=sys.stderr)
|
||||
return 2
|
||||
if not 0 < args.strength <= 1:
|
||||
print(f"strength must be in (0, 1]: {args.strength}", file=sys.stderr)
|
||||
return 2
|
||||
if args.steps < 1:
|
||||
print(f"steps must be >= 1: {args.steps}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
raw_upstream = args.upstream_dir or os.environ.get("NOAI_WATERMARK_DIR")
|
||||
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
|
||||
if upstream is None:
|
||||
print(
|
||||
"CtrlRegen not configured: set NOAI_WATERMARK_DIR or pass --upstream-dir",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
src_dir = upstream / "src"
|
||||
if not src_dir.is_dir():
|
||||
print(f"upstream src dir not found: {src_dir}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
sys.path.insert(0, str(src_dir))
|
||||
try:
|
||||
from PIL import Image # noqa: E402
|
||||
from ctrlregen.engine import CtrlRegenEngine, is_ctrlregen_available # noqa: E402
|
||||
except ImportError as e:
|
||||
print(f"CtrlRegen dependencies missing: {e}", file=sys.stderr)
|
||||
print("run setup_ctrlregen.sh first", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
if not is_ctrlregen_available():
|
||||
print(
|
||||
"CtrlRegen dependencies not installed; run setup_ctrlregen.sh first",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
try:
|
||||
image = Image.open(args.path).convert("RGB")
|
||||
image.load()
|
||||
except Exception as e:
|
||||
print(f"could not load image: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
device = resolve_device(args.device)
|
||||
output = args.output or cleaned_path(args.path, ".ctrlregen")
|
||||
|
||||
engine = CtrlRegenEngine(
|
||||
base_model_id=None,
|
||||
device=device,
|
||||
torch_dtype=None,
|
||||
hf_token=os.environ.get("HF_TOKEN"),
|
||||
progress_callback=_progress,
|
||||
)
|
||||
|
||||
try:
|
||||
result = engine.run(
|
||||
image,
|
||||
strength=args.strength,
|
||||
num_inference_steps=args.steps,
|
||||
guidance_scale=DEFAULT_GUIDANCE_SCALE,
|
||||
seed=args.seed,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"CtrlRegen error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
data = save_image_bytes(result, output)
|
||||
safe_write_bytes(output, data)
|
||||
except (OSError, ValueError) as e:
|
||||
print(f"cannot write output: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
payload = {
|
||||
"available": True,
|
||||
"upstream_dir": str(upstream),
|
||||
"output": str(output),
|
||||
"strength": args.strength,
|
||||
"steps": args.steps,
|
||||
"device": device,
|
||||
"seed": args.seed,
|
||||
"input_size": list(image.size),
|
||||
"output_size": list(result.size),
|
||||
"bytes_out": len(data),
|
||||
}
|
||||
|
||||
if args.json:
|
||||
json.dump(payload, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
print(
|
||||
f"CtrlRegen removed: {args.path} -> {output} "
|
||||
f"({payload['input_size']} -> {payload['output_size']}, "
|
||||
f"strength {args.strength}, device {device})"
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -35,6 +35,48 @@ def main() -> int:
|
||||
default=None,
|
||||
help="reverse-SynthID checkout root for optional pixel SynthID scoring",
|
||||
)
|
||||
p.add_argument(
|
||||
"--remove-pixel",
|
||||
choices=["ctrlregen"],
|
||||
default=None,
|
||||
help="Run optional CtrlRegen pixel-watermark removal after metadata cleaning",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="noai-watermark checkout root (default: $NOAI_WATERMARK_DIR)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-strength",
|
||||
type=float,
|
||||
default=0.25,
|
||||
help="CtrlRegen strength in (0, 1] (default: 0.25, conservative)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-steps",
|
||||
type=int,
|
||||
default=50,
|
||||
help="CtrlRegen diffusion steps (default: 50)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-device",
|
||||
type=str,
|
||||
default=None,
|
||||
help="CtrlRegen device: auto|cpu|cuda|mps (default: auto)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-seed",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Optional CtrlRegen RNG seed",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ctrlregen-timeout",
|
||||
type=int,
|
||||
default=3600,
|
||||
help="CtrlRegen subprocess timeout in seconds (default: 3600)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
if not args.path.is_file():
|
||||
@@ -55,6 +97,13 @@ def main() -> int:
|
||||
dest,
|
||||
strip_all_metadata=not args.keep_non_ai_metadata,
|
||||
synthid_dir=args.synthid_dir,
|
||||
remove_pixel=args.remove_pixel,
|
||||
ctrlregen_dir=args.ctrlregen_dir,
|
||||
ctrlregen_strength=args.ctrlregen_strength,
|
||||
ctrlregen_steps=args.ctrlregen_steps,
|
||||
ctrlregen_device=args.ctrlregen_device,
|
||||
ctrlregen_seed=args.ctrlregen_seed,
|
||||
ctrlregen_timeout=args.ctrlregen_timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
eprint(f"error: {e}")
|
||||
@@ -80,10 +129,22 @@ def main() -> int:
|
||||
f"confidence {result['synthid_after'].get('confidence', 0.0):.3f} "
|
||||
f"(watermarked: {label})"
|
||||
)
|
||||
pr = result.get("pixel_removal")
|
||||
if pr is not None:
|
||||
if pr.get("available"):
|
||||
eprint(f"CtrlRegen: removed on {pr.get('device', 'unknown device')}")
|
||||
else:
|
||||
eprint(f"CtrlRegen: unavailable: {pr.get('error', 'unknown error')}")
|
||||
|
||||
failed = False
|
||||
if result["still_has_c2pa"] or result["still_has_ai_metadata"]:
|
||||
eprint("warning: residual C2PA/AI signals may remain")
|
||||
for f in result.get("post_findings") or []:
|
||||
eprint(f" ! {f}")
|
||||
failed = True
|
||||
if pr is not None and not pr.get("available"):
|
||||
failed = True
|
||||
if failed:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@@ -17,6 +17,26 @@ from common import classify_finding_confidence, safe_arg, safe_write_bytes, subp
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# CtrlRegen is torch-based and needs far more address space than the stdlib
|
||||
# parsers. The invoking clean_image.py subprocess applies these higher,
|
||||
# env-overridable caps instead of the default child limits in common.py.
|
||||
_CTRLREGEN_RLIMIT_AS = int(os.environ.get("WATERMARKS_CTRLREGEN_RLIMIT_AS", str(32 << 30)))
|
||||
_CTRLREGEN_RLIMIT_FSIZE = int(os.environ.get("WATERMARKS_CTRLREGEN_RLIMIT_FSIZE", str(2 << 30)))
|
||||
|
||||
|
||||
def ctrlregen_preexec_fn() -> None:
|
||||
"""Higher resource caps for the CtrlRegen subprocess (torch memory)."""
|
||||
try:
|
||||
import resource
|
||||
|
||||
resource.setrlimit(resource.RLIMIT_AS, (_CTRLREGEN_RLIMIT_AS, _CTRLREGEN_RLIMIT_AS))
|
||||
resource.setrlimit(resource.RLIMIT_FSIZE, (_CTRLREGEN_RLIMIT_FSIZE, _CTRLREGEN_RLIMIT_FSIZE))
|
||||
except (ImportError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
ctrlregen_subprocess_preexec_fn = ctrlregen_preexec_fn if os.name == "posix" else None
|
||||
|
||||
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
JPEG_SOI = b"\xff\xd8"
|
||||
|
||||
@@ -304,6 +324,89 @@ def run_synthid_score(
|
||||
return {"available": False, "error": f"bad scorer JSON: {e}"}
|
||||
|
||||
|
||||
def _ctrlregen_python(upstream: Path) -> str:
|
||||
"""Prefer the checkout venv so torch/diffusers are importable."""
|
||||
if os.name == "nt":
|
||||
venv = upstream / ".venv" / "Scripts" / "python.exe"
|
||||
else:
|
||||
venv = upstream / ".venv" / "bin" / "python"
|
||||
if venv.is_file():
|
||||
return str(venv)
|
||||
return sys.executable
|
||||
|
||||
|
||||
def run_ctrlregen_clean(
|
||||
path: Path,
|
||||
output: Path,
|
||||
*,
|
||||
upstream_dir: str | None = None,
|
||||
strength: float = 0.25,
|
||||
steps: int = 50,
|
||||
device: str | None = None,
|
||||
seed: int | None = None,
|
||||
timeout: int = 3600,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the optional CtrlRegen remover in a subprocess.
|
||||
|
||||
Returns ``{"available": False, "error": ...}`` when the remover is not
|
||||
configured, its dependencies are missing, or it fails at runtime; a
|
||||
successful run is ``{"available": True, ...}``.
|
||||
"""
|
||||
if upstream_dir is None:
|
||||
upstream_dir = os.environ.get("NOAI_WATERMARK_DIR")
|
||||
if not upstream_dir:
|
||||
return {
|
||||
"available": False,
|
||||
"error": "CtrlRegen not configured (set NOAI_WATERMARK_DIR or pass --ctrlregen-dir)",
|
||||
}
|
||||
|
||||
upstream = Path(upstream_dir).expanduser().resolve()
|
||||
if not upstream.is_dir():
|
||||
return {"available": False, "error": f"CtrlRegen dir not found: {upstream}"}
|
||||
|
||||
script = SCRIPTS_DIR / "clean_ctrlregen.py"
|
||||
cmd = [
|
||||
_ctrlregen_python(upstream),
|
||||
str(script),
|
||||
str(path),
|
||||
"-o",
|
||||
str(output),
|
||||
"--upstream-dir",
|
||||
str(upstream),
|
||||
"--strength",
|
||||
str(strength),
|
||||
"--steps",
|
||||
str(steps),
|
||||
"--json",
|
||||
]
|
||||
if device:
|
||||
cmd += ["--device", str(device)]
|
||||
if seed is not None:
|
||||
cmd += ["--seed", str(seed)]
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
preexec_fn=ctrlregen_subprocess_preexec_fn,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"available": False, "error": f"CtrlRegen timed out after {timeout}s"}
|
||||
except Exception as e:
|
||||
return {"available": False, "error": str(e)}
|
||||
|
||||
if r.returncode != 0:
|
||||
return {"available": False, "error": (r.stderr or "").strip()[:2000]}
|
||||
try:
|
||||
payload = json.loads(r.stdout or "{}")
|
||||
except json.JSONDecodeError as e:
|
||||
return {"available": False, "error": f"bad CtrlRegen adapter JSON: {e}"}
|
||||
payload["available"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def inspect_image(
|
||||
path: Path,
|
||||
synthid_dir: str | None = None,
|
||||
@@ -486,6 +589,13 @@ def clean_image(
|
||||
*,
|
||||
strip_all_metadata: bool = True,
|
||||
synthid_dir: str | None = None,
|
||||
remove_pixel: str | None = None,
|
||||
ctrlregen_dir: str | None = None,
|
||||
ctrlregen_strength: float = 0.25,
|
||||
ctrlregen_steps: int = 50,
|
||||
ctrlregen_device: str | None = None,
|
||||
ctrlregen_seed: int | None = None,
|
||||
ctrlregen_timeout: int = 3600,
|
||||
) -> dict[str, Any]:
|
||||
synthid_before = run_synthid_score(path, synthid_dir)
|
||||
data = path.read_bytes()
|
||||
@@ -519,6 +629,28 @@ def clean_image(
|
||||
except Exception as e:
|
||||
actions.append(f"exiftool failed: {e}")
|
||||
|
||||
pixel_removal: dict[str, Any] | None = None
|
||||
if remove_pixel:
|
||||
if remove_pixel != "ctrlregen":
|
||||
raise ValueError(f"unknown pixel remover: {remove_pixel}")
|
||||
pixel_removal = run_ctrlregen_clean(
|
||||
dest,
|
||||
dest,
|
||||
upstream_dir=ctrlregen_dir,
|
||||
strength=ctrlregen_strength,
|
||||
steps=ctrlregen_steps,
|
||||
device=ctrlregen_device,
|
||||
seed=ctrlregen_seed,
|
||||
timeout=ctrlregen_timeout,
|
||||
)
|
||||
if pixel_removal.get("available"):
|
||||
actions.append(f"CtrlRegen pixel removal (strength {ctrlregen_strength})")
|
||||
else:
|
||||
actions.append(
|
||||
"CtrlRegen pixel removal skipped: "
|
||||
f"{pixel_removal.get('error', 'unknown error')}"
|
||||
)
|
||||
|
||||
after = inspect_image(dest, synthid_dir=synthid_dir)
|
||||
return {
|
||||
"input": str(path),
|
||||
@@ -532,4 +664,5 @@ def clean_image(
|
||||
"post_findings": after.findings,
|
||||
"synthid_before": synthid_before,
|
||||
"synthid_after": after.synthid,
|
||||
"pixel_removal": pixel_removal,
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ def main() -> int:
|
||||
f"confidence {report.synthid.get('confidence', 0.0):.3f} "
|
||||
f"(watermarked: {label})"
|
||||
)
|
||||
if report.synthid.get("is_watermarked"):
|
||||
print(
|
||||
"Hint: optional pixel removal is available via "
|
||||
"clean_image.py IMG --remove-pixel ctrlregen "
|
||||
"--ctrlregen-dir $NOAI_WATERMARK_DIR"
|
||||
)
|
||||
elif report.synthid and report.synthid.get("error"):
|
||||
print(f"SynthID score: error: {report.synthid['error']}")
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Dependencies for the optional CtrlRegen pixel-removal backend
|
||||
# (mertizci/noai-watermark). The backend is cloned at a pinned commit by
|
||||
# setup_ctrlregen.sh and imported at runtime; it is never bundled.
|
||||
#
|
||||
# torch is installed separately in setup_ctrlregen.sh with the correct
|
||||
# platform index (CUDA or CPU), so it is intentionally not listed here.
|
||||
#
|
||||
# ML libs are pinned to versions the upstream CtrlRegen research code
|
||||
# (yepengliu/CtrlRegen) was built against. Validate against the pinned
|
||||
# noai-watermark commit before bumping.
|
||||
diffusers==0.27.2
|
||||
transformers==4.37.2
|
||||
accelerate==0.27.2
|
||||
controlnet-aux==0.0.9
|
||||
color-matcher==0.6.0
|
||||
safetensors==0.4.3
|
||||
Pillow==12.3.0
|
||||
piexif==1.1.3
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Bootstrap an external noai-watermark checkout for the optional CtrlRegen
|
||||
# pixel-removal backend.
|
||||
#
|
||||
# The upstream project (https://github.com/mertizci/noai-watermark) does not
|
||||
# ship a LICENSE file, so its code is treated as all-rights-reserved and is
|
||||
# NOT bundled in this repository. This script clones it locally and installs
|
||||
# only the dependencies needed by clean_ctrlregen.py.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_DIR="${NOAI_WATERMARK_DIR:-$HOME/noai-watermark}"
|
||||
DIR=""
|
||||
# Pinned upstream commit (2026-08-13). Do not point at a moving branch.
|
||||
REF="b642ae45d20eded52c96d570985eb4e3e427aac8"
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: setup_ctrlregen.sh [--dir PATH] [--ref REF] [--python PYTHON]
|
||||
|
||||
Clones (if needed) mertizci/noai-watermark, creates a venv, and installs the
|
||||
Python dependencies required by clean_ctrlregen.py (including torch).
|
||||
|
||||
Options:
|
||||
--dir PATH checkout directory (default: $NOAI_WATERMARK_DIR or ~/noai-watermark)
|
||||
--ref REF git ref to checkout (default: pinned commit SHA)
|
||||
--python PY Python interpreter used to create the venv (default: python3)
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--dir)
|
||||
DIR="${2:?--dir requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--ref)
|
||||
REF="${2:?--ref requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
--python)
|
||||
PYTHON="${2:?--python requires a value}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
DIR="${DIR:-$DEFAULT_DIR}"
|
||||
mkdir -p "$(dirname "$DIR")"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
DIR="$(realpath -m "$DIR")"
|
||||
else
|
||||
DIR="$(cd "$(dirname "$DIR")" && pwd)/$(basename "$DIR")"
|
||||
fi
|
||||
|
||||
if [[ ! -d "$DIR/.git" ]]; then
|
||||
echo "Cloning noai-watermark into $DIR (pinned ref: $REF)"
|
||||
git clone --depth 1 --filter=blob:none --sparse \
|
||||
https://github.com/mertizci/noai-watermark.git "$DIR"
|
||||
git -C "$DIR" fetch --depth 1 origin "$REF"
|
||||
git -C "$DIR" checkout --detach "$REF"
|
||||
git -C "$DIR" sparse-checkout set --no-cone '/src/'
|
||||
HEAD_SHA="$(git -C "$DIR" rev-parse HEAD)"
|
||||
if [[ "$HEAD_SHA" != "$REF" ]]; then
|
||||
echo "error: expected pinned ref $REF, got $HEAD_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "Using existing checkout: $DIR"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$DIR/.venv/bin/python" ]]; then
|
||||
echo "Creating venv at $DIR/.venv"
|
||||
"$PYTHON" -m venv "$DIR/.venv"
|
||||
fi
|
||||
|
||||
echo "Installing Python dependencies"
|
||||
# Pin pip itself (unpinned --upgrade pip was a supply-chain drift point).
|
||||
"$DIR/.venv/bin/python" -m pip install --upgrade "pip==26.2.1"
|
||||
|
||||
# Install torch with the right platform index before the other pinned deps.
|
||||
if command -v nvidia-smi >/dev/null 2>&1; then
|
||||
cuda="$(nvidia-smi 2>/dev/null | sed -n 's/.*CUDA Version: \([0-9]*\.[0-9]*\).*/\1/p' | head -1)"
|
||||
if [[ -n "$cuda" ]]; then
|
||||
tag="cu${cuda/./}"
|
||||
index="https://download.pytorch.org/whl/$tag"
|
||||
echo "NVIDIA GPU detected (CUDA $cuda); installing torch from $index"
|
||||
"$DIR/.venv/bin/python" -m pip install torch --index-url "$index"
|
||||
else
|
||||
echo "nvidia-smi present but no CUDA version found; installing default torch"
|
||||
"$DIR/.venv/bin/python" -m pip install torch
|
||||
fi
|
||||
else
|
||||
echo "No NVIDIA GPU detected; installing default torch (CPU/MPS)"
|
||||
"$DIR/.venv/bin/python" -m pip install torch
|
||||
fi
|
||||
|
||||
"$DIR/.venv/bin/python" -m pip install -r "$SCRIPT_DIR/requirements-ctrlregen.txt"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
Done. Remove a watermark with:
|
||||
|
||||
export NOAI_WATERMARK_DIR="$DIR"
|
||||
"$DIR/.venv/bin/python" "$SCRIPT_DIR/clean_ctrlregen.py" IMAGE -o OUT
|
||||
EOF
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Tests for the optional CtrlRegen pixel-watermark remover adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "skills" / "remove-ai-marks" / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import image_meta # noqa: E402
|
||||
from image_meta import run_ctrlregen_clean # noqa: E402
|
||||
|
||||
CLEAN_SCRIPT = SCRIPTS / "clean_ctrlregen.py"
|
||||
|
||||
FAKE_PIL = '''\
|
||||
class Image:
|
||||
def __init__(self, size=(10, 20)):
|
||||
self.size = size
|
||||
@staticmethod
|
||||
def open(path):
|
||||
return Image()
|
||||
def convert(self, mode):
|
||||
return self
|
||||
def load(self):
|
||||
return self
|
||||
def save(self, fp, format=None, **kwargs):
|
||||
fp.write(b"FAKEIMAGE")
|
||||
'''
|
||||
|
||||
|
||||
def _fake_engine(fail_run: bool) -> str:
|
||||
run_body = 'raise RuntimeError("model missing")' if fail_run else "return image"
|
||||
return (
|
||||
"class CtrlRegenEngine:\n"
|
||||
" def __init__(self, **kwargs):\n"
|
||||
" pass\n"
|
||||
" def run(self, image, strength=0.5, num_inference_steps=50, "
|
||||
"guidance_scale=2.0, seed=None):\n"
|
||||
f" {run_body}\n"
|
||||
"\n"
|
||||
"def is_ctrlregen_available():\n"
|
||||
" return True\n"
|
||||
)
|
||||
|
||||
|
||||
def _make_fake_upstream(tmp_path: Path, *, fail_run: bool = False) -> Path:
|
||||
upstream = tmp_path / "noai-watermark"
|
||||
ctrlregen = upstream / "src" / "ctrlregen"
|
||||
pil = upstream / "src" / "PIL"
|
||||
ctrlregen.mkdir(parents=True)
|
||||
pil.mkdir(parents=True)
|
||||
(ctrlregen / "__init__.py").write_text("")
|
||||
(pil / "__init__.py").write_text(FAKE_PIL)
|
||||
(ctrlregen / "engine.py").write_text(_fake_engine(fail_run))
|
||||
return upstream
|
||||
|
||||
|
||||
def _run_adapter(*args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env.pop("NOAI_WATERMARK_DIR", None)
|
||||
return subprocess.run(
|
||||
[sys.executable, str(CLEAN_SCRIPT), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _minimal_png() -> bytes:
|
||||
def chunk(ctype: bytes, payload: bytes) -> bytes:
|
||||
crc = zlib.crc32(ctype)
|
||||
crc = zlib.crc32(payload, crc) & 0xFFFFFFFF
|
||||
return struct.pack(">I", len(payload)) + ctype + payload + struct.pack(">I", crc)
|
||||
|
||||
sig = b"\x89PNG\r\n\x1a\n"
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
|
||||
idat = zlib.compress(b"\x00\x00\x00")
|
||||
return sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def test_cli_unavailable_without_upstream(tmp_path: Path):
|
||||
dummy = tmp_path / "img.png"
|
||||
dummy.write_bytes(b"not really an image")
|
||||
r = _run_adapter(str(dummy))
|
||||
assert r.returncode == 3
|
||||
assert "NOAI_WATERMARK_DIR" in (r.stderr or "")
|
||||
|
||||
|
||||
def test_cli_bad_input_missing_file(tmp_path: Path):
|
||||
r = _run_adapter(str(tmp_path / "missing.png"))
|
||||
assert r.returncode == 2
|
||||
|
||||
|
||||
def test_cli_bad_strength(tmp_path: Path):
|
||||
dummy = tmp_path / "img.png"
|
||||
dummy.write_bytes(b"x")
|
||||
r = _run_adapter(str(dummy), "--strength", "0")
|
||||
assert r.returncode == 2
|
||||
|
||||
|
||||
def test_cli_unavailable_missing_src_dir(tmp_path: Path):
|
||||
dummy = tmp_path / "img.png"
|
||||
dummy.write_bytes(b"x")
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
r = _run_adapter(str(dummy), "--upstream-dir", str(empty))
|
||||
assert r.returncode == 3
|
||||
|
||||
|
||||
def test_cli_json_success(tmp_path: Path):
|
||||
upstream = _make_fake_upstream(tmp_path)
|
||||
img = tmp_path / "img.png"
|
||||
img.write_bytes(b"x")
|
||||
out = tmp_path / "out.png"
|
||||
r = _run_adapter(
|
||||
str(img), "-o", str(out), "--upstream-dir", str(upstream), "--device", "cpu", "--json",
|
||||
)
|
||||
assert r.returncode == 0, r.stderr
|
||||
payload = json.loads(r.stdout)
|
||||
assert payload["available"] is True
|
||||
assert payload["output"] == str(out)
|
||||
assert out.read_bytes() == b"FAKEIMAGE"
|
||||
|
||||
|
||||
def test_cli_runtime_error(tmp_path: Path):
|
||||
upstream = _make_fake_upstream(tmp_path, fail_run=True)
|
||||
img = tmp_path / "img.png"
|
||||
img.write_bytes(b"x")
|
||||
r = _run_adapter(
|
||||
str(img), "-o", str(tmp_path / "out.png"), "--upstream-dir", str(upstream),
|
||||
"--device", "cpu", "--json",
|
||||
)
|
||||
assert r.returncode == 1
|
||||
assert "model missing" in (r.stderr or "")
|
||||
|
||||
|
||||
def test_cli_refuses_symlink_output(tmp_path: Path):
|
||||
upstream = _make_fake_upstream(tmp_path)
|
||||
img = tmp_path / "img.png"
|
||||
img.write_bytes(b"x")
|
||||
victim = tmp_path / "victim"
|
||||
victim.write_bytes(b"original")
|
||||
out = tmp_path / "out.png"
|
||||
try:
|
||||
out.symlink_to(victim)
|
||||
except OSError:
|
||||
pytest.skip("symlinks unavailable")
|
||||
r = _run_adapter(
|
||||
str(img), "-o", str(out), "--upstream-dir", str(upstream), "--device", "cpu", "--json",
|
||||
)
|
||||
assert r.returncode == 1
|
||||
assert victim.read_bytes() == b"original"
|
||||
|
||||
|
||||
def test_run_ctrlregen_clean_unconfigured_returns_unavailable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.delenv("NOAI_WATERMARK_DIR", raising=False)
|
||||
result = run_ctrlregen_clean(Path("x.png"), Path("y.png"))
|
||||
assert result["available"] is False
|
||||
assert "NOAI_WATERMARK_DIR" in result["error"]
|
||||
|
||||
|
||||
def test_run_ctrlregen_clean_success_parses_json(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
upstream = tmp_path / "upstream"
|
||||
upstream.mkdir()
|
||||
payload = {"available": True, "output": "/tmp/y.png", "device": "cpu"}
|
||||
captured: dict = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["kwargs"] = kwargs
|
||||
return SimpleNamespace(returncode=0, stdout=json.dumps(payload), stderr="")
|
||||
|
||||
monkeypatch.setattr(image_meta.subprocess, "run", fake_run)
|
||||
result = run_ctrlregen_clean(
|
||||
Path("x.png"),
|
||||
Path("y.png"),
|
||||
upstream_dir=str(upstream),
|
||||
strength=0.3,
|
||||
steps=40,
|
||||
device="cpu",
|
||||
seed=7,
|
||||
timeout=99,
|
||||
)
|
||||
|
||||
assert result["available"] is True
|
||||
assert result["device"] == "cpu"
|
||||
assert "--json" in captured["cmd"]
|
||||
assert captured["kwargs"]["timeout"] == 99
|
||||
if os.name == "posix":
|
||||
assert captured["kwargs"]["preexec_fn"] is image_meta.ctrlregen_subprocess_preexec_fn
|
||||
|
||||
|
||||
def test_run_ctrlregen_clean_unavailable_exit3(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
upstream = tmp_path / "upstream"
|
||||
upstream.mkdir()
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
return SimpleNamespace(returncode=3, stdout="", stderr="deps missing")
|
||||
|
||||
monkeypatch.setattr(image_meta.subprocess, "run", fake_run)
|
||||
result = run_ctrlregen_clean(Path("x.png"), Path("y.png"), upstream_dir=str(upstream))
|
||||
assert result["available"] is False
|
||||
assert "deps missing" in result["error"]
|
||||
|
||||
|
||||
def test_run_ctrlregen_clean_prefers_venv_python(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
upstream = tmp_path / "upstream"
|
||||
(upstream / ".venv" / "bin").mkdir(parents=True)
|
||||
venv_python = upstream / ".venv" / "bin" / "python"
|
||||
venv_python.write_text("")
|
||||
captured: dict = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
return SimpleNamespace(returncode=0, stdout="{}", stderr="")
|
||||
|
||||
monkeypatch.setattr(image_meta.subprocess, "run", fake_run)
|
||||
run_ctrlregen_clean(Path("x.png"), Path("y.png"), upstream_dir=str(upstream))
|
||||
assert captured["cmd"][0] == str(venv_python)
|
||||
|
||||
|
||||
def test_clean_image_ctrlregen_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("NOAI_WATERMARK_DIR", raising=False)
|
||||
monkeypatch.delenv("REVERSE_SYNTHID_DIR", raising=False)
|
||||
src = tmp_path / "t.png"
|
||||
src.write_bytes(_minimal_png())
|
||||
dest = tmp_path / "t.cleaned.png"
|
||||
captured: dict = {}
|
||||
|
||||
def fake_rc(path, output, **kwargs):
|
||||
captured["path"] = path
|
||||
captured["output"] = output
|
||||
captured["kwargs"] = kwargs
|
||||
return {"available": True, "device": "cpu"}
|
||||
|
||||
monkeypatch.setattr(image_meta, "run_ctrlregen_clean", fake_rc)
|
||||
result = image_meta.clean_image(
|
||||
src,
|
||||
dest,
|
||||
remove_pixel="ctrlregen",
|
||||
ctrlregen_dir="/tmp/upstream",
|
||||
ctrlregen_strength=0.3,
|
||||
)
|
||||
|
||||
assert result["pixel_removal"]["available"] is True
|
||||
assert any("CtrlRegen pixel removal" in a for a in result["actions"])
|
||||
assert captured["path"] == dest
|
||||
assert captured["output"] == dest
|
||||
assert captured["kwargs"]["strength"] == 0.3
|
||||
Reference in New Issue
Block a user