Compare commits

..
5 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 5 38399e4eb0 Release 0.25.0
Breaking, on top of the released 0.24.0.

Removed from the CLI: --model, --steps, --guidance-scale, --device, and the
deprecated --auto. Removed from InvisibleEngine and WatermarkRemover: the
model_id, num_inference_steps and guidance_scale parameters, remove_watermark_batch,
and the remover's region/region_feather path. Each pinned a value the two
remaining profiles fix -- the model stack, the per-stage distilled schedule,
CFG 1.0, CUDA -- so their only outcome was an error raised several frames below
the caller.

Also removed: the public remove_ai_watermarks.upscaler module, the
--min-resolution and --upscaler options and the published esrgan extra (0.24.0
shipped them unreachable); the pytorch-xpu index; and "diffusion" from the "all"
extra, which qwen-zimage already pulls.

Behaviour changes a caller can see:
- invisible-watermark removal now requires the qwen-zimage extra, not diffusion.
  Both profiles run the DiffSynth Z-Image face stage, so a torch+diffusers-only
  environment used to pass the availability gate and then die there. Every
  install hint names qwen-zimage now.
- get_device() answers cuda or cpu only, and the CUDA-only refusal names the
  resolved device rather than the raw argument.
- adaptive_polish is tri-state. Unset follows the profile (off for qwen-zimage,
  on for sdxl-zimage) and is resolved inside the engine, so a library caller and
  a CLI caller on one profile now produce the same pixels; they did not before.

ComfyUI node 0.1.15 is already published and tracks this surface.

pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - n/a, version bump only; 3) docs sync - version
appears in pyproject.toml, __init__.py and uv.lock, all three updated; 4)
CLAUDE.md - no rule change

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:59:10 -07:00
Victor KuznetsovandClaude Opus 5 2f72257996 Fix what the verification pass found in the knob removal
An adversarial review of 52b2c11 (five independent audits, each finding put to
two skeptics, plus a completeness critic) found four defects that commit
introduced and several stale claims it should have caught.

The install hint no longer installs -- again. Folding five hints into one
INVISIBLE_EXTRA constant dropped the shell quoting the originals had, so the
printed remediation was `pip install remove-ai-watermarks[qwen-zimage]`. Bare
brackets are a glob in zsh, the macOS default shell: it dies with "no matches
found" before pip runs. That is the exact failure 52b2c11 existed to stop
producing, reintroduced in a different form by a bulk replace. The constant is
quoted now, and a test asserts the quotes rather than the bare substring -- the
old assertions passed either way, which is why nothing caught it.

Three tests were not guarding what they claimed:

- The commit's headline behaviour change, per-profile polish resolution inside
  the engine, had no test at all. Rebinding resolve_adaptive_polish to the
  pre-commit `bool(value)` left the full suite green. Now covered by a test that
  drives the real engine and observes whether humanizer.adaptive_polish ran;
  that mutation now fails it.
- TestAvailability still asserted the pre-commit (torch, diffusers) contract, so
  in a diffusion-only environment it was simply wrong, and comparing each gate to
  a tuple copied from itself could never catch the two gates disagreeing -- the
  drift the shared REMOVAL_MODULES was introduced to prevent. Replaced with a
  test that simulates each module's absence and requires BOTH gates to close.
- Both CUDA-refusal guards skipped in every environment, including CI: they were
  gated on the diffusion stack, which no CI job installs. The refusal fires
  before any torch attribute is read, so they now run everywhere; only the dtype
  assertion keeps its skip.

Also: the retired-knob test covered `invisible` but not `all` or `batch`, though
all three declared those options separately; and smoke_matrix.py still called
remove_watermark(region=...), a parameter 52b2c11 deleted, with the resulting
TypeError swallowed into a skip by a broad except.

Stale documentation the previous sweep missed: known-limitations still described
an MPS out-of-memory fallback and a lighter-pipeline escape that no code can
produce; module-internals declared Canny thresholds of 100/200 as a compatibility
contract while the code uses 13/64, attributed enable_model_cpu_offload to
deleted profiles, and still warned that the engine and CLI defaults differ (this
commit's predecessor made them identical); cli.md gated `all` on the `diffusion`
extra; python-api claimed "cuda" was the only accepted explicit device when
"auto" is too. The claim that `device` is not a parameter was wrong in both
module-internals and .claude/rules/development.md -- it is one, deliberately, and
now says so. `--cpu-offload` help and the pipeline's CUDA guard both still
pointed at MPS.

Not fixed here, reported instead -- both are outside this repo:
- ComfyUI-remove-ai-watermarks nodes.py:332 passes num_inference_steps and
  guidance_scale (plus min_resolution/upscaler from bf4bfc1). distribute.yml's
  comfyui job runs on every release and fails the release if the node sync fails,
  so 0.25.0 needs that node updated first.
- raiw-app modal_app.py:422-425 forwards the same two kwargs into
  remove_watermark. Latent: it is pinned to 1a77e24 and nothing supplies a value
  today, so it fires on the next pin bump.

pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - not re-run, this commit is the applied output of
a five-dimension adversarial review; 3) docs sync - grepped MPS/mps, the extras
names and every symbol touched across README, docs/, scripts/, .claude/; updated
6 docs; 4) CLAUDE.md - corrected the device claim in .claude/rules/development.md
and added the shell-quoting rule

Verified by execution, not assertion: smoke_matrix --quick 51 pass / 0 fail,
_knob_rows driven directly 10 pass / 0 fail / 7 skip (no CUDA), the install hint
rendered and round-tripped through zsh, and each new test confirmed to fail
under the mutation it is meant to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:31:39 -07:00
Victor KuznetsovandClaude Opus 5 52b2c115e8 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>
2026-08-03 15:38:40 -07:00
Victor KuznetsovandClaude Opus 5 bf4bfc1ab7 Remove the unreachable ESRGAN upscale chain
The min-resolution floor lifted small inputs toward SDXL's ~1024 training size,
and Real-ESRGAN was an optional way to do that lifting. Both surviving profiles
run at native geometry, so the engine forced the floor to 0 on every path; the
floor never fired, `upscaling` was never true, and nothing downstream of it could
execute. Gone: upscaler.py, _esrgan_upscale, the min_resolution and upscaler
parameters, _target_size's floor branch, --min-resolution, --upscaler,
_warn_if_esrgan_unavailable and the `esrgan` extra. max_resolution stays and is
now the only lever on geometry; it can only scale down.

scripts/smoke_matrix.py was the one live consumer and neither gate saw it -
Pyright is scoped to src/ and Ruff cannot resolve its function-local import - so
`--diffusion` would have died at import. Its knob rows were written for the
removed profiles besides (--pipeline sdxl, --steps 20, --guidance-scale 5.0,
--device mps), so they are rewritten rather than patched: most now assert a knob
is REJECTED, which is the coverage worth having when the CLI accepts a value the
library refuses several layers down. Accepted-knob rows skip without CUDA, so the
row count is host-dependent and verification-plan.md no longer claims a fixed 68.

This removes a public module, a CLI option and a published extra, so the next
release is 0.25.0, not a patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:54:26 -07:00
github-actions[bot] 95a6964e04 Sync conda recipe with v0.24.0 2026-08-03 21:27:17 +00:00
33 changed files with 789 additions and 1540 deletions

No files matched your search

