├── .gitignore ├── README.md ├── shotdetect.py ├── shotdetect ├── __init__.py ├── detectors │ ├── __init__.py │ ├── average_detector.py │ ├── content_detector.py │ ├── content_detector_hsv_l2.py │ ├── content_detector_hsv_luv.py │ ├── motion_detector.py │ └── threshold_detector.py ├── frame_timecode.py ├── keyf_img_saver.py ├── platform.py ├── shot_detector.py ├── shot_manager.py ├── stats_manager.py ├── video_manager.py └── video_splitter.py └── shotdetect_p.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *__pycache__ 3 | *.pyc 4 | *.pyo 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shot Detection 2 | 3 | ![Python](https://img.shields.io/badge/Python->=3.6-Blue?logo=python) ![mmcv](https://img.shields.io/badge/mmcv-%3E%3D0.4.0-green) 4 | 5 | ## Easy-to-use 6 | ``` 7 | python shotdetect.py # to process a single video 8 | python shotdetect_p.py # to process a list of videos in parallel 9 | ``` 10 | 11 | ## Introduction 12 | Shot detection from videos 13 | with useful portals for long complicated videos, e.g., [movies](http://movienet.site/) scenarios. 14 | The repo is based on [PySceneDetect](https://pyscenedetect.readthedocs.io/en/latest/), which is under BSD 3-Clause License. 15 | 16 | ## Features 17 | - Parallel processing. 18 | - Keyframe saving. 19 | - Optimal detector that is tested on movie/tv epsoides scenarios, e.g., HSV-LUV joint model. 20 | - Average sampler. 21 | 22 | ## Misc 23 | The following function could help you to organize the output list file (in the case with three keyframes) into a dictionary. 24 | ``` 25 | def read_shot_list(txt_fn): 26 | list_raw = read_txt_list(txt_fn) 27 | shot_dict = {} 28 | for shot_ind, item in enumerate(list_raw): 29 | shot_dict[str(shot_ind).zfill(4)] = { 30 | "start": int(item.split(" ")[0]), 31 | "end": int(item.split(" ")[1]), 32 | 'keyf': int(item.split(" ")[3]) 33 | } 34 | return shot_dict 35 | ``` 36 | 37 | ## Citation 38 | ``` 39 | @inproceedings{rao2020local, 40 | title={A Local-to-Global Approach to Multi-modal Movie Scene Segmentation}, 41 | author={Rao, Anyi and Xu, Linning and Xiong, Yu and Xu, Guodong and Huang, Qingqiu and Zhou, Bolei and Lin, Dahua}, 42 | booktitle={IEEE Conference on Computer Vision and Pattern Recognition (CVPR)}, 43 | year={2020} 44 | } 45 | 46 | @misc{brandon2018, 47 | author = {Brandon Castellano}, 48 | title = {PySceneDetect: Intelligent scene cut detection and video splitting tool}, 49 | year = 2018, 50 | howpublished = {\url{https://pyscenedetect.readthedocs.io/en/latest/}}, 51 | } 52 | ``` 53 | -------------------------------------------------------------------------------- /shotdetect.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import argparse 4 | import os 5 | import os.path as osp 6 | 7 | from shotdetect.detectors.average_detector import AverageDetector 8 | from shotdetect.detectors.content_detector_hsv_luv import ContentDetectorHSVLUV 9 | from shotdetect.keyf_img_saver import generate_images, generate_images_txt 10 | from shotdetect.shot_manager import ShotManager 11 | from shotdetect.stats_manager import StatsManager 12 | from shotdetect.video_manager import VideoManager 13 | from shotdetect.video_splitter import split_video_ffmpeg 14 | 15 | 16 | def main(args, data_root): 17 | video_path = osp.abspath(args.video_path) 18 | video_prefix = video_path.split(".")[0].split("/")[-1] 19 | stats_file_folder_path = osp.join(data_root, "shot_stats") 20 | os.makedirs(stats_file_folder_path, exist_ok=True) 21 | 22 | stats_file_path = osp.join(stats_file_folder_path, '{}.csv'.format(video_prefix)) 23 | video_manager = VideoManager([video_path]) 24 | stats_manager = StatsManager() 25 | # Construct our shotManager and pass it our StatsManager. 26 | shot_manager = ShotManager(stats_manager) 27 | 28 | # Add ContentDetector algorithm (each detector's constructor 29 | # takes detector options, e.g. threshold). 30 | if args.avg_sample: 31 | shot_manager.add_detector(AverageDetector(shot_length=50)) 32 | else: 33 | shot_manager.add_detector(ContentDetectorHSVLUV(threshold=20)) 34 | base_timecode = video_manager.get_base_timecode() 35 | 36 | shot_list = [] 37 | 38 | try: 39 | # If stats file exists, load it. 40 | if osp.exists(stats_file_path): 41 | # Read stats from CSV file opened in read mode: 42 | with open(stats_file_path, 'r') as stats_file: 43 | stats_manager.load_from_csv(stats_file, base_timecode) 44 | 45 | # Set begin and end time 46 | if args.begin_time is not None: 47 | start_time = base_timecode + args.begin_time 48 | end_time = base_timecode + args.end_time 49 | video_manager.set_duration(start_time=start_time, end_time=end_time) 50 | elif args.begin_frame is not None: 51 | start_frame = base_timecode + args.begin_frame 52 | end_frame = base_timecode + args.end_frame 53 | video_manager.set_duration(start_time=start_frame, end_time=end_frame) 54 | pass 55 | # Set downscale factor to improve processing speed. 56 | if args.keep_resolution: 57 | video_manager.set_downscale_factor(1) 58 | else: 59 | video_manager.set_downscale_factor() 60 | # Start video_manager. 61 | video_manager.start() 62 | 63 | # Perform shot detection on video_manager. 64 | shot_manager.detect_shots(frame_source=video_manager) 65 | 66 | # Obtain list of detected shots. 67 | shot_list = shot_manager.get_shot_list(base_timecode) 68 | # Each shot is a tuple of (start, end) FrameTimecodes. 69 | if args.print_result: 70 | print('List of shots obtained:') 71 | for i, shot in enumerate(shot_list): 72 | print( 73 | 'Shot %4d: Start %s / Frame %d, End %s / Frame %d' % ( 74 | i, 75 | shot[0].get_timecode(), shot[0].get_frames(), 76 | shot[1].get_timecode(), shot[1].get_frames(),)) 77 | # Save keyf img for each shot 78 | if args.save_keyf: 79 | output_dir = osp.join(data_root, "shot_keyf", video_prefix) 80 | generate_images(video_manager, shot_list, output_dir, num_images=3) 81 | 82 | # Save keyf txt of frame ind 83 | if args.save_keyf_txt: 84 | output_dir = osp.join(data_root, "shot_txt", "{}.txt".format(video_prefix)) 85 | os.makedirs(osp.join(data_root, 'shot_txt'), exist_ok=True) 86 | generate_images_txt(shot_list, output_dir) 87 | 88 | # Split video into shot video 89 | if args.split_video: 90 | output_dir = osp.join(data_root, "shot_split_video", video_prefix) 91 | split_video_ffmpeg([video_path], shot_list, output_dir, suppress_output=False) 92 | 93 | # We only write to the stats file if a save is required: 94 | if stats_manager.is_save_required(): 95 | with open(stats_file_path, 'w') as stats_file: 96 | stats_manager.save_to_csv(stats_file, base_timecode) 97 | finally: 98 | video_manager.release() 99 | 100 | 101 | if __name__ == '__main__': 102 | parser = argparse.ArgumentParser("Single Video ShotDetect") 103 | parser.add_argument('--video_path', type=str, 104 | default=osp.join("../data/demo", "video/demo.mp4"), 105 | help="path to the video to be processed") 106 | parser.add_argument('--save_data_root_path', type=str, 107 | default="../data/demo", 108 | help="path to the saved data") 109 | parser.add_argument('--print_result', action="store_true") 110 | parser.add_argument('--save_keyf', action="store_true") 111 | parser.add_argument('--save_keyf_txt', action="store_true") 112 | parser.add_argument('--split_video', action="store_true") 113 | parser.add_argument('--keep_resolution', action="store_true") 114 | parser.add_argument('--avg_sample', action="store_true") 115 | parser.add_argument('--begin_time', type=float, default=None, help="float: timecode") 116 | parser.add_argument('--end_time', type=float, default=120.0, help="float: timecode") 117 | parser.add_argument('--begin_frame', type=int, default=None, help="int: frame") 118 | parser.add_argument('--end_frame', type=int, default=1000, help="int: frame") 119 | args = parser.parse_args() 120 | main(args, args.save_data_root_path) 121 | -------------------------------------------------------------------------------- /shotdetect/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import sys 3 | import os 4 | import time 5 | 6 | from shotdetect.shot_manager import ShotManager 7 | from shotdetect.frame_timecode import FrameTimecode 8 | from shotdetect.video_manager import VideoManager 9 | -------------------------------------------------------------------------------- /shotdetect/detectors/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/anyirao/ShotDetection/fcc63d5b09c2e9a9b538223a573026b5d2608562/shotdetect/detectors/__init__.py -------------------------------------------------------------------------------- /shotdetect/detectors/average_detector.py: -------------------------------------------------------------------------------- 1 | import pdb 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from shotdetect.shot_detector import shotDetector 7 | 8 | 9 | class AverageDetector(shotDetector): 10 | """Average sampling around 11 | noraml [-1, 1] * 0.2 * self.shot_length_fix + self.shot_length_fix 12 | """ 13 | 14 | def __init__(self, shot_length=50, random=False): 15 | super(AverageDetector, self).__init__() 16 | self.random = random 17 | self.random_ratio = 0.2 18 | self.shot_length_fix = shot_length 19 | 20 | if random: 21 | self.shot_length = int(self.shot_length_fix + (np.random.rand() - 0.5) * 2 * self.random_ratio * self.shot_length_fix) 22 | else: 23 | self.shot_length = shot_length 24 | 25 | # self.min_shot_len = min_shot_len # minimum length of any given shot, in frames 26 | self.last_frame = None 27 | self.last_frame_num = 0 28 | self.last_shot_cut = 0 29 | self._metric_keys = ['frame_num'] 30 | 31 | def process_frame(self, frame_num, frame_img): 32 | """ 33 | Args: 34 | frame_num (int): Frame number of frame that is being passed. 35 | 36 | frame_img (Optional[int]): Decoded frame image (np.ndarray) to perform shot 37 | detection on. Can be None *only* if the self.is_processing_required() method 38 | (inhereted from the base shotDetector class) returns True. 39 | 40 | Returns: 41 | List[int]: List of frames where shot cuts have been detected. There may be 0 42 | or more frames in the list, and not necessarily the same as frame_num. 43 | """ 44 | cut_list = [] 45 | metric_keys = self._metric_keys 46 | _unused = '' 47 | # print(frame_num, cut_list, frame_num / self.shot_length == 0) 48 | # pdb.set_trace() 49 | if self.last_frame is not None: 50 | self.stats_manager.set_metrics(frame_num, { 51 | metric_keys[0]: frame_num, 52 | }) 53 | 54 | # if self.last_shot_cut is None or ( 55 | # (frame_num - self.last_shot_cut) >= self.min_shot_len): 56 | if frame_num - self.last_shot_cut >= self.shot_length and frame_num != 0: 57 | cut_list.append(frame_num) 58 | self.last_shot_cut = frame_num 59 | if self.random: 60 | self.shot_length = int(self.shot_length_fix + (np.random.rand() - 0.5) * 2 * self.random_ratio * self.shot_length_fix) 61 | else: 62 | self.shot_length = self.shot_length_fix 63 | assert self.shot_length > 0 64 | if (self.stats_manager is not None and 65 | self.stats_manager.metrics_exist(frame_num+1, metric_keys)): 66 | self.last_frame = _unused 67 | else: 68 | self.last_frame = frame_img.copy() 69 | # if len(cut_list) > 0: 70 | # print(frame_num, cut_list) 71 | # pdb.set_trace() 72 | return cut_list 73 | -------------------------------------------------------------------------------- /shotdetect/detectors/content_detector.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | import pdb 12 | 13 | import cv2 14 | import numpy as np 15 | 16 | from shotdetect.shot_detector import shotDetector 17 | 18 | 19 | class ContentDetector(shotDetector): 20 | """Detects fast cuts using changes in colour and intensity between frames. 21 | 22 | Since the difference between frames is used, unlike the ThresholdDetector, 23 | only fast cuts are detected with this method. To detect slow fades between 24 | content shots still using HSV information, use the DissolveDetector. 25 | """ 26 | 27 | def __init__(self, threshold=32.0, min_shot_len=15): 28 | super(ContentDetector, self).__init__() 29 | self.threshold = threshold 30 | self.min_shot_len = min_shot_len # minimum length of any given shot, in frames 31 | self.last_frame = None 32 | self.last_shot_cut = None 33 | self.last_hsv = None 34 | self._metric_keys = ['content_val', 'delta_hue', 'delta_sat', 'delta_lum'] 35 | self.cli_name = 'detect-content' 36 | 37 | def process_frame(self, frame_num, frame_img): 38 | # type: (int, numpy.ndarray) -> List[int] 39 | """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead 40 | of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). 41 | 42 | Args: 43 | frame_num (int): Frame number of frame that is being passed. 44 | 45 | frame_img (Optional[int]): Decoded frame image (numpy.ndarray) to perform shot 46 | detection on. Can be None *only* if the self.is_processing_required() method 47 | (inhereted from the base shotDetector class) returns True. 48 | 49 | Returns: 50 | List[int]: List of frames where shot cuts have been detected. There may be 0 51 | or more frames in the list, and not necessarily the same as frame_num. 52 | """ 53 | cut_list = [] 54 | metric_keys = self._metric_keys 55 | _unused = '' 56 | 57 | if self.last_frame is not None: 58 | # Change in average of HSV (hsv), (h)ue only, (s)aturation only, (l)uminance only. 59 | delta_hsv_avg, delta_h, delta_s, delta_v = 0.0, 0.0, 0.0, 0.0 60 | 61 | if (self.stats_manager is not None and 62 | self.stats_manager.metrics_exist(frame_num, metric_keys)): 63 | delta_hsv_avg, delta_h, delta_s, delta_v = self.stats_manager.get_metrics( 64 | frame_num, metric_keys) 65 | 66 | else: 67 | num_pixels = frame_img.shape[0] * frame_img.shape[1] 68 | curr_hsv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) 69 | curr_hsv = [x.astype(np.int32) for x in curr_hsv] 70 | last_hsv = self.last_hsv 71 | if not last_hsv: 72 | last_hsv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2HSV)) 73 | 74 | delta_hsv = [0, 0, 0, 0] 75 | for i in range(3): 76 | num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] 77 | delta_hsv[i] = np.sum( 78 | np.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) 79 | delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 80 | delta_h, delta_s, delta_v, delta_hsv_avg = delta_hsv 81 | 82 | if self.stats_manager is not None: 83 | self.stats_manager.set_metrics(frame_num, { 84 | metric_keys[0]: delta_hsv_avg, 85 | metric_keys[1]: delta_h, 86 | metric_keys[2]: delta_s, 87 | metric_keys[3]: delta_v}) 88 | 89 | self.last_hsv = curr_hsv 90 | # pdb.set_trace() 91 | if delta_hsv_avg >= self.threshold: 92 | if self.last_shot_cut is None or ( 93 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 94 | cut_list.append(frame_num) 95 | self.last_shot_cut = frame_num 96 | 97 | if self.last_frame is not None and self.last_frame is not _unused: 98 | del self.last_frame 99 | 100 | # If we have the next frame computed, don't copy the current frame 101 | # into last_frame since we won't use it on the next call anyways. 102 | if (self.stats_manager is not None and 103 | self.stats_manager.metrics_exist(frame_num+1, metric_keys)): 104 | self.last_frame = _unused 105 | else: 106 | self.last_frame = frame_img.copy() 107 | # if len(cut_list) > 0: 108 | # print(frame_num,cut_list) 109 | return cut_list 110 | -------------------------------------------------------------------------------- /shotdetect/detectors/content_detector_hsv_l2.py: -------------------------------------------------------------------------------- 1 | import pdb 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from shotdetect.shot_detector import shotDetector 7 | 8 | 9 | class ContentDetectorHSVL2(shotDetector): 10 | """Detects fast cuts using changes in colour and intensity between frames. 11 | 12 | Since the difference between frames is used, unlike the ThresholdDetector, 13 | only fast cuts are detected with this method. To detect slow fades between 14 | content shots still using HSV information, use the DissolveDetector. 15 | """ 16 | 17 | def __init__(self, threshold=30.0, min_shot_len=15): 18 | super(ContentDetectorHSVL2, self).__init__() 19 | self.hsv_threshold = threshold 20 | self.delta_hsv_gap_threshold = 10 21 | self.rgb_threshold = 50 22 | self.hsv_weight = 3 23 | self.min_shot_len = min_shot_len # minimum length of any given shot, in frames 24 | self.last_frame = None 25 | self.last_shot_cut = None 26 | self.last_hsv = None 27 | self._metric_keys = ['hsv_content_val', 'delta_hsv_hue', 'delta_hsv_sat', 'delta_hsv_lum','rgb_content_val', 'delta_rgb_hue', 'delta_rgb_sat', 'delta_rgb_lum'] 28 | self.cli_name = 'detect-content' 29 | self.last_rgb = None 30 | 31 | def process_frame(self, frame_num, frame_img): 32 | # type: (int, np.ndarray) -> List[int] 33 | """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead 34 | of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). 35 | 36 | Args: 37 | frame_num (int): Frame number of frame that is being passed. 38 | 39 | frame_img (Optional[int]): Decoded frame image (np.ndarray) to perform shot 40 | detection on. Can be None *only* if the self.is_processing_required() method 41 | (inhereted from the base shotDetector class) returns True. 42 | 43 | Returns: 44 | List[int]: List of frames where shot cuts have been detected. There may be 0 45 | or more frames in the list, and not necessarily the same as frame_num. 46 | """ 47 | cut_list = [] 48 | metric_keys = self._metric_keys 49 | _unused = '' 50 | 51 | if self.last_frame is not None: 52 | # Change in average of HSV (hsv), (h)ue only, (s)aturation only, (l)uminance only. 53 | delta_hsv_avg, delta_hsv_h, delta_hsv_s, delta_hsv_v = 0.0, 0.0, 0.0, 0.0 54 | delta_rgb_avg, delta_rgb_h, delta_rgb_s, delta_rgb_v = 0.0, 0.0, 0.0, 0.0 55 | 56 | if (self.stats_manager is not None and 57 | self.stats_manager.metrics_exist(frame_num, metric_keys)): 58 | delta_hsv_avg, delta_hsv_h, delta_hsv_s, delta_hsv_v, delta_rgb_avg, delta_rgb_h, delta_rgb_s, delta_rgb_v = self.stats_manager.get_metrics( 59 | frame_num, metric_keys) 60 | 61 | else: 62 | num_pixels = frame_img.shape[0] * frame_img.shape[1] 63 | curr_rgb = cv2.split(frame_img) 64 | curr_hsv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) 65 | curr_rgb = [x.astype(np.int32) for x in curr_rgb] 66 | curr_hsv = [x.astype(np.int32) for x in curr_hsv] 67 | last_hsv = self.last_hsv 68 | last_rgb = self.last_rgb 69 | if not last_hsv: 70 | last_hsv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2HSV)) 71 | last_rgb = cv2.split(self.last_frame) 72 | 73 | delta_hsv = [0, 0, 0, 0] 74 | for i in range(3): 75 | num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] 76 | delta_hsv[i] = np.sum( 77 | np.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) 78 | delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 79 | delta_hsv_h, delta_hsv_s, delta_hsv_v, delta_hsv_avg = delta_hsv 80 | 81 | delta_rgb = [0, 0, 0, 0] 82 | for i in range(3): 83 | num_pixels = curr_rgb[i].shape[0] * curr_rgb[i].shape[1] 84 | delta_rgb[i] = np.sum( 85 | np.abs(curr_rgb[i] - last_rgb[i])) / float(num_pixels) 86 | delta_rgb[3] = sum(delta_rgb[0:3]) / 3.0 87 | delta_rgb_h, delta_rgb_s, delta_rgb_v, delta_rgb_avg = delta_rgb 88 | 89 | if self.stats_manager is not None: 90 | self.stats_manager.set_metrics(frame_num, { 91 | metric_keys[0]: delta_hsv_avg, 92 | metric_keys[1]: delta_hsv_h, 93 | metric_keys[2]: delta_hsv_s, 94 | metric_keys[3]: delta_hsv_v, 95 | metric_keys[0+4]: delta_rgb_avg, 96 | metric_keys[1+4]: delta_rgb_h, 97 | metric_keys[2+4]: delta_rgb_s, 98 | metric_keys[3+4]: delta_rgb_v, 99 | }) 100 | 101 | self.last_hsv = curr_hsv 102 | self.last_rgb = curr_rgb 103 | # pdb.set_trace() 104 | if delta_hsv_avg >= self.hsv_threshold and delta_hsv_avg - self.hsv_threshold >= self.delta_hsv_gap_threshold: 105 | # print(frame_num, delta_hsv_avg, delta_rgb_avg) 106 | if self.last_shot_cut is None or ( 107 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 108 | cut_list.append(frame_num) 109 | self.last_shot_cut = frame_num 110 | elif delta_hsv_avg >= self.hsv_threshold and delta_hsv_avg - self.hsv_threshold < self.delta_hsv_gap_threshold \ 111 | and delta_rgb_avg + self.hsv_weight * (delta_hsv_avg - self.hsv_threshold) > self.rgb_threshold: 112 | # print(frame_num, delta_hsv_avg, delta_rgb_avg) 113 | if self.last_shot_cut is None or ( 114 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 115 | cut_list.append(frame_num) 116 | self.last_shot_cut = frame_num 117 | 118 | if self.last_frame is not None and self.last_frame is not _unused: 119 | del self.last_frame 120 | 121 | # If we have the next frame computed, don't copy the current frame 122 | # into last_frame since we won't use it on the next call anyways. 123 | if (self.stats_manager is not None and 124 | self.stats_manager.metrics_exist(frame_num+1, metric_keys)): 125 | self.last_frame = _unused 126 | else: 127 | self.last_frame = frame_img.copy() 128 | # if len(cut_list) > 0: 129 | # print(frame_num,cut_list) 130 | pdb.set_trace() 131 | return cut_list 132 | -------------------------------------------------------------------------------- /shotdetect/detectors/content_detector_hsv_luv.py: -------------------------------------------------------------------------------- 1 | import pdb 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | from shotdetect.shot_detector import shotDetector 7 | 8 | 9 | class ContentDetectorHSVLUV(shotDetector): 10 | """Detects fast cuts using changes in colour and intensity between frames. 11 | 12 | Since the difference between frames is used, unlike the ThresholdDetector, 13 | only fast cuts are detected with this method. To detect slow fades between 14 | content shots still using HSV information, use the DissolveDetector. 15 | """ 16 | 17 | def __init__(self, threshold=30.0, min_shot_len=15): 18 | super(ContentDetectorHSVLUV, self).__init__() 19 | self.hsv_threshold = threshold 20 | self.delta_hsv_gap_threshold = 10 21 | self.luv_threshold = 20 22 | self.hsv_weight = 5 23 | self.min_shot_len = min_shot_len # minimum length of any given shot, in frames 24 | self.last_frame = None 25 | self.last_shot_cut = None 26 | self.last_hsv = None 27 | self._metric_keys = ['hsv_content_val', 'delta_hsv_hue', 'delta_hsv_sat', 'delta_hsv_lum','luv_content_val', 'delta_luv_hue', 'delta_luv_sat', 'delta_luv_lum'] 28 | self.cli_name = 'detect-content' 29 | self.last_luv = None 30 | 31 | def process_frame(self, frame_num, frame_img): 32 | # type: (int, np.ndarray) -> List[int] 33 | """ Similar to ThresholdDetector, but using the HSV colour space DIFFERENCE instead 34 | of single-frame RGB/grayscale intensity (thus cannot detect slow fades with this method). 35 | 36 | Args: 37 | frame_num (int): Frame number of frame that is being passed. 38 | 39 | frame_img (Optional[int]): Decoded frame image (np.ndarray) to perform shot 40 | detection on. Can be None *only* if the self.is_processing_required() method 41 | (inhereted from the base shotDetector class) returns True. 42 | 43 | Returns: 44 | List[int]: List of frames where shot cuts have been detected. There may be 0 45 | or more frames in the list, and not necessarily the same as frame_num. 46 | """ 47 | cut_list = [] 48 | metric_keys = self._metric_keys 49 | _unused = '' 50 | 51 | if self.last_frame is not None: 52 | # Change in average of HSV (hsv), (h)ue only, (s)aturation only, (l)uminance only. 53 | delta_hsv_avg, delta_hsv_h, delta_hsv_s, delta_hsv_v = 0.0, 0.0, 0.0, 0.0 54 | delta_luv_avg, delta_luv_h, delta_luv_s, delta_luv_v = 0.0, 0.0, 0.0, 0.0 55 | 56 | if (self.stats_manager is not None and 57 | self.stats_manager.metrics_exist(frame_num, metric_keys)): 58 | delta_hsv_avg, delta_hsv_h, delta_hsv_s, delta_hsv_v, delta_luv_avg, delta_luv_h, delta_luv_s, delta_luv_v = self.stats_manager.get_metrics( 59 | frame_num, metric_keys) 60 | 61 | else: 62 | num_pixels = frame_img.shape[0] * frame_img.shape[1] 63 | curr_luv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2Luv)) 64 | curr_hsv = cv2.split(cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV)) 65 | curr_luv = [x.astype(np.int32) for x in curr_luv] 66 | curr_hsv = [x.astype(np.int32) for x in curr_hsv] 67 | last_hsv = self.last_hsv 68 | last_luv = self.last_luv 69 | if not last_hsv: 70 | last_hsv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2HSV)) 71 | last_luv = cv2.split(cv2.cvtColor(self.last_frame, cv2.COLOR_BGR2Luv)) 72 | 73 | delta_hsv = [0, 0, 0, 0] 74 | for i in range(3): 75 | num_pixels = curr_hsv[i].shape[0] * curr_hsv[i].shape[1] 76 | delta_hsv[i] = np.sum( 77 | np.abs(curr_hsv[i] - last_hsv[i])) / float(num_pixels) 78 | delta_hsv[3] = sum(delta_hsv[0:3]) / 3.0 79 | delta_hsv_h, delta_hsv_s, delta_hsv_v, delta_hsv_avg = delta_hsv 80 | 81 | delta_luv = [0, 0, 0, 0] 82 | for i in range(3): 83 | num_pixels = curr_luv[i].shape[0] * curr_luv[i].shape[1] 84 | delta_luv[i] = np.sum( 85 | np.abs(curr_luv[i] - last_luv[i])) / float(num_pixels) 86 | delta_luv[3] = sum(delta_luv[0:3]) / 3.0 87 | delta_luv_h, delta_luv_s, delta_luv_v, delta_luv_avg = delta_luv 88 | 89 | if self.stats_manager is not None: 90 | self.stats_manager.set_metrics(frame_num, { 91 | metric_keys[0]: delta_hsv_avg, 92 | metric_keys[1]: delta_hsv_h, 93 | metric_keys[2]: delta_hsv_s, 94 | metric_keys[3]: delta_hsv_v, 95 | metric_keys[0+4]: delta_luv_avg, 96 | metric_keys[1+4]: delta_luv_h, 97 | metric_keys[2+4]: delta_luv_s, 98 | metric_keys[3+4]: delta_luv_v, 99 | }) 100 | 101 | self.last_hsv = curr_hsv 102 | self.last_luv = curr_luv 103 | # pdb.set_trace() 104 | if delta_hsv_avg >= self.hsv_threshold and delta_hsv_avg - self.hsv_threshold >= self.delta_hsv_gap_threshold: 105 | # print(frame_num,delta_hsv_avg,delta_luv_avg) 106 | if self.last_shot_cut is None or ( 107 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 108 | cut_list.append(frame_num) 109 | self.last_shot_cut = frame_num 110 | elif delta_hsv_avg >= self.hsv_threshold and delta_hsv_avg - self.hsv_threshold < self.delta_hsv_gap_threshold \ 111 | and delta_luv_avg + self.hsv_weight * (delta_hsv_avg - self.hsv_threshold) > self.luv_threshold: 112 | # print(frame_num,delta_hsv_avg,delta_luv_avg) 113 | if self.last_shot_cut is None or ( 114 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 115 | cut_list.append(frame_num) 116 | self.last_shot_cut = frame_num 117 | 118 | if self.last_frame is not None and self.last_frame is not _unused: 119 | del self.last_frame 120 | 121 | # If we have the next frame computed, don't copy the current frame 122 | # into last_frame since we won't use it on the next call anyways. 123 | if (self.stats_manager is not None and 124 | self.stats_manager.metrics_exist(frame_num+1, metric_keys)): 125 | self.last_frame = _unused 126 | else: 127 | self.last_frame = frame_img.copy() 128 | # if len(cut_list) > 0: 129 | # print(frame_num,cut_list) 130 | return cut_list 131 | -------------------------------------------------------------------------------- /shotdetect/detectors/motion_detector.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | import cv2 12 | import numpy 13 | 14 | 15 | from shotdetect.shot_detector import shotDetector 16 | 17 | class MotionDetector(shotDetector): 18 | """Detects motion events in shots containing a static background. 19 | 20 | Uses background subtraction followed by noise removal (via morphological 21 | opening) to generate a frame score compared against the set threshold. 22 | 23 | Attributes: 24 | threshold: floating point value compared to each frame's score, which 25 | represents average intensity change per pixel (lower values are 26 | more sensitive to motion changes). Default 0.5, must be > 0.0. 27 | num_frames_post_shot: Number of frames to include in each motion 28 | event after the frame score falls below the threshold, adding any 29 | subsequent motion events to the same shot. 30 | kernel_size: Size of morphological opening kernel for noise removal. 31 | Setting to -1 (default) will auto-compute based on video resolution 32 | (typically 3 for SD, 5-7 for HD). Must be an odd integer > 1. 33 | """ 34 | def __init__(self, threshold = 0.50, num_frames_post_shot = 30, 35 | kernel_size = -1): 36 | """Initializes motion-based shot detector object.""" 37 | # Requires porting to v0.5 API. 38 | raise NotImplementedError() 39 | 40 | self.threshold = float(threshold) 41 | self.num_frames_post_shot = int(num_frames_post_shot) 42 | 43 | self.kernel_size = int(kernel_size) 44 | if self.kernel_size < 0: 45 | # Set kernel size when process_frame first runs based on 46 | # video resolution (480p = 3x3, 720p = 5x5, 1080p = 7x7). 47 | pass 48 | 49 | self.bg_subtractor = cv2.createBackgroundSubtractorMOG2( 50 | detectShadows = False ) 51 | 52 | self.last_frame_score = 0.0 53 | 54 | self.in_motion_event = False 55 | self.first_motion_frame_index = -1 56 | self.last_motion_frame_index = -1 57 | self.cli_name = 'detect-motion' 58 | return 59 | 60 | def process_frame(self, frame_num, frame_img, frame_metrics, shot_list): 61 | 62 | # Value to return indiciating if a shot cut was found or not. 63 | cut_detected = False 64 | 65 | frame_grayscale = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) 66 | masked_frame = self.bg_subtractor.apply(frame_grayscale) 67 | 68 | kernel = numpy.ones((self.kernel_size, self.kernel_size), numpy.uint8) 69 | filtered_frame = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) 70 | 71 | frame_score = numpy.sum(filtered_frame) / float( 72 | filtered_frame.shape[0] * filtered_frame.shape[1] ) 73 | 74 | return cut_detected 75 | 76 | def post_process(self, shot_list, frame_num): 77 | """Writes the last shot if the video ends while in a motion event. 78 | """ 79 | 80 | # If the last fade detected was a fade out, we add a corresponding new 81 | # shot break to indicate the end of the shot. This is only done for 82 | # fade-outs, as a shot cut is already added when a fade-in is found. 83 | 84 | if self.in_motion_event: 85 | # Write new shot based on first and last motion event frames. 86 | pass 87 | return self.in_motion_event 88 | 89 | 90 | -------------------------------------------------------------------------------- /shotdetect/detectors/threshold_detector.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | 12 | import numpy 13 | 14 | from shotdetect.shot_detector import shotDetector 15 | 16 | 17 | def compute_frame_average(frame): 18 | """Computes the average pixel value/intensity for all pixels in a frame. 19 | 20 | The value is computed by adding up the 8-bit R, G, and B values for 21 | each pixel, and dividing by the number of pixels multiplied by 3. 22 | 23 | Returns: 24 | Floating point value representing average pixel intensity. 25 | """ 26 | num_pixel_values = float( 27 | frame.shape[0] * frame.shape[1] * frame.shape[2]) 28 | avg_pixel_value = numpy.sum(frame[:, :, :]) / num_pixel_values 29 | return avg_pixel_value 30 | 31 | 32 | class ThresholdDetector(shotDetector): 33 | """Detects fast cuts/slow fades in from and out to a given threshold level. 34 | 35 | Detects both fast cuts and slow fades so long as an appropriate threshold 36 | is chosen (especially taking into account the minimum grey/black level). 37 | 38 | Attributes: 39 | threshold: 8-bit intensity value that each pixel value (R, G, and B) 40 | must be <= to in order to trigger a fade in/out. 41 | min_percent: Float between 0.0 and 1.0 which represents the minimum 42 | percent of pixels in a frame that must meet the threshold value in 43 | order to trigger a fade in/out. 44 | min_shot_len: Unsigned integer greater than 0 representing the 45 | minimum length, in frames, of a shot (or subsequent shot cut). 46 | fade_bias: Float between -1.0 and +1.0 representing the percentage of 47 | timecode skew for the start of a shot (-1.0 causing a cut at the 48 | fade-to-black, 0.0 in the middle, and +1.0 causing the cut to be 49 | right at the position where the threshold is passed). 50 | add_final_shot: Boolean indicating if the video ends on a fade-out to 51 | generate an additional shot at this timecode. 52 | block_size: Number of rows in the image to sum per iteration (can be 53 | tuned to increase performance in some cases; should be computed 54 | programmatically in the future). 55 | """ 56 | def __init__(self, threshold=12, min_percent=0.95, min_shot_len=15, 57 | fade_bias=0.0, add_final_shot=False, block_size=8): 58 | """Initializes threshold-based shot detector object.""" 59 | 60 | super(ThresholdDetector, self).__init__() 61 | self.threshold = int(threshold) 62 | self.fade_bias = fade_bias 63 | self.min_percent = min_percent 64 | self.min_shot_len = min_shot_len 65 | self.last_frame_avg = None 66 | self.last_shot_cut = None 67 | # Whether to add an additional shot or not when ending on a fade out 68 | # (as cuts are only added on fade ins; see post_process() for details). 69 | self.add_final_shot = add_final_shot 70 | # Where the last fade (threshold crossing) was detected. 71 | self.last_fade = { 72 | 'frame': 0, # frame number where the last detected fade is 73 | 'type': None # type of fade, can be either 'in' or 'out' 74 | } 75 | self.block_size = block_size 76 | self._metric_keys = ['delta_rgb'] 77 | self.cli_name = 'detect-threshold' 78 | 79 | def frame_under_threshold(self, frame): 80 | """Check if the frame is below (true) or above (false) the threshold. 81 | 82 | Instead of using the average, we check all pixel values (R, G, and B) 83 | meet the given threshold (within the minimum percent). This ensures 84 | that the threshold is not exceeded while maintaining some tolerance for 85 | compression and noise. 86 | 87 | This is the algorithm used for absolute mode of the threshold detector. 88 | 89 | Returns: 90 | Boolean, True if the number of pixels whose R, G, and B values are 91 | all <= the threshold is within min_percent pixels, or False if not. 92 | """ 93 | # First we compute the minimum number of pixels that need to meet the 94 | # threshold. Internally, we check for values greater than the threshold 95 | # as it's more likely that a given frame contains actual content. This 96 | # is done in blocks of rows, so in many cases we only have to check a 97 | # small portion of the frame instead of inspecting every single pixel. 98 | num_pixel_values = float(frame.shape[0] * frame.shape[1] * frame.shape[2]) 99 | min_pixels = int(num_pixel_values * (1.0 - self.min_percent)) 100 | 101 | curr_frame_amt = 0 102 | curr_frame_row = 0 103 | 104 | while curr_frame_row < frame.shape[0]: 105 | # Add and total the number of individual pixel values (R, G, and B) 106 | # in the current row block that exceed the threshold. 107 | curr_frame_amt += int(numpy.sum( 108 | frame[curr_frame_row : curr_frame_row + self.block_size, :, :] > self.threshold)) 109 | # If we've already exceeded the most pixels allowed to be above the 110 | # threshold, we can skip processing the rest of the pixels. 111 | if curr_frame_amt > min_pixels: 112 | return False 113 | curr_frame_row += self.block_size 114 | return True 115 | 116 | def process_frame(self, frame_num, frame_img): 117 | # type: (int, Optional[numpy.ndarray]) -> List[int] 118 | """ 119 | Args: 120 | frame_num (int): Frame number of frame that is being passed. 121 | frame_img (numpy.ndarray or None): Decoded frame image (numpy.ndarray) to perform 122 | shot detection with. Can be None *only* if the self.is_processing_required() 123 | method (inhereted from the base shotDetector class) returns True. 124 | Returns: 125 | List[int]: List of frames where shot cuts have been detected. There may be 0 126 | or more frames in the list, and not necessarily the same as frame_num. 127 | """ 128 | 129 | # Compare the # of pixels under threshold in current_frame & last_frame. 130 | # If absolute value of pixel intensity delta is above the threshold, 131 | # then we trigger a new shot cut/break. 132 | 133 | # List of cuts to return. 134 | cut_list = [] 135 | 136 | # The metric used here to detect shot breaks is the percent of pixels 137 | # less than or equal to the threshold; however, since this differs on 138 | # user-supplied values, we supply the average pixel intensity as this 139 | # frame metric instead (to assist with manually selecting a threshold) 140 | frame_avg = 0.0 141 | 142 | if (self.stats_manager is not None and 143 | self.stats_manager.metrics_exist(frame_num, self._metric_keys)): 144 | frame_avg = self.stats_manager.get_metrics(frame_num, self._metric_keys)[0] 145 | else: 146 | frame_avg = compute_frame_average(frame_img) 147 | if self.stats_manager is not None: 148 | self.stats_manager.set_metrics(frame_num, { 149 | self._metric_keys[0]: frame_avg}) 150 | 151 | if self.last_frame_avg is not None: 152 | if self.last_fade['type'] == 'in' and self.frame_under_threshold(frame_img): 153 | # Just faded out of a shot, wait for next fade in. 154 | self.last_fade['type'] = 'out' 155 | self.last_fade['frame'] = frame_num 156 | elif self.last_fade['type'] == 'out' and not self.frame_under_threshold(frame_img): 157 | # Just faded into a new shot, compute timecode for the shot 158 | # split based on the fade bias. 159 | f_in = frame_num 160 | f_out = self.last_fade['frame'] 161 | f_split = int((f_in + f_out + int(self.fade_bias * (f_in - f_out))) / 2) 162 | # Only add the shot if min_shot_len frames have passed. 163 | if self.last_shot_cut is None or ( 164 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 165 | cut_list.append(f_split) 166 | self.last_shot_cut = frame_num 167 | self.last_fade['type'] = 'in' 168 | self.last_fade['frame'] = frame_num 169 | else: 170 | self.last_fade['frame'] = 0 171 | if self.frame_under_threshold(frame_img): 172 | self.last_fade['type'] = 'out' 173 | else: 174 | self.last_fade['type'] = 'in' 175 | # Before returning, we keep track of the last frame average (can also 176 | # be used to compute fades independently of the last fade type). 177 | self.last_frame_avg = frame_avg 178 | return cut_list 179 | 180 | def post_process(self, frame_num): 181 | """Writes a final shot cut if the last detected fade was a fade-out. 182 | 183 | Only writes the shot cut if add_final_shot is true, and the last fade 184 | that was detected was a fade-out. There is no bias applied to this cut 185 | (since there is no corresponding fade-in) so it will be located at the 186 | exact frame where the fade-out crossed the detection threshold. 187 | """ 188 | 189 | # If the last fade detected was a fade out, we add a corresponding new 190 | # shot break to indicate the end of the shot. This is only done for 191 | # fade-outs, as a shot cut is already added when a fade-in is found. 192 | cut_times = [] 193 | if self.last_fade['type'] == 'out' and self.add_final_shot and ( 194 | self.last_shot_cut is None or 195 | (frame_num - self.last_shot_cut) >= self.min_shot_len): 196 | cut_times.append(self.last_fade['frame']) 197 | return cut_times 198 | -------------------------------------------------------------------------------- /shotdetect/frame_timecode.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | """ 12 | This module contains the :py:class:`FrameTimecode` object, which is used as a way for 13 | ShotDetect to store frame-accurate timestamps of each cut. This is done by also 14 | specifying the video framerate with the timecode, allowing a frame number to be 15 | converted to/from a floating-point number of seconds, or string in the form 16 | `"HH:MM:SS[.nnn]"` where the `[.nnn]` part is optional. 17 | 18 | See the following examples, or the :py:class:`FrameTimecode constructor `. 19 | 20 | Unit tests for the FrameTimecode object can be found in `tests/test_timecode.py`. 21 | """ 22 | 23 | import math 24 | 25 | from shotdetect.platform import STRING_TYPE 26 | 27 | MINIMUM_FRAMES_PER_SECOND_FLOAT = 1.0 / 1000.0 28 | MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT = 1.0 / 100000 29 | 30 | 31 | class FrameTimecode(object): 32 | """ Object for frame-based timecodes, using the video framerate 33 | to compute back and forth between frame number and second/timecode formats. 34 | 35 | The timecode argument is valid only if it complies with one of the following 36 | three types/formats: 37 | 38 | 1) string: standard timecode HH:MM:SS[.nnn]: 39 | `str` in form 'HH:MM:SS' or 'HH:MM:SS.nnn', or 40 | `list`/`tuple` in form [HH, MM, SS] or [HH, MM, SS.nnn] 41 | 2) float: number of seconds S[.SSS], where S >= 0.0: 42 | `float` in form S.SSS, or 43 | `str` in form 'Ss' or 'S.SSSs' (e.g. '5s', '1.234s') 44 | 3) int: Exact number of frames N, where N >= 0: 45 | `int` in form `N`, or 46 | `str` in form 'N' 47 | 48 | Args: 49 | timecode (str, float, int, or FrameTimecode): A timecode or frame 50 | number, given in any of the above valid formats/types. This 51 | argument is always required. 52 | fps (float, or FrameTimecode, conditionally required): The framerate 53 | to base all frame to time arithmetic on (if FrameTimecode, copied 54 | from the passed framerate), to allow frame-accurate arithmetic. The 55 | framerate must be the same when combining FrameTimecode objects 56 | in operations. This argument is always required, unless **timecode** 57 | is a FrameTimecode. 58 | Raises: 59 | TypeError: Thrown if timecode is wrong type/format, or if fps is None 60 | or a type other than int or float. 61 | ValueError: Thrown when specifying a negative timecode or framerate. 62 | """ 63 | 64 | def __init__(self, timecode=None, fps=None): 65 | # type: (Union[int, float, str, FrameTimecode], float, 66 | # Union[int, float, str, FrameTimecode]) 67 | # The following two properties are what is used to keep track of time 68 | # in a frame-specific manner. Note that once the framerate is set, 69 | # the value should never be modified (only read if required). 70 | self.framerate = None 71 | self.frame_num = None 72 | 73 | # Copy constructor. Only the timecode argument is used in this case. 74 | if isinstance(timecode, FrameTimecode): 75 | self.framerate = timecode.framerate 76 | self.frame_num = timecode.frame_num 77 | if fps is not None: 78 | raise TypeError('Framerate cannot be overwritten when copying a FrameTimecode.') 79 | else: 80 | # Ensure other arguments are consistent with API. 81 | if fps is None: 82 | raise TypeError('Framerate (fps) is a required argument.') 83 | if isinstance(fps, FrameTimecode): 84 | fps = fps.framerate 85 | 86 | # Process the given framerate, if it was not already set. 87 | if not isinstance(fps, (int, float)): 88 | raise TypeError('Framerate must be of type int/float.') 89 | elif (isinstance(fps, int) and not fps > 0) or ( 90 | isinstance(fps, float) and not fps >= MINIMUM_FRAMES_PER_SECOND_FLOAT): 91 | raise ValueError('Framerate must be positive and greater than zero.') 92 | self.framerate = float(fps) 93 | 94 | # Process the timecode value, storing it as an exact number of frames. 95 | if isinstance(timecode, (str, STRING_TYPE)): 96 | self.frame_num = self._parse_timecode_string(timecode) 97 | else: 98 | self.frame_num = self._parse_timecode_number(timecode) 99 | 100 | # Alternative formats under consideration (require unit tests before adding): 101 | 102 | # Standard timecode in list format [HH, MM, SS.nnn] 103 | #elif isinstance(timecode, (list, tuple)) and len(timecode) == 3: 104 | # if any(not isinstance(x, (int, float)) for x in timecode): 105 | # raise ValueError('Timecode components must be of type int/float.') 106 | # hrs, mins, secs = timecode 107 | # if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 108 | # and secs < 60): 109 | # raise ValueError('Timecode components must be positive.') 110 | # secs += (((hrs * 60.0) + mins) * 60.0) 111 | # self.frame_num = int(secs * self.framerate) 112 | 113 | 114 | def get_frames(self): 115 | # type: () -> int 116 | """ Get the current time/position in number of frames. This is the 117 | equivalent of accessing the self.frame_num property (which, along 118 | with the specified framerate, forms the base for all of the other 119 | time measurement calculations, e.g. the :py:meth:`get_seconds` method). 120 | 121 | If using to compare a :py:class:`FrameTimecode` with a frame number, 122 | you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 10``). 123 | 124 | Returns: 125 | int: The current time in frames (the current frame number). 126 | """ 127 | return int(self.frame_num) 128 | 129 | 130 | def get_framerate(self): 131 | # type: () -> float 132 | """ Get Framerate: Returns the framerate used by the FrameTimecode object. 133 | 134 | Returns: 135 | float: Framerate of the current FrameTimecode object, in frames per second. 136 | """ 137 | return self.framerate 138 | 139 | 140 | def equal_framerate(self, fps): 141 | # type: (float) -> bool 142 | """ Equal Framerate: Determines if the passed framerate is equal to that of the 143 | FrameTimecode object. 144 | 145 | Args: 146 | fps: Framerate (float) to compare against within the precision constant 147 | MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT defined in this module. 148 | 149 | Returns: 150 | bool: True if passed fps matches the FrameTimecode object's framerate, False otherwise. 151 | 152 | """ 153 | return math.fabs(self.framerate - fps) < MINIMUM_FRAMES_PER_SECOND_DELTA_FLOAT 154 | 155 | 156 | def get_seconds(self): 157 | # type: () -> float 158 | """ Get the frame's position in number of seconds. 159 | 160 | If using to compare a :py:class:`FrameTimecode` with a frame number, 161 | you can do so directly against the object (e.g. ``FrameTimecode(10, 10.0) <= 1.0``). 162 | 163 | Returns: 164 | float: The current time/position in seconds. 165 | """ 166 | return float(self.frame_num) / self.framerate 167 | 168 | 169 | def get_timecode(self, precision=3, use_rounding=True): 170 | # type: (int, bool) -> str 171 | """ Get a formatted timecode string of the form HH:MM:SS[.nnn]. 172 | 173 | Args: 174 | precision: The number of decimal places to include in the output ``[.nnn]``. 175 | use_rounding: True (default) to round the output to the desired precision. 176 | 177 | Returns: 178 | str: The current time in the form ``"HH:MM:SS[.nnn]"``. 179 | """ 180 | # Compute hours and minutes based off of seconds, and update seconds. 181 | secs = self.get_seconds() 182 | base = 60.0 * 60.0 183 | hrs = int(secs / base) 184 | secs -= (hrs * base) 185 | base = 60.0 186 | mins = int(secs / base) 187 | secs -= (mins * base) 188 | # Convert seconds into string based on required precision. 189 | if precision > 0: 190 | if use_rounding: 191 | secs = round(secs, precision) 192 | #secs = math.ceil(secs * (10**precision)) / float(10**precision) 193 | msec = format(secs, '.%df' % precision)[-precision:] 194 | secs = '%02d.%s' % (int(secs), msec) 195 | else: 196 | secs = '%02d' % int(round(secs, 0)) if use_rounding else '%02d' % int(secs) 197 | # Return hours, minutes, and seconds as a formatted timecode string. 198 | return '%02d:%02d:%s' % (hrs, mins, secs) 199 | 200 | 201 | def _seconds_to_frames(self, seconds): 202 | # type: (float) -> int 203 | """ Converts the passed value seconds to the nearest number of frames using 204 | the current FrameTimecode object's FPS (self.framerate). 205 | 206 | Returns: 207 | Integer number of frames the passed number of seconds represents using 208 | the current FrameTimecode's framerate property. 209 | """ 210 | return int(seconds * self.framerate) 211 | 212 | 213 | def _parse_timecode_number(self, timecode): 214 | # type: (Union[int, float]) -> int 215 | """ Parses a timecode number, storing it as the exact number of frames. 216 | Can be passed as frame number (int), seconds (float) 217 | 218 | Raises: 219 | TypeError, ValueError 220 | """ 221 | # Process the timecode value, storing it as an exact number of frames. 222 | # Exact number of frames N 223 | if isinstance(timecode, int): 224 | if timecode < 0: 225 | raise ValueError('Timecode frame number must be positive and greater than zero.') 226 | return timecode 227 | # Number of seconds S 228 | elif isinstance(timecode, float): 229 | if timecode < 0.0: 230 | raise ValueError('Timecode value must be positive and greater than zero.') 231 | return self._seconds_to_frames(timecode) 232 | # FrameTimecode 233 | elif isinstance(timecode, FrameTimecode): 234 | return timecode.frame_num 235 | elif timecode is None: 236 | raise TypeError('Timecode/frame number must be specified!') 237 | else: 238 | raise TypeError('Timecode format/type unrecognized.') 239 | 240 | 241 | def _parse_timecode_string(self, timecode_string): 242 | # type: (str) -> int 243 | """ Parses a string based on the three possible forms (in timecode format, 244 | as an integer number of frames, or floating-point seconds, ending with 's'). 245 | Requires that the framerate property is set before calling this method. 246 | Assuming a framerate of 30.0 FPS, the strings '00:05:00.000', '00:05:00', 247 | '9000', '300s', and '300.0s' are all possible valid values, all representing 248 | a period of time equal to 5 minutes, 300 seconds, or 9000 frames (at 30 FPS). 249 | 250 | Raises: 251 | TypeError, ValueError 252 | """ 253 | if self.framerate is None: 254 | raise TypeError('self.framerate must be set before calling _parse_timecode_string.') 255 | # Number of seconds S 256 | if timecode_string.endswith('s'): 257 | secs = timecode_string[:-1] 258 | if not secs.replace('.', '').isdigit(): 259 | raise ValueError('All characters in timecode seconds string must be digits.') 260 | secs = float(secs) 261 | if secs < 0.0: 262 | raise ValueError('Timecode seconds value must be positive.') 263 | return int(secs * self.framerate) 264 | # Exact number of frames N 265 | elif timecode_string.isdigit(): 266 | timecode = int(timecode_string) 267 | if timecode < 0: 268 | raise ValueError('Timecode frame number must be positive.') 269 | return timecode 270 | # Standard timecode in string format 'HH:MM:SS[.nnn]' 271 | else: 272 | tc_val = timecode_string.split(':') 273 | if not (len(tc_val) == 3 and tc_val[0].isdigit() and tc_val[1].isdigit() 274 | and tc_val[2].replace('.', '').isdigit()): 275 | raise ValueError('Unrecognized or improperly formatted timecode string.') 276 | hrs, mins = int(tc_val[0]), int(tc_val[1]) 277 | secs = float(tc_val[2]) if '.' in tc_val[2] else int(tc_val[2]) 278 | if not (hrs >= 0 and mins >= 0 and secs >= 0 and mins < 60 and secs < 60): 279 | raise ValueError('Invalid timecode range (values outside allowed range).') 280 | secs += (((hrs * 60.0) + mins) * 60.0) 281 | return int(secs * self.framerate) 282 | 283 | 284 | def __iadd__(self, other): 285 | # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 286 | if isinstance(other, int): 287 | self.frame_num += other 288 | elif isinstance(other, FrameTimecode): 289 | if self.equal_framerate(other.framerate): 290 | self.frame_num += other.frame_num 291 | else: 292 | raise ValueError('FrameTimecode instances require equal framerate for addition.') 293 | # Check if value to add is in number of seconds. 294 | elif isinstance(other, float): 295 | self.frame_num += self._seconds_to_frames(other) 296 | else: 297 | raise TypeError('Unsupported type for performing addition with FrameTimecode.') 298 | if self.frame_num < 0: # Required to allow adding negative seconds/frames. 299 | self.frame_num = 0 300 | return self 301 | 302 | 303 | def __add__(self, other): 304 | # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 305 | to_return = FrameTimecode(timecode=self) 306 | to_return += other 307 | return to_return 308 | 309 | 310 | def __isub__(self, other): 311 | # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 312 | if isinstance(other, int): 313 | self.frame_num -= other 314 | elif isinstance(other, FrameTimecode): 315 | if self.equal_framerate(other.framerate): 316 | self.frame_num -= other.frame_num 317 | else: 318 | raise ValueError('FrameTimecode instances require equal framerate for subtraction.') 319 | # Check if value to add is in number of seconds. 320 | elif isinstance(other, float): 321 | self.frame_num -= self._seconds_to_frames(other) 322 | else: 323 | raise TypeError('Unsupported type for performing subtraction with FrameTimecode.') 324 | if self.frame_num < 0: 325 | self.frame_num = 0 326 | return self 327 | 328 | 329 | def __sub__(self, other): 330 | # type: (Union[int, float, str, FrameTimecode]) -> FrameTimecode 331 | to_return = FrameTimecode(timecode=self) 332 | to_return -= other 333 | return to_return 334 | 335 | 336 | def __eq__(self, other): 337 | # type: (Union[int, float, str, FrameTimecode]) -> bool 338 | if isinstance(other, int): 339 | return self.frame_num == other 340 | elif isinstance(other, float): 341 | return self.get_seconds() == other 342 | elif isinstance(other, str): 343 | return self.frame_num == self._parse_timecode_string(other) 344 | elif isinstance(other, FrameTimecode): 345 | if self.equal_framerate(other.framerate): 346 | return self.frame_num == other.frame_num 347 | else: 348 | raise TypeError( 349 | 'FrameTimecode objects must have the same framerate to be compared.') 350 | elif other is None: 351 | return False 352 | else: 353 | raise TypeError('Unsupported type for performing == with FrameTimecode.') 354 | 355 | 356 | def __ne__(self, other): 357 | # type: (Union[int, float, str, FrameTimecode]) -> bool 358 | return not self == other 359 | 360 | 361 | def __lt__(self, other): 362 | # type: (Union[int, float, str, FrameTimecode]) -> bool 363 | if isinstance(other, int): 364 | return self.frame_num < other 365 | elif isinstance(other, float): 366 | return self.get_seconds() < other 367 | elif isinstance(other, str): 368 | return self.frame_num < self._parse_timecode_string(other) 369 | elif isinstance(other, FrameTimecode): 370 | if self.equal_framerate(other.framerate): 371 | return self.frame_num < other.frame_num 372 | else: 373 | raise TypeError( 374 | 'FrameTimecode objects must have the same framerate to be compared.') 375 | #elif other is None: 376 | # return False 377 | else: 378 | raise TypeError('Unsupported type for performing < with FrameTimecode.') 379 | 380 | 381 | def __le__(self, other): 382 | # type: (Union[int, float, str, FrameTimecode]) -> bool 383 | if isinstance(other, int): 384 | return self.frame_num <= other 385 | elif isinstance(other, float): 386 | return self.get_seconds() <= other 387 | elif isinstance(other, str): 388 | return self.frame_num <= self._parse_timecode_string(other) 389 | elif isinstance(other, FrameTimecode): 390 | if self.equal_framerate(other.framerate): 391 | return self.frame_num <= other.frame_num 392 | else: 393 | raise TypeError( 394 | 'FrameTimecode objects must have the same framerate to be compared.') 395 | #elif other is None: 396 | # return False 397 | else: 398 | raise TypeError('Unsupported type for performing <= with FrameTimecode.') 399 | 400 | 401 | def __gt__(self, other): 402 | # type: (Union[int, float, str, FrameTimecode]) -> bool 403 | if isinstance(other, int): 404 | return self.frame_num > other 405 | elif isinstance(other, float): 406 | return self.get_seconds() > other 407 | elif isinstance(other, str): 408 | return self.frame_num > self._parse_timecode_string(other) 409 | elif isinstance(other, FrameTimecode): 410 | if self.equal_framerate(other.framerate): 411 | return self.frame_num > other.frame_num 412 | else: 413 | raise TypeError( 414 | 'FrameTimecode objects must have the same framerate to be compared.') 415 | #elif other is None: 416 | # return False 417 | else: 418 | raise TypeError('Unsupported type (%s) for performing > with FrameTimecode.' % 419 | type(other).__name__) 420 | 421 | 422 | def __ge__(self, other): 423 | # type: (Union[int, float, str, FrameTimecode]) -> bool 424 | if isinstance(other, int): 425 | return self.frame_num >= other 426 | elif isinstance(other, float): 427 | return self.get_seconds() >= other 428 | elif isinstance(other, str): 429 | return self.frame_num >= self._parse_timecode_string(other) 430 | elif isinstance(other, FrameTimecode): 431 | if self.equal_framerate(other.framerate): 432 | return self.frame_num >= other.frame_num 433 | else: 434 | raise TypeError( 435 | 'FrameTimecode objects must have the same framerate to be compared.') 436 | #elif other is None: 437 | # return False 438 | else: 439 | raise TypeError('Unsupported type for performing >= with FrameTimecode.') 440 | 441 | 442 | 443 | def __int__(self): 444 | return self.frame_num 445 | 446 | def __float__(self): 447 | return self.get_seconds() 448 | 449 | def __str__(self): 450 | return self.get_timecode() 451 | 452 | def __repr__(self): 453 | return 'FrameTimecode(frame=%d, fps=%f)' % (self.frame_num, self.framerate) 454 | -------------------------------------------------------------------------------- /shotdetect/keyf_img_saver.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import logging 4 | import math 5 | import os 6 | import pdb 7 | from string import Template 8 | 9 | import cv2 10 | 11 | from shotdetect.platform import get_cv2_imwrite_params, tqdm 12 | 13 | 14 | def get_output_file_path(file_path, output_dir=None): 15 | """ Get Output File Path: Gets full path to output file passed as argument, in 16 | the specified global output directory (scenedetect -o/--output) if set, creating 17 | any required directories along the way. 18 | 19 | Args: 20 | file_path (str): File name to get path for. If file_path is an absolute 21 | path (e.g. starts at a drive/root), no modification of the path 22 | is performed, only ensuring that all output directories are created. 23 | output_dir (Optional[str]): An optional output directory to override the 24 | global output directory option, if set. 25 | 26 | Returns: 27 | (str) Full path to output file suitable for writing. 28 | 29 | """ 30 | output_directory = None 31 | if file_path is None: 32 | return None 33 | output_dir = output_directory if output_dir is None else output_dir 34 | # If an output directory is defined and the file path is a relative path, open 35 | # the file handle in the output directory instead of the working directory. 36 | if output_dir is not None and not os.path.isabs(file_path): 37 | file_path = os.path.join(output_dir, file_path) 38 | # Now that file_path is an absolute path, let's make sure all the directories 39 | # exist for us to start writing files there. 40 | try: 41 | os.makedirs(os.path.split(os.path.abspath(file_path))[0]) 42 | except OSError: 43 | pass 44 | return file_path 45 | 46 | 47 | def generate_images(video_manager, shot_list, output_dir, num_images=3, 48 | image_name_template='shot_${SHOT_NUMBER}_img_${IMAGE_NUMBER}', 49 | ): 50 | ''' 51 | Args: 52 | num_images: number of keyframes 53 | ''' 54 | assert num_images >= 1 55 | os.makedirs(output_dir, exist_ok=True) 56 | if num_images == 1: 57 | image_name_template = 'shot_${SHOT_NUMBER}' 58 | else: 59 | pass 60 | filename_template = Template(image_name_template) 61 | 62 | quiet_mode = False 63 | imwrite_params = get_cv2_imwrite_params() 64 | image_param = None 65 | image_extension = 'jpg' 66 | if not shot_list: 67 | return 68 | 69 | imwrite_param = [] 70 | if image_param is not None: 71 | imwrite_param = [imwrite_params[image_extension], image_param] 72 | 73 | # Reset video manager and downscale factor. 74 | video_manager.release() 75 | video_manager.reset() 76 | video_manager.set_downscale_factor(1) 77 | video_manager.start() 78 | 79 | # Setup flags and init progress bar if available. 80 | completed = True 81 | logging.info('Generating output images (%d per shot)...', num_images) 82 | progress_bar = None 83 | if tqdm and not quiet_mode: 84 | progress_bar = tqdm( 85 | total=len(shot_list) * num_images, unit='images', desc="Save Keyf") 86 | 87 | 88 | shot_num_format = '%0' 89 | shot_num_format += str(max(4, math.floor(math.log(len(shot_list), 10)) + 1)) + 'd' 90 | image_num_format = '%0' 91 | image_num_format += str(math.floor(math.log(num_images, 10)) + 1) + 'd' 92 | 93 | timecode_list = dict() 94 | 95 | for i in range(len(shot_list)): 96 | timecode_list[i] = [] 97 | 98 | if num_images == 1: 99 | for i, (start_time, end_time) in enumerate(shot_list): 100 | duration = end_time - start_time 101 | timecode_list[i].append(start_time + int(duration.get_frames() / 2)) 102 | 103 | else: 104 | middle_images = num_images - 2 105 | for i, (start_time, end_time) in enumerate(shot_list): 106 | timecode_list[i].append(start_time) 107 | 108 | if middle_images > 0: 109 | duration = (end_time.get_frames() - 1) - start_time.get_frames() 110 | duration_increment = None 111 | duration_increment = int(duration / (middle_images + 1)) # middle_images + 1 is the middle segment number 112 | for j in range(middle_images): 113 | timecode_list[i].append(start_time + ((j+1) * duration_increment)) 114 | 115 | # End FrameTimecode is always the same frame as the next shot's start_time 116 | # (one frame past the end), so we need to subtract 1 here. 117 | timecode_list[i].append(end_time - 1) 118 | 119 | for i in timecode_list: 120 | for j, image_timecode in enumerate(timecode_list[i]): 121 | video_manager.seek(image_timecode) 122 | video_manager.grab() 123 | ret_val, frame_im = video_manager.retrieve() 124 | if ret_val: 125 | cv2.imwrite( 126 | get_output_file_path( 127 | '%s.%s' % (filename_template.safe_substitute( 128 | SHOT_NUMBER=shot_num_format % (i), # start from 0 129 | IMAGE_NUMBER=image_num_format % (j) # start from 0 130 | ), image_extension), 131 | output_dir=output_dir), frame_im, imwrite_param) 132 | else: 133 | completed = False 134 | break 135 | if progress_bar: 136 | progress_bar.update(1) 137 | 138 | if not completed: 139 | logging.error('Could not generate all output images.') 140 | 141 | 142 | def generate_images_txt(shot_list, output_dir, num_images=5): 143 | assert num_images >= 3 144 | timecode_list = dict() 145 | for i in range(len(shot_list)): 146 | timecode_list[i] = [] 147 | middle_images = num_images - 2 148 | for i, (start_time, end_time) in enumerate(shot_list): 149 | timecode_list[i].append(start_time) 150 | if middle_images > 0: 151 | duration = (end_time.get_frames() - 1) - start_time.get_frames() 152 | duration_increment = None 153 | duration_increment = int(duration / (middle_images + 1)) # middle_images + 1 is the middle segment number 154 | for j in range(middle_images): 155 | timecode_list[i].append(start_time + ((j+1) * duration_increment)) 156 | 157 | # End FrameTimecode is always the same frame as the next shot's start_time 158 | # (one frame past the end), so we need to subtract 1 here. 159 | timecode_list[i].append(end_time - 1) 160 | 161 | frames_list = [] 162 | for i in timecode_list: 163 | frame_list = [] 164 | for j, image_timecode in enumerate(timecode_list[i]): 165 | frame_list.append(image_timecode.get_frames()) 166 | frames_item = "{} {} ".format(frame_list[0], frame_list[-1]) 167 | for i in range(num_images-2): 168 | frames_item += "{} ".format(frame_list[i+1]) 169 | frames_list.append(frames_item[:-1]) 170 | 171 | with open(output_dir, 'w') as f: 172 | for frames in frames_list: 173 | f.write("{}\n".format(frames)) 174 | -------------------------------------------------------------------------------- /shotdetect/platform.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | 12 | """ 13 | This file contains all platform/library/OS-specific compatibility fixes, 14 | intended to improve the systems that are able to run ShotDetect, and allow 15 | for maintaining backwards compatibility with existing libraries going forwards. 16 | Other helper functions related to the detection of the appropriate dependency 17 | DLLs on Windows and getting uniform line-terminating csv reader/writer objects 18 | are also included in this module. 19 | 20 | With respect to the Python standard library itself and Python 2 versus 3, 21 | this module adds compatibility wrappers for Python's Queue/queue (Python 2/3, 22 | respectively) as scenedetect.platform.queue. 23 | 24 | For OpenCV 2.x, the scenedetect.platform module also makes a copy of the 25 | OpenCV VideoCapture property constants from the cv2.cv namespace directly 26 | to the cv2 namespace. This ensures that the cv2 API is consistent 27 | with those changes made to it in OpenCV 3.0 and above. 28 | 29 | This module also includes an alias for the unicode/string types in Python 2/3 30 | as STRING_TYPE intended to help with parsing string types from the CLI parser. 31 | """ 32 | 33 | from __future__ import print_function 34 | import sys 35 | import os 36 | import platform 37 | import struct 38 | import csv 39 | import cv2 40 | 41 | # pylint: disable=unused-import 42 | 43 | 44 | ## 45 | ## Python 2/3 Queue/queue Library (scenedetect.platform.queue) 46 | ## 47 | 48 | if sys.version_info[0] == 2: 49 | import Queue as queue 50 | else: 51 | import queue 52 | 53 | try: 54 | from tqdm import tqdm 55 | except ImportError: 56 | tqdm = None 57 | 58 | 59 | # pylint: enable=unused-import 60 | 61 | # String type (used to allow FrameTimecode object to take both unicode and native 62 | # string objects when being constructed via scenedetect.platform.STRING_TYPE). 63 | # pylint: disable=invalid-name, undefined-variable 64 | if sys.version_info[0] == 2: 65 | STRING_TYPE = unicode 66 | else: 67 | STRING_TYPE = str 68 | # pylint: enable=invalid-name, undefined-variable 69 | 70 | 71 | ## 72 | ## OpenCV 2.x Compatibility Fix 73 | ## 74 | 75 | # Compatibility fix for OpenCV v2.x (copies CAP_PROP_* properties from the 76 | # cv2.cv namespace to the cv2 namespace, as the cv2.cv namespace was removed 77 | # with the release of OpenCV 3.0). 78 | # pylint: disable=c-extension-no-member 79 | if cv2.__version__[0] == '2' or not ( 80 | cv2.__version__[0].isdigit() and int(cv2.__version__[0]) >= 3): 81 | cv2.CAP_PROP_FRAME_WIDTH = cv2.cv.CV_CAP_PROP_FRAME_WIDTH 82 | cv2.CAP_PROP_FRAME_HEIGHT = cv2.cv.CV_CAP_PROP_FRAME_HEIGHT 83 | cv2.CAP_PROP_FPS = cv2.cv.CV_CAP_PROP_FPS 84 | cv2.CAP_PROP_POS_MSEC = cv2.cv.CV_CAP_PROP_POS_MSEC 85 | cv2.CAP_PROP_POS_FRAMES = cv2.cv.CV_CAP_PROP_POS_FRAMES 86 | cv2.CAP_PROP_FRAME_COUNT = cv2.cv.CV_CAP_PROP_FRAME_COUNT 87 | # pylint: enable=c-extension-no-member 88 | 89 | 90 | def check_opencv_ffmpeg_dll(): 91 | """ Check OpenCV FFmpeg DLL: Checks if OpenCV video I/O support is available, 92 | on Windows only, by checking for the appropriate opencv_ffmpeg*.dll file. 93 | 94 | On non-Windows systems always returns True, or for OpenCV versions that do 95 | not follow the X.Y.Z version numbering pattern. Thus there may be false 96 | positives (True) with this function, but not false negatives (False). 97 | In those cases, ShotDetect will report that it could not open the 98 | video file, and for Windows users, also gives an additional warning message 99 | that the error may be due to the missing DLL file. 100 | 101 | Returns: 102 | (bool) True if OpenCV video support is detected (e.g. the appropriate 103 | opencv_ffmpegXYZ.dll file is in PATH), False otherwise. 104 | """ 105 | if platform.system() == 'Windows' and ( 106 | cv2.__version__[0].isdigit() and cv2.__version__.find('.') > 0): 107 | is_64_bit_str = '_64' if struct.calcsize("P") == 8 else '' 108 | dll_filename = 'opencv_ffmpeg{OPENCV_VERSION}{IS_64_BIT}.dll'.format( 109 | OPENCV_VERSION=cv2.__version__.replace('.', ''), 110 | IS_64_BIT=is_64_bit_str) 111 | return any([os.path.exists(os.path.join(path_path, dll_filename)) 112 | for path_path in os.environ['PATH'].split(';')]), dll_filename 113 | return True 114 | 115 | 116 | def _get_cv2_param(param_name): 117 | if param_name.startswith('CV_'): 118 | param_name = param_name[3:] 119 | try: 120 | return getattr(cv2, param_name) 121 | except AttributeError: 122 | return None 123 | 124 | 125 | def get_cv2_imwrite_params(): 126 | """ Get OpenCV imwrite Params: Returns a dict of supported image formats and 127 | their associated quality/compression parameter. 128 | 129 | Returns: 130 | (Dict[str, int]) Dictionary of image formats/extensions ('jpg', 131 | 'png', etc...) mapped to the respective OpenCV quality or 132 | compression parameter (e.g. 'jpg' -> cv2.IMWRITE_JPEG_QUALITY, 133 | 'png' -> cv2.IMWRITE_PNG_COMPRESSION).. 134 | """ 135 | return { 136 | 'jpg': _get_cv2_param('IMWRITE_JPEG_QUALITY'), 137 | 'png': _get_cv2_param('IMWRITE_PNG_COMPRESSION'), 138 | 'webp': _get_cv2_param('IMWRITE_WEBP_QUALITY') 139 | } 140 | 141 | 142 | def get_csv_reader(file_handle): 143 | # type: (File) -> csv.reader 144 | """ Returns a csv.reader object using the passed file handle. """ 145 | return csv.reader(file_handle, lineterminator='\n') 146 | 147 | 148 | def get_csv_writer(file_handle): 149 | # type: (File) -> csv.writer 150 | """ Returns a csv.writer object using the passed file handle. """ 151 | return csv.writer(file_handle, lineterminator='\n') 152 | -------------------------------------------------------------------------------- /shotdetect/shot_detector.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | # pylint: disable=unused-argument, no-self-use 12 | 13 | 14 | class shotDetector(object): 15 | """ Base class to inheret from when implementing a shot detection algorithm. 16 | 17 | Also see the implemented shot detectors in the shotdetect.detectors module 18 | to get an idea of how a particular detector can be created. 19 | """ 20 | 21 | stats_manager = None 22 | """ Optional :py:class:`StatsManager ` to 23 | use for caching frame metrics to and from.""" 24 | 25 | _metric_keys = [] 26 | """ List of frame metric keys to be registered with the :py:attr:`stats_manager`, 27 | if available. """ 28 | 29 | cli_name = 'detect-none' 30 | """ Name of detector to use in command-line interface description. """ 31 | 32 | def is_processing_required(self, frame_num): 33 | """ Is Processing Required: Test if all calculations for a given frame are already done. 34 | 35 | Returns: 36 | bool: False if the shotDetector has assigned _metric_keys, and the 37 | stats_manager property is set to a valid StatsManager object containing 38 | the required frame metrics/calculations for the given frame - thus, not 39 | needing the frame to perform shot detection. 40 | 41 | True otherwise (i.e. the frame_img passed to process_frame is required 42 | to be passed to process_frame for the given frame_num). 43 | """ 44 | return not self._metric_keys or not ( 45 | self.stats_manager is not None and 46 | self.stats_manager.metrics_exist(frame_num, self._metric_keys)) 47 | 48 | def get_metrics(self): 49 | """ Get Metrics: Get a list of all metric names/keys used by the detector. 50 | 51 | Returns: 52 | List[str]: A list of strings of frame metric key names that will be used by 53 | the detector when a StatsManager is passed to process_frame. 54 | """ 55 | return self._metric_keys 56 | 57 | def process_frame(self, frame_num, frame_img): 58 | """ Process Frame: Computes/stores metrics and detects any shot changes. 59 | 60 | Prototype method, no actual detection. 61 | 62 | Returns: 63 | List[int]: List of frame numbers of cuts to be added to the cutting list. 64 | """ 65 | return [] 66 | 67 | def post_process(self, frame_num): 68 | """ Post Process: Performs any processing after the last frame has been read. 69 | 70 | Prototype method, no actual detection. 71 | 72 | Returns: 73 | List[int]: List of frame numbers of cuts to be added to the cutting list. 74 | """ 75 | return [] 76 | -------------------------------------------------------------------------------- /shotdetect/shot_manager.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | 12 | from __future__ import print_function 13 | 14 | import math 15 | import pdb 16 | 17 | import cv2 18 | 19 | from shotdetect.frame_timecode import FrameTimecode 20 | from shotdetect.platform import get_csv_writer, tqdm 21 | from shotdetect.stats_manager import FrameMetricRegistered 22 | 23 | 24 | def get_shots_from_cuts(cut_list, base_timecode, num_frames, start_frame=0): 25 | # type: List[FrameTimecode], FrameTimecode, Union[int, FrameTimecode], 26 | # Optional[Union[int, FrameTimecode]] -> List[Tuple[FrameTimecode, FrameTimecode]] 27 | """ Returns a list of tuples of start/end FrameTimecodes for each shot based on a 28 | list of detected shot cuts/breaks. 29 | 30 | This function is called when using the :py:meth:`shotManager.get_shot_list` method. 31 | The shot list is generated from a cutting list (:py:meth:`shotManager.get_cut_list`), 32 | noting that each shot is contiguous, starting from the first to last frame of the input. 33 | 34 | 35 | Args: 36 | cut_list (List[FrameTimecode]): List of FrameTimecode objects where shot cuts/breaks occur. 37 | base_timecode (FrameTimecode): The base_timecode of which all FrameTimecodes in the cut_list 38 | are based on. 39 | num_frames (int or FrameTimecode): The number of frames, or FrameTimecode representing 40 | duration, of the video that was processed (used to generate last shot's end time). 41 | start_frame (int or FrameTimecode): The start frame or FrameTimecode of the cut list. 42 | Used to generate the first shot's start time. 43 | Returns: 44 | List of tuples in the form (start_time, end_time), where both start_time and 45 | end_time are FrameTimecode objects representing the exact time/frame where each 46 | shot occupies based on the input cut_list. 47 | """ 48 | # shot list, where shots are tuples of (Start FrameTimecode, End FrameTimecode). 49 | shot_list = [] 50 | if not cut_list: 51 | shot_list.append((base_timecode + start_frame, base_timecode + num_frames)) 52 | return shot_list 53 | # Initialize last_cut to the first frame we processed,as it will be 54 | # the start timecode for the first shot in the list. 55 | last_cut = base_timecode + start_frame 56 | for cut in cut_list: 57 | shot_list.append((last_cut, cut)) 58 | last_cut = cut 59 | # Last shot is from last cut to end of video. 60 | shot_list.append((last_cut, base_timecode + num_frames)) 61 | 62 | return shot_list 63 | 64 | 65 | def write_shot_list(output_csv_file, shot_list, cut_list=None): 66 | """ Writes the given list of shots to an output file handle in CSV format. 67 | 68 | Args: 69 | output_csv_file: Handle to open file in write mode. 70 | shot_list: List of pairs of FrameTimecodes denoting each shot's start/end FrameTimecode. 71 | cut_list: Optional list of FrameTimecode objects denoting the cut list (i.e. the frames 72 | in the video that need to be split to generate individual shots). If not passed, 73 | the start times of each shot (besides the 0th shot) is used instead. 74 | """ 75 | # type: (File, List[Tuple[FrameTimecode, FrameTimecode]], Optional[List[FrameTimecode]]) -> None 76 | csv_writer = get_csv_writer(output_csv_file) 77 | # Output Timecode List 78 | csv_writer.writerow( 79 | ["Timecode List:"] + 80 | cut_list if cut_list else [start.get_timecode() for start, _ in shot_list[1:]]) 81 | csv_writer.writerow([ 82 | "shot Number", 83 | "Start Frame", "Start Timecode", "Start Time (seconds)", 84 | "End Frame", "End Timecode", "End Time (seconds)", 85 | "Length (frames)", "Length (timecode)", "Length (seconds)"]) 86 | for i, (start, end) in enumerate(shot_list): 87 | duration = end - start 88 | csv_writer.writerow([ 89 | '%d' % (i+1), 90 | '%d' % start.get_frames(), start.get_timecode(), '%.3f' % start.get_seconds(), 91 | '%d' % end.get_frames(), end.get_timecode(), '%.3f' % end.get_seconds(), 92 | '%d' % duration.get_frames(), duration.get_timecode(), '%.3f' % duration.get_seconds()]) 93 | 94 | 95 | class ShotManager(object): 96 | """ The shotManager facilitates detection of shots via the :py:meth:`detect_shots` method, 97 | given a video source (:py:class:`VideoManager ` 98 | or cv2.VideoCapture), and shotDetector algorithms added via the :py:meth:`add_detector` method. 99 | 100 | Can also optionally take a StatsManager instance during construction to cache intermediate 101 | shot detection calculations, making subsequent calls to :py:meth:`detect_shots` much faster, 102 | allowing the cached values to be saved/loaded to/from disk, and also manually determining 103 | the optimal threshold values or other options for various detection algorithms. 104 | """ 105 | 106 | def __init__(self, stats_manager=None): 107 | # type: (Optional[StatsManager]) 108 | self._cutting_list = [] 109 | self._detector_list = [] 110 | self._stats_manager = stats_manager 111 | self._num_frames = 0 112 | self._start_frame = 0 113 | 114 | def add_detector(self, detector): 115 | # type: (shotDetector) -> None 116 | """ Adds/registers a shotDetector (e.g. ContentDetector, ThresholdDetector) to 117 | run when detect_shots is called. The shotManager owns the detector object, 118 | so a temporary may be passed. 119 | 120 | Args: 121 | detector (shotDetector): shot detector to add to the shotManager. 122 | """ 123 | detector.stats_manager = self._stats_manager 124 | self._detector_list.append(detector) 125 | if self._stats_manager is not None: 126 | # Allow multiple detection algorithms of the same type to be added 127 | # by suppressing any FrameMetricRegistered exceptions due to attempts 128 | # to re-register the same frame metric keys. 129 | try: 130 | self._stats_manager.register_metrics(detector.get_metrics()) 131 | except FrameMetricRegistered: 132 | pass 133 | 134 | def get_num_detectors(self): 135 | # type: () -> int 136 | """ Gets number of registered shot detectors added via add_detector. """ 137 | return len(self._detector_list) 138 | 139 | def clear(self): 140 | # type: () -> None 141 | """ Clears all cuts/shots and resets the shotManager's position. 142 | 143 | Any statistics generated are still saved in the StatsManager object 144 | passed to the shotManager's constructor, and thus, subsequent 145 | calls to detect_shots, using the same frame source reset at the 146 | initial time (if it is a VideoManager, use the reset() method), 147 | will use the cached frame metrics that were computed and saved 148 | in the previous call to detect_shots. 149 | """ 150 | self._cutting_list.clear() 151 | self._num_frames = 0 152 | self._start_frame = 0 153 | 154 | def clear_detectors(self): 155 | # type: () -> None 156 | """ Removes all shot detectors added to the shotManager via add_detector(). """ 157 | self._detector_list.clear() 158 | 159 | def get_shot_list(self, base_timecode): 160 | # type: (FrameTimecode) -> List[Tuple[FrameTimecode, FrameTimecode]] 161 | """ Returns a list of tuples of start/end FrameTimecodes for each shot. 162 | 163 | The shot list is generated by calling :py:func:`get_shots_from_cuts` on the cutting 164 | list from :py:meth:`get_cut_list`, noting that each shot is contiguous, starting from 165 | the first and ending at the last frame of the input. 166 | 167 | Returns: 168 | List of tuples in the form (start_time, end_time), where both start_time and 169 | end_time are FrameTimecode objects representing the exact time/frame where each 170 | detected shot in the video begins and ends. 171 | """ 172 | return get_shots_from_cuts( 173 | self.get_cut_list(base_timecode), base_timecode, 174 | self._num_frames, self._start_frame) 175 | 176 | def get_cut_list(self, base_timecode): 177 | # type: (FrameTimecode) -> List[FrameTimecode] 178 | """ Returns a list of FrameTimecodes of the detected shot changes/cuts. 179 | 180 | Unlike get_shot_list, the cutting list returns a list of FrameTimecodes representing 181 | the point in the input video(s) where a new shot was detected, and thus the frame 182 | where the input should be cut/split. The cutting list, in turn, is used to generate 183 | the shot list, noting that each shot is contiguous starting from the first frame 184 | and ending at the last frame detected. 185 | 186 | Returns: 187 | List of FrameTimecode objects denoting the points in time where a shot change 188 | was detected in the input video(s), which can also be passed to external tools 189 | for automated splitting of the input into individual shots. 190 | """ 191 | 192 | return [FrameTimecode(cut, base_timecode) 193 | for cut in self._get_cutting_list()] 194 | 195 | def _get_cutting_list(self): 196 | # type: () -> list 197 | """ Returns a sorted list of unique frame numbers of any detected shot cuts. """ 198 | # We remove duplicates here by creating a set then back to a list and sort it. 199 | return sorted(list(set(self._cutting_list))) 200 | 201 | def _add_cut(self, frame_num): 202 | # type: (int) -> None 203 | # Adds a cut to the cutting list. 204 | self._cutting_list.append(frame_num) 205 | 206 | def _add_cuts(self, cut_list): 207 | # type: (List[int]) -> None 208 | # Adds a list of cuts to the cutting list. 209 | self._cutting_list += cut_list 210 | 211 | def _process_frame(self, frame_num, frame_im): 212 | # type(int, numpy.ndarray) -> None 213 | """ Adds any cuts detected with the current frame to the cutting list. """ 214 | for detector in self._detector_list: 215 | self._add_cuts(detector.process_frame(frame_num, frame_im)) 216 | 217 | def _is_processing_required(self, frame_num): 218 | # type(int) -> bool 219 | """ Is Processing Required: Returns True if frame metrics not in StatsManager, 220 | False otherwise. 221 | """ 222 | return all([detector.is_processing_required(frame_num) for detector in self._detector_list]) 223 | 224 | def _post_process(self, frame_num): 225 | # type(int, numpy.ndarray) -> None 226 | """ Adds any remaining cuts to the cutting list after processing the last frame. """ 227 | for detector in self._detector_list: 228 | self._add_cuts(detector.post_process(frame_num)) 229 | 230 | def detect_shots(self, frame_source, end_time=None, frame_skip=0, 231 | show_progress=True): 232 | # type: (VideoManager, Union[int, FrameTimecode], 233 | # Optional[Union[int, FrameTimecode]], Optional[bool]) -> int 234 | """ Perform shot detection on the given frame_source using the added shotDetectors. 235 | 236 | Blocks until all frames in the frame_source have been processed. Results can 237 | be obtained by calling either the get_shot_list() or get_cut_list() methods. 238 | 239 | Args: 240 | frame_source (shotdetect.video_manager.VideoManager or cv2.VideoCapture): 241 | A source of frames to process (using frame_source.read() as in VideoCapture). 242 | VideoManager is preferred as it allows concatenation of multiple videos 243 | as well as seeking, by defining start time and end time/duration. 244 | end_time (int or FrameTimecode): Maximum number of frames to detect 245 | (set to None to detect all available frames). Only needed for OpenCV 246 | VideoCapture objects; for VideoManager objects, use set_duration() instead. 247 | frame_skip (int): Not recommended except for extremely high framerate videos. 248 | Number of frames to skip (i.e. process every 1 in N+1 frames, 249 | where N is frame_skip, processing only 1/N+1 percent of the video, 250 | speeding up the detection time at the expense of accuracy). 251 | `frame_skip` **must** be 0 (the default) when using a StatsManager. 252 | show_progress (bool): If True, and the ``tqdm`` module is available, displays 253 | a progress bar with the progress, framerate, and expected time to 254 | complete processing the video frame source. 255 | Returns: 256 | int: Number of frames read and processed from the frame source. 257 | Raises: 258 | ValueError: `frame_skip` **must** be 0 (the default) if the shotManager 259 | was constructed with a StatsManager object. 260 | """ 261 | 262 | if frame_skip > 0 and self._stats_manager is not None: 263 | raise ValueError('frame_skip must be 0 when using a StatsManager.') 264 | 265 | start_frame = 0 266 | curr_frame = 0 267 | end_frame = None 268 | 269 | total_frames = math.trunc(frame_source.get(cv2.CAP_PROP_FRAME_COUNT)) 270 | 271 | start_time = frame_source.get(cv2.CAP_PROP_POS_FRAMES) 272 | if isinstance(start_time, FrameTimecode): 273 | start_frame = start_time.get_frames() 274 | elif start_time is not None: 275 | start_frame = int(start_time) 276 | self._start_frame = start_frame 277 | 278 | curr_frame = start_frame 279 | 280 | if isinstance(end_time, FrameTimecode): 281 | end_frame = end_time.get_frames() 282 | elif end_time is not None: 283 | end_frame = int(end_time) 284 | 285 | if end_frame is not None: 286 | total_frames = end_frame 287 | 288 | if start_frame is not None and not isinstance(start_time, FrameTimecode): 289 | total_frames -= start_frame 290 | 291 | if total_frames < 0: 292 | total_frames = 0 293 | 294 | progress_bar = None 295 | if tqdm and show_progress: 296 | progress_bar = tqdm( 297 | total=total_frames, unit='frames') 298 | try: 299 | 300 | while True: 301 | if end_frame is not None and curr_frame >= end_frame: 302 | break 303 | # We don't compensate for frame_skip here as the frame_skip option 304 | # is not allowed when using a StatsManager - thus, processing is 305 | # *always* required for *all* frames when frame_skip > 0. 306 | if (self._is_processing_required(self._num_frames + start_frame) 307 | or self._is_processing_required(self._num_frames + start_frame + 1)): 308 | ret_val, frame_im = frame_source.read() 309 | else: 310 | ret_val = frame_source.grab() 311 | frame_im = None 312 | if not ret_val: 313 | break 314 | self._process_frame(self._num_frames + start_frame, frame_im) 315 | 316 | curr_frame += 1 317 | self._num_frames += 1 318 | if progress_bar: 319 | progress_bar.update(1) 320 | 321 | if frame_skip > 0: 322 | for _ in range(frame_skip): 323 | if not frame_source.grab(): 324 | break 325 | curr_frame += 1 326 | self._num_frames += 1 327 | if progress_bar: 328 | progress_bar.update(1) 329 | 330 | self._post_process(curr_frame) 331 | 332 | num_frames = curr_frame - start_frame 333 | finally: 334 | 335 | if progress_bar: 336 | progress_bar.close() 337 | 338 | return num_frames 339 | -------------------------------------------------------------------------------- /shotdetect/stats_manager.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # --------------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | 12 | from __future__ import print_function 13 | 14 | import logging 15 | 16 | from shotdetect.frame_timecode import MINIMUM_FRAMES_PER_SECOND_FLOAT 17 | from shotdetect.platform import get_csv_reader, get_csv_writer 18 | 19 | # pylint: disable=useless-super-delegation 20 | 21 | COLUMN_NAME_FPS = "Frame Rate:" 22 | COLUMN_NAME_FRAME_NUMBER = "Frame Number" 23 | COLUMN_NAME_TIMECODE = "Timecode" 24 | 25 | 26 | class FrameMetricRegistered(Exception): 27 | """ Raised when attempting to register a frame metric key which has 28 | already been registered. """ 29 | def __init__(self, metric_key, message="Attempted to re-register frame metric key."): 30 | # type: (str, str) 31 | # Pass message string to base Exception class. 32 | super(FrameMetricRegistered, self).__init__(message) 33 | self.metric_key = metric_key 34 | 35 | 36 | class FrameMetricNotRegistered(Exception): 37 | """ Raised when attempting to call get_metrics(...)/set_metrics(...) with a 38 | frame metric that does not exist, or has not been registered. """ 39 | def __init__(self, metric_key, message= 40 | "Attempted to get/set frame metrics for unregistered metric key."): 41 | # type: (str, str) 42 | # Pass message string to base Exception class. 43 | super(FrameMetricNotRegistered, self).__init__(message) 44 | self.metric_key = metric_key 45 | 46 | 47 | class StatsFileCorrupt(Exception): 48 | """ Raised when frame metrics/stats could not be loaded from a provided CSV file. """ 49 | def __init__(self, message= 50 | "Could not load frame metric data data from passed CSV file."): 51 | # type: (str, str) 52 | # Pass message string to base Exception class. 53 | super(StatsFileCorrupt, self).__init__(message) 54 | 55 | 56 | class StatsFileFramerateMismatch(Exception): 57 | """ Raised when attempting to load a CSV file with a framerate that differs from 58 | the current base timecode / VideoManager. """ 59 | def __init__(self, base_timecode_fps, stats_file_fps, message= 60 | "Framerate differs between stats file and base timecode."): 61 | # type: (str, str) 62 | # Pass message string to base Exception class. 63 | super(StatsFileFramerateMismatch, self).__init__(message) 64 | self.base_timecode_fps = base_timecode_fps 65 | self.stats_file_fps = stats_file_fps 66 | 67 | 68 | class NoMetricsRegistered(Exception): 69 | """ Raised when attempting to save a CSV file via save_to_csv(...) without any 70 | frame metrics having been registered (i.e. no shotDetector objects were added 71 | to the owning shotManager object, if any). """ 72 | pass 73 | 74 | 75 | class NoMetricsSet(Exception): 76 | """ Raised if no frame metrics have been set via set_metrics(...) when attempting 77 | to save the stats to a CSV file via save_to_csv(...). This may also indicate that 78 | detect_shots(...) was not called on the owning shotManager object, if any. """ 79 | pass 80 | 81 | 82 | ## 83 | ## StatsManager Class Implementation 84 | ## 85 | 86 | class StatsManager(object): 87 | """ Provides a key-value store for frame metrics/calculations which can be used 88 | as a cache to speed up subsequent calls to a shotManager's detect_shots(...) 89 | method. The statistics can be saved to a CSV file, and loaded from disk. 90 | 91 | Analyzing a statistics CSV file is also very useful for finding the optimal 92 | algorithm parameters for certain detection methods. Additionally, the data 93 | may be plotted by a graphing module (e.g. matplotlib) by obtaining the 94 | metric of interest for a series of frames by iteratively calling get_metrics(), 95 | after having called the detect_shots(...) method on the shotManager object 96 | which owns the given StatsManager instance. 97 | """ 98 | 99 | def __init__(self): 100 | # type: () 101 | # Frame metrics is a dict of frame (int): metric_dict (Dict[str, float]) 102 | # of each frame metric key and the value it represents (usually float). 103 | self._frame_metrics = dict() # Dict[FrameTimecode, Dict[str, float]] 104 | self._registered_metrics = set() # Set of frame metric keys. 105 | self._loaded_metrics = set() # Metric keys loaded from stats file. 106 | self._metrics_updated = False # Flag indicating if metrics require saving. 107 | 108 | 109 | def register_metrics(self, metric_keys): 110 | # type: (List[str]) -> bool 111 | """ Register Metrics 112 | 113 | Register a list of metric keys that will be used by the detector. 114 | Used to ensure that multiple detector keys don't overlap. 115 | 116 | Raises: 117 | FrameMetricRegistered: A particular metric_key has already been registered/added 118 | to the StatsManager. Only if the StatsManager is being used for read-only 119 | access (i.e. all frames in the video have already been processed for the given 120 | metric_key in the exception) is this behavior desirable. 121 | """ 122 | for metric_key in metric_keys: 123 | if metric_key not in self._registered_metrics: 124 | self._registered_metrics.add(metric_key) 125 | else: 126 | raise FrameMetricRegistered(metric_key) 127 | 128 | 129 | def get_metrics(self, frame_number, metric_keys): 130 | # type: (int, List[str]) -> List[Union[None, int, float, str]] 131 | """ Get Metrics: Returns the requested statistics/metrics for a given frame. 132 | 133 | Args: 134 | frame_number (int): Frame number to retrieve metrics for. 135 | metric_keys (List[str]): A list of metric keys to look up. 136 | 137 | Returns: 138 | A list containing the requested frame metrics for the given frame number 139 | in the same order as the input list of metric keys. If a metric could 140 | not be found, None is returned for that particular metric. 141 | """ 142 | return [self._get_metric(frame_number, metric_key) for metric_key in metric_keys] 143 | 144 | 145 | def set_metrics(self, frame_number, metric_kv_dict): 146 | # type: (int, Dict[str, Union[None, int, float, str]]) -> None 147 | """ Set Metrics: Sets the provided statistics/metrics for a given frame. 148 | 149 | Args: 150 | frame_number (int): Frame number to retrieve metrics for. 151 | metric_kv_dict (Dict[str, metric]): A dict mapping metric keys to the 152 | respective integer/floating-point metric values to set. 153 | """ 154 | for metric_key in metric_kv_dict: 155 | self._set_metric(frame_number, metric_key, metric_kv_dict[metric_key]) 156 | 157 | 158 | def metrics_exist(self, frame_number, metric_keys): 159 | # type: (int, List[str]) -> bool 160 | """ Metrics Exist: Checks if the given metrics/stats exist for the given frame. 161 | 162 | Returns: 163 | bool: True if the given metric keys exist for the frame, False otherwise. 164 | """ 165 | return all([self._metric_exists(frame_number, metric_key) for metric_key in metric_keys]) 166 | 167 | 168 | def is_save_required(self): 169 | # type: () -> bool 170 | """ Is Save Required: Checks if the stats have been updated since loading. 171 | 172 | Returns: 173 | bool: True if there are frame metrics/statistics not yet written to disk, 174 | False otherwise. 175 | """ 176 | return self._metrics_updated 177 | 178 | 179 | def save_to_csv(self, csv_file, base_timecode, force_save=True): 180 | # type: (File [w], FrameTimecode, bool) -> None 181 | """ Save To CSV: Saves all frame metrics stored in the StatsManager to a CSV file. 182 | 183 | Args: 184 | csv_file: A file handle opened in write mode (e.g. open('...', 'w')). 185 | base_timecode: The base_timecode obtained from the frame source VideoManager. 186 | If using an OpenCV VideoCapture, create one using the video framerate by 187 | setting base_timecode=FrameTimecode(0, fps=video_framerate). 188 | force_save: If True, forcably writes metrics out even if there are no 189 | registered metrics or frame statistics. If False, a NoMetricsRegistered 190 | will be thrown if there are no registered metrics, and a NoMetricsSet 191 | exception will be thrown if is_save_required() returns False. 192 | 193 | Raises: 194 | NoMetricsRegistered: No frame metrics have been registered to save, 195 | nor is there any frame data to save. 196 | NoMetricsSet: No frame metrics have been entered/updated, thus there 197 | is no frame data to save. 198 | """ 199 | csv_writer = get_csv_writer(csv_file) 200 | # Ensure we need to write to the file, and that we have data to do so with. 201 | if ((self.is_save_required() or force_save) and 202 | self._registered_metrics and self._frame_metrics): 203 | # Header rows. 204 | metric_keys = sorted(list(self._registered_metrics.union(self._loaded_metrics))) 205 | csv_writer.writerow([COLUMN_NAME_FPS, '%.10f' % base_timecode.get_framerate()]) 206 | csv_writer.writerow( 207 | [COLUMN_NAME_FRAME_NUMBER, COLUMN_NAME_TIMECODE] + metric_keys) 208 | frame_keys = sorted(self._frame_metrics.keys()) 209 | print("Writing %d frames to CSV..." % len(frame_keys)) 210 | for frame_key in frame_keys: 211 | frame_timecode = base_timecode + frame_key 212 | csv_writer.writerow( 213 | [frame_timecode.get_frames(), frame_timecode.get_timecode()] + 214 | [str(metric) for metric in self.get_metrics(frame_key, metric_keys)]) 215 | else: 216 | if not self._registered_metrics: 217 | raise NoMetricsRegistered() 218 | if not self._frame_metrics: 219 | raise NoMetricsSet() 220 | 221 | 222 | def load_from_csv(self, csv_file, base_timecode=None, reset_save_required=True): 223 | # type: (File [r], FrameTimecode, Optional[bool] -> int 224 | """ Load From CSV: Loads all metrics stored in a CSV file into the StatsManager instance. 225 | 226 | Args: 227 | csv_file: A file handle opened in read mode (e.g. open('...', 'r')). 228 | base_timecode: The base_timecode obtained from the frame source VideoManager. 229 | If using an OpenCV VideoCapture, create one using the video framerate by 230 | setting base_timecode=FrameTimecode(0, fps=video_framerate). 231 | If base_timecode is not set (i.e. is None), the framerate is not validated. 232 | reset_save_required: If True, clears the flag indicating that a save is required. 233 | 234 | Returns: 235 | int or None: Number of frames/rows read from the CSV file, or None if the 236 | input file was blank. 237 | 238 | Raises: 239 | StatsFileCorrupt: Stats file is corrupt and can't be loaded, or wrong file 240 | was specified. 241 | StatsFileFramerateMismatch: Framerate does not match the loaded stats file, 242 | indicating either the wrong video or wrong stats file was specified. 243 | """ 244 | csv_reader = get_csv_reader(csv_file) 245 | num_cols = None 246 | num_metrics = None 247 | num_frames = None 248 | # First row: Framerate, [video_framerate] 249 | try: 250 | row = next(csv_reader) 251 | except StopIteration: 252 | # If the file is blank or we couldn't decode anything, assume the file was empty. 253 | return num_frames 254 | # First Row (FPS = [...]) and ensure framerate equals base_timecode if set. 255 | if not len(row) == 2 or not row[0] == COLUMN_NAME_FPS: 256 | raise StatsFileCorrupt() 257 | stats_file_framerate = float(row[1]) 258 | if stats_file_framerate < MINIMUM_FRAMES_PER_SECOND_FLOAT: 259 | raise StatsFileCorrupt("Invalid framerate detected in CSV stats file " 260 | "(decoded FPS: %f)." % stats_file_framerate) 261 | if base_timecode is not None and not base_timecode.equal_framerate(stats_file_framerate): 262 | raise StatsFileFramerateMismatch(base_timecode.get_framerate(), stats_file_framerate) 263 | # Second Row: Frame Num, Timecode, [metrics...] 264 | try: 265 | row = next(csv_reader) 266 | except StopIteration: 267 | raise StatsFileCorrupt("Header row(s) missing.") 268 | if not row or not len(row) >= 2: 269 | raise StatsFileCorrupt() 270 | if row[0] != COLUMN_NAME_FRAME_NUMBER or row[1] != COLUMN_NAME_TIMECODE: 271 | raise StatsFileCorrupt() 272 | num_cols = len(row) 273 | num_metrics = num_cols - 2 274 | if not num_metrics > 0: 275 | raise StatsFileCorrupt('No metrics defined in CSV file.') 276 | metric_keys = row[2:] 277 | num_frames = 0 278 | for row in csv_reader: 279 | metric_dict = {} 280 | if not len(row) == num_cols: 281 | raise StatsFileCorrupt('Wrong number of columns detected in stats file row.') 282 | for i, metric_str in enumerate(row[2:]): 283 | if metric_str and metric_str != 'None': 284 | try: 285 | metric_dict[metric_keys[i]] = float(metric_str) 286 | except ValueError: 287 | raise StatsFileCorrupt('Corrupted value in stats file: %s' % metric_str) 288 | self.set_metrics(int(row[0]), metric_dict) 289 | num_frames += 1 290 | logging.info('Loaded %d metrics for %d frames.', num_metrics, num_frames) 291 | if reset_save_required: 292 | self._metrics_updated = False 293 | return num_frames 294 | 295 | 296 | def _get_metric(self, frame_number, metric_key): 297 | # type: (int, str) -> Union[None, int, float, str] 298 | if self._metric_exists(frame_number, metric_key): 299 | return self._frame_metrics[frame_number][metric_key] 300 | return None 301 | 302 | 303 | def _set_metric(self, frame_number, metric_key, metric_value): 304 | self._metrics_updated = True 305 | # type: (int, str, Union[None, int, float, str]) -> None 306 | if not frame_number in self._frame_metrics: 307 | self._frame_metrics[frame_number] = dict() 308 | self._frame_metrics[frame_number][metric_key] = metric_value 309 | 310 | 311 | def _metric_exists(self, frame_number, metric_key): 312 | # type: (int, List[str]) -> bool 313 | return (frame_number in self._frame_metrics and 314 | metric_key in self._frame_metrics[frame_number]) 315 | -------------------------------------------------------------------------------- /shotdetect/video_manager.py: -------------------------------------------------------------------------------- 1 | 2 | # The codes below partially refer to the PySceneDetect. According 3 | # to its BSD 3-Clause License, we keep the following. 4 | # 5 | # PySceneDetect: Python-Based Video Scene Detector 6 | # --------------------------------------------------------------- 7 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 8 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 9 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 10 | # 11 | # Copyright (C) 2014-2021 Brandon Castellano . 12 | 13 | from __future__ import print_function 14 | 15 | import math 16 | import os 17 | import pdb 18 | 19 | import cv2 20 | 21 | import shotdetect.frame_timecode 22 | from shotdetect.frame_timecode import FrameTimecode 23 | from shotdetect.platform import STRING_TYPE 24 | 25 | 26 | class VideoOpenFailure(Exception): 27 | """ VideoOpenFailure: Raised when an OpenCV VideoCapture object fails to open (i.e. calling 28 | the isOpened() method returns a non True value). """ 29 | def __init__(self, file_list=None, message= 30 | "OpenCV VideoCapture object failed to return True when calling isOpened()."): 31 | # type: (Iterable[(str, str)], str) 32 | # Pass message string to base Exception class. 33 | super(VideoOpenFailure, self).__init__(message) 34 | # list of (filename: str, filepath: str) 35 | self.file_list = file_list 36 | 37 | 38 | class VideoFramerateUnavailable(Exception): 39 | """ VideoFramerateUnavailable: Raised when the framerate cannot be determined from the video, 40 | and the framerate has not been overriden/forced in the VideoManager. """ 41 | def __init__(self, file_name=None, file_path=None, message= 42 | "OpenCV VideoCapture object failed to return framerate when calling " 43 | "get(cv2.CAP_PROP_FPS)."): 44 | # type: (str, str, str) 45 | # Pass message string to base Exception class. 46 | super(VideoFramerateUnavailable, self).__init__(message) 47 | # Set other exception properties. 48 | self.file_name = file_name 49 | self.file_path = file_path 50 | 51 | 52 | class VideoParameterMismatch(Exception): 53 | """ VideoParameterMismatch: Raised when opening multiple videos with a VideoManager, and some 54 | of the video parameters (frame height, frame width, and framerate/FPS) do not match. """ 55 | def __init__(self, file_list=None, message= 56 | "OpenCV VideoCapture object parameters do not match."): 57 | # type: (Iterable[Tuple[int, float, float, str, str]], str) 58 | # Pass message string to base Exception class. 59 | super(VideoParameterMismatch, self).__init__(message) 60 | # list of (param_mismatch_type: int, parameter value, expected value, 61 | # filename: str, filepath: str) 62 | # where param_mismatch_type is an OpenCV CAP_PROP (e.g. CAP_PROP_FPS). 63 | self.file_list = file_list 64 | 65 | 66 | class VideoDecodingInProgress(RuntimeError): 67 | """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that 68 | must be called *before* start() has been called. """ 69 | pass 70 | 71 | 72 | class VideoDecoderNotStarted(RuntimeError): 73 | """ VideoDecodingInProgress: Raised when attempting to call certain VideoManager methods that 74 | must be called *after* start() has been called. """ 75 | pass 76 | 77 | 78 | class InvalidDownscaleFactor(ValueError): 79 | """ InvalidDownscaleFactor: Raised when trying to set invalid downscale factor, 80 | i.e. the supplied downscale factor was not a positive integer greater than zero. """ 81 | pass 82 | 83 | 84 | DEFAULT_DOWNSCALE_FACTORS = { 85 | 3200: 12, # ~4k 86 | 2100: 8, # ~2k 87 | 1700: 6, # ~1080p 88 | 1200: 5, 89 | 900: 4, # ~720p 90 | 600: 3, 91 | 400: 2 # ~480p 92 | } 93 | """Dict[int, int]: The default downscale factor for a video of size W x H, 94 | which enforces the constraint that W >= 200 to ensure an adequate amount 95 | of pixels for shot detection while providing a speedup in processing. """ 96 | 97 | 98 | 99 | def compute_downscale_factor(frame_width): 100 | # type: (int) -> int 101 | """ Compute Downscale Factor: Returns the optimal default downscale factor based on 102 | a video's resolution (specifically, the width parameter). 103 | 104 | Returns: 105 | int: The defalt downscale factor to use with a video of frame_height x frame_width. 106 | """ 107 | for width in sorted(DEFAULT_DOWNSCALE_FACTORS, reverse=True): 108 | if frame_width >= width: 109 | return DEFAULT_DOWNSCALE_FACTORS[width] 110 | return 1 111 | 112 | 113 | def get_video_name(video_file): 114 | # type: (str) -> Tuple[str, str] 115 | """ Get Video Name: Returns a string representing the video file/device name. 116 | 117 | Returns: 118 | str: Video file name or device ID. In the case of a video, only the file 119 | name is returned, not the whole path. For a device, the string format 120 | is 'Device 123', where 123 is the integer ID of the capture device. 121 | """ 122 | if isinstance(video_file, int): 123 | return ('Device %d' % video_file, video_file) 124 | return (os.path.split(video_file)[1], video_file) 125 | 126 | 127 | def get_num_frames(cap_list): 128 | """ Get Number of Frames: Returns total number of frames in the cap_list. 129 | 130 | Calls get(CAP_PROP_FRAME_COUNT) and returns the sum for all VideoCaptures. 131 | """ 132 | return sum([math.trunc(cap.get(cv2.CAP_PROP_FRAME_COUNT)) for cap in cap_list]) 133 | 134 | 135 | def open_captures(video_files, framerate=None, validate_parameters=True): 136 | """ Open Captures - helper function to open all capture objects, set the framerate, 137 | and ensure that all open captures have been opened and the framerates match on a list 138 | of video file paths, or a list containing a single device ID. 139 | 140 | Args: 141 | video_files (list of str(s)/int): A list of one or more paths (str), or a list 142 | of a single integer device ID, to open as an OpenCV VideoCapture object. 143 | A ValueError will be raised if the list does not conform to the above. 144 | framerate (float, optional): Framerate to assume when opening the video_files. 145 | If not set, the first open video is used for deducing the framerate of 146 | all videos in the sequence. 147 | validate_parameters (bool, optional): If true, will ensure that the frame sizes 148 | (width, height) and frame rate (FPS) of all passed videos is the same. 149 | A VideoParameterMismatch is raised if the framerates do not match. 150 | 151 | Returns: 152 | A tuple of form (cap_list, framerate, framesize) where cap_list is a list of open 153 | OpenCV VideoCapture objects in the same order as the video_files list, framerate 154 | is a float of the video(s) framerate(s), and framesize is a tuple of (width, height) 155 | where width and height are integers representing the frame size in pixels. 156 | 157 | Raises: 158 | ValueError: No video file(s) specified, or invalid/multiple device IDs specified. 159 | TypeError: `framerate` must be type `float`. 160 | IOError: Video file(s) not found. 161 | VideoFramerateUnavailable: Video framerate could not be obtained and `framerate` 162 | was not set manually. 163 | VideoParameterMismatch: All videos in `video_files` do not have equal parameters. 164 | Set `validate_parameters=False` to skip this check. 165 | VideoOpenFailure: Video(s) could not be opened. 166 | """ 167 | is_device = False 168 | if not video_files: 169 | raise ValueError("Expected at least 1 video file or device ID.") 170 | if isinstance(video_files[0], int): 171 | if len(video_files) > 1: 172 | raise ValueError("If device ID is specified, no video sources may be appended.") 173 | elif video_files[0] < 0: 174 | raise ValueError("Invalid/negative device ID specified.") 175 | is_device = True 176 | elif not all([isinstance(video_file, (str, STRING_TYPE)) for video_file in video_files]): 177 | raise ValueError("Unexpected element type in video_files list (expected str(s)/int).") 178 | elif framerate is not None and not isinstance(framerate, float): 179 | raise TypeError("Expected type float for parameter framerate.") 180 | # Check if files exist. 181 | if not is_device and any([not os.path.exists(video_file) for video_file in video_files]): 182 | raise IOError("Video file(s) not found.") 183 | cap_list = [] 184 | 185 | try: 186 | cap_list = [cv2.VideoCapture(video_file) for video_file in video_files] 187 | video_names = [get_video_name(video_file) for video_file in video_files] 188 | closed_caps = [video_names[i] for i, cap in 189 | enumerate(cap_list) if not cap.isOpened()] 190 | if closed_caps: 191 | raise VideoOpenFailure(closed_caps) 192 | 193 | cap_framerates = [cap.get(cv2.CAP_PROP_FPS) for cap in cap_list] 194 | cap_framerate, check_framerate = validate_capture_framerate( 195 | video_names, cap_framerates, framerate) 196 | # Store frame sizes as integers (VideoCapture.get() returns float). 197 | cap_frame_sizes = [(math.trunc(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), 198 | math.trunc(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))) 199 | for cap in cap_list] 200 | cap_frame_size = cap_frame_sizes[0] 201 | 202 | # If we need to validate the parameters, we check that the FPS and width/height 203 | # of all open captures is identical (or almost identical in the case of FPS). 204 | if validate_parameters: 205 | validate_capture_parameters( 206 | video_names=video_names, cap_frame_sizes=cap_frame_sizes, 207 | check_framerate=check_framerate, cap_framerates=cap_framerates) 208 | 209 | except: 210 | release_captures(cap_list) 211 | raise 212 | 213 | return (cap_list, cap_framerate, cap_frame_size) 214 | 215 | 216 | def release_captures(cap_list): 217 | """ Close Captures: Calls the release() method on every capture in cap_list. """ 218 | for cap in cap_list: 219 | cap.release() 220 | 221 | 222 | def close_captures(cap_list): 223 | """ Close Captures: Calls the close() method on every capture in cap_list. """ 224 | for cap in cap_list: 225 | cap.close() 226 | 227 | 228 | def validate_capture_framerate(video_names, cap_framerates, framerate=None): 229 | """ Validate Capture Framerate: Ensures that the passed capture framerates are valid and equal. 230 | 231 | Raises: 232 | ValueError: Invalid framerate (must be positive non-zero value). 233 | TypeError: Framerate must be of type float. 234 | VideoFramerateUnavailable: Framerate for video could not be obtained, 235 | and `framerate` was not set. 236 | """ 237 | check_framerate = True 238 | cap_framerate = cap_framerates[0] 239 | if framerate is not None: 240 | if isinstance(framerate, float): 241 | if framerate < shotdetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT: 242 | raise ValueError("Invalid framerate (must be a positive non-zero value).") 243 | cap_framerate = framerate 244 | check_framerate = False 245 | else: 246 | raise TypeError("Expected float for framerate, got %s." % type(framerate).__name__) 247 | else: 248 | unavailable_framerates = [(video_names[i][0], video_names[i][1]) for 249 | i, fps in enumerate(cap_framerates) if fps < 250 | shotdetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT] 251 | if unavailable_framerates: 252 | raise VideoFramerateUnavailable(unavailable_framerates) 253 | return (cap_framerate, check_framerate) 254 | 255 | 256 | def validate_capture_parameters(video_names, cap_frame_sizes, check_framerate=False, 257 | cap_framerates=None): 258 | """ Validate Capture Parameters: Ensures that all passed capture frame sizes and (optionally) 259 | framerates are equal. Raises VideoParameterMismatch if there is a mismatch. 260 | 261 | Raises: 262 | VideoParameterMismatch 263 | """ 264 | bad_params = [] 265 | max_framerate_delta = shotdetect.frame_timecode.MINIMUM_FRAMES_PER_SECOND_FLOAT 266 | # Check heights/widths match. 267 | bad_params += [(cv2.CAP_PROP_FRAME_WIDTH, frame_size[0], 268 | cap_frame_sizes[0][0], video_names[i][0], video_names[i][1]) for 269 | i, frame_size in enumerate(cap_frame_sizes) 270 | if abs(frame_size[0] - cap_frame_sizes[0][0]) > 0] 271 | bad_params += [(cv2.CAP_PROP_FRAME_HEIGHT, frame_size[1], 272 | cap_frame_sizes[0][1], video_names[i][0], video_names[i][1]) for 273 | i, frame_size in enumerate(cap_frame_sizes) 274 | if abs(frame_size[1] - cap_frame_sizes[0][1]) > 0] 275 | # Check framerates if required. 276 | if check_framerate: 277 | bad_params += [(cv2.CAP_PROP_FPS, fps, cap_framerates[0], video_names[i][0], 278 | video_names[i][1]) for i, fps in enumerate(cap_framerates) 279 | if math.fabs(fps - cap_framerates[0]) > max_framerate_delta] 280 | 281 | if bad_params: 282 | raise VideoParameterMismatch(bad_params) 283 | 284 | 285 | class VideoManager(object): 286 | """ Provides a cv2.VideoCapture-like interface to a set of one or more video files, 287 | or a single device ID. Supports seeking and setting end time/duration. """ 288 | 289 | def __init__(self, video_files, framerate=None, logger=None): 290 | """ VideoManager Constructor Method (__init__) 291 | 292 | Args: 293 | video_files (list of str(s)/int): A list of one or more paths (str), or a list 294 | of a single integer device ID, to open as an OpenCV VideoCapture object. 295 | framerate (float, optional): Framerate to assume when storing FrameTimecodes. 296 | If not set (i.e. is None), it will be deduced from the first open capture 297 | in video_files, else raises a VideoFramerateUnavailable exception. 298 | 299 | Raises: 300 | ValueError: No video file(s) specified, or invalid/multiple device IDs specified. 301 | TypeError: `framerate` must be type `float`. 302 | IOError: Video file(s) not found. 303 | VideoFramerateUnavailable: Video framerate could not be obtained and `framerate` 304 | was not set manually. 305 | VideoParameterMismatch: All videos in `video_files` do not have equal parameters. 306 | Set `validate_parameters=False` to skip this check. 307 | VideoOpenFailure: Video(s) could not be opened. 308 | """ 309 | if not video_files: 310 | raise ValueError("At least one string/integer must be passed in the video_files list.") 311 | # These VideoCaptures are only open in this process. 312 | self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( 313 | video_files=video_files, framerate=framerate) 314 | self._end_of_video = False 315 | self._start_time = self.get_base_timecode() 316 | self._end_time = None 317 | self._curr_time = self.get_base_timecode() 318 | self._last_frame = None 319 | self._curr_cap, self._curr_cap_idx = None, None 320 | self._video_file_paths = video_files 321 | self._logger = logger 322 | if self._logger is not None: 323 | self._logger.info( 324 | 'Loaded %d video%s, framerate: %.2f FPS, resolution: %d x %d', 325 | len(self._cap_list), 's' if len(self._cap_list) > 1 else '', 326 | self.get_framerate(), *self.get_framesize()) 327 | self._started = False 328 | self._downscale_factor = 1 329 | self._frame_length = get_num_frames(self._cap_list) 330 | 331 | def set_downscale_factor(self, downscale_factor=None): 332 | """ Set Downscale Factor - sets the downscale/subsample factor of returned frames. 333 | 334 | If N is the downscale_factor, the size of the frames returned becomes 335 | frame_width/N x frame_height/N via subsampling. 336 | 337 | If downscale_factor is None, the downscale factor is computed automatically 338 | based on the current video's resolution. A downscale_factor of 1 indicates 339 | no downscaling. 340 | """ 341 | if downscale_factor is None: 342 | self._downscale_factor = compute_downscale_factor(self.get_framesize()[0]) 343 | else: 344 | if not downscale_factor > 0: 345 | raise InvalidDownscaleFactor() 346 | self._downscale_factor = downscale_factor 347 | if self._logger is not None: 348 | effective_framesize = self.get_framesize_effective() 349 | self._logger.info( 350 | 'Downscale factor set to %d, effective resolution: %d x %d', 351 | self._downscale_factor, effective_framesize[0], effective_framesize[1]) 352 | 353 | def get_num_videos(self): 354 | """ Get Number of Videos - returns the length of the capture list (self._cap_list), 355 | representing the number of videos the VideoManager has opened. 356 | 357 | Returns: 358 | int: Number of videos, equal to length of capture list. 359 | """ 360 | return len(self._cap_list) 361 | 362 | def get_video_paths(self): 363 | """ Get Video Paths - returns list of strings containing paths to the open video(s). 364 | 365 | Returns: 366 | List[str]: List of paths to the video files opened by the VideoManager. 367 | """ 368 | return list(self._video_file_paths) 369 | 370 | def get_framerate(self): 371 | """ Get Framerate - returns the framerate the VideoManager is assuming for all 372 | open VideoCaptures. Obtained from either the capture itself, or the passed 373 | framerate parameter when the VideoManager object was constructed. 374 | 375 | Returns: 376 | float: Framerate, in frames/sec. 377 | """ 378 | return self._cap_framerate 379 | 380 | def get_base_timecode(self): 381 | """ Get Base Timecode - returns a FrameTimecode object at frame 0 / time 00:00:00. 382 | 383 | The timecode returned by this method can be used to perform arithmetic (e.g. 384 | addition), passing the resulting values back to the VideoManager (e.g. for the 385 | set_duration() method), as the framerate of the returned FrameTimecode object 386 | matches that of the VideoManager. 387 | 388 | As such, this method is equivalent to creating a FrameTimecode at frame 0 with 389 | the VideoManager framerate, for example, given a VideoManager called obj, 390 | the following expression will evaluate as True: 391 | 392 | obj.get_base_timecode() == FrameTimecode(0, obj.get_framerate()) 393 | 394 | Furthermore, the base timecode object returned by a particular VideoManager 395 | should not be passed to another one, unless you first verify that their 396 | framerates are the same. 397 | 398 | Returns: 399 | FrameTimecode object set to frame 0/time 00:00:00 with the video(s) framerate. 400 | """ 401 | return FrameTimecode(timecode=0, fps=self._cap_framerate) 402 | 403 | def get_current_timecode(self): 404 | """ Get Current Timecode - returns a FrameTimecode object at current VideoManager position. 405 | 406 | Returns: 407 | FrameTimecode: Timecode at the current VideoManager position. 408 | """ 409 | return self._curr_time 410 | 411 | def get_framesize(self): 412 | """ Get Frame Size - returns the frame size of the video(s) open in the 413 | VideoManager's capture objects. 414 | 415 | Returns: 416 | Tuple[int, int]: Video frame size in the form (width, height) where width 417 | and height represent the size of the video frame in pixels. 418 | """ 419 | return self._cap_framesize 420 | 421 | 422 | def get_framesize_effective(self): 423 | """ Get Frame Size - returns the frame size of the video(s) open in the 424 | VideoManager's capture objects, divided by the current downscale factor. 425 | 426 | Returns: 427 | Tuple[int, int]: Video frame size in the form (width, height) where width 428 | and height represent the size of the video frame in pixels. 429 | """ 430 | return [num_pixels / self._downscale_factor for num_pixels in self._cap_framesize] 431 | 432 | def set_duration(self, duration=None, start_time=None, end_time=None): 433 | """ Set Duration - sets the duration/length of the video(s) to decode, as well as 434 | the start/end times. Must be called before start() is called, otherwise a 435 | VideoDecodingInProgress exception will be thrown. May be called after reset() 436 | as well. 437 | 438 | Args: 439 | duration (Optional[FrameTimecode]): The (maximum) duration in time to 440 | decode from the opened video(s). Mutually exclusive with end_time 441 | (i.e. if duration is set, end_time must be None). 442 | start_time (Optional[FrameTimecode]): The time/first frame at which to 443 | start decoding frames from. If set, the input video(s) will be 444 | seeked to when start() is called, at which point the frame at 445 | start_time can be obtained by calling retrieve(). 446 | end_time (Optional[FrameTimecode]): The time at which to stop decoding 447 | frames from the opened video(s). Mutually exclusive with duration 448 | (i.e. if end_time is set, duration must be None). 449 | 450 | Raises: 451 | VideoDecodingInProgress: Must call before start(). 452 | """ 453 | if self._started: 454 | raise VideoDecodingInProgress() 455 | 456 | # Ensure any passed timecodes have the proper framerate. 457 | if ((duration is not None and not duration.equal_framerate(self._cap_framerate)) or 458 | (start_time is not None and not start_time.equal_framerate(self._cap_framerate)) or 459 | (end_time is not None and not end_time.equal_framerate(self._cap_framerate))): 460 | raise ValueError("FrameTimecode framerate does not match.") 461 | 462 | if duration is not None and end_time is not None: 463 | raise TypeError("Only one of duration and end_time may be specified, not both.") 464 | 465 | if start_time is not None: 466 | self._start_time = start_time 467 | 468 | if end_time is not None: 469 | if end_time < start_time: 470 | raise ValueError("end_time is before start_time in time.") 471 | self._end_time = end_time 472 | elif duration is not None: 473 | self._end_time = self._start_time + duration 474 | 475 | if self._end_time is not None: 476 | self._frame_length = min(self._frame_length, self._end_time.get_frames() + 1) 477 | self._frame_length -= self._start_time.get_frames() 478 | 479 | if self._logger is not None: 480 | self._logger.info( 481 | 'Duration set, start: %s, duration: %s, end: %s.', 482 | start_time.get_timecode() if start_time is not None else start_time, 483 | duration.get_timecode() if duration is not None else duration, 484 | end_time.get_timecode() if end_time is not None else end_time) 485 | 486 | def start(self): 487 | """ Start - starts video decoding and seeks to start time. Raises 488 | exception VideoDecodingInProgress if the method is called after the 489 | decoder process has already been started. 490 | 491 | Raises: 492 | VideoDecodingInProgress: Must call stop() before this method if 493 | start() has already been called after initial construction. 494 | """ 495 | if self._started: 496 | raise VideoDecodingInProgress() 497 | 498 | self._started = True 499 | self._get_next_cap() 500 | self.seek(self._start_time) 501 | 502 | def seek(self, timecode): 503 | """ Seek - seeks forwards to the passed timecode. 504 | 505 | Only supports seeking forwards (i.e. timecode must be greater than the 506 | current VideoManager position). Can only be used after the start() 507 | method has been called. 508 | 509 | Args: 510 | timecode (FrameTimecode): Time in video to seek forwards to. 511 | 512 | Returns: 513 | bool: True if seeking succeeded, False if no more frames / end of video. 514 | 515 | Raises: 516 | VideoDecoderNotStarted: Must call start() before this method. 517 | """ 518 | if not self._started: 519 | raise VideoDecoderNotStarted() 520 | 521 | while self._curr_time < timecode: 522 | if self._curr_cap is None and not self._get_next_cap(): 523 | return False 524 | if self._curr_cap.grab(): 525 | self._curr_time += 1 526 | else: 527 | if not self._get_next_cap(): 528 | return False 529 | return True 530 | 531 | def release(self): 532 | """ Release (cv2.VideoCapture method), releases all open capture(s). """ 533 | release_captures(self._cap_list) 534 | self._cap_list = [] 535 | self._started = False 536 | 537 | def reset(self): 538 | """ Reset - Reopens captures passed to the constructor of the VideoManager. 539 | 540 | Can only be called after the release() method has been called. 541 | 542 | Raises: 543 | VideoDecodingInProgress: Must call release() before this method. 544 | """ 545 | if self._started: 546 | raise VideoDecodingInProgress() 547 | 548 | self._started = False 549 | self._end_of_video = False 550 | self._curr_time = self.get_base_timecode() 551 | self._cap_list, self._cap_framerate, self._cap_framesize = open_captures( 552 | video_files=self._video_file_paths, framerate=self._curr_time.get_framerate()) 553 | self._curr_cap, self._curr_cap_idx = None, None 554 | 555 | def get(self, capture_prop, index=None): 556 | """ Get (cv2.VideoCapture method) - obtains capture properties from the current 557 | VideoCapture object in use. Index represents the same index as the original 558 | video_files list passed to the constructor. Getting/setting the position (POS) 559 | properties has no effect; seeking is implemented using VideoDecoder methods. 560 | 561 | Note that getting the property CAP_PROP_FRAME_COUNT will return the integer sum of 562 | the frame count for all VideoCapture objects if index is not specified (or is None), 563 | otherwise the frame count for the given VideoCapture index is returned instead. 564 | 565 | Args: 566 | capture_prop: OpenCV VideoCapture property to get (i.e. CAP_PROP_FPS). 567 | index (int, optional): Index in file_list of capture to get property from (default 568 | is zero). Index is not checked and will raise exception if out of bounds. 569 | 570 | Returns: 571 | float: Return value from calling get(property) on the VideoCapture object. 572 | """ 573 | if capture_prop == cv2.CAP_PROP_FRAME_COUNT and index is None: 574 | return self._frame_length 575 | elif capture_prop == cv2.CAP_PROP_POS_FRAMES: 576 | return self._curr_time 577 | elif index is None: 578 | index = 0 579 | return self._cap_list[index].get(capture_prop) 580 | 581 | def grab(self): 582 | """ Grab (cv2.VideoCapture method) - retrieves a frame but does not return it. 583 | 584 | Returns: 585 | bool: True if a frame was grabbed, False otherwise. 586 | 587 | Raises: 588 | VideoDecoderNotStarted: Must call start() before this method. 589 | """ 590 | if not self._started: 591 | raise VideoDecoderNotStarted() 592 | 593 | grabbed = False 594 | if self._curr_cap is not None and self._end_of_video != True: 595 | while not grabbed: 596 | grabbed = self._curr_cap.grab() 597 | if not grabbed and not self._get_next_cap(): 598 | break 599 | else: 600 | self._curr_time += 1 601 | if self._end_time is not None and self._curr_time > self._end_time: 602 | grabbed = False 603 | self._last_frame = None 604 | return grabbed 605 | 606 | def retrieve(self): 607 | """ Retrieve (cv2.VideoCapture method) - retrieves and returns a frame. 608 | 609 | Frame returned corresponds to last call to get(). 610 | 611 | Returns: 612 | Tuple[bool, Union[None, numpy.ndarray]]: Returns tuple of 613 | (True, frame_image) if a frame was grabbed during the last call 614 | to grab(), and where frame_image is a numpy ndarray of the 615 | decoded frame, otherwise returns (False, None). 616 | 617 | Raises: 618 | VideoDecoderNotStarted: Must call start() before this method. 619 | """ 620 | if not self._started: 621 | raise VideoDecoderNotStarted() 622 | 623 | retrieved = False 624 | if self._curr_cap is not None and self._end_of_video != True: 625 | while not retrieved: 626 | retrieved, self._last_frame = self._curr_cap.retrieve() 627 | if not retrieved and not self._get_next_cap(): 628 | break 629 | if self._downscale_factor > 1: 630 | self._last_frame = self._last_frame[ 631 | ::self._downscale_factor, ::self._downscale_factor, :] 632 | if self._end_time is not None and self._curr_time > self._end_time: 633 | retrieved = False 634 | self._last_frame = None 635 | return (retrieved, self._last_frame) 636 | 637 | def read(self): 638 | """ Read (cv2.VideoCapture method) - retrieves and returns a frame. 639 | 640 | Returns: 641 | Tuple[bool, Union[None, numpy.ndarray]]: Returns tuple of 642 | (True, frame_image) if a frame was grabbed, where frame_image 643 | is a numpy ndarray of the decoded frame, otherwise (False, None). 644 | 645 | Raises: 646 | VideoDecoderNotStarted: Must call start() before this method. 647 | """ 648 | if not self._started: 649 | raise VideoDecoderNotStarted() 650 | 651 | read_frame = False 652 | if self._curr_cap is not None and self._end_of_video != True: 653 | while not read_frame: 654 | read_frame, self._last_frame = self._curr_cap.read() 655 | if not read_frame and not self._get_next_cap(): 656 | break 657 | if self._downscale_factor > 1: 658 | self._last_frame = self._last_frame[ 659 | ::self._downscale_factor, ::self._downscale_factor, :] 660 | if self._end_time is not None and self._curr_time > self._end_time: 661 | read_frame = False 662 | self._last_frame = None 663 | if read_frame: 664 | self._curr_time += 1 665 | return (read_frame, self._last_frame) 666 | 667 | def _get_next_cap(self): 668 | self._curr_cap = None 669 | if self._curr_cap_idx is None: 670 | self._curr_cap_idx = 0 671 | self._curr_cap = self._cap_list[0] 672 | return True 673 | else: 674 | if not (self._curr_cap_idx + 1) < len(self._cap_list): 675 | self._end_of_video = True 676 | return False 677 | self._curr_cap_idx += 1 678 | self._curr_cap = self._cap_list[self._curr_cap_idx] 679 | return True 680 | -------------------------------------------------------------------------------- /shotdetect/video_splitter.py: -------------------------------------------------------------------------------- 1 | # The codes below partially refer to the PySceneDetect. According 2 | # to its BSD 3-Clause License, we keep the following. 3 | # 4 | # PySceneDetect: Python-Based Video Scene Detector 5 | # ----------------------------------------------------------- 6 | # [ Site: http://www.bcastell.com/projects/PySceneDetect/ ] 7 | # [ Github: https://github.com/Breakthrough/PySceneDetect/ ] 8 | # [ Documentation: http://pyscenedetect.readthedocs.org/ ] 9 | # 10 | # Copyright (C) 2014-2021 Brandon Castellano . 11 | 12 | import logging 13 | import math 14 | import os 15 | import pdb 16 | import subprocess 17 | import time 18 | from string import Template 19 | 20 | from shotdetect.platform import tqdm 21 | 22 | 23 | def is_mkvmerge_available(): 24 | """ Is mkvmerge Available: Gracefully checks if mkvmerge command is available. 25 | 26 | Returns: 27 | (bool) True if the mkvmerge command is available, False otherwise. 28 | """ 29 | ret_val = None 30 | try: 31 | ret_val = subprocess.call(['mkvmerge', '--quiet']) 32 | except OSError: 33 | return False 34 | if ret_val is not None and ret_val != 2: 35 | return False 36 | return True 37 | 38 | 39 | def is_ffmpeg_available(): 40 | """ Is ffmpeg Available: Gracefully checks if ffmpeg command is available. 41 | 42 | Returns: 43 | (bool) True if the ffmpeg command is available, False otherwise. 44 | """ 45 | ret_val = None 46 | try: 47 | ret_val = subprocess.call(['ffmpeg', '-v', 'quiet']) 48 | except OSError: 49 | return False 50 | if ret_val is not None and ret_val != 1: 51 | return False 52 | return True 53 | 54 | 55 | def split_video_mkvmerge(input_video_paths, shot_list, output_file_prefix, 56 | video_name, suppress_output=False): 57 | """ Calls the mkvmerge command on the input video(s), splitting it at the 58 | passed timecodes, where each shot is written in sequence from 001. """ 59 | 60 | if not input_video_paths or not shot_list: 61 | return 62 | 63 | logging.info('Splitting input video%s using mkvmerge, output path template:\n %s', 64 | 's' if len(input_video_paths) > 1 else '', output_file_prefix) 65 | 66 | ret_val = None 67 | # mkvmerge automatically appends '-$SHOT_NUMBER'. 68 | output_file_name = output_file_prefix.replace('-${SHOT_NUMBER}', '') 69 | output_file_name = output_file_prefix.replace('-$SHOT_NUMBER', '') 70 | output_file_template = Template(output_file_name) 71 | output_file_name = output_file_template.safe_substitute( 72 | VIDEO_NAME=video_name, 73 | SHOT_NUMBER='') 74 | 75 | try: 76 | call_list = ['mkvmerge'] 77 | if suppress_output: 78 | call_list.append('--quiet') 79 | call_list += [ 80 | '-o', output_file_name, 81 | '--split', 82 | #'timecodes:%s' % ','.join( 83 | # [start_time.get_timecode() for start_time, _ in shot_list[1:]]), 84 | 'parts:%s' % ','.join( 85 | ['%s-%s' % (start_time.get_timecode(), end_time.get_timecode()) 86 | for start_time, end_time in shot_list]), 87 | ' +'.join(input_video_paths)] 88 | total_frames = shot_list[-1][1].get_frames() - shot_list[0][0].get_frames() 89 | processing_start_time = time.time() 90 | ret_val = subprocess.call(call_list) 91 | if not suppress_output: 92 | print('') 93 | logging.info('Average processing speed %.2f frames/sec.', 94 | float(total_frames) / (time.time() - processing_start_time)) 95 | except OSError: 96 | logging.error('mkvmerge could not be found on the system.' 97 | ' Please install mkvmerge to enable video output support.') 98 | raise 99 | if ret_val is not None and ret_val != 0: 100 | logging.error('Error splitting video (mkvmerge returned %d).', ret_val) 101 | 102 | 103 | def split_video_ffmpeg(input_video_paths, shot_list, output_dir, 104 | output_file_template="${OUTPUT_DIR}/shot_${SHOT_NUMBER}.mp4", 105 | compress_output=False, 106 | hide_progress=False, 107 | suppress_output=False): 108 | """ Calls the ffmpeg command on the input video(s), generating a new video for 109 | each shot based on the start/end timecodes. """ 110 | 111 | os.makedirs(output_dir, exist_ok=True) 112 | if not input_video_paths or not shot_list: 113 | return 114 | 115 | logging.info( 116 | 'Splitting input video%s using ffmpeg, output path template:\n %s', 117 | 's' if len(input_video_paths) > 1 else '', output_file_template) 118 | if len(input_video_paths) > 1: 119 | # TODO: Add support for splitting multiple/appended input videos. 120 | # https://trac.ffmpeg.org/wiki/Concatenate#samecodec 121 | # Requires generating a temporary file list for ffmpeg. 122 | logging.error( 123 | 'Sorry, splitting multiple appended/concatenated input videos with' 124 | ' ffmpeg is not supported yet. This feature will be added to a future' 125 | ' version of ShotDetect. In the meantime, you can try using the' 126 | ' -c / --copy option with the split-video to use mkvmerge, which' 127 | ' generates less accurate output, but supports multiple input videos.') 128 | raise NotImplementedError() 129 | 130 | ret_val = None 131 | filename_template = Template(output_file_template) 132 | shot_num_format = '%0' 133 | shot_num_format += str(max(4, math.floor(math.log(len(shot_list), 10)) + 1)) + 'd' 134 | try: 135 | progress_bar = None 136 | total_frames = shot_list[-1][1].get_frames() - shot_list[0][0].get_frames() 137 | if tqdm and not hide_progress: 138 | progress_bar = tqdm(total=total_frames, unit='frame', miniters=1, desc="Split Video") 139 | processing_start_time = time.time() 140 | for i, (start_time, end_time) in enumerate(shot_list): 141 | end_time = end_time.__sub__(1) # Fix the last frame of a shot to be 1 less than the first frame of the next shot 142 | duration = (end_time - start_time) 143 | # an alternative way to do it 144 | # duration = (end_time.get_frames()-1)/end_time.framerate - (start_time.get_frames())/start_time.framerate 145 | # duration_frame = end_time.get_frames()-1 - start_time.get_frames() 146 | call_list = ['ffmpeg'] 147 | if suppress_output: 148 | call_list += ['-v', 'quiet'] 149 | elif i > 0: 150 | # Only show ffmpeg output for the first call, which will display any 151 | # errors if it fails, and then break the loop. We only show error messages 152 | # for the remaining calls. 153 | call_list += ['-v', 'error'] 154 | call_list += [ 155 | '-y', 156 | '-ss', 157 | start_time.get_timecode(), 158 | '-i', 159 | input_video_paths[0]] 160 | if compress_output: 161 | call_list += '[-crf 21]' # compress 162 | call_list += ['-map_chapters', '-1'] # remove meta stream 163 | call_list += [ 164 | '-strict', 165 | '-2', 166 | '-t', 167 | duration.get_timecode(), 168 | '-sn', 169 | filename_template.safe_substitute( 170 | OUTPUT_DIR=output_dir, 171 | SHOT_NUMBER=shot_num_format % (i)) 172 | ] 173 | ret_val = subprocess.call(call_list) 174 | if not suppress_output and i == 0 and len(shot_list) > 1: 175 | logging.info( 176 | 'Output from ffmpeg for shot 1 shown above, splitting remaining shots...') 177 | if ret_val != 0: 178 | break 179 | if progress_bar: 180 | progress_bar.update(duration.get_frames()+1) # to compensate the missing one frame caused above 181 | if progress_bar: 182 | print('') 183 | logging.info('Average processing speed %.2f frames/sec.', 184 | float(total_frames) / (time.time() - processing_start_time)) 185 | except OSError: 186 | logging.error('ffmpeg could not be found on the system.' 187 | ' Please install ffmpeg to enable video output support.') 188 | if ret_val is not None and ret_val != 0: 189 | logging.error('Error splitting video (ffmpeg returned %d).', ret_val) 190 | -------------------------------------------------------------------------------- /shotdetect_p.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Parallel detect shot according to a list 3 | ''' 4 | 5 | from __future__ import print_function 6 | 7 | import argparse 8 | import multiprocessing 9 | import os 10 | import os.path as osp 11 | import pdb 12 | from datetime import datetime 13 | 14 | from shotdetect.detectors.average_detector import AverageDetector 15 | from shotdetect.detectors.content_detector_hsv_luv import ContentDetectorHSVLUV 16 | from shotdetect.keyf_img_saver import generate_images, generate_images_txt 17 | from shotdetect.shot_manager import ShotManager 18 | from shotdetect.stats_manager import StatsManager 19 | from shotdetect.video_manager import VideoManager 20 | from shotdetect.video_splitter import split_video_ffmpeg 21 | 22 | global parallel_cnt 23 | global parallel_num 24 | parallel_cnt = 0 25 | 26 | 27 | def main(args, video_path, data_root): 28 | stats_file_folder_path = osp.join(data_root, "shot_stats") 29 | os.makedirs(stats_file_folder_path, exist_ok=True) 30 | 31 | video_prefix = video_path.split(".")[0].split("/")[-1] 32 | stats_file_path = osp.join(stats_file_folder_path, '{}.csv'.format(video_prefix)) 33 | # print(video_path) 34 | video_manager = VideoManager([video_path]) 35 | stats_manager = StatsManager() 36 | # Construct our shotManager and pass it our StatsManager. 37 | shot_manager = ShotManager(stats_manager) 38 | 39 | # Add ContentDetector algorithm (each detector's constructor 40 | # takes detector options, e.g. threshold). 41 | if args.avg_sample: 42 | shot_manager.add_detector(AverageDetector(shot_length=50)) 43 | else: 44 | shot_manager.add_detector(ContentDetectorHSVLUV(threshold=20)) 45 | base_timecode = video_manager.get_base_timecode() 46 | 47 | shot_list = [] 48 | 49 | try: 50 | # If stats file exists, load it. 51 | if osp.exists(stats_file_path): 52 | # Read stats from CSV file opened in read mode: 53 | with open(stats_file_path, 'r') as stats_file: 54 | stats_manager.load_from_csv(stats_file, base_timecode) 55 | 56 | # Set begin and end time 57 | if args.begin_time is not None: 58 | start_time = base_timecode + args.begin_time 59 | end_time = base_timecode + args.end_time 60 | video_manager.set_duration(start_time=start_time, end_time=end_time) 61 | elif args.begin_frame is not None: 62 | start_frame = base_timecode + args.begin_frame 63 | end_frame = base_timecode + args.end_frame 64 | video_manager.set_duration(start_time=start_frame, end_time=end_frame) 65 | pass 66 | # Set downscale factor to improve processing speed. 67 | if args.keep_resolution: 68 | video_manager.set_downscale_factor(1) 69 | else: 70 | video_manager.set_downscale_factor() 71 | 72 | # Start video_manager. 73 | video_manager.start() 74 | 75 | # Perform shot detection on video_manager. 76 | shot_manager.detect_shots(frame_source=video_manager) 77 | 78 | # Obtain list of detected shots. 79 | shot_list = shot_manager.get_shot_list(base_timecode) 80 | # Each shot is a tuple of (start, end) FrameTimecodes. 81 | # Save keyf img for each shot 82 | if args.save_keyf: 83 | output_dir = osp.join(data_root, "shot_keyf", video_prefix) 84 | generate_images(video_manager, shot_list, output_dir, num_images=3) 85 | 86 | # Save keyf txt of frame ind 87 | if args.save_keyf_txt: 88 | output_dir = osp.join(data_root, "shot_txt", "{}.txt".format(video_prefix)) 89 | os.makedirs(osp.join(data_root, 'shot_txt'), exist_ok=True) 90 | generate_images_txt(shot_list, output_dir, num_images=5) 91 | 92 | # Split video into shot video 93 | if args.split_video: 94 | output_dir = osp.join(data_root, "shot_split_video", video_prefix) 95 | split_video_ffmpeg([video_path], shot_list, output_dir, suppress_output=True) 96 | 97 | # We only write to the stats file if a save is required: 98 | if stats_manager.is_save_required(): 99 | with open(stats_file_path, 'w') as stats_file: 100 | stats_manager.save_to_csv(stats_file, base_timecode) 101 | finally: 102 | video_manager.release() 103 | 104 | return shot_list 105 | 106 | 107 | def call_back(rst): 108 | global parallel_cnt 109 | global parallel_num 110 | parallel_cnt += 1 111 | if parallel_cnt % 1 == 0: 112 | print('{}, {:5d} / {:5d} done!'.format(datetime.now(), parallel_cnt, parallel_num)) 113 | 114 | 115 | if __name__ == '__main__': 116 | parser = argparse.ArgumentParser("Parallel ShotDetect") 117 | parser.add_argument('--num_workers', type=int, default=2, help='number of processors.') 118 | parser.add_argument('--source_path', type=str, 119 | default=osp.join("../../data/video"), 120 | help="path to the videos to be processed, please use absolute path") 121 | parser.add_argument('--list_file', type=str, 122 | default="../../data/meta.txt", 123 | help='The list of videos to be processed,\ 124 | in the form of xxxx0.mp4\nxxxx1.mp4\nxxxx2.mp4\n') 125 | parser.add_argument('--save_data_root_path', type=str, 126 | default="../../data", 127 | help="path to the saved data, please use absolute path") 128 | parser.add_argument('--save_keyf', action="store_true") 129 | parser.add_argument('--save_keyf_txt', action="store_true") 130 | parser.add_argument('--split_video', action="store_true") 131 | parser.add_argument('--keep_resolution', action="store_true") 132 | parser.add_argument('--avg_sample', action="store_true") 133 | parser.add_argument('--begin_time', type=float, default=None, help="float: timecode") 134 | parser.add_argument('--end_time', type=float, default=120.0, help="float: timecode") 135 | parser.add_argument('--begin_frame', type=int, default=None, help="int: frame") 136 | parser.add_argument('--end_frame', type=int, default=1000, help="int: frame") 137 | args = parser.parse_args() 138 | 139 | if args.list_file is None: 140 | video_list = sorted(os.listdir(args.source_path)) 141 | else: 142 | video_list = [x.strip() for x in open(args.list_file)] 143 | 144 | parallel_num = len(video_list) 145 | pool = multiprocessing.Pool(processes=args.num_workers) 146 | for video_id in video_list: 147 | video_path = osp.abspath(osp.join(args.source_path, f"{video_id}.mp4")) 148 | # uncommnet the following line and turn to non-parallel mode if wish to debug 149 | # main(args, video_path, args.save_data_root_path) 150 | pool.apply_async(main, args=(args, video_path, args.save_data_root_path), callback=call_back) 151 | pool.close() 152 | pool.join() 153 | --------------------------------------------------------------------------------