mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-19 20:17:12 +02:00
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.
Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.
Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.
`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.
Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:
* A TC260 label now relaxes the vendor its `ContentProducer` names rather than
ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
of them the previously relaxed pair was simply the wrong vendor, and the
mark actually present never reached the relaxed gate its own
`provenance_ncc_factor` was calibrated for.
* A confident LibLibAI detection suppresses the Jimeng pill, like every other
TC260 product's mark. It was registered alongside RunningHub and Baidu, both
of which were added to the hand-written veto list, and it was not. 1 sample
moves, and it is exactly the co-firing case.
Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.
Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.7 KiB
Python
123 lines
4.7 KiB
Python
"""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
|