mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 23:50:40 +02:00
190 lines
7.2 KiB
Python
190 lines
7.2 KiB
Python
"""Pure motion-compensated helpers shared by video pipelines."""
|
|
|
|
from __future__ import annotations
|
|
|
|
# OpenCV exposes incomplete types for optical-flow and remap operations.
|
|
# Public signatures remain annotated while this third-party boundary is relaxed.
|
|
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false, reportCallIssue=false, reportArgumentType=false
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Sequence
|
|
|
|
from numpy.typing import NDArray
|
|
|
|
|
|
def _backward_map(
|
|
current_gray: NDArray[Any],
|
|
previous_gray: NDArray[Any],
|
|
) -> tuple[NDArray[Any], NDArray[Any]]:
|
|
"""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
|
|
flow[..., 0] += np.arange(width, dtype=np.float32)[None, :]
|
|
flow[..., 1] += np.arange(height, dtype=np.float32)[:, None]
|
|
return flow[..., 0], flow[..., 1]
|
|
|
|
|
|
def _backward_warp(
|
|
image: NDArray[Any],
|
|
maps: tuple[NDArray[Any], NDArray[Any]],
|
|
*,
|
|
interpolation: int = cv2.INTER_LINEAR,
|
|
) -> NDArray[Any]:
|
|
"""Apply a precomputed backward optical-flow map."""
|
|
return cv2.remap(
|
|
image,
|
|
maps[0],
|
|
maps[1],
|
|
interpolation=interpolation,
|
|
borderMode=cv2.BORDER_REFLECT,
|
|
)
|
|
|
|
|
|
def _motion_residual(
|
|
current: NDArray[Any],
|
|
previous: NDArray[Any],
|
|
maps: tuple[NDArray[Any], NDArray[Any]],
|
|
) -> float:
|
|
"""Return mean absolute residual after warping the previous frame."""
|
|
current_f32 = np.asarray(current, dtype=np.float32)
|
|
previous_f32 = np.asarray(previous, dtype=np.float32)
|
|
warped_previous = _backward_warp(previous_f32, maps)
|
|
return float(np.mean(np.abs(current_f32 - warped_previous)))
|
|
|
|
|
|
def build_temporal_reference(
|
|
reference: Sequence[NDArray[Any]],
|
|
) -> tuple[tuple[tuple[NDArray[Any], NDArray[Any]], ...], 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[NDArray[Any], NDArray[Any]]] = []
|
|
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)
|
|
reference_residuals.append(_motion_residual(reference[index], reference[index - 1], frame_maps))
|
|
return tuple(maps), float(np.mean(reference_residuals))
|
|
|
|
|
|
def temporal_residual_ratio(
|
|
candidate: Sequence[NDArray[Any]],
|
|
maps: Sequence[tuple[NDArray[Any], NDArray[Any]]],
|
|
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):
|
|
candidate_residuals.append(_motion_residual(candidate[index], candidate[index - 1], frame_maps))
|
|
measured = float(np.mean(candidate_residuals))
|
|
return measured / max(baseline, 1e-6)
|
|
|
|
|
|
def stabilize_filled_frame(
|
|
previous_source: NDArray[Any],
|
|
previous_cleaned: NDArray[Any],
|
|
previous_mask: NDArray[Any],
|
|
current_source: NDArray[Any],
|
|
current_cleaned: NDArray[Any],
|
|
current_mask: NDArray[Any],
|
|
*,
|
|
blend: float = 0.5,
|
|
max_context_residual: float = 12.0,
|
|
copy: bool = True,
|
|
) -> NDArray[Any]:
|
|
"""Blend a motion-aligned prior fill when nearby source pixels agree.
|
|
|
|
The prior contributes only where its warped removal mask covers the current
|
|
mask. A context ring outside both masks gates the blend, so scene cuts or
|
|
non-rigid local changes keep the independent current-frame fill.
|
|
"""
|
|
if not 0.0 <= blend <= 1.0:
|
|
raise ValueError("Temporal blend must be between 0 and 1")
|
|
if max_context_residual <= 0.0:
|
|
raise ValueError("Context residual threshold must be positive")
|
|
if (
|
|
previous_source.shape != current_source.shape
|
|
or previous_cleaned.shape != current_cleaned.shape
|
|
or previous_source.shape != previous_cleaned.shape
|
|
or previous_mask.shape != current_mask.shape
|
|
or previous_mask.shape != current_source.shape[:2]
|
|
):
|
|
raise ValueError("Temporal fill inputs must share frame and mask geometry")
|
|
|
|
union = (previous_mask > 0) | (current_mask > 0)
|
|
ys, xs = np.where(union)
|
|
if len(xs) == 0:
|
|
return current_cleaned
|
|
height, width = current_mask.shape
|
|
mask_width = int(xs.max() - xs.min() + 1)
|
|
mask_height = int(ys.max() - ys.min() + 1)
|
|
padding = max(24, round(max(mask_width, mask_height) * 0.75))
|
|
x0 = max(0, int(xs.min()) - padding)
|
|
y0 = max(0, int(ys.min()) - padding)
|
|
x1 = min(width, int(xs.max()) + padding + 1)
|
|
y1 = min(height, int(ys.max()) + padding + 1)
|
|
|
|
previous_source_crop = previous_source[y0:y1, x0:x1]
|
|
current_source_crop = current_source[y0:y1, x0:x1]
|
|
current_cleaned_crop = current_cleaned[y0:y1, x0:x1]
|
|
previous_cleaned_crop = previous_cleaned[y0:y1, x0:x1]
|
|
previous_mask_crop = previous_mask[y0:y1, x0:x1]
|
|
current_mask_crop = current_mask[y0:y1, x0:x1]
|
|
maps = _backward_map(
|
|
cv2.cvtColor(current_source_crop, cv2.COLOR_BGR2GRAY),
|
|
cv2.cvtColor(previous_source_crop, cv2.COLOR_BGR2GRAY),
|
|
)
|
|
warped_previous_source = _backward_warp(previous_source_crop, maps)
|
|
warped_previous_cleaned = _backward_warp(previous_cleaned_crop, maps)
|
|
warped_previous_mask = _backward_warp(
|
|
previous_mask_crop,
|
|
maps,
|
|
interpolation=cv2.INTER_NEAREST,
|
|
)
|
|
|
|
current_hole = current_mask_crop > 0
|
|
if not np.any(current_hole):
|
|
return current_cleaned
|
|
covered = current_hole & (warped_previous_mask > 0)
|
|
if float(np.mean(covered[current_hole])) < 0.85:
|
|
return current_cleaned
|
|
|
|
occupied = current_hole | (warped_previous_mask > 0)
|
|
dilation = max(7, round(max(mask_width, mask_height) * 0.25))
|
|
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilation | 1, dilation | 1))
|
|
context = cv2.dilate(occupied.astype(np.uint8), kernel).astype(bool) & ~occupied
|
|
if np.count_nonzero(context) < 64:
|
|
return current_cleaned
|
|
residual = np.abs(current_source_crop.astype(np.float32) - warped_previous_source.astype(np.float32))
|
|
context_residual = float(np.mean(residual[context]))
|
|
if context_residual > max_context_residual:
|
|
return current_cleaned
|
|
|
|
effective_blend = blend * (1.0 - context_residual / max_context_residual)
|
|
blended = (1.0 - effective_blend) * current_cleaned_crop[covered].astype(
|
|
np.float32
|
|
) + effective_blend * warped_previous_cleaned[covered].astype(np.float32)
|
|
result = current_cleaned.copy() if copy else current_cleaned
|
|
result_crop = result[y0:y1, x0:x1]
|
|
result_crop[covered] = np.clip(blended, 0, 255).astype(np.uint8)
|
|
return result
|