diff --git a/.github/actions/setup-ffmpeg/action.yml b/.github/actions/setup-ffmpeg/action.yml new file mode 100644 index 00000000..2bfb4f7e --- /dev/null +++ b/.github/actions/setup-ffmpeg/action.yml @@ -0,0 +1,41 @@ +name: 'Setup FFmpeg' +inputs: + github-token: + required: true + +runs: + using: 'composite' + steps: + - name: Setup FFmpeg (latest) + id: latest + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + + - name: Setup FFmpeg (7.0.0) + if: ${{ steps.latest.outcome == 'failure' }} + id: v7-0-0 + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "7.0.0" + + - name: Setup FFmpeg (6.1.1) + if: ${{ steps.v7-0-0.outcome == 'failure' }} + id: v6-1-1 + continue-on-error: true + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "6.1.1" + + # The oldest version we allow falling back to must not have `continue-on-error: true` + - name: Setup FFmpeg (6.1.0) + if: ${{ steps.v6-1-1.outcome == 'failure' }} + id: v6-1-0 + uses: FedericoCarboni/setup-ffmpeg@v3 + with: + github-token: ${{ inputs.github-token }} + ffmpeg-version: "6.1.0" diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index febc4f6c..b9305634 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -30,11 +30,14 @@ jobs: matrix: python-version: ["3.9"] + env: + ffmpeg-version: "7.0" + steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' @@ -50,16 +53,16 @@ jobs: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ - - name: Download FFMPEG + - name: Download FFMPEG ${{ env.ffmpeg-version }} uses: dsaltares/fetch-gh-release-asset@1.1.1 with: repo: 'GyanD/codexffmpeg' - version: 'tags/6.0' - file: 'ffmpeg-6.0-full_build.7z' + version: 'tags/${{ env.ffmpeg-version }}' + file: 'ffmpeg-${{ env.ffmpeg-version }}-full_build.7z' - name: Unit Test run: | - 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r + 7z e ffmpeg-${{ env.ffmpeg-version }}-full_build.7z ffmpeg.exe -r python -m pytest -vv - name: Build PySceneDetect @@ -77,7 +80,7 @@ jobs: Move-Item -Path dist/windows/README* -Destination dist/scenedetect/ Move-Item -Path dist/windows/LICENSE* -Destination dist/scenedetect/thirdparty/ Move-Item -Path scenedetect/_thirdparty/LICENSE* -Destination dist/scenedetect/thirdparty/ - 7z e -odist/ffmpeg ffmpeg-6.0-full_build.7z LICENSE -r + 7z e -odist/ffmpeg ffmpeg-${{ env.ffmpeg-version }}-full_build.7z LICENSE -r Move-Item -Path ffmpeg.exe -Destination dist/scenedetect/ffmpeg.exe Move-Item -Path dist/ffmpeg/LICENSE -Destination dist/scenedetect/thirdparty/LICENSE-FFMPEG @@ -95,7 +98,7 @@ jobs: runs-on: windows-latest needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: ref: resources diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index eb710405..4ccdfb69 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,22 +27,39 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [macos-latest, ubuntu-20.04, ubuntu-latest, windows-latest] - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + os: [macos-13, macos-14, ubuntu-20.04, ubuntu-latest, windows-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"] + exclude: + # macos-14 builders use M1 (ARM64) which does not have a Python 3.7 package available. + - os: macos-14 + python-version: "3.7" + + env: + # Version is extracted below and used to find correct package install path. + scenedetect_version: "" + # Setuptools must be pinned for the Python 3.7 builders. + setuptools_version: "${{ matrix.python-version == '3.7' && '==62.3.4' || '' }}" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + + - name: Setup FFmpeg + # TODO: This action currently does not work for non-x64 builders (e.g. macos-14): + # https://github.com/federicocarboni/setup-ffmpeg/issues/21 + if: ${{ runner.arch == 'X64' }} + uses: ./.github/actions/setup-ffmpeg + with: + github-token: ${{ secrets.GITHUB_TOKEN }} - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install Dependencies - # TODO: `setuptools` is pinned for the Python 3.7 builder and can be unpinned when removed. run: | - python -m pip install --upgrade pip build wheel virtualenv setuptools==62.3.4 + python -m pip install --upgrade pip build wheel virtualenv setuptools${{ env.setuptools_version }} pip install av opencv-python-headless --only-binary :all: pip install -r requirements_headless.txt @@ -51,21 +68,6 @@ jobs: git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources git checkout refs/remotes/origin/resources -- tests/resources/ - # TODO: Cache this: https://github.com/actions/cache - # TODO: Install ffmpeg/mkvtoolnix on all runners. - - name: Download FFMPEG - if: ${{ matrix.os == 'windows-latest' }} - uses: dsaltares/fetch-gh-release-asset@1.1.1 - with: - repo: 'GyanD/codexffmpeg' - version: 'tags/6.0' - file: 'ffmpeg-6.0-full_build.7z' - - - name: Extract FFMPEG - if: ${{ matrix.os == 'windows-latest' }} - run: | - 7z e ffmpeg-6.0-full_build.7z ffmpeg.exe -r - - name: Unit Tests run: | python -m pytest -vv @@ -77,22 +79,13 @@ jobs: python -m scenedetect -i tests/resources/testvideo.mp4 -b pyav time --end 2s python -m pip uninstall -y scenedetect - - name: Build Documentation - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} - run: | - pip install -r docs/requirements.txt - git mv docs docs_src - sphinx-build -b singlehtml docs_src docs - - # TODO: Make the version extraction work on powershell so package smoke tests can run on Windows. - name: Build Package - if: ${{ matrix.os != 'windows-latest' }} + shell: bash run: | python -m build echo "scenedetect_version=`python -c \"import scenedetect; print(scenedetect.__version__.replace('-', '.'))\"`" >> "$GITHUB_ENV" - name: Smoke Test Package (Source Dist) - if: ${{ matrix.os != 'windows-latest' }} run: | python -m pip install dist/scenedetect-${{ env.scenedetect_version }}.tar.gz scenedetect version @@ -101,7 +94,6 @@ jobs: python -m pip uninstall -y scenedetect - name: Smoke Test Package (Wheel) - if: ${{ matrix.os != 'windows-latest' }} run: | python -m pip install dist/scenedetect-${{ env.scenedetect_version }}-py3-none-any.whl scenedetect version @@ -110,7 +102,7 @@ jobs: python -m pip uninstall -y scenedetect - name: Upload Package - if: ${{ matrix.python-version == '3.11' && matrix.os == 'ubuntu-latest' }} + if: ${{ matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' }} uses: actions/upload-artifact@v3 with: name: scenedetect-dist diff --git a/.github/workflows/check-code-format.yml b/.github/workflows/check-code-format.yml index 407d9664..49c51b61 100644 --- a/.github/workflows/check-code-format.yml +++ b/.github/workflows/check-code-format.yml @@ -17,11 +17,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.11 + - uses: actions/checkout@v4 + - name: Set up Python 3.12 uses: actions/setup-python@v3 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Update pip diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d01ee55f..b99eaeb3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index b0dedc42..4e751977 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -15,6 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: 'Dependency Review' uses: actions/dependency-review-action@v3 diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml index 7c0b0002..c3adbc9d 100644 --- a/.github/workflows/generate-docs.yml +++ b/.github/workflows/generate-docs.yml @@ -20,12 +20,12 @@ jobs: scenedetect_docs_dest: '' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Set Destination (Releases) diff --git a/.github/workflows/generate-website.yml b/.github/workflows/generate-website.yml index cee6c601..328ac2cc 100644 --- a/.github/workflows/generate-website.yml +++ b/.github/workflows/generate-website.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - name: Set up Python 3.12 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' cache: 'pip' - name: Install Dependencies diff --git a/dist/package-info.rst b/dist/package-info.rst index 91ea9e9d..94fdfc8e 100644 --- a/dist/package-info.rst +++ b/dist/package-info.rst @@ -5,13 +5,10 @@ PySceneDetect Video Scene Cut Detection and Analysis Tool ---------------------------------------------------------- -.. image:: https://img.shields.io/github/actions/workflow/status/Breakthrough/PySceneDetect/build.yml - :target: https://github.com/Breakthrough/PySceneDetect/actions - -.. image:: https://img.shields.io/github/release/Breakthrough/PySceneDetect.svg +.. image:: https://img.shields.io/pypi/status/scenedetect.svg :target: https://github.com/Breakthrough/PySceneDetect -.. image:: https://img.shields.io/pypi/status/scenedetect.svg +.. image:: https://img.shields.io/github/release/Breakthrough/PySceneDetect.svg :target: https://github.com/Breakthrough/PySceneDetect .. image:: https://img.shields.io/pypi/l/scenedetect.svg @@ -22,21 +19,19 @@ Video Scene Cut Detection and Analysis Tool ---------------------------------------------------------- -Website: https://www.scenedetect.com/ - Documentation: https://www.scenedetect.com/docs Github Repo: https://github.com/Breakthrough/PySceneDetect/ ----------------------------------------------------------- +Install: ``pip install --upgrade scenedetect[opencv]`` -PySceneDetect is a command-line tool and Python library which analyzes a video, looking for scene changes or cuts. PySceneDetect integrates with external tools (e.g. `ffmpeg`, `mkvmerge`) to automatically split the video into individual clips when using the `split-video` command and has several other features. +---------------------------------------------------------- -Install: ``pip install --upgrade scenedetect[opencv]`` +**PySceneDetect** is a tool for detecting shot changes in videos, and can automatically split videos into separate clips. PySceneDetect is free and open-source software, and has several detection methods to find fast-cuts and threshold-based fades. -Split video via CLI: ``scenedetect -i video.mp4 split-video`` +For example, to split a video: ``scenedetect -i video.mp4 split-video`` -Split video using Python API: +You can also use the Python API (`docs `_) to do the same: .. code-block:: python diff --git a/docs/index.rst b/docs/index.rst index 642e1d6f..7e820574 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,13 +6,15 @@ PySceneDetect Documentation ####################################################################### -This documentation covers the PySceneDetect command-line interface (the `scenedetect` command) and Python API (the `scenedetect` module). The latest release of PySceneDetect can be installed via `pip install scenedetect[opencv]`. Windows builds and source releases can be found at `scenedetect.com/download `_. Note that PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. +Welcome to the PySceneDetect docs. The docs are split into two separate parts: one for the command-line interface (the `scenedetect` command) and another for the Python API (the `scenedetect` module). + +You can install the latest release of PySceneDetect by running `pip install scenedetect[opencv]` or downloading the Windows build from `scenedetect.com/download `_. PySceneDetect requires `ffmpeg` or `mkvmerge` for video splitting support. .. note:: If you see any errors in the documentation, or want to suggest improvements, feel free to raise an issue on `the PySceneDetect issue tracker `_. -The latest source code for PySceneDetect can be found on Github at `github.com/Breakthrough/PySceneDetect `_. +PySceneDetect development happens on Github at `github.com/Breakthrough/PySceneDetect `_. *********************************************************************** diff --git a/scenedetect.cfg b/scenedetect.cfg index d9a71c25..782f32da 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -39,13 +39,14 @@ # Method to use for downscaling (nearest, linear, cubic, area, lanczos4). #downscale-method = linear -# Minimum length of a given scene (shorter scenes will be merged). +# Minimum length of a given scene. #min-scene-len = 0.6s -# Merge last scene if it is shorter than min-scene-len (yes/no) +# Merge last scene if it is shorter than min-scene-len (yes/no). This can occur +# when a cut is detected just before the video ends. #merge-last-scene = no -# Drop scenes shorter than min-scene-len instead of merging (yes/no) +# Drop scenes shorter than min-scene-len instead of merging (yes/no). #drop-short-scenes = no # Verbosity of console output (debug, info, warning, error, or none). @@ -64,6 +65,14 @@ # Sensitivity threshold from 0 to 255. Lower values are more sensitive. #threshold = 27 +# Minimum length of a given scene (overrides [global] option). +#min-scene-len = 0.6s + +# Mode to use when filtering scenes to comply with min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge + # Weight to place on each component when calculating frame score (the value # `threshold` is compared against). The components are in the order # (delta_hue, delta_sat, delta_lum, delta_edges). Description of components: @@ -83,8 +92,10 @@ # than or equal to 3. If None, automatically set using video resolution. #kernel-size = -1 -# Minimum length of a given scene (overrides [global] option). -#min-scene-len = 0.6s +# Mode to use for enforcing min-scene-len: +# merge: Consecutive scenes shorter than min-scene-len are combined. +# suppress: No new scenes can be generated until min-scene-len passes. +#filter-mode = merge [detect-threshold] diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index aa00f00f..ad26d9c6 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -36,7 +36,7 @@ from scenedetect.video_stream import VideoStream, VideoOpenFailure from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge from scenedetect.scene_detector import SceneDetector -from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector +from scenedetect.detectors import ContentDetector, AdaptiveDetector, ThresholdDetector, HistogramDetector, HashDetector from scenedetect.backends import (AVAILABLE_BACKENDS, VideoStreamCv2, VideoStreamAv, VideoStreamMoviePy, VideoCaptureAdapter) from scenedetect.stats_manager import StatsManager, StatsFileCorrupt @@ -47,7 +47,7 @@ # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). -__version__ = '0.6.3' +__version__ = '0.7-dev0' init_logger() logger = getLogger('pyscenedetect') diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 442c35ea..afac2161 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -443,52 +443,62 @@ def time_command( ) -@click.command('detect-content', cls=_Command) +@click.command("detect-content", cls=_Command) @click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-content']['threshold'].min_val, - CONFIG_MAP['detect-content']['threshold'].max_val), + "--threshold", + "-t", + metavar="VAL", + type=click.FloatRange(CONFIG_MAP["detect-content"]["threshold"].min_val, + CONFIG_MAP["detect-content"]["threshold"].max_val), default=None, - help='Threshold (float) that frame score must exceed to trigger a cut. Refers to "content_val" in stats file.%s' + help="Threshold (float) that frame score must exceed to trigger a cut. Refers to \"content_val\" in stats file.%s" % (USER_CONFIG.get_help_string("detect-content", "threshold")), ) @click.option( - '--weights', - '-w', + "--weights", + "-w", type=(float, float, float, float), default=None, - metavar='HUE SAT LUM EDGE', - help='Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s' + metavar="HUE SAT LUM EDGE", + help="Weights of 4 components used to calculate frame score from (delta_hue, delta_sat, delta_lum, delta_edges).%s" % (USER_CONFIG.get_help_string("detect-content", "weights")), ) @click.option( - '--luma-only', - '-l', + "--luma-only", + "-l", is_flag=True, flag_value=True, - help='Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting "-w 0 0 1 0".%s' + help="Only use luma (brightness) channel. Useful for greyscale videos. Equivalent to setting -w=\"0 0 1 0\".%s" % (USER_CONFIG.get_help_string("detect-content", "luma-only")), ) @click.option( - '--kernel-size', - '-k', - metavar='N', + "--kernel-size", + "-k", + metavar="N", type=click.INT, default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s' + help="Size of kernel for expanding detected edges. Must be odd integer greater than or equal to 3. If unset, kernel size is estimated using video resolution.%s" % (USER_CONFIG.get_help_string("detect-content", "kernel-size")), ) @click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', + "--min-scene-len", + "-m", + metavar="TIMECODE", type=click.STRING, default=None, - help='Minimum length of any scene. Overrides global option -m/--min-scene-len. TIMECODE can be specified in frames (-m=100), in seconds with `s` suffix (-m=3.5s), or timecode (-m=00:01:52.778).%s' - % ('' if USER_CONFIG.is_default('detect-content', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-content', 'min-scene-len')), + help="Minimum length of any scene. Overrides global option -m/--min-scene-len. %s" % + ("" if USER_CONFIG.is_default("detect-content", "min-scene-len") else + USER_CONFIG.get_help_string("detect-content", "min-scene-len")), +) +@click.option( + "--filter-mode", + "-f", + metavar="MODE", + type=click.Choice(CHOICE_MAP["detect-content"]["filter-mode"], False), + default=None, + help="Mode used to enforce -m/--min-scene-len option. Can be one of: %s. %s" % + (", ".join(CHOICE_MAP["detect-content"]["filter-mode"]), + USER_CONFIG.get_help_string("detect-content", "filter-mode")), ) @click.pass_context def detect_content_command( @@ -498,6 +508,7 @@ def detect_content_command( luma_only: bool, kernel_size: Optional[int], min_scene_len: Optional[str], + filter_mode: Optional[str], ): """Perform content detection algorithm on input video. @@ -527,7 +538,8 @@ def detect_content_command( luma_only=luma_only, min_scene_len=min_scene_len, weights=weights, - kernel_size=kernel_size) + kernel_size=kernel_size, + filter_mode=filter_mode) logger.debug('Adding detector: ContentDetector(%s)', detector_args) ctx.obj.add_detector(ContentDetector(**detector_args)) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 0b76efea..d8e38867 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -27,6 +27,7 @@ from scenedetect.detectors import ContentDetector from scenedetect.frame_timecode import FrameTimecode +from scenedetect.scene_detector import FlashFilter from scenedetect.scene_manager import Interpolation from scenedetect.video_splitter import DEFAULT_FFMPEG_ARGS @@ -263,6 +264,7 @@ def format(self, timecode: FrameTimecode) -> str: 'min-delta-hsv': RangeValue(15.0, min_val=0.0, max_val=255.0), }, 'detect-content': { + 'filter-mode': 'merge', 'kernel-size': KernelSizeValue(-1), 'luma-only': False, 'min-scene-len': TimecodeValue(0), @@ -342,7 +344,10 @@ def format(self, timecode: FrameTimecode) -> str: CHOICE_MAP: Dict[str, Dict[str, List[str]]] = { 'backend-pyav': { - 'threading_mode': [str(mode).lower() for mode in VALID_PYAV_THREAD_MODES], + 'threading_mode': [mode.lower() for mode in VALID_PYAV_THREAD_MODES], + }, + 'detect-content': { + 'filter-mode': [mode.name.lower() for mode in FlashFilter.Mode], }, 'global': { 'backend': ['opencv', 'pyav', 'moviepy'], diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index b6b89016..56da6478 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -23,7 +23,7 @@ from scenedetect import open_video, AVAILABLE_BACKENDS -from scenedetect.scene_detector import SceneDetector +from scenedetect.scene_detector import SceneDetector, FlashFilter 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 @@ -317,6 +317,7 @@ def get_detect_content_params( min_scene_len: Optional[str] = None, weights: Optional[Tuple[float, float, float, float]] = None, kernel_size: Optional[int] = None, + filter_mode: Optional[str] = None, ) -> Dict[str, Any]: """Handle detect-content command options and return dict to construct one with.""" self._ensure_input_open() @@ -337,12 +338,21 @@ def get_detect_content_params( except ValueError as ex: logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint='weights') + return { - 'weights': self.config.get_value('detect-content', 'weights', weights), - 'kernel_size': self.config.get_value('detect-content', 'kernel-size', kernel_size), - 'luma_only': luma_only or self.config.get_value('detect-content', 'luma-only'), - 'min_scene_len': min_scene_len, - 'threshold': self.config.get_value('detect-content', 'threshold', threshold), + 'weights': + self.config.get_value('detect-content', 'weights', weights), + 'kernel_size': + self.config.get_value('detect-content', 'kernel-size', kernel_size), + 'luma_only': + luma_only or self.config.get_value('detect-content', 'luma-only'), + 'min_scene_len': + min_scene_len, + 'threshold': + self.config.get_value('detect-content', 'threshold', threshold), + 'filter_mode': + FlashFilter.Mode[self.config.get_value("detect-content", "filter-mode", + filter_mode).upper()], } def get_detect_adaptive_params( diff --git a/scenedetect/_cli/controller.py b/scenedetect/_cli/controller.py index 4350de32..d7180542 100644 --- a/scenedetect/_cli/controller.py +++ b/scenedetect/_cli/controller.py @@ -335,9 +335,6 @@ def _postprocess_scene_list( # Handle --drop-short-scenes. if context.drop_short_scenes and context.min_scene_len > 0: - print([str(s[1] - s[0]) for s in scene_list].__str__()) - print(context.min_scene_len) scene_list = [s for s in scene_list if (s[1] - s[0]) >= context.min_scene_len] - print([str(s[1] - s[0]) for s in scene_list].__str__()) return scene_list diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 0142f1b1..55ec8689 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -32,6 +32,7 @@ from scenedetect.detectors.content_detector import ContentDetector from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector +from scenedetect.detectors.hash_detector import HashDetector from scenedetect.detectors.histogram_detector import HistogramDetector # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # diff --git a/scenedetect/detectors/adaptive_detector.py b/scenedetect/detectors/adaptive_detector.py index 85778158..064255f5 100644 --- a/scenedetect/detectors/adaptive_detector.py +++ b/scenedetect/detectors/adaptive_detector.py @@ -140,30 +140,30 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List if not len(self._buffer) >= required_frames: return [] self._buffer = self._buffer[-required_frames:] - target = self._buffer[self.window_width] + (target_frame, target_score) = self._buffer[self.window_width] average_window_score = ( - sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) / + sum(score for i, (_frame, score) 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: + adaptive_ratio = min(target_score / average_window_score, 255.0) + elif average_is_zero and target_score >= 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}) + self.stats_manager.set_metrics(target_frame, {self._adaptive_ratio_key: adaptive_ratio}) # Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there # being a large enough content_val to trigger a cut threshold_met: bool = ( - adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val) + adaptive_ratio >= self.adaptive_threshold and target_score >= self.min_content_val) min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len if threshold_met and min_length_met: - self._last_cut = target[0] - return [target[0]] + self._last_cut = target_frame + return [target_frame] return [] def get_content_val(self, frame_num: int) -> Optional[float]: diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index c209bb78..954a91d7 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -22,7 +22,7 @@ import numpy import cv2 -from scenedetect.scene_detector import SceneDetector +from scenedetect.scene_detector import SceneDetector, FlashFilter def _mean_pixel_distance(left: numpy.ndarray, right: numpy.ndarray) -> float: @@ -105,6 +105,7 @@ def __init__( weights: 'ContentDetector.Components' = DEFAULT_COMPONENT_WEIGHTS, luma_only: bool = False, kernel_size: Optional[int] = None, + filter_mode: FlashFilter.Mode = FlashFilter.Mode.MERGE, ): """ Arguments: @@ -118,11 +119,12 @@ def __init__( 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. + filter_mode: Mode to use when filtering cuts to meet `min_scene_len`. """ super().__init__() self._threshold: float = threshold self._min_scene_len: int = min_scene_len - self._last_scene_cut: Optional[int] = None + self._last_above_threshold: Optional[int] = None self._last_frame: Optional[ContentDetector._FrameData] = None self._weights: ContentDetector.Components = weights if luma_only: @@ -134,6 +136,7 @@ def __init__( 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 + self._flash_filter = FlashFilter(mode=filter_mode, length=min_scene_len) def get_metrics(self): return ContentDetector.METRIC_KEYS @@ -195,22 +198,12 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: 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 - 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: bool = (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 [] + above_threshold: bool = self._frame_score >= self._threshold + return self._flash_filter.filter(frame_num=frame_num, above_threshold=above_threshold) def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray: """Detect edges using the luma channel of a frame. diff --git a/scenedetect/detectors/hash_detector.py b/scenedetect/detectors/hash_detector.py new file mode 100644 index 00000000..7b94bed0 --- /dev/null +++ b/scenedetect/detectors/hash_detector.py @@ -0,0 +1,180 @@ +# -*- 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-2022 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. +# +""" ``scenedetect.detectors.hash_detector`` Module + +This module implements the :py:class:`HashDetector`, which calculates a hash +value for each from of a video using a perceptual hashing algorithm. Then, the +differences in hash value between frames is calculated. If this difference +exceeds a set threshold, a scene cut is triggered. + +This detector is available from the command-line interface by using the +`detect-hash` command. +""" + +# Third-Party Library Imports +import numpy +import cv2 + +# PySceneDetect Library Imports +from scenedetect.scene_detector import SceneDetector + + +def calculate_frame_hash(frame_img, hash_size, highfreq_factor): + """Helper function that calculates the hash of a frame and returns it. + + Perceptual hashing algorithm based on phash, updated to use OpenCV instead of PIL + scipy + https://github.com/JohannesBuchner/imagehash + """ + + # Transform to grayscale + gray_img = cv2.cvtColor(frame_img, cv2.COLOR_BGR2GRAY) + + # Resize image to square to help with DCT + imsize = hash_size * highfreq_factor + resized_img = cv2.resize(gray_img, (imsize, imsize), interpolation=cv2.INTER_AREA) + + # Check to avoid dividing by zero + max_value = numpy.max(numpy.max(resized_img)) + if max_value == 0: + # Just set the max to 1 to not change the values + max_value = 1 + + # Calculate discrete cosine tranformation of the image + resized_img = numpy.float32(resized_img) / max_value + dct_complete = cv2.dct(resized_img) + + # Only keep the low frequency information + dct_low_freq = dct_complete[:hash_size, :hash_size] + + # Calculate the median of the low frequency informations + med = numpy.median(dct_low_freq) + + # Transform the low frequency information into a binary image based on > or < median + hash_img = dct_low_freq > med + + return hash_img + + +class HashDetector(SceneDetector): + """Detects cuts using a perceptual hashing algorithm. For more information + on the perceptual hashing algorithm see references below. + + 1. https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html + 2. https://github.com/JohannesBuchner/imagehash + + Since the difference between frames is used, unlike the ThresholdDetector, + only fast cuts are detected with this method. + + Arguments: + threshold: How much of a difference between subsequent hash values should trigger a cut + min_scene_len: Minimum length of any given scene, in frames (int) or FrameTimecode + hash_size: Size of square of low frequency data to include from the discrete cosine transform + highfreq_factor: How much high frequency information to filter from the DCT. A value of + 2 means keep lower 1/2 of the frequency data, 4 means only keep 1/4, etc... + """ + + def __init__( + self, + threshold: float = 101.0, + min_scene_len: int = 15, + hash_size: int = 16, + highfreq_factor: int = 2, + ): + super(HashDetector, self).__init__() + self._threshold = threshold + self._min_scene_len = min_scene_len + self._hash_size = hash_size + self._highfreq_factor = highfreq_factor + self._last_frame = None + self._last_scene_cut = None + self._last_hash = numpy.array([]) + self._metric_keys = ['hash_dist'] + + def get_metrics(self): + return self._metric_keys + + def is_processing_required(self, frame_num): + return True + + def process_frame(self, frame_num, frame_img): + """ Similar to ContentDetector, but using a perceptual hashing algorithm + to calculate a hash for each frame and then calculate a hash difference + frame to frame. + + 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: + 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. + """ + + cut_list = [] + metric_keys = self._metric_keys + + # 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 + + # 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 hash value between subsequent frames. + curr_hash = calculate_frame_hash( + frame_img=frame_img, + hash_size=self._hash_size, + highfreq_factor=self._highfreq_factor) + + last_hash = self._last_hash + + if last_hash.size == 0: + # Calculate hash of last frame + last_hash = calculate_frame_hash( + frame_img=self._last_frame, + hash_size=self._hash_size, + highfreq_factor=self._highfreq_factor) + + # Hamming distance is calculated to compare to last frame + hash_dist = numpy.count_nonzero(curr_hash.flatten() != last_hash.flatten()) + + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, {metric_keys[0]: hash_dist}) + + self._last_hash = curr_hash + + # 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 hash_dist >= self._threshold and ((frame_num - self._last_scene_cut) + >= self._min_scene_len): + cut_list.append(frame_num) + self._last_scene_cut = frame_num + + self._last_frame = frame_img.copy() + + return cut_list diff --git a/scenedetect/scene_detector.py b/scenedetect/scene_detector.py index a5d5dc8b..ded5d35d 100644 --- a/scenedetect/scene_detector.py +++ b/scenedetect/scene_detector.py @@ -25,7 +25,8 @@ event (in, out, cut, etc...). """ -from typing import List, Optional, Tuple +from enum import Enum +import typing as ty import numpy @@ -46,7 +47,7 @@ class SceneDetector: """ # TODO(v0.7): Make this a proper abstract base class. - stats_manager: Optional[StatsManager] = None + stats_manager: ty.Optional[StatsManager] = None """Optional :class:`StatsManager ` to use for caching frame metrics to and from.""" @@ -77,7 +78,7 @@ def stats_manager_required(self) -> bool: """ return False - def get_metrics(self) -> List[str]: + def get_metrics(self) -> ty.List[str]: """Get Metrics: Get a list of all metric names/keys used by the detector. Returns: @@ -86,7 +87,7 @@ def get_metrics(self) -> List[str]: """ return [] - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int]: """Process the next frame. `frame_num` is assumed to be sequential. Args: @@ -103,7 +104,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: """ return [] - def post_process(self, frame_num: int) -> List[int]: + def post_process(self, frame_num: int) -> ty.List[int]: """Post Process: Performs any processing after the last frame has been read. Prototype method, no actual detection. @@ -132,7 +133,8 @@ class SparseSceneDetector(SceneDetector): An example of a SparseSceneDetector is the MotionDetector. """ - def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[Tuple[int, int]]: + def process_frame(self, frame_num: int, + frame_img: numpy.ndarray) -> ty.List[ty.Tuple[int, int]]: """Process Frame: Computes/stores metrics and detects any scene changes. Prototype method, no actual detection. @@ -143,7 +145,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[Tuple[ """ return [] - def post_process(self, frame_num: int) -> List[Tuple[int, int]]: + def post_process(self, frame_num: int) -> ty.List[ty.Tuple[int, int]]: """Post Process: Performs any processing after the last frame has been read. Prototype method, no actual detection. @@ -153,3 +155,66 @@ def post_process(self, frame_num: int) -> List[Tuple[int, int]]: to be added to the output scene list directly. """ return [] + + +class FlashFilter: + + class Mode(Enum): + MERGE = 0 + """Merge consecutive cuts shorter than filter length.""" + SUPPRESS = 1 + """Suppress consecutive cuts until the filter length has passed.""" + + def __init__(self, mode: Mode, length: int): + self._mode = mode + self._filter_length = length # Number of frames to use for activating the filter. + self._last_above = None # Last frame above threshold. + self._merge_enabled = False # Used to disable merging until at least one cut was found. + self._merge_triggered = False # True when the merge filter is active. + self._merge_start = None # Frame number where we started the merge filte. + + def filter(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + if not self._filter_length > 0: + return [frame_num] if above_threshold else [] + if self._last_above is None: + self._last_above = frame_num + if self._mode == FlashFilter.Mode.MERGE: + return self._filter_merge(frame_num=frame_num, above_threshold=above_threshold) + if self._mode == FlashFilter.Mode.SUPPRESS: + return self._filter_suppress(frame_num=frame_num, above_threshold=above_threshold) + + def _filter_suppress(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + if not (above_threshold and min_length_met): + return [] + # Both length and threshold requirements were satisfied. Emit the cut, and wait until both + # requirements are met again. + self._last_above = frame_num + return [frame_num] + + def _filter_merge(self, frame_num: int, above_threshold: bool) -> ty.List[int]: + min_length_met: bool = (frame_num - self._last_above) >= self._filter_length + # Ensure last frame is always advanced to the most recent one that was above the threshold. + if above_threshold: + self._last_above = frame_num + if self._merge_triggered: + # This frame was under the threshold, see if enough frames passed to disable the filter. + num_merged_frames = self._last_above - self._merge_start + if min_length_met and not above_threshold and num_merged_frames >= self._filter_length: + self._merge_triggered = False + return [self._last_above] + # Keep merging until enough frames pass below the threshold. + return [] + # Wait for next frame above the threshold. + if not above_threshold: + return [] + # If we met the minimum length requirement, no merging is necessary. + if min_length_met: + # Only allow the merge filter once the first cut is emitted. + self._merge_enabled = True + return [frame_num] + # Start merging cuts until the length requirement is met. + if self._merge_enabled: + self._merge_triggered = True + self._merge_start = frame_num + return [] diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 3d3bd435..64db99dd 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -383,9 +383,9 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], encoder_param: Quality/compression efficiency, based on type of image: 'jpg' / 'webp': Quality 0-100, higher is better quality. 100 is lossless for webp. 'png': Compression from 1-9, where 9 achieves best filesize but is slower to encode. - image_name_template: Template to use when creating the images on disk. Can - use the macros $VIDEO_NAME, $SCENE_NUMBER, and $IMAGE_NUMBER. The image - extension is applied automatically as per the argument image_extension. + image_name_template: Template to use when creating the images on disk. Can use the macros + $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, $FRAME_NUMBER, and $TIMESTAMP_MS. + The image extension is applied automatically as per the argument image_extension. output_dir: Directory to output the images into. If not set, the output is created in the working directory. show_progress: If True, shows a progress bar if tqdm is installed. @@ -489,11 +489,16 @@ def save_images(scene_list: List[Tuple[FrameTimecode, FrameTimecode]], frame_im = video.read() if frame_im is not None: # TODO: Allow NUM to be a valid suffix in addition to NUMBER. - file_path = '%s.%s' % (filename_template.safe_substitute( - VIDEO_NAME=video.name, - SCENE_NUMBER=scene_num_format % (i + 1), - IMAGE_NUMBER=image_num_format % (j + 1), - FRAME_NUMBER=image_timecode.get_frames()), image_extension) + file_path = '%s.%s' % ( + filename_template.safe_substitute( + VIDEO_NAME=video.name, + SCENE_NUMBER=scene_num_format % (i + 1), + IMAGE_NUMBER=image_num_format % (j + 1), + FRAME_NUMBER=image_timecode.get_frames(), + TIMESTAMP_MS=int(image_timecode.get_seconds() * 1000), + TIMECODE=image_timecode.get_timecode().replace(":", ";")), + image_extension, + ) image_filenames[i].append(file_path) # TODO: Combine this resize with the ones below. if aspect_ratio is not None: @@ -558,7 +563,7 @@ def __init__( """ self._cutting_list = [] self._event_list = [] - self._detector_list = [] + self._detector_list: List[SceneDetector] = [] self._sparse_detector_list = [] # TODO(v1.0): This class should own a StatsManager instead of taking an optional one. # Expose a new `stats_manager` @property from the SceneManager, and either change the diff --git a/tests/test_cli.py b/tests/test_cli.py index 8e5fc27f..45c0098a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,6 +43,7 @@ # TODO: Missing tests for --min-scene-len and --drop-short-scenes. SCENEDETECT_CMD = 'python -m scenedetect' +# TODO(v0.7): Add `detect-hash` to this list. ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] ALL_BACKENDS = ['opencv', 'pyav'] diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 4751f521..38152b01 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -24,9 +24,18 @@ import pytest from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector +from scenedetect.detectors import * from scenedetect.backends.opencv import VideoStreamCv2 +FAST_CUT_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = ( + AdaptiveDetector, + ContentDetector, + HashDetector, + HistogramDetector, +) + +ALL_DETECTORS: ty.Tuple[ty.Type[SceneDetector]] = (*FAST_CUT_DETECTORS, ThresholdDetector) + # TODO(#53): Add a test that verifies algorithms output relatively consistent frame scores # regardless of resolution. This will ensure that threshold values will hold true for different # input sources. Most detectors already provide this guarantee, so this is more to prevent any @@ -78,56 +87,30 @@ def detect(self): def get_fast_cut_test_cases(): """Fixture for parameterized test cases that detect fast cuts.""" - return [ - pytest.param( - TestCase( - path=get_absolute_path("resources/goldeneye.mp4"), - detector=ContentDetector(), - start_time=1199, - end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), - id="content_default"), - pytest.param( - TestCase( - path=get_absolute_path("resources/goldeneye.mp4"), - detector=AdaptiveDetector(), - start_time=1199, - end_time=1450, - scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), - id="adaptive_default"), + test_cases = [] + # goldeneye.mp4 with min_scene_len = 15 (default) + test_cases += [ pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), - detector=HistogramDetector(), + detector=detector_type(min_scene_len=15), start_time=1199, end_time=1450, scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]), - id="histogram_default"), - pytest.param( - TestCase( - path=get_absolute_path("resources/goldeneye.mp4"), - detector=ContentDetector(min_scene_len=30), - start_time=1199, - end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365]), - id="content_min_scene_len"), - pytest.param( - TestCase( - path=get_absolute_path("resources/goldeneye.mp4"), - detector=AdaptiveDetector(min_scene_len=30), - start_time=1199, - end_time=1450, - scene_boundaries=[1199, 1260, 1334, 1365]), - id="adaptive_min_scene_len"), + id="%s/default" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS + ] + # goldeneye.mp4 with min_scene_len = 30 + test_cases += [ pytest.param( TestCase( path=get_absolute_path("resources/goldeneye.mp4"), - detector=HistogramDetector(min_scene_len=30), + detector=detector_type(min_scene_len=30), start_time=1199, end_time=1450, scene_boundaries=[1199, 1260, 1334, 1365]), - id="histogram_min_scene_len"), + id="%s/m=30" % detector_type.__name__) for detector_type in FAST_CUT_DETECTORS ] + return test_cases def get_fade_in_out_test_cases(): @@ -155,7 +138,7 @@ def get_fade_in_out_test_cases(): TestCase( path=get_absolute_path("resources/fades.mp4"), detector=ThresholdDetector( - threshold=12.0, + threshold=11.0, method=ThresholdDetector.Method.FLOOR, add_final_scene=True, ), @@ -182,7 +165,8 @@ def get_fade_in_out_test_cases(): def test_detect_fast_cuts(test_case: TestCase): scene_list = test_case.detect() start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert test_case.scene_boundaries == start_frames + + assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time @@ -191,7 +175,7 @@ def test_detect_fast_cuts(test_case: TestCase): def test_detect_fades(test_case: TestCase): scene_list = test_case.detect() start_frames = [timecode.get_frames() for timecode, _ in scene_list] - assert test_case.scene_boundaries == start_frames + assert start_frames == test_case.scene_boundaries assert scene_list[0][0] == test_case.start_time assert scene_list[-1][1] == test_case.end_time @@ -199,7 +183,7 @@ def test_detect_fades(test_case: TestCase): def test_detectors_with_stats(test_video_file): """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). - for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector, HistogramDetector]: + for detector in ALL_DETECTORS: video = VideoStreamCv2(test_video_file) stats = StatsManager() scene_manager = SceneManager(stats_manager=stats) @@ -208,14 +192,12 @@ def test_detectors_with_stats(test_video_file): end_time = FrameTimecode('00:00:08', video.frame_rate) scene_manager.detect_scenes(video=video, end_time=end_time) initial_scene_len = len(scene_manager.get_scene_list()) - assert initial_scene_len > 0 # test case must have at least one scene! - # Re-analyze using existing stats manager. + assert initial_scene_len > 0, "Test case must have at least one scene." + # Re-analyze using existing stats manager. scene_manager = SceneManager(stats_manager=stats) scene_manager.add_detector(detector()) - video.reset() scene_manager.auto_downscale = True - scene_manager.detect_scenes(video=video, end_time=end_time) scene_list = scene_manager.get_scene_list() assert len(scene_list) == initial_scene_len diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 20ef4677..c974398d 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -94,7 +94,9 @@ def test_save_images(test_video_file): sm.add_detector(ContentDetector()) image_name_glob = 'scenedetect.tempfile.*.jpg' - image_name_template = 'scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER' + image_name_template = ('scenedetect.tempfile.' + '$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER.' + '$TIMESTAMP_MS.$TIMECODE') try: video_fps = video.frame_rate diff --git a/website/pages/api.md b/website/pages/api.md index 60baca4a..3b09d398 100644 --- a/website/pages/api.md +++ b/website/pages/api.md @@ -29,6 +29,11 @@ The threshold-based scene detector (`detect-threshold`) is how most traditional The scene change detection algorithm uses histograms of the Y channel in the YCbCr color space to detect scene changes, which helps mitigate issues caused by lighting variations. Each frame of the video is converted from its original color space to the YCbCr color space.The Y channel, which represents luminance, is extracted from the YCbCr color space. This helps in focusing on intensity variations rather than color variations. A histogram of the Y channel is computed using the specified number of bins (--bins/-b). The histogram is normalized to ensure that it can be consistently compared with histograms from other frames. The normalized histogram of the current frame is compared with the normalized histogram of the previous frame using the correlation method (cv2.HISTCMP_CORREL). A scene change is detected if the correlation between the histograms of consecutive frames is below the specified threshold (--threshold/-t). This indicates a significant change in luminance, suggesting a scene change. +## Perceptual Hash Detector + +The perceptual hash detector (`detect-hash`) calculates a hash for a frame and compares that hash to the previous frame's hash. If the hashes differ by more than the defined threshold, then a scene change is recorded. The hashing algorithm used for this detector is an implementation of `phash` from the [imagehash](https://github.com/JohannesBuchner/imagehash) library. In practice, this detector works similarly to `detect-content` in that it picks up large differences between adjacent frames. One important note is that the hashing algorithm converts the frames to grayscale, so this detector is insensitive to changes in colors if the brightness remains constant. In general, this algorithm is very computationally efficient compared to `detect-content` or `detect-adaptive`, especially if downscaling is not used. See [here](https://www.hackerfactor.com/blog/index.php?/archives/432-Looks-Like-It.html) for an overview of how a perceptual hashing algorithm can be used for detecting similarity (or otherwise) of images and a visual depiction of the algorithm. + + # Creating New Detection Algorithms All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). @@ -70,5 +75,5 @@ Processing is done by calling the `process_frame(...)` function for all frames i `post_process(...)` is called **after** the final frame has been processed, to allow for any stored scene cuts to be written *if required* (e.g. in the case of the `ThresholdDetector`). -You may also want to look into the implementation of current detectors to understand how frame metrics are saved/loaded to/from a [`StatsManager`](https://pyscenedetect.readthedocs.io/projects/Manual/en/stable/api/stats_manager.html) for caching and allowing values to be written to a stats file for users to graph and find trends in to tweak detector options. Also see the documentation for the [`SceneManager`](https://pyscenedetect.readthedocs.io/projects/Manual/en/stable/api/scene_manager.html) for details. +You may also want to look into the implementation of current detectors to understand how frame metrics are saved/loaded to/from a [`StatsManager`](https://www.scenedetect.com/docs/latest/api/stats_manager.html) for caching and allowing values to be written to a stats file for users to graph and find trends in to tweak detector options. Also see the documentation for the [`SceneManager`](https://www.scenedetect.com/docs/latest/api/scene_manager.html) for details. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 1bdb8be6..45b27215 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -6,8 +6,19 @@ Releases ### 0.6.4 (In Development) - - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) +#### Release Notes + +Includes new histogram and perceptual hash based detectors (thanks @wjs018 and @ash2703), adds flash filter to content detector, and includes various bugfixes. +#### Changelog + + - [feature] New detector: `detect-hist` / `HistogramDetector`, [thanks @wjs018](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/53) + - [feature] Add flash suppression filter for `detect-content` / `ContentDetector`, greatly reduces number of cuts generated during strobing or flashing effects [#35](https://github.com/Breakthrough/PySceneDetect/pull/295) [#53](https://github.com/Breakthrough/PySceneDetect/issues/35) + - Can be configured using `--filter-mode` option, enabled by default + - `--filter-mode = merge` (new default) merges consecutive scenes shorter than `min-scene-len` + - `--filter-mode = suppress` (previous default) disables generating new scenes until `min-scene-len` has passed + - [bugfix] Remove extraneous console output when using `--drop-short-scenes` + - [bugfix] Fix scene lengths being smaller than `min-scene-len` when using `detect-adaptive` / `AdaptiveDetector` with large values of `--frame-window` ### 0.6.3 (March 9, 2024) diff --git a/website/pages/cli.md b/website/pages/cli.md index 18d2ea7d..d7800feb 100644 --- a/website/pages/cli.md +++ b/website/pages/cli.md @@ -1,7 +1,7 @@ # PySceneDetect CLI -See [the documentation](../docs/stable/) for a complete reference to the `scenedetect` command with more examples. +See [the documentation](../docs/latest/) for a complete reference to the `scenedetect` command with more examples. ## Quickstart diff --git a/website/pages/contributing.md b/website/pages/contributing.md index 33fcd308..f45b753f 100644 --- a/website/pages/contributing.md +++ b/website/pages/contributing.md @@ -1,17 +1,19 @@ ##   Bug Reports -Bugs, issues, features, and improvements to PySceneDetect are handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs using PySceneDetect, please [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new). Provide as much detail as you can - include an example that clearly demonstrates the problem (if possible), and make sure to include any/all relevant program output or error messages. +Bugs, issues, features, and improvements to PySceneDetect are handled through [the issue tracker on Github](https://github.com/Breakthrough/PySceneDetect/issues). If you run into any bugs using PySceneDetect, please [create a new issue](https://github.com/Breakthrough/PySceneDetect/issues/new/choose). -When submitting bug reports, please provide debug logs by adding `-l BUG_REPORT.txt` to your `scenedetect` command, and attach the generated `BUG_REPORT.txt` file. - -Before opening a new issue, please do [search for any existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) which might report similar issues/bugs to avoid creating duplicate entries. If you do find a duplicate report, feel free to add any additional information you feel may be relevant. +Try to [find an existing issue](https://github.com/Breakthrough/PySceneDetect/issues?q=) before creating a new one, as there may be a workaround posted there. Additional information is also helpful for existing reports. ##   Contributing to Development -The development of PySceneDetect is done on the Github Repo, guided by [the feature roadmap](features.md). Code you wish to submit should be attached to a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=) (with the appropriate tags for bugfixes, new features, enhancements, etc...), and allows for easier communication regarding development structure. Feel free to create a new entry if required, as some planned features or bugs/issues may not yet exist in the tracker. +Development of PySceneDetect happens on [github.com/Breakthrough/PySceneDetect](https://github.com/Breakthrough/PySceneDetect). Pull requests are accepted and encouraged. Where possible, PRs should be submitted with a dedicated entry in [the issue tracker](https://github.com/Breakthrough/PySceneDetect/issues?q=). Issues and features are typically grouped into version milestones. + +The following checklist covers the basics of pre-submission requirements: -All submitted code should be linted with pylint, and follow the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) as closely as possible. Also, ensure that you search through [all existing issues](https://github.com/Breakthrough/PySceneDetect/issues?q=) (both open and closed) beforehand to avoid creating duplicate entries. + - Code passes all unit tests (run `pytest`) + - Code is formatted (run `python -m yapf -i -r scenedetect/ tests/` to format in place) + - Generally follows the [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html) Note that PySceneDetect is released under the BSD 3-Clause license, and submitted code should comply with this license (see [License & Copyright Information](copyright.md) for details). @@ -19,22 +21,28 @@ Note that PySceneDetect is released under the BSD 3-Clause license, and submitte The following is a "wishlist" of features which PySceneDetect eventually should have, but does not currently due to lack of resources. Anyone who is able to contribute in any capacity to these items is encouraged to do so by starting a dialogue by opening a new issue on Github as per above. -### GUI - -A graphical user interface will be crucial for making PySceneDetect approchable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. - -### Localization +### Flash Suppression -PySceneDetect currently is not localized for other languages. Anyone who can help improve how localization can be approached for development material is encouraged to contribute in any way possible. Whether it is the GUI program, the command line interface, or documentation, localization will allow PySceneDetect to be used by much more users in their native languages. +Some detection methods struggle with bright flashes and fast camera movement. The detection pipeline has some filters in place to deal with these cases, but there are still drawbacks. We are actively seeking methods which can improve both performance and accuracy in these cases. -### Automatic Threshold / Peak Detection +### Automatic Thresholding The `detect-content` command requires a manual threshold to be set currently. Methods to use peak detection to dynamically determine when scene cuts occur would allow for the program to work with a much wider amount of material without requiring manual tuning, but would require statistical analysis. Ideally, this would be something like `-threshold=auto` as a default. -### Advanced Detection Strategies +### Dissolve Detection + +Depending on the length of the dissolve and parameters being used, detection accuracy for these types of cuts can vary widely. A method to improve accuracy with minimal performance loss is an open problem. -Research into advanced scene detection for content detection would be most useful, perhaps in terms of histogram analysis or edge detection. This could be integrated into the existing `detect-content` command, or be a separate command. The real blocker here is achieving reasonable performance utilizing the current software architecture. +### Advanced Strategies + +Research into detection methods and performance are ongoing. All contributions in this regard are most welcome. + +### GUI + +A graphical user interface will be crucial for making PySceneDetect approchable by a wider audience. There have been several suggested designs, but nothing concrete has been developed yet. Any proposed solution for the GUI should work across Windows, Linux, and OSX. + +### Localization -There are many open issues on the issue tracker that contain reference implementations contributed by various community members. There are already several concepts which are proven to be viable candidates for production, but still require some degree optimization. +PySceneDetect currently is not localized for other languages. Anyone who can help improve how localization can be approached for development material is encouraged to contribute in any way possible. Whether it is the GUI program, the command line interface, or documentation, localization will allow PySceneDetect to be used by much more users in their native languages. \ No newline at end of file diff --git a/website/pages/index.md b/website/pages/index.md index 1ec0f316..6d7e41e9 100644 --- a/website/pages/index.md +++ b/website/pages/index.md @@ -3,13 +3,12 @@

  Latest Release: v0.6.3 (March 9, 2024)

