mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Bug fixes (each with a regression test): - metadata strip parity across every marker placement: IPTC digitalSourceType in XMP, the Samsung post-EOI trailer, the China TC260 AIGC block in EXIF UserComment, a bare AIGC block in a non-standard APP segment, and the ISOBMFF EXIF path (AIGC + xAI) are all now stripped -- anything a scanner flags, the strip reaches - Samsung genAIType detected when its trailer sits past the 512 KB scan window (file-tail read on large photos) - crashes on edge inputs: Gemini detector on images with a short side < 16px, footprint_mask on a zero-size ndarray, the humanizer on chromatic_shift >= width, and the CLI on unreadable/corrupt/empty input (clean error, not a traceback) - WebP written losslessly (cv2 quality 101), not lossy at 100 - the IPTC digitalSourceType algorithmicMedia (procedural, not trained on sampled data) is no longer flagged as AI-generated, so clean procedural content is not scrubbed - c2pa source-type: compositeWithTrainedAlgorithmicMedia is checked before the bare algorithmicMedia token, so an AI-enhanced composite is not misclassified Detection: - integrity-clash coverage now normalizes ByteDance / Canva / ElevenLabs / Black Forest Labs, so a transplanted manifest next to an independent conflicting stamp is caught; the generic China TC260 AIGC label is attributed to a co-present TC260 vendor, so a legit Doubao image (its own C2PA + TC260 label) does not clash (corpus-validated: 0 new clashes on 5000 carriers) CLI: - batch exits non-zero (with a warning) when any image errors or a GPU-missing SynthID scrub is skipped, and copies the input through so the output dir stays complete -- it used to always exit 0 and could silently drop files Perf: - GeminiEngine reused as a process-wide singleton with a precomputed template ladder: -24% on the identify sparkle path, detection byte-identical Internal: one shared _ai_exif_targets rule set feeds both EXIF scrubbers so their coverage cannot drift; docs synced; maintain.sh hardened so the uv-secure internal teardown crash no longer aborts the gate (still fails on a real finding). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
8.7 KiB
Python
220 lines
8.7 KiB
Python
"""Tests for the invisible watermark engine (unit tests, no GPU required)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
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 gpu extra) import.
|
|
|
|
Must not assume the full stack: the core+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"
|
|
|
|
|
|
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)))
|
|
|
|
|
|
class TestCannyControlImage:
|
|
"""The ControlNet canny conditioning image builder (pure cv2/numpy; behind the gpu
|
|
extra since it lives on WatermarkRemover). Skips when torch/diffusers are absent."""
|
|
|
|
def test_edge_map_is_3channel_rgb(self):
|
|
if not is_available():
|
|
pytest.skip("gpu extra (torch/diffusers) not installed")
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
|
|
|
rng = np.random.default_rng(0)
|
|
img = Image.fromarray(rng.integers(0, 256, (64, 80, 3), dtype=np.uint8))
|
|
# The method uses no instance state, so call it unbound with a dummy self.
|
|
out = WatermarkRemover._build_canny_control_image(None, img) # type: ignore[arg-type]
|
|
arr = np.array(out)
|
|
assert out.mode == "RGB"
|
|
assert arr.shape == (64, 80, 3)
|
|
assert arr.max() <= 255
|