mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-16 06:20:48 +02:00
215 lines
7.8 KiB
Python
Executable File
215 lines
7.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Revision-pinned local PixelSeal oracle for development-only benchmarks.
|
|
|
|
Meta publishes PixelSeal in the VideoSeal source tree, but not as a standalone
|
|
TorchScript artifact. This loader uses the exact upstream source revision and
|
|
official checkpoint, verifies both the model card and checkpoint digests, and
|
|
builds only the image inference graph. It avoids the upstream convenience
|
|
loader because that loader imports training datasets and writes downloads
|
|
relative to the current working directory.
|
|
|
|
Nothing here ships in the installed CLI or public Python API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.metadata
|
|
import logging
|
|
import os
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
SOURCE_COMMIT = "870ca7fb33578b90f14c602016b6c2788096226e"
|
|
PACKAGE_NAME = "videoseal"
|
|
PACKAGE_VERSION = "1.0"
|
|
CHECKPOINT_URL = "https://dl.fbaipublicfiles.com/videoseal/pixelseal/checkpoint.pth"
|
|
CHECKPOINT_SHA256 = "0c5665cff20eb6ce1b5aaa7d91c19dafb418bfee32d02dd3344e4ed60d9d75bd"
|
|
MODEL_CARD_SHA256 = "96c4a448f7d81810780e5bde5a51b039db544b7ec2baf65a605302c1b17362c9"
|
|
N_BITS = 256
|
|
MESSAGE_SEED = 7
|
|
DETECTION_BIT_ACCURACY_THRESHOLD = 0.9
|
|
CACHE_DIR = Path(
|
|
os.environ.get(
|
|
"PIXELSEAL_CACHE_DIR",
|
|
str(Path.home() / ".cache" / "remove-ai-watermarks-dev" / "pixelseal"),
|
|
)
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PixelSealReading:
|
|
"""Decoded bits and matched-message accuracy for one image."""
|
|
|
|
bit_accuracy: float
|
|
decoded_bits: tuple[int, ...]
|
|
|
|
@property
|
|
def detected(self) -> bool:
|
|
"""Apply this adapter's explicit matched-message rule."""
|
|
return self.bit_accuracy >= DETECTION_BIT_ACCURACY_THRESHOLD
|
|
|
|
@property
|
|
def label(self) -> str:
|
|
return "".join(f"{byte:02x}" for byte in _bytes(self.decoded_bits))
|
|
|
|
|
|
def _bytes(bits: Sequence[int]) -> bytes:
|
|
return bytes(sum(bit << (7 - index) for index, bit in enumerate(bits[i : i + 8])) for i in range(0, len(bits), 8))
|
|
|
|
|
|
def message_bits(seed: int = MESSAGE_SEED) -> tuple[int, ...]:
|
|
"""The oracle's fixed 256-bit message, deterministic across processes."""
|
|
import numpy as np
|
|
|
|
rng = np.random.default_rng(seed)
|
|
return tuple(int(bit) for bit in rng.integers(0, 2, N_BITS))
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def import_failure() -> Exception | None:
|
|
"""Return the missing or incompatible dependency error, if any."""
|
|
try:
|
|
import omegaconf # noqa: F401
|
|
import timm # noqa: F401
|
|
import torch # noqa: F401
|
|
import torchvision # noqa: F401
|
|
import videoseal # noqa: F401
|
|
|
|
if importlib.metadata.version(PACKAGE_NAME) != PACKAGE_VERSION:
|
|
raise RuntimeError(
|
|
f"{PACKAGE_NAME} must be installed from source commit {SOURCE_COMMIT} "
|
|
f"(package version {PACKAGE_VERSION})"
|
|
)
|
|
except (ImportError, importlib.metadata.PackageNotFoundError, RuntimeError) as exc:
|
|
return exc
|
|
return None
|
|
|
|
|
|
def available() -> bool:
|
|
"""Whether the pinned PixelSeal source stack is importable."""
|
|
return import_failure() is None
|
|
|
|
|
|
def checkpoint_path() -> Path:
|
|
"""Return the pinned local checkpoint, downloading and verifying it once."""
|
|
target = CACHE_DIR / "checkpoint.pth"
|
|
if target.is_file() and _sha256_file(target) == CHECKPOINT_SHA256:
|
|
return target
|
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
temporary = CACHE_DIR / "checkpoint.pth.part"
|
|
log.info("Downloading pinned PixelSeal checkpoint from %s", CHECKPOINT_URL)
|
|
urllib.request.urlretrieve(CHECKPOINT_URL, temporary)
|
|
digest = _sha256_file(temporary)
|
|
if digest != CHECKPOINT_SHA256:
|
|
temporary.unlink(missing_ok=True)
|
|
raise RuntimeError(f"pinned PixelSeal checkpoint digest mismatch: {digest} != {CHECKPOINT_SHA256}")
|
|
temporary.replace(target)
|
|
return target
|
|
|
|
|
|
def load_model() -> object:
|
|
"""Build PixelSeal from the pinned official source and weights on CPU."""
|
|
failure = import_failure()
|
|
if failure is not None:
|
|
raise RuntimeError(
|
|
"PixelSeal oracle requires facebookresearch/videoseal at source commit "
|
|
f"{SOURCE_COMMIT}, plus torch, torchvision, timm, and omegaconf"
|
|
) from failure
|
|
|
|
import torch
|
|
import videoseal
|
|
from omegaconf import OmegaConf
|
|
from videoseal.augmentation.augmenter import get_dummy_augmenter
|
|
from videoseal.models import Videoseal, build_embedder, build_extractor
|
|
from videoseal.modules.jnd import JND
|
|
|
|
package_root = Path(videoseal.__file__).resolve().parent
|
|
card_path = package_root / "cards" / "pixelseal.yaml"
|
|
if not card_path.is_file() or _sha256_file(card_path) != MODEL_CARD_SHA256:
|
|
raise RuntimeError("installed videoseal source does not carry the pinned PixelSeal model card")
|
|
config = OmegaConf.load(card_path)
|
|
args = config.args
|
|
embedder = build_embedder(
|
|
config.embedder.model,
|
|
config.embedder.params,
|
|
args.nbits,
|
|
args.hidden_size_multiplier,
|
|
)
|
|
extractor = build_extractor(
|
|
config.extractor.model,
|
|
config.extractor.params,
|
|
args.img_size_proc,
|
|
args.nbits,
|
|
)
|
|
model = Videoseal(
|
|
embedder,
|
|
extractor,
|
|
get_dummy_augmenter(),
|
|
attenuation=JND(in_channels=1, out_channels=1),
|
|
scaling_w=args.scaling_w,
|
|
scaling_i=args.scaling_i,
|
|
img_size=args.img_size_proc,
|
|
chunk_size=args.videowam_chunk_size,
|
|
step_size=args.videowam_step_size,
|
|
)
|
|
checkpoint = torch.load(checkpoint_path(), map_location="cpu", weights_only=True)
|
|
loaded = model.load_state_dict(checkpoint["model"], strict=False)
|
|
if loaded.missing_keys or loaded.unexpected_keys:
|
|
raise RuntimeError(
|
|
f"PixelSeal checkpoint/source mismatch: missing={loaded.missing_keys}, unexpected={loaded.unexpected_keys}"
|
|
)
|
|
model.eval()
|
|
return model
|
|
|
|
|
|
def _image_tensor(image: object) -> object:
|
|
import numpy as np
|
|
import torch
|
|
|
|
array = np.asarray(image)
|
|
if array.ndim != 3 or array.shape[2] != 3:
|
|
raise ValueError(f"PixelSeal expects HxWx3 RGB pixels, got {array.shape}")
|
|
values = array.astype(np.float32) / 255.0 if array.dtype == np.uint8 else np.asarray(array, dtype=np.float32)
|
|
if not np.isfinite(values).all() or float(values.min()) < 0.0 or float(values.max()) > 1.0:
|
|
raise ValueError("PixelSeal RGB pixels must be finite and in [0, 1]")
|
|
return torch.from_numpy(np.ascontiguousarray(values)).permute(2, 0, 1).unsqueeze(0)
|
|
|
|
|
|
def embed(model: object, image: object, message: Sequence[int]) -> object:
|
|
"""Embed one message into an RGB image, returning float RGB in [0, 1]."""
|
|
import torch
|
|
|
|
tensor = _image_tensor(image)
|
|
msg = torch.tensor([list(message)], dtype=torch.float32)
|
|
with torch.inference_mode():
|
|
marked = model.embed(tensor, msgs=msg, is_video=False)["imgs_w"]
|
|
return marked[0].permute(1, 2, 0).numpy()
|
|
|
|
|
|
def read(model: object, image: object, *, message: Sequence[int] | None = None) -> PixelSealReading:
|
|
"""Decode one image and compare its bits with the oracle's fixed message."""
|
|
import torch
|
|
|
|
expected = message_bits() if message is None else tuple(message)
|
|
tensor = _image_tensor(image)
|
|
with torch.inference_mode():
|
|
predictions = model.detect(tensor)["preds"]
|
|
decoded = tuple(int(bit) for bit in (predictions[0, 1:] > 0).to(torch.int64).tolist())
|
|
accuracy = sum(int(bit == want) for bit, want in zip(decoded, expected, strict=True)) / N_BITS
|
|
return PixelSealReading(bit_accuracy=accuracy, decoded_bits=decoded)
|