"""Detect open invisible watermarks embedded by the ``invisible-watermark`` (imwatermark) library -- used by Stable Diffusion, SDXL, and FLUX. Unlike SynthID (proprietary, no local decoder), these are DWT-DCT watermarks with a PUBLIC decoder and no secret key, so a fresh, un-re-encoded output can be identified locally. The known fixed patterns were verified against upstream source: - **Stable Diffusion XL** -- diffusers ``StableDiffusionXLWatermarker`` ``WATERMARK_MESSAGE`` (48-bit). - **FLUX.2** -- ``black-forest-labs/flux2`` ``src/flux2/watermark.py`` (48-bit). - **Stable Diffusion 1.x / 2.x** -- the library's default ``"StableDiffusionV1"`` string (136-bit). The watermark is fragile: it does NOT survive JPEG re-encoding or resizing (verified -- gone after JPEG q90), so detection works only on pristine PNG originals. Absence is never proof. Requires the optional ``detect`` extra; ``detect_invisible_watermark`` returns None when it is not installed. """ # The optional numeric libraries do not provide complete types for this path. # pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false from __future__ import annotations import logging from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Iterable from pathlib import Path from typing import Any from numpy.typing import NDArray logger = logging.getLogger(__name__) # Known 48-bit ``bits`` watermarks (dwtDct, no key), name -> message integer. _BITS_48: dict[str, int] = { "Stable Diffusion XL": 0b101100111110110010010000011110111011000110011110, "FLUX.2 (Black Forest Labs)": 0b001010101111111010000111100111001111010100101110, } # The invisible-watermark default string watermark (SD 1.x / 2.x). _SD1_STRING = b"StableDiffusionV1" # Decoded bits/bytes never match a 48-bit pattern by chance: random decode lands # near 24/48, an exact embed at 48/48 (measured). 44 (<=4 bit errors) is a safe # floor that tolerates light perturbation without risking a false positive. _MATCH_48 = 44 _MATCH_SD1_FRAC = 0.92 # fraction of the 136 string bits that must match def is_available() -> bool: """True when all dependencies for the optional DWT-DCT decoder exist.""" from .optional_deps import module_available return module_available("cv2", "numpy", "pywt") def _bits_match(value: int, ref: int, width: int = 48) -> int: """Number of matching bits between two ``width``-bit integers.""" return width - bin(value ^ ref).count("1") def _bytes_match_frac(a: bytes, b: bytes) -> float: """Fraction of matching bits between two equal-length byte strings.""" if len(a) != len(b) or not a: return 0.0 diff = sum(bin(x ^ y).count("1") for x, y in zip(a, b, strict=True)) return 1.0 - diff / (8 * len(b)) def _bits_to_int(bits: Iterable[object]) -> int: value = 0 for bit in bits: value = (value << 1) | int(bool(bit)) return value def _bits_to_bytes(bits: Iterable[object], nbytes: int) -> bytes: import numpy as np packed = np.packbits([int(bool(bit)) for bit in bits]) return bytes(int(value) for value in packed[:nbytes]) def detect_invisible_watermark(image_path: Path, *, image: NDArray[Any] | None = None) -> str | None: """Return the embedding scheme name if a known open watermark is decoded. Returns e.g. ``"Stable Diffusion XL"`` / ``"FLUX.2 (Black Forest Labs)"`` / ``"Stable Diffusion 1.x / 2.x"``, or None if none matches, the decoder is unavailable, or the image can't be read. Meaningful only on pristine (un-re-encoded) images. """ if not is_available(): return None from remove_ai_watermarks import image_io from remove_ai_watermarks.dwt_dct import decode_dwt_dct_lengths # ``image`` lets a caller that has already decoded these pixels hand them in # (mirrors gemini_engine.detect_sparkle_confidence). The decoder only reads the # array -- it converts colour spaces into fresh buffers -- so no copy is needed. img = image if image is not None else image_io.imread(image_path) if img is None: return None try: decoded = decode_dwt_dct_lengths(img, (48, 8 * len(_SD1_STRING))) except Exception as exc: # decode can fail on tiny images logger.debug("watermark decode failed for %s: %s", image_path, exc) return None value = _bits_to_int(decoded[48]) for name, ref in _BITS_48.items(): if _bits_match(value, ref) >= _MATCH_48: return name raw = _bits_to_bytes(decoded[8 * len(_SD1_STRING)], len(_SD1_STRING)) if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC: return "Stable Diffusion 1.x / 2.x" return None