From e305d0188ed063b200a3f025233f2f719d77c17e Mon Sep 17 00:00:00 2001 From: k4yt3x Date: Mon, 11 May 2020 20:24:18 -0400 Subject: [PATCH] added image and GIF upscale support --- src/requirements.txt | 2 + src/upscaler.py | 282 ++++++++++++++++---------- src/video2x.py | 9 +- src/video2x.yaml | 19 +- src/video2x_gui.py | 84 ++++++-- src/video2x_setup.py | 93 ++++----- src/wrappers/anime4kcpp.py | 8 +- src/wrappers/ffmpeg.py | 45 ++-- src/wrappers/gifski.py | 70 +++++++ src/wrappers/srmd_ncnn_vulkan.py | 4 +- src/wrappers/waifu2x_caffe.py | 8 +- src/wrappers/waifu2x_converter_cpp.py | 4 +- src/wrappers/waifu2x_ncnn_vulkan.py | 4 +- 13 files changed, 401 insertions(+), 231 deletions(-) create mode 100644 src/wrappers/gifski.py diff --git a/src/requirements.txt b/src/requirements.txt index c014cd6..398c5ea 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -3,6 +3,8 @@ colorama patool psutil pyqt5 +python-magic +python-magic-bin pyunpack pyyaml requests diff --git a/src/upscaler.py b/src/upscaler.py index c342e04..f3b55a7 100755 --- a/src/upscaler.py +++ b/src/upscaler.py @@ -4,7 +4,7 @@ Name: Video2X Upscaler Author: K4YT3X Date Created: December 10, 2018 -Last Modified: May 10, 2020 +Last Modified: May 11, 2020 Description: This file contains the Upscaler class. Each instance of the Upscaler class is an upscaler on an image or @@ -16,6 +16,7 @@ from exceptions import * from image_cleaner import ImageCleaner from progress_monitor import ProgressMonitor from wrappers.ffmpeg import Ffmpeg +from wrappers.gifski import Gifski # built-in imports from fractions import Fraction @@ -24,7 +25,6 @@ import copy import gettext import importlib import locale -import os import pathlib import queue import re @@ -36,6 +36,7 @@ import traceback # third-party imports from avalon_framework import Avalon +import magic # internationalization constants DOMAIN = 'video2x' @@ -67,12 +68,13 @@ class Upscaler: ArgumentError -- if argument is not valid """ - def __init__(self, input_path, output_path, driver_settings, ffmpeg_settings): + def __init__(self, input_path, output_path, driver_settings, ffmpeg_settings, gifski_settings): # mandatory arguments self.input = input_path self.output = output_path self.driver_settings = driver_settings self.ffmpeg_settings = ffmpeg_settings + self.gifski_settings = gifski_settings # optional arguments self.driver = 'waifu2x_caffe' @@ -86,9 +88,9 @@ class Upscaler: self.running = False self.total_frames_upscaled = 0 self.total_frames = 0 - self.total_videos = 0 + self.total_files = 0 self.total_processed = 0 - self.current_input_video = pathlib.Path() + self.current_input_file = pathlib.Path() self.last_frame_upscaled = pathlib.Path() def create_temp_directories(self): @@ -154,10 +156,10 @@ class Upscaler: Avalon.error(_('Input and output path type mismatch')) Avalon.error(_('Input is single file but output is directory')) raise ArgumentError('input output path type mismatch') - if not re.search(r'.*\..*$', str(self.output)): + if self.output.suffix == '': Avalon.error(_('No suffix found in output file path')) - Avalon.error(_('Suffix must be specified for FFmpeg')) - raise ArgumentError('no output video suffix specified') + Avalon.error(_('Suffix must be specified')) + raise ArgumentError('no output file suffix specified') # if input is a directory elif self.input.is_dir(): @@ -238,6 +240,14 @@ class Upscaler: self.driver_settings['scale_width'] = None self.driver_settings['scale_height'] = None + # temporary file type check for Anime4KCPP + # it doesn't support GIF processing yet + if self.driver == 'anime4kcpp': + for task in self.processing_queue.queue: + if task[0].suffix.lower() == '.gif': + Avalon.error(_('Anime4KCPP doesn\'t yet support GIF processing')) + raise AttributeError('Anime4KCPP doesn\'t yet support GIF file processing') + def _upscale_frames(self): """ Upscale video frames with waifu2x-caffe @@ -393,9 +403,8 @@ class Upscaler: # load options from upscaler class into driver settings self.driver_object.load_configurations(self) - # parse arguments for waifu2x - # check argument sanity - self._check_arguments() + # initialize FFmpeg object + self.ffmpeg_object = Ffmpeg(self.ffmpeg_settings, self.image_format) # define processing queue self.processing_queue = queue.Queue() @@ -408,17 +417,17 @@ class Upscaler: for input_path in self.input: if input_path.is_file(): - output_video = self.output / input_path.name - self.processing_queue.put((input_path.absolute(), output_video.absolute())) + output_path = self.output / input_path.name + self.processing_queue.put((input_path.absolute(), output_path.absolute())) elif input_path.is_dir(): - for input_video in [f for f in input_path.iterdir() if f.is_file()]: - output_video = self.output / input_video.name - self.processing_queue.put((input_video.absolute(), output_video.absolute())) + for input_path in [f for f in input_path.iterdir() if f.is_file()]: + output_path = self.output / input_path.name + self.processing_queue.put((input_path.absolute(), output_path.absolute())) # if input specified is single file elif self.input.is_file(): - Avalon.info(_('Upscaling single video file: {}').format(self.input)) + Avalon.info(_('Upscaling single file: {}').format(self.input)) self.processing_queue.put((self.input.absolute(), self.output.absolute())) # if input specified is a directory @@ -426,99 +435,147 @@ class Upscaler: # make output directory if it doesn't exist self.output.mkdir(parents=True, exist_ok=True) - for input_video in [f for f in self.input.iterdir() if f.is_file()]: - output_video = self.output / input_video.name - self.processing_queue.put((input_video.absolute(), output_video.absolute())) + for input_path in [f for f in self.input.iterdir() if f.is_file()]: + output_path = self.output / input_path.name + self.processing_queue.put((input_path.absolute(), output_path.absolute())) - # record video count for external calls - self.total_videos = self.processing_queue.qsize() + # check argument sanity before running + self._check_arguments() - while not self.processing_queue.empty(): - self.current_input_video, output_video = self.processing_queue.get() - # drivers that have native support for video processing - if self.driver == 'anime4kcpp': - # append FFmpeg path to the end of PATH - # Anime4KCPP will then use FFmpeg to migrate audio tracks - os.environ['PATH'] += f';{self.ffmpeg_settings["ffmpeg_path"]}' - Avalon.info(_('Starting to upscale extracted images')) + # record file count for external calls + self.total_files = self.processing_queue.qsize() - # run Anime4KCPP - self.process_pool.append(self.driver_object.upscale(self.current_input_video, output_video)) - self._wait() - Avalon.info(_('Upscaling completed')) + try: + while not self.processing_queue.empty(): - else: - try: - self.create_temp_directories() + # reset current processing progress for new job + self.total_frames_upscaled = 0 + self.total_frames = 0 - # initialize objects for ffmpeg and waifu2x-caffe - fm = Ffmpeg(self.ffmpeg_settings, self.image_format) + # get new job from queue + self.current_input_file, output_path = self.processing_queue.get() - Avalon.info(_('Reading video information')) - video_info = fm.get_video_info(self.current_input_video) - # analyze original video with FFprobe and retrieve framerate - # width, height = info['streams'][0]['width'], info['streams'][0]['height'] + # get file type + input_file_mime_type = magic.from_file(str(self.current_input_file.absolute()), mime=True) + input_file_type = input_file_mime_type.split('/')[0] + input_file_subtype = input_file_mime_type.split('/')[1] - # find index of video stream - video_stream_index = None - for stream in video_info['streams']: - if stream['codec_type'] == 'video': - video_stream_index = stream['index'] - break - - # exit if no video stream found - if video_stream_index is None: - Avalon.error(_('Aborting: No video stream found')) - raise StreamNotFoundError('no video stream found') - - # extract frames from video - self.process_pool.append((fm.extract_frames(self.current_input_video, self.extracted_frames))) + # start handling input + # if input file is a static image + if input_file_type == 'image' and input_file_subtype != 'gif': + Avalon.info(_('Starting to upscale image')) + self.process_pool.append(self.driver_object.upscale(self.current_input_file, output_path)) self._wait() - - # get average frame rate of video stream - framerate = float(Fraction(video_info['streams'][video_stream_index]['r_frame_rate'])) - fm.pixel_format = video_info['streams'][video_stream_index]['pix_fmt'] - - if self.driver == 'waifu2x_caffe': - # get a dict of all pixel formats and corresponding bit depth - pixel_formats = fm.get_pixel_formats() - - # try getting pixel format's corresponding bti depth - try: - self.driver_settings['output_depth'] = pixel_formats[fm.pixel_format] - except KeyError: - Avalon.error(_('Unsupported pixel format: {}').format(fm.pixel_format)) - raise UnsupportedPixelError(f'unsupported pixel format {fm.pixel_format}') - - Avalon.info(_('Framerate: {}').format(framerate)) - - # width/height will be coded width/height x upscale factor - original_width = video_info['streams'][video_stream_index]['width'] - original_height = video_info['streams'][video_stream_index]['height'] - scale_width = int(self.scale_ratio * original_width) - scale_height = int(self.scale_ratio * original_height) - - # upscale images one by one using waifu2x - Avalon.info(_('Starting to upscale extracted images')) - self._upscale_frames() Avalon.info(_('Upscaling completed')) - # frames to Video - Avalon.info(_('Converting extracted frames into video')) + # static images don't require GIF or video encoding + # go to the next task + self.processing_queue.task_done() + self.total_processed += 1 + continue - # use user defined output size - self.process_pool.append(fm.assemble_video(framerate, - f'{scale_width}x{scale_height}', - self.upscaled_frames)) + # if input file is a image/gif file or a video + elif input_file_mime_type == 'image/gif' or input_file_type == 'video': + + # drivers that have native support for video processing + if input_file_type == 'video' and self.driver == 'anime4kcpp': + Avalon.info(_('Starting to upscale video with Anime4KCPP')) + # enable video processing mode for Anime4KCPP + self.driver_settings['videoMode'] = True + self.process_pool.append(self.driver_object.upscale(self.current_input_file, output_path)) + self._wait() + Avalon.info(_('Upscaling completed')) + + else: + self.create_temp_directories() + + # get video information JSON using FFprobe + Avalon.info(_('Reading video information')) + video_info = self.ffmpeg_object.probe_file_info(self.current_input_file) + # analyze original video with FFprobe and retrieve framerate + # width, height = info['streams'][0]['width'], info['streams'][0]['height'] + + # find index of video stream + video_stream_index = None + for stream in video_info['streams']: + if stream['codec_type'] == 'video': + video_stream_index = stream['index'] + break + + # exit if no video stream found + if video_stream_index is None: + Avalon.error(_('Aborting: No video stream found')) + raise StreamNotFoundError('no video stream found') + + # get average frame rate of video stream + framerate = float(Fraction(video_info['streams'][video_stream_index]['r_frame_rate'])) + # self.ffmpeg_object.pixel_format = video_info['streams'][video_stream_index]['pix_fmt'] + + # extract frames from video + self.process_pool.append((self.ffmpeg_object.extract_frames(self.current_input_file, self.extracted_frames))) + self._wait() + + # if driver is waifu2x-caffe + # pass pixel format output depth information + if self.driver == 'waifu2x_caffe': + # get a dict of all pixel formats and corresponding bit depth + pixel_formats = self.ffmpeg_object.get_pixel_formats() + + # try getting pixel format's corresponding bti depth + try: + self.driver_settings['output_depth'] = pixel_formats[self.ffmpeg_object.pixel_format] + except KeyError: + Avalon.error(_('Unsupported pixel format: {}').format(self.ffmpeg_object.pixel_format)) + raise UnsupportedPixelError(f'unsupported pixel format {self.ffmpeg_object.pixel_format}') + + Avalon.info(_('Framerate: {}').format(framerate)) + + # width/height will be coded width/height x upscale factor + # original_width = video_info['streams'][video_stream_index]['width'] + # original_height = video_info['streams'][video_stream_index]['height'] + # scale_width = int(self.scale_ratio * original_width) + # scale_height = int(self.scale_ratio * original_height) + + # upscale images one by one using waifu2x + Avalon.info(_('Starting to upscale extracted frames')) + self._upscale_frames() + Avalon.info(_('Upscaling completed')) + + # if file is none of: image, image/gif, video + # skip to the next task + else: + Avalon.error(_('File {} ({}) neither an image of a video').format(self.current_input_file, input_file_mime_type)) + Avalon.warning(_('Skipping this file')) + self.processing_queue.task_done() + self.total_processed += 1 + continue + + # start handling output + # output can be either GIF or video + + # if the desired output is gif file + if output_path.suffix.lower() == '.gif': + Avalon.info(_('Converting extracted frames into GIF image')) + gifski_object = Gifski(self.gifski_settings) + self.process_pool.append(gifski_object.make_gif(self.upscaled_frames, output_path, framerate, self.image_format)) + self._wait() + Avalon.info(_('Conversion completed')) + + # if the desired output is video + else: + # frames to video + Avalon.info(_('Converting extracted frames into video')) + self.process_pool.append(self.ffmpeg_object.assemble_video(framerate, self.upscaled_frames)) + # f'{scale_width}x{scale_height}', self._wait() Avalon.info(_('Conversion completed')) try: # migrate audio tracks and subtitles Avalon.info(_('Migrating audio, subtitles and other streams to upscaled video')) - self.process_pool.append(fm.migrate_streams(self.current_input_video, - output_video, - self.upscaled_frames)) + self.process_pool.append(self.ffmpeg_object.migrate_streams(self.current_input_file, + output_path, + self.upscaled_frames)) self._wait() # if failed to copy streams @@ -527,29 +584,32 @@ class Upscaler: Avalon.error(_('Failed to migrate streams')) Avalon.warning(_('Trying to output video without additional streams')) - # construct output file path - output_video_path = output_video.parent / f'{output_video.stem}{fm.intermediate_file_name.suffix}' + if input_file_mime_type == 'image/gif': + (self.upscaled_frames / self.ffmpeg_object.intermediate_file_name).replace(output_path) - # if output file already exists, cancel - if output_video_path.exists(): - Avalon.error(_('Output video file exists, aborting')) - - # otherwise, rename intermediate file to the output file else: - Avalon.info(_('Writing intermediate file to: {}').format(output_video_path.absolute())) - (self.upscaled_frames / fm.intermediate_file_name).rename(output_video_path) + # construct output file path + output_video_path = output_path.parent / f'{output_path.stem}{self.ffmpeg_object.intermediate_file_name.suffix}' - # destroy temp directories - self.cleanup_temp_directories() + # if output file already exists, cancel + if output_video_path.exists(): + Avalon.error(_('Output video file exists, aborting')) - except (Exception, KeyboardInterrupt, SystemExit) as e: - with contextlib.suppress(ValueError): - self.cleanup_temp_directories() - self.running = False - raise e + # otherwise, rename intermediate file to the output file + else: + Avalon.info(_('Writing intermediate file to: {}').format(output_video_path.absolute())) + (self.upscaled_frames / self.ffmpeg_object.intermediate_file_name).rename(output_video_path) - # increment total number of videos processed - self.total_processed += 1 + # increment total number of files processed + self.cleanup_temp_directories() + self.processing_queue.task_done() + self.total_processed += 1 + + except (Exception, KeyboardInterrupt, SystemExit) as e: + with contextlib.suppress(ValueError): + self.cleanup_temp_directories() + self.running = False + raise e # signal upscaling completion self.running = False diff --git a/src/video2x.py b/src/video2x.py index b89cb71..f1457c8 100755 --- a/src/video2x.py +++ b/src/video2x.py @@ -13,7 +13,7 @@ __ __ _ _ ___ __ __ Name: Video2X Controller Creator: K4YT3X Date Created: Feb 24, 2018 -Last Modified: May 10, 2020 +Last Modified: May 11, 2020 Editor: BrianPetkovsek Last Modified: June 17, 2019 @@ -181,6 +181,10 @@ driver_settings['path'] = os.path.expandvars(driver_settings['path']) ffmpeg_settings = config['ffmpeg'] ffmpeg_settings['ffmpeg_path'] = os.path.expandvars(ffmpeg_settings['ffmpeg_path']) +# read Gifski configuration +gifski_settings = config['gifski'] +gifski_settings['gifski_path'] = os.path.expandvars(gifski_settings['gifski_path']) + # load video2x settings image_format = config['video2x']['image_format'].lower() preserve_frames = config['video2x']['preserve_frames'] @@ -213,7 +217,8 @@ try: upscaler = Upscaler(input_path=video2x_args.input, output_path=video2x_args.output, driver_settings=driver_settings, - ffmpeg_settings=ffmpeg_settings) + ffmpeg_settings=ffmpeg_settings, + gifski_settings=gifski_settings) # set upscaler optional options upscaler.driver = video2x_args.driver diff --git a/src/video2x.yaml b/src/video2x.yaml index 4919e5e..023309e 100644 --- a/src/video2x.yaml +++ b/src/video2x.yaml @@ -1,7 +1,7 @@ # Name: Video2X Configuration File # Creator: K4YT3X # Date Created: October 23, 2018 -# Last Modified: May 9, 2020 +# Last Modified: May 11, 2020 # Values here are the default values. Change the value here to # save the default value permanently. # Items commented out are parameters irrelevant to this context @@ -87,7 +87,7 @@ anime4kcpp: zoomFactor: 2.0 # zoom factor for resizing (double [=2]) threads: 16 # Threads count for video processing (unsigned int [=16]) fastMode: false # Faster but maybe low quality - videoMode: true # Video process + videoMode: false # Video process preview: null # Preview image preprocessing: False # Enable pre processing postprocessing: False # Enable post processing @@ -101,9 +101,9 @@ anime4kcpp: ffmpeg: ffmpeg_path: '%LOCALAPPDATA%\video2x\ffmpeg-latest-win64-static\bin' intermediate_file_name: 'intermediate.mkv' - # step 1: extract all frames from original video + # step 1: extract all frames from original input # into temporary directory - video_to_frames: + input_to_frames: output_options: '-qscale:v': null '-pix_fmt': rgba64be @@ -138,6 +138,17 @@ ffmpeg: '-metadata': 'comment=Upscaled by Video2X' '-hwaccel': auto '-y': true +gifski: + gifski_path: '%LOCALAPPDATA%\video2x\gifski\win\gifski' + # output: null # Destination file to write to + # fps: 20 # Animation frames per second (for PNG frames only) [default: 20] + fast: false # 3 times faster encoding, but 10% lower quality and bigger file + quality: 100 # Lower quality may give smaller file + width: null # Maximum width + height: null # Maximum height (if width is also set) + once: false # Do not loop the GIF + nosort: false # Use files exactly in the order given, rather than sorted + quiet: false # Do not show a progress bar video2x: video2x_cache_directory: null # default: %TEMP%\video2x image_format: png diff --git a/src/video2x_gui.py b/src/video2x_gui.py index 97f9b12..97fe8ff 100755 --- a/src/video2x_gui.py +++ b/src/video2x_gui.py @@ -4,7 +4,7 @@ Creator: Video2X GUI Author: K4YT3X Date Created: May 5, 2020 -Last Modified: May 10, 2020 +Last Modified: May 11, 2020 """ # local imports @@ -25,6 +25,7 @@ import yaml from PyQt5 import QtGui, uic from PyQt5.QtCore import * from PyQt5.QtWidgets import * +import magic # QObject, pyqtSlot, pyqtSignal, QRunnable, QThreadPool, QAbstractTableModel, Qt VERSION = '2.0.0' @@ -110,13 +111,34 @@ class InputTableModel(QAbstractTableModel): def data(self, index, role): if role == Qt.DisplayRole: + file_path = self._data[index.row()] + if index.column() == 0: - return str(self._data[index.row()].absolute()) + return str(file_path.absolute()) else: - if self._data[index.row()].is_file(): - return 'File' - elif self._data[index.row()].is_dir(): + + # determine file type + # if path is a folder + if file_path.is_dir(): return 'Folder' + + # if path is single file + # determine file type + elif file_path.is_file(): + input_file_mime_type = magic.from_file(str(file_path.absolute()), mime=True) + input_file_type = input_file_mime_type.split('/')[0] + input_file_subtype = input_file_mime_type.split('/')[1] + if input_file_type == 'image': + if input_file_subtype == 'gif': + return 'GIF' + return 'Image' + + elif input_file_type == 'video': + return 'Video' + + else: + return 'Unknown' + else: return 'Unknown' @@ -373,6 +395,10 @@ class Video2XMainWindow(QMainWindow): self.ffmpeg_settings = self.config['ffmpeg'] self.ffmpeg_settings['ffmpeg_path'] = str(pathlib.Path(os.path.expandvars(self.ffmpeg_settings['ffmpeg_path'])).absolute()) + # read Gifski configuration + self.gifski_settings = self.config['gifski'] + self.gifski_settings['gifski_path'] = str(pathlib.Path(os.path.expandvars(self.gifski_settings['gifski_path'])).absolute()) + # set cache directory path if self.config['video2x']['video2x_cache_directory'] is None: self.config['video2x']['video2x_cache_directory'] = str((pathlib.Path(tempfile.gettempdir()) / 'video2x').absolute()) @@ -585,7 +611,34 @@ class Video2XMainWindow(QMainWindow): return if input_path.is_file(): - output_path = input_path.parent / f'{input_path.stem}_output.mp4' + + # generate suffix automatically + input_file_mime_type = magic.from_file(str(input_path.absolute()), mime=True) + input_file_type = input_file_mime_type.split('/')[0] + input_file_subtype = input_file_mime_type.split('/')[1] + + # if input file is an image + if input_file_type == 'image': + + # if file is a gif, use .gif + if input_file_subtype == 'gif': + suffix = '.gif' + + # otherwise, use .png by default for all images + else: + suffix = '.png' + + # if input is video, use .mp4 as output by default + elif input_file_type == 'video': + suffix = '.mp4' + + # if failed to detect file type + # use input file's suffix + else: + suffix = input_path.suffix + + output_path = input_path.parent / f'{input_path.stem}_output{suffix}' + elif input_path.is_dir(): output_path = input_path.parent / f'{input_path.stem}_output' @@ -593,7 +646,7 @@ class Video2XMainWindow(QMainWindow): output_path_id = 0 while output_path.exists() and output_path_id <= 1000: if input_path.is_file(): - output_path = input_path.parent / pathlib.Path(f'{input_path.stem}_output_{output_path_id}.mp4') + output_path = input_path.parent / pathlib.Path(f'{input_path.stem}_output_{output_path_id}{suffix}') elif input_path.is_dir(): output_path = input_path.parent / pathlib.Path(f'{input_path.stem}_output_{output_path_id}') output_path_id += 1 @@ -693,8 +746,8 @@ You can [submit an issue on GitHub](https://github.com/k4yt3x/video2x/issues/new self.upscaler.total_frames_upscaled, self.upscaler.total_frames, self.upscaler.total_processed, - self.upscaler.total_videos, - self.upscaler.current_input_video, + self.upscaler.total_files, + self.upscaler.current_input_file, self.upscaler.last_frame_upscaled)) time.sleep(1) @@ -707,8 +760,8 @@ You can [submit an issue on GitHub](https://github.com/k4yt3x/video2x/issues/new total_frames_upscaled = progress_information[1] total_frames = progress_information[2] total_processed = progress_information[3] - total_videos = progress_information[4] - current_input_video = progress_information[5] + total_files = progress_information[4] + current_input_file = progress_information[5] last_frame_upscaled = progress_information[6] # calculate fields based on frames and time elapsed @@ -727,10 +780,10 @@ You can [submit an issue on GitHub](https://github.com/k4yt3x/video2x/issues/new self.time_elapsed_label.setText('Time Elapsed: {}'.format(time.strftime("%H:%M:%S", time.gmtime(time_elapsed)))) self.time_remaining_label.setText('Time Remaining: {}'.format(time.strftime("%H:%M:%S", time.gmtime(time_remaining)))) self.rate_label.setText('Rate (FPS): {}'.format(round(rate, 2))) - self.overall_progress_label.setText('Overall Progress: {}/{}'.format(total_processed, total_videos)) - self.overall_progress_bar.setMaximum(total_videos) + self.overall_progress_label.setText('Overall Progress: {}/{}'.format(total_processed, total_files)) + self.overall_progress_bar.setMaximum(total_files) self.overall_progress_bar.setValue(total_processed) - self.currently_processing_label.setText('Currently Processing: {}'.format(str(current_input_video.name))) + self.currently_processing_label.setText('Currently Processing: {}'.format(str(current_input_file.name))) # if show frame is checked, show preview image if self.frame_preview_show_preview_check_box.isChecked() and last_frame_upscaled.is_file(): @@ -798,7 +851,8 @@ You can [submit an issue on GitHub](https://github.com/k4yt3x/video2x/issues/new self.upscaler = Upscaler(input_path=input_directory, output_path=output_directory, driver_settings=self.driver_settings, - ffmpeg_settings=self.ffmpeg_settings) + ffmpeg_settings=self.ffmpeg_settings, + gifski_settings=self.gifski_settings) # set optional options self.upscaler.driver = AVAILABLE_DRIVERS[self.driver_combo_box.currentText()] diff --git a/src/video2x_setup.py b/src/video2x_setup.py index 999203c..f0b2399 100755 --- a/src/video2x_setup.py +++ b/src/video2x_setup.py @@ -31,6 +31,7 @@ import re import shutil import subprocess import sys +import tarfile import tempfile import time import traceback @@ -42,12 +43,19 @@ import zipfile # later in the script. # import requests -VERSION = '1.8.0' +VERSION = '2.0.0' # global static variables LOCALAPPDATA = pathlib.Path(os.getenv('localappdata')) VIDEO2X_CONFIG = pathlib.Path(__file__).parent.absolute() / 'video2x.yaml' -DRIVER_OPTIONS = ['all', 'ffmpeg', 'waifu2x_caffe', 'waifu2x_converter_cpp', 'waifu2x_ncnn_vulkan', 'anime4kcpp', 'srmd_ncnn_vulkan'] +DRIVER_OPTIONS = ['all', + 'ffmpeg', + 'gifski', + 'waifu2x_caffe', + 'waifu2x_converter_cpp', + 'waifu2x_ncnn_vulkan', + 'anime4kcpp', + 'srmd_ncnn_vulkan'] def parse_arguments(): @@ -76,32 +84,21 @@ class Video2xSetup: self.trash = [] def run(self): + # regardless of which driver to install + # always ensure Python modules are installed and up-to-date if self.download_python_modules: print('\nInstalling Python libraries') self._install_python_requirements() + # if all drivers are to be installed if self.driver == 'all': - self._install_ffmpeg() - self._install_waifu2x_caffe() - self._install_waifu2x_converter_cpp() - self._install_waifu2x_ncnn_vulkan() - self._install_anime4kcpp() - self._install_srmd_ncnn_vulkan() - elif self.driver == 'ffmpeg': - self._install_ffmpeg() - elif self.driver == 'waifu2x_caffe': - self._install_waifu2x_caffe() - elif self.driver == 'waifu2x_converter_cpp': - self._install_waifu2x_converter_cpp() - elif self.driver == 'waifu2x_ncnn_vulkan': - self._install_waifu2x_ncnn_vulkan() - elif self.driver == 'anime4kcpp': - self._install_anime4kcpp() - elif self.driver == 'srmd_ncnn_vulkan': - self._install_srmd_ncnn_vulkan() + DRIVER_OPTIONS.remove('all') + for driver in DRIVER_OPTIONS: + getattr(self, f'_install_{driver}')() - print('\nGenerating Video2X configuration file') - self._generate_config() + # install only the selected driver + else: + getattr(self, f'_install_{self.driver}')() print('\nCleaning up temporary files') self._cleanup() @@ -139,6 +136,22 @@ class Video2xSetup: with zipfile.ZipFile(ffmpeg_zip) as zipf: zipf.extractall(LOCALAPPDATA / 'video2x') + def _install_gifski(self): + print('\nInstalling Gifski') + import requests + + # Get latest release of waifu2x-ncnn-vulkan via Github API + latest_release = requests.get('https://api.github.com/repos/ImageOptim/gifski/releases/latest').json() + + for a in latest_release['assets']: + if re.search(r'gifski-.*\.tar\.xz', a['browser_download_url']): + gifski_tar_gz = download(a['browser_download_url'], tempfile.gettempdir()) + self.trash.append(gifski_tar_gz) + + # extract and rename + with tarfile.open(gifski_tar_gz) as archive: + archive.extractall(LOCALAPPDATA / 'video2x' / 'gifski') + def _install_waifu2x_caffe(self): """ Install waifu2x_caffe """ @@ -248,42 +261,6 @@ class Video2xSetup: # rename the newly extracted directory (LOCALAPPDATA / 'video2x' / zipf.namelist()[0]).rename(srmd_ncnn_vulkan_directory) - def _generate_config(self): - """ Generate video2x config - """ - import yaml - - # open current video2x configuration file as template - with open(VIDEO2X_CONFIG, 'r') as template: - template_dict = yaml.load(template, Loader=yaml.FullLoader) - template.close() - - # configure only the specified drivers - if self.driver == 'all': - template_dict['waifu2x_caffe']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-caffe' / 'waifu2x-caffe-cui') - template_dict['waifu2x_converter_cpp']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-converter-cpp' / 'waifu2x-converter-cpp') - template_dict['waifu2x_ncnn_vulkan']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-ncnn-vulkan' / 'waifu2x-ncnn-vulkan') - template_dict['srmd_ncnn_vulkan']['path'] = str(LOCALAPPDATA / 'video2x' / 'srmd-ncnn-vulkan' / 'srmd-ncnn-vulkan') - template_dict['anime4kcpp']['path'] = str(LOCALAPPDATA / 'video2x' / 'anime4kcpp' / 'CLI' / 'Anime4KCPP_CLI' / 'Anime4KCPP_CLI') - elif self.driver == 'waifu2x_caffe': - template_dict['waifu2x_caffe']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-caffe' / 'waifu2x-caffe-cui') - elif self.driver == 'waifu2x_converter_cpp': - template_dict['waifu2x_converter_cpp']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-converter-cpp' / 'waifu2x-converter-cpp') - elif self.driver == 'waifu2x_ncnn_vulkan': - template_dict['waifu2x_ncnn_vulkan']['path'] = str(LOCALAPPDATA / 'video2x' / 'waifu2x-ncnn-vulkan' / 'waifu2x-ncnn-vulkan') - elif self.driver == 'srmd_ncnn_vulkan': - template_dict['srmd_ncnn_vulkan']['path'] = str(LOCALAPPDATA / 'video2x' / 'srmd-ncnn-vulkan' / 'srmd-ncnn-vulkan') - elif self.driver == 'anime4kcpp': - template_dict['anime4kcpp']['path'] = str(LOCALAPPDATA / 'video2x' / 'anime4kcpp' / 'CLI' / 'Anime4KCPP_CLI' / 'Anime4KCPP_CLI') - - template_dict['ffmpeg']['ffmpeg_path'] = str(LOCALAPPDATA / 'video2x' / 'ffmpeg-latest-win64-static' / 'bin') - template_dict['video2x']['video2x_cache_directory'] = None - template_dict['video2x']['preserve_frames'] = False - - # write configuration into file - with open(VIDEO2X_CONFIG, 'w') as config: - yaml.dump(template_dict, config) - def download(url, save_path, chunk_size=4096): """ Download file to local with requests library diff --git a/src/wrappers/anime4kcpp.py b/src/wrappers/anime4kcpp.py index 3f482d4..e435fab 100644 --- a/src/wrappers/anime4kcpp.py +++ b/src/wrappers/anime4kcpp.py @@ -69,6 +69,10 @@ class WrapperMain: self.driver_settings['zoomFactor'] = upscaler.scale_ratio self.driver_settings['threads'] = upscaler.processes + # append FFmpeg path to the end of PATH + # Anime4KCPP will then use FFmpeg to migrate audio tracks + os.environ['PATH'] += f';{upscaler.ffmpeg_settings["ffmpeg_path"]}' + def upscale(self, input_file, output_file): """This is the core function for WAIFU2X class @@ -90,14 +94,14 @@ class WrapperMain: # list to be executed # initialize the list with waifu2x binary path as the first element - execute = [self.driver_settings.pop('path')] + execute = [self.driver_settings['path']] for key in self.driver_settings.keys(): value = self.driver_settings[key] # null or None means that leave this option out (keep default) - if value is None or value is False: + if key == 'path' or value is None or value is False: continue else: if len(key) == 1: diff --git a/src/wrappers/ffmpeg.py b/src/wrappers/ffmpeg.py index f1bac56..fadcc7d 100644 --- a/src/wrappers/ffmpeg.py +++ b/src/wrappers/ffmpeg.py @@ -12,6 +12,7 @@ Description: This class handles all FFmpeg related operations. # built-in imports import json import pathlib +import shlex import subprocess # third-party imports @@ -36,7 +37,7 @@ class Ffmpeg: # video metadata self.image_format = image_format self.intermediate_file_name = pathlib.Path(self.ffmpeg_settings['intermediate_file_name']) - self.pixel_format = None + self.pixel_format = self.ffmpeg_settings['input_to_frames']['output_options']['-pix_fmt'] def get_pixel_formats(self): """ Get a dictionary of supported pixel formats @@ -49,8 +50,8 @@ class Ffmpeg: """ execute = [ self.ffmpeg_probe_binary, - '-v', - 'quiet', + # '-v', + # 'quiet', '-pix_fmts' ] @@ -74,7 +75,7 @@ class Ffmpeg: return pixel_formats - def get_video_info(self, input_video): + def probe_file_info(self, input_video): """ Gets input video information This method reads input video information @@ -104,31 +105,25 @@ class Ffmpeg: # turn elements into str execute = [str(e) for e in execute] - Avalon.debug_info(f'Executing: {" ".join(execute)}') + Avalon.debug_info(f'Executing: {shlex.join(execute)}') json_str = subprocess.run(execute, check=True, stdout=subprocess.PIPE).stdout return json.loads(json_str.decode('utf-8')) - def extract_frames(self, input_video, extracted_frames): - """Extract every frame from original videos - - This method extracts every frame from input video using FFmpeg - - Arguments: - input_video {string} -- input video path - extracted_frames {string} -- video output directory + def extract_frames(self, input_file, extracted_frames): + """ extract frames from video or GIF file """ execute = [ self.ffmpeg_binary ] - execute.extend(self._read_configuration(phase='video_to_frames')) + execute.extend(self._read_configuration(phase='input_to_frames')) execute.extend([ '-i', - input_video + input_file ]) - execute.extend(self._read_configuration(phase='video_to_frames', section='output_options')) + execute.extend(self._read_configuration(phase='input_to_frames', section='output_options')) execute.extend([ extracted_frames / f'extracted_%0d.{self.image_format}' @@ -136,7 +131,7 @@ class Ffmpeg: return(self._execute(execute)) - def assemble_video(self, framerate, resolution, upscaled_frames): + def assemble_video(self, framerate, upscaled_frames): """Converts images into videos This method converts a set of images into a video @@ -149,9 +144,9 @@ class Ffmpeg: execute = [ self.ffmpeg_binary, '-r', - str(framerate), - '-s', - resolution + str(framerate) + # '-s', + # resolution ] # read other options @@ -274,17 +269,9 @@ class Ffmpeg: return configuration def _execute(self, execute): - """ execute command - - Arguments: - execute {list} -- list of arguments to be executed - - Returns: - int -- execution return code - """ # turn all list elements into string to avoid errors execute = [str(e) for e in execute] - Avalon.debug_info(f'Executing: {execute}') + Avalon.debug_info(f'Executing: {shlex.join(execute)}') return subprocess.Popen(execute) diff --git a/src/wrappers/gifski.py b/src/wrappers/gifski.py new file mode 100644 index 0000000..8d7d22c --- /dev/null +++ b/src/wrappers/gifski.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Name: Gifski Wrapper +Creator: K4YT3X +Date Created: May 11, 2020 +Last Modified: May 11, 2020 + +Description: High-level wrapper for Gifski. +""" + +# built-in imports +import pathlib +import subprocess + +# third-party imports +from avalon_framework import Avalon + + +class Gifski: + + def __init__(self, gifski_settings): + self.gifski_settings = gifski_settings + + def make_gif(self, upscaled_frames: pathlib.Path, output_path: pathlib.Path, framerate: float, image_format: str) -> subprocess.Popen: + execute = [ + self.gifski_settings['gifski_path'], + '-o', + output_path, + '--fps', + int(round(framerate, 0)) + ] + + # load configurations from config file + execute.extend(self._load_configuration()) + + # append frames location + execute.extend([upscaled_frames / f'extracted_*.{image_format}']) + + return(self._execute(execute)) + + def _load_configuration(self): + + configuration = [] + + for key in self.gifski_settings.keys(): + + value = self.gifski_settings[key] + + # null or None means that leave this option out (keep default) + if key == 'gifski_path' or value is None or value is False: + continue + else: + if len(key) == 1: + configuration.append(f'-{key}') + else: + configuration.append(f'--{key}') + + # true means key is an option + if value is not True: + configuration.append(str(value)) + return configuration + + def _execute(self, execute: list) -> subprocess.Popen: + # turn all list elements into string to avoid errors + execute = [str(e) for e in execute] + + Avalon.debug_info(f'Executing: {execute}') + + return subprocess.Popen(execute) diff --git a/src/wrappers/srmd_ncnn_vulkan.py b/src/wrappers/srmd_ncnn_vulkan.py index f04e1cd..8fe6749 100644 --- a/src/wrappers/srmd_ncnn_vulkan.py +++ b/src/wrappers/srmd_ncnn_vulkan.py @@ -77,14 +77,14 @@ class WrapperMain: # list to be executed # initialize the list with the binary path as the first element - execute = [self.driver_settings.pop('path')] + execute = [self.driver_settings['path']] for key in self.driver_settings.keys(): value = self.driver_settings[key] # null or None means that leave this option out (keep default) - if value is None or value is False: + if key == 'path' or value is None or value is False: continue else: if len(key) == 1: diff --git a/src/wrappers/waifu2x_caffe.py b/src/wrappers/waifu2x_caffe.py index 67ce81a..9a7aec2 100644 --- a/src/wrappers/waifu2x_caffe.py +++ b/src/wrappers/waifu2x_caffe.py @@ -56,8 +56,8 @@ class WrapperMain: parser.add_argument('-m', '--mode', choices=['noise', 'scale', 'noise_scale', 'auto_scale'], help='image processing mode') parser.add_argument('-e', '--output_extention', type=str, help='extention to output image file when output_path is (auto) or input_path is folder') parser.add_argument('-l', '--input_extention_list', type=str, help='extention to input image file when input_path is folder') - parser.add_argument('-o', '--output', type=str, help=argparse.SUPPRESS) # help='path to output image file (when input_path is folder, output_path must be folder)') - parser.add_argument('-i', '--input_file', type=str, help=argparse.SUPPRESS) # help='(required) path to input image file') + parser.add_argument('-o', '--output_path', type=str, help=argparse.SUPPRESS) # help='path to output image file (when input_path is folder, output_path must be folder)') + parser.add_argument('-i', '--input_path', type=str, help=argparse.SUPPRESS) # help='(required) path to input image file') return parser.parse_args(arguments) def load_configurations(self, upscaler): @@ -79,14 +79,14 @@ class WrapperMain: # list to be executed # initialize the list with waifu2x binary path as the first element - execute = [self.driver_settings.pop('path')] + execute = [self.driver_settings['path']] for key in self.driver_settings.keys(): value = self.driver_settings[key] # null or None means that leave this option out (keep default) - if value is None or value is False: + if key == 'path' or value is None or value is False: continue else: if len(key) == 1: diff --git a/src/wrappers/waifu2x_converter_cpp.py b/src/wrappers/waifu2x_converter_cpp.py index f53b796..ed87c8b 100644 --- a/src/wrappers/waifu2x_converter_cpp.py +++ b/src/wrappers/waifu2x_converter_cpp.py @@ -93,14 +93,14 @@ class WrapperMain: # list to be executed # initialize the list with waifu2x binary path as the first element - execute = [self.driver_settings.pop('path')] + execute = [self.driver_settings['path']] for key in self.driver_settings.keys(): value = self.driver_settings[key] # null or None means that leave this option out (keep default) - if value is None or value is False: + if key == 'path' or value is None or value is False: continue else: if len(key) == 1: diff --git a/src/wrappers/waifu2x_ncnn_vulkan.py b/src/wrappers/waifu2x_ncnn_vulkan.py index eac68ff..ac32ab7 100644 --- a/src/wrappers/waifu2x_ncnn_vulkan.py +++ b/src/wrappers/waifu2x_ncnn_vulkan.py @@ -80,14 +80,14 @@ class WrapperMain: # list to be executed # initialize the list with waifu2x binary path as the first element - execute = [self.driver_settings.pop('path')] + execute = [self.driver_settings['path']] for key in self.driver_settings.keys(): value = self.driver_settings[key] # null or None means that leave this option out (keep default) - if value is None or value is False: + if key == 'path' or value is None or value is False: continue else: if len(key) == 1: