mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
Add streaming video SynthID regeneration
This commit is contained in:
@@ -8,6 +8,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
|
||||
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
|
||||
raiw.inspect_video_metadata("in.mp4") # -> VideoMetadataReport
|
||||
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
|
||||
raiw.remove_video_invisible("in.mp4", "out.mp4") # unverified SynthID candidate
|
||||
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
|
||||
|
||||
For a provenance verdict use the ``identify`` submodule::
|
||||
@@ -33,6 +34,7 @@ __version__ = "0.20.2"
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"inspect_video_metadata",
|
||||
"remove_video_invisible",
|
||||
"remove_video_metadata",
|
||||
"remove_video_visible",
|
||||
"remove_visible",
|
||||
@@ -41,7 +43,12 @@ __all__ = [
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.api import remove_visible, visible_provenance
|
||||
from remove_ai_watermarks.video import inspect_video_metadata, remove_video_metadata, remove_video_visible
|
||||
from remove_ai_watermarks.video import (
|
||||
inspect_video_metadata,
|
||||
remove_video_invisible,
|
||||
remove_video_metadata,
|
||||
remove_video_visible,
|
||||
)
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
@@ -51,7 +58,7 @@ def __getattr__(name: str) -> object:
|
||||
from remove_ai_watermarks import api
|
||||
|
||||
return getattr(api, name)
|
||||
if name in ("inspect_video_metadata", "remove_video_metadata", "remove_video_visible"):
|
||||
if name in ("inspect_video_metadata", "remove_video_invisible", "remove_video_metadata", "remove_video_visible"):
|
||||
from remove_ai_watermarks import video
|
||||
|
||||
return getattr(video, name)
|
||||
|
||||
@@ -28,6 +28,13 @@ from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
strength_default_help,
|
||||
vendor_for_strength,
|
||||
)
|
||||
from remove_ai_watermarks.video_synthid import (
|
||||
DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
VIDEO_SYNTHID_VERIFICATION_PROMPT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
@@ -1141,6 +1148,87 @@ def cmd_video_metadata(
|
||||
console.print(f" AI metadata stripped -> {result.output}")
|
||||
|
||||
|
||||
@cmd_video.command("invisible")
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"-o",
|
||||
"--output",
|
||||
type=click.Path(path_type=Path),
|
||||
default=None,
|
||||
help="Candidate path (default: <source>_synthid_candidate with the same container).",
|
||||
)
|
||||
@click.option(
|
||||
"--noise-std",
|
||||
type=click.FloatRange(min=0.0, max=1.0),
|
||||
default=DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
show_default=True,
|
||||
help="Shared latent-noise strength. Higher values change more detail.",
|
||||
)
|
||||
@click.option(
|
||||
"--long-side",
|
||||
type=click.IntRange(min=VIDEO_SYNTHID_LATENT_MULTIPLE),
|
||||
default=DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
show_default=True,
|
||||
help="Regenerated video long side in pixels.",
|
||||
)
|
||||
@click.option(
|
||||
"--fps",
|
||||
type=click.FloatRange(min=1.0),
|
||||
default=DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
show_default=True,
|
||||
help="Output frame rate, capped at the source frame rate.",
|
||||
)
|
||||
@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(
|
||||
"--device",
|
||||
type=click.Choice(["auto", "cuda", "mps", "cpu"]),
|
||||
default="auto",
|
||||
show_default=True,
|
||||
help="VAE inference device.",
|
||||
)
|
||||
def cmd_video_invisible(
|
||||
source: Path,
|
||||
output: Path | None,
|
||||
noise_std: float,
|
||||
long_side: int,
|
||||
fps: float,
|
||||
batch_size: int,
|
||||
seed: int,
|
||||
device: str,
|
||||
) -> None:
|
||||
"""Generate an externally verifiable video SynthID candidate."""
|
||||
from remove_ai_watermarks.video import remove_video_invisible
|
||||
|
||||
_banner()
|
||||
console.print(f" Regenerating {source.name} with temporally shared VAE noise...")
|
||||
try:
|
||||
result = remove_video_invisible(
|
||||
source,
|
||||
output,
|
||||
noise_std=noise_std,
|
||||
long_side=long_side,
|
||||
fps=fps,
|
||||
batch_size=batch_size,
|
||||
seed=seed,
|
||||
device=device,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
raise click.ClickException(str(e)) from e
|
||||
|
||||
if result.remaining_metadata:
|
||||
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
|
||||
raise SystemExit(1)
|
||||
console.print(
|
||||
f" Candidate generated: {result.width}x{result.height}, "
|
||||
f"{result.total_frames} frames at {result.fps:.4g} fps -> {result.output}"
|
||||
)
|
||||
console.print(
|
||||
" UNVERIFIED: no local video SynthID decoder exists. Upload this output to Gemini Flash and ask: "
|
||||
f'"{VIDEO_SYNTHID_VERIFICATION_PROMPT}"'
|
||||
)
|
||||
|
||||
|
||||
@cmd_video.command("visible")
|
||||
@click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
"""High-level video processing API.
|
||||
|
||||
Supported experimental stages are container-level AI metadata inspection and
|
||||
removal plus temporally stabilized visible Sora, Veo, Seedance, and Dola
|
||||
removal. The pixel path reuses the image package's shared fill backends.
|
||||
removal, temporally stabilized visible Sora, Veo, Seedance, and Dola removal,
|
||||
and VAE regeneration that produces an externally verifiable SynthID candidate.
|
||||
The visible pixel path reuses the image package's shared fill backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, Literal
|
||||
|
||||
from remove_ai_watermarks.video_synthid import (
|
||||
DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
DEFAULT_VIDEO_SYNTHID_VAE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.video_invisible import RegenerationMetrics
|
||||
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv"})
|
||||
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
|
||||
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
|
||||
_REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
|
||||
_EBML_MAGIC = b"\x1aE\xdf\xa3"
|
||||
|
||||
|
||||
@@ -48,6 +61,42 @@ class VideoVisibleResult:
|
||||
remaining_metadata: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoInvisibleResult:
|
||||
"""Result of generating an externally verifiable SynthID candidate."""
|
||||
|
||||
source: Path
|
||||
output: Path
|
||||
noise_std: float
|
||||
metrics: RegenerationMetrics
|
||||
remaining_metadata: dict[str, str]
|
||||
requires_external_verification: ClassVar[Literal[True]] = True
|
||||
|
||||
@property
|
||||
def total_frames(self) -> int:
|
||||
return self.metrics.frames
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
return self.metrics.fps
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.metrics.width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.metrics.height
|
||||
|
||||
@property
|
||||
def psnr_db(self) -> float:
|
||||
return self.metrics.psnr_db
|
||||
|
||||
@property
|
||||
def temporal_residual_ratio(self) -> float:
|
||||
return self.metrics.temporal_residual_ratio
|
||||
|
||||
|
||||
def _video_source(source: str | Path) -> Path:
|
||||
path = Path(source)
|
||||
if not path.exists():
|
||||
@@ -231,3 +280,57 @@ def remove_video_visible(
|
||||
removed_frames=removed_frames,
|
||||
remaining_metadata=remaining_metadata,
|
||||
)
|
||||
|
||||
|
||||
def remove_video_invisible(
|
||||
source: str | Path,
|
||||
output: str | Path | None = None,
|
||||
*,
|
||||
noise_std: float = DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
long_side: int = DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
fps: float = DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
batch_size: int = 4,
|
||||
seed: int = 0,
|
||||
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
|
||||
device: str = "auto",
|
||||
) -> VideoInvisibleResult:
|
||||
"""Generate a video SynthID-removal candidate through VAE regeneration.
|
||||
|
||||
The function strips source metadata during the transcode, but cannot verify
|
||||
the proprietary pixel watermark locally. ``requires_external_verification``
|
||||
therefore remains true for every result. Verify important output with
|
||||
Google's matching content-verification flow.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
|
||||
|
||||
source_path = _video_source(source)
|
||||
if source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
|
||||
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
|
||||
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
|
||||
candidate_output = (
|
||||
Path(output) if output is not None else source_path.with_stem(source_path.stem + "_synthid_candidate")
|
||||
)
|
||||
output_path = _video_output(
|
||||
source_path,
|
||||
candidate_output,
|
||||
operation="SynthID candidate generation",
|
||||
)
|
||||
metrics = regenerate_video_candidate(
|
||||
source_path,
|
||||
output_path,
|
||||
noise_std=noise_std,
|
||||
long_side=long_side,
|
||||
fps=fps,
|
||||
batch_size=batch_size,
|
||||
seed=seed,
|
||||
model=model,
|
||||
device=device,
|
||||
)
|
||||
return VideoInvisibleResult(
|
||||
source=source_path,
|
||||
output=output_path,
|
||||
noise_std=noise_std,
|
||||
metrics=metrics,
|
||||
remaining_metadata=get_ai_metadata(output_path),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Shared ffmpeg raw-video encoding helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _video_codec_args(suffix: str, *, crf: int) -> list[str]:
|
||||
if suffix == ".webm":
|
||||
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0"]
|
||||
return ["-c:v", "libx264", "-preset", "medium", "-crf", str(crf)]
|
||||
|
||||
|
||||
def raw_video_command(
|
||||
source: Path,
|
||||
output: Path,
|
||||
*,
|
||||
width: int,
|
||||
height: int,
|
||||
fps: float,
|
||||
strip_metadata: bool,
|
||||
crf: int,
|
||||
) -> list[str]:
|
||||
"""Build an ffmpeg command that accepts BGR frames on standard input."""
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise RuntimeError("Video processing requires ffmpeg on PATH")
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-s:v",
|
||||
f"{width}x{height}",
|
||||
"-r",
|
||||
f"{fps:.12g}",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a?",
|
||||
*_video_codec_args(output.suffix.lower(), crf=crf),
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-map_metadata",
|
||||
"-1" if strip_metadata else "1",
|
||||
"-map_chapters",
|
||||
"-1" if strip_metadata else "1",
|
||||
"-shortest",
|
||||
]
|
||||
if output.suffix.lower() in {".mp4", ".mov", ".m4v"}:
|
||||
command.extend(["-movflags", "+faststart"])
|
||||
command.append(str(output))
|
||||
return command
|
||||
|
||||
|
||||
def start_raw_video_encoder(command: list[str]) -> subprocess.Popen[bytes]:
|
||||
"""Start ffmpeg and validate its raw-frame pipes."""
|
||||
log.info("Starting ffmpeg video encode: command=%s", command)
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
if process.stdin is None or process.stderr is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise RuntimeError("Could not open ffmpeg pipes")
|
||||
return process
|
||||
|
||||
|
||||
def finish_raw_video_encoder(
|
||||
process: subprocess.Popen[bytes],
|
||||
output: Path,
|
||||
*,
|
||||
operation: str,
|
||||
) -> None:
|
||||
"""Close the frame stream and raise when ffmpeg rejects the encode."""
|
||||
if process.stdin is None or process.stderr is None:
|
||||
raise RuntimeError("ffmpeg pipes are unavailable")
|
||||
process.stdin.close()
|
||||
stderr = process.stderr.read().decode("utf-8", errors="replace")
|
||||
return_code = process.wait()
|
||||
log.info("ffmpeg %s finished: status=%s stderr=%s", operation, return_code, stderr)
|
||||
if return_code != 0:
|
||||
raise RuntimeError(f"ffmpeg failed to encode {output}: {stderr.strip()[:500]}")
|
||||
|
||||
|
||||
def abort_raw_video_encoder(process: subprocess.Popen[bytes]) -> None:
|
||||
"""Stop an incomplete ffmpeg encode."""
|
||||
process.kill()
|
||||
process.wait()
|
||||
@@ -0,0 +1,513 @@
|
||||
"""VAE regeneration for externally verified video SynthID candidates.
|
||||
|
||||
Google does not publish a local video SynthID decoder. This module therefore
|
||||
regenerates pixels and measures fidelity, but never labels its output clean.
|
||||
Callers must verify the candidate with Google's matching content-verification
|
||||
flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# torch/diffusers/cv2 expose incomplete types at this boundary. Pure helpers
|
||||
# remain annotated while third-party tensor and image calls are relaxed here.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.video_encoding import (
|
||||
abort_raw_video_encoder,
|
||||
finish_raw_video_encoder,
|
||||
raw_video_command,
|
||||
start_raw_video_encoder,
|
||||
)
|
||||
from remove_ai_watermarks.video_synthid import (
|
||||
DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
DEFAULT_VIDEO_SYNTHID_VAE,
|
||||
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegenerationMetrics:
|
||||
"""Measured properties of one regenerated video candidate."""
|
||||
|
||||
frames: int
|
||||
fps: float
|
||||
width: int
|
||||
height: int
|
||||
psnr_db: float
|
||||
temporal_residual_ratio: float
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Return whether the optional VAE runtime can be imported."""
|
||||
return find_spec("torch") is not None and find_spec("diffusers") is not None
|
||||
|
||||
|
||||
def _fit_size(width: int, height: int, long_side: int) -> tuple[int, int]:
|
||||
"""Fit dimensions to ``long_side`` while preserving aspect and VAE alignment."""
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError("Video dimensions must be positive")
|
||||
if long_side < VIDEO_SYNTHID_LATENT_MULTIPLE:
|
||||
raise ValueError(f"Long side must be at least {VIDEO_SYNTHID_LATENT_MULTIPLE}")
|
||||
scale = long_side / max(width, height)
|
||||
fitted_width = max(
|
||||
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
round(width * scale) // VIDEO_SYNTHID_LATENT_MULTIPLE * VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
)
|
||||
fitted_height = max(
|
||||
VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
round(height * scale) // VIDEO_SYNTHID_LATENT_MULTIPLE * VIDEO_SYNTHID_LATENT_MULTIPLE,
|
||||
)
|
||||
return fitted_width, fitted_height
|
||||
|
||||
|
||||
def _pick_device(requested: str) -> str:
|
||||
import torch
|
||||
|
||||
if requested == "auto":
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
return "cpu"
|
||||
if requested == "cuda" and not torch.cuda.is_available():
|
||||
raise RuntimeError("CUDA was requested but is not available")
|
||||
if requested == "mps" and not (hasattr(torch.backends, "mps") and torch.backends.mps.is_available()):
|
||||
raise RuntimeError("MPS was requested but is not available")
|
||||
return requested
|
||||
|
||||
|
||||
def _shared_latent_noise(
|
||||
spatial_shape: Sequence[int],
|
||||
*,
|
||||
seed: int,
|
||||
device: str,
|
||||
dtype: Any,
|
||||
) -> Any:
|
||||
"""Return one deterministic spatial noise field for reuse across time."""
|
||||
import torch
|
||||
|
||||
if len(spatial_shape) != 3 or any(size <= 0 for size in spatial_shape):
|
||||
raise ValueError("Expected a positive CHW latent shape")
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
noise = torch.randn((1, *spatial_shape), generator=generator, dtype=torch.float32)
|
||||
return noise.to(device=device, dtype=dtype)
|
||||
|
||||
|
||||
def paired_psnr(reference: np.ndarray, candidate: np.ndarray) -> float:
|
||||
"""Return paired PSNR over uint8 frame stacks."""
|
||||
if reference.shape != candidate.shape:
|
||||
raise ValueError("PSNR inputs must have matching shapes")
|
||||
mse = float(np.mean((reference.astype(np.float32) - candidate.astype(np.float32)) ** 2))
|
||||
if mse == 0.0:
|
||||
return math.inf
|
||||
return 20.0 * math.log10(255.0 / math.sqrt(mse))
|
||||
|
||||
|
||||
def _backward_map(current_gray: np.ndarray, previous_gray: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Build a remap from a previous frame into current coordinates."""
|
||||
flow = cv2.calcOpticalFlowFarneback(
|
||||
current_gray,
|
||||
previous_gray,
|
||||
None,
|
||||
0.5,
|
||||
3,
|
||||
15,
|
||||
3,
|
||||
5,
|
||||
1.2,
|
||||
0,
|
||||
)
|
||||
height, width = current_gray.shape
|
||||
grid_x, grid_y = np.meshgrid(np.arange(width, dtype=np.float32), np.arange(height, dtype=np.float32))
|
||||
return grid_x + flow[..., 0], grid_y + flow[..., 1]
|
||||
|
||||
|
||||
def _backward_warp(image: np.ndarray, maps: tuple[np.ndarray, np.ndarray]) -> np.ndarray:
|
||||
"""Apply a precomputed backward optical-flow map."""
|
||||
return cv2.remap(
|
||||
image,
|
||||
maps[0],
|
||||
maps[1],
|
||||
interpolation=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REFLECT,
|
||||
)
|
||||
|
||||
|
||||
def build_temporal_reference(
|
||||
reference: Sequence[np.ndarray],
|
||||
) -> tuple[tuple[tuple[np.ndarray, np.ndarray], ...], float]:
|
||||
"""Precompute source motion maps and its mean residual."""
|
||||
if len(reference) < 2:
|
||||
raise ValueError("Temporal metric needs at least two frames")
|
||||
maps: list[tuple[np.ndarray, np.ndarray]] = []
|
||||
reference_residuals: list[float] = []
|
||||
for index in range(1, len(reference)):
|
||||
current_gray = cv2.cvtColor(reference[index], cv2.COLOR_BGR2GRAY)
|
||||
previous_gray = cv2.cvtColor(reference[index - 1], cv2.COLOR_BGR2GRAY)
|
||||
frame_maps = _backward_map(current_gray, previous_gray)
|
||||
maps.append(frame_maps)
|
||||
warped_reference = _backward_warp(reference[index - 1], frame_maps)
|
||||
reference_residuals.append(
|
||||
float(np.mean(np.abs(reference[index].astype(np.float32) - warped_reference.astype(np.float32))))
|
||||
)
|
||||
return tuple(maps), float(np.mean(reference_residuals))
|
||||
|
||||
|
||||
def temporal_residual_ratio(
|
||||
candidate: Sequence[np.ndarray],
|
||||
maps: Sequence[tuple[np.ndarray, np.ndarray]],
|
||||
baseline: float,
|
||||
) -> float:
|
||||
"""Measure candidate flicker against a precomputed source residual."""
|
||||
if len(candidate) != len(maps) + 1:
|
||||
raise ValueError("Temporal metric needs one map per adjacent frame pair")
|
||||
candidate_residuals: list[float] = []
|
||||
for index, frame_maps in enumerate(maps, start=1):
|
||||
warped_candidate = _backward_warp(candidate[index - 1], frame_maps)
|
||||
candidate_residuals.append(
|
||||
float(np.mean(np.abs(candidate[index].astype(np.float32) - warped_candidate.astype(np.float32))))
|
||||
)
|
||||
measured = float(np.mean(candidate_residuals))
|
||||
return measured / max(baseline, 1e-6)
|
||||
|
||||
|
||||
def _probe_video(source: Path) -> tuple[int, int, float]:
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
raise ValueError(f"Could not open video: {source}")
|
||||
try:
|
||||
width = round(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = round(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
source_fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
finally:
|
||||
capture.release()
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"Video has no usable dimensions: {source}")
|
||||
if source_fps <= 0.0:
|
||||
raise ValueError(f"Video has no usable frame rate: {source}")
|
||||
return width, height, source_fps
|
||||
|
||||
|
||||
def read_sampled_frames(
|
||||
source: Path,
|
||||
*,
|
||||
duration: float | None,
|
||||
output_fps: float,
|
||||
size: tuple[int, int],
|
||||
) -> tuple[list[np.ndarray], float]:
|
||||
"""Read uniformly sampled frames and resize them to the VAE geometry."""
|
||||
_width, _height, source_fps = _probe_video(source)
|
||||
effective_fps = min(output_fps, source_fps)
|
||||
frames = list(
|
||||
_iter_sampled_frames(
|
||||
source,
|
||||
source_fps=source_fps,
|
||||
duration=duration,
|
||||
effective_fps=effective_fps,
|
||||
size=size,
|
||||
)
|
||||
)
|
||||
if len(frames) < 2:
|
||||
raise ValueError("The selected clip produced fewer than two frames")
|
||||
return frames, effective_fps
|
||||
|
||||
|
||||
def _iter_sampled_frames(
|
||||
source: Path,
|
||||
*,
|
||||
source_fps: float,
|
||||
duration: float | None,
|
||||
effective_fps: float,
|
||||
size: tuple[int, int],
|
||||
) -> Iterable[np.ndarray]:
|
||||
"""Yield uniformly sampled frames without retaining the full video."""
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
raise ValueError(f"Could not open video: {source}")
|
||||
sample_period = 1.0 / effective_fps
|
||||
next_sample_time = 0.0
|
||||
frame_index = 0
|
||||
try:
|
||||
while True:
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
timestamp = frame_index / source_fps
|
||||
if duration is not None and timestamp + 1e-9 >= duration:
|
||||
break
|
||||
if timestamp + 1e-9 >= next_sample_time:
|
||||
yield cv2.resize(frame, size, interpolation=cv2.INTER_LANCZOS4)
|
||||
next_sample_time += sample_period
|
||||
frame_index += 1
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
|
||||
def _frame_batches(frames: Sequence[np.ndarray], batch_size: int) -> Iterable[Sequence[np.ndarray]]:
|
||||
for start in range(0, len(frames), batch_size):
|
||||
yield frames[start : start + batch_size]
|
||||
|
||||
|
||||
def _stream_batches(frames: Iterable[np.ndarray], batch_size: int) -> Iterable[list[np.ndarray]]:
|
||||
batch: list[np.ndarray] = []
|
||||
for frame in frames:
|
||||
batch.append(frame)
|
||||
if len(batch) == batch_size:
|
||||
yield batch
|
||||
batch = []
|
||||
if batch:
|
||||
yield batch
|
||||
|
||||
|
||||
def _encode_frame_latents(
|
||||
frames: Sequence[np.ndarray],
|
||||
*,
|
||||
vae: Any,
|
||||
device: str,
|
||||
batch_size: int,
|
||||
) -> list[Any]:
|
||||
"""Encode source frames once so every candidate can reuse identical latents."""
|
||||
import torch
|
||||
|
||||
latent_batches: list[Any] = []
|
||||
scaling_factor = float(vae.config.scaling_factor)
|
||||
with torch.inference_mode():
|
||||
for batch in _frame_batches(frames, batch_size):
|
||||
rgb = np.stack([frame[:, :, ::-1] for frame in batch])
|
||||
tensor = torch.from_numpy(np.ascontiguousarray(rgb)).permute(0, 3, 1, 2)
|
||||
tensor = tensor.to(device=device, dtype=vae.dtype) / 127.5 - 1.0
|
||||
latents = vae.encode(tensor).latent_dist.mode() * scaling_factor
|
||||
latent_batches.append(latents)
|
||||
return latent_batches
|
||||
|
||||
|
||||
def _decode_frame_latents(
|
||||
latent_batches: Sequence[Any],
|
||||
*,
|
||||
vae: Any,
|
||||
noise_std: float,
|
||||
shared_noise: Any,
|
||||
) -> list[np.ndarray]:
|
||||
"""Decode cached latents with one perturbation shared across time."""
|
||||
import torch
|
||||
|
||||
output: list[np.ndarray] = []
|
||||
scaling_factor = float(vae.config.scaling_factor)
|
||||
with torch.inference_mode():
|
||||
for latents in latent_batches:
|
||||
perturbed = latents + noise_std * shared_noise.expand(latents.shape[0], -1, -1, -1)
|
||||
decoded = vae.decode(perturbed / scaling_factor).sample
|
||||
decoded = ((decoded / 2.0 + 0.5).clamp(0.0, 1.0) * 255.0).round().to(torch.uint8)
|
||||
decoded = decoded.permute(0, 2, 3, 1).cpu().numpy()
|
||||
output.extend(np.ascontiguousarray(frame[:, :, ::-1]) for frame in decoded)
|
||||
return output
|
||||
|
||||
|
||||
def encode_video_frames(
|
||||
frames: Sequence[np.ndarray],
|
||||
source: Path,
|
||||
output: Path,
|
||||
*,
|
||||
fps: float,
|
||||
) -> None:
|
||||
"""Encode regenerated frames, copy audio, and omit source metadata."""
|
||||
if not frames:
|
||||
raise ValueError("At least one frame is required for video encoding")
|
||||
height, width = frames[0].shape[:2]
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
process = start_raw_video_encoder(
|
||||
raw_video_command(
|
||||
source,
|
||||
output,
|
||||
width=width,
|
||||
height=height,
|
||||
fps=fps,
|
||||
strip_metadata=True,
|
||||
crf=18,
|
||||
)
|
||||
)
|
||||
frame_pipe = process.stdin
|
||||
if frame_pipe is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError("Could not open ffmpeg input pipe")
|
||||
try:
|
||||
for frame in frames:
|
||||
if frame.shape[:2] != (height, width):
|
||||
raise ValueError("Video frames must have matching dimensions")
|
||||
frame_pipe.write(frame.tobytes())
|
||||
finish_raw_video_encoder(process, output, operation="SynthID candidate encode")
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise
|
||||
|
||||
|
||||
def regenerate_video_candidate(
|
||||
source: Path,
|
||||
output: Path,
|
||||
*,
|
||||
noise_std: float = DEFAULT_VIDEO_SYNTHID_NOISE_STD,
|
||||
long_side: int = DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
fps: float = DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
batch_size: int = 4,
|
||||
seed: int = 0,
|
||||
model: str = DEFAULT_VIDEO_SYNTHID_VAE,
|
||||
device: str = "auto",
|
||||
duration: float | None = None,
|
||||
) -> RegenerationMetrics:
|
||||
"""Regenerate video pixels and return fidelity metrics.
|
||||
|
||||
This function does not verify SynthID. Its output is an oracle candidate,
|
||||
not a locally proven clean file.
|
||||
"""
|
||||
if not 0.0 <= noise_std <= 1.0:
|
||||
raise ValueError("noise_std must be between 0 and 1")
|
||||
if fps < 1.0:
|
||||
raise ValueError("fps must be at least 1")
|
||||
if batch_size < 1:
|
||||
raise ValueError("batch_size must be at least 1")
|
||||
if duration is not None and duration <= 0:
|
||||
raise ValueError("duration must be positive")
|
||||
if device not in {"auto", "cuda", "mps", "cpu"}:
|
||||
raise ValueError("device must be auto, cuda, mps, or cpu")
|
||||
if not is_available():
|
||||
raise RuntimeError("Video SynthID regeneration requires the gpu extra")
|
||||
|
||||
import torch
|
||||
from diffusers import AutoencoderKL
|
||||
|
||||
width, height, source_fps = _probe_video(source)
|
||||
size = _fit_size(width, height, long_side)
|
||||
effective_fps = min(fps, source_fps)
|
||||
|
||||
resolved_device = _pick_device(device)
|
||||
dtype = torch.float16 if resolved_device == "cuda" else torch.float32
|
||||
log.info("Loading %s on %s", model, resolved_device)
|
||||
vae = AutoencoderKL.from_pretrained(model, torch_dtype=dtype).to(resolved_device)
|
||||
vae.eval()
|
||||
vae.enable_slicing()
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix=f".{output.stem}-",
|
||||
suffix=output.suffix,
|
||||
dir=output.parent,
|
||||
delete=False,
|
||||
) as stream:
|
||||
temporary_output = Path(stream.name)
|
||||
try:
|
||||
process = start_raw_video_encoder(
|
||||
raw_video_command(
|
||||
source,
|
||||
temporary_output,
|
||||
width=size[0],
|
||||
height=size[1],
|
||||
fps=effective_fps,
|
||||
strip_metadata=True,
|
||||
crf=18,
|
||||
)
|
||||
)
|
||||
frame_pipe = process.stdin
|
||||
if frame_pipe is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError("Could not open ffmpeg input pipe")
|
||||
frame_count = 0
|
||||
squared_error = 0.0
|
||||
pixel_count = 0
|
||||
temporal_baseline = 0.0
|
||||
temporal_candidate = 0.0
|
||||
previous_reference: np.ndarray | None = None
|
||||
previous_candidate: np.ndarray | None = None
|
||||
shared_noise: Any | None = None
|
||||
try:
|
||||
sampled_frames = _iter_sampled_frames(
|
||||
source,
|
||||
source_fps=source_fps,
|
||||
duration=duration,
|
||||
effective_fps=effective_fps,
|
||||
size=size,
|
||||
)
|
||||
for frames in _stream_batches(sampled_frames, batch_size):
|
||||
latent_batches = _encode_frame_latents(
|
||||
frames,
|
||||
vae=vae,
|
||||
device=resolved_device,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
latents = latent_batches[0]
|
||||
if shared_noise is None:
|
||||
shared_noise = _shared_latent_noise(
|
||||
latents.shape[1:],
|
||||
seed=seed,
|
||||
device=resolved_device,
|
||||
dtype=latents.dtype,
|
||||
)
|
||||
regenerated = _decode_frame_latents(
|
||||
latent_batches,
|
||||
vae=vae,
|
||||
noise_std=noise_std,
|
||||
shared_noise=shared_noise,
|
||||
)
|
||||
for reference, candidate in zip(frames, regenerated, strict=True):
|
||||
frame_pipe.write(candidate.tobytes())
|
||||
difference = reference.astype(np.float32) - candidate.astype(np.float32)
|
||||
squared_error += float(np.sum(difference * difference, dtype=np.float64))
|
||||
pixel_count += reference.size
|
||||
if previous_reference is not None and previous_candidate is not None:
|
||||
current_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY)
|
||||
previous_gray = cv2.cvtColor(previous_reference, cv2.COLOR_BGR2GRAY)
|
||||
frame_maps = _backward_map(current_gray, previous_gray)
|
||||
warped_reference = _backward_warp(previous_reference, frame_maps)
|
||||
warped_candidate = _backward_warp(previous_candidate, frame_maps)
|
||||
temporal_baseline += float(
|
||||
np.mean(np.abs(reference.astype(np.float32) - warped_reference.astype(np.float32)))
|
||||
)
|
||||
temporal_candidate += float(
|
||||
np.mean(np.abs(candidate.astype(np.float32) - warped_candidate.astype(np.float32)))
|
||||
)
|
||||
previous_reference = reference
|
||||
previous_candidate = candidate
|
||||
frame_count += 1
|
||||
if frame_count < 2:
|
||||
raise ValueError("The selected clip produced fewer than two frames")
|
||||
finish_raw_video_encoder(
|
||||
process,
|
||||
temporary_output,
|
||||
operation="SynthID candidate encode",
|
||||
)
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise
|
||||
|
||||
mse = squared_error / pixel_count
|
||||
metrics = RegenerationMetrics(
|
||||
frames=frame_count,
|
||||
fps=effective_fps,
|
||||
width=size[0],
|
||||
height=size[1],
|
||||
psnr_db=math.inf if mse == 0.0 else 20.0 * math.log10(255.0 / math.sqrt(mse)),
|
||||
temporal_residual_ratio=temporal_candidate / max(temporal_baseline, 1e-6),
|
||||
)
|
||||
os.replace(temporary_output, output)
|
||||
finally:
|
||||
temporary_output.unlink(missing_ok=True)
|
||||
return metrics
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Shared configuration for experimental video SynthID regeneration."""
|
||||
|
||||
DEFAULT_VIDEO_SYNTHID_VAE = "stabilityai/sd-vae-ft-mse"
|
||||
DEFAULT_VIDEO_SYNTHID_NOISE_STD = 0.10
|
||||
DEFAULT_VIDEO_SYNTHID_LONG_SIDE = 512
|
||||
DEFAULT_VIDEO_SYNTHID_FPS = 12.0
|
||||
VIDEO_SYNTHID_LATENT_MULTIPLE = 8
|
||||
VIDEO_SYNTHID_VERIFICATION_PROMPT = (
|
||||
"Was this uploaded video created or edited by Google AI? Use the built-in content verification result."
|
||||
)
|
||||
@@ -22,8 +22,6 @@ because visible-mark removal changes pixels.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from itertools import pairwise
|
||||
@@ -33,6 +31,13 @@ import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from remove_ai_watermarks.video_encoding import (
|
||||
abort_raw_video_encoder,
|
||||
finish_raw_video_encoder,
|
||||
raw_video_command,
|
||||
start_raw_video_encoder,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@@ -729,12 +734,6 @@ def scan_dola_video(source: Path) -> VideoScan:
|
||||
return _scan_video(source, detect_dola_frame)
|
||||
|
||||
|
||||
def _ffmpeg_video_args(suffix: str) -> list[str]:
|
||||
if suffix == ".webm":
|
||||
return ["-c:v", "libvpx-vp9", "-crf", "18", "-b:v", "0"]
|
||||
return ["-c:v", "libx264", "-preset", "medium", "-crf", "14"]
|
||||
|
||||
|
||||
def _mask_for_region(
|
||||
frame_bgr: NDArray[Any],
|
||||
region: Region,
|
||||
@@ -792,61 +791,29 @@ def encode_clean_video(
|
||||
"""Decode again, fill accepted regions, and encode video while copying audio."""
|
||||
from remove_ai_watermarks.watermark_registry import fill, resolve_backend
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise RuntimeError("Visible video removal requires ffmpeg on PATH")
|
||||
if len(regions) != len(scan.detections):
|
||||
raise ValueError("Temporal localization count does not match the scanned frame count")
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-s:v",
|
||||
f"{scan.width}x{scan.height}",
|
||||
"-r",
|
||||
f"{scan.fps:.12g}",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a?",
|
||||
*_ffmpeg_video_args(output.suffix.lower()),
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-map_metadata",
|
||||
"-1" if strip_metadata else "1",
|
||||
"-map_chapters",
|
||||
"-1" if strip_metadata else "1",
|
||||
"-shortest",
|
||||
]
|
||||
if output.suffix.lower() in {".mp4", ".mov", ".m4v"}:
|
||||
command.extend(["-movflags", "+faststart"])
|
||||
command.append(str(output))
|
||||
log.info("Encoding visible-watermark removal with ffmpeg: command=%s", command)
|
||||
|
||||
process = subprocess.Popen( # noqa: S603
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
process = start_raw_video_encoder(
|
||||
raw_video_command(
|
||||
source,
|
||||
output,
|
||||
width=scan.width,
|
||||
height=scan.height,
|
||||
fps=scan.fps,
|
||||
strip_metadata=strip_metadata,
|
||||
crf=14,
|
||||
)
|
||||
)
|
||||
if process.stdin is None or process.stderr is None:
|
||||
process.kill()
|
||||
raise RuntimeError("Could not open ffmpeg pipes")
|
||||
frame_pipe = process.stdin
|
||||
if frame_pipe is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError("Could not open ffmpeg input pipe")
|
||||
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
process.kill()
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
|
||||
|
||||
removed_frames = 0
|
||||
@@ -868,20 +835,19 @@ def encode_clean_video(
|
||||
backend=resolved_backend,
|
||||
)
|
||||
removed_frames += 1
|
||||
process.stdin.write(frame.tobytes())
|
||||
process.stdin.close()
|
||||
stderr = process.stderr.read().decode("utf-8", errors="replace")
|
||||
return_code = process.wait()
|
||||
frame_pipe.write(frame.tobytes())
|
||||
finish_raw_video_encoder(
|
||||
process,
|
||||
output,
|
||||
operation="visible-watermark encode",
|
||||
)
|
||||
except Exception:
|
||||
process.kill()
|
||||
process.wait()
|
||||
if process.poll() is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
log.info("ffmpeg visible-watermark encode finished: status=%s stderr=%s", return_code, stderr)
|
||||
if return_code != 0:
|
||||
raise RuntimeError(f"ffmpeg failed to encode {output}: {stderr.strip()[:500]}")
|
||||
return removed_frames
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user