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:
Guillaume Meyer (The Opinionated Man)
2026-08-13 08:53:53 -07:00
parent 396c83dbae
commit 8d8fe7ad84
14 changed files with 1013 additions and 11 deletions
+63
View File
@@ -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"]
+18 -1
View File
@@ -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
+95 -6
View File
@@ -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)
+18 -1
View File
@@ -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
+1 -1
View File
@@ -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
+267
View File
@@ -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