Claude 5e2094f2ad
feat(media): local inference sidecar for Media Studio
FastAPI service in infra/media-worker implementing every
MediaInferenceProvider operation behind HTTP API v1, plus capabilities.
Bearer auth, MEDIA_ROOT realpath enforcement, strict request schemas,
cancellation on disconnect, GPU/CPU gates with 503 overflow, input
size/duration limits, lazy SHA256-pinned model downloads, fail-closed
redaction with atomic output, offline pytest suite with fake backends,
CPU/CUDA/ROCm Dockerfile and a license review of every model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138YqDj3ttfayRwuMKKrqVj
2026-10-03 18:19:47 +00:00

164 lines
7.2 KiB
Python

"""inpaintVideo: LaMa (Apache-2.0, ONNX) per frame with optical-flow smoothing.
Per frame: crop around the mask, inpaint at 512x512, paste back through a
feathered mask. When the previous frame was inpainted too, its result is
warped into the current frame with DIS optical flow and blended in, which
damps flicker. Quality is measured on the output:
- temporalStability: 1 - mean absolute change inside the mask between
consecutive frames after motion compensation (scaled so 32 levels -> 0).
- edgeQuality: 1 - excess gradient on the mask boundary over the gradient
just outside it.
- score: 0.6 * temporalStability + 0.4 * edgeQuality.
Callers fall back to cover/reframe when the score is low.
"""
from __future__ import annotations
import os
import threading
from typing import Any
import cv2
import numpy as np
from .. import media_io, paths
from ..errors import BadRequest
from ..schemas import InpaintVideoRequest
from .common import Ctx, clamp01, open_input
from .devices import onnx_providers
from .masks import MaskIndex
from .redact import OUTPUT_EXTENSIONS
LAMA_SIZE = 512
TEMPORAL_BLEND = 0.35
class LamaInpainter:
def __init__(self, path: str, settings: Any) -> None:
import onnxruntime
self.session = onnxruntime.InferenceSession(path, providers=onnx_providers(settings))
def inpaint(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""image: BGR uint8 512x512, mask: uint8 0/255 512x512 -> BGR uint8."""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
pixel = rgb.transpose(2, 0, 1)[None]
m = (mask > 127).astype(np.float32)[None, None]
(out,) = self.session.run(["output"], {"image": pixel, "mask": m})
result = np.clip(out[0].transpose(1, 2, 0), 0, 255).astype(np.uint8)
return cv2.cvtColor(result, cv2.COLOR_RGB2BGR)
def load_inpainter(files: dict[str, str], settings: Any) -> LamaInpainter:
return LamaInpainter(files["lama_fp32.onnx"], settings)
def _crop_box(mask: np.ndarray) -> tuple[int, int, int, int]:
height, width = mask.shape
ys, xs = np.nonzero(mask)
cx, cy = (xs.min() + xs.max()) / 2, (ys.min() + ys.max()) / 2
side = max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1) * 2.2
side = int(min(max(side, 256), max(width, height)))
x0 = int(min(max(cx - side / 2, 0), max(width - side, 0)))
y0 = int(min(max(cy - side / 2, 0), max(height - side, 0)))
return x0, y0, min(x0 + side, width), min(y0 + side, height)
class Smoother:
"""Temporal blending and quality bookkeeping across frames."""
def __init__(self) -> None:
self.flow = cv2.DISOpticalFlow_create(cv2.DISOPTICAL_FLOW_PRESET_FAST)
self.prev_gray: np.ndarray | None = None
self.prev_out: np.ndarray | None = None
self.prev_masked = False
self.stability: list[float] = []
self.edges: list[float] = []
def _warp_prev(self, gray: np.ndarray) -> np.ndarray | None:
if self.prev_gray is None or self.prev_out is None:
return None
flow = self.flow.calc(gray, self.prev_gray, None)
h, w = gray.shape
grid_x, grid_y = np.meshgrid(np.arange(w, dtype=np.float32), np.arange(h, dtype=np.float32))
return cv2.remap(
self.prev_out,
grid_x + flow[..., 0],
grid_y + flow[..., 1],
cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
def step(self, source: np.ndarray, inpainted: np.ndarray | None, mask: np.ndarray | None) -> np.ndarray:
gray = cv2.cvtColor(source, cv2.COLOR_BGR2GRAY)
out = source
if inpainted is not None and mask is not None:
soft = cv2.GaussianBlur(mask.astype(np.float32) / 255.0, (0, 0), 3)
soft = np.maximum(soft, mask.astype(np.float32) / 255.0)[..., None]
warped = self._warp_prev(gray) if self.prev_masked else None
fill = inpainted.astype(np.float32)
if warped is not None:
fill = fill * (1 - TEMPORAL_BLEND) + warped.astype(np.float32) * TEMPORAL_BLEND
out = np.clip(source.astype(np.float32) * (1 - soft) + fill * soft, 0, 255).astype(np.uint8)
inside = mask > 0
if warped is not None:
# Raw LaMa output against the motion-compensated previous frame: flicker before smoothing.
diff = np.abs(inpainted.astype(np.float32) - warped.astype(np.float32))[inside].mean()
self.stability.append(clamp01(1.0 - float(diff) / 32.0))
self.edges.append(_edge_quality(out, mask))
self.prev_gray = gray
self.prev_out = out
self.prev_masked = mask is not None
return out
def quality(self) -> dict[str, float]:
temporal = float(np.mean(self.stability)) if self.stability else 1.0
edge = float(np.mean(self.edges)) if self.edges else 1.0
return {
"temporalStability": clamp01(temporal),
"edgeQuality": clamp01(edge),
"score": clamp01(0.6 * temporal + 0.4 * edge),
}
def _edge_quality(frame: np.ndarray, mask: np.ndarray) -> float:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY).astype(np.float32)
grad = cv2.magnitude(cv2.Sobel(gray, cv2.CV_32F, 1, 0), cv2.Sobel(gray, cv2.CV_32F, 0, 1))
kernel = np.ones((5, 5), np.uint8)
boundary = (cv2.dilate(mask, kernel) > 0) & (cv2.erode(mask, kernel) == 0)
outer = (cv2.dilate(mask, np.ones((15, 15), np.uint8)) > 0) & ~(cv2.dilate(mask, kernel) > 0)
if not boundary.any() or not outer.any():
return 1.0
excess = max(0.0, float(grad[boundary].mean()) - float(grad[outer].mean()))
return clamp01(1.0 - excess / (float(grad[outer].mean()) + 40.0))
def run(ctx: Ctx, body: InpaintVideoRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
masks = MaskIndex(paths.input_dir(ctx.settings.media_root, body.maskDir), info.frame_ms())
out_real = paths.output_file(ctx.settings.media_root, body.outPath)
if os.path.splitext(out_real)[1].lower() not in OUTPUT_EXTENSIONS:
raise BadRequest("outPath must end in .mkv, .mp4 or .mov")
if out_real == real:
raise BadRequest("outPath must differ from path")
inpainter = ctx.registry.runtime("inpaintVideo")
smoother = Smoother()
def per_frame(_index: int, t_ms: int, frame: np.ndarray) -> np.ndarray:
height, width = frame.shape[:2]
mask = masks.lookup(t_ms, width, height)
if mask is None or not mask.any():
return smoother.step(frame, None, None)
# Grow slightly so the seam lands on untouched pixels.
mask = cv2.dilate(mask, np.ones((7, 7), np.uint8))
x0, y0, x1, y1 = _crop_box(mask)
crop = cv2.resize(frame[y0:y1, x0:x1], (LAMA_SIZE, LAMA_SIZE), interpolation=cv2.INTER_AREA)
crop_mask = cv2.resize(mask[y0:y1, x0:x1], (LAMA_SIZE, LAMA_SIZE), interpolation=cv2.INTER_NEAREST)
filled = inpainter.inpaint(crop, crop_mask)
full = frame.copy()
full[y0:y1, x0:x1] = cv2.resize(filled, (x1 - x0, y1 - y0), interpolation=cv2.INTER_CUBIC)
return smoother.step(frame, full, mask)
media_io.transform_video(real, out_real, info, cancel, per_frame)
return {"outPath": body.outPath, "quality": smoother.quality()}