diff --git a/.travis.yml b/.travis.yml index 96f7b140..44b52a02 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,5 +9,6 @@ install: - "pip install -r requirements.txt" - "git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources" - "git checkout refs/remotes/origin/resources -- tests/testvideo.mp4" + - "git checkout refs/remotes/origin/resources -- tests/goldeneye/goldeneye.mp4" script: - python -m pytest tests/ diff --git a/README.md b/README.md index dca90697..55a5f064 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Video Scene Cut Detection and Analysis Tool [![Build Status](https://img.shields.io/travis/com/Breakthrough/PySceneDetect)](https://travis-ci.com/github/Breakthrough/PySceneDetect) [![PyPI Status](https://img.shields.io/pypi/status/scenedetect.svg)](https://pypi.python.org/pypi/scenedetect/) [![PyPI Version](https://img.shields.io/pypi/v/scenedetect?color=blue)](https://pypi.python.org/pypi/scenedetect/) [![PyPI License](https://img.shields.io/pypi/l/scenedetect.svg)](http://pyscenedetect.readthedocs.org/en/latest/copyright/) -### Latest Release: v0.5.5 (January 17, 2021) +### Latest Release: v0.5.6 (August 15, 2021) **Main Webpage**: [py.scenedetect.com](http://py.scenedetect.com) diff --git a/docs/changelog.md b/docs/changelog.md index 999d3912..342f8493 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,7 +4,37 @@ PySceneDetect Releases ## PySceneDetect 0.5 -### 0.5.5 (January 17, 2021)   +### 0.5.6 (August 15, 2021)   + +#### Release Notes + + * **New detection algorithm**: `detect-adaptive` which works similar to `detect-content`, but with reduced false negatives during fast camera movement (thanks @scarwire and @wjs018) + * Images generated by `save-images` can now be resized via the command line + * Statsfiles now work properly with `detect-threshold` + * Removed the `-p`/`--min-percent` option from `detect-threshold` + * Add new option `-l`/`--luma-only` to `detect-content`/`detect-adaptive` to only consider brightness channel (useful for greyscale videos) + +#### Changelog + + * [feature] New adaptive content detector algorithm `detect-adaptive` ([#153](https://github.com/Breakthrough/PySceneDetect/issues/153)thanks @scarwire and @wjs018) + * [feature] Images generated with the `save-images` command (`scene_manager.save_images()` function in the Python API) can now be scaled or resized ([#160](https://github.com/Breakthrough/PySceneDetect/issues/160) and [PR #203](https://github.com/Breakthrough/PySceneDetect/pull/203), thanks @wjs018) + * Images can be resized by a constant scaling factory using `-s`/`--scale` (e.g. `--scale 0.5` shrinks the height/width by half) + * Images can be resized to a specified height (`-h`/`--height`) and/or width (`-w`/`--width`), in pixels; if only one is specified, the aspect ratio of the original video is kept + * [api] Calling `seek()` on a `VideoManager` will now respect the end time if set + * [api] The `split_video_` functions now return the exit code of invoking `ffmpeg` or `mkvmerge` ([#209](https://github.com/Breakthrough/PySceneDetect/issues/209), thanks @AdrienLF)(https://github.com/Breakthrough/PySceneDetect/issues/211), thanks @jeremymeyers) + * [api] Removed the `min_percent` argument from `ThresholdDetector` as was not providing any performance benefit for the majority of use cases ([#178](https://github.com/Breakthrough/PySceneDetect/issues/178)) + * [bugfix] The `detect-threshold` command now works properly with a statsfile ([#211](https://github.com/Breakthrough/PySceneDetect/issues/211)) + * [bugfix] Fixed crash due to unhandled `TypeError` exception when using non-PyPI OpenCV packages from certain Linux distributions ([#220](https://github.com/Breakthrough/PySceneDetect/issues/220)) + * [bugfix] A warning is now displayed for videos which may not be decoded correctly, esp. VP9 ([#86](https://github.com/Breakthrough/PySceneDetect/issues/86)) + * [api] A named logger is now used for both API and CLI logging instead of the root logger ([#205](https://github.com/Breakthrough/PySceneDetect/issues/205)) + +#### Known Issues + + * Image sequences or URL inputs are not supported by the `save-images` or `split-video` commands + * Variable framerate videos (VFR) are not fully supported, and will yield incorrect timestamps ([#168](https://github.com/Breakthrough/PySceneDetect/issues/168)) + + +### 0.5.5 (January 17, 2021) #### Release Notes @@ -105,7 +135,7 @@ PySceneDetect Releases * [bugfix] `--min-scene-len` option was not respected by first scene ([#105](https://github.com/Breakthrough/PySceneDetect/issues/105), thanks @charlesvestal) * [bugfix] Splitting videos with an analyzed duration only splits within analyzed area ([#106](https://github.com/Breakthrough/PySceneDetect/issues/106), thanks @charlesvestal) * [bugfix] Improper start timecode applied to the `split-video` command when using `ffmpeg` ([#93](https://github.com/Breakthrough/PySceneDetect/issues/93), thanks @typoman) - * [bugfix] Added links and filename sanitation to html output ([#139](https://github.com/Breakthrough/PySceneDetect/issues/139) and [#140](https://github.com/Breakthrough/PySceneDetect/issues/140), thanks @wsj018) + * [bugfix] Added links and filename sanitation to html output ([#139](https://github.com/Breakthrough/PySceneDetect/issues/139) and [#140](https://github.com/Breakthrough/PySceneDetect/issues/140), thanks @wjs018) * [bugfix] UnboundLocalError in `detect_scenes` when `frame_skip` is larger than 0 ([#126](https://github.com/Breakthrough/PySceneDetect/issues/126), thanks @twostarxx) diff --git a/docs/download.md b/docs/download.md index ae4672f1..cbff6b54 100644 --- a/docs/download.md +++ b/docs/download.md @@ -18,12 +18,20 @@ PySceneDetect is compatible with both Python 2 and 3. Note that Python 3 usuall PySceneDetect is available via `pip` as [the `scenedetect` package](https://pypi.org/project/scenedetect/). See below for instructions on installing a non-pip version of OpenCV. To ensure you have all the requirements installed, open a `python` interpreter, and ensure you can run `import cv2` without any errors. +### Windows Build (64-bit Only)   + +
+

Latest Release: v0.5.6

+

  Release Date:  August 15, 2021

+  Installer  (recommended)        Portable .zip        Getting Started +
+ ### Python Installer (All Platforms)      
-

Latest Release: v0.5.5

-

  Release Date:  January 17, 2021

-  Source  .zip        Source  .tar.gz        Getting Started +

Latest Release: v0.5.6

+

  Release Date:  August 15, 2021

+  Source  .zip        Source  .tar.gz        Getting Started
To install from source, download and extract the latest release to a location of your choice, and make sure you have the appropriate [system requirements](#dependencies) installed before continuing. PySceneDetect can be installed by running the following command in the location of the extracted files (don't forget `sudo` if you're installing system-wide): @@ -54,8 +62,8 @@ PySceneDetect requires [Python 2 or 3](https://www.python.org/) and the followin For video splitting support, you need to have the following tools available: - - [ffmpeg](https://ffmpeg.org/download.html), part of mkvtoolnix, command-line tool, required to split video files in precise/high-quality mode (`split-video` or `split-video -h/--high-quality`) - - [mkvmerge](https://mkvtoolnix.download/), part of mkvtoolnix, command-line tool, required to split video files in copy mode (`split-video -c/--copy`) + - [ffmpeg](https://ffmpeg.org/download.html), required to split video files (`split-video`) + - [mkvmerge](https://mkvtoolnix.download/), part of mkvtoolnix, command-line tool, required to split video files in stream copy mode (`split-video -c/--copy`) Note that Linux users should use a package manager if possible (e.g. `sudo apt-get install ffmpeg`). Windows users may require additional steps in order for PySceneDetect to detect `ffmpeg` - see the section Manually Enabling `split-video` Support below for details. diff --git a/docs/index.md b/docs/index.md index b6fd8c55..1ed2d636 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,7 +3,7 @@

Intelligent scene cut detection and video splitting tool.

-

  Latest Release: v0.5.5 (January 17, 2021)

+

  Latest Release: v0.5.6 (August 15, 2021)

  Download        Changelog        Manual        Getting Started
See the changelog for the latest release notes and known issues. diff --git a/docs/other/copyright.md b/docs/other/copyright.md index b937b27b..9c298c73 100644 --- a/docs/other/copyright.md +++ b/docs/other/copyright.md @@ -69,12 +69,6 @@ This section contains links to the license agreements for all third-party softwa - URL: https://raw.githubusercontent.com/tqdm/tqdm/master/LICENCE -### click - - - Copyright (C) 2004-2017, Holger Krekel and others. - - URL: https://docs.pytest.org/en/latest/license.html - - ### simpletable - Copyright (C) 2014-2019, Matheus Vieira Portela and others diff --git a/manual/conf.py b/manual/conf.py index 1e775de9..e41668e0 100644 --- a/manual/conf.py +++ b/manual/conf.py @@ -28,7 +28,7 @@ # The short X.Y version version = '' # The full version, including alpha/beta/rc tags -release = 'v0.5.5' +release = 'v0.5.6' # -- General configuration --------------------------------------------------- diff --git a/manual/index.rst b/manual/index.rst index 96ac4316..588c0733 100644 --- a/manual/index.rst +++ b/manual/index.rst @@ -4,7 +4,7 @@ ####################################################################### -PySceneDetect v0.5.5 Manual +PySceneDetect v0.5.6 Manual ####################################################################### This manual refers to both the PySceneDetect diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 3dcbf5be..d4922ec0 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -43,15 +43,16 @@ from scenedetect.video_manager import VideoManager from scenedetect.stats_manager import StatsManager - # We also bring the detectors into the main scenedetect package namespace # for convenience as well. Examples still reference the full package. -from scenedetect.detectors import ThresholdDetector, ContentDetector +from scenedetect.detectors import ThresholdDetector +from scenedetect.detectors import ContentDetector +from scenedetect.detectors import AdaptiveDetector # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = 'v0.5.5' +__version__ = 'v0.5.6' # About & copyright message string shown for the 'about' CLI command (scenedetect about). diff --git a/scenedetect/__main__.py b/scenedetect/__main__.py index 5fdd4c2a..58559810 100755 --- a/scenedetect/__main__.py +++ b/scenedetect/__main__.py @@ -42,8 +42,8 @@ """ # PySceneDetect Library Imports -from scenedetect.cli import CliContext from scenedetect.cli import scenedetect_cli as cli +from scenedetect.cli.context import CliContext def main(): """ Main: PySceneDetect command-line interface (CLI) entry point. diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index 5c8bf0a8..6f3d5310 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -42,8 +42,6 @@ # Standard Library Imports from __future__ import print_function -import sys -import string import logging # Third-Party Library Imports @@ -52,20 +50,13 @@ # PySceneDetect Library Imports import scenedetect -from scenedetect.cli.context import CliContext from scenedetect.cli.context import check_split_video_requirements from scenedetect.cli.context import contains_sequence_or_url from scenedetect.cli.context import parse_timecode -from scenedetect.frame_timecode import FrameTimecode - from scenedetect.platform import get_and_create_path - -from scenedetect.video_manager import VideoManager - -from scenedetect.video_splitter import is_mkvmerge_available -from scenedetect.video_splitter import is_ffmpeg_available - +from scenedetect.platform import init_logger +logger = logging.getLogger('pyscenedetect') def get_help_command_preface(command_name='scenedetect'): """ Preface/intro help message shown at the beginning of the help command. """ @@ -153,7 +144,7 @@ def duplicate_command(ctx, param_hint): error_strs.append('Error: Command %s specified multiple times.' % param_hint) error_strs.append('The %s command may appear only one time.') - logging.error('\n'.join(error_strs)) + ctx.obj.logger.error('\n'.join(error_strs)) raise click.BadParameter('\n Command %s may only be specified once.' % param_hint, param_hint='%s command' % param_hint) @@ -222,9 +213,8 @@ def duplicate_command(ctx, param_hint): @click.option( '--quiet', '-q', is_flag=True, flag_value=True, help= - 'Suppresses all output of PySceneDetect except for those from the specified' - ' commands. Equivalent to setting `--verbosity none`. Overrides the current verbosity' - ' level, even if `-v`/`--verbosity` is set.') + 'Suppresses all output of PySceneDetect to the terminal/stdout. If a logfile is' + ' specified, it will still be generated with the specified verbosity.') @click.pass_context # pylint: disable=redefined-builtin def scenedetect_cli(ctx, input, output, framerate, downscale, frame_skip, @@ -241,52 +231,36 @@ def scenedetect_cli(ctx, input, output, framerate, downscale, frame_skip, """ ctx.call_on_close(ctx.obj.process_input) - logging.disable(logging.NOTSET) - format_str = '[PySceneDetect] %(message)s' - if verbosity.lower() == 'none': - verbosity = None - elif verbosity.lower() == 'debug': - format_str = '%(levelname)s: %(module)s.%(funcName)s(): %(message)s' + logging.disable(logging.NOTSET) - if quiet: - verbosity = None + verbosity = getattr(logging, verbosity.upper()) if verbosity is not None else None + init_logger(log_level=verbosity, show_stdout=not quiet, log_file=logfile) - ctx.obj.quiet_mode = True if verbosity is None else False + ctx.obj.quiet_mode = True if quiet else False ctx.obj.output_directory = output - if logfile is not None: - logfile = get_and_create_path(logfile) - logging.basicConfig( - filename=logfile, filemode='a', format=format_str, - level=getattr(logging, verbosity.upper()) if verbosity is not None else verbosity) - elif verbosity is not None: - logging.basicConfig(format=format_str, - level=getattr(logging, verbosity.upper())) - else: - logging.disable(logging.CRITICAL) - - logging.info('PySceneDetect %s', scenedetect.__version__) + ctx.obj.logger.info('PySceneDetect %s', scenedetect.__version__) if stats is not None and frame_skip != 0: ctx.obj.options_processed = False error_strs = [ 'Unable to detect scenes with stats file if frame skip is not 1.', ' Either remove the -fs/--frame-skip option, or the -s/--stats file.\n'] - logging.error('\n'.join(error_strs)) + ctx.obj.logger.error('\n'.join(error_strs)) raise click.BadParameter( '\n Combining the -s/--stats and -fs/--frame-skip options is not supported.', param_hint='frame skip + stats file') try: if ctx.obj.output_directory is not None: - logging.info('Output directory set:\n %s', ctx.obj.output_directory) + ctx.obj.logger.info('Output directory set:\n %s', ctx.obj.output_directory) ctx.obj.parse_options( input_list=input, framerate=framerate, stats_file=stats, downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, drop_short_scenes=drop_short_scenes) except Exception as ex: - logging.error('Could not parse CLI options.: %s', ex) + ctx.obj.logger.error('Could not parse CLI options.: %s', ex) raise @@ -404,8 +378,12 @@ def time_command(ctx, start, duration, end): type=click.FLOAT, default=30.0, show_default=True, help= 'Threshold value (float) that the content_val frame metric must exceed to trigger a new scene.' ' Refers to frame metric content_val in stats file.') +@click.option( + '--luma-only', '-l', + is_flag=True, flag_value=True, help= + 'Only consider luma/brightness channel (useful for greyscale videos).') @click.pass_context -def detect_content_command(ctx, threshold): +def detect_content_command(ctx, threshold, luma_only): """ Perform content detection algorithm on input video(s). detect-content @@ -414,15 +392,71 @@ def detect_content_command(ctx, threshold): """ min_scene_len = 0 if ctx.obj.drop_short_scenes else ctx.obj.min_scene_len - logging.debug('Detecting content, parameters:\n' - ' threshold: %d, min-scene-len: %d', - threshold, min_scene_len) + luma_mode_str = '' if not luma_only else ', luma_only mode' + ctx.obj.logger.debug('Detecting content, parameters:\n' + ' threshold: %d, min-scene-len: %d%s', + threshold, min_scene_len, luma_mode_str) # Initialize detector and add to scene manager. # Need to ensure that a detector is not added twice, or will cause # a frame metric key error when registering the detector. ctx.obj.add_detector(scenedetect.detectors.ContentDetector( - threshold=threshold, min_scene_len=min_scene_len)) + threshold=threshold, min_scene_len=min_scene_len, luma_only=luma_only)) + + +@click.command('detect-adaptive') +@click.option( + '--threshold', '-t', metavar='VAL', + type=click.FLOAT, default=3.0, show_default=True, help= + 'Threshold value (float) that the calculated frame score must exceed to' + ' trigger a new scene (see frame metric adaptive_ratio in stats file).') +@click.option( + '--min-scene-len', '-m', metavar='TIMECODE', + type=click.STRING, default="0.6s", show_default=True, help= + 'Minimum size/length of any scene. TIMECODE can be specified as exact' + ' number of frames, a time in seconds followed by s, or a timecode in the' + ' format HH:MM:SS or HH:MM:SS.nnn') +@click.option( + '--min-delta-hsv', '-d', metavar='VAL', + type=click.FLOAT, default=15.0, show_default=True, help= + 'Minimum threshold (float) that the content_val must exceed in order to register as a new' + ' scene. This is calculated the same way that `detect-content` calculates frame score.') +@click.option( + '--frame-window', '-w', metavar='VAL', + type=click.INT, default=2, show_default=True, help= + 'Size of window (number of frames) before and after each frame to average together in' + ' order to detect deviations from the mean.') +@click.option( + '--luma-only', '-l', + is_flag=True, flag_value=True, help= + 'Only consider luma/brightness channel (useful for greyscale videos).') +@click.pass_context +def detect_adaptive_command(ctx, threshold, min_scene_len, min_delta_hsv, + frame_window, luma_only): + """ Perform adaptive detection algorithm on input video(s). + + detect-adaptive + + detect-adaptive --threshold 3.2 + """ + + min_scene_len = parse_timecode(ctx.obj, min_scene_len) + luma_mode_str = '' if not luma_only else ', luma_only mode' + + ctx.obj.logger.debug('Adaptively detecting content, parameters:\n' + ' threshold: %d, min-scene-len: %d%s', + threshold, min_scene_len, luma_mode_str) + + # Initialize detector and add to scene manager. + # Need to ensure that a detector is not added twice, or will cause + # a frame metric key error when registering the detector. + ctx.obj.add_detector(scenedetect.detectors.AdaptiveDetector( + video_manager=ctx.obj.video_manager, + adaptive_threshold=threshold, + min_scene_len=min_scene_len, + min_delta_hsv=min_delta_hsv, + window_width=frame_window, + luma_only=luma_only)) @@ -463,7 +497,7 @@ def detect_threshold_command(ctx, threshold, fade_bias, add_last_scene, min_scene_len = 0 if ctx.obj.drop_short_scenes else ctx.obj.min_scene_len - logging.debug('Detecting threshold, parameters:\n' + ctx.obj.logger.debug('Detecting threshold, parameters:\n' ' threshold: %d, min-scene-len: %d, fade-bias: %d,\n' ' add-last-scene: %s, min-percent: %d, block-size: %d', threshold, min_scene_len, fade_bias, @@ -477,7 +511,8 @@ def detect_threshold_command(ctx, threshold, fade_bias, add_last_scene, fade_bias /= 100.0 ctx.obj.add_detector(scenedetect.detectors.ThresholdDetector( threshold=threshold, min_scene_len=min_scene_len, fade_bias=fade_bias, - add_final_scene=add_last_scene, min_percent=min_percent, block_size=block_size)) + add_final_scene=add_last_scene, block_size=block_size)) + @@ -611,7 +646,7 @@ def split_video_command(ctx, output, filename, high_quality, override_args, quie if contains_sequence_or_url(ctx.obj.video_manager.get_video_paths()): ctx.obj.options_processed = False error_str = 'The save-images command is incompatible with image sequences/URLs.' - logging.error(error_str) + ctx.obj.logger.error(error_str) raise click.BadParameter(error_str, param_hint='save-images') ctx.obj.split_video = True @@ -621,9 +656,9 @@ def split_video_command(ctx, output, filename, high_quality, override_args, quie if copy: ctx.obj.split_mkvmerge = True if high_quality: - logging.warning('-hq/--high-quality flag ignored due to -c/--copy.') + ctx.obj.logger.warning('-hq/--high-quality flag ignored due to -c/--copy.') if override_args: - logging.warning('-f/--ffmpeg-args option ignored due to -c/--copy.') + ctx.obj.logger.warning('-f/--ffmpeg-args option ignored due to -c/--copy.') if not override_args: if rate_factor is None: rate_factor = 22 if not high_quality else 17 @@ -632,11 +667,11 @@ def split_video_command(ctx, output, filename, high_quality, override_args, quie override_args = ('-c:v libx264 -preset {PRESET} -crf {RATE_FACTOR} -c:a aac'.format( PRESET=preset, RATE_FACTOR=rate_factor)) if not copy: - logging.info('FFmpeg codec args set: %s', override_args) + ctx.obj.logger.info('FFmpeg codec args set: %s', override_args) if filename: - logging.info('Video output file name format: %s', filename) + ctx.obj.logger.info('Video output file name format: %s', filename) if ctx.obj.split_directory is not None: - logging.info('Video output path set: \n%s', ctx.obj.split_directory) + ctx.obj.logger.info('Video output path set: \n%s', ctx.obj.split_directory) ctx.obj.split_args = override_args @@ -650,8 +685,8 @@ def split_video_command(ctx, output, filename, high_quality, override_args, quie '--filename', '-f', metavar='NAME', default='$VIDEO_NAME-Scene-$SCENE_NUMBER-$IMAGE_NUMBER', type=click.STRING, show_default=True, help= 'Filename format, *without* extension, to use when saving image files. You can use the' - ' $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name. Note that you' - ' may have to wrap the format in single quotes.') + ' $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name.' + ' Note that you may have to wrap the format in single quotes.') @click.option( '--num-images', '-n', metavar='N', default=3, type=click.INT, help= @@ -759,3 +794,4 @@ def colors_command(ctx): # Detection Algorithms add_cli_command(scenedetect_cli, detect_content_command) add_cli_command(scenedetect_cli, detect_threshold_command) +add_cli_command(scenedetect_cli, detect_adaptive_command) diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index 4f7957fc..a354b246 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -213,12 +213,15 @@ def __init__(self): self.image_width = None # export-html -w/--image-width self.image_height = None # export-html -h/--image-height + # Logger for CLI output. + self.logger = logging.getLogger('pyscenedetect') + def cleanup(self): # type: () -> None """ Cleanup: Releases all resources acquired by the CliContext (esp. the VideoManager). """ try: - logging.debug('Cleaning up...\n\n') + self.logger.debug('Cleaning up...\n\n') finally: if self.video_manager is not None: self.video_manager.release() @@ -231,7 +234,7 @@ def _open_stats_file(self): if self.stats_file_path is not None: if os.path.exists(self.stats_file_path): - logging.info('Loading frame metrics from stats file: %s', + self.logger.info('Loading frame metrics from stats file: %s', os.path.basename(self.stats_file_path)) try: with open(self.stats_file_path, 'rt') as stats_file: @@ -244,7 +247,7 @@ def _open_stats_file(self): ' again to re-generate the stats file.') error_strs = [ 'Could not load stats file.', 'Failed to parse stats file:', error_info ] - logging.error('\n'.join(error_strs)) + self.logger.error('\n'.join(error_strs)) raise click.BadParameter( '\n Could not load given stats file, see above output for details.', param_hint='input stats file') @@ -256,56 +259,66 @@ def process_input(self): Run after all command line options/sub-commands have been parsed. """ - logging.debug('Processing input...') + self.logger.debug('Processing input...') if not self.options_processed: - logging.debug('Skipping processing, CLI options were not parsed successfully.') + self.logger.debug('Skipping processing, CLI options were not parsed successfully.') return self.check_input_open() assert self.scene_manager.get_num_detectors() >= 0 if self.scene_manager.get_num_detectors() == 0: - logging.error( + self.logger.error( 'No scene detectors specified (detect-content, detect-threshold, etc...),\n' ' or failed to process all command line arguments.') return + # Display a warning if the video codec type seems unsupported (#86). + if int(abs(self.video_manager.get(cv2.CAP_PROP_FOURCC))) == 0: + self.logger.error( + 'Video codec detection failed, output may be incorrect.\nThis could be caused' + ' by using an outdated version of OpenCV, or using codecs that currently are' + ' not well supported (e.g. VP9).\n' + 'As a workaround, consider re-encoding the source material before processing.\n' + 'For details, see https://github.com/Breakthrough/PySceneDetect/issues/86') + # Handle scene detection commands (detect-content, detect-threshold, etc...). self.video_manager.start() start_time = time.time() - logging.info('Detecting scenes...') + self.logger.info('Detecting scenes...') num_frames = self.scene_manager.detect_scenes( frame_source=self.video_manager, frame_skip=self.frame_skip, show_progress=not self.quiet_mode) # Handle case where video fails with multiple audio tracks (#179). - # TODO: Is there a fix for this? See #179. + # TODO: Using a different video backend as per #213 may also resolve this issue, + # as well as numerous other timing related issues. if num_frames <= 0: - logging.critical('\n'.join([ + self.logger.critical( 'Failed to read any frames from video file. This could be caused' ' by the video having multiple audio tracks. If so, please try' - ' removing the audio tracks or muxing to mkv via:' - ' ffmpeg -i input.mp4 -c copy -an output.mp4' - 'or:' - ' mkvmerge -o output.mkv input.mp4' - ' For details, see https://pyscenedetect.readthedocs.io/en/latest/faq/'])) + ' removing the audio tracks or muxing to mkv via:\n' + ' ffmpeg -i input.mp4 -c copy -an output.mp4\n' + 'or:\n' + ' mkvmerge -o output.mkv input.mp4\n' + 'For details, see https://pyscenedetect.readthedocs.io/en/latest/faq/') return duration = time.time() - start_time - logging.info('Processed %d frames in %.1f seconds (average %.2f FPS).', + self.logger.info('Processed %d frames in %.1f seconds (average %.2f FPS).', num_frames, duration, float(num_frames)/duration) # Handle -s/--statsfile option. if self.stats_file_path is not None: if self.stats_manager.is_save_required(): with open(self.stats_file_path, 'wt') as stats_file: - logging.info('Saving frame metrics to stats file: %s', + self.logger.info('Saving frame metrics to stats file: %s', os.path.basename(self.stats_file_path)) base_timecode = self.video_manager.get_base_timecode() self.stats_manager.save_to_csv( stats_file, base_timecode) else: - logging.debug('No frame metrics updated, skipping update of the stats file.') + self.logger.debug('No frame metrics updated, skipping update of the stats file.') # Get list of detected cuts and scenes from the SceneManager to generate the required output # files with based on the given commands (list-scenes, split-video, save-images, etc...). @@ -323,12 +336,12 @@ def process_input(self): video_name = self.video_manager.get_video_name() if scene_list: # Ensure we don't divide by zero. - logging.info('Detected %d scenes, average shot length %.1f seconds.', + self.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: - logging.info('No scenes detected.') + self.logger.info('No scenes detected.') # Handle list-scenes command. if self.scene_list_output: @@ -340,7 +353,7 @@ def process_input(self): scene_list_filename, self.scene_list_directory if self.scene_list_directory is not None else self.output_directory) - logging.info('Writing scene list to CSV file:\n %s', scene_list_path) + self.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, @@ -349,7 +362,7 @@ def process_input(self): cut_list=cut_list) if self.print_scene_list: - logging.info("""Scene List: + self.logger.info("""Scene List: ----------------------------------------------------------------------- | Scene # | Start Frame | Start Time | End Frame | End Time | ----------------------------------------------------------------------- @@ -363,7 +376,7 @@ def process_input(self): for i, (start_time, end_time) in enumerate(scene_list)])) if cut_list: - logging.info('Comma-separated timecode list:\n %s', + self.logger.info('Comma-separated timecode list:\n %s', ','.join([cut.get_timecode() for cut in cut_list])) # Handle save-images command. @@ -397,7 +410,7 @@ def process_input(self): html_filename, self.image_directory if self.image_directory is not None else self.output_directory) - logging.info('Exporting to html file:\n %s:', html_path) + self.logger.info('Exporting to html file:\n %s:', html_path) if not self.html_include_images: image_filenames = None write_scene_list_html(html_path, scene_list, cut_list, @@ -432,7 +445,7 @@ def process_input(self): hide_progress=self.quiet_mode, suppress_output=self.quiet_mode or self.split_quiet) if scene_list: - logging.info('Video splitting completed, individual scenes written to disk.') + self.logger.info('Video splitting completed, individual scenes written to disk.') @@ -449,7 +462,7 @@ def check_input_open(self): error_strs = ["No input video(s) specified.", "Make sure '--input VIDEO' is specified at the start of the command."] error_str = '\n'.join(error_strs) - logging.debug(error_str) + self.logger.debug(error_str) raise click.BadParameter(error_str, param_hint='input video') @@ -470,11 +483,11 @@ def _init_video_manager(self, input_list, framerate, downscale): self.base_timecode = None - logging.debug('Initializing VideoManager.') + self.logger.debug('Initializing VideoManager.') video_manager_initialized = False try: self.video_manager = VideoManager( - video_files=input_list, framerate=framerate, logger=logging) + video_files=input_list, framerate=framerate, logger=self.logger) video_manager_initialized = True self.base_timecode = self.video_manager.get_base_timecode() self.video_manager.set_downscale_factor(downscale) @@ -489,7 +502,7 @@ def _init_video_manager(self, input_list, framerate, downscale): 'Error: OpenCV dependency %s not found.' % dll_name, 'Ensure that you installed the Python OpenCV module, and that the', '%s file can be found to enable video support.' % dll_name] - logging.debug('\n'.join(error_strs[1:])) + self.logger.debug('\n'.join(error_strs[1:])) if not dll_okay: click.echo(click.style( '\nOpenCV dependency missing, video input/decoding not available.\n', fg='red')) @@ -498,7 +511,7 @@ def _init_video_manager(self, input_list, framerate, downscale): error_strs = ['could not get framerate from video(s)', 'Failed to obtain framerate for video file %s.' % ex.file_name] error_strs.append('Specify framerate manually with the -f / --framerate option.') - logging.debug('\n'.join(error_strs)) + self.logger.debug('\n'.join(error_strs)) raise click.BadParameter('\n'.join(error_strs), param_hint='input video') except VideoParameterMismatch as ex: error_strs = ['video parameters do not match.', 'List of mismatched parameters:'] @@ -514,11 +527,11 @@ def _init_video_manager(self, input_list, framerate, downscale): error_strs.append( 'Multiple videos may only be specified if they have the same framerate and' ' resolution. -f / --framerate may be specified to override the framerate.') - logging.debug('\n'.join(error_strs)) + self.logger.debug('\n'.join(error_strs)) raise click.BadParameter('\n'.join(error_strs), param_hint='input videos') except InvalidDownscaleFactor as ex: error_strs = ['Downscale value is not > 0.', str(ex)] - logging.debug('\n'.join(error_strs)) + self.logger.debug('\n'.join(error_strs)) raise click.BadParameter('\n'.join(error_strs), param_hint='downscale factor') return video_manager_initialized @@ -539,7 +552,7 @@ def parse_options(self, input_list, framerate, stats_file, downscale, frame_skip if not input_list: return - logging.debug('Parsing program options.') + self.logger.debug('Parsing program options.') self.frame_skip = frame_skip @@ -549,9 +562,9 @@ def parse_options(self, input_list, framerate, stats_file, downscale, frame_skip # Ensure VideoManager is initialized, and open StatsManager if --stats is specified. if not video_manager_initialized: self.video_manager = None - logging.info('VideoManager not initialized.') + self.logger.info('VideoManager not initialized.') else: - logging.debug('VideoManager initialized.') + self.logger.debug('VideoManager initialized.') self.stats_file_path = get_and_create_path(stats_file, self.output_directory) if self.stats_file_path is not None: self.check_input_open() @@ -575,7 +588,7 @@ def time_command(self, start=None, duration=None, end=None): Raises: click.BadParameter, VideoDecodingInProgress """ - logging.debug('Setting video time:\n start: %s, duration: %s, end: %s', + self.logger.debug('Setting video time:\n start: %s, duration: %s, end: %s', start, duration, end) self.check_input_open() @@ -607,10 +620,10 @@ def list_scenes_command(self, output_path, filename_format, no_output_mode, self.scene_list_directory = output_path self.scene_list_name_format = filename_format if self.scene_list_name_format is not None and not no_output_mode: - logging.info('Scene list CSV file name format:\n %s', self.scene_list_name_format) + self.logger.info('Scene list CSV file name format:\n %s', self.scene_list_name_format) self.scene_list_output = False if no_output_mode else True if self.scene_list_directory is not None: - logging.info('Scene list output directory set:\n %s', self.scene_list_directory) + self.logger.info('Scene list output directory set:\n %s', self.scene_list_directory) self.skip_cuts = skip_cuts @@ -627,7 +640,7 @@ def export_html_command(self, filename, no_images, image_width, image_height): self.html_name_format = filename if self.html_name_format is not None: - logging.info('Scene list html file name format:\n %s', self.html_name_format) + self.logger.info('Scene list html file name format:\n %s', self.html_name_format) self.html_include_images = False if no_images else True self.image_width = image_width self.image_height = image_height @@ -648,7 +661,7 @@ def save_images_command(self, num_images, output, name_format, jpeg, webp, quali if contains_sequence_or_url(self.video_manager.get_video_paths()): self.options_processed = False error_str = '\nThe save-images command is incompatible with image sequences/URLs.' - logging.error(error_str) + self.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]]) @@ -667,7 +680,7 @@ def save_images_command(self, num_images, output, name_format, jpeg, webp, quali '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. '] - logging.debug('\n'.join(error_strs)) + self.logger.debug('\n'.join(error_strs)) raise click.BadParameter('\n'.join(error_strs), param_hint='save-images') self.save_images = True @@ -686,12 +699,12 @@ def save_images_command(self, num_images, output, name_format, jpeg, webp, quali if self.image_param: image_param_type = 'Compression' if image_type == 'PNG' else 'Quality' image_param_type = ' [%s: %d]' % (image_param_type, self.image_param) - logging.info('Image output format set: %s%s', image_type, image_param_type) + self.logger.info('Image output format set: %s%s', image_type, image_param_type) if self.image_directory is not None: - logging.info('Image output directory set:\n %s', + self.logger.info('Image output directory set:\n %s', os.path.abspath(self.image_directory)) else: self.options_processed = False - logging.error('Multiple image type flags set for save-images command.') + self.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') diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 2d7a8ec3..9bcb01f8 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -39,6 +39,7 @@ # PySceneDetect Detection Algorithm Imports from scenedetect.detectors.content_detector import ContentDetector from scenedetect.detectors.threshold_detector import ThresholdDetector +from scenedetect.detectors.adaptive_detector import AdaptiveDetector # Algorithms being ported: #from scenedetect.detectors.motion_detector import MotionDetector diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py new file mode 100644 index 00000000..5bb39be2 --- /dev/null +++ b/scenedetect/detectors/adaptive_detector.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://pyscenedetect.readthedocs.org/ ] +# +# Copyright (C) 2014-2021 Brandon Castellano . +# +# PySceneDetect is licensed under the BSD 3-Clause License; see the included +# LICENSE file, or visit one of the following pages for details: +# - https://github.com/Breakthrough/PySceneDetect/ +# - http://www.bcastell.com/projects/PySceneDetect/ +# +# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest. +# See the included LICENSE files or one of the above URLs for more information. +# +# 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 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. +# + +""" Module: ``scenedetect.detectors.adaptive_detector`` + +This module implements the :py:class:`AdaptiveDetector`, which 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 interface by using the +`adaptive-detect-content` command. +""" + +# PySceneDetect Library Imports +from scenedetect.detectors import ContentDetector + + +class AdaptiveDetector(ContentDetector): + """Detects cuts using HSV changes similar to ContentDetector, but with a + rolling average that can help mitigate false detections in situations such + as camera moves. + """ + + ADAPTIVE_RATIO_KEY_TEMPLATE = "adaptive_ratio{luma_only} (w={window_width})" + + def __init__(self, video_manager, adaptive_threshold=3.0, + luma_only=False, min_scene_len=15, min_delta_hsv=15.0, window_width=2): + super(AdaptiveDetector, self).__init__() + self.video_manager = video_manager + self.min_scene_len = min_scene_len # minimum length of any given scene, in frames (int) or FrameTimecode + self.adaptive_threshold = adaptive_threshold + self.min_delta_hsv = min_delta_hsv + self.window_width = window_width + self._luma_only = luma_only + self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format( + window_width=window_width, luma_only='' if not luma_only else '_lum') + + + def get_metrics(self): + # type: () -> List[str] + """ Combines base ContentDetector metric keys with the AdaptiveDetector one. """ + return super(AdaptiveDetector, self).get_metrics() + [self._adaptive_ratio_key] + + def stats_manager_required(self): + # type: () -> bool + """ Overload to indicate that this detector requires a StatsManager. + + Returns: + True as AdaptiveDetector requires stats. + """ + return True + + def process_frame(self, frame_num, frame_img): + # type: (int, numpy.ndarray) -> List[int] + """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead + of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). + + Arguments: + frame_num (int): Frame number of frame that is being passed. + + frame_img (Optional[int]): 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 + """ + + # Call the process_frame function of ContentDetector but ignore any + # returned cuts + if self.is_processing_required(frame_num): + super(AdaptiveDetector, self).process_frame( + frame_num=frame_num, frame_img=frame_img) + + return [] + + + def get_content_val(self, frame_num): + """ + Returns the average content change for a frame. + """ + metric_key = (ContentDetector.FRAME_SCORE_KEY if not self._luma_only + else ContentDetector.DELTA_V_KEY) + return self.stats_manager.get_metrics( + frame_num, [metric_key])[0] + + + def post_process(self, _): + """ + After an initial run through the video to detect content change + between each frame, we try to identify fast cuts as short peaks in the + `content_val` value. If a single frame has a high `content-val` while + the frames around it are low, we can be sure it's fast cut. If several + frames in a row have high `content-val`, it probably isn't a cut -- it + could be fast camera movement or a change in lighting that lasts for + more than a single frame. + """ + cut_list = [] + _, start_timecode, end_timecode = self.video_manager.get_duration() + start_frame = start_timecode.get_frames() + end_frame = end_timecode.get_frames() + adaptive_threshold = self.adaptive_threshold + window_width = self.window_width + last_cut = None + + assert self.stats_manager is not None + + if self.stats_manager is not None: + # Loop through the stats, building the adaptive_ratio metric + for frame_num in range(start_frame + window_width + 1, end_frame - window_width): + # If the content-val of the frame is more than + # adaptive_threshold times the mean content_val of the + # frames around it, then we mark it as a cut. + denominator = 0 + for offset in range(-window_width, window_width + 1): + if offset == 0: + continue + else: + denominator += self.get_content_val(frame_num + offset) + + denominator = denominator / (2.0 * window_width) + denominator_is_zero = abs(denominator) < 0.00001 + + if not denominator_is_zero: + adaptive_ratio = self.get_content_val(frame_num) / denominator + elif denominator_is_zero and self.get_content_val(frame_num) >= self.min_delta_hsv: + # if we would have divided by zero, set adaptive_ratio to the max (255.0) + adaptive_ratio = 255.0 + else: + # avoid dividing by zero by setting adaptive_ratio to zero if content_val + # is still very low + adaptive_ratio = 0.0 + + self.stats_manager.set_metrics( + frame_num, {self._adaptive_ratio_key: adaptive_ratio}) + + # Loop through the frames again now that adaptive_ratio has been calculated to detect + # cuts using adaptive_ratio + for frame_num in range(start_frame + window_width + 1, end_frame - window_width): + # 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 (self.stats_manager.get_metrics( + frame_num, [self._adaptive_ratio_key])[0] >= adaptive_threshold and + self.get_content_val(frame_num) >= self.min_delta_hsv): + + if last_cut is None: + # No previously detected cuts + cut_list.append(frame_num) + last_cut = frame_num + elif (frame_num - last_cut) >= self.min_scene_len: + # Respect the min_scene_len parameter + cut_list.append(frame_num) + last_cut = frame_num + + return cut_list + + # Stats manager must be used for this detector + return None diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index bb4e6d71..87e8cce2 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -50,17 +50,52 @@ class ContentDetector(SceneDetector): content scenes still using HSV information, use the DissolveDetector. """ - def __init__(self, threshold=30.0, min_scene_len=15): + FRAME_SCORE_KEY = 'content_val' + DELTA_H_KEY, DELTA_S_KEY, DELTA_V_KEY = ('delta_hue', 'delta_sat', 'delta_lum') + METRIC_KEYS = [FRAME_SCORE_KEY, DELTA_H_KEY, DELTA_S_KEY, DELTA_V_KEY] + + + def __init__(self, threshold=30.0, min_scene_len=15, luma_only=False): # type: (float, Union[int, FrameTimecode]) -> None super(ContentDetector, self).__init__() self.threshold = threshold # Minimum length of any given scene, in frames (int) or FrameTimecode self.min_scene_len = min_scene_len + self.luma_only = luma_only self.last_frame = None self.last_scene_cut = None self.last_hsv = None - self._metric_keys = ['content_val', 'delta_hue', 'delta_sat', 'delta_lum'] - self.cli_name = 'detect-content' + + + def get_metrics(self): + return ContentDetector.METRIC_KEYS + + + def is_processing_required(self, frame_num): + return self.stats_manager is None or ( + not self.stats_manager.metrics_exist(frame_num, ContentDetector.METRIC_KEYS)) + + + def calculate_frame_score(self, frame_num, curr_hsv, last_hsv): + + delta_hsv = [0, 0, 0, 0] + for i in range(3): + num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] + curr_hsv[i] = curr_hsv[i].astype(numpy.int32) + last_hsv[i] = last_hsv[i].astype(numpy.int32) + delta_hsv[i] = numpy.sum( + numpy.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) + + delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 + delta_h, delta_s, delta_v, delta_content = delta_hsv + + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, { + self.FRAME_SCORE_KEY: delta_content, + self.DELTA_H_KEY: delta_h, + self.DELTA_S_KEY: delta_s, + self.DELTA_V_KEY: delta_v}) + return delta_content if not self.luma_only else delta_v def process_frame(self, frame_num, frame_img): @@ -81,7 +116,6 @@ def process_frame(self, frame_num, frame_img): """ cut_list = [] - metric_keys = self._metric_keys _unused = '' # Initialize last scene cut point at the beginning of the frames of interest. @@ -90,41 +124,27 @@ def process_frame(self, frame_num, frame_img): # We can only start detecting once we have a frame to compare with. if self.last_frame is not None: - # We obtain the change in average of HSV (delta_hsv_avg), (h)ue only, + # We obtain the change in average of HSV (frame_score), (h)ue only, # (s)aturation only, and (l)uminance only. These are refered to in a statsfile # as their respective metric keys. + metric_key = (ContentDetector.DELTA_V_KEY if self.luma_only + else ContentDetector.FRAME_SCORE_KEY) if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num, metric_keys)): - delta_hsv_avg, delta_h, delta_s, delta_v = self.stats_manager.get_metrics( - frame_num, metric_keys) + self.stats_manager.metrics_exist(frame_num, [metric_key])): + frame_score = self.stats_manager.get_metrics(frame_num, [metric_key])[0] else: curr_hsv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) last_hsv = self.last_hsv if not last_hsv: last_hsv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2HSV)) - delta_hsv = [0, 0, 0, 0] - for i in range(3): - num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] - curr_hsv[i] = curr_hsv[i].astype(numpy.int32) - last_hsv[i] = last_hsv[i].astype(numpy.int32) - delta_hsv[i] = numpy.sum( - numpy.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) - delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 - delta_h, delta_s, delta_v, delta_hsv_avg = delta_hsv - - if self.stats_manager is not None: - self.stats_manager.set_metrics(frame_num, { - metric_keys[0]: delta_hsv_avg, - metric_keys[1]: delta_h, - metric_keys[2]: delta_s, - metric_keys[3]: delta_v}) + frame_score = self.calculate_frame_score(frame_num, curr_hsv, last_hsv) self.last_hsv = curr_hsv # We consider any frame over the threshold a new scene, but only if # the minimum scene length has been reached (otherwise it is ignored). - if delta_hsv_avg >= self.threshold and ( + if frame_score >= self.threshold and ( (frame_num - self.last_scene_cut) >= self.min_scene_len): cut_list.append(frame_num) self.last_scene_cut = frame_num @@ -135,7 +155,7 @@ def process_frame(self, frame_num, frame_img): # If we have the next frame computed, don't copy the current frame # into last_frame since we won't use it on the next call anyways. if (self.stats_manager is not None and - self.stats_manager.metrics_exist(frame_num+1, metric_keys)): + self.stats_manager.metrics_exist(frame_num+1, self.get_metrics())): self.last_frame = _unused else: self.last_frame = frame_img.copy() diff --git a/scenedetect/detectors/motion_detector.py b/scenedetect/detectors/motion_detector.py index 8b4c0175..52d4e04e 100644 --- a/scenedetect/detectors/motion_detector.py +++ b/scenedetect/detectors/motion_detector.py @@ -79,9 +79,8 @@ def __init__(self, threshold = 0.50, num_frames_post_scene = 30, self.in_motion_event = False self.first_motion_frame_index = -1 self.last_motion_frame_index = -1 - self.cli_name = 'detect-motion' """ - return + def process_frame(self, frame_num, frame_img): # TODO. """ diff --git a/scenedetect/detectors/threshold_detector.py b/scenedetect/detectors/threshold_detector.py index cf49f386..ebf20b06 100644 --- a/scenedetect/detectors/threshold_detector.py +++ b/scenedetect/detectors/threshold_detector.py @@ -73,9 +73,6 @@ class ThresholdDetector(SceneDetector): Attributes: 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_percent: Float between 0.0 and 1.0 which represents the minimum - percent of pixels in a frame that must meet the threshold value 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 @@ -88,14 +85,16 @@ class ThresholdDetector(SceneDetector): tuned to increase performance in some cases; should be computed programmatically in the future). """ - def __init__(self, threshold=12, min_percent=0.95, min_scene_len=15, - fade_bias=0.0, add_final_scene=False, block_size=8): + + THRESHOLD_VALUE_KEY = 'delta_rgb' + + def __init__(self, threshold=12, min_scene_len=15, fade_bias=0.0, + add_final_scene=False, block_size=8): """Initializes threshold-based scene detector object.""" super(ThresholdDetector, self).__init__() self.threshold = int(threshold) self.fade_bias = fade_bias - self.min_percent = min_percent self.min_scene_len = min_scene_len self.processed_frame = False self.last_scene_cut = None @@ -108,62 +107,12 @@ def __init__(self, threshold=12, min_percent=0.95, min_scene_len=15, 'type': None # type of fade, can be either 'in' or 'out' } self.block_size = block_size - self._metric_keys = ['delta_rgb'] - self.cli_name = 'detect-threshold' - - def is_processing_required(self, frame_num): - # type: (int) -> bool - """ Is Processing Required: Test if all calculations are already done. - - TODO: Update statsfile logic to include frame_under_threshold metric - using the threshold + minimum pixel percentage as metric keys (#178). - - Returns: - bool: True, since all frames are required for calculations. - """ - return True + self._metric_keys = [ThresholdDetector.THRESHOLD_VALUE_KEY] - def frame_under_threshold(self, frame): - """Check if the frame is below (true) or above (false) the threshold. - Instead of using the average, we check all pixel values (R, G, and B) - meet the given threshold (within the minimum percent). This ensures - that the threshold is not exceeded while maintaining some tolerance for - compression and noise. + def get_metrics(self): + return self._metric_keys - This is the algorithm used for absolute mode of the threshold detector. - - Returns: - Boolean, True if the number of pixels whose R, G, and B values are - all <= the threshold is within min_percent pixels, or False if not. - """ - # First we compute the minimum number of pixels that need to meet the - # threshold. Internally, we check for values greater than the threshold - # as it's more likely that a given frame contains actual content. This - # is done in blocks of rows, so in many cases we only have to check a - # small portion of the frame instead of inspecting every single pixel. - num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2]) - large_ratio = self.min_percent > 0.5 - ratio = 1.0 - self.min_percent if large_ratio else self.min_percent - min_pixels = int(num_pixel_values * ratio) - - curr_frame_amt = 0 - curr_frame_row = 0 - - while curr_frame_row < frame.shape[0]: - # Add and total the number of individual pixel values (R, G, and B) - # in the current row block that exceed the threshold. - block = frame[curr_frame_row : curr_frame_row + self.block_size, :, :] - if large_ratio: - curr_frame_amt += int(numpy.sum(block > self.threshold)) - else: - curr_frame_amt += int(numpy.sum(block <= self.threshold)) - # If we've already exceeded the most pixels allowed to be above the - # threshold, we can skip processing the rest of the pixels. - if curr_frame_amt > min_pixels: - return not large_ratio - curr_frame_row += self.block_size - return large_ratio def process_frame(self, frame_num, frame_img): # type: (int, Optional[numpy.ndarray]) -> List[int] @@ -194,17 +143,21 @@ def process_frame(self, frame_num, frame_img): # 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 ( - not self.stats_manager.metrics_exist(frame_num, self._metric_keys)): - self.stats_manager.set_metrics( - frame_num, - {self._metric_keys[0]: compute_frame_average(frame_img)}) + 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.frame_under_threshold(frame_img): + if self.last_fade['type'] == 'in' 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 not self.frame_under_threshold(frame_img): + elif self.last_fade['type'] == 'out' 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 @@ -218,13 +171,14 @@ def process_frame(self, frame_num, frame_img): self.last_fade['frame'] = frame_num else: self.last_fade['frame'] = 0 - if self.frame_under_threshold(frame_img): + 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): """Writes a final scene cut if the last detected fade was a fade-out. diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 008bec62..489fd085 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -50,6 +50,7 @@ from __future__ import print_function import csv +import logging import os import os.path import platform @@ -90,51 +91,22 @@ # pylint: enable=invalid-name, undefined-variable -## -## OpenCV Compatibility Fixes -## - -def opencv_version_required(min_version, version=None): - # type: (List[int], Optional[str]) - """ Checks if the OpenCV library version is at least min_version. - - Arguments: - min_version: List[int] of the version to compare against. - version: Optional string representing version string to - compare with, used for testing purposes. - - Returns: - bool: True if the installed version is at least min_version, - False otherwise. - """ - if version is None: - version = cv2.__version__ - if not version[0].isdigit(): - return False - try: - version = [int(x) for x in version.split('.')] - if len(version) < len(min_version): - version += [0] * (len(min_version) - len(version)) - return not any([x[0] < x[1] for x in zip(version, min_version)]) - except ValueError: - return False - - # Compatibility fix for OpenCV v2.x (copies CAP_PROP_* properties from the # cv2.cv namespace to the cv2 namespace, as the cv2.cv namespace was removed # with the release of OpenCV 3.0). -if not opencv_version_required([3, 0]): +if not 'CAP_PROP_FPS' in dir(cv2): cv2.CAP_PROP_FRAME_WIDTH = cv2.cv.CV_CAP_PROP_FRAME_WIDTH cv2.CAP_PROP_FRAME_HEIGHT = cv2.cv.CV_CAP_PROP_FRAME_HEIGHT cv2.CAP_PROP_FPS = cv2.cv.CV_CAP_PROP_FPS cv2.CAP_PROP_POS_MSEC = cv2.cv.CV_CAP_PROP_POS_MSEC cv2.CAP_PROP_POS_FRAMES = cv2.cv.CV_CAP_PROP_POS_FRAMES cv2.CAP_PROP_FRAME_COUNT = cv2.cv.CV_CAP_PROP_FRAME_COUNT + cv2.CAP_PROP_FOURCC = cv2.cv.CV_CAP_PROP_FOURCC cv2.INTER_CUBIC = cv2.cv.INTER_CUBIC def get_aspect_ratio(cap, epsilon=0.01): - # type: (cv2.VideoCapture) -> float + # type: (cv2.VideoCapture, float) -> float """ Compatibility fix for OpenCV < v3.4.1 to get the aspect ratio of a video. For older versions, this function always returns 1.0. @@ -148,7 +120,7 @@ def get_aspect_ratio(cap, epsilon=0.01): if for some reason the numerator/denominator returned is zero (can happen if the video was not opened correctly). """ - if not opencv_version_required([3, 4, 1]): + if not 'CAP_PROP_SAR_NUM' in dir(cv2): return 1.0 num = cap.get(cv2.CAP_PROP_SAR_NUM) den = cap.get(cv2.CAP_PROP_SAR_DEN) @@ -195,16 +167,6 @@ def check_opencv_ffmpeg_dll(): ## OpenCV imwrite Supported Image Types & Quality/Compression Parameters ## -def _get_cv2_param(param_name): - # type: (str) -> Union[int, None] - if param_name.startswith('CV_'): - param_name = param_name[3:] - try: - return getattr(cv2, param_name) - except AttributeError: - return None - - def get_cv2_imwrite_params(): # type: () -> Dict[str, Union[int, None]] """ Get OpenCV imwrite Params: Returns a dict of supported image formats and @@ -216,6 +178,16 @@ def get_cv2_imwrite_params(): compression parameter (e.g. 'jpg' -> cv2.IMWRITE_JPEG_QUALITY, 'png' -> cv2.IMWRITE_PNG_COMPRESSION).. """ + + def _get_cv2_param(param_name): + # type: (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'), @@ -277,6 +249,53 @@ def get_and_create_path(file_path, output_directory=None): return file_path +## +## Logging +## + +def init_logger(log_level=logging.INFO, show_stdout=False, log_file=None): + """ Initializes the Python logging module for PySceneDetect. + + Mainly used by the command line interface, but can also be used by other modules + by calling init_logger(). The logger instance used is named 'pyscenedetect-logger'. + + All existing log handlers are removed every time this function is invoked. + + Arguments: + log_level: Verbosity of log messages. + quiet_mode: If True, no output will be generated to stdout. + log_file: File to also send messages to, in addition to stdout. + """ + # Format of log messages depends on verbosity. + format_str = '[PySceneDetect] %(message)s' + if log_level == logging.DEBUG: + format_str = '%(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=format_str)) + logger_instance.addHandler(handler) + # Add file handler if required. + if log_file: + log_file = get_and_create_path(log_file) + handler = logging.FileHandler(log_file) + handler.setLevel(log_level) + handler.setFormatter(logging.Formatter(fmt=format_str)) + logger_instance.addHandler(handler) + return logger_instance + +# Default logger to be used by library objects. +logger = init_logger() + + +## +## Running External Commands +## class CommandTooLong(Exception): """ Raised when the length of a command line argument doesn't play nicely @@ -284,9 +303,8 @@ class CommandTooLong(Exception): # pylint: disable=unnecessary-pass pass - def invoke_command(args): - # type: (List[str] -> None) + # type: (List[str]) -> None """ Same as calling Python's subprocess.call() method, but explicitly raises a different exception when the command length is too long. diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index f813fa74..e4da3af8 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -37,7 +37,7 @@ class SceneDetector(object): - """ Base class to inheret from when implementing a scene detection algorithm. + """ Base class to inherit from when implementing a scene detection algorithm. This represents a "dense" scene detector, which returns a list of frames where the next scene/shot begins in a video. @@ -50,13 +50,6 @@ class SceneDetector(object): """ Optional :py:class:`StatsManager ` to use for caching frame metrics to and from.""" - _metric_keys = [] - """ List of frame metric keys to be registered with the :py:attr:`stats_manager`, - if available. """ - - cli_name = 'detect-none' - """ Name of detector to use in command-line interface description. """ - def is_processing_required(self, frame_num): # type: (int) -> bool """ Is Processing Required: Test if all calculations for a given frame are already done. @@ -70,9 +63,20 @@ def is_processing_required(self, frame_num): True otherwise (i.e. the frame_img passed to process_frame is required to be passed to process_frame for the given frame_num). """ - return not self._metric_keys or not ( + 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, self._metric_keys)) + self.stats_manager.metrics_exist(frame_num, metric_keys)) + + + def stats_manager_required(self): + # type: () -> bool + """ Stats Manager Required: Prototype indicating if detector requires stats. + + Returns: + bool: True if a StatsManager is required for the detector, False otherwise. + """ + return False def get_metrics(self): @@ -83,7 +87,7 @@ def get_metrics(self): List[str]: A list of strings of frame metric key names that will be used by the detector when a StatsManager is passed to process_frame. """ - return self._metric_keys + return [] def process_frame(self, frame_num, frame_img): diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index cc65d4df..46c5c63b 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -64,6 +64,7 @@ from scenedetect.frame_timecode import FrameTimecode from scenedetect.platform import get_csv_writer from scenedetect.platform import get_cv2_imwrite_params +from scenedetect.stats_manager import StatsManager from scenedetect.stats_manager import FrameMetricRegistered from scenedetect.scene_detector import SparseSceneDetector @@ -71,6 +72,8 @@ from scenedetect.thirdparty.simpletable import SimpleTableRow, SimpleTable, HTMLPage +logger = logging.getLogger('pyscenedetect') + ## ## SceneManager Helper Functions @@ -325,7 +328,7 @@ def save_images(scene_list, video_manager, num_images=3, frame_margin=1, # Setup flags and init progress bar if available. completed = True - logging.info('Generating output images (%d per scene)...', num_images) + logger.info('Generating output images (%d per scene)...', num_images) progress_bar = None if show_progress and tqdm: progress_bar = tqdm( @@ -394,7 +397,7 @@ def save_images(scene_list, video_manager, num_images=3, frame_margin=1, frame_im = cv2.resize( frame_im, (0, 0), fx=aspect_ratio, fy=1.0, interpolation=cv2.INTER_CUBIC) - + # Get frame dimensions prior to resizing or scaling frame_height = frame_im.shape[0] frame_width = frame_im.shape[1] @@ -428,7 +431,7 @@ def save_images(scene_list, video_manager, num_images=3, frame_margin=1, progress_bar.update(1) if not completed: - logging.error('Could not generate all output images.') + logger.error('Could not generate all output images.') return image_filenames @@ -469,6 +472,12 @@ def add_detector(self, detector): Arguments: detector (SceneDetector): Scene detector to add to the SceneManager. """ + if self._stats_manager is None and detector.stats_manager_required(): + # Make sure the lists are empty so that the detectors don't get + # out of sync (require an explicit statsmanager instead) + assert not self._detector_list and not self._sparse_detector_list + self._stats_manager = StatsManager() + detector.stats_manager = self._stats_manager if self._stats_manager is not None: # Allow multiple detection algorithms of the same type to be added @@ -617,11 +626,11 @@ def _post_process(self, frame_num): for detector in self._detector_list: self._cutting_list += detector.post_process(frame_num) - def detect_scenes(self, frame_source, end_time=None, frame_skip=0, show_progress=True, callback=None): # type: (VideoManager, Union[int, FrameTimecode], - # Optional[Union[int, FrameTimecode]], Optional[bool], optional[callable[numpy.ndarray]) -> int + # Optional[Union[int, FrameTimecode]], Optional[bool], + # Optional[Callable[numpy.ndarray]) -> int """ Perform scene detection on the given frame_source using the added SceneDetectors. Blocks until all frames in the frame_source have been processed. Results can @@ -644,7 +653,9 @@ def detect_scenes(self, frame_source, end_time=None, frame_skip=0, a progress bar with the progress, framerate, and expected time to complete processing the video frame source. callback ((image_ndarray, frame_num: int) -> None): If not None, called after - each scene/event detected. + each scene/event detected. Note that the signature of the callback will + undergo breaking changes in v0.6 to provide more context to the callback + (detector type, event type, etc... - see #177 for further details). Returns: int: Number of frames read and processed from the frame source. Raises: diff --git a/scenedetect/stats_manager.py b/scenedetect/stats_manager.py index fc2eaee4..a8f011a9 100644 --- a/scenedetect/stats_manager.py +++ b/scenedetect/stats_manager.py @@ -55,6 +55,7 @@ # pylint: disable=useless-super-delegation +logger = logging.getLogger('pyscenedetect') ## ## StatsManager CSV File Column Names (Header Row) @@ -240,7 +241,7 @@ def save_to_csv(self, csv_file, base_timecode, force_save=True): csv_writer.writerow( [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) frame_keys = sorted(self._frame_metrics.keys()) - print("Writing %d frames to CSV..." % len(frame_keys)) + logger.info("Writing %d frames to CSV...", len(frame_keys)) for frame_key in frame_keys: frame_timecode = base_timecode + frame_key csv_writer.writerow( @@ -305,7 +306,7 @@ def load_from_csv(self, csv_file, reset_save_required=True): num_metrics = num_cols - 2 if not num_metrics > 0: raise StatsFileCorrupt('No metrics defined in CSV file.') - metric_keys = row[2:] + self._loaded_metrics = row[2:] num_frames = 0 for row in csv_reader: metric_dict = {} @@ -314,12 +315,12 @@ def load_from_csv(self, csv_file, reset_save_required=True): for i, metric_str in enumerate(row[2:]): if metric_str and metric_str != 'None': try: - metric_dict[metric_keys[i]] = float(metric_str) + metric_dict[self._loaded_metrics[i]] = float(metric_str) except ValueError: raise StatsFileCorrupt('Corrupted value in stats file: %s' % metric_str) self.set_metrics(int(row[0]), metric_dict) num_frames += 1 - logging.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) + logger.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) if reset_save_required: self._metrics_updated = False return num_frames diff --git a/scenedetect/video_manager.py b/scenedetect/video_manager.py index e120f89b..a65bdbe6 100644 --- a/scenedetect/video_manager.py +++ b/scenedetect/video_manager.py @@ -57,10 +57,10 @@ import cv2 # PySceneDetect Library Imports +from scenedetect.platform import logger as default_logger from scenedetect.platform import STRING_TYPE from scenedetect.frame_timecode import FrameTimecode, MINIMUM_FRAMES_PER_SECOND_FLOAT - ## ## VideoManager Exceptions ## @@ -336,7 +336,7 @@ class VideoManager(object): """ 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. """ - def __init__(self, video_files, framerate=None, logger=None): + def __init__(self, video_files, framerate=None, logger=default_logger): # type: (List[str], Optional[float]) """ VideoManager Constructor Method (__init__) @@ -372,12 +372,13 @@ def __init__(self, video_files, framerate=None, logger=None): self._logger = logger if self._logger is not None: self._logger.info( - 'Loaded %d video%s, framerate: %.2f FPS, resolution: %d x %d', + '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._downscale_factor = 1 - self._frame_length = get_num_frames(self._cap_list) + 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]]) def set_downscale_factor(self, downscale_factor=None): @@ -550,15 +551,15 @@ def set_duration(self, duration=None, start_time=None, end_time=None): self._start_time = start_time if end_time is not None: - if end_time < start_time: + 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.get_frames() + 1) - self._frame_length -= self._start_time.get_frames() + 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( @@ -580,11 +581,10 @@ def get_duration(self): Tuple[FrameTimecode, FrameTimecode, FrameTimecode]: The current video(s) total duration, start timecode, and end timecode. """ - frame_length = self.get_base_timecode() + self._frame_length end_time = self._end_time if end_time is None: - end_time = self.get_base_timecode() + frame_length - return (frame_length, self._start_time, end_time) + end_time = self.get_base_timecode() + self._frame_length + return (self._frame_length, self._start_time, end_time) def start(self): @@ -627,10 +627,23 @@ def seek(self, timecode): if not self._started: raise VideoDecoderNotStarted() - if isinstance(self._curr_cap, cv2.VideoCapture): - 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 + 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.warn('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(): # raises VideoDecoderNotStarted if start() was not called @@ -687,7 +700,7 @@ def get(self, capture_prop, index=None): 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 + 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: @@ -716,11 +729,13 @@ def grab(self): grabbed = self._curr_cap.grab() if not grabbed and not self._get_next_cap(): break - else: - self._curr_time += 1 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 @@ -790,6 +805,8 @@ def read(self): self._last_frame = None if read_frame: self._curr_time += 1 + else: + self._correct_frame_length() return (read_frame, self._last_frame) @@ -807,3 +824,13 @@ def _get_next_cap(self): self._curr_cap_idx += 1 self._curr_cap = self._cap_list[self._curr_cap_idx] return True + + + def _correct_frame_length(self): + # type: () -> 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 diff --git a/scenedetect/video_splitter.py b/scenedetect/video_splitter.py index d14e7670..985eed57 100644 --- a/scenedetect/video_splitter.py +++ b/scenedetect/video_splitter.py @@ -80,6 +80,8 @@ # PySceneDetect Imports from scenedetect.platform import tqdm, invoke_command, CommandTooLong +logger = logging.getLogger('pyscenedetect') + 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 @@ -148,15 +150,19 @@ def split_video_mkvmerge(input_video_paths, scene_list, output_file_template, Can use $VIDEO_NAME as a parameter in the template. video_name (str): Name of the video to be substituted in output_file_template. suppress_output (bool): If True, adds the --quiet flag when invoking `mkvmerge`. + + Returns: + Optional[int]: Return code of invoking mkvmerge (0 on success). Returns None if + there are no videos or scenes to process. """ if not input_video_paths or not scene_list: - return + return None - logging.info('Splitting input video%s using mkvmerge, output path template:\n %s', + logger.info('Splitting input video%s using mkvmerge, output path template:\n %s', 's' if len(input_video_paths) > 1 else '', output_file_template) - ret_val = None + 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', '') @@ -181,22 +187,23 @@ def split_video_mkvmerge(input_video_paths, scene_list, output_file_template, ret_val = invoke_command(call_list) if not suppress_output: print('') - logging.info('Average processing speed %.2f frames/sec.', + logger.info('Average processing speed %.2f frames/sec.', float(total_frames) / (time.time() - processing_start_time)) except CommandTooLong: - logging.error(COMMAND_TOO_LONG_STRING) + logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logging.error('mkvmerge could not be found on the system.' + logger.error('mkvmerge could not be found on the system.' ' Please install mkvmerge to enable video output support.') - if ret_val is not None and ret_val != 0: - logging.error('Error splitting video (mkvmerge returned %d).', ret_val) + if ret_val != 0: + logger.error('Error splitting video (mkvmerge returned %d).', ret_val) + return ret_val def split_video_ffmpeg(input_video_paths, scene_list, output_file_template, video_name, arg_override='-c:v libx264 -preset fast -crf 21 -c:a aac', hide_progress=False, suppress_output=False): # type: (List[str], List[Tuple[FrameTimecode, FrameTimecode]], Optional[str], - # Optional[str], Optional[bool], Optional[bool]) -> None + # Optional[str], Optional[bool], Optional[bool]) -> Optional[int] """ Calls the ffmpeg command on the input video(s), generating a new video for each scene based on the start/end timecodes. @@ -212,20 +219,26 @@ def split_video_ffmpeg(input_video_paths, scene_list, output_file_template, vide arg_override (str): Allows overriding the arguments passed to ffmpeg for encoding. hide_progress (bool): If True, will hide progress bar provided by tqdm (if installed). suppress_output (bool): If True, will set verbosity to quiet for the first scene. + + Returns: + Optional[int]: Return code of invoking ffmpeg (0 on success). Returns None if + there are no videos or scenes to process. """ if not input_video_paths or not scene_list: - return + return None - logging.info( + logger.info( 'Splitting input video%s using ffmpeg, output path template:\n %s', 's' if len(input_video_paths) > 1 else '', output_file_template) if len(input_video_paths) > 1: # TODO: Add support for splitting multiple/appended input videos. - # https://trac.ffmpeg.org/wiki/Concatenate#samecodec - # Requires generating a temporary file list for ffmpeg. - logging.error( + # https://github.com/Breakthrough/PySceneDetect/issues/71 + # + # Requires generating a temporary file list for ffmpeg to use as an input + # (see https://trac.ffmpeg.org/wiki/Concatenate#samecodec for details). + logger.error( 'Sorry, splitting multiple appended/concatenated input videos with' ' ffmpeg is not supported yet. This feature will be added to a future' ' version of PySceneDetect. In the meantime, you can try using the' @@ -235,7 +248,7 @@ def split_video_ffmpeg(input_video_paths, scene_list, output_file_template, vide arg_override = arg_override.replace('\\"', '"') - ret_val = None + ret_val = 0 arg_override = arg_override.split(' ') filename_template = Template(output_file_template) scene_num_format = '%0' @@ -280,21 +293,21 @@ def split_video_ffmpeg(input_video_paths, scene_list, output_file_template, vide ] ret_val = invoke_command(call_list) if not suppress_output and i == 0 and len(scene_list) > 1: - logging.info( + logger.info( 'Output from ffmpeg for Scene 1 shown above, splitting remaining scenes...') if ret_val != 0: + logger.error('Error splitting video (ffmpeg returned %d).', ret_val) break if progress_bar: progress_bar.update(duration.get_frames()) if progress_bar: print('') - logging.info('Average processing speed %.2f frames/sec.', + logger.info('Average processing speed %.2f frames/sec.', float(total_frames) / (time.time() - processing_start_time)) except CommandTooLong: - logging.error(COMMAND_TOO_LONG_STRING) + logger.error(COMMAND_TOO_LONG_STRING) except OSError: - logging.error('ffmpeg could not be found on the system.' + logger.error('ffmpeg could not be found on the system.' ' Please install ffmpeg to enable video output support.') - if ret_val is not None and ret_val != 0: - logging.error('Error splitting video (ffmpeg returned %d).', ret_val) + return ret_val diff --git a/setup.py b/setup.py index ffef1cd3..0fa4da0d 100644 --- a/setup.py +++ b/setup.py @@ -71,7 +71,7 @@ def get_extra_requires(): setup( name='scenedetect', - version='0.5.5', + version='0.5.6', description="A cross-platform, OpenCV-based video scene detection program and Python library. ", long_description=open('package-info.rst').read(), author='Brandon Castellano', @@ -111,5 +111,11 @@ def get_extra_requires(): 'Topic :: Multimedia :: Video :: Conversion', 'Topic :: Multimedia :: Video :: Non-Linear Editor', 'Topic :: Utilities' - ] + ], + project_urls={ + 'Homepage': 'https://pyscenedetect.readthedocs.io/', + 'Manual': 'https://pyscenedetect.readthedocs.io/projects/Manual/en/latest/', + 'Changelog': 'https://pyscenedetect.readthedocs.io/en/latest/changelog/', + 'Bug Tracker': 'https://github.com/Breakthrough/PySceneDetect/issues', + } ) diff --git a/tests/conftest.py b/tests/conftest.py index 8e99f656..9adb25cd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -71,3 +71,12 @@ def test_video_file(): Access in test case by adding a test_video_file argument to obtain the path. """ return get_absolute_path("testvideo.mp4") + +@pytest.fixture +def test_movie_clip(): + # type: () -> str + """ Fixture for test movie clip path (ensures file exists). + + Access in test case by adding a test_movie_clip argument to obtain the path. + """ + return get_absolute_path("goldeneye/goldeneye.mp4") diff --git a/tests/test_detectors.py b/tests/test_detectors.py new file mode 100644 index 00000000..12ce3fd8 --- /dev/null +++ b/tests/test_detectors.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# [ Documentation: http://pyscenedetect.readthedocs.org/ ] +# +# Copyright (C) 2014-2021 Brandon Castellano . +# +# PySceneDetect is licensed under the BSD 3-Clause License; see the included +# LICENSE file, or visit one of the following pages for details: +# - https://github.com/Breakthrough/PySceneDetect/ +# - http://www.bcastell.com/projects/PySceneDetect/ +# +# This software uses Numpy, OpenCV, click, tqdm, simpletable, and pytest. +# See the included LICENSE files or one of the above URLs for more information. +# +# 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 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. +# + +""" PySceneDetect Scene Detection Tests + +These tests ensure that the detection algorithms deliver consistent +results by using known ground truths of scene cut locations in the +test case material. +""" + +# Standard project pylint disables for unit tests using pytest. +# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name +# pylint: disable=redefined-outer-name + +# PySceneDetect Library Imports +from scenedetect.scene_manager import SceneManager +from scenedetect.frame_timecode import FrameTimecode +from scenedetect.video_manager import VideoManager +from scenedetect.stats_manager import StatsManager +from scenedetect.detectors import ContentDetector +from scenedetect.detectors import ThresholdDetector +from scenedetect.detectors import AdaptiveDetector + + +# Test case ground truth format: (threshold, [scene start frame]) +TEST_MOVIE_CLIP_GROUND_TRUTH_CONTENT = [ + (30, [1198, 1226, 1260, 1281, 1334, 1365, 1697, 1871]), + (27, [1198, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871]) +] + + +# TODO: Remove when an actual detector factory is created. +def create_detector(detector_type, video_manager): + if detector_type == AdaptiveDetector: + return detector_type(video_manager=video_manager) + return detector_type() + + + +def test_content_detector(test_movie_clip): + """ Test SceneManager with VideoManager and ContentDetector. """ + for threshold, start_frames in TEST_MOVIE_CLIP_GROUND_TRUTH_CONTENT: + vm = VideoManager([test_movie_clip]) + sm = SceneManager() + sm.add_detector(ContentDetector(threshold=threshold)) + + try: + video_fps = vm.get_framerate() + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + vm.set_duration(start_time=start_time, end_time=end_time) + vm.set_downscale_factor() + + vm.start() + sm.detect_scenes(frame_source=vm) + scene_list = sm.get_scene_list() + assert len(scene_list) == len(start_frames) + detected_start_frames = [ + timecode.get_frames() for timecode, _ in scene_list ] + assert all(x == y for (x, y) in zip(start_frames, detected_start_frames)) + + finally: + vm.release() + + +def test_adaptive_detector(test_movie_clip): + """ Test SceneManager with VideoManager and AdaptiveDetector. """ + # We use the ground truth of ContentDetector with threshold=27. + start_frames = TEST_MOVIE_CLIP_GROUND_TRUTH_CONTENT[1][1] + vm = VideoManager([test_movie_clip]) + sm = SceneManager() + assert sm._stats_manager is None + # The SceneManager should implicitly create a StatsManager since this + # detector requires it. + sm.add_detector(AdaptiveDetector(video_manager=vm)) + assert sm._stats_manager is not None + + try: + video_fps = vm.get_framerate() + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + vm.set_duration(start_time=start_time, end_time=end_time) + vm.set_downscale_factor() + + vm.start() + sm.detect_scenes(frame_source=vm) + scene_list = sm.get_scene_list() + assert len(scene_list) == len(start_frames) + detected_start_frames = [ + timecode.get_frames() for timecode, _ in scene_list ] + assert all(x == y for (x, y) in zip(start_frames, detected_start_frames)) + + finally: + vm.release() + + +# Defaults for now. +TEST_VIDEO_FILE_GROUND_TRUTH_THRESHOLD = [ + 0, 15, 198, 376 +] + +def test_threshold_detector(test_video_file): + """ Test SceneManager with VideoManager and ThresholdDetector. """ + vm = VideoManager([test_video_file]) + sm = SceneManager() + sm.add_detector(ThresholdDetector()) + + try: + vm.set_downscale_factor() + + vm.start() + sm.detect_scenes(frame_source=vm) + scene_list = sm.get_scene_list() + assert len(scene_list) == len(TEST_VIDEO_FILE_GROUND_TRUTH_THRESHOLD) + detected_start_frames = [ + timecode.get_frames() for timecode, _ in scene_list ] + assert all(x == y for (x, y) in zip( + TEST_VIDEO_FILE_GROUND_TRUTH_THRESHOLD, detected_start_frames)) + + finally: + vm.release() + + + +def test_detectors_with_stats(test_video_file): + """ Test all detectors functionality with a StatsManager. """ + for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector]: + vm = VideoManager([test_video_file]) + stats = StatsManager() + sm = SceneManager(stats_manager=stats) + sm.add_detector(create_detector(detector, vm)) + + try: + end_time = FrameTimecode('00:00:15', vm.get_framerate()) + + vm.set_duration(end_time=end_time) + vm.set_downscale_factor() + + vm.start() + sm.detect_scenes(frame_source=vm) + initial_scene_len = len(sm.get_scene_list()) + assert initial_scene_len > 0 # test case must have at least one scene! + # Re-analyze using existing stats manager. + sm = SceneManager(stats_manager=stats) + sm.add_detector(create_detector(detector, vm)) + + vm.release() + vm.reset() + vm.set_duration(end_time=end_time) + vm.set_downscale_factor() + vm.start() + + sm.detect_scenes(frame_source=vm) + scene_list = sm.get_scene_list() + assert len(scene_list) == initial_scene_len + + finally: + vm.release() diff --git a/tests/test_platform.py b/tests/test_platform.py index 23f7b057..c7b1af0f 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -44,7 +44,6 @@ from scenedetect.video_manager import VideoManager from scenedetect.platform import CommandTooLong, invoke_command -from scenedetect.platform import opencv_version_required from scenedetect.platform import get_aspect_ratio @@ -63,37 +62,6 @@ def test_long_command(): invoke_command('x' * 2**15) -def test_opencv_version_required(): - """ Test version requirement function for checking installed OpenCV - version to ensure compatibility layer works correctly. """ - - version = "3.1.2" - assert opencv_version_required([2, 0, 0], version) - assert opencv_version_required([3], version) - assert opencv_version_required([3, 0], version) - assert opencv_version_required([3, 1], version) - assert opencv_version_required([3, 1, 1], version) - assert opencv_version_required([3, 1, 2], version) - assert not opencv_version_required([3, 1, 3], version) - assert not opencv_version_required([3, 2], version) - assert not opencv_version_required([4], version) - - version = "3.1" - assert opencv_version_required([2, 0, 0], version) - assert opencv_version_required([3], version) - assert opencv_version_required([3, 0], version) - assert opencv_version_required([3, 1], version) - assert not opencv_version_required([3, 1, 1], version) - assert not opencv_version_required([3, 1, 2], version) - assert not opencv_version_required([3, 2], version) - assert not opencv_version_required([4], version) - - # Test invalid version strings. - # Incorrect version strings always return False. - assert not opencv_version_required([2, 0, 0], "b21412") - assert not opencv_version_required([2, 0, 0], "2b.4") - - def test_get_aspect_ratio(test_video_file): """ Test get_aspect_ratio function. """ expected_value = 1.0 diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 86c46e4d..4ee623da 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -65,7 +65,7 @@ from scenedetect.detectors import ContentDetector -def test_content_detect(test_video_file): +def test_using_pyscenedetect_videomanager(test_video_file): """ Test SceneManager with VideoManager and ContentDetector. """ vm = VideoManager([test_video_file]) sm = SceneManager() @@ -87,7 +87,7 @@ def test_content_detect(test_video_file): vm.release() -def test_content_detect_opencv_videocap(test_video_file): +def test_using_opencv_videocapture(test_video_file): """ Test SceneManager with cv2.VideoCapture and ContentDetector. """ cap = cv2.VideoCapture(test_video_file) sm = SceneManager() @@ -185,3 +185,44 @@ def test_save_images(test_video_file): vm.release() for path in glob.glob(image_name_glob): os.remove(path) + + +class FakeCallback(object): + """ Fake callback used for testing purposes only. Currently just stores + the number of times the callback was invoked.""" + def __init__(self): + self._i = 0 + + def num_invoked(self): + return self._i + + def get_callback(self): + return lambda image, frame_num: self._callback(image, frame_num) + + def _callback(self, image, frame_num): + self._i += 1 + + +def test_detect_scenes_callback(test_video_file): + """ Test SceneManager detect_scenes method with a callback function. + + Note that the API signature of the callback will undergo breaking changes in v0.6. + """ + vm = VideoManager([test_video_file]) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + fake_callback = FakeCallback() + + try: + video_fps = vm.get_framerate() + start_time = FrameTimecode('00:00:05', video_fps) + end_time = FrameTimecode('00:00:15', video_fps) + vm.set_duration(start_time=start_time, end_time=end_time) + vm.set_downscale_factor() + vm.start() + num_frames = sm.detect_scenes(frame_source=vm, callback=fake_callback.get_callback()) + assert fake_callback.num_invoked() == (len(sm.get_scene_list()) - 1) + + finally: + vm.release()