"""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}