mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-05-28 15:11:27 +02:00
27ad5b7645
Research found one locally-fillable detection gap: Stable Diffusion, SDXL, and FLUX all embed an open DWT-DCT watermark via the invisible-watermark (imwatermark) library -- a PUBLIC decoder, no secret key, unlike SynthID. New invisible_watermark.py decodes the known fixed patterns (verified against upstream source: diffusers SDXL WATERMARK_MESSAGE, FLUX.2 src/flux2/watermark.py, and the 'StableDiffusionV1' default string) and identify() reports the scheme as a high-confidence signal. Verified locally end-to-end: embedding SDXL's exact 48-bit message and decoding it back recovers 48/48 bits; a clean image and our own fal-SDXL outputs decode to ~21/48 (no match). Caveat baked into the report: the watermark is fragile -- gone after JPEG q90 -- so it confirms origin only on pristine files; absence is never proof. imwatermark is an optional dep (extra 'detect'; pulls non-headless opencv), so the import is guarded and the signal is skipped when absent. CLI --no-visible now means metadata-only (skips both pixel-domain detectors). Also records the broader watermarking landscape in CLAUDE.md: which services are locally detectable (SD/SDXL/FLUX), C2PA-covered (Bing/Canva/ Getty/Shutterstock unsampled), or proprietary-only like SynthID (Amazon Titan/Nova, Kakao). Midjourney embeds neither C2PA nor an invisible mark. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
91 lines
2.9 KiB
Python
91 lines
2.9 KiB
Python
"""Tests for open invisible-watermark (imwatermark) 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.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from remove_ai_watermarks.invisible_watermark import (
|
|
_BITS_48,
|
|
_SD1_STRING,
|
|
_bits_match,
|
|
_bytes_match_frac,
|
|
detect_invisible_watermark,
|
|
is_available,
|
|
)
|
|
|
|
pytestmark = pytest.mark.skipif(not is_available(), reason="invisible-watermark not installed")
|
|
|
|
|
|
def _base_image() -> np.ndarray:
|
|
# imwatermark needs enough DWT coefficients; use a 512x512 textured image.
|
|
rng = np.random.default_rng(0)
|
|
return rng.integers(0, 255, (512, 512, 3), dtype=np.uint8)
|
|
|
|
|
|
def _write_bits_watermark(tmp_path: Path, message: int) -> Path:
|
|
from imwatermark import WatermarkEncoder
|
|
|
|
bits = [int(b) for b in format(message, "048b")]
|
|
enc = WatermarkEncoder()
|
|
enc.set_watermark("bits", bits)
|
|
wm = enc.encode(_base_image(), "dwtDct")
|
|
path = tmp_path / "wm.png"
|
|
cv2.imwrite(str(path), wm)
|
|
return path
|
|
|
|
|
|
class TestHelpers:
|
|
def test_bits_match_exact(self):
|
|
assert _bits_match(0b1010, 0b1010, width=4) == 4
|
|
|
|
def test_bits_match_one_off(self):
|
|
assert _bits_match(0b1010, 0b1011, width=4) == 3
|
|
|
|
def test_bytes_match_identical(self):
|
|
assert _bytes_match_frac(_SD1_STRING, _SD1_STRING) == 1.0
|
|
|
|
def test_bytes_match_length_mismatch_is_zero(self):
|
|
assert _bytes_match_frac(b"abc", b"abcd") == 0.0
|
|
|
|
|
|
class TestDetect:
|
|
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"
|
|
|
|
def test_detects_flux(self, tmp_path: Path):
|
|
path = _write_bits_watermark(tmp_path, _BITS_48["FLUX.2 (Black Forest Labs)"])
|
|
assert detect_invisible_watermark(path) == "FLUX.2 (Black Forest Labs)"
|
|
|
|
def test_detects_sd1_string(self, tmp_path: Path):
|
|
from imwatermark import WatermarkEncoder
|
|
|
|
enc = WatermarkEncoder()
|
|
enc.set_watermark("bytes", _SD1_STRING)
|
|
wm = enc.encode(_base_image(), "dwtDct")
|
|
path = tmp_path / "sd1.png"
|
|
cv2.imwrite(str(path), wm)
|
|
assert detect_invisible_watermark(path) == "Stable Diffusion 1.x / 2.x"
|
|
|
|
def test_clean_image_is_none(self, tmp_path: Path):
|
|
path = tmp_path / "clean.png"
|
|
cv2.imwrite(str(path), _base_image())
|
|
assert detect_invisible_watermark(path) is None
|
|
|
|
def test_unreadable_file_is_none(self, tmp_path: Path):
|
|
path = tmp_path / "not_image.png"
|
|
path.write_bytes(b"not a png")
|
|
assert detect_invisible_watermark(path) is None
|