Skip to content

Commit 2430ba6

Browse files
committed
[detectors] Complete timecode migration
Complete migrating all existing detectors and `StatsManager` to use timecodes instead of frame numbers. In some cases (e.g. `ThresholdDetector`), we still use frame numbers pending conversion of the algorithm.
1 parent 0a2b4b8 commit 2430ba6

File tree

8 files changed

+95
-148
lines changed

8 files changed

+95
-148
lines changed

scenedetect/detectors/adaptive_detector.py

Lines changed: 13 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def __init__(
8181
kernel_size=kernel_size,
8282
)
8383

84-
# TODO: Turn these options into properties.
84+
# TODO: Turn these public options into properties.
8585
self.min_scene_len = min_scene_len
8686
self.adaptive_threshold = adaptive_threshold
8787
self.min_content_val = min_content_val
@@ -90,41 +90,21 @@ def __init__(
9090
self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format(
9191
window_width=window_width, luma_only="" if not luma_only else "_lum"
9292
)
93-
self._first_frame_num = None
94-
95-
# NOTE: This must be different than `self._last_scene_cut` which is used by the base class.
96-
self._last_cut: ty.Optional[int] = None
97-
98-
self._buffer = []
93+
self._buffer: ty.List[ty.Tuple[FrameTimecode, float]] = []
94+
# NOTE: The name of last cut is different from `self._last_scene_cut` from our base class,
95+
# and serves a different purpose!
96+
self._last_cut: ty.Optional[FrameTimecode] = None
9997

10098
@property
10199
def event_buffer_length(self) -> int:
102-
"""Number of frames any detected cuts will be behind the current frame due to buffering."""
103100
return self.window_width
104101

105102
def get_metrics(self) -> ty.List[str]:
106-
"""Combines base ContentDetector metric keys with the AdaptiveDetector one."""
107103
return super().get_metrics() + [self._adaptive_ratio_key]
108104

109-
def stats_manager_required(self) -> bool:
110-
"""Not required for AdaptiveDetector."""
111-
return False
112-
113105
def process_frame(
114-
self, timecode: FrameTimecode, frame_img: ty.Optional[np.ndarray]
115-
) -> ty.List[int]:
116-
"""Process the next frame. `frame_num` is assumed to be sequential.
117-
118-
Args:
119-
frame_num (int): Frame number of frame that is being passed. Can start from any value
120-
but must remain sequential.
121-
frame_img (numpy.ndarray or None): Video frame corresponding to `frame_img`.
122-
123-
Returns:
124-
ty.List[int]: List of frames where scene cuts have been detected. There may be 0
125-
or more frames in the list, and not necessarily the same as frame_num.
126-
"""
127-
106+
self, timecode: FrameTimecode, frame_img: np.ndarray
107+
) -> ty.List[FrameTimecode]:
128108
# TODO(#283): Merge this with ContentDetector and turn it on by default.
129109

130110
super().process_frame(timecode=timecode, frame_img=frame_img)
@@ -138,7 +118,7 @@ def process_frame(
138118
if not len(self._buffer) >= required_frames:
139119
return []
140120
self._buffer = self._buffer[-required_frames:]
141-
(target_frame, target_score) = self._buffer[self.window_width]
121+
(target_timecode, target_score) = self._buffer[self.window_width]
142122
average_window_score = sum(
143123
score for i, (_frame, score) in enumerate(self._buffer) if i != self.window_width
144124
) / (2.0 * self.window_width)
@@ -152,7 +132,9 @@ def process_frame(
152132
# if we would have divided by zero, set adaptive_ratio to the max (255.0)
153133
adaptive_ratio = 255.0
154134
if self.stats_manager is not None:
155-
self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio})
135+
self.stats_manager.set_metrics(
136+
target_timecode, {self._adaptive_ratio_key: adaptive_ratio}
137+
)
156138

157139
# Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there
158140
# being a large enough content_val to trigger a cut
@@ -161,21 +143,6 @@ def process_frame(
161143
)
162144
min_length_met: bool = (timecode - self._last_cut) >= self.min_scene_len
163145
if threshold_met and min_length_met:
164-
self._last_cut = target_frame
165-
return [target_frame]
166-
return []
167-
168-
def get_content_val(self, frame_num: int) -> ty.Optional[float]:
169-
"""Returns the average content change for a frame."""
170-
# TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7.
171-
logger.error(
172-
"get_content_val is deprecated and will be removed. Lookup the value"
173-
" using a StatsManager with ContentDetector.FRAME_SCORE_KEY."
174-
)
175-
if self.stats_manager is not None:
176-
return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0]
177-
return 0.0
178-
179-
def post_process(self, _unused_frame_num: int):
180-
"""Not required for AdaptiveDetector."""
146+
self._last_cut = target_timecode
147+
return [target_timecode]
181148
return []

scenedetect/detectors/content_detector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ def _calculate_frame_score(self, timecode: FrameTimecode, frame_img: numpy.ndarr
179179
if self.stats_manager is not None:
180180
metrics = {self.FRAME_SCORE_KEY: frame_score}
181181
metrics.update(score_components._asdict())
182-
self.stats_manager.set_metrics(timecode.frame_num, metrics)
182+
self.stats_manager.set_metrics(timecode, metrics)
183183

184184
# Store all data required to calculate the next frame's score.
185185
self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges)

scenedetect/detectors/hash_detector.py

Lines changed: 24 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,27 @@
11
#
2-
# PySceneDetect: Python-Based Video Scene Detector
3-
# ---------------------------------------------------------------
4-
# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ]
5-
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
6-
# [ Documentation: http://pyscenedetect.readthedocs.org/ ]
2+
# PySceneDetect: Python-Based Video Scene Detector
3+
# -------------------------------------------------------------------
4+
# [ Site: https://scenedetect.com ]
5+
# [ Docs: https://scenedetect.com/docs/ ]
6+
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
77
#
88
# Copyright (C) 2014-2022 Brandon Castellano <http://www.bcastell.com>.
9+
# PySceneDetect is licensed under the BSD 3-Clause License; see the
10+
# included LICENSE file, or visit one of the above pages for details.
911
#
10-
# PySceneDetect is licensed under the BSD 3-Clause License; see the included
11-
# LICENSE file, or visit one of the following pages for details:
12-
# - https://github.com/Breakthrough/PySceneDetect/
13-
# - http://www.bcastell.com/projects/PySceneDetect/
14-
#
15-
# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest.
16-
# See the included LICENSE files or one of the above URLs for more information.
17-
#
18-
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19-
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20-
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21-
# AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
22-
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
23-
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24-
#
25-
"""``scenedetect.detectors.hash_detector`` Module
12+
""":py:class:`HashDetector` calculates a hash for each frame of a video using a perceptual
13+
hashing algorithm. The differences (distance) in hash value between frames is calculated.
14+
If this difference exceeds a set threshold, a scene cut is triggered.
2615
27-
This module implements the :py:class:`HashDetector`, which calculates a hash
28-
value for each from of a video using a perceptual hashing algorithm. Then, the
29-
differences in hash value between frames is calculated. If this difference
30-
exceeds a set threshold, a scene cut is triggered.
31-
32-
This detector is available from the command-line interface by using the
33-
`detect-hash` command.
16+
This detector is available from the command-line interface by using the `detect-hash` command.
3417
"""
3518

36-
# Third-Party Library Imports
19+
import typing as ty
20+
3721
import cv2
3822
import numpy
3923

40-
# PySceneDetect Library Imports
24+
from scenedetect.common import FrameTimecode
4125
from scenedetect.detector import SceneDetector
4226

4327

@@ -74,15 +58,17 @@ def __init__(
7458
self._size = size
7559
self._size_sq = float(size * size)
7660
self._factor = lowpass
77-
self._last_frame = None
78-
self._last_scene_cut = None
61+
self._last_frame: numpy.ndarray = None
62+
self._last_scene_cut: FrameTimecode = None
7963
self._last_hash = numpy.array([])
8064
self._metric_key = f"hash_dist [size={self._size} lowpass={self._factor}]"
8165

8266
def get_metrics(self):
8367
return [self._metric_key]
8468

85-
def process_frame(self, frame_num: int, frame_img: numpy.ndarray):
69+
def process_frame(
70+
self, timecode: FrameTimecode, frame_img: numpy.ndarray
71+
) -> ty.List[FrameTimecode]:
8672
"""Similar to ContentDetector, but using a perceptual hashing algorithm
8773
to calculate a hash for each frame and then calculate a hash difference
8874
frame to frame."""
@@ -91,7 +77,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray):
9177

9278
# Initialize last scene cut point at the beginning of the frames of interest.
9379
if self._last_scene_cut is None:
94-
self._last_scene_cut = frame_num
80+
self._last_scene_cut = timecode
9581

9682
# We can only start detecting once we have a frame to compare with.
9783
if self._last_frame is not None:
@@ -115,17 +101,17 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray):
115101
hash_dist_norm = hash_dist / self._size_sq
116102

117103
if self.stats_manager is not None:
118-
self.stats_manager.set_metrics(frame_num, {self._metric_key: hash_dist_norm})
104+
self.stats_manager.set_metrics(timecode, {self._metric_key: hash_dist_norm})
119105

120106
self._last_hash = curr_hash
121107

122108
# We consider any frame over the threshold a new scene, but only if
123109
# the minimum scene length has been reached (otherwise it is ignored).
124110
if hash_dist_norm >= self._threshold and (
125-
(frame_num - self._last_scene_cut) >= self._min_scene_len
111+
(timecode - self._last_scene_cut) >= self._min_scene_len
126112
):
127-
cut_list.append(frame_num)
128-
self._last_scene_cut = frame_num
113+
cut_list.append(timecode)
114+
self._last_scene_cut = timecode
129115

130116
self._last_frame = frame_img.copy()
131117

scenedetect/detectors/histogram_detector.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import cv2
2121
import numpy
2222

23-
# PySceneDetect Library Imports
23+
from scenedetect.common import FrameTimecode
2424
from scenedetect.detector import SceneDetector
2525

2626

@@ -48,10 +48,10 @@ def __init__(self, threshold: float = 0.05, bins: int = 256, min_scene_len: int
4848
self._bins = bins
4949
self._min_scene_len = min_scene_len
5050
self._last_hist = None
51-
self._last_scene_cut = None
51+
self._last_cut = None
5252
self._metric_key = f"hist_diff [bins={self._bins}]"
5353

54-
def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]:
54+
def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]:
5555
"""Computes the histogram of the luma channel of the frame image and compares it with the
5656
histogram of the luma channel of the previous frame. If the difference between the histograms
5757
exceeds the threshold, a scene cut is detected.
@@ -77,8 +77,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int
7777
raise ValueError("Image must have three color channels for HistogramDetector")
7878

