mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
Harden automatic video watermark removal for product use
This commit is contained in:
@@ -28,6 +28,7 @@ from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
strength_default_help,
|
||||
vendor_for_strength,
|
||||
)
|
||||
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
|
||||
from remove_ai_watermarks.video_synthid import (
|
||||
DEFAULT_VIDEO_SYNTHID_FPS,
|
||||
DEFAULT_VIDEO_SYNTHID_LONG_SIDE,
|
||||
@@ -1240,9 +1241,9 @@ def cmd_video_invisible(
|
||||
)
|
||||
@click.option(
|
||||
"--mark",
|
||||
type=click.Choice(["sora", "veo", "seedance", "dola", "hailuo", "kling"]),
|
||||
default="sora",
|
||||
help="Visible AI mark to remove.",
|
||||
type=click.Choice(["auto", *VIDEO_VISIBLE_MARKS]),
|
||||
default="auto",
|
||||
help="Visible AI mark to remove. Auto scans every supported provider in one decode pass.",
|
||||
)
|
||||
@click.option(
|
||||
"--backend",
|
||||
@@ -1281,7 +1282,8 @@ def cmd_video_visible(
|
||||
console.print(f" FAILED: {len(result.remaining_metadata)} AI metadata marker(s) survived in {result.output}")
|
||||
raise SystemExit(1)
|
||||
console.print(
|
||||
f" Removed {mark} watermark from {result.removed_frames}/{result.total_frames} frames -> {result.output}"
|
||||
f" Removed {result.mark} watermark from "
|
||||
f"{result.removed_frames}/{result.total_frames} frames -> {result.output}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -22,8 +22,10 @@ from remove_ai_watermarks.video_synthid import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.video_invisible import RegenerationMetrics
|
||||
from remove_ai_watermarks.video_visible import VideoScan
|
||||
|
||||
VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"})
|
||||
VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "dola", "hailuo", "kling")
|
||||
_ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"})
|
||||
_EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"})
|
||||
_RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"})
|
||||
@@ -182,17 +184,20 @@ def remove_video_visible(
|
||||
source: str | Path,
|
||||
output: str | Path | None = None,
|
||||
*,
|
||||
mark: str = "sora",
|
||||
mark: str = "auto",
|
||||
backend: str = "cv2",
|
||||
strip_metadata: bool = True,
|
||||
) -> VideoVisibleResult:
|
||||
"""Remove a supported visible AI wordmark from a video.
|
||||
|
||||
Supported marks are ``sora``, ``veo``, ``seedance``, ``dola``, ``hailuo``,
|
||||
and ``kling``. The full sequence is scanned before pixels change, and only
|
||||
recurring candidates are accepted. Audio is copied without re-encoding;
|
||||
video is transcoded because the pixels change. When no stable mark is found,
|
||||
no output is written and ``output`` in the result is ``None``.
|
||||
``mark="auto"`` scans every supported provider in one decode pass and selects
|
||||
the first stable match in specificity order. Explicit marks are ``sora``,
|
||||
``veo``, ``seedance``, ``dola``, ``hailuo``, and ``kling``. The full
|
||||
sequence is scanned before pixels change, and only recurring candidates are
|
||||
accepted. Complete audio is copied without re-encoding; video is transcoded
|
||||
because the pixels change. Completed output is published atomically. When
|
||||
no stable mark is found, no output is written and ``output`` in the result
|
||||
is ``None``.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata
|
||||
from remove_ai_watermarks.video_visible import (
|
||||
@@ -200,12 +205,7 @@ def remove_video_visible(
|
||||
has_bytedance_video_provenance,
|
||||
has_sora_provenance,
|
||||
has_veo_provenance,
|
||||
scan_dola_video,
|
||||
scan_hailuo_video,
|
||||
scan_kling_video,
|
||||
scan_seedance_video,
|
||||
scan_sora_video,
|
||||
scan_veo_video,
|
||||
scan_video_marks,
|
||||
stabilize_dola_localizations,
|
||||
stabilize_hailuo_localizations,
|
||||
stabilize_kling_localizations,
|
||||
@@ -215,56 +215,98 @@ def remove_video_visible(
|
||||
)
|
||||
from remove_ai_watermarks.watermark_registry import resolve_backend
|
||||
|
||||
if mark not in {"sora", "veo", "seedance", "dola", "hailuo", "kling"}:
|
||||
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, dola, hailuo, or kling")
|
||||
if mark not in {"auto", *VIDEO_VISIBLE_MARKS}:
|
||||
raise ValueError("Unsupported visible video mark; expected auto, sora, veo, seedance, dola, hailuo, or kling")
|
||||
if backend not in {"auto", "cv2", "migan", "lama"}:
|
||||
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
|
||||
|
||||
source_path = _video_source(source)
|
||||
output_path = _video_output(source_path, output, operation="visible watermark removal")
|
||||
markers = get_ai_metadata(source_path)
|
||||
if mark == "sora":
|
||||
scan = scan_sora_video(source_path)
|
||||
regions = stabilize_sora_localizations(
|
||||
scan.detections,
|
||||
provenance=has_sora_provenance(markers),
|
||||
|
||||
def removal_plan(
|
||||
selected_mark: str,
|
||||
selected_scan: VideoScan,
|
||||
) -> tuple[list[tuple[int, int, int, int] | None], float, Literal["box", "veo"]]:
|
||||
if selected_mark == "sora":
|
||||
return (
|
||||
stabilize_sora_localizations(
|
||||
selected_scan.detections,
|
||||
provenance=has_sora_provenance(markers),
|
||||
),
|
||||
0.28,
|
||||
"box",
|
||||
)
|
||||
if selected_mark == "veo":
|
||||
return (
|
||||
stabilize_veo_localizations(
|
||||
selected_scan.detections,
|
||||
provenance=has_veo_provenance(markers),
|
||||
),
|
||||
0.18,
|
||||
"veo",
|
||||
)
|
||||
if selected_mark == "seedance":
|
||||
return (
|
||||
stabilize_seedance_localizations(
|
||||
selected_scan.detections,
|
||||
provenance=has_bytedance_video_provenance(markers),
|
||||
),
|
||||
0.0,
|
||||
"box",
|
||||
)
|
||||
if selected_mark == "dola":
|
||||
return (
|
||||
stabilize_dola_localizations(
|
||||
selected_scan.detections,
|
||||
provenance=has_bytedance_video_provenance(markers),
|
||||
),
|
||||
0.20,
|
||||
"box",
|
||||
)
|
||||
if selected_mark == "hailuo":
|
||||
return stabilize_hailuo_localizations(selected_scan.detections), 0.12, "box"
|
||||
return stabilize_kling_localizations(selected_scan.detections), 0.12, "box"
|
||||
|
||||
candidate_marks = VIDEO_VISIBLE_MARKS if mark == "auto" else (mark,)
|
||||
scans = scan_video_marks(source_path, candidate_marks)
|
||||
selected: (
|
||||
tuple[
|
||||
str,
|
||||
VideoScan,
|
||||
list[tuple[int, int, int, int] | None],
|
||||
float,
|
||||
Literal["box", "veo"],
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
for candidate_mark in candidate_marks:
|
||||
candidate_scan = scans[candidate_mark]
|
||||
candidate_regions, candidate_padding, candidate_mask_style = removal_plan(
|
||||
candidate_mark,
|
||||
candidate_scan,
|
||||
)
|
||||
padding_fraction = 0.28
|
||||
mask_style = "box"
|
||||
elif mark == "veo":
|
||||
scan = scan_veo_video(source_path)
|
||||
regions = stabilize_veo_localizations(
|
||||
scan.detections,
|
||||
provenance=has_veo_provenance(markers),
|
||||
if any(region is not None for region in candidate_regions):
|
||||
selected = (
|
||||
candidate_mark,
|
||||
candidate_scan,
|
||||
candidate_regions,
|
||||
candidate_padding,
|
||||
candidate_mask_style,
|
||||
)
|
||||
break
|
||||
if selected is None:
|
||||
scan = scans[candidate_marks[0]]
|
||||
return VideoVisibleResult(
|
||||
source=source_path,
|
||||
output=None,
|
||||
mark=mark,
|
||||
total_frames=len(scan.detections),
|
||||
detected_frames=0,
|
||||
removed_frames=0,
|
||||
remaining_metadata=markers if strip_metadata else {},
|
||||
)
|
||||
padding_fraction = 0.18
|
||||
mask_style = "veo"
|
||||
elif mark == "seedance":
|
||||
scan = scan_seedance_video(source_path)
|
||||
regions = stabilize_seedance_localizations(
|
||||
scan.detections,
|
||||
provenance=has_bytedance_video_provenance(markers),
|
||||
)
|
||||
padding_fraction = 0.0
|
||||
mask_style = "box"
|
||||
elif mark == "dola":
|
||||
scan = scan_dola_video(source_path)
|
||||
regions = stabilize_dola_localizations(
|
||||
scan.detections,
|
||||
provenance=has_bytedance_video_provenance(markers),
|
||||
)
|
||||
padding_fraction = 0.20
|
||||
mask_style = "box"
|
||||
elif mark == "hailuo":
|
||||
scan = scan_hailuo_video(source_path)
|
||||
regions = stabilize_hailuo_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
else:
|
||||
scan = scan_kling_video(source_path)
|
||||
regions = stabilize_kling_localizations(scan.detections)
|
||||
padding_fraction = 0.12
|
||||
mask_style = "box"
|
||||
mark, scan, regions, padding_fraction, mask_style = selected
|
||||
detected_frames = sum(region is not None for region in regions)
|
||||
if detected_frames == 0:
|
||||
return VideoVisibleResult(
|
||||
|
||||
@@ -3,16 +3,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from collections.abc import Generator
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_video_output(output: Path) -> Generator[Path]:
|
||||
"""Yield a sibling temporary path and publish it only after success."""
|
||||
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:
|
||||
yield temporary_output
|
||||
os.replace(temporary_output, output)
|
||||
finally:
|
||||
temporary_output.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _video_codec_args(suffix: str, *, crf: int) -> list[str]:
|
||||
if suffix == ".webm":
|
||||
return ["-c:v", "libvpx-vp9", "-crf", str(crf), "-b:v", "0"]
|
||||
@@ -61,7 +83,6 @@ def raw_video_command(
|
||||
"-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"])
|
||||
|
||||
@@ -13,11 +13,8 @@ from __future__ import annotations
|
||||
# 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
|
||||
@@ -25,6 +22,7 @@ import numpy as np
|
||||
|
||||
from remove_ai_watermarks.video_encoding import (
|
||||
abort_raw_video_encoder,
|
||||
atomic_video_output,
|
||||
finish_raw_video_encoder,
|
||||
raw_video_command,
|
||||
start_raw_video_encoder,
|
||||
@@ -39,6 +37,7 @@ from remove_ai_watermarks.video_synthid import (
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Sequence
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -405,15 +404,7 @@ def regenerate_video_candidate(
|
||||
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:
|
||||
with atomic_video_output(output) as temporary_output:
|
||||
process = start_raw_video_encoder(
|
||||
raw_video_command(
|
||||
source,
|
||||
@@ -499,7 +490,7 @@ def regenerate_video_candidate(
|
||||
raise
|
||||
|
||||
mse = squared_error / pixel_count
|
||||
metrics = RegenerationMetrics(
|
||||
return RegenerationMetrics(
|
||||
frames=frame_count,
|
||||
fps=effective_fps,
|
||||
width=size[0],
|
||||
@@ -507,7 +498,3 @@ def regenerate_video_candidate(
|
||||
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
|
||||
|
||||
@@ -11,8 +11,8 @@ requires the candidate to recur at the same location across adjacent frames.
|
||||
This keeps isolated lookalikes in clean videos from becoming removal masks.
|
||||
|
||||
Video pixels are decoded with OpenCV and encoded with the system ``ffmpeg``.
|
||||
Audio is stream-copied from the source. The video stream must be transcoded
|
||||
because visible-mark removal changes pixels.
|
||||
Complete audio is stream-copied from the source. The video stream must be
|
||||
transcoded because visible-mark removal changes pixels.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: these packages do not expose usable types for many array
|
||||
@@ -32,8 +32,10 @@ import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
|
||||
from remove_ai_watermarks.video_encoding import (
|
||||
abort_raw_video_encoder,
|
||||
atomic_video_output,
|
||||
finish_raw_video_encoder,
|
||||
raw_video_command,
|
||||
start_raw_video_encoder,
|
||||
@@ -105,6 +107,15 @@ class VideoScan:
|
||||
detections: tuple[FrameLocalization, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreparedFrame:
|
||||
"""Frame representations shared by every detector in one scan pass."""
|
||||
|
||||
gray: NDArray[Any]
|
||||
normalized_gray: NDArray[Any]
|
||||
normalized_scale: float
|
||||
|
||||
|
||||
def _scalable_default_font(size: int) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
|
||||
"""Load Pillow's bundled scalable font, with a Pillow 10.0 fallback."""
|
||||
try:
|
||||
@@ -291,6 +302,43 @@ def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
|
||||
return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _template_sources() -> dict[str, NDArray[Any]]:
|
||||
"""Return every immutable synthetic template by detector-local key."""
|
||||
sora_word, sora_icon = _sora_templates()
|
||||
veo_diamond, veo_text = _veo_templates()
|
||||
sources = {
|
||||
"sora-word": sora_word,
|
||||
"sora-icon": sora_icon,
|
||||
"veo-diamond": veo_diamond,
|
||||
"veo-text": veo_text,
|
||||
"seedance": _seedance_template(),
|
||||
"dola": _dola_template(),
|
||||
"hailuo": _hailuo_template(),
|
||||
"kling-logo": _kling_logo_template(),
|
||||
}
|
||||
sources.update({f"kling-{index}": template for index, template in enumerate(_kling_templates())})
|
||||
return sources
|
||||
|
||||
|
||||
@lru_cache(maxsize=512)
|
||||
def _resized_template_feature(
|
||||
template_key: str,
|
||||
width: int,
|
||||
height: int,
|
||||
kernel_size: int,
|
||||
) -> tuple[NDArray[Any], NDArray[Any]]:
|
||||
"""Resize one template and cache its invariant top-hat representation."""
|
||||
template = cv2.resize(
|
||||
_template_sources()[template_key],
|
||||
(width, height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
|
||||
feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
|
||||
return template, feature
|
||||
|
||||
|
||||
def _normalized_gray(image_bgr: NDArray[Any]) -> tuple[NDArray[Any], float]:
|
||||
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||
height, width = gray.shape[:2]
|
||||
@@ -307,6 +355,13 @@ def _normalized_gray(image_bgr: NDArray[Any]) -> tuple[NDArray[Any], float]:
|
||||
return gray, scale
|
||||
|
||||
|
||||
def _prepare_frame(image_bgr: NDArray[Any]) -> _PreparedFrame:
|
||||
"""Compute the shared grayscale representations for one decoded frame."""
|
||||
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||
normalized_gray, normalized_scale = _normalized_gray(gray)
|
||||
return _PreparedFrame(gray, normalized_gray, normalized_scale)
|
||||
|
||||
|
||||
def _expanded_region(
|
||||
location: tuple[int, int],
|
||||
template_width: int,
|
||||
@@ -328,7 +383,12 @@ def _expanded_region(
|
||||
return x, y, max(1, width), max(1, height)
|
||||
|
||||
|
||||
def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_sora_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the strongest synthetic Sora-wordmark match in one frame.
|
||||
|
||||
The returned candidate is intentionally untrusted. Call
|
||||
@@ -339,7 +399,8 @@ def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
gray, scale = _normalized_gray(image_bgr)
|
||||
prepared = prepared or _prepare_frame(image_bgr)
|
||||
gray, scale = prepared.normalized_gray, prepared.normalized_scale
|
||||
normalized_height, normalized_width = gray.shape[:2]
|
||||
feature = _top_hat(gray)
|
||||
best_confidence = 0.0
|
||||
@@ -347,17 +408,19 @@ def detect_sora_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Frame
|
||||
|
||||
for template_index, base_template in enumerate(_sora_templates()):
|
||||
icon_only = template_index == 1
|
||||
template_key = "sora-icon" if icon_only else "sora-word"
|
||||
for relative_height in _SORA_RELATIVE_HEIGHTS:
|
||||
template_height = max(16, round(min(normalized_height, normalized_width) * relative_height))
|
||||
template_width = max(1, round(base_template.shape[1] * template_height / base_template.shape[0]))
|
||||
if template_height >= normalized_height or template_width >= normalized_width:
|
||||
continue
|
||||
template = cv2.resize(
|
||||
base_template,
|
||||
(template_width, template_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
_, template_feature = _resized_template_feature(
|
||||
template_key,
|
||||
template_width,
|
||||
template_height,
|
||||
7,
|
||||
)
|
||||
scores = cv2.matchTemplate(feature, _top_hat(template), cv2.TM_CCOEFF_NORMED)
|
||||
scores = cv2.matchTemplate(feature, template_feature, cv2.TM_CCOEFF_NORMED)
|
||||
_, confidence, _, location = cv2.minMaxLoc(scores)
|
||||
if confidence <= best_confidence:
|
||||
continue
|
||||
@@ -381,6 +444,7 @@ def _match_template(
|
||||
*,
|
||||
region: Region,
|
||||
kernel_size: int,
|
||||
template_feature: NDArray[Any] | None = None,
|
||||
) -> tuple[float, Region | None]:
|
||||
"""Match one synthetic silhouette inside a bounded frame region."""
|
||||
x, y, width, height = region
|
||||
@@ -390,7 +454,8 @@ def _match_template(
|
||||
return 0.0, None
|
||||
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
|
||||
feature = cv2.morphologyEx(roi, cv2.MORPH_TOPHAT, kernel)
|
||||
template_feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
|
||||
if template_feature is None:
|
||||
template_feature = cv2.morphologyEx(template, cv2.MORPH_TOPHAT, kernel)
|
||||
scores = cv2.matchTemplate(feature, template_feature, cv2.TM_CCOEFF_NORMED)
|
||||
_, confidence, _, location = cv2.minMaxLoc(scores)
|
||||
return float(confidence), (
|
||||
@@ -441,7 +506,7 @@ def _bounded_region(
|
||||
|
||||
def _detect_fixed_mark(
|
||||
image_bgr: NDArray[Any],
|
||||
template: NDArray[Any],
|
||||
template_key: str,
|
||||
*,
|
||||
relative_heights: tuple[float, ...],
|
||||
search_origin: tuple[float, float],
|
||||
@@ -467,19 +532,23 @@ def _detect_fixed_mark(
|
||||
normalized_height - search_y,
|
||||
)
|
||||
matches: list[tuple[float, Region]] = []
|
||||
base_template = _template_sources()[template_key]
|
||||
for relative_height in relative_heights:
|
||||
template_height = max(6, round(short_side * relative_height))
|
||||
template_width = max(1, round(template.shape[1] * template_height / template.shape[0]))
|
||||
resized = cv2.resize(
|
||||
template,
|
||||
(template_width, template_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
template_width = max(1, round(base_template.shape[1] * template_height / base_template.shape[0]))
|
||||
kernel_size = max(3, round(template_height * kernel_fraction) | 1)
|
||||
resized, template_feature = _resized_template_feature(
|
||||
template_key,
|
||||
template_width,
|
||||
template_height,
|
||||
kernel_size,
|
||||
)
|
||||
confidence, candidate = _match_template(
|
||||
gray,
|
||||
resized,
|
||||
region=search_region,
|
||||
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
|
||||
kernel_size=kernel_size,
|
||||
template_feature=template_feature,
|
||||
)
|
||||
if candidate is not None and confidence > 0:
|
||||
matches.append((confidence, candidate))
|
||||
@@ -509,38 +578,56 @@ def _detect_fixed_mark(
|
||||
)
|
||||
|
||||
|
||||
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_seedance_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Seedance boxed-AI candidate."""
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_seedance_template(),
|
||||
"seedance",
|
||||
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
|
||||
search_origin=(0.68, 0.72),
|
||||
kernel_fraction=0.12,
|
||||
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
|
||||
frame_index=frame_index,
|
||||
)
|
||||
|
||||
|
||||
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_dola_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the strongest fixed Dola AI text candidate."""
|
||||
return _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_dola_template(),
|
||||
"dola",
|
||||
relative_heights=_DOLA_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.65, 0.85),
|
||||
kernel_fraction=0.50,
|
||||
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
|
||||
frame_index=frame_index,
|
||||
)
|
||||
|
||||
|
||||
def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_hailuo_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the strongest fixed MINIMAX/Hailuo composite-label candidate."""
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_hailuo_template(),
|
||||
"hailuo",
|
||||
relative_heights=_HAILUO_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.28, 0.76),
|
||||
kernel_fraction=0.18,
|
||||
normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale),
|
||||
frame_index=frame_index,
|
||||
)
|
||||
if detection.region is None:
|
||||
@@ -563,17 +650,23 @@ def detect_hailuo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fra
|
||||
)
|
||||
|
||||
|
||||
def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_kling_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the fixed Kling wordmark core and include its version suffix."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
normalized = _normalized_gray(image_bgr)
|
||||
prepared = prepared or _prepare_frame(image_bgr)
|
||||
normalized = prepared.normalized_gray, prepared.normalized_scale
|
||||
expanded: list[FrameLocalization] = []
|
||||
for template in _kling_templates():
|
||||
for template_index, _template in enumerate(_kling_templates()):
|
||||
detection = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
template,
|
||||
f"kling-{template_index}",
|
||||
relative_heights=_KLING_RELATIVE_HEIGHTS,
|
||||
search_origin=(0.64, 0.84),
|
||||
kernel_fraction=0.18,
|
||||
@@ -615,7 +708,7 @@ def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fram
|
||||
|
||||
logo = _detect_fixed_mark(
|
||||
image_bgr,
|
||||
_kling_logo_template(),
|
||||
"kling-logo",
|
||||
relative_heights=tuple(value / 1000 for value in range(20, 61, 3)),
|
||||
search_origin=(0.62, 0.90),
|
||||
kernel_fraction=0.18,
|
||||
@@ -664,15 +757,20 @@ def detect_kling_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> Fram
|
||||
return best
|
||||
|
||||
|
||||
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
|
||||
def detect_veo_frame(
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
frame_index: int = 0,
|
||||
prepared: _PreparedFrame | None = None,
|
||||
) -> FrameLocalization:
|
||||
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
|
||||
if image_bgr.size == 0:
|
||||
return FrameLocalization(frame_index, 0.0, None)
|
||||
|
||||
frame_height, frame_width = image_bgr.shape[:2]
|
||||
gray = image_bgr if image_bgr.ndim == 2 else cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
|
||||
gray = (prepared or _prepare_frame(image_bgr)).gray
|
||||
short_scale = min(frame_height, frame_width) / _VEO_REFERENCE_SHORT_SIDE
|
||||
diamond_base, text_base = _veo_templates()
|
||||
_, text_base = _veo_templates()
|
||||
best_confidence = 0.0
|
||||
best_region: Region | None = None
|
||||
|
||||
@@ -680,10 +778,12 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
|
||||
for base_size, right_margin, bottom_margin in _VEO_DIAMOND_PROFILES:
|
||||
diamond_size = max(16, round(base_size * short_scale))
|
||||
diamond_sizes.add(diamond_size)
|
||||
template = cv2.resize(
|
||||
diamond_base,
|
||||
(diamond_size, diamond_size),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
kernel_size = max(3, round(7 * short_scale) | 1)
|
||||
template, template_feature = _resized_template_feature(
|
||||
"veo-diamond",
|
||||
diamond_size,
|
||||
diamond_size,
|
||||
kernel_size,
|
||||
)
|
||||
expected_x = round(frame_width - (right_margin + base_size) * short_scale)
|
||||
expected_y = round(frame_height - (bottom_margin + base_size) * short_scale)
|
||||
@@ -696,7 +796,8 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
|
||||
gray,
|
||||
template,
|
||||
region=(search_x, search_y, search_width, search_height),
|
||||
kernel_size=max(3, round(7 * short_scale) | 1),
|
||||
kernel_size=kernel_size,
|
||||
template_feature=template_feature,
|
||||
)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
@@ -710,16 +811,19 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
|
||||
corner_y = round(frame_height * 0.65)
|
||||
corner_region = (corner_x, corner_y, frame_width - corner_x, frame_height - corner_y)
|
||||
for diamond_size in diamond_sizes:
|
||||
template = cv2.resize(
|
||||
diamond_base,
|
||||
(diamond_size, diamond_size),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
kernel_size = max(3, round(7 * short_scale) | 1)
|
||||
template, template_feature = _resized_template_feature(
|
||||
"veo-diamond",
|
||||
diamond_size,
|
||||
diamond_size,
|
||||
kernel_size,
|
||||
)
|
||||
confidence, candidate = _match_template(
|
||||
gray,
|
||||
template,
|
||||
region=corner_region,
|
||||
kernel_size=max(3, round(7 * short_scale) | 1),
|
||||
kernel_size=kernel_size,
|
||||
template_feature=template_feature,
|
||||
)
|
||||
if confidence >= 0.70 and confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
@@ -736,16 +840,19 @@ def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameL
|
||||
text_heights = sorted({max(5, round(height * short_scale)) for height in range(8, 22)})
|
||||
for text_height in text_heights:
|
||||
text_width = max(1, round(text_base.shape[1] * text_height / text_base.shape[0]))
|
||||
template = cv2.resize(
|
||||
text_base,
|
||||
(text_width, text_height),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
kernel_size = max(3, round(3 * short_scale) | 1)
|
||||
template, template_feature = _resized_template_feature(
|
||||
"veo-text",
|
||||
text_width,
|
||||
text_height,
|
||||
kernel_size,
|
||||
)
|
||||
confidence, candidate = _match_template(
|
||||
gray,
|
||||
template,
|
||||
region=text_region,
|
||||
kernel_size=max(3, round(3 * short_scale) | 1),
|
||||
kernel_size=kernel_size,
|
||||
template_feature=template_feature,
|
||||
)
|
||||
if confidence > best_confidence:
|
||||
best_confidence = confidence
|
||||
@@ -959,11 +1066,11 @@ def _stabilize_localizations(
|
||||
return accepted
|
||||
|
||||
|
||||
def _scan_video(
|
||||
def _scan_video_detectors(
|
||||
source: Path,
|
||||
detector: Any,
|
||||
) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted candidate per frame."""
|
||||
detectors: dict[str, Any],
|
||||
) -> dict[str, VideoScan]:
|
||||
"""Decode once and collect one untrusted candidate per detector and frame."""
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(f"OpenCV could not decode video: {source}")
|
||||
@@ -974,7 +1081,7 @@ def _scan_video(
|
||||
capture.release()
|
||||
raise RuntimeError(f"Video has invalid stream geometry or frame rate: {source}")
|
||||
|
||||
detections: list[FrameLocalization] = []
|
||||
detections: dict[str, list[FrameLocalization]] = {mark: [] for mark in detectors}
|
||||
frame_index = 0
|
||||
while True:
|
||||
ok, frame = capture.read()
|
||||
@@ -983,12 +1090,56 @@ def _scan_video(
|
||||
if frame.shape[:2] != (height, width):
|
||||
capture.release()
|
||||
raise RuntimeError(f"Video changes frame dimensions at frame {frame_index}: {source}")
|
||||
detections.append(detector(frame, frame_index=frame_index))
|
||||
prepared = _prepare_frame(frame)
|
||||
for mark, detector in detectors.items():
|
||||
detections[mark].append(
|
||||
detector(
|
||||
frame,
|
||||
frame_index=frame_index,
|
||||
prepared=prepared,
|
||||
)
|
||||
)
|
||||
frame_index += 1
|
||||
capture.release()
|
||||
if not detections:
|
||||
if frame_index == 0:
|
||||
raise RuntimeError(f"Video contains no decodable frames: {source}")
|
||||
return VideoScan(width, height, fps, tuple(detections))
|
||||
return {mark: VideoScan(width, height, fps, tuple(mark_detections)) for mark, mark_detections in detections.items()}
|
||||
|
||||
|
||||
def _scan_video(
|
||||
source: Path,
|
||||
detector: Any,
|
||||
) -> VideoScan:
|
||||
"""Decode a video once and collect one untrusted candidate per frame."""
|
||||
return _scan_video_detectors(source, {"selected": detector})["selected"]
|
||||
|
||||
|
||||
def scan_video_marks(
|
||||
source: Path,
|
||||
marks: tuple[str, ...] = VIDEO_VISIBLE_MARKS,
|
||||
) -> dict[str, VideoScan]:
|
||||
"""Decode once and collect candidates for every requested provider mark."""
|
||||
detectors = dict(
|
||||
zip(
|
||||
VIDEO_VISIBLE_MARKS,
|
||||
(
|
||||
detect_sora_frame,
|
||||
detect_veo_frame,
|
||||
detect_seedance_frame,
|
||||
detect_dola_frame,
|
||||
detect_hailuo_frame,
|
||||
detect_kling_frame,
|
||||
),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
unsupported = sorted(set(marks) - detectors.keys())
|
||||
if unsupported:
|
||||
raise ValueError(f"Unsupported visible video mark: {', '.join(unsupported)}")
|
||||
return _scan_video_detectors(
|
||||
source,
|
||||
{mark: detectors[mark] for mark in marks},
|
||||
)
|
||||
|
||||
|
||||
def scan_sora_video(source: Path) -> VideoScan:
|
||||
@@ -1075,65 +1226,65 @@ def encode_clean_video(
|
||||
padding_fraction: float = 0.28,
|
||||
mask_style: Literal["box", "veo"] = "box",
|
||||
) -> int:
|
||||
"""Decode again, fill accepted regions, and encode video while copying audio."""
|
||||
"""Decode again, fill accepted regions, and atomically encode with complete audio."""
|
||||
from remove_ai_watermarks.watermark_registry import fill, resolve_backend
|
||||
|
||||
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)
|
||||
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,
|
||||
with atomic_video_output(output) as temporary_output:
|
||||
process = start_raw_video_encoder(
|
||||
raw_video_command(
|
||||
source,
|
||||
temporary_output,
|
||||
width=scan.width,
|
||||
height=scan.height,
|
||||
fps=scan.fps,
|
||||
strip_metadata=strip_metadata,
|
||||
crf=14,
|
||||
)
|
||||
)
|
||||
)
|
||||
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():
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
|
||||
|
||||
removed_frames = 0
|
||||
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
|
||||
try:
|
||||
for frame_index, region in enumerate(regions):
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
raise RuntimeError(f"Video ended while reading frame {frame_index}: {source}")
|
||||
if region is not None:
|
||||
frame = fill(
|
||||
frame,
|
||||
_mask_for_region(
|
||||
frame,
|
||||
region,
|
||||
padding_fraction=padding_fraction,
|
||||
mask_style=mask_style,
|
||||
),
|
||||
backend=resolved_backend,
|
||||
)
|
||||
removed_frames += 1
|
||||
frame_pipe.write(frame.tobytes())
|
||||
finish_raw_video_encoder(
|
||||
process,
|
||||
output,
|
||||
operation="visible-watermark encode",
|
||||
)
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
frame_pipe = process.stdin
|
||||
if frame_pipe is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise
|
||||
finally:
|
||||
capture.release()
|
||||
raise RuntimeError("Could not open ffmpeg input pipe")
|
||||
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
abort_raw_video_encoder(process)
|
||||
raise RuntimeError(f"OpenCV could not reopen video for removal: {source}")
|
||||
|
||||
removed_frames = 0
|
||||
resolved_backend: Literal["cv2", "migan", "lama"] = resolve_backend(backend)
|
||||
try:
|
||||
for frame_index, region in enumerate(regions):
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
raise RuntimeError(f"Video ended while reading frame {frame_index}: {source}")
|
||||
if region is not None:
|
||||
frame = fill(
|
||||
frame,
|
||||
_mask_for_region(
|
||||
frame,
|
||||
region,
|
||||
padding_fraction=padding_fraction,
|
||||
mask_style=mask_style,
|
||||
),
|
||||
backend=resolved_backend,
|
||||
)
|
||||
removed_frames += 1
|
||||
frame_pipe.write(frame.tobytes())
|
||||
finish_raw_video_encoder(
|
||||
process,
|
||||
temporary_output,
|
||||
operation="visible-watermark encode",
|
||||
)
|
||||
except Exception:
|
||||
if process.poll() is None:
|
||||
abort_raw_video_encoder(process)
|
||||
raise
|
||||
finally:
|
||||
capture.release()
|
||||
|
||||
return removed_frames
|
||||
|
||||
|
||||
Reference in New Issue
Block a user