-  Download        Changelog        Documentation        Getting Started +  Download        Changelog        Documentation        Getting Started
See the changelog for the latest release notes and known issues.
-**PySceneDetect** is a tool/library for **detecting shot changes in videos** ([example](cli.md)), and **automatically splitting the video into separate clips**. PySceneDetect is free and open-source software, and there are [several detection methods available](features.md) - from simple threshold-based fade in/out detection, to advanced content aware fast-cut detection of each shot. - +**PySceneDetect** is a tool for **detecting shot changes in videos** ([example](cli.md)), and can **automatically split the video into separate clips**. PySceneDetect is free and open-source software, and has several [detection methods](features.md#detection-methods) to find fast-cuts and threshold-based fades.

Quickstart

diff --git a/website/pages/literature.md b/website/pages/literature.md index 65d49b3f..8bce6ca8 100644 --- a/website/pages/literature.md +++ b/website/pages/literature.md @@ -3,6 +3,8 @@ PySceneDetect is a useful tool for statistical analysis of video. Below are links to various research articles/papers which have either used PySceneDetect as a part of their analysis, or propose more accurate detection algorithms using the current implementation as a comparison. + - [Panda-70M: Captioning 70M Videos with Multiple Cross-Modality Teachers](https://arxiv.org/abs/2402.19479) by Tsai-Shien Chen, Aliaksandr Siarohin, Willi Menapace, Ekaterina Deyneka, Hsiang-wei Chao, Byung Eun Jeon, Yuwei Fang, Hsin-Ying Lee, Jian Ren, Ming-Hsuan Yang, Sergey Tulyakov (2024) + - [Stable Remaster: Bridging the Gap Between Old Content and New Displays](https://arxiv.org/pdf/2306.06803.pdf) by Nathan Paull, Shuvam Keshari, Yian Wong (2023) - [LoL-V2T: Large-Scale Esports Video Description Dataset](https://ieeexplore.ieee.org/abstract/document/9522986) by Tsunehiko Tanaka, Edgar Simo-Serra (2021) diff --git a/website/pages/similar.md b/website/pages/similar.md index c7f20bc5..6e5173be 100644 --- a/website/pages/similar.md +++ b/website/pages/similar.md @@ -8,4 +8,6 @@ The following is a list of programs or commands also performing scene cut analys - [Shotdetect](http://johmathe.name/shotdetect.html) - appears to be only for *NIX, content mode only - [Matlab Scene Change Detection](http://www.mathworks.com/help/vision/examples/scene-change-detection.html) - requires Matlab and Simulink/Computer Vision Toolbox, uses feature extraction and edge detection - [chaptertool](https://github.com/Mtillmann/chaptertool) - CLI/Web tool that converts PySceneDetect output to other formats + - [TransNetV2](https://github.com/soCzech/TransNetV2) - Shot Boundary Detection Neural Network (2020) + - [AutoShot] https://github.com/wentaozhu/AutoShot - Shot Boundary Detection Neural Network, based on a neural architecture search (2023)