Remove the unreachable ESRGAN upscale chain

The min-resolution floor lifted small inputs toward SDXL's ~1024 training size,
and Real-ESRGAN was an optional way to do that lifting. Both surviving profiles
run at native geometry, so the engine forced the floor to 0 on every path; the
floor never fired, `upscaling` was never true, and nothing downstream of it could
execute. Gone: upscaler.py, _esrgan_upscale, the min_resolution and upscaler
parameters, _target_size's floor branch, --min-resolution, --upscaler,
_warn_if_esrgan_unavailable and the `esrgan` extra. max_resolution stays and is
now the only lever on geometry; it can only scale down.

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-08-03 14:54:26 -07:00
co-authored by Claude Opus 5
parent 95a6964e04
commit bf4bfc1ab7
15 changed files with 99 additions and 521 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ removal.
| Every production feature | `remove-ai-watermarks[all]` | | Every production feature | `remove-ai-watermarks[all]` |
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`, Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
`migan`, `lama`, `esrgan`, and `qwen-zimage`. The `migan`, `lama`, and `qwen-zimage`. The
[installation guide](docs/installation.md#feature-extras) documents their exact [installation guide](docs/installation.md#feature-extras) documents their exact
dependency composition and model requirements. dependency composition and model requirements.
-2
View File
@@ -98,7 +98,6 @@ application actually uses:
| `diffusion` | Diffusion-based invisible watermark removal | `pixels`, Torch, Diffusers | Yes | | `diffusion` | Diffusion-based invisible watermark removal | `pixels`, Torch, Diffusers | Yes |
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `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 | | `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` | CUDA-only Qwen Image plus Z-Image pipeline | `diffusion`, DiffSynth | Yes |
| `all` | Every production feature | All rows above | 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 | | `dev` | Tests, linting, typing, and upstream parity checks | `visible`, `detect`, upstream invisible-watermark | Yes, for parity tests |
@@ -113,7 +112,6 @@ flowchart LR
diffusion --> pixels diffusion --> pixels
migan --> visible migan --> visible
lama --> visible lama --> visible
esrgan --> pixels
qwen["qwen-zimage"] --> diffusion qwen["qwen-zimage"] --> diffusion
heif heif
trustmark trustmark
+6 -5
View File
@@ -150,13 +150,14 @@ deprecated, emits a warning, and changes nothing.
## Resolution and memory ## 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 There is no minimum-resolution floor. It existed to enlarge small inputs toward
`1024`. Smaller inputs are enlarged before diffusion and restored to their SDXL's ~1024 training resolution and was removed with the SDXL profiles, which
original dimensions afterward. Set `--min-resolution 0` to disable the floor. 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 ### Large images stay at native resolution unless capped
+9 -7
View File
@@ -492,7 +492,7 @@ stage.
router. router.
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) handles [`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 interface. It delegates model execution to
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py). [`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).
@@ -725,18 +725,20 @@ Regression coverage:
- [`test_tiling.py`](../tests/test_tiling.py) - [`test_tiling.py`](../tests/test_tiling.py)
### Upscaling and postprocessing ### 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.
[`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit [`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit
grain, unsharp masking, and adaptive polish helpers. 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: Regression coverage:
- [`test_upscaler.py`](../tests/test_upscaler.py)
- [`test_humanizer.py`](../tests/test_humanizer.py) - [`test_humanizer.py`](../tests/test_humanizer.py)
## Image input and output ## Image input and output
+1 -1
View File
@@ -422,7 +422,7 @@ engine = InvisibleEngine(pipeline="sdxl-zimage")
The `qwen-zimage` extra must be installed for that profile. The `qwen-zimage` extra must be installed for that profile.
The full `remove_watermark` signature includes strength, steps, guidance, The full `remove_watermark` signature includes strength, steps, guidance,
seeding, tiling, resolution, upscaling, and postprocessing controls. Read the seeding, tiling, resolution, and postprocessing controls. Read the
method signature in method signature in
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use [`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use
the CLI guide for the concepts. the CLI guide for the concepts.
+5 -3
View File
@@ -72,8 +72,10 @@ Run detection, removal, and re-detection over a representative local set. Confir
### A5. Contract sweep across every parameter choice ### A5. Contract sweep across every parameter choice
`scripts/smoke_matrix.py` (exists, 68 rows, 0 skipped with `--diffusion`) covers every `scripts/smoke_matrix.py` covers every choice-valued flag on fixtures. Its knob rows
choice-valued flag on fixtures. Extend from fixtures to a stratified corpus slice 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 (~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
rather than just absence of crash. rather than just absence of crash.
@@ -85,7 +87,7 @@ sweep asserts on stderr, or the codes get split -- the latter is the better fix.
Compare the flags and values exercised by the matrix against the options declared by 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 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 and the ffmpeg audio/video strip. The gap to find is not only
"logic untested" but "logic untested" but
"never executed on real data", which is precisely what this campaign is for. "never executed on real data", which is precisely what this campaign is for.
+1 -14
View File
@@ -150,19 +150,6 @@ migan = [
"onnxruntime>=1.16.0; python_version >= '3.11'", "onnxruntime>=1.16.0; python_version >= '3.11'",
"huggingface-hub>=0.20.0", "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 = [ dev = [
"remove-ai-watermarks[video]", "remove-ai-watermarks[video]",
"remove-ai-watermarks[detect]", "remove-ai-watermarks[detect]",
@@ -178,7 +165,7 @@ dev = [
"uv-outdated>=0.1.0; python_version >= '3.12'", "uv-outdated>=0.1.0; python_version >= '3.12'",
"uv-secure>=0.12.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]"] all = ["remove-ai-watermarks[video,heif,detect,trustmark,diffusion,qwen-zimage,lama,migan]"]
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for # 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 # the default CPU/CUDA install: uv consults it only when a torch install
+51 -60
View File
@@ -354,18 +354,16 @@ def main() -> None:
def _knob_rows(r: Runner, tmp: Path, img: Path) -> None: def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
"""Every diffusion knob the matrix never touched. """Every diffusion knob the matrix never touched.
Deliberately cheap (`--steps 4`, `--max-resolution 384`): these rows answer "is the Deliberately cheap (`--max-resolution 384`): these rows answer "is the knob accepted
knob accepted and does the run complete", NOT "is the output good". Quality per knob and does the run complete", NOT "is the output good". Quality per knob needs a
needs a per-knob oracle and most of them have none (`--humanize` has no oracle at per-knob oracle and most of them have none (`--humanize` has no oracle at all), so
all), so claiming more here would be dishonest. claiming more here would be dishonest.
""" """
from remove_ai_watermarks import upscaler # Both surviving profiles are CUDA-only and pin a distilled four-step schedule at
# CFG 1.0, so most rows here now assert a knob is REJECTED rather than accepted.
# --steps 20 is the floor that WORKS, not an arbitrary choice: effective timesteps # That is the coverage worth having: a knob the CLI takes and the library refuses
# are int(steps * strength), so at the default strength 0.15 anything below # several layers down is exactly what this matrix exists to catch.
# --steps 7 rounds to ZERO and the pipeline dies inside torch. The first version of fast = ["--max-resolution", "384", "--force", "--seed", "0"]
# 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"]
def run(name: str, extra: list[str], *, tag: str, expect: int | None = 0) -> None: def run(name: str, extra: list[str], *, tag: str, expect: int | None = 0) -> None:
r.run( r.run(
@@ -375,61 +373,52 @@ def _knob_rows(r: Runner, tmp: Path, img: Path) -> None:
timeout=2400, timeout=2400,
) )
run("--pipeline sdxl", ["--pipeline", "sdxl"], tag="sdxl") # Click rejects a retired profile at parse time (exit 2) rather than remapping it.
run("--pipeline controlnet", ["--pipeline", "controlnet"], tag="cnet") for retired in ("sdxl", "controlnet", "qwen", "default"):
run("--strength", ["--strength", "0.2"], tag="strength") run(f"--pipeline {retired} is rejected", ["--pipeline", retired], tag=f"retired_{retired}", expect=2)
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")
if upscaler.is_available(): # Fixed-graph knobs: accepted by Click, refused by the library (exit 1).
run("--upscaler esrgan", ["--upscaler", "esrgan"], tag="esrgan") run("--steps 20 is rejected", ["--steps", "20"], tag="steps20", expect=1)
else: run("--guidance-scale 5.0 is rejected", ["--guidance-scale", "5.0"], tag="gs5", expect=1)
r.skip("--upscaler esrgan", "the `esrgan` extra is not installed") run("--model override is rejected", ["--model", "org/custom"], tag="model", expect=1)
# 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: try:
import torch import torch
cuda = bool(torch.cuda.is_available()) cuda = bool(torch.cuda.is_available())
except Exception: except Exception:
cuda = False cuda = False
if cuda:
run("--pipeline qwen", ["--pipeline", "qwen"], tag="qwen") if not cuda:
else: # Without CUDA the honest outcome is a CLEAN refusal naming the reason, not a
r.skip("--pipeline qwen", "CUDA-class pipeline; no CUDA device on this host") # traceback from inside a half-built pipeline.
run("no CUDA fails cleanly", [], tag="nocuda", expect=1)
for name, extra, tag in (
("--device cpu fails cleanly", ["--device", "cpu"], "cpu"),
("--device mps fails cleanly", ["--device", "mps"], "mps"),
):
run(name, extra, tag=tag, expect=1)
for label in (
"--strength",
"--controlnet-scale",
"--humanize",
"--unsharp",
"--no-adaptive-polish",
"--tile",
"--auto",
"--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")
run("--auto (deprecated, requests polish only)", ["--auto"], tag="auto")
# --model and --hf-token are deliberately not exercised: one would download a second # --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. # multi-GB checkpoint, the other needs a real credential. Skipped loudly, not passed.
@@ -596,9 +585,11 @@ def _diffusion_rows(r: Runner, tmp: Path, doubao: Path) -> None:
src = imread(str(mj)) src = imread(str(mj))
h, w = src.shape[:2] h, w = src.shape[:2]
box = (w // 4, h // 4, w // 4, h // 4) box = (w // 4, h // 4, w // 4, h // 4)
rem = WatermarkRemover(pipeline="controlnet") # Default profile, default four-step schedule: the remover rejects any other
# step count now, and the retired controlnet profile no longer exists.
rem = WatermarkRemover()
rout = tmp / "region_composite.png" rout = tmp / "region_composite.png"
rem.remove_watermark(mj, rout, strength=0.15, num_inference_steps=20, seed=0, region=box) rem.remove_watermark(mj, rout, strength=0.15, seed=0, region=box)
got = imread(str(rout)) got = imread(str(rout))
if got is None or got.shape != src.shape: if got is None or got.shape != src.shape:
r.check("region composite keeps the frame outside the box", False, "shape changed or unreadable") r.check("region composite keeps the frame outside the box", False, "shape changed or unreadable")
-59
View File
@@ -180,28 +180,10 @@ _controlnet_scale_option = click.option(
"(structure/text preservation strength). Higher = closer to original structure.", "(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_option = click.option(
"--unsharp", type=float, default=0.0, help="Unsharp-mask sharpening strength (0 = off, typical: 0.3-0.8)." "--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_option = click.option(
"--auto", "--auto",
is_flag=True, is_flag=True,
@@ -376,21 +358,6 @@ def _resolve_profile_polish(auto: bool, adaptive_polish: bool, pipeline: str) ->
return adaptive_polish 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]: def _visible_provenance(path: Path | None) -> frozenset[str]:
"""Vendor keys local metadata confirms, the EVIDENCE that drives ``auto`` """Vendor keys local metadata confirms, the EVIDENCE that drives ``auto``
sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one sensitivity. Thin wrapper over the public :func:`api.visible_provenance` (one
@@ -896,9 +863,7 @@ def cmd_erase(
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.", help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
) )
@_controlnet_scale_option @_controlnet_scale_option
@_min_resolution_option
@_unsharp_option @_unsharp_option
@_upscaler_option
@_model_option @_model_option
@_guidance_scale_option @_guidance_scale_option
@_auto_option @_auto_option
@@ -920,9 +885,7 @@ def cmd_invisible(
humanize: float, humanize: float,
unsharp: float, unsharp: float,
max_resolution: int, max_resolution: int,
min_resolution: int,
controlnet_scale: float, controlnet_scale: float,
upscaler: str,
model: str | None, model: str | None,
guidance_scale: float | None, guidance_scale: float | None,
auto: bool, auto: bool,
@@ -952,7 +915,6 @@ def cmd_invisible(
source = _validate_image(source) source = _validate_image(source)
steps = resolve_steps(steps) steps = resolve_steps(steps)
seed = resolve_seed(seed) seed = resolve_seed(seed)
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline) adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
if output is None: if output is None:
output = source.with_stem(source.stem + "_clean") output = source.with_stem(source.stem + "_clean")
@@ -998,8 +960,6 @@ def cmd_invisible(
unsharp=unsharp, unsharp=unsharp,
adaptive_polish=adaptive_polish, adaptive_polish=adaptive_polish,
max_resolution=max_resolution, max_resolution=max_resolution,
min_resolution=min_resolution,
upscaler=upscaler,
vendor=vendor, vendor=vendor,
tile=tile, tile=tile,
tile_size=tile_size, tile_size=tile_size,
@@ -1587,9 +1547,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.", help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
) )
@_controlnet_scale_option @_controlnet_scale_option
@_min_resolution_option
@_unsharp_option @_unsharp_option
@_upscaler_option
@_guidance_scale_option @_guidance_scale_option
@_auto_option @_auto_option
@_adaptive_polish_option @_adaptive_polish_option
@@ -1613,9 +1571,7 @@ def cmd_all(
humanize: float, humanize: float,
unsharp: float, unsharp: float,
max_resolution: int, max_resolution: int,
min_resolution: int,
controlnet_scale: float, controlnet_scale: float,
upscaler: str,
guidance_scale: float | None, guidance_scale: float | None,
auto: bool, auto: bool,
adaptive_polish: bool, adaptive_polish: bool,
@@ -1638,7 +1594,6 @@ def cmd_all(
source = _validate_image(source) source = _validate_image(source)
steps = resolve_steps(steps) steps = resolve_steps(steps)
seed = resolve_seed(seed) seed = resolve_seed(seed)
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline) adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
if output is None: if output is None:
@@ -1744,8 +1699,6 @@ def cmd_all(
unsharp=unsharp, unsharp=unsharp,
adaptive_polish=adaptive_polish, adaptive_polish=adaptive_polish,
max_resolution=max_resolution, max_resolution=max_resolution,
min_resolution=min_resolution,
upscaler=upscaler,
vendor=vendor, vendor=vendor,
tile=tile, tile=tile,
tile_size=tile_size, tile_size=tile_size,
@@ -1838,9 +1791,7 @@ class _BatchOptions:
sensitivity: str = "auto" sensitivity: str = "auto"
unsharp: float = 0.0 unsharp: float = 0.0
max_resolution: int = 0 max_resolution: int = 0
min_resolution: int = 1024
controlnet_scale: float = 1.0 controlnet_scale: float = 1.0
upscaler: str = "lanczos"
model: str | None = None model: str | None = None
guidance_scale: float | None = None guidance_scale: float | None = None
adaptive_polish: bool = False adaptive_polish: bool = False
@@ -1895,8 +1846,6 @@ def _run_batch_invisible(
unsharp=options.unsharp, unsharp=options.unsharp,
adaptive_polish=options.adaptive_polish, adaptive_polish=options.adaptive_polish,
max_resolution=options.max_resolution, max_resolution=options.max_resolution,
min_resolution=options.min_resolution,
upscaler=options.upscaler,
tile=options.tile, tile=options.tile,
tile_size=options.tile_size, tile_size=options.tile_size,
tile_overlap=options.tile_overlap, tile_overlap=options.tile_overlap,
@@ -2030,9 +1979,7 @@ def _process_batch_image(
default=0, default=0,
help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.", help="Cap long side (px) before diffusion; 0 = native and preserves the most detail. Raise only on GPU/MPS OOM.",
) )
@_min_resolution_option
@_unsharp_option @_unsharp_option
@_upscaler_option
@_controlnet_scale_option @_controlnet_scale_option
@_model_option @_model_option
@_guidance_scale_option @_guidance_scale_option
@@ -2058,9 +2005,7 @@ def cmd_batch(
humanize: float, humanize: float,
unsharp: float, unsharp: float,
max_resolution: int, max_resolution: int,
min_resolution: int,
controlnet_scale: float, controlnet_scale: float,
upscaler: str,
model: str | None, model: str | None,
guidance_scale: float | None, guidance_scale: float | None,
auto: bool, auto: bool,
@@ -2087,8 +2032,6 @@ def cmd_batch(
console.print(f" Found {len(images)} images in {directory}") console.print(f" Found {len(images)} images in {directory}")
console.print(f" Output -> {output_dir}") console.print(f" Output -> {output_dir}")
console.print(f" Mode: {mode}") console.print(f" Mode: {mode}")
if mode in ("invisible", "all"):
_warn_if_esrgan_unavailable(upscaler)
adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline) adaptive_polish = _resolve_profile_polish(auto, adaptive_polish, pipeline)
steps = resolve_steps(steps) steps = resolve_steps(steps)
seed = resolve_seed(seed) seed = resolve_seed(seed)
@@ -2104,9 +2047,7 @@ def cmd_batch(
sensitivity=sensitivity, sensitivity=sensitivity,
unsharp=unsharp, unsharp=unsharp,
max_resolution=max_resolution, max_resolution=max_resolution,
min_resolution=min_resolution,
controlnet_scale=controlnet_scale, controlnet_scale=controlnet_scale,
upscaler=upscaler,
model=model, model=model,
guidance_scale=guidance_scale, guidance_scale=guidance_scale,
adaptive_polish=adaptive_polish, adaptive_polish=adaptive_polish,
+17 -82
View File
@@ -14,7 +14,7 @@ import logging
import os import os
import warnings import warnings
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING
from ._internal.watermark_profiles import ( from ._internal.watermark_profiles import (
DEFAULT_MODEL_ID as DEFAULT_SDXL_MODEL_ID, DEFAULT_MODEL_ID as DEFAULT_SDXL_MODEL_ID,
@@ -48,21 +48,19 @@ def is_available() -> bool:
return module_available("diffusers", "torch") return module_available("diffusers", "torch")
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. """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 There was also a ``min_resolution`` floor that scaled small inputs UP toward
(integer-truncated, matching the PIL ``resize`` call site). 0/negative = no SDXL's ~1024 training size. It went with the SDXL profiles: both surviving
cap. Set only to bound GPU/MPS memory on very large inputs (issue #10). profiles run at native geometry, so the floor was forced to 0 on every path and
- ``min_resolution`` (floor): else if the long side is below it, scale UP to it could not fire.
(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.
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. resolution decision is unit-testable without loading the diffusion model.
""" """
long_side = max(width, height) long_side = max(width, height)
@@ -71,9 +69,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 # 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(). # at 1024) would otherwise truncate it to 0 and crash image.resize().
return (max(1, int(width * ratio)), max(1, int(height * ratio))) 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 return None
@@ -144,32 +139,6 @@ class InvisibleEngine:
""" """
self._remover.preload(global_only=global_only) 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( def remove_watermark(
self, self,
image_path: Path, image_path: Path,
@@ -180,11 +149,9 @@ class InvisibleEngine:
seed: int | None = None, seed: int | None = None,
humanize: float = 0.0, humanize: float = 0.0,
max_resolution: int = 0, max_resolution: int = 0,
min_resolution: int = 1024,
vendor: str | None = None, vendor: str | None = None,
unsharp: float = 0.0, unsharp: float = 0.0,
adaptive_polish: bool = False, adaptive_polish: bool = False,
upscaler: str = "lanczos",
tile: bool = False, tile: bool = False,
tile_size: int = 1024, tile_size: int = 1024,
tile_overlap: int = 128, tile_overlap: int = 128,
@@ -215,17 +182,6 @@ class InvisibleEngine:
= no cap. Set a positive value only to bound GPU/MPS memory on = no cap. Set a positive value only to bound GPU/MPS memory on
very large inputs (it reintroduces a lossy downscale->upscale very large inputs (it reintroduces a lossy downscale->upscale
round-trip). 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.
tile: Process the diffusion pass in overlapping tiles instead of one tile: Process the diffusion pass in overlapping tiles instead of one
forward pass. This retains the input's native dimensions instead forward pass. This retains the input's native dimensions instead
of applying ``max_resolution``, but each tile is still regenerated. of applying ``max_resolution``, but each tile is still regenerated.
@@ -244,10 +200,7 @@ class InvisibleEngine:
from PIL import Image, ImageOps from PIL import Image, ImageOps
# Resolution policy: a max_resolution cap (0 = none) bounds memory on huge # Resolution policy: a max_resolution cap (0 = none) bounds memory on huge
# inputs, and a min_resolution floor (1024 = default) upscales tiny inputs so # inputs. See _target_size for why it is the only lever left.
# 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.
# Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT) # 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 # 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. # what would otherwise register it, so a bare Image.open would fail on HEIC.
@@ -261,33 +214,15 @@ class InvisibleEngine:
# reassigned to the resized copy below; PIL resize returns a new object). # reassigned to the resized copy below; PIL resize returns a new object).
reference_pil = image reference_pil = image
# qwen-zimage operates at the input's native geometry in its reference graph. # Both profiles run at the input's native geometry, so only the explicit max
# Keep an explicit max cap available for callers, but do not apply the SDXL # cap can move it, and it can only ever scale down.
# 1024px minimum-resolution floor to this profile. target = _target_size(image.width, image.height, max_resolution)
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,
)
if target is not None: if target is not None:
upscaling = max(target) > max(image.width, image.height)
if self._progress_callback: if self._progress_callback:
reason = ( self._progress_callback(
f"min-resolution floor {min_resolution}px" f"Downscaling {image.width}x{image.height} to {target[0]}x{target[1]} "
if upscaling f"(max-resolution cap {max_resolution}px)..."
else 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 # Always persist to a temp file, even without downscaling: WatermarkRemover
-126
View File
@@ -1,126 +0,0 @@
"""Optional pre-diffusion super-resolution for small inputs (Real-ESRGAN via spandrel).
Mirrors ``region_eraser``'s optional-backend pattern: ``is_available()`` guards the
``spandrel`` import, a lazy singleton (double-checked lock) holds the loaded model, and
the weights download on first use (cached by ``torch.hub``) -- they are never bundled.
The DEFAULT upscaler stays Lanczos (cv2, no model download); this is opt-in via the ``esrgan``
extra and feeds the ``--upscaler esrgan`` path. ``spandrel`` is a pure model-loader
(MIT) with NO basicsr dependency -- it pulls only torch/torchvision/safetensors/numpy/
einops -- so it sidesteps the basicsr / ``torchvision.transforms.functional_tensor``
breakage that the retired ``restore`` (GFPGAN) extra had to shim. Real-ESRGAN weights
are BSD-3-Clause.
CPU works but is slow on large inputs, so this is meant for the pre-diffusion upscale of
SMALL inputs (and the GPU worker). On a memory-constrained host it is a no-op (the extra
is absent), and the caller falls back to Lanczos.
"""
# torch/spandrel boundary: these libs ship no usable element types; relax the
# unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false
from __future__ import annotations
import logging
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from numpy.typing import NDArray
logger = logging.getLogger(__name__)
# Real-ESRGAN x2plus (BSD-3-Clause), official release. x2 is the right native factor for
# the pre-diffusion floor upscale (small inputs ~512 -> ~1024); spandrel infers the
# architecture and scale from the checkpoint, so swapping the URL is enough to change it.
_MODEL_URL = "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth"
_MODEL_FILENAME = "RealESRGAN_x2plus.pth"
_model: Any = None # lazy singleton (spandrel ImageModelDescriptor)
_model_device: str = "cpu"
_lock = threading.Lock()
def is_available() -> bool:
"""True if the ``esrgan`` extra (spandrel + torch) is importable."""
from .optional_deps import module_available
return module_available("spandrel", "torch")
def _model_cache_path() -> Path:
"""Path the weights are cached at (the torch.hub checkpoints dir)."""
import torch
cache_dir = Path(torch.hub.get_dir()) / "checkpoints"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / _MODEL_FILENAME
def _get_model(device: str) -> Any:
"""Load the Real-ESRGAN model once (downloading the weights on first use)."""
global _model, _model_device
if _model is not None and _model_device == device:
return _model
with _lock:
if _model is None:
import torch
from spandrel import ImageModelDescriptor, ModelLoader
dst = _model_cache_path()
if not dst.exists():
logger.info("Downloading Real-ESRGAN weights to %s", dst)
torch.hub.download_url_to_file(_MODEL_URL, str(dst), progress=False)
model = ModelLoader().load_from_file(str(dst))
if not isinstance(model, ImageModelDescriptor):
raise RuntimeError(f"Unexpected spandrel model type: {type(model).__name__}")
_model = model.eval()
if _model_device != device:
_model.to(device)
_model_device = device
return _model
def scale() -> int:
"""The model's native upscale factor (e.g. 2 for x2plus). Loads the model if needed."""
return int(_get_model("cpu").scale)
def upscale(image: NDArray[Any], device: str | None = None) -> NDArray[Any]:
"""Upscale a BGR uint8 image by the model's native factor with Real-ESRGAN.
Returns a BGR uint8 array. Falls back to CPU if the requested device errors (an
MPS/CUDA OOM or unsupported-op on the small pre-diffusion input), mirroring the
diffusion engine's MPS->CPU fallback.
Raises:
RuntimeError: if the ``esrgan`` extra is not installed (guard with
``is_available()`` first).
"""
if not is_available():
raise RuntimeError("Real-ESRGAN upscaler needs the 'esrgan' extra (spandrel). Install it or use Lanczos.")
import cv2
import numpy as np
import torch
target_device = (device or "cpu").lower()
if target_device not in {"cpu", "mps", "cuda", "xpu"}:
target_device = "cpu"
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
tensor = torch.from_numpy(rgb).permute(2, 0, 1).float().div(255.0).unsqueeze(0)
def _run(dev: str) -> NDArray[Any]:
model = _get_model(dev)
with torch.no_grad():
out = model(tensor.to(dev))
arr = out.clamp(0.0, 1.0).squeeze(0).permute(1, 2, 0).cpu().numpy() * 255.0
return cv2.cvtColor(arr.round().astype(np.uint8), cv2.COLOR_RGB2BGR)
try:
return _run(target_device)
except Exception as e: # GPU OOM / unsupported op: fall back to CPU
if target_device == "cpu":
raise
logger.warning("Real-ESRGAN on %s failed (%s); retrying on CPU", target_device, e)
return _run("cpu")
+4 -96
View File
@@ -61,7 +61,7 @@ class TestNativeOutputSize:
out = tmp_path / "out.png" out = tmp_path / "out.png"
Image.new("RGB", (24, 18), (128, 128, 128)).save(src) 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) assert Image.open(out).size == (24, 18)
@@ -107,35 +107,10 @@ class TestTargetSize:
assert _target_size(5000, 3, 1024) == (1024, 1) assert _target_size(5000, 3, 1024) == (1024, 1)
assert _target_size(3, 5000, 1024) == (1, 1024) assert _target_size(3, 5000, 1024) == (1, 1024)
# ── min_resolution floor (small inputs upscaled so SDXL runs near 1024) ── def test_a_small_input_is_left_at_native_size(self):
"""No minimum-resolution floor: only the cap can move geometry."""
def test_floor_default_off(self):
# min_resolution defaults to 0 -> no upscale, preserving legacy behavior.
assert _target_size(381, 512, 0) is None assert _target_size(381, 512, 0) is None
assert _target_size(381, 512, 4096) 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
class TestEngineDoesNotFabricateAModelId: class TestEngineDoesNotFabricateAModelId:
@@ -165,70 +140,3 @@ class TestEngineDoesNotFabricateAModelId:
with patch("remove_ai_watermarks._internal.watermark_remover.WatermarkRemover") as remover: with patch("remove_ai_watermarks._internal.watermark_remover.WatermarkRemover") as remover:
engine_module.InvisibleEngine(model_id="org/custom", pipeline="qwen-zimage") engine_module.InvisibleEngine(model_id="org/custom", pipeline="qwen-zimage")
assert remover.call_args.kwargs["model_id"] == "org/custom" assert remover.call_args.kwargs["model_id"] == "org/custom"
class TestEsrganUpscale:
"""Branches of InvisibleEngine._esrgan_upscale (no diffusion model loaded).
A SimpleNamespace stands in for the engine so we exercise the helper without
constructing a real InvisibleEngine (which would load WatermarkRemover).
"""
@staticmethod
def _fake_engine():
from types import SimpleNamespace
return SimpleNamespace(_remover=SimpleNamespace(device="cpu"))
@staticmethod
def _pil(w=120, h=80):
import numpy as np
from PIL import Image
return Image.fromarray(np.full((h, w, 3), 128, dtype=np.uint8))
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)))
+1 -5
View File
@@ -767,11 +767,7 @@ def test_invisible_engine_uses_qwen_zimage_step_default(tmp_image_path, tmp_path
engine._remover = MagicMock(model_profile="qwen-zimage") engine._remover = MagicMock(model_profile="qwen-zimage")
engine._remover.remove_watermark.return_value = tmp_path / "clean.png" engine._remover.remove_watermark.return_value = tmp_path / "clean.png"
engine.remove_watermark( engine.remove_watermark(tmp_image_path, tmp_path / "clean.png")
tmp_image_path,
tmp_path / "clean.png",
min_resolution=0,
)
assert engine._remover.remove_watermark.call_args.kwargs["num_inference_steps"] == 4 assert engine._remover.remove_watermark.call_args.kwargs["num_inference_steps"] == 4
-32
View File
@@ -1,32 +0,0 @@
"""Tests for the optional Real-ESRGAN upscaler (no model download).
The model-running path is exercised manually (it downloads ~67 MB of BSD-3-Clause
weights on first use); these tests cover the availability guard and the no-model
control flow, mirroring the repo convention for ML-adjacent modules.
"""
from __future__ import annotations
import numpy as np
import pytest
from remove_ai_watermarks import upscaler
class TestIsAvailable:
def test_returns_bool(self):
assert isinstance(upscaler.is_available(), bool)
class TestUpscaleGuard:
def test_raises_without_extra(self, monkeypatch):
monkeypatch.setattr(upscaler, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="esrgan"):
upscaler.upscale(np.full((32, 32, 3), 128, dtype=np.uint8))
class TestModelCachePath:
def test_cache_path_uses_model_filename(self):
if not upscaler.is_available():
pytest.skip("esrgan extra (torch) not installed")
assert upscaler._model_cache_path().name == upscaler._MODEL_FILENAME
Generated
+2 -27
View File
@@ -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.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 = "pywavelets", version = "1.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "safetensors" }, { name = "safetensors" },
{ name = "spandrel" },
{ name = "tokenizers" }, { name = "tokenizers" },
{ name = "torch" }, { name = "torch" },
{ name = "torchvision" }, { name = "torchvision" },
@@ -3397,11 +3396,6 @@ diffusion = [
{ name = "torch" }, { name = "torch" },
{ name = "transformers" }, { name = "transformers" },
] ]
esrgan = [
{ name = "numpy" },
{ name = "opencv-python-headless" },
{ name = "spandrel" },
]
heif = [ heif = [
{ name = "pillow-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 = ["diffusion"], marker = "extra == 'qwen-zimage'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'detect'" }, { 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 == 'diffusion'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'esrgan'" },
{ name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" }, { name = "remove-ai-watermarks", extras = ["pixels"], marker = "extra == 'visible'" },
{ name = "remove-ai-watermarks", extras = ["video"], marker = "extra == 'dev'" }, { 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", "diffusion", "qwen-zimage", "lama", "migan"], marker = "extra == 'all'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'lama'" }, { 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 == 'migan'" },
{ name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" }, { name = "remove-ai-watermarks", extras = ["visible"], marker = "extra == 'video'" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
{ name = "safetensors", marker = "extra == 'diffusion'" }, { 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 = "tokenizers", marker = "extra == 'diffusion'", specifier = ">=0.22,<0.23" },
{ name = "torch", marker = "extra == 'diffusion'", specifier = ">=2.0.0" }, { name = "torch", marker = "extra == 'diffusion'", specifier = ">=2.0.0" },
{ name = "torchvision", marker = "extra == 'qwen-zimage'", specifier = ">=0.20.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-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" }, { 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]] [[package]]
name = "requests" 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" }, { 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]] [[package]]
name = "stamina" name = "stamina"
version = "26.1.0" version = "26.1.0"