mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
The shipped profile was certified by one oracle row, but only noise_std was pinned: long_side and fps -- two thirds of what the verifier was actually shown -- could move with a green suite. The test now derives the pin from data/evaluations/video-synthid-oracle.csv, so a default without a certifying row fails. The certified profile is a perturbation-to-signal ratio, not a bare noise_std. sd-vae-ft-mse publishes no scaling_factor key, so 0.18215 comes from the AutoencoderKL class default under an upper-unbounded diffusers pin. The loader now gates that value, carries it on VideoVaeRuntime, and passes it into encode and decode so the validated value is the applied value. video_synthid_sweep.py loads through the same function: the harness producing the certified rows was the one path exempt from the gate it exists to feed. psnr_db is measured against the already-resized frame and before the encoder, so it cannot see the downscale, the decimation, or the codec, and no in-loop metric can. scripts/video_fidelity_probe.py scores the delivered file end to end, streaming the way the engine does and sharing its frame-selection rule rather than copying it -- a frame-count check cannot catch a rule that reorders frames without changing how many. The manifest gains source geometry, vae, track, verbatim verdict and session fields. The two 2026-07-31 rows keep them empty: they were never recorded and are not recoverable. Verdicts now have four states, because the verifier's unclear reading logged as not_detected is the silent regression the manifest exists to prevent. docs/video-synthid-quality-research.md records the research behind this: the noise axis is worth about 2 dB and is nearly exhausted, resolution is the real prize but is an uncertified destruction axis rather than a free win, and every proposed autoencoder swap was refuted. First local measurements included. Verified: engine output is byte-identical before and after the refactor on a locally built clip, at noise_std 0.00 and 0.15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
8.5 KiB
Python
244 lines
8.5 KiB
Python
"""Build oracle-gated video regeneration candidates for SynthID research.
|
|
|
|
This is a research harness, not a shipped removal command. Google does not
|
|
publish a local video SynthID decoder, so the script cannot label a candidate as
|
|
clean. It produces:
|
|
|
|
* a re-encode control with the same duration, frame rate, dimensions, and codec;
|
|
* one VAE-regenerated video per requested latent-noise level;
|
|
* paired fidelity and temporal-residual measurements;
|
|
* a CSV column for the external Gemini SynthID verdict.
|
|
|
|
The control is load-bearing. If it reads clean, the experiment is invalid:
|
|
resize, frame-rate conversion, or H.264 compression already silenced the oracle,
|
|
so a VAE candidate cannot be credited with removal.
|
|
|
|
The regeneration attack follows the general encode, perturb, reconstruct family
|
|
from WatermarkAttacker (NeurIPS 2024). A single spatial latent-noise sample is
|
|
shared by every frame. Independent per-frame noise creates avoidable flicker and
|
|
does not test the video-specific question.
|
|
|
|
Run with the project's GPU extra:
|
|
|
|
uv run --extra gpu python scripts/video_synthid_sweep.py input.mp4 -o out/
|
|
|
|
Then upload ``control.mp4`` and each candidate in separate Gemini chats, invoke
|
|
the built-in SynthID verifier (``@synthid``), and use the question printed by
|
|
the script. Do not follow the verdict with an adversarial prompt asking the chat
|
|
model to reinterpret the detector: that switches back to ordinary reasoning.
|
|
Only a control-positive, candidate-negative pair from the built-in verifier is
|
|
removal evidence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import hashlib
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import click
|
|
import numpy as np
|
|
|
|
from remove_ai_watermarks.video_invisible import (
|
|
_decode_frame_latents,
|
|
_encode_frame_latents,
|
|
_fit_size,
|
|
_probe_video,
|
|
_shared_latent_noise,
|
|
build_temporal_reference,
|
|
encode_video_frames,
|
|
load_video_vae_runtime,
|
|
paired_psnr,
|
|
read_sampled_frames,
|
|
temporal_residual_ratio,
|
|
)
|
|
from remove_ai_watermarks.video_synthid import (
|
|
DEFAULT_VIDEO_SYNTHID_FPS,
|
|
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
|
DEFAULT_VIDEO_SYNTHID_VAE,
|
|
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
|
VIDEO_SYNTHID_VERIFICATION_PROMPT,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_noise_levels(values: str) -> tuple[float, ...]:
|
|
levels = tuple(float(value.strip()) for value in values.split(",") if value.strip())
|
|
if not levels:
|
|
raise click.BadParameter("At least one noise level is required")
|
|
if any(not 0.0 <= value <= 1.0 for value in levels):
|
|
raise click.BadParameter("Noise levels must be between 0 and 1")
|
|
return levels
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _write_manifest(output_dir: Path, rows: Sequence[dict[str, str]]) -> Path:
|
|
path = output_dir / "sweep.csv"
|
|
# Mirrors the tracked manifest's run-configuration fields (data/README.md) so a
|
|
# row carries into data/evaluations/video-synthid-oracle.csv without hand
|
|
# reconstruction. The two 2026-07-31 rows show the cost of not doing this: their
|
|
# geometry was never recorded and cannot be recovered from the row.
|
|
fieldnames = [
|
|
"variant",
|
|
"source_sha256",
|
|
"source_width",
|
|
"source_height",
|
|
"source_fps",
|
|
"duration_seconds",
|
|
"vae",
|
|
"noise_std",
|
|
"long_side",
|
|
"fps",
|
|
"seed",
|
|
"psnr_db",
|
|
"temporal_residual_ratio",
|
|
"file",
|
|
"sha256",
|
|
"synthid_oracle",
|
|
]
|
|
with path.open("w", newline="", encoding="utf-8") as stream:
|
|
writer = csv.DictWriter(stream, fieldnames=fieldnames)
|
|
writer.writeheader()
|
|
writer.writerows(rows)
|
|
return path
|
|
|
|
|
|
@click.command()
|
|
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
@click.option("-o", "--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
|
@click.option("--noise-levels", default="0,0.05,0.1,0.15", show_default=True)
|
|
@click.option("--duration", type=click.FloatRange(min=0.1), default=2.0, show_default=True)
|
|
@click.option("--fps", type=click.FloatRange(min=1.0), default=DEFAULT_VIDEO_SYNTHID_FPS, show_default=True)
|
|
@click.option(
|
|
"--long-side",
|
|
type=click.IntRange(min=VIDEO_SYNTHID_LATENT_MULTIPLE),
|
|
default=DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
|
show_default=True,
|
|
)
|
|
@click.option("--batch-size", type=click.IntRange(min=1), default=4, show_default=True)
|
|
@click.option("--seed", type=int, default=0, show_default=True)
|
|
@click.option("--model", default=DEFAULT_VIDEO_SYNTHID_VAE, show_default=True)
|
|
@click.option("--device", type=click.Choice(["auto", "cuda", "mps", "cpu"]), default="auto", show_default=True)
|
|
def main(
|
|
source: Path,
|
|
output_dir: Path,
|
|
noise_levels: str,
|
|
duration: float,
|
|
fps: float,
|
|
long_side: int,
|
|
batch_size: int,
|
|
seed: int,
|
|
model: str,
|
|
device: str,
|
|
) -> None:
|
|
"""Generate VAE video candidates from the prefix of SOURCE."""
|
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
levels = _parse_noise_levels(noise_levels)
|
|
width, height, source_fps = _probe_video(source)
|
|
size = _fit_size(width, height, long_side)
|
|
frames, effective_fps = read_sampled_frames(source, duration=duration, output_fps=fps, size=size)
|
|
run = {
|
|
"source_sha256": _sha256(source),
|
|
"source_width": str(width),
|
|
"source_height": str(height),
|
|
"source_fps": f"{source_fps:.4f}",
|
|
"duration_seconds": f"{duration:.4f}",
|
|
"vae": model,
|
|
"long_side": str(long_side),
|
|
"fps": f"{effective_fps:.4f}",
|
|
"seed": str(seed),
|
|
}
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
control_path = output_dir / "control.mp4"
|
|
encode_video_frames(frames, source, control_path, fps=effective_fps)
|
|
rows: list[dict[str, str]] = [
|
|
{
|
|
**run,
|
|
"variant": "control",
|
|
"noise_std": "",
|
|
"psnr_db": "inf",
|
|
"temporal_residual_ratio": "1",
|
|
"file": control_path.name,
|
|
"sha256": _sha256(control_path),
|
|
"synthid_oracle": "",
|
|
}
|
|
]
|
|
|
|
# Load through the engine's own loader rather than repeating it here: the
|
|
# scaling-factor gate lives there, and the harness that produces the certified
|
|
# rows is the last place that should be exempt from it.
|
|
runtime = load_video_vae_runtime(model=model, device=device)
|
|
vae, resolved_device = runtime.vae, runtime.resolved_device
|
|
|
|
log.info("Encoding source frames")
|
|
latent_batches = _encode_frame_latents(
|
|
frames,
|
|
vae=vae,
|
|
device=resolved_device,
|
|
batch_size=batch_size,
|
|
scaling_factor=runtime.scaling_factor,
|
|
)
|
|
first_latents = latent_batches[0]
|
|
shared_noise = _shared_latent_noise(
|
|
first_latents.shape[1:],
|
|
seed=seed,
|
|
device=resolved_device,
|
|
dtype=first_latents.dtype,
|
|
)
|
|
reference_stack = np.stack(frames)
|
|
temporal_maps, temporal_baseline = build_temporal_reference(frames)
|
|
for level in levels:
|
|
log.info("Decoding latent noise %.4f", level)
|
|
regenerated = _decode_frame_latents(
|
|
latent_batches,
|
|
vae=vae,
|
|
noise_std=level,
|
|
shared_noise=shared_noise,
|
|
scaling_factor=runtime.scaling_factor,
|
|
)
|
|
output_path = output_dir / f"vae-noise-{level:.4f}.mp4"
|
|
encode_video_frames(
|
|
regenerated,
|
|
source,
|
|
output_path,
|
|
fps=effective_fps,
|
|
)
|
|
psnr = paired_psnr(reference_stack, np.stack(regenerated))
|
|
temporal_ratio = temporal_residual_ratio(regenerated, temporal_maps, temporal_baseline)
|
|
rows.append(
|
|
{
|
|
**run,
|
|
"variant": "vae",
|
|
"noise_std": f"{level:.4f}",
|
|
"psnr_db": f"{psnr:.4f}",
|
|
"temporal_residual_ratio": f"{temporal_ratio:.4f}",
|
|
"file": output_path.name,
|
|
"sha256": _sha256(output_path),
|
|
"synthid_oracle": "",
|
|
}
|
|
)
|
|
|
|
manifest = _write_manifest(output_dir, rows)
|
|
log.info("Wrote %s", manifest)
|
|
log.info(
|
|
"Verify control.mp4 first with Gemini's built-in SynthID verifier and this question: %s",
|
|
VIDEO_SYNTHID_VERIFICATION_PROMPT,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|