mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
qwen-zimage becomes the default and sdxl-zimage the only alternative. The
controlnet, sdxl, qwen and default profiles are gone, and with them the CPU and
MPS paths for invisible-watermark removal: neither matched the two-stage
recipe's face preservation, so keeping them advertised a quality this library no
longer delivers. Visible-mark removal and every identify command still run
anywhere.
Retired names are rejected rather than remapped. Silently routing --pipeline
sdxl onward would run an old script at a different strength, on a different
model, at a different quality, and report success.
CUDA is now checked when the remover is constructed instead of when the model
loads. Auto-detection cheerfully returned mps on a Mac, so the failure arrived
several layers down, after the dependency check and the pipeline import, in a
message naming whichever internal pipeline happened to raise. _DEVICES collapses
to {"cuda"} and the cpu/mps float32 branch goes with it.
resolve_strength stays total. It briefly returned None for qwen-zimage, meaning
"ask the resolution curve", which pushed a branch onto both callers and left one
of the two strength policies outside the strength module; the CLI copy had
already grown an `or 0.0` guarding a path its own comment called unreachable. It
now takes the image size and answers for both profiles, so the displayed value
cannot drift from the executed one.
Deletion fallout removed with it: img2img_runner and progress.py (the MPS
recovery path and its progress monitor had no callers left), viable_steps, the
fp16 degenerate-output retry, the fp16 VAE fix, and the Qwen img2img call
builders. try_empty_device_cache moved into watermark_remover rather than
leaving a module whose docstring outlived its code. _HAS_DIFFUSERS routes
through optional_deps.module_available, which is what the rest of the library
uses and what correctly rejects a pruned namespace remnant.
--steps, --guidance-scale and --model now have exactly one legal value each and
are still accepted at parse time, then rejected in remove(). Their help text
says so, but validating them beside the option would be better.
Not addressed, and worth its own decision: invisible_engine forces
min_resolution to 0 for both profiles, so the --min-resolution floor, --upscaler,
_esrgan_upscale, upscaler.py and the esrgan extra are all unreachable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
206 lines
8.1 KiB
Python
206 lines
8.1 KiB
Python
"""Tests for the invisible watermark engine (unit tests, no GPU required)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
from PIL import Image
|
|
|
|
from remove_ai_watermarks.invisible_engine import InvisibleEngine, _target_size, is_available
|
|
|
|
|
|
class TestIsAvailable:
|
|
"""Tests for dependency checking."""
|
|
|
|
def test_returns_bool(self):
|
|
result = is_available()
|
|
assert isinstance(result, bool)
|
|
|
|
def test_available_reflects_dependencies(self):
|
|
"""is_available() is True iff torch + diffusers (the diffusion extra) import.
|
|
|
|
Must not assume the full stack: the default+dev CI env has no diffusers.
|
|
"""
|
|
import importlib.util
|
|
|
|
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
|
|
assert is_available() is expected
|
|
|
|
|
|
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))
|
|
|
|
engine.preload(global_only=True)
|
|
|
|
assert engine._preload_kwargs == {"global_only": True}
|
|
|
|
|
|
class TestNativeOutputSize:
|
|
"""Model-side latent-grid rounding must not change the public output size."""
|
|
|
|
def test_no_polish_restores_native_non_multiple_of_eight_size(self, tmp_path):
|
|
engine = object.__new__(InvisibleEngine)
|
|
|
|
def _remove_watermark(image_path, output_path=None, **_kwargs):
|
|
out = output_path or image_path.with_stem(image_path.stem + "_clean")
|
|
# Model-side latent-grid rounding: 18px becomes 16px.
|
|
Image.open(image_path).crop((0, 0, 24, 16)).save(out)
|
|
return out
|
|
|
|
engine._remover = SimpleNamespace(remove_watermark=_remove_watermark)
|
|
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)
|
|
|
|
assert Image.open(out).size == (24, 18)
|
|
|
|
|
|
class TestTargetSize:
|
|
"""Regression guard for the native-resolution decision (issues #10 / #15).
|
|
|
|
max_resolution=0 must NOT downscale -- the forced downscale->upscale
|
|
round-trip was the quality loss in #10, and downscaling at all let SynthID
|
|
survive in #15 (the native SDXL pass at strength ~0.05 is what defeats it).
|
|
"""
|
|
|
|
def test_native_default_no_downscale(self):
|
|
# The default (0) means native resolution: no resize, regardless of size.
|
|
assert _target_size(4096, 4096, 0) is None
|
|
assert _target_size(123, 456, 0) is None
|
|
|
|
def test_negative_cap_treated_as_native(self):
|
|
assert _target_size(4096, 4096, -1) is None
|
|
|
|
def test_cap_below_long_side_downscales(self):
|
|
# 2000x1000, cap 1024 -> long side scaled to 1024, aspect preserved.
|
|
assert _target_size(2000, 1000, 1024) == (1024, 512)
|
|
|
|
def test_cap_uses_long_side_for_portrait(self):
|
|
# Portrait: height is the long side, so it drives the ratio.
|
|
assert _target_size(1000, 2000, 1024) == (512, 1024)
|
|
|
|
def test_cap_at_or_above_long_side_no_downscale(self):
|
|
# Already within the cap (and exactly equal) -> no resize.
|
|
assert _target_size(800, 600, 1024) is None
|
|
assert _target_size(1024, 768, 1024) is None
|
|
|
|
def test_integer_truncation_matches_pil_call_site(self):
|
|
# 1254x1254 (the gpt-image sample) capped at 1000: int(1254*1000/1254)=1000.
|
|
assert _target_size(1254, 1254, 1000) == (1000, 1000)
|
|
# Non-divisible ratio truncates toward zero like int() at the call site.
|
|
assert _target_size(1000, 333, 500) == (500, 166)
|
|
|
|
def test_extreme_aspect_ratio_clamps_short_side_to_one(self):
|
|
# 5000x3 capped at 1024: int(3 * 1024/5000) = 0 would crash resize();
|
|
# the short side must clamp to 1, never 0.
|
|
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.
|
|
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
|
|
|
|
|
|
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)))
|