Delete every knob the fixed profiles cannot honor

The CLI still advertised --model, --steps, --guidance-scale, --device and a
deprecated --auto. Each pinned a value the two surviving profiles fix -- the
model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only
outcome any of them had was an error raised several frames below the caller,
under a message naming an internal profile. A flag whose sole result is a
refusal is worse than no flag: it advertises a capability that does not exist,
and it lets a wrapper thread a value that will silently do nothing. They are
gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the
failure is now a TypeError or a Click "No such option" at the point the caller
can act on.

The install hint was wrong in the same way. is_available() checked torch and
diffusers, then told the user to install [diffusion] -- which contains neither
DiffSynth nor the Z-Image face stage both profiles run. Following the advice
produced a second, different failure. The module list and the extra name now
live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read
by both the CLI gate and the remover's precondition, which cannot drift apart
because they are the same tuple.

The adaptive-polish default moved out of the argument parser. It was resolved by
reading Click's parameter source, which put per-profile data in the CLI layer,
left the engine declaring the opposite default (False vs True) so a library
caller and a CLI caller on one profile got different output, and lost the polish
entirely for anything that supplies the flag non-interactively. The flag is now
tri-state (default=None) and resolve_adaptive_polish owns the per-profile
answer. The seed follows the same rule: the CLI stopped pre-resolving it.

Dead code removed with it: six scan_*_video wrappers and the _scan_video helper
none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and
the remover region path that was only reachable from a no-caller convenience
wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the
_generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal
PEP 562 shim that no caller ever went through. get_device now answers cuda or
cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while
costing a device probe each, and that refusal now names the resolved device, so
device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel
index went with them.

Docs: README, cli, installation, python-api, supported-signals,
known-limitations and module-internals all still described the removed profiles,
the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra.
known-limitations still listed the retired SDXL strength ladder as current.
scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps.

Next release is 0.25.0, not a patch: this removes public parameters and
narrows a published extra on top of the released 0.24.0.

pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped
(dropping the `device` parameter entirely, which raiw-app pins; folding
diffsynth into the `diffusion` extra, which video-only callers do not need);
3) docs sync - grepped every removed identifier across README, docs/, scripts/,
.claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to
.claude/rules/development.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-08-03 15:38:40 -07:00
co-authored by Claude Opus 5
parent bf4bfc1ab7
commit 52b2c115e8
28 changed files with 543 additions and 955 deletions
+14 -10
View File
@@ -13,20 +13,20 @@ WHAT IT COVERS
metadata real AI-metadata files -> --check detects, --remove strip-and-verifies clean
visible real marked images per mark -> the mark is gone on re-detect, output written
erase a real image, each fill backend (cv2 / migan / lama) -> output written
invisible a real SynthID image on MPS at reduced resolution -> a CHANGED image is written
invisible a real SynthID image at reduced resolution -> a CHANGED image is written
all a real marked image through the full pipeline -> output written
batch a real directory -> every input produces an output
invisible/all run the diffusion model, so they are gated behind --diffusion and run at a
small --max-resolution on MPS (the user's "reduced size on MPS" path). Everything else is
cv2/numpy and fast.
small --max-resolution. Both profiles are CUDA-only, so that section needs an NVIDIA
GPU; everything else is cv2/numpy, fast, and runs anywhere.
DATA SAFETY
Treat input datasets as sensitive and read-only. Output stays in a gitignored temp
dir. Records example uids and pass/fail, never image content.
uv run python scripts/real_examples_e2e.py # fast surface (no diffusion)
uv run python scripts/real_examples_e2e.py --diffusion # + invisible/all on MPS
uv run python scripts/real_examples_e2e.py --diffusion # + invisible/all (needs CUDA)
"""
from __future__ import annotations
@@ -239,12 +239,16 @@ def check_erase(res: Results, tmp: Path) -> None:
def check_diffusion(res: Results, tmp: Path, gemini_src: str, openai_src: str) -> None:
"""The GPU path: invisible + all on MPS at reduced resolution -> a CHANGED image."""
"""The GPU path: invisible + all at reduced resolution -> a CHANGED image.
CUDA-only. On a machine without an NVIDIA GPU every row here fails with the
library's clean refusal; run this section on a GPU box.
"""
import numpy as np
from remove_ai_watermarks.image_io import imread
print("\ninvisible / all -- real SynthID image on MPS, reduced resolution")
print("\ninvisible / all -- real SynthID image, reduced resolution (CUDA required)")
for label, src in (("invisible/gemini", gemini_src), ("invisible/openai", openai_src)):
if not src:
res.add(label, "-", True, "no real positive found (skipped)")
@@ -252,7 +256,7 @@ def check_diffusion(res: Results, tmp: Path, gemini_src: str, openai_src: str) -
sp = Path(src)
outp = tmp / f"inv_{sp.stem}.png"
code, out = run(
["invisible", src, "-o", str(outp), "--device", "mps", "--max-resolution", "512", "--seed", "0"],
["invisible", src, "-o", str(outp), "--max-resolution", "512", "--seed", "0"],
timeout=1200,
)
if not outp.exists():
@@ -276,7 +280,7 @@ def check_diffusion(res: Results, tmp: Path, gemini_src: str, openai_src: str) -
sp = Path(gemini_src)
outp = tmp / f"all_{sp.stem}.png"
code, out = run(
["all", gemini_src, "-o", str(outp), "--device", "mps", "--max-resolution", "512", "--seed", "0"],
["all", gemini_src, "-o", str(outp), "--max-resolution", "512", "--seed", "0"],
timeout=1200,
)
ok = outp.exists() and outp.stat().st_size > 0
@@ -304,7 +308,7 @@ def check_batch(res: Results, tmp: Path) -> None:
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--diffusion", action="store_true", help="also run invisible/all on MPS (slow)")
ap.add_argument("--diffusion", action="store_true", help="also run invisible/all (slow, needs CUDA)")
ap.add_argument("--gemini", default="")
ap.add_argument("--openai", default="")
a = ap.parse_args()
@@ -320,7 +324,7 @@ def main() -> None:
if a.diffusion:
check_diffusion(res, tmp, a.gemini, a.openai)
else:
print("\n(diffusion skipped -- pass --diffusion to run invisible/all on MPS)")
print("\n(diffusion skipped -- pass --diffusion to run invisible/all)")
raise SystemExit(res.report())
+23 -23
View File
@@ -17,9 +17,10 @@ WHAT IT COVERS AND WHY THAT SHAPE
work" report); a no-op must be byte-identical; `metadata --remove` must actually
strip; a JPEG strip must not touch the pixels. Note the pixel-lossless contract is
the DEFAULT path's -- `--remove-all` deliberately re-encodes (see metadata.py).
* The diffusion bodies under `--diffusion`, at `--max-resolution 512` so they fit MPS
(~1 min/image on 32 GB unified memory). Not just exit codes: `invisible` must
restore the input resolution and must NOT re-stamp SDXL's own open watermark.
* The diffusion bodies under `--diffusion`, at a small `--max-resolution`. Both
profiles are CUDA-only, so those rows need an NVIDIA GPU and are reported as
skips elsewhere. Not just exit codes: `invisible` must restore the input
resolution and must NOT re-stamp SDXL's own open watermark.
WHAT IT DOES NOT COVER, DELIBERATELY AND LOUDLY
* Without `--diffusion`, the model-running bodies are reported as SKIPPED with a
@@ -34,7 +35,7 @@ WHAT IT DOES NOT COVER, DELIBERATELY AND LOUDLY
uv run python scripts/smoke_matrix.py # corpus + fixtures
uv run python scripts/smoke_matrix.py --quick # fixtures only, no corpus
uv run python scripts/smoke_matrix.py --diffusion # + the SDXL model paths
uv run python scripts/smoke_matrix.py --diffusion # + the model paths (needs CUDA)
"""
from __future__ import annotations
@@ -113,7 +114,7 @@ def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--quick", action="store_true", help="fixtures only; skip the corpus rows")
ap.add_argument(
"--diffusion", action="store_true", help="also run the model-running paths (SDXL weights, ~1 min/image)"
"--diffusion", action="store_true", help="also run the model-running paths (needs CUDA, ~1 min/image)"
)
a = ap.parse_args()
@@ -359,10 +360,12 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
per-knob oracle and most of them have none (`--humanize` has no oracle at all), so
claiming more here would be dishonest.
"""
# Both surviving profiles are CUDA-only and pin a distilled four-step schedule at
# CFG 1.0, so most rows here now assert a knob is REJECTED rather than accepted.
# That is the coverage worth having: a knob the CLI takes and the library refuses
# several layers down is exactly what this matrix exists to catch.
# Both surviving profiles are CUDA-only, pin a fixed model stack and let each stage
# own its schedule and CFG. The knobs that used to contradict that (--model, --steps,
# --guidance-scale, --device, --auto) are gone from the parser, so the rows below
# assert Click itself refuses them. That is the coverage worth having: an option the
# CLI accepts and the library refuses several layers down is what this matrix exists
# to catch, and the cheapest way to keep it caught is for the option not to exist.
fast = ["--max-resolution", "384", "--force", "--seed", "0"]
def run(name: str, extra: list[str], *, tag: str, expect: int | None = 0) -> None:
@@ -377,10 +380,15 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
for retired in ("sdxl", "controlnet", "qwen", "default"):
run(f"--pipeline {retired} is rejected", ["--pipeline", retired], tag=f"retired_{retired}", expect=2)
# Fixed-graph knobs: accepted by Click, refused by the library (exit 1).
run("--steps 20 is rejected", ["--steps", "20"], tag="steps20", expect=1)
run("--guidance-scale 5.0 is rejected", ["--guidance-scale", "5.0"], tag="gs5", expect=1)
run("--model override is rejected", ["--model", "org/custom"], tag="model", expect=1)
# Retired options: no longer parsed at all (exit 2, "No such option").
for args, tag in (
(["--steps", "20"], "steps20"),
(["--guidance-scale", "5.0"], "gs5"),
(["--model", "org/custom"], "model"),
(["--device", "cpu"], "devcpu"),
(["--auto"], "auto"),
):
run(f"{args[0]} is no longer an option", args, tag=tag, expect=2)
try:
import torch
@@ -393,11 +401,6 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
# Without CUDA the honest outcome is a CLEAN refusal naming the reason, not a
# traceback from inside a half-built pipeline.
run("no CUDA fails cleanly", [], tag="nocuda", expect=1)
for name, extra, tag in (
("--device cpu fails cleanly", ["--device", "cpu"], "cpu"),
("--device mps fails cleanly", ["--device", "mps"], "mps"),
):
run(name, extra, tag=tag, expect=1)
for label in (
"--strength",
"--controlnet-scale",
@@ -405,7 +408,6 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
"--unsharp",
"--no-adaptive-polish",
"--tile",
"--auto",
"--pipeline sdxl-zimage",
):
r.skip(label, "accepted-knob rows need a CUDA device")
@@ -418,11 +420,9 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
run("--unsharp", ["--unsharp", "0.5"], tag="uns")
run("--no-adaptive-polish", ["--no-adaptive-polish"], tag="nap")
run("--tile", ["--tile", "--tile-size", "256", "--tile-overlap", "64"], tag="tile")
run("--auto (deprecated, requests polish only)", ["--auto"], tag="auto")
# --model and --hf-token are deliberately not exercised: one would download a second
# multi-GB checkpoint, the other needs a real credential. Skipped loudly, not passed.
r.skip("--model", "would download a second multi-GB checkpoint")
# --hf-token needs a real credential, so it cannot be exercised meaningfully here.
# Skipped loudly rather than silently passed over.
r.skip("--hf-token", "needs a real credential; cannot be exercised meaningfully here")
# CONTRACT, not just execution: the same seed must reproduce the same pixels.