mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-19 07:37:14 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38399e4eb0 | ||
|
|
2f72257996 | ||
|
|
52b2c115e8 | ||
|
|
bf4bfc1ab7 | ||
|
|
95a6964e04 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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__",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ─────────────────────────────────
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user