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

245 lines
9.3 KiB
Python

"""segmentPrompted: SAM2 (Apache-2.0) from a click or box, propagated over the video.
Frames are sampled at up to 15 fps and fed to the SAM2 video predictor in
chunks so memory stays bounded; each chunk is re-prompted with the bounding
box of the previous chunk's last mask. Masks are written as `<tMs>.png`
(0 or 255) at the source resolution.
"""
from __future__ import annotations
import os
import tempfile
import threading
from collections.abc import Iterator
from typing import Any
import cv2
import numpy as np
from .. import paths
from ..errors import BadRequest, Cancelled, MediaError
from ..media_io import FFMPEG, run_cancellable, scaled_size
from ..schemas import SegmentPromptedRequest
from .common import Ctx, clamp01, norm_box, open_input
from .devices import torch_device
from .masks import MASK_NAME
SEGMENT_MAX_FPS = 15.0
SEGMENT_MAX_SIDE = 1024
CHUNK_FRAMES = 64
MaskStream = Iterator[tuple[int, np.ndarray, float]]
def _mask_box(mask: np.ndarray) -> tuple[int, int, int, int] | None:
ys, xs = np.nonzero(mask)
if xs.size == 0:
return None
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
class Sam2Segmenter:
def __init__(self, checkpoint: str, settings: Any) -> None:
import torch
from sam2.build_sam import build_sam2_video_predictor
self.torch = torch
self.device = torch_device(settings)
self.predictor = build_sam2_video_predictor(
"configs/sam2.1/sam2.1_hiera_t.yaml", checkpoint, device=self.device
)
self._lock = threading.Lock()
def _chunk(
self,
frames: list[str],
prompt_at: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
reverse: bool,
cancel: threading.Event,
) -> MaskStream:
with tempfile.TemporaryDirectory(prefix="sam2-") as chunk_dir:
for i, source in enumerate(frames):
os.symlink(source, os.path.join(chunk_dir, f"{i}.jpg"))
torch = self.torch
with torch.inference_mode():
state = self.predictor.init_state(video_path=chunk_dir, offload_video_to_cpu=True)
kwargs: dict[str, Any] = {}
if box is not None:
kwargs["box"] = np.array(box, dtype=np.float32)
if point is not None:
kwargs["points"] = np.array([point], dtype=np.float32)
kwargs["labels"] = np.array([1], dtype=np.int32)
self.predictor.add_new_points_or_box(state, frame_idx=prompt_at, obj_id=1, **kwargs)
for index, _, logits in self.predictor.propagate_in_video(
state, start_frame_idx=prompt_at, reverse=reverse
):
if cancel.is_set():
raise Cancelled()
values = logits[0, 0].float().cpu().numpy()
mask = values > 0
confidence = float(1.0 / (1.0 + np.exp(-values[mask])).mean()) if mask.any() else 0.0
yield index, mask, confidence
self.predictor.reset_state(state)
def segment(
self,
frames: list[str],
prompt_index: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
propagate: bool,
cancel: threading.Event,
) -> MaskStream:
with self._lock:
if not propagate:
for _, mask, confidence in self._chunk([frames[prompt_index]], 0, box, point, False, cancel):
yield prompt_index, mask, confidence
return
yield from self._forward(frames, prompt_index, box, point, cancel)
yield from self._backward(frames, prompt_index, box, point, cancel)
def _forward(
self,
frames: list[str],
anchor: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
cancel: threading.Event,
) -> MaskStream:
emit_anchor = True
while True:
chunk = frames[anchor : anchor + CHUNK_FRAMES]
last: np.ndarray | None = None
for index, mask, confidence in self._chunk(chunk, 0, box, point, False, cancel):
if index == 0 and not emit_anchor:
continue
last = mask
yield anchor + index, mask, confidence
end = anchor + len(chunk) - 1
box = _mask_box(last) if last is not None else None
if end >= len(frames) - 1 or box is None:
return
anchor, point, emit_anchor = end, None, False
def _backward(
self,
frames: list[str],
anchor: int,
box: tuple[int, int, int, int] | None,
point: tuple[int, int] | None,
cancel: threading.Event,
) -> MaskStream:
while anchor > 0:
start = max(0, anchor - CHUNK_FRAMES + 1)
last: np.ndarray | None = None
for index, mask, confidence in self._chunk(
frames[start : anchor + 1], anchor - start, box, point, True, cancel
):
if start + index == anchor:
continue # emitted by the forward pass
last = mask
yield start + index, mask, confidence
box = _mask_box(last) if last is not None else None
if start == 0 or box is None:
return
anchor, point = start, None
def load_segmenter(files: dict[str, str], settings: Any) -> Sam2Segmenter:
return Sam2Segmenter(files["sam2.1_hiera_tiny.pt"], settings)
def _extract_frames(
path: str,
fps: float,
size: tuple[int, int],
directory: str,
cancel: threading.Event,
at_ms: int | None = None,
) -> list[str]:
argv = [FFMPEG, "-v", "error", "-nostdin"]
if at_ms is not None:
argv += ["-ss", f"{at_ms / 1000:.3f}"]
argv += ["-i", path, "-map", "0:v:0", "-an", "-sn"]
filters = [] if at_ms is not None else [f"fps={fps:.6f}"]
filters.append(f"scale={size[0]}:{size[1]}:flags=area")
argv += ["-vf", ",".join(filters)]
if at_ms is not None:
argv += ["-frames:v", "1"]
argv += ["-q:v", "2", "-start_number", "0", os.path.join(directory, "%06d.jpg")]
result = run_cancellable(argv, cancel)
if result.returncode != 0:
raise MediaError("internal", "frame extraction failed")
names = sorted(n for n in os.listdir(directory) if n.endswith(".jpg"))
if not names:
raise MediaError("internal", "no frames extracted")
return [os.path.join(directory, n) for n in names]
def run(ctx: Ctx, body: SegmentPromptedRequest, cancel: threading.Event) -> dict[str, Any]:
real, info = open_input(ctx, body.path, cancel)
out_dir = paths.output_dir(ctx.settings.media_root, body.outDir)
segmenter = ctx.registry.runtime("segmentPrompted")
fps = min(info.fps, SEGMENT_MAX_FPS)
width, height = scaled_size(info.width, info.height, SEGMENT_MAX_SIDE)
prompt = body.prompt
if prompt.tMs > int(info.duration_s * 1000) + 1:
raise BadRequest("prompt.tMs is after the end of the video")
with tempfile.TemporaryDirectory(prefix="segment-") as work:
if body.propagate:
frames = _extract_frames(real, fps, (width, height), work, cancel)
times = [int(round(i * 1000.0 / fps)) for i in range(len(frames))]
prompt_index = int(np.argmin([abs(t - prompt.tMs) for t in times]))
else:
frames = _extract_frames(real, fps, (width, height), work, cancel, at_ms=prompt.tMs)
times = [prompt.tMs]
prompt_index = 0
box = None
if prompt.box is not None:
b = prompt.box
box = (
int(round(b.x * width)),
int(round(b.y * height)),
int(round(clamp01(b.x + b.w) * width)),
int(round(clamp01(b.y + b.h) * height)),
)
point = (
(int(round(prompt.point.x * width)), int(round(prompt.point.y * height)))
if prompt.point
else None
)
# Replace masks from an earlier run in the same directory.
for name in os.listdir(out_dir):
if MASK_NAME.match(name):
os.unlink(os.path.join(out_dir, name))
keyframes = []
for index, mask, confidence in segmenter.segment(
frames, prompt_index, box, point, body.propagate, cancel
):
if cancel.is_set():
raise Cancelled()
t_ms = times[index]
full = cv2.resize(
mask.astype(np.uint8) * 255, (info.width, info.height), interpolation=cv2.INTER_NEAREST
)
found = _mask_box(full > 0)
if found is None:
continue
if not cv2.imwrite(os.path.join(out_dir, f"{t_ms}.png"), full):
raise MediaError("internal", "could not write mask")
x0, y0, x1, y1 = found
keyframes.append(
{
"tMs": t_ms,
"box": norm_box(x0, y0, x1 - x0, y1 - y0, info.width, info.height),
"confidence": clamp01(confidence),
}
)
keyframes.sort(key=lambda k: k["tMs"])
score = float(np.mean([k["confidence"] for k in keyframes])) if keyframes else 0.0
return {"maskDir": body.outDir, "keyframes": keyframes, "score": score}