+6
View File
@@ -11,6 +11,12 @@ Every single-image command declares `source` with `dir_okay=False`; `batch` decl
Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it.
Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it.
`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. It is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds.
The same rule applies to install hints: name the extra that actually makes the command work (`qwen-zimage`, not `diffusion`), and keep the printed command shell-quoted -- bare `pkg[extra]` is a glob in zsh.
## Local gate
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
+14 -12
View File
@@ -52,11 +52,11 @@ removal.
| Visible video processing | `remove-ai-watermarks[video]` |
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
| Diffusion removal | `remove-ai-watermarks[diffusion]` |
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
| Every production feature | `remove-ai-watermarks[all]` |
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
`migan`, `lama`, `esrgan`, and `qwen-zimage`. The
`migan`, `lama`, and `diffusion`. The
[installation guide](docs/installation.md#feature-extras) documents their exact
dependency composition and model requirements.
@@ -180,10 +180,11 @@ check. Google does not publish a local decoder, so a fresh provider check
remains useful for unusually important files or after provider changes, but it
is not a product result state.
For invisible watermark removal, install the diffusion dependencies:
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback.
```bash
uv tool install --force "remove-ai-watermarks[diffusion]"
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
remove-ai-watermarks invisible image.png -o clean.png
```
@@ -207,14 +208,14 @@ features, and development setup.
### High quality invisible removal
The `qwen-zimage` profile is the highest fidelity option for face heavy images.
It is CUDA only and uses a much larger model stack than the default ControlNet
profile.
`qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny
ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
stage. Both are CUDA only.
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --force
remove-ai-watermarks invisible image.png -o clean.png --force
```
| OpenAI example before | OpenAI example after |
@@ -273,7 +274,7 @@ remove-ai-watermarks invisible image.png -o clean.png \
```
CPU offload lowers CUDA memory pressure by moving model components between CPU
and GPU. It is slower and has no effect on CPU or MPS.
and GPU, at the cost of speed.
### Process a directory
@@ -378,8 +379,9 @@ invisible removal.
The shipped profile is oracle-certified, but no public local decoder can
certify an arbitrary output at runtime. Recheck unusually important outputs
after provider changes.
- `qwen-zimage` requires CUDA. The other diffusion profiles also support the
devices listed by `remove-ai-watermarks invisible --help`.
- Invisible-watermark removal requires CUDA. Both profiles refuse any other
device at construction rather than falling back to one that cannot run them.
Visible removal, metadata stripping and `identify` still run anywhere.
- Provider watermark systems can change. Validate important outputs with the
provider's own verifier when one is available.
+11 -8
View File
@@ -20,8 +20,7 @@ defaults. This page focuses on choosing the right command.
| `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
| `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` |
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
| `invisible` | `remove-ai-watermarks[diffusion]` |
| `invisible --pipeline qwen-zimage` | `remove-ai-watermarks[qwen-zimage]` |
| `invisible` and `all` (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
| `video metadata` and `video identify --no-visible` | Default package |
| `video identify`, `video visible`, and visible/all batch modes | `remove-ai-watermarks[video]` |
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` |
@@ -340,10 +339,11 @@ failed encode does not overwrite an existing result.
## Remove invisible watermarks
Install the diffusion dependencies first:
Install the removal dependencies first. Both profiles are CUDA-only and both
run the DiffSynth Z-Image face stage, so this is the extra either one needs:
```bash
uv tool install --force "remove-ai-watermarks[diffusion]"
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
```
Then run:
@@ -380,8 +380,11 @@ remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --force
```
The legacy `default` value is an alias for `sdxl`. The `--auto` option is
deprecated, emits a warning, and changes nothing.
There is no `--model`, `--steps`, `--guidance-scale` or `--device` option, and the
deprecated `--auto` is gone. Each profile pins its model stack, its per-stage
schedule, CFG 1.0 and CUDA, so every one of those flags existed only to be refused
several layers down. They are not parsed at all now, which fails at the point the
user can act on rather than after a model load.
### Work with limited memory
@@ -415,7 +418,7 @@ It is a memory strategy, not a guarantee of better quality.
The `all` command and the `all` installation extra are separate concepts. The
command runs every applicable stage. Installing `remove-ai-watermarks[all]`
makes every production backend available; a smaller installation such as
`remove-ai-watermarks[visible,diffusion]` can also run the command with fewer
`remove-ai-watermarks[visible,qwen-zimage]` can also run the command with fewer
optional backends.
```bash
@@ -430,7 +433,7 @@ The command runs:
The visible options and diffusion options are also available on `all`.
If diffusion is required but the `diffusion` extra is unavailable, `all` still
If invisible removal is required but the `qwen-zimage` extra is unavailable, `all` still
writes the result of the visible and metadata stages, prints a prominent
warning, and exits with code 1. This prevents a partial result from being
reported as complete.
+19 -17
View File
@@ -65,22 +65,24 @@ uv tool install --force "remove-ai-watermarks[video,diffusion]"
## Invisible watermark removal
Diffusion based removal needs the `diffusion` extra:
```bash
uv tool install --force "remove-ai-watermarks[diffusion]"
```
The code supports CUDA, XPU, MPS, and CPU devices. A GPU is recommended because
CPU inference is slow.
For the CUDA only Qwen Image plus Z-Image profile:
Install the `qwen-zimage` extra:
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
```
The `qwen-zimage` extra includes the normal `diffusion` dependencies.
Both remaining profiles run a Z-Image face stage on the DiffSynth runtime, so
both need this extra. It includes the `diffusion` dependencies; `diffusion` on its
own covers the torch and diffusers imports but not the face stage, so it is not
enough to run a removal.
**An NVIDIA GPU is required.** `qwen-zimage` and `sdxl-zimage` are CUDA-only, and
construction refuses any other device rather than falling back to a slow or broken
one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark
removal, metadata stripping and every `identify` command still run anywhere.
Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS;
it needs the `diffusion` extra, not this one.
## Feature extras
@@ -95,11 +97,10 @@ application actually uses:
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
| `trustmark` | Adobe TrustMark detection | trustmark | Yes |
| `diffusion` | Diffusion-based invisible watermark removal | `pixels`, Torch, Diffusers | Yes |
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes |
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
| `esrgan` | Real-ESRGAN upscaling before diffusion | `pixels`, spandrel | Yes |
| `qwen-zimage` | CUDA-only Qwen Image plus Z-Image pipeline | `diffusion`, DiffSynth | Yes |
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
| `all` | Every production feature | All rows above | Yes |
| `dev` | Tests, linting, typing, and upstream parity checks | `visible`, `detect`, upstream invisible-watermark | Yes, for parity tests |
@@ -113,7 +114,6 @@ flowchart LR
diffusion --> pixels
migan --> visible
lama --> visible
esrgan --> pixels
qwen["qwen-zimage"] --> diffusion
heif
trustmark
@@ -216,5 +216,7 @@ The normal behavior is to skip diffusion when no supported local signal is
found. A missing signal does not prove that the image is clean. If you know the
image came from a relevant generator, use `--force`.
If the CLI reports that diffusion dependencies are unavailable, install the
`diffusion` extra. Video SynthID removal needs both `video` and `diffusion`.
If the CLI reports that the removal dependencies are unavailable, install the
`qwen-zimage` extra. `diffusion` alone covers Torch and Diffusers but not the
DiffSynth face stage that both profiles run. Video SynthID removal is a separate
path and needs `video` and `diffusion`.
+46 -34
View File
@@ -120,43 +120,53 @@ prefix so it can reuse identical latents across candidate strengths.
### Strength is content and seed dependent
For SDXL and ControlNet, the CLI resolves an unset strength from the detected
vendor:
The two profiles resolve an unset strength differently, because different things
were measured for each.
- OpenAI: `0.10`;
- Google: `0.15`;
- unknown: `0.15`.
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
curve. The vendor is deliberately ignored: the curve, not the issuer, is what was
calibrated.
An explicit `--strength` overrides these defaults. The defaults are operating
points, not universal guarantees. Near a removal threshold, different content
or a different random seed may change the verifier result.
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
The base Qwen and `qwen-zimage` profiles have profile specific strength
behavior. Consult `remove-ai-watermarks invisible --help` and the source of
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py)
for the current resolver.
- OpenAI: `0.15`;
- Google: `0.25`;
- unknown: `0.25`, following the stricter of the two.
An SDXL global pass needs more denoise than Qwen at the same fidelity, and the
values are flat rather than a curve because flat values are what was measured: each
verdict came from a fixed strength at one size, and no size dependence has been
established for that stage.
An explicit `--strength` overrides both. The defaults are operating points, not
universal guarantees. Near a removal threshold, different content or a different
random seed may change the verifier result, which is why both profiles are
certified at a fixed seed. The live resolver is
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py).
### Pipelines have different quality tradeoffs
| Pipeline | Main limit |
| --- | --- |
| `controlnet` | Edge conditioning can preserve a watermark carrying region too closely, and faces may drift. |
| `sdxl` | Flat graphics and precise structure may receive too little or unhelpful change. |
| `qwen` | Large CUDA oriented model; face smoothing can still be significant. |
| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. |
| `sdxl-zimage` | CUDA only. Its strength ladder is flat per vendor, not a resolution curve, because flat values are what was measured. |
The legacy `default` profile name maps to `sdxl`. The `--auto` flag is
deprecated, emits a warning, and changes nothing.
The `controlnet`, `sdxl`, `qwen` and `default` profiles were removed, not aliased
onward: a retired name is rejected at parse time rather than routed into a profile
the caller never chose. There is no `--model`, `--steps`, `--guidance-scale`,
`--device` or `--auto` option either; each profile pins its model stack, its
per-stage schedule, CFG 1.0 and CUDA.
## Resolution and memory
### Small images are enlarged before SDXL based diffusion
### Small images are processed at their native size
The SDXL, ControlNet, and base Qwen paths use a default minimum long side of
`1024`. Smaller inputs are enlarged before diffusion and restored to their
original dimensions afterward. Set `--min-resolution 0` to disable the floor.
There is no minimum-resolution floor. It existed to enlarge small inputs toward
SDXL's ~1024 training resolution and was removed with the SDXL profiles, which
never applied it anyway. Both surviving profiles run at native geometry, so a
small input is neither enlarged before diffusion nor restored afterward.
`qwen-zimage` does not apply this SDXL minimum resolution floor.
`--max-resolution` still caps very large inputs, and only ever scales down.
### Large images stay at native resolution unless capped
@@ -169,23 +179,25 @@ overlapping tiles. It avoids the explicit downscale, but it is not pixel
lossless: each tile is independently regenerated. With `qwen-zimage`, only the
global Qwen stage is tiled; the face stage runs after tile blending.
### CPU offload is CUDA only
### CPU offload trades speed for VRAM
`--cpu-offload` moves Diffusers model components between CPU and CUDA instead of
keeping the complete standard pipeline in GPU memory. For `qwen-zimage`, it
forces the face stack to use its offload path.
`--cpu-offload` forces both stacks of the two-stage profile out of automatic
device residency, streaming weights instead of pinning them. It reduces CUDA
memory pressure at the cost of speed.
The option reduces CUDA memory pressure at the cost of speed. It has no effect
on CPU or MPS and fails loudly when a CUDA Diffusers pipeline does not expose
the required offload method.
Residency is otherwise chosen from the card's total VRAM. On a card large enough
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
device and re-reads every parameter from disk on each stage transition.
### MPS may fall back to CPU
### There is no CPU or MPS fallback
The SDXL paths include an MPS out-of-memory fallback that reloads on CPU. A run
that appears much slower after an MPS failure may be continuing on CPU.
Invisible-watermark removal refuses any device but CUDA at construction. There is
no MPS out-of-memory fallback that continues on CPU, and no lighter profile to
drop to: both remaining profiles need an NVIDIA GPU.
Memory needs depend on the pipeline, input size, dtype, and machine. Use tiling,
a resolution cap, or a lighter pipeline when necessary.
When a run does not fit, the levers are `--tile` (native geometry, tiled
diffusion), `--max-resolution` (an explicit downscale), and `--cpu-offload`.
Memory needs depend on the profile, input size, dtype, and card.
## Metadata and formats
+77 -36
View File
@@ -53,9 +53,19 @@ The decorators for diffusion options are shared by `invisible`, `all`, and
`batch`. The runtime help generated by Click is the source of truth for option
names and defaults.
The deprecated `--auto` option does not select a pipeline or change adaptive
polishing. [`_resolve_auto_polish`](../src/remove_ai_watermarks/cli.py) emits a
warning and returns the explicit polish value unchanged.
`--adaptive-polish` is tri-state: it declares `default=None`, so "the user did not
choose" is a value the CLI passes through rather than a default it has to invent.
`resolve_adaptive_polish` in `watermark_profiles.py` turns that `None` into the
profile's answer (off for `qwen-zimage`, whose output already matches the input's
detail level; on for `sdxl-zimage`). The same call runs inside
`InvisibleEngine.remove_watermark`, so a library caller and a CLI caller on one
profile get the same output.
It used to read Click's parameter source in the CLI instead. That put per-profile
data in the argument-parsing layer, left the engine declaring the opposite default,
and silently lost the polish for anything supplying the flag non-interactively (an
envvar default or a wrapper calling `main()` with a defaulted list is classified
`DEFAULT`). The seed follows the same rule: the CLI does not pre-resolve it either.
Regression coverage:
@@ -477,48 +487,75 @@ Regression coverage:
[`_internal/watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py)
is the source of truth for:
- profile aliases;
- default model identifiers;
- default steps and seeds;
- vendor-adaptive strength resolution;
- the minimum viable step calculation.
- profile names and their underscore spellings;
- the fixed seed;
- the SDXL global-stage checkpoint id (`SDXL_MODEL_ID`) and the Canny ControlNet id;
- strength resolution for both profiles.
The current profiles are `qwen-zimage` (the default) and `sdxl-zimage`, and both
are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather than
kept as a CPU path, and are rejected rather than aliased onward. There is no
content-dependent automatic router.
The current profiles are `controlnet`, `sdxl`, `qwen`, and `qwen-zimage`.
For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
mandatory Qwen stage and YuNet while leaving the optional Z-Image and SAM face
mandatory global stage and YuNet while leaving the optional Z-Image and SAM face
stack lazy until a face is detected. The default `preload()` still loads every
stage.
`default` is a legacy alias for `sdxl`. There is no content-dependent automatic
router.
**What is deliberately not a parameter.** Model id, step count and CFG are fixed
by the profile, so none of them appears in `WatermarkRemover.__init__`,
`remove_watermark`, `InvisibleEngine`, or the CLI. They used to be accepted and
then rejected several frames down; a signature that refuses the argument outright
fails where the caller can act on it, and stops a wrapper from threading a value
that would silently do nothing. The step count and CFG live with the stage that
runs them (`GLOBAL_STEPS`, `FACE_STEPS`, `GLOBAL_CFG`, `FACE_CFG` in
`qwen_zimage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage
dtype" for what an override cost the last time one existed.
`device` is the exception and remains a library parameter: `None` or `"auto"`
detect, `"cuda"` pins without detecting (which is what a container that knows its
hardware wants), and any other value raises at construction. It is not a CLI
option, because the only useful value a user could type is the one detection
already returns.
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) handles
image sizing, optional pre-upscaling, postprocessing, and the public engine
image sizing, postprocessing, and the public engine
interface. It delegates model execution to
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).
The Python engine and CLI do not have identical defaults for every optional
postprocessing argument. Integrations that require reproducibility should pass
the relevant values explicitly.
`get_device` in that module answers only `cuda` or `cpu`. An `mps` or `xpu` answer
would travel one frame to the same CUDA-only refusal while costing a device probe,
and reporting it implied an Apple-silicon or Intel-GPU path that does not exist.
The refusal names the *resolved* device, so `device=None` on a CUDA-less host says
`'cpu'` rather than `'None'`.
The standard Qwen and ControlNet prompts are calibrated model inputs, and the
ControlNet edge map uses fixed Canny thresholds of 100 and 200. Treat those
values as behavioral compatibility contracts: a refactor must preserve them,
and any deliberate change requires image-quality evaluation rather than only a
unit-test pass. Exact prompt and edge-map regression guards live in
`test_platform.py` and `test_invisible_engine.py`.
The Python engine and the CLI now resolve the same defaults: the CLI forwards an
unset `--adaptive-polish` and `--seed` as `None` and the engine applies the
profile's answer, so a library caller and a CLI caller on one profile produce the
same pixels. They diverged before, in opposite directions, for exactly this knob.
The global and face prompts are calibrated model inputs, and the Canny edge map
uses fixed thresholds of `_CANNY_LOW = 13` / `_CANNY_HIGH = 64`
(`qwen_zimage_pipeline.py`). Treat those values as behavioral compatibility
contracts: a refactor must preserve them, and any deliberate change requires
image-quality evaluation rather than only a unit-test pass. The prompt and
edge-map regression guards are
`test_qwen_zimage_pipeline.py::test_global_kwargs_use_lightning_and_diffsynth_controlnet_shape`,
`::test_face_kwargs_use_project_zimage_settings` and
`::test_canny_control_image_is_three_channel_and_detects_an_edge`.
Regression coverage:
- [`test_watermark_profiles.py`](../tests/test_watermark_profiles.py)
- [`test_invisible_engine.py`](../tests/test_invisible_engine.py)
- [`test_img2img_runner.py`](../tests/test_img2img_runner.py)
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
- [`test_platform.py`](../tests/test_platform.py)
### CPU offload
CPU offload is enabled only when requested on CUDA. The standard Diffusers
profiles call `enable_model_cpu_offload`. The `qwen-zimage` profile uses the
same flag to force **both** its stacks out of automatic device residency.
CPU offload is enabled only when requested. Nothing calls Diffusers'
`enable_model_cpu_offload` any more -- that belonged to the deleted single-stage
profiles. `--cpu-offload` now forces **both** stacks of the two-stage profiles out
of automatic device residency.
Residency is otherwise chosen from the card's total VRAM, once per stack:
`resolve_global_model_residency` gates the mandatory Qwen stack at
@@ -712,31 +749,35 @@ Regression coverage:
### Tiling
[`_internal/tiling.py`](../src/remove_ai_watermarks/_internal/tiling.py) contains pure
tile planning, feather weights, tile orchestration, and region compositing.
tile planning, feather weights, and tile orchestration.
Tiling engages only when requested and the long side exceeds the tile size.
It avoids an explicit full-image downscale but does not make diffusion
pixel-preserving. Each tile is still regenerated.
`feather_region_composite` changes only the requested box and leaves pixels
outside it unchanged.
It also held a `feather_region_composite` for AI-*enhanced* composites, where only
the edited region should change. Nothing ever reached it: the `erase` command
inpaints through `region_eraser`, and the remover's `region` argument was only
reachable from a module-level convenience wrapper with no callers. Both went.
Regression coverage:
- [`test_tiling.py`](../tests/test_tiling.py)
### Upscaling and postprocessing
[`upscaler.py`](../src/remove_ai_watermarks/upscaler.py) is the optional
Real-ESRGAN path used only when enlarging a small image to the minimum
resolution floor. Failure or an absent extra falls back to Lanczos.
### Postprocessing
[`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit
grain, unsharp masking, and adaptive polish helpers.
`upscaler.py` held an optional Real-ESRGAN path, reachable only when enlarging a
small image to the minimum-resolution floor. That floor existed to lift small
inputs toward SDXL's ~1024 training size; when the SDXL profiles were removed it
was forced to 0 on every path, so the module, the `--min-resolution` and
`--upscaler` options and the `esrgan` extra were all unreachable and went with
it. Only the `max_resolution` cap can move geometry now, and it only scales down.
Regression coverage:
- [`test_upscaler.py`](../tests/test_upscaler.py)
- [`test_humanizer.py`](../tests/test_humanizer.py)
## Image input and output
+18 -12
View File
@@ -6,8 +6,9 @@ and pipeline modules are intended for maintainers and specialized workflows.
Dependency groups are identical for the CLI and Python API. The default install
covers metadata extraction, normalization, verdict logic, and stripping.
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
uses `detect`; diffusion removal uses `diffusion`; and visible video processing
uses `video`. Video SynthID removal combines `video` and `diffusion`. Add `heif`
uses `detect`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
visible video processing uses `video`. Video SynthID removal is a separate VAE
path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
the complete [feature-extra matrix](installation.md#feature-extras).
@@ -380,8 +381,8 @@ to 8-bit SDR.
## Remove invisible watermarks
Install `remove-ai-watermarks[diffusion]` for the standard pipelines or
`remove-ai-watermarks[qwen-zimage]` for the CUDA-only high-fidelity profile.
Install `remove-ai-watermarks[qwen-zimage]`. Both profiles need it, and both
need an NVIDIA GPU.
```python
from pathlib import Path
@@ -400,8 +401,9 @@ engine.remove_watermark(
)
```
`device=None` selects the device automatically. Supported explicit values are
defined by the CLI and runtime device resolver.
`device=None` and `device="auto"` both run detection. `"cuda"` pins it without
detecting. Every other value raises at construction rather than deferring a
guaranteed failure to model-load time.
For limited CUDA memory:
@@ -412,18 +414,22 @@ engine = InvisibleEngine(
)
```
Both profiles are CUDA-only, so `device=None` resolving to CPU or MPS cannot run
invisible-watermark removal at all. For the SDXL global stage instead of Qwen:
Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
resolves to `cpu` and construction raises. For the SDXL global stage instead of
Qwen:
```python
engine = InvisibleEngine(pipeline="sdxl-zimage")
```
The `qwen-zimage` extra must be installed for that profile.
The `qwen-zimage` extra is required for both profiles: each runs the same
DiffSynth Z-Image face stage.
The full `remove_watermark` signature includes strength, steps, guidance,
seeding, tiling, resolution, upscaling, and postprocessing controls. Read the
method signature in
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
controls. It takes no model id, step count or guidance scale, and neither does the
constructor: each profile pins its model stack, its per-stage schedule and CFG
1.0, so passing one raises `TypeError` at the call rather than being accepted and
refused several layers down. Read the method signature in
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use
the CLI guide for the concepts.
Defaults can differ between the Python method and CLI profile resolution, so
+6 -6
View File
@@ -114,13 +114,13 @@ The `invisible` command uses diffusion regeneration. It targets watermark
patterns by changing the image rather than decoding and deleting a known
payload.
Current pipeline values:
Current pipeline values, both CUDA-only:
- `controlnet`;
- `sdxl`;
- `qwen`;
- `qwen-zimage`;
- legacy alias `default`, which resolves to `sdxl`.
- `qwen-zimage`, the default;
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass.
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
is rejected at parse time rather than remapped onto a surviving profile.
SynthID does not have a public local pixel decoder in this project. The tool can
infer likely presence from supported provenance metadata, but after that
+5 -4
View File
@@ -714,10 +714,11 @@ end has simply never been through the Gemini oracle on any pipeline. Do not reas
a resolution trend here; measure it.
**Current implication:** the old floor table remains evidence about the dated
test set, not the current resolver. The shipped SDXL and ControlNet defaults are
defined in `watermark_profiles.py`, and face restoration is available only
through the separate `qwen-zimage` profile. Removal near a threshold remains
seed dependent, so reproducible verification requires a fixed seed.
test set, not the current resolver. The SDXL and ControlNet profiles it measured
no longer exist; the shipped defaults are defined in `watermark_profiles.py`, and
both surviving profiles run face repair as a built-in second stage rather than as
an optional restore. Removal near a threshold remains seed dependent, so
reproducible verification requires a fixed seed.
---
+22 -12
View File
@@ -72,8 +72,10 @@ Run detection, removal, and re-detection over a representative local set. Confir
### A5. Contract sweep across every parameter choice
`scripts/smoke_matrix.py` (exists, 68 rows, 0 skipped with `--diffusion`) covers every
choice-valued flag on fixtures. Extend from fixtures to a stratified corpus slice
`scripts/smoke_matrix.py` covers every choice-valued flag on fixtures. Its knob rows
were rewritten when the CPU/MPS profiles and the ESRGAN chain were removed: most now
assert a knob is REJECTED, and the accepted-knob rows skip without a CUDA device, so
the row count is host-dependent rather than the fixed 68 recorded here before. Extend from fixtures to a stratified corpus slice
(~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
rather than just absence of crash.
@@ -84,25 +86,33 @@ sweep asserts on stderr, or the codes get split -- the latter is the better fix.
#### Coverage method
Compare the flags and values exercised by the matrix against the options declared by
the CLI. Include optional backends, batch modes, tiling, region-targeted composition,
the ESRGAN upscaler, and the ffmpeg audio/video strip. The gap to find is not only
the CLI. Include optional backends, batch modes, tiling, and the ffmpeg audio/video
strip. (Region-targeted diffusion composition was on this list until the parameter
was deleted: nothing but a caller-less convenience wrapper ever reached it, and the
user-facing region path is `erase`, which inpaints instead.) The gap to find is not only
"logic untested" but
"never executed on real data", which is precisely what this campaign is for.
#### Bug found by the extension: `--steps` below ~7 crashes inside torch
#### Bug found by the extension: `--steps` below ~7 crashed inside torch
Effective timesteps are `int(steps * strength)`. At the vendor-adaptive default strength
(0.15, or 0.10 for OpenAI) any `--steps` under 7 rounds to **zero**, and the pipeline dies
with a raw traceback:
**Fixed by deletion.** `--steps` no longer exists, on the CLI or in the Python API,
so this class of failure is unreachable. Kept as a record of why.
Effective timesteps were `int(steps * strength)`. At the vendor-adaptive default
strength (0.15, or 0.10 for OpenAI) any `--steps` under 7 rounded to **zero**, and the
pipeline died with a raw traceback:
```
$ remove-ai-watermarks invisible img.png --steps 5
RuntimeError: cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]
```
Fully valid CLI arguments, no special flags, no `--force`. The value is accepted, the
crash is a torch internal, and nothing tells the user that steps and strength interact.
Fix is either a clamp to >=1 effective step or an up-front validation naming both values.
Fully valid CLI arguments, no special flags, no `--force`. The value was accepted, the
crash was a torch internal, and nothing told the user that steps and strength interact.
The considered fixes were a clamp to >=1 effective step or an up-front validation
naming both values; what shipped instead is that each stage owns its own distilled
schedule and no caller can set it. The general lesson stands: a knob whose valid range
depends on another knob's value needs the interaction validated where both are known,
or it needs to not be a knob.
Method note: the first run of the knob rows failed 12 times with this identical error,
which read like twelve broken features. It was one bad harness parameter (`--steps 4`)
+1 -1
View File
@@ -10,7 +10,7 @@ package:
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: e68dd641c6b4d70149c30d4f6871675a9fe41567326482845847108409e2774e
sha256: ba2fe5d7a28950d90f72768fa5e6228ee02f74a9638c2ea84c822e1de965221e
build:
noarch: python
+8 -33
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.24.0"
version = "0.25.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.10.1"
@@ -86,16 +86,11 @@ detect = [
]
diffusion = [
"remove-ai-watermarks[pixels]",
# A CUDA-enabled torch build is required: invisible-watermark removal has no
# CPU, MPS or XPU path. The default PyPI wheel carries CUDA on Linux/Windows;
# on macOS there is no CUDA build and this extra installs only for the
# non-diffusion imports it shares.
"torch>=2.0.0",
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
# from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS
# XPU build). Install that build first, then this extra (torch is then
# already satisfied and won't be re-pulled):
# pip install torch --index-url https://download.pytorch.org/whl/xpu
# pip install 'remove-ai-watermarks[diffusion]'
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
"diffusers>=0.38.0",
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
@@ -150,19 +145,6 @@ migan = [
"onnxruntime>=1.16.0; python_version >= '3.11'",
"huggingface-hub>=0.20.0",
]
# Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN). Loaded via
# spandrel (MIT) -- a pure model-loader with NO basicsr dependency (it pulls only
# torch / torchvision / safetensors / numpy / einops).
# The Real-ESRGAN weights (BSD-3-Clause) download on first use and are cached; they
# are never bundled. CPU works but is slow on large inputs -- it is meant for the
# pre-diffusion upscale of SMALL inputs (and the GPU worker). Guarded by
# upscaler.is_available(); the default upscaler stays Lanczos (cv2, no deps). The
# weights are fetched with torch.hub (bundled with spandrel's torch), so no extra
# download dependency is needed.
esrgan = [
"remove-ai-watermarks[pixels]",
"spandrel>=0.3.0",
]
dev = [
"remove-ai-watermarks[video]",
"remove-ai-watermarks[detect]",
@@ -178,16 +160,9 @@ dev = [
"uv-outdated>=0.1.0; python_version >= '3.12'",
"uv-secure>=0.12.0; python_version >= '3.12'",
]
all = ["remove-ai-watermarks[video,heif,detect,trustmark,diffusion,qwen-zimage,lama,migan,esrgan]"]
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
# the default CPU/CUDA install: uv consults it only when a torch install
# explicitly targets it (see the ``diffusion`` extra comment), so it does not alter
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
[[tool.uv.index]]
name = "pytorch-xpu"
url = "https://download.pytorch.org/whl/xpu"
explicit = true
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
# independently sufficient for a removal, which it is not.
all = ["remove-ai-watermarks[video,heif,detect,trustmark,qwen-zimage,lama,migan]"]
[project.scripts]
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
+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())
+61 -104
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()
@@ -303,7 +304,7 @@ def main() -> None:
_diffusion_rows(r, tmp, doubao)
else:
for name in ("invisible", "all", "batch --mode invisible"):
r.skip(f"{name} (model-running body)", "pass --diffusion to exercise it (needs the SDXL weights)")
r.skip(f"{name} (model-running body)", "pass --diffusion to exercise it (needs CUDA and the model weights)")
# ---- real-data formats and shapes ---------------------------------------
if not a.quick:
@@ -354,18 +355,18 @@ def main() -> None:
def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
"""Every diffusion knob the matrix never touched.
Deliberately cheap (`--steps 4`, `--max-resolution 384`): these rows answer "is the
knob accepted and does the run complete", NOT "is the output good". Quality per knob
needs a per-knob oracle and most of them have none (`--humanize` has no oracle at
all), so claiming more here would be dishonest.
Deliberately cheap (`--max-resolution 384`): these rows answer "is the knob accepted
and does the run complete", NOT "is the output good". Quality per knob needs a
per-knob oracle and most of them have none (`--humanize` has no oracle at all), so
claiming more here would be dishonest.
"""
from remove_ai_watermarks import upscaler
# --steps 20 is the floor that WORKS, not an arbitrary choice: effective timesteps
# are int(steps * strength), so at the default strength 0.15 anything below
# --steps 7 rounds to ZERO and the pipeline dies inside torch. The first version of
# these rows used --steps 4 and every single one failed with the same reshape error.
fast = ["--max-resolution", "384", "--min-resolution", "0", "--steps", "20", "--force", "--seed", "0"]
# 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:
r.run(
@@ -375,65 +376,53 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
timeout=2400,
)
run("--pipeline sdxl", ["--pipeline", "sdxl"], tag="sdxl")
run("--pipeline controlnet", ["--pipeline", "controlnet"], tag="cnet")
run("--strength", ["--strength", "0.2"], tag="strength")
run("--guidance-scale", ["--guidance-scale", "5.0"], tag="gs")
run("--controlnet-scale", ["--controlnet-scale", "0.5"], tag="cns")
run("--humanize", ["--humanize", "0.3"], tag="hum")
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("--device mps", ["--device", "mps"], tag="mps")
run("--upscaler lanczos", ["--upscaler", "lanczos"], tag="lanczos")
run("--auto (deprecated no-op)", ["--auto"], tag="auto")
# Click rejects a retired profile at parse time (exit 2) rather than remapping it.
for retired in ("sdxl", "controlnet", "qwen", "default"):
run(f"--pipeline {retired} is rejected", ["--pipeline", retired], tag=f"retired_{retired}", expect=2)
if upscaler.is_available():
run("--upscaler esrgan", ["--upscaler", "esrgan"], tag="esrgan")
else:
r.skip("--upscaler esrgan", "the `esrgan` extra is not installed")
# 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)
# CPU is correctness-relevant (it is the documented MPS-OOM fallback) but slow, so
# it gets the smallest possible run rather than being skipped.
r.run(
"--device cpu",
[
"invisible",
str(img),
"-o",
str(tmp / "k_cpu.png"),
"--device",
"cpu",
"--max-resolution",
"256",
"--min-resolution",
"0",
"--steps",
"20",
"--force",
"--seed",
"0",
],
timeout=3600,
)
# qwen is CUDA-class by design (bf16 MMDiT, no MPS fallback). On this host the
# honest outcome is a CLEAN failure, not a crash -- assert it does not hang or
# dump a traceback at the user.
try:
import torch
cuda = bool(torch.cuda.is_available())
except Exception:
cuda = False
if cuda:
run("--pipeline qwen", ["--pipeline", "qwen"], tag="qwen")
else:
r.skip("--pipeline qwen", "CUDA-class pipeline; no CUDA device on this host")
# --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")
if not cuda:
# 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 label in (
"--strength",
"--controlnet-scale",
"--humanize",
"--unsharp",
"--no-adaptive-polish",
"--tile",
"--pipeline sdxl-zimage",
):
r.skip(label, "accepted-knob rows need a CUDA device")
return
run("--pipeline sdxl-zimage", ["--pipeline", "sdxl-zimage"], tag="sdxlz")
run("--strength", ["--strength", "0.2"], tag="strength")
run("--controlnet-scale", ["--controlnet-scale", "0.5"], tag="cns")
run("--humanize", ["--humanize", "0.3"], tag="hum")
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")
# --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.
@@ -497,7 +486,7 @@ def _sdxl_watermark_bits(img: object) -> float:
def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
"""Exercise the model-running bodies at a reduced resolution (MPS-friendly).
"""Exercise the model-running bodies at a reduced resolution (CUDA required).
Bounded with `--max-resolution 512` and a fixed seed: the point is that the paths
RUN and keep their contracts, not to certify removal strength (that needs the
@@ -519,7 +508,7 @@ def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
# OUTPUT and the row degrades to a skip rather than a false pass if it goes fragile.
mj = SAMPLES / "mj-1.png"
inv = tmp / "inv_mj.png"
res = r.run("invisible runs (mps, 512px)", ["invisible", str(mj), "-o", str(inv), "--force", *small], timeout=1800)
res = r.run("invisible runs (512px)", ["invisible", str(mj), "-o", str(inv), "--force", *small], timeout=1800)
if res.status == "pass" and inv.exists():
src_img, out_img = imread(str(mj)), imread(str(inv))
r.check(
@@ -547,7 +536,7 @@ def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
# `all`: every stage must land -- the visible mark AND the metadata both gone.
allout = tmp / "all_out.png"
res = r.run("all runs (mps, 512px)", ["all", str(doubao), "-o", str(allout), *small], timeout=1800)
res = r.run("all runs (512px)", ["all", str(doubao), "-o", str(allout), *small], timeout=1800)
if res.status == "pass" and allout.exists():
rep = json.loads(_capture(["identify", str(allout), "--json"]))
r.check(
@@ -566,7 +555,7 @@ def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
shutil.copy(f, bd / f.name)
bout = tmp / "batch_inv_out"
res = r.run(
"batch --mode invisible runs (mps, 512px)",
"batch --mode invisible runs (512px)",
["batch", str(bd), "--mode", "invisible", "-o", str(bout), *small],
expect_exit=None,
timeout=3600,
@@ -578,44 +567,12 @@ def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
# `batch --mode all` -- the only --mode value the matrix never ran.
aout = tmp / "batch_all_out"
r.run(
"batch --mode all runs (mps, 512px)",
"batch --mode all runs (512px)",
["batch", str(bd), "--mode", "all", "-o", str(aout), *small],
expect_exit=None,
timeout=3600,
)
# The AI-enhanced composite path: regenerate ONLY a region and feather it back,
# leaving everything outside the box pixel-exact. Library-level -- the CLI has no
# flag for it, so it would otherwise never be exercised on real data.
try:
import numpy as np
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
from remove_ai_watermarks.image_io import imread
src = imread(str(mj))
h, w = src.shape[:2]
box = (w // 4, h // 4, w // 4, h // 4)
rem = WatermarkRemover(pipeline="controlnet")
rout = tmp / "region_composite.png"
rem.remove_watermark(mj, rout, strength=0.15, num_inference_steps=20, seed=0, region=box)
got = imread(str(rout))
if got is None or got.shape != src.shape:
r.check("region composite keeps the frame outside the box", False, "shape changed or unreadable")
else:
mask = np.ones(src.shape[:2], dtype=bool)
x, y, bw, bh = box
# Outside the box PLUS the feather margin must be untouched.
pad = 96
mask[max(0, y - pad) : y + bh + pad, max(0, x - pad) : x + bw + pad] = False
r.check(
"region composite keeps the frame outside the box",
bool(np.array_equal(src[mask], got[mask])),
"pixels changed outside the regenerated region",
)
except Exception as e:
r.skip("region composite (remove_watermark(region=...))", f"{type(e).__name__}: {e}"[:120])
if __name__ == "__main__":
main()
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.24.0"
__version__ = "0.25.0"
__all__ = [
"__version__",
+13 -34
View File
@@ -1,36 +1,15 @@
"""Compatibility namespace for metadata and regeneration helpers.
"""Private namespace for metadata parsing and regeneration internals.
The public API (``WatermarkRemover`` / ``remove_watermark`` / ``remove_ai_metadata``)
is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
(e.g. ``_internal.c2pa`` / ``_internal.constants`` from ``identify``) must NOT eagerly pull
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
torch) even in a full install where the ``diffusion`` extra is present --
otherwise the mere presence of torch in the env inflated identify to ~420 MB and
risked OOM on a 512 MB host.
Deliberately empty. It carried a PEP 562 ``__getattr__`` re-exporting
``WatermarkRemover`` and ``remove_ai_metadata`` as a "compatibility namespace",
but nothing ever reached for either through this package -- every caller imports
the submodule directly. The laziness it defended is real and still enforced, just
elsewhere: importing a light submodule (``_internal.c2pa`` / ``_internal.constants``
from ``identify``) must not pull ``watermark_remover``, which imports torch at
module top. That property comes from those direct submodule imports, not from a
shim here; a re-export in this file would be the one thing that could break it.
Keep this module free of imports. ``import remove_ai_watermarks.identify`` stays
around 36 MB even in a full install where torch is present; routing anything heavy
through here inflated it to roughly 420 MB and risked OOM on a 512 MB host.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover, remove_watermark
from remove_ai_watermarks.metadata import remove_ai_metadata
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
def __getattr__(name: str) -> object:
"""Resolve the public API on first access (PEP 562), not at package import."""
if name == "remove_ai_metadata":
# Re-export the single, robust stripper (byte-level, lossless-for-JPEG, all
# containers); the old legacy metadata helper implementation is retired.
from remove_ai_watermarks.metadata import remove_ai_metadata
return remove_ai_metadata
if name in ("WatermarkRemover", "remove_watermark"):
from remove_ai_watermarks._internal import watermark_remover
return getattr(watermark_remover, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -14,9 +14,6 @@ AI_METADATA_KEYS = _tokens(
"parameters|postprocessing|extras|workflow|prompt|Dream|SD:mode|StableDiffusionVersion|"
"generation_time|Model|Model hash|Seed"
)
PNG_METADATA_KEYS = _tokens(
"Author|Title|Description|Copyright|Creation Time|Software|Disclaimer|Warning|Source|Comment"
)
AI_KEYWORDS = _tokens(
"prompt|negative_prompt|sampler|cfg_scale|lora|diffusion|comfy|midjourney|dall-e|dalle|imagen|firefly|c2pa|chatgpt|gpt-4|sora|openai|truepic|stable_diffusion|invokeai"
)
@@ -636,8 +636,9 @@ class QwenZImagePipeline:
def _require_cuda(self) -> None:
if self.device != "cuda":
raise RuntimeError(
"The qwen-zimage pipeline is CUDA-only. Its Qwen-Image-2512 and "
"Z-Image models do not fit the supported MPS path."
"The qwen-zimage pipeline is CUDA-only; there is no CPU or MPS path "
"for it. WatermarkRemover already refuses a non-CUDA device, so "
"reaching this guard means the pipeline was constructed directly."
)
def _vram_limit(self) -> float | None:
@@ -30,9 +30,9 @@ from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
)
from remove_ai_watermarks._internal.watermark_profiles import (
CONTROLNET_CANNY_MODEL,
DEFAULT_MODEL_ID,
SDXL_LIGHTNING_MODEL_ID,
SDXL_LIGHTNING_PATTERN,
SDXL_MODEL_ID,
)
log = logging.getLogger(__name__)
@@ -88,7 +88,7 @@ class SdxlZImagePipeline(QwenZImagePipeline):
controlnet = ControlNetModel.from_pretrained(CONTROLNET_CANNY_MODEL, torch_dtype=torch.float16, **token)
vae = AutoencoderKL.from_pretrained(SDXL_VAE_MODEL_ID, torch_dtype=torch.float16, **token)
pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(
DEFAULT_MODEL_ID,
SDXL_MODEL_ID,
controlnet=controlnet,
vae=vae,
torch_dtype=torch.float16,
+6 -59
View File
@@ -1,11 +1,11 @@
"""Sliding-window tiled diffusion for large images.
The img2img / ControlNet pipeline denoises the WHOLE image in one forward pass,
so it OOMs on MPS/GPU above ~2K (issue #10). Tiling splits the image into
overlapping tiles -- each kept near SDXL's ~1024 training size -- regenerates
each tile independently, and feather-blends the overlaps. The result retains the
input's native dimensions without an explicit ``--max-resolution`` downscale, but
it is not pixel-lossless because every tile is regenerated.
The global stage denoises the WHOLE image in one forward pass, so it OOMs on a
GPU above ~2K (issue #10). Tiling splits the image into overlapping tiles -- each
kept near the ~1024 training size -- regenerates each tile independently, and
feather-blends the overlaps. The result retains the input's native dimensions
without an explicit ``--max-resolution`` downscale, but it is not pixel-lossless
because every tile is regenerated.
The geometry (``plan_tiles``) and the blend weighting (``feather_weights``) are
pure functions, unit-tested without the diffusion model. ``run_tiled`` is the
@@ -100,59 +100,6 @@ def feather_weights(width: int, height: int, overlap: int) -> NDArray[Any]:
return weights
def feather_region_composite(
base: NDArray[Any],
regenerated: NDArray[Any],
box: tuple[int, int, int, int],
*,
feather: int = 64,
) -> NDArray[Any]:
"""Composite ``regenerated`` over ``base`` inside ``box`` only, feathering the seam.
For AI-ENHANCED composites (digitalSourceType ``compositeWithTrainedAlgorithmicMedia``):
the diffusion remover regenerates the whole frame, but only the AI-composited
REGION should change -- the rest is a real photo that must be preserved. This
blends the regenerated pixels in over ``box = (x, y, w, h)`` with a separable
linear taper of ``feather`` px at the box edges, so the result equals ``base``
EXACTLY outside the box and ramps smoothly (no hard seam) at the boundary.
Pure and model-free (unit-tested): ``base`` and ``regenerated`` must be the same
shape (H x W, or H x W x C). The output preserves ``base``'s dtype. ``feather`` is
clamped to half the box on each axis, so a small region still tapers symmetrically;
``feather=0`` is a hard-edged paste.
"""
import numpy as np
if base.shape != regenerated.shape:
raise ValueError(f"shape mismatch: base {base.shape} vs regenerated {regenerated.shape}")
h, w = base.shape[:2]
x, y, bw, bh = box
x0, y0 = max(0, x), max(0, y)
x1, y1 = min(w, x + bw), min(h, y + bh)
out = base.copy()
if x1 <= x0 or y1 <= y0:
return out # empty / off-image box -> nothing regenerated
def taper(n: int) -> NDArray[Any]:
win = np.ones(n, dtype=np.float32)
f = min(max(feather, 0), n // 2)
if f > 0:
ramp = (np.arange(f, dtype=np.float32) + 1.0) / (f + 1.0) # in (0, 1), 0 at the edge
win[:f] = ramp
win[n - f :] = ramp[::-1]
return win
rh, rw = y1 - y0, x1 - x0
wmap = np.outer(taper(rh), taper(rw)) # ~0 at the box edge, 1 in the interior
if base.ndim == 3:
wmap = wmap[:, :, None]
roi_base = base[y0:y1, x0:x1].astype(np.float32)
roi_gen = regenerated[y0:y1, x0:x1].astype(np.float32)
blended = roi_base * (1.0 - wmap) + roi_gen * wmap
out[y0:y1, x0:x1] = np.clip(blended, 0, 255).astype(base.dtype)
return out
def run_tiled(
generate_tile: Callable[[PILImage.Image], PILImage.Image],
image: PILImage.Image,
@@ -17,8 +17,10 @@ if TYPE_CHECKING:
from pathlib import Path
# SDXL base is no longer a profile of its own, but it is still the global stage of
# sdxl-zimage, so the checkpoint id stays.
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
# sdxl-zimage, so the checkpoint id stays. Named for what it is rather than
# ``DEFAULT_MODEL_ID``: there is no user-selectable model any more, so "default"
# implied an override that both profiles reject.
SDXL_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
@@ -26,14 +28,31 @@ SDXL_ZIMAGE_PROFILE = "sdxl-zimage"
DEFAULT_PROFILE = QWEN_ZIMAGE_PROFILE
PROFILE_CHOICES = (QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE)
# The modules a real removal run needs, and the extra that installs them. Both live
# here, in the only profile module that imports nothing heavy, because the CLI's
# availability gate and the remover's own precondition must agree: when they drifted,
# the CLI passed on a torch+diffusers environment and the run then died at the
# DiffSynth face stage, telling the user to install an extra that does not contain it.
REMOVAL_MODULES = ("torch", "diffusers", "diffsynth")
# Shell-quoted, because this string is printed as a command the user copy-pastes.
# Bare brackets are a glob in zsh (the macOS default shell): an unquoted
# ``pip install remove-ai-watermarks[qwen-zimage]`` dies with "no matches found"
# before pip ever runs -- another install hint that does not install.
INVISIBLE_EXTRA = "'remove-ai-watermarks[qwen-zimage]'"
# qwen-zimage's output already matches the input's detail level, so polishing it is a
# no-op at best. sdxl-zimage's global pass leaves the softer output the polish exists
# for. This is per-profile data, not a CLI concern: the flag defaults to None so that
# "the user did not choose" stays a value rather than an inference from Click state.
PROFILE_ADAPTIVE_POLISH = {QWEN_ZIMAGE_PROFILE: False, SDXL_ZIMAGE_PROFILE: True}
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
# Both profiles run the same distilled four-step schedule, and both are certified at a
# fixed seed because SynthID removal near the strength floor is seed-dependent.
PROFILE_STEPS = 4
# Both profiles are certified at a fixed seed because SynthID removal near the
# strength floor is seed-dependent. The step count and CFG are not settable at all --
# each stage owns them (``GLOBAL_STEPS`` / ``FACE_STEPS`` in qwen_zimage_pipeline).
PROFILE_SEED = 0
PROFILE_CFG = 1.0
# sdxl-zimage runs the qwen-zimage recipe on an SDXL global stage, and strength is
# architecture-bound: at Qwen's 0.154 an SDXL global pass leaves SynthID on a native
@@ -77,16 +96,18 @@ def normalize_profile(profile: str) -> str:
return _ALIASES.get(value, value)
def resolve_steps(num_inference_steps: int | None) -> int:
"""Return an explicit step count or the distilled four-step default."""
return PROFILE_STEPS if num_inference_steps is None else num_inference_steps
def resolve_seed(seed: int | None) -> int:
"""Keep both profiles reproducible by default."""
return PROFILE_SEED if seed is None else seed
def resolve_adaptive_polish(adaptive_polish: bool | None, pipeline: str) -> bool:
"""Return an explicit polish choice, or the profile's calibrated default."""
if adaptive_polish is not None:
return adaptive_polish
return PROFILE_ADAPTIVE_POLISH.get(normalize_profile(pipeline), True)
def strength_default_help() -> str:
"""Describe the live default policy without duplicating its values."""
return (
@@ -3,7 +3,6 @@
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations
import contextlib
import logging
import os
import subprocess
@@ -12,16 +11,13 @@ from typing import TYPE_CHECKING, Any
from PIL import Image
from remove_ai_watermarks._internal.watermark_profiles import (
DEFAULT_MODEL_ID,
DEFAULT_PROFILE,
PROFILE_CFG,
INVISIBLE_EXTRA,
PROFILE_CHOICES,
PROFILE_STEPS,
QWEN_ZIMAGE_PROFILE,
REMOVAL_MODULES,
SDXL_ZIMAGE_PROFILE,
normalize_profile,
resolve_seed,
resolve_steps,
resolve_strength,
)
from remove_ai_watermarks.optional_deps import module_available
@@ -32,13 +28,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Both two-stage profiles share the face stage, the four-step schedule, CFG 1.0, the
# fixed model stack and the native-resolution contract; only the global model differs.
_ZIMAGE_STACKS = {
QWEN_ZIMAGE_PROFILE: "Qwen-Image-2512 and Z-Image",
SDXL_ZIMAGE_PROFILE: "SDXL and Z-Image",
}
try:
import torch
@@ -47,18 +36,20 @@ except ImportError:
torch = None # type: ignore[assignment]
_HAS_TORCH = False
_HAS_DIFFUSERS = module_available("diffusers")
# Probed once at import. ``torch`` is imported above rather than probed because this
# module needs the object, not just the answer.
_HAS_REMOVAL_MODULES = module_available(*(name for name in REMOVAL_MODULES if name != "torch"))
def is_watermark_removal_available() -> bool:
"""Return whether the standard diffusion runtime can be imported."""
return _HAS_TORCH and _HAS_DIFFUSERS
"""Return whether the full removal runtime can be imported."""
return _HAS_TORCH and _HAS_REMOVAL_MODULES
def _ensure_watermark_deps() -> None:
if not is_watermark_removal_available():
raise ImportError(
"Invisible watermark regeneration requires the 'diffusion' extra. Install remove-ai-watermarks[diffusion]."
f"Invisible watermark regeneration requires the 'qwen-zimage' extra: pip install {INVISIBLE_EXTRA}."
)
@@ -75,26 +66,9 @@ def _has_nvidia_gpu() -> bool:
return True
def try_empty_device_cache(device: str) -> None:
"""Ask Torch to release cached accelerator memory when the backend supports it.
Moved here when ``img2img_runner`` was deleted: the runner and its MPS recovery
path went with the CPU/MPS profiles, leaving this as that module's only content.
Silent by design -- it runs in cleanup paths where a raise would replace the real
error.
"""
if not _HAS_TORCH:
return
backend = getattr(torch, device, None) # type: ignore[union-attr]
empty_cache = getattr(backend, "empty_cache", None)
if callable(empty_cache):
with contextlib.suppress(Exception):
empty_cache()
def _backend_works(device: str) -> bool:
def _cuda_works() -> bool:
try:
probe = torch.tensor([1.0], device=device) # type: ignore[union-attr]
probe = torch.tensor([1.0], device="cuda") # type: ignore[union-attr]
_ = probe + probe
except (AssertionError, RuntimeError):
return False
@@ -102,33 +76,28 @@ def _backend_works(device: str) -> bool:
def get_device() -> str:
"""Select CUDA, XPU, MPS, or CPU in that order when each backend is usable."""
"""Return ``"cuda"`` when a usable CUDA backend is present, else ``"cpu"``.
Deliberately binary. Both profiles are CUDA-only, so an XPU or MPS answer would
only travel one frame further to the same refusal in :class:`WatermarkRemover`,
while costing a probe on each. ``"cpu"`` here means "no CUDA", which is exactly
what that refusal reports.
"""
if not _HAS_TORCH:
return "cpu"
if torch.cuda.is_available() and _backend_works("cuda"): # type: ignore[union-attr]
if torch.cuda.is_available() and _cuda_works(): # type: ignore[union-attr]
return "cuda"
xpu = getattr(torch, "xpu", None)
if xpu is not None and xpu.is_available() and _backend_works("xpu"):
return "xpu"
if _has_nvidia_gpu():
logger.warning("NVIDIA GPU detected, but the installed PyTorch build has no working CUDA backend")
mps = getattr(getattr(torch, "backends", None), "mps", None)
if mps is not None and mps.is_available():
return "mps"
return "cpu"
class WatermarkRemover:
"""Load one regeneration profile and write a metadata-clean raster output."""
DEFAULT_MODEL_ID = DEFAULT_MODEL_ID
_DEVICES = frozenset({"cuda"})
def __init__(
self,
model_id: str | None = None,
device: str | None = None,
torch_dtype: Any = None,
progress_callback: Callable[[str], None] | None = None,
hf_token: str | None = None,
pipeline: str = DEFAULT_PROFILE,
@@ -138,31 +107,25 @@ class WatermarkRemover:
self.model_profile = normalize_profile(pipeline)
if self.model_profile not in PROFILE_CHOICES:
raise ValueError(f"Unsupported pipeline '{pipeline}'. Use one of: {', '.join(PROFILE_CHOICES)}.")
if model_id is not None:
raise ValueError(
f"The {self.model_profile} profile uses a fixed {_ZIMAGE_STACKS[self.model_profile]} model stack."
)
self.model_id = (
"Qwen/Qwen-Image-2512 + Tongyi-MAI/Z-Image-Turbo"
if self.model_profile == QWEN_ZIMAGE_PROFILE
else f"{DEFAULT_MODEL_ID} + Tongyi-MAI/Z-Image-Turbo"
)
# There is no ``model_id`` parameter and no ``model_id`` attribute: each
# profile pins a fixed model stack, and the dtype below is bound to that
# stack's weights. Both used to be constructor overrides that existed only to
# be rejected or to break the run, and the attribute only existed to echo the
# rejected value back.
_ensure_watermark_deps()
selected_device = (device or get_device()).casefold()
self.device = get_device() if selected_device == "auto" else selected_device
# CUDA is a precondition of the object, not of the run. Both profiles raise on
# any other device, so accepting one here only defers a guaranteed failure to
# model-load time, several layers down and under the wrong profile's name.
if self.device not in self._DEVICES:
if self.device != "cuda":
raise ValueError(
f"Invisible-watermark removal is CUDA-only, so '{device}' cannot run it. "
f"Invisible-watermark removal is CUDA-only, so '{self.device}' cannot run it. "
"Both remaining profiles need an NVIDIA GPU. Visible-mark removal and "
"every identify command still run on CPU."
)
if torch_dtype is not None:
self.torch_dtype = torch_dtype
elif self.model_profile == SDXL_ZIMAGE_PROFILE:
if self.model_profile == SDXL_ZIMAGE_PROFILE:
# SDXL ships fp16 weights and an fp16-safe VAE; bf16 would give up the
# variant without buying anything on this architecture.
self.torch_dtype = torch.float16 # type: ignore[union-attr]
@@ -175,18 +138,13 @@ class WatermarkRemover:
self._progress_callback = progress_callback
self._qwen_zimage_pipeline: Any = None
def _set_progress(self, message: str) -> None:
if self._progress_callback is not None:
with contextlib.suppress(Exception):
self._progress_callback(message)
def preload(self, *, global_only: bool = False) -> None:
"""Materialize the selected model stack before the first request."""
self._load_qwen_zimage_pipeline().preload(global_only=global_only)
def _load_qwen_zimage_pipeline(self) -> Any:
if self._qwen_zimage_pipeline is None:
if getattr(self, "model_profile", QWEN_ZIMAGE_PROFILE) == SDXL_ZIMAGE_PROFILE:
if self.model_profile == SDXL_ZIMAGE_PROFILE:
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
SdxlZImagePipeline as _Pipeline,
)
@@ -206,44 +164,6 @@ class WatermarkRemover:
)
return self._qwen_zimage_pipeline
def _run_qwen_zimage(
self,
init_image: Image.Image,
strength: float,
seed: int | None,
*,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
) -> Image.Image:
return self._load_qwen_zimage_pipeline().run(
init_image,
strength=strength,
seed=seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
def _generate(
self,
image: Image.Image,
strength: float,
seed: int | None,
*,
tile: bool,
tile_size: int,
tile_overlap: int,
) -> Image.Image:
return self._run_qwen_zimage(
image,
strength,
seed,
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
def _write_output(self, image: Image.Image, output_path: Path) -> None:
import numpy as np
@@ -262,17 +182,18 @@ class WatermarkRemover:
image_path: Path,
output_path: Path | None = None,
strength: float | None = None,
num_inference_steps: int | None = None,
guidance_scale: float | None = None,
seed: int | None = None,
vendor: str | None = None,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
region: tuple[int, int, int, int] | None = None,
region_feather: int = 64,
) -> Path:
"""Regenerate image pixels and write the result without AI metadata."""
"""Regenerate image pixels and write the result without AI metadata.
Step count and CFG are not parameters. Each stage of both profiles is a
distilled schedule that owns its own, so the only thing a caller-supplied
value could do is break the run or be rejected.
"""
if not image_path.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
destination = output_path or image_path
@@ -283,82 +204,13 @@ class WatermarkRemover:
if not 0.0 <= resolved_strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
# Both profiles are distilled four-step schedules at CFG 1.0. Anything else is
# a caller error rather than a knob, so it is rejected instead of coerced.
steps = resolve_steps(num_inference_steps)
if steps != PROFILE_STEPS:
raise ValueError(f"The {self.model_profile} profile requires {PROFILE_STEPS} steps.")
if guidance_scale is not None and guidance_scale != PROFILE_CFG:
raise ValueError(f"The {self.model_profile} profile requires CFG {PROFILE_CFG}.")
result = self._generate(
result = self._load_qwen_zimage_pipeline().run(
source,
resolved_strength,
resolve_seed(seed),
strength=resolved_strength,
seed=resolve_seed(seed),
tile=tile,
tile_size=tile_size,
tile_overlap=tile_overlap,
)
if region is not None:
import numpy as np
from remove_ai_watermarks._internal.tiling import feather_region_composite
if result.size != source.size:
result = result.resize(source.size, Image.Resampling.LANCZOS)
merged = feather_region_composite(
np.asarray(source),
np.asarray(result.convert("RGB")),
region,
feather=region_feather,
)
result = Image.fromarray(merged)
self._write_output(result, destination)
return destination
def remove_watermark_batch(
self,
input_dir: Path,
output_dir: Path,
strength: float | None = None,
num_inference_steps: int | None = None,
extensions: tuple[str, ...] = (".png", ".jpg", ".jpeg", ".webp"),
) -> list[Path]:
"""Process matching files in a directory, logging and continuing on failures."""
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
output_dir.mkdir(parents=True, exist_ok=True)
outputs: list[Path] = []
candidates = sorted(path for path in input_dir.iterdir() if path.suffix.casefold() in extensions)
for source in candidates:
try:
outputs.append(self.remove_watermark(source, output_dir / source.name, strength, num_inference_steps))
except Exception as error:
logger.error("Failed to process %s: %s", source, error)
finally:
try_empty_device_cache(self.device)
return outputs
def remove_watermark(
image_path: Path,
output_path: Path | None = None,
strength: float | None = None,
model_id: str | None = None,
device: str | None = None,
hf_token: str | None = None,
region: tuple[int, int, int, int] | None = None,
) -> Path:
"""Convenience wrapper using the default ControlNet profile."""
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
remover = WatermarkRemover(model_id=model_id, device=device, hf_token=hf_token)
return remover.remove_watermark(
image_path,
output_path,
strength,
vendor=vendor_for_strength(image_path),
region=region,
)
+56 -287
View File
@@ -25,10 +25,8 @@ from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS
from remove_ai_watermarks._internal.utils import is_supported_format
from remove_ai_watermarks._internal.watermark_profiles import (
DEFAULT_PROFILE,
INVISIBLE_EXTRA,
PROFILE_CHOICES,
QWEN_ZIMAGE_PROFILE,
resolve_seed,
resolve_steps,
resolve_strength,
strength_default_help,
vendor_for_strength,
@@ -180,45 +178,18 @@ _controlnet_scale_option = click.option(
"(structure/text preservation strength). Higher = closer to original structure.",
)
_min_resolution_option = click.option(
"--min-resolution",
type=int,
default=1024,
help="Upscale long side UP to this (px) before diffusion when the input is smaller, so SDXL runs "
"near 1024 (small inputs distort at native); output is restored to the input size. 0 = off. Default 1024.",
)
_unsharp_option = click.option(
"--unsharp", type=float, default=0.0, help="Unsharp-mask sharpening strength (0 = off, typical: 0.3-0.8)."
)
_upscaler_option = click.option(
"--upscaler",
type=click.Choice(["lanczos", "esrgan"]),
default="lanczos",
help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no model) or "
"esrgan (Real-ESRGAN via the 'esrgan' extra; better detail, slower on CPU). Best for photo/texture "
"content -- as a generic GAN with no face/glyph prior it can degrade faces (diffusion mitigates) and "
"thin text, so lanczos stays the default. Falls back to lanczos if the extra is absent. Only when upscaling.",
)
_auto_option = click.option(
"--auto",
is_flag=True,
default=False,
help="DEPRECATED: it no longer selects a pipeline. It now only requests the "
"adaptive polish, which the two-stage profiles otherwise leave off to keep their "
"output untouched. Prefer --adaptive-polish.",
)
_adaptive_polish_option = click.option(
"--adaptive-polish/--no-adaptive-polish",
default=True,
default=None,
help="Restore the input's detail level after removal (capped unsharp + edge-masked grain "
"targeting the input's sharpness, sparing text), countering the over-smoothed look. ON by "
"default except for qwen-zimage, whose upstream-matching output is left unchanged; it "
"self-limits where there is no detail deficit (text/flat graphics). Pass --adaptive-polish "
"or --no-adaptive-polish to override. Independent of --unsharp/--humanize.",
"targeting the input's sharpness, sparing text), countering the over-smoothed look. "
"Unset follows the profile: ON for sdxl-zimage, OFF for qwen-zimage, whose "
"upstream-matching output is left unchanged. It self-limits where there is no detail "
"deficit (text/flat graphics). Independent of --unsharp/--humanize.",
)
@@ -248,23 +219,11 @@ def _tile_options(f: Any) -> Any:
)(f)
# HuggingFace model + CFG knobs, shared by the diffusion commands (invisible/all/batch)
# so the surface stays identical across them.
_model_option = click.option(
"--model",
type=str,
default=None,
help="HuggingFace model ID. Both profiles pin a fixed model stack, so anything "
"other than the default is rejected rather than silently ignored.",
)
_guidance_scale_option = click.option(
"--guidance-scale",
type=float,
default=None,
help="Classifier-free guidance scale (CFG). Both profiles are distilled and fix "
"CFG at 1.0, so any other value is rejected.",
)
# There is deliberately no --model, --steps, --guidance-scale or --device option.
# Each profile pins a fixed model stack, a distilled per-stage schedule, CFG 1.0 and
# CUDA; every one of those knobs existed only so the library could reject it several
# layers down. A flag whose sole outcome is an error is worse than no flag at all --
# it advertises a capability that does not exist.
# The two-stage profiles are the only ones left. The former controlnet, sdxl, qwen and
# default profiles were removed rather than kept as a CPU path: none matched this
@@ -294,6 +253,23 @@ _strength_option = click.option(
default=None,
help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.",
)
_seed_option = click.option(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility. Default 0: both profiles are certified "
"at a fixed seed, because SynthID removal near the strength floor is seed-dependent.",
)
_hf_token_option = click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
_humanize_option = click.option(
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
_max_resolution_option = click.option(
"--max-resolution",
type=int,
default=0,
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU OOM.",
)
_force_option = click.option(
"--force/--no-force",
default=False,
@@ -310,8 +286,7 @@ _cpu_offload_option = click.option(
help=(
"Offload model components to CPU between CUDA calls instead of keeping the "
"whole pipeline in VRAM, at the cost of speed. For qwen-zimage, forces the "
"face stack to offload instead of using automatic residency. No effect on "
"cpu/mps."
"face stack to offload instead of using automatic residency."
),
)
@@ -341,56 +316,6 @@ _visible_sensitivity_option = click.option(
)
def _resolve_auto_polish(auto: bool, adaptive_polish: bool) -> bool:
"""Warn on the retired ``--auto`` flag, returning ``adaptive_polish`` unchanged.
``--auto`` used to plan the pipeline + polish from content detection. There is now
only one default pipeline, and the content detectors were removed, so the flag
survives purely as a polish request: it emits a deprecation warning and passes
``adaptive_polish`` through, with an explicit ``--no-adaptive-polish`` still winning.
"""
if auto:
click.echo(
"Warning: --auto is deprecated and now does nothing (the adaptive polish it "
"enabled is ON by default). Use --no-adaptive-polish to turn the polish off.",
err=True,
)
return adaptive_polish
def _resolve_profile_polish(auto: bool, adaptive_polish: bool, pipeline: str) -> bool:
"""Keep the upstream qwen-zimage output unchanged unless polish was explicit.
``--auto`` counts as explicit. It is deprecated, but it is still a request for the
polish, and once qwen-zimage became the DEFAULT pipeline the source check below
would otherwise have silently turned that flag into a no-op for every caller.
"""
adaptive_polish = _resolve_auto_polish(auto, adaptive_polish)
if pipeline != QWEN_ZIMAGE_PROFILE or auto:
return adaptive_polish
ctx = click.get_current_context(silent=True)
if ctx is None:
return adaptive_polish
if ctx.get_parameter_source("adaptive_polish") == click.core.ParameterSource.DEFAULT:
return False
return adaptive_polish
def _warn_if_esrgan_unavailable(upscaler: str) -> None:
"""Tell the user once if ``--upscaler esrgan`` will silently fall back to Lanczos.
The engine downgrades to Lanczos when the ``esrgan`` extra is absent (fail-safe, so
a batch never breaks mid-run) -- but without this notice the user would believe
Real-ESRGAN ran. Surfaced at the CLI layer, once per invocation (not per image).
"""
if upscaler != "esrgan":
return
from remove_ai_watermarks import upscaler as _upscaler
if not _upscaler.is_available():
console.print(" Note: --upscaler esrgan needs the 'esrgan' extra; falling back to Lanczos.")
def _visible_provenance(path: Path | None) -> frozenset[str]:
"""Vendor keys local metadata confirms, the EVIDENCE that drives ``auto``
sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one
@@ -866,42 +791,13 @@ def cmd_erase(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@_strength_option
@click.option(
"--steps",
type=int,
default=None,
help="Number of denoising steps. Both profiles are distilled four-step schedules, so 4 is the only accepted value.",
)
@_pipeline_option
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@click.option(
"--max-resolution",
type=int,
default=0,
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_seed_option
@_hf_token_option
@_humanize_option
@_max_resolution_option
@_controlnet_scale_option
@_min_resolution_option
@_unsharp_option
@_upscaler_option
@_model_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@@ -912,21 +808,14 @@ def cmd_invisible(
source: Path,
output: Path | None,
strength: float | None,
steps: int | None,
pipeline: str,
device: str,
seed: int | None,
hf_token: str | None,
humanize: float,
unsharp: float,
max_resolution: int,
min_resolution: int,
controlnet_scale: float,
upscaler: str,
model: str | None,
guidance_scale: float | None,
auto: bool,
adaptive_polish: bool,
adaptive_polish: bool | None,
tile: bool,
tile_size: int,
tile_overlap: int,
@@ -935,30 +824,24 @@ def cmd_invisible(
) -> None:
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
Uses diffusion-based regeneration. Requires GPU for reasonable speed.
Requires the [diffusion] extra: pip install 'remove-ai-watermarks[diffusion]'
Regenerates the pixels with the two-stage diffusion profile. CUDA-only:
pip install 'remove-ai-watermarks[qwen-zimage]'
"""
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
if not invisible_available():
console.print(
"Error: Diffusion dependencies not installed.\n"
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
"Error: the invisible-removal dependencies are not installed.\n"
f" Install them with: pip install {INVISIBLE_EXTRA}"
)
raise SystemExit(1)
from remove_ai_watermarks.invisible_engine import InvisibleEngine
source = _validate_image(source)
steps = resolve_steps(steps)
seed = resolve_seed(seed)
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
if output is None:
output = source.with_stem(source.stem + "_clean")
device_str = None if device == "auto" else device
# Gate BEFORE building the engine: skip the destructive regeneration when no
# invisible AI watermark is locally detectable (it would only degrade a clean
# image -- dominant paid score-0 cause), so the common skip path pays nothing for
@@ -970,8 +853,6 @@ def cmd_invisible(
console.print(f" {msg}")
engine = InvisibleEngine(
model_id=model,
device=device_str,
pipeline=pipeline,
hf_token=hf_token,
progress_callback=progress_cb,
@@ -984,22 +865,18 @@ def cmd_invisible(
vendor = vendor_for_strength(source)
console.print(f" Input: {source.name}")
console.print(f" Pipeline: {pipeline}")
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)} Steps: {steps}")
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
t0 = time.monotonic()
result_path = engine.remove_watermark(
image_path=source,
output_path=output,
strength=strength,
num_inference_steps=steps,
guidance_scale=guidance_scale,
seed=seed,
humanize=humanize,
unsharp=unsharp,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
min_resolution=min_resolution,
upscaler=upscaler,
vendor=vendor,
tile=tile,
tile_size=tile_size,
@@ -1556,42 +1433,13 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
@_visible_backend_option
@_visible_sensitivity_option
@_strength_option
@click.option(
"--steps",
type=int,
default=None,
help="Number of denoising steps. Both profiles are distilled four-step schedules, so 4 is the only accepted value.",
)
@_pipeline_option
@_model_option
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@click.option(
"--max-resolution",
type=int,
default=0,
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_seed_option
@_hf_token_option
@_humanize_option
@_max_resolution_option
@_controlnet_scale_option
@_min_resolution_option
@_unsharp_option
@_upscaler_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@@ -1604,21 +1452,14 @@ def cmd_all(
backend: str,
sensitivity: str,
strength: float | None,
steps: int | None,
pipeline: str,
model: str | None,
device: str,
seed: int | None,
hf_token: str | None,
humanize: float,
unsharp: float,
max_resolution: int,
min_resolution: int,
controlnet_scale: float,
upscaler: str,
guidance_scale: float | None,
auto: bool,
adaptive_polish: bool,
adaptive_polish: bool | None,
tile: bool,
tile_size: int,
tile_overlap: int,
@@ -1636,10 +1477,6 @@ def cmd_all(
"""
_banner()
source = _validate_image(source)
steps = resolve_steps(steps)
seed = resolve_seed(seed)
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
if output is None:
output = source.with_stem(source.stem + "_clean")
@@ -1694,7 +1531,7 @@ def cmd_all(
synthid_skipped = True
console.print(
" Warning: Skipped - GPU dependencies not installed.\n"
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
f" Install them with: pip install {INVISIBLE_EXTRA}"
)
elif _should_skip_invisible_scrub(force, source):
# No locally-detectable invisible watermark -> skip the destructive
@@ -1711,14 +1548,10 @@ def cmd_all(
else:
from remove_ai_watermarks.invisible_engine import InvisibleEngine
device_str = None if device == "auto" else device
def progress_cb(msg: str) -> None:
console.print(f" {msg}")
inv_engine = InvisibleEngine(
model_id=model,
device=device_str,
pipeline=pipeline,
hf_token=hf_token,
progress_callback=progress_cb,
@@ -1730,22 +1563,16 @@ def cmd_all(
# already lost its C2PA to the visible-removal pass, so reading it would
# always resolve to the unknown-vendor default.
vendor = vendor_for_strength(source)
console.print(
f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)} Steps: {steps}"
)
console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}")
inv_engine.remove_watermark(
image_path=tmp_path,
output_path=tmp_path,
strength=strength,
num_inference_steps=steps,
guidance_scale=guidance_scale,
seed=seed,
humanize=humanize,
unsharp=unsharp,
adaptive_polish=adaptive_polish,
max_resolution=max_resolution,
min_resolution=min_resolution,
upscaler=upscaler,
vendor=vendor,
tile=tile,
tile_size=tile_size,
@@ -1800,7 +1627,7 @@ def cmd_all(
" visible mark and metadata were stripped.\n"
"\n"
" Install the extra and rerun to remove it:\n"
" pip install 'remove-ai-watermarks[diffusion]'\n"
f" pip install {INVISIBLE_EXTRA}\n"
" ====================================================================="
)
raise SystemExit(1)
@@ -1822,15 +1649,13 @@ class _BatchOptions:
"""Validated processing options shared by every image in one batch.
Click necessarily exposes these as individual command parameters, but the
processing core should receive one coherent value instead of a 21-argument
processing core should receive one coherent value instead of a long positional
call. Keeping the object immutable also makes it safe to reuse while the
batch caches model instances in ``ctx.obj``.
"""
strength: float | None
steps: int
pipeline: str
device: str
seed: int | None
hf_token: str | None
humanize: float
@@ -1838,12 +1663,9 @@ class _BatchOptions:
sensitivity: str = "auto"
unsharp: float = 0.0
max_resolution: int = 0
min_resolution: int = 1024
controlnet_scale: float = 1.0
upscaler: str = "lanczos"
model: str | None = None
guidance_scale: float | None = None
adaptive_polish: bool = False
# None means "the user did not choose"; the library resolves it per profile.
adaptive_polish: bool | None = None
tile: bool = False
tile_size: int = 1024
tile_overlap: int = 128
@@ -1877,8 +1699,6 @@ def _run_batch_invisible(
engines = ctx.obj.setdefault("_inv_engines", {})
if options.pipeline not in engines:
engines[options.pipeline] = InvisibleEngine(
model_id=options.model,
device=None if options.device == "auto" else options.device,
pipeline=options.pipeline,
hf_token=options.hf_token,
controlnet_conditioning_scale=options.controlnet_scale,
@@ -1888,15 +1708,11 @@ def _run_batch_invisible(
img_path if mode == "invisible" else out_path,
out_path,
strength=options.strength,
num_inference_steps=options.steps,
guidance_scale=options.guidance_scale,
seed=options.seed,
humanize=options.humanize,
unsharp=options.unsharp,
adaptive_polish=options.adaptive_polish,
max_resolution=options.max_resolution,
min_resolution=options.min_resolution,
upscaler=options.upscaler,
tile=options.tile,
tile_size=options.tile_size,
tile_overlap=options.tile_overlap,
@@ -1999,44 +1815,15 @@ def _process_batch_image(
"--mode", type=click.Choice(["visible", "invisible", "metadata", "all"]), default="visible", help="Processing mode."
)
@_strength_option
@click.option(
"--steps",
type=int,
default=None,
help="Number of denoising steps. Both profiles are distilled four-step schedules, so 4 is the only accepted value.",
)
@_visible_backend_option
@_visible_sensitivity_option
@click.option(
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
)
@_humanize_option
@_pipeline_option
@click.option(
"--device",
type=click.Choice(["auto", "cpu", "mps", "cuda", "xpu"]),
default="auto",
help="Inference device.",
)
@click.option(
"--seed",
type=int,
default=None,
help="Random seed for reproducibility. Default: 0 for qwen-zimage, random otherwise.",
)
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
@click.option(
"--max-resolution",
type=int,
default=0,
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
)
@_min_resolution_option
@_seed_option
@_hf_token_option
@_max_resolution_option
@_unsharp_option
@_upscaler_option
@_controlnet_scale_option
@_model_option
@_guidance_scale_option
@_auto_option
@_adaptive_polish_option
@_tile_options
@_force_option
@@ -2048,9 +1835,7 @@ def cmd_batch(
mode: str,
output_dir: Path | None,
strength: float | None,
steps: int | None,
pipeline: str,
device: str,
seed: int | None,
hf_token: str | None,
backend: str,
@@ -2058,13 +1843,8 @@ def cmd_batch(
humanize: float,
unsharp: float,
max_resolution: int,
min_resolution: int,
controlnet_scale: float,
upscaler: str,
model: str | None,
guidance_scale: float | None,
auto: bool,
adaptive_polish: bool,
adaptive_polish: bool | None,
tile: bool,
tile_size: int,
tile_overlap: int,
@@ -2087,16 +1867,9 @@ def cmd_batch(
console.print(f" Found {len(images)} images in {directory}")
console.print(f" Output -> {output_dir}")
console.print(f" Mode: {mode}")
if mode in ("invisible", "all"):
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
steps = resolve_steps(steps)
seed = resolve_seed(seed)
options = _BatchOptions(
strength=strength,
steps=steps,
pipeline=pipeline,
device=device,
seed=seed,
hf_token=hf_token,
humanize=humanize,
@@ -2104,11 +1877,7 @@ def cmd_batch(
sensitivity=sensitivity,
unsharp=unsharp,
max_resolution=max_resolution,
min_resolution=min_resolution,
controlnet_scale=controlnet_scale,
upscaler=upscaler,
model=model,
guidance_scale=guidance_scale,
adaptive_polish=adaptive_polish,
tile=tile,
tile_size=tile_size,
@@ -2162,7 +1931,7 @@ def cmd_batch(
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
f"so those outputs still carry the invisible watermark.\n"
f" Install the extra and rerun: pip install 'remove-ai-watermarks[diffusion]'"
f" Install the extra and rerun: pip install {INVISIBLE_EXTRA}"
)
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
+49 -142
View File
@@ -1,7 +1,7 @@
"""Diffusion engine for regenerating images that carry invisible AI watermarks.
This module requires the 'gpu' extra dependencies:
uv pip install 'remove-ai-watermarks[diffusion]'
Requires the 'qwen-zimage' extra and a CUDA device:
uv pip install 'remove-ai-watermarks[qwen-zimage]'
"""
# cv2/torch boundary: this engine wraps cv2 (resize/imwrite/cvtColor) and the
@@ -14,15 +14,13 @@ import logging
import os
import warnings
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from ._internal.watermark_profiles import (
DEFAULT_MODEL_ID as DEFAULT_SDXL_MODEL_ID,
)
from ._internal.watermark_profiles import (
DEFAULT_PROFILE,
REMOVAL_MODULES,
resolve_adaptive_polish,
resolve_seed,
resolve_steps,
)
if TYPE_CHECKING:
@@ -42,27 +40,30 @@ logger = logging.getLogger(__name__)
def is_available() -> bool:
"""Check if invisible watermark removal dependencies are installed."""
"""Whether the dependencies for a real removal run are installed.
Shares :data:`REMOVAL_MODULES` with the remover's own precondition so the two
cannot drift. When they did, a torch+diffusers-only environment passed this gate
and then died at the DiffSynth face stage.
"""
from .optional_deps import module_available
return module_available("diffusers", "torch")
return module_available(*REMOVAL_MODULES)
def _target_size(width: int, height: int, max_resolution: int, min_resolution: int = 0) -> tuple[int, int] | None:
def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int] | None:
"""Compute the (width, height) to process at, or None for native.
Two opposite long-side adjustments, in precedence order:
One long-side adjustment: if it exceeds ``max_resolution``, scale DOWN to it
(integer-truncated, matching the PIL ``resize`` call site). 0/negative = no cap.
Set only to bound GPU/MPS memory on very large inputs (issue #10).
- ``max_resolution`` (cap): if the long side exceeds it, scale DOWN to it
(integer-truncated, matching the PIL ``resize`` call site). 0/negative = no
cap. Set only to bound GPU/MPS memory on very large inputs (issue #10).
- ``min_resolution`` (floor): else if the long side is below it, scale UP to it
(rounded) so SDXL img2img runs near its ~1024 training resolution instead of
degrading on a tiny latent (a 381x512 portrait distorts badly at native).
The output is restored to the original size by the caller, so the floor is a
transparent quality boost. 0 = no floor. Skipped on a ``min > max`` misconfig.
There was also a ``min_resolution`` floor that scaled small inputs UP toward
SDXL's ~1024 training size. It went with the SDXL profiles: both surviving
profiles run at native geometry, so the floor was forced to 0 on every path and
could not fire.
Returns None when neither applies (native resolution). Pure function so the
Returns None when the cap does not apply (native resolution). Pure function so the
resolution decision is unit-testable without loading the diffusion model.
"""
long_side = max(width, height)
@@ -71,9 +72,6 @@ def _target_size(width: int, height: int, max_resolution: int, min_resolution: i
# Clamp the short side to >=1: extreme aspect ratios (e.g. 5000x3 capped
# at 1024) would otherwise truncate it to 0 and crash image.resize().
return (max(1, int(width * ratio)), max(1, int(height * ratio)))
if min_resolution > 0 and long_side < min_resolution and (max_resolution <= 0 or min_resolution <= max_resolution):
ratio = min_resolution / long_side
return (max(1, round(width * ratio)), max(1, round(height * ratio)))
return None
@@ -84,13 +82,8 @@ class InvisibleEngine:
to break watermark patterns, and reconstructs via reverse diffusion.
"""
# SDXL base is the default since May 2026; the vendor-adaptive strength
# removes the current SynthID (see watermark_profiles + docs/synthid.md).
DEFAULT_MODEL_ID = DEFAULT_SDXL_MODEL_ID
def __init__(
self,
model_id: str | None = None,
device: str | None = None,
pipeline: str = DEFAULT_PROFILE,
hf_token: str | None = None,
@@ -101,8 +94,9 @@ class InvisibleEngine:
"""Initialize the invisible watermark removal engine.
Args:
model_id: HuggingFace model ID. None = use the SDXL base default.
device: Device for inference (auto/cpu/mps/cuda/xpu). None = auto.
device: Device for inference. Both profiles are CUDA-only, so the
usable values are "cuda" and None/"auto" (which detects it);
anything else raises rather than falling back.
pipeline: Pipeline profile, one of "qwen-zimage" (DEFAULT;
Qwen-Image-2512 Lightning + Canny, then SAM-masked Z-Image face repair)
or "sdxl-zimage" (the same recipe and the same face stage on an SDXL
@@ -121,11 +115,7 @@ class InvisibleEngine:
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
# Pass model_id through untouched. Substituting DEFAULT_MODEL_ID for None here
# meant the engine always supplied a model the remover is required to reject,
# so EVERY construction raised once that check tightened to "is not None".
self._remover = WatermarkRemover(
model_id=model_id,
device=device,
progress_callback=progress_callback,
hf_token=hf_token,
@@ -144,47 +134,17 @@ class InvisibleEngine:
"""
self._remover.preload(global_only=global_only)
def _esrgan_upscale(self, image: Any, target: tuple[int, int]) -> Any:
"""Upscale a PIL image to ``target`` with Real-ESRGAN, else Lanczos.
Runs Real-ESRGAN at its native factor (on the remover's device, CPU fallback),
then resizes to the exact ``target`` with Lanczos. Falls back to a plain Lanczos
resize when the ``esrgan`` extra is absent or the model errors.
"""
import cv2
import numpy as np
from PIL import Image
from remove_ai_watermarks import upscaler
if not upscaler.is_available():
logger.debug("esrgan upscaler requested but the extra is absent; using Lanczos")
return image.resize(target, Image.Resampling.LANCZOS)
try:
bgr = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR)
big = upscaler.upscale(bgr, device=self._remover.device)
if (big.shape[1], big.shape[0]) != target:
big = cv2.resize(big, target, interpolation=cv2.INTER_LANCZOS4)
return Image.fromarray(cv2.cvtColor(big, cv2.COLOR_BGR2RGB))
except Exception as e: # never let an optional upscaler break removal
logger.warning("Real-ESRGAN upscale failed (%s); using Lanczos", e)
return image.resize(target, Image.Resampling.LANCZOS)
def remove_watermark(
self,
image_path: Path,
output_path: Path | None = None,
strength: float | None = None,
num_inference_steps: int | None = None,
guidance_scale: float | None = None,
seed: int | None = None,
humanize: float = 0.0,
max_resolution: int = 0,
min_resolution: int = 1024,
vendor: str | None = None,
unsharp: float = 0.0,
adaptive_polish: bool = False,
upscaler: str = "lanczos",
adaptive_polish: bool | None = None,
tile: bool = False,
tile_size: int = 1024,
tile_overlap: int = 128,
@@ -194,38 +154,26 @@ class InvisibleEngine:
Args:
image_path: Path to the watermarked image.
output_path: Output path (None = overwrite source).
strength: Denoising strength (0.0-1.0). None -> the vendor-adaptive
default.
num_inference_steps: Number of denoising steps. None keeps the existing
100-step library default, except qwen-zimage uses its required
four-step Lightning schedule.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility. None resolves to 0 for
qwen-zimage and stays random for the other profiles.
strength: Denoising strength (0.0-1.0). None -> the profile's calibrated
default (resolution-adaptive for qwen-zimage, vendor-adaptive for
sdxl-zimage).
seed: Random seed for reproducibility. None resolves to 0, because both
profiles are certified at a fixed seed.
humanize: Intensity of Analog Humanizer film grain (0 = off).
unsharp: Final unsharp-mask sharpening strength (0 = off, default).
Applied last to counter the soft / over-smoothed look of the
diffusion pass; ~0.5-0.8 is a safe range, higher risks edge halos.
adaptive_polish: When True (the CLI default), restore the input's detail
level in the softened output: a capped unsharp + edge-masked grain
targeting the input's Laplacian variance. Self-limiting -- a no-op when
the output already meets the input's detail level (text/flat graphics),
so it only acts on over-smoothed photo/face texture. Runs LAST.
adaptive_polish: Restore the input's detail level in the softened
output: a capped unsharp + edge-masked grain targeting the input's
Laplacian variance. Self-limiting -- a no-op when the output already
meets the input's detail level (text/flat graphics), so it only acts on
over-smoothed photo/face texture. Runs LAST. None (the default) follows
the profile: off for qwen-zimage, on for sdxl-zimage. This resolves
through the same ``resolve_adaptive_polish`` the CLI uses, so a library
caller and a CLI caller on one profile get the same output.
max_resolution: Cap the long side (px) before diffusion. 0 (default)
= no cap. Set a positive value only to bound GPU/MPS memory on
very large inputs (it reintroduces a lossy downscale->upscale
round-trip).
min_resolution: Upscale the long side UP to this (px) before diffusion
when the input is smaller, so SDXL runs near its ~1024 training
resolution (small inputs degrade/distort badly at native). 1024
(default) = on; 0 = off. The output is restored to the original
input size, so this is a transparent quality boost; it adds time
and memory on small inputs. Ignored on a min > max misconfig.
upscaler: How to upscale a small input to the ``min_resolution`` floor:
``"lanczos"`` (default, cv2, no model download) or ``"esrgan"`` (Real-ESRGAN
via the ``esrgan`` extra). Only applies when UPscaling (the floor
case); a ``max_resolution`` downscale always uses Lanczos. Falls back
to Lanczos if the extra is absent.
= no cap. Set a positive value only to bound GPU memory on very large
inputs (it reintroduces a lossy downscale->upscale round-trip).
tile: Process the diffusion pass in overlapping tiles instead of one
forward pass. This retains the input's native dimensions instead
of applying ``max_resolution``, but each tile is still regenerated.
@@ -238,16 +186,13 @@ class InvisibleEngine:
"""
import tempfile
num_inference_steps = resolve_steps(num_inference_steps)
seed = resolve_seed(seed)
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile)
from PIL import Image, ImageOps
# Resolution policy: a max_resolution cap (0 = none) bounds memory on huge
# inputs, and a min_resolution floor (1024 = default) upscales tiny inputs so
# SDXL img2img runs near its ~1024 training size instead of distorting on a
# tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up).
# The output is restored to orig_size below, so the floor is transparent.
# inputs. See _target_size for why it is the only lever left.
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT)
# decodes here too. The --force skip path bypasses image_io.imread, which is
# what would otherwise register it, so a bare Image.open would fail on HEIC.
@@ -261,34 +206,16 @@ class InvisibleEngine:
# reassigned to the resized copy below; PIL resize returns a new object).
reference_pil = image
# qwen-zimage operates at the input's native geometry in its reference graph.
# Keep an explicit max cap available for callers, but do not apply the SDXL
# 1024px minimum-resolution floor to this profile.
effective_min_resolution = (
0 if getattr(self._remover, "model_profile", None) in {"qwen-zimage", "sdxl-zimage"} else min_resolution
)
target = _target_size(
image.width,
image.height,
max_resolution,
effective_min_resolution,
)
# Both profiles run at the input's native geometry, so only the explicit max
# cap can move it, and it can only ever scale down.
target = _target_size(image.width, image.height, max_resolution)
if target is not None:
upscaling = max(target) > max(image.width, image.height)
if self._progress_callback:
reason = (
f"min-resolution floor {min_resolution}px"
if upscaling
else f"max-resolution cap {max_resolution}px"
self._progress_callback(
f"Downscaling {image.width}x{image.height} to {target[0]}x{target[1]} "
f"(max-resolution cap {max_resolution}px)..."
)
verb = "Upscaling" if upscaling else "Downscaling"
self._progress_callback(f"{verb} {image.width}x{image.height} to {target[0]}x{target[1]} ({reason})...")
# Real-ESRGAN only helps when UPscaling (the floor case); a downscale cap
# always uses Lanczos. _esrgan_upscale falls back to Lanczos if the extra is absent.
if upscaling and upscaler == "esrgan":
image = self._esrgan_upscale(image, target)
else:
image = image.resize(target, Image.Resampling.LANCZOS)
image = image.resize(target, Image.Resampling.LANCZOS)
# Always persist to a temp file, even without downscaling: WatermarkRemover
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
@@ -308,8 +235,6 @@ class InvisibleEngine:
image_path=image_path,
output_path=output_path,
strength=strength,
num_inference_steps=num_inference_steps,
guidance_scale=guidance_scale,
seed=seed,
vendor=vendor,
tile=tile,
@@ -380,21 +305,3 @@ class InvisibleEngine:
# _tmp_path is always set above (we persist the image unconditionally).
if _tmp_path.exists():
_tmp_path.unlink()
def remove_watermark_batch(
self,
input_dir: Path,
output_dir: Path,
strength: float | None = None,
steps: int | None = None,
) -> list[Path]:
"""Remove invisible watermarks from all images in a directory."""
if steps is None:
profile = getattr(self._remover, "model_profile", None)
steps = 4 if profile in {"qwen-zimage", "sdxl-zimage"} else 50
return self._remover.remove_watermark_batch(
input_dir=input_dir,
output_dir=output_dir,
strength=strength,
num_inference_steps=steps,
)
-126
View File
@@ -1,126 +0,0 @@
"""Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN via spandrel).
Mirrors ``region_eraser``'s optional-backend pattern: ``is_available()`` guards the
``spandrel`` import, a lazy singleton (double-checked lock) holds the loaded model, and
the weights download on first use (cached by ``torch.hub``) -- they are never bundled.
The DEFAULT upscaler stays Lanczos (cv2, no model download); this is opt-in via the ``esrgan``
extra and feeds the ``--upscaler esrgan`` path. ``spandrel`` is a pure model-loader
(MIT) with NO basicsr dependency -- it pulls only torch/torchvision/safetensors/numpy/
einops -- so it sidesteps the basicsr / ``torchvision.transforms.functional_tensor``
breakage that the retired ``restore`` (GFPGAN) extra had to shim. Real-ESRGAN weights
are BSD-3-Clause.
CPU works but is slow on large inputs, so this is meant for the pre-diffusion upscale of
SMALL inputs (and the GPU worker). On a memory-constrained host it is a no-op (the extra
is absent), and the caller falls back to Lanczos.
"""
# torch/spandrel boundary: these libs ship no usable element types; relax the
# unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
# Real-ESRGAN x2plus (BSD-3-Clause), official release. x2 is the right native factor for
# the pre-diffusion floor upscale (small inputs ~512 -> ~1024); spandrel infers the
# architecture and scale from the checkpoint, so swapping the URL is enough to change it.
_MODEL_URL = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"
_MODEL_FILENAME = "RealESRGAN_x2plus.pth"
_model: Any = None # lazy singleton (spandrel ImageModelDescriptor)
_model_device: str = "cpu"
_lock = threading.Lock()
def is_available() -> bool:
"""True if the ``esrgan`` extra (spandrel + torch) is importable."""
from .optional_deps import module_available
return module_available("spandrel", "torch")
def _model_cache_path() -> Path:
"""Path the weights are cached at (the torch.hub checkpoints dir)."""
import torch
cache_dir = Path(torch.hub.get_dir()) / "checkpoints"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / _MODEL_FILENAME
def _get_model(device: str) -> Any:
"""Load the Real-ESRGAN model once (downloading the weights on first use)."""
global _model, _model_device
if _model is not None and _model_device == device:
return _model
with _lock:
if _model is None:
import torch
from spandrel import ImageModelDescriptor, ModelLoader
dst = _model_cache_path()
if not dst.exists():
logger.info("Downloading Real-ESRGAN weights to %s", dst)
torch.hub.download_url_to_file(_MODEL_URL, str(dst), progress=False)
model = ModelLoader().load_from_file(str(dst))
if not isinstance(model, ImageModelDescriptor):
raise RuntimeError(f"Unexpected spandrel model type: {type(model).__name__}")
_model = model.eval()
if _model_device != device:
_model.to(device)
_model_device = device
return _model
def scale() -> int:
"""The model's native upscale factor (e.g. 2 for x2plus). Loads the model if needed."""
return int(_get_model("cpu").scale)
def upscale(image: NDArray[Any], device: str | None = None) -> NDArray[Any]:
"""Upscale a BGR uint8 image by the model's native factor with Real-ESRGAN.
Returns a BGR uint8 array. Falls back to CPU if the requested device errors (an
MPS/CUDA OOM or unsupported-op on the small pre-diffusion input), mirroring the
diffusion engine's MPS->CPU fallback.
Raises:
RuntimeError: if the ``esrgan`` extra is not installed (guard with
``is_available()`` first).
"""
if not is_available():
raise RuntimeError("Real-ESRGAN upscaler needs the 'esrgan' extra (spandrel). Install it or use Lanczos.")
import cv2
import numpy as np
import torch
target_device = (device or "cpu").lower()
if target_device not in {"cpu", "mps", "cuda", "xpu"}:
target_device = "cpu"
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
tensor = torch.from_numpy(rgb).permute(2, 0, 1).float().div(255.0).unsqueeze(0)
def _run(dev: str) -> NDArray[Any]:
model = _get_model(dev)
with torch.no_grad():
out = model(tensor.to(dev))
arr = out.clamp(0.0, 1.0).squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0
return cv2.cvtColor(arr.round().astype(np.uint8), cv2.COLOR_RGB2BGR)
try:
return _run(target_device)
except Exception as e: # GPU OOM / unsupported op: fall back to CPU
if target_device == "cpu":
raise
logger.warning("Real-ESRGAN on %s failed (%s); retrying on CPU", target_device, e)
return _run("cpu")
-38
View File
@@ -1138,14 +1138,6 @@ def _scan_video_detectors(
}
def _scan_video(
source: Path,
detector: Any,
) -> VideoScan:
"""Decode a video once and collect one untrusted candidate per frame."""
return _scan_video_detectors(source, {"selected": detector})["selected"]
def scan_video_marks(
source: Path,
marks: tuple[str, ...] = VIDEO_VISIBLE_MARKS,
@@ -1181,36 +1173,6 @@ def scan_video_marks(
)
def scan_sora_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Sora candidate per frame."""
return _scan_video(source, detect_sora_frame)
def scan_veo_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Veo candidate per frame."""
return _scan_video(source, detect_veo_frame)
def scan_seedance_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Seedance candidate per frame."""
return _scan_video(source, detect_seedance_frame)
def scan_dola_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Dola candidate per frame."""
return _scan_video(source, detect_dola_frame)
def scan_hailuo_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Hailuo candidate per frame."""
return _scan_video(source, detect_hailuo_frame)
def scan_kling_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Kling candidate per frame."""
return _scan_video(source, detect_kling_frame)
def _mask_for_region(
frame_bgr: NDArray[Any],
region: Region,
+72 -36
View File
@@ -327,7 +327,13 @@ class TestInvisibleCommand:
expected = sample_png.with_stem(sample_png.stem + "_clean")
assert expected.exists()
def test_invisible_adaptive_polish_off_by_default_under_qwen_zimage(self, runner, sample_png):
def test_invisible_leaves_the_polish_default_to_the_library(self, runner, sample_png):
"""An untyped --adaptive-polish reaches the engine as None, not as a value.
The per-profile default lives in watermark_profiles, so the CLI must pass the
user's non-choice through rather than resolving it here. Resolving in the CLI
is how the library and the CLI came to disagree on the same profile.
"""
mock_cls, mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
@@ -336,13 +342,7 @@ class TestInvisibleCommand:
):
result = runner.invoke(main, ["invisible", str(sample_png), "--force"])
assert result.exit_code == 0, result.output
# The default profile is qwen-zimage, and _resolve_profile_polish keeps its
# output untouched unless polish was asked for explicitly. It stays available:
# passing --adaptive-polish still turns it on (covered separately).
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
# Default model is None (the SDXL base) and CFG is None (the library's 7.5).
assert mock_cls.call_args.kwargs["model_id"] is None
assert mock_engine.remove_watermark.call_args.kwargs["guidance_scale"] is None
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is None
def test_invisible_no_adaptive_polish_disables(self, runner, sample_png):
mock_cls, mock_engine = _mock_invisible_engine()
@@ -355,20 +355,29 @@ class TestInvisibleCommand:
assert result.exit_code == 0, result.output
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
def test_invisible_model_and_guidance_scale_flow_to_engine(self, runner, sample_png):
mock_cls, mock_engine = _mock_invisible_engine()
with (
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
):
result = runner.invoke(
main,
["invisible", str(sample_png), "--model", "org/custom-sdxl", "--guidance-scale", "5.5", "--force"],
)
assert result.exit_code == 0, result.output
assert mock_cls.call_args.kwargs["model_id"] == "org/custom-sdxl"
assert mock_engine.remove_watermark.call_args.kwargs["guidance_scale"] == 5.5
def test_knobs_the_fixed_stack_cannot_honor_are_not_offered(self, runner, sample_png):
"""--model/--steps/--guidance-scale/--device/--auto are gone, not rejected.
Each pinned a value the profiles fix (model stack, per-stage schedule, CFG 1.0,
CUDA), so accepting one only produced an error several layers down -- a flag
that advertises a capability the library does not have. Click now refuses the
option itself, which is the honest answer and the one a caller can act on.
"""
retired = (
["--model", "org/custom-sdxl"],
["--steps", "20"],
["--guidance-scale", "5.5"],
["--device", "cpu"],
["--auto"],
)
# All THREE diffusion commands, not just `invisible`. Each used to declare these
# inline, so removing them from one and not the others is a live possibility.
for command in ("invisible", "all", "batch"):
target = str(sample_png.parent) if command == "batch" else str(sample_png)
for args in retired:
result = runner.invoke(main, [command, target, *args, "--force"])
assert result.exit_code == 2, f"{command} {args[0]}: {result.output}"
assert "No such option" in result.output, f"{command} {args[0]}: {result.output}"
def test_retired_pipeline_names_are_rejected_not_silently_remapped(self, runner, sample_png):
"""default/sdxl/controlnet/qwen were removed with their CPU code paths.
@@ -530,7 +539,7 @@ class TestAllCommand:
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
assert result.exit_code != 0, result.output
assert "NOT removed" in result.output
assert "remove-ai-watermarks[diffusion]" in result.output
assert "remove-ai-watermarks[qwen-zimage]" in result.output
assert output.exists() # visible + metadata still produced a file
def test_all_reports_metadata_that_survived_stripping(self, runner, sample_png, tmp_path):
@@ -858,10 +867,12 @@ class TestBatchCommand:
assert out[0, 0, 3] == 0
assert out[100, 100, 3] == 255
def test_batch_auto_is_deprecated_and_enables_polish(self, runner, tmp_path):
"""--auto is retired: it warns and just enables the adaptive polish.
def test_batch_forwards_an_explicit_adaptive_polish(self, runner, tmp_path):
"""A typed --adaptive-polish must reach the engine as True, not as None.
It no longer selects a pipeline: qwen-zimage is the only default there is.
This mocks the engine, so it covers the CLI's forwarding only; the per-profile
resolution of an UNSET flag happens inside the real engine and is guarded by
test_invisible_engine.py::TestEngineResolvesThePolishPerProfile.
"""
input_dir = _make_batch_dir(tmp_path, count=2)
output_dir = tmp_path / "output"
@@ -874,12 +885,19 @@ class TestBatchCommand:
):
result = runner.invoke(
main,
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible", "--auto", "--force"],
[
"batch",
str(input_dir),
"-o",
str(output_dir),
"--mode",
"invisible",
"--adaptive-polish",
"--force",
],
)
assert result.exit_code == 0, result.output
assert "2 processed" in result.output
assert "deprecated" in result.output.lower()
# Pipeline stays the default controlnet; --auto only turned the polish on.
assert mock_cls.call_args.kwargs["pipeline"] == "qwen-zimage"
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
@@ -923,21 +941,39 @@ class TestBatchCommand:
class TestGpuHintMarkup:
"""The diffusion install hint must reach the user with the ``[diffusion]`` token
intact (plain output prints it verbatim, with no markup parsing)."""
"""The install hint must name the extra that actually makes a removal run.
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
It must also survive to the user with its ``[...]`` token intact (plain output
prints it verbatim, with no markup parsing). It used to say ``[diffusion]``,
which installs torch and diffusers but not the DiffSynth face stage both
profiles run -- so following the advice produced a second, different failure.
"""
def test_the_hint_is_a_command_the_user_can_actually_paste(self, runner, sample_png):
"""The extras bracket must be shell-quoted.
A bulk replace once folded these hints into one constant and dropped the quotes
the originals had. `pip install remove-ai-watermarks[qwen-zimage]` dies with
"zsh: no matches found" on the macOS default shell before pip ever runs -- an
install hint that does not install, which is the failure this hint was fixed to
stop producing in the first place.
"""
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["invisible", str(sample_png)])
assert "pip install 'remove-ai-watermarks[qwen-zimage]'" in result.output
def test_invisible_install_hint_names_the_working_extra(self, runner, sample_png):
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["invisible", str(sample_png)])
assert result.exit_code != 0
assert "remove-ai-watermarks[diffusion]" in result.output
assert "remove-ai-watermarks[qwen-zimage]" in result.output
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
def test_all_install_hint_names_the_working_extra(self, runner, sample_png):
# The `all` pipeline skips the invisible step with a warning that carries
# the same hint; it must keep the [diffusion] extra too.
# the same hint; it must name the same extra.
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
result = runner.invoke(main, ["all", str(sample_png)])
assert "remove-ai-watermarks[diffusion]" in result.output
assert "remove-ai-watermarks[qwen-zimage]" in result.output
class TestEraseCommand:
+64 -111
View File
@@ -16,24 +16,24 @@ class TestIsAvailable:
result = is_available()
assert isinstance(result, bool)
def test_available_reflects_dependencies(self):
"""is_available() is True iff torch + diffusers (the diffusion extra) import.
def test_the_module_list_includes_the_face_stage_runtime(self):
"""diffsynth is part of the answer, not an optional upgrade.
Must not assume the full stack: the default+dev CI env has no diffusers.
Both profiles repair faces with the DiffSynth Z-Image stage, so a
torch+diffusers-only environment used to clear this gate and then die there.
The discriminating guard -- that this gate and the remover's precondition
BOTH close when any one module is missing -- lives in
``test_platform.py::TestAvailability``; comparing ``is_available()`` to a
tuple derived from itself passes on every host and proves nothing.
"""
import importlib.util
from remove_ai_watermarks._internal.watermark_profiles import REMOVAL_MODULES
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert is_available() is expected
assert "diffsynth" in REMOVAL_MODULES
class TestInvisibleEngineInit:
"""Tests for InvisibleEngine construction (no GPU required)."""
def test_default_model_id(self):
# SDXL base became the default in May 2026 (defeats SynthID v2).
assert InvisibleEngine.DEFAULT_MODEL_ID == "stabilityai/stable-diffusion-xl-base-1.0"
def test_preload_forwards_global_only(self):
engine = object.__new__(InvisibleEngine)
engine._remover = SimpleNamespace(preload=lambda **kwargs: setattr(engine, "_preload_kwargs", kwargs))
@@ -55,13 +55,13 @@ class TestNativeOutputSize:
Image.open(image_path).crop((0, 0, 24, 16)).save(out)
return out
engine._remover = SimpleNamespace(remove_watermark=_remove_watermark)
engine._remover = SimpleNamespace(remove_watermark=_remove_watermark, model_profile="qwen-zimage")
engine._progress_callback = None
src = tmp_path / "src.png"
out = tmp_path / "out.png"
Image.new("RGB", (24, 18), (128, 128, 128)).save(src)
engine.remove_watermark(src, out, min_resolution=0, adaptive_polish=False)
engine.remove_watermark(src, out, adaptive_polish=False)
assert Image.open(out).size == (24, 18)
@@ -107,128 +107,81 @@ class TestTargetSize:
assert _target_size(5000, 3, 1024) == (1024, 1)
assert _target_size(3, 5000, 1024) == (1, 1024)
# ── min_resolution floor (small inputs upscaled so SDXL runs near 1024) ──
def test_floor_default_off(self):
# min_resolution defaults to 0 -> no upscale, preserving legacy behavior.
def test_a_small_input_is_left_at_native_size(self):
"""No minimum-resolution floor: only the cap can move geometry."""
assert _target_size(381, 512, 0) is None
def test_floor_upscales_small_input(self):
# 381x512 portrait, floor 1024 -> long side 512 scaled up to 1024 (x2).
assert _target_size(381, 512, 0, 1024) == (762, 1024)
# Landscape: width is the long side.
assert _target_size(512, 381, 0, 1024) == (1024, 762)
def test_floor_rounds_short_side(self):
# 333x500, floor 1024: ratio 2.048 -> 333*2.048=681.98 rounds to 682.
assert _target_size(333, 500, 0, 1024) == (682, 1024)
def test_floor_no_op_at_or_above_floor(self):
# Long side already >= floor -> no upscale (and no cap set -> native).
assert _target_size(1024, 768, 0, 1024) is None
assert _target_size(2000, 1000, 0, 1024) is None
def test_cap_takes_precedence_over_floor(self):
# A huge input with both set: the cap downscales; the floor never fires.
assert _target_size(2000, 1000, 1024, 1024) == (1024, 512)
def test_floor_skipped_on_min_above_max_misconfig(self):
# min(1024) > max(800) is a misconfig: the floor must not upscale above the
# cap, so it is skipped and the (within-cap) input stays native.
assert _target_size(500, 400, 800, 1024) is None
assert _target_size(381, 512, 4096) is None
class TestEngineDoesNotFabricateAModelId:
"""The engine must forward model_id untouched, including None.
class TestEngineConstructsWithoutAModelId:
"""Plain construction must reach the remover, and must not name a model.
It used to substitute DEFAULT_MODEL_ID for None. Once the remover tightened its
"you may not override the fixed stack" check from `not in {None, DEFAULT_MODEL_ID}`
to `is not None`, that substitution made EVERY InvisibleEngine construction raise -
and no test saw it, because the library tests build WatermarkRemover directly while
the engine tests mock it. A deployed Modal worker caught it instead.
The engine used to take a ``model_id`` and substitute the SDXL default for None.
Once the remover tightened its "you may not override the fixed stack" check to
``is not None``, that substitution made EVERY construction raise -- and no test
saw it, because the library tests build WatermarkRemover directly while the engine
tests mock it. A deployed Modal worker caught it instead. The parameter is gone on
both sides now, so guard the property that broke: a default construction reaches
the remover, carrying no model at all.
"""
def test_none_stays_none(self):
def test_default_construction_names_no_model(self):
from unittest.mock import patch
import remove_ai_watermarks.invisible_engine as engine_module
with patch("remove_ai_watermarks._internal.watermark_remover.WatermarkRemover") as remover:
engine_module.InvisibleEngine(pipeline="qwen-zimage")
assert remover.call_args.kwargs["model_id"] is None
assert remover.call_count == 1
assert "model_id" not in remover.call_args.kwargs
def test_an_explicit_model_id_still_reaches_the_remover_to_be_rejected(self):
from unittest.mock import patch
def test_a_model_id_is_not_accepted(self):
import pytest
import remove_ai_watermarks.invisible_engine as engine_module
with patch("remove_ai_watermarks._internal.watermark_remover.WatermarkRemover") as remover:
engine_module.InvisibleEngine(model_id="org/custom", pipeline="qwen-zimage")
assert remover.call_args.kwargs["model_id"] == "org/custom"
with pytest.raises(TypeError):
engine_module.InvisibleEngine(model_id="org/custom", pipeline="qwen-zimage") # type: ignore[call-arg]
class TestEsrganUpscale:
"""Branches of InvisibleEngine._esrgan_upscale (no diffusion model loaded).
class TestEngineResolvesThePolishPerProfile:
"""The engine, not the CLI, turns an unset adaptive_polish into the profile default.
A SimpleNamespace stands in for the engine so we exercise the helper without
constructing a real InvisibleEngine (which would load WatermarkRemover).
This is the change that stopped a library caller and a CLI caller on one profile
from producing different pixels, and it had no test: rebinding
``resolve_adaptive_polish`` to ``bool(value)`` -- exactly the pre-commit behaviour --
left the whole suite green.
"""
@staticmethod
def _fake_engine():
from types import SimpleNamespace
def _engine(profile: str):
from unittest.mock import MagicMock
return SimpleNamespace(_remover=SimpleNamespace(device="cpu"))
engine = object.__new__(InvisibleEngine)
engine._progress_callback = None
engine._remover = MagicMock(model_profile=profile)
return engine
@staticmethod
def _pil(w=120, h=80):
import numpy as np
from PIL import Image
def _polish_used(self, profile: str, requested, tmp_path, monkeypatch) -> bool:
seen: list[bool] = []
monkeypatch.setattr(
"remove_ai_watermarks.humanizer.adaptive_polish",
lambda out, ref, seed=None: (seen.append(True), out)[1],
)
src = tmp_path / f"{profile}_{requested}.png"
Image.new("RGB", (32, 32), (90, 120, 150)).save(src)
engine = self._engine(profile)
engine._remover.remove_watermark.side_effect = lambda **kw: (
Image.open(kw["image_path"]).save(kw["output_path"]),
kw["output_path"],
)[1]
engine.remove_watermark(src, tmp_path / f"out_{profile}_{requested}.png", adaptive_polish=requested)
return bool(seen)
return Image.fromarray(np.full((h, w, 3), 128, dtype=np.uint8))
def test_unset_follows_the_profile_not_the_signature_default(self, tmp_path, monkeypatch):
assert self._polish_used("qwen-zimage", None, tmp_path, monkeypatch) is False
assert self._polish_used("sdxl-zimage", None, tmp_path, monkeypatch) is True
def test_falls_back_to_lanczos_when_extra_absent(self, monkeypatch):
import numpy as np
from PIL import Image
from remove_ai_watermarks import upscaler
monkeypatch.setattr(upscaler, "is_available", lambda: False)
img = self._pil()
out = InvisibleEngine._esrgan_upscale(self._fake_engine(), img, (1024, 683))
assert out.size == (1024, 683)
# Identical to a plain Lanczos resize (the fallback path).
assert np.array_equal(np.asarray(out), np.asarray(img.resize((1024, 683), Image.Resampling.LANCZOS)))
def test_resizes_esrgan_output_to_exact_target(self, monkeypatch):
import cv2
from remove_ai_watermarks import upscaler
monkeypatch.setattr(upscaler, "is_available", lambda: True)
# Fake a 2x upscale that does NOT match the requested target; the helper must
# resize it to the exact target.
def _fake_upscale(bgr, device=None):
return cv2.resize(bgr, (bgr.shape[1] * 2, bgr.shape[0] * 2), interpolation=cv2.INTER_NEAREST)
monkeypatch.setattr(upscaler, "upscale", _fake_upscale)
out = InvisibleEngine._esrgan_upscale(self._fake_engine(), self._pil(), (1024, 683))
assert out.size == (1024, 683)
def test_falls_back_to_lanczos_when_upscale_raises(self, monkeypatch):
import numpy as np
from PIL import Image
from remove_ai_watermarks import upscaler
monkeypatch.setattr(upscaler, "is_available", lambda: True)
def _boom(bgr, device=None):
raise RuntimeError("model exploded")
monkeypatch.setattr(upscaler, "upscale", _boom)
img = self._pil()
out = InvisibleEngine._esrgan_upscale(self._fake_engine(), img, (512, 341))
assert out.size == (512, 341)
assert np.array_equal(np.asarray(out), np.asarray(img.resize((512, 341), Image.Resampling.LANCZOS)))
def test_an_explicit_value_still_wins_on_both_profiles(self, tmp_path, monkeypatch):
assert self._polish_used("qwen-zimage", True, tmp_path, monkeypatch) is True
assert self._polish_used("sdxl-zimage", False, tmp_path, monkeypatch) is False
+111 -53
View File
@@ -1,7 +1,7 @@
"""Tests for cross-platform and cross-device compatibility.
"""Tests for device detection, profile resolution, and platform-specific paths.
Verifies that device detection, MPS fallback, and platform-specific
code paths work correctly on CPU, MPS (macOS), and CUDA (Linux/Windows).
Invisible-watermark removal is CUDA-only, so the device tests here assert a binary
answer and a clean refusal rather than a fallback ladder.
"""
from __future__ import annotations
@@ -14,6 +14,7 @@ import pytest
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
from remove_ai_watermarks._internal.watermark_profiles import (
PROFILE_CHOICES,
REMOVAL_MODULES,
SDXL_ZIMAGE_GEMINI_STRENGTH,
SDXL_ZIMAGE_OPENAI_STRENGTH,
SDXL_ZIMAGE_UNKNOWN_STRENGTH,
@@ -27,34 +28,38 @@ from remove_ai_watermarks._internal.watermark_remover import get_device, is_wate
class TestDeviceDetection:
"""Tests for get_device() across platforms."""
"""get_device() is binary: CUDA, or the "cpu" that names its absence."""
def test_returns_valid_device(self):
device = get_device()
assert device in ("cpu", "mps", "cuda", "xpu")
def test_answer_is_cuda_or_cpu(self):
"""No mps/xpu answer exists. Both would travel one frame to the same refusal.
def test_cpu_fallback_when_no_gpu(self):
"""On CI / machines without GPU, should fall back to cpu or mps."""
device = get_device()
# Just verify it doesn't crash and returns a valid string
assert isinstance(device, str)
Reporting them anyway cost a device probe each and let a caller believe the
library had an Apple-silicon or Intel-GPU path that it does not.
"""
assert get_device() in ("cpu", "cuda")
@patch("remove_ai_watermarks._internal.watermark_remover._HAS_TORCH", False)
def test_no_torch_returns_cpu(self):
assert get_device() == "cpu"
def test_xpu_selected_when_available(self):
"""An XPU-enabled torch (no CUDA) routes to the Intel GPU backend.
def test_working_cuda_is_selected_and_probed(self):
"""A reported CUDA device is smoke-tested before it is returned.
The whole torch module is mocked so the smoke-test ops succeed without
any real device; cuda must read False so the cuda branch is skipped.
torch.cuda.is_available() can be True on a build whose CUDA backend then
raises on the first real op; without the probe that surfaced much later.
"""
fake_torch = MagicMock()
fake_torch.cuda.is_available.return_value = False
fake_torch.xpu.is_available.return_value = True
fake_torch.cuda.is_available.return_value = True
with patch("remove_ai_watermarks._internal.watermark_remover.torch", fake_torch):
assert get_device() == "xpu"
fake_torch.tensor.assert_called_with([1.0], device="xpu")
assert get_device() == "cuda"
fake_torch.tensor.assert_called_with([1.0], device="cuda")
def test_broken_cuda_backend_falls_back_to_cpu(self):
fake_torch = MagicMock()
fake_torch.cuda.is_available.return_value = True
fake_torch.tensor.side_effect = RuntimeError("no kernel image")
with patch("remove_ai_watermarks._internal.watermark_remover.torch", fake_torch):
assert get_device() == "cpu"
def test_non_cuda_devices_are_refused_at_construction(self):
"""CUDA is a precondition of the object, not of the run.
@@ -63,36 +68,47 @@ class TestDeviceDetection:
here only defers a guaranteed failure to model-load time - several layers down,
after the dependency check and the pipeline import, under a message naming
whichever profile the internal pipeline happens to be.
Deliberately NOT gated on the diffusion stack. It used to be, and since no CI
job installs diffusers or diffsynth (the dev extra pulls torch only, via
invisible-watermark) the guard skipped in every environment it ran in --
including the maintainer's. The refusal fires before any torch attribute is
touched, so faking the dependency probe is enough to reach it.
"""
from remove_ai_watermarks._internal import watermark_remover as module
with patch.object(module, "is_watermark_removal_available", return_value=True):
for device in ("cpu", "mps", "xpu"):
with pytest.raises(ValueError, match="CUDA-only"):
module.WatermarkRemover(device=device)
def test_the_refusal_names_the_resolved_device_not_a_bare_none(self):
"""``device=None`` on a CUDA-less host must report "cpu", not "None".
The message used to interpolate the raw argument, so the common auto-detect
path told the user that ``'None'`` cannot run the removal.
"""
from remove_ai_watermarks._internal import watermark_remover as module
with (
patch.object(module, "is_watermark_removal_available", return_value=True),
patch.object(module, "get_device", return_value="cpu"),
pytest.raises(ValueError, match="'cpu' cannot run it"),
):
module.WatermarkRemover(device=None)
def test_a_cuda_remover_picks_the_profile_dtype(self):
"""The dtype half still needs real torch, so it keeps its skip."""
if not is_watermark_removal_available():
pytest.skip("torch/diffusers not installed")
pytest.skip("the qwen-zimage extra is not installed")
import torch
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
for device in ("cpu", "mps", "xpu"):
with pytest.raises(ValueError, match="CUDA-only"):
WatermarkRemover(device=device)
remover = WatermarkRemover(device="cuda")
assert remover.device == "cuda"
assert remover.torch_dtype == torch.bfloat16
class TestEmptyDeviceCache:
"""try_empty_device_cache is all that remains of the img2img runner.
Its module lost run_img2img and the MPS fallback along with the CPU/MPS profiles;
both surviving profiles are CUDA-only, so there is no MPS failure left to recover
from. The helper must stay silent on a backend that cannot empty a cache, because
it runs in cleanup paths where a raise would replace the real error.
"""
def test_unknown_backend_is_a_silent_no_op(self):
from remove_ai_watermarks._internal.watermark_remover import try_empty_device_cache
try_empty_device_cache("cpu")
try_empty_device_cache("definitely-not-a-backend")
assert WatermarkRemover(device="cuda", pipeline="sdxl-zimage").torch_dtype == torch.float16
class TestModelProfiles:
@@ -116,6 +132,26 @@ class TestModelProfiles:
assert normalize_profile(retired) not in PROFILE_CHOICES
class TestResolveAdaptivePolish:
"""The polish default is per-profile data, not a CLI parameter-source inference."""
def test_unset_follows_the_profile(self):
from remove_ai_watermarks._internal.watermark_profiles import resolve_adaptive_polish
# qwen-zimage already matches the input's detail level, so polishing it only
# moves the output away from upstream. An SDXL global pass leaves the softer
# result the polish exists for.
assert resolve_adaptive_polish(None, "qwen-zimage") is False
assert resolve_adaptive_polish(None, "sdxl-zimage") is True
assert resolve_adaptive_polish(None, "qwen_zimage") is False
def test_an_explicit_choice_always_wins(self):
from remove_ai_watermarks._internal.watermark_profiles import resolve_adaptive_polish
assert resolve_adaptive_polish(True, "qwen-zimage") is True
assert resolve_adaptive_polish(False, "sdxl-zimage") is False
class TestNoReembeddedWatermark:
"""F2 regression: the SDXL global stage must disable the diffusers watermarker.
@@ -131,7 +167,7 @@ class TestNoReembeddedWatermark:
def test_sdxl_global_stage_disables_watermarker(self, monkeypatch: pytest.MonkeyPatch):
if not is_watermark_removal_available():
pytest.skip("torch/diffusers not installed")
pytest.skip("the qwen-zimage extra is not installed")
import diffusers
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
@@ -291,25 +327,47 @@ class TestFormatUtils:
class TestAvailability:
"""Tests for dependency availability checks."""
"""The CLI gate and the remover precondition must answer from the same module list.
def test_watermark_removal_available(self):
# Reflects the actual environment: True iff torch + diffusers (the gpu
# extra) are importable. The default+dev CI env has no diffusers, so this
# must not assume the full stack is present.
import importlib.util
Both used to hardcode (torch, diffusers) while the code moved to REMOVAL_MODULES,
which includes diffsynth. In a torch+diffusers-only environment the assertions were
then simply wrong -- and, worse, comparing each gate against a tuple copied from
itself can never catch the two disagreeing, which is the drift that let the CLI pass
an environment the run then died in.
"""
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert is_watermark_removal_available() is expected
def test_invisible_is_available(self):
def test_both_gates_agree_and_read_the_shared_module_list(self):
import importlib.util
from remove_ai_watermarks.invisible_engine import is_available
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
assert "diffsynth" in REMOVAL_MODULES
expected = all(importlib.util.find_spec(m) is not None for m in REMOVAL_MODULES)
assert is_watermark_removal_available() is expected
assert is_available() is expected
def test_a_missing_module_closes_both_gates(self, monkeypatch: pytest.MonkeyPatch):
"""Discriminating, not vacuous: it must FAIL if either gate stops requiring one.
Comparing the live answer to a tuple derived from the same constant passes on
any host -- with the full stack (True == True) and with none of it
(False == False). Simulate each module's absence instead.
"""
import remove_ai_watermarks.invisible_engine as engine_module
from remove_ai_watermarks._internal import watermark_remover as remover_module
for missing in REMOVAL_MODULES:
present = {name: name != missing for name in REMOVAL_MODULES}
monkeypatch.setattr(
"remove_ai_watermarks.optional_deps.module_available",
lambda *names, _p=present: all(_p.get(n, True) for n in names),
)
# The remover probes at import time, so drive its cached flags directly.
monkeypatch.setattr(remover_module, "_HAS_TORCH", missing != "torch")
monkeypatch.setattr(remover_module, "_HAS_REMOVAL_MODULES", missing == "torch")
assert engine_module.is_available() is False, f"engine gate ignores a missing {missing}"
assert remover_module.is_watermark_removal_available() is False, f"remover gate ignores a missing {missing}"
# ── Platform-specific path handling ─────────────────────────────────
+33 -32
View File
@@ -175,6 +175,7 @@ def test_cpu_offload_forces_both_stacks_to_stream(monkeypatch, cpu_offload, expe
monkeypatch.setattr(pipeline_module, "QwenZImagePipeline", Recorder)
remover = module.WatermarkRemover.__new__(module.WatermarkRemover)
remover.model_profile = "qwen-zimage"
remover.device = "cuda"
remover.torch_dtype = None
remover.hf_token = None
@@ -526,16 +527,13 @@ def test_face_composite_preserves_every_pixel_outside_mask():
assert np.all(result[12:20, 12:20] == 240)
def test_profile_defaults_to_four_global_steps():
from remove_ai_watermarks._internal.watermark_profiles import (
normalize_profile,
resolve_seed,
resolve_steps,
)
def test_profile_defaults_to_four_global_steps_and_a_fixed_seed():
"""The step count belongs to the stage, not to a caller-settable profile knob."""
from remove_ai_watermarks._internal.qwen_zimage_pipeline import GLOBAL_STEPS
from remove_ai_watermarks._internal.watermark_profiles import normalize_profile, resolve_seed
assert normalize_profile("qwen-zimage") == "qwen-zimage"
assert resolve_steps(None) == 4
assert resolve_steps(12) == 12
assert GLOBAL_STEPS == 4
assert resolve_seed(None) == 0
assert resolve_seed(17) == 17
@@ -560,8 +558,11 @@ def test_cli_qwen_zimage_keeps_profile_postprocess_default(tmp_image_path, monke
)
assert result.exit_code == 0, result.output
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
assert mock_engine.remove_watermark.call_args.kwargs["seed"] == 0
# Both defaults are the profile's, resolved once by the library rather than
# pre-resolved here: the CLI passes them through unset so a library caller on the
# same profile gets the same answer.
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is None
assert mock_engine.remove_watermark.call_args.kwargs["seed"] is None
result = CliRunner().invoke(
cli.main,
@@ -590,7 +591,6 @@ def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
assert remover.model_id == "Qwen/Qwen-Image-2512 + Tongyi-MAI/Z-Image-Turbo"
remover.remove_watermark(
source,
@@ -743,23 +743,30 @@ def test_watermark_remover_forwards_global_only_preload(monkeypatch):
runtime.preload.assert_called_once_with(global_only=True)
def test_qwen_zimage_rejects_runtime_knobs_that_change_fixed_graph(tmp_path, monkeypatch):
def test_the_fixed_graph_offers_no_runtime_knob_to_reject(tmp_path, monkeypatch):
"""model_id, steps and CFG are not parameters at any layer.
They used to be accepted and then rejected, which put the failure several frames
below the caller and made the surface advertise choices the pinned stack cannot
honor. TypeError from the signature is the earlier, clearer answer -- and it is
what keeps a wrapper from threading a value that would silently do nothing.
"""
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
_mock_watermark_runtime_deps(monkeypatch)
with pytest.raises(ValueError, match="fixed Qwen-Image-2512"):
WatermarkRemover(model_id="custom/model", device="cuda", pipeline="qwen-zimage")
with pytest.raises(TypeError):
WatermarkRemover(model_id="custom/model", device="cuda", pipeline="qwen-zimage") # type: ignore[call-arg]
source = tmp_path / "source.png"
Image.new("RGB", (64, 48)).save(source)
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
with pytest.raises(ValueError, match=r"CFG 1\.0"):
remover.remove_watermark(source, guidance_scale=2.0)
with pytest.raises(ValueError, match="requires 4 steps"):
remover.remove_watermark(source, num_inference_steps=8)
with pytest.raises(TypeError):
remover.remove_watermark(source, guidance_scale=2.0) # type: ignore[call-arg]
with pytest.raises(TypeError):
remover.remove_watermark(source, num_inference_steps=8) # type: ignore[call-arg]
def test_invisible_engine_uses_qwen_zimage_step_default(tmp_image_path, tmp_path):
def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path, tmp_path):
from remove_ai_watermarks.invisible_engine import InvisibleEngine
engine = InvisibleEngine.__new__(InvisibleEngine)
@@ -767,13 +774,12 @@ def test_invisible_engine_uses_qwen_zimage_step_default(tmp_image_path, tmp_path
engine._remover = MagicMock(model_profile="qwen-zimage")
engine._remover.remove_watermark.return_value = tmp_path / "clean.png"
engine.remove_watermark(
tmp_image_path,
tmp_path / "clean.png",
min_resolution=0,
)
engine.remove_watermark(tmp_image_path, tmp_path / "clean.png")
assert engine._remover.remove_watermark.call_args.kwargs["num_inference_steps"] == 4
kwargs = engine._remover.remove_watermark.call_args.kwargs
assert kwargs["seed"] == 0
assert "num_inference_steps" not in kwargs
assert "guidance_scale" not in kwargs
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
@@ -795,15 +801,10 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
def test_sdxl_zimage_shares_the_four_step_seed_and_step_contract():
from remove_ai_watermarks._internal.watermark_profiles import (
normalize_profile,
resolve_seed,
resolve_steps,
)
def test_sdxl_zimage_shares_the_fixed_seed_contract():
from remove_ai_watermarks._internal.watermark_profiles import normalize_profile, resolve_seed
assert normalize_profile("sdxl_zimage") == "sdxl-zimage"
assert resolve_steps(None) == 4
assert resolve_seed(None) == 0
-70
View File
@@ -15,7 +15,6 @@ from PIL import Image
from remove_ai_watermarks._internal.tiling import (
Tile,
_axis_positions,
feather_region_composite,
feather_weights,
plan_tiles,
run_tiled,
@@ -139,72 +138,3 @@ class TestRunTiled:
image = Image.new("RGB", (1500, 1100), (200, 100, 50))
out = run_tiled(generate, image, tile_size=1024, overlap=128)
assert out.size == (1500, 1100)
class TestFeatherRegionComposite:
"""Region-targeted compositing for AI-enhanced composites: only the AI box is
regenerated, the real photo outside it stays pixel-exact (roadmap P1#8)."""
@staticmethod
def _frames(h=200, w=300):
base = np.full((h, w, 3), 80, np.uint8)
regenerated = np.full((h, w, 3), 200, np.uint8)
return base, regenerated
def test_outside_box_is_pixel_exact(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=8)
# Far corners are well outside the box -> identical to base.
assert np.array_equal(out[:50, :80], base[:50, :80])
assert np.array_equal(out[150:, 220:], base[150:, 220:])
def test_interior_equals_regenerated(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=8)
# Deep interior of the box (past the feather ramp) is fully regenerated.
assert np.array_equal(out[80:90, 130:150], regen[80:90, 130:150])
def test_hard_paste_when_no_feather(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=0)
assert np.array_equal(out[60:110, 100:180], regen[60:110, 100:180])
assert np.array_equal(out[:60], base[:60])
def test_seam_is_monotonic_ramp(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (100, 60, 80, 50), feather=10).astype(np.float32)
# Along a horizontal line crossing the left edge, values rise from base(80)
# toward regenerated(200) monotonically through the feather band.
row = out[85, 100:115, 0]
assert row[0] < row[-1]
assert np.all(np.diff(row) >= -1e-3)
def test_dtype_preserved(self):
base, regen = self._frames()
out = feather_region_composite(base, regen, (50, 50, 40, 40), feather=4)
assert out.dtype == base.dtype
def test_grayscale_2d_supported(self):
base = np.full((100, 120), 30, np.uint8)
regen = np.full((100, 120), 220, np.uint8)
out = feather_region_composite(base, regen, (40, 30, 30, 30), feather=4)
assert out.shape == base.shape
assert np.array_equal(out[:30], base[:30])
def test_empty_or_offimage_box_returns_base(self):
base, regen = self._frames()
assert np.array_equal(feather_region_composite(base, regen, (0, 0, 0, 0)), base)
assert np.array_equal(feather_region_composite(base, regen, (500, 500, 40, 40)), base)
def test_box_clamped_to_image_bounds(self):
base, regen = self._frames()
# Box overhangs the bottom-right; only the in-image part is composited.
out = feather_region_composite(base, regen, (280, 180, 60, 60), feather=0)
assert np.array_equal(out[180:, 280:], regen[180:, 280:])
assert out.shape == base.shape
def test_shape_mismatch_raises(self):
base, _ = self._frames(200, 300)
bad = np.full((100, 100, 3), 200, np.uint8)
with pytest.raises(ValueError, match="shape mismatch"):
feather_region_composite(base, bad, (10, 10, 20, 20))
-32
View File
@@ -1,32 +0,0 @@
"""Tests for the optional Real-ESRGAN upscaler (no model download).
The model-running path is exercised manually (it downloads ~67 MB of BSD-3-Clause
weights on first use); these tests cover the availability guard and the no-model
control flow, mirroring the repo convention for ML-adjacent modules.
"""
from __future__ import annotations
import numpy as np
import pytest
from remove_ai_watermarks import upscaler
class TestIsAvailable:
def test_returns_bool(self):
assert isinstance(upscaler.is_available(), bool)
class TestUpscaleGuard:
def test_raises_without_extra(self, monkeypatch):
monkeypatch.setattr(upscaler, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="esrgan"):
upscaler.upscale(np.full((32, 32, 3), 128, dtype=np.uint8))
class TestModelCachePath:
def test_cache_path_uses_model_filename(self):
if not upscaler.is_available():
pytest.skip("esrgan extra (torch) not installed")
assert upscaler._model_cache_path().name == upscaler._MODEL_FILENAME
Generated
+3 -28
View File
@@ -3331,7 +3331,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.24.0"
version = "0.25.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },
@@ -3357,7 +3357,6 @@ all = [
{ name = "pywavelets", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "pywavelets", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "safetensors" },
{ name = "spandrel" },
{ name = "tokenizers" },
{ name = "torch" },
{ name = "torchvision" },
@@ -3397,11 +3396,6 @@ diffusion = [
{ name = "torch" },
{ name = "transformers" },
]
esrgan = [
{ name = "numpy" },
{ name = "opencv-python-headless" },
{ name = "spandrel" },
]
heif = [
{ name = "pillow-heif" },
]
@@ -3481,16 +3475,14 @@ requires-dist = [
{ name = "remove-ai-watermarks", extras = ["diffusion"], marker = "extra == 'qwen-zimage'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'diffusion'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'esrgan'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" },
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "diffusion", "qwen-zimage", "lama", "migan", "esrgan"], marker = "extra == 'all'" },
{ name = "remove-ai-watermarks", extras = ["video", "heif", "detect", "trustmark", "qwen-zimage", "lama", "migan"], marker = "extra == 'all'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'migan'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "safetensors", marker = "extra == 'diffusion'" },
{ name = "spandrel", marker = "extra == 'esrgan'", specifier = ">=0.3.0" },
{ name = "tokenizers", marker = "extra == 'diffusion'", specifier = ">=0.22,<0.23" },
{ name = "torch", marker = "extra == 'diffusion'", specifier = ">=2.0.0" },
{ name = "torchvision", marker = "extra == 'qwen-zimage'", specifier = ">=0.20.0" },
@@ -3499,7 +3491,7 @@ requires-dist = [
{ name = "uv-outdated", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.1.0" },
{ name = "uv-secure", marker = "python_full_version >= '3.12' and extra == 'dev'", specifier = ">=0.12.0" },
]
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "lama", "migan", "esrgan", "dev", "all"]
provides-extras = ["pixels", "heif", "visible", "video", "detect", "diffusion", "qwen-zimage", "trustmark", "lama", "migan", "dev", "all"]
[[package]]
name = "requests"
@@ -3662,23 +3654,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "spandrel"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "einops" },
{ name = "numpy" },
{ name = "safetensors" },
{ name = "torch" },
{ name = "torchvision" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/8f/ab4565c23dd67a036ab72101a830cebd7ca026b2fddf5771bbf6284f6228/spandrel-0.4.2.tar.gz", hash = "sha256:fefa4ea966c6a5b7721dcf24f3e2062a5a96a395c8bedcb570fb55971fdcbccb", size = 247544, upload-time = "2026-02-21T01:52:26.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/31/411ea965835534c43d4b98d451968354876e0e867ea1fd42669e4cca0732/spandrel-0.4.2-py3-none-any.whl", hash = "sha256:6c93e3ecbeb0e548fd2df45a605472b34c1614287c56b51bb33cdef7ae5235b5", size = 320811, upload-time = "2026-02-21T01:52:25.015Z" },
]
[[package]]
name = "stamina"
version = "26.1.0"