mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 05:20:46 +02:00
179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""Measure VideoSeal message replacement on decoded, saved MP4 artifacts.
|
|
|
|
The pair of carriers is the synthetic benchmark gradient and a committed,
|
|
publication-cleared video fixture. Each clip is encoded before it is used by
|
|
the next stage, so detector and fidelity measurements refer to the retained
|
|
MP4 bytes. Outputs belong outside the repository under ``.local-eval/``.
|
|
|
|
uv run --extra dev python scripts/rewatermarking_video_study.py \
|
|
--output-dir /path/to/.local-eval/rewatermarking-video-2026-09-27
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import shutil
|
|
import statistics
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
REPO = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(REPO / "src"))
|
|
sys.path.insert(0, str(REPO / "scripts"))
|
|
|
|
import videoseal_oracle # noqa: E402
|
|
from videoseal_temporal_study import real_carrier_frames # noqa: E402
|
|
from watermark_benchmark import decode_video_artifact, repository_state, sha256_file # noqa: E402
|
|
from watermark_benchmark_video_cohort import encode_clip, synth_carrier # noqa: E402
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def bit_accuracy(decoded: Sequence[int], expected: Sequence[int]) -> float:
|
|
"""Return matching-bit fraction for equal-length messages."""
|
|
if not decoded or len(decoded) != len(expected):
|
|
raise ValueError("bit strings must be non-empty and have equal length")
|
|
return sum(int(bit == want) for bit, want in zip(decoded, expected, strict=True)) / len(expected)
|
|
|
|
|
|
def mean_video_psnr(reference: np.ndarray[Any, Any], artifact: np.ndarray[Any, Any]) -> float | None:
|
|
"""Match the benchmark kernel's mean of per-frame decoded RGB PSNR."""
|
|
if reference.shape != artifact.shape or reference.ndim != 4 or reference.shape[-1] != 3:
|
|
raise ValueError("video frames must share a (T, H, W, 3) shape")
|
|
per_frame_mse = np.empty(reference.shape[0], dtype=np.float64)
|
|
for index, (reference_frame, artifact_frame) in enumerate(zip(reference, artifact, strict=True)):
|
|
delta = np.subtract(artifact_frame, reference_frame, dtype=np.float32)
|
|
np.square(delta, out=delta)
|
|
per_frame_mse[index] = np.mean(delta, dtype=np.float64)
|
|
finite = per_frame_mse[per_frame_mse > 0.0]
|
|
if finite.size == 0:
|
|
return None
|
|
return float(np.mean(10.0 * np.log10(1.0 / finite)))
|
|
|
|
|
|
def _measure(
|
|
model: object,
|
|
frames: np.ndarray[Any, Any],
|
|
message_a: Sequence[int],
|
|
message_b: Sequence[int],
|
|
) -> dict[str, Any]:
|
|
reading = videoseal_oracle.read(model, frames, message=message_a)
|
|
accuracy_b = bit_accuracy(reading.decoded_bits, message_b)
|
|
return {
|
|
"bit_accuracy_vs_a": reading.bit_accuracy,
|
|
"bit_accuracy_vs_b": accuracy_b,
|
|
"detected_as_a": reading.bit_accuracy >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD,
|
|
"detected_as_b": accuracy_b >= videoseal_oracle.DETECTION_BIT_ACCURACY_THRESHOLD,
|
|
}
|
|
|
|
|
|
def run_study(output_dir: Path, *, torch_threads: int = 2) -> Path:
|
|
if output_dir.exists():
|
|
raise FileExistsError(f"refusing to overwrite existing output directory {output_dir}")
|
|
ffmpeg = shutil.which("ffmpeg")
|
|
if ffmpeg is None:
|
|
raise RuntimeError("ffmpeg is required for the video study")
|
|
import torch
|
|
|
|
torch.set_num_threads(torch_threads)
|
|
model = videoseal_oracle.load_model()
|
|
message_a = videoseal_oracle.message_bits(7)
|
|
message_b = videoseal_oracle.message_bits(8)
|
|
output_dir.mkdir(parents=True)
|
|
carriers = {
|
|
"moving_gradient": (
|
|
synth_carrier("moving_gradient")[:64],
|
|
{"source": "deterministic synthetic benchmark carrier"},
|
|
),
|
|
"real_sora": real_carrier_frames("sora"),
|
|
}
|
|
rows: list[dict[str, Any]] = []
|
|
for name, (source_frames, provenance) in carriers.items():
|
|
log.info("Measuring %s", name)
|
|
case_dir = output_dir / name
|
|
case_dir.mkdir()
|
|
clean_path = encode_clip(ffmpeg, case_dir / "clean.mp4", source_frames)
|
|
clean, clean_sha256 = decode_video_artifact(clean_path)
|
|
marked_path = encode_clip(
|
|
ffmpeg,
|
|
case_dir / "marked-a.mp4",
|
|
np.asarray(videoseal_oracle.embed(model, clean, message_a)),
|
|
)
|
|
marked, marked_sha256 = decode_video_artifact(marked_path)
|
|
rewatermarked_path = encode_clip(
|
|
ffmpeg,
|
|
case_dir / "rewatermarked-b.mp4",
|
|
np.asarray(videoseal_oracle.embed(model, marked, message_b)),
|
|
)
|
|
rewatermarked, rewatermarked_sha256 = decode_video_artifact(rewatermarked_path)
|
|
baseline = _measure(model, clean, message_a, message_b)
|
|
marked_reading = _measure(model, marked, message_a, message_b)
|
|
rewatermarked_reading = _measure(model, rewatermarked, message_a, message_b)
|
|
row = {
|
|
"carrier": name,
|
|
"provenance": provenance,
|
|
"frame_shape": list(clean.shape),
|
|
"clean": {"sha256": clean_sha256, **baseline},
|
|
"marked": {
|
|
"sha256": marked_sha256,
|
|
**marked_reading,
|
|
"mean_psnr_from_clean_db": mean_video_psnr(clean, marked),
|
|
},
|
|
"rewatermarked": {
|
|
"sha256": rewatermarked_sha256,
|
|
**rewatermarked_reading,
|
|
"mean_psnr_from_clean_db": mean_video_psnr(clean, rewatermarked),
|
|
"mean_psnr_from_marked_db": mean_video_psnr(marked, rewatermarked),
|
|
},
|
|
"attack_success": (
|
|
marked_reading["detected_as_a"]
|
|
and not rewatermarked_reading["detected_as_a"]
|
|
and rewatermarked_reading["detected_as_b"]
|
|
),
|
|
}
|
|
rows.append(row)
|
|
|
|
cases_path = output_dir / "cases.jsonl"
|
|
cases_path.write_text("".join(json.dumps(row, sort_keys=True) + "\n" for row in rows), encoding="utf-8")
|
|
report = {
|
|
"repository_head": repository_state()["commit"],
|
|
"study_script_sha256": sha256_file(Path(__file__)),
|
|
"case_rows_sha256": sha256_file(cases_path),
|
|
"videoseal_jit_sha256": videoseal_oracle.JIT_SHA256,
|
|
"torch_threads": torch_threads,
|
|
"carriers": len(rows),
|
|
"attack_successes": sum(int(row["attack_success"]) for row in rows),
|
|
"mean_psnr_from_clean_db": statistics.fmean(row["rewatermarked"]["mean_psnr_from_clean_db"] for row in rows),
|
|
}
|
|
report_path = output_dir / "report.json"
|
|
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
log.info("Wrote %s", report_path)
|
|
return report_path
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output-dir", type=Path, required=True)
|
|
parser.add_argument("--torch-threads", type=int, default=2)
|
|
args = parser.parse_args()
|
|
if args.torch_threads < 1:
|
|
parser.error("--torch-threads must be positive")
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|
try:
|
|
run_study(args.output_dir.resolve(), torch_threads=args.torch_threads)
|
|
except (FileExistsError, ImportError, OSError, RuntimeError, ValueError) as exc:
|
|
parser.error(str(exc))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|