新增视频场景检测
This commit is contained in:
@@ -5,6 +5,10 @@ from pathlib import Path
|
||||
import threading
|
||||
import cv2
|
||||
import sys
|
||||
|
||||
from backend.scenedetect import scene_detect
|
||||
from backend.scenedetect.detectors import ContentDetector
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from backend.inpaint.lama_inpaint import LamaInpaint
|
||||
@@ -116,6 +120,21 @@ class SubtitleDetect:
|
||||
new_subtitle_frame_no_box_dict[key] = subtitle_frame_no_box_dict[key]
|
||||
return new_subtitle_frame_no_box_dict
|
||||
|
||||
@staticmethod
|
||||
def get_scene_div_frame_no(v_path):
|
||||
"""
|
||||
获取发生场景切换的帧号
|
||||
"""
|
||||
scene_div_frame_no_list = []
|
||||
scene_list = scene_detect(v_path, ContentDetector())
|
||||
for scene in scene_list:
|
||||
start, end = scene
|
||||
if start.frame_num == 0:
|
||||
pass
|
||||
else:
|
||||
scene_div_frame_no_list.append(start.frame_num + 1)
|
||||
return scene_div_frame_no_list
|
||||
|
||||
@staticmethod
|
||||
def are_similar(region1, region2):
|
||||
"""判断两个区域是否相似。"""
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""The ``scenedetect`` module comes with helper functions to simplify common use cases.
|
||||
:func:`scene_detect` can be used to perform scene detection on a video by path. :func:`open_video`
|
||||
can be used to open a video for a
|
||||
:class:`SceneManager <scenedetect.scene_manager.SceneManager>`.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
# OpenCV is a required package, but we don't have it as an explicit dependency since we
|
||||
# need to support both opencv-python and opencv-python-headless. Include some additional
|
||||
# context with the exception if this is the case.
|
||||
try:
|
||||
import cv2 as _
|
||||
except ModuleNotFoundError as ex:
|
||||
raise ModuleNotFoundError(
|
||||
"OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python",
|
||||
name='cv2',
|
||||
) from ex
|
||||
|
||||
# Commonly used classes/functions exported under the `scenedetect` namespace for brevity.
|
||||
from backend.scenedetect.platform import init_logger
|
||||
from backend.scenedetect.frame_timecode import FrameTimecode
|
||||
from backend.scenedetect.video_stream import VideoStream, VideoOpenFailure
|
||||
from backend.scenedetect.scene_detector import SceneDetector
|
||||
from backend.scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv,
|
||||
VideoStreamMoviePy, VideoCaptureAdapter)
|
||||
from backend.scenedetect.stats_manager import StatsManager, StatsFileCorrupt
|
||||
from backend.scenedetect.scene_manager import SceneManager, save_images
|
||||
|
||||
# Used for module identification and when printing version & about info
|
||||
# (e.g. calling `scenedetect version` or `scenedetect about`).
|
||||
__version__ = '0.6.2'
|
||||
|
||||
init_logger()
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
|
||||
def open_video(
|
||||
path: str,
|
||||
framerate: Optional[float] = None,
|
||||
backend: str = 'opencv',
|
||||
**kwargs,
|
||||
) -> VideoStream:
|
||||
"""Open a video at the given path. If `backend` is specified but not available on the current
|
||||
system, OpenCV (`VideoStreamCv2`) will be used as a fallback.
|
||||
|
||||
Arguments:
|
||||
path: Path to video file to open.
|
||||
framerate: Overrides detected framerate if set.
|
||||
backend: Name of specific backend to use, if possible. See
|
||||
:data:`scenedetect.backends.AVAILABLE_BACKENDS` for backends available on the current
|
||||
system. If the backend fails to open the video, OpenCV will be used as a fallback.
|
||||
kwargs: Optional named arguments to pass to the specified `backend` constructor for
|
||||
overriding backend-specific options.
|
||||
|
||||
Returns:
|
||||
Backend object created with the specified video path.
|
||||
|
||||
Raises:
|
||||
:class:`VideoOpenFailure`: Constructing the VideoStream fails. If multiple backends have
|
||||
been attempted, the error from the first backend will be returned.
|
||||
"""
|
||||
last_error: Exception = None
|
||||
# If `backend` is available, try to open the video at `path` using it.
|
||||
if backend in AVAILABLE_BACKENDS:
|
||||
backend_type = AVAILABLE_BACKENDS[backend]
|
||||
try:
|
||||
logger.debug('Opening video with %s...', backend_type.BACKEND_NAME)
|
||||
return backend_type(path, framerate, **kwargs)
|
||||
except VideoOpenFailure as ex:
|
||||
logger.warning('Failed to open video with %s: %s', backend_type.BACKEND_NAME, str(ex))
|
||||
if backend == VideoStreamCv2.BACKEND_NAME:
|
||||
raise
|
||||
last_error = ex
|
||||
else:
|
||||
logger.warning('Backend %s not available.', backend)
|
||||
# Fallback to OpenCV if `backend` is unavailable, or specified backend failed to open `path`.
|
||||
backend_type = VideoStreamCv2
|
||||
logger.warning('Trying another backend: %s', backend_type.BACKEND_NAME)
|
||||
try:
|
||||
return backend_type(path, framerate)
|
||||
except VideoOpenFailure as ex:
|
||||
logger.debug('Failed to open video: %s', str(ex))
|
||||
if last_error is None:
|
||||
last_error = ex
|
||||
# Propagate any exceptions raised from specified backend, instead of errors from the fallback.
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
|
||||
def scene_detect(
|
||||
video_path: str,
|
||||
detector: SceneDetector,
|
||||
stats_file_path: Optional[str] = None,
|
||||
show_progress: bool = False,
|
||||
start_time: Optional[Union[str, float, int]] = None,
|
||||
end_time: Optional[Union[str, float, int]] = None,
|
||||
start_in_scene: bool = False,
|
||||
) -> List[Tuple[FrameTimecode, FrameTimecode]]:
|
||||
"""Perform scene detection on a given video `path` using the specified `detector`.
|
||||
|
||||
Arguments:
|
||||
video_path: Path to input video (absolute or relative to working directory).
|
||||
detector: A `SceneDetector` instance (see :mod:`scenedetect.detectors` for a full list
|
||||
of detectors).
|
||||
stats_file_path: Path to save per-frame metrics to for statistical analysis or to
|
||||
determine a better threshold value.
|
||||
show_progress: Show a progress bar with estimated time remaining. Default is False.
|
||||
start_time: Starting point in video, in the form of a timecode ``HH:MM:SS[.nnn]`` (`str`),
|
||||
number of seconds ``123.45`` (`float`), or number of frames ``200`` (`int`).
|
||||
end_time: Starting point in video, in the form of a timecode ``HH:MM:SS[.nnn]`` (`str`),
|
||||
number of seconds ``123.45`` (`float`), or number of frames ``200`` (`int`).
|
||||
start_in_scene: Assume the video begins in a scene. This means that when detecting
|
||||
fast cuts with `ContentDetector`, if no cuts are found, the resulting scene list
|
||||
will contain a single scene spanning the entire video (instead of no scenes).
|
||||
When detecting fades with `ThresholdDetector`, the beginning portion of the video
|
||||
will always be included until the first fade-out event is detected.
|
||||
|
||||
Returns:
|
||||
List of scenes (pairs of :class:`FrameTimecode` objects).
|
||||
|
||||
Raises:
|
||||
:class:`VideoOpenFailure`: `video_path` could not be opened.
|
||||
:class:`StatsFileCorrupt`: `stats_file_path` is an invalid stats file
|
||||
ValueError: `start_time` or `end_time` are incorrectly formatted.
|
||||
TypeError: `start_time` or `end_time` are invalid types.
|
||||
"""
|
||||
video = open_video(video_path)
|
||||
if start_time is not None:
|
||||
start_time = video.base_timecode + start_time
|
||||
video.seek(start_time)
|
||||
if end_time is not None:
|
||||
end_time = video.base_timecode + end_time
|
||||
# To reduce memory consumption when not required, we only add a StatsManager if we
|
||||
# need to save frame metrics to disk.
|
||||
scene_manager = SceneManager(StatsManager() if stats_file_path else None)
|
||||
scene_manager.add_detector(detector)
|
||||
scene_manager.detect_scenes(
|
||||
video=video,
|
||||
show_progress=show_progress,
|
||||
end_time=end_time,
|
||||
)
|
||||
if not scene_manager.stats_manager is None:
|
||||
scene_manager.stats_manager.save_to_csv(csv_file=stats_file_path)
|
||||
return scene_manager.get_scene_list(start_in_scene=start_in_scene)
|
||||
Executable
+61
@@ -0,0 +1,61 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""Entry point for PySceneDetect's command-line interface."""
|
||||
|
||||
from logging import getLogger
|
||||
import sys
|
||||
|
||||
from backend.scenedetect._cli import scenedetect
|
||||
from backend.scenedetect._cli.context import CliContext
|
||||
from backend.scenedetect._cli.controller import run_scenedetect
|
||||
|
||||
from backend.scenedetect.platform import logging_redirect_tqdm, FakeTqdmLoggingRedirect
|
||||
|
||||
|
||||
def main():
|
||||
"""PySceneDetect command-line interface (CLI) entry point."""
|
||||
cli_ctx = CliContext()
|
||||
try:
|
||||
# Process command line arguments and subcommands to initialize the context.
|
||||
scenedetect.main(obj=cli_ctx) # Parse CLI arguments with registered callbacks.
|
||||
except SystemExit as exit:
|
||||
help_command = any(arg in sys.argv for arg in ['-h', '--help'])
|
||||
if help_command or exit.code != 0:
|
||||
raise
|
||||
|
||||
# If we get here, processing the command line and loading the context worked. Let's run
|
||||
# the controller if we didn't process any help requests.
|
||||
logger = getLogger('pyscenedetect')
|
||||
# Ensure log messages don't conflict with any progress bars. If we're in quiet mode, where
|
||||
# no progress bars get created, we instead create a fake context manager. This is done here
|
||||
# to avoid needing a separate context manager at each point a progress bar is created.
|
||||
log_redirect = FakeTqdmLoggingRedirect() if cli_ctx.quiet_mode else logging_redirect_tqdm(
|
||||
loggers=[logger])
|
||||
|
||||
with log_redirect:
|
||||
try:
|
||||
run_scenedetect(cli_ctx)
|
||||
except KeyboardInterrupt:
|
||||
logger.info('Stopped.')
|
||||
if __debug__:
|
||||
raise
|
||||
except BaseException as ex:
|
||||
if __debug__:
|
||||
raise
|
||||
else:
|
||||
logger.critical('Unhandled exception:', exc_info=ex)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,548 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""Handles loading configuration files from disk and validating each section. Only validation of the
|
||||
config file schema and data types are performed. Constants/defaults are also defined here where
|
||||
possible and re-used by the CLI so that there is one source of truth.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
from configparser import ConfigParser, ParsingError
|
||||
from typing import Any, AnyStr, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from scenedetect.detectors import ContentDetector
|
||||
from scenedetect.frame_timecode import FrameTimecode
|
||||
from scenedetect.scene_manager import Interpolation
|
||||
from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS
|
||||
|
||||
VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO']
|
||||
|
||||
|
||||
class OptionParseFailure(Exception):
|
||||
"""Raised when a value provided in a user config file fails validation."""
|
||||
|
||||
def __init__(self, error):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
|
||||
|
||||
class ValidatedValue(ABC):
|
||||
"""Used to represent configuration values that must be validated against constraints."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def value(self) -> Any:
|
||||
"""Get the value after validation."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue':
|
||||
"""Validate and get the user-specified configuration option.
|
||||
|
||||
Raises:
|
||||
OptionParseFailure: Value from config file did not meet validation constraints.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class TimecodeValue(ValidatedValue):
|
||||
"""Validator for timecode values in frames (1234), seconds (123.4s), or HH:MM:SS.
|
||||
|
||||
Stores value in original representation."""
|
||||
|
||||
def __init__(self, value: Union[int, float, str]):
|
||||
# Ensure value is a valid timecode.
|
||||
FrameTimecode(timecode=value, fps=100.0)
|
||||
self._value = value
|
||||
|
||||
@property
|
||||
def value(self) -> Union[int, float, str]:
|
||||
return self._value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def from_config(config_value: str, default: 'TimecodeValue') -> 'TimecodeValue':
|
||||
try:
|
||||
return TimecodeValue(config_value)
|
||||
except ValueError as ex:
|
||||
raise OptionParseFailure(
|
||||
'Timecodes must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400).'
|
||||
) from ex
|
||||
|
||||
|
||||
class RangeValue(ValidatedValue):
|
||||
"""Validator for int/float ranges. `min_val` and `max_val` are inclusive."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: Union[int, float],
|
||||
min_val: Union[int, float],
|
||||
max_val: Union[int, float],
|
||||
):
|
||||
if value < min_val or value > max_val:
|
||||
# min and max are inclusive.
|
||||
raise ValueError()
|
||||
self._value = value
|
||||
self._min_val = min_val
|
||||
self._max_val = max_val
|
||||
|
||||
@property
|
||||
def value(self) -> Union[int, float]:
|
||||
return self._value
|
||||
|
||||
@property
|
||||
def min_val(self) -> Union[int, float]:
|
||||
"""Minimum value of the range."""
|
||||
return self._min_val
|
||||
|
||||
@property
|
||||
def max_val(self) -> Union[int, float]:
|
||||
"""Maximum value of the range."""
|
||||
return self._max_val
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def from_config(config_value: str, default: 'RangeValue') -> 'RangeValue':
|
||||
try:
|
||||
return RangeValue(
|
||||
value=int(config_value) if isinstance(default.value, int) else float(config_value),
|
||||
min_val=default.min_val,
|
||||
max_val=default.max_val,
|
||||
)
|
||||
except ValueError as ex:
|
||||
raise OptionParseFailure('Value must be between %s and %s.' %
|
||||
(default.min_val, default.max_val)) from ex
|
||||
|
||||
|
||||
class ScoreWeightsValue(ValidatedValue):
|
||||
"""Validator for score weight values (currently a tuple of four numbers)."""
|
||||
|
||||
_IGNORE_CHARS = [',', '/', '(', ')']
|
||||
"""Characters to ignore."""
|
||||
|
||||
def __init__(self, value: Union[str, ContentDetector.Components]):
|
||||
if isinstance(value, ContentDetector.Components):
|
||||
self._value = value
|
||||
else:
|
||||
translation_table = str.maketrans(
|
||||
{char: ' ' for char in ScoreWeightsValue._IGNORE_CHARS})
|
||||
values = value.translate(translation_table).split()
|
||||
if not len(values) == 4:
|
||||
raise ValueError("Score weights must be specified as four numbers!")
|
||||
self._value = ContentDetector.Components(*(float(val) for val in values))
|
||||
|
||||
@property
|
||||
def value(self) -> Tuple[float, float, float, float]:
|
||||
return self._value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return '%.3f, %.3f, %.3f, %.3f' % self.value
|
||||
|
||||
@staticmethod
|
||||
def from_config(config_value: str, default: 'ScoreWeightsValue') -> 'ScoreWeightsValue':
|
||||
try:
|
||||
return ScoreWeightsValue(config_value)
|
||||
except ValueError as ex:
|
||||
raise OptionParseFailure(
|
||||
'Score weights must be specified as four numbers in the form (H,S,L,E),'
|
||||
' e.g. (0.9, 0.2, 2.0, 0.5). Commas/brackets/slashes are ignored.') from ex
|
||||
|
||||
|
||||
class KernelSizeValue(ValidatedValue):
|
||||
"""Validator for kernel sizes (odd integer > 1, or -1 for auto size)."""
|
||||
|
||||
def __init__(self, value: int):
|
||||
if value == -1:
|
||||
# Downscale factor of -1 maps to None internally for auto downscale.
|
||||
value = None
|
||||
elif value < 0:
|
||||
# Disallow other negative values.
|
||||
raise ValueError()
|
||||
elif value % 2 == 0:
|
||||
# Disallow even values.
|
||||
raise ValueError()
|
||||
self._value = value
|
||||
|
||||
@property
|
||||
def value(self) -> int:
|
||||
return self._value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value is None:
|
||||
return 'auto'
|
||||
return str(self.value)
|
||||
|
||||
@staticmethod
|
||||
def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeValue':
|
||||
try:
|
||||
return KernelSizeValue(int(config_value))
|
||||
except ValueError as ex:
|
||||
raise OptionParseFailure(
|
||||
'Value must be an odd integer greater than 1, or set to -1 for auto kernel size.'
|
||||
) from ex
|
||||
|
||||
|
||||
ConfigValue = Union[bool, int, float, str]
|
||||
ConfigDict = Dict[str, Dict[str, ConfigValue]]
|
||||
|
||||
_CONFIG_FILE_NAME: AnyStr = 'scenedetect.cfg'
|
||||
_CONFIG_FILE_DIR: AnyStr = user_config_dir("PySceneDetect", False)
|
||||
|
||||
CONFIG_FILE_PATH: AnyStr = os.path.join(_CONFIG_FILE_DIR, _CONFIG_FILE_NAME)
|
||||
|
||||
CONFIG_MAP: ConfigDict = {
|
||||
'backend-opencv': {
|
||||
'max-decode-attempts': 5,
|
||||
},
|
||||
'backend-pyav': {
|
||||
'suppress-output': False,
|
||||
'threading-mode': 'auto',
|
||||
},
|
||||
'scene_detect-adaptive': {
|
||||
'frame-window': 2,
|
||||
'kernel-size': KernelSizeValue(-1),
|
||||
'luma-only': False,
|
||||
'min-content-val': RangeValue(15.0, min_val=0.0, max_val=255.0),
|
||||
'min-scene-len': TimecodeValue(0),
|
||||
'threshold': RangeValue(3.0, min_val=0.0, max_val=255.0),
|
||||
'weights': ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS),
|
||||
# TODO(v0.7): Remove `min-delta-hsv``.
|
||||
'min-delta-hsv': RangeValue(15.0, min_val=0.0, max_val=255.0),
|
||||
},
|
||||
'scene_detect-content': {
|
||||
'kernel-size': KernelSizeValue(-1),
|
||||
'luma-only': False,
|
||||
'min-scene-len': TimecodeValue(0),
|
||||
'threshold': RangeValue(27.0, min_val=0.0, max_val=255.0),
|
||||
'weights': ScoreWeightsValue(ContentDetector.DEFAULT_COMPONENT_WEIGHTS),
|
||||
},
|
||||
'scene_detect-threshold': {
|
||||
'add-last-scene': True,
|
||||
'fade-bias': RangeValue(0, min_val=-100.0, max_val=100.0),
|
||||
'min-scene-len': TimecodeValue(0),
|
||||
'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0),
|
||||
},
|
||||
'load-scenes': {
|
||||
'start-col-name': 'Start Frame',
|
||||
},
|
||||
'export-html': {
|
||||
'filename': '$VIDEO_NAME-Scenes.html',
|
||||
'image-height': 0,
|
||||
'image-width': 0,
|
||||
'no-images': False,
|
||||
},
|
||||
'list-scenes': {
|
||||
'output': '',
|
||||
'filename': '$VIDEO_NAME-Scenes.csv',
|
||||
'no-output-file': False,
|
||||
'quiet': False,
|
||||
'skip-cuts': False,
|
||||
},
|
||||
'global': {
|
||||
'backend': 'opencv',
|
||||
'default-detector': 'scene_detect-adaptive',
|
||||
'downscale': 0,
|
||||
'downscale-method': 'linear',
|
||||
'drop-short-scenes': False,
|
||||
'frame-skip': 0,
|
||||
'merge-last-scene': False,
|
||||
'min-scene-len': TimecodeValue('0.6s'),
|
||||
'output': '',
|
||||
'verbosity': 'info',
|
||||
},
|
||||
'save-images': {
|
||||
'compression': RangeValue(3, min_val=0, max_val=9),
|
||||
'filename': '$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER',
|
||||
'format': 'jpeg',
|
||||
'frame-margin': 1,
|
||||
'height': 0,
|
||||
'num-images': 3,
|
||||
'output': '',
|
||||
'quality': RangeValue(0, min_val=0, max_val=100), # Default depends on format
|
||||
'scale': 1.0,
|
||||
'scale-method': 'linear',
|
||||
'width': 0,
|
||||
},
|
||||
'split-video': {
|
||||
'args': DEFAULT_FFMPEG_ARGS,
|
||||
'copy': False,
|
||||
'filename': '$VIDEO_NAME-Scene-$SCENE_NUMBER',
|
||||
'high-quality': False,
|
||||
'mkvmerge': False,
|
||||
'output': '',
|
||||
'preset': 'veryfast',
|
||||
'quiet': False,
|
||||
'rate-factor': RangeValue(22, min_val=0, max_val=100),
|
||||
},
|
||||
}
|
||||
"""Mapping of valid configuration file parameters and their default values or placeholders.
|
||||
The types of these values are used when decoding the configuration file. Valid choices for
|
||||
certain string options are stored in `CHOICE_MAP`."""
|
||||
|
||||
CHOICE_MAP: Dict[str, Dict[str, List[str]]] = {
|
||||
'global': {
|
||||
'backend': ['opencv', 'pyav', 'moviepy'],
|
||||
'default-detector': ['scene_detect-adaptive', 'scene_detect-content', 'scene_detect-threshold'],
|
||||
'downscale-method': [value.name.lower() for value in Interpolation],
|
||||
'verbosity': ['debug', 'info', 'warning', 'error', 'none'],
|
||||
},
|
||||
'split-video': {
|
||||
'preset': [
|
||||
'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower',
|
||||
'veryslow'
|
||||
],
|
||||
},
|
||||
'save-images': {
|
||||
'format': ['jpeg', 'png', 'webp'],
|
||||
'scale-method': [value.name.lower() for value in Interpolation],
|
||||
},
|
||||
'backend-pyav': {
|
||||
'threading_mode': [str(mode).lower() for mode in VALID_PYAV_THREAD_MODES],
|
||||
},
|
||||
}
|
||||
"""Mapping of string options which can only be of a particular set of values. We use a list instead
|
||||
of a set to preserve order when generating error contexts. Values are case-insensitive, and must be
|
||||
in lowercase in this map."""
|
||||
|
||||
|
||||
def _validate_structure(config: ConfigParser) -> List[str]:
|
||||
"""Validates the layout of the section/option mapping.
|
||||
|
||||
Returns:
|
||||
List of any parsing errors in human-readable form.
|
||||
"""
|
||||
errors: List[str] = []
|
||||
for section in config.sections():
|
||||
if not section in CONFIG_MAP.keys():
|
||||
errors.append('Unsupported config section: [%s]' % (section))
|
||||
continue
|
||||
for (option_name, _) in config.items(section):
|
||||
if not option_name in CONFIG_MAP[section].keys():
|
||||
errors.append('Unsupported config option in [%s]: %s' % (section, option_name))
|
||||
return errors
|
||||
|
||||
|
||||
def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
|
||||
"""Process the given configuration into a key-value mapping.
|
||||
|
||||
Returns:
|
||||
Configuration mapping and list of any processing errors in human readable form.
|
||||
"""
|
||||
out_map: ConfigDict = {}
|
||||
errors: List[str] = []
|
||||
for command in CONFIG_MAP:
|
||||
out_map[command] = {}
|
||||
for option in CONFIG_MAP[command]:
|
||||
if command in config and option in config[command]:
|
||||
try:
|
||||
value_type = None
|
||||
if isinstance(CONFIG_MAP[command][option], bool):
|
||||
value_type = 'yes/no value'
|
||||
out_map[command][option] = config.getboolean(command, option)
|
||||
continue
|
||||
elif isinstance(CONFIG_MAP[command][option], int):
|
||||
value_type = 'integer'
|
||||
out_map[command][option] = config.getint(command, option)
|
||||
continue
|
||||
elif isinstance(CONFIG_MAP[command][option], float):
|
||||
value_type = 'number'
|
||||
out_map[command][option] = config.getfloat(command, option)
|
||||
continue
|
||||
except ValueError as _:
|
||||
errors.append('Invalid [%s] value for %s: %s is not a valid %s.' %
|
||||
(command, option, config.get(command, option), value_type))
|
||||
continue
|
||||
|
||||
# Handle custom validation types.
|
||||
config_value = config.get(command, option)
|
||||
default = CONFIG_MAP[command][option]
|
||||
option_type = type(default)
|
||||
if issubclass(option_type, ValidatedValue):
|
||||
try:
|
||||
out_map[command][option] = option_type.from_config(
|
||||
config_value=config_value, default=default)
|
||||
except OptionParseFailure as ex:
|
||||
errors.append('Invalid [%s] value for %s:\n %s\n%s' %
|
||||
(command, option, config_value, ex.error))
|
||||
continue
|
||||
|
||||
# If we didn't process the value as a given type, handle it as a string. We also
|
||||
# replace newlines with spaces, and strip any remaining leading/trailing whitespace.
|
||||
if value_type is None:
|
||||
config_value = config.get(command, option).replace('\n', ' ').strip()
|
||||
if command in CHOICE_MAP and option in CHOICE_MAP[command]:
|
||||
if config_value.lower() not in CHOICE_MAP[command][option]:
|
||||
errors.append('Invalid [%s] value for %s: %s. Must be one of: %s.' %
|
||||
(command, option, config.get(command, option), ', '.join(
|
||||
choice for choice in CHOICE_MAP[command][option])))
|
||||
continue
|
||||
out_map[command][option] = config_value
|
||||
continue
|
||||
|
||||
return (out_map, errors)
|
||||
|
||||
|
||||
class ConfigLoadFailure(Exception):
|
||||
"""Raised when a user-specified configuration file fails to be loaded or validated."""
|
||||
|
||||
def __init__(self, init_log: Tuple[int, str], reason: Optional[Exception] = None):
|
||||
super().__init__()
|
||||
self.init_log = init_log
|
||||
self.reason = reason
|
||||
|
||||
|
||||
class ConfigRegistry:
|
||||
|
||||
def __init__(self, path: Optional[str] = None, throw_exception: bool = True):
|
||||
self._config: ConfigDict = {} # Options set in the loaded config file.
|
||||
self._init_log: List[Tuple[int, str]] = []
|
||||
self._initialized = False
|
||||
|
||||
try:
|
||||
self._load_from_disk(path)
|
||||
self._initialized = True
|
||||
|
||||
except ConfigLoadFailure as ex:
|
||||
if throw_exception:
|
||||
raise
|
||||
# If we fail to load the user config file, ensure the object is flagged as
|
||||
# uninitialized, and log the error so it can be dealt with if necessary.
|
||||
self._init_log = ex.init_log
|
||||
if ex.reason is not None:
|
||||
self._init_log += [
|
||||
(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' ')),
|
||||
]
|
||||
self._initialized = False
|
||||
|
||||
@property
|
||||
def config_dict(self) -> ConfigDict:
|
||||
"""Current configuration options that are set for each command."""
|
||||
return self._config
|
||||
|
||||
@property
|
||||
def initialized(self) -> bool:
|
||||
"""True if the ConfigRegistry was constructed without errors, False otherwise."""
|
||||
return self._initialized
|
||||
|
||||
def get_init_log(self):
|
||||
"""Get initialization log. Consumes the log, so subsequent calls will return None."""
|
||||
init_log = self._init_log
|
||||
self._init_log = []
|
||||
return init_log
|
||||
|
||||
def _log(self, log_level, log_str):
|
||||
self._init_log.append((log_level, log_str))
|
||||
|
||||
def _load_from_disk(self, path=None):
|
||||
# Validate `path`, or if not provided, use CONFIG_FILE_PATH if it exists.
|
||||
if path:
|
||||
self._init_log.append((logging.INFO, "Loading config from file:\n %s" % path))
|
||||
if not os.path.exists(path):
|
||||
self._init_log.append((logging.ERROR, "File not found: %s" % (path)))
|
||||
raise ConfigLoadFailure(self._init_log)
|
||||
else:
|
||||
# Gracefully handle the case where there isn't a user config file.
|
||||
if not os.path.exists(CONFIG_FILE_PATH):
|
||||
self._init_log.append((logging.DEBUG, "User config file not found."))
|
||||
return
|
||||
path = CONFIG_FILE_PATH
|
||||
self._init_log.append((logging.INFO, "Loading user config file:\n %s" % path))
|
||||
# Try to load and parse the config file at `path`.
|
||||
config = ConfigParser()
|
||||
try:
|
||||
with open(path, 'r') as config_file:
|
||||
config_file_contents = config_file.read()
|
||||
config.read_string(config_file_contents, source=path)
|
||||
except ParsingError as ex:
|
||||
raise ConfigLoadFailure(self._init_log, reason=ex)
|
||||
except OSError as ex:
|
||||
raise ConfigLoadFailure(self._init_log, reason=ex)
|
||||
# At this point the config file syntax is correct, but we need to still validate
|
||||
# the parsed options (i.e. that the options have valid values).
|
||||
errors = _validate_structure(config)
|
||||
if not errors:
|
||||
self._config, errors = _parse_config(config)
|
||||
if errors:
|
||||
for log_str in errors:
|
||||
self._init_log.append((logging.ERROR, log_str))
|
||||
raise ConfigLoadFailure(self._init_log)
|
||||
|
||||
def is_default(self, command: str, option: str) -> bool:
|
||||
"""True if specified config option is unset (i.e. the default), False otherwise."""
|
||||
return not (command in self._config and option in self._config[command])
|
||||
|
||||
def get_value(self,
|
||||
command: str,
|
||||
option: str,
|
||||
override: Optional[ConfigValue] = None,
|
||||
ignore_default: bool = False) -> ConfigValue:
|
||||
"""Get the current setting or default value of the specified command option."""
|
||||
assert command in CONFIG_MAP and option in CONFIG_MAP[command]
|
||||
if override is not None:
|
||||
return override
|
||||
if command in self._config and option in self._config[command]:
|
||||
value = self._config[command][option]
|
||||
else:
|
||||
value = CONFIG_MAP[command][option]
|
||||
if ignore_default:
|
||||
return None
|
||||
if issubclass(type(value), ValidatedValue):
|
||||
return value.value
|
||||
return value
|
||||
|
||||
def get_help_string(self,
|
||||
command: str,
|
||||
option: str,
|
||||
show_default: Optional[bool] = None) -> str:
|
||||
"""Get a string to specify for the help text indicating the current command option value,
|
||||
if set, or the default.
|
||||
|
||||
Arguments:
|
||||
command: A command name or, "global" for global options.
|
||||
option: Command-line option to set within `command`.
|
||||
show_default: Always show default value. Default is False for flag/bool values,
|
||||
True otherwise.
|
||||
"""
|
||||
assert command in CONFIG_MAP and option in CONFIG_MAP[command]
|
||||
is_flag = isinstance(CONFIG_MAP[command][option], bool)
|
||||
if command in self._config and option in self._config[command]:
|
||||
if is_flag:
|
||||
value_str = 'on' if self._config[command][option] else 'off'
|
||||
else:
|
||||
value_str = str(self._config[command][option])
|
||||
return ' [setting: %s]' % (value_str)
|
||||
if show_default is False or (show_default is None and is_flag
|
||||
and CONFIG_MAP[command][option] is False):
|
||||
return ''
|
||||
return ' [default: %s]' % (str(CONFIG_MAP[command][option]))
|
||||
@@ -0,0 +1,820 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""Context of which command-line options and config settings the user provided."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, AnyStr, Dict, Optional, Tuple, Type
|
||||
|
||||
import click
|
||||
|
||||
import scenedetect
|
||||
|
||||
from scenedetect import open_video, AVAILABLE_BACKENDS
|
||||
from scenedetect._scene_loader import SceneLoader
|
||||
|
||||
from scenedetect.scene_detector import SceneDetector
|
||||
from scenedetect.platform import get_and_create_path, get_cv2_imwrite_params, init_logger
|
||||
from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA
|
||||
from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable
|
||||
from scenedetect.video_splitter import is_mkvmerge_available, is_ffmpeg_available
|
||||
from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector
|
||||
from scenedetect.stats_manager import StatsManager
|
||||
from scenedetect.scene_manager import SceneManager, Interpolation
|
||||
|
||||
from scenedetect._cli.config import ConfigRegistry, ConfigLoadFailure, CHOICE_MAP
|
||||
|
||||
logger = logging.getLogger('pyscenedetect')
|
||||
|
||||
USER_CONFIG = ConfigRegistry(throw_exception=False)
|
||||
|
||||
|
||||
def parse_timecode(value: str,
|
||||
frame_rate: float,
|
||||
first_index_is_one: bool = False) -> FrameTimecode:
|
||||
"""Parses a user input string into a FrameTimecode assuming the given framerate.
|
||||
|
||||
If value is None, None will be returned instead of processing the value.
|
||||
|
||||
Raises:
|
||||
click.BadParameter
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
if first_index_is_one and value.isdigit():
|
||||
value = int(value)
|
||||
if value >= 1:
|
||||
value -= 1
|
||||
return FrameTimecode(timecode=value, fps=frame_rate)
|
||||
except ValueError as ex:
|
||||
raise click.BadParameter(
|
||||
'timecode must be in frames (1234), seconds (123.4s), or HH:MM:SS (00:02:03.400)'
|
||||
) from ex
|
||||
|
||||
|
||||
def contains_sequence_or_url(video_path: str) -> bool:
|
||||
"""Checks if the video path is a URL or image sequence."""
|
||||
return '%' in video_path or '://' in video_path
|
||||
|
||||
|
||||
def check_split_video_requirements(use_mkvmerge: bool) -> None:
|
||||
""" Validates that the proper tool is available on the system to perform the
|
||||
`split-video` command.
|
||||
|
||||
Arguments:
|
||||
use_mkvmerge: True if mkvmerge (-m), False otherwise.
|
||||
|
||||
Raises: click.BadParameter if the proper video splitting tool cannot be found.
|
||||
"""
|
||||
|
||||
if (use_mkvmerge and not is_mkvmerge_available()) or not is_ffmpeg_available():
|
||||
error_strs = [
|
||||
"{EXTERN_TOOL} is required for split-video{EXTRA_ARGS}.".format(
|
||||
EXTERN_TOOL='mkvmerge' if use_mkvmerge else 'ffmpeg',
|
||||
EXTRA_ARGS=' when mkvmerge (-m) is set' if use_mkvmerge else '')
|
||||
]
|
||||
error_strs += ['Ensure the program is available on your system and try again.']
|
||||
if not use_mkvmerge and is_mkvmerge_available():
|
||||
error_strs += ['You can specify mkvmerge (-m) to use mkvmerge for splitting.']
|
||||
elif use_mkvmerge and is_ffmpeg_available():
|
||||
error_strs += ['You can specify copy (-c) to use ffmpeg stream copying.']
|
||||
error_str = '\n'.join(error_strs)
|
||||
raise click.BadParameter(error_str, param_hint='split-video')
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes,too-many-arguments,too-many-locals
|
||||
class CliContext:
|
||||
"""Context of the command-line interface and config file parameters passed between sub-commands.
|
||||
|
||||
Handles validation of options taken in from the CLI *and* configuration files.
|
||||
|
||||
After processing the main program options via `handle_options`, the CLI will then call
|
||||
the respective `handle_*` method for each command. Once all commands have been
|
||||
processed, the main program actions are executed by passing this object to the
|
||||
`run_scenedetect` function in `scenedetect.cli.controller`.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = USER_CONFIG
|
||||
self.video_stream: VideoStream = None
|
||||
self.scene_manager: SceneManager = None
|
||||
self.stats_manager: StatsManager = None
|
||||
|
||||
# Global `scenedetect` Options
|
||||
self.output_directory: str = None # -o/--output
|
||||
self.quiet_mode: bool = None # -q/--quiet or -v/--verbosity quiet
|
||||
self.stats_file_path: str = None # -s/--stats
|
||||
self.drop_short_scenes: bool = None # --drop-short-scenes
|
||||
self.merge_last_scene: bool = None # --merge-last-scene
|
||||
self.min_scene_len: FrameTimecode = None # -m/--min-scene-len
|
||||
self.frame_skip: int = None # -fs/--frame-skip
|
||||
self.default_detector: Tuple[Type[SceneDetector],
|
||||
Dict[str, Any]] = None # [global] default-detector
|
||||
|
||||
# `time` Command Options
|
||||
self.time: bool = False
|
||||
self.start_time: FrameTimecode = None # time -s/--start
|
||||
self.end_time: FrameTimecode = None # time -e/--end
|
||||
self.duration: FrameTimecode = None # time -d/--duration
|
||||
|
||||
# `save-images` Command Options
|
||||
self.save_images: bool = False
|
||||
self.image_extension: str = None # save-images -j/--jpeg, -w/--webp, -p/--png
|
||||
self.image_directory: str = None # save-images -o/--output
|
||||
self.image_param: int = None # save-images -q/--quality if -j/-w,
|
||||
# otherwise -c/--compression if -p
|
||||
self.image_name_format: str = None # save-images -f/--name-format
|
||||
self.num_images: int = None # save-images -n/--num-images
|
||||
self.frame_margin: int = 1 # save-images -m/--frame-margin
|
||||
self.scale: float = None # save-images -s/--scale
|
||||
self.height: int = None # save-images -h/--height
|
||||
self.width: int = None # save-images -w/--width
|
||||
self.scale_method: Interpolation = None # [save-images] scale-method
|
||||
|
||||
# `split-video` Command Options
|
||||
self.split_video: bool = False
|
||||
self.split_mkvmerge: bool = None # split-video -m/--mkvmerge
|
||||
self.split_args: str = None # split-video -a/--args, -c/--copy
|
||||
self.split_directory: str = None # split-video -o/--output
|
||||
self.split_name_format: str = None # split-video -f/--filename
|
||||
self.split_quiet: bool = None # split-video -q/--quiet
|
||||
|
||||
# `list-scenes` Command Options
|
||||
self.list_scenes: bool = False
|
||||
self.print_scene_list: bool = None # list-scenes -q/--quiet
|
||||
self.scene_list_directory: str = None # list-scenes -o/--output
|
||||
self.scene_list_name_format: str = None # list-scenes -f/--filename
|
||||
self.scene_list_output: bool = None # list-scenes -n/--no-output
|
||||
self.skip_cuts: bool = None # list-scenes -s/--skip-cuts
|
||||
|
||||
# `export-html` Command Options
|
||||
self.export_html: bool = False
|
||||
self.html_name_format: str = None # export-html -f/--filename
|
||||
self.html_include_images: bool = None # export-html --no-images
|
||||
self.image_width: int = None # export-html -w/--image-width
|
||||
self.image_height: int = None # export-html -h/--image-height
|
||||
|
||||
#
|
||||
# Command Handlers
|
||||
#
|
||||
|
||||
def handle_options(
|
||||
self,
|
||||
input_path: AnyStr,
|
||||
output: Optional[AnyStr],
|
||||
framerate: float,
|
||||
stats_file: Optional[AnyStr],
|
||||
downscale: Optional[int],
|
||||
frame_skip: int,
|
||||
min_scene_len: str,
|
||||
drop_short_scenes: bool,
|
||||
merge_last_scene: bool,
|
||||
backend: Optional[str],
|
||||
quiet: bool,
|
||||
logfile: Optional[AnyStr],
|
||||
config: Optional[AnyStr],
|
||||
stats: Optional[AnyStr],
|
||||
verbosity: Optional[str],
|
||||
):
|
||||
"""Parse all global options/arguments passed to the main scenedetect command,
|
||||
before other sub-commands (e.g. this function processes the [options] when calling
|
||||
`scenedetect [options] [commands [command options]]`).
|
||||
|
||||
Raises:
|
||||
click.BadParameter: One of the given options/parameters is invalid.
|
||||
click.Abort: Fatal initialization failure.
|
||||
"""
|
||||
|
||||
# TODO(v1.0): Make the stats value optional (e.g. allow -s only), and allow use of
|
||||
# $VIDEO_NAME macro in the name. Default to $VIDEO_NAME.csv.
|
||||
|
||||
try:
|
||||
init_failure = not self.config.initialized
|
||||
init_log = self.config.get_init_log()
|
||||
quiet = not init_failure and quiet
|
||||
self._initialize_logging(quiet=quiet, verbosity=verbosity, logfile=logfile)
|
||||
|
||||
# Configuration file was specified via CLI argument -c/--config.
|
||||
if config and not init_failure:
|
||||
self.config = ConfigRegistry(config)
|
||||
init_log += self.config.get_init_log()
|
||||
# Re-initialize logger with the correct verbosity.
|
||||
if verbosity is None and not self.config.is_default('global', 'verbosity'):
|
||||
verbosity_str = self.config.get_value('global', 'verbosity')
|
||||
assert verbosity_str in CHOICE_MAP['global']['verbosity']
|
||||
self.quiet_mode = False
|
||||
self._initialize_logging(verbosity=verbosity_str, logfile=logfile)
|
||||
|
||||
except ConfigLoadFailure as ex:
|
||||
init_failure = True
|
||||
init_log += ex.init_log
|
||||
if ex.reason is not None:
|
||||
init_log += [(logging.ERROR, 'Error: %s' % str(ex.reason).replace('\t', ' '))]
|
||||
finally:
|
||||
# Make sure we print the version number even on any kind of init failure.
|
||||
logger.info('PySceneDetect %s', scenedetect.__version__)
|
||||
for (log_level, log_str) in init_log:
|
||||
logger.log(log_level, log_str)
|
||||
if init_failure:
|
||||
logger.critical("Error processing configuration file.")
|
||||
raise click.Abort()
|
||||
|
||||
if self.config.config_dict:
|
||||
logger.debug("Current configuration:\n%s", str(self.config.config_dict))
|
||||
|
||||
logger.debug('Parsing program options.')
|
||||
if stats is not None and frame_skip:
|
||||
error_strs = [
|
||||
'Unable to scene_detect scenes with stats file if frame skip is not 0.',
|
||||
' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n'
|
||||
]
|
||||
logger.error('\n'.join(error_strs))
|
||||
raise click.BadParameter(
|
||||
'Combining the -s/--stats and -fs/--frame-skip options is not supported.',
|
||||
param_hint='frame skip + stats file')
|
||||
|
||||
# Handle the case where -i/--input was not specified (e.g. for the `help` command).
|
||||
if input_path is None:
|
||||
return
|
||||
|
||||
# Have to load the input video to obtain a time base before parsing timecodes.
|
||||
self._open_video_stream(
|
||||
input_path=input_path,
|
||||
framerate=framerate,
|
||||
backend=self.config.get_value("global", "backend", backend, ignore_default=True))
|
||||
|
||||
self.output_directory = output if output else self.config.get_value("global", "output")
|
||||
if self.output_directory:
|
||||
logger.info('Output directory set:\n %s', self.output_directory)
|
||||
|
||||
self.min_scene_len = parse_timecode(
|
||||
min_scene_len if min_scene_len is not None else self.config.get_value(
|
||||
"global", "min-scene-len"), self.video_stream.frame_rate)
|
||||
self.drop_short_scenes = drop_short_scenes or self.config.get_value(
|
||||
"global", "drop-short-scenes")
|
||||
self.merge_last_scene = merge_last_scene or self.config.get_value(
|
||||
"global", "merge-last-scene")
|
||||
self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip)
|
||||
|
||||
# Create StatsManager if --stats is specified.
|
||||
if stats_file:
|
||||
self.stats_file_path = get_and_create_path(stats_file, self.output_directory)
|
||||
self.stats_manager = StatsManager()
|
||||
|
||||
# Initialize default detector with values in the config file.
|
||||
default_detector = self.config.get_value("global", "default-detector")
|
||||
if default_detector == 'scene_detect-adaptive':
|
||||
self.default_detector = (AdaptiveDetector, self.get_detect_adaptive_params())
|
||||
elif default_detector == 'scene_detect-content':
|
||||
self.default_detector = (ContentDetector, self.get_detect_content_params())
|
||||
elif default_detector == 'scene_detect-threshold':
|
||||
self.default_detector = (ThresholdDetector, self.get_detect_threshold_params())
|
||||
else:
|
||||
raise click.BadParameter("Unknown detector type!", param_hint='default-detector')
|
||||
|
||||
logger.debug('Initializing SceneManager.')
|
||||
scene_manager = SceneManager(self.stats_manager)
|
||||
|
||||
if downscale is None and self.config.is_default("global", "downscale"):
|
||||
scene_manager.auto_downscale = True
|
||||
else:
|
||||
scene_manager.auto_downscale = False
|
||||
downscale = self.config.get_value("global", "downscale", downscale)
|
||||
try:
|
||||
scene_manager.downscale = downscale
|
||||
except ValueError as ex:
|
||||
logger.debug(str(ex))
|
||||
raise click.BadParameter(str(ex), param_hint='downscale factor')
|
||||
scene_manager.interpolation = Interpolation[self.config.get_value(
|
||||
'global', 'downscale-method').upper()]
|
||||
self.scene_manager = scene_manager
|
||||
|
||||
def get_detect_content_params(
|
||||
self,
|
||||
threshold: Optional[float] = None,
|
||||
luma_only: bool = None,
|
||||
min_scene_len: Optional[str] = None,
|
||||
weights: Optional[Tuple[float, float, float, float]] = None,
|
||||
kernel_size: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle scene_detect-content command options and return dict to construct one with."""
|
||||
self._ensure_input_open()
|
||||
|
||||
if self.drop_short_scenes:
|
||||
min_scene_len = 0
|
||||
else:
|
||||
if min_scene_len is None:
|
||||
if self.config.is_default('scene_detect-content', 'min-scene-len'):
|
||||
min_scene_len = self.min_scene_len.frame_num
|
||||
else:
|
||||
min_scene_len = self.config.get_value('scene_detect-content', 'min-scene-len')
|
||||
min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
|
||||
|
||||
if weights is not None:
|
||||
try:
|
||||
weights = ContentDetector.Components(*weights)
|
||||
except ValueError as ex:
|
||||
logger.debug(str(ex))
|
||||
raise click.BadParameter(str(ex), param_hint='weights')
|
||||
return {
|
||||
'weights': self.config.get_value('scene_detect-content', 'weights', weights),
|
||||
'kernel_size': self.config.get_value('scene_detect-content', 'kernel-size', kernel_size),
|
||||
'luma_only': luma_only or self.config.get_value('scene_detect-content', 'luma-only'),
|
||||
'min_scene_len': min_scene_len,
|
||||
'threshold': self.config.get_value('scene_detect-content', 'threshold', threshold),
|
||||
}
|
||||
|
||||
def get_detect_adaptive_params(
|
||||
self,
|
||||
threshold: Optional[float] = None,
|
||||
min_content_val: Optional[float] = None,
|
||||
frame_window: Optional[int] = None,
|
||||
luma_only: bool = None,
|
||||
min_scene_len: Optional[str] = None,
|
||||
weights: Optional[Tuple[float, float, float, float]] = None,
|
||||
kernel_size: Optional[int] = None,
|
||||
min_delta_hsv: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle scene_detect-adaptive command options and return dict to construct one with."""
|
||||
self._ensure_input_open()
|
||||
|
||||
# TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv.
|
||||
if min_delta_hsv is not None:
|
||||
logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.')
|
||||
if min_content_val is None:
|
||||
min_content_val = min_delta_hsv
|
||||
# Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val.
|
||||
if not self.config.is_default("scene_detect-adaptive", "min-delta-hsv"):
|
||||
logger.error('[scene_detect-adaptive] config file option `min-delta-hsv` is deprecated'
|
||||
', use `min-delta-hsv` instead.')
|
||||
if self.config.is_default("scene_detect-adaptive", "min-content-val"):
|
||||
self.config.config_dict["scene_detect-adaptive"]["min-content-val"] = (
|
||||
self.config.config_dict["scene_detect-adaptive"]["min-deleta-hsv"])
|
||||
|
||||
if self.drop_short_scenes:
|
||||
min_scene_len = 0
|
||||
else:
|
||||
if min_scene_len is None:
|
||||
if self.config.is_default("scene_detect-adaptive", "min-scene-len"):
|
||||
min_scene_len = self.min_scene_len.frame_num
|
||||
else:
|
||||
min_scene_len = self.config.get_value("scene_detect-adaptive", "min-scene-len")
|
||||
min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
|
||||
|
||||
if weights is not None:
|
||||
try:
|
||||
weights = ContentDetector.Components(*weights)
|
||||
except ValueError as ex:
|
||||
logger.debug(str(ex))
|
||||
raise click.BadParameter(str(ex), param_hint='weights')
|
||||
return {
|
||||
'adaptive_threshold':
|
||||
self.config.get_value("scene_detect-adaptive", "threshold", threshold),
|
||||
'weights':
|
||||
self.config.get_value("scene_detect-adaptive", "weights", weights),
|
||||
'kernel_size':
|
||||
self.config.get_value("scene_detect-adaptive", "kernel-size", kernel_size),
|
||||
'luma_only':
|
||||
luma_only or self.config.get_value("scene_detect-adaptive", "luma-only"),
|
||||
'min_content_val':
|
||||
self.config.get_value("scene_detect-adaptive", "min-content-val", min_content_val),
|
||||
'min_scene_len':
|
||||
min_scene_len,
|
||||
'window_width':
|
||||
self.config.get_value("scene_detect-adaptive", "frame-window", frame_window),
|
||||
}
|
||||
|
||||
def get_detect_threshold_params(
|
||||
self,
|
||||
threshold: Optional[float] = None,
|
||||
fade_bias: Optional[float] = None,
|
||||
add_last_scene: bool = None,
|
||||
min_scene_len: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle scene_detect-threshold command options and return dict to construct one with."""
|
||||
self._ensure_input_open()
|
||||
|
||||
if self.drop_short_scenes:
|
||||
min_scene_len = 0
|
||||
else:
|
||||
if min_scene_len is None:
|
||||
if self.config.is_default("scene_detect-threshold", "min-scene-len"):
|
||||
min_scene_len = self.min_scene_len.frame_num
|
||||
else:
|
||||
min_scene_len = self.config.get_value("scene_detect-threshold", "min-scene-len")
|
||||
min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num
|
||||
|
||||
return {
|
||||
# TODO(v1.0): add_last_scene cannot be disabled right now.
|
||||
'add_final_scene':
|
||||
add_last_scene or self.config.get_value("scene_detect-threshold", "add-last-scene"),
|
||||
'fade_bias':
|
||||
self.config.get_value("scene_detect-threshold", "fade-bias", fade_bias),
|
||||
'min_scene_len':
|
||||
min_scene_len,
|
||||
'threshold':
|
||||
self.config.get_value("scene_detect-threshold", "threshold", threshold),
|
||||
}
|
||||
|
||||
def handle_load_scenes(self, input: AnyStr, start_col_name: Optional[str]):
|
||||
"""Handle `load-scenes` command options."""
|
||||
self._ensure_input_open()
|
||||
start_col_name = self.config.get_value("load-scenes", "start-col-name", start_col_name)
|
||||
self.add_detector(
|
||||
SceneLoader(
|
||||
file=input, framerate=self.video_stream.frame_rate, start_col_name=start_col_name))
|
||||
|
||||
def handle_export_html(
|
||||
self,
|
||||
filename: Optional[AnyStr],
|
||||
no_images: bool,
|
||||
image_width: Optional[int],
|
||||
image_height: Optional[int],
|
||||
):
|
||||
"""Handle `export-html` command options."""
|
||||
self._ensure_input_open()
|
||||
if self.export_html:
|
||||
self._on_duplicate_command('export_html')
|
||||
|
||||
no_images = no_images or self.config.get_value('export-html', 'no-images')
|
||||
self.html_include_images = not no_images
|
||||
|
||||
self.html_name_format = self.config.get_value('export-html', 'filename', filename)
|
||||
self.image_width = self.config.get_value('export-html', 'image-width', image_width)
|
||||
self.image_height = self.config.get_value('export-html', 'image-height', image_height)
|
||||
|
||||
if not self.save_images and not no_images:
|
||||
raise click.BadArgumentUsage(
|
||||
'The export-html command requires that the save-images command\n'
|
||||
'is specified before it, unless --no-images is specified.')
|
||||
logger.info('HTML file name format:\n %s', filename)
|
||||
|
||||
self.export_html = True
|
||||
|
||||
def handle_list_scenes(
|
||||
self,
|
||||
output: Optional[AnyStr],
|
||||
filename: Optional[AnyStr],
|
||||
no_output_file: bool,
|
||||
quiet: bool,
|
||||
skip_cuts: bool,
|
||||
):
|
||||
"""Handle `list-scenes` command options."""
|
||||
self._ensure_input_open()
|
||||
if self.list_scenes:
|
||||
self._on_duplicate_command('list-scenes')
|
||||
|
||||
self.skip_cuts = skip_cuts or self.config.get_value('list-scenes', 'skip-cuts')
|
||||
self.print_scene_list = not (quiet or self.config.get_value('list-scenes', 'quiet'))
|
||||
no_output_file = no_output_file or self.config.get_value('list-scenes', 'no-output-file')
|
||||
|
||||
self.scene_list_directory = self.config.get_value(
|
||||
'list-scenes', 'output', output, ignore_default=True)
|
||||
self.scene_list_name_format = self.config.get_value('list-scenes', 'filename', filename)
|
||||
if self.scene_list_name_format is not None and not no_output_file:
|
||||
logger.info('Scene list filename format:\n %s', self.scene_list_name_format)
|
||||
self.scene_list_output = not no_output_file
|
||||
if self.scene_list_directory is not None:
|
||||
logger.info('Scene list output directory:\n %s', self.scene_list_directory)
|
||||
|
||||
self.list_scenes = True
|
||||
|
||||
def handle_split_video(
|
||||
self,
|
||||
output: Optional[AnyStr],
|
||||
filename: Optional[AnyStr],
|
||||
quiet: bool,
|
||||
copy: bool,
|
||||
high_quality: bool,
|
||||
rate_factor: Optional[int],
|
||||
preset: Optional[str],
|
||||
args: Optional[str],
|
||||
mkvmerge: bool,
|
||||
):
|
||||
"""Handle `split-video` command options."""
|
||||
self._ensure_input_open()
|
||||
if self.split_video:
|
||||
self._on_duplicate_command('split-video')
|
||||
|
||||
check_split_video_requirements(use_mkvmerge=mkvmerge)
|
||||
|
||||
if contains_sequence_or_url(self.video_stream.path):
|
||||
error_str = 'The split-video command is incompatible with image sequences/URLs.'
|
||||
raise click.BadParameter(error_str, param_hint='split-video')
|
||||
|
||||
##
|
||||
## Common Arguments/Options
|
||||
##
|
||||
|
||||
self.split_video = True
|
||||
self.split_quiet = quiet or self.config.get_value('split-video', 'quiet')
|
||||
self.split_directory = self.config.get_value(
|
||||
'split-video', 'output', output, ignore_default=True)
|
||||
if self.split_directory is not None:
|
||||
logger.info('Video output path set: \n%s', self.split_directory)
|
||||
self.split_name_format = self.config.get_value('split-video', 'filename', filename)
|
||||
|
||||
# We only load the config values for these flags/options if none of the other
|
||||
# encoder flags/options were set via the CLI to avoid any conflicting options
|
||||
# (e.g. if the config file sets `high-quality = yes` but `--copy` is specified).
|
||||
if not (mkvmerge or copy or high_quality or args or rate_factor or preset):
|
||||
mkvmerge = self.config.get_value('split-video', 'mkvmerge')
|
||||
copy = self.config.get_value('split-video', 'copy')
|
||||
high_quality = self.config.get_value('split-video', 'high-quality')
|
||||
rate_factor = self.config.get_value('split-video', 'rate-factor')
|
||||
preset = self.config.get_value('split-video', 'preset')
|
||||
args = self.config.get_value('split-video', 'args')
|
||||
|
||||
# Disallow certain combinations of flags/options.
|
||||
if mkvmerge or copy:
|
||||
command = 'mkvmerge (-m)' if mkvmerge else 'copy (-c)'
|
||||
if high_quality:
|
||||
raise click.BadParameter(
|
||||
'high-quality (-hq) cannot be used with %s' % (command),
|
||||
param_hint='split-video')
|
||||
if args:
|
||||
raise click.BadParameter(
|
||||
'args (-a) cannot be used with %s' % (command), param_hint='split-video')
|
||||
if rate_factor:
|
||||
raise click.BadParameter(
|
||||
'rate-factor (crf) cannot be used with %s' % (command),
|
||||
param_hint='split-video')
|
||||
if preset:
|
||||
raise click.BadParameter(
|
||||
'preset (-p) cannot be used with %s' % (command), param_hint='split-video')
|
||||
|
||||
##
|
||||
## mkvmerge-Specific Arguments/Options
|
||||
##
|
||||
if mkvmerge:
|
||||
if copy:
|
||||
logger.warning('copy mode (-c) ignored due to mkvmerge mode (-m).')
|
||||
self.split_mkvmerge = True
|
||||
logger.info('Using mkvmerge for video splitting.')
|
||||
return
|
||||
|
||||
##
|
||||
## ffmpeg-Specific Arguments/Options
|
||||
##
|
||||
if copy:
|
||||
args = '-map 0 -c:v copy -c:a copy'
|
||||
elif not args:
|
||||
if rate_factor is None:
|
||||
rate_factor = 22 if not high_quality else 17
|
||||
if preset is None:
|
||||
preset = 'veryfast' if not high_quality else 'slow'
|
||||
args = ('-map 0 -c:v libx264 -preset {PRESET} -crf {RATE_FACTOR} -c:a aac'.format(
|
||||
PRESET=preset, RATE_FACTOR=rate_factor))
|
||||
|
||||
logger.info('ffmpeg arguments: %s', args)
|
||||
self.split_args = args
|
||||
if filename:
|
||||
logger.info('Output file name format: %s', filename)
|
||||
|
||||
def handle_save_images(
|
||||
self,
|
||||
num_images: Optional[int],
|
||||
output: Optional[AnyStr],
|
||||
filename: Optional[AnyStr],
|
||||
jpeg: bool,
|
||||
webp: bool,
|
||||
quality: Optional[int],
|
||||
png: bool,
|
||||
compression: Optional[int],
|
||||
frame_margin: Optional[int],
|
||||
scale: Optional[float],
|
||||
height: Optional[int],
|
||||
width: Optional[int],
|
||||
):
|
||||
"""Handle `save-images` command options."""
|
||||
self._ensure_input_open()
|
||||
if self.save_images:
|
||||
self._on_duplicate_command('save-images')
|
||||
|
||||
if '://' in self.video_stream.path:
|
||||
error_str = '\nThe save-images command is incompatible with URLs.'
|
||||
logger.error(error_str)
|
||||
raise click.BadParameter(error_str, param_hint='save-images')
|
||||
|
||||
num_flags = sum([1 if flag else 0 for flag in [jpeg, webp, png]])
|
||||
if num_flags > 1:
|
||||
logger.error('Multiple image type flags set for save-images command.')
|
||||
raise click.BadParameter(
|
||||
'Only one image type (JPG/PNG/WEBP) can be specified.', param_hint='save-images')
|
||||
# Only use config params for image format if one wasn't specified.
|
||||
elif num_flags == 0:
|
||||
image_format = self.config.get_value('save-images', 'format').lower()
|
||||
jpeg = image_format == 'jpeg'
|
||||
webp = image_format == 'webp'
|
||||
png = image_format == 'png'
|
||||
|
||||
# Only use config params for scale/height/width if none of them are specified explicitly.
|
||||
if scale is None and height is None and width is None:
|
||||
self.scale = self.config.get_value('save-images', 'scale')
|
||||
self.height = self.config.get_value('save-images', 'height')
|
||||
self.width = self.config.get_value('save-images', 'width')
|
||||
else:
|
||||
self.scale = scale
|
||||
self.height = height
|
||||
self.width = width
|
||||
|
||||
self.scale_method = Interpolation[self.config.get_value('save-images',
|
||||
'scale-method').upper()]
|
||||
|
||||
default_quality = 100 if webp else 95
|
||||
quality = (
|
||||
default_quality if self.config.is_default('save-images', 'quality') else
|
||||
self.config.get_value('save-images', 'quality'))
|
||||
|
||||
compression = self.config.get_value('save-images', 'compression', compression)
|
||||
self.image_param = compression if png else quality
|
||||
|
||||
self.image_extension = 'jpg' if jpeg else 'png' if png else 'webp'
|
||||
valid_params = get_cv2_imwrite_params()
|
||||
if not self.image_extension in valid_params or valid_params[self.image_extension] is None:
|
||||
error_strs = [
|
||||
'Image encoder type `%s` not supported.' % self.image_extension.upper(),
|
||||
'The specified encoder type could not be found in the current OpenCV module.',
|
||||
'To enable this output format, please update the installed version of OpenCV.',
|
||||
'If you build OpenCV, ensure the the proper dependencies are enabled. '
|
||||
]
|
||||
logger.debug('\n'.join(error_strs))
|
||||
raise click.BadParameter('\n'.join(error_strs), param_hint='save-images')
|
||||
|
||||
self.image_directory = self.config.get_value(
|
||||
'save-images', 'output', output, ignore_default=True)
|
||||
|
||||
self.image_name_format = self.config.get_value('save-images', 'filename', filename)
|
||||
self.num_images = self.config.get_value('save-images', 'num-images', num_images)
|
||||
self.frame_margin = self.config.get_value('save-images', 'frame-margin', frame_margin)
|
||||
|
||||
image_type = ('jpeg' if jpeg else self.image_extension).upper()
|
||||
image_param_type = 'Compression' if png else 'Quality'
|
||||
image_param_type = ' [%s: %d]' % (image_param_type, self.image_param)
|
||||
logger.info('Image output format set: %s%s', image_type, image_param_type)
|
||||
if self.image_directory is not None:
|
||||
logger.info('Image output directory set:\n %s', os.path.abspath(self.image_directory))
|
||||
|
||||
self.save_images = True
|
||||
|
||||
def handle_time(self, start, duration, end):
|
||||
"""Handle `time` command options."""
|
||||
self._ensure_input_open()
|
||||
if self.time:
|
||||
self._on_duplicate_command('time')
|
||||
|
||||
if duration is not None and end is not None:
|
||||
raise click.BadParameter(
|
||||
'Only one of --duration/-d or --end/-e can be specified, not both.',
|
||||
param_hint='time')
|
||||
|
||||
logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration,
|
||||
end)
|
||||
|
||||
self.start_time = parse_timecode(
|
||||
start, self.video_stream.frame_rate, first_index_is_one=True)
|
||||
self.end_time = parse_timecode(end, self.video_stream.frame_rate, first_index_is_one=True)
|
||||
self.duration = parse_timecode(
|
||||
duration, self.video_stream.frame_rate, first_index_is_one=True)
|
||||
self.time = True
|
||||
|
||||
#
|
||||
# Private Methods
|
||||
#
|
||||
|
||||
def _initialize_logging(
|
||||
self,
|
||||
quiet: Optional[bool] = None,
|
||||
verbosity: Optional[str] = None,
|
||||
logfile: Optional[AnyStr] = None,
|
||||
):
|
||||
"""Setup logging based on CLI args and user configuration settings."""
|
||||
if quiet is not None:
|
||||
self.quiet_mode = bool(quiet)
|
||||
curr_verbosity = logging.INFO
|
||||
# Convert verbosity into it's log level enum, and override quiet mode if set.
|
||||
if verbosity is not None:
|
||||
assert verbosity in CHOICE_MAP['global']['verbosity']
|
||||
if verbosity.lower() == 'none':
|
||||
self.quiet_mode = True
|
||||
verbosity = 'info'
|
||||
else:
|
||||
# Override quiet mode if verbosity is set.
|
||||
self.quiet_mode = False
|
||||
curr_verbosity = getattr(logging, verbosity.upper())
|
||||
else:
|
||||
verbosity_str = USER_CONFIG.get_value('global', 'verbosity')
|
||||
assert verbosity_str in CHOICE_MAP['global']['verbosity']
|
||||
if verbosity_str.lower() == 'none':
|
||||
self.quiet_mode = True
|
||||
else:
|
||||
curr_verbosity = getattr(logging, verbosity_str.upper())
|
||||
# Override quiet mode if verbosity is set.
|
||||
if not USER_CONFIG.is_default('global', 'verbosity'):
|
||||
self.quiet_mode = False
|
||||
# Initialize logger with the set CLI args / user configuration.
|
||||
init_logger(log_level=curr_verbosity, show_stdout=not self.quiet_mode, log_file=logfile)
|
||||
|
||||
def add_detector(self, detector):
|
||||
""" Add Detector: Adds a detection algorithm to the CliContext's SceneManager. """
|
||||
self._ensure_input_open()
|
||||
try:
|
||||
self.scene_manager.add_detector(detector)
|
||||
except scenedetect.stats_manager.FrameMetricRegistered as ex:
|
||||
raise click.BadParameter(
|
||||
message='Cannot specify detection algorithm twice.',
|
||||
param_hint=detector.cli_name) from ex
|
||||
|
||||
def _ensure_input_open(self) -> None:
|
||||
"""Ensure self.video_stream was initialized (i.e. -i/--input was specified),
|
||||
otherwise raises an exception. Should only be used from commands that require an
|
||||
input video to process the options (e.g. those that require a timecode).
|
||||
|
||||
Raises:
|
||||
click.BadParameter: self.video_stream was not initialized.
|
||||
"""
|
||||
if self.video_stream is None:
|
||||
raise click.ClickException('No input video (-i/--input) was specified.')
|
||||
|
||||
def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float],
|
||||
backend: Optional[str]):
|
||||
if '%' in input_path and backend != 'opencv':
|
||||
raise click.BadParameter(
|
||||
'The OpenCV backend (`--backend opencv`) must be used to process image sequences.',
|
||||
param_hint='-i/--input')
|
||||
if framerate is not None and framerate < MAX_FPS_DELTA:
|
||||
raise click.BadParameter('Invalid framerate specified!', param_hint='-f/--framerate')
|
||||
try:
|
||||
if backend is None:
|
||||
backend = self.config.get_value('global', 'backend')
|
||||
else:
|
||||
if not backend in AVAILABLE_BACKENDS:
|
||||
raise click.BadParameter(
|
||||
'Specified backend %s is not available on this system!' % backend,
|
||||
param_hint='-b/--backend')
|
||||
# Open the video with the specified backend, loading any required config settings.
|
||||
if backend == 'pyav':
|
||||
self.video_stream = open_video(
|
||||
path=input_path,
|
||||
framerate=framerate,
|
||||
backend=backend,
|
||||
threading_mode=self.config.get_value('backend-pyav', 'threading-mode'),
|
||||
suppress_output=self.config.get_value('backend-pyav', 'suppress-output'),
|
||||
)
|
||||
elif backend == 'opencv':
|
||||
self.video_stream = open_video(
|
||||
path=input_path,
|
||||
framerate=framerate,
|
||||
backend=backend,
|
||||
max_decode_attempts=self.config.get_value('backend-opencv',
|
||||
'max-decode-attempts'),
|
||||
)
|
||||
# Handle backends without any config options.
|
||||
else:
|
||||
self.video_stream = open_video(
|
||||
path=input_path,
|
||||
framerate=framerate,
|
||||
backend=backend,
|
||||
)
|
||||
logger.debug('Video opened using backend %s', type(self.video_stream).__name__)
|
||||
except FrameRateUnavailable as ex:
|
||||
raise click.BadParameter(
|
||||
'Failed to obtain framerate for input video. Manually specify framerate with the'
|
||||
' -f/--framerate option, or try re-encoding the file.',
|
||||
param_hint='-i/--input') from ex
|
||||
except VideoOpenFailure as ex:
|
||||
raise click.BadParameter(
|
||||
'Failed to open input video%s: %s' %
|
||||
(' using %s backend' % backend if backend else '', str(ex)),
|
||||
param_hint='-i/--input') from ex
|
||||
except OSError as ex:
|
||||
raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input')
|
||||
|
||||
def _on_duplicate_command(self, command: str) -> None:
|
||||
"""Called when a command is duplicated to stop parsing and raise an error.
|
||||
|
||||
Arguments:
|
||||
command: Command that was duplicated for error context.
|
||||
|
||||
Raises:
|
||||
click.BadParameter
|
||||
"""
|
||||
error_strs = []
|
||||
error_strs.append('Error: Command %s specified multiple times.' % command)
|
||||
error_strs.append('The %s command may appear only one time.')
|
||||
|
||||
logger.error('\n'.join(error_strs))
|
||||
raise click.BadParameter(
|
||||
'\n Command %s may only be specified once.' % command,
|
||||
param_hint='%s command' % command)
|
||||
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""Logic for the PySceneDetect command."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from string import Template
|
||||
import time
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from string import Template
|
||||
|
||||
from scenedetect.detectors import AdaptiveDetector
|
||||
from scenedetect.frame_timecode import FrameTimecode
|
||||
from scenedetect.platform import get_and_create_path, get_file_name
|
||||
from scenedetect.scene_manager import save_images, write_scene_list, write_scene_list_html
|
||||
from scenedetect.video_splitter import split_video_mkvmerge, split_video_ffmpeg
|
||||
from scenedetect.video_stream import SeekError
|
||||
|
||||
from scenedetect._cli.context import CliContext, check_split_video_requirements
|
||||
|
||||
logger = logging.getLogger('pyscenedetect')
|
||||
|
||||
|
||||
def run_scenedetect(context: CliContext):
|
||||
"""Perform main CLI application control logic. Run once all command-line options and
|
||||
configuration file options have been validated.
|
||||
|
||||
Arguments:
|
||||
context: Prevalidated command-line option context to use for processing.
|
||||
"""
|
||||
# No input may have been specified depending on the commands/args that were used.
|
||||
logger.debug("Running controller.")
|
||||
if context.scene_manager is None:
|
||||
logger.debug("No input specified.")
|
||||
return
|
||||
# Use default detector if one was not specified.
|
||||
if context.scene_manager.get_num_detectors() == 0:
|
||||
detector_type, detector_args = context.default_detector
|
||||
logger.debug('Using default detector: %s(%s)' % (detector_type.__name__, detector_args))
|
||||
context.scene_manager.add_detector(detector_type(**detector_args))
|
||||
|
||||
perf_start_time = time.time()
|
||||
if context.start_time is not None:
|
||||
logger.debug('Seeking to start time...')
|
||||
try:
|
||||
context.video_stream.seek(target=context.start_time)
|
||||
except SeekError as ex:
|
||||
logging.critical('Failed to seek to %s / frame %d: %s',
|
||||
context.start_time.get_timecode(), context.start_time.get_frames(),
|
||||
str(ex))
|
||||
return
|
||||
|
||||
num_frames = context.scene_manager.detect_scenes(
|
||||
video=context.video_stream,
|
||||
duration=context.duration,
|
||||
end_time=context.end_time,
|
||||
frame_skip=context.frame_skip,
|
||||
show_progress=not context.quiet_mode)
|
||||
|
||||
# Handle case where video failure is most likely due to multiple audio tracks (#179).
|
||||
if num_frames <= 0 and context.video_stream.BACKEND_NAME == 'opencv':
|
||||
logger.critical(
|
||||
'Failed to read any frames from video file. This could be caused by the video'
|
||||
' having multiple audio tracks. If so, try installing the PyAV backend:\n'
|
||||
' pip install av\n'
|
||||
'Or remove the audio tracks by running either:\n'
|
||||
' ffmpeg -i input.mp4 -c copy -an output.mp4\n'
|
||||
' mkvmerge -o output.mkv input.mp4\n'
|
||||
'For details, see https://scenedetect.com/faq/')
|
||||
return
|
||||
|
||||
perf_duration = time.time() - perf_start_time
|
||||
logger.info('Processed %d frames in %.1f seconds (average %.2f FPS).', num_frames,
|
||||
perf_duration,
|
||||
float(num_frames) / perf_duration)
|
||||
|
||||
# Handle -s/--stats option.
|
||||
_save_stats(context)
|
||||
|
||||
# Get list of detected cuts/scenes from the SceneManager to generate the required output
|
||||
# files, based on the given commands (list-scenes, split-video, save-images, etc...).
|
||||
cut_list = context.scene_manager.get_cut_list(show_warning=False)
|
||||
scene_list = context.scene_manager.get_scene_list(start_in_scene=True)
|
||||
|
||||
# Handle --merge-last-scene.
|
||||
if context.merge_last_scene and context.min_scene_len is not None and context.min_scene_len > 0:
|
||||
if len(scene_list) > 1 and (scene_list[-1][1] - scene_list[-1][0]) < context.min_scene_len:
|
||||
new_last_scene = (scene_list[-2][0], scene_list[-1][1])
|
||||
scene_list = scene_list[:-2] + [new_last_scene]
|
||||
|
||||
# Handle --drop-short-scenes.
|
||||
if context.drop_short_scenes and context.min_scene_len > 0:
|
||||
scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len]
|
||||
|
||||
# Ensure we don't divide by zero.
|
||||
if scene_list:
|
||||
logger.info(
|
||||
'Detected %d scenes, average shot length %.1f seconds.', len(scene_list),
|
||||
sum([(end_time - start_time).get_seconds() for start_time, end_time in scene_list]) /
|
||||
float(len(scene_list)))
|
||||
else:
|
||||
logger.info('No scenes detected.')
|
||||
|
||||
# Handle list-scenes command.
|
||||
_list_scenes(context, scene_list, cut_list)
|
||||
|
||||
# Handle save-images command.
|
||||
image_filenames = _save_images(context, scene_list)
|
||||
|
||||
# Handle export-html command.
|
||||
_export_html(context, scene_list, cut_list, image_filenames)
|
||||
|
||||
# Handle split-video command.
|
||||
_split_video(context, scene_list)
|
||||
|
||||
|
||||
def _save_stats(context: CliContext) -> None:
|
||||
"""Handles saving the statsfile if -s/--stats was specified."""
|
||||
if context.stats_file_path is not None:
|
||||
# We check if the save is required in order to reduce unnecessary log messages.
|
||||
if context.stats_manager.is_save_required():
|
||||
logger.info('Saving frame metrics to stats file: %s',
|
||||
os.path.basename(context.stats_file_path))
|
||||
context.stats_manager.save_to_csv(csv_file=context.stats_file_path)
|
||||
else:
|
||||
logger.debug('No frame metrics updated, skipping update of the stats file.')
|
||||
|
||||
|
||||
def _list_scenes(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]],
|
||||
cut_list: List[FrameTimecode]) -> None:
|
||||
"""Handles the `list-scenes` command."""
|
||||
if context.scene_list_output:
|
||||
scene_list_filename = Template(
|
||||
context.scene_list_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name)
|
||||
if not scene_list_filename.lower().endswith('.csv'):
|
||||
scene_list_filename += '.csv'
|
||||
scene_list_path = get_and_create_path(
|
||||
scene_list_filename, context.scene_list_directory
|
||||
if context.scene_list_directory is not None else context.output_directory)
|
||||
logger.info('Writing scene list to CSV file:\n %s', scene_list_path)
|
||||
with open(scene_list_path, 'wt') as scene_list_file:
|
||||
write_scene_list(
|
||||
output_csv_file=scene_list_file,
|
||||
scene_list=scene_list,
|
||||
include_cut_list=not context.skip_cuts,
|
||||
cut_list=cut_list)
|
||||
|
||||
if context.print_scene_list:
|
||||
logger.info(
|
||||
"""Scene List:
|
||||
-----------------------------------------------------------------------
|
||||
| Scene # | Start Frame | Start Time | End Frame | End Time |
|
||||
-----------------------------------------------------------------------
|
||||
%s
|
||||
-----------------------------------------------------------------------
|
||||
""", '\n'.join([
|
||||
' | %5d | %11d | %s | %11d | %s |' %
|
||||
(i + 1, start_time.get_frames() + 1, start_time.get_timecode(),
|
||||
end_time.get_frames(), end_time.get_timecode())
|
||||
for i, (start_time, end_time) in enumerate(scene_list)
|
||||
]))
|
||||
|
||||
if cut_list:
|
||||
logger.info('Comma-separated timecode list:\n %s',
|
||||
','.join([cut.get_timecode() for cut in cut_list]))
|
||||
|
||||
|
||||
def _save_images(
|
||||
context: CliContext,
|
||||
scene_list: List[Tuple[FrameTimecode, FrameTimecode]]) -> Optional[Dict[int, List[str]]]:
|
||||
"""Handles the `save-images` command."""
|
||||
if not context.save_images:
|
||||
return None
|
||||
|
||||
image_output_dir = context.output_directory
|
||||
if context.image_directory is not None:
|
||||
image_output_dir = context.image_directory
|
||||
|
||||
return save_images(
|
||||
scene_list=scene_list,
|
||||
video=context.video_stream,
|
||||
num_images=context.num_images,
|
||||
frame_margin=context.frame_margin,
|
||||
image_extension=context.image_extension,
|
||||
encoder_param=context.image_param,
|
||||
image_name_template=context.image_name_format,
|
||||
output_dir=image_output_dir,
|
||||
show_progress=not context.quiet_mode,
|
||||
scale=context.scale,
|
||||
height=context.height,
|
||||
width=context.width,
|
||||
interpolation=context.scale_method)
|
||||
|
||||
|
||||
def _export_html(context: CliContext, scene_list: List[Tuple[FrameTimecode, FrameTimecode]],
|
||||
cut_list: List[FrameTimecode], image_filenames: Optional[Dict[int,
|
||||
List[str]]]) -> None:
|
||||
"""Handles the `export-html` command."""
|
||||
if not context.export_html:
|
||||
return
|
||||
|
||||
html_filename = Template(
|
||||
context.html_name_format).safe_substitute(VIDEO_NAME=context.video_stream.name)
|
||||
if not html_filename.lower().endswith('.html'):
|
||||
html_filename += '.html'
|
||||
html_path = get_and_create_path(
|
||||
html_filename, context.image_directory
|
||||
if context.image_directory is not None else context.output_directory)
|
||||
logger.info('Exporting to html file:\n %s:', html_path)
|
||||
if not context.html_include_images:
|
||||
image_filenames = None
|
||||
write_scene_list_html(
|
||||
html_path,
|
||||
scene_list,
|
||||
cut_list,
|
||||
image_filenames=image_filenames,
|
||||
image_width=context.image_width,
|
||||
image_height=context.image_height)
|
||||
|
||||
|
||||
def _split_video(context: CliContext, scene_list: List[Tuple[FrameTimecode,
|
||||
FrameTimecode]]) -> None:
|
||||
"""Handles the `split-video` command."""
|
||||
if not context.split_video:
|
||||
return
|
||||
|
||||
output_path_template = context.split_name_format
|
||||
# Add proper extension to filename template if required.
|
||||
dot_pos = output_path_template.rfind('.')
|
||||
extension_length = 0 if dot_pos < 0 else len(output_path_template) - (dot_pos + 1)
|
||||
# If using mkvmerge, force extension to .mkv.
|
||||
if context.split_mkvmerge and not output_path_template.endswith('.mkv'):
|
||||
output_path_template += '.mkv'
|
||||
# Otherwise, if using ffmpeg, only add an extension if one doesn't exist.
|
||||
elif not 2 <= extension_length <= 4:
|
||||
output_path_template += '.mp4'
|
||||
# Pre-expand $VIDEO_NAME so it can be used for a directory.
|
||||
# TODO: Do this elsewhere in a future version for all output options.
|
||||
output_path_template = Template(output_path_template).safe_substitute(
|
||||
VIDEO_NAME=get_file_name(context.video_stream.path, include_extension=False))
|
||||
output_path_template = get_and_create_path(
|
||||
output_path_template, context.split_directory
|
||||
if context.split_directory is not None else context.output_directory)
|
||||
# Ensure the appropriate tool is available before handling split-video.
|
||||
check_split_video_requirements(context.split_mkvmerge)
|
||||
if context.split_mkvmerge:
|
||||
split_video_mkvmerge(
|
||||
input_video_path=context.video_stream.path,
|
||||
scene_list=scene_list,
|
||||
output_file_template=output_path_template,
|
||||
show_output=not (context.quiet_mode or context.split_quiet),
|
||||
)
|
||||
else:
|
||||
split_video_ffmpeg(
|
||||
input_video_path=context.video_stream.path,
|
||||
scene_list=scene_list,
|
||||
output_file_template=output_path_template,
|
||||
arg_override=context.split_args,
|
||||
show_progress=not context.quiet_mode,
|
||||
show_output=not (context.quiet_mode or context.split_quiet),
|
||||
)
|
||||
if scene_list:
|
||||
logger.info('Video splitting completed, scenes written to disk.')
|
||||
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# ---------------------------------------------------------------
|
||||
# [ Site: http://www.scenedetect.scenedetect.com/ ]
|
||||
# [ Docs: http://manual.scenedetect.scenedetect.com/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`SceneLoader` is a class designed for use cases in which a list of
|
||||
scenes is read from a csv file and actual detection of scene boundaries does not
|
||||
need to occur.
|
||||
|
||||
This is available from the command-line as the `load-scenes` command.
|
||||
"""
|
||||
|
||||
import os
|
||||
import csv
|
||||
|
||||
import typing as ty
|
||||
|
||||
import numpy
|
||||
|
||||
from scenedetect.scene_detector import SceneDetector
|
||||
from scenedetect.frame_timecode import FrameTimecode
|
||||
|
||||
|
||||
class SceneLoader(SceneDetector):
|
||||
"""Detector which load a list of predefined cuts from a CSV file. Used by the CLI to implement
|
||||
the `load-scenes` functionality. Incompatible with other detectors.
|
||||
"""
|
||||
|
||||
def __init__(self, file: ty.TextIO, framerate: float, start_col_name: str = "Start Frame"):
|
||||
"""
|
||||
Arguments:
|
||||
file: Path to csv file containing scene data for video
|
||||
framerate: Framerate used to construct `FrameTimecode` for parsing input.
|
||||
start_col_name: Header for column containing the frame/timecode where cuts occur.
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# Check to make specified csv file exists
|
||||
if not file:
|
||||
raise ValueError('file path to csv file must be specified')
|
||||
if not os.path.exists(file):
|
||||
raise ValueError('specified csv file does not exist')
|
||||
|
||||
self.csv_file = file
|
||||
|
||||
# Open csv and check and read first row for column headers
|
||||
(self.file_reader, csv_headers) = self._open_csv(self.csv_file, start_col_name)
|
||||
|
||||
# Check to make sure column headers are present
|
||||
if start_col_name not in csv_headers:
|
||||
raise ValueError('specified column header for scene start is not present')
|
||||
|
||||
self._col_idx = csv_headers.index(start_col_name)
|
||||
self._last_scene_row = None
|
||||
self._scene_start = None
|
||||
|
||||
# `SceneDetector` works on cuts, so we have to skip the first scene and use the first frame
|
||||
# of the next scene as the cut point. This can be fixed if we used `SparseSceneDetector`
|
||||
# but this part of the API is being reworked and hasn't been used by any detectors yet.
|
||||
self._cut_list = sorted(
|
||||
FrameTimecode(row[self._col_idx], fps=framerate).frame_num - 1
|
||||
for row in self.file_reader)
|
||||
if self._cut_list:
|
||||
self._cut_list = self._cut_list[1:]
|
||||
|
||||
def _open_csv(self, csv_file, start_col_name):
|
||||
"""Opens the specified csv file for reading.
|
||||
|
||||
Arguments:
|
||||
csv_file: Path to csv file containing scene data for video
|
||||
|
||||
Returns:
|
||||
(reader, headers): csv.reader object and headers
|
||||
"""
|
||||
input_file = open(csv_file, 'r')
|
||||
file_reader = csv.reader(input_file)
|
||||
csv_headers = next(file_reader)
|
||||
if not start_col_name in csv_headers:
|
||||
csv_headers = next(file_reader)
|
||||
return (file_reader, csv_headers)
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]:
|
||||
"""Simply reads cut data from a given csv file. Video is not analyzed. Therefore this
|
||||
detector is incompatible with other detectors or a StatsManager.
|
||||
|
||||
Arguments:
|
||||
frame_num: Frame number of frame that is being passed.
|
||||
frame_img: Decoded frame image (numpy.ndarray) to perform scene detection on. This is
|
||||
unused for this detector as the video is not analyzed, but is allowed for
|
||||
compatibility.
|
||||
|
||||
Returns:
|
||||
cut_list: List of cuts (as provided by input csv file)
|
||||
"""
|
||||
if frame_num in self._cut_list:
|
||||
return [frame_num]
|
||||
return []
|
||||
|
||||
def is_processing_required(self, frame_num):
|
||||
return False
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""Includes third-party libraries distributed with PySceneDetect. To simplify distribution of binary
|
||||
builds, the source directory also includes license files for the packages PySceneDetect depends on.
|
||||
"""
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# The MIT License (MIT)
|
||||
#
|
||||
# Copyright (c) 2014 Matheus Vieira Portela
|
||||
#
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to deal
|
||||
# in the Software without restriction, including without limitation the rights
|
||||
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
# copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
#
|
||||
# The above copyright notice and this permission notice shall be included in all
|
||||
# copies or substantial portions of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
# SOFTWARE.
|
||||
"""simpletable.py - v0.1 2014-07-31 Matheus Vieira Portela
|
||||
|
||||
This module provides simple classes and interfaces to generate simple HTML
|
||||
tables based on Python native types, such as lists.
|
||||
|
||||
Author's website: http://matheusvportela.wordpress.com/
|
||||
|
||||
v0.4 2019-05-24 by Walter Schwenger
|
||||
"""
|
||||
|
||||
### CHANGES ###
|
||||
# 2014-07-31: v0.1 MVP:
|
||||
# - First version
|
||||
# 2014-08-05: v0.2 MVP:
|
||||
# - Method for defining header rows
|
||||
# - SimpleTable method to create a SimpleTable from lists
|
||||
# - Method to create a table from a simple list of elements and a column size
|
||||
# 2014-08-20: v0.3 MVP:
|
||||
# - Enable SimplePage to accept a list of tables
|
||||
# - Enable SimplePage to iterate over its tables
|
||||
# 2019-05-24: v0.4 WS:
|
||||
# - Added SimpleTableImage class to handle adding images to tables
|
||||
# - Added test images and image example to __main__
|
||||
|
||||
### REFERENCES ###
|
||||
# Decalage HTML.py module: http://www.decalage.info/python/html
|
||||
|
||||
import codecs
|
||||
|
||||
|
||||
# noinspection PyCompatibility,PyUnresolvedReferences
|
||||
def quote(string):
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
return quote(string)
|
||||
except ModuleNotFoundError:
|
||||
from urllib import pathname2url
|
||||
return pathname2url(string)
|
||||
|
||||
|
||||
class SimpleTableCell(object):
|
||||
"""A table class to create table cells.
|
||||
|
||||
Example:
|
||||
cell = SimpleTableCell('Hello, world!')
|
||||
"""
|
||||
|
||||
def __init__(self, text, header=False):
|
||||
"""Table cell constructor.
|
||||
|
||||
Keyword arguments:
|
||||
text -- text to be displayed
|
||||
header -- flag to indicate this cell is a header cell.
|
||||
"""
|
||||
self.text = text
|
||||
self.header = header
|
||||
|
||||
def __str__(self):
|
||||
"""Return the HTML code for the table cell."""
|
||||
if self.header:
|
||||
return '<th>%s</th>' % (self.text)
|
||||
else:
|
||||
return '<td>%s</td>' % (self.text)
|
||||
|
||||
|
||||
class SimpleTableImage(object):
|
||||
"""A table class to create table cells with an image.
|
||||
|
||||
Example:
|
||||
cell = SimpleTableImage('images/image_1.jpg')
|
||||
"""
|
||||
|
||||
def __init__(self, image_file, width=None, height=None):
|
||||
"""Table cell constructor.
|
||||
|
||||
Keyword arguments:
|
||||
image_file -- relative filepath to image file to display.
|
||||
width -- (optional) width of the image in pixels
|
||||
height -- (optional) height of the image in pixels
|
||||
"""
|
||||
self.image_file = image_file
|
||||
if width:
|
||||
self.width = round(width)
|
||||
else:
|
||||
self.width = width
|
||||
if height:
|
||||
self.height = round(height)
|
||||
else:
|
||||
self.height = height
|
||||
|
||||
def __str__(self):
|
||||
"""Return the HTML code for the table cell with the image."""
|
||||
safe_filename = quote(self.image_file)
|
||||
output = '<a href="%s" target="_blank">' % (safe_filename)
|
||||
output += '<img src="%s"' % (safe_filename)
|
||||
if self.height:
|
||||
output += ' height="%s"' % (self.height)
|
||||
if self.width:
|
||||
output += ' width="%s"' % (self.width)
|
||||
output += '></a>'
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class SimpleTableRow(object):
|
||||
"""A table class to create table rows, populated by table cells.
|
||||
|
||||
Example:
|
||||
# Row from list
|
||||
row = SimpleTableRow(['Hello,', 'world!'])
|
||||
|
||||
# Row from SimpleTableCell
|
||||
cell1 = SimpleTableCell('Hello,')
|
||||
cell2 = SimpleTableCell('world!')
|
||||
row = SimpleTableRow([cell1, cell2])
|
||||
"""
|
||||
|
||||
def __init__(self, cells=None, header=False):
|
||||
"""Table row constructor.
|
||||
|
||||
Keyword arguments:
|
||||
cells -- iterable of SimpleTableCell (default None)
|
||||
header -- flag to indicate this row is a header row.
|
||||
if the cells are SimpleTableCell, it is the programmer's
|
||||
responsibility to verify whether it was created with the
|
||||
header flag set to True.
|
||||
"""
|
||||
cells = cells or []
|
||||
if isinstance(cells[0], SimpleTableCell):
|
||||
self.cells = cells
|
||||
else:
|
||||
self.cells = [SimpleTableCell(cell, header=header) for cell in cells]
|
||||
|
||||
self.header = header
|
||||
|
||||
def __str__(self):
|
||||
"""Return the HTML code for the table row and its cells as a string."""
|
||||
row = []
|
||||
|
||||
row.append('<tr>')
|
||||
|
||||
for cell in self.cells:
|
||||
row.append(str(cell))
|
||||
|
||||
row.append('</tr>')
|
||||
|
||||
return '\n'.join(row)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate through row cells"""
|
||||
for cell in self.cells:
|
||||
yield cell
|
||||
|
||||
def add_cell(self, cell):
|
||||
"""Add a SimpleTableCell object to the list of cells."""
|
||||
self.cells.append(cell)
|
||||
|
||||
def add_cells(self, cells):
|
||||
"""Add a list of SimpleTableCell objects to the list of cells."""
|
||||
for cell in cells:
|
||||
self.cells.append(cell)
|
||||
|
||||
|
||||
class SimpleTable(object):
|
||||
"""A table class to create HTML tables, populated by HTML table rows.
|
||||
|
||||
Example:
|
||||
# Table from lists
|
||||
table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']])
|
||||
|
||||
# Table with header row
|
||||
table = SimpleTable([['Hello,', 'world!'], ['How', 'are', 'you?']],
|
||||
header_row=['Header1', 'Header2', 'Header3'])
|
||||
|
||||
# Table from SimpleTableRow
|
||||
rows = SimpleTableRow(['Hello,', 'world!'])
|
||||
table = SimpleTable(rows)
|
||||
"""
|
||||
|
||||
def __init__(self, rows=None, header_row=None, css_class=None):
|
||||
"""Table constructor.
|
||||
|
||||
Keyword arguments:
|
||||
rows -- iterable of SimpleTableRow
|
||||
header_row -- row that will be displayed at the beginning of the table.
|
||||
if this row is SimpleTableRow, it is the programmer's
|
||||
responsibility to verify whether it was created with the
|
||||
header flag set to True.
|
||||
css_class -- table CSS class
|
||||
"""
|
||||
rows = rows or []
|
||||
if isinstance(rows[0], SimpleTableRow):
|
||||
self.rows = rows
|
||||
else:
|
||||
self.rows = [SimpleTableRow(row) for row in rows]
|
||||
|
||||
if header_row is None:
|
||||
self.header_row = None
|
||||
elif isinstance(header_row, SimpleTableRow):
|
||||
self.header_row = header_row
|
||||
else:
|
||||
self.header_row = SimpleTableRow(header_row, header=True)
|
||||
|
||||
self.css_class = css_class
|
||||
|
||||
def __str__(self):
|
||||
"""Return the HTML code for the table as a string."""
|
||||
table = []
|
||||
|
||||
if self.css_class:
|
||||
table.append('<table class=%s>' % self.css_class)
|
||||
else:
|
||||
table.append('<table>')
|
||||
|
||||
if self.header_row:
|
||||
table.append(str(self.header_row))
|
||||
|
||||
for row in self.rows:
|
||||
table.append(str(row))
|
||||
|
||||
table.append('</table>')
|
||||
|
||||
return '\n'.join(table)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate through table rows"""
|
||||
for row in self.rows:
|
||||
yield row
|
||||
|
||||
def add_row(self, row):
|
||||
"""Add a SimpleTableRow object to the list of rows."""
|
||||
self.rows.append(row)
|
||||
|
||||
def add_rows(self, rows):
|
||||
"""Add a list of SimpleTableRow objects to the list of rows."""
|
||||
for row in rows:
|
||||
self.rows.append(row)
|
||||
|
||||
|
||||
class HTMLPage(object):
|
||||
"""A class to create HTML pages containing CSS and tables."""
|
||||
|
||||
def __init__(self, tables=None, css=None, encoding="utf-8"):
|
||||
"""HTML page constructor.
|
||||
|
||||
Keyword arguments:
|
||||
tables -- List of SimpleTable objects
|
||||
css -- Cascading Style Sheet specification that is appended before the
|
||||
table string
|
||||
encoding -- Characters encoding. Default: UTF-8
|
||||
"""
|
||||
self.tables = tables or []
|
||||
self.css = css
|
||||
self.encoding = encoding
|
||||
|
||||
def __str__(self):
|
||||
"""Return the HTML page as a string."""
|
||||
page = []
|
||||
|
||||
if self.css:
|
||||
page.append('<style type="text/css">\n%s\n</style>' % self.css)
|
||||
|
||||
# Set encoding
|
||||
page.append('<meta http-equiv="Content-Type" content="text/html;'
|
||||
'charset=%s">' % self.encoding)
|
||||
|
||||
for table in self.tables:
|
||||
page.append(str(table))
|
||||
page.append('<br />')
|
||||
|
||||
return '\n'.join(page)
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate through tables"""
|
||||
for table in self.tables:
|
||||
yield table
|
||||
|
||||
def save(self, filename):
|
||||
"""Save HTML page to a file using the proper encoding"""
|
||||
with codecs.open(filename, 'w', self.encoding) as outfile:
|
||||
for line in str(self):
|
||||
outfile.write(line)
|
||||
|
||||
def add_table(self, table):
|
||||
"""Add a SimpleTable to the page list of tables"""
|
||||
self.tables.append(table)
|
||||
|
||||
|
||||
def fit_data_to_columns(data, num_cols):
|
||||
"""Format data into the configured number of columns in a proper format to
|
||||
generate a SimpleTable.
|
||||
|
||||
Example:
|
||||
test_data = [str(x) for x in range(20)]
|
||||
fitted_data = fit_data_to_columns(test_data, 5)
|
||||
table = SimpleTable(fitted_data)
|
||||
"""
|
||||
num_iterations = len(data) / num_cols
|
||||
|
||||
if len(data) % num_cols != 0:
|
||||
num_iterations += 1
|
||||
|
||||
return [data[num_cols * i:num_cols * i + num_cols] for i in range(num_iterations)]
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.backends`` Module
|
||||
|
||||
This module contains :class:`VideoStream <scenedetect.video_stream.VideoStream>` implementations
|
||||
backed by various Python multimedia libraries. In addition to creating backend objects directly,
|
||||
:func:`scenedetect.open_video` can be used to open a video with a specified backend, falling
|
||||
back to OpenCV if not available.
|
||||
|
||||
All backends available on the current system can be found via :data:`AVAILABLE_BACKENDS`.
|
||||
|
||||
If you already have a `cv2.VideoCapture` object you want to use for scene detection, you can
|
||||
use a :class:`VideoCaptureAdapter <scenedetect.backends.opencv.VideoCaptureAdapter>` instead
|
||||
of a backend. This is useful when working with devices or streams, for example.
|
||||
|
||||
===============================================================
|
||||
Video Files
|
||||
===============================================================
|
||||
|
||||
Assuming we have a file `video.mp4` in our working directory, we can load it and perform scene
|
||||
detection on it using :func:`open_video`:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from scenedetect import open_video
|
||||
video = open_video('video.mp4')
|
||||
|
||||
An optional backend from :data:`AVAILABLE_BACKENDS` can be passed to :func:`open_video`
|
||||
(e.g. `backend='opencv'`). Additional keyword arguments passed to :func:`open_video`
|
||||
will be forwarded to the backend constructor. If the specified backend is unavailable, or
|
||||
loading the video fails, ``opencv`` will be tried as a fallback.
|
||||
|
||||
Lastly, to use a specific backend directly:
|
||||
|
||||
.. code:: python
|
||||
|
||||
# Manually importing and constructing a backend:
|
||||
from scenedetect.backends.opencv import VideoStreamCv2
|
||||
video = VideoStreamCv2('video.mp4')
|
||||
|
||||
In both examples above, the resulting ``video`` can be used with
|
||||
:meth:`SceneManager.detect_scenes() <scenedetect.scene_manager.SceneManager.detect_scenes>`.
|
||||
|
||||
===============================================================
|
||||
Devices / Cameras / Pipes
|
||||
===============================================================
|
||||
|
||||
You can use an existing `cv2.VideoCapture` object with the PySceneDetect API using a
|
||||
:class:`VideoCaptureAdapter <scenedetect.backends.opencv.VideoCaptureAdapter>`. For example,
|
||||
to use a :class:`SceneManager <scenedetect.scene_manager.SceneManager>` with a webcam device:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from scenedetect import SceneManager, ContentDetector
|
||||
from scenedetect.backends import VideoCaptureAdapter
|
||||
# Open device ID 2.
|
||||
cap = cv2.VideoCapture(2)
|
||||
video = VideoCaptureAdapter(cap)
|
||||
total_frames = 1000
|
||||
scene_manager = SceneManager()
|
||||
scene_manager.add_detector(ContentDetector())
|
||||
scene_manager.detect_scenes(video=video, duration=total_frames)
|
||||
|
||||
When working with live inputs, note that you can pass a callback to
|
||||
:meth:`detect_scenes() <scenedetect.scene_manager.SceneManager.detect_scenes>` to be
|
||||
called on every scene detection event. See the :mod:`SceneManager <scenedetect.scene_manager>`
|
||||
examples for details.
|
||||
"""
|
||||
|
||||
# TODO(v1.0): Consider removing and making this a namespace package so that additional backends can
|
||||
# be dynamically added. The preferred approach for this should probably be:
|
||||
# https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins/#using-namespace-packages
|
||||
|
||||
# TODO: Future VideoStream implementations under consideration:
|
||||
# - Nvidia VPF: https://developer.nvidia.com/blog/vpf-hardware-accelerated-video-processing-framework-in-python/
|
||||
|
||||
from typing import Dict, Type
|
||||
|
||||
# OpenCV must be available at minimum.
|
||||
from backend.scenedetect.backends.opencv import VideoStreamCv2, VideoCaptureAdapter
|
||||
|
||||
try:
|
||||
from scenedetect.backends.pyav import VideoStreamAv
|
||||
except ImportError:
|
||||
VideoStreamAv = None
|
||||
|
||||
try:
|
||||
from scenedetect.backends.moviepy import VideoStreamMoviePy
|
||||
except ImportError:
|
||||
VideoStreamMoviePy = None
|
||||
|
||||
# TODO(0.6.3): Replace this with a function named `get_available_backends`.
|
||||
AVAILABLE_BACKENDS: Dict[str, Type] = {
|
||||
backend.BACKEND_NAME: backend for backend in filter(None, [
|
||||
VideoStreamCv2,
|
||||
VideoStreamAv,
|
||||
VideoStreamMoviePy,
|
||||
])
|
||||
}
|
||||
"""All available backends that :func:`scenedetect.open_video` can consider for the `backend`
|
||||
parameter. These backends must support construction with the following signature:
|
||||
|
||||
BackendType(path: str, framerate: Optional[float])
|
||||
"""
|
||||
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`VideoStreamMoviePy` provides an adapter for MoviePy's `FFMPEG_VideoReader`.
|
||||
|
||||
Uses string identifier ``'moviepy'``.
|
||||
|
||||
.. warning::
|
||||
|
||||
The MoviePy backend is still under development. Some features are not yet supported.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from typing import AnyStr, Tuple, Union, Optional
|
||||
|
||||
import cv2
|
||||
from moviepy.video.io.ffmpeg_reader import FFMPEG_VideoReader
|
||||
from numpy import ndarray
|
||||
|
||||
from scenedetect.frame_timecode import FrameTimecode
|
||||
from scenedetect.platform import get_file_name
|
||||
from scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure
|
||||
from scenedetect.backends.opencv import VideoStreamCv2
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
|
||||
class VideoStreamMoviePy(VideoStream):
|
||||
"""MoviePy `FFMPEG_VideoReader` backend."""
|
||||
|
||||
def __init__(self, path: AnyStr, framerate: Optional[float] = None, print_infos: bool = False):
|
||||
"""Open a video or device.
|
||||
|
||||
Arguments:
|
||||
path: Path to video,.
|
||||
framerate: If set, overrides the detected framerate.
|
||||
print_infos: If True, prints information about the opened video to stdout.
|
||||
|
||||
Raises:
|
||||
OSError: file could not be found, access was denied, or the video is corrupt
|
||||
VideoOpenFailure: video could not be opened (may be corrupted)
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# TODO(0.6.3) - Investigate how MoviePy handles ffmpeg not being on PATH.
|
||||
# TODO(0.6.3): Add framerate override.
|
||||
if framerate is not None:
|
||||
raise NotImplementedError("TODO(0.6.3)")
|
||||
|
||||
self._path = path
|
||||
# TODO(0.6.3): Need to map errors based on the strings, since several failure
|
||||
# cases return IOErrors (e.g. could not read duration/video resolution). These
|
||||
# should be mapped to specific errors, e.g. write a function to map MoviePy
|
||||
# exceptions to a new set of equivalents.
|
||||
self._reader = FFMPEG_VideoReader(path, print_infos=print_infos)
|
||||
# This will always be one behind self._reader.lastread when we finally call read()
|
||||
# as MoviePy caches the first frame when opening the video. Thus self._last_frame
|
||||
# will always be the current frame, and self._reader.lastread will be the next.
|
||||
self._last_frame: Union[bool, ndarray] = False
|
||||
self._last_frame_rgb: Optional[ndarray] = None
|
||||
# Older versions don't track the video position when calling read_frame so we need
|
||||
# to keep track of the current frame number.
|
||||
self._frame_number = 0
|
||||
# We need to manually keep track of EOF as duration may not be accurate.
|
||||
self._eof = False
|
||||
# MoviePy doesn't support extracting the aspect ratio yet, so for now we just fall
|
||||
# back to using OpenCV to determine it.
|
||||
try:
|
||||
self._aspect_ratio = VideoStreamCv2(self._path).aspect_ratio
|
||||
except VideoOpenFailure as ex:
|
||||
logger.warning("Unable to determine aspect ratio: %s", str(ex))
|
||||
self._aspect_ratio = 1.0
|
||||
|
||||
#
|
||||
# VideoStream Methods/Properties
|
||||
#
|
||||
|
||||
BACKEND_NAME = 'moviepy'
|
||||
"""Unique name used to identify this backend."""
|
||||
|
||||
@property
|
||||
def frame_rate(self) -> float:
|
||||
"""Framerate in frames/sec."""
|
||||
return self._reader.fps
|
||||
|
||||
@property
|
||||
def path(self) -> Union[bytes, str]:
|
||||
"""Video path."""
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the video, without extension, or device."""
|
||||
return get_file_name(self.path, include_extension=False)
|
||||
|
||||
@property
|
||||
def is_seekable(self) -> bool:
|
||||
"""True if seek() is allowed, False otherwise."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Size of each video frame in pixels as a tuple of (width, height)."""
|
||||
return tuple(self._reader.infos['video_size'])
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[FrameTimecode]:
|
||||
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
|
||||
assert isinstance(self._reader.infos['duration'], float)
|
||||
return self.base_timecode + self._reader.infos['duration']
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
return self._aspect_ratio
|
||||
|
||||
@property
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode.
|
||||
|
||||
This can be interpreted as presentation time stamp of the last frame which was
|
||||
decoded by calling `read` with advance=True.
|
||||
|
||||
This method will always return 0 (e.g. be equal to `base_timecode`) if no frames
|
||||
have been `read`."""
|
||||
frame_number = max(self._frame_number - 1, 0)
|
||||
return FrameTimecode(frame_number, self.frame_rate)
|
||||
|
||||
@property
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in milliseconds.
|
||||
The first frame has a time of 0.0 ms.
|
||||
|
||||
This method will always return 0.0 if no frames have been `read`."""
|
||||
return self.position.get_seconds() * 1000.0
|
||||
|
||||
@property
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream in frames as an int.
|
||||
|
||||
1 indicates the first frame was just decoded by the last call to `read` with advance=True,
|
||||
whereas 0 indicates that no frames have been `read`.
|
||||
|
||||
This method will always return 0 if no frames have been `read`."""
|
||||
return self._frame_number
|
||||
|
||||
def seek(self, target: Union[FrameTimecode, float, int]):
|
||||
"""Seek to the given timecode. If given as a frame number, represents the current seek
|
||||
pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video).
|
||||
|
||||
For 1-based indices (first frame is frame #1), the target frame number needs to be converted
|
||||
to 0-based by subtracting one. For example, if we want to seek to the first frame, we call
|
||||
seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed
|
||||
by read(), at which point frame_number will be 5.
|
||||
|
||||
Not supported if the VideoStream is a device/camera. Untested with web streams.
|
||||
|
||||
Arguments:
|
||||
target: Target position in video stream to seek to.
|
||||
If float, interpreted as time in seconds.
|
||||
If int, interpreted as frame number.
|
||||
Raises:
|
||||
SeekError: An error occurs while seeking, or seeking is not supported.
|
||||
ValueError: `target` is not a valid value (i.e. it is negative).
|
||||
"""
|
||||
if not isinstance(target, FrameTimecode):
|
||||
target = FrameTimecode(target, self.frame_rate)
|
||||
try:
|
||||
self._reader.get_frame(target.get_seconds())
|
||||
except IOError as ex:
|
||||
# Leave the object in a valid state.
|
||||
self.reset()
|
||||
if target >= self.duration:
|
||||
raise SeekError("Target frame is beyond end of video!") from ex
|
||||
raise
|
||||
self._last_frame = self._reader.lastread
|
||||
self._frame_number = target.frame_num
|
||||
|
||||
def reset(self):
|
||||
""" Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """
|
||||
self._reader.initialize()
|
||||
self._last_frame = self._reader.read_frame()
|
||||
self._frame_number = 0
|
||||
self._eof = False
|
||||
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
"""Read and decode the next frame as a numpy.ndarray. Returns False when video ends.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will return the current (last) frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video.
|
||||
If decode = False, a bool indicating if advancing to the the next frame succeeded.
|
||||
"""
|
||||
if not advance:
|
||||
if self._last_frame_rgb is None:
|
||||
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
|
||||
return self._last_frame_rgb
|
||||
if not hasattr(self._reader, 'lastread'):
|
||||
return False
|
||||
self._last_frame = self._reader.lastread
|
||||
self._reader.read_frame()
|
||||
if self._last_frame is self._reader.lastread:
|
||||
# Didn't decode a new frame, must have hit EOF.
|
||||
if self._eof:
|
||||
return False
|
||||
self._eof = True
|
||||
self._frame_number += 1
|
||||
if decode:
|
||||
if self._last_frame is not None:
|
||||
self._last_frame_rgb = cv2.cvtColor(self._last_frame, cv2.COLOR_BGR2RGB)
|
||||
return self._last_frame_rgb
|
||||
return True
|
||||
@@ -0,0 +1,539 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`VideoStreamCv2` is backed by the OpenCV `VideoCapture` object. This is the default
|
||||
backend. Works with video files, image sequences, and network streams/URLs.
|
||||
|
||||
For wrapping input devices or pipes, there is also :class:`VideoCaptureAdapter` which can be
|
||||
constructed from an existing `cv2.VideoCapture`. This allows performing scene detection on inputs
|
||||
which do not support seeking.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
import math
|
||||
from typing import AnyStr, Tuple, Union, Optional
|
||||
import os.path
|
||||
|
||||
import cv2
|
||||
from numpy import ndarray
|
||||
|
||||
from backend.scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA
|
||||
from backend.scenedetect.platform import get_file_name
|
||||
from backend.scenedetect.video_stream import VideoStream, SeekError, VideoOpenFailure, FrameRateUnavailable
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
IMAGE_SEQUENCE_IDENTIFIER = '%'
|
||||
|
||||
NON_VIDEO_FILE_INPUT_IDENTIFIERS = (
|
||||
IMAGE_SEQUENCE_IDENTIFIER, # image sequence
|
||||
'://', # URL/network stream
|
||||
' ! ', # gstreamer pipe
|
||||
)
|
||||
|
||||
|
||||
def _get_aspect_ratio(cap: cv2.VideoCapture, epsilon: float = 0.0001) -> float:
|
||||
"""Display/pixel aspect ratio of the VideoCapture as a float (1.0 represents square pixels)."""
|
||||
# Versions of OpenCV < 3.4.1 do not support this, so we fall back to 1.0.
|
||||
if not 'CAP_PROP_SAR_NUM' in dir(cv2):
|
||||
return 1.0
|
||||
num: float = cap.get(cv2.CAP_PROP_SAR_NUM)
|
||||
den: float = cap.get(cv2.CAP_PROP_SAR_DEN)
|
||||
# If numerator or denominator are close to zero, so we fall back to 1.0.
|
||||
if abs(num) < epsilon or abs(den) < epsilon:
|
||||
return 1.0
|
||||
return num / den
|
||||
|
||||
|
||||
class VideoStreamCv2(VideoStream):
|
||||
"""OpenCV `cv2.VideoCapture` backend."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: AnyStr = None,
|
||||
framerate: Optional[float] = None,
|
||||
max_decode_attempts: int = 5,
|
||||
path_or_device: Union[bytes, str, int] = None,
|
||||
):
|
||||
"""Open a video file, image sequence, or network stream.
|
||||
|
||||
Arguments:
|
||||
path: Path to the video. Can be a file, image sequence (`'folder/DSC_%04d.jpg'`),
|
||||
or network stream.
|
||||
framerate: If set, overrides the detected framerate.
|
||||
max_decode_attempts: Number of attempts to continue decoding the video
|
||||
after a frame fails to decode. This allows processing videos that
|
||||
have a few corrupted frames or metadata (in which case accuracy
|
||||
of detection algorithms may be lower). Once this limit is passed,
|
||||
decoding will stop and emit an error.
|
||||
path_or_device: [DEPRECATED] Specify `path` for files, image sequences, or
|
||||
network streams/URLs. Use `VideoCaptureAdapter` for devices/pipes.
|
||||
|
||||
Raises:
|
||||
OSError: file could not be found or access was denied
|
||||
VideoOpenFailure: video could not be opened (may be corrupted)
|
||||
ValueError: specified framerate is invalid
|
||||
"""
|
||||
super().__init__()
|
||||
# TODO(v0.7): Replace with DeprecationWarning that `path_or_device` will be removed in v0.8.
|
||||
if path_or_device is not None:
|
||||
logger.error('path_or_device is deprecated, use path or VideoCaptureAdapter instead.')
|
||||
path = path_or_device
|
||||
if path is None:
|
||||
raise ValueError('Path must be specified!')
|
||||
if framerate is not None and framerate < MAX_FPS_DELTA:
|
||||
raise ValueError('Specified framerate (%f) is invalid!' % framerate)
|
||||
if max_decode_attempts < 0:
|
||||
raise ValueError('Maximum decode attempts must be >= 0!')
|
||||
|
||||
self._path_or_device = path
|
||||
self._is_device = isinstance(self._path_or_device, int)
|
||||
|
||||
# Initialized in _open_capture:
|
||||
self._cap: Optional[
|
||||
cv2.VideoCapture] = None # Reference to underlying cv2.VideoCapture object.
|
||||
self._frame_rate: Optional[float] = None
|
||||
|
||||
# VideoCapture state
|
||||
self._has_grabbed = False
|
||||
self._max_decode_attempts = max_decode_attempts
|
||||
self._decode_failures = 0
|
||||
self._warning_displayed = False
|
||||
|
||||
self._open_capture(framerate)
|
||||
|
||||
#
|
||||
# Backend-Specific Methods/Properties
|
||||
#
|
||||
|
||||
@property
|
||||
def capture(self) -> cv2.VideoCapture:
|
||||
"""Returns reference to underlying VideoCapture object. Use with caution.
|
||||
|
||||
Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object
|
||||
backing this object. Seeking or using the read/grab methods through this property are
|
||||
unsupported and will leave this object in an inconsistent state.
|
||||
"""
|
||||
assert self._cap
|
||||
return self._cap
|
||||
|
||||
#
|
||||
# VideoStream Methods/Properties
|
||||
#
|
||||
|
||||
BACKEND_NAME = 'opencv'
|
||||
"""Unique name used to identify this backend."""
|
||||
|
||||
@property
|
||||
def frame_rate(self) -> float:
|
||||
"""Framerate in frames/sec."""
|
||||
assert self._frame_rate
|
||||
return self._frame_rate
|
||||
|
||||
@property
|
||||
def path(self) -> Union[bytes, str]:
|
||||
"""Video or device path."""
|
||||
if self._is_device:
|
||||
assert isinstance(self._path_or_device, (int))
|
||||
return "Device %d" % self._path_or_device
|
||||
assert isinstance(self._path_or_device, (bytes, str))
|
||||
return self._path_or_device
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Name of the video, without extension, or device."""
|
||||
if self._is_device:
|
||||
return self.path
|
||||
file_name: str = get_file_name(self.path, include_extension=False)
|
||||
if IMAGE_SEQUENCE_IDENTIFIER in file_name:
|
||||
# file_name is an image sequence, trim everything including/after the %.
|
||||
# TODO: This excludes any suffix after the sequence identifier.
|
||||
file_name = file_name[:file_name.rfind(IMAGE_SEQUENCE_IDENTIFIER)]
|
||||
return file_name
|
||||
|
||||
@property
|
||||
def is_seekable(self) -> bool:
|
||||
"""True if seek() is allowed, False otherwise.
|
||||
|
||||
Always False if opening a device/webcam."""
|
||||
return not self._is_device
|
||||
|
||||
@property
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Size of each video frame in pixels as a tuple of (width, height)."""
|
||||
return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[FrameTimecode]:
|
||||
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
|
||||
if self._is_device:
|
||||
return None
|
||||
return self.base_timecode + math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
return _get_aspect_ratio(self._cap)
|
||||
|
||||
@property
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode.
|
||||
|
||||
This can be interpreted as presentation time stamp of the last frame which was
|
||||
decoded by calling `read` with advance=True.
|
||||
|
||||
This method will always return 0 (e.g. be equal to `base_timecode`) if no frames
|
||||
have been `read`."""
|
||||
if self.frame_number < 1:
|
||||
return self.base_timecode
|
||||
return self.base_timecode + (self.frame_number - 1)
|
||||
|
||||
@property
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in milliseconds.
|
||||
The first frame has a time of 0.0 ms.
|
||||
|
||||
This method will always return 0.0 if no frames have been `read`."""
|
||||
return self._cap.get(cv2.CAP_PROP_POS_MSEC)
|
||||
|
||||
@property
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream in frames as an int.
|
||||
|
||||
1 indicates the first frame was just decoded by the last call to `read` with advance=True,
|
||||
whereas 0 indicates that no frames have been `read`.
|
||||
|
||||
This method will always return 0 if no frames have been `read`."""
|
||||
return math.trunc(self._cap.get(cv2.CAP_PROP_POS_FRAMES))
|
||||
|
||||
def seek(self, target: Union[FrameTimecode, float, int]):
|
||||
"""Seek to the given timecode. If given as a frame number, represents the current seek
|
||||
pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video).
|
||||
|
||||
For 1-based indices (first frame is frame #1), the target frame number needs to be converted
|
||||
to 0-based by subtracting one. For example, if we want to seek to the first frame, we call
|
||||
seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed
|
||||
by read(), at which point frame_number will be 5.
|
||||
|
||||
Not supported if the VideoStream is a device/camera. Untested with web streams.
|
||||
|
||||
Arguments:
|
||||
target: Target position in video stream to seek to.
|
||||
If float, interpreted as time in seconds.
|
||||
If int, interpreted as frame number.
|
||||
Raises:
|
||||
SeekError: An error occurs while seeking, or seeking is not supported.
|
||||
ValueError: `target` is not a valid value (i.e. it is negative).
|
||||
"""
|
||||
if self._is_device:
|
||||
raise SeekError("Cannot seek if input is a device!")
|
||||
if target < 0:
|
||||
raise ValueError("Target seek position cannot be negative!")
|
||||
|
||||
# Have to seek one behind and call grab() after to that the VideoCapture
|
||||
# returns a valid timestamp when using CAP_PROP_POS_MSEC.
|
||||
target_frame_cv2 = (self.base_timecode + target).get_frames()
|
||||
if target_frame_cv2 > 0:
|
||||
target_frame_cv2 -= 1
|
||||
self._cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame_cv2)
|
||||
self._has_grabbed = False
|
||||
# Preemptively grab the frame behind the target position if possible.
|
||||
if target > 0:
|
||||
self._has_grabbed = self._cap.grab()
|
||||
# If we seeked past the end of the video, need to seek one frame backwards
|
||||
# from the current position and grab that frame instead.
|
||||
if not self._has_grabbed:
|
||||
seek_pos = round(self._cap.get(cv2.CAP_PROP_POS_FRAMES) - 1.0)
|
||||
self._cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, seek_pos))
|
||||
self._has_grabbed = self._cap.grab()
|
||||
|
||||
def reset(self):
|
||||
""" Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """
|
||||
self._cap.release()
|
||||
self._open_capture(self._frame_rate)
|
||||
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
"""Read and decode the next frame as a numpy.ndarray. Returns False when video ends,
|
||||
or the maximum number of decode attempts has passed.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will return the current (last) frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video.
|
||||
If decode = False, a bool indicating if advancing to the the next frame succeeded.
|
||||
"""
|
||||
if not self._cap.isOpened():
|
||||
return False
|
||||
# Grab the next frame if possible.
|
||||
if advance:
|
||||
has_grabbed = self._cap.grab()
|
||||
# If we failed to grab the frame, retry a few times if required.
|
||||
if not has_grabbed:
|
||||
if self.duration > 0 and self.position < (self.duration - 1):
|
||||
for _ in range(self._max_decode_attempts):
|
||||
has_grabbed = self._cap.grab()
|
||||
if has_grabbed:
|
||||
break
|
||||
# Report previous failure in debug mode.
|
||||
if has_grabbed:
|
||||
self._decode_failures += 1
|
||||
logger.debug('Frame failed to decode.')
|
||||
if not self._warning_displayed and self._decode_failures > 1:
|
||||
logger.warning('Failed to decode some frames, results may be inaccurate.')
|
||||
# We didn't manage to grab a frame even after retrying, so just return.
|
||||
if not has_grabbed:
|
||||
return False
|
||||
self._has_grabbed = True
|
||||
# Need to make sure we actually grabbed a frame before calling retrieve.
|
||||
if decode and self._has_grabbed:
|
||||
_, frame = self._cap.retrieve()
|
||||
return frame
|
||||
return self._has_grabbed
|
||||
|
||||
#
|
||||
# Private Methods
|
||||
#
|
||||
|
||||
def _open_capture(self, framerate: Optional[float] = None):
|
||||
"""Opens capture referenced by this object and resets internal state."""
|
||||
if self._is_device and self._path_or_device < 0:
|
||||
raise ValueError('Invalid/negative device ID specified.')
|
||||
input_is_video_file = not self._is_device and not any(
|
||||
identifier in self._path_or_device for identifier in NON_VIDEO_FILE_INPUT_IDENTIFIERS)
|
||||
# We don't have a way of querying why opening a video fails (errors are logged at least),
|
||||
# so provide a better error message if we try to open a file that doesn't exist.
|
||||
if input_is_video_file:
|
||||
if not os.path.exists(self._path_or_device):
|
||||
raise OSError('Video file not found.')
|
||||
|
||||
cap = cv2.VideoCapture(self._path_or_device)
|
||||
if not cap.isOpened():
|
||||
raise VideoOpenFailure(
|
||||
'Ensure file is valid video and system dependencies are up to date.\n')
|
||||
|
||||
# Display an error if the video codec type seems unsupported (#86) as this indicates
|
||||
# potential video corruption, or may explain missing frames. We only perform this check
|
||||
# for video files on-disk (skipped for devices, image sequences, streams, etc...).
|
||||
codec_unsupported: bool = (int(abs(cap.get(cv2.CAP_PROP_FOURCC))) == 0)
|
||||
if codec_unsupported and input_is_video_file:
|
||||
logger.error('Video codec detection failed. If output is incorrect:\n'
|
||||
' - Re-encode the input video with ffmpeg\n'
|
||||
' - Update OpenCV (pip install --upgrade opencv-python)\n'
|
||||
' - Use the PyAV backend (--backend pyav)\n'
|
||||
'For details, see https://github.com/Breakthrough/PySceneDetect/issues/86')
|
||||
|
||||
# Ensure the framerate is correct to avoid potential divide by zero errors. This can be
|
||||
# addressed in the PyAV backend if required since it supports integer timebases.
|
||||
assert framerate is None or framerate > MAX_FPS_DELTA, "Framerate must be validated if set!"
|
||||
if framerate is None:
|
||||
framerate = cap.get(cv2.CAP_PROP_FPS)
|
||||
if framerate < MAX_FPS_DELTA:
|
||||
raise FrameRateUnavailable()
|
||||
|
||||
self._cap = cap
|
||||
self._frame_rate = framerate
|
||||
self._has_grabbed = False
|
||||
|
||||
|
||||
# TODO(#168): Support non-monotonic timing for `position`. VFR timecode support is a
|
||||
# prerequisite for this. Timecodes are currently calculated by multiplying the framerate
|
||||
# by number of frames. Actual elapsed time can be obtained via `position_ms` for now.
|
||||
class VideoCaptureAdapter(VideoStream):
|
||||
"""Adapter for existing VideoCapture objects. Unlike VideoStreamCv2, this class supports
|
||||
VideoCaptures which may not support seeking.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cap: cv2.VideoCapture,
|
||||
framerate: Optional[float] = None,
|
||||
max_read_attempts: int = 5,
|
||||
):
|
||||
"""Create from an existing OpenCV VideoCapture object. Used for webcams, live streams,
|
||||
pipes, or other inputs which may not support seeking.
|
||||
|
||||
Arguments:
|
||||
cap: The `cv2.VideoCapture` object to wrap. Must already be opened and ready to
|
||||
have `cap.read()` called on it.
|
||||
framerate: If set, overrides the detected framerate.
|
||||
max_read_attempts: Number of attempts to continue decoding the video
|
||||
after a frame fails to decode. This allows processing videos that
|
||||
have a few corrupted frames or metadata (in which case accuracy
|
||||
of detection algorithms may be lower). Once this limit is passed,
|
||||
decoding will stop and emit an error.
|
||||
|
||||
Raises:
|
||||
ValueError: capture is not open, framerate or max_read_attempts is invalid
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
if framerate is not None and framerate < MAX_FPS_DELTA:
|
||||
raise ValueError('Specified framerate (%f) is invalid!' % framerate)
|
||||
if max_read_attempts < 0:
|
||||
raise ValueError('Maximum decode attempts must be >= 0!')
|
||||
if not cap.isOpened():
|
||||
raise ValueError('Specified VideoCapture must already be opened!')
|
||||
if framerate is None:
|
||||
framerate = cap.get(cv2.CAP_PROP_FPS)
|
||||
if framerate < MAX_FPS_DELTA:
|
||||
raise FrameRateUnavailable()
|
||||
|
||||
self._cap = cap
|
||||
self._frame_rate: float = framerate
|
||||
self._num_frames = 0
|
||||
self._max_read_attempts = max_read_attempts
|
||||
self._decode_failures = 0
|
||||
self._warning_displayed = False
|
||||
self._time_base: float = 0.0
|
||||
|
||||
#
|
||||
# Backend-Specific Methods/Properties
|
||||
#
|
||||
|
||||
@property
|
||||
def capture(self) -> cv2.VideoCapture:
|
||||
"""Returns reference to underlying VideoCapture object. Use with caution.
|
||||
|
||||
Prefer to use this property only to take ownership of the underlying cv2.VideoCapture object
|
||||
backing this object. Using the read/grab methods through this property are unsupported and
|
||||
will leave this object in an inconsistent state.
|
||||
"""
|
||||
assert self._cap
|
||||
return self._cap
|
||||
|
||||
#
|
||||
# VideoStream Methods/Properties
|
||||
#
|
||||
|
||||
BACKEND_NAME = 'opencv_adapter'
|
||||
"""Unique name used to identify this backend."""
|
||||
|
||||
@property
|
||||
def frame_rate(self) -> float:
|
||||
"""Framerate in frames/sec."""
|
||||
assert self._frame_rate
|
||||
return self._frame_rate
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
"""Always 'CAP_ADAPTER'."""
|
||||
return 'CAP_ADAPTER'
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Always 'CAP_ADAPTER'."""
|
||||
return 'CAP_ADAPTER'
|
||||
|
||||
@property
|
||||
def is_seekable(self) -> bool:
|
||||
"""Always False, as the underlying VideoCapture is assumed to not support seeking."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Reported size of each video frame in pixels as a tuple of (width, height)."""
|
||||
return (math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
math.trunc(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[FrameTimecode]:
|
||||
"""Always None, as the underlying VideoCapture is assumed to not have a known duration."""
|
||||
None
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
return _get_aspect_ratio(self._cap)
|
||||
|
||||
@property
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode. Use the :meth:`position_ms`
|
||||
if an accurate duration of elapsed time is required, as `position` is currently
|
||||
based off of the number of frames, and may not be accurate for devicesor live streams.
|
||||
|
||||
This method will always return 0 (e.g. be equal to `base_timecode`) if no frames
|
||||
have been `read`."""
|
||||
if self.frame_number < 1:
|
||||
return self.base_timecode
|
||||
return self.base_timecode + (self.frame_number - 1)
|
||||
|
||||
@property
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in milliseconds.
|
||||
The first frame has a time of 0.0 ms.
|
||||
|
||||
This method will always return 0.0 if no frames have been `read`."""
|
||||
if self._num_frames == 0:
|
||||
return 0.0
|
||||
return self._cap.get(cv2.CAP_PROP_POS_MSEC) - self._time_base
|
||||
|
||||
@property
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream in frames as an int.
|
||||
|
||||
1 indicates the first frame was just decoded by the last call to `read` with advance=True,
|
||||
whereas 0 indicates that no frames have been `read`.
|
||||
|
||||
This method will always return 0 if no frames have been `read`."""
|
||||
return self._num_frames
|
||||
|
||||
def seek(self, target: Union[FrameTimecode, float, int]):
|
||||
"""The underlying VideoCapture is assumed to not support seeking."""
|
||||
raise NotImplementedError("Seeking is not supported.")
|
||||
|
||||
def reset(self):
|
||||
"""Not supported."""
|
||||
raise NotImplementedError("Reset is not supported.")
|
||||
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
"""Read and decode the next frame as a numpy.ndarray. Returns False when video ends,
|
||||
or the maximum number of decode attempts has passed.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will return the current (last) frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video.
|
||||
If decode = False, a bool indicating if advancing to the the next frame succeeded.
|
||||
"""
|
||||
if not self._cap.isOpened():
|
||||
return False
|
||||
# Grab the next frame if possible.
|
||||
if advance:
|
||||
has_grabbed = self._cap.grab()
|
||||
# If we failed to grab the frame, retry a few times if required.
|
||||
if not has_grabbed:
|
||||
for _ in range(self._max_read_attempts):
|
||||
has_grabbed = self._cap.grab()
|
||||
if has_grabbed:
|
||||
break
|
||||
# Report previous failure in debug mode.
|
||||
if has_grabbed:
|
||||
self._decode_failures += 1
|
||||
logger.debug('Frame failed to decode.')
|
||||
if not self._warning_displayed and self._decode_failures > 1:
|
||||
logger.warning('Failed to decode some frames, results may be inaccurate.')
|
||||
# We didn't manage to grab a frame even after retrying, so just return.
|
||||
if not has_grabbed:
|
||||
return False
|
||||
if self._num_frames == 0:
|
||||
self._time_base = self._cap.get(cv2.CAP_PROP_POS_MSEC)
|
||||
self._num_frames += 1
|
||||
# Need to make sure we actually grabbed a frame before calling retrieve.
|
||||
if decode and self._num_frames > 0:
|
||||
_, frame = self._cap.retrieve()
|
||||
return frame
|
||||
return True
|
||||
@@ -0,0 +1,355 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`VideoStreamAv` provides an adapter for the PyAV av.InputContainer object.
|
||||
|
||||
Uses string identifier ``'pyav'``.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from typing import AnyStr, BinaryIO, Optional, Tuple, Union
|
||||
|
||||
# pylint: disable=c-extension-no-member
|
||||
import av
|
||||
from numpy import ndarray
|
||||
|
||||
from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA
|
||||
from scenedetect.platform import get_file_name
|
||||
from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
VALID_THREAD_MODES = [
|
||||
av.codec.context.ThreadType.NONE,
|
||||
av.codec.context.ThreadType.SLICE,
|
||||
av.codec.context.ThreadType.FRAME,
|
||||
av.codec.context.ThreadType.AUTO,
|
||||
]
|
||||
|
||||
|
||||
class VideoStreamAv(VideoStream):
|
||||
"""PyAV `av.InputContainer` backend."""
|
||||
|
||||
# TODO: Investigate adding an accurate_duration option to backends to calculate the duration
|
||||
# with higher precision. Sometimes it doesn't exactly match what the codec or VLC reports,
|
||||
# but we can try to seek to the end of the video first to determine it. Investigate how VLC
|
||||
# calculates the end time.
|
||||
def __init__(
|
||||
self,
|
||||
path_or_io: Union[AnyStr, BinaryIO],
|
||||
framerate: Optional[float] = None,
|
||||
name: Optional[str] = None,
|
||||
threading_mode: Optional[str] = None,
|
||||
suppress_output: bool = False,
|
||||
):
|
||||
"""Open a video by path.
|
||||
|
||||
.. warning::
|
||||
|
||||
Using `threading_mode` with `suppress_output = True` can cause lockups in your
|
||||
application. See the PyAV documentation for details:
|
||||
https://pyav.org/docs/stable/overview/caveats.html#sub-interpeters
|
||||
|
||||
Arguments:
|
||||
path_or_io: Path to the video, or a file-like object.
|
||||
framerate: If set, overrides the detected framerate.
|
||||
name: Overrides the `name` property derived from the video path. Should be set if
|
||||
`path_or_io` is a file-like object.
|
||||
threading_mode: The PyAV video stream `thread_type`. See av.codec.context.ThreadType
|
||||
for valid threading modes ('AUTO', 'FRAME', 'NONE', and 'SLICE'). If this mode is
|
||||
'AUTO' or 'FRAME' and not all frames have been decoded, the video will be reopened
|
||||
if seekable, and the remaining frames decoded in single-threaded mode.
|
||||
suppress_output: If False, ffmpeg output will be sent to stdout/stderr by calling
|
||||
`av.logging.restore_default_callback()` before any other library calls. If True
|
||||
the application may deadlock if threading_mode is set. See the PyAV documentation
|
||||
for details: https://pyav.org/docs/stable/overview/caveats.html#sub-interpeters
|
||||
|
||||
Raises:
|
||||
OSError: file could not be found or access was denied
|
||||
VideoOpenFailure: video could not be opened (may be corrupted)
|
||||
ValueError: specified framerate is invalid
|
||||
"""
|
||||
self._container = None
|
||||
|
||||
# TODO(#258): See what self._container.discard_corrupt = True does with corrupt videos.
|
||||
super().__init__()
|
||||
|
||||
# Ensure specified framerate is valid if set.
|
||||
if framerate is not None and framerate < MAX_FPS_DELTA:
|
||||
raise ValueError('Specified framerate (%f) is invalid!' % framerate)
|
||||
|
||||
self._name = '' if name is None else name
|
||||
self._path = ''
|
||||
self._frame = None
|
||||
self._reopened = True
|
||||
|
||||
if threading_mode:
|
||||
threading_mode = threading_mode.upper()
|
||||
if not threading_mode in VALID_THREAD_MODES:
|
||||
raise ValueError('Invalid threading mode! Must be one of: %s' % VALID_THREAD_MODES)
|
||||
|
||||
if not suppress_output:
|
||||
logger.debug('Restoring default ffmpeg log callbacks.')
|
||||
av.logging.restore_default_callback()
|
||||
|
||||
try:
|
||||
if isinstance(path_or_io, (str, bytes)):
|
||||
self._path = path_or_io
|
||||
self._io = open(path_or_io, 'rb')
|
||||
if not self._name:
|
||||
self._name = get_file_name(self.path, include_extension=False)
|
||||
else:
|
||||
self._io = path_or_io
|
||||
|
||||
self._container = av.open(self._io)
|
||||
if threading_mode is not None:
|
||||
self._video_stream.thread_type = threading_mode
|
||||
self._reopened = False
|
||||
logger.debug('Threading mode set: %s', threading_mode)
|
||||
except OSError:
|
||||
raise
|
||||
except Exception as ex:
|
||||
raise VideoOpenFailure(str(ex)) from ex
|
||||
|
||||
if framerate is None:
|
||||
# Calculate framerate from video container. `guessed_rate` below appears in PyAV 9.
|
||||
frame_rate = self._video_stream.guessed_rate if hasattr(
|
||||
self._video_stream, 'guessed_rate') else self._codec_context.framerate
|
||||
if frame_rate is None or frame_rate == 0:
|
||||
raise FrameRateUnavailable()
|
||||
# TODO: Refactor FrameTimecode to support raw timing rather than framerate based calculations.
|
||||
# See https://pyav.org/docs/develop/api/stream.html for details.
|
||||
frame_rate = frame_rate.numerator / float(frame_rate.denominator)
|
||||
if frame_rate < MAX_FPS_DELTA:
|
||||
raise FrameRateUnavailable()
|
||||
self._frame_rate: float = frame_rate
|
||||
else:
|
||||
assert framerate >= MAX_FPS_DELTA
|
||||
self._frame_rate: float = framerate
|
||||
|
||||
# Calculate duration after we have set the framerate.
|
||||
self._duration_frames = self._get_duration()
|
||||
|
||||
def __del__(self):
|
||||
if self._container is not None:
|
||||
self._container.close()
|
||||
|
||||
#
|
||||
# VideoStream Methods/Properties
|
||||
#
|
||||
|
||||
BACKEND_NAME = 'pyav'
|
||||
"""Unique name used to identify this backend."""
|
||||
|
||||
@property
|
||||
def path(self) -> Union[bytes, str]:
|
||||
"""Video path."""
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def name(self) -> Union[bytes, str]:
|
||||
"""Name of the video, without extension."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_seekable(self) -> bool:
|
||||
"""True if seek() is allowed, False otherwise."""
|
||||
return self._io.seekable()
|
||||
|
||||
@property
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Size of each video frame in pixels as a tuple of (width, height)."""
|
||||
return (self._codec_context.width, self._codec_context.height)
|
||||
|
||||
@property
|
||||
def duration(self) -> FrameTimecode:
|
||||
"""Duration of the video as a FrameTimecode."""
|
||||
return self.base_timecode + self._duration_frames
|
||||
|
||||
@property
|
||||
def frame_rate(self) -> float:
|
||||
"""Frame rate in frames/sec."""
|
||||
return self._frame_rate
|
||||
|
||||
@property
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode.
|
||||
|
||||
This can be interpreted as presentation time stamp, thus frame 1 corresponds
|
||||
to the presentation time 0. Returns 0 even if `frame_number` is 1."""
|
||||
if self._frame is None:
|
||||
return self.base_timecode
|
||||
return FrameTimecode(round(self._frame.time * self.frame_rate), self.frame_rate)
|
||||
|
||||
@property
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in
|
||||
milliseconds. The first frame has a PTS of 0."""
|
||||
if self._frame is None:
|
||||
return 0.0
|
||||
return self._frame.time * 1000.0
|
||||
|
||||
@property
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream as the frame number.
|
||||
|
||||
Will return 0 until the first frame is `read`."""
|
||||
if self._frame:
|
||||
return self.position.frame_num + 1
|
||||
return 0
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
display_aspect_ratio = (
|
||||
self._codec_context.display_aspect_ratio.numerator /
|
||||
self._codec_context.display_aspect_ratio.denominator)
|
||||
frame_aspect_ratio = self.frame_size[0] / self.frame_size[1]
|
||||
return display_aspect_ratio / frame_aspect_ratio
|
||||
|
||||
def seek(self, target: Union[FrameTimecode, float, int]) -> None:
|
||||
"""Seek to the given timecode. If given as a frame number, represents the current seek
|
||||
pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video).
|
||||
|
||||
For 1-based indices (first frame is frame #1), the target frame number needs to be converted
|
||||
to 0-based by subtracting one. For example, if we want to seek to the first frame, we call
|
||||
seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed
|
||||
by read(), at which point frame_number will be 5.
|
||||
|
||||
May not be supported on all input codecs (see `is_seekable`).
|
||||
|
||||
Arguments:
|
||||
target: Target position in video stream to seek to.
|
||||
If float, interpreted as time in seconds.
|
||||
If int, interpreted as frame number.
|
||||
Raises:
|
||||
ValueError: `target` is not a valid value (i.e. it is negative).
|
||||
"""
|
||||
if target < 0:
|
||||
raise ValueError("Target cannot be negative!")
|
||||
beginning = (target == 0)
|
||||
target = (self.base_timecode + target)
|
||||
if target >= 1:
|
||||
target = target - 1
|
||||
target_pts = self._video_stream.start_time + int(
|
||||
(self.base_timecode + target).get_seconds() / self._video_stream.time_base)
|
||||
self._frame = None
|
||||
self._container.seek(target_pts, stream=self._video_stream)
|
||||
if not beginning:
|
||||
self.read(decode=False, advance=True)
|
||||
while self.position < target:
|
||||
if self.read(decode=False, advance=True) is False:
|
||||
break
|
||||
|
||||
def reset(self):
|
||||
""" Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """
|
||||
self._container.close()
|
||||
self._frame = None
|
||||
try:
|
||||
self._container = av.open(self._path if self._path else self._io)
|
||||
except Exception as ex:
|
||||
raise VideoOpenFailure() from ex
|
||||
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
"""Read and decode the next frame as a numpy.ndarray. Returns False when video ends.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will return the current (last) frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video.
|
||||
If decode = False, a bool indicating if advancing to the the next frame succeeded.
|
||||
"""
|
||||
has_advanced = False
|
||||
if advance:
|
||||
try:
|
||||
last_frame = self._frame
|
||||
self._frame = next(self._container.decode(video=0))
|
||||
except av.error.EOFError:
|
||||
self._frame = last_frame
|
||||
if self._handle_eof():
|
||||
return self.read(decode, advance=True)
|
||||
return False
|
||||
except StopIteration:
|
||||
return False
|
||||
has_advanced = True
|
||||
if decode:
|
||||
return self._frame.to_ndarray(format='bgr24')
|
||||
return has_advanced
|
||||
|
||||
#
|
||||
# Private Methods/Properties
|
||||
#
|
||||
|
||||
@property
|
||||
def _video_stream(self):
|
||||
"""PyAV `av.video.stream.VideoStream` being used."""
|
||||
return self._container.streams.video[0]
|
||||
|
||||
@property
|
||||
def _codec_context(self):
|
||||
"""PyAV `av.codec.context.CodecContext` being used."""
|
||||
return self._video_stream.codec_context
|
||||
|
||||
def _get_duration(self) -> int:
|
||||
"""Get video duration as number of frames based on the video and set framerate."""
|
||||
# See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV
|
||||
# handle time calculations internally and which time base to use.
|
||||
assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!"
|
||||
# See if we can obtain the number of frames directly from the stream itself.
|
||||
if self._video_stream.frames > 0:
|
||||
return self._video_stream.frames
|
||||
# Calculate based on the reported container duration.
|
||||
duration_sec = None
|
||||
container = self._video_stream.container
|
||||
if container.duration is not None and container.duration > 0:
|
||||
# Containers use AV_TIME_BASE as the time base.
|
||||
duration_sec = float(self._video_stream.container.duration / av.time_base)
|
||||
# Lastly, if that calculation fails, try to calculate it based on the stream duration.
|
||||
if duration_sec is None or duration_sec < MAX_FPS_DELTA:
|
||||
if self._video_stream.duration is None:
|
||||
logger.warning('Video duration unavailable.')
|
||||
return 0
|
||||
# Streams use stream `time_base` as the time base.
|
||||
time_base = self._video_stream.time_base
|
||||
if time_base.denominator == 0:
|
||||
logger.warning(
|
||||
'Unable to calculate video duration: time_base (%s) has zero denominator!',
|
||||
str(time_base))
|
||||
return 0
|
||||
duration_sec = float(self._video_stream.duration / time_base)
|
||||
return round(duration_sec * self.frame_rate)
|
||||
|
||||
def _handle_eof(self):
|
||||
"""Fix for issue where if thread_type is 'AUTO' the whole video is not decoded.
|
||||
|
||||
Re-open video if the threading mode is AUTO and we didn't decode all of the frames."""
|
||||
# Don't re-open the video if we already did, or if we already decoded all the frames.
|
||||
if self._reopened or self.frame_number >= self.duration:
|
||||
return False
|
||||
self._reopened = True
|
||||
# Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode.
|
||||
if not self.is_seekable or not self._video_stream.thread_type in ('AUTO', 'FRAME'):
|
||||
return False
|
||||
last_frame = self.frame_number
|
||||
orig_pos = self._io.tell()
|
||||
try:
|
||||
self._io.seek(0)
|
||||
container = av.open(self._io)
|
||||
except:
|
||||
self._io.seek(orig_pos)
|
||||
raise
|
||||
self._container.close()
|
||||
self._container = container
|
||||
self.seek(last_frame)
|
||||
return True
|
||||
@@ -0,0 +1,81 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.detectors`` Module
|
||||
|
||||
This module contains the following scene detection algorithms:
|
||||
|
||||
* :mod:`ContentDetector <scenedetect.detectors.content_detector>`:
|
||||
Detects shot changes by considering pixel changes in the HSV colorspace.
|
||||
|
||||
* :mod:`ThresholdDetector <scenedetect.detectors.threshold_detector>`:
|
||||
Detects transitions below a set pixel intensity (cuts or fades to black).
|
||||
|
||||
* :mod:`AdaptiveDetector <scenedetect.detectors.adaptive_detector>`:
|
||||
Two-pass version of `ContentDetector` that handles fast camera movement better in some cases.
|
||||
|
||||
Detection algorithms are created by implementing the
|
||||
:class:`SceneDetector <scenedetect.scene_detector.SceneDetector>` interface. Detectors are
|
||||
typically attached to a :class:`SceneManager <scenedetect.scene_manager.SceneManager>` when
|
||||
processing videos, however they can also be used to process frames directly.
|
||||
"""
|
||||
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
# #
|
||||
# Detection Methods & Algorithms Planned or In Development #
|
||||
# #
|
||||
#
|
||||
# class EdgeDetector(SceneDetector):
|
||||
# """Detects fast cuts/slow fades by using edge detection on adjacent frames.
|
||||
#
|
||||
# Computes the difference image between subsequent frames after applying a
|
||||
# Sobel filter (can also use a high-pass or other edge detection filters) and
|
||||
# comparing the result with a set threshold (may be found using -stats mode).
|
||||
# Detects both fast cuts and slow fades, although some parameters may need to
|
||||
# be modified for accurate slow fade detection.
|
||||
# """
|
||||
# def __init__(self):
|
||||
# super(EdgeDetector, self).__init__()
|
||||
# #
|
||||
# #
|
||||
# class DissolveDetector(SceneDetector):
|
||||
# """Detects slow fades (dissolve cuts) via changes in the HSV colour space.
|
||||
#
|
||||
# Detects slow fades only; to scene_detect fast cuts between content scenes, the
|
||||
# ContentDetector should be used instead.
|
||||
# """
|
||||
#
|
||||
# def __init__(self):
|
||||
# super(DissolveDetector, self).__init__()
|
||||
# #
|
||||
# #
|
||||
# class HistogramDetector(SceneDetector):
|
||||
# """Detects fast cuts via histogram changes between sequential frames.
|
||||
#
|
||||
# Detects fast cuts between content (using histogram deltas, much like the
|
||||
# ContentDetector uses HSV colourspace deltas), as well as both fades and
|
||||
# cuts to/from black (using a threshold, much like the ThresholdDetector).
|
||||
# """
|
||||
#
|
||||
# def __init__(self):
|
||||
# super(DissolveDetector, self).__init__()
|
||||
# #
|
||||
# #
|
||||
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
|
||||
|
||||
# PySceneDetect Detection Algorithm Imports
|
||||
from backend.scenedetect.detectors.content_detector import ContentDetector
|
||||
from backend.scenedetect.detectors.threshold_detector import ThresholdDetector
|
||||
from backend.scenedetect.detectors.adaptive_detector import AdaptiveDetector
|
||||
|
||||
# Algorithms being ported:
|
||||
#from scenedetect.detectors.motion_detector import MotionDetector
|
||||
@@ -0,0 +1,184 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`AdaptiveDetector` compares the difference in content between adjacent frames similar
|
||||
to `ContentDetector` except the threshold isn't fixed, but is a rolling average of adjacent frame
|
||||
changes. This can help mitigate false detections in situations such as fast camera motions.
|
||||
|
||||
This detector is available from the command-line as the `scene_detect-adaptive` command.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from typing import List, Optional
|
||||
|
||||
from numpy import ndarray
|
||||
|
||||
from backend.scenedetect.detectors import ContentDetector
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
|
||||
class AdaptiveDetector(ContentDetector):
|
||||
"""Two-pass detector that calculates frame scores with ContentDetector, and then applies
|
||||
a rolling average when processing the result that can help mitigate false detections
|
||||
in situations such as camera movement.
|
||||
"""
|
||||
|
||||
ADAPTIVE_RATIO_KEY_TEMPLATE = "adaptive_ratio{luma_only} (w={window_width})"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adaptive_threshold: float = 3.0,
|
||||
min_scene_len: int = 15,
|
||||
window_width: int = 2,
|
||||
min_content_val: float = 15.0,
|
||||
weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS,
|
||||
luma_only: bool = False,
|
||||
kernel_size: Optional[int] = None,
|
||||
video_manager=None,
|
||||
min_delta_hsv: Optional[float] = None,
|
||||
):
|
||||
"""
|
||||
Arguments:
|
||||
adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a
|
||||
new scene (see frame metric adaptive_ratio in stats file).
|
||||
min_scene_len: Minimum length of any scene.
|
||||
window_width: Size of window (number of frames) before and after each frame to
|
||||
average together in order to scene_detect deviations from the mean. Must be at least 1.
|
||||
min_content_val: Minimum threshold (float) that the content_val must exceed in order to
|
||||
register as a new scene. This is calculated the same way that `scene_detect-content`
|
||||
calculates frame score based on `weights`/`luma_only`/`kernel_size`.
|
||||
weights: Weight to place on each component when calculating frame score
|
||||
(`content_val` in a statsfile, the value `threshold` is compared against).
|
||||
If omitted, the default ContentDetector weights are used.
|
||||
luma_only: If True, only considers changes in the luminance channel of the video.
|
||||
Equivalent to specifying `weights` as :data:`ContentDetector.LUMA_ONLY`.
|
||||
Overrides `weights` if both are set.
|
||||
kernel_size: Size of kernel to use for post edge detection filtering. If None,
|
||||
automatically set based on video resolution.
|
||||
video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only.
|
||||
min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead.
|
||||
"""
|
||||
# TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will
|
||||
# be removed in v0.8.
|
||||
if video_manager is not None:
|
||||
logger.error('video_manager is deprecated, use video instead.')
|
||||
if min_delta_hsv is not None:
|
||||
logger.error('min_delta_hsv is deprecated, use min_content_val instead.')
|
||||
min_content_val = min_delta_hsv
|
||||
if window_width < 1:
|
||||
raise ValueError('window_width must be at least 1.')
|
||||
|
||||
super().__init__(
|
||||
threshold=255.0,
|
||||
min_scene_len=0,
|
||||
weights=weights,
|
||||
luma_only=luma_only,
|
||||
kernel_size=kernel_size,
|
||||
)
|
||||
|
||||
# TODO: Turn these options into properties.
|
||||
self.min_scene_len = min_scene_len
|
||||
self.adaptive_threshold = adaptive_threshold
|
||||
self.min_content_val = min_content_val
|
||||
self.window_width = window_width
|
||||
|
||||
self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format(
|
||||
window_width=window_width, luma_only='' if not luma_only else '_lum')
|
||||
self._first_frame_num = None
|
||||
self._last_frame_num = None
|
||||
|
||||
self._last_cut: Optional[int] = None
|
||||
|
||||
self._buffer = []
|
||||
|
||||
@property
|
||||
def event_buffer_length(self) -> int:
|
||||
"""Number of frames any detected cuts will be behind the current frame due to buffering."""
|
||||
return self.window_width
|
||||
|
||||
def get_metrics(self) -> List[str]:
|
||||
"""Combines base ContentDetector metric keys with the AdaptiveDetector one."""
|
||||
return super().get_metrics() + [self._adaptive_ratio_key]
|
||||
|
||||
def stats_manager_required(self) -> bool:
|
||||
"""Not required for AdaptiveDetector."""
|
||||
return False
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: Optional[ndarray]) -> List[int]:
|
||||
""" Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead
|
||||
of single-frame RGB/grayscale intensity (thus cannot scene_detect slow fades with this method).
|
||||
|
||||
Arguments:
|
||||
frame_num: Frame number of frame that is being passed.
|
||||
|
||||
frame_img: Decoded frame image (numpy.ndarray) to perform scene
|
||||
detection on. Can be None *only* if the self.is_processing_required() method
|
||||
(inhereted from the base SceneDetector class) returns True.
|
||||
|
||||
Returns:
|
||||
Empty list
|
||||
"""
|
||||
|
||||
# TODO(#283): Merge this with ContentDetector and turn it on by default.
|
||||
|
||||
super().process_frame(frame_num=frame_num, frame_img=frame_img)
|
||||
|
||||
required_frames = 1 + (2 * self.window_width)
|
||||
self._buffer.append((frame_num, self._frame_score))
|
||||
if not len(self._buffer) >= required_frames:
|
||||
return []
|
||||
self._buffer = self._buffer[-required_frames:]
|
||||
target = self._buffer[self.window_width]
|
||||
average_window_score = (
|
||||
sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) /
|
||||
(2.0 * self.window_width))
|
||||
|
||||
average_is_zero = abs(average_window_score) < 0.00001
|
||||
|
||||
adaptive_ratio = 0.0
|
||||
if not average_is_zero:
|
||||
adaptive_ratio = min(target[1] / average_window_score, 255.0)
|
||||
elif average_is_zero and target[1] >= self.min_content_val:
|
||||
# if we would have divided by zero, set adaptive_ratio to the max (255.0)
|
||||
adaptive_ratio = 255.0
|
||||
if self.stats_manager is not None:
|
||||
self.stats_manager.set_metrics(target[0], {self._adaptive_ratio_key: adaptive_ratio})
|
||||
|
||||
cut_list = []
|
||||
# Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there
|
||||
# being a large enough content_val to trigger a cut
|
||||
if (adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val):
|
||||
|
||||
if self._last_cut is None:
|
||||
# No previously detected cuts
|
||||
cut_list.append(target[0])
|
||||
self._last_cut = target[0]
|
||||
elif (target[0] - self._last_cut) >= self.min_scene_len:
|
||||
# Respect the min_scene_len parameter
|
||||
cut_list.append(target[0])
|
||||
# TODO: Should this be updated every time the threshold is exceeded?
|
||||
# It might help with flash suppression for example.
|
||||
self._last_cut = target[0]
|
||||
|
||||
return cut_list
|
||||
|
||||
# TODO(0.6.3): Deprecate & remove this method.
|
||||
def get_content_val(self, frame_num: int) -> Optional[float]:
|
||||
"""Returns the average content change for a frame."""
|
||||
if self.stats_manager is not None:
|
||||
return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0]
|
||||
return 0.0
|
||||
|
||||
def post_process(self, _unused_frame_num: int):
|
||||
"""Not required for AdaptiveDetector."""
|
||||
return []
|
||||
@@ -0,0 +1,259 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`ContentDetector` compares the difference in content between adjacent frames against a
|
||||
set threshold/score, which if exceeded, triggers a scene cut.
|
||||
|
||||
This detector is available from the command-line as the `scene_detect-content` command.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
import numpy
|
||||
import cv2
|
||||
|
||||
from backend.scenedetect.scene_detector import SceneDetector
|
||||
|
||||
|
||||
def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float:
|
||||
"""Return the mean average distance in pixel values between `left` and `right`.
|
||||
Both `left and `right` should be 2 dimensional 8-bit images of the same shape.
|
||||
"""
|
||||
assert len(left.shape) == 2 and len(right.shape) == 2
|
||||
assert left.shape == right.shape
|
||||
num_pixels: float = float(left.shape[0] * left.shape[1])
|
||||
return (numpy.sum(numpy.abs(left.astype(numpy.int32) - right.astype(numpy.int32))) / num_pixels)
|
||||
|
||||
|
||||
def _estimated_kernel_size(frame_width: int, frame_height: int) -> int:
|
||||
"""Estimate kernel size based on video resolution."""
|
||||
# TODO: This equation is based on manual estimation from a few videos.
|
||||
# Create a more comprehensive test suite to optimize against.
|
||||
size: int = 4 + round(math.sqrt(frame_width * frame_height) / 192)
|
||||
if size % 2 == 0:
|
||||
size += 1
|
||||
return size
|
||||
|
||||
|
||||
class ContentDetector(SceneDetector):
|
||||
"""Detects fast cuts using changes in colour and intensity between frames.
|
||||
|
||||
Since the difference between frames is used, unlike the ThresholdDetector,
|
||||
only fast cuts are detected with this method. To scene_detect slow fades between
|
||||
content scenes still using HSV information, use the DissolveDetector.
|
||||
"""
|
||||
|
||||
# TODO: Come up with some good weights for a new default if there is one that can pass
|
||||
# a wider variety of test cases.
|
||||
class Components(NamedTuple):
|
||||
"""Components that make up a frame's score, and their default values."""
|
||||
delta_hue: float = 1.0
|
||||
"""Difference between pixel hue values of adjacent frames."""
|
||||
delta_sat: float = 1.0
|
||||
"""Difference between pixel saturation values of adjacent frames."""
|
||||
delta_lum: float = 1.0
|
||||
"""Difference between pixel luma (brightness) values of adjacent frames."""
|
||||
delta_edges: float = 0.0
|
||||
"""Difference between calculated edges of adjacent frames.
|
||||
|
||||
Edge differences are typically larger than the other components, so the detection
|
||||
threshold may need to be adjusted accordingly."""
|
||||
|
||||
DEFAULT_COMPONENT_WEIGHTS = Components()
|
||||
"""Default component weights. Actual default values are specified in :class:`Components`
|
||||
to allow adding new components without breaking existing usage."""
|
||||
|
||||
LUMA_ONLY_WEIGHTS = Components(
|
||||
delta_hue=0.0,
|
||||
delta_sat=0.0,
|
||||
delta_lum=1.0,
|
||||
delta_edges=0.0,
|
||||
)
|
||||
"""Component weights to use if `luma_only` is set."""
|
||||
|
||||
FRAME_SCORE_KEY = 'content_val'
|
||||
"""Key in statsfile representing the final frame score after weighed by specified components."""
|
||||
|
||||
METRIC_KEYS = [FRAME_SCORE_KEY, *Components._fields]
|
||||
"""All statsfile keys this detector produces."""
|
||||
|
||||
@dataclass
|
||||
class _FrameData:
|
||||
"""Data calculated for a given frame."""
|
||||
hue: numpy.ndarray
|
||||
"""Frame hue map [2D 8-bit]."""
|
||||
sat: numpy.ndarray
|
||||
"""Frame saturation map [2D 8-bit]."""
|
||||
lum: numpy.ndarray
|
||||
"""Frame luma/brightness map [2D 8-bit]."""
|
||||
edges: Optional[numpy.ndarray]
|
||||
"""Frame edge map [2D 8-bit, edges are 255, non edges 0]. Affected by `kernel_size`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
threshold: float = 27.0,
|
||||
min_scene_len: int = 15,
|
||||
weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS,
|
||||
luma_only: bool = False,
|
||||
kernel_size: Optional[int] = None,
|
||||
):
|
||||
"""
|
||||
Arguments:
|
||||
threshold: Threshold the average change in pixel intensity must exceed to trigger a cut.
|
||||
min_scene_len: Once a cut is detected, this many frames must pass before a new one can
|
||||
be added to the scene list.
|
||||
weights: Weight to place on each component when calculating frame score
|
||||
(`content_val` in a statsfile, the value `threshold` is compared against).
|
||||
luma_only: If True, only considers changes in the luminance channel of the video.
|
||||
Equivalent to specifying `weights` as :data:`ContentDetector.LUMA_ONLY`.
|
||||
Overrides `weights` if both are set.
|
||||
kernel_size: Size of kernel for expanding detected edges. Must be odd integer
|
||||
greater than or equal to 3. If None, automatically set using video resolution.
|
||||
"""
|
||||
super().__init__()
|
||||
self._threshold: float = threshold
|
||||
self._min_scene_len: int = min_scene_len
|
||||
self._last_scene_cut: Optional[int] = None
|
||||
self._last_frame: Optional[ContentDetector._FrameData] = None
|
||||
self._weights: ContentDetector.Components = weights
|
||||
if luma_only:
|
||||
self._weights = ContentDetector.LUMA_ONLY_WEIGHTS
|
||||
self._kernel: Optional[numpy.ndarray] = None
|
||||
if kernel_size is not None:
|
||||
print(kernel_size)
|
||||
if kernel_size < 3 or kernel_size % 2 == 0:
|
||||
raise ValueError('kernel_size must be odd integer >= 3')
|
||||
self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)
|
||||
self._frame_score: Optional[float] = None
|
||||
|
||||
def get_metrics(self):
|
||||
return ContentDetector.METRIC_KEYS
|
||||
|
||||
def is_processing_required(self, frame_num):
|
||||
return True
|
||||
|
||||
def _calculate_frame_score(self, frame_num: int, frame_img: numpy.ndarray) -> float:
|
||||
"""Calculate score representing relative amount of motion in `frame_img` compared to
|
||||
the last time the function was called (returns 0.0 on the first call)."""
|
||||
# TODO: Add option to enable motion estimation before calculating score components.
|
||||
# TODO: Investigate methods of performing cheaper alternatives, e.g. shifting or resizing
|
||||
# the frame to simulate camera movement, using optical flow, etc...
|
||||
|
||||
# Convert image into HSV colorspace.
|
||||
hue, sat, lum = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV))
|
||||
|
||||
# Performance: Only calculate edges if we have to.
|
||||
calculate_edges: bool = ((self._weights.delta_edges > 0.0)
|
||||
or self.stats_manager is not None)
|
||||
edges = self._detect_edges(lum) if calculate_edges else None
|
||||
|
||||
if self._last_frame is None:
|
||||
# Need another frame to compare with for score calculation.
|
||||
self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges)
|
||||
return 0.0
|
||||
|
||||
score_components = ContentDetector.Components(
|
||||
delta_hue=_mean_pixel_distance(hue, self._last_frame.hue),
|
||||
delta_sat=_mean_pixel_distance(sat, self._last_frame.sat),
|
||||
delta_lum=_mean_pixel_distance(lum, self._last_frame.lum),
|
||||
delta_edges=(0.0 if edges is None else _mean_pixel_distance(
|
||||
edges, self._last_frame.edges)),
|
||||
)
|
||||
|
||||
frame_score: float = (
|
||||
sum(component * weight for (component, weight) in zip(score_components, self._weights))
|
||||
/ sum(abs(weight) for weight in self._weights))
|
||||
|
||||
# Record components and frame score if needed for analysis.
|
||||
if self.stats_manager is not None:
|
||||
metrics = {self.FRAME_SCORE_KEY: frame_score}
|
||||
metrics.update(score_components._asdict())
|
||||
self.stats_manager.set_metrics(frame_num, metrics)
|
||||
|
||||
# Store all data required to calculate the next frame's score.
|
||||
self._last_frame = ContentDetector._FrameData(hue, sat, lum, edges)
|
||||
return frame_score
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]:
|
||||
""" Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead
|
||||
of single-frame RGB/grayscale intensity (thus cannot scene_detect slow fades with this method).
|
||||
|
||||
Arguments:
|
||||
frame_num: Frame number of frame that is being passed.
|
||||
frame_img: Decoded frame image (numpy.ndarray) to perform scene
|
||||
detection on. Can be None *only* if the self.is_processing_required() method
|
||||
(inhereted from the base SceneDetector class) returns True.
|
||||
|
||||
Returns:
|
||||
List of frames where scene cuts have been detected. There may be 0
|
||||
or more frames in the list, and not necessarily the same as frame_num.
|
||||
"""
|
||||
if frame_img is None:
|
||||
# TODO(0.6.3): Make frame_img a required argument in the interface. Log a warning
|
||||
# that passing None is deprecated and results will be incorrect if this is the case.
|
||||
return []
|
||||
|
||||
# Initialize last scene cut point at the beginning of the frames of interest.
|
||||
if self._last_scene_cut is None:
|
||||
self._last_scene_cut = frame_num
|
||||
|
||||
self._frame_score = self._calculate_frame_score(frame_num, frame_img)
|
||||
if self._frame_score is None:
|
||||
return []
|
||||
|
||||
# We consider any frame over the threshold a new scene, but only if
|
||||
# the minimum scene length has been reached (otherwise it is ignored).
|
||||
min_length_met = (frame_num - self._last_scene_cut) >= self._min_scene_len
|
||||
if self._frame_score >= self._threshold and min_length_met:
|
||||
self._last_scene_cut = frame_num
|
||||
return [frame_num]
|
||||
|
||||
return []
|
||||
|
||||
# TODO(#250): Based on the parameters passed to the ContentDetector constructor,
|
||||
# ensure that the last scene meets the minimum length requirement, otherwise it
|
||||
# should be merged with the previous scene. This can be done by caching the cuts
|
||||
# for the amount of time the minimum length is set to, returning any outstanding
|
||||
# final cuts in post_process.
|
||||
|
||||
#def post_process(self, frame_num):
|
||||
# """
|
||||
# return []
|
||||
|
||||
def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray:
|
||||
"""Detect edges using the luma channel of a frame.
|
||||
|
||||
Arguments:
|
||||
lum: 2D 8-bit image representing the luma channel of a frame.
|
||||
|
||||
Returns:
|
||||
2D 8-bit image of the same size as the input, where pixels with values of 255
|
||||
represent edges, and all other pixels are 0.
|
||||
"""
|
||||
# Initialize kernel.
|
||||
if self._kernel is None:
|
||||
kernel_size = _estimated_kernel_size(lum.shape[1], lum.shape[0])
|
||||
self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)
|
||||
|
||||
# Estimate levels for thresholding.
|
||||
# TODO(0.6.3): Add config file entries for sigma, aperture/kernel size, etc.
|
||||
sigma: float = 1.0 / 3.0
|
||||
median = numpy.median(lum)
|
||||
low = int(max(0, (1.0 - sigma) * median))
|
||||
high = int(min(255, (1.0 + sigma) * median))
|
||||
|
||||
# Calculate edges using Canny algorithm, and reduce noise by dilating the edges.
|
||||
# This increases edge overlap leading to improved robustness against noise and slow
|
||||
# camera movement. Note that very large kernel sizes can negatively affect accuracy.
|
||||
edges = cv2.Canny(lum, low, high)
|
||||
return cv2.dilate(edges, self._kernel)
|
||||
@@ -0,0 +1,92 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`MotionDetector`, detects motion events using background subtraction, morphological
|
||||
transforms, and thresholding."""
|
||||
|
||||
# Third-Party Library Imports
|
||||
import cv2
|
||||
|
||||
# PySceneDetect Library Imports
|
||||
from scenedetect.scene_detector import SparseSceneDetector
|
||||
|
||||
|
||||
class MotionDetector(SparseSceneDetector):
|
||||
"""Detects motion events in scenes containing a static background.
|
||||
|
||||
Uses background subtraction followed by noise removal (via morphological
|
||||
opening) to generate a frame score compared against the set threshold.
|
||||
|
||||
Attributes:
|
||||
threshold: floating point value compared to each frame's score, which
|
||||
represents average intensity change per pixel (lower values are
|
||||
more sensitive to motion changes). Default 0.5, must be > 0.0.
|
||||
num_frames_post_scene: Number of frames to include in each motion
|
||||
event after the frame score falls below the threshold, adding any
|
||||
subsequent motion events to the same scene.
|
||||
kernel_size: Size of morphological opening kernel for noise removal.
|
||||
Setting to -1 (default) will auto-compute based on video resolution
|
||||
(typically 3 for SD, 5-7 for HD). Must be an odd integer > 1.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold=0.50, num_frames_post_scene=30, kernel_size=-1):
|
||||
"""Initializes motion-based scene detector object."""
|
||||
# TODO: Requires porting to v0.5 API.
|
||||
raise NotImplementedError()
|
||||
"""
|
||||
self.threshold = float(threshold)
|
||||
self.num_frames_post_scene = int(num_frames_post_scene)
|
||||
|
||||
self.kernel_size = int(kernel_size)
|
||||
if self.kernel_size < 0:
|
||||
# Set kernel size when process_frame first runs based on
|
||||
# video resolution (480p = 3x3, 720p = 5x5, 1080p = 7x7).
|
||||
pass
|
||||
|
||||
self.bg_subtractor = cv2.createBackgroundSubtractorMOG2(
|
||||
detectShadows = False )
|
||||
|
||||
self.last_frame_score = 0.0
|
||||
|
||||
self.in_motion_event = False
|
||||
self.first_motion_frame_index = -1
|
||||
self.last_motion_frame_index = -1
|
||||
"""
|
||||
|
||||
def process_frame(self, frame_num, frame_img):
|
||||
# TODO.
|
||||
"""
|
||||
frame_grayscale = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
masked_frame = self.bg_subtractor.apply(frame_grayscale)
|
||||
|
||||
kernel = numpy.ones((self.kernel_size, self.kernel_size), numpy.uint8)
|
||||
filtered_frame = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel)
|
||||
|
||||
frame_score = numpy.sum(filtered_frame) / float(
|
||||
filtered_frame.shape[0] * filtered_frame.shape[1] )
|
||||
"""
|
||||
return []
|
||||
|
||||
def post_process(self, frame_num):
|
||||
"""Writes the last scene if the video ends while in a motion event.
|
||||
"""
|
||||
|
||||
# If the last fade detected was a fade out, we add a corresponding new
|
||||
# scene break to indicate the end of the scene. This is only done for
|
||||
# fade-outs, as a scene cut is already added when a fade-in is found.
|
||||
"""
|
||||
if self.in_motion_event:
|
||||
# Write new scene based on first and last motion event frames.
|
||||
pass
|
||||
return self.in_motion_event
|
||||
"""
|
||||
return []
|
||||
@@ -0,0 +1,203 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
""":class:`ThresholdDetector` uses a set intensity as a threshold to scene_detect cuts, which are
|
||||
triggered when the average pixel intensity exceeds or falls below this threshold.
|
||||
|
||||
This detector is available from the command-line as the `scene_detect-threshold` command.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
from typing import List, Optional
|
||||
|
||||
import numpy
|
||||
|
||||
from backend.scenedetect.scene_detector import SceneDetector
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
##
|
||||
## ThresholdDetector Helper Functions
|
||||
##
|
||||
|
||||
|
||||
def _compute_frame_average(frame: numpy.ndarray) -> float:
|
||||
"""Computes the average pixel value/intensity for all pixels in a frame.
|
||||
|
||||
The value is computed by adding up the 8-bit R, G, and B values for
|
||||
each pixel, and dividing by the number of pixels multiplied by 3.
|
||||
|
||||
Arguments:
|
||||
frame: Frame representing the RGB pixels to average.
|
||||
|
||||
Returns:
|
||||
Average pixel intensity across all 3 channels of `frame`
|
||||
"""
|
||||
num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2])
|
||||
avg_pixel_value = numpy.sum(frame[:, :, :]) / num_pixel_values
|
||||
return avg_pixel_value
|
||||
|
||||
|
||||
##
|
||||
## ThresholdDetector Class Implementation
|
||||
##
|
||||
|
||||
|
||||
class ThresholdDetector(SceneDetector):
|
||||
"""Detects fast cuts/slow fades in from and out to a given threshold level.
|
||||
|
||||
Detects both fast cuts and slow fades so long as an appropriate threshold
|
||||
is chosen (especially taking into account the minimum grey/black level).
|
||||
"""
|
||||
|
||||
class Method(Enum):
|
||||
"""Method for ThresholdDetector to use when comparing frame brightness to the threshold."""
|
||||
FLOOR = 0
|
||||
"""Fade out happens when frame brightness falls below threshold."""
|
||||
CEILING = 1
|
||||
"""Fade out happens when frame brightness rises above threshold."""
|
||||
|
||||
THRESHOLD_VALUE_KEY = 'average_rgb'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
threshold: float = 12,
|
||||
min_scene_len: int = 15,
|
||||
fade_bias: float = 0.0,
|
||||
add_final_scene: bool = False,
|
||||
method: Method = Method.FLOOR,
|
||||
block_size=None,
|
||||
):
|
||||
"""
|
||||
Arguments:
|
||||
threshold: 8-bit intensity value that each pixel value (R, G, and B)
|
||||
must be <= to in order to trigger a fade in/out.
|
||||
min_scene_len: FrameTimecode object or integer greater than 0 of the
|
||||
minimum length, in frames, of a scene (or subsequent scene cut).
|
||||
fade_bias: Float between -1.0 and +1.0 representing the percentage of
|
||||
timecode skew for the start of a scene (-1.0 causing a cut at the
|
||||
fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be
|
||||
right at the position where the threshold is passed).
|
||||
add_final_scene: Boolean indicating if the video ends on a fade-out to
|
||||
generate an additional scene at this timecode.
|
||||
method: How to treat `threshold` when detecting fade events.
|
||||
block_size: [DEPRECATED] DO NOT USE. For backwards compatibility.
|
||||
"""
|
||||
# TODO(v0.7): Replace with DeprecationWarning that `block_size` will be removed in v0.8.
|
||||
if block_size is not None:
|
||||
logger.error('block_size is deprecated.')
|
||||
|
||||
super().__init__()
|
||||
self.threshold = int(threshold)
|
||||
self.method = ThresholdDetector.Method(method)
|
||||
self.fade_bias = fade_bias
|
||||
self.min_scene_len = min_scene_len
|
||||
self.processed_frame = False
|
||||
self.last_scene_cut = None
|
||||
# Whether to add an additional scene or not when ending on a fade out
|
||||
# (as cuts are only added on fade ins; see post_process() for details).
|
||||
self.add_final_scene = add_final_scene
|
||||
# Where the last fade (threshold crossing) was detected.
|
||||
self.last_fade = {
|
||||
'frame': 0, # frame number where the last detected fade is
|
||||
'type': None # type of fade, can be either 'in' or 'out'
|
||||
}
|
||||
self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY]
|
||||
|
||||
def get_metrics(self) -> List[str]:
|
||||
return self._metric_keys
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: Optional[numpy.ndarray]) -> List[int]:
|
||||
"""
|
||||
Args:
|
||||
frame_num (int): Frame number of frame that is being passed.
|
||||
frame_img (numpy.ndarray or None): Decoded frame image (numpy.ndarray) to perform
|
||||
scene detection with. Can be None *only* if the self.is_processing_required()
|
||||
method (inhereted from the base SceneDetector class) returns True.
|
||||
Returns:
|
||||
List[int]: List of frames where scene cuts have been detected. There may be 0
|
||||
or more frames in the list, and not necessarily the same as frame_num.
|
||||
"""
|
||||
|
||||
# Initialize last scene cut point at the beginning of the frames of interest.
|
||||
if self.last_scene_cut is None:
|
||||
self.last_scene_cut = frame_num
|
||||
|
||||
# Compare the # of pixels under threshold in current_frame & last_frame.
|
||||
# If absolute value of pixel intensity delta is above the threshold,
|
||||
# then we trigger a new scene cut/break.
|
||||
|
||||
# List of cuts to return.
|
||||
cut_list = []
|
||||
|
||||
# The metric used here to scene_detect scene breaks is the percent of pixels
|
||||
# less than or equal to the threshold; however, since this differs on
|
||||
# user-supplied values, we supply the average pixel intensity as this
|
||||
# frame metric instead (to assist with manually selecting a threshold)
|
||||
if (self.stats_manager is not None) and (self.stats_manager.metrics_exist(
|
||||
frame_num, self._metric_keys)):
|
||||
frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0]
|
||||
else:
|
||||
frame_avg = _compute_frame_average(frame_img)
|
||||
if self.stats_manager is not None:
|
||||
self.stats_manager.set_metrics(frame_num, {self._metric_keys[0]: frame_avg})
|
||||
|
||||
if self.processed_frame:
|
||||
if self.last_fade['type'] == 'in' and ((
|
||||
(self.method == ThresholdDetector.Method.FLOOR and frame_avg < self.threshold) or
|
||||
(self.method == ThresholdDetector.Method.CEILING and frame_avg >= self.threshold))):
|
||||
# Just faded out of a scene, wait for next fade in.
|
||||
self.last_fade['type'] = 'out'
|
||||
self.last_fade['frame'] = frame_num
|
||||
|
||||
elif self.last_fade['type'] == 'out' and (
|
||||
(self.method == ThresholdDetector.Method.FLOOR and frame_avg >= self.threshold) or
|
||||
(self.method == ThresholdDetector.Method.CEILING and frame_avg < self.threshold)):
|
||||
# Only add the scene if min_scene_len frames have passed.
|
||||
if (frame_num - self.last_scene_cut) >= self.min_scene_len:
|
||||
# Just faded into a new scene, compute timecode for the scene
|
||||
# split based on the fade bias.
|
||||
f_out = self.last_fade['frame']
|
||||
f_split = int(
|
||||
(frame_num + f_out + int(self.fade_bias * (frame_num - f_out))) / 2)
|
||||
cut_list.append(f_split)
|
||||
self.last_scene_cut = frame_num
|
||||
self.last_fade['type'] = 'in'
|
||||
self.last_fade['frame'] = frame_num
|
||||
else:
|
||||
self.last_fade['frame'] = 0
|
||||
if frame_avg < self.threshold:
|
||||
self.last_fade['type'] = 'out'
|
||||
else:
|
||||
self.last_fade['type'] = 'in'
|
||||
self.processed_frame = True
|
||||
return cut_list
|
||||
|
||||
def post_process(self, frame_num: int):
|
||||
"""Writes a final scene cut if the last detected fade was a fade-out.
|
||||
|
||||
Only writes the scene cut if add_final_scene is true, and the last fade
|
||||
that was detected was a fade-out. There is no bias applied to this cut
|
||||
(since there is no corresponding fade-in) so it will be located at the
|
||||
exact frame where the fade-out crossed the detection threshold.
|
||||
"""
|
||||
|
||||
# If the last fade detected was a fade out, we add a corresponding new
|
||||
# scene break to indicate the end of the scene. This is only done for
|
||||
# fade-outs, as a scene cut is already added when a fade-in is found.
|
||||
cut_times = []
|
||||
if self.last_fade['type'] == 'out' and self.add_final_scene and (
|
||||
(self.last_scene_cut is None and frame_num >= self.min_scene_len) or
|
||||
(frame_num - self.last_scene_cut) >= self.min_scene_len):
|
||||
cut_times.append(self.last_fade['frame'])
|
||||
return cut_times
|
||||
@@ -0,0 +1,462 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.frame_timecode`` Module
|
||||
|
||||
This module implements :class:`FrameTimecode` which is used as a way for PySceneDetect to store
|
||||
frame-accurate timestamps of each cut. This is done by also specifying the video framerate with the
|
||||
timecode, allowing a frame number to be converted to/from a floating-point number of seconds, or
|
||||
string in the form `"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional.
|
||||
|
||||
See the following examples, or the :class:`FrameTimecode constructor <FrameTimecode>`.
|
||||
|
||||
===============================================================
|
||||
Usage Examples
|
||||
===============================================================
|
||||
|
||||
A :class:`FrameTimecode` can be created by specifying a timecode (`int` for number of frames,
|
||||
`float` for number of seconds, or `str` in the form "HH:MM:SS" or "HH:MM:SS.nnn") with a framerate:
|
||||
|
||||
.. code:: python
|
||||
|
||||
frames = FrameTimecode(timecode = 29, fps = 29.97)
|
||||
seconds_float = FrameTimecode(timecode = 10.0, fps = 10.0)
|
||||
timecode_str = FrameTimecode(timecode = "00:00:10.000", fps = 10.0)
|
||||
|
||||
|
||||
Arithmetic/comparison operations with :class:`FrameTimecode` objects is also possible, and the
|
||||
other operand can also be of the above types:
|
||||
|
||||
.. code:: python
|
||||
|
||||
x = FrameTimecode(timecode = "00:01:00.000", fps = 10.0)
|
||||
# Can add int (frames), float (seconds), or str (timecode).
|
||||
print(x + 10)
|
||||
print(x + 10.0)
|
||||
print(x + "00:10:00")
|
||||
# Same for all comparison operators.
|
||||
print((x + 10.0) == "00:01:10.000")
|
||||
|
||||
|
||||
:class:`FrameTimecode` objects can be added and subtracted, however the current implementation
|
||||
disallows negative values, and will clamp negative results to 0.
|
||||
|
||||
.. warning::
|
||||
|
||||
Be careful when subtracting :class:`FrameTimecode` objects or adding negative
|
||||
amounts of frames/seconds. In the example below, ``c`` will be at frame 0 since
|
||||
``b > a``, but ``d`` will be at frame 5:
|
||||
|
||||
.. code:: python
|
||||
|
||||
a = FrameTimecode(5, 10.0)
|
||||
b = FrameTimecode(10, 10.0)
|
||||
c = a - b # b > a, so c == 0
|
||||
d = b - a
|
||||
assert(c == 0)
|
||||
assert(d == 5)
|
||||
|
||||
"""
|
||||
|
||||
import math
|
||||
from typing import Union
|
||||
|
||||
MAX_FPS_DELTA: float = 1.0 / 100000
|
||||
"""Maximum amount two framerates can differ by for equality testing."""
|
||||
|
||||
# TODO(0.6.3): Replace uses of Union[int, float, str] with TimecodeValue.
|
||||
TimecodeValue = Union[int, float, str]
|
||||
"""Named type for values representing timecodes. Must be in one of the following forms:
|
||||
|
||||
1. Timecode as `str` in the form 'HH:MM:SS[.nnn]' (`'01:23:45'` or `'01:23:45.678'`)
|
||||
2. Number of seconds as `float`, or `str` in form 'Ss' or 'S.SSSs' (`'2s'` or `'2.3456s'`)
|
||||
3. Exact number of frames as `int`, or `str` in form NNNNN (`123` or `'123'`)
|
||||
"""
|
||||
|
||||
|
||||
class FrameTimecode:
|
||||
"""Object for frame-based timecodes, using the video framerate to compute back and
|
||||
forth between frame number and seconds/timecode.
|
||||
|
||||
A timecode is valid only if it complies with one of the following three types/formats:
|
||||
|
||||
1. Timecode as `str` in the form 'HH:MM:SS[.nnn]' (`'01:23:45'` or `'01:23:45.678'`)
|
||||
2. Number of seconds as `float`, or `str` in form 'Ss' or 'S.SSSs' (`'2s'` or `'2.3456s'`)
|
||||
3. Exact number of frames as `int`, or `str` in form NNNNN (`123` or `'123'`)
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
timecode: Union[int, float, str, 'FrameTimecode'] = None,
|
||||
fps: Union[int, float, str, 'FrameTimecode'] = None):
|
||||
"""
|
||||
Arguments:
|
||||
timecode: A frame number (int), number of seconds (float), or timecode (str in
|
||||
the form `'HH:MM:SS'` or `'HH:MM:SS.nnn'`).
|
||||
fps: The framerate or FrameTimecode to use as a time base for all arithmetic.
|
||||
Raises:
|
||||
TypeError: Thrown if either `timecode` or `fps` are unsupported types.
|
||||
ValueError: Thrown when specifying a negative timecode or framerate.
|
||||
"""
|
||||
# The following two properties are what is used to keep track of time
|
||||
# in a frame-specific manner. Note that once the framerate is set,
|
||||
# the value should never be modified (only read if required).
|
||||
# TODO(v1.0): Make these actual @properties.
|
||||
self.framerate = None
|
||||
self.frame_num = None
|
||||
|
||||
# Copy constructor. Only the timecode argument is used in this case.
|
||||
if isinstance(timecode, FrameTimecode):
|
||||
self.framerate = timecode.framerate
|
||||
self.frame_num = timecode.frame_num
|
||||
if fps is not None:
|
||||
raise TypeError('Framerate cannot be overwritten when copying a FrameTimecode.')
|
||||
else:
|
||||
# Ensure other arguments are consistent with API.
|
||||
if fps is None:
|
||||
raise TypeError('Framerate (fps) is a required argument.')
|
||||
if isinstance(fps, FrameTimecode):
|
||||
fps = fps.framerate
|
||||
|
||||
# Process the given framerate, if it was not already set.
|
||||
if not isinstance(fps, (int, float)):
|
||||
raise TypeError('Framerate must be of type int/float.')
|
||||
if (isinstance(fps, int) and not fps > 0) or (isinstance(fps, float)
|
||||
and not fps >= MAX_FPS_DELTA):
|
||||
raise ValueError('Framerate must be positive and greater than zero.')
|
||||
self.framerate = float(fps)
|
||||
|
||||
# Process the timecode value, storing it as an exact number of frames.
|
||||
if isinstance(timecode, str):
|
||||
self.frame_num = self._parse_timecode_string(timecode)
|
||||
else:
|
||||
self.frame_num = self._parse_timecode_number(timecode)
|
||||
|
||||
# TODO(v1.0): Add a `frame` property to replace the existing one and deprecate this getter.
|
||||
def get_frames(self) -> int:
|
||||
"""Get the current time/position in number of frames. This is the
|
||||
equivalent of accessing the self.frame_num property (which, along
|
||||
with the specified framerate, forms the base for all of the other
|
||||
time measurement calculations, e.g. the :meth:`get_seconds` method).
|
||||
|
||||
If using to compare a :class:`FrameTimecode` with a frame number,
|
||||
you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``).
|
||||
|
||||
Returns:
|
||||
int: The current time in frames (the current frame number).
|
||||
"""
|
||||
return self.frame_num
|
||||
|
||||
# TODO(v1.0): Add a `framerate` property to replace the existing one and deprecate this getter.
|
||||
def get_framerate(self) -> float:
|
||||
"""Get Framerate: Returns the framerate used by the FrameTimecode object.
|
||||
|
||||
Returns:
|
||||
float: Framerate of the current FrameTimecode object, in frames per second.
|
||||
"""
|
||||
return self.framerate
|
||||
|
||||
def equal_framerate(self, fps) -> bool:
|
||||
"""Equal Framerate: Determines if the passed framerate is equal to that of this object.
|
||||
|
||||
Arguments:
|
||||
fps: Framerate to compare against within the precision constant defined in this module
|
||||
(see :data:`MAX_FPS_DELTA`).
|
||||
Returns:
|
||||
bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise.
|
||||
|
||||
"""
|
||||
return math.fabs(self.framerate - fps) < MAX_FPS_DELTA
|
||||
|
||||
# TODO(v1.0): Add a `seconds` property to replace this and deprecate the existing one.
|
||||
def get_seconds(self) -> float:
|
||||
"""Get the frame's position in number of seconds.
|
||||
|
||||
If using to compare a :class:`FrameTimecode` with a frame number,
|
||||
you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``).
|
||||
|
||||
Returns:
|
||||
float: The current time/position in seconds.
|
||||
"""
|
||||
return float(self.frame_num) / self.framerate
|
||||
|
||||
# TODO(v1.0): Add a `timecode` property to replace this and deprecate the existing one.
|
||||
def get_timecode(self, precision: int = 3, use_rounding: bool = True) -> str:
|
||||
"""Get a formatted timecode string of the form HH:MM:SS[.nnn].
|
||||
|
||||
Args:
|
||||
precision: The number of decimal places to include in the output ``[.nnn]``.
|
||||
use_rounding: Rounds the output to the desired precision. If False, the value
|
||||
will be truncated to the specified precision.
|
||||
|
||||
Returns:
|
||||
str: The current time in the form ``"HH:MM:SS[.nnn]"``.
|
||||
"""
|
||||
# Compute hours and minutes based off of seconds, and update seconds.
|
||||
secs = self.get_seconds()
|
||||
base = 60.0 * 60.0
|
||||
hrs = int(secs / base)
|
||||
secs -= (hrs * base)
|
||||
base = 60.0
|
||||
mins = int(secs / base)
|
||||
secs -= (mins * base)
|
||||
# Convert seconds into string based on required precision.
|
||||
if precision > 0:
|
||||
if use_rounding:
|
||||
secs = round(secs, precision)
|
||||
msec = format(secs, '.%df' % precision)[-precision:]
|
||||
secs = '%02d.%s' % (int(secs), msec)
|
||||
else:
|
||||
secs = '%02d' % int(round(secs, 0)) if use_rounding else '%02d' % int(secs)
|
||||
# Return hours, minutes, and seconds as a formatted timecode string.
|
||||
return '%02d:%02d:%s' % (hrs, mins, secs)
|
||||
|
||||
# TODO(v1.0): Add a `previous` property to replace the existing one and deprecate this getter.
|
||||
def previous_frame(self) -> 'FrameTimecode':
|
||||
"""Return a new FrameTimecode for the previous frame (or 0 if on frame 0)."""
|
||||
new_timecode = FrameTimecode(self)
|
||||
new_timecode.frame_num = max(0, new_timecode.frame_num - 1)
|
||||
return new_timecode
|
||||
|
||||
def _seconds_to_frames(self, seconds: float) -> int:
|
||||
"""Convert the passed value seconds to the nearest number of frames using
|
||||
the current FrameTimecode object's FPS (self.framerate).
|
||||
|
||||
Returns:
|
||||
Integer number of frames the passed number of seconds represents using
|
||||
the current FrameTimecode's framerate property.
|
||||
"""
|
||||
return round(seconds * self.framerate)
|
||||
|
||||
def _parse_timecode_number(self, timecode: Union[int, float]) -> int:
|
||||
""" Parse a timecode number, storing it as the exact number of frames.
|
||||
Can be passed as frame number (int), seconds (float)
|
||||
|
||||
Raises:
|
||||
TypeError, ValueError
|
||||
"""
|
||||
# Process the timecode value, storing it as an exact number of frames.
|
||||
# Exact number of frames N
|
||||
if isinstance(timecode, int):
|
||||
if timecode < 0:
|
||||
raise ValueError('Timecode frame number must be positive and greater than zero.')
|
||||
return timecode
|
||||
# Number of seconds S
|
||||
elif isinstance(timecode, float):
|
||||
if timecode < 0.0:
|
||||
raise ValueError('Timecode value must be positive and greater than zero.')
|
||||
return self._seconds_to_frames(timecode)
|
||||
# FrameTimecode
|
||||
elif isinstance(timecode, FrameTimecode):
|
||||
return timecode.frame_num
|
||||
elif timecode is None:
|
||||
raise TypeError('Timecode/frame number must be specified!')
|
||||
else:
|
||||
raise TypeError('Timecode format/type unrecognized.')
|
||||
|
||||
def _parse_timecode_string(self, timecode_string: str) -> int:
|
||||
"""Parses a string based on the three possible forms (in timecode format,
|
||||
as an integer number of frames, or floating-point seconds, ending with 's').
|
||||
|
||||
Requires that the `framerate` property is set before calling this method.
|
||||
Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00',
|
||||
'9000', '300s', and '300.0s' are all possible valid values, all representing
|
||||
a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS).
|
||||
|
||||
Raises:
|
||||
TypeError, ValueError
|
||||
"""
|
||||
if self.framerate is None:
|
||||
raise TypeError('self.framerate must be set before calling _parse_timecode_string.')
|
||||
# Number of seconds S
|
||||
if timecode_string.endswith('s'):
|
||||
secs = timecode_string[:-1]
|
||||
if not secs.replace('.', '').isdigit():
|
||||
raise ValueError('All characters in timecode seconds string must be digits.')
|
||||
secs = float(secs)
|
||||
if secs < 0.0:
|
||||
raise ValueError('Timecode seconds value must be positive.')
|
||||
return self._seconds_to_frames(secs)
|
||||
# Exact number of frames N
|
||||
elif timecode_string.isdigit():
|
||||
timecode = int(timecode_string)
|
||||
if timecode < 0:
|
||||
raise ValueError('Timecode frame number must be positive.')
|
||||
return timecode
|
||||
# Standard timecode in string format 'HH:MM:SS[.nnn]'
|
||||
else:
|
||||
tc_val = timecode_string.split(':')
|
||||
if not (len(tc_val) == 3 and tc_val[0].isdigit() and tc_val[1].isdigit()
|
||||
and tc_val[2].replace('.', '').isdigit()):
|
||||
raise ValueError('Unrecognized or improperly formatted timecode string.')
|
||||
hrs, mins = int(tc_val[0]), int(tc_val[1])
|
||||
secs = float(tc_val[2]) if '.' in tc_val[2] else int(tc_val[2])
|
||||
if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60):
|
||||
raise ValueError('Invalid timecode range (values outside allowed range).')
|
||||
secs += (((hrs * 60.0) + mins) * 60.0)
|
||||
return self._seconds_to_frames(secs)
|
||||
|
||||
def __iadd__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
|
||||
if isinstance(other, int):
|
||||
self.frame_num += other
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
self.frame_num += other.frame_num
|
||||
else:
|
||||
raise ValueError('FrameTimecode instances require equal framerate for addition.')
|
||||
# Check if value to add is in number of seconds.
|
||||
elif isinstance(other, float):
|
||||
self.frame_num += self._seconds_to_frames(other)
|
||||
elif isinstance(other, str):
|
||||
self.frame_num += self._parse_timecode_string(other)
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing addition with FrameTimecode.')
|
||||
if self.frame_num < 0: # Required to allow adding negative seconds/frames.
|
||||
self.frame_num = 0
|
||||
return self
|
||||
|
||||
def __add__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
|
||||
to_return = FrameTimecode(timecode=self)
|
||||
to_return += other
|
||||
return to_return
|
||||
|
||||
def __isub__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
|
||||
if isinstance(other, int):
|
||||
self.frame_num -= other
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
self.frame_num -= other.frame_num
|
||||
else:
|
||||
raise ValueError('FrameTimecode instances require equal framerate for subtraction.')
|
||||
# Check if value to add is in number of seconds.
|
||||
elif isinstance(other, float):
|
||||
self.frame_num -= self._seconds_to_frames(other)
|
||||
elif isinstance(other, str):
|
||||
self.frame_num -= self._parse_timecode_string(other)
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing subtraction with FrameTimecode: %s' %
|
||||
type(other))
|
||||
if self.frame_num < 0:
|
||||
self.frame_num = 0
|
||||
return self
|
||||
|
||||
def __sub__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
|
||||
to_return = FrameTimecode(timecode=self)
|
||||
to_return -= other
|
||||
return to_return
|
||||
|
||||
def __eq__(self, other: Union[int, float, str, 'FrameTimecode']) -> 'FrameTimecode':
|
||||
if isinstance(other, int):
|
||||
return self.frame_num == other
|
||||
elif isinstance(other, float):
|
||||
return self.get_seconds() == other
|
||||
elif isinstance(other, str):
|
||||
return self.frame_num == self._parse_timecode_string(other)
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
return self.frame_num == other.frame_num
|
||||
else:
|
||||
raise TypeError(
|
||||
'FrameTimecode objects must have the same framerate to be compared.')
|
||||
elif other is None:
|
||||
return False
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing == with FrameTimecode: %s' %
|
||||
type(other))
|
||||
|
||||
def __ne__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
|
||||
return not self == other
|
||||
|
||||
def __lt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
|
||||
if isinstance(other, int):
|
||||
return self.frame_num < other
|
||||
elif isinstance(other, float):
|
||||
return self.get_seconds() < other
|
||||
elif isinstance(other, str):
|
||||
return self.frame_num < self._parse_timecode_string(other)
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
return self.frame_num < other.frame_num
|
||||
else:
|
||||
raise TypeError(
|
||||
'FrameTimecode objects must have the same framerate to be compared.')
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing < with FrameTimecode: %s' %
|
||||
type(other))
|
||||
|
||||
def __le__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
|
||||
if isinstance(other, int):
|
||||
return self.frame_num <= other
|
||||
elif isinstance(other, float):
|
||||
return self.get_seconds() <= other
|
||||
elif isinstance(other, str):
|
||||
return self.frame_num <= self._parse_timecode_string(other)
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
return self.frame_num <= other.frame_num
|
||||
else:
|
||||
raise TypeError(
|
||||
'FrameTimecode objects must have the same framerate to be compared.')
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing <= with FrameTimecode: %s' %
|
||||
type(other))
|
||||
|
||||
def __gt__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
|
||||
if isinstance(other, int):
|
||||
return self.frame_num > other
|
||||
elif isinstance(other, float):
|
||||
return self.get_seconds() > other
|
||||
elif isinstance(other, str):
|
||||
return self.frame_num > self._parse_timecode_string(other)
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
return self.frame_num > other.frame_num
|
||||
else:
|
||||
raise TypeError(
|
||||
'FrameTimecode objects must have the same framerate to be compared.')
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing > with FrameTimecode: %s' %
|
||||
type(other))
|
||||
|
||||
def __ge__(self, other: Union[int, float, str, 'FrameTimecode']) -> bool:
|
||||
if isinstance(other, int):
|
||||
return self.frame_num >= other
|
||||
elif isinstance(other, float):
|
||||
return self.get_seconds() >= other
|
||||
elif isinstance(other, str):
|
||||
return self.frame_num >= self._parse_timecode_string(other)
|
||||
elif isinstance(other, FrameTimecode):
|
||||
if self.equal_framerate(other.framerate):
|
||||
return self.frame_num >= other.frame_num
|
||||
else:
|
||||
raise TypeError(
|
||||
'FrameTimecode objects must have the same framerate to be compared.')
|
||||
else:
|
||||
raise TypeError('Unsupported type for performing >= with FrameTimecode: %s' %
|
||||
type(other))
|
||||
|
||||
# TODO(v1.0): __int__ and __float__ should be removed. Mark as deprecated, and indicate
|
||||
# need to use relevant property instead.
|
||||
|
||||
def __int__(self) -> int:
|
||||
return self.frame_num
|
||||
|
||||
def __float__(self) -> float:
|
||||
return self.get_seconds()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.get_timecode()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return '%s [frame=%d, fps=%.3f]' % (self.get_timecode(), self.frame_num, self.framerate)
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return self.frame_num
|
||||
@@ -0,0 +1,357 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.platform`` Module
|
||||
|
||||
This moduke contains all platform/library specific compatibility fixes, as well as some utility
|
||||
functions to handle logging and invoking external commands.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
import os.path
|
||||
import platform
|
||||
import re
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import AnyStr, Dict, List, Optional, Union
|
||||
|
||||
import cv2
|
||||
|
||||
##
|
||||
## tqdm Library
|
||||
##
|
||||
|
||||
|
||||
class FakeTqdmObject:
|
||||
"""Provides a no-op tqdm-like object."""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def __init__(self, **kawrgs):
|
||||
"""No-op."""
|
||||
|
||||
def update(self, n=1):
|
||||
"""No-op."""
|
||||
|
||||
def close(self):
|
||||
"""No-op."""
|
||||
|
||||
def set_description(self, desc=None, refresh=True):
|
||||
"""No-op."""
|
||||
|
||||
# pylint: enable=unused-argument
|
||||
|
||||
|
||||
class FakeTqdmLoggingRedirect:
|
||||
"""Provides a no-op tqdm context manager for redirecting log messages."""
|
||||
|
||||
# pylint: disable=redefined-builtin,unused-argument
|
||||
def __init__(self, **kawrgs):
|
||||
"""No-op."""
|
||||
|
||||
def __enter__(self):
|
||||
"""No-op."""
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
"""No-op."""
|
||||
|
||||
# pylint: enable=redefined-builtin,unused-argument
|
||||
|
||||
|
||||
# Try to import tqdm and the logging redirect, otherwise provide fake implementations..
|
||||
try:
|
||||
# pylint: disable=unused-import
|
||||
from tqdm import tqdm
|
||||
from tqdm.contrib.logging import logging_redirect_tqdm
|
||||
# pylint: enable=unused-import
|
||||
except ModuleNotFoundError:
|
||||
# pylint: disable=invalid-name
|
||||
tqdm = FakeTqdmObject
|
||||
logging_redirect_tqdm = FakeTqdmLoggingRedirect
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
##
|
||||
## OpenCV imwrite Supported Image Types & Quality/Compression Parameters
|
||||
##
|
||||
|
||||
|
||||
# TODO: Move this into scene_manager.
|
||||
def get_cv2_imwrite_params() -> Dict[str, Union[int, None]]:
|
||||
""" Get OpenCV imwrite Params: Returns a dict of supported image formats and
|
||||
their associated quality/compression parameter index, or None if that format
|
||||
is not supported.
|
||||
|
||||
Returns:
|
||||
Dictionary of supported image formats/extensions ('jpg', 'png', etc...) mapped to the
|
||||
respective OpenCV quality or compression parameter as {'jpg': cv2.IMWRITE_JPEG_QUALITY,
|
||||
'png': cv2.IMWRITE_PNG_COMPRESSION, ...}. Parameter will be None if not found on the
|
||||
current system library (e.g. {'jpg': None}).
|
||||
"""
|
||||
|
||||
def _get_cv2_param(param_name: str) -> Union[int, None]:
|
||||
if param_name.startswith('CV_'):
|
||||
param_name = param_name[3:]
|
||||
try:
|
||||
return getattr(cv2, param_name)
|
||||
except AttributeError:
|
||||
return None
|
||||
|
||||
return {
|
||||
'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'),
|
||||
'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'),
|
||||
'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY')
|
||||
}
|
||||
|
||||
|
||||
##
|
||||
## File I/O
|
||||
##
|
||||
|
||||
|
||||
def get_file_name(file_path: AnyStr, include_extension=True) -> AnyStr:
|
||||
"""Return the file name that `file_path` refers to, optionally removing the extension.
|
||||
|
||||
If `include_extension` is False, the result will always be a str.
|
||||
|
||||
E.g. /tmp/foo.bar -> foo"""
|
||||
file_name = os.path.basename(file_path)
|
||||
if not include_extension:
|
||||
file_name = str(file_name)
|
||||
last_dot_pos = file_name.rfind('.')
|
||||
if last_dot_pos >= 0:
|
||||
file_name = file_name[:last_dot_pos]
|
||||
return file_name
|
||||
|
||||
|
||||
def get_and_create_path(file_path: AnyStr, output_directory: Optional[AnyStr] = None) -> AnyStr:
|
||||
""" Get & Create Path: Gets and returns the full/absolute path to file_path
|
||||
in the specified output_directory if set, creating any required directories
|
||||
along the way.
|
||||
|
||||
If file_path is already an absolute path, then output_directory is ignored.
|
||||
|
||||
Arguments:
|
||||
file_path: File name to get path for. If file_path is an absolute
|
||||
path (e.g. starts at a drive/root), no modification of the path
|
||||
is performed, only ensuring that all output directories are created.
|
||||
output_dir: An optional output directory to override the
|
||||
directory of file_path if it is relative to the working directory.
|
||||
|
||||
Returns:
|
||||
Full path to output file suitable for writing.
|
||||
|
||||
"""
|
||||
# If an output directory is defined and the file path is a relative path, open
|
||||
# the file handle in the output directory instead of the working directory.
|
||||
if output_directory is not None and not os.path.isabs(file_path):
|
||||
file_path = os.path.join(output_directory, file_path)
|
||||
# Now that file_path is an absolute path, let's make sure all the directories
|
||||
# exist for us to start writing files there.
|
||||
os.makedirs(os.path.split(os.path.abspath(file_path))[0], exist_ok=True)
|
||||
return file_path
|
||||
|
||||
|
||||
##
|
||||
## Logging
|
||||
##
|
||||
|
||||
|
||||
def init_logger(log_level: int = logging.INFO,
|
||||
show_stdout: bool = False,
|
||||
log_file: Optional[str] = None):
|
||||
"""Initializes logging for PySceneDetect. The logger instance used is named 'pyscenedetect'.
|
||||
By default the logger has no handlers to suppress output. All existing log handlers are replaced
|
||||
every time this function is invoked.
|
||||
|
||||
Arguments:
|
||||
log_level: Verbosity of log messages. Should be one of [logging.INFO, logging.DEBUG,
|
||||
logging.WARNING, logging.ERROR, logging.CRITICAL].
|
||||
show_stdout: If True, add handler to show log messages on stdout (default: False).
|
||||
log_file: If set, add handler to dump debug log messages to given file path.
|
||||
"""
|
||||
# Format of log messages depends on verbosity.
|
||||
INFO_TEMPLATE = '[PySceneDetect] %(message)s'
|
||||
DEBUG_TEMPLATE = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s'
|
||||
# Get the named logger and remove any existing handlers.
|
||||
logger_instance = logging.getLogger('pyscenedetect')
|
||||
logger_instance.handlers = []
|
||||
logger_instance.setLevel(log_level)
|
||||
# Add stdout handler if required.
|
||||
if show_stdout:
|
||||
handler = logging.StreamHandler(stream=sys.stdout)
|
||||
handler.setLevel(log_level)
|
||||
handler.setFormatter(
|
||||
logging.Formatter(fmt=DEBUG_TEMPLATE if log_level == logging.DEBUG else INFO_TEMPLATE))
|
||||
logger_instance.addHandler(handler)
|
||||
# Add debug log handler if required.
|
||||
if log_file:
|
||||
log_file = get_and_create_path(log_file)
|
||||
handler = logging.FileHandler(log_file)
|
||||
handler.setLevel(logging.DEBUG)
|
||||
handler.setFormatter(logging.Formatter(fmt=DEBUG_TEMPLATE))
|
||||
logger_instance.addHandler(handler)
|
||||
|
||||
|
||||
##
|
||||
## Running External Commands
|
||||
##
|
||||
|
||||
|
||||
class CommandTooLong(Exception):
|
||||
"""Raised if the length of a command line argument exceeds the limit allowed on Windows."""
|
||||
|
||||
|
||||
def invoke_command(args: List[str]) -> int:
|
||||
"""Same as calling Python's subprocess.call() method, but explicitly
|
||||
raises a different exception when the command length is too long.
|
||||
|
||||
See https://github.com/Breakthrough/PySceneDetect/issues/164 for details.
|
||||
|
||||
Arguments:
|
||||
args: List of strings to pass to subprocess.call().
|
||||
|
||||
Returns:
|
||||
Return code of command.
|
||||
|
||||
Raises:
|
||||
CommandTooLong: `args` exceeds built in command line length limit on Windows.
|
||||
"""
|
||||
try:
|
||||
return subprocess.call(args)
|
||||
except OSError as err:
|
||||
if os.name != 'nt':
|
||||
raise
|
||||
exception_string = str(err)
|
||||
# Error 206: The filename or extension is too long
|
||||
# Error 87: The parameter is incorrect
|
||||
to_match = ('206', '87')
|
||||
if any([x in exception_string for x in to_match]):
|
||||
raise CommandTooLong() from err
|
||||
raise
|
||||
|
||||
|
||||
def get_ffmpeg_path() -> Optional[str]:
|
||||
"""Get path to ffmpeg if available on the current system. First looks at PATH, then checks if
|
||||
one is available from the `imageio_ffmpeg` package. Returns None if ffmpeg couldn't be found.
|
||||
"""
|
||||
try:
|
||||
subprocess.call(['ffmpeg', '-v', 'quiet'])
|
||||
return 'ffmpeg'
|
||||
except OSError:
|
||||
pass
|
||||
# Failed to invoke ffmpeg from PATH, see if we have a copy from imageio_ffmpeg.
|
||||
try:
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from imageio_ffmpeg import get_ffmpeg_exe
|
||||
# pylint: enable=import-outside-toplevel
|
||||
subprocess.call([get_ffmpeg_exe(), '-v', 'quiet'])
|
||||
return get_ffmpeg_exe()
|
||||
# Gracefully handle case where imageio_ffmpeg is not available.
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
# Handle case where path might be wrong/non-existent.
|
||||
except OSError:
|
||||
pass
|
||||
# get_ffmpeg_exe may throw a RuntimeError if the executable is not available.
|
||||
except RuntimeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_ffmpeg_version() -> Optional[str]:
|
||||
"""Get ffmpeg version identifier, or None if ffmpeg is not found. Uses `get_ffmpeg_path()`."""
|
||||
ffmpeg_path = get_ffmpeg_path()
|
||||
if ffmpeg_path is None:
|
||||
return None
|
||||
# If get_ffmpeg_path() returns a value, the path it returns should be invocable.
|
||||
output = subprocess.check_output(args=[ffmpeg_path, '-version'], text=True)
|
||||
output_split = output.split()
|
||||
if len(output_split) >= 3 and output_split[1] == 'version':
|
||||
return output_split[2]
|
||||
# If parsing the version fails, return the entire first line of output.
|
||||
return output.splitlines()[0]
|
||||
|
||||
|
||||
def get_mkvmerge_version() -> Optional[str]:
|
||||
"""Get mkvmerge version identifier, or None if mkvmerge is not found in PATH."""
|
||||
tool_name = 'mkvmerge'
|
||||
try:
|
||||
output = subprocess.check_output(args=[tool_name, '--version'], text=True)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
output_split = output.split()
|
||||
if len(output_split) >= 1 and output_split[0] == tool_name:
|
||||
return ' '.join(output_split[1:])
|
||||
# If parsing the version fails, return the entire first line of output.
|
||||
return output.splitlines()[0]
|
||||
|
||||
|
||||
def get_system_version_info() -> str:
|
||||
"""Get the system's operating system, Python, packages, and external tool versions.
|
||||
Useful for debugging or filing bug reports.
|
||||
|
||||
Used for the `scenedetect version -a` command.
|
||||
"""
|
||||
output_template = '{:<12} {}'
|
||||
line_separator = '-' * 60
|
||||
not_found_str = 'Not Installed'
|
||||
out_lines = []
|
||||
|
||||
# System (Python, OS)
|
||||
out_lines += ['System Info', line_separator]
|
||||
out_lines += [
|
||||
output_template.format(name, version) for name, version in (
|
||||
('OS', '%s' % platform.platform()),
|
||||
('Python', '%d.%d.%d' % sys.version_info[0:3]),
|
||||
)
|
||||
]
|
||||
|
||||
# Third-Party Packages
|
||||
out_lines += ['', 'Packages', line_separator]
|
||||
third_party_packages = (
|
||||
'av',
|
||||
'click',
|
||||
'cv2',
|
||||
'moviepy',
|
||||
'numpy',
|
||||
'platformdirs',
|
||||
'scenedetect',
|
||||
'tqdm',
|
||||
)
|
||||
for module_name in third_party_packages:
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
out_lines.append(output_template.format(module_name, module.__version__))
|
||||
except ModuleNotFoundError:
|
||||
out_lines.append(output_template.format(module_name, not_found_str))
|
||||
|
||||
# External Tools
|
||||
out_lines += ['', 'Tools', line_separator]
|
||||
|
||||
tool_version_info = (
|
||||
('ffmpeg', get_ffmpeg_version()),
|
||||
('mkvmerge', get_mkvmerge_version()),
|
||||
)
|
||||
|
||||
for (tool_name, tool_version) in tool_version_info:
|
||||
out_lines.append(
|
||||
output_template.format(tool_name, tool_version if tool_version else not_found_str))
|
||||
|
||||
return '\n'.join(out_lines)
|
||||
|
||||
|
||||
class Template(string.Template):
|
||||
"""Template matcher used to replace instances of $TEMPLATES in filenames."""
|
||||
idpattern = '[A-Z0-9_]+'
|
||||
flags = re.ASCII
|
||||
@@ -0,0 +1,148 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.scene_detector`` Module
|
||||
|
||||
This module contains the :class:`SceneDetector` interface, from which all scene detectors in
|
||||
:mod:`scenedetect.detectors` module are derived from.
|
||||
|
||||
The SceneDetector class represents the interface which detection algorithms are expected to provide
|
||||
in order to be compatible with PySceneDetect.
|
||||
|
||||
.. warning::
|
||||
|
||||
This API is still unstable, and changes and design improvements are planned for the v1.0
|
||||
release. Instead of just timecodes, detection algorithms will also provide a specific type of
|
||||
event (in, out, cut, etc...).
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy
|
||||
|
||||
from backend.scenedetect.stats_manager import StatsManager
|
||||
|
||||
|
||||
# pylint: disable=unused-argument, no-self-use
|
||||
class SceneDetector:
|
||||
""" Base class to inherit from when implementing a scene detection algorithm.
|
||||
|
||||
This API is not yet stable and subject to change.
|
||||
|
||||
This represents a "dense" scene detector, which returns a list of frames where
|
||||
the next scene/shot begins in a video.
|
||||
|
||||
Also see the implemented scene detectors in the scenedetect.detectors module
|
||||
to get an idea of how a particular detector can be created.
|
||||
"""
|
||||
# TODO(v0.7): Make this a proper abstract base class.
|
||||
|
||||
stats_manager: Optional[StatsManager] = None
|
||||
"""Optional :class:`StatsManager <scenedetect.stats_manager.StatsManager>` to
|
||||
use for caching frame metrics to and from."""
|
||||
|
||||
# TODO(v1.0): Remove - this is a rarely used case for what is now a neglegible performance gain.
|
||||
def is_processing_required(self, frame_num: int) -> bool:
|
||||
"""[DEPRECATED] DO NOT USE
|
||||
|
||||
Test if all calculations for a given frame are already done.
|
||||
|
||||
Returns:
|
||||
False if the SceneDetector has assigned _metric_keys, and the
|
||||
stats_manager property is set to a valid StatsManager object containing
|
||||
the required frame metrics/calculations for the given frame - thus, not
|
||||
needing the frame to perform scene detection.
|
||||
|
||||
True otherwise (i.e. the frame_img passed to process_frame is required
|
||||
to be passed to process_frame for the given frame_num).
|
||||
"""
|
||||
metric_keys = self.get_metrics()
|
||||
return not metric_keys or not (self.stats_manager is not None
|
||||
and self.stats_manager.metrics_exist(frame_num, metric_keys))
|
||||
|
||||
def stats_manager_required(self) -> bool:
|
||||
"""Stats Manager Required: Prototype indicating if detector requires stats.
|
||||
|
||||
Returns:
|
||||
True if a StatsManager is required for the detector, False otherwise.
|
||||
"""
|
||||
return False
|
||||
|
||||
def get_metrics(self) -> List[str]:
|
||||
"""Get Metrics: Get a list of all metric names/keys used by the detector.
|
||||
|
||||
Returns:
|
||||
List of strings of frame metric key names that will be used by
|
||||
the detector when a StatsManager is passed to process_frame.
|
||||
"""
|
||||
return []
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: Optional[numpy.ndarray]) -> List[int]:
|
||||
"""Process Frame: Computes/stores metrics and detects any scene changes.
|
||||
|
||||
Prototype method, no actual detection.
|
||||
|
||||
Returns:
|
||||
List of frame numbers of cuts to be added to the cutting list.
|
||||
"""
|
||||
return []
|
||||
|
||||
def post_process(self, frame_num: int) -> List[int]:
|
||||
"""Post Process: Performs any processing after the last frame has been read.
|
||||
|
||||
Prototype method, no actual detection.
|
||||
|
||||
Returns:
|
||||
List of frame numbers of cuts to be added to the cutting list.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def event_buffer_length(self) -> int:
|
||||
"""The amount of frames a given event can be buffered for, in time. Represents maximum
|
||||
amount any event can be behind `frame_number` in the result of :meth:`process_frame`.
|
||||
"""
|
||||
return 0
|
||||
|
||||
|
||||
class SparseSceneDetector(SceneDetector):
|
||||
"""Base class to inherit from when implementing a sparse scene detection algorithm.
|
||||
|
||||
This class will be removed in v1.0 and should not be used.
|
||||
|
||||
Unlike dense detectors, sparse detectors scene_detect "events" and return a *pair* of frames,
|
||||
as opposed to just a single cut.
|
||||
|
||||
An example of a SparseSceneDetector is the MotionDetector.
|
||||
"""
|
||||
|
||||
def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[Tuple[int, int]]:
|
||||
"""Process Frame: Computes/stores metrics and detects any scene changes.
|
||||
|
||||
Prototype method, no actual detection.
|
||||
|
||||
Returns:
|
||||
List of frame pairs representing individual scenes
|
||||
to be added to the output scene list directly.
|
||||
"""
|
||||
return []
|
||||
|
||||
def post_process(self, frame_num: int) -> List[Tuple[int, int]]:
|
||||
"""Post Process: Performs any processing after the last frame has been read.
|
||||
|
||||
Prototype method, no actual detection.
|
||||
|
||||
Returns:
|
||||
List of frame pairs representing individual scenes
|
||||
to be added to the output scene list directly.
|
||||
"""
|
||||
return []
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,324 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.stats_manager`` Module
|
||||
|
||||
This module contains the :class:`StatsManager` class, which provides a key-value store for each
|
||||
:class:`SceneDetector <scenedetect.scene_detector.SceneDetector>` to write the metrics calculated
|
||||
for each frame. The :class:`StatsManager` must be registered to a
|
||||
:class:`SceneManager <scenedetect.scene_manager.SceneManager>` upon construction.
|
||||
|
||||
The entire :class:`StatsManager` can be :meth:`saved to <StatsManager.save_to_csv>` a
|
||||
human-readable CSV file, allowing for precise determination of the ideal threshold (or other
|
||||
detection parameters) for the given input.
|
||||
"""
|
||||
|
||||
import csv
|
||||
from logging import getLogger
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union
|
||||
import os.path
|
||||
|
||||
from backend.scenedetect.frame_timecode import FrameTimecode
|
||||
|
||||
logger = getLogger('pyscenedetect')
|
||||
|
||||
##
|
||||
## StatsManager CSV File Column Names (Header Row)
|
||||
##
|
||||
|
||||
COLUMN_NAME_FRAME_NUMBER = "Frame Number"
|
||||
"""Name of column containing frame numbers in the statsfile CSV."""
|
||||
|
||||
COLUMN_NAME_TIMECODE = "Timecode"
|
||||
"""Name of column containing timecodes in the statsfile CSV."""
|
||||
|
||||
##
|
||||
## StatsManager Exceptions
|
||||
##
|
||||
|
||||
|
||||
class FrameMetricRegistered(Exception):
|
||||
""" Raised when attempting to register a frame metric key which has
|
||||
already been registered. """
|
||||
|
||||
def __init__(self,
|
||||
metric_key: str,
|
||||
message: str = "Attempted to re-register frame metric key."):
|
||||
super().__init__(message)
|
||||
self.metric_key = metric_key
|
||||
|
||||
|
||||
class FrameMetricNotRegistered(Exception):
|
||||
""" Raised when attempting to call get_metrics(...)/set_metrics(...) with a
|
||||
frame metric that does not exist, or has not been registered. """
|
||||
|
||||
def __init__(self,
|
||||
metric_key: str,
|
||||
message: str = "Attempted to get/set frame metrics for unregistered metric key."):
|
||||
super().__init__(message)
|
||||
self.metric_key = metric_key
|
||||
|
||||
|
||||
class StatsFileCorrupt(Exception):
|
||||
"""Raised when frame metrics/stats could not be loaded from a provided CSV file."""
|
||||
|
||||
def __init__(self,
|
||||
message: str = "Could not load frame metric data data from passed CSV file."):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
##
|
||||
## StatsManager Class Implementation
|
||||
##
|
||||
|
||||
|
||||
# TODO(v1.0): Relax restriction on metric types only being float or int when loading from disk
|
||||
# is fully deprecated.
|
||||
class StatsManager:
|
||||
"""Provides a key-value store for frame metrics/calculations which can be used
|
||||
for two-pass detection algorithms, as well as saving stats to a CSV file.
|
||||
|
||||
Analyzing a statistics CSV file is also very useful for finding the optimal
|
||||
algorithm parameters for certain detection methods. Additionally, the data
|
||||
may be plotted by a graphing module (e.g. matplotlib) by obtaining the
|
||||
metric of interest for a series of frames by iteratively calling get_metrics(),
|
||||
after having called the detect_scenes(...) method on the SceneManager object
|
||||
which owns the given StatsManager instance.
|
||||
|
||||
Only metrics consisting of `float` or `int` should be used currently.
|
||||
"""
|
||||
|
||||
def __init__(self, base_timecode: FrameTimecode = None):
|
||||
"""Initialize a new StatsManager.
|
||||
|
||||
Arguments:
|
||||
base_timecode: Timecode associated with this object. Must not be None (default value
|
||||
will be removed in a future release).
|
||||
"""
|
||||
# Frame metrics is a dict of frame (int): metric_dict (Dict[str, float])
|
||||
# of each frame metric key and the value it represents (usually float).
|
||||
self._frame_metrics: Dict[FrameTimecode, Dict[str, float]] = dict()
|
||||
self._registered_metrics: Set[str] = set() # Set of frame metric keys.
|
||||
self._loaded_metrics: Set[str] = set() # Metric keys loaded from stats file.
|
||||
self._metrics_updated: bool = False # Flag indicating if metrics require saving.
|
||||
self._base_timecode: Optional[FrameTimecode] = base_timecode # Used for timing calculations.
|
||||
|
||||
def register_metrics(self, metric_keys: Iterable[str]) -> None:
|
||||
"""Register a list of metric keys that will be used by the detector.
|
||||
|
||||
Used to ensure that multiple detector keys don't overlap.
|
||||
|
||||
Raises:
|
||||
FrameMetricRegistered: A particular metric_key has already been registered/added
|
||||
to the StatsManager. Only if the StatsManager is being used for read-only
|
||||
access (i.e. all frames in the video have already been processed for the given
|
||||
metric_key in the exception) is this behavior desirable.
|
||||
"""
|
||||
for metric_key in metric_keys:
|
||||
if metric_key not in self._registered_metrics:
|
||||
self._registered_metrics.add(metric_key)
|
||||
else:
|
||||
raise FrameMetricRegistered(metric_key)
|
||||
|
||||
# TODO(v1.0): Change frame_number to a FrameTimecode now that it is just a hash and will
|
||||
# be required for VFR support.
|
||||
def get_metrics(self, frame_number: int, metric_keys: Iterable[str]) -> List[Any]:
|
||||
"""Return the requested statistics/metrics for a given frame.
|
||||
|
||||
Arguments:
|
||||
frame_number (int): Frame number to retrieve metrics for.
|
||||
metric_keys (List[str]): A list of metric keys to look up.
|
||||
|
||||
Returns:
|
||||
A list containing the requested frame metrics for the given frame number
|
||||
in the same order as the input list of metric keys. If a metric could
|
||||
not be found, None is returned for that particular metric.
|
||||
"""
|
||||
return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys]
|
||||
|
||||
def set_metrics(self, frame_number: int, metric_kv_dict: Dict[str, Any]) -> None:
|
||||
""" Set Metrics: Sets the provided statistics/metrics for a given frame.
|
||||
|
||||
Arguments:
|
||||
frame_number: Frame number to retrieve metrics for.
|
||||
metric_kv_dict: A dict mapping metric keys to the
|
||||
respective integer/floating-point metric values to set.
|
||||
"""
|
||||
for metric_key in metric_kv_dict:
|
||||
self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key])
|
||||
|
||||
def metrics_exist(self, frame_number: int, metric_keys: Iterable[str]) -> bool:
|
||||
""" Metrics Exist: Checks if the given metrics/stats exist for the given frame.
|
||||
|
||||
Returns:
|
||||
bool: True if the given metric keys exist for the frame, False otherwise.
|
||||
"""
|
||||
return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys])
|
||||
|
||||
def is_save_required(self) -> bool:
|
||||
""" Is Save Required: Checks if the stats have been updated since loading.
|
||||
|
||||
Returns:
|
||||
bool: True if there are frame metrics/statistics not yet written to disk,
|
||||
False otherwise.
|
||||
"""
|
||||
return self._metrics_updated
|
||||
|
||||
def save_to_csv(self,
|
||||
csv_file: Union[str, bytes, TextIO],
|
||||
base_timecode: Optional[FrameTimecode] = None,
|
||||
force_save=True) -> None:
|
||||
""" Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file.
|
||||
|
||||
Arguments:
|
||||
csv_file: A file handle opened in write mode (e.g. open('...', 'w')) or a path as str.
|
||||
base_timecode: [DEPRECATED] DO NOT USE. For backwards compatibility.
|
||||
force_save: If True, writes metrics out even if an update is not required.
|
||||
|
||||
Raises:
|
||||
OSError: If `path` cannot be opened or a write failure occurs.
|
||||
"""
|
||||
# TODO(v0.7): Replace with DeprecationWarning that `base_timecode` will be removed in v0.8.
|
||||
if base_timecode is not None:
|
||||
logger.error('base_timecode is deprecated.')
|
||||
|
||||
# Ensure we need to write to the file, and that we have data to do so with.
|
||||
if not ((self.is_save_required() or force_save) and self._registered_metrics
|
||||
and self._frame_metrics):
|
||||
logger.info("No metrics to save.")
|
||||
return
|
||||
|
||||
assert self._base_timecode is not None
|
||||
|
||||
# If we get a path instead of an open file handle, recursively call ourselves
|
||||
# again but with file handle instead of path.
|
||||
if isinstance(csv_file, (str, bytes)):
|
||||
with open(csv_file, 'w') as file:
|
||||
self.save_to_csv(csv_file=file, force_save=force_save)
|
||||
return
|
||||
|
||||
csv_writer = csv.writer(csv_file, lineterminator='\n')
|
||||
metric_keys = sorted(list(self._registered_metrics.union(self._loaded_metrics)))
|
||||
csv_writer.writerow([COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys)
|
||||
frame_keys = sorted(self._frame_metrics.keys())
|
||||
logger.info("Writing %d frames to CSV...", len(frame_keys))
|
||||
for frame_key in frame_keys:
|
||||
frame_timecode = self._base_timecode + frame_key
|
||||
csv_writer.writerow(
|
||||
[frame_timecode.get_frames() +
|
||||
1, frame_timecode.get_timecode()] +
|
||||
[str(metric) for metric in self.get_metrics(frame_key, metric_keys)])
|
||||
|
||||
@staticmethod
|
||||
def valid_header(row: List[str]) -> bool:
|
||||
"""Check that the given CSV row is a valid header for a statsfile.
|
||||
|
||||
Arguments:
|
||||
row: A row decoded from the CSV reader.
|
||||
|
||||
Returns:
|
||||
True if `row` is a valid statsfile header, False otherwise.
|
||||
"""
|
||||
if not row or not len(row) >= 2:
|
||||
return False
|
||||
if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE:
|
||||
return False
|
||||
return True
|
||||
|
||||
# TODO(v1.0): Remove.
|
||||
def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]:
|
||||
"""[DEPRECATED] DO NOT USE
|
||||
|
||||
Load all metrics stored in a CSV file into the StatsManager instance. Will be removed in a
|
||||
future release after becoming a no-op.
|
||||
|
||||
Arguments:
|
||||
csv_file: A file handle opened in read mode (e.g. open('...', 'r')) or a path as str.
|
||||
|
||||
Returns:
|
||||
int or None: Number of frames/rows read from the CSV file, or None if the
|
||||
input file was blank or could not be found.
|
||||
|
||||
Raises:
|
||||
StatsFileCorrupt: Stats file is corrupt and can't be loaded, or wrong file
|
||||
was specified.
|
||||
"""
|
||||
# TODO: Make this an error, then make load_from_csv() a no-op, and finally, remove it.
|
||||
logger.warning("load_from_csv() is deprecated and will be removed in a future release.")
|
||||
|
||||
# If we get a path instead of an open file handle, check that it exists, and if so,
|
||||
# recursively call ourselves again but with file set instead of path.
|
||||
if isinstance(csv_file, (str, bytes)):
|
||||
if os.path.exists(csv_file):
|
||||
with open(csv_file, 'r') as file:
|
||||
return self.load_from_csv(csv_file=file)
|
||||
# Path doesn't exist.
|
||||
return None
|
||||
|
||||
# If we get here, file is a valid file handle in read-only text mode.
|
||||
csv_reader = csv.reader(csv_file, lineterminator='\n')
|
||||
num_cols = None
|
||||
num_metrics = None
|
||||
num_frames = None
|
||||
# First Row: Frame Num, Timecode, [metrics...]
|
||||
try:
|
||||
row = next(csv_reader)
|
||||
# Backwards compatibility for previous versions of statsfile
|
||||
# which included an additional header row.
|
||||
if not self.valid_header(row):
|
||||
row = next(csv_reader)
|
||||
except StopIteration:
|
||||
# If the file is blank or we couldn't decode anything, assume the file was empty.
|
||||
return None
|
||||
if not self.valid_header(row):
|
||||
raise StatsFileCorrupt()
|
||||
num_cols = len(row)
|
||||
num_metrics = num_cols - 2
|
||||
if not num_metrics > 0:
|
||||
raise StatsFileCorrupt('No metrics defined in CSV file.')
|
||||
self._loaded_metrics = row[2:]
|
||||
num_frames = 0
|
||||
for row in csv_reader:
|
||||
metric_dict = {}
|
||||
if not len(row) == num_cols:
|
||||
raise StatsFileCorrupt('Wrong number of columns detected in stats file row.')
|
||||
for i, metric_str in enumerate(row[2:]):
|
||||
if metric_str and metric_str != 'None':
|
||||
try:
|
||||
metric_dict[self._loaded_metrics[i]] = float(metric_str)
|
||||
except ValueError:
|
||||
raise StatsFileCorrupt('Corrupted value in stats file: %s' %
|
||||
metric_str) from ValueError
|
||||
frame_number = int(row[0])
|
||||
# Switch from 1-based to 0-based frame numbers.
|
||||
if frame_number > 0:
|
||||
frame_number -= 1
|
||||
self.set_metrics(frame_number, metric_dict)
|
||||
num_frames += 1
|
||||
logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames)
|
||||
self._metrics_updated = False
|
||||
return num_frames
|
||||
|
||||
def _get_metric(self, frame_number: int, metric_key: str) -> Optional[Any]:
|
||||
if self._metric_exists(frame_number, metric_key):
|
||||
return self._frame_metrics[frame_number][metric_key]
|
||||
return None
|
||||
|
||||
def _set_metric(self, frame_number: int, metric_key: str, metric_value: Any) -> None:
|
||||
self._metrics_updated = True
|
||||
if not frame_number in self._frame_metrics:
|
||||
self._frame_metrics[frame_number] = dict()
|
||||
self._frame_metrics[frame_number][metric_key] = metric_value
|
||||
|
||||
def _metric_exists(self, frame_number: int, metric_key: str) -> bool:
|
||||
return (frame_number in self._frame_metrics
|
||||
and metric_key in self._frame_metrics[frame_number])
|
||||
@@ -0,0 +1,772 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.video_manager`` Module
|
||||
|
||||
[DEPRECATED] DO NOT USE. Use `open_video` from `scenedetect.backends` or create a
|
||||
VideoStreamCv2 object (`scenedetect.backends.opencv`) instead.
|
||||
|
||||
This module exists for *some* backwards compatibility with v0.5, and will be removed
|
||||
in a future release.
|
||||
"""
|
||||
|
||||
import os
|
||||
import math
|
||||
from logging import getLogger
|
||||
|
||||
from typing import Iterable, List, Optional, Tuple, Union
|
||||
from numpy import ndarray
|
||||
import cv2
|
||||
|
||||
from scenedetect.platform import get_file_name
|
||||
from scenedetect.frame_timecode import FrameTimecode, MAX_FPS_DELTA
|
||||
from scenedetect.video_stream import VideoStream, VideoOpenFailure, FrameRateUnavailable
|
||||
from scenedetect.backends.opencv import _get_aspect_ratio
|
||||
|
||||
##
|
||||
## VideoManager Exceptions
|
||||
##
|
||||
|
||||
|
||||
class VideoParameterMismatch(Exception):
|
||||
""" VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some
|
||||
of the video parameters (frame height, frame width, and framerate/FPS) do not match. """
|
||||
|
||||
def __init__(self,
|
||||
file_list=None,
|
||||
message="OpenCV VideoCapture object parameters do not match."):
|
||||
# type: (Iterable[Tuple[int, float, float, str, str]], str) -> None
|
||||
# Pass message string to base Exception class.
|
||||
super(VideoParameterMismatch, self).__init__(message)
|
||||
# list of (param_mismatch_type: int, parameter value, expected value,
|
||||
# filename: str, filepath: str)
|
||||
# where param_mismatch_type is an OpenCV CAP_PROP (e.g. CAP_PROP_FPS).
|
||||
self.file_list = file_list
|
||||
|
||||
|
||||
class VideoDecodingInProgress(RuntimeError):
|
||||
""" VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that
|
||||
must be called *before* start() has been called. """
|
||||
|
||||
|
||||
class InvalidDownscaleFactor(ValueError):
|
||||
""" InvalidDownscaleFactor: Raised when trying to set invalid downscale factor,
|
||||
i.e. the supplied downscale factor was not a positive integer greater than zero. """
|
||||
|
||||
|
||||
##
|
||||
## VideoManager Helper Functions
|
||||
##
|
||||
|
||||
|
||||
def get_video_name(video_file: str) -> Tuple[str, str]:
|
||||
"""Get the video file/device name.
|
||||
|
||||
Returns:
|
||||
Tuple of the form [name, video_file].
|
||||
"""
|
||||
if isinstance(video_file, int):
|
||||
return ('Device %d' % video_file, video_file)
|
||||
return (os.path.split(video_file)[1], video_file)
|
||||
|
||||
|
||||
def get_num_frames(cap_list: Iterable[cv2.VideoCapture]) -> int:
|
||||
""" Get Number of Frames: Returns total number of frames in the cap_list.
|
||||
|
||||
Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures.
|
||||
"""
|
||||
return sum([math.trunc(cap.get(cv2.CAP_PROP_FRAME_COUNT)) for cap in cap_list])
|
||||
|
||||
|
||||
def open_captures(
|
||||
video_files: Iterable[str],
|
||||
framerate: Optional[float] = None,
|
||||
validate_parameters: bool = True,
|
||||
) -> Tuple[List[cv2.VideoCapture], float, Tuple[int, int]]:
|
||||
""" Open Captures - helper function to open all capture objects, set the framerate,
|
||||
and ensure that all open captures have been opened and the framerates match on a list
|
||||
of video file paths, or a list containing a single device ID.
|
||||
|
||||
Arguments:
|
||||
video_files: List of one or more paths (str), or a list
|
||||
of a single integer device ID, to open as an OpenCV VideoCapture object.
|
||||
A ValueError will be raised if the list does not conform to the above.
|
||||
framerate: Framerate to assume when opening the video_files.
|
||||
If not set, the first open video is used for deducing the framerate of
|
||||
all videos in the sequence.
|
||||
validate_parameters (bool, optional): If true, will ensure that the frame sizes
|
||||
(width, height) and frame rate (FPS) of all passed videos is the same.
|
||||
A VideoParameterMismatch is raised if the framerates do not match.
|
||||
|
||||
Returns:
|
||||
A tuple of form (cap_list, framerate, framesize) where cap_list is a list of open
|
||||
OpenCV VideoCapture objects in the same order as the video_files list, framerate
|
||||
is a float of the video(s) framerate(s), and framesize is a tuple of (width, height)
|
||||
where width and height are integers representing the frame size in pixels.
|
||||
|
||||
Raises:
|
||||
ValueError: No video file(s) specified, or invalid/multiple device IDs specified.
|
||||
TypeError: `framerate` must be type `float`.
|
||||
IOError: Video file(s) not found.
|
||||
FrameRateUnavailable: Video framerate could not be obtained and `framerate`
|
||||
was not set manually.
|
||||
VideoParameterMismatch: All videos in `video_files` do not have equal parameters.
|
||||
Set `validate_parameters=False` to skip this check.
|
||||
VideoOpenFailure: Video(s) could not be opened.
|
||||
"""
|
||||
is_device = False
|
||||
if not video_files:
|
||||
raise ValueError("Expected at least 1 video file or device ID.")
|
||||
if isinstance(video_files[0], int):
|
||||
if len(video_files) > 1:
|
||||
raise ValueError("If device ID is specified, no video sources may be appended.")
|
||||
elif video_files[0] < 0:
|
||||
raise ValueError("Invalid/negative device ID specified.")
|
||||
is_device = True
|
||||
elif not all([isinstance(video_file, (str, bytes)) for video_file in video_files]):
|
||||
print(video_files)
|
||||
raise ValueError("Unexpected element type in video_files list (expected str(s)/int).")
|
||||
elif framerate is not None and not isinstance(framerate, float):
|
||||
raise TypeError("Expected type float for parameter framerate.")
|
||||
# Check if files exist if passed video file is not an image sequence
|
||||
# (checked with presence of % in filename) or not a URL (://).
|
||||
if not is_device and any([
|
||||
not os.path.exists(video_file)
|
||||
for video_file in video_files
|
||||
if not ('%' in video_file or '://' in video_file)
|
||||
]):
|
||||
raise IOError("Video file(s) not found.")
|
||||
cap_list = []
|
||||
|
||||
try:
|
||||
cap_list = [cv2.VideoCapture(video_file) for video_file in video_files]
|
||||
video_names = [get_video_name(video_file) for video_file in video_files]
|
||||
closed_caps = [video_names[i] for i, cap in enumerate(cap_list) if not cap.isOpened()]
|
||||
if closed_caps:
|
||||
raise VideoOpenFailure(str(closed_caps))
|
||||
|
||||
cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list]
|
||||
cap_framerate, check_framerate = validate_capture_framerate(video_names, cap_framerates,
|
||||
framerate)
|
||||
# Store frame sizes as integers (VideoCapture.get() returns float).
|
||||
cap_frame_sizes = [(math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) for cap in cap_list]
|
||||
cap_frame_size = cap_frame_sizes[0]
|
||||
|
||||
# If we need to validate the parameters, we check that the FPS and width/height
|
||||
# of all open captures is identical (or almost identical in the case of FPS).
|
||||
if validate_parameters:
|
||||
validate_capture_parameters(
|
||||
video_names=video_names,
|
||||
cap_frame_sizes=cap_frame_sizes,
|
||||
check_framerate=check_framerate,
|
||||
cap_framerates=cap_framerates)
|
||||
|
||||
except:
|
||||
for cap in cap_list:
|
||||
cap.release()
|
||||
raise
|
||||
|
||||
return (cap_list, cap_framerate, cap_frame_size)
|
||||
|
||||
|
||||
def validate_capture_framerate(
|
||||
video_names: Iterable[Tuple[str, str]],
|
||||
cap_framerates: List[float],
|
||||
framerate: Optional[float] = None,
|
||||
) -> Tuple[float, bool]:
|
||||
"""Ensure the passed capture framerates are valid and equal.
|
||||
|
||||
Raises:
|
||||
ValueError: Invalid framerate (must be positive non-zero value).
|
||||
TypeError: Framerate must be of type float.
|
||||
FrameRateUnavailable: Framerate for video could not be obtained,
|
||||
and `framerate` was not set.
|
||||
"""
|
||||
check_framerate = True
|
||||
cap_framerate = cap_framerates[0]
|
||||
if framerate is not None:
|
||||
if isinstance(framerate, float):
|
||||
if framerate < MAX_FPS_DELTA:
|
||||
raise ValueError("Invalid framerate (must be a positive non-zero value).")
|
||||
cap_framerate = framerate
|
||||
check_framerate = False
|
||||
else:
|
||||
raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__)
|
||||
else:
|
||||
unavailable_framerates = [(video_names[i][0], video_names[i][1])
|
||||
for i, fps in enumerate(cap_framerates)
|
||||
if fps < MAX_FPS_DELTA]
|
||||
if unavailable_framerates:
|
||||
raise FrameRateUnavailable()
|
||||
return (cap_framerate, check_framerate)
|
||||
|
||||
|
||||
def validate_capture_parameters(
|
||||
video_names: List[Tuple[str, str]],
|
||||
cap_frame_sizes: List[Tuple[int, int]],
|
||||
check_framerate: bool = False,
|
||||
cap_framerates: Optional[List[float]] = None,
|
||||
) -> None:
|
||||
""" Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally)
|
||||
framerates are equal. Raises VideoParameterMismatch if there is a mismatch.
|
||||
|
||||
Raises:
|
||||
VideoParameterMismatch
|
||||
"""
|
||||
bad_params = []
|
||||
max_framerate_delta = MAX_FPS_DELTA
|
||||
# Check heights/widths match.
|
||||
bad_params += [(cv2.CAP_PROP_FRAME_WIDTH, frame_size[0], cap_frame_sizes[0][0],
|
||||
video_names[i][0], video_names[i][1])
|
||||
for i, frame_size in enumerate(cap_frame_sizes)
|
||||
if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0]
|
||||
bad_params += [(cv2.CAP_PROP_FRAME_HEIGHT, frame_size[1], cap_frame_sizes[0][1],
|
||||
video_names[i][0], video_names[i][1])
|
||||
for i, frame_size in enumerate(cap_frame_sizes)
|
||||
if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0]
|
||||
# Check framerates if required.
|
||||
if check_framerate:
|
||||
bad_params += [(cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0],
|
||||
video_names[i][1])
|
||||
for i, fps in enumerate(cap_framerates)
|
||||
if math.fabs(fps - cap_framerates[0]) > max_framerate_delta]
|
||||
|
||||
if bad_params:
|
||||
raise VideoParameterMismatch(bad_params)
|
||||
|
||||
|
||||
##
|
||||
## VideoManager Class Implementation
|
||||
##
|
||||
|
||||
|
||||
class VideoManager(VideoStream):
|
||||
"""[DEPRECATED] DO NOT USE.
|
||||
|
||||
Provides a cv2.VideoCapture-like interface to a set of one or more video files,
|
||||
or a single device ID. Supports seeking and setting end time/duration."""
|
||||
|
||||
BACKEND_NAME = 'video_manager_do_not_use'
|
||||
|
||||
def __init__(self,
|
||||
video_files: List[str],
|
||||
framerate: Optional[float] = None,
|
||||
logger=getLogger('pyscenedetect')):
|
||||
"""[DEPRECATED] DO NOT USE.
|
||||
|
||||
Arguments:
|
||||
video_files (list of str(s)/int): A list of one or more paths (str), or a list
|
||||
of a single integer device ID, to open as an OpenCV VideoCapture object.
|
||||
framerate (float, optional): Framerate to assume when storing FrameTimecodes.
|
||||
If not set (i.e. is None), it will be deduced from the first open capture
|
||||
in video_files, else raises a FrameRateUnavailable exception.
|
||||
|
||||
Raises:
|
||||
ValueError: No video file(s) specified, or invalid/multiple device IDs specified.
|
||||
TypeError: `framerate` must be type `float`.
|
||||
IOError: Video file(s) not found.
|
||||
FrameRateUnavailable: Video framerate could not be obtained and `framerate`
|
||||
was not set manually.
|
||||
VideoParameterMismatch: All videos in `video_files` do not have equal parameters.
|
||||
Set `validate_parameters=False` to skip this check.
|
||||
VideoOpenFailure: Video(s) could not be opened.
|
||||
"""
|
||||
# TODO(v0.7): Add DeprecationWarning that this class will be removed in v0.8: 'VideoManager
|
||||
# will be removed in PySceneDetect v0.8. Use VideoStreamCv2 or VideoCaptureAdapter instead.'
|
||||
logger.error("VideoManager is deprecated and will be removed.")
|
||||
if not video_files:
|
||||
raise ValueError("At least one string/integer must be passed in the video_files list.")
|
||||
# Need to support video_files as a single str too for compatibility.
|
||||
if isinstance(video_files, str):
|
||||
video_files = [video_files]
|
||||
# These VideoCaptures are only open in this process.
|
||||
self._is_device = isinstance(video_files[0], int)
|
||||
self._cap_list, self._cap_framerate, self._cap_framesize = open_captures(
|
||||
video_files=video_files, framerate=framerate)
|
||||
self._path = video_files[0] if not self._is_device else video_files
|
||||
self._end_of_video = False
|
||||
self._start_time = self.get_base_timecode()
|
||||
self._end_time = None
|
||||
self._curr_time = self.get_base_timecode()
|
||||
self._last_frame = None
|
||||
self._curr_cap, self._curr_cap_idx = None, None
|
||||
self._video_file_paths = video_files
|
||||
self._logger = logger
|
||||
if self._logger is not None:
|
||||
self._logger.info('Loaded %d video%s, framerate: %.3f FPS, resolution: %d x %d',
|
||||
len(self._cap_list), 's' if len(self._cap_list) > 1 else '',
|
||||
self.get_framerate(), *self.get_framesize())
|
||||
self._started = False
|
||||
self._frame_length = self.get_base_timecode() + get_num_frames(self._cap_list)
|
||||
self._first_cap_len = self.get_base_timecode() + get_num_frames([self._cap_list[0]])
|
||||
self._aspect_ratio = _get_aspect_ratio(self._cap_list[0])
|
||||
|
||||
def set_downscale_factor(self, downscale_factor=None):
|
||||
"""No-op. Set downscale_factor in `SceneManager` instead."""
|
||||
_ = downscale_factor
|
||||
|
||||
def get_num_videos(self) -> int:
|
||||
"""Get the length of the internal capture list,
|
||||
representing the number of videos the VideoManager was constructed with.
|
||||
|
||||
Returns:
|
||||
int: Number of videos, equal to length of capture list.
|
||||
"""
|
||||
return len(self._cap_list)
|
||||
|
||||
def get_video_paths(self) -> List[str]:
|
||||
"""Get list of strings containing paths to the open video(s).
|
||||
|
||||
Returns:
|
||||
List[str]: List of paths to the video files opened by the VideoManager.
|
||||
"""
|
||||
return list(self._video_file_paths)
|
||||
|
||||
def get_video_name(self) -> str:
|
||||
"""Get name of the video based on the first video path.
|
||||
|
||||
Returns:
|
||||
The base name of the video file, without extension.
|
||||
"""
|
||||
video_paths = self.get_video_paths()
|
||||
if not video_paths:
|
||||
return ''
|
||||
video_name = os.path.basename(video_paths[0])
|
||||
if video_name.rfind('.') >= 0:
|
||||
video_name = video_name[:video_name.rfind('.')]
|
||||
return video_name
|
||||
|
||||
def get_framerate(self) -> float:
|
||||
"""Get the framerate the VideoManager is assuming for all
|
||||
open VideoCaptures. Obtained from either the capture itself, or the passed
|
||||
framerate parameter when the VideoManager object was constructed.
|
||||
|
||||
Returns:
|
||||
Framerate, in frames/sec.
|
||||
"""
|
||||
return self._cap_framerate
|
||||
|
||||
def get_base_timecode(self) -> FrameTimecode:
|
||||
"""Get a FrameTimecode object at frame 0 / time 00:00:00.
|
||||
|
||||
The timecode returned by this method can be used to perform arithmetic (e.g.
|
||||
addition), passing the resulting values back to the VideoManager (e.g. for the
|
||||
:meth:`set_duration()` method), as the framerate of the returned FrameTimecode
|
||||
object matches that of the VideoManager.
|
||||
|
||||
As such, this method is equivalent to creating a FrameTimecode at frame 0 with
|
||||
the VideoManager framerate, for example, given a VideoManager called obj,
|
||||
the following expression will evaluate as True:
|
||||
|
||||
obj.get_base_timecode() == FrameTimecode(0, obj.get_framerate())
|
||||
|
||||
Furthermore, the base timecode object returned by a particular VideoManager
|
||||
should not be passed to another one, unless you first verify that their
|
||||
framerates are the same.
|
||||
|
||||
Returns:
|
||||
FrameTimecode at frame 0/time 00:00:00 with the video(s) framerate.
|
||||
"""
|
||||
return FrameTimecode(timecode=0, fps=self._cap_framerate)
|
||||
|
||||
def get_current_timecode(self) -> FrameTimecode:
|
||||
""" Get Current Timecode - returns a FrameTimecode object at current VideoManager position.
|
||||
|
||||
Returns:
|
||||
Timecode at the current VideoManager position.
|
||||
"""
|
||||
return self._curr_time
|
||||
|
||||
def get_framesize(self) -> Tuple[int, int]:
|
||||
"""Get frame size of the video(s) open in the VideoManager's capture objects.
|
||||
|
||||
Returns:
|
||||
Video frame size, in pixels, in the form (width, height).
|
||||
"""
|
||||
return self._cap_framesize
|
||||
|
||||
def get_framesize_effective(self) -> Tuple[int, int]:
|
||||
""" Get Frame Size - returns the frame size of the video(s) open in the
|
||||
VideoManager's capture objects.
|
||||
|
||||
Returns:
|
||||
Video frame size, in pixels, in the form (width, height).
|
||||
"""
|
||||
return self._cap_framesize
|
||||
|
||||
def set_duration(self,
|
||||
duration: Optional[FrameTimecode] = None,
|
||||
start_time: Optional[FrameTimecode] = None,
|
||||
end_time: Optional[FrameTimecode] = None) -> None:
|
||||
""" Set Duration - sets the duration/length of the video(s) to decode, as well as
|
||||
the start/end times. Must be called before :meth:`start()` is called, otherwise
|
||||
a VideoDecodingInProgress exception will be thrown. May be called after
|
||||
:meth:`reset()` as well.
|
||||
|
||||
Arguments:
|
||||
duration (Optional[FrameTimecode]): The (maximum) duration in time to
|
||||
decode from the opened video(s). Mutually exclusive with end_time
|
||||
(i.e. if duration is set, end_time must be None).
|
||||
start_time (Optional[FrameTimecode]): The time/first frame at which to
|
||||
start decoding frames from. If set, the input video(s) will be
|
||||
seeked to when start() is called, at which point the frame at
|
||||
start_time can be obtained by calling retrieve().
|
||||
end_time (Optional[FrameTimecode]): The time at which to stop decoding
|
||||
frames from the opened video(s). Mutually exclusive with duration
|
||||
(i.e. if end_time is set, duration must be None).
|
||||
|
||||
Raises:
|
||||
VideoDecodingInProgress: Must call before start().
|
||||
"""
|
||||
if self._started:
|
||||
raise VideoDecodingInProgress()
|
||||
|
||||
# Ensure any passed timecodes have the proper framerate.
|
||||
if ((duration is not None and not duration.equal_framerate(self._cap_framerate))
|
||||
or (start_time is not None and not start_time.equal_framerate(self._cap_framerate))
|
||||
or (end_time is not None and not end_time.equal_framerate(self._cap_framerate))):
|
||||
raise ValueError("FrameTimecode framerate does not match.")
|
||||
|
||||
if duration is not None and end_time is not None:
|
||||
raise TypeError("Only one of duration and end_time may be specified, not both.")
|
||||
|
||||
if start_time is not None:
|
||||
self._start_time = start_time
|
||||
|
||||
if end_time is not None:
|
||||
if end_time < self._start_time:
|
||||
raise ValueError("end_time is before start_time in time.")
|
||||
self._end_time = end_time
|
||||
elif duration is not None:
|
||||
self._end_time = self._start_time + duration
|
||||
|
||||
if self._end_time is not None:
|
||||
self._frame_length = min(self._frame_length, self._end_time + 1)
|
||||
self._frame_length -= self._start_time
|
||||
|
||||
if self._logger is not None:
|
||||
self._logger.info('Duration set, start: %s, duration: %s, end: %s.',
|
||||
start_time.get_timecode() if start_time is not None else start_time,
|
||||
duration.get_timecode() if duration is not None else duration,
|
||||
end_time.get_timecode() if end_time is not None else end_time)
|
||||
|
||||
def get_duration(self) -> FrameTimecode:
|
||||
""" Get Duration - gets the duration/length of the video(s) to decode,
|
||||
as well as the start/end times.
|
||||
|
||||
If the end time was not set by :meth:`set_duration()`, the end timecode
|
||||
is calculated as the start timecode + total duration.
|
||||
|
||||
Returns:
|
||||
Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s)
|
||||
total duration, start timecode, and end timecode.
|
||||
"""
|
||||
end_time = self._end_time
|
||||
if end_time is None:
|
||||
end_time = self.get_base_timecode() + self._frame_length
|
||||
return (self._frame_length, self._start_time, end_time)
|
||||
|
||||
def start(self) -> None:
|
||||
""" Start - starts video decoding and seeks to start time. Raises
|
||||
exception VideoDecodingInProgress if the method is called after the
|
||||
decoder process has already been started.
|
||||
|
||||
Raises:
|
||||
VideoDecodingInProgress: Must call :meth:`stop()` before this
|
||||
method if :meth:`start()` has already been called after
|
||||
initial construction.
|
||||
"""
|
||||
if self._started:
|
||||
raise VideoDecodingInProgress()
|
||||
|
||||
self._started = True
|
||||
self._get_next_cap()
|
||||
if self._start_time != 0:
|
||||
self.seek(self._start_time)
|
||||
|
||||
# This overrides the seek method from the VideoStream interface, but the name was changed
|
||||
# from `timecode` to `target`. For compatibility, we allow calling seek with the form
|
||||
# seek(0), seek(timecode=0), and seek(target=0). Specifying both arguments is an error.
|
||||
# pylint: disable=arguments-differ
|
||||
def seek(self, timecode: FrameTimecode = None, target: FrameTimecode = None) -> bool:
|
||||
"""Seek forwards to the passed timecode.
|
||||
|
||||
Only supports seeking forwards (i.e. timecode must be greater than the
|
||||
current position). Can only be used after the :meth:`start()`
|
||||
method has been called.
|
||||
|
||||
Arguments:
|
||||
timecode: Time in video to seek forwards to. Only one of timecode or target can be set.
|
||||
target: Same as timecode. Only one of timecode or target can be set.
|
||||
|
||||
Returns:
|
||||
bool: True if seeking succeeded, False if no more frames / end of video.
|
||||
|
||||
Raises:
|
||||
ValueError: Either none or both `timecode` and `target` were set.
|
||||
"""
|
||||
if timecode is None and target is None:
|
||||
raise ValueError('`target` must be set.')
|
||||
if timecode is not None and target is not None:
|
||||
raise ValueError('Only one of `timecode` or `target` can be set.')
|
||||
if target is not None:
|
||||
timecode = target
|
||||
assert timecode is not None
|
||||
if timecode < 0:
|
||||
raise ValueError("Target seek position cannot be negative!")
|
||||
|
||||
if not self._started:
|
||||
self.start()
|
||||
|
||||
timecode = self.base_timecode + timecode
|
||||
if self._end_time is not None and timecode > self._end_time:
|
||||
timecode = self._end_time
|
||||
|
||||
# TODO: Seeking only works for the first (or current) video in the VideoManager.
|
||||
# Warn the user there are multiple videos in the VideoManager, and the requested
|
||||
# seek time exceeds the length of the first video.
|
||||
if len(self._cap_list) > 1 and timecode > self._first_cap_len:
|
||||
# TODO: This should throw an exception instead of potentially failing silently
|
||||
# if no logger was provided.
|
||||
if self._logger is not None:
|
||||
self._logger.error('Seeking past the first input video is not currently supported.')
|
||||
self._logger.warning('Seeking to end of first input.')
|
||||
timecode = self._first_cap_len
|
||||
if self._curr_cap is not None and self._end_of_video is not True:
|
||||
self._curr_cap.set(cv2.CAP_PROP_POS_FRAMES, timecode.get_frames() - 1)
|
||||
self._curr_time = timecode - 1
|
||||
|
||||
while self._curr_time < timecode:
|
||||
if not self.grab():
|
||||
return False
|
||||
return True
|
||||
|
||||
# pylint: enable=arguments-differ
|
||||
|
||||
def release(self) -> None:
|
||||
""" Release (cv2.VideoCapture method), releases all open capture(s). """
|
||||
for cap in self._cap_list:
|
||||
cap.release()
|
||||
self._cap_list = []
|
||||
self._started = False
|
||||
|
||||
def reset(self) -> None:
|
||||
""" Reset - Reopens captures passed to the constructor of the VideoManager.
|
||||
|
||||
Can only be called after the :meth:`release()` method has been called.
|
||||
|
||||
Raises:
|
||||
VideoDecodingInProgress: Must call :meth:`release()` before this method.
|
||||
"""
|
||||
if self._started:
|
||||
self.release()
|
||||
|
||||
self._started = False
|
||||
self._end_of_video = False
|
||||
self._curr_time = self.get_base_timecode()
|
||||
self._cap_list, self._cap_framerate, self._cap_framesize = open_captures(
|
||||
video_files=self._video_file_paths, framerate=self._curr_time.get_framerate())
|
||||
self._curr_cap, self._curr_cap_idx = None, None
|
||||
|
||||
def get(self, capture_prop: int, index: Optional[int] = None) -> Union[float, int]:
|
||||
""" Get (cv2.VideoCapture method) - obtains capture properties from the current
|
||||
VideoCapture object in use. Index represents the same index as the original
|
||||
video_files list passed to the constructor. Getting/setting the position (POS)
|
||||
properties has no effect; seeking is implemented using VideoDecoder methods.
|
||||
|
||||
Note that getting the property CAP_PROP_FRAME_COUNT will return the integer sum of
|
||||
the frame count for all VideoCapture objects if index is not specified (or is None),
|
||||
otherwise the frame count for the given VideoCapture index is returned instead.
|
||||
|
||||
Arguments:
|
||||
capture_prop: OpenCV VideoCapture property to get (i.e. CAP_PROP_FPS).
|
||||
index (int, optional): Index in file_list of capture to get property from (default
|
||||
is zero). Index is not checked and will raise exception if out of bounds.
|
||||
|
||||
Returns:
|
||||
float: Return value from calling get(property) on the VideoCapture object.
|
||||
"""
|
||||
if capture_prop == cv2.CAP_PROP_FRAME_COUNT and index is None:
|
||||
return self._frame_length.get_frames()
|
||||
elif capture_prop == cv2.CAP_PROP_POS_FRAMES:
|
||||
return self._curr_time
|
||||
elif capture_prop == cv2.CAP_PROP_FPS:
|
||||
return self._cap_framerate
|
||||
elif index is None:
|
||||
index = 0
|
||||
return self._cap_list[index].get(capture_prop)
|
||||
|
||||
def grab(self) -> bool:
|
||||
""" Grab (cv2.VideoCapture method) - retrieves a frame but does not return it.
|
||||
|
||||
Returns:
|
||||
bool: True if a frame was grabbed, False otherwise.
|
||||
"""
|
||||
if not self._started:
|
||||
self.start()
|
||||
|
||||
grabbed = False
|
||||
if self._curr_cap is not None and not self._end_of_video:
|
||||
while not grabbed:
|
||||
grabbed = self._curr_cap.grab()
|
||||
if not grabbed and not self._get_next_cap():
|
||||
break
|
||||
if self._end_time is not None and self._curr_time > self._end_time:
|
||||
grabbed = False
|
||||
self._last_frame = None
|
||||
if grabbed:
|
||||
self._curr_time += 1
|
||||
else:
|
||||
self._correct_frame_length()
|
||||
return grabbed
|
||||
|
||||
def retrieve(self) -> Tuple[bool, Optional[ndarray]]:
|
||||
""" Retrieve (cv2.VideoCapture method) - retrieves and returns a frame.
|
||||
|
||||
Frame returned corresponds to last call to :meth:`grab()`.
|
||||
|
||||
Returns:
|
||||
Tuple of (True, frame_image) if a frame was grabbed during the last call to grab(),
|
||||
and where frame_image is a numpy ndarray of the decoded frame. Otherwise (False, None).
|
||||
"""
|
||||
if not self._started:
|
||||
self.start()
|
||||
|
||||
retrieved = False
|
||||
if self._curr_cap is not None and not self._end_of_video:
|
||||
while not retrieved:
|
||||
retrieved, self._last_frame = self._curr_cap.retrieve()
|
||||
if not retrieved and not self._get_next_cap():
|
||||
break
|
||||
if self._end_time is not None and self._curr_time > self._end_time:
|
||||
retrieved = False
|
||||
self._last_frame = None
|
||||
return (retrieved, self._last_frame)
|
||||
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
""" Return next frame (or current if advance = False), or False if end of video.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will remain on the current frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, returns either the decoded frame, or False if end of video.
|
||||
If decode = False, a boolean indicating if the next frame was advanced to or not is
|
||||
returned.
|
||||
"""
|
||||
if not self._started:
|
||||
self.start()
|
||||
has_grabbed = False
|
||||
if advance:
|
||||
has_grabbed = self.grab()
|
||||
if decode:
|
||||
retrieved, frame = self.retrieve()
|
||||
return frame if retrieved else False
|
||||
return has_grabbed
|
||||
|
||||
def _get_next_cap(self) -> bool:
|
||||
self._curr_cap = None
|
||||
if self._curr_cap_idx is None:
|
||||
self._curr_cap_idx = 0
|
||||
self._curr_cap = self._cap_list[0]
|
||||
return True
|
||||
else:
|
||||
if not (self._curr_cap_idx + 1) < len(self._cap_list):
|
||||
self._end_of_video = True
|
||||
return False
|
||||
self._curr_cap_idx += 1
|
||||
self._curr_cap = self._cap_list[self._curr_cap_idx]
|
||||
return True
|
||||
|
||||
def _correct_frame_length(self) -> None:
|
||||
""" Checks if the current frame position exceeds that originally calculated,
|
||||
and adjusts the internally calculated frame length accordingly. Called after
|
||||
exhausting all input frames from the video source(s).
|
||||
"""
|
||||
self._end_time = self._curr_time
|
||||
self._frame_length = self._curr_time - self._start_time
|
||||
|
||||
# VideoStream Interface (Some Covered Above)
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Display/pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
return self._aspect_ratio
|
||||
|
||||
@property
|
||||
def duration(self) -> Optional[FrameTimecode]:
|
||||
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
|
||||
return self.get_duration()[0]
|
||||
|
||||
@property
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode.
|
||||
|
||||
This can be interpreted as presentation time stamp of the last frame which was
|
||||
decoded by calling `read` with advance=True.
|
||||
|
||||
This method will always return 0 (e.g. be equal to `base_timecode`) if no frames
|
||||
have been `read`."""
|
||||
frames = self._curr_time.get_frames()
|
||||
if frames < 1:
|
||||
return self.base_timecode
|
||||
return self.base_timecode + (frames - 1)
|
||||
|
||||
@property
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in milliseconds.
|
||||
The first frame has a time of 0.0 ms.
|
||||
|
||||
This method will always return 0.0 if no frames have been `read`."""
|
||||
return self.position.get_seconds() * 1000.0
|
||||
|
||||
@property
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream in frames as an int.
|
||||
|
||||
1 indicates the first frame was just decoded by the last call to `read` with advance=True,
|
||||
whereas 0 indicates that no frames have been `read`.
|
||||
|
||||
This method will always return 0 if no frames have been `read`."""
|
||||
return self._curr_time.get_frames()
|
||||
|
||||
@property
|
||||
def frame_rate(self) -> float:
|
||||
"""Framerate in frames/sec."""
|
||||
return self._cap_framerate
|
||||
|
||||
@property
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Size of each video frame in pixels as a tuple of (width, height)."""
|
||||
return (math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_WIDTH)),
|
||||
math.trunc(self._cap_list[0].get(cv2.CAP_PROP_FRAME_HEIGHT)))
|
||||
|
||||
@property
|
||||
def is_seekable(self) -> bool:
|
||||
"""Just returns True."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def path(self) -> Union[bytes, str]:
|
||||
"""Video or device path."""
|
||||
if self._is_device:
|
||||
return "Device %d" % self._path
|
||||
return self._path
|
||||
|
||||
@property
|
||||
def name(self) -> Union[bytes, str]:
|
||||
"""Name of the video, without extension, or device."""
|
||||
if self._is_device:
|
||||
return self.path
|
||||
return get_file_name(self.path, include_extension=False)
|
||||
@@ -0,0 +1,296 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
# This software may also invoke mkvmerge or FFmpeg, if available.
|
||||
# FFmpeg is a trademark of Fabrice Bellard.
|
||||
# mkvmerge is Copyright (C) 2005-2016, Matroska.
|
||||
# Certain distributions of PySceneDetect may include the above software;
|
||||
# see the included LICENSE-FFMPEG and LICENSE-MKVMERGE files.
|
||||
#
|
||||
"""``scenedetect.video_splitter`` Module
|
||||
|
||||
The `scenedetect.video_splitter` module contains functions to split existing videos into clips
|
||||
using ffmpeg or mkvmerge.
|
||||
|
||||
These programs can be obtained from following URLs (note that mkvmerge is a part mkvtoolnix):
|
||||
|
||||
* FFmpeg: [ https://ffmpeg.org/download.html ]
|
||||
* mkvmerge: [ https://mkvtoolnix.download/downloads.html ]
|
||||
|
||||
If you are a Linux user, you can likely obtain the above programs from your package manager.
|
||||
|
||||
Once installed, ensure the program can be accessed system-wide by calling the `mkvmerge` or `ffmpeg`
|
||||
command from a terminal/command prompt. PySceneDetect will automatically use whichever program is
|
||||
available on the computer, depending on the specified command-line options.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import math
|
||||
import time
|
||||
from typing import Iterable, Optional, Tuple
|
||||
|
||||
from scenedetect.platform import (tqdm, invoke_command, CommandTooLong, get_file_name,
|
||||
get_ffmpeg_path, Template)
|
||||
from scenedetect.frame_timecode import FrameTimecode
|
||||
|
||||
logger = logging.getLogger('pyscenedetect')
|
||||
|
||||
TimecodePair = Tuple[FrameTimecode, FrameTimecode]
|
||||
"""Named type for pairs of timecodes, which typically represents the start/end of a scene."""
|
||||
|
||||
COMMAND_TOO_LONG_STRING = """
|
||||
Cannot split video due to too many scenes (resulting command
|
||||
is too large to process). To work around this issue, you can
|
||||
split the video manually by exporting a list of cuts with the
|
||||
`list-scenes` command.
|
||||
See https://github.com/Breakthrough/PySceneDetect/issues/164
|
||||
for details. Sorry about that!
|
||||
"""
|
||||
|
||||
FFMPEG_PATH: Optional[str] = get_ffmpeg_path()
|
||||
"""Relative path to the Ffmpeg binary on this system, if any (will be None if not available)."""
|
||||
|
||||
DEFAULT_FFMPEG_ARGS = '-map 0 -c:v libx264 -preset veryfast -crf 22 -c:a aac'
|
||||
"""Default arguments passed to ffmpeg when invoking the `split_video_ffmpeg` function."""
|
||||
|
||||
##
|
||||
## Command Availability Checking Functions
|
||||
##
|
||||
|
||||
|
||||
def is_mkvmerge_available() -> bool:
|
||||
""" Is mkvmerge Available: Gracefully checks if mkvmerge command is available.
|
||||
|
||||
Returns:
|
||||
True if `mkvmerge` can be invoked, False otherwise.
|
||||
"""
|
||||
ret_val = None
|
||||
try:
|
||||
ret_val = subprocess.call(['mkvmerge', '--quiet'])
|
||||
except OSError:
|
||||
return False
|
||||
if ret_val is not None and ret_val != 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_ffmpeg_available() -> bool:
|
||||
""" Is ffmpeg Available: Gracefully checks if ffmpeg command is available.
|
||||
|
||||
Returns:
|
||||
True if `ffmpeg` can be invoked, False otherwise.
|
||||
"""
|
||||
return FFMPEG_PATH is not None
|
||||
|
||||
|
||||
##
|
||||
## Split Video Functions
|
||||
##
|
||||
|
||||
|
||||
def split_video_mkvmerge(
|
||||
input_video_path: str,
|
||||
scene_list: Iterable[TimecodePair],
|
||||
output_file_template: str = '$VIDEO_NAME.mkv',
|
||||
video_name: Optional[str] = None,
|
||||
show_output: bool = False,
|
||||
suppress_output=None,
|
||||
):
|
||||
""" Calls the mkvmerge command on the input video, splitting it at the
|
||||
passed timecodes, where each scene is written in sequence from 001.
|
||||
|
||||
Arguments:
|
||||
input_video_path: Path to the video to be split.
|
||||
scene_list : List of scenes as pairs of FrameTimecodes denoting the start/end times.
|
||||
output_file_template: Template to use for output files. Mkvmerge always adds the suffix
|
||||
"-$SCENE_NUMBER". Can use $VIDEO_NAME as a template parameter (e.g. "$VIDEO_NAME.mkv").
|
||||
video_name (str): Name of the video to be substituted in output_file_template for
|
||||
$VIDEO_NAME. If not specified, will be obtained from the filename.
|
||||
show_output: If False, adds the --quiet flag when invoking `mkvmerge`..
|
||||
suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only.
|
||||
Returns:
|
||||
Return code of invoking mkvmerge (0 on success). If scene_list is empty, will
|
||||
still return 0, but no commands will be invoked.
|
||||
"""
|
||||
# Handle backwards compatibility with v0.5 API.
|
||||
if isinstance(input_video_path, list):
|
||||
logger.error('Using a list of paths is deprecated. Pass a single path instead.')
|
||||
if len(input_video_path) > 1:
|
||||
raise ValueError('Concatenating multiple input videos is not supported.')
|
||||
input_video_path = input_video_path[0]
|
||||
if suppress_output is not None:
|
||||
logger.error('suppress_output is deprecated, use show_output instead.')
|
||||
show_output = not suppress_output
|
||||
|
||||
if not scene_list:
|
||||
return 0
|
||||
|
||||
logger.info('Splitting input video using mkvmerge, output path template:\n %s',
|
||||
output_file_template)
|
||||
|
||||
if video_name is None:
|
||||
video_name = get_file_name(input_video_path, include_extension=False)
|
||||
|
||||
ret_val = 0
|
||||
# mkvmerge automatically appends '-$SCENE_NUMBER', so we remove it if present.
|
||||
output_file_template = output_file_template.replace('-$SCENE_NUMBER',
|
||||
'').replace('$SCENE_NUMBER', '')
|
||||
output_file_name = Template(output_file_template).safe_substitute(VIDEO_NAME=video_name)
|
||||
|
||||
try:
|
||||
call_list = ['mkvmerge']
|
||||
if not show_output:
|
||||
call_list.append('--quiet')
|
||||
call_list += [
|
||||
'-o', output_file_name, '--split',
|
||||
'parts:%s' % ','.join([
|
||||
'%s-%s' % (start_time.get_timecode(), end_time.get_timecode())
|
||||
for start_time, end_time in scene_list
|
||||
]), input_video_path
|
||||
]
|
||||
total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames()
|
||||
processing_start_time = time.time()
|
||||
# TODO(v0.6.2): Capture stdout/stderr and show that if the command fails.
|
||||
ret_val = invoke_command(call_list)
|
||||
if show_output:
|
||||
logger.info('Average processing speed %.2f frames/sec.',
|
||||
float(total_frames) / (time.time() - processing_start_time))
|
||||
except CommandTooLong:
|
||||
logger.error(COMMAND_TOO_LONG_STRING)
|
||||
except OSError:
|
||||
logger.error('mkvmerge could not be found on the system.'
|
||||
' Please install mkvmerge to enable video output support.')
|
||||
if ret_val != 0:
|
||||
logger.error('Error splitting video (mkvmerge returned %d).', ret_val)
|
||||
return ret_val
|
||||
|
||||
|
||||
def split_video_ffmpeg(
|
||||
input_video_path: str,
|
||||
scene_list: Iterable[TimecodePair],
|
||||
output_file_template: str = '$VIDEO_NAME-Scene-$SCENE_NUMBER.mp4',
|
||||
video_name: Optional[str] = None,
|
||||
arg_override: str = DEFAULT_FFMPEG_ARGS,
|
||||
show_progress: bool = False,
|
||||
show_output: bool = False,
|
||||
suppress_output=None,
|
||||
hide_progress=None,
|
||||
):
|
||||
""" Calls the ffmpeg command on the input video, generating a new video for
|
||||
each scene based on the start/end timecodes.
|
||||
|
||||
Arguments:
|
||||
input_video_path: Path to the video to be split.
|
||||
scene_list (List[Tuple[FrameTimecode, FrameTimecode]]): List of scenes
|
||||
(pairs of FrameTimecodes) denoting the start/end frames of each scene.
|
||||
output_file_template (str): Template to use for generating the output filenames.
|
||||
Can use $VIDEO_NAME and $SCENE_NUMBER in this format, for example:
|
||||
`$VIDEO_NAME - Scene $SCENE_NUMBER.mp4`
|
||||
video_name (str): Name of the video to be substituted in output_file_template. If not
|
||||
passed will be calculated from input_video_path automatically.
|
||||
arg_override (str): Allows overriding the arguments passed to ffmpeg for encoding.
|
||||
show_progress (bool): If True, will show progress bar provided by tqdm (if installed).
|
||||
show_output (bool): If True, will show output from ffmpeg for first split.
|
||||
suppress_output: [DEPRECATED] DO NOT USE. For backwards compatibility only.
|
||||
hide_progress: [DEPRECATED] DO NOT USE. For backwards compatibility only.
|
||||
|
||||
Returns:
|
||||
Return code of invoking ffmpeg (0 on success). If scene_list is empty, will
|
||||
still return 0, but no commands will be invoked.
|
||||
"""
|
||||
# Handle backwards compatibility with v0.5 API.
|
||||
if isinstance(input_video_path, list):
|
||||
logger.error('Using a list of paths is deprecated. Pass a single path instead.')
|
||||
if len(input_video_path) > 1:
|
||||
raise ValueError('Concatenating multiple input videos is not supported.')
|
||||
input_video_path = input_video_path[0]
|
||||
if suppress_output is not None:
|
||||
logger.error('suppress_output is deprecated, use show_output instead.')
|
||||
show_output = not suppress_output
|
||||
if hide_progress is not None:
|
||||
logger.error('hide_progress is deprecated, use show_progress instead.')
|
||||
show_progress = not hide_progress
|
||||
|
||||
if not scene_list:
|
||||
return 0
|
||||
|
||||
logger.info('Splitting input video using ffmpeg, output path template:\n %s',
|
||||
output_file_template)
|
||||
|
||||
if video_name is None:
|
||||
video_name = get_file_name(input_video_path, include_extension=False)
|
||||
|
||||
arg_override = arg_override.replace('\\"', '"')
|
||||
|
||||
ret_val = 0
|
||||
arg_override = arg_override.split(' ')
|
||||
scene_num_format = '%0'
|
||||
scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + 'd'
|
||||
|
||||
try:
|
||||
progress_bar = None
|
||||
total_frames = scene_list[-1][1].get_frames() - scene_list[0][0].get_frames()
|
||||
if show_progress:
|
||||
progress_bar = tqdm(total=total_frames, unit='frame', miniters=1, dynamic_ncols=True)
|
||||
processing_start_time = time.time()
|
||||
for i, (start_time, end_time) in enumerate(scene_list):
|
||||
duration = (end_time - start_time)
|
||||
# Format output filename with template variable
|
||||
output_file_template_iter = Template(output_file_template).safe_substitute(
|
||||
VIDEO_NAME=video_name,
|
||||
SCENE_NUMBER=scene_num_format % (i + 1),
|
||||
START_TIME=str(start_time.get_timecode().replace(":", ";")),
|
||||
END_TIME=str(end_time.get_timecode().replace(":", ";")),
|
||||
START_FRAME=str(start_time.get_frames()),
|
||||
END_FRAME=str(end_time.get_frames()))
|
||||
|
||||
# Gracefully handle case where FFMPEG_PATH might be unset.
|
||||
call_list = [FFMPEG_PATH if FFMPEG_PATH is not None else 'ffmpeg']
|
||||
if not show_output:
|
||||
call_list += ['-v', 'quiet']
|
||||
elif i > 0:
|
||||
# Only show ffmpeg output for the first call, which will display any
|
||||
# errors if it fails, and then break the loop. We only show error messages
|
||||
# for the remaining calls.
|
||||
call_list += ['-v', 'error']
|
||||
call_list += [
|
||||
'-nostdin', '-y', '-ss',
|
||||
str(start_time.get_seconds()), '-i', input_video_path, '-t',
|
||||
str(duration.get_seconds())
|
||||
]
|
||||
call_list += arg_override
|
||||
call_list += ['-sn']
|
||||
call_list += [output_file_template_iter]
|
||||
ret_val = invoke_command(call_list)
|
||||
if show_output and i == 0 and len(scene_list) > 1:
|
||||
logger.info(
|
||||
'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...')
|
||||
if ret_val != 0:
|
||||
# TODO(v0.6.2): Capture stdout/stderr and display it on any failed calls.
|
||||
logger.error('Error splitting video (ffmpeg returned %d).', ret_val)
|
||||
break
|
||||
if progress_bar:
|
||||
progress_bar.update(duration.get_frames())
|
||||
|
||||
if progress_bar:
|
||||
progress_bar.close()
|
||||
if show_output:
|
||||
logger.info('Average processing speed %.2f frames/sec.',
|
||||
float(total_frames) / (time.time() - processing_start_time))
|
||||
|
||||
except CommandTooLong:
|
||||
logger.error(COMMAND_TOO_LONG_STRING)
|
||||
except OSError:
|
||||
logger.error('ffmpeg could not be found on the system.'
|
||||
' Please install ffmpeg to enable video output support.')
|
||||
return ret_val
|
||||
@@ -0,0 +1,222 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# PySceneDetect: Python-Based Video Scene Detector
|
||||
# -------------------------------------------------------------------
|
||||
# [ Site: https://scenedetect.com ]
|
||||
# [ Docs: https://scenedetect.com/docs/ ]
|
||||
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
|
||||
#
|
||||
# Copyright (C) 2014-2023 Brandon Castellano <http://www.bcastell.com>.
|
||||
# PySceneDetect is licensed under the BSD 3-Clause License; see the
|
||||
# included LICENSE file, or visit one of the above pages for details.
|
||||
#
|
||||
"""``scenedetect.video_stream`` Module
|
||||
|
||||
This module contains the :class:`VideoStream` class, which provides a library agnostic
|
||||
interface for video input. To open a video by path, use :func:`scenedetect.open_video`:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from scenedetect import open_video
|
||||
video = open_video('video.mp4')
|
||||
while True:
|
||||
frame = video.read()
|
||||
if frame is False:
|
||||
break
|
||||
print("Read %d frames" % video.frame_number)
|
||||
|
||||
You can also optionally specify a framerate and a specific backend library to use. Unless specified,
|
||||
OpenCV will be used as the video backend. See :mod:`scenedetect.backends` for a detailed example.
|
||||
|
||||
New :class:`VideoStream <scenedetect.video_stream.VideoStream>` implementations can be
|
||||
tested by adding it to the test suite in `tests/test_video_stream.py`.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from logging import getLogger
|
||||
from typing import Tuple, Optional, Union
|
||||
|
||||
from numpy import ndarray
|
||||
|
||||
from backend.scenedetect.frame_timecode import FrameTimecode
|
||||
|
||||
##
|
||||
## VideoStream Exceptions
|
||||
##
|
||||
|
||||
|
||||
class SeekError(Exception):
|
||||
"""Either an unrecoverable error happened while attempting to seek, or the underlying
|
||||
stream is not seekable (additional information will be provided when possible).
|
||||
|
||||
The stream is guaranteed to be left in a valid state, but the position may be reset."""
|
||||
|
||||
|
||||
class VideoOpenFailure(Exception):
|
||||
"""Raised by a backend if opening a video fails."""
|
||||
|
||||
# pylint: disable=useless-super-delegation
|
||||
def __init__(self, message: str = "Unknown backend error."):
|
||||
"""
|
||||
Arguments:
|
||||
message: Additional context the backend can provide for the open failure.
|
||||
"""
|
||||
super().__init__(message)
|
||||
|
||||
# pylint: enable=useless-super-delegation
|
||||
|
||||
|
||||
class FrameRateUnavailable(VideoOpenFailure):
|
||||
"""Exception instance to provide consistent error messaging across backends when the video frame
|
||||
rate is unavailable or cannot be calculated. Subclass of VideoOpenFailure."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__('Unable to obtain video framerate! Specify `framerate` manually, or'
|
||||
' re-encode/re-mux the video and try again.')
|
||||
|
||||
|
||||
##
|
||||
## VideoStream Interface (Base Class)
|
||||
##
|
||||
|
||||
|
||||
class VideoStream(ABC):
|
||||
""" Interface which all video backends must implement. """
|
||||
|
||||
#
|
||||
# Default Implementations
|
||||
#
|
||||
|
||||
@property
|
||||
def base_timecode(self) -> FrameTimecode:
|
||||
"""FrameTimecode object to use as a time base."""
|
||||
return FrameTimecode(timecode=0, fps=self.frame_rate)
|
||||
|
||||
#
|
||||
# Abstract Static Methods
|
||||
#
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def BACKEND_NAME() -> str:
|
||||
"""Unique name used to identify this backend. Should be a static property in derived
|
||||
classes (`BACKEND_NAME = 'backend_identifier'`)."""
|
||||
raise NotImplementedError
|
||||
|
||||
#
|
||||
# Abstract Properties
|
||||
#
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def path(self) -> Union[bytes, str]:
|
||||
"""Video or device path."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> Union[bytes, str]:
|
||||
"""Name of the video, without extension, or device."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_seekable(self) -> bool:
|
||||
"""True if seek() is allowed, False otherwise."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def frame_rate(self) -> float:
|
||||
"""Frame rate in frames/sec."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def duration(self) -> Optional[FrameTimecode]:
|
||||
"""Duration of the stream as a FrameTimecode, or None if non terminating."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def frame_size(self) -> Tuple[int, int]:
|
||||
"""Size of each video frame in pixels as a tuple of (width, height)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def aspect_ratio(self) -> float:
|
||||
"""Pixel aspect ratio as a float (1.0 represents square pixels)."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def position(self) -> FrameTimecode:
|
||||
"""Current position within stream as FrameTimecode.
|
||||
|
||||
This can be interpreted as presentation time stamp, thus frame 1 corresponds
|
||||
to the presentation time 0. Returns 0 even if `frame_number` is 1."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def position_ms(self) -> float:
|
||||
"""Current position within stream as a float of the presentation time in
|
||||
milliseconds. The first frame has a PTS of 0."""
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def frame_number(self) -> int:
|
||||
"""Current position within stream as the frame number.
|
||||
|
||||
Will return 0 until the first frame is `read`."""
|
||||
raise NotImplementedError
|
||||
|
||||
#
|
||||
# Abstract Methods
|
||||
#
|
||||
|
||||
@abstractmethod
|
||||
def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool]:
|
||||
"""Read and decode the next frame as a numpy.ndarray. Returns False when video ends.
|
||||
|
||||
Arguments:
|
||||
decode: Decode and return the frame.
|
||||
advance: Seek to the next frame. If False, will return the current (last) frame.
|
||||
|
||||
Returns:
|
||||
If decode = True, the decoded frame (numpy.ndarray), or False (bool) if end of video.
|
||||
If decode = False, a bool indicating if advancing to the the next frame succeeded.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def reset(self) -> None:
|
||||
""" Close and re-open the VideoStream (equivalent to seeking back to beginning). """
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def seek(self, target: Union[FrameTimecode, float, int]) -> None:
|
||||
"""Seek to the given timecode. If given as a frame number, represents the current seek
|
||||
pointer (e.g. if seeking to 0, the next frame decoded will be the first frame of the video).
|
||||
|
||||
For 1-based indices (first frame is frame #1), the target frame number needs to be converted
|
||||
to 0-based by subtracting one. For example, if we want to seek to the first frame, we call
|
||||
seek(0) followed by read(). If we want to seek to the 5th frame, we call seek(4) followed
|
||||
by read(), at which point frame_number will be 5.
|
||||
|
||||
May not be supported on all backend types or inputs (e.g. cameras).
|
||||
|
||||
Arguments:
|
||||
target: Target position in video stream to seek to.
|
||||
If float, interpreted as time in seconds.
|
||||
If int, interpreted as frame number.
|
||||
Raises:
|
||||
SeekError: An error occurs while seeking, or seeking is not supported.
|
||||
ValueError: `target` is not a valid value (i.e. it is negative).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
# TODO(0.6.3): Add a StreamJoiner class to concatenate multiple videos using a specified backend.
|
||||
Reference in New Issue
Block a user