mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +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>
335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""Tests for cross-platform and cross-device compatibility.
|
|
|
|
Verifies that device detection, MPS fallback, and platform-specific
|
|
code paths work correctly on CPU, MPS (macOS), and CUDA (Linux/Windows).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
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,
|
|
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
|
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
|
SDXL_ZIMAGE_UNKNOWN_STRENGTH,
|
|
normalize_profile,
|
|
resolve_strength,
|
|
strength_default_help,
|
|
)
|
|
from remove_ai_watermarks._internal.watermark_remover import get_device, is_watermark_removal_available
|
|
|
|
# ── Device detection ────────────────────────────────────────────────
|
|
|
|
|
|
class TestDeviceDetection:
|
|
"""Tests for get_device() across platforms."""
|
|
|
|
def test_returns_valid_device(self):
|
|
device = get_device()
|
|
assert device in ("cpu", "mps", "cuda", "xpu")
|
|
|
|
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)
|
|
|
|
@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.
|
|
|
|
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.
|
|
"""
|
|
fake_torch = MagicMock()
|
|
fake_torch.cuda.is_available.return_value = False
|
|
fake_torch.xpu.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")
|
|
|
|
def test_non_cuda_devices_are_refused_at_construction(self):
|
|
"""CUDA is a precondition of the object, not of the run.
|
|
|
|
Both remaining profiles raise on any other device, so accepting cpu/mps/xpu
|
|
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.
|
|
"""
|
|
if not is_watermark_removal_available():
|
|
pytest.skip("torch/diffusers 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")
|
|
|
|
|
|
class TestModelProfiles:
|
|
"""Only the two CUDA-only two-stage profiles remain."""
|
|
|
|
def test_canonical_profiles_unchanged(self):
|
|
assert normalize_profile("qwen-zimage") == "qwen-zimage"
|
|
assert normalize_profile("sdxl-zimage") == "sdxl-zimage"
|
|
|
|
def test_underscore_spellings_resolve(self):
|
|
assert normalize_profile("qwen_zimage") == "qwen-zimage"
|
|
assert normalize_profile(" SDXL_ZImage ") == "sdxl-zimage"
|
|
|
|
def test_retired_names_no_longer_resolve_to_a_profile(self):
|
|
"""default/sdxl/controlnet/qwen were removed, not aliased onward.
|
|
|
|
Silently mapping them at the alias layer would route an old script into a
|
|
profile it never asked for; the remover raises on the unknown name instead.
|
|
"""
|
|
for retired in ("default", "sdxl", "controlnet", "qwen"):
|
|
assert normalize_profile(retired) not in PROFILE_CHOICES
|
|
|
|
|
|
class TestNoReembeddedWatermark:
|
|
"""F2 regression: the SDXL global stage must disable the diffusers watermarker.
|
|
|
|
diffusers stamps an open "Stable Diffusion XL" DWT-DCT watermark onto every SDXL
|
|
output whenever ``invisible-watermark`` is installed. A watermark REMOVER that left
|
|
it on would replace one detectable AI watermark (SynthID) with another -- the
|
|
cleaned output re-reads as AI. The ControlNet sub-model load must NOT receive the
|
|
kwarg, since it is not a pipeline and does not accept it.
|
|
|
|
Only sdxl-zimage carries an SDXL pipeline now; qwen-zimage's global stage is
|
|
DiffSynth, which has no such watermarker.
|
|
"""
|
|
|
|
def test_sdxl_global_stage_disables_watermarker(self, monkeypatch: pytest.MonkeyPatch):
|
|
if not is_watermark_removal_available():
|
|
pytest.skip("torch/diffusers not installed")
|
|
import diffusers
|
|
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
|
|
|
|
calls: dict[str, dict] = {}
|
|
|
|
def record(name):
|
|
def fake(*_args, **kwargs):
|
|
calls[name] = kwargs
|
|
return MagicMock()
|
|
|
|
return fake
|
|
|
|
monkeypatch.setattr(diffusers.ControlNetModel, "from_pretrained", record("controlnet"))
|
|
monkeypatch.setattr(diffusers.AutoencoderKL, "from_pretrained", record("vae"))
|
|
monkeypatch.setattr(diffusers.StableDiffusionXLControlNetImg2ImgPipeline, "from_pretrained", record("pipeline"))
|
|
monkeypatch.setattr("huggingface_hub.hf_hub_download", lambda *a, **k: "lora.safetensors")
|
|
# from_config would otherwise resolve the mock's config as a repo id.
|
|
monkeypatch.setattr(diffusers.EulerDiscreteScheduler, "from_config", lambda *a, **k: MagicMock())
|
|
|
|
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=None)
|
|
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
|
|
pipeline._load_sdxl()
|
|
|
|
assert calls["pipeline"].get("add_watermarker") is False
|
|
assert "add_watermarker" not in calls["controlnet"]
|
|
|
|
|
|
class TestResolveStrength:
|
|
"""resolve_strength answers for sdxl-zimage and defers for qwen-zimage."""
|
|
|
|
def test_qwen_zimage_answers_from_the_resolution_curve(self):
|
|
"""The function is total: it owns both policies rather than returning None.
|
|
|
|
qwen-zimage picks strength from image area, so it takes the size. Returning
|
|
None for it would push that branch onto every caller and leave one of the two
|
|
strength policies living outside this module. The vendor is ignored here on
|
|
purpose - the curve, not the issuer, is what was calibrated.
|
|
"""
|
|
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
|
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
|
|
|
|
def test_qwen_zimage_without_a_size_fails_loudly(self):
|
|
"""A missing size must not silently fall back to some vendor value."""
|
|
with pytest.raises(ValueError, match="size is required"):
|
|
resolve_strength(None, "google", "qwen-zimage")
|
|
|
|
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
|
|
|
|
assert SDXL_ZIMAGE_OPENAI_STRENGTH == 0.15
|
|
assert SDXL_ZIMAGE_GEMINI_STRENGTH == 0.25
|
|
assert SDXL_ZIMAGE_UNKNOWN_STRENGTH == SDXL_ZIMAGE_GEMINI_STRENGTH
|
|
assert resolve_strength(None, "openai", "sdxl-zimage") == SDXL_ZIMAGE_OPENAI_STRENGTH
|
|
assert resolve_strength(None, "google", "sdxl-zimage") == SDXL_ZIMAGE_GEMINI_STRENGTH
|
|
# An unrecognised issuer takes the stricter Gemini value, not the OpenAI one.
|
|
assert resolve_strength(None, "adobe", "sdxl-zimage") == SDXL_ZIMAGE_UNKNOWN_STRENGTH
|
|
assert resolve_strength(None, None, "sdxl-zimage") == SDXL_ZIMAGE_UNKNOWN_STRENGTH
|
|
|
|
def test_strength_default_help_derives_from_constants(self):
|
|
|
|
h = strength_default_help()
|
|
assert str(SDXL_ZIMAGE_OPENAI_STRENGTH) in h
|
|
assert str(SDXL_ZIMAGE_GEMINI_STRENGTH) in h
|
|
|
|
def test_explicit_value_overrides_vendor(self):
|
|
|
|
assert resolve_strength(0.3, "openai", "sdxl-zimage") == 0.3
|
|
assert resolve_strength(0.3, None, "qwen-zimage") == 0.3
|
|
|
|
def test_explicit_zero_is_respected_not_treated_as_unset(self):
|
|
# 0.0 is falsy but explicit -- it must not fall through to the vendor default
|
|
# (the old `strength or DEFAULT` bug would have). Range validation lives in
|
|
# remove_watermark, not here.
|
|
|
|
assert resolve_strength(0.0, "google", "sdxl-zimage") == 0.0
|
|
assert resolve_strength(0.0, None, "qwen-zimage") == 0.0
|
|
|
|
|
|
class TestVendorForStrength:
|
|
"""vendor_for_strength normalizes the C2PA SynthID proxy to openai/google/None."""
|
|
|
|
@staticmethod
|
|
def _patch(value):
|
|
return patch("remove_ai_watermarks.metadata.synthid_source", return_value=value)
|
|
|
|
def test_openai(self):
|
|
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
|
|
|
with self._patch("OpenAI"):
|
|
assert vendor_for_strength(Path("x.png")) == "openai"
|
|
|
|
def test_google(self):
|
|
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
|
|
|
with self._patch("Google"):
|
|
assert vendor_for_strength(Path("x.png")) == "google"
|
|
|
|
def test_both_issuers_google_wins(self):
|
|
# The more-robust watermark wins -> safer (higher) strength.
|
|
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
|
|
|
with self._patch("OpenAI, Google"):
|
|
assert vendor_for_strength(Path("x.png")) == "google"
|
|
|
|
def test_none_when_no_synthid_source(self):
|
|
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
|
|
|
with self._patch(None):
|
|
assert vendor_for_strength(Path("x.png")) is None
|
|
|
|
def test_unreadable_metadata_is_none(self):
|
|
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
|
|
|
with patch("remove_ai_watermarks.metadata.synthid_source", side_effect=OSError):
|
|
assert vendor_for_strength(Path("x.png")) is None
|
|
|
|
|
|
# ── Format utilities ────────────────────────────────────────────────
|
|
|
|
|
|
class TestFormatUtils:
|
|
"""Tests for utils.py format helpers."""
|
|
|
|
def test_supported_png(self, tmp_path):
|
|
assert is_supported_format(tmp_path / "test.png")
|
|
|
|
def test_supported_jpg(self, tmp_path):
|
|
assert is_supported_format(tmp_path / "test.jpg")
|
|
|
|
def test_supported_jpeg(self, tmp_path):
|
|
assert is_supported_format(tmp_path / "test.jpeg")
|
|
|
|
def test_supported_webp(self, tmp_path):
|
|
assert is_supported_format(tmp_path / "test.webp")
|
|
|
|
def test_unsupported_bmp(self, tmp_path):
|
|
assert not is_supported_format(tmp_path / "test.bmp")
|
|
|
|
def test_unsupported_gif(self, tmp_path):
|
|
assert not is_supported_format(tmp_path / "test.gif")
|
|
|
|
def test_get_format_png(self, tmp_path):
|
|
assert get_image_format(tmp_path / "x.png") == "PNG"
|
|
|
|
def test_get_format_jpg(self, tmp_path):
|
|
assert get_image_format(tmp_path / "x.jpg") == "JPEG"
|
|
|
|
def test_get_format_jpeg(self, tmp_path):
|
|
assert get_image_format(tmp_path / "x.jpeg") == "JPEG"
|
|
|
|
def test_get_format_webp_defaults_png(self, tmp_path):
|
|
# .webp falls through to PNG in current implementation
|
|
assert get_image_format(tmp_path / "x.webp") == "PNG"
|
|
|
|
|
|
# ── Availability checks ────────────────────────────────────────────
|
|
|
|
|
|
class TestAvailability:
|
|
"""Tests for dependency availability checks."""
|
|
|
|
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
|
|
|
|
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):
|
|
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 is_available() is expected
|
|
|
|
|
|
# ── Platform-specific path handling ─────────────────────────────────
|
|
|
|
|
|
class TestPlatformPaths:
|
|
"""Verify path handling works on current platform."""
|
|
|
|
def test_pathlib_works_for_assets(self):
|
|
from pathlib import Path
|
|
|
|
asset_dir = Path(__file__).parent.parent / "src" / "remove_ai_watermarks" / "assets"
|
|
assert (asset_dir / "gemini_bg_48.png").exists()
|
|
assert (asset_dir / "gemini_bg_96.png").exists()
|
|
|
|
def test_asset_loading_works(self):
|
|
"""Verify embedded assets load correctly (critical for packaging)."""
|
|
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
|
|
|
engine = GeminiEngine()
|
|
# If we get here without error, asset loading works
|
|
assert engine._alpha_small.shape == (48, 48)
|
|
assert engine._alpha_large.shape == (96, 96)
|