新增视频场景检测

This commit is contained in:
YaoFANGUK
2023-12-12 17:06:05 +08:00
parent 1cb7705660
commit 6d741cd9e4
27 changed files with 9139 additions and 0 deletions
+19
View File
@@ -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):
"""判断两个区域是否相似。"""
+158
View File
@@ -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)
+61
View File
@@ -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
+548
View File
@@ -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]))
+820
View File
@@ -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)
+273
View File
@@ -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.')
+107
View File
@@ -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
View File
@@ -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
View File
@@ -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)]
+114
View File
@@ -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])
"""
+224
View File
@@ -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
+539
View File
@@ -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
+355
View File
@@ -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
+81
View File
@@ -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
+462
View File
@@ -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
+357
View File
@@ -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
+148
View File
@@ -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
+324
View File
@@ -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])
+772
View File
@@ -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)
+296
View File
@@ -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
+222
View File
@@ -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.