Files
Victor Kuznetsov 464fd142ea Add audio and video watermark benchmark oracles, cohorts, and research studies
Extend the development-only benchmark family from images to audio and
video, and close four research directions from the watermark-landscape
map with local, revision-pinned evidence.

Benchmark schema: media_type audio and video rows, audioseal and
videoseal adapters with media-checked adapter binding, sample-domain
audio fidelity (SNR with unbounded_identical), frame-domain video
fidelity (mean PSNR, changed-frame fraction), and per-frame temporal
evidence in videoseal detection records. The report accepts all three
media types and carries SNR columns.

Audio: shared pinned AudioSeal oracle, synthetic and system-voice
speech cohorts with matched negatives, codec, noise, removed, and
forged arms, and the provenance experiment proving visual-only video
cleaning leaves the audio bitstream and watermark verdict invariant.

Video: TorchScript-only VideoSeal oracle (the pip package is unusable
on macOS arm64 and its loader resolves paths relative to cwd), video
cohort with crf/scale/fps attacks and four-state arms, temporal
evaluation measuring the crf decay curve, the frame-aggregation matrix
(computed locally after finding the TorchScript build ignores the
aggregation argument), and real provider clips as carriers.

Forgery: double-embedding study showing the last writer wins completely
under both oracles, scoped remover-trace probe, and the forged/
removed cohort arms encoding each scheme's correct expected verdict
(presence rule for audioseal, message rule for videoseal).

C2PA resolution: measured the five vendors' resolver contracts and the
C2PA 2.2 18.10.5 privacy gradient; product decision recorded that this
tool never resolves over the network. A registry guard pins the
published resolution-API set.

Gate: ruff and pyright src/ clean, 2114 passed / 3 skipped; maintain.sh
stops only on the known unfixed lightning 2.6.5 CVEs, pre-existing.
2026-09-07 18:23:18 -07:00

