mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
Merge main into video watermark pipeline
This commit is contained in:
+5
-5
@@ -542,7 +542,7 @@ class TestAllCommand:
|
||||
result = runner.invoke(main, ["all", str(sample_png), "-o", str(output)])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "NOT removed" in result.output
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
assert output.exists() # visible + metadata still produced a file
|
||||
|
||||
def test_all_reports_metadata_that_survived_stripping(self, runner, sample_png, tmp_path):
|
||||
@@ -933,21 +933,21 @@ class TestBatchCommand:
|
||||
|
||||
|
||||
class TestGpuHintMarkup:
|
||||
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
|
||||
"""The diffusion install hint must reach the user with the ``[diffusion]`` token
|
||||
intact (plain output prints it verbatim, with no markup parsing)."""
|
||||
|
||||
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png)])
|
||||
assert result.exit_code != 0
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
|
||||
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
# The `all` pipeline skips the invisible step with a warning that carries
|
||||
# the same hint; it must keep the [gpu] extra too.
|
||||
# the same hint; it must keep the [diffusion] extra too.
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["all", str(sample_png)])
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
assert "remove-ai-watermarks[diffusion]" in result.output
|
||||
|
||||
|
||||
class TestEraseCommand:
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Regression tests for Dependabot compatibility constraints."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_dependabot_blocks_opencv_releases_that_require_numpy_2() -> None:
|
||||
config = Path(".github/dependabot.yml").read_text()
|
||||
|
||||
opencv_ignore = config.split('dependency-name: "opencv-python-headless"', maxsplit=1)[1]
|
||||
assert '"<4.12"' not in opencv_ignore
|
||||
assert '- ">=4.12"' in opencv_ignore
|
||||
+138
-1
@@ -6,6 +6,7 @@ against the real committed C2PA / IPTC fixtures in data/fixtures/provenance/.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -16,14 +17,18 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.identify import (
|
||||
ProvenanceEvidence,
|
||||
ProvenanceReport,
|
||||
_ai_tools_in,
|
||||
_attribute_platform,
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
evidence_from_metadata_record,
|
||||
extract_provenance_evidence,
|
||||
has_invisible_target,
|
||||
identify,
|
||||
identify_from_evidence,
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
@@ -33,6 +38,129 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "external.jpg"
|
||||
signature = "A" * 64
|
||||
artist = "c8045292-06d2-4c7d-b4f0-4f93b94e4801"
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {
|
||||
"0th": {
|
||||
"ImageDescription": f"Signature: {signature}",
|
||||
"Artist": artist,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
evidence = evidence_from_metadata_record(record, path=path)
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert evidence.path == path
|
||||
assert evidence.ai_metadata["parameters"] == "Steps: 20, Sampler: Euler"
|
||||
assert evidence.xai_signature is True
|
||||
assert report.is_ai_generated is True
|
||||
assert {signal.name for signal in report.signals} >= {"gen_params", "xai_signature"}
|
||||
|
||||
def test_external_scanner_diagnostics_do_not_create_c2pa_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "plain.jpg"
|
||||
record = {
|
||||
"c2pa_store": {"error": "ManifestNotFound: no JUMBF data found"},
|
||||
"jpeg": {
|
||||
"segments": [
|
||||
{
|
||||
"marker": "APP11",
|
||||
"kind": "c2pa_or_jumbf",
|
||||
"base64": "AAA=",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.signals == []
|
||||
assert report.watermarks == []
|
||||
|
||||
def test_external_scanner_raw_bytes_still_create_c2pa_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "signed.jpg"
|
||||
manifest = b"jumb c2pa OpenAI trainedAlgorithmicMedia"
|
||||
record = {
|
||||
"jpeg": {
|
||||
"segments": [
|
||||
{
|
||||
"marker": "APP11",
|
||||
"kind": "c2pa_or_jumbf",
|
||||
"base64": base64.b64encode(manifest).decode(),
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
|
||||
assert [signal.name for signal in report.signals] == ["c2pa"]
|
||||
|
||||
def test_external_generator_bytes_are_normalized(self, tmp_path: Path):
|
||||
evidence = evidence_from_metadata_record(
|
||||
{"exif": {"0th": {"Software": b"NovelAI"}}},
|
||||
path=tmp_path / "external.png",
|
||||
)
|
||||
|
||||
assert evidence.exif_generator == "NovelAI"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"chatgpt-1.png",
|
||||
"chatgpt-2.png",
|
||||
"doubao-1.png",
|
||||
"firefly-1.png",
|
||||
"flux-1.jpg",
|
||||
"flux-1.png",
|
||||
"grok-1.jpg",
|
||||
"mj-1.png",
|
||||
],
|
||||
)
|
||||
def test_metadata_only_identify_matches_extracted_evidence(self, filename: str):
|
||||
path = SAMPLES_DIR / filename
|
||||
|
||||
direct = identify(path, check_visible=False, check_invisible=False)
|
||||
evidence = extract_provenance_evidence(path)
|
||||
extracted = identify_from_evidence(evidence)
|
||||
|
||||
assert isinstance(evidence, ProvenanceEvidence)
|
||||
assert extracted == direct
|
||||
|
||||
def test_identify_from_evidence_does_not_read_the_source(self, monkeypatch, tmp_path: Path):
|
||||
path = tmp_path / "generated.jpg"
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2paOpenAI DALL-E trainedAlgorithmicMedia\xff\xd9")
|
||||
evidence = extract_provenance_evidence(path)
|
||||
|
||||
def fail_if_called(*args, **kwargs):
|
||||
raise AssertionError("identify_from_evidence must not read the source file")
|
||||
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.extract_c2pa_info", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.get_ai_metadata", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.scan_head", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.iptc_ai_system", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.aigc_label", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.exif_generator", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.xai_signature", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.huggingface_job", fail_if_called)
|
||||
monkeypatch.setattr("remove_ai_watermarks.identify.samsung_genai", fail_if_called)
|
||||
monkeypatch.setattr("builtins.open", fail_if_called)
|
||||
monkeypatch.setattr(Path, "open", fail_if_called)
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert any(signal.name == "c2pa" for signal in report.signals)
|
||||
|
||||
|
||||
# ── Pure attribution logic (no file IO) ─────────────────────────────
|
||||
|
||||
|
||||
@@ -662,6 +790,15 @@ class TestIdentifyVisibleTextMarks:
|
||||
identify(tmp_clean_png, check_visible=True, check_invisible=False)
|
||||
assert mock_imread.call_count == 1
|
||||
|
||||
def test_missing_pixel_extra_preserves_metadata_verdict(self, tmp_png_with_ai_metadata: Path):
|
||||
import remove_ai_watermarks.image_io as image_io
|
||||
|
||||
with patch.object(image_io, "imread", side_effect=ModuleNotFoundError("No module named 'cv2'")):
|
||||
report = identify(tmp_png_with_ai_metadata, check_visible=True, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "high"
|
||||
|
||||
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
@@ -869,7 +1006,7 @@ class TestIdentifyC2paDevice:
|
||||
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _wm_available(), reason="invisible-watermark not installed")
|
||||
@pytest.mark.skipif(not _wm_available(), reason="detect extra not installed")
|
||||
class TestIdentifyInvisibleWatermark:
|
||||
def _sdxl_watermarked(self, tmp_path: Path) -> Path:
|
||||
import cv2
|
||||
|
||||
@@ -18,9 +18,9 @@ class TestIsAvailable:
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_available_reflects_dependencies(self):
|
||||
"""is_available() is True iff torch + diffusers (the gpu extra) import.
|
||||
"""is_available() is True iff torch + diffusers (the diffusion extra) import.
|
||||
|
||||
Must not assume the full stack: the core+dev CI env has no diffusers.
|
||||
Must not assume the full stack: the default+dev CI env has no diffusers.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestCannyControlImage:
|
||||
|
||||
def test_edge_map_is_3channel_rgb(self):
|
||||
if not is_available():
|
||||
pytest.skip("gpu extra (torch/diffusers) not installed")
|
||||
pytest.skip("diffusion extra (torch/diffusers) not installed")
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Tests for open invisible-watermark (imwatermark) detection.
|
||||
"""Tests for open DWT-DCT watermark detection.
|
||||
|
||||
Each known scheme is round-tripped: embed its exact upstream pattern with the
|
||||
encoder, then assert the detector names it. Skipped entirely if the optional
|
||||
``invisible-watermark`` package is not installed.
|
||||
The upstream encoder supplies known watermarks, while the in-tree decoder must
|
||||
both identify them and match the upstream decoder bit for bit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +24,7 @@ from remove_ai_watermarks.invisible_watermark import (
|
||||
is_available,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not is_available(), reason="invisible-watermark not installed")
|
||||
pytestmark = pytest.mark.skipif(not is_available(), reason="detect extra not installed")
|
||||
|
||||
|
||||
def _base_image() -> np.ndarray:
|
||||
@@ -61,6 +60,20 @@ class TestHelpers:
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_in_tree_decoder_matches_upstream(self, tmp_path: Path):
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
from remove_ai_watermarks.dwt_dct import decode_dwt_dct
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
|
||||
image = imread(path)
|
||||
assert image is not None
|
||||
|
||||
upstream = np.asarray(WatermarkDecoder("bits", 48).decode(image, "dwtDct"), dtype=bool)
|
||||
ours = np.asarray(decode_dwt_dct(image, wm_len=48), dtype=bool)
|
||||
assert np.array_equal(ours, upstream)
|
||||
|
||||
def test_detects_sdxl(self, tmp_path: Path):
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
|
||||
assert detect_invisible_watermark(path) == "Stable Diffusion XL"
|
||||
|
||||
+2
-2
@@ -52,8 +52,8 @@ class TestConstants:
|
||||
assert ".jpg" in SUPPORTED_FORMATS
|
||||
|
||||
def test_supported_formats_include_heic_avif(self):
|
||||
# HEIC/AVIF are first-class on the pixel path now (read+write via pillow-heif),
|
||||
# so batch discovers them and the CLI does not warn.
|
||||
# HEIC/AVIF are first-class when the visible pixel extra is installed
|
||||
# (read+write via pillow-heif), so batch discovers them without a warning.
|
||||
assert {".heic", ".heif", ".avif"} <= SUPPORTED_FORMATS
|
||||
|
||||
def test_supported_formats_exclude_jpeg_xl(self):
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Published dependency boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import metadata, requires
|
||||
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
|
||||
def _requirement_names(extra: str | None = None) -> set[str]:
|
||||
selected_extra = extra or ""
|
||||
parsed = (Requirement(value) for value in requires("remove-ai-watermarks") or [])
|
||||
return {
|
||||
canonicalize_name(requirement.name)
|
||||
for requirement in parsed
|
||||
if requirement.marker is None or requirement.marker.evaluate({"extra": selected_extra})
|
||||
}
|
||||
|
||||
|
||||
def test_default_install_is_metadata_focused():
|
||||
default = _requirement_names()
|
||||
|
||||
assert {
|
||||
"c2pa-python",
|
||||
"click",
|
||||
"piexif",
|
||||
"pillow",
|
||||
"python-dotenv",
|
||||
} <= default
|
||||
assert {
|
||||
"av",
|
||||
"invisible-watermark",
|
||||
"numpy",
|
||||
"opencv-python-headless",
|
||||
"pillow-heif",
|
||||
"torch",
|
||||
"trustmark",
|
||||
}.isdisjoint(default)
|
||||
|
||||
|
||||
def test_pixels_extra_owns_shared_numeric_dependencies():
|
||||
assert {
|
||||
"numpy",
|
||||
"opencv-python-headless",
|
||||
} <= _requirement_names("pixels")
|
||||
|
||||
|
||||
def test_video_extra_owns_timestamp_dependency():
|
||||
assert "av" in _requirement_names("video")
|
||||
|
||||
|
||||
def test_file_format_and_detector_dependencies_are_independent():
|
||||
assert "pillow-heif" in _requirement_names("heif")
|
||||
assert "pywavelets" in _requirement_names("detect")
|
||||
|
||||
|
||||
def test_extras_use_capability_names_without_legacy_aliases():
|
||||
extras = set(metadata("remove-ai-watermarks").get_all("Provides-Extra") or [])
|
||||
|
||||
assert {"pixels", "heif", "visible", "video", "detect", "diffusion"} <= extras
|
||||
assert {"gpu", "remove", "detect-pywavelets"}.isdisjoint(extras)
|
||||
|
||||
|
||||
def test_production_all_does_not_include_development_tools():
|
||||
assert {
|
||||
"pyright",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist",
|
||||
"ruff",
|
||||
}.isdisjoint(_requirement_names("all"))
|
||||
@@ -238,7 +238,7 @@ class TestQwenKwargs:
|
||||
"""_build_qwen_kwargs is pure (no torch); guards the Qwen-Image call shape.
|
||||
|
||||
watermark_remover imports torch under a try/except, so the module (and this pure
|
||||
helper) imports fine in the core+dev CI env where torch is absent.
|
||||
helper) imports fine in the default+dev CI env where torch is absent.
|
||||
"""
|
||||
|
||||
def test_uses_true_cfg_not_guidance_scale(self):
|
||||
@@ -431,7 +431,7 @@ class TestAvailability:
|
||||
|
||||
def test_watermark_removal_available(self):
|
||||
# Reflects the actual environment: True iff torch + diffusers (the gpu
|
||||
# extra) are importable. The core+dev CI env has no diffusers, so this
|
||||
# extra) are importable. The default+dev CI env has no diffusers, so this
|
||||
# must not assume the full stack is present.
|
||||
import importlib.util
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_update_recipe_rejects_ambiguous_recipe() -> None:
|
||||
update_recipe(duplicate, version="2.0.0", sha256=_NEW_SHA)
|
||||
|
||||
|
||||
def test_repository_recipe_includes_timestamp_bridge() -> None:
|
||||
recipe = Path("packaging/conda/recipe.yaml").read_text()
|
||||
def test_repository_recipe_stays_metadata_only() -> None:
|
||||
recipe = Path("packaging/conda/recipe.yaml").read_text(encoding="utf-8")
|
||||
|
||||
assert " - av >=16\n" in recipe
|
||||
assert " - av >=16\n" not in recipe
|
||||
|
||||
@@ -501,6 +501,17 @@ def _regeneration_metrics(
|
||||
)
|
||||
|
||||
|
||||
class TestVideoDependencies:
|
||||
def test_visible_runtime_reports_video_extra(self, monkeypatch):
|
||||
from remove_ai_watermarks import optional_deps
|
||||
from remove_ai_watermarks.video import _require_video_runtime
|
||||
|
||||
monkeypatch.setattr(optional_deps, "module_available", lambda *_names: False)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"remove-ai-watermarks\[video\]"):
|
||||
_require_video_runtime()
|
||||
|
||||
|
||||
class TestVideoMetadataApi:
|
||||
def test_top_level_api_is_lazy_exported(self):
|
||||
import remove_ai_watermarks as raiw
|
||||
|
||||
Reference in New Issue
Block a user