mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-14 13:30:55 +02:00
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.
This commit is contained in:
29 files changed
+4604
-48
No files matched your search
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Development-only VideoSeal temporal evaluation study.
|
||||
|
||||
Three questions the case-level benchmark verdict aggregates away:
|
||||
|
||||
- how the decoded bit accuracy decays across H.264 quality (crf 18-32),
|
||||
downscaling, and frame-rate halving;
|
||||
- whether the frame-aggregation choice (avg, squared_avg, l1norm_avg,
|
||||
l2norm_avg) changes the outcome on the same artifact;
|
||||
- how the per-frame bit accuracy moves in time on real provider clips versus
|
||||
synthetic carriers.
|
||||
|
||||
Carriers are the two synthetic cohort clips plus committed real provider
|
||||
videos (their bytes already carry the project's public test clearance). No
|
||||
provider API is contacted; artifacts stay outside the repository under
|
||||
``.local-eval/``.
|
||||
|
||||
uv run --extra dev python scripts/videoseal_temporal_study.py \
|
||||
--output-dir .local-eval/videoseal-temporal-v1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
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 audioseal_experiment import ffmpeg_version # noqa: E402
|
||||
from videoseal_oracle import read, read_aggregation_matrix # noqa: E402
|
||||
from watermark_benchmark import _decode_video, sha256_file # noqa: E402
|
||||
from watermark_benchmark_video_cohort import ( # noqa: E402
|
||||
FRAME_RATE,
|
||||
encode_clip,
|
||||
synth_carrier,
|
||||
)
|
||||
|
||||
log = logging.getLogger("videoseal_temporal_study")
|
||||
|
||||
CRF_SWEEP: tuple[int, ...] = (18, 23, 28, 32)
|
||||
ATTACKS: tuple[str, ...] = ("scale_075", "fps_half")
|
||||
REAL_CARRIERS: tuple[str, ...] = ("veo", "sora")
|
||||
STUDY_FRAMES = 64
|
||||
|
||||
|
||||
def require_tools() -> str:
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise SystemExit("videoseal temporal study requires ffmpeg on PATH")
|
||||
return ffmpeg
|
||||
|
||||
|
||||
def real_carrier_frames(name: str) -> tuple[np.ndarray, dict[str, object]]:
|
||||
"""Decode a committed provider clip to a square 256x256 64-frame prefix."""
|
||||
import cv2
|
||||
|
||||
source = REPO / "data" / "fixtures" / "visible" / name / "provider-original.mp4"
|
||||
if not source.is_file():
|
||||
raise SystemExit(f"provider fixture missing: {source}")
|
||||
frames = _decode_video(source)
|
||||
if frames is None or frames.shape[0] < STUDY_FRAMES:
|
||||
raise SystemExit(f"provider fixture {name} did not decode to {STUDY_FRAMES} frames")
|
||||
square = frames[:STUDY_FRAMES]
|
||||
height, width = square.shape[1:3]
|
||||
side = min(height, width)
|
||||
top = (height - side) // 2
|
||||
left = (width - side) // 2
|
||||
cropped = square[:, top : top + side, left : left + side, :]
|
||||
resized = np.stack([cv2.resize(frame, (256, 256), interpolation=cv2.INTER_AREA) for frame in cropped]).astype(
|
||||
np.float32
|
||||
)
|
||||
provenance = {
|
||||
"source": f"data/fixtures/visible/{name}/provider-original.mp4",
|
||||
"source_sha256": sha256_file(source),
|
||||
"source_geometry": [int(height), int(width)],
|
||||
"recipe": "first-64-frames, center-crop to square, INTER_AREA resize to 256x256",
|
||||
}
|
||||
return resized, provenance
|
||||
|
||||
|
||||
def apply_crf(ffmpeg: str, marked: Path, crf: int, workdir: Path) -> Path:
|
||||
target = workdir / f"{marked.stem}-crf{crf}.mp4"
|
||||
subprocess.run( # noqa: S603 - resolved ffmpeg with fixed arguments
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(marked),
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(target),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def apply_scale(ffmpeg: str, marked: Path, workdir: Path) -> Path:
|
||||
target = workdir / f"{marked.stem}-scale_075.mp4"
|
||||
subprocess.run( # noqa: S603 - resolved ffmpeg with fixed arguments
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(marked),
|
||||
"-vf",
|
||||
"scale=192:192",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"18",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(target),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def apply_fps_half(ffmpeg: str, marked: Path, workdir: Path) -> Path:
|
||||
target = workdir / f"{marked.stem}-fps_half.mp4"
|
||||
subprocess.run( # noqa: S603 - resolved ffmpeg with fixed arguments
|
||||
[
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(marked),
|
||||
"-vf",
|
||||
f"fps={FRAME_RATE / 2:.12g}",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
"8",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(target),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
out_dir: Path = args.output_dir.resolve()
|
||||
if out_dir.exists():
|
||||
raise SystemExit(f"refusing to overwrite existing output directory {out_dir}")
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
ffmpeg = require_tools()
|
||||
out_dir.mkdir(parents=True)
|
||||
model = videoseal_oracle.load_model()
|
||||
message = videoseal_oracle.message_bits()
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
|
||||
def record(stage: str, **fields: object) -> None:
|
||||
row = {"stage": stage, **fields}
|
||||
rows.append(row)
|
||||
log.info("%s", json.dumps(row, sort_keys=True, default=str)[:400])
|
||||
|
||||
import importlib.metadata as md
|
||||
|
||||
record(
|
||||
"pins",
|
||||
torch=md.version("torch"),
|
||||
videoseal_jit_sha256=videoseal_oracle.JIT_SHA256,
|
||||
study_source_sha256=sha256_file(Path(__file__)),
|
||||
ffmpeg=ffmpeg_version(ffmpeg),
|
||||
message_sha256=hashlib.sha256(bytes(message)).hexdigest(),
|
||||
)
|
||||
|
||||
carriers: dict[str, tuple[np.ndarray, dict[str, object]]] = {
|
||||
name: (synth_carrier(name)[:STUDY_FRAMES], {"recipe": "synthetic cohort carrier"})
|
||||
for name in ("moving_gradient", "moving_texture")
|
||||
}
|
||||
for name in REAL_CARRIERS:
|
||||
carriers[f"real_{name}"] = real_carrier_frames(name)
|
||||
|
||||
for name, (clean_frames, provenance) in carriers.items():
|
||||
workdir = out_dir / name
|
||||
workdir.mkdir()
|
||||
clean_path = encode_clip(ffmpeg, workdir / f"{name}-clean.mp4", clean_frames)
|
||||
record(
|
||||
"carrier_clean",
|
||||
carrier=name,
|
||||
sha256=sha256_file(clean_path),
|
||||
aggregation_matrix=read_aggregation_matrix(model, clean_frames),
|
||||
**provenance,
|
||||
)
|
||||
|
||||
marked_frames = np.asarray(videoseal_oracle.embed(model, clean_frames, message), dtype=np.float32)
|
||||
marked_path = encode_clip(ffmpeg, workdir / f"{name}-marked.mp4", marked_frames)
|
||||
marked_read = read(model, marked_frames)
|
||||
record(
|
||||
"carrier_marked",
|
||||
carrier=name,
|
||||
sha256=sha256_file(marked_path),
|
||||
bit_accuracy_avg=marked_read.bit_accuracy,
|
||||
aggregation_matrix=read_aggregation_matrix(model, marked_frames),
|
||||
per_frame_bit_accuracy=[round(v, 4) for v in marked_read.per_frame_bit_accuracy],
|
||||
wm_snr_db=round(
|
||||
float(
|
||||
10
|
||||
* np.log10(
|
||||
np.sum(clean_frames.astype(np.float64) ** 2)
|
||||
/ max(np.sum((marked_frames - clean_frames).astype(np.float64) ** 2), 1e-12)
|
||||
)
|
||||
),
|
||||
3,
|
||||
),
|
||||
)
|
||||
|
||||
attacked: list[tuple[str, Path]] = [
|
||||
(f"crf{crf}", apply_crf(ffmpeg, marked_path, crf, workdir)) for crf in CRF_SWEEP
|
||||
]
|
||||
attacked += [
|
||||
("scale_075", apply_scale(ffmpeg, marked_path, workdir)),
|
||||
("fps_half", apply_fps_half(ffmpeg, marked_path, workdir)),
|
||||
]
|
||||
for attack, path in attacked:
|
||||
frames = _decode_video(path)
|
||||
if frames is None:
|
||||
raise SystemExit(f"attack artifact failed to decode: {path}")
|
||||
reading = read(model, frames)
|
||||
record(
|
||||
"attack",
|
||||
carrier=name,
|
||||
attack=attack,
|
||||
sha256=sha256_file(path),
|
||||
frames=frames.shape[0],
|
||||
bit_accuracy_avg=reading.bit_accuracy,
|
||||
aggregation_matrix=read_aggregation_matrix(model, frames),
|
||||
per_frame_bit_accuracy=[round(v, 4) for v in reading.per_frame_bit_accuracy],
|
||||
)
|
||||
|
||||
cases_path = out_dir / "cases.jsonl"
|
||||
cases_path.write_text("".join(json.dumps(r, default=str) + "\n" for r in rows))
|
||||
summary_path = out_dir / "matrix.md"
|
||||
summary_path.write_text(render_matrix(rows))
|
||||
print(f"wrote {cases_path}")
|
||||
print(f"wrote {summary_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def cast_to_matrix(value: object) -> dict[str, float]:
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"aggregation matrix must be a dict, got {type(value).__name__}")
|
||||
return value # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def render_matrix(rows: list[dict[str, object]]) -> str:
|
||||
aggregations = videoseal_oracle.AGGREGATIONS
|
||||
lines = [
|
||||
"# VideoSeal temporal evaluation study",
|
||||
"",
|
||||
"Bit accuracy of the aggregated 256-bit decode per carrier, attack, and",
|
||||
"frame aggregation. The benchmark threshold (0.9) is the adapter's",
|
||||
"verdict rule; this matrix shows the readings underneath it.",
|
||||
"",
|
||||
"| Carrier | Attack | " + " | ".join(aggregations) + " |",
|
||||
"| --- | --- | " + " | ".join(["---"] * len(aggregations)) + " |",
|
||||
]
|
||||
clean_rows = {str(r["carrier"]): r for r in rows if r["stage"] == "carrier_clean"}
|
||||
for carrier, row in sorted(clean_rows.items()):
|
||||
matrix = cast_to_matrix(row["aggregation_matrix"])
|
||||
lines.append(f"| {carrier} | (clean) | " + " | ".join(f"{float(matrix[a]):.3f}" for a in aggregations) + " |")
|
||||
for r in rows:
|
||||
if r["stage"] != "attack":
|
||||
continue
|
||||
matrix = cast_to_matrix(r["aggregation_matrix"])
|
||||
lines.append(
|
||||
f"| {r['carrier']} | {r['attack']} | " + " | ".join(f"{float(matrix[a]):.3f}" for a in aggregations) + " |"
|
||||
)
|
||||
lines += [
|
||||
"",
|
||||
"Per-frame bit accuracy ranges (avg aggregation) for marked clips:",
|
||||
"",
|
||||
"| Carrier | min | mean | max |",
|
||||
"| --- | --- | --- | --- |",
|
||||
]
|
||||
for r in rows:
|
||||
if r["stage"] != "carrier_marked":
|
||||
continue
|
||||
per_frame = [float(v) for v in r["per_frame_bit_accuracy"]] # type: ignore[arg-type]
|
||||
lines.append(
|
||||
f"| {r['carrier']} | {min(per_frame):.3f} | {sum(per_frame) / len(per_frame):.3f} | {max(per_frame):.3f} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user