mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 16:10:33 +02:00
The CLI still advertised --model, --steps, --guidance-scale, --device and a deprecated --auto. Each pinned a value the two surviving profiles fix -- the model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only outcome any of them had was an error raised several frames below the caller, under a message naming an internal profile. A flag whose sole result is a refusal is worse than no flag: it advertises a capability that does not exist, and it lets a wrapper thread a value that will silently do nothing. They are gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the failure is now a TypeError or a Click "No such option" at the point the caller can act on. The install hint was wrong in the same way. is_available() checked torch and diffusers, then told the user to install [diffusion] -- which contains neither DiffSynth nor the Z-Image face stage both profiles run. Following the advice produced a second, different failure. The module list and the extra name now live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read by both the CLI gate and the remover's precondition, which cannot drift apart because they are the same tuple. The adaptive-polish default moved out of the argument parser. It was resolved by reading Click's parameter source, which put per-profile data in the CLI layer, left the engine declaring the opposite default (False vs True) so a library caller and a CLI caller on one profile got different output, and lost the polish entirely for anything that supplies the flag non-interactively. The flag is now tri-state (default=None) and resolve_adaptive_polish owns the per-profile answer. The seed follows the same rule: the CLI stopped pre-resolving it. Dead code removed with it: six scan_*_video wrappers and the _scan_video helper none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and the remover region path that was only reachable from a no-caller convenience wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the _generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal PEP 562 shim that no caller ever went through. get_device now answers cuda or cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while costing a device probe each, and that refusal now names the resolved device, so device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel index went with them. Docs: README, cli, installation, python-api, supported-signals, known-limitations and module-internals all still described the removed profiles, the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra. known-limitations still listed the retired SDXL strength ladder as current. scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps. Next release is 0.25.0, not a patch: this removes public parameters and narrows a published extra on top of the released 0.24.0. pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped (dropping the `device` parameter entirely, which raiw-app pins; folding diffsynth into the `diffusion` extra, which video-only callers do not need); 3) docs sync - grepped every removed identifier across README, docs/, scripts/, .claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to .claude/rules/development.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
359 lines
15 KiB
Python
359 lines
15 KiB
Python
"""Tests for device detection, profile resolution, and platform-specific paths.
|
|
|
|
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
|
|
|
|
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:
|
|
"""get_device() is binary: CUDA, or the "cpu" that names its absence."""
|
|
|
|
def test_answer_is_cuda_or_cpu(self):
|
|
"""No mps/xpu answer exists. Both would travel one frame to the same refusal.
|
|
|
|
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_working_cuda_is_selected_and_probed(self):
|
|
"""A reported CUDA device is smoke-tested before it is returned.
|
|
|
|
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 = True
|
|
with patch("remove_ai_watermarks._internal.watermark_remover.torch", fake_torch):
|
|
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.
|
|
|
|
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
|
|
|
|
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.
|
|
"""
|
|
if not is_watermark_removal_available():
|
|
pytest.skip("torch/diffusers not installed")
|
|
from remove_ai_watermarks._internal import watermark_remover as module
|
|
|
|
with (
|
|
patch.object(module, "get_device", return_value="cpu"),
|
|
pytest.raises(ValueError, match="'cpu' cannot run it"),
|
|
):
|
|
module.WatermarkRemover(device=None)
|
|
|
|
|
|
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 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.
|
|
|
|
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)
|