7979
# Initialize last scene cut point at the beginning of the frames of interest.
80-
if not self._last_scene_cut:
81-
self._last_scene_cut = frame_num
80+
if not self._last_cut:
81+
self._last_cut = timecode
8282

8383
hist = self.calculate_histogram(frame_img, bins=self._bins)
8484

@@ -98,14 +98,14 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int
9898
# Example: If `_threshold` is set to 0.8, it implies that only changes resulting in a correlation
9999
# less than 0.8 between histograms will be considered significant enough to denote a scene change.
100100
if hist_diff <= self._threshold and (
101-
(frame_num - self._last_scene_cut) >= self._min_scene_len
101+
(timecode - self._last_cut) >= self._min_scene_len
102102
):
103-
cut_list.append(frame_num)
104-
self._last_scene_cut = frame_num
103+
cut_list.append(timecode)
104+
self._last_cut = timecode
105105

106106
# Save stats to a StatsManager if it is being used
107107
if self.stats_manager is not None:
108-
self.stats_manager.set_metrics(frame_num, {self._metric_key: hist_diff})
108+
self.stats_manager.set_metrics(timecode, {self._metric_key: hist_diff})
109109

110110
self._last_hist = hist
111111

scenedetect/detectors/threshold_detector.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ def __init__(
9393
def get_metrics(self) -> ty.List[str]:
9494
return self._metric_keys
9595

96-
def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty.List[int]:
96+
def process_frame(
97+
self, timecode: FrameTimecode, frame_img: numpy.ndarray
98+
) -> ty.List[FrameTimecode]:
9799
"""Process the next frame. `frame_num` is assumed to be sequential.
98100
99101
Args:
@@ -105,7 +107,8 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty
105107
ty.List[int]: List of frames where scene cuts have been detected. There may be 0
106108
or more frames in the list, and not necessarily the same as frame_num.
107109
"""
108-
# TODO(v0.7): We might need to consider PTS here instead.
110+
# TODO(v0.7): We need to consider PTS here instead. The methods below using frame numbers
111+
# won't work for variable framerates.
109112
frame_num = timecode.frame_num
110113

111114
# Initialize last scene cut point at the beginning of the frames of interest.
@@ -130,7 +133,7 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty
130133
else:
131134
frame_avg = numpy.mean(frame_img)
132135
if self.stats_manager is not None:
133-
self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg})
136+
self.stats_manager.set_metrics(timecode, {self._metric_keys[0]: frame_avg})
134137

135138
if self.processed_frame:
136139
if self.last_fade["type"] == "in" and (
@@ -166,7 +169,7 @@ def process_frame(self, timecode: FrameTimecode, frame_img: numpy.ndarray) -> ty
166169
self.processed_frame = True
167170
return [FrameTimecode(cut, fps=timecode) for cut in cuts]
168171

169-
def post_process(self, timecode: FrameTimecode):
172+
def post_process(self, timecode: FrameTimecode) -> ty.List[FrameTimecode]:
170173
"""Writes a final scene cut if the last detected fade was a fade-out.
171174
172175
Only writes the scene cut if add_final_scene is true, and the last fade

0 commit comments

Comments
 (0)