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
104 lines
3.7 KiB
Python
104 lines
3.7 KiB
Python
"""detectScenes: PySceneDetect (adaptive) for shots, histogram grouping for scenes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Any
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from scenedetect import AdaptiveDetector, SceneManager, StatsManager, open_video
|
|
|
|
from .. import media_io
|
|
from ..errors import Cancelled
|
|
from ..schemas import PathRequest
|
|
from .common import Ctx, clamp01, open_input
|
|
|
|
# Neighbouring shots whose middle frames correlate at least this much share a scene.
|
|
SCENE_HIST_CORRELATION = 0.6
|
|
ADAPTIVE_THRESHOLD = 3.0
|
|
|
|
|
|
class SceneDetector:
|
|
def detect(self, path: str, cancel: threading.Event) -> list[tuple[float, float, float]]:
|
|
"""Return shots as (start_s, end_s, score in [0,1])."""
|
|
video = open_video(path, backend="opencv")
|
|
stats = StatsManager()
|
|
manager = SceneManager(stats_manager=stats)
|
|
manager.add_detector(AdaptiveDetector(adaptive_threshold=ADAPTIVE_THRESHOLD))
|
|
stop = threading.Event()
|
|
|
|
def watch() -> None:
|
|
while not stop.wait(0.2):
|
|
if cancel.is_set():
|
|
manager.stop()
|
|
return
|
|
|
|
watcher = threading.Thread(target=watch, daemon=True)
|
|
watcher.start()
|
|
try:
|
|
manager.detect_scenes(video=video)
|
|
finally:
|
|
stop.set()
|
|
if cancel.is_set():
|
|
raise Cancelled()
|
|
scenes = manager.get_scene_list(start_in_scene=True)
|
|
ratio_key = next(
|
|
(k for k in AdaptiveDetector().get_metrics() if k.startswith("adaptive_ratio")), None
|
|
)
|
|
shots: list[tuple[float, float, float]] = []
|
|
for index, (start, end) in enumerate(scenes):
|
|
score = 1.0
|
|
if index > 0 and ratio_key:
|
|
value = (
|
|
stats.get_metrics(start.frame_num, [ratio_key])[0]
|
|
if stats.metrics_exist(start.frame_num, [ratio_key])
|
|
else None
|
|
)
|
|
score = clamp01((value or ADAPTIVE_THRESHOLD) / (2 * ADAPTIVE_THRESHOLD))
|
|
shots.append((start.seconds, end.seconds, score))
|
|
return shots
|
|
|
|
|
|
def load_scene_detector(_files: dict[str, str], _settings: Any) -> SceneDetector:
|
|
return SceneDetector()
|
|
|
|
|
|
def _histogram(frame: np.ndarray) -> np.ndarray:
|
|
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
|
|
hist = cv2.calcHist([hsv], [0, 1], None, [32, 16], [0, 180, 0, 256])
|
|
cv2.normalize(hist, hist)
|
|
return hist
|
|
|
|
|
|
def run(ctx: Ctx, body: PathRequest, cancel: threading.Event) -> dict[str, Any]:
|
|
real, info = open_input(ctx, body.path, cancel)
|
|
detector = ctx.registry.runtime("detectScenes")
|
|
raw = detector.detect(real, cancel)
|
|
duration_ms = int(round(info.duration_s * 1000))
|
|
if not raw:
|
|
raw = [(0.0, info.duration_s, 1.0)]
|
|
shots: list[dict[str, Any]] = []
|
|
for start_s, end_s, score in raw:
|
|
start_ms = int(round(start_s * 1000))
|
|
end_ms = max(int(round(end_s * 1000)), start_ms)
|
|
if duration_ms:
|
|
end_ms = min(end_ms, duration_ms)
|
|
shots.append({"startMs": start_ms, "endMs": end_ms, "score": float(score)})
|
|
|
|
scenes: list[dict[str, int]] = []
|
|
previous: np.ndarray | None = None
|
|
for shot in shots:
|
|
middle = (shot["startMs"] + shot["endMs"]) // 2
|
|
hist = _histogram(media_io.frame_at(real, info, middle, cancel, max_side=320))
|
|
same = (
|
|
previous is not None
|
|
and cv2.compareHist(previous, hist, cv2.HISTCMP_CORREL) >= SCENE_HIST_CORRELATION
|
|
)
|
|
if same and scenes:
|
|
scenes[-1]["endMs"] = shot["endMs"]
|
|
else:
|
|
scenes.append({"startMs": shot["startMs"], "endMs": shot["endMs"]})
|
|
previous = hist
|
|
return {"shots": shots, "scenes": scenes}
|