forked from kamilstanuch/Autocrop-vertical
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Expand file tree
/
Copy pathscene_detection.py
More file actions
148 lines (119 loc) · 5.61 KB
/
Copy pathscene_detection.py
File metadata and controls
148 lines (119 loc) · 5.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
"""Scene detection engines for the reframe pipeline.
Primary engine: TransNetV2, a small neural shot-boundary detector that is
markedly more accurate than threshold-based detection (handles fast camera
motion, flashes and gradual transitions such as fades/dissolves). Runs on
48x27 frames, faster than realtime even on CPU.
Fallback/legacy engine: PySceneDetect ContentDetector, byte-for-byte the
pre-existing behavior. Any TransNetV2 failure (missing package, corrupt
weights, decode error) falls back automatically so a scene-detection edge
case can never kill a job.
Environment variables:
SCENE_ENGINE "transnetv2" (default) | "pyscenedetect" (legacy)
SCENE_MIN_SEC minimum scene length in seconds; shorter scenes are
merged into a neighbor (default 0.4, TransNetV2 path
only — the legacy path stays untouched)
TRANSNETV2_THRESHOLD shot-boundary probability threshold (default 0.5)
TRANSNETV2_DEVICE torch device: "auto" (default) | "cpu" | "cuda" | "mps"
"""
import os
import subprocess
import threading
import cv2
import numpy as np
from scenedetect import open_video, SceneManager, FrameTimecode
from scenedetect.detectors import ContentDetector
# TransNetV2 input size (width x height), fixed by the trained model.
_TN2_W, _TN2_H = 48, 27
# One shared model instance; clips can process in parallel (CLIP_WORKERS) so
# inference is serialized like the other detectors in main.py.
_TN2_LOCK = threading.Lock()
_tn2_model = None
def detect_scenes(video_path):
"""Detect scenes. Returns (scene_list, fps) where scene_list is a list of
(FrameTimecode, FrameTimecode) pairs — the same contract PySceneDetect's
SceneManager.get_scene_list() has always given callers."""
engine = os.environ.get("SCENE_ENGINE", "transnetv2").strip().lower()
if engine != "pyscenedetect":
try:
return _detect_transnetv2(video_path)
except Exception as e:
print(f" ⚠️ TransNetV2 scene detection failed "
f"({type(e).__name__}: {e}) — falling back to PySceneDetect")
return _detect_pyscenedetect(video_path)
# --- legacy engine ----------------------------------------------------------
def _detect_pyscenedetect(video_path):
video = open_video(video_path)
scene_manager = SceneManager()
scene_manager.add_detector(ContentDetector())
scene_manager.detect_scenes(video=video)
scene_list = scene_manager.get_scene_list()
fps = video.frame_rate
return scene_list, fps
# --- TransNetV2 engine ------------------------------------------------------
def _get_tn2_model():
global _tn2_model
if _tn2_model is None:
from transnetv2_pytorch import TransNetV2
device = os.environ.get("TRANSNETV2_DEVICE", "auto")
model = TransNetV2(device=device)
model.eval()
_tn2_model = model
return _tn2_model
def _extract_frames_small(video_path):
"""Decode the whole clip as 48x27 RGB frames via ffmpeg (~4KB/frame)."""
cmd = [
"ffmpeg", "-nostdin", "-i", video_path,
"-vf", f"scale={_TN2_W}:{_TN2_H}",
"-pix_fmt", "rgb24", "-f", "rawvideo", "-",
]
proc = subprocess.run(cmd, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, check=True, timeout=900)
frame_bytes = _TN2_H * _TN2_W * 3
n = len(proc.stdout) // frame_bytes
if n == 0:
raise RuntimeError("ffmpeg produced no frames")
return np.frombuffer(proc.stdout[:n * frame_bytes],
dtype=np.uint8).reshape(n, _TN2_H, _TN2_W, 3)
def _detect_transnetv2(video_path):
import torch
cap = cv2.VideoCapture(video_path)
fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
frames = _extract_frames_small(video_path)
model = _get_tn2_model()
threshold = float(os.environ.get("TRANSNETV2_THRESHOLD", "0.5"))
with _TN2_LOCK, torch.no_grad():
tensor = torch.from_numpy(np.ascontiguousarray(frames)).to(model.device)
single_frame_pred, _ = model.predict_frames(tensor, quiet=True)
# predictions_to_scenes returns [[start, end], ...] with INCLUSIVE ends;
# downstream expects PySceneDetect's exclusive ends.
raw = model.predictions_to_scenes(single_frame_pred.numpy(), threshold=threshold)
bounds = [(int(s), int(e) + 1) for s, e in raw]
# cv2's frame count can differ by a few frames from what ffmpeg decodes;
# downstream loops run to the decoder's count, so cover the gap.
if total_frames > bounds[-1][1]:
bounds[-1] = (bounds[-1][0], total_frames)
min_sec = float(os.environ.get("SCENE_MIN_SEC", "0.4"))
bounds = _merge_short_scenes(bounds, fps, min_sec)
scene_list = [(FrameTimecode(s, fps), FrameTimecode(e, fps))
for s, e in bounds]
print(f" 🎬 Scene engine: TransNetV2 — {len(scene_list)} scenes")
return scene_list, fps
def _merge_short_scenes(bounds, fps, min_sec):
"""Absorb scenes shorter than min_sec into a neighbor. Ultra-short scenes
cause camera-snap bursts and starve the per-scene strategy sampling."""
if len(bounds) <= 1 or min_sec <= 0:
return bounds
min_frames = max(1, int(round(min_sec * float(fps))))
merged = []
for s, e in bounds:
if merged and (e - s) < min_frames:
merged[-1] = (merged[-1][0], e)
else:
merged.append((s, e))
# The first scene can still be short — fold it into the one after it.
if len(merged) > 1 and (merged[0][1] - merged[0][0]) < min_frames:
merged[1] = (merged[0][0], merged[1][1])
merged.pop(0)
return merged