mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-07 06:28:36 +02:00
Finish CPU offload support on current main
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
"""Tests for the standalone structural AI-generation scorer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import ai_score
|
||||
|
||||
|
||||
def _complete_record() -> dict[str, Any]:
|
||||
return {
|
||||
"noise": {"noise_std": 1.0, "noise_kurtosis": 2.0},
|
||||
"fft": {
|
||||
"cfa_peak": 3.0,
|
||||
"cfa_peaks": [2.5, 3.0],
|
||||
"fft_band_energy": list(range(8)),
|
||||
},
|
||||
"ela": {"ela_mean": 4.0, "ela_p95": 5.0},
|
||||
"gradient": {"laplacian_var": 6.0, "gradient_hist": list(range(10))},
|
||||
"color": {
|
||||
"saturation_mean": 0.5,
|
||||
"value_mean": 0.75,
|
||||
"color_hist_4x4x4": list(range(64)),
|
||||
},
|
||||
"dct": {
|
||||
"benford_mad": 0.1,
|
||||
"dct_ac_hist": [[1] * 21 for _ in range(8)],
|
||||
},
|
||||
"jpeg_forensics": {
|
||||
"subsampling": "4:4:4",
|
||||
"progressive": True,
|
||||
"quant_tables": {
|
||||
"0": list(range(1, 65)),
|
||||
"1": list(range(65, 129)),
|
||||
},
|
||||
"huffman_tables_hex": ["00ff", "abcd12"],
|
||||
"scan_count": 10,
|
||||
"restart_interval": 4,
|
||||
"precision_bits": 8,
|
||||
"adobe_transform": 1,
|
||||
"jfif": {"version": "1.1"},
|
||||
},
|
||||
"pil": {"width": 2000, "height": 1000},
|
||||
"content_format": "jpeg",
|
||||
}
|
||||
|
||||
|
||||
def test_v1_feature_schema_is_fixed_for_sparse_records() -> None:
|
||||
assert len(ai_score.feature_names("v1")) == 97
|
||||
assert len(ai_score.features_of({}, schema="v1")) == 97
|
||||
|
||||
|
||||
def test_v2_feature_schema_includes_existing_forensic_data() -> None:
|
||||
record = _complete_record()
|
||||
|
||||
names = ai_score.feature_names("v2")
|
||||
values = ai_score.features_of(record, schema="v2")
|
||||
by_name = dict(zip(names, values, strict=True))
|
||||
|
||||
assert len(names) == len(values) == 406
|
||||
assert by_name["cfa_peak_0"] == 2.5
|
||||
assert by_name["cfa_peak_1"] == 3.0
|
||||
assert by_name["dct_ac_0_0"] == 1 / 21
|
||||
assert by_name["dct_ac_7_20"] == 1 / 21
|
||||
assert by_name["jpeg_quant_0_0"] == 1.0
|
||||
assert by_name["jpeg_quant_1_63"] == 128.0
|
||||
assert by_name["jpeg_quant_table_count"] == 2.0
|
||||
assert by_name["jpeg_huffman_table_count"] == 2.0
|
||||
assert by_name["jpeg_huffman_total_bytes"] == 5.0
|
||||
assert by_name["jpeg_scan_count"] == 10.0
|
||||
assert by_name["jpeg_jfif_present"] == 1.0
|
||||
assert by_name["format_webp"] == 0.0
|
||||
assert by_name["format_isobmff"] == 0.0
|
||||
assert by_name["format_other"] == 0.0
|
||||
|
||||
|
||||
def test_v2_feature_schema_is_fixed_when_forensics_are_missing() -> None:
|
||||
names = ai_score.feature_names("v2")
|
||||
values = ai_score.features_of({}, schema="v2")
|
||||
|
||||
assert len(names) == len(values) == 406
|
||||
assert np.isnan(values[names.index("dct_ac_0_0")])
|
||||
assert np.isnan(values[names.index("jpeg_quant_0_0")])
|
||||
assert np.isnan(values[names.index("jpeg_scan_count")])
|
||||
|
||||
|
||||
def test_grouped_stratified_split_keeps_hashes_on_one_side() -> None:
|
||||
labels = np.asarray([1, 1, 1, 0, 0, 0, 1, 0])
|
||||
hashes = np.asarray(["a", "a", "b", "c", "c", "d", "e", "f"])
|
||||
|
||||
train, test = ai_score.grouped_stratified_split(labels, hashes, test_size=0.5, random_state=7)
|
||||
|
||||
assert set(hashes[train]).isdisjoint(set(hashes[test]))
|
||||
assert set(labels[train]) == {0, 1}
|
||||
assert set(labels[test]) == {0, 1}
|
||||
assert sorted(np.concatenate([train, test]).tolist()) == list(range(len(labels)))
|
||||
|
||||
|
||||
def test_grouped_stratified_split_rejects_conflicting_labels() -> None:
|
||||
labels = np.asarray([0, 1, 0, 1])
|
||||
hashes = np.asarray(["same", "same", "negative", "positive"])
|
||||
|
||||
with np.testing.assert_raises_regex(ValueError, "conflicting labels"):
|
||||
ai_score.grouped_stratified_split(labels, hashes)
|
||||
|
||||
|
||||
def test_temporal_holdout_excludes_hashes_seen_during_training() -> None:
|
||||
dates = np.asarray(["2026-01-01", "2026-01-01", "2026-01-02", "2026-01-03", "2026-01-04", "2026-01-04"])
|
||||
hashes = np.asarray(["repeated", "old", "middle", "new-a", "repeated", "new-b"])
|
||||
|
||||
train, test, cutoff = ai_score.temporal_holdout_split(dates, hashes, train_fraction=0.5)
|
||||
|
||||
assert cutoff == "2026-01-03"
|
||||
assert set(hashes[train]).isdisjoint(set(hashes[test]))
|
||||
assert set(hashes[test]) == {"new-a", "new-b"}
|
||||
|
||||
|
||||
def test_legacy_model_bundle_defaults_to_v1_schema() -> None:
|
||||
assert ai_score.model_schema({}) == "v1"
|
||||
assert ai_score.model_schema({"feature_schema": "v2"}) == "v2"
|
||||
@@ -299,6 +299,22 @@ class TestInvisibleCommand:
|
||||
assert output.exists()
|
||||
mock_engine.remove_watermark.assert_called_once()
|
||||
|
||||
def test_invisible_cpu_offload_flows_to_engine(self, runner, sample_png, tmp_path):
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["invisible", str(sample_png), "-o", str(output), "--cpu-offload", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_cls.call_args.kwargs["cpu_offload"] is True
|
||||
|
||||
def test_invisible_default_output(self, runner, sample_png):
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
@@ -456,6 +472,22 @@ class TestAllCommand:
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
|
||||
def test_all_cpu_offload_flows_to_engine(self, runner, sample_png, tmp_path):
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["all", str(sample_png), "-o", str(output), "--cpu-offload", "--force"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_cls.call_args.kwargs["cpu_offload"] is True
|
||||
|
||||
def test_all_nonexistent_file(self, runner):
|
||||
result = runner.invoke(main, ["all", "/nonexistent/file.png"])
|
||||
assert result.exit_code != 0
|
||||
@@ -718,6 +750,33 @@ class TestBatchCommand:
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "3 processed" in result.output
|
||||
|
||||
def test_batch_cpu_offload_flows_to_cached_engine(self, runner, tmp_path):
|
||||
input_dir = _make_batch_dir(tmp_path)
|
||||
output_dir = tmp_path / "output"
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"batch",
|
||||
str(input_dir),
|
||||
"-o",
|
||||
str(output_dir),
|
||||
"--mode",
|
||||
"invisible",
|
||||
"--cpu-offload",
|
||||
"--force",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert mock_cls.call_args.kwargs["cpu_offload"] is True
|
||||
|
||||
def test_batch_invisible_skips_no_signal_and_copies_through(self, runner, tmp_path):
|
||||
"""P0#5: batch invisible mode skips the scrub on signal-less images (no
|
||||
--force) and copies the input through, so the output dir is complete with the
|
||||
|
||||
+30
-11
@@ -1,12 +1,9 @@
|
||||
"""Unit tests for the --cpu-offload device-placement branch (mocked pipeline).
|
||||
"""Unit tests for the --cpu-offload device-placement branch.
|
||||
|
||||
``WatermarkRemover._move_to_device_and_optimize`` chooses between a full
|
||||
``pipeline.to("cuda")`` and ``enable_model_cpu_offload()`` (low-VRAM streaming).
|
||||
Constructing the remover is cheap -- the diffusion pipeline is lazy and the
|
||||
device string is not validated -- so the placement decision is exercised with a
|
||||
mock pipeline, no model download or GPU required. Gated on torch (the module
|
||||
imports it at top), so it runs under the ``gpu`` extra and skips the core CI
|
||||
matrix, matching the model-running test policy.
|
||||
``pipeline.to("cuda")`` and ``enable_model_cpu_offload()``. The placement
|
||||
decision is exercised with a mock pipeline and an uninitialized remover, so the
|
||||
core CI matrix needs no diffusion dependency, model download, or GPU.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,13 +12,15 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("torch")
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
|
||||
def _remover(device: str, cpu_offload: bool) -> WatermarkRemover:
|
||||
return WatermarkRemover(device=device, pipeline="sdxl", cpu_offload=cpu_offload)
|
||||
remover = WatermarkRemover.__new__(WatermarkRemover)
|
||||
remover.device = device
|
||||
remover.cpu_offload = cpu_offload
|
||||
remover._progress_callback = None
|
||||
return remover
|
||||
|
||||
|
||||
class TestCpuOffloadPlacement:
|
||||
@@ -31,7 +30,7 @@ class TestCpuOffloadPlacement:
|
||||
|
||||
returned = remover._move_to_device_and_optimize(pipeline)
|
||||
|
||||
pipeline.enable_model_cpu_offload.assert_called_once_with()
|
||||
pipeline.enable_model_cpu_offload.assert_called_once_with(device="cuda")
|
||||
pipeline.to.assert_not_called()
|
||||
# Offload leaves the pipeline object in place (accelerate hooks handle it).
|
||||
assert returned is pipeline
|
||||
@@ -54,3 +53,23 @@ class TestCpuOffloadPlacement:
|
||||
|
||||
pipeline.to.assert_called_once_with("cpu")
|
||||
pipeline.enable_model_cpu_offload.assert_not_called()
|
||||
|
||||
def test_offload_fails_loudly_when_pipeline_lacks_support(self):
|
||||
remover = _remover("cuda", cpu_offload=True)
|
||||
pipeline = Mock(spec=["to"])
|
||||
|
||||
with pytest.raises(RuntimeError, match="does not support"):
|
||||
remover._move_to_device_and_optimize(pipeline)
|
||||
|
||||
pipeline.to.assert_not_called()
|
||||
|
||||
def test_qwen_zimage_forces_face_stack_offload(self):
|
||||
remover = _remover("cuda", cpu_offload=True)
|
||||
remover.torch_dtype = object()
|
||||
remover.hf_token = None
|
||||
remover.controlnet_conditioning_scale = 1.0
|
||||
remover._qwen_zimage_pipeline = None
|
||||
|
||||
runtime = remover._load_qwen_zimage_pipeline()
|
||||
|
||||
assert runtime.keep_face_models_on_device is False
|
||||
|
||||
@@ -171,6 +171,23 @@ class TestIdentifyNonPng:
|
||||
assert r.platform == "ElevenLabs"
|
||||
assert not any("SynthID" in w for w in r.watermarks) # ElevenLabs does not use SynthID
|
||||
|
||||
def test_fal_ai_attributed(self, tmp_path: Path):
|
||||
# fal.ai signs as "fal - Features & Labels Inc." with a "fal-ai/<model>"
|
||||
# claim generator; corpus-measured 2026-07-23 (17 files).
|
||||
path = self._c2pa_jpeg(tmp_path, b"fal - Features & Labels Inc. fal-ai/seedvr trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "fal.ai"
|
||||
|
||||
def test_bria_attributed_without_source_type(self, tmp_path: Path):
|
||||
# Bria signs as "Bria Artificial Intelligence" with source type ``empty``
|
||||
# (NO trainedAlgorithmicMedia) -- a pure-generator asserts_ai vendor, so
|
||||
# the issuer/generator strings alone must flag AI. Corpus-found 2026-07-23.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Bria Artificial Intelligence Bria Ai c2pa.created c2pa.edited")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Bria AI"
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
@@ -285,6 +302,20 @@ class TestIdentifyRealSamples:
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_apple_clean_up_attributed(self, tmp_path: Path):
|
||||
# Apple Photos Clean Up (Apple Intelligence object removal) marks the
|
||||
# AI edit via photoshop:Credit next to compositeWithTrainedAlgorithmicMedia
|
||||
# -- it must be attributed, not reported as a generic made-with-AI tag.
|
||||
# Corpus-measured 2026-07-23 (35 files).
|
||||
p = tmp_path / "apple_cleanup.jpg"
|
||||
p.write_bytes(
|
||||
b'\xff\xd8\xff\xe1<x:xmpmeta photoshop:Credit="Apple Photos Clean Up" '
|
||||
b"Iptc4xmpExt:DigitalSourceType=compositeWithTrainedAlgorithmicMedia></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Apple Photos (Clean Up AI edit)"
|
||||
|
||||
def test_flux_bfl_c2pa_png(self):
|
||||
# flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA).
|
||||
r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False)
|
||||
|
||||
@@ -777,6 +777,23 @@ class TestExifGenerator:
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_apple_clean_up_removal_parity(self, tmp_path: Path):
|
||||
# The "Apple Photos Clean Up" credit is an AI-edit VALUE under a
|
||||
# non-AI key, so removal must drop it by value too (corpus 2026-07-23).
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
info = PngInfo()
|
||||
info.add_text("Software", "Apple Photos Clean Up")
|
||||
src = tmp_path / "apple.png"
|
||||
Image.new("RGB", (64, 64)).save(src, pnginfo=info)
|
||||
assert exif_generator(src) is not None
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# ...and the EXIF ImageDescription field.
|
||||
exif = piexif.dump(
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
"""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 test_resolution_adaptive_denoise_matches_reference_formula():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_pipeline import resolution_adaptive_denoise
|
||||
|
||||
# The reference node maps 0.30 MP to the lower bound and 3.70 MP to the
|
||||
# upper bound. Level 6 adds one fifth of the configured upward spread.
|
||||
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_matches_reference_formula():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_pipeline import largest_face_denoise
|
||||
|
||||
image_size = (1000, 1000)
|
||||
assert largest_face_denoise([(0, 0, 300, 100)], image_size) == 0.10
|
||||
assert largest_face_denoise([(0, 0, 150, 100)], image_size) == 0.05
|
||||
assert largest_face_denoise([(0, 0, 900, 900)], image_size) == 0.28
|
||||
|
||||
|
||||
def test_global_kwargs_use_lightning_and_diffsynth_controlnet_shape():
|
||||
from remove_ai_watermarks.noai.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))
|
||||
|
||||
|
||||
def test_face_kwargs_use_zimage_reference_settings():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_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
|
||||
|
||||
|
||||
def test_canny_control_image_matches_reference_thresholds():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_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])
|
||||
assert result.max() == 255
|
||||
|
||||
|
||||
def test_yunet_download_targets_verified_lfs_artifact():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_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"
|
||||
# YuNet scores are not calibrated like the upstream YOLO detector's scores.
|
||||
# A 0.2 YuNet threshold admitted background and decorative false positives,
|
||||
# multiplying the serial Z-Image face-stage cost on crowded scenes.
|
||||
assert pytest.approx(0.5) == YUNET_SCORE_THRESHOLD
|
||||
|
||||
|
||||
def test_resident_face_models_disable_vram_offload():
|
||||
from remove_ai_watermarks.noai.qwen_zimage_pipeline import (
|
||||
QwenZImagePipeline,
|
||||
_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.noai.qwen_zimage_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_sam_pixels_match_model_dtype_without_casting_boxes():
|
||||
import torch
|
||||
|
||||
from remove_ai_watermarks.noai.qwen_zimage_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.noai.qwen_zimage_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.noai.qwen_zimage_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.noai.qwen_zimage_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.noai.qwen_zimage_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():
|
||||
from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
normalize_profile,
|
||||
resolve_seed,
|
||||
resolve_steps,
|
||||
)
|
||||
|
||||
assert normalize_profile("qwen-zimage") == "qwen-zimage"
|
||||
assert resolve_steps(None, "qwen-zimage") == 4
|
||||
assert resolve_steps(None, "controlnet") == 50
|
||||
assert resolve_steps(12, "qwen-zimage") == 12
|
||||
assert resolve_seed(None, "qwen-zimage") == 0
|
||||
assert resolve_seed(None, "controlnet") is None
|
||||
assert resolve_seed(17, "qwen-zimage") == 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_upstream_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.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
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["adaptive_polish"] is False
|
||||
assert mock_engine.remove_watermark.call_args.kwargs["seed"] == 0
|
||||
|
||||
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_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
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="cpu", pipeline="qwen-zimage")
|
||||
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
||||
assert remover.model_id == "Qwen/Qwen-Image-2512 + Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
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 output.exists()
|
||||
|
||||
|
||||
def test_watermark_remover_dispatches_qwen_tiling_to_full_pipeline(tmp_path, monkeypatch):
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
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="cpu", 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_qwen_tiling_runs_global_tiles_then_one_full_frame_face_stage(monkeypatch):
|
||||
from remove_ai_watermarks.noai.qwen_zimage_pipeline import (
|
||||
QwenZImagePipeline,
|
||||
resolution_adaptive_denoise,
|
||||
)
|
||||
from remove_ai_watermarks.noai.tiling import plan_tiles
|
||||
|
||||
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.noai.qwen_zimage_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.args[0] is image
|
||||
assert face_stage.call_args.args[1].size == image.size
|
||||
assert result.size == image.size
|
||||
|
||||
|
||||
def test_qwen_zimage_rejects_runtime_knobs_that_change_fixed_graph(tmp_path):
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
with pytest.raises(ValueError, match="fixed Qwen-Image-2512"):
|
||||
WatermarkRemover(model_id="custom/model", device="cpu", pipeline="qwen-zimage")
|
||||
|
||||
source = tmp_path / "source.png"
|
||||
Image.new("RGB", (64, 48)).save(source)
|
||||
remover = WatermarkRemover(device="cpu", pipeline="qwen-zimage")
|
||||
with pytest.raises(ValueError, match=r"CFG 1\.0"):
|
||||
remover.remove_watermark(source, guidance_scale=2.0)
|
||||
with pytest.raises(ValueError, match="4-step Lightning"):
|
||||
remover.remove_watermark(source, num_inference_steps=8)
|
||||
|
||||
|
||||
def test_invisible_engine_uses_qwen_zimage_step_default(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",
|
||||
min_resolution=0,
|
||||
)
|
||||
|
||||
assert engine._remover.remove_watermark.call_args.kwargs["num_inference_steps"] == 4
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -15,6 +16,18 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import synthid_corpus
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
CORPUS_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus"
|
||||
QUALITY_SET = CORPUS_DIR / "quality_sets" / "full_pipeline_quality_2026-07-25.csv"
|
||||
|
||||
EXPECTED_QUALITY_SOURCE_FILENAMES = {
|
||||
"ChatGPT Image May 30, 2026, 10_31_08 AM.png",
|
||||
"ChatGPT Image May 31, 2026, 02_02_23 PM.png",
|
||||
"ChatGPT Image May 31, 2026, 02_03_55 PM.png",
|
||||
"Gemini_Generated_Image_3mc4t93mc4t93mc4.png",
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png",
|
||||
"Gemini_Generated_Image_akdbeiakdbeiakdb.png",
|
||||
"Gemini_Generated_Image_y48j3cy48j3cy48j.png",
|
||||
}
|
||||
|
||||
|
||||
def _manifest_rows(root: Path) -> list[dict[str, str]]:
|
||||
@@ -22,6 +35,18 @@ def _manifest_rows(root: Path) -> list[dict[str, str]]:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def test_reusable_quality_set_has_expected_inputs_and_valid_hashes() -> None:
|
||||
with open(QUALITY_SET, newline="") as f:
|
||||
rows = list(csv.DictReader(f))
|
||||
|
||||
# Keep this literal independent of the CSV so deleting a fixture fails.
|
||||
assert {row["source_filename"] for row in rows} == EXPECTED_QUALITY_SOURCE_FILENAMES
|
||||
for row in rows:
|
||||
corpus_path = CORPUS_DIR / row["corpus_path"]
|
||||
assert corpus_path.is_file(), corpus_path
|
||||
assert hashlib.sha256(corpus_path.read_bytes()).hexdigest() == row["sha256"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestIngest:
|
||||
def test_ingest_openai_flags_synthid_metadata(self, tmp_path: Path):
|
||||
|
||||
Reference in New Issue
Block a user