191 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""Revision-pinned local AudioSeal oracle for development-only benchmarks.
One shared seam for the benchmark kernel adapter, the audio cohort builder,
and the audio-provenance experiment. Weights are pinned to an exact Hugging
Face revision of ``facebook/audioseal`` and verified against pinned SHA-256
digests before loading; the loader mirrors ``AudioSeal.parse_model`` with the
checkpoint bytes pinned, so card values override the checkpoint's embedded
``xp.cfg`` exactly like the package loader does for card loads.
The first load may download the pinned checkpoints into the local Hugging
Face cache when it is incomplete, like the optional TrustMark weights. Prepare
the dependency before an offline run. Nothing here contacts a provenance
oracle or provider API, and none of this ships in the installed CLI or the
public Python API.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Sequence
HF_REPO = "facebook/audioseal"
HF_REVISION = "3c19eba53390776cf2cc9ed5f6c9ac67ce72ecba"
GENERATOR_SHA256 = "7a845b5fbe9364a63a3909d8ab3fe064d13a76ae4c2e983573e08c69b7b51748"
DETECTOR_SHA256 = "8a78e8a83584113523e161fc599fcab10fd0e94c04d2eb9d2fa1e9ec91ab69d9"
SAMPLE_RATE = 16_000
N_BITS = 16
DETECTION_THRESHOLD = 0.5
MESSAGE_THRESHOLD = 0.5
GENERATOR_CARD = "audioseal_wm_16bits"
DETECTOR_CARD = "audioseal_detector_16bits"
@dataclass(frozen=True)
class AudioSealReading:
"""Detector readings on one decoded mono sample array."""
mean_detect_prob: float
frac_above_threshold: float
decoded_bits: tuple[int, ...]
@property
def detected(self) -> bool:
"""The detector's own document decision rule at the default threshold."""
return self.frac_above_threshold >= DETECTION_THRESHOLD
@property
def label(self) -> str:
return "".join(str(bit) for bit in self.decoded_bits)
def import_failure() -> ImportError | None:
"""Return the missing-dependency error, or None when the stack is present."""
try:
import audioseal # noqa: F401
import huggingface_hub # noqa: F401
import omegaconf # noqa: F401
import torch # noqa: F401
except ImportError as exc:
return exc
return None
def available() -> bool:
"""Whether the audioseal stack is importable in this environment."""
return import_failure() is None
def load_pinned_models() -> tuple[object, object]:
"""Load generator and detector from the pinned, digest-verified revision."""
failure = import_failure()
if failure is not None:
raise RuntimeError(
"audioseal oracle requires the dev extra: uv sync --extra dev (audioseal, torch, huggingface-hub)"
) from failure
import hashlib
import audioseal as audioseal_pkg
import torch
from audioseal import AudioSeal
from audioseal.builder import create_detector, create_generator
from audioseal.loader import (
AudioSealDetectorConfig,
AudioSealWMConfig,
convert_state_dict_for_scriptable_model,
load_model_checkpoint,
load_state_dict,
)
from huggingface_hub import hf_hub_download
from omegaconf import OmegaConf
cards_dir = Path(audioseal_pkg.__file__).parent / "cards"
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 load(card_name: str, filename: str, cfg_type: object, create: object) -> object:
local = Path(hf_hub_download(HF_REPO, filename, revision=HF_REVISION))
digest = sha256_file(local)
expected = GENERATOR_SHA256 if "generator" in filename else DETECTOR_SHA256
if digest != expected:
raise RuntimeError(f"pinned checkpoint digest mismatch for {filename}: {digest} != {expected}")
config_dict = OmegaConf.to_container(OmegaConf.load(cards_dir / f"{card_name}.yaml"))
if not isinstance(config_dict, dict):
raise RuntimeError(f"audioseal card {card_name} did not parse into a mapping")
config_dict.pop("checkpoint")
checkpoint = load_model_checkpoint(str(local))
merged = {**checkpoint.get("xp.cfg", {}), **config_dict}
config = AudioSeal.parse_config(merged, cfg_type) # type: ignore[arg-type]
raw = checkpoint.get("best_state", checkpoint.get("model", checkpoint))
raw = convert_state_dict_for_scriptable_model(raw)
model = create(config, device="cpu") # type: ignore[call-arg]
load_state_dict(model, state_dict=raw)
model.eval()
return model
generator = load(GENERATOR_CARD, "generator_base.pth", AudioSealWMConfig, create_generator)
detector = load(DETECTOR_CARD, "detector_base.pth", AudioSealDetectorConfig, create_detector)
if torch is None: # pragma: no cover - defensive, import kept for symmetry
raise RuntimeError("torch import disappeared")
return generator, detector
def embed(generator: object, samples: object, message: Sequence[int]) -> object:
"""Embed one fixed bit message into a float32 mono sample array."""
import torch
wav = torch.from_numpy(samples).view(1, 1, -1)
msg = torch.tensor([list(message)], dtype=torch.float32)
with torch.no_grad():
marked = generator(wav, sample_rate=SAMPLE_RATE, alpha=1.0, message=msg)
return marked.view(-1).numpy()
def read(detector: object, samples: object) -> AudioSealReading:
"""Read detection probabilities and the decoded message from samples."""
import torch
wav = torch.from_numpy(samples).view(1, 1, -1)
with torch.no_grad():
result, message = detector(wav, sample_rate=SAMPLE_RATE)
probs = result[0, 1, :].numpy()
bits = tuple(int(bit) for bit in (message.view(-1).numpy() > MESSAGE_THRESHOLD).astype(int))
return AudioSealReading(
mean_detect_prob=float(probs.mean()),
frac_above_threshold=float((probs > DETECTION_THRESHOLD).mean()),
decoded_bits=bits,
)
def window_bounds(total_samples: int, window_s: float, sample_rate: int) -> list[tuple[int, int]]:
"""Half-open sample bounds of fixed seconds-long windows over a signal.
A trailing remainder shorter than ``window_s`` is its own final window, so
interval reporting never silently drops a partial second.
"""
if total_samples < 0:
raise ValueError("total_samples must be non-negative")
if window_s <= 0:
raise ValueError("window_s must be positive")
if sample_rate <= 0:
raise ValueError("sample_rate must be positive")
size = int(window_s * sample_rate)
if size <= 0:
raise ValueError("window_s is shorter than one sample")
return [(start, min(start + size, total_samples)) for start in range(0, total_samples, size)]
def read_intervals(detector: object, samples: object, *, window_s: float = 1.0) -> list[AudioSealReading]:
"""Read one detection per fixed time window, oldest window first."""
import numpy as np
array = np.asarray(samples, dtype=np.float32).reshape(-1)
return [read(detector, array[start:end]) for start, end in window_bounds(array.shape[0], window_s, SAMPLE_RATE)]
def detect_sample_array(detector: object, samples: object) -> str | None:
"""Return the decoded message label when the detector fires, else None."""
reading = read(detector, samples)
return reading.label if reading.detected else None