mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
1369 lines
56 KiB
Python
1369 lines
56 KiB
Python
"""Tests for the Qwen 2512 + Z-Image SynthID removal profile."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from unittest.mock import MagicMock
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
from PIL import Image
|
|
|
|
|
|
def _mock_watermark_runtime_deps(monkeypatch):
|
|
"""Bypass optional GPU imports while testing Qwen Z-Image routing."""
|
|
from remove_ai_watermarks._internal import watermark_remover
|
|
|
|
fake_torch = MagicMock()
|
|
fake_torch.float16 = object()
|
|
fake_torch.float32 = object()
|
|
monkeypatch.setattr(watermark_remover, "torch", fake_torch)
|
|
monkeypatch.setattr(watermark_remover, "_HAS_TORCH", False)
|
|
monkeypatch.setattr(watermark_remover, "is_watermark_removal_available", lambda: True)
|
|
|
|
|
|
def test_resolution_adaptive_denoise_preserves_calibrated_values():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import resolution_adaptive_denoise
|
|
|
|
assert resolution_adaptive_denoise(600, 500, adaptive_level=6) == pytest.approx(0.084)
|
|
assert resolution_adaptive_denoise(2000, 1850, adaptive_level=6) == pytest.approx(0.154)
|
|
|
|
|
|
def test_largest_face_denoise_preserves_calibrated_values():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import largest_face_denoise
|
|
|
|
image_size = (1000, 1000)
|
|
assert largest_face_denoise([(0, 0, 300, 100)], image_size) == pytest.approx(0.10)
|
|
assert largest_face_denoise([(0, 0, 150, 100)], image_size) == pytest.approx(0.05)
|
|
assert largest_face_denoise([(0, 0, 900, 900)], image_size) == pytest.approx(0.28)
|
|
|
|
|
|
def test_global_kwargs_use_lightning_and_diffsynth_controlnet_shape():
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import build_global_kwargs
|
|
|
|
image = Image.new("RGB", (1122, 1402))
|
|
kwargs = build_global_kwargs(image, strength=0.11, seed=7, controlnet_input="CONTROL")
|
|
|
|
assert kwargs["input_image"].size == (1120, 1392)
|
|
assert kwargs["blockwise_controlnet_inputs"] == ["CONTROL"]
|
|
assert kwargs["denoising_strength"] == 0.11
|
|
assert kwargs["num_inference_steps"] == 4
|
|
assert kwargs["cfg_scale"] == 1.0
|
|
assert kwargs["seed"] == 7
|
|
assert kwargs["width"] == 1120
|
|
assert kwargs["height"] == 1392
|
|
assert kwargs["exponential_shift_mu"] == pytest.approx(math.log(3.0))
|
|
assert kwargs["prompt"] == "ultra clear and smoothe skin, spotless skin"
|
|
assert kwargs["negative_prompt"] == "moles, freckes, high detail skin"
|
|
|
|
|
|
def test_face_kwargs_use_project_zimage_settings():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import build_face_kwargs
|
|
|
|
crop = Image.new("RGB", (713, 941))
|
|
kwargs = build_face_kwargs(crop, strength=0.17, seed=9)
|
|
|
|
assert kwargs["input_image"].size == (704, 928)
|
|
assert kwargs["denoising_strength"] == 0.17
|
|
assert kwargs["num_inference_steps"] == 8
|
|
assert kwargs["cfg_scale"] == 1.0
|
|
assert kwargs["seed"] == 9
|
|
assert kwargs["width"] == 704
|
|
assert kwargs["height"] == 928
|
|
assert kwargs["prompt"] == ""
|
|
assert kwargs["negative_prompt"] == "blurry, ugly, bad quality,"
|
|
|
|
|
|
def test_canny_control_image_is_three_channel_and_detects_an_edge():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import build_canny_control_image
|
|
|
|
source = np.zeros((64, 80, 3), dtype=np.uint8)
|
|
source[:, 40:] = 255
|
|
result = np.asarray(build_canny_control_image(Image.fromarray(source)))
|
|
|
|
assert result.shape == (64, 80, 3)
|
|
assert np.array_equal(result[:, :, 0], result[:, :, 1])
|
|
assert np.array_equal(result[:, :, 1], result[:, :, 2])
|
|
import cv2
|
|
|
|
expected = cv2.Canny(cv2.cvtColor(source, cv2.COLOR_RGB2GRAY), 13, 64)
|
|
assert np.array_equal(result[:, :, 0], expected)
|
|
|
|
|
|
def test_face_crop_geometry_preserves_calibrated_values():
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _expanded_box
|
|
|
|
assert _expanded_box((100, 100, 200, 200), (500, 500)) == (25, 25, 275, 275)
|
|
assert QwenZImagePipeline._detail_size((500, 400), (100, 80)) == (1024, 816)
|
|
|
|
|
|
def test_yunet_download_targets_verified_lfs_artifact():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
|
YUNET_MODEL_SHA256,
|
|
YUNET_MODEL_URL,
|
|
YUNET_SCORE_THRESHOLD,
|
|
)
|
|
|
|
assert YUNET_MODEL_URL.startswith("https://media.githubusercontent.com/media/opencv/opencv_zoo/")
|
|
assert YUNET_MODEL_SHA256 == "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"
|
|
assert pytest.approx(0.5) == YUNET_SCORE_THRESHOLD
|
|
|
|
|
|
def test_global_stack_is_resident_on_a_large_card_and_streams_on_a_small_one():
|
|
"""The mandatory Qwen stack must not stream from disk on a card that can hold it.
|
|
|
|
DiffSynth offloads by dropping weights to the meta device and re-reading every
|
|
parameter through its DiskMap, so the streaming config costs a full model reload
|
|
on each stage transition. Benchmark in docs/module-internals.md, "CPU offload".
|
|
"""
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
|
|
QwenZImagePipeline,
|
|
resolve_global_model_residency,
|
|
)
|
|
|
|
assert resolve_global_model_residency(None, total_memory_gib=79.2) is True
|
|
assert resolve_global_model_residency(None, total_memory_gib=39.5) is False
|
|
assert resolve_global_model_residency(False, total_memory_gib=79.2) is False
|
|
assert resolve_global_model_residency(True, total_memory_gib=39.5) is True
|
|
|
|
large = QwenZImagePipeline(
|
|
device="cuda",
|
|
torch_dtype=torch.bfloat16,
|
|
keep_global_models_on_device=True,
|
|
)._qwen_vram_config()
|
|
# No "disk" anywhere: DiffSynth latches disk_offload from offload_dtype once, so
|
|
# leaving it in would keep the meta-drop even with every device set to cuda.
|
|
assert "disk" not in large.values()
|
|
assert large["offload_device"] == "cuda"
|
|
assert large["onload_device"] == "cuda"
|
|
assert large["computation_dtype"] is torch.bfloat16
|
|
|
|
small = QwenZImagePipeline(
|
|
device="cuda",
|
|
torch_dtype=torch.bfloat16,
|
|
keep_global_models_on_device=False,
|
|
)._qwen_vram_config()
|
|
assert small["offload_dtype"] == "disk"
|
|
assert small["offload_device"] == "disk"
|
|
assert small["onload_device"] == "cpu"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cpu_offload", "expected"),
|
|
[(True, False), (False, None)],
|
|
)
|
|
def test_cpu_offload_forces_both_stacks_to_stream(monkeypatch, cpu_offload, expected):
|
|
"""``cpu_offload`` is the caller's escape hatch and must cover the global stack too.
|
|
|
|
Without the global flag it silenced only the face stack, so a caller asking for
|
|
low VRAM still got the larger global stack pinned.
|
|
"""
|
|
from remove_ai_watermarks._internal import qwen_zimage_pipeline as pipeline_module
|
|
from remove_ai_watermarks._internal import watermark_remover as module
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class Recorder:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
# `_load_qwen_zimage_pipeline` imports the class inside the function body, so the
|
|
# patch has to land on the defining module rather than on watermark_remover.
|
|
monkeypatch.setattr(pipeline_module, "QwenZImagePipeline", Recorder)
|
|
|
|
remover = module.WatermarkRemover.__new__(module.WatermarkRemover)
|
|
remover.model_profile = "qwen-zimage"
|
|
remover.device = "cuda"
|
|
remover.torch_dtype = None
|
|
remover.hf_token = None
|
|
remover._progress_callback = None
|
|
remover.controlnet_conditioning_scale = 1.0
|
|
remover.cpu_offload = cpu_offload
|
|
remover._qwen_zimage_pipeline = None
|
|
|
|
remover._load_qwen_zimage_pipeline()
|
|
|
|
assert captured["keep_global_models_on_device"] is expected
|
|
assert captured["keep_face_models_on_device"] is expected
|
|
|
|
|
|
def test_face_stage_loads_in_its_own_dtype_when_the_global_stage_differs(monkeypatch, tmp_path):
|
|
"""A subclass that changes the pipeline dtype must not change the face stage's.
|
|
|
|
``sdxl-zimage`` is constructed fp16 for its global model. That dtype used to reach
|
|
the inherited ``_load_zimage``, which builds its modules bf16 from
|
|
``_zimage_vram_config``, so Z-Image got fp16 latents into bf16 convolutions and
|
|
every image containing a face died in the VAE. Zero-face inputs never enter the
|
|
face stage, so the profile looked healthy right up to the first portrait.
|
|
|
|
Asserts the dtype the loaders actually RECEIVE. Comparing the accessor against the
|
|
config it is derived from would restate the implementation and pass for any
|
|
consistently-wrong value.
|
|
"""
|
|
import torch
|
|
|
|
# The loaders this asserts on are monkeypatched, not called, but the modules still
|
|
# have to be importable to be patched -- and CI's base job installs the library
|
|
# without the qwen-zimage extra.
|
|
transformers = pytest.importorskip("transformers")
|
|
z_image = pytest.importorskip("diffsynth.pipelines.z_image")
|
|
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_zimage(**kwargs):
|
|
captured["zimage"] = kwargs["torch_dtype"]
|
|
return MagicMock(units=[])
|
|
|
|
def fake_sam(_model_id, **kwargs):
|
|
captured["sam"] = kwargs["torch_dtype"]
|
|
return MagicMock()
|
|
|
|
monkeypatch.setattr(z_image.ZImagePipeline, "from_pretrained", staticmethod(fake_zimage))
|
|
monkeypatch.setattr(transformers.AutoProcessor, "from_pretrained", staticmethod(lambda *a, **k: MagicMock()))
|
|
monkeypatch.setattr(transformers.AutoModelForMaskGeneration, "from_pretrained", staticmethod(fake_sam))
|
|
|
|
pipeline = SdxlZImagePipeline(device="cuda", torch_dtype=torch.float16)
|
|
pipeline._load_zimage()
|
|
pipeline._load_sam()
|
|
|
|
assert pipeline.torch_dtype == torch.float16, "the global stage keeps its own dtype"
|
|
# Z-Image is the one that crashed; SAM never did, but it read the same wrong field.
|
|
assert captured["zimage"] == torch.bfloat16
|
|
assert captured["sam"] == torch.bfloat16
|
|
|
|
|
|
def test_resident_face_models_disable_vram_offload():
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
|
_pin_vram_managed_models,
|
|
resolve_face_model_residency,
|
|
)
|
|
|
|
config = QwenZImagePipeline._zimage_vram_config()
|
|
|
|
assert config["offload_device"] == "cpu"
|
|
assert config["onload_device"] == "cpu"
|
|
assert config["preparing_device"] == "cuda"
|
|
assert config["computation_device"] == "cuda"
|
|
assert resolve_face_model_residency(None, total_memory_gib=79.2) is True
|
|
assert resolve_face_model_residency(None, total_memory_gib=39.5) is False
|
|
assert resolve_face_model_residency(False, total_memory_gib=79.2) is False
|
|
assert resolve_face_model_residency(True, total_memory_gib=39.5) is True
|
|
|
|
class ManagedModule:
|
|
offload_dtype = "bf16"
|
|
offload_device = "cpu"
|
|
onload_dtype = "bf16"
|
|
onload_device = "cpu"
|
|
preparing_dtype = "bf16"
|
|
preparing_device = "cuda"
|
|
computation_dtype = "bf16"
|
|
computation_device = "cuda"
|
|
|
|
def modules(self):
|
|
return [self]
|
|
|
|
class Pipe:
|
|
text_encoder = ManagedModule()
|
|
dit = ManagedModule()
|
|
vae_encoder = ManagedModule()
|
|
vae_decoder = ManagedModule()
|
|
|
|
def __init__(self):
|
|
self.loaded = None
|
|
|
|
def load_models_to_device(self, names):
|
|
self.loaded = names
|
|
|
|
pipe = Pipe()
|
|
_pin_vram_managed_models(pipe)
|
|
|
|
assert pipe.loaded == ["text_encoder", "dit", "vae_encoder", "vae_decoder"]
|
|
assert pipe.dit.offload_device == "cuda"
|
|
assert pipe.dit.onload_device == "cuda"
|
|
assert pipe.dit.preparing_device == "cuda"
|
|
|
|
|
|
def test_static_prompt_cache_reuses_embeddings_without_caching_image_edits():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _cache_static_prompt_embeddings
|
|
|
|
class PromptUnit:
|
|
output_params = ("prompt_embeds",)
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def process(self, _pipe, prompt, edit_image=None):
|
|
self.calls += 1
|
|
return {"prompt_embeds": [object()], "prompt": prompt, "edit_image": edit_image}
|
|
|
|
unit = PromptUnit()
|
|
pipe = MagicMock()
|
|
pipe.units = [unit]
|
|
|
|
assert _cache_static_prompt_embeddings(pipe, ("prompt_embeds",)) is True
|
|
first = unit.process(pipe, "constant")
|
|
second = unit.process(pipe, "constant")
|
|
different = unit.process(pipe, "different")
|
|
edited_first = unit.process(pipe, "constant", edit_image=object())
|
|
edited_second = unit.process(pipe, "constant", edit_image=object())
|
|
|
|
assert first is second
|
|
assert first is not different
|
|
assert edited_first is not edited_second
|
|
assert unit.calls == 4
|
|
|
|
|
|
def test_prompt_cache_path_is_keyed_by_version_model_outputs_and_prompt(monkeypatch, tmp_path):
|
|
"""A model, prompt or format change must not read a stale embedding."""
|
|
from remove_ai_watermarks._internal import two_stage_pipeline as ts
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
|
baseline = ts._prompt_cache_path("model/a", ("prompt_emb",), "text")
|
|
|
|
assert baseline.parent == tmp_path / "remove-ai-watermarks" / "prompt-embeddings"
|
|
assert baseline == ts._prompt_cache_path("model/a", ("prompt_emb",), "text")
|
|
assert baseline != ts._prompt_cache_path("model/b", ("prompt_emb",), "text")
|
|
assert baseline != ts._prompt_cache_path("model/a", ("prompt_embeds",), "text")
|
|
assert baseline != ts._prompt_cache_path("model/a", ("prompt_emb",), "other")
|
|
|
|
monkeypatch.setattr(ts, "_PROMPT_CACHE_VERSION", ts._PROMPT_CACHE_VERSION + 1)
|
|
assert baseline != ts._prompt_cache_path("model/a", ("prompt_emb",), "text")
|
|
|
|
|
|
def test_model_cache_dir_prefers_the_persistent_hugging_face_root(monkeypatch, tmp_path):
|
|
"""A scale-to-zero runner only mounts HF_HOME, so it must win over XDG."""
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _model_cache_dir
|
|
|
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
|
monkeypatch.delenv("HF_HOME", raising=False)
|
|
assert _model_cache_dir() == tmp_path / "xdg" / "remove-ai-watermarks"
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path / "hf"))
|
|
assert _model_cache_dir() == tmp_path / "hf" / "remove-ai-watermarks"
|
|
|
|
|
|
def test_stored_prompt_embedding_round_trips_without_casting_the_mask(monkeypatch, tmp_path):
|
|
"""The mask rides in the same payload and is integer; casting it corrupts the prompt."""
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal import two_stage_pipeline as ts
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _QWEN_PROMPT_OUTPUTS
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
|
path = ts._prompt_cache_path("model/a", _QWEN_PROMPT_OUTPUTS, "text")
|
|
payload = {
|
|
"prompt_emb": torch.ones((1, 2, 3), dtype=torch.float32),
|
|
"prompt_emb_mask": torch.ones((1, 2), dtype=torch.int64),
|
|
}
|
|
ts._store_prompt_payload(path, payload)
|
|
restored = ts._load_prompt_payload(path, "cpu", torch.bfloat16)
|
|
|
|
assert restored["prompt_emb"].dtype == torch.bfloat16
|
|
assert restored["prompt_emb_mask"].dtype == torch.int64
|
|
assert torch.equal(restored["prompt_emb"].float(), payload["prompt_emb"])
|
|
|
|
|
|
def test_persisted_prompt_cache_lets_a_second_pipeline_skip_the_text_encoder(monkeypatch, tmp_path):
|
|
"""The whole point: container two must not call the encoder container one ran."""
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal import two_stage_pipeline as ts
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
|
|
|
class PromptUnit:
|
|
output_params = ts._ZIMAGE_PROMPT_OUTPUTS
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
def process(self, _pipe, prompt, edit_image=None):
|
|
self.calls += 1
|
|
return {"prompt_embeds": [torch.ones((2, 2), dtype=torch.float32)]}
|
|
|
|
def build():
|
|
unit = PromptUnit()
|
|
pipe = MagicMock(units=[unit], device="cpu", torch_dtype=torch.float32)
|
|
return unit, pipe
|
|
|
|
first_unit, first_pipe = build()
|
|
ts._cache_static_prompt_embeddings(first_pipe, ts._ZIMAGE_PROMPT_OUTPUTS, model_id="model/a", require_cache=False)
|
|
first_unit.process(first_pipe, ts._FACE_PROMPT)
|
|
assert first_unit.calls == 1
|
|
|
|
second_unit, second_pipe = build()
|
|
ts._cache_static_prompt_embeddings(second_pipe, ts._ZIMAGE_PROMPT_OUTPUTS, model_id="model/a", require_cache=True)
|
|
restored = second_unit.process(second_pipe, ts._FACE_PROMPT)
|
|
|
|
assert second_unit.calls == 0
|
|
assert torch.equal(restored["prompt_embeds"][0], torch.ones((2, 2)))
|
|
|
|
|
|
def test_a_missing_cache_fails_loudly_once_the_text_encoder_was_left_out(monkeypatch, tmp_path):
|
|
"""Silently calling an absent text encoder would surface as an opaque crash."""
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal import two_stage_pipeline as ts
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _QWEN_PROMPT_OUTPUTS
|
|
|
|
monkeypatch.setenv("HF_HOME", str(tmp_path))
|
|
|
|
class PromptUnit:
|
|
output_params = _QWEN_PROMPT_OUTPUTS
|
|
|
|
def process(self, _pipe, prompt, edit_image=None):
|
|
raise AssertionError("the text encoder is not loaded")
|
|
|
|
unit = PromptUnit()
|
|
pipe = MagicMock(units=[unit], device="cpu", torch_dtype=torch.float32)
|
|
ts._cache_static_prompt_embeddings(pipe, _QWEN_PROMPT_OUTPUTS, model_id="model/a", require_cache=True)
|
|
|
|
with pytest.raises(RuntimeError, match="disappeared"):
|
|
unit.process(pipe, "never cached")
|
|
|
|
|
|
def test_sam_pixels_match_model_dtype_without_casting_boxes():
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _prepare_sam_inputs
|
|
|
|
class Inputs(dict[str, torch.Tensor]):
|
|
def to(self, device: str):
|
|
return Inputs({name: value.to(device) for name, value in self.items()})
|
|
|
|
inputs = Inputs(
|
|
{
|
|
"pixel_values": torch.zeros((1, 3, 8, 8), dtype=torch.float32),
|
|
"input_boxes": torch.zeros((1, 1, 4), dtype=torch.float32),
|
|
}
|
|
)
|
|
|
|
prepared = _prepare_sam_inputs(inputs, "cpu", torch.bfloat16)
|
|
|
|
assert prepared["pixel_values"].dtype == torch.bfloat16
|
|
assert prepared["input_boxes"].dtype == torch.float32
|
|
|
|
|
|
def test_sam_prompts_match_impact_center_and_clip_masks_to_boxes():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
|
_clip_sam_masks_to_boxes,
|
|
_sam_point_prompts,
|
|
)
|
|
|
|
boxes = [(2, 3, 8, 9), (10, 4, 16, 12)]
|
|
points, labels = _sam_point_prompts(boxes)
|
|
masks = [np.full((14, 18), 255, dtype=np.uint8) for _box in boxes]
|
|
|
|
clipped = _clip_sam_masks_to_boxes(masks, boxes, (18, 14))
|
|
|
|
assert points == [[[[5.0, 6.0]], [[13.0, 8.0]]]]
|
|
assert labels == [[[1], [1]]]
|
|
assert np.count_nonzero(clipped[0]) == 36
|
|
assert np.count_nonzero(clipped[1]) == 48
|
|
assert clipped[0][2, 2] == 0
|
|
assert clipped[0][3, 2] == 255
|
|
|
|
|
|
def test_sam_proposal_selection_matches_impact_sub_threshold():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _select_sam_masks
|
|
|
|
masks = np.zeros((2, 3, 8, 8), dtype=np.float32)
|
|
masks[0, 0, 1:3, 1:3] = 1.0
|
|
masks[0, 1, 4:7, 4:7] = 1.0
|
|
masks[0, 2, :, :] = 1.0
|
|
masks[1, 0, :, :] = 1.0
|
|
masks[1, 1, 1:5, 1:5] = 1.0
|
|
masks[1, 2, 2:4, 2:5] = 1.0
|
|
scores = np.asarray(
|
|
[
|
|
[0.95, 0.94, 0.50],
|
|
[0.50, 0.70, 0.90],
|
|
],
|
|
dtype=np.float32,
|
|
)
|
|
|
|
selected = _select_sam_masks(masks, scores)
|
|
|
|
# The first face unions both proposals over 0.93. The second has none over
|
|
# 0.93, so it falls back to its single highest-IoU proposal.
|
|
assert np.count_nonzero(selected[0]) == 13
|
|
assert np.count_nonzero(selected[1]) == 6
|
|
assert selected[0][6, 6] == 255
|
|
assert selected[1][1, 1] == 0
|
|
|
|
|
|
def test_sam_bfloat16_outputs_convert_to_numpy_float32():
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _sam_outputs_to_numpy
|
|
|
|
masks = torch.ones((1, 2, 3, 4, 4), dtype=torch.bfloat16)
|
|
scores = torch.tensor([[[0.95, 0.75, 0.50], [0.99, 0.80, 0.60]]], dtype=torch.bfloat16)
|
|
|
|
mask_array, score_array = _sam_outputs_to_numpy(masks, scores)
|
|
|
|
assert mask_array.dtype == np.float32
|
|
assert score_array.dtype == np.float32
|
|
assert score_array[0, 0, 0] == pytest.approx(0.94921875)
|
|
|
|
|
|
def test_face_composite_preserves_every_pixel_outside_mask():
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import composite_face
|
|
|
|
base = np.full((32, 32, 3), 10, dtype=np.uint8)
|
|
detail = np.full((32, 32, 3), 240, dtype=np.uint8)
|
|
mask = np.zeros((32, 32), dtype=np.uint8)
|
|
mask[12:20, 12:20] = 255
|
|
|
|
result = composite_face(base, detail, mask, feather=0)
|
|
|
|
assert np.array_equal(result[:12], base[:12])
|
|
assert np.array_equal(result[:, :12], base[:, :12])
|
|
assert np.all(result[12:20, 12:20] == 240)
|
|
|
|
|
|
def test_profile_defaults_to_four_global_steps_and_a_fixed_seed():
|
|
"""The step count belongs to the stage, not to a caller-settable profile knob."""
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import GLOBAL_STEPS
|
|
from remove_ai_watermarks._internal.watermark_profiles import normalize_profile, resolve_seed
|
|
|
|
assert normalize_profile("qwen-zimage") == "qwen-zimage"
|
|
assert GLOBAL_STEPS == 4
|
|
assert resolve_seed(None) == 0
|
|
assert resolve_seed(17) == 17
|
|
|
|
|
|
def test_cli_exposes_qwen_zimage_profile():
|
|
from remove_ai_watermarks.cli import _PIPELINE_CHOICES
|
|
|
|
assert "qwen-zimage" in _PIPELINE_CHOICES
|
|
|
|
|
|
def test_cli_qwen_zimage_keeps_profile_postprocess_default(tmp_image_path, monkeypatch):
|
|
from remove_ai_watermarks import cli
|
|
|
|
mock_engine = MagicMock()
|
|
mock_engine.remove_watermark.return_value = tmp_image_path
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
|
|
|
|
result = CliRunner().invoke(
|
|
cli.main,
|
|
["invisible", str(tmp_image_path), "--pipeline", "qwen-zimage", "--force"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
# Both defaults are the profile's, resolved once by the library rather than
|
|
# pre-resolved here: the CLI passes them through unset so a library caller on the
|
|
# same profile gets the same answer.
|
|
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is None
|
|
assert mock_engine.remove_watermark.call_args.kwargs["seed"] is None
|
|
|
|
result = CliRunner().invoke(
|
|
cli.main,
|
|
[
|
|
"invisible",
|
|
str(tmp_image_path),
|
|
"--pipeline",
|
|
"qwen-zimage",
|
|
"--adaptive-polish",
|
|
"--force",
|
|
],
|
|
)
|
|
assert result.exit_code == 0, result.output
|
|
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is True
|
|
|
|
|
|
def test_cli_forwards_verified_text_manifest(tmp_image_path, tmp_path, monkeypatch):
|
|
from remove_ai_watermarks import cli
|
|
|
|
manifest = tmp_path / "manifest.json"
|
|
manifest.write_text("{}", encoding="utf-8")
|
|
mock_engine = MagicMock()
|
|
mock_engine.remove_watermark.return_value = tmp_image_path
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
|
|
|
|
result = CliRunner().invoke(
|
|
cli.main,
|
|
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert mock_engine.remove_watermark.call_args.kwargs["text_manifest"] == manifest
|
|
|
|
|
|
def test_cli_reports_verified_text_manifest_errors(tmp_image_path, tmp_path, monkeypatch):
|
|
from remove_ai_watermarks import cli
|
|
|
|
manifest = tmp_path / "manifest.json"
|
|
manifest.write_text("{}", encoding="utf-8")
|
|
mock_engine = MagicMock()
|
|
mock_engine.remove_watermark.side_effect = ValueError("manifest pixels do not match")
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.is_available", lambda: True)
|
|
monkeypatch.setattr("remove_ai_watermarks.invisible_engine.InvisibleEngine", MagicMock(return_value=mock_engine))
|
|
|
|
result = CliRunner().invoke(
|
|
cli.main,
|
|
["invisible", str(tmp_image_path), "--text-manifest", str(manifest), "--force"],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert "manifest pixels do not match" in result.output
|
|
|
|
|
|
def test_no_face_path_still_runs_verified_text_restoration(monkeypatch):
|
|
from remove_ai_watermarks._internal import text_restoration, two_stage_pipeline
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
|
|
|
pipeline = object.__new__(QwenZImagePipeline)
|
|
pipeline.device = "cuda"
|
|
pipeline.progress_callback = None
|
|
source = Image.new("RGB", (32, 32), (10, 20, 30))
|
|
donor = Image.new("RGB", (32, 32), (40, 50, 60))
|
|
global_result = Image.new("RGB", (32, 32), (70, 80, 90))
|
|
anchor = Image.new("RGB", (32, 32), (100, 110, 120))
|
|
restored = Image.new("RGB", (32, 32), (130, 140, 150))
|
|
pipeline._vae_roundtrip = MagicMock(return_value=donor)
|
|
pipeline._run_global = MagicMock(return_value=global_result)
|
|
monkeypatch.setattr(two_stage_pipeline, "detect_faces", lambda _image: [])
|
|
blend = MagicMock(return_value=anchor)
|
|
restore = MagicMock(return_value=restored)
|
|
monkeypatch.setattr(text_restoration, "blend_fidelity_anchor", blend)
|
|
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
|
|
manifest = VerifiedTextManifest(
|
|
"0" * 64,
|
|
32,
|
|
32,
|
|
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
|
)
|
|
|
|
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest)
|
|
|
|
assert result is restored
|
|
# Off by default since 0.27.1 (leak finding, docs/text-protection-research.md):
|
|
# no whole-frame donor blend; the raw global result feeds restoration.
|
|
blend.assert_not_called()
|
|
restore.assert_called_once_with(source, global_result, donor, manifest.lines)
|
|
|
|
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True)
|
|
|
|
assert result is restored
|
|
blend.assert_called_once_with(global_result, donor)
|
|
restore.assert_called_with(source, anchor, donor, manifest.lines)
|
|
|
|
|
|
def test_tiled_verified_text_is_rejected_before_model_work():
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
|
|
|
pipeline = object.__new__(QwenZImagePipeline)
|
|
pipeline.device = "cuda"
|
|
pipeline.progress_callback = None
|
|
source = Image.new("RGB", (96, 80), (10, 20, 30))
|
|
manifest = VerifiedTextManifest(
|
|
"0" * 64,
|
|
96,
|
|
80,
|
|
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="not calibrated with tiled diffusion"):
|
|
pipeline.run(
|
|
source,
|
|
strength=0.1,
|
|
seed=0,
|
|
tile=True,
|
|
tile_size=64,
|
|
tile_overlap=16,
|
|
text_manifest=manifest,
|
|
)
|
|
|
|
|
|
def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
output = tmp_path / "output.png"
|
|
Image.new("RGB", (64, 48), (20, 30, 40)).save(source)
|
|
|
|
runtime = MagicMock()
|
|
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
|
|
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
remover.remove_watermark(
|
|
source,
|
|
output,
|
|
)
|
|
|
|
runtime.run.assert_called_once()
|
|
_, kwargs = runtime.run.call_args
|
|
assert kwargs["strength"] == pytest.approx(0.084)
|
|
assert kwargs["seed"] == 0
|
|
assert kwargs["text_manifest"] is None
|
|
assert output.exists()
|
|
|
|
|
|
def test_watermark_remover_dispatches_qwen_tiling_to_full_pipeline(tmp_path, monkeypatch):
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
output = tmp_path / "output.png"
|
|
Image.new("RGB", (96, 80), (20, 30, 40)).save(source)
|
|
|
|
runtime = MagicMock()
|
|
runtime.run.return_value = Image.new("RGB", (96, 80), (50, 60, 70))
|
|
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
remover.remove_watermark(
|
|
source,
|
|
output,
|
|
seed=0,
|
|
tile=True,
|
|
tile_size=64,
|
|
tile_overlap=16,
|
|
)
|
|
|
|
runtime.run.assert_called_once()
|
|
_, kwargs = runtime.run.call_args
|
|
assert kwargs["seed"] == 0
|
|
assert kwargs["tile"] is True
|
|
assert kwargs["tile_size"] == 64
|
|
assert kwargs["tile_overlap"] == 16
|
|
assert output.exists()
|
|
|
|
|
|
def test_watermark_remover_rejects_tiled_verified_text_before_pipeline(tmp_path, monkeypatch):
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
Image.new("RGB", (96, 80), (20, 30, 40)).save(source)
|
|
manifest = VerifiedTextManifest(
|
|
"0" * 64,
|
|
96,
|
|
80,
|
|
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
|
)
|
|
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
|
|
runtime = MagicMock()
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
with pytest.raises(ValueError, match="not calibrated with tiled diffusion"):
|
|
remover.remove_watermark(source, text_manifest=manifest, tile=True)
|
|
|
|
runtime.run.assert_not_called()
|
|
|
|
|
|
def test_qwen_tiling_runs_global_tiles_then_one_full_frame_face_stage(monkeypatch):
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.tiling import plan_tiles
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import resolution_adaptive_denoise
|
|
|
|
image = Image.new("RGB", (1500, 1500), (20, 30, 40))
|
|
runtime = QwenZImagePipeline(device="cuda", torch_dtype="bf16")
|
|
monkeypatch.setattr(runtime, "_require_cuda", lambda: None)
|
|
|
|
global_calls = []
|
|
|
|
def fake_global(tile, strength, seed):
|
|
global_calls.append((tile.size, strength, seed))
|
|
return tile
|
|
|
|
face_stage = MagicMock(return_value=image)
|
|
monkeypatch.setattr(runtime, "_run_global", fake_global)
|
|
monkeypatch.setattr(runtime, "_run_faces", face_stage)
|
|
monkeypatch.setattr(
|
|
"remove_ai_watermarks._internal.two_stage_pipeline.detect_faces",
|
|
lambda _image: [(100, 100, 300, 300)],
|
|
)
|
|
monkeypatch.setattr(runtime, "_sam_masks", lambda _image, _boxes: [np.ones((1500, 1500), dtype=np.uint8)])
|
|
|
|
result = runtime.run(
|
|
image,
|
|
strength=None,
|
|
seed=0,
|
|
tile=True,
|
|
tile_size=1024,
|
|
tile_overlap=128,
|
|
)
|
|
|
|
expected_tiles = plan_tiles(1500, 1500, 1024, 128)
|
|
assert len(global_calls) == len(expected_tiles) == 4
|
|
assert all(size == (1024, 1024) for size, _strength, _seed in global_calls)
|
|
assert all(strength == pytest.approx(resolution_adaptive_denoise(1500, 1500)) for _, strength, _ in global_calls)
|
|
assert all(seed == 0 for _, _, seed in global_calls)
|
|
face_stage.assert_called_once()
|
|
assert face_stage.call_args.kwargs["strength"] == pytest.approx(0.0296296296)
|
|
assert face_stage.call_args.args[0] is image
|
|
assert face_stage.call_args.args[1].size == image.size
|
|
assert result.size == image.size
|
|
|
|
|
|
def test_global_only_preload_skips_face_models(monkeypatch):
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
|
|
runtime = QwenZImagePipeline(device="cuda", torch_dtype="bf16")
|
|
global_stage = MagicMock()
|
|
zimage = MagicMock()
|
|
sam = MagicMock()
|
|
yunet = MagicMock()
|
|
monkeypatch.setattr(runtime, "_load_global", global_stage)
|
|
monkeypatch.setattr(runtime, "_load_zimage", zimage)
|
|
monkeypatch.setattr(runtime, "_load_sam", sam)
|
|
monkeypatch.setattr(
|
|
"remove_ai_watermarks._internal.two_stage_pipeline._yunet_model_path",
|
|
yunet,
|
|
)
|
|
|
|
runtime.preload(global_only=True)
|
|
|
|
global_stage.assert_called_once_with()
|
|
zimage.assert_not_called()
|
|
sam.assert_not_called()
|
|
yunet.assert_called_once_with()
|
|
|
|
|
|
def test_full_preload_still_loads_face_models(monkeypatch):
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
|
|
runtime = QwenZImagePipeline(device="cuda", torch_dtype="bf16")
|
|
global_stage = MagicMock()
|
|
zimage = MagicMock()
|
|
sam = MagicMock()
|
|
yunet = MagicMock()
|
|
monkeypatch.setattr(runtime, "_load_global", global_stage)
|
|
monkeypatch.setattr(runtime, "_load_zimage", zimage)
|
|
monkeypatch.setattr(runtime, "_load_sam", sam)
|
|
monkeypatch.setattr(
|
|
"remove_ai_watermarks._internal.two_stage_pipeline._yunet_model_path",
|
|
yunet,
|
|
)
|
|
|
|
runtime.preload()
|
|
|
|
global_stage.assert_called_once_with()
|
|
zimage.assert_called_once_with()
|
|
sam.assert_called_once_with()
|
|
yunet.assert_called_once_with()
|
|
|
|
|
|
def test_watermark_remover_forwards_global_only_preload(monkeypatch):
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
runtime = MagicMock()
|
|
remover = WatermarkRemover.__new__(WatermarkRemover)
|
|
remover.model_profile = "qwen-zimage"
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
remover.preload(global_only=True)
|
|
|
|
runtime.preload.assert_called_once_with(global_only=True)
|
|
|
|
|
|
def test_the_fixed_graph_offers_no_runtime_knob_to_reject(tmp_path, monkeypatch):
|
|
"""model_id, steps and CFG are not parameters at any layer.
|
|
|
|
They used to be accepted and then rejected, which put the failure several frames
|
|
below the caller and made the surface advertise choices the pinned stack cannot
|
|
honor. TypeError from the signature is the earlier, clearer answer -- and it is
|
|
what keeps a wrapper from threading a value that would silently do nothing.
|
|
"""
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
with pytest.raises(TypeError):
|
|
WatermarkRemover(model_id="custom/model", device="cuda", pipeline="qwen-zimage") # type: ignore[call-arg]
|
|
|
|
source = tmp_path / "source.png"
|
|
Image.new("RGB", (64, 48)).save(source)
|
|
remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage")
|
|
with pytest.raises(TypeError):
|
|
remover.remove_watermark(source, guidance_scale=2.0) # type: ignore[call-arg]
|
|
with pytest.raises(TypeError):
|
|
remover.remove_watermark(source, num_inference_steps=8) # type: ignore[call-arg]
|
|
|
|
|
|
def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path, tmp_path):
|
|
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
|
|
|
engine = InvisibleEngine.__new__(InvisibleEngine)
|
|
engine._progress_callback = None
|
|
engine._remover = MagicMock(model_profile="qwen-zimage")
|
|
engine._remover.remove_watermark.return_value = tmp_path / "clean.png"
|
|
|
|
engine.remove_watermark(tmp_image_path, tmp_path / "clean.png")
|
|
|
|
kwargs = engine._remover.remove_watermark.call_args.kwargs
|
|
assert kwargs["seed"] == 0
|
|
assert "num_inference_steps" not in kwargs
|
|
assert "guidance_scale" not in kwargs
|
|
|
|
|
|
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
|
|
"""An SDXL global pass needs more strength than Qwen, so it gets its own policy."""
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
|
QWEN_ZIMAGE_OPENAI_STRENGTH,
|
|
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
|
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
|
resolve_strength,
|
|
)
|
|
|
|
assert resolve_strength(None, "openai", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_OPENAI_STRENGTH)
|
|
assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
|
# Unknown provenance takes the stricter of the two.
|
|
assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
|
# An explicit value still wins. qwen-zimage's unknown cohort keeps the curve,
|
|
# while measured providers take their flat operating points.
|
|
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
|
|
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
|
QWEN_ZIMAGE_OPENAI_STRENGTH
|
|
)
|
|
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
|
QWEN_ZIMAGE_GOOGLE_STRENGTH
|
|
)
|
|
|
|
|
|
def test_sdxl_zimage_shares_the_fixed_seed_contract():
|
|
from remove_ai_watermarks._internal.watermark_profiles import normalize_profile, resolve_seed
|
|
|
|
assert normalize_profile("sdxl_zimage") == "sdxl-zimage"
|
|
assert resolve_seed(None) == 0
|
|
|
|
|
|
def test_sdxl_requested_steps_compensate_for_the_diffusers_truncation():
|
|
"""Diffusers truncates the step COUNT where DiffSynth truncates the sigma range.
|
|
|
|
Asking Diffusers for four steps at strength 0.15 runs int(4 * 0.15) = 0 and
|
|
returns a bare VAE round-trip, so the request has to be scaled up instead.
|
|
"""
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import requested_steps
|
|
|
|
for strength in (0.15, 0.25, 0.0896):
|
|
steps = requested_steps(4, strength)
|
|
assert int(steps * strength) >= 4
|
|
# Naively asking for four would have under-spent every time, and at the
|
|
# strengths this profile actually uses it would have run nothing at all.
|
|
assert int(4 * strength) < 4
|
|
assert int(4 * 0.15) == 0
|
|
|
|
|
|
def test_sdxl_zimage_floors_to_its_own_latent_grid():
|
|
"""SDXL aligns to 8 pixels where Qwen aligns to 16."""
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import sdxl_target_size
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _target_size
|
|
|
|
assert sdxl_target_size(1122, 1402) == (1120, 1400)
|
|
assert _target_size(1122, 1402) == (1120, 1392)
|
|
assert sdxl_target_size(3, 3) == (8, 8)
|
|
|
|
|
|
def test_sdxl_zimage_inherits_the_face_stage_rather_than_copying_it():
|
|
"""The face stage must not be able to diverge between profiles.
|
|
|
|
Both profiles are siblings of the shared two-stage base, not a chain: an SDXL or
|
|
Flux profile must never inherit from the Qwen profile, and the shared stages live
|
|
on the base so a change there reaches every profile at once.
|
|
"""
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import SdxlZImagePipeline
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import TwoStageZImagePipeline
|
|
|
|
assert issubclass(SdxlZImagePipeline, TwoStageZImagePipeline)
|
|
assert issubclass(QwenZImagePipeline, TwoStageZImagePipeline)
|
|
assert QwenZImagePipeline not in SdxlZImagePipeline.__mro__
|
|
for shared in ("_run_faces", "_sam_masks", "_load_zimage", "_load_sam", "run", "preload"):
|
|
base_method = getattr(TwoStageZImagePipeline, shared)
|
|
assert getattr(SdxlZImagePipeline, shared) is base_method
|
|
assert getattr(QwenZImagePipeline, shared) is base_method
|
|
# Only the global stage and what it needs may differ.
|
|
assert SdxlZImagePipeline._run_global is not QwenZImagePipeline._run_global
|
|
assert SdxlZImagePipeline._load_global is not QwenZImagePipeline._load_global
|
|
|
|
|
|
def test_chroma_zimage_strength_uses_measured_flat_floors():
|
|
"""The chroma floors come from the four-cohort oracle calibration; they must not
|
|
drift from their derivation in watermark_profiles."""
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
|
CHROMA_ZIMAGE_META_STRENGTH,
|
|
CHROMA_ZIMAGE_MICROSOFT_STRENGTH,
|
|
CHROMA_ZIMAGE_OPENAI_STRENGTH,
|
|
CHROMA_ZIMAGE_UNKNOWN_STRENGTH,
|
|
normalize_profile,
|
|
resolve_strength,
|
|
)
|
|
|
|
assert normalize_profile("chroma_zimage") == "chroma-zimage"
|
|
assert resolve_strength(None, "openai", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_OPENAI_STRENGTH)
|
|
assert pytest.approx(0.09) == CHROMA_ZIMAGE_OPENAI_STRENGTH
|
|
assert resolve_strength(None, "microsoft", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_MICROSOFT_STRENGTH)
|
|
assert pytest.approx(0.125) == CHROMA_ZIMAGE_MICROSOFT_STRENGTH
|
|
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_GOOGLE_STRENGTH)
|
|
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
|
assert resolve_strength(None, "meta", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_META_STRENGTH)
|
|
assert pytest.approx(0.17) == CHROMA_ZIMAGE_META_STRENGTH
|
|
# Unknown follows the strictest measured cohort, as with the other profiles.
|
|
assert resolve_strength(None, None, "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
|
|
assert CHROMA_ZIMAGE_UNKNOWN_STRENGTH == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
|
# An explicit strength still wins, and other profiles are untouched.
|
|
assert resolve_strength(0.2, "google", "chroma-zimage") == pytest.approx(0.2)
|
|
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.07675)
|
|
|
|
|
|
def test_chroma_zimage_inherits_the_shared_stages_and_only_swaps_the_global():
|
|
"""Like sdxl-zimage: a sibling of the base, never a chain, with the shared
|
|
stages inherited verbatim."""
|
|
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
|
|
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import TwoStageZImagePipeline
|
|
|
|
assert issubclass(ChromaZImagePipeline, TwoStageZImagePipeline)
|
|
assert QwenZImagePipeline not in ChromaZImagePipeline.__mro__
|
|
for shared in ("_run_faces", "_sam_masks", "_load_zimage", "_load_sam", "run", "preload"):
|
|
base_method = getattr(TwoStageZImagePipeline, shared)
|
|
assert getattr(ChromaZImagePipeline, shared) is base_method
|
|
assert ChromaZImagePipeline._run_global is not QwenZImagePipeline._run_global
|
|
assert ChromaZImagePipeline._load_global is not QwenZImagePipeline._load_global
|
|
assert ChromaZImagePipeline.profile_name == "chroma-zimage"
|
|
|
|
|
|
def test_chroma_requested_steps_compensate_for_the_diffusers_truncation():
|
|
from remove_ai_watermarks._internal.chroma_zimage_pipeline import CHROMA_STEPS, requested_steps
|
|
|
|
for strength in (0.09, 0.125, 0.17, 0.40):
|
|
steps = requested_steps(CHROMA_STEPS, strength)
|
|
assert int(steps * strength) >= CHROMA_STEPS
|
|
assert int(CHROMA_STEPS * strength) < CHROMA_STEPS
|
|
|
|
|
|
def test_chroma_zimage_uses_its_own_calibrated_prompts():
|
|
"""The floors were measured with the neutral faithful prompt, not the shared
|
|
canny-stage prompt; swapping them silently moves the oracle boundaries."""
|
|
from remove_ai_watermarks._internal import chroma_zimage_pipeline as chroma_mod
|
|
from remove_ai_watermarks._internal.two_stage_pipeline import _GLOBAL_PROMPT
|
|
|
|
assert chroma_mod.CHROMA_PROMPT != _GLOBAL_PROMPT
|
|
assert chroma_mod.CHROMA_PROMPT == "high quality, sharp, detailed, faithful to the original"
|
|
assert chroma_mod.CHROMA_GUIDANCE == 5.0
|
|
assert chroma_mod.CHROMA_STEPS == 4
|
|
|
|
|
|
def test_chroma_global_stage_calls_diffusers_with_the_calibrated_shape(monkeypatch):
|
|
"""The calibrated call shape must not drift: prompt, guidance, width/height
|
|
on the /16 grid, effective-step count, and no Canny conditioning. A silent
|
|
parameter change moves the oracle floors without any test seeing it."""
|
|
# CI's base job installs the library without the qwen-zimage extra.
|
|
diffusers = pytest.importorskip("diffusers")
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.chroma_zimage_pipeline import (
|
|
CHROMA_GUIDANCE,
|
|
ChromaZImagePipeline,
|
|
)
|
|
|
|
calls: dict[str, dict] = {}
|
|
|
|
def fake_chroma(*_args, **kwargs):
|
|
calls["chroma"] = kwargs
|
|
from PIL import Image
|
|
|
|
return type("R", (), {"images": [Image.new("RGB", (kwargs["width"], kwargs["height"]))]})()
|
|
|
|
class FakeChromaPipe:
|
|
text_encoder = object() # truthy: the encoder is loaded
|
|
|
|
def to(self, _device):
|
|
return self
|
|
|
|
@staticmethod
|
|
def encode_prompt(*args, **kwargs):
|
|
import torch
|
|
|
|
n = 8 # sequence length stub
|
|
embed = torch.zeros(1, n, 64) # prompt embeds stub
|
|
neg_embed = torch.zeros(1, n, 64)
|
|
mask = torch.ones(1, n, dtype=torch.long)
|
|
neg_mask = torch.ones(1, n, dtype=torch.long)
|
|
return embed, None, mask, neg_embed, None, neg_mask
|
|
|
|
@staticmethod
|
|
def __call__(*args, **kwargs):
|
|
return fake_chroma(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(
|
|
diffusers.ChromaImg2ImgPipeline,
|
|
"from_pretrained",
|
|
staticmethod(lambda *a, **k: FakeChromaPipe()),
|
|
)
|
|
|
|
# _run_global creates a torch.Generator(device="cuda"), which needs a real
|
|
# CUDA library; patch the module's torch reference to yield a CPU-safe stub.
|
|
class FakeGenerator:
|
|
def manual_seed(self, _seed):
|
|
return self
|
|
|
|
monkeypatch.setattr("torch.Generator", lambda device=None: FakeGenerator())
|
|
|
|
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.bfloat16)
|
|
monkeypatch.setattr(type(pipeline), "_require_cuda", lambda self: None)
|
|
result = pipeline._run_global(Image.new("RGB", (1122, 1402)), 0.09, 0)
|
|
|
|
assert result.size == (1122, 1402)
|
|
call = calls["chroma"]
|
|
# The calibrated prompt is cached on first encode; every subsequent call
|
|
# passes prompt_embeds (not the raw prompt string), skipping the T5 inference.
|
|
assert "prompt" not in call, "cached path must not send the raw prompt"
|
|
assert "prompt_embeds" in call, "cached path must pass pre-computed embeddings"
|
|
assert "negative_prompt_embeds" in call
|
|
# The negative prompt is baked into the cached embeddings too.
|
|
assert call["guidance_scale"] == CHROMA_GUIDANCE
|
|
# The /16 latent grid, not the raw input size.
|
|
assert call["width"] == 1120
|
|
assert call["height"] == 1392
|
|
# Effective-step compensation: ceil(4 / 0.09) = 45 requested.
|
|
assert call["num_inference_steps"] == 45
|
|
assert call["strength"] == 0.09
|
|
# No Canny conditioning: the calibrated path is a plain strength pass.
|
|
assert "control_image" not in call
|
|
assert "controlnet_conditioning_scale" not in call
|
|
|
|
|
|
def test_chroma_zimage_floors_to_its_own_latent_grid():
|
|
"""Chroma1 floors to /16, the same grid as Qwen but unlike SDXL's /8."""
|
|
from remove_ai_watermarks._internal.chroma_zimage_pipeline import chroma_target_size
|
|
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import sdxl_target_size
|
|
|
|
assert chroma_target_size(1122, 1402) == (1120, 1392)
|
|
assert sdxl_target_size(1122, 1402) == (1120, 1400)
|
|
assert chroma_target_size(3, 3) == (16, 16)
|
|
|
|
|
|
def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_path, monkeypatch):
|
|
"""Chroma owns a VAE donor; SDXL still fails at the public boundary."""
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
Image.new("RGB", (96, 80)).save(source)
|
|
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
|
|
|
|
chroma = WatermarkRemover(device="cuda", pipeline="chroma-zimage")
|
|
chroma_runtime = MagicMock()
|
|
chroma_runtime.run.return_value = Image.new("RGB", (96, 80))
|
|
monkeypatch.setattr(chroma, "_load_qwen_zimage_pipeline", lambda: chroma_runtime)
|
|
|
|
chroma.remove_watermark(source, text_manifest=manifest)
|
|
|
|
assert chroma_runtime.run.call_count == 1
|
|
assert chroma_runtime.run.call_args.kwargs["text_manifest"] is manifest
|
|
with pytest.raises(ValueError, match=r"fidelity anchor.*qwen-zimage"):
|
|
chroma.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
|
|
assert chroma_runtime.run.call_count == 1
|
|
|
|
sdxl = WatermarkRemover(device="cuda", pipeline="sdxl-zimage")
|
|
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
|
|
sdxl.remove_watermark(source, text_manifest=manifest)
|
|
|
|
|
|
def test_watermark_remover_dispatches_to_chroma_pipeline(monkeypatch):
|
|
"""The remover must construct ChromaZImagePipeline, not fall through to qwen."""
|
|
from remove_ai_watermarks._internal import chroma_zimage_pipeline as chroma_module
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class Recorder:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
|
|
monkeypatch.setattr(chroma_module, "ChromaZImagePipeline", Recorder)
|
|
|
|
remover = WatermarkRemover.__new__(WatermarkRemover)
|
|
remover.model_profile = "chroma-zimage"
|
|
remover.device = "cuda"
|
|
remover.torch_dtype = None
|
|
remover.hf_token = None
|
|
remover._progress_callback = None
|
|
remover.controlnet_conditioning_scale = 1.0
|
|
remover.cpu_offload = False
|
|
remover._qwen_zimage_pipeline = None
|
|
|
|
remover._load_qwen_zimage_pipeline()
|
|
|
|
assert captured["device"] == "cuda"
|
|
assert captured["hf_token"] is None
|
|
|
|
|
|
def test_chroma_adaptive_polish_defaults_off():
|
|
"""Like qwen-zimage: Chroma1's output already matches the input's detail
|
|
level, so polishing would add grain to an already-faithful output."""
|
|
from remove_ai_watermarks._internal.watermark_profiles import resolve_adaptive_polish
|
|
|
|
assert resolve_adaptive_polish(None, "chroma-zimage") is False
|
|
assert resolve_adaptive_polish(True, "chroma-zimage") is True
|
|
|
|
|
|
def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(monkeypatch):
|
|
"""The Chroma donor pads without distorting geometry and never samples."""
|
|
import torch
|
|
|
|
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
|
|
|
|
seen: dict[str, object] = {}
|
|
latent = torch.ones(1, 4, 4, 6)
|
|
|
|
class LatentDistribution:
|
|
def mode(self):
|
|
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
|
|
return latent
|
|
|
|
def sample(self, *_args, **_kwargs):
|
|
raise AssertionError("A verified-text donor must not sample VAE latents")
|
|
|
|
class FakeVae:
|
|
@staticmethod
|
|
def encode(tensor):
|
|
seen["encoded_shape"] = tuple(tensor.shape)
|
|
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
|
|
|
|
@staticmethod
|
|
def decode(received, *, return_dict):
|
|
assert received is latent
|
|
assert return_dict is False
|
|
return (torch.zeros(1, 3, 32, 48),)
|
|
|
|
class FakeProcessor:
|
|
@staticmethod
|
|
def preprocess(image, *, height, width):
|
|
seen["preprocess"] = (image.size, height, width)
|
|
return torch.zeros(1, 3, height, width)
|
|
|
|
@staticmethod
|
|
def postprocess(decoded, *, output_type):
|
|
assert output_type == "pil"
|
|
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
|
|
|
|
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
|
|
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.float32)
|
|
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
|
|
|
|
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
|
|
|
|
assert seen["preprocess"] == ((48, 32), 32, 48)
|
|
assert seen["encoded_shape"] == (1, 3, 32, 48)
|
|
assert seen["mode_calls"] == 1
|
|
assert result.size == (35, 19)
|
|
|
|
|
|
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
|
|
"""Face content on Google SynthID clears at 0.125, not 0.40.
|
|
|
|
The four-fixture calibration showed a clean face-count split: both zero-face
|
|
text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm
|
|
saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content.
|
|
"""
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH,
|
|
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
|
resolve_strength,
|
|
)
|
|
|
|
assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
|
|
# Face content gets the lower adaptive floor.
|
|
assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125)
|
|
assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125)
|
|
# Zero-face content gets the flat floor (text/graphic cohort).
|
|
assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40)
|
|
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
|
# No face_count hint: conservative (flat floor).
|
|
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40)
|
|
# Other vendors ignore face_count: the split is Google-SynthID-specific.
|
|
assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.09)
|
|
assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17)
|
|
# An explicit strength still wins over the adaptive arm.
|
|
assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20)
|
|
# Other profiles are untouched by the face_count parameter.
|
|
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)
|
|
|
|
|
|
def test_auto_profile_routes_to_the_measured_engine_per_vendor():
|
|
"""--pipeline auto picks the engine the four-cohort calibration supports:
|
|
chroma-zimage for OpenAI/Microsoft, qwen-zimage for Google/Meta/unknown."""
|
|
from remove_ai_watermarks._internal.watermark_profiles import (
|
|
CHROMA_ZIMAGE_PROFILE,
|
|
QWEN_ZIMAGE_PROFILE,
|
|
normalize_profile,
|
|
resolve_auto_profile,
|
|
)
|
|
|
|
assert normalize_profile("auto") == "auto"
|
|
assert resolve_auto_profile("openai") == CHROMA_ZIMAGE_PROFILE
|
|
assert resolve_auto_profile("microsoft") == CHROMA_ZIMAGE_PROFILE
|
|
assert resolve_auto_profile("google") == QWEN_ZIMAGE_PROFILE
|
|
assert resolve_auto_profile("meta") == QWEN_ZIMAGE_PROFILE
|
|
assert resolve_auto_profile(None) == QWEN_ZIMAGE_PROFILE
|
|
assert resolve_auto_profile("unknown") == QWEN_ZIMAGE_PROFILE
|
|
|
|
|
|
def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
|
|
"""After auto resolves to chroma-zimage for an OpenAI file, the strength
|
|
must be chroma's 0.09 floor, not qwen's 0.07675."""
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
Image.new("RGB", (64, 48)).save(source)
|
|
|
|
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
|
assert remover.model_profile == "auto"
|
|
|
|
runtime = MagicMock()
|
|
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
remover.remove_watermark(source, vendor="openai")
|
|
|
|
# Auto resolved to chroma-zimage for the OpenAI vendor.
|
|
assert remover.model_profile == "chroma-zimage"
|
|
_, kwargs = runtime.run.call_args
|
|
assert kwargs["strength"] == pytest.approx(0.09)
|
|
|
|
|
|
def test_auto_profile_text_manifest_uses_the_measured_chroma_engine_once(tmp_path, monkeypatch):
|
|
"""A manifest does not override the researched OpenAI-to-Chroma decision."""
|
|
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
|
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
|
|
|
_mock_watermark_runtime_deps(monkeypatch)
|
|
source = tmp_path / "source.png"
|
|
Image.new("RGB", (96, 80)).save(source)
|
|
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
|
|
|
|
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
|
runtime = MagicMock()
|
|
runtime.run.return_value = Image.new("RGB", (96, 80))
|
|
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
|
|
|
remover.remove_watermark(source, vendor="openai", text_manifest=manifest)
|
|
|
|
assert remover.model_profile == "chroma-zimage"
|
|
assert runtime.run.call_count == 1
|
|
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
|