From 7a1acfc4d6321180773b407d9946da3b6e1041ab Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:45:06 +0200 Subject: [PATCH 01/25] new version, add graph, new metric assessment PSNR SSIM --- pertitleanalysis/crf_analyzer.py | 37 ++ pertitleanalysis/metric_analyzer.py | 28 ++ pertitleanalysis/per_title_analysis.py | 472 ++++++++++++------------- pertitleanalysis/task_providers.py | 22 +- 4 files changed, 302 insertions(+), 257 deletions(-) create mode 100644 pertitleanalysis/crf_analyzer.py create mode 100644 pertitleanalysis/metric_analyzer.py diff --git a/pertitleanalysis/crf_analyzer.py b/pertitleanalysis/crf_analyzer.py new file mode 100644 index 0000000..21585ba --- /dev/null +++ b/pertitleanalysis/crf_analyzer.py @@ -0,0 +1,37 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys + +path=str(sys.argv[1]) +print ("\nfile=",path) +crf_value=str(sys.argv[2]) +print("crf = ",crf_value) +number_of_parts=int(sys.argv[3]) +print("number_of_parts=",number_of_parts) +model=str(sys.argv[4]) +print("model value 1 for True (linear), 0 for False (for each):", model, "\n\n") + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.CrfAnalyzer(path, LADDER) + +# Launch various analysis (here crf) +if model==1: #model = linear (True) or for each (False) + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, True) +else: + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, False) + ANALYSIS.process(number_of_parts, 1280, 720, crf_value, 2, False) + ANALYSIS.process(number_of_parts, 960, 540, crf_value, 2, False) + ANALYSIS.process(number_of_parts, 640, 360, crf_value, 2, False) + ANALYSIS.process(number_of_parts, 480, 270, crf_value, 2, False) diff --git a/pertitleanalysis/metric_analyzer.py b/pertitleanalysis/metric_analyzer.py new file mode 100644 index 0000000..ba24be3 --- /dev/null +++ b/pertitleanalysis/metric_analyzer.py @@ -0,0 +1,28 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys + +path=str(sys.argv[1]) +metric=str(sys.argv[2]) +limit_metric_value=float(sys.argv[3]) +print('\nmetric:', metric) +print('limit metric =', limit_metric_value) + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.MetricAnalyzer(path, LADDER) + +# Launch various analysis (here ssim or psnr) + #(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required) +ANALYSIS.process(metric, limit_metric_value, 4000000, 2, True) diff --git a/pertitleanalysis/per_title_analysis.py b/pertitleanalysis/per_title_analysis.py index 7673b1a..967854d 100755 --- a/pertitleanalysis/per_title_analysis.py +++ b/pertitleanalysis/per_title_analysis.py @@ -1,138 +1,59 @@ # -*- coding: utf-8 -*- +#importation + from __future__ import division +from pylab import * +import matplotlib.pyplot as plt +import matplotlib.animation as animation +import sys import os import json import datetime import statistics -from .task_providers import Probe, CrfEncode, CbrEncode, Metric +from task_providers import Probe, CrfEncode, CbrEncode, Metric + +#class_1_création des profiles d'encodage ('ici on travaille à l'échelle d'un profil d'encodage) class EncodingProfile(object): - """This class defines an encoding profile""" - - def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required): - """EncodingProfile initialization - - :param width: Video profile width - :type width: int - :param height: Video profile height - :type height: int - :param bitrate_default: Video profile bitrate default (in bits per second) - :type bitrate_default: int - :param bitrate_min: Video profile bitrate min constraint (in bits per second) - :type bitrate_min: int - :param bitrate_max: Video profile bitrate max constraint (in bits per second) - :type bitrate_max: int - :param required: The video profile is required and cannot be removed from the optimized encoding ladder - :type required: bool - """ - - if width is None: - raise ValueError('The EncodingProfile.width value is required') - else: - self.width = int(width) - if height is None: - raise ValueError('The EncodingProfile.height value is required') - else: - self.height = int(height) + def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_by_default): + #call: PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True, 150000)) - if bitrate_default is None: - raise ValueError('The EncodingProfile.bitrate_default value is required') - else: + self.width = int(width) + self.height = int(height) self.bitrate_default = int(bitrate_default) - - if int(bitrate_min) <= self.bitrate_default: self.bitrate_min = int(bitrate_min) - else: - self.bitrate_min = self.bitrate_default - - if int(bitrate_max) >= self.bitrate_default: self.bitrate_max = int(bitrate_max) - else: - self.bitrate_max = self.bitrate_default - - if required is not None: - self.required = required - else: - self.required = True - - self.bitrate_factor = None - - def __str__(self): - """Display the encoding profile informations - - :return: human readable string describing an encoding profil object - :rtype: str - """ - return "{}x{}, bitrate_default={}, bitrate_min={}, bitrate_max={}, bitrate_factor={}, required={}".format(self.width, self.height, self.bitrate_default, self.bitrate_min, self.bitrate_max, self.bitrate_factor, self.required) - - def get_json(self): - """Return object details in json - - :return: json object describing the encoding profile and the configured constraints - :rtype: str - """ - profile = {} - profile['width'] = self.width - profile['height'] = self.height - profile['bitrate'] = self.bitrate_default - profile['constraints'] = {} - profile['constraints']['bitrate_min'] = self.bitrate_min - profile['constraints']['bitrate_max'] = self.bitrate_max - profile['constraints']['bitrate_factor'] = self.bitrate_factor - profile['constraints']['required'] = self.required - return json.dumps(profile) - - def set_bitrate_factor(self, ladder_max_bitrate): - """Set the bitrate factor from the max bitrate in the encoding ladder""" + self.bitrate_factor = None + self.bitrate_steps_by_default = int(bitrate_steps_by_default) + if required is not None: + self.required = required + else: + self.required = True + + def set_bitrate_factor(self, ladder_max_bitrate): #fonction utilisée dans le calculate_bitrate_factors self.bitrate_factor = ladder_max_bitrate/self.bitrate_default + #print('the bitrate factor for ',self.width, 'x', self.height, 'is:', self.bitrate_factor) + + +#class_2_création de l'échelle des profiles d'encodage (ici on travaille à l'échelle de la liste d'ensemble des profiles) class EncodingLadder(object): - """This class defines an over-the-top encoding ladder template""" def __init__(self, encoding_profile_list): - """EncodingLadder initialization + #call: LADDER = pta.EncodingLadder(PROFILE_LIST) - :param encoding_profile_list: A list of multiple encoding profiles - :type encoding_profile_list: per_title.EncodingProfile[] - """ self.encoding_profile_list = encoding_profile_list self.calculate_bitrate_factors() - - def __str__(self): - """Display the encoding ladder informations - - :return: human readable string describing the encoding ladder template - :rtype: str - """ - string = "{} encoding profiles\n".format(len(self.encoding_profile_list)) - for encoding_profile in self.encoding_profile_list: - string += str(encoding_profile) + "\n" - return string - - def get_json(self): - """Return object details in json - - :return: json object describing the encoding ladder template - :rtype: str - """ - ladder = {} - ladder['overall_bitrate_ladder'] = self.get_overall_bitrate() - ladder['encoding_profiles'] = [] - for encoding_profile in self.encoding_profile_list: - ladder['encoding_profiles'].append(json.loads(encoding_profile.get_json())) - return json.dumps(ladder) + #pour calculer le bitrate_factor on a besoin d'abord d'obtenir le bitrate_default max de la liste encoding_profile_list, ensuite on applique la fonction set_bitrate factor qui + #pour chaque profile va déterminer le facteur par une simple division. def get_max_bitrate(self): - """Get the max bitrate in the ladder - - :return: The maximum bitrate into the encoding laddder template - :rtype: int - """ + #parcours la liste des débits dispo et ressort le max parmi les bitrate default !! ladder_max_bitrate = 0 for encoding_profile in self.encoding_profile_list: if encoding_profile.bitrate_default > ladder_max_bitrate: @@ -140,34 +61,26 @@ def get_max_bitrate(self): return ladder_max_bitrate def get_overall_bitrate(self): - """Get the overall bitrate for the ladder - :return: The sum of all bitrate profiles into the encoding laddder template - :rtype: int - """ ladder_overall_bitrate = 0 for encoding_profile in self.encoding_profile_list: ladder_overall_bitrate += encoding_profile.bitrate_default return ladder_overall_bitrate - def calculate_bitrate_factors(self): - """Calculate the bitrate factor for each profile""" + def calculate_bitrate_factors(self): #cf plus haut ! + ladder_max_bitrate = self.get_max_bitrate() for encoding_profile in self.encoding_profile_list: encoding_profile.set_bitrate_factor(ladder_max_bitrate) +#class_3_préparation des opérations d'analyse, initialisation des variables + class Analyzer(object): - """This class defines a Per-Title Analyzer""" def __init__(self, input_file_path, encoding_ladder): - """Analyzer initialization - :param input_file_path: The input video file path - :type input_file_path: str - :param encoding_ladder: An EncodingLadder object - :type encoding_ladder: per_title.EncodingLadder - """ + self.input_file_path = input_file_path self.encoding_ladder = encoding_ladder @@ -176,79 +89,70 @@ def __init__(self, input_file_path, encoding_ladder): self.optimal_bitrate = None self.peak_bitrate = None - # init json result - self.json = {} - self.json['input_file_path'] = self.input_file_path - self.json['template_encoding_ladder'] = json.loads(self.encoding_ladder.get_json()) - self.json['analyses'] = [] - def __str__(self): - """Display the per title analysis informations - :return: human readable string describing all analyzer configuration - :rtype: str - """ - string = "Per-Title Analysis for: {}\n".format(self.input_file_path) - string += str(self.encoding_ladder) - return string +#class_4_analyse du débit oprimal pour une qualité constante définie, le number of part désigne le nombre de découpage réalisé sur la source pour pouvoir trouver au mieux pour optimiser le débit sur des cas complexes. - def get_json(self): - """Return object details in json +class CrfAnalyzer(Analyzer): + #cette classe s'appuie grandement sur la classe prècèdente donc pas d'init !!! elle est appellé dans le fichier test.py juste deux fois - :return: json object describing all inputs configuration and output analyses - :rtype: str - """ - return json.dumps(self.json, indent=4, sort_keys=True) + def set_bitrate(self,number_of_parts): + + overall_bitrate_optimal = 0 #pour tous les profiles on va réaliser des opérations avec le facteur calculé pour en déduire les débit 'mode linéaire' + for encoding_profile in self.encoding_ladder.encoding_profile_list: + #print('encoding profile is : ',encoding_profile.bitrate_default) + + target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) + #print ('target bitrate=',target_bitrate) + remove_profile = False + if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: + remove_profile = True + + # if target_bitrate < encoding_profile.bitrate_min: + # target_bitrate = encoding_profile.bitrate_min + # + # if target_bitrate > encoding_profile.bitrate_max: + # target_bitrate = encoding_profile.bitrate_max + + if remove_profile is False: + overall_bitrate_optimal += target_bitrate + + print(' ',encoding_profile.width,'x',encoding_profile.height,' ',target_bitrate*1e-3,'kbps linear',' / nbr part:',number_of_parts,' ') -class CrfAnalyzer(Analyzer): - """This class defines a Per-Title Analyzer based on calculating the top bitrate wit CRF, then deducting the ladder""" - - def process(self, number_of_parts, width, height, crf_value, idr_interval): - """Do the necessary crf encodings and assessments - - :param number_of_parts: Number of part/segment for the analysis - :type number_of_parts: int - :param width: Width of the CRF encode - :type width: int - :param height: Height of the CRF encode - :type height: int - :param crf_value: Constant Rate Factor: this is a constant quality factor, see ffmpeg.org for more documentation on this parameter - :type crf_value: int - :param idr_interval: IDR interval in seconds - :type idr_interval: int - """ - # Start by probing the input video file - input_probe = Probe(self.input_file_path) - input_probe.execute() - crf_bitrate_list = [] - part_duration = input_probe.duration/number_of_parts - idr_interval_frames = idr_interval*input_probe.framerate - for i in range(0,number_of_parts): - part_start_time = i*part_duration - # Do a CRF encode for the input file + def process(self, number_of_parts, width, height, crf_value, idr_interval, model): + + #première étape je commence par analyser le fichier d'origine + input_probe = Probe(self.input_file_path) #appel nécessaire à la classe Probe, création d'un objet de type probe + input_probe.execute()#application de la méthode execute de la classe probe + + crf_bitrate_list = [] #création d'une liste + part_duration = input_probe.duration/number_of_parts #définition des varibles durée d'une partie à partir du probe + idr_interval_frames = idr_interval*input_probe.framerate #rcl: An IDR frame is a special type of I-frame in H.264. An IDR frame specifies that no frame after the IDR frame can reference any frame before it. This makes seeking the H.264 file easier and more responsive in the player. + #comme j'ai une idr toutes les deux secondes alors connaissant le framerate je peux en déduire le nombre d'images entre deux idr + + + for i in range(0,number_of_parts): #la on commence les choses sérieuses + part_start_time = i*part_duration #on sélectionne le début des extraits à encoder + crf_encode = CrfEncode(self.input_file_path, width, height, crf_value, idr_interval_frames, part_start_time, part_duration) - crf_encode.execute() + crf_encode.execute() #pour chaque extrait on créé une instance de la classe crfencode et on appelle la methode execute() - # Get the Bitrate from the CRF encoded file crf_probe = Probe(crf_encode.output_file_path) - crf_probe.execute() + crf_probe.execute() #pour chaque extrait on fait un probe du fichier de sortie de l'encodage, on récupère les valeur grace à la methode execute du probe + + os.remove(crf_encode.output_file_path) # on supprime le fichier - # Remove temporary CRF encoded file - os.remove(crf_encode.output_file_path) + crf_bitrate_list.append(crf_probe.bitrate) #on ajoute dans la liste qu'on avait créé le bitrate trouvé grace au probe de l'output - # Set the crf bitrate - crf_bitrate_list.append(crf_probe.bitrate) - # Calculate the average bitrate for all CRF encodings self.average_bitrate = statistics.mean(crf_bitrate_list) self.peak_bitrate = max(crf_bitrate_list) if number_of_parts > 1: - # Calculate the the standard deviation of crf bitrate values self.standard_deviation = statistics.stdev(crf_bitrate_list) weight = 1 @@ -273,72 +177,21 @@ def process(self, number_of_parts, width, height, crf_value, idr_interval): self.optimal_bitrate = weighted_bitrate_sum/weighted_bitrate_len else: - # Set the optimal bitrate from the average of all crf encoded parts self.optimal_bitrate = self.average_bitrate - # Adding results to json - result = {} - result['processing_date'] = str(datetime.datetime.now()) - result['parameters'] = {} - result['parameters']['method'] = "CRF" - result['parameters']['width'] = width - result['parameters']['height'] = height - result['parameters']['crf_value'] = crf_value - result['parameters']['idr_interval'] = idr_interval - result['parameters']['number_of_parts'] = number_of_parts - result['parameters']['part_duration'] = part_duration - result['bitrate'] = {} - result['bitrate']['optimal'] = self.optimal_bitrate - result['bitrate']['average'] = self.average_bitrate - result['bitrate']['peak'] = self.average_bitrate - result['bitrate']['standard_deviation'] = self.standard_deviation - result['optimized_encoding_ladder'] = {} - result['optimized_encoding_ladder']['encoding_profiles'] = [] - - overall_bitrate_optimal = 0 - for encoding_profile in self.encoding_ladder.encoding_profile_list: + if not model: + print(' ',width,'x',height,' ',self.optimal_bitrate*1e-3,'kbps encode_for_each','/ nbr part:',number_of_parts,' ') - target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) + if model: + self.set_bitrate(number_of_parts) - remove_profile = False - if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: - remove_profile = True - - if target_bitrate < encoding_profile.bitrate_min: - target_bitrate = encoding_profile.bitrate_min - - if target_bitrate > encoding_profile.bitrate_max: - target_bitrate = encoding_profile.bitrate_max - - if remove_profile is False: - overall_bitrate_optimal += target_bitrate - profile = {} - profile['width'] = encoding_profile.width - profile['height'] = encoding_profile.height - profile['bitrate'] = target_bitrate - profile['bitrate_savings'] = encoding_profile.bitrate_default - target_bitrate - result['optimized_encoding_ladder']['encoding_profiles'].append(profile) - - result['optimized_encoding_ladder']['overall_bitrate_ladder'] = overall_bitrate_optimal - result['optimized_encoding_ladder']['overall_bitrate_savings'] = self.encoding_ladder.get_overall_bitrate() - overall_bitrate_optimal - self.json['analyses'].append(result) +#class_5_en fonction de la métrique choisie class MetricAnalyzer(Analyzer): - """This class defines a Per-Title Analyzer based on VQ Metric and Multiple bitrate encodes""" - - def process(self, metric, bitrate_steps, idr_interval): - """Do the necessary encodings and quality metric assessments - :param metric: Supporting "ssim" or "psnr" - :type metric: string - :param bitrate_steps: Bitrate gap between every encoding - :type bitrate_steps: int - :param idr_interval: IDR interval in seconds - :type idr_interval: int - """ + def process(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required): - # Start by probing the input video file input_probe = Probe(self.input_file_path) input_probe.execute() @@ -347,39 +200,49 @@ def process(self, metric, bitrate_steps, idr_interval): idr_interval_frames = idr_interval*input_probe.framerate metric = str(metric).strip().lower() - # Adding results to json - json_ouput = {} - json_ouput['processing_date'] = str(datetime.datetime.now()) - json_ouput['parameters'] = {} - json_ouput['parameters']['method'] = "Metric" - json_ouput['parameters']['metric'] = metric - json_ouput['parameters']['bitrate_steps'] = bitrate_steps - json_ouput['parameters']['idr_interval'] = idr_interval - json_ouput['parameters']['number_of_parts'] = 1 - json_ouput['parameters']['part_duration'] = part_duration - json_ouput['optimized_encoding_ladder'] = {} - json_ouput['optimized_encoding_ladder']['encoding_profiles'] = [] + #création listes bitrate pour graph 2 + optimal_bitrate_array = [] + default_bitrate_array = [] + + print('\n********************************\n********Encoding Started********\n********************************\n') + print('File Selected: ', os.path.basename(self.input_file_path)) + +#pour tous les profils on a le process for encoding_profile in self.encoding_ladder.encoding_profile_list: + default_bitrate_array.append(encoding_profile.bitrate_default) + profile = {} profile['width'] = encoding_profile.width profile['height'] = encoding_profile.height profile['cbr_encodings'] = [] profile['optimal_bitrate'] = None + if steps_individual_bitrate_required: + bitrate_steps_by_default = encoding_profile.bitrate_steps_by_default + print('\n\n __________________________________________') + print(' The bitrate_step is: ',bitrate_steps_by_default*10**(-3),'kbps') + + print('\n |||',encoding_profile.width, 'x', encoding_profile.height,'|||\n') last_metric_value = 0 last_quality_step_ratio = 0 + bitrate_array = [] + quality_array = [] - for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps), bitrate_steps): +#pour chaque profile on a calcul de la qualité sur le range bitrare + + for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps_by_default), bitrate_steps_by_default): # Do a CRF encode for the input file cbr_encode = CbrEncode(self.input_file_path, encoding_profile.width, encoding_profile.height, bitrate, idr_interval_frames, part_start_time, part_duration) cbr_encode.execute() + print('cbr_encode -> in progress -> ->') # Get the Bitrate from the CRF encoded file metric_assessment = Metric(metric, cbr_encode.output_file_path, self.input_file_path, input_probe.width, input_probe.height) metric_assessment.execute() + print('-> -> probe |>', bitrate*10**(-3),'kbps |>',metric,' = ',metric_assessment.output_value, '\n') # Remove temporary CRF encoded file os.remove(cbr_encode.output_file_path) @@ -390,7 +253,7 @@ def process(self, metric, bitrate_steps, idr_interval): profile['optimal_bitrate'] = bitrate quality_step_ratio = (metric_assessment.output_value)/bitrate # frist step from null to the starting bitrate else: - quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps + quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps_by_default if quality_step_ratio >= (last_quality_step_ratio/2): profile['optimal_bitrate'] = bitrate @@ -407,11 +270,124 @@ def process(self, metric, bitrate_steps, idr_interval): encoding = {} encoding['bitrate'] = bitrate + bitrate_array.append(bitrate) #pour un profile on a trous les bitrate + #print(bitrate_array) + quality_array.append(metric_assessment.output_value) #pour un profile on a toutes les qualités + #print(quality_array) encoding['metric_value'] = metric_assessment.output_value encoding['quality_step_ratio'] = quality_step_ratio profile['cbr_encodings'].append(encoding) + profile['bitrate_savings'] = encoding_profile.bitrate_default - profile['optimal_bitrate'] - json_ouput['optimized_encoding_ladder']['encoding_profiles'].append(profile) - self.json['analyses'].append(json_ouput) + #**************graph matplotlib************* + #initialisation + diff_quality_array=0 #ordonnées + diff_bitrate_array=1 #abscisses + taux_accroissement=1 + + #création de la courbe + figure(1) + plot(bitrate_array, quality_array, label=str(encoding_profile.width)+'x'+str(encoding_profile.height)) + xlabel('bitrate (bps)') + ylabel("quality: "+str(metric).upper()) + title(os.path.basename(self.input_file_path)) + + #calcul du taux d'accroissement et recherche du point idéal + for j in range(0, len(quality_array)-1): + diff_quality_array=quality_array[j+1]-quality_array[j] + diff_bitrate_array=bitrate_array[j+1]-bitrate_array[j] + + #limited_evolution_metric=0.005 #à régler, autour 0.1 pour psnr avec 100000 en bitrate step et 0.05 avec 50000 en bitrate step et 0.005 pour ssim + limited_evolution_metric=limit_metric + + taux_accroissement = diff_quality_array/diff_bitrate_array + if taux_accroissement <= limited_evolution_metric/bitrate_steps_by_default: + #scatter(bitrate_array[j], quality_array[j],s=4) #j'ai trouvé le point + break + + #traitement des valeurs trouvées + print ('\nI found the best values for ||--- ', str(encoding_profile.width)+'x'+str(encoding_profile.height),' ---|| >> ',metric,':',quality_array[j],'| bitrate = ',bitrate_array[j]*10**(-3),'kbps') + optimal_bitrate_array.append(bitrate_array[j]) + + + #mise en forme graph for each profile + annotation=str(bitrate_array[j]*1e-3)+' kbps' + #plot([bitrate_array[j],bitrate_array[j]], [0, quality_array[j]], linestyle='--' ) + annotate(annotation, xy=(bitrate_array[j], quality_array[j]), xycoords='data', xytext=(+1, +20), textcoords='offset points', fontsize=8, arrowprops=dict(arrowstyle="->", connectionstyle="arc,rad=0.2")) + #plot([0, bitrate_array[j]], [quality_array[j], quality_array[j]], linestyle='--' ) + scatter(bitrate_array[j], quality_array[j], s=7) + grid() + legend() + draw() + show(block=False) + pause(0.001) + + + #save graph1 and plot graph2 + name=str(os.path.basename(self.input_file_path)) + input("\n\n\nPress [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' !") + newpath = '/home/labo/Documents/per_title_analysis/results/%s' % (name) + if not os.path.exists(newpath): + os.makedirs(newpath) + plt.savefig(newpath+"/%s-%s-%s-Per_Title.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + + #print(optimal_bitrate_array) + #print(default_bitrate_array) + bitrate_data = [list(i) for i in zip(optimal_bitrate_array, default_bitrate_array)] + #print(bitrate_data) + + + #graph2 computation******************* + + figure(2) + columns = ('Dynamic (kbps)', 'Fix (kbps)') + rows = ['%s' % resolution for resolution in ('1920 x 1080', '1280 x 720', '960 x 540', '640 x 360', '480 x 270')] + + ylabel("bitrate (bps)") + title(os.path.basename(self.input_file_path)) + + # Get some pastel shades for the colors + colors = plt.cm.YlOrBr(np.linspace(0.35, 0.8, len(rows))) + #size and positions + n_rows = len(bitrate_data)-1 + index = np.arange(len(columns)) + 0.3 + bar_width = 0.5 + + # Initialize the vertical-offset for the stacked bar chart. + y_offset = np.zeros(len(columns)) + + # Plot bars and create text labels for the table + cell_text = [] + for row in range(n_rows+1): #ca s'arrette à n-1 + plt.bar(index, bitrate_data[n_rows-row], bar_width, bottom=y_offset, color=colors[row]) + y_offset = y_offset + bitrate_data[n_rows-row] + #print('this is y_offset',y_offset) + cell_text.append(['%1.1f' % (x / 1000.0) for x in bitrate_data[n_rows-row]]) + # Reverse colors and text labels to display the last value at the top. + colors = colors[::-1] + cell_text.reverse() + + + # Add a table at the bottom of the axes + the_table = plt.table(cellText=cell_text, + rowLabels=rows, + rowColours=colors, + colLabels=columns, + loc='bottom') + + # Adjust layout to make room for the table: + plt.subplots_adjust(left=0.5, bottom=0.2) + + #plt.ylabel("Loss in ${0}'s".format(value_increment)) + #plt.yticks(values * value_increment, ['%d' % val for val in values]) + plt.xticks([]) + #plt.title('Loss by Disaster') + + show(block=False) + pause(0.001) + print('\n\n->->\nloading graphic Histogram\n->->\n\n') + input("Press [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' ") + plt.savefig(newpath+"/%s-%s-%s-Per_Title_Histogram.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + print('\n\n\n************ALL DONE********** !\n\n') diff --git a/pertitleanalysis/task_providers.py b/pertitleanalysis/task_providers.py index 1943d88..cfa63db 100755 --- a/pertitleanalysis/task_providers.py +++ b/pertitleanalysis/task_providers.py @@ -73,12 +73,16 @@ def execute(self): for stream in data['streams']: if stream['codec_type'] == 'video': self.width = int(stream['width']) + #print('the probe',self.width) self.height = int(stream['height']) - self.bitrate = int(stream['bit_rate']) self.duration = float(stream['duration']) self.video_codec = stream['codec_name'] - self.framerate = int(stream['r_frame_rate'].replace('/1','')) + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace + self.bitrate = float(stream['bit_rate']) #error + self.video_codec = stream['codec_name'] + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace except: + # TODO: error management pass @@ -115,13 +119,13 @@ def __init__(self, input_file_path, width, height, crf_value, idr_interval, part # Generate a temporary file name for the task output self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") - print(self.output_file_path) - print(self.part_start_time) - print(self.input_file_path) - print(self.part_duration) - print(self.crf_value) - print(self.definition) - print(self.idr_interval) + # print(self.output_file_path) + # print(self.part_start_time) + # print(self.input_file_path) + # print(self.part_duration) + # print(self.crf_value) + # print(self.definition) + # print(self.idr_interval) def execute(self): """Using FFmpeg to CRF Encode a file or part of a file""" From 823d77cd0ea1f3b0249c0a593635c24371ae3187 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:46:53 +0200 Subject: [PATCH 02/25] Delete per_title_analysis.py --- pertitleanalysis/per_title_analysis.py | 393 ------------------------- 1 file changed, 393 deletions(-) delete mode 100755 pertitleanalysis/per_title_analysis.py diff --git a/pertitleanalysis/per_title_analysis.py b/pertitleanalysis/per_title_analysis.py deleted file mode 100755 index 967854d..0000000 --- a/pertitleanalysis/per_title_analysis.py +++ /dev/null @@ -1,393 +0,0 @@ -# -*- coding: utf-8 -*- - -#importation - -from __future__ import division -from pylab import * -import matplotlib.pyplot as plt -import matplotlib.animation as animation -import sys -import os -import json -import datetime -import statistics - -from task_providers import Probe, CrfEncode, CbrEncode, Metric - - -#class_1_création des profiles d'encodage ('ici on travaille à l'échelle d'un profil d'encodage) - -class EncodingProfile(object): - - def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_by_default): - #call: PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True, 150000)) - - self.width = int(width) - self.height = int(height) - self.bitrate_default = int(bitrate_default) - self.bitrate_min = int(bitrate_min) - self.bitrate_max = int(bitrate_max) - self.bitrate_factor = None - self.bitrate_steps_by_default = int(bitrate_steps_by_default) - if required is not None: - self.required = required - else: - self.required = True - - def set_bitrate_factor(self, ladder_max_bitrate): #fonction utilisée dans le calculate_bitrate_factors - self.bitrate_factor = ladder_max_bitrate/self.bitrate_default - #print('the bitrate factor for ',self.width, 'x', self.height, 'is:', self.bitrate_factor) - - - -#class_2_création de l'échelle des profiles d'encodage (ici on travaille à l'échelle de la liste d'ensemble des profiles) - -class EncodingLadder(object): - - def __init__(self, encoding_profile_list): - #call: LADDER = pta.EncodingLadder(PROFILE_LIST) - - self.encoding_profile_list = encoding_profile_list - self.calculate_bitrate_factors() - #pour calculer le bitrate_factor on a besoin d'abord d'obtenir le bitrate_default max de la liste encoding_profile_list, ensuite on applique la fonction set_bitrate factor qui - #pour chaque profile va déterminer le facteur par une simple division. - - def get_max_bitrate(self): - #parcours la liste des débits dispo et ressort le max parmi les bitrate default !! - ladder_max_bitrate = 0 - for encoding_profile in self.encoding_profile_list: - if encoding_profile.bitrate_default > ladder_max_bitrate: - ladder_max_bitrate = encoding_profile.bitrate_default - return ladder_max_bitrate - - def get_overall_bitrate(self): - - ladder_overall_bitrate = 0 - for encoding_profile in self.encoding_profile_list: - ladder_overall_bitrate += encoding_profile.bitrate_default - return ladder_overall_bitrate - - def calculate_bitrate_factors(self): #cf plus haut ! - - ladder_max_bitrate = self.get_max_bitrate() - for encoding_profile in self.encoding_profile_list: - encoding_profile.set_bitrate_factor(ladder_max_bitrate) - - -#class_3_préparation des opérations d'analyse, initialisation des variables - -class Analyzer(object): - - def __init__(self, input_file_path, encoding_ladder): - - - self.input_file_path = input_file_path - self.encoding_ladder = encoding_ladder - - self.average_bitrate = None - self.standard_deviation = None - self.optimal_bitrate = None - self.peak_bitrate = None - - - -#class_4_analyse du débit oprimal pour une qualité constante définie, le number of part désigne le nombre de découpage réalisé sur la source pour pouvoir trouver au mieux pour optimiser le débit sur des cas complexes. - -class CrfAnalyzer(Analyzer): - #cette classe s'appuie grandement sur la classe prècèdente donc pas d'init !!! elle est appellé dans le fichier test.py juste deux fois - - - def set_bitrate(self,number_of_parts): - - overall_bitrate_optimal = 0 #pour tous les profiles on va réaliser des opérations avec le facteur calculé pour en déduire les débit 'mode linéaire' - for encoding_profile in self.encoding_ladder.encoding_profile_list: - #print('encoding profile is : ',encoding_profile.bitrate_default) - - target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) - #print ('target bitrate=',target_bitrate) - remove_profile = False - if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: - remove_profile = True - - # if target_bitrate < encoding_profile.bitrate_min: - # target_bitrate = encoding_profile.bitrate_min - # - # if target_bitrate > encoding_profile.bitrate_max: - # target_bitrate = encoding_profile.bitrate_max - - if remove_profile is False: - overall_bitrate_optimal += target_bitrate - - print(' ',encoding_profile.width,'x',encoding_profile.height,' ',target_bitrate*1e-3,'kbps linear',' / nbr part:',number_of_parts,' ') - - - - - def process(self, number_of_parts, width, height, crf_value, idr_interval, model): - - #première étape je commence par analyser le fichier d'origine - input_probe = Probe(self.input_file_path) #appel nécessaire à la classe Probe, création d'un objet de type probe - input_probe.execute()#application de la méthode execute de la classe probe - - crf_bitrate_list = [] #création d'une liste - part_duration = input_probe.duration/number_of_parts #définition des varibles durée d'une partie à partir du probe - idr_interval_frames = idr_interval*input_probe.framerate #rcl: An IDR frame is a special type of I-frame in H.264. An IDR frame specifies that no frame after the IDR frame can reference any frame before it. This makes seeking the H.264 file easier and more responsive in the player. - #comme j'ai une idr toutes les deux secondes alors connaissant le framerate je peux en déduire le nombre d'images entre deux idr - - - for i in range(0,number_of_parts): #la on commence les choses sérieuses - part_start_time = i*part_duration #on sélectionne le début des extraits à encoder - - crf_encode = CrfEncode(self.input_file_path, width, height, crf_value, idr_interval_frames, part_start_time, part_duration) - crf_encode.execute() #pour chaque extrait on créé une instance de la classe crfencode et on appelle la methode execute() - - crf_probe = Probe(crf_encode.output_file_path) - crf_probe.execute() #pour chaque extrait on fait un probe du fichier de sortie de l'encodage, on récupère les valeur grace à la methode execute du probe - - os.remove(crf_encode.output_file_path) # on supprime le fichier - - crf_bitrate_list.append(crf_probe.bitrate) #on ajoute dans la liste qu'on avait créé le bitrate trouvé grace au probe de l'output - - - self.average_bitrate = statistics.mean(crf_bitrate_list) - self.peak_bitrate = max(crf_bitrate_list) - - if number_of_parts > 1: - self.standard_deviation = statistics.stdev(crf_bitrate_list) - - weight = 1 - weighted_bitrate_sum = 0 - weighted_bitrate_len = 0 - - for bitrate in crf_bitrate_list: - if bitrate > (self.average_bitrate + self.standard_deviation): - weight = 4 - elif bitrate > (self.average_bitrate + self.standard_deviation/2): - weight = 2 - elif bitrate < (self.average_bitrate - self.standard_deviation/2): - weight = 0.5 - elif bitrate < (self.average_bitrate - self.standard_deviation): - weight = 0 - else: - weight = 1 - - weighted_bitrate_sum += weight*bitrate - weighted_bitrate_len += weight - - self.optimal_bitrate = weighted_bitrate_sum/weighted_bitrate_len - - else: - self.optimal_bitrate = self.average_bitrate - - if not model: - print(' ',width,'x',height,' ',self.optimal_bitrate*1e-3,'kbps encode_for_each','/ nbr part:',number_of_parts,' ') - - if model: - self.set_bitrate(number_of_parts) - - -#class_5_en fonction de la métrique choisie - -class MetricAnalyzer(Analyzer): - - def process(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required): - - input_probe = Probe(self.input_file_path) - input_probe.execute() - - part_start_time = 0 - part_duration = input_probe.duration - idr_interval_frames = idr_interval*input_probe.framerate - metric = str(metric).strip().lower() - - #création listes bitrate pour graph 2 - optimal_bitrate_array = [] - default_bitrate_array = [] - - print('\n********************************\n********Encoding Started********\n********************************\n') - print('File Selected: ', os.path.basename(self.input_file_path)) - -#pour tous les profils on a le process - - for encoding_profile in self.encoding_ladder.encoding_profile_list: - - default_bitrate_array.append(encoding_profile.bitrate_default) - - profile = {} - profile['width'] = encoding_profile.width - profile['height'] = encoding_profile.height - profile['cbr_encodings'] = [] - profile['optimal_bitrate'] = None - - if steps_individual_bitrate_required: - bitrate_steps_by_default = encoding_profile.bitrate_steps_by_default - print('\n\n __________________________________________') - print(' The bitrate_step is: ',bitrate_steps_by_default*10**(-3),'kbps') - - print('\n |||',encoding_profile.width, 'x', encoding_profile.height,'|||\n') - last_metric_value = 0 - last_quality_step_ratio = 0 - bitrate_array = [] - quality_array = [] - - -#pour chaque profile on a calcul de la qualité sur le range bitrare - - for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps_by_default), bitrate_steps_by_default): - # Do a CRF encode for the input file - cbr_encode = CbrEncode(self.input_file_path, encoding_profile.width, encoding_profile.height, bitrate, idr_interval_frames, part_start_time, part_duration) - cbr_encode.execute() - print('cbr_encode -> in progress -> ->') - - # Get the Bitrate from the CRF encoded file - metric_assessment = Metric(metric, cbr_encode.output_file_path, self.input_file_path, input_probe.width, input_probe.height) - metric_assessment.execute() - print('-> -> probe |>', bitrate*10**(-3),'kbps |>',metric,' = ',metric_assessment.output_value, '\n') - - # Remove temporary CRF encoded file - os.remove(cbr_encode.output_file_path) - - if last_metric_value is 0 : - # for first value, you cannot calculate acurate jump in quality from nothing - last_metric_value = metric_assessment.output_value - profile['optimal_bitrate'] = bitrate - quality_step_ratio = (metric_assessment.output_value)/bitrate # frist step from null to the starting bitrate - else: - quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps_by_default - - if quality_step_ratio >= (last_quality_step_ratio/2): - profile['optimal_bitrate'] = bitrate - - #if 'ssim' in metric: - # if metric_assessment.output_value >= (last_metric_value + 0.01): - # profile['optimal_bitrate'] = bitrate - #elif 'psnr' in metric: - # if metric_assessment.output_value > last_metric_value: - # profile['optimal_bitrate'] = bitrate - - last_metric_value = metric_assessment.output_value - last_quality_step_ratio = quality_step_ratio - - encoding = {} - encoding['bitrate'] = bitrate - bitrate_array.append(bitrate) #pour un profile on a trous les bitrate - #print(bitrate_array) - quality_array.append(metric_assessment.output_value) #pour un profile on a toutes les qualités - #print(quality_array) - encoding['metric_value'] = metric_assessment.output_value - encoding['quality_step_ratio'] = quality_step_ratio - profile['cbr_encodings'].append(encoding) - - - profile['bitrate_savings'] = encoding_profile.bitrate_default - profile['optimal_bitrate'] - - #**************graph matplotlib************* - #initialisation - diff_quality_array=0 #ordonnées - diff_bitrate_array=1 #abscisses - taux_accroissement=1 - - #création de la courbe - figure(1) - plot(bitrate_array, quality_array, label=str(encoding_profile.width)+'x'+str(encoding_profile.height)) - xlabel('bitrate (bps)') - ylabel("quality: "+str(metric).upper()) - title(os.path.basename(self.input_file_path)) - - #calcul du taux d'accroissement et recherche du point idéal - for j in range(0, len(quality_array)-1): - diff_quality_array=quality_array[j+1]-quality_array[j] - diff_bitrate_array=bitrate_array[j+1]-bitrate_array[j] - - #limited_evolution_metric=0.005 #à régler, autour 0.1 pour psnr avec 100000 en bitrate step et 0.05 avec 50000 en bitrate step et 0.005 pour ssim - limited_evolution_metric=limit_metric - - taux_accroissement = diff_quality_array/diff_bitrate_array - if taux_accroissement <= limited_evolution_metric/bitrate_steps_by_default: - #scatter(bitrate_array[j], quality_array[j],s=4) #j'ai trouvé le point - break - - #traitement des valeurs trouvées - print ('\nI found the best values for ||--- ', str(encoding_profile.width)+'x'+str(encoding_profile.height),' ---|| >> ',metric,':',quality_array[j],'| bitrate = ',bitrate_array[j]*10**(-3),'kbps') - optimal_bitrate_array.append(bitrate_array[j]) - - - #mise en forme graph for each profile - annotation=str(bitrate_array[j]*1e-3)+' kbps' - #plot([bitrate_array[j],bitrate_array[j]], [0, quality_array[j]], linestyle='--' ) - annotate(annotation, xy=(bitrate_array[j], quality_array[j]), xycoords='data', xytext=(+1, +20), textcoords='offset points', fontsize=8, arrowprops=dict(arrowstyle="->", connectionstyle="arc,rad=0.2")) - #plot([0, bitrate_array[j]], [quality_array[j], quality_array[j]], linestyle='--' ) - scatter(bitrate_array[j], quality_array[j], s=7) - grid() - legend() - draw() - show(block=False) - pause(0.001) - - - #save graph1 and plot graph2 - name=str(os.path.basename(self.input_file_path)) - input("\n\n\nPress [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' !") - newpath = '/home/labo/Documents/per_title_analysis/results/%s' % (name) - if not os.path.exists(newpath): - os.makedirs(newpath) - plt.savefig(newpath+"/%s-%s-%s-Per_Title.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) - - #print(optimal_bitrate_array) - #print(default_bitrate_array) - bitrate_data = [list(i) for i in zip(optimal_bitrate_array, default_bitrate_array)] - #print(bitrate_data) - - - #graph2 computation******************* - - figure(2) - columns = ('Dynamic (kbps)', 'Fix (kbps)') - rows = ['%s' % resolution for resolution in ('1920 x 1080', '1280 x 720', '960 x 540', '640 x 360', '480 x 270')] - - ylabel("bitrate (bps)") - title(os.path.basename(self.input_file_path)) - - # Get some pastel shades for the colors - colors = plt.cm.YlOrBr(np.linspace(0.35, 0.8, len(rows))) - #size and positions - n_rows = len(bitrate_data)-1 - index = np.arange(len(columns)) + 0.3 - bar_width = 0.5 - - # Initialize the vertical-offset for the stacked bar chart. - y_offset = np.zeros(len(columns)) - - # Plot bars and create text labels for the table - cell_text = [] - for row in range(n_rows+1): #ca s'arrette à n-1 - plt.bar(index, bitrate_data[n_rows-row], bar_width, bottom=y_offset, color=colors[row]) - y_offset = y_offset + bitrate_data[n_rows-row] - #print('this is y_offset',y_offset) - cell_text.append(['%1.1f' % (x / 1000.0) for x in bitrate_data[n_rows-row]]) - # Reverse colors and text labels to display the last value at the top. - colors = colors[::-1] - cell_text.reverse() - - - # Add a table at the bottom of the axes - the_table = plt.table(cellText=cell_text, - rowLabels=rows, - rowColours=colors, - colLabels=columns, - loc='bottom') - - # Adjust layout to make room for the table: - plt.subplots_adjust(left=0.5, bottom=0.2) - - #plt.ylabel("Loss in ${0}'s".format(value_increment)) - #plt.yticks(values * value_increment, ['%d' % val for val in values]) - plt.xticks([]) - #plt.title('Loss by Disaster') - - show(block=False) - pause(0.001) - print('\n\n->->\nloading graphic Histogram\n->->\n\n') - input("Press [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' ") - plt.savefig(newpath+"/%s-%s-%s-Per_Title_Histogram.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) - print('\n\n\n************ALL DONE********** !\n\n') From 3055e7e48ed10593509ca1b974575c2a848b5447 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:47:32 +0200 Subject: [PATCH 03/25] Delete __init__.py --- pertitleanalysis/__init__.py | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100755 pertitleanalysis/__init__.py diff --git a/pertitleanalysis/__init__.py b/pertitleanalysis/__init__.py deleted file mode 100755 index 1ca3e57..0000000 --- a/pertitleanalysis/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf8 -*- -""" - pertitleanalysis - ----- - A smart and simple Per-Title video analysis tool for optimizing your over-the-top encoding ladder - :copyright: (c) 2017 by Antoine Henning. - :license: MIT, see LICENSE for more details. -""" - -__title__ = 'pertitleanalysis' -__author__ = 'Antoine Henning' -__version__ = '0.1-dev' From ce4247d0fecac838f861c029d03e266fa8ea6e2e Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:47:39 +0200 Subject: [PATCH 04/25] Delete crf_analyzer.py --- pertitleanalysis/crf_analyzer.py | 37 -------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 pertitleanalysis/crf_analyzer.py diff --git a/pertitleanalysis/crf_analyzer.py b/pertitleanalysis/crf_analyzer.py deleted file mode 100644 index 21585ba..0000000 --- a/pertitleanalysis/crf_analyzer.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- coding: utf8 -*- -import per_title_analysis as pta -import sys - -path=str(sys.argv[1]) -print ("\nfile=",path) -crf_value=str(sys.argv[2]) -print("crf = ",crf_value) -number_of_parts=int(sys.argv[3]) -print("number_of_parts=",number_of_parts) -model=str(sys.argv[4]) -print("model value 1 for True (linear), 0 for False (for each):", model, "\n\n") - -# create your template encoding ladder -PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) -PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) -#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) - -LADDER = pta.EncodingLadder(PROFILE_LIST) - - -# Create a new Metric analysis provider -ANALYSIS = pta.CrfAnalyzer(path, LADDER) - -# Launch various analysis (here crf) -if model==1: #model = linear (True) or for each (False) - ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, True) -else: - ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, False) - ANALYSIS.process(number_of_parts, 1280, 720, crf_value, 2, False) - ANALYSIS.process(number_of_parts, 960, 540, crf_value, 2, False) - ANALYSIS.process(number_of_parts, 640, 360, crf_value, 2, False) - ANALYSIS.process(number_of_parts, 480, 270, crf_value, 2, False) From 16396e4e8b74f85f2925076535f571120628f64b Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:47:45 +0200 Subject: [PATCH 05/25] Delete metric_analyzer.py --- pertitleanalysis/metric_analyzer.py | 28 ---------------------------- 1 file changed, 28 deletions(-) delete mode 100644 pertitleanalysis/metric_analyzer.py diff --git a/pertitleanalysis/metric_analyzer.py b/pertitleanalysis/metric_analyzer.py deleted file mode 100644 index ba24be3..0000000 --- a/pertitleanalysis/metric_analyzer.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf8 -*- -import per_title_analysis as pta -import sys - -path=str(sys.argv[1]) -metric=str(sys.argv[2]) -limit_metric_value=float(sys.argv[3]) -print('\nmetric:', metric) -print('limit metric =', limit_metric_value) - -# create your template encoding ladder -PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) -PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) -#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) - -LADDER = pta.EncodingLadder(PROFILE_LIST) - - -# Create a new Metric analysis provider -ANALYSIS = pta.MetricAnalyzer(path, LADDER) - -# Launch various analysis (here ssim or psnr) - #(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required) -ANALYSIS.process(metric, limit_metric_value, 4000000, 2, True) From 1b364e9b92d4a4d178b01327f031116b424a5133 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:47:55 +0200 Subject: [PATCH 06/25] Delete task_providers.py --- pertitleanalysis/task_providers.py | 249 ----------------------------- 1 file changed, 249 deletions(-) delete mode 100755 pertitleanalysis/task_providers.py diff --git a/pertitleanalysis/task_providers.py b/pertitleanalysis/task_providers.py deleted file mode 100755 index cfa63db..0000000 --- a/pertitleanalysis/task_providers.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import json -import subprocess -import uuid - -class Task(object): - """This class defines a processing task""" - - def __init__(self, input_file_path): - """Task initialization - - :param input_file_path: The input video file path - :type input_file_path: str - """ - if os.path.isfile(input_file_path) is True: - self.input_file_path = input_file_path - else: - raise ValueError('Cannot access the file: {}'.format(input_file_path)) - - self.subprocess_pid = None - self.subprocess_out = None - self.subprocess_err = None - - def execute(self, command): - """Launch a subprocess task - - :param command: Arguments array for the subprocess task - :type command: str[] - """ - proc = subprocess.Popen(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE) - self.subprocess_pid = proc.pid - - try: - self.subprocess_out, self.subprocess_err = proc.communicate() - except: - print(self.subprocess_err) - # TODO: error management - - -class Probe(Task): - """This class defines a Probing task""" - - def __init__(self, input_file_path): - """Probe initialization - - :param input_file_path: The input video file path - :type input_file_path: str - """ - Task.__init__(self, input_file_path) - - self.width = None - self.height = None - self.bitrate = None - self.duration = None - self.video_codec = None - self.framerate = None - - def execute(self): - """Using FFprobe to get input video file informations""" - command = ['ffprobe', - '-hide_banner', - '-i', self.input_file_path, - '-show_format', '-show_streams', - '-print_format', 'json'] - Task.execute(self, command) - - # Parse output data - try: - response = self.subprocess_out - data = json.loads(response.decode('utf-8')) - for stream in data['streams']: - if stream['codec_type'] == 'video': - self.width = int(stream['width']) - #print('the probe',self.width) - self.height = int(stream['height']) - self.duration = float(stream['duration']) - self.video_codec = stream['codec_name'] - self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace - self.bitrate = float(stream['bit_rate']) #error - self.video_codec = stream['codec_name'] - self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace - except: - - # TODO: error management - pass - - -class CrfEncode(Task): - """This class defines a CRF encoding task""" - - def __init__(self, input_file_path, width, height, crf_value, idr_interval, part_start_time, part_duration): - """CrfEncode initialization - - :param input_file_path: The input video file path - :type input_file_path: str - :param width: Width of the CRF encode - :type width: int - :param height: Height of the CRF encode - :type height: int - :param crf_value: The CRF Encoding value for ffmpeg - :type crf_value: int - :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) - :type idr_interval: int - :param part_start_time: Encode seek start time (in seconds) - :type part_start_time: float - :param part_duration: Encode duration (in seconds) - :type part_duration: float - """ - Task.__init__(self, input_file_path) - - self.definition = str(width)+'x'+str(height) - self.crf_value = crf_value - self.idr_interval = idr_interval - self.part_start_time = part_start_time - self.part_duration = part_duration - - # Generate a temporary file name for the task output - self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), - os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") - # print(self.output_file_path) - # print(self.part_start_time) - # print(self.input_file_path) - # print(self.part_duration) - # print(self.crf_value) - # print(self.definition) - # print(self.idr_interval) - - def execute(self): - """Using FFmpeg to CRF Encode a file or part of a file""" - command = ['ffmpeg', - '-hide_banner', '-loglevel', 'quiet', '-nostats', - '-ss', str(self.part_start_time), - '-i', self.input_file_path, - '-t', str(self.part_duration), - '-preset', 'ultrafast', - '-an', '-deinterlace', - '-crf', str(self.crf_value), - '-pix_fmt', 'yuv420p', - '-s', self.definition, - '-x264opts', 'keyint=' + str(self.idr_interval), - '-y', self.output_file_path] - Task.execute(self, command) - - -class CbrEncode(Task): - """This class defines a CBR encoding task""" - - def __init__(self, input_file_path, width, height, cbr_value, idr_interval, part_start_time, part_duration): - """CrfEncode initialization - - :param input_file_path: The input video file path - :type input_file_path: str - :param width: Width of the CBR encode - :type width: int - :param height: Height of the CBR encode - :type height: int - :param cbr_value: The CBR Encoding value for ffmpeg - :type cbr_value: int - :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) - :type idr_interval: int - :param part_start_time: Encode seek start time (in seconds) - :type part_start_time: float - :param part_duration: Encode duration (in seconds) - :type part_duration: float - """ - Task.__init__(self, input_file_path) - - self.definition = str(width)+'x'+str(height) - self.cbr_value = cbr_value - self.idr_interval = idr_interval - self.part_start_time = part_start_time - self.part_duration = part_duration - - # Generate a temporary file name for the task output - self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), - os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") - - def execute(self): - """Using FFmpeg to CRF Encode a file or part of a file""" - command = ['ffmpeg', - '-hide_banner', '-loglevel', 'quiet', '-nostats', - '-ss', str(self.part_start_time), - '-i', self.input_file_path, - '-t', str(self.part_duration), - '-c:v', 'libx264', - '-an', '-deinterlace', - '-b:v', str(self.cbr_value), - '-pix_fmt', 'yuv420p', - '-s', self.definition, - '-x264opts', 'keyint=' + str(self.idr_interval), - '-y', self.output_file_path] - Task.execute(self, command) - - -class Metric(Task): - """This class defines a Probing task""" - - def __init__(self, metric, input_file_path, ref_file_path, ref_width, ref_height): - """Probe initialization - - :param metric: Supporting "ssim" or "psnr" - :type metric: string - :param input_file_path: The input video file path, the one to be analyzed - :type input_file_path: str - :param ref_file_path: The reference video file path - :type ref_file_path: str - - """ - Task.__init__(self, input_file_path) - - if os.path.isfile(ref_file_path) is True: - self.ref_file_path = ref_file_path - self.ref_width = ref_width - self.ref_height = ref_height - else: - raise ValueError('Cannot access the file: {}'.format(ref_file_path)) - - available_metrics = ['ssim', 'psnr'] - self.metric = str(metric).strip().lower() - if self.metric not in available_metrics: - raise ValueError('Available metrics are "ssim" and "psnr", does not include: {}'.format(metric)) - - self.output_value = None - - def execute(self): - """Using FFmpeg to process metric assessments""" - command = ['ffmpeg', - '-hide_banner', - '-i', self.input_file_path, - '-i', self.ref_file_path, - '-lavfi', '[0]scale='+str(self.ref_width)+':'+str(self.ref_height)+'[scaled];[scaled][1]'+str(self.metric)+'=stats_file=-', - '-f', 'null', '-'] - Task.execute(self, command) - - # Parse output data - try: - data = self.subprocess_err.splitlines() - for line in data: - line = str(line) - if 'Parsed_ssim' in line: - self.output_value = float(line.split('All:')[1].split('(')[0].strip()) - elif 'Parsed_psnr' in line: - self.output_value = float(line.split('average:')[1].split('min:')[0].strip()) - - except: - # TODO: error management - pass From bb6301d488b42e07cedef7ec820c637beec64a47 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:49:01 +0200 Subject: [PATCH 07/25] New Version Per_Title_Analysis new tools: graph, json export, optimised bitrate finder --- __init__.py | 12 + crf_analyzer.py | 46 ++++ metric_analyzer.py | 36 +++ per_title_analysis.py | 592 ++++++++++++++++++++++++++++++++++++++++++ task_providers.py | 249 ++++++++++++++++++ 5 files changed, 935 insertions(+) create mode 100644 __init__.py create mode 100644 crf_analyzer.py create mode 100644 metric_analyzer.py create mode 100644 per_title_analysis.py create mode 100644 task_providers.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..415656c --- /dev/null +++ b/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf8 -*- +""" + pertitleanalysis + ----- + A smart and simple Per-Title video analysis tool for optimizing your over-the-top encoding ladder + :copyright: (c) 2018 by Antoine Henning & Thom Marin. + :license: MIT, see LICENSE for more details. +""" + +__title__ = 'pertitleanalysis' +__author__ = 'Antoine Henning, Thom Marin' +__version__ = '0.2-dev' diff --git a/crf_analyzer.py b/crf_analyzer.py new file mode 100644 index 0000000..6e3cc2f --- /dev/null +++ b/crf_analyzer.py @@ -0,0 +1,46 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys +import os +import json + +path=str(sys.argv[1]) +print ("\nfile=",path) +crf_value=str(sys.argv[2]) +print("crf =",crf_value) +number_of_parts=int(sys.argv[3]) +print("number_of_parts =",number_of_parts) +model=int(sys.argv[4]) +print("model value 1 for True (linear), 0 for False (for each):", model, "\n\n") + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.CrfAnalyzer(path, LADDER) + +# Launch various analysis (here crf) +if model == 1: #model = linear (True) or for each (False) + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, True) +if model == 0: + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 1280, 720, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 960, 540, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 640, 360, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 480, 270, crf_value, 2, None) + + +# Save JSON results +name=str(os.path.basename(path)) +filePathNameWExt = str(os.getcwd())+"/results/%s/%s-CRF-nbr_parts:%s-%s-Per_Title.json" % (name, name, number_of_parts , str(crf_value)) +with open(filePathNameWExt, 'w') as fp: + print(ANALYSIS.get_json(), file=fp) diff --git a/metric_analyzer.py b/metric_analyzer.py new file mode 100644 index 0000000..e978f0e --- /dev/null +++ b/metric_analyzer.py @@ -0,0 +1,36 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys +import os +import json + +path=str(sys.argv[1]) +metric=str(sys.argv[2]) +limit_metric_value=float(sys.argv[3]) +print('\nmetric:', metric) +print('limit metric =', limit_metric_value) + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.MetricAnalyzer(path, LADDER) + +# Launch various analysis (here ssim or psnr) + #(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required) +ANALYSIS.process(metric, limit_metric_value, 200000, 2, False) + +# Save JSON results +name=str(os.path.basename(path)) +filePathNameWExt = str(os.getcwd())+"/results/%s/%s-METRIC-%s-%s-Per_Title.json" % (name, name, (metric).strip().upper(), str(limit_metric_value)) +with open(filePathNameWExt, 'w') as fp: + print(ANALYSIS.get_json(), file=fp) diff --git a/per_title_analysis.py b/per_title_analysis.py new file mode 100644 index 0000000..79544eb --- /dev/null +++ b/per_title_analysis.py @@ -0,0 +1,592 @@ +# -*- coding: utf-8 -*- + +#importation + +from __future__ import division +from pylab import * +import sys +import os +import json +import datetime +import statistics +import matplotlib.pyplot as plt +import matplotlib.animation as animation + +from task_providers import Probe, CrfEncode, CbrEncode, Metric + + + +class EncodingProfile(object): + """This class defines an encoding profile""" + + + def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual): + """EncodingProfile initialization + + :param width: Video profile width + :type width: int + :param height: Video profile height + :type height: int + :param bitrate_default: Video profile bitrate default (in bits per second) + :type bitrate_default: int + :param bitrate_min: Video profile bitrate min constraint (in bits per second) + :type bitrate_min: int + :param bitrate_max: Video profile bitrate max constraint (in bits per second) + :type bitrate_max: int + :param required: The video profile is required and cannot be removed from the optimized encoding ladder + :type required: bool + :param bitrate_steps_individual: Step Bitrate Range defined for each Video profile (in bits per second) + :type bitrate_steps_individual: int + """ + #call: PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True, 150000)) + + + if width is None: + raise ValueError('The EncodingProfile.width value is required') + else: + self.width = int(width) + + if height is None: + raise ValueError('The EncodingProfile.height value is required') + else: + self.height = int(height) + + if bitrate_default is None: + raise ValueError('The EncodingProfile.bitrate_default value is required') + else: + self.bitrate_default = int(bitrate_default) + + if int(bitrate_min) <= self.bitrate_default: + self.bitrate_min = int(bitrate_min) + else: + self.bitrate_min = self.bitrate_default + + if int(bitrate_max) >= self.bitrate_default: + self.bitrate_max = int(bitrate_max) + else: + self.bitrate_max = self.bitrate_default + + if bitrate_steps_individual is None: + self.bitrate_steps_individual = None + else: + self.bitrate_steps_individual = int(bitrate_steps_individual) + + if required is not None: + self.required = required + else: + self.required = True + + self.bitrate_factor = None + + + def __str__(self): + """Display the encoding profile informations + :return: human readable string describing an encoding profil object + :rtype: str + """ + return "{}x{}, bitrate_default={}, bitrate_min={}, bitrate_max={}, bitrate_steps_individual{}, bitrate_factor={}, required={}".format(self.width, self.height, self.bitrate_default, self.bitrate_min, self.bitrate_max, self.bitrate_steps_individual, self.bitrate_factor, self.required) + + + def get_json(self): + """Return object details in json + :return: json object describing the encoding profile and the configured constraints + :rtype: str + """ + profile = {} + profile['width'] = self.width + profile['height'] = self.height + profile['bitrate'] = self.bitrate_default + profile['constraints'] = {} + profile['constraints']['bitrate_min'] = self.bitrate_min + profile['constraints']['bitrate_max'] = self.bitrate_max + profile['constraints']['bitrate_factor'] = self.bitrate_factor + profile['constraints']['required'] = self.required + return json.dumps(profile) + + + def set_bitrate_factor(self, ladder_max_bitrate): + """Set the bitrate factor from the max bitrate in the encoding ladder""" + self.bitrate_factor = ladder_max_bitrate/self.bitrate_default + + + + +class EncodingLadder(object): + """This class defines an over-the-top encoding ladder template""" + + + def __init__(self, encoding_profile_list): + """EncodingLadder initialization + + :param encoding_profile_list: A list of multiple encoding profiles + :type encoding_profile_list: per_title.EncodingProfile[] + """ + #call: LADDER = pta.EncodingLadder(PROFILE_LIST) + + self.encoding_profile_list = encoding_profile_list + self.calculate_bitrate_factors() + + + def __str__(self): + """Display the encoding ladder informations + :return: human readable string describing the encoding ladder template + :rtype: str + """ + string = "{} encoding profiles\n".format(len(self.encoding_profile_list)) + for encoding_profile in self.encoding_profile_list: + string += str(encoding_profile) + "\n" + return string + + + def get_json(self): + """Return object details in json + :return: json object describing the encoding ladder template + :rtype: str + """ + ladder = {} + ladder['overall_bitrate_ladder'] = self.get_overall_bitrate() + ladder['encoding_profiles'] = [] + for encoding_profile in self.encoding_profile_list: + ladder['encoding_profiles'].append(json.loads(encoding_profile.get_json())) + return json.dumps(ladder) + + + def get_max_bitrate(self): + """Get the max bitrate in the ladder + :return: The maximum bitrate into the encoding laddder template + :rtype: int + """ + ladder_max_bitrate = 0 + for encoding_profile in self.encoding_profile_list: + if encoding_profile.bitrate_default > ladder_max_bitrate: + ladder_max_bitrate = encoding_profile.bitrate_default + return ladder_max_bitrate + + def get_overall_bitrate(self): + """Get the overall bitrate for the ladder + :return: The sum of all bitrate profiles into the encoding laddder template + :rtype: int + """ + ladder_overall_bitrate = 0 + for encoding_profile in self.encoding_profile_list: + ladder_overall_bitrate += encoding_profile.bitrate_default + return ladder_overall_bitrate + + def calculate_bitrate_factors(self): #cf plus haut ! + """Calculate the bitrate factor for each profile""" + ladder_max_bitrate = self.get_max_bitrate() + for encoding_profile in self.encoding_profile_list: + encoding_profile.set_bitrate_factor(ladder_max_bitrate) + + + +class Analyzer(object): + """This class defines a Per-Title Analyzer""" + + def __init__(self, input_file_path, encoding_ladder): + """Analyzer initialization + :param input_file_path: The input video file path + :type input_file_path: str + :param encoding_ladder: An EncodingLadder object + :type encoding_ladder: per_title.EncodingLadder + """ + self.input_file_path = input_file_path + self.encoding_ladder = encoding_ladder + + self.average_bitrate = None + self.standard_deviation = None + self.optimal_bitrate = None + self.peak_bitrate = None + + # init json result + self.json = {} + self.json['input_file_path'] = self.input_file_path + self.json['template_encoding_ladder'] = json.loads(self.encoding_ladder.get_json()) + self.json['analyses'] = [] + + + def __str__(self): + """Display the per title analysis informations + :return: human readable string describing all analyzer configuration + :rtype: str + """ + string = "Per-Title Analysis for: {}\n".format(self.input_file_path) + string += str(self.encoding_ladder) + return string + + def get_json(self): + """Return object details in json + :return: json object describing all inputs configuration and output analyses + :rtype: str + """ + return json.dumps(self.json, indent=4, sort_keys=True) + + + +class CrfAnalyzer(Analyzer): + """This class defines a Per-Title Analyzer based on calculating the top bitrate wit CRF, then deducting the ladder""" + + + def set_bitrate(self,number_of_parts): + """In linear mode, optimal_bitrates are defined from the first analysis thanks to the bitrate_factor + : print results in linear mode for CRF analyzer + """ + + overall_bitrate_optimal = 0 + for encoding_profile in self.encoding_ladder.encoding_profile_list: + target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) + remove_profile = False + if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: + remove_profile = True + + if target_bitrate < encoding_profile.bitrate_min: + target_bitrate = encoding_profile.bitrate_min + + if target_bitrate > encoding_profile.bitrate_max: + target_bitrate = encoding_profile.bitrate_max + + if remove_profile is False: + overall_bitrate_optimal += target_bitrate + + print(' ',encoding_profile.width,'x',encoding_profile.height,' ',target_bitrate*1e-3,'kbps linear',' / nbr part:',number_of_parts,' ') + + + def process(self, number_of_parts, width, height, crf_value, idr_interval, model): + """Do the necessary crf encodings and assessments + :param number_of_parts: Number of part/segment for the analysis + :type number_of_parts: int + :param width: Width of the CRF encode + :type width: int + :param height: Height of the CRF encode + :type height: int + :param crf_value: Constant Rate Factor: this is a constant quality factor, see ffmpeg.org for more documentation on this parameter + :type crf_value: int + :param idr_interval: IDR interval in seconds + :type idr_interval: int + :param model: linear (True) or for each (False) + :type model: bool + """ + + # Start by probing the input video file + input_probe = Probe(self.input_file_path) + input_probe.execute() + + crf_bitrate_list = [] + part_duration = input_probe.duration/number_of_parts + idr_interval_frames = idr_interval*input_probe.framerate #rcl: An IDR frame is a special type of I-frame in H.264. An IDR frame specifies that no frame after the IDR frame can reference any frame before it. This makes seeking the H.264 file easier and more responsive in the player. + #As I have an IDR_FRAME every 2 seconds, I can find out the number of frame between two IDR using framerate ! + + # Start Analysis + for i in range(0,number_of_parts): + part_start_time = i*part_duration #select extracts to encode + + # Do a CRF encode for the input file + crf_encode = CrfEncode(self.input_file_path, width, height, crf_value, idr_interval_frames, part_start_time, part_duration) + crf_encode.execute() + + # Get the Bitrate from the CRF encoded file + crf_probe = Probe(crf_encode.output_file_path) + crf_probe.execute() + + # Remove temporary CRF encoded file + os.remove(crf_encode.output_file_path) + + # Set the crf bitrate + crf_bitrate_list.append(crf_probe.bitrate) + + # Calculate the average bitrate for all CRF encodings + self.average_bitrate = statistics.mean(crf_bitrate_list) + self.peak_bitrate = max(crf_bitrate_list) + + if number_of_parts > 1: + # Calculate the the standard deviation of crf bitrate values + self.standard_deviation = statistics.stdev(crf_bitrate_list) + + weight = 1 + weighted_bitrate_sum = 0 + weighted_bitrate_len = 0 + + # Giving weight for each bitrate based on the standard deviation + for bitrate in crf_bitrate_list: + if bitrate > (self.average_bitrate + self.standard_deviation): + weight = 4 + elif bitrate > (self.average_bitrate + self.standard_deviation/2): + weight = 2 + elif bitrate < (self.average_bitrate - self.standard_deviation/2): + weight = 0.5 + elif bitrate < (self.average_bitrate - self.standard_deviation): + weight = 0 + else: + weight = 1 + + weighted_bitrate_sum += weight*bitrate + weighted_bitrate_len += weight + + # Set the optimal bitrate from the weighted bitrate of all crf encoded parts + self.optimal_bitrate = weighted_bitrate_sum/weighted_bitrate_len + + else: + # Set the optimal bitrate from the only one crf result + self.optimal_bitrate = self.average_bitrate + + if not model: + print(' ',width,'x',height,' ',self.optimal_bitrate*1e-3,'kbps encode_for_each','/ nbr part:',number_of_parts,' ') + + if model: + # We calculate optimal bitrate of the the remaining profiles using bitrate factor + self.set_bitrate(number_of_parts) + + # Adding results to json + result = {} + result['processing_date'] = str(datetime.datetime.now()) + result['parameters'] = {} + result['parameters']['method'] = "CRF" + result['parameters']['width'] = width + result['parameters']['height'] = height + result['parameters']['crf_value'] = crf_value + result['parameters']['idr_interval'] = idr_interval + result['parameters']['number_of_parts'] = number_of_parts + result['parameters']['part_duration'] = part_duration + result['bitrate'] = {} + result['bitrate']['optimal'] = self.optimal_bitrate + result['bitrate']['average'] = self.average_bitrate + result['bitrate']['peak'] = self.average_bitrate + result['bitrate']['standard_deviation'] = self.standard_deviation + result['optimized_encoding_ladder'] = {} + if model == "True": + result['optimized_encoding_ladder']['model'] = "linear" + if model == "False": + result['optimized_encoding_ladder']['model'] = "encode_for_each" + + self.json['analyses'].append(result) + + +class MetricAnalyzer(Analyzer): + """This class defines a Per-Title Analyzer based on VQ Metric and Multiple bitrate encodes""" + + def process(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required): + """Do the necessary encodings and quality metric assessments + :param metric: Supporting "ssim" or "psnr" + :type metric: string + :param limit_metric: limit value of "ssim" or "psnr" use to find optimal bitrate + :type limit_metric: int + :param bitrate_steps_by_default: Bitrate gap between every encoding, only use if steps_individual_bitrate_required is False + :type bitrate_steps_by_default: int + :param idr_interval: IDR interval in seconds + :type idr_interval: int + :param steps_individual_bitrate_required: The step is the same for each profile and cannot be set individually if False + :type steps_individual_bitrate_required: bool + """ + + # Start by probing the input video file + input_probe = Probe(self.input_file_path) + input_probe.execute() + + part_start_time = 0 + part_duration = input_probe.duration + idr_interval_frames = idr_interval*input_probe.framerate + metric = str(metric).strip().lower() + + #Create two lists for GRAPH 2 + optimal_bitrate_array = [] + default_bitrate_array = [] + + print('\n********************************\n********Encoding Started********\n********************************\n') + print('File Selected: ', os.path.basename(self.input_file_path)) + + # Adding results to json + json_ouput = {} + json_ouput['processing_date'] = str(datetime.datetime.now()) + json_ouput['parameters'] = {} + json_ouput['parameters']['method'] = "Metric" + json_ouput['parameters']['metric'] = metric + json_ouput['parameters']['bitrate_steps'] = bitrate_steps_by_default + json_ouput['parameters']['idr_interval'] = idr_interval + json_ouput['parameters']['number_of_parts'] = 1 + json_ouput['parameters']['part_duration'] = part_duration + json_ouput['optimized_encoding_ladder'] = {} + json_ouput['optimized_encoding_ladder']['encoding_profiles'] = [] + + + # Start Analysis + for encoding_profile in self.encoding_ladder.encoding_profile_list: + + profile = {} + profile['width'] = encoding_profile.width + profile['height'] = encoding_profile.height + profile['cbr_encodings'] = [] + profile['optimal_bitrate'] = None + + default_bitrate_array.append(encoding_profile.bitrate_default) + + if steps_individual_bitrate_required: + bitrate_steps_by_default = encoding_profile.bitrate_steps_individual + print('\n\n __________________________________________') + print(' The bitrate_step is: ',bitrate_steps_by_default*10**(-3),'kbps') + print('\n |||',encoding_profile.width, 'x', encoding_profile.height,'|||\n') + + last_metric_value = 0 + last_quality_step_ratio = 0 + bitrate_array = [] + quality_array = [] + + + for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps_by_default), bitrate_steps_by_default): + # Do a CBR encode for the input file + cbr_encode = CbrEncode(self.input_file_path, encoding_profile.width, encoding_profile.height, bitrate, idr_interval_frames, part_start_time, part_duration) + cbr_encode.execute() + print('cbr_encode -> in progress -> ->') + + # Get the Bitrate from the CBR encoded file + metric_assessment = Metric(metric, cbr_encode.output_file_path, self.input_file_path, input_probe.width, input_probe.height) + metric_assessment.execute() + print('-> -> probe |>', bitrate*10**(-3),'kbps |>',metric,' = ',metric_assessment.output_value, '\n') + + # Remove temporary CBR encoded file + os.remove(cbr_encode.output_file_path) + + + # OLD method to find optimal bitrate_min + + # if last_metric_value is 0 : + # # for first value, you cannot calculate acurate jump in quality from nothing + # last_metric_value = metric_assessment.output_value + # profile['optimal_bitrate'] = bitrate + # quality_step_ratio = (metric_assessment.output_value)/bitrate # first step from null to the starting bitrate + # else: + # quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps_by_default + # + # if quality_step_ratio >= (last_quality_step_ratio/2): + # profile['optimal_bitrate'] = bitrate + + # if 'ssim' in metric: + # if metric_assessment.output_value >= (last_metric_value + 0.01): + # profile['optimal_bitrate'] = bitrate + # elif 'psnr' in metric: + # if metric_assessment.output_value > last_metric_value: + # profile['optimal_bitrate'] = bitrate + + # last_metric_value = metric_assessment.output_value + # last_quality_step_ratio = quality_step_ratio + + # New method + bitrate_array.append(bitrate) # All bitrate for one profile + print(bitrate_array) + quality_array.append(metric_assessment.output_value) #pour un profile on a toutes les qualités + print(quality_array) + + + #**************GRAPH 1 matplotlib************* + # Initialize + diff_bitrate_array=1 # X + diff_quality_array=0 # Y + taux_accroissement=1 + + #Curve + figure(1) + plot(bitrate_array, quality_array, label=str(encoding_profile.width)+'x'+str(encoding_profile.height)) + xlabel('bitrate (bps)') + ylabel("quality: "+str(metric).upper()) + title(str(self.input_file_path)) + + # Rate of change and find out the optimal bitrate in the array + for j in range(0, len(quality_array)-1): + diff_quality_array=quality_array[j+1]-quality_array[j] + diff_bitrate_array=bitrate_array[j+1]-bitrate_array[j] + + #limited_evolution_metric=0.005 -> indication: set arround 0.1 for psnr with a 100000 bps bitrate step and 0.05 with a 50000 bitrate step for ssim + limited_evolution_metric=limit_metric + + taux_accroissement = diff_quality_array/diff_bitrate_array + + encoding = {} + encoding['bitrate'] = bitrate_array[j] + encoding['metric_value'] = quality_array[j] + encoding['quality_step_ratio'] = taux_accroissement + profile['cbr_encodings'].append(encoding) + + if taux_accroissement <= limited_evolution_metric/bitrate_steps_by_default: + #scatter(bitrate_array[j], quality_array[j]) # I found out the good point + break + + # Display good values ! + print ('\nI found the best values for ||--- ', str(encoding_profile.width)+'x'+str(encoding_profile.height),' ---|| >> ',metric,':',quality_array[j],'| bitrate = ',bitrate_array[j]*10**(-3),'kbps') + optimal_bitrate_array.append(bitrate_array[j]) # use in GRAPH 2 + profile['optimal_bitrate'] = bitrate_array[j] + profile['bitrate_savings'] = encoding_profile.bitrate_default - profile['optimal_bitrate'] + + # Graph annotations + annotation=str(bitrate_array[j]*1e-3)+' kbps' + #plot([bitrate_array[j],bitrate_array[j]], [0, quality_array[j]], linestyle='--' ) + annotate(annotation, xy=(bitrate_array[j], quality_array[j]), xycoords='data', xytext=(+1, +20), textcoords='offset points', fontsize=8, arrowprops=dict(arrowstyle="->", connectionstyle="arc,rad=0.2")) + #plot([0, bitrate_array[j]], [quality_array[j], quality_array[j]], linestyle='--' ) + scatter(bitrate_array[j], quality_array[j], s=7) + grid() + legend() + draw() + show(block=False) + pause(0.001) + + + #save graph1 and plot graph2 + name=str(os.path.basename(self.input_file_path)) + input("\n\n\nPress [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' !") + newpath = str(os.getcwd())+"/results/%s" % (name) + #newpath = '/home/labo/Documents/per_title_analysis/results/%s' % (name) + if not os.path.exists(newpath): + os.makedirs(newpath) + plt.savefig(newpath+"/%s-%s-%s-Per_Title.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + + bitrate_data = [list(i) for i in zip(optimal_bitrate_array, default_bitrate_array)] + + # GRAH 2 Computation + figure(2) + columns = ('Dynamic (kbps)', 'Fix (kbps)') + rows = ['%s' % resolution for resolution in ('1920 x 1080', '1280 x 720', '960 x 540', '640 x 360', '480 x 270')] + + ylabel("bitrate (bps)") + title(str(self.input_file_path)) + + # Get some pastel shades for the colors + colors = plt.cm.YlOrBr(np.linspace(0.35, 0.8, len(rows))) + #size and positions + n_rows = len(bitrate_data)-1 + index = np.arange(len(columns)) + 0.3 + bar_width = 0.5 + + # Initialize the vertical-offset for the stacked bar chart. + y_offset = np.zeros(len(columns)) + + # Plot bars and create text labels for the table + cell_text = [] + for row in range(n_rows+1): # until n_rows + plt.bar(index, bitrate_data[n_rows-row], bar_width, bottom=y_offset, color=colors[row]) + y_offset = y_offset + bitrate_data[n_rows-row] + print('this is y_offset',y_offset) + cell_text.append(['%1.1f' % (x / 1000.0) for x in bitrate_data[n_rows-row]]) + # Reverse colors and text labels to display the last value at the top. + colors = colors[::-1] + cell_text.reverse() + + + # Add a table at the bottom of the axes + the_table = plt.table(cellText=cell_text, + rowLabels=rows, + rowColours=colors, + colLabels=columns, + loc='bottom') + + # Adjust layout to make room for the table: + plt.subplots_adjust(left=0.5, bottom=0.2) + + #plt.ylabel("Loss in ${0}'s".format(value_increment)) + #plt.yticks(values * value_increment, ['%d' % val for val in values]) + plt.xticks([]) + #plt.title('Loss by Disaster') + + show(block=False) + pause(0.001) + print('\n\n->->\nloading graphic Histogram\n->->\n\n') + input("Press [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' ") + plt.savefig(newpath+"/%s-%s-%s-Per_Title_Histogram.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + print('\n\n\n************ALL DONE********** !\n\n') diff --git a/task_providers.py b/task_providers.py new file mode 100644 index 0000000..cfa63db --- /dev/null +++ b/task_providers.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- + +import os +import json +import subprocess +import uuid + +class Task(object): + """This class defines a processing task""" + + def __init__(self, input_file_path): + """Task initialization + + :param input_file_path: The input video file path + :type input_file_path: str + """ + if os.path.isfile(input_file_path) is True: + self.input_file_path = input_file_path + else: + raise ValueError('Cannot access the file: {}'.format(input_file_path)) + + self.subprocess_pid = None + self.subprocess_out = None + self.subprocess_err = None + + def execute(self, command): + """Launch a subprocess task + + :param command: Arguments array for the subprocess task + :type command: str[] + """ + proc = subprocess.Popen(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE) + self.subprocess_pid = proc.pid + + try: + self.subprocess_out, self.subprocess_err = proc.communicate() + except: + print(self.subprocess_err) + # TODO: error management + + +class Probe(Task): + """This class defines a Probing task""" + + def __init__(self, input_file_path): + """Probe initialization + + :param input_file_path: The input video file path + :type input_file_path: str + """ + Task.__init__(self, input_file_path) + + self.width = None + self.height = None + self.bitrate = None + self.duration = None + self.video_codec = None + self.framerate = None + + def execute(self): + """Using FFprobe to get input video file informations""" + command = ['ffprobe', + '-hide_banner', + '-i', self.input_file_path, + '-show_format', '-show_streams', + '-print_format', 'json'] + Task.execute(self, command) + + # Parse output data + try: + response = self.subprocess_out + data = json.loads(response.decode('utf-8')) + for stream in data['streams']: + if stream['codec_type'] == 'video': + self.width = int(stream['width']) + #print('the probe',self.width) + self.height = int(stream['height']) + self.duration = float(stream['duration']) + self.video_codec = stream['codec_name'] + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace + self.bitrate = float(stream['bit_rate']) #error + self.video_codec = stream['codec_name'] + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace + except: + + # TODO: error management + pass + + +class CrfEncode(Task): + """This class defines a CRF encoding task""" + + def __init__(self, input_file_path, width, height, crf_value, idr_interval, part_start_time, part_duration): + """CrfEncode initialization + + :param input_file_path: The input video file path + :type input_file_path: str + :param width: Width of the CRF encode + :type width: int + :param height: Height of the CRF encode + :type height: int + :param crf_value: The CRF Encoding value for ffmpeg + :type crf_value: int + :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) + :type idr_interval: int + :param part_start_time: Encode seek start time (in seconds) + :type part_start_time: float + :param part_duration: Encode duration (in seconds) + :type part_duration: float + """ + Task.__init__(self, input_file_path) + + self.definition = str(width)+'x'+str(height) + self.crf_value = crf_value + self.idr_interval = idr_interval + self.part_start_time = part_start_time + self.part_duration = part_duration + + # Generate a temporary file name for the task output + self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), + os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") + # print(self.output_file_path) + # print(self.part_start_time) + # print(self.input_file_path) + # print(self.part_duration) + # print(self.crf_value) + # print(self.definition) + # print(self.idr_interval) + + def execute(self): + """Using FFmpeg to CRF Encode a file or part of a file""" + command = ['ffmpeg', + '-hide_banner', '-loglevel', 'quiet', '-nostats', + '-ss', str(self.part_start_time), + '-i', self.input_file_path, + '-t', str(self.part_duration), + '-preset', 'ultrafast', + '-an', '-deinterlace', + '-crf', str(self.crf_value), + '-pix_fmt', 'yuv420p', + '-s', self.definition, + '-x264opts', 'keyint=' + str(self.idr_interval), + '-y', self.output_file_path] + Task.execute(self, command) + + +class CbrEncode(Task): + """This class defines a CBR encoding task""" + + def __init__(self, input_file_path, width, height, cbr_value, idr_interval, part_start_time, part_duration): + """CrfEncode initialization + + :param input_file_path: The input video file path + :type input_file_path: str + :param width: Width of the CBR encode + :type width: int + :param height: Height of the CBR encode + :type height: int + :param cbr_value: The CBR Encoding value for ffmpeg + :type cbr_value: int + :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) + :type idr_interval: int + :param part_start_time: Encode seek start time (in seconds) + :type part_start_time: float + :param part_duration: Encode duration (in seconds) + :type part_duration: float + """ + Task.__init__(self, input_file_path) + + self.definition = str(width)+'x'+str(height) + self.cbr_value = cbr_value + self.idr_interval = idr_interval + self.part_start_time = part_start_time + self.part_duration = part_duration + + # Generate a temporary file name for the task output + self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), + os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") + + def execute(self): + """Using FFmpeg to CRF Encode a file or part of a file""" + command = ['ffmpeg', + '-hide_banner', '-loglevel', 'quiet', '-nostats', + '-ss', str(self.part_start_time), + '-i', self.input_file_path, + '-t', str(self.part_duration), + '-c:v', 'libx264', + '-an', '-deinterlace', + '-b:v', str(self.cbr_value), + '-pix_fmt', 'yuv420p', + '-s', self.definition, + '-x264opts', 'keyint=' + str(self.idr_interval), + '-y', self.output_file_path] + Task.execute(self, command) + + +class Metric(Task): + """This class defines a Probing task""" + + def __init__(self, metric, input_file_path, ref_file_path, ref_width, ref_height): + """Probe initialization + + :param metric: Supporting "ssim" or "psnr" + :type metric: string + :param input_file_path: The input video file path, the one to be analyzed + :type input_file_path: str + :param ref_file_path: The reference video file path + :type ref_file_path: str + + """ + Task.__init__(self, input_file_path) + + if os.path.isfile(ref_file_path) is True: + self.ref_file_path = ref_file_path + self.ref_width = ref_width + self.ref_height = ref_height + else: + raise ValueError('Cannot access the file: {}'.format(ref_file_path)) + + available_metrics = ['ssim', 'psnr'] + self.metric = str(metric).strip().lower() + if self.metric not in available_metrics: + raise ValueError('Available metrics are "ssim" and "psnr", does not include: {}'.format(metric)) + + self.output_value = None + + def execute(self): + """Using FFmpeg to process metric assessments""" + command = ['ffmpeg', + '-hide_banner', + '-i', self.input_file_path, + '-i', self.ref_file_path, + '-lavfi', '[0]scale='+str(self.ref_width)+':'+str(self.ref_height)+'[scaled];[scaled][1]'+str(self.metric)+'=stats_file=-', + '-f', 'null', '-'] + Task.execute(self, command) + + # Parse output data + try: + data = self.subprocess_err.splitlines() + for line in data: + line = str(line) + if 'Parsed_ssim' in line: + self.output_value = float(line.split('All:')[1].split('(')[0].strip()) + elif 'Parsed_psnr' in line: + self.output_value = float(line.split('average:')[1].split('min:')[0].strip()) + + except: + # TODO: error management + pass From 4898154b699f23175a497944b01cb6568bb556b3 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:51:32 +0200 Subject: [PATCH 08/25] Delete __init__.py --- __init__.py | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 __init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index 415656c..0000000 --- a/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# -*- coding: utf8 -*- -""" - pertitleanalysis - ----- - A smart and simple Per-Title video analysis tool for optimizing your over-the-top encoding ladder - :copyright: (c) 2018 by Antoine Henning & Thom Marin. - :license: MIT, see LICENSE for more details. -""" - -__title__ = 'pertitleanalysis' -__author__ = 'Antoine Henning, Thom Marin' -__version__ = '0.2-dev' From 8bcef546270a4236cf4982f7241c8d5ce0663e34 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:51:45 +0200 Subject: [PATCH 09/25] Delete per_title_analysis.py --- per_title_analysis.py | 592 ------------------------------------------ 1 file changed, 592 deletions(-) delete mode 100644 per_title_analysis.py diff --git a/per_title_analysis.py b/per_title_analysis.py deleted file mode 100644 index 79544eb..0000000 --- a/per_title_analysis.py +++ /dev/null @@ -1,592 +0,0 @@ -# -*- coding: utf-8 -*- - -#importation - -from __future__ import division -from pylab import * -import sys -import os -import json -import datetime -import statistics -import matplotlib.pyplot as plt -import matplotlib.animation as animation - -from task_providers import Probe, CrfEncode, CbrEncode, Metric - - - -class EncodingProfile(object): - """This class defines an encoding profile""" - - - def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual): - """EncodingProfile initialization - - :param width: Video profile width - :type width: int - :param height: Video profile height - :type height: int - :param bitrate_default: Video profile bitrate default (in bits per second) - :type bitrate_default: int - :param bitrate_min: Video profile bitrate min constraint (in bits per second) - :type bitrate_min: int - :param bitrate_max: Video profile bitrate max constraint (in bits per second) - :type bitrate_max: int - :param required: The video profile is required and cannot be removed from the optimized encoding ladder - :type required: bool - :param bitrate_steps_individual: Step Bitrate Range defined for each Video profile (in bits per second) - :type bitrate_steps_individual: int - """ - #call: PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True, 150000)) - - - if width is None: - raise ValueError('The EncodingProfile.width value is required') - else: - self.width = int(width) - - if height is None: - raise ValueError('The EncodingProfile.height value is required') - else: - self.height = int(height) - - if bitrate_default is None: - raise ValueError('The EncodingProfile.bitrate_default value is required') - else: - self.bitrate_default = int(bitrate_default) - - if int(bitrate_min) <= self.bitrate_default: - self.bitrate_min = int(bitrate_min) - else: - self.bitrate_min = self.bitrate_default - - if int(bitrate_max) >= self.bitrate_default: - self.bitrate_max = int(bitrate_max) - else: - self.bitrate_max = self.bitrate_default - - if bitrate_steps_individual is None: - self.bitrate_steps_individual = None - else: - self.bitrate_steps_individual = int(bitrate_steps_individual) - - if required is not None: - self.required = required - else: - self.required = True - - self.bitrate_factor = None - - - def __str__(self): - """Display the encoding profile informations - :return: human readable string describing an encoding profil object - :rtype: str - """ - return "{}x{}, bitrate_default={}, bitrate_min={}, bitrate_max={}, bitrate_steps_individual{}, bitrate_factor={}, required={}".format(self.width, self.height, self.bitrate_default, self.bitrate_min, self.bitrate_max, self.bitrate_steps_individual, self.bitrate_factor, self.required) - - - def get_json(self): - """Return object details in json - :return: json object describing the encoding profile and the configured constraints - :rtype: str - """ - profile = {} - profile['width'] = self.width - profile['height'] = self.height - profile['bitrate'] = self.bitrate_default - profile['constraints'] = {} - profile['constraints']['bitrate_min'] = self.bitrate_min - profile['constraints']['bitrate_max'] = self.bitrate_max - profile['constraints']['bitrate_factor'] = self.bitrate_factor - profile['constraints']['required'] = self.required - return json.dumps(profile) - - - def set_bitrate_factor(self, ladder_max_bitrate): - """Set the bitrate factor from the max bitrate in the encoding ladder""" - self.bitrate_factor = ladder_max_bitrate/self.bitrate_default - - - - -class EncodingLadder(object): - """This class defines an over-the-top encoding ladder template""" - - - def __init__(self, encoding_profile_list): - """EncodingLadder initialization - - :param encoding_profile_list: A list of multiple encoding profiles - :type encoding_profile_list: per_title.EncodingProfile[] - """ - #call: LADDER = pta.EncodingLadder(PROFILE_LIST) - - self.encoding_profile_list = encoding_profile_list - self.calculate_bitrate_factors() - - - def __str__(self): - """Display the encoding ladder informations - :return: human readable string describing the encoding ladder template - :rtype: str - """ - string = "{} encoding profiles\n".format(len(self.encoding_profile_list)) - for encoding_profile in self.encoding_profile_list: - string += str(encoding_profile) + "\n" - return string - - - def get_json(self): - """Return object details in json - :return: json object describing the encoding ladder template - :rtype: str - """ - ladder = {} - ladder['overall_bitrate_ladder'] = self.get_overall_bitrate() - ladder['encoding_profiles'] = [] - for encoding_profile in self.encoding_profile_list: - ladder['encoding_profiles'].append(json.loads(encoding_profile.get_json())) - return json.dumps(ladder) - - - def get_max_bitrate(self): - """Get the max bitrate in the ladder - :return: The maximum bitrate into the encoding laddder template - :rtype: int - """ - ladder_max_bitrate = 0 - for encoding_profile in self.encoding_profile_list: - if encoding_profile.bitrate_default > ladder_max_bitrate: - ladder_max_bitrate = encoding_profile.bitrate_default - return ladder_max_bitrate - - def get_overall_bitrate(self): - """Get the overall bitrate for the ladder - :return: The sum of all bitrate profiles into the encoding laddder template - :rtype: int - """ - ladder_overall_bitrate = 0 - for encoding_profile in self.encoding_profile_list: - ladder_overall_bitrate += encoding_profile.bitrate_default - return ladder_overall_bitrate - - def calculate_bitrate_factors(self): #cf plus haut ! - """Calculate the bitrate factor for each profile""" - ladder_max_bitrate = self.get_max_bitrate() - for encoding_profile in self.encoding_profile_list: - encoding_profile.set_bitrate_factor(ladder_max_bitrate) - - - -class Analyzer(object): - """This class defines a Per-Title Analyzer""" - - def __init__(self, input_file_path, encoding_ladder): - """Analyzer initialization - :param input_file_path: The input video file path - :type input_file_path: str - :param encoding_ladder: An EncodingLadder object - :type encoding_ladder: per_title.EncodingLadder - """ - self.input_file_path = input_file_path - self.encoding_ladder = encoding_ladder - - self.average_bitrate = None - self.standard_deviation = None - self.optimal_bitrate = None - self.peak_bitrate = None - - # init json result - self.json = {} - self.json['input_file_path'] = self.input_file_path - self.json['template_encoding_ladder'] = json.loads(self.encoding_ladder.get_json()) - self.json['analyses'] = [] - - - def __str__(self): - """Display the per title analysis informations - :return: human readable string describing all analyzer configuration - :rtype: str - """ - string = "Per-Title Analysis for: {}\n".format(self.input_file_path) - string += str(self.encoding_ladder) - return string - - def get_json(self): - """Return object details in json - :return: json object describing all inputs configuration and output analyses - :rtype: str - """ - return json.dumps(self.json, indent=4, sort_keys=True) - - - -class CrfAnalyzer(Analyzer): - """This class defines a Per-Title Analyzer based on calculating the top bitrate wit CRF, then deducting the ladder""" - - - def set_bitrate(self,number_of_parts): - """In linear mode, optimal_bitrates are defined from the first analysis thanks to the bitrate_factor - : print results in linear mode for CRF analyzer - """ - - overall_bitrate_optimal = 0 - for encoding_profile in self.encoding_ladder.encoding_profile_list: - target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) - remove_profile = False - if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: - remove_profile = True - - if target_bitrate < encoding_profile.bitrate_min: - target_bitrate = encoding_profile.bitrate_min - - if target_bitrate > encoding_profile.bitrate_max: - target_bitrate = encoding_profile.bitrate_max - - if remove_profile is False: - overall_bitrate_optimal += target_bitrate - - print(' ',encoding_profile.width,'x',encoding_profile.height,' ',target_bitrate*1e-3,'kbps linear',' / nbr part:',number_of_parts,' ') - - - def process(self, number_of_parts, width, height, crf_value, idr_interval, model): - """Do the necessary crf encodings and assessments - :param number_of_parts: Number of part/segment for the analysis - :type number_of_parts: int - :param width: Width of the CRF encode - :type width: int - :param height: Height of the CRF encode - :type height: int - :param crf_value: Constant Rate Factor: this is a constant quality factor, see ffmpeg.org for more documentation on this parameter - :type crf_value: int - :param idr_interval: IDR interval in seconds - :type idr_interval: int - :param model: linear (True) or for each (False) - :type model: bool - """ - - # Start by probing the input video file - input_probe = Probe(self.input_file_path) - input_probe.execute() - - crf_bitrate_list = [] - part_duration = input_probe.duration/number_of_parts - idr_interval_frames = idr_interval*input_probe.framerate #rcl: An IDR frame is a special type of I-frame in H.264. An IDR frame specifies that no frame after the IDR frame can reference any frame before it. This makes seeking the H.264 file easier and more responsive in the player. - #As I have an IDR_FRAME every 2 seconds, I can find out the number of frame between two IDR using framerate ! - - # Start Analysis - for i in range(0,number_of_parts): - part_start_time = i*part_duration #select extracts to encode - - # Do a CRF encode for the input file - crf_encode = CrfEncode(self.input_file_path, width, height, crf_value, idr_interval_frames, part_start_time, part_duration) - crf_encode.execute() - - # Get the Bitrate from the CRF encoded file - crf_probe = Probe(crf_encode.output_file_path) - crf_probe.execute() - - # Remove temporary CRF encoded file - os.remove(crf_encode.output_file_path) - - # Set the crf bitrate - crf_bitrate_list.append(crf_probe.bitrate) - - # Calculate the average bitrate for all CRF encodings - self.average_bitrate = statistics.mean(crf_bitrate_list) - self.peak_bitrate = max(crf_bitrate_list) - - if number_of_parts > 1: - # Calculate the the standard deviation of crf bitrate values - self.standard_deviation = statistics.stdev(crf_bitrate_list) - - weight = 1 - weighted_bitrate_sum = 0 - weighted_bitrate_len = 0 - - # Giving weight for each bitrate based on the standard deviation - for bitrate in crf_bitrate_list: - if bitrate > (self.average_bitrate + self.standard_deviation): - weight = 4 - elif bitrate > (self.average_bitrate + self.standard_deviation/2): - weight = 2 - elif bitrate < (self.average_bitrate - self.standard_deviation/2): - weight = 0.5 - elif bitrate < (self.average_bitrate - self.standard_deviation): - weight = 0 - else: - weight = 1 - - weighted_bitrate_sum += weight*bitrate - weighted_bitrate_len += weight - - # Set the optimal bitrate from the weighted bitrate of all crf encoded parts - self.optimal_bitrate = weighted_bitrate_sum/weighted_bitrate_len - - else: - # Set the optimal bitrate from the only one crf result - self.optimal_bitrate = self.average_bitrate - - if not model: - print(' ',width,'x',height,' ',self.optimal_bitrate*1e-3,'kbps encode_for_each','/ nbr part:',number_of_parts,' ') - - if model: - # We calculate optimal bitrate of the the remaining profiles using bitrate factor - self.set_bitrate(number_of_parts) - - # Adding results to json - result = {} - result['processing_date'] = str(datetime.datetime.now()) - result['parameters'] = {} - result['parameters']['method'] = "CRF" - result['parameters']['width'] = width - result['parameters']['height'] = height - result['parameters']['crf_value'] = crf_value - result['parameters']['idr_interval'] = idr_interval - result['parameters']['number_of_parts'] = number_of_parts - result['parameters']['part_duration'] = part_duration - result['bitrate'] = {} - result['bitrate']['optimal'] = self.optimal_bitrate - result['bitrate']['average'] = self.average_bitrate - result['bitrate']['peak'] = self.average_bitrate - result['bitrate']['standard_deviation'] = self.standard_deviation - result['optimized_encoding_ladder'] = {} - if model == "True": - result['optimized_encoding_ladder']['model'] = "linear" - if model == "False": - result['optimized_encoding_ladder']['model'] = "encode_for_each" - - self.json['analyses'].append(result) - - -class MetricAnalyzer(Analyzer): - """This class defines a Per-Title Analyzer based on VQ Metric and Multiple bitrate encodes""" - - def process(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required): - """Do the necessary encodings and quality metric assessments - :param metric: Supporting "ssim" or "psnr" - :type metric: string - :param limit_metric: limit value of "ssim" or "psnr" use to find optimal bitrate - :type limit_metric: int - :param bitrate_steps_by_default: Bitrate gap between every encoding, only use if steps_individual_bitrate_required is False - :type bitrate_steps_by_default: int - :param idr_interval: IDR interval in seconds - :type idr_interval: int - :param steps_individual_bitrate_required: The step is the same for each profile and cannot be set individually if False - :type steps_individual_bitrate_required: bool - """ - - # Start by probing the input video file - input_probe = Probe(self.input_file_path) - input_probe.execute() - - part_start_time = 0 - part_duration = input_probe.duration - idr_interval_frames = idr_interval*input_probe.framerate - metric = str(metric).strip().lower() - - #Create two lists for GRAPH 2 - optimal_bitrate_array = [] - default_bitrate_array = [] - - print('\n********************************\n********Encoding Started********\n********************************\n') - print('File Selected: ', os.path.basename(self.input_file_path)) - - # Adding results to json - json_ouput = {} - json_ouput['processing_date'] = str(datetime.datetime.now()) - json_ouput['parameters'] = {} - json_ouput['parameters']['method'] = "Metric" - json_ouput['parameters']['metric'] = metric - json_ouput['parameters']['bitrate_steps'] = bitrate_steps_by_default - json_ouput['parameters']['idr_interval'] = idr_interval - json_ouput['parameters']['number_of_parts'] = 1 - json_ouput['parameters']['part_duration'] = part_duration - json_ouput['optimized_encoding_ladder'] = {} - json_ouput['optimized_encoding_ladder']['encoding_profiles'] = [] - - - # Start Analysis - for encoding_profile in self.encoding_ladder.encoding_profile_list: - - profile = {} - profile['width'] = encoding_profile.width - profile['height'] = encoding_profile.height - profile['cbr_encodings'] = [] - profile['optimal_bitrate'] = None - - default_bitrate_array.append(encoding_profile.bitrate_default) - - if steps_individual_bitrate_required: - bitrate_steps_by_default = encoding_profile.bitrate_steps_individual - print('\n\n __________________________________________') - print(' The bitrate_step is: ',bitrate_steps_by_default*10**(-3),'kbps') - print('\n |||',encoding_profile.width, 'x', encoding_profile.height,'|||\n') - - last_metric_value = 0 - last_quality_step_ratio = 0 - bitrate_array = [] - quality_array = [] - - - for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps_by_default), bitrate_steps_by_default): - # Do a CBR encode for the input file - cbr_encode = CbrEncode(self.input_file_path, encoding_profile.width, encoding_profile.height, bitrate, idr_interval_frames, part_start_time, part_duration) - cbr_encode.execute() - print('cbr_encode -> in progress -> ->') - - # Get the Bitrate from the CBR encoded file - metric_assessment = Metric(metric, cbr_encode.output_file_path, self.input_file_path, input_probe.width, input_probe.height) - metric_assessment.execute() - print('-> -> probe |>', bitrate*10**(-3),'kbps |>',metric,' = ',metric_assessment.output_value, '\n') - - # Remove temporary CBR encoded file - os.remove(cbr_encode.output_file_path) - - - # OLD method to find optimal bitrate_min - - # if last_metric_value is 0 : - # # for first value, you cannot calculate acurate jump in quality from nothing - # last_metric_value = metric_assessment.output_value - # profile['optimal_bitrate'] = bitrate - # quality_step_ratio = (metric_assessment.output_value)/bitrate # first step from null to the starting bitrate - # else: - # quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps_by_default - # - # if quality_step_ratio >= (last_quality_step_ratio/2): - # profile['optimal_bitrate'] = bitrate - - # if 'ssim' in metric: - # if metric_assessment.output_value >= (last_metric_value + 0.01): - # profile['optimal_bitrate'] = bitrate - # elif 'psnr' in metric: - # if metric_assessment.output_value > last_metric_value: - # profile['optimal_bitrate'] = bitrate - - # last_metric_value = metric_assessment.output_value - # last_quality_step_ratio = quality_step_ratio - - # New method - bitrate_array.append(bitrate) # All bitrate for one profile - print(bitrate_array) - quality_array.append(metric_assessment.output_value) #pour un profile on a toutes les qualités - print(quality_array) - - - #**************GRAPH 1 matplotlib************* - # Initialize - diff_bitrate_array=1 # X - diff_quality_array=0 # Y - taux_accroissement=1 - - #Curve - figure(1) - plot(bitrate_array, quality_array, label=str(encoding_profile.width)+'x'+str(encoding_profile.height)) - xlabel('bitrate (bps)') - ylabel("quality: "+str(metric).upper()) - title(str(self.input_file_path)) - - # Rate of change and find out the optimal bitrate in the array - for j in range(0, len(quality_array)-1): - diff_quality_array=quality_array[j+1]-quality_array[j] - diff_bitrate_array=bitrate_array[j+1]-bitrate_array[j] - - #limited_evolution_metric=0.005 -> indication: set arround 0.1 for psnr with a 100000 bps bitrate step and 0.05 with a 50000 bitrate step for ssim - limited_evolution_metric=limit_metric - - taux_accroissement = diff_quality_array/diff_bitrate_array - - encoding = {} - encoding['bitrate'] = bitrate_array[j] - encoding['metric_value'] = quality_array[j] - encoding['quality_step_ratio'] = taux_accroissement - profile['cbr_encodings'].append(encoding) - - if taux_accroissement <= limited_evolution_metric/bitrate_steps_by_default: - #scatter(bitrate_array[j], quality_array[j]) # I found out the good point - break - - # Display good values ! - print ('\nI found the best values for ||--- ', str(encoding_profile.width)+'x'+str(encoding_profile.height),' ---|| >> ',metric,':',quality_array[j],'| bitrate = ',bitrate_array[j]*10**(-3),'kbps') - optimal_bitrate_array.append(bitrate_array[j]) # use in GRAPH 2 - profile['optimal_bitrate'] = bitrate_array[j] - profile['bitrate_savings'] = encoding_profile.bitrate_default - profile['optimal_bitrate'] - - # Graph annotations - annotation=str(bitrate_array[j]*1e-3)+' kbps' - #plot([bitrate_array[j],bitrate_array[j]], [0, quality_array[j]], linestyle='--' ) - annotate(annotation, xy=(bitrate_array[j], quality_array[j]), xycoords='data', xytext=(+1, +20), textcoords='offset points', fontsize=8, arrowprops=dict(arrowstyle="->", connectionstyle="arc,rad=0.2")) - #plot([0, bitrate_array[j]], [quality_array[j], quality_array[j]], linestyle='--' ) - scatter(bitrate_array[j], quality_array[j], s=7) - grid() - legend() - draw() - show(block=False) - pause(0.001) - - - #save graph1 and plot graph2 - name=str(os.path.basename(self.input_file_path)) - input("\n\n\nPress [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' !") - newpath = str(os.getcwd())+"/results/%s" % (name) - #newpath = '/home/labo/Documents/per_title_analysis/results/%s' % (name) - if not os.path.exists(newpath): - os.makedirs(newpath) - plt.savefig(newpath+"/%s-%s-%s-Per_Title.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) - - bitrate_data = [list(i) for i in zip(optimal_bitrate_array, default_bitrate_array)] - - # GRAH 2 Computation - figure(2) - columns = ('Dynamic (kbps)', 'Fix (kbps)') - rows = ['%s' % resolution for resolution in ('1920 x 1080', '1280 x 720', '960 x 540', '640 x 360', '480 x 270')] - - ylabel("bitrate (bps)") - title(str(self.input_file_path)) - - # Get some pastel shades for the colors - colors = plt.cm.YlOrBr(np.linspace(0.35, 0.8, len(rows))) - #size and positions - n_rows = len(bitrate_data)-1 - index = np.arange(len(columns)) + 0.3 - bar_width = 0.5 - - # Initialize the vertical-offset for the stacked bar chart. - y_offset = np.zeros(len(columns)) - - # Plot bars and create text labels for the table - cell_text = [] - for row in range(n_rows+1): # until n_rows - plt.bar(index, bitrate_data[n_rows-row], bar_width, bottom=y_offset, color=colors[row]) - y_offset = y_offset + bitrate_data[n_rows-row] - print('this is y_offset',y_offset) - cell_text.append(['%1.1f' % (x / 1000.0) for x in bitrate_data[n_rows-row]]) - # Reverse colors and text labels to display the last value at the top. - colors = colors[::-1] - cell_text.reverse() - - - # Add a table at the bottom of the axes - the_table = plt.table(cellText=cell_text, - rowLabels=rows, - rowColours=colors, - colLabels=columns, - loc='bottom') - - # Adjust layout to make room for the table: - plt.subplots_adjust(left=0.5, bottom=0.2) - - #plt.ylabel("Loss in ${0}'s".format(value_increment)) - #plt.yticks(values * value_increment, ['%d' % val for val in values]) - plt.xticks([]) - #plt.title('Loss by Disaster') - - show(block=False) - pause(0.001) - print('\n\n->->\nloading graphic Histogram\n->->\n\n') - input("Press [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' ") - plt.savefig(newpath+"/%s-%s-%s-Per_Title_Histogram.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) - print('\n\n\n************ALL DONE********** !\n\n') From 2d74feaad4c17fd166e2a6fa9fbb63a2e83ff449 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:51:52 +0200 Subject: [PATCH 10/25] Delete crf_analyzer.py --- crf_analyzer.py | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 crf_analyzer.py diff --git a/crf_analyzer.py b/crf_analyzer.py deleted file mode 100644 index 6e3cc2f..0000000 --- a/crf_analyzer.py +++ /dev/null @@ -1,46 +0,0 @@ -# -*- coding: utf8 -*- -import per_title_analysis as pta -import sys -import os -import json - -path=str(sys.argv[1]) -print ("\nfile=",path) -crf_value=str(sys.argv[2]) -print("crf =",crf_value) -number_of_parts=int(sys.argv[3]) -print("number_of_parts =",number_of_parts) -model=int(sys.argv[4]) -print("model value 1 for True (linear), 0 for False (for each):", model, "\n\n") - -# create your template encoding ladder -PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) -PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) -#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) - -LADDER = pta.EncodingLadder(PROFILE_LIST) - - -# Create a new Metric analysis provider -ANALYSIS = pta.CrfAnalyzer(path, LADDER) - -# Launch various analysis (here crf) -if model == 1: #model = linear (True) or for each (False) - ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, True) -if model == 0: - ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, None) - ANALYSIS.process(number_of_parts, 1280, 720, crf_value, 2, None) - ANALYSIS.process(number_of_parts, 960, 540, crf_value, 2, None) - ANALYSIS.process(number_of_parts, 640, 360, crf_value, 2, None) - ANALYSIS.process(number_of_parts, 480, 270, crf_value, 2, None) - - -# Save JSON results -name=str(os.path.basename(path)) -filePathNameWExt = str(os.getcwd())+"/results/%s/%s-CRF-nbr_parts:%s-%s-Per_Title.json" % (name, name, number_of_parts , str(crf_value)) -with open(filePathNameWExt, 'w') as fp: - print(ANALYSIS.get_json(), file=fp) From dbb41d3cdaf7dde64d4f7ed7495435e06f6abd13 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:52:01 +0200 Subject: [PATCH 11/25] Delete metric_analyzer.py --- metric_analyzer.py | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 metric_analyzer.py diff --git a/metric_analyzer.py b/metric_analyzer.py deleted file mode 100644 index e978f0e..0000000 --- a/metric_analyzer.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf8 -*- -import per_title_analysis as pta -import sys -import os -import json - -path=str(sys.argv[1]) -metric=str(sys.argv[2]) -limit_metric_value=float(sys.argv[3]) -print('\nmetric:', metric) -print('limit metric =', limit_metric_value) - -# create your template encoding ladder -PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) -PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) -#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) - -LADDER = pta.EncodingLadder(PROFILE_LIST) - - -# Create a new Metric analysis provider -ANALYSIS = pta.MetricAnalyzer(path, LADDER) - -# Launch various analysis (here ssim or psnr) - #(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required) -ANALYSIS.process(metric, limit_metric_value, 200000, 2, False) - -# Save JSON results -name=str(os.path.basename(path)) -filePathNameWExt = str(os.getcwd())+"/results/%s/%s-METRIC-%s-%s-Per_Title.json" % (name, name, (metric).strip().upper(), str(limit_metric_value)) -with open(filePathNameWExt, 'w') as fp: - print(ANALYSIS.get_json(), file=fp) From b12c5e31616f9cc4f64b88d4ae5c708015c8178b Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:52:09 +0200 Subject: [PATCH 12/25] Delete task_providers.py --- task_providers.py | 249 ---------------------------------------------- 1 file changed, 249 deletions(-) delete mode 100644 task_providers.py diff --git a/task_providers.py b/task_providers.py deleted file mode 100644 index cfa63db..0000000 --- a/task_providers.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import json -import subprocess -import uuid - -class Task(object): - """This class defines a processing task""" - - def __init__(self, input_file_path): - """Task initialization - - :param input_file_path: The input video file path - :type input_file_path: str - """ - if os.path.isfile(input_file_path) is True: - self.input_file_path = input_file_path - else: - raise ValueError('Cannot access the file: {}'.format(input_file_path)) - - self.subprocess_pid = None - self.subprocess_out = None - self.subprocess_err = None - - def execute(self, command): - """Launch a subprocess task - - :param command: Arguments array for the subprocess task - :type command: str[] - """ - proc = subprocess.Popen(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE) - self.subprocess_pid = proc.pid - - try: - self.subprocess_out, self.subprocess_err = proc.communicate() - except: - print(self.subprocess_err) - # TODO: error management - - -class Probe(Task): - """This class defines a Probing task""" - - def __init__(self, input_file_path): - """Probe initialization - - :param input_file_path: The input video file path - :type input_file_path: str - """ - Task.__init__(self, input_file_path) - - self.width = None - self.height = None - self.bitrate = None - self.duration = None - self.video_codec = None - self.framerate = None - - def execute(self): - """Using FFprobe to get input video file informations""" - command = ['ffprobe', - '-hide_banner', - '-i', self.input_file_path, - '-show_format', '-show_streams', - '-print_format', 'json'] - Task.execute(self, command) - - # Parse output data - try: - response = self.subprocess_out - data = json.loads(response.decode('utf-8')) - for stream in data['streams']: - if stream['codec_type'] == 'video': - self.width = int(stream['width']) - #print('the probe',self.width) - self.height = int(stream['height']) - self.duration = float(stream['duration']) - self.video_codec = stream['codec_name'] - self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace - self.bitrate = float(stream['bit_rate']) #error - self.video_codec = stream['codec_name'] - self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace - except: - - # TODO: error management - pass - - -class CrfEncode(Task): - """This class defines a CRF encoding task""" - - def __init__(self, input_file_path, width, height, crf_value, idr_interval, part_start_time, part_duration): - """CrfEncode initialization - - :param input_file_path: The input video file path - :type input_file_path: str - :param width: Width of the CRF encode - :type width: int - :param height: Height of the CRF encode - :type height: int - :param crf_value: The CRF Encoding value for ffmpeg - :type crf_value: int - :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) - :type idr_interval: int - :param part_start_time: Encode seek start time (in seconds) - :type part_start_time: float - :param part_duration: Encode duration (in seconds) - :type part_duration: float - """ - Task.__init__(self, input_file_path) - - self.definition = str(width)+'x'+str(height) - self.crf_value = crf_value - self.idr_interval = idr_interval - self.part_start_time = part_start_time - self.part_duration = part_duration - - # Generate a temporary file name for the task output - self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), - os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") - # print(self.output_file_path) - # print(self.part_start_time) - # print(self.input_file_path) - # print(self.part_duration) - # print(self.crf_value) - # print(self.definition) - # print(self.idr_interval) - - def execute(self): - """Using FFmpeg to CRF Encode a file or part of a file""" - command = ['ffmpeg', - '-hide_banner', '-loglevel', 'quiet', '-nostats', - '-ss', str(self.part_start_time), - '-i', self.input_file_path, - '-t', str(self.part_duration), - '-preset', 'ultrafast', - '-an', '-deinterlace', - '-crf', str(self.crf_value), - '-pix_fmt', 'yuv420p', - '-s', self.definition, - '-x264opts', 'keyint=' + str(self.idr_interval), - '-y', self.output_file_path] - Task.execute(self, command) - - -class CbrEncode(Task): - """This class defines a CBR encoding task""" - - def __init__(self, input_file_path, width, height, cbr_value, idr_interval, part_start_time, part_duration): - """CrfEncode initialization - - :param input_file_path: The input video file path - :type input_file_path: str - :param width: Width of the CBR encode - :type width: int - :param height: Height of the CBR encode - :type height: int - :param cbr_value: The CBR Encoding value for ffmpeg - :type cbr_value: int - :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) - :type idr_interval: int - :param part_start_time: Encode seek start time (in seconds) - :type part_start_time: float - :param part_duration: Encode duration (in seconds) - :type part_duration: float - """ - Task.__init__(self, input_file_path) - - self.definition = str(width)+'x'+str(height) - self.cbr_value = cbr_value - self.idr_interval = idr_interval - self.part_start_time = part_start_time - self.part_duration = part_duration - - # Generate a temporary file name for the task output - self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), - os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") - - def execute(self): - """Using FFmpeg to CRF Encode a file or part of a file""" - command = ['ffmpeg', - '-hide_banner', '-loglevel', 'quiet', '-nostats', - '-ss', str(self.part_start_time), - '-i', self.input_file_path, - '-t', str(self.part_duration), - '-c:v', 'libx264', - '-an', '-deinterlace', - '-b:v', str(self.cbr_value), - '-pix_fmt', 'yuv420p', - '-s', self.definition, - '-x264opts', 'keyint=' + str(self.idr_interval), - '-y', self.output_file_path] - Task.execute(self, command) - - -class Metric(Task): - """This class defines a Probing task""" - - def __init__(self, metric, input_file_path, ref_file_path, ref_width, ref_height): - """Probe initialization - - :param metric: Supporting "ssim" or "psnr" - :type metric: string - :param input_file_path: The input video file path, the one to be analyzed - :type input_file_path: str - :param ref_file_path: The reference video file path - :type ref_file_path: str - - """ - Task.__init__(self, input_file_path) - - if os.path.isfile(ref_file_path) is True: - self.ref_file_path = ref_file_path - self.ref_width = ref_width - self.ref_height = ref_height - else: - raise ValueError('Cannot access the file: {}'.format(ref_file_path)) - - available_metrics = ['ssim', 'psnr'] - self.metric = str(metric).strip().lower() - if self.metric not in available_metrics: - raise ValueError('Available metrics are "ssim" and "psnr", does not include: {}'.format(metric)) - - self.output_value = None - - def execute(self): - """Using FFmpeg to process metric assessments""" - command = ['ffmpeg', - '-hide_banner', - '-i', self.input_file_path, - '-i', self.ref_file_path, - '-lavfi', '[0]scale='+str(self.ref_width)+':'+str(self.ref_height)+'[scaled];[scaled][1]'+str(self.metric)+'=stats_file=-', - '-f', 'null', '-'] - Task.execute(self, command) - - # Parse output data - try: - data = self.subprocess_err.splitlines() - for line in data: - line = str(line) - if 'Parsed_ssim' in line: - self.output_value = float(line.split('All:')[1].split('(')[0].strip()) - elif 'Parsed_psnr' in line: - self.output_value = float(line.split('average:')[1].split('min:')[0].strip()) - - except: - # TODO: error management - pass From c010a56777b052febccf7ebf67d9e390863ee368 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:53:32 +0200 Subject: [PATCH 13/25] Create per_title_analysis.py --- per_title_analysis/per_title_analysis.py | 592 +++++++++++++++++++++++ 1 file changed, 592 insertions(+) create mode 100644 per_title_analysis/per_title_analysis.py diff --git a/per_title_analysis/per_title_analysis.py b/per_title_analysis/per_title_analysis.py new file mode 100644 index 0000000..79544eb --- /dev/null +++ b/per_title_analysis/per_title_analysis.py @@ -0,0 +1,592 @@ +# -*- coding: utf-8 -*- + +#importation + +from __future__ import division +from pylab import * +import sys +import os +import json +import datetime +import statistics +import matplotlib.pyplot as plt +import matplotlib.animation as animation + +from task_providers import Probe, CrfEncode, CbrEncode, Metric + + + +class EncodingProfile(object): + """This class defines an encoding profile""" + + + def __init__(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual): + """EncodingProfile initialization + + :param width: Video profile width + :type width: int + :param height: Video profile height + :type height: int + :param bitrate_default: Video profile bitrate default (in bits per second) + :type bitrate_default: int + :param bitrate_min: Video profile bitrate min constraint (in bits per second) + :type bitrate_min: int + :param bitrate_max: Video profile bitrate max constraint (in bits per second) + :type bitrate_max: int + :param required: The video profile is required and cannot be removed from the optimized encoding ladder + :type required: bool + :param bitrate_steps_individual: Step Bitrate Range defined for each Video profile (in bits per second) + :type bitrate_steps_individual: int + """ + #call: PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True, 150000)) + + + if width is None: + raise ValueError('The EncodingProfile.width value is required') + else: + self.width = int(width) + + if height is None: + raise ValueError('The EncodingProfile.height value is required') + else: + self.height = int(height) + + if bitrate_default is None: + raise ValueError('The EncodingProfile.bitrate_default value is required') + else: + self.bitrate_default = int(bitrate_default) + + if int(bitrate_min) <= self.bitrate_default: + self.bitrate_min = int(bitrate_min) + else: + self.bitrate_min = self.bitrate_default + + if int(bitrate_max) >= self.bitrate_default: + self.bitrate_max = int(bitrate_max) + else: + self.bitrate_max = self.bitrate_default + + if bitrate_steps_individual is None: + self.bitrate_steps_individual = None + else: + self.bitrate_steps_individual = int(bitrate_steps_individual) + + if required is not None: + self.required = required + else: + self.required = True + + self.bitrate_factor = None + + + def __str__(self): + """Display the encoding profile informations + :return: human readable string describing an encoding profil object + :rtype: str + """ + return "{}x{}, bitrate_default={}, bitrate_min={}, bitrate_max={}, bitrate_steps_individual{}, bitrate_factor={}, required={}".format(self.width, self.height, self.bitrate_default, self.bitrate_min, self.bitrate_max, self.bitrate_steps_individual, self.bitrate_factor, self.required) + + + def get_json(self): + """Return object details in json + :return: json object describing the encoding profile and the configured constraints + :rtype: str + """ + profile = {} + profile['width'] = self.width + profile['height'] = self.height + profile['bitrate'] = self.bitrate_default + profile['constraints'] = {} + profile['constraints']['bitrate_min'] = self.bitrate_min + profile['constraints']['bitrate_max'] = self.bitrate_max + profile['constraints']['bitrate_factor'] = self.bitrate_factor + profile['constraints']['required'] = self.required + return json.dumps(profile) + + + def set_bitrate_factor(self, ladder_max_bitrate): + """Set the bitrate factor from the max bitrate in the encoding ladder""" + self.bitrate_factor = ladder_max_bitrate/self.bitrate_default + + + + +class EncodingLadder(object): + """This class defines an over-the-top encoding ladder template""" + + + def __init__(self, encoding_profile_list): + """EncodingLadder initialization + + :param encoding_profile_list: A list of multiple encoding profiles + :type encoding_profile_list: per_title.EncodingProfile[] + """ + #call: LADDER = pta.EncodingLadder(PROFILE_LIST) + + self.encoding_profile_list = encoding_profile_list + self.calculate_bitrate_factors() + + + def __str__(self): + """Display the encoding ladder informations + :return: human readable string describing the encoding ladder template + :rtype: str + """ + string = "{} encoding profiles\n".format(len(self.encoding_profile_list)) + for encoding_profile in self.encoding_profile_list: + string += str(encoding_profile) + "\n" + return string + + + def get_json(self): + """Return object details in json + :return: json object describing the encoding ladder template + :rtype: str + """ + ladder = {} + ladder['overall_bitrate_ladder'] = self.get_overall_bitrate() + ladder['encoding_profiles'] = [] + for encoding_profile in self.encoding_profile_list: + ladder['encoding_profiles'].append(json.loads(encoding_profile.get_json())) + return json.dumps(ladder) + + + def get_max_bitrate(self): + """Get the max bitrate in the ladder + :return: The maximum bitrate into the encoding laddder template + :rtype: int + """ + ladder_max_bitrate = 0 + for encoding_profile in self.encoding_profile_list: + if encoding_profile.bitrate_default > ladder_max_bitrate: + ladder_max_bitrate = encoding_profile.bitrate_default + return ladder_max_bitrate + + def get_overall_bitrate(self): + """Get the overall bitrate for the ladder + :return: The sum of all bitrate profiles into the encoding laddder template + :rtype: int + """ + ladder_overall_bitrate = 0 + for encoding_profile in self.encoding_profile_list: + ladder_overall_bitrate += encoding_profile.bitrate_default + return ladder_overall_bitrate + + def calculate_bitrate_factors(self): #cf plus haut ! + """Calculate the bitrate factor for each profile""" + ladder_max_bitrate = self.get_max_bitrate() + for encoding_profile in self.encoding_profile_list: + encoding_profile.set_bitrate_factor(ladder_max_bitrate) + + + +class Analyzer(object): + """This class defines a Per-Title Analyzer""" + + def __init__(self, input_file_path, encoding_ladder): + """Analyzer initialization + :param input_file_path: The input video file path + :type input_file_path: str + :param encoding_ladder: An EncodingLadder object + :type encoding_ladder: per_title.EncodingLadder + """ + self.input_file_path = input_file_path + self.encoding_ladder = encoding_ladder + + self.average_bitrate = None + self.standard_deviation = None + self.optimal_bitrate = None + self.peak_bitrate = None + + # init json result + self.json = {} + self.json['input_file_path'] = self.input_file_path + self.json['template_encoding_ladder'] = json.loads(self.encoding_ladder.get_json()) + self.json['analyses'] = [] + + + def __str__(self): + """Display the per title analysis informations + :return: human readable string describing all analyzer configuration + :rtype: str + """ + string = "Per-Title Analysis for: {}\n".format(self.input_file_path) + string += str(self.encoding_ladder) + return string + + def get_json(self): + """Return object details in json + :return: json object describing all inputs configuration and output analyses + :rtype: str + """ + return json.dumps(self.json, indent=4, sort_keys=True) + + + +class CrfAnalyzer(Analyzer): + """This class defines a Per-Title Analyzer based on calculating the top bitrate wit CRF, then deducting the ladder""" + + + def set_bitrate(self,number_of_parts): + """In linear mode, optimal_bitrates are defined from the first analysis thanks to the bitrate_factor + : print results in linear mode for CRF analyzer + """ + + overall_bitrate_optimal = 0 + for encoding_profile in self.encoding_ladder.encoding_profile_list: + target_bitrate = int(self.optimal_bitrate/encoding_profile.bitrate_factor) + remove_profile = False + if target_bitrate < encoding_profile.bitrate_min and encoding_profile.required is False: + remove_profile = True + + if target_bitrate < encoding_profile.bitrate_min: + target_bitrate = encoding_profile.bitrate_min + + if target_bitrate > encoding_profile.bitrate_max: + target_bitrate = encoding_profile.bitrate_max + + if remove_profile is False: + overall_bitrate_optimal += target_bitrate + + print(' ',encoding_profile.width,'x',encoding_profile.height,' ',target_bitrate*1e-3,'kbps linear',' / nbr part:',number_of_parts,' ') + + + def process(self, number_of_parts, width, height, crf_value, idr_interval, model): + """Do the necessary crf encodings and assessments + :param number_of_parts: Number of part/segment for the analysis + :type number_of_parts: int + :param width: Width of the CRF encode + :type width: int + :param height: Height of the CRF encode + :type height: int + :param crf_value: Constant Rate Factor: this is a constant quality factor, see ffmpeg.org for more documentation on this parameter + :type crf_value: int + :param idr_interval: IDR interval in seconds + :type idr_interval: int + :param model: linear (True) or for each (False) + :type model: bool + """ + + # Start by probing the input video file + input_probe = Probe(self.input_file_path) + input_probe.execute() + + crf_bitrate_list = [] + part_duration = input_probe.duration/number_of_parts + idr_interval_frames = idr_interval*input_probe.framerate #rcl: An IDR frame is a special type of I-frame in H.264. An IDR frame specifies that no frame after the IDR frame can reference any frame before it. This makes seeking the H.264 file easier and more responsive in the player. + #As I have an IDR_FRAME every 2 seconds, I can find out the number of frame between two IDR using framerate ! + + # Start Analysis + for i in range(0,number_of_parts): + part_start_time = i*part_duration #select extracts to encode + + # Do a CRF encode for the input file + crf_encode = CrfEncode(self.input_file_path, width, height, crf_value, idr_interval_frames, part_start_time, part_duration) + crf_encode.execute() + + # Get the Bitrate from the CRF encoded file + crf_probe = Probe(crf_encode.output_file_path) + crf_probe.execute() + + # Remove temporary CRF encoded file + os.remove(crf_encode.output_file_path) + + # Set the crf bitrate + crf_bitrate_list.append(crf_probe.bitrate) + + # Calculate the average bitrate for all CRF encodings + self.average_bitrate = statistics.mean(crf_bitrate_list) + self.peak_bitrate = max(crf_bitrate_list) + + if number_of_parts > 1: + # Calculate the the standard deviation of crf bitrate values + self.standard_deviation = statistics.stdev(crf_bitrate_list) + + weight = 1 + weighted_bitrate_sum = 0 + weighted_bitrate_len = 0 + + # Giving weight for each bitrate based on the standard deviation + for bitrate in crf_bitrate_list: + if bitrate > (self.average_bitrate + self.standard_deviation): + weight = 4 + elif bitrate > (self.average_bitrate + self.standard_deviation/2): + weight = 2 + elif bitrate < (self.average_bitrate - self.standard_deviation/2): + weight = 0.5 + elif bitrate < (self.average_bitrate - self.standard_deviation): + weight = 0 + else: + weight = 1 + + weighted_bitrate_sum += weight*bitrate + weighted_bitrate_len += weight + + # Set the optimal bitrate from the weighted bitrate of all crf encoded parts + self.optimal_bitrate = weighted_bitrate_sum/weighted_bitrate_len + + else: + # Set the optimal bitrate from the only one crf result + self.optimal_bitrate = self.average_bitrate + + if not model: + print(' ',width,'x',height,' ',self.optimal_bitrate*1e-3,'kbps encode_for_each','/ nbr part:',number_of_parts,' ') + + if model: + # We calculate optimal bitrate of the the remaining profiles using bitrate factor + self.set_bitrate(number_of_parts) + + # Adding results to json + result = {} + result['processing_date'] = str(datetime.datetime.now()) + result['parameters'] = {} + result['parameters']['method'] = "CRF" + result['parameters']['width'] = width + result['parameters']['height'] = height + result['parameters']['crf_value'] = crf_value + result['parameters']['idr_interval'] = idr_interval + result['parameters']['number_of_parts'] = number_of_parts + result['parameters']['part_duration'] = part_duration + result['bitrate'] = {} + result['bitrate']['optimal'] = self.optimal_bitrate + result['bitrate']['average'] = self.average_bitrate + result['bitrate']['peak'] = self.average_bitrate + result['bitrate']['standard_deviation'] = self.standard_deviation + result['optimized_encoding_ladder'] = {} + if model == "True": + result['optimized_encoding_ladder']['model'] = "linear" + if model == "False": + result['optimized_encoding_ladder']['model'] = "encode_for_each" + + self.json['analyses'].append(result) + + +class MetricAnalyzer(Analyzer): + """This class defines a Per-Title Analyzer based on VQ Metric and Multiple bitrate encodes""" + + def process(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required): + """Do the necessary encodings and quality metric assessments + :param metric: Supporting "ssim" or "psnr" + :type metric: string + :param limit_metric: limit value of "ssim" or "psnr" use to find optimal bitrate + :type limit_metric: int + :param bitrate_steps_by_default: Bitrate gap between every encoding, only use if steps_individual_bitrate_required is False + :type bitrate_steps_by_default: int + :param idr_interval: IDR interval in seconds + :type idr_interval: int + :param steps_individual_bitrate_required: The step is the same for each profile and cannot be set individually if False + :type steps_individual_bitrate_required: bool + """ + + # Start by probing the input video file + input_probe = Probe(self.input_file_path) + input_probe.execute() + + part_start_time = 0 + part_duration = input_probe.duration + idr_interval_frames = idr_interval*input_probe.framerate + metric = str(metric).strip().lower() + + #Create two lists for GRAPH 2 + optimal_bitrate_array = [] + default_bitrate_array = [] + + print('\n********************************\n********Encoding Started********\n********************************\n') + print('File Selected: ', os.path.basename(self.input_file_path)) + + # Adding results to json + json_ouput = {} + json_ouput['processing_date'] = str(datetime.datetime.now()) + json_ouput['parameters'] = {} + json_ouput['parameters']['method'] = "Metric" + json_ouput['parameters']['metric'] = metric + json_ouput['parameters']['bitrate_steps'] = bitrate_steps_by_default + json_ouput['parameters']['idr_interval'] = idr_interval + json_ouput['parameters']['number_of_parts'] = 1 + json_ouput['parameters']['part_duration'] = part_duration + json_ouput['optimized_encoding_ladder'] = {} + json_ouput['optimized_encoding_ladder']['encoding_profiles'] = [] + + + # Start Analysis + for encoding_profile in self.encoding_ladder.encoding_profile_list: + + profile = {} + profile['width'] = encoding_profile.width + profile['height'] = encoding_profile.height + profile['cbr_encodings'] = [] + profile['optimal_bitrate'] = None + + default_bitrate_array.append(encoding_profile.bitrate_default) + + if steps_individual_bitrate_required: + bitrate_steps_by_default = encoding_profile.bitrate_steps_individual + print('\n\n __________________________________________') + print(' The bitrate_step is: ',bitrate_steps_by_default*10**(-3),'kbps') + print('\n |||',encoding_profile.width, 'x', encoding_profile.height,'|||\n') + + last_metric_value = 0 + last_quality_step_ratio = 0 + bitrate_array = [] + quality_array = [] + + + for bitrate in range(encoding_profile.bitrate_min, (encoding_profile.bitrate_max + bitrate_steps_by_default), bitrate_steps_by_default): + # Do a CBR encode for the input file + cbr_encode = CbrEncode(self.input_file_path, encoding_profile.width, encoding_profile.height, bitrate, idr_interval_frames, part_start_time, part_duration) + cbr_encode.execute() + print('cbr_encode -> in progress -> ->') + + # Get the Bitrate from the CBR encoded file + metric_assessment = Metric(metric, cbr_encode.output_file_path, self.input_file_path, input_probe.width, input_probe.height) + metric_assessment.execute() + print('-> -> probe |>', bitrate*10**(-3),'kbps |>',metric,' = ',metric_assessment.output_value, '\n') + + # Remove temporary CBR encoded file + os.remove(cbr_encode.output_file_path) + + + # OLD method to find optimal bitrate_min + + # if last_metric_value is 0 : + # # for first value, you cannot calculate acurate jump in quality from nothing + # last_metric_value = metric_assessment.output_value + # profile['optimal_bitrate'] = bitrate + # quality_step_ratio = (metric_assessment.output_value)/bitrate # first step from null to the starting bitrate + # else: + # quality_step_ratio = (metric_assessment.output_value - last_metric_value)/bitrate_steps_by_default + # + # if quality_step_ratio >= (last_quality_step_ratio/2): + # profile['optimal_bitrate'] = bitrate + + # if 'ssim' in metric: + # if metric_assessment.output_value >= (last_metric_value + 0.01): + # profile['optimal_bitrate'] = bitrate + # elif 'psnr' in metric: + # if metric_assessment.output_value > last_metric_value: + # profile['optimal_bitrate'] = bitrate + + # last_metric_value = metric_assessment.output_value + # last_quality_step_ratio = quality_step_ratio + + # New method + bitrate_array.append(bitrate) # All bitrate for one profile + print(bitrate_array) + quality_array.append(metric_assessment.output_value) #pour un profile on a toutes les qualités + print(quality_array) + + + #**************GRAPH 1 matplotlib************* + # Initialize + diff_bitrate_array=1 # X + diff_quality_array=0 # Y + taux_accroissement=1 + + #Curve + figure(1) + plot(bitrate_array, quality_array, label=str(encoding_profile.width)+'x'+str(encoding_profile.height)) + xlabel('bitrate (bps)') + ylabel("quality: "+str(metric).upper()) + title(str(self.input_file_path)) + + # Rate of change and find out the optimal bitrate in the array + for j in range(0, len(quality_array)-1): + diff_quality_array=quality_array[j+1]-quality_array[j] + diff_bitrate_array=bitrate_array[j+1]-bitrate_array[j] + + #limited_evolution_metric=0.005 -> indication: set arround 0.1 for psnr with a 100000 bps bitrate step and 0.05 with a 50000 bitrate step for ssim + limited_evolution_metric=limit_metric + + taux_accroissement = diff_quality_array/diff_bitrate_array + + encoding = {} + encoding['bitrate'] = bitrate_array[j] + encoding['metric_value'] = quality_array[j] + encoding['quality_step_ratio'] = taux_accroissement + profile['cbr_encodings'].append(encoding) + + if taux_accroissement <= limited_evolution_metric/bitrate_steps_by_default: + #scatter(bitrate_array[j], quality_array[j]) # I found out the good point + break + + # Display good values ! + print ('\nI found the best values for ||--- ', str(encoding_profile.width)+'x'+str(encoding_profile.height),' ---|| >> ',metric,':',quality_array[j],'| bitrate = ',bitrate_array[j]*10**(-3),'kbps') + optimal_bitrate_array.append(bitrate_array[j]) # use in GRAPH 2 + profile['optimal_bitrate'] = bitrate_array[j] + profile['bitrate_savings'] = encoding_profile.bitrate_default - profile['optimal_bitrate'] + + # Graph annotations + annotation=str(bitrate_array[j]*1e-3)+' kbps' + #plot([bitrate_array[j],bitrate_array[j]], [0, quality_array[j]], linestyle='--' ) + annotate(annotation, xy=(bitrate_array[j], quality_array[j]), xycoords='data', xytext=(+1, +20), textcoords='offset points', fontsize=8, arrowprops=dict(arrowstyle="->", connectionstyle="arc,rad=0.2")) + #plot([0, bitrate_array[j]], [quality_array[j], quality_array[j]], linestyle='--' ) + scatter(bitrate_array[j], quality_array[j], s=7) + grid() + legend() + draw() + show(block=False) + pause(0.001) + + + #save graph1 and plot graph2 + name=str(os.path.basename(self.input_file_path)) + input("\n\n\nPress [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' !") + newpath = str(os.getcwd())+"/results/%s" % (name) + #newpath = '/home/labo/Documents/per_title_analysis/results/%s' % (name) + if not os.path.exists(newpath): + os.makedirs(newpath) + plt.savefig(newpath+"/%s-%s-%s-Per_Title.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + + bitrate_data = [list(i) for i in zip(optimal_bitrate_array, default_bitrate_array)] + + # GRAH 2 Computation + figure(2) + columns = ('Dynamic (kbps)', 'Fix (kbps)') + rows = ['%s' % resolution for resolution in ('1920 x 1080', '1280 x 720', '960 x 540', '640 x 360', '480 x 270')] + + ylabel("bitrate (bps)") + title(str(self.input_file_path)) + + # Get some pastel shades for the colors + colors = plt.cm.YlOrBr(np.linspace(0.35, 0.8, len(rows))) + #size and positions + n_rows = len(bitrate_data)-1 + index = np.arange(len(columns)) + 0.3 + bar_width = 0.5 + + # Initialize the vertical-offset for the stacked bar chart. + y_offset = np.zeros(len(columns)) + + # Plot bars and create text labels for the table + cell_text = [] + for row in range(n_rows+1): # until n_rows + plt.bar(index, bitrate_data[n_rows-row], bar_width, bottom=y_offset, color=colors[row]) + y_offset = y_offset + bitrate_data[n_rows-row] + print('this is y_offset',y_offset) + cell_text.append(['%1.1f' % (x / 1000.0) for x in bitrate_data[n_rows-row]]) + # Reverse colors and text labels to display the last value at the top. + colors = colors[::-1] + cell_text.reverse() + + + # Add a table at the bottom of the axes + the_table = plt.table(cellText=cell_text, + rowLabels=rows, + rowColours=colors, + colLabels=columns, + loc='bottom') + + # Adjust layout to make room for the table: + plt.subplots_adjust(left=0.5, bottom=0.2) + + #plt.ylabel("Loss in ${0}'s".format(value_increment)) + #plt.yticks(values * value_increment, ['%d' % val for val in values]) + plt.xticks([]) + #plt.title('Loss by Disaster') + + show(block=False) + pause(0.001) + print('\n\n->->\nloading graphic Histogram\n->->\n\n') + input("Press [enter] to continue, This will close the graphic and save the figure as ''file_name_metric_limit_metric.png'' ") + plt.savefig(newpath+"/%s-%s-%s-Per_Title_Histogram.png" % (name, str(metric).strip().upper(), str(limited_evolution_metric))) + print('\n\n\n************ALL DONE********** !\n\n') From 5f3c802260323164ac5096beeb82920449086096 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 15:54:17 +0200 Subject: [PATCH 14/25] New version per_title Add Graph and some tools --- per_title_analysis/__init__.py | 12 ++ per_title_analysis/crf_analyzer.py | 46 +++++ per_title_analysis/metric_analyzer.py | 36 ++++ per_title_analysis/task_providers.py | 249 ++++++++++++++++++++++++++ 4 files changed, 343 insertions(+) create mode 100644 per_title_analysis/__init__.py create mode 100644 per_title_analysis/crf_analyzer.py create mode 100644 per_title_analysis/metric_analyzer.py create mode 100644 per_title_analysis/task_providers.py diff --git a/per_title_analysis/__init__.py b/per_title_analysis/__init__.py new file mode 100644 index 0000000..415656c --- /dev/null +++ b/per_title_analysis/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf8 -*- +""" + pertitleanalysis + ----- + A smart and simple Per-Title video analysis tool for optimizing your over-the-top encoding ladder + :copyright: (c) 2018 by Antoine Henning & Thom Marin. + :license: MIT, see LICENSE for more details. +""" + +__title__ = 'pertitleanalysis' +__author__ = 'Antoine Henning, Thom Marin' +__version__ = '0.2-dev' diff --git a/per_title_analysis/crf_analyzer.py b/per_title_analysis/crf_analyzer.py new file mode 100644 index 0000000..6e3cc2f --- /dev/null +++ b/per_title_analysis/crf_analyzer.py @@ -0,0 +1,46 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys +import os +import json + +path=str(sys.argv[1]) +print ("\nfile=",path) +crf_value=str(sys.argv[2]) +print("crf =",crf_value) +number_of_parts=int(sys.argv[3]) +print("number_of_parts =",number_of_parts) +model=int(sys.argv[4]) +print("model value 1 for True (linear), 0 for False (for each):", model, "\n\n") + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.CrfAnalyzer(path, LADDER) + +# Launch various analysis (here crf) +if model == 1: #model = linear (True) or for each (False) + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, True) +if model == 0: + ANALYSIS.process(number_of_parts, 1920, 1080, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 1280, 720, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 960, 540, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 640, 360, crf_value, 2, None) + ANALYSIS.process(number_of_parts, 480, 270, crf_value, 2, None) + + +# Save JSON results +name=str(os.path.basename(path)) +filePathNameWExt = str(os.getcwd())+"/results/%s/%s-CRF-nbr_parts:%s-%s-Per_Title.json" % (name, name, number_of_parts , str(crf_value)) +with open(filePathNameWExt, 'w') as fp: + print(ANALYSIS.get_json(), file=fp) diff --git a/per_title_analysis/metric_analyzer.py b/per_title_analysis/metric_analyzer.py new file mode 100644 index 0000000..e978f0e --- /dev/null +++ b/per_title_analysis/metric_analyzer.py @@ -0,0 +1,36 @@ +# -*- coding: utf8 -*- +import per_title_analysis as pta +import sys +import os +import json + +path=str(sys.argv[1]) +metric=str(sys.argv[2]) +limit_metric_value=float(sys.argv[3]) +print('\nmetric:', metric) +print('limit metric =', limit_metric_value) + +# create your template encoding ladder +PROFILE_LIST = [] #(self, width, height, bitrate_default, bitrate_min, bitrate_max, required, bitrate_steps_individual) +PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 1000000, 6000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 800000, 5000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 600000, 4000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 3000000, True, 100000)) +PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 200000, 2000000, False, 100000)) +#PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 200000, 2000000, True, 100000)) + +LADDER = pta.EncodingLadder(PROFILE_LIST) + + +# Create a new Metric analysis provider +ANALYSIS = pta.MetricAnalyzer(path, LADDER) + +# Launch various analysis (here ssim or psnr) + #(self, metric, limit_metric, bitrate_steps_by_default, idr_interval, steps_individual_bitrate_required) +ANALYSIS.process(metric, limit_metric_value, 200000, 2, False) + +# Save JSON results +name=str(os.path.basename(path)) +filePathNameWExt = str(os.getcwd())+"/results/%s/%s-METRIC-%s-%s-Per_Title.json" % (name, name, (metric).strip().upper(), str(limit_metric_value)) +with open(filePathNameWExt, 'w') as fp: + print(ANALYSIS.get_json(), file=fp) diff --git a/per_title_analysis/task_providers.py b/per_title_analysis/task_providers.py new file mode 100644 index 0000000..cfa63db --- /dev/null +++ b/per_title_analysis/task_providers.py @@ -0,0 +1,249 @@ +# -*- coding: utf-8 -*- + +import os +import json +import subprocess +import uuid + +class Task(object): + """This class defines a processing task""" + + def __init__(self, input_file_path): + """Task initialization + + :param input_file_path: The input video file path + :type input_file_path: str + """ + if os.path.isfile(input_file_path) is True: + self.input_file_path = input_file_path + else: + raise ValueError('Cannot access the file: {}'.format(input_file_path)) + + self.subprocess_pid = None + self.subprocess_out = None + self.subprocess_err = None + + def execute(self, command): + """Launch a subprocess task + + :param command: Arguments array for the subprocess task + :type command: str[] + """ + proc = subprocess.Popen(command, stderr=subprocess.PIPE, stdout=subprocess.PIPE) + self.subprocess_pid = proc.pid + + try: + self.subprocess_out, self.subprocess_err = proc.communicate() + except: + print(self.subprocess_err) + # TODO: error management + + +class Probe(Task): + """This class defines a Probing task""" + + def __init__(self, input_file_path): + """Probe initialization + + :param input_file_path: The input video file path + :type input_file_path: str + """ + Task.__init__(self, input_file_path) + + self.width = None + self.height = None + self.bitrate = None + self.duration = None + self.video_codec = None + self.framerate = None + + def execute(self): + """Using FFprobe to get input video file informations""" + command = ['ffprobe', + '-hide_banner', + '-i', self.input_file_path, + '-show_format', '-show_streams', + '-print_format', 'json'] + Task.execute(self, command) + + # Parse output data + try: + response = self.subprocess_out + data = json.loads(response.decode('utf-8')) + for stream in data['streams']: + if stream['codec_type'] == 'video': + self.width = int(stream['width']) + #print('the probe',self.width) + self.height = int(stream['height']) + self.duration = float(stream['duration']) + self.video_codec = stream['codec_name'] + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace + self.bitrate = float(stream['bit_rate']) #error + self.video_codec = stream['codec_name'] + self.framerate = int(stream['r_frame_rate'].replace('/1','')) #c'est quoi ce replace + except: + + # TODO: error management + pass + + +class CrfEncode(Task): + """This class defines a CRF encoding task""" + + def __init__(self, input_file_path, width, height, crf_value, idr_interval, part_start_time, part_duration): + """CrfEncode initialization + + :param input_file_path: The input video file path + :type input_file_path: str + :param width: Width of the CRF encode + :type width: int + :param height: Height of the CRF encode + :type height: int + :param crf_value: The CRF Encoding value for ffmpeg + :type crf_value: int + :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) + :type idr_interval: int + :param part_start_time: Encode seek start time (in seconds) + :type part_start_time: float + :param part_duration: Encode duration (in seconds) + :type part_duration: float + """ + Task.__init__(self, input_file_path) + + self.definition = str(width)+'x'+str(height) + self.crf_value = crf_value + self.idr_interval = idr_interval + self.part_start_time = part_start_time + self.part_duration = part_duration + + # Generate a temporary file name for the task output + self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), + os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") + # print(self.output_file_path) + # print(self.part_start_time) + # print(self.input_file_path) + # print(self.part_duration) + # print(self.crf_value) + # print(self.definition) + # print(self.idr_interval) + + def execute(self): + """Using FFmpeg to CRF Encode a file or part of a file""" + command = ['ffmpeg', + '-hide_banner', '-loglevel', 'quiet', '-nostats', + '-ss', str(self.part_start_time), + '-i', self.input_file_path, + '-t', str(self.part_duration), + '-preset', 'ultrafast', + '-an', '-deinterlace', + '-crf', str(self.crf_value), + '-pix_fmt', 'yuv420p', + '-s', self.definition, + '-x264opts', 'keyint=' + str(self.idr_interval), + '-y', self.output_file_path] + Task.execute(self, command) + + +class CbrEncode(Task): + """This class defines a CBR encoding task""" + + def __init__(self, input_file_path, width, height, cbr_value, idr_interval, part_start_time, part_duration): + """CrfEncode initialization + + :param input_file_path: The input video file path + :type input_file_path: str + :param width: Width of the CBR encode + :type width: int + :param height: Height of the CBR encode + :type height: int + :param cbr_value: The CBR Encoding value for ffmpeg + :type cbr_value: int + :param idr_interval: IDR Interval in frames ('None' value is no fix IDR interval needed) + :type idr_interval: int + :param part_start_time: Encode seek start time (in seconds) + :type part_start_time: float + :param part_duration: Encode duration (in seconds) + :type part_duration: float + """ + Task.__init__(self, input_file_path) + + self.definition = str(width)+'x'+str(height) + self.cbr_value = cbr_value + self.idr_interval = idr_interval + self.part_start_time = part_start_time + self.part_duration = part_duration + + # Generate a temporary file name for the task output + self.output_file_path = os.path.join(os.path.dirname(self.input_file_path), + os.path.splitext(os.path.basename(self.input_file_path))[0] + "_"+uuid.uuid4().hex+".mp4") + + def execute(self): + """Using FFmpeg to CRF Encode a file or part of a file""" + command = ['ffmpeg', + '-hide_banner', '-loglevel', 'quiet', '-nostats', + '-ss', str(self.part_start_time), + '-i', self.input_file_path, + '-t', str(self.part_duration), + '-c:v', 'libx264', + '-an', '-deinterlace', + '-b:v', str(self.cbr_value), + '-pix_fmt', 'yuv420p', + '-s', self.definition, + '-x264opts', 'keyint=' + str(self.idr_interval), + '-y', self.output_file_path] + Task.execute(self, command) + + +class Metric(Task): + """This class defines a Probing task""" + + def __init__(self, metric, input_file_path, ref_file_path, ref_width, ref_height): + """Probe initialization + + :param metric: Supporting "ssim" or "psnr" + :type metric: string + :param input_file_path: The input video file path, the one to be analyzed + :type input_file_path: str + :param ref_file_path: The reference video file path + :type ref_file_path: str + + """ + Task.__init__(self, input_file_path) + + if os.path.isfile(ref_file_path) is True: + self.ref_file_path = ref_file_path + self.ref_width = ref_width + self.ref_height = ref_height + else: + raise ValueError('Cannot access the file: {}'.format(ref_file_path)) + + available_metrics = ['ssim', 'psnr'] + self.metric = str(metric).strip().lower() + if self.metric not in available_metrics: + raise ValueError('Available metrics are "ssim" and "psnr", does not include: {}'.format(metric)) + + self.output_value = None + + def execute(self): + """Using FFmpeg to process metric assessments""" + command = ['ffmpeg', + '-hide_banner', + '-i', self.input_file_path, + '-i', self.ref_file_path, + '-lavfi', '[0]scale='+str(self.ref_width)+':'+str(self.ref_height)+'[scaled];[scaled][1]'+str(self.metric)+'=stats_file=-', + '-f', 'null', '-'] + Task.execute(self, command) + + # Parse output data + try: + data = self.subprocess_err.splitlines() + for line in data: + line = str(line) + if 'Parsed_ssim' in line: + self.output_value = float(line.split('All:')[1].split('(')[0].strip()) + elif 'Parsed_psnr' in line: + self.output_value = float(line.split('average:')[1].split('min:')[0].strip()) + + except: + # TODO: error management + pass From e11a497ea7b279ebc57a53fc67c8466846d29479 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:41:09 +0200 Subject: [PATCH 15/25] add screenshots --- my_movie.mxf-PSNR-0.09-Per_Title.png | Bin 0 -> 44107 bytes my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png | Bin 0 -> 26253 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 my_movie.mxf-PSNR-0.09-Per_Title.png create mode 100644 my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png diff --git a/my_movie.mxf-PSNR-0.09-Per_Title.png b/my_movie.mxf-PSNR-0.09-Per_Title.png new file mode 100644 index 0000000000000000000000000000000000000000..d8d331678c8fd56c1854584654284cba4d85fe83 GIT binary patch literal 44107 zcmeFZbyQYs*Ef0tii!%TpnwP>(xrrSh=hc+($WpmT`D1sv`7k4(y2%zA>G~G-TlqA z_w$VRJL8P^kMsZ8W9+ff4L56D>$>Lr)m*+ZQldAph_Fy7)J?IMLUJe+`g;@#ZSpE6 z{0&>z=q&u_lC_|i!d3Xk<*Kd^{P~*2OJ!>m3P%h17cHGX%?SRI(?(dyM&4ZC#$L-x z4`rrhV_|A;V``-R&`!_F+Q|IfLuSUOj8Eww8rs-ca4|9c&o?ldTNyB+=A~m$sD~&q zq2~$?v8!YD4hnt!)l(C?axXXVLvgvtiXNkfFn)fB=6U_&y&Df-F+Nnk{9SD4$Kt2J zoR62#@Si)${vpp=#cl-uRcrL5rTM(a*TM6|V8gooYn$Pl{UPBL zYQK-Tf>+5r(^&(Wxlv@E@CVtF$7p!}{GIOq|AlAz|Mx{upiYyDd&J6$$Hc_c+SX=i zVeycf+GlffQ`?rDoP238pZv|6H-ke%Teo1UQ{+NIL*>S& zu9C$+4@F^-(S~!|uTL&5HJu*q3{|>vPq^&d?8{W7qNfiiwO&|WQ7?B0v0EDsNl56h zpKxtd)NrkzYl$)%`_0zW)HHW^?L4}*Rnq(BeeX}7L>)gp@^tXdz!R2j=7vY^@cAx- z3N9(Bw3-?*%Jq0xYogLst-=Za{{8zXZ5^Eq^|EI(jlm0N2NO&k7NjGKUCEp-hfltJ z`_>xA9W^_v8yg$j%uP>L$0Fi{^1_wAOhrW%5EzI?O--%2(x1J%+$WK6iss<#+*9f5 z{OsLmI)7@<(id)fM%$JC`q5EhZftxgpWfMiw{W+u@Rwf7{U5F89{7w%*Xt(CE)r zRh;I_2thH>GD_P%bhIIX@k*a9&xNo1J~4`xmz%@cyT&D*hOD^Ybg&RGzpv ziC4*lF7F<%tjHN4>Y) zCvV*MLr=zjy3SWTkccgOb=6c@7Ef=!H5x1NF%=cR)vDmtt%uuJm9EN?y^7S&h}(ATY(%n|^au2$NwZkZzJz`0C6es;odYh+c(c;&>=@lp@+uP`g5>bpO$!KP;4;WXb7ZH)lWR7w!K}-Jt!rG zVrpuNUb)~>vC6BMXJ!uYu&>>?;i5^5jg5`D@#@v94QC#9_Iq@6bZ3_A>_4>!%AHsD z*G3dF-vripT-iYmW_7i|S@P28s2Ylu=%&8DzLKgc|C2XxY?Qh&s68EMS>Z-Menihv zFB56|%J!6 zM0(R@F?4iv;JG#g^Pm5{Lh6#Z@xb5TUk6TZ04aZEoe5Nxt}=V`cjM(vzbt2be0^&M z27W5coXTe)$jd$wFM@P2^2z&|)S>TiC)qdfco0}UCbb464ms?*)CRq;3n433=Vd z!>b$6aButZI`fzH0yb0&)_0>{pfc_(XXY-iz!$-1CkH$GBj!D@9`GO>r@Zbd!A9j1 zyJR>~$$S5qsb;+&z9B2o=;-KTZ^j+ivyF9imr%m8)b#X1X+6AK9X%D!_C&0P)kQ`< zKK}m6`fXpclncEpT~GM7oUf79{Rxc|@Wf3DPgkH72_#CCrz;wCN=} z^}k>IB@iIY-5W;y~f3P zE{g}n5E@DK-*l^eFER8(oVv!wnWZJ=aV_K|w&78|7!#{WHEB4>p z{=e?OS}v6uOC>w9wzhT~doBZt=+kG98B$-NeM4y{d-m+jg(KXbkKgpt*)`P~8b9<+ z1*)gh2_f#zu`y@y|&XEi5ow?5kRne4^x z01Z9;V^-GitSoxBvpq%V$c+F$zCd3hum3@f<#7ck)qJ9oBGQDFh|6}_6CPP((1V%6 z4nCclkC$Cew;GSRO!~95wr87KV>!dF-(%c_+Ew6o=9DTKos?S$UqF{^`F#Gou87HM)y86KK^Z%WO5gEV=$>=K|w(?Spn4*83c#8yDxQ9 zT$X8%=gs7QBO{Z@!NEb@62(-tU|V^z&Rq}BqRY70FNwMt8Uj85wz>JX7`Ui`oY$?b zt>J@%N-2qn0!y9WJZ|5g^`T?Ie{Dyzq^A__$8@6=Wgp{Ej)~)n&qKh?$N_5#= zR`-lYTXRqkd;DR=`oT_F!Dl_q=FdsgvxY5h_SdcN?A=lIc*4YlQ?03d2aaGy22IuZ zG2M$#*bH0cu<=YyO`kktJZ=^i5}I6C_yqq{B8|(@(J_|K^(%CaeB(Y^qGzUm(-Pd8 z2lI6ZpK4#q$;oN`%C_=tCeX(x(QKsTOLR2hs6lf$OZ4ilUBssJ2LDydjac;=V`(4gm`@8CS}#}!o2FC7+qH{L|X$QHBWUO6pSSQ--f zr;VF)w5dPRBJx(6d1_CoY#(#*fil%KO~U4`yVKk{iqR7Xr>nU1k_oN+wiQl$oBR9v za1r$Czwsm_B>2t`b>O-EvR!%OyD(bm8pCNlzXk5~iRn-+>^Z0hj*AX=Zr@ISTk*RlzwF@E%a_$~q7>gcdI2hi-tUz} zjfaP~nQ(C`ny{Jj{kx2kk`nz}MMXvU#UxyJq4M;nEExJ$#0j^qnQF`XotMWfQW%ny z(?!0**ZDE5D$_PjY=57H^=HzOQb)>-N4YM*!-8QIJ#XHBhTueEVq%%hKAY9Skb4>~ z`E|!cf!>{+GA^#J(VW()&y~e0Lj95mh~1CoqK1B1l5bQTkU%?yy~!0!yfd+pe9DP7 zyS@eOk;QzR2RMMx&U{;`)f^TSmHM%6SF3AD zvyAh0T_?R+C(^TpPhQW=X?{raxE#NK*MaNHjFI{C*Ur{IhGTT(=iI9%O^Q`Y%&SC_ zjZ0F_@@Xl=P%WQJeZTG$zG$ASl$H|^Kp`ZFmpAV8)Fn19&S<=x^TUS^n0wIRkg^C3 z-E{Ol@TU#KxXb?fKw1mWq%)aUA?W&3Wg2F;h%jrwT#lxDZTYsQa(Dh41g@<1{ z&84z9-d*za@>(L*U^gB73{}V*hqN8ag$JM#{E|M0hcp(?(kICm zoJ+3z-7a6r7}A}aaHkF{EGF!Gz_4jo^!>MvY{A0bLQ9QYiEuXcV>J2~v=Pg`ynfkN zP0jq>==}ZsrVqDf0QiY&q;?rzy><-JM)$+{S2dHV`ziWegQ=S zN>S1<2b@hnNSLqFi2d7UNzl-cnyGxV_C+HBfSTS6`KFGJMyNvWq|hbKlBZTzSGCLR z()*0@gaH6ZzIt`->Q&8-c;4OBA$bi0_@aEbTjJmI@_30;C5OV`cAQ-JqPj%WBd${F zsg_FV&@{OZpRzgMV&8S(R+*eL{1BW>F&-2Nf6z4AUpB> z@|(|*X38RwMpRE1> zWuJ6#WCX)vt{I=1g+=V;%ZF4{pXAe|&?Z+_{Gm8O`=0@@sn?nKToU+4dOG!dM?wd5 z$=xb1JD`aGhrgu0idiN_lTP4^TOTV+esgaxUh2z}4{#Oe7#P>Eu(XE?^q`wQ)vog? zI1;@j=+l_U&}+%d7Gu(+tR~S!f4tLCm8Ls>VwpYs(krckvoDT5?x{%119+aTeY zoz{Sa)i4U#eK?Et#Qbit&>dDLq`y2tdk;{y06HLa`g+f6cK~PC!6`z+z$6HW;I^Fh zJl}ektx|j==fQ&quYhXFX7-tmlmv#5i*^_2NkGS58ptIjb=gFN;tE`g1Ou%N02PX%Xj|IBPnF0Ej zXJ$mS5q?HJsR)dMv$fQpeIJf^&%n~iPo|&d6Z$}JM}OO1=jZ1yF`o$f`Sak8ku_8$ zH8>LKO*1OZ2^Ee_7b{)<5^bq6X`?%8_e@w^V(BxcOoeY!Nx)ZImL+vP(fx(xI3^J(^#_Km_~ zVkB&Airfx9cgwNLIB~20wsHJU*c}m9G=hLi9_gepJYh0E-l!H|LN&40Su1VKs4(Wz z&@Yw~mX5C_12!!SlDrEk*fuDg+l8CrZ6o|n%$2@Q6rY@(=FlUIiA-D>w&t~K*Wi?C znVGd2B)Fzd{N8la1GII6nB#MJI3AoTB_QsVm6bU3CuN|eZ0`+dV07kc@Oy0ic2AMZ z`1|*7b#rs`-Ig6^%oIawnlBEpxq3ct?aaB~XKpbx3r z;Rmu8-nh4JZB-XkaXD;0gzh*8d%AjX@Cy*1?jNt|1O=PjgweGYrR?~k?y!`Gf2oP6 zyOEQEN8!4)#5uY$6q_I7N7NqKYnPu|l8JAtmYuPtEo@6AA=xW^Ab;myP0vTpj=jbs zN!{|Ct9zTuOoS!8?@G-ZG-506MLyt(}h7bJsX@fis6dRcM+hgy;_!n{33O zX#HvdoFEzqT;k=2jEq5y>c4UC-}eW~{rvg!U%!4mG3lpoPvDo<*QW$n29(tU0FIWq zc?aBx!$O?>6K3Y5#KiYd8eca@? zv9QigR{9LBZ@hyQB1ZZ!Khdm4!O^=?-5$qHs$_R|Y>Qy35vm^wv>*=vPy5r|y$v@Y zjhdOBHXJF*K6iyfcQo~hbe+%uG@TdTH)`tZy#nvEFW>2gqO_!lu{d7AjquQ>Pzp>v zKrsQtoT2vPj-Q16?>;pfW{sp*uHEV2&n2BWp>JuXn-rJp8Z1vQbEnj|+L3BcVC%JD z>z|DMVK?7vOSjyXd9)xDK-B(bQ1#1SC&wg(fB2BzWQ=(wnr7dR*X2j^F{TrhXCL$q zhU@2_>$x8c{KoluFi>i3fINe68u=SR_G`oVfEIw6K$)diDN0u&tOiH~eHjP!fXhY` z6rJ7mv6#t8&9lJ zU)6kUr`#_+QAQMC4kM-EFAIre>HSa^aW|J0T|IHLdnU`jRAfi`RQN?YnicjI0{Wep zomCXB{&SHu-G;2X`ufvTQ)txy_$_U0>S}7HD)peSL7l%%Leif2++#3L3j>(`0RMD_ z#*jwE>2^y13GY{^tlFlgt%wvlT57#PxUsv`qg?628OLR-4P4_`c;Khmh=`or?fw1z zQ(!tai=EHi+sByi-Md$Lx-G$Fw|cd@-v6FaXCm57B33wpO8olykL2*YW?Oj}$D1eR z1TpX3VgB_ck&Kom>=a!+|5$`J9cm*6&V3?>!N~1vtzz@rOA7q%6s`ayG00*_JLxrGR zeOE8DgWLE7R0@5wnJ%{z3f4&jkK3t3R#uh+NE^F4x4GfT00Tn^TV!P9v7X+!%H0PK zvJ)<$KhbN(W7Mb&fJO@SnEq|~VH4{z|95IE_HHYVdkO7BF+}a>M+V~hO%+b_l*Y@y zmE}9yzGA*MGn&+T;mUr$^_3p`jji2(t64}|8kJ};iR&!$dk~@`e-*WOG@o--;vzB* z@Y9(O`RBGSlX+Ty2%eb|$P{5us@vaNs2E|n3h26dxi8aS-5e+;PPu_m&k~-td?BYb zOPn=(&tB78C#fwGPI7z&`-1QBF>HIIrn_PZX%${s$yD*2$fodgpu}6o1GvWF%o10~ z-h=$)&K42#HkMx>n`~zKjB#MXCkD6IW`;6-Z=CjoQXctV^VP;C;d%M<-597Q0x5Ec z%H=R~U3AMfK7YRnr1z-G&9*kFZPC73Ea0VqCu_N>TsNUo~2UL9%}=+HNz z3WDBEz@mTS=;&ZP_H^S-j+(D{3hh<|z|LwY)h_|gTG-h&G&Fc8CO!n|^Yy9u2igf<;fMJkOT4;#k z`;7csfxK&r6QlkY=b2=x6NdFe_AdsXrLt`tyc1HAu|2I=r8v41;kS$O(*}ZTIQr{p zZpA(k>@a5PmiBf^X68^R8Hg6$-Q7*V{*HL8%zgpT>ai1O_y99MRX87j4%`l6>(X#B zonr3WdcfMXwSs~#UVyqc5Axa^ln6M_xN%bU6KKoseE=S`b)qqE5(z;ig?|KOnMX3@ zsQ`-wLx0n7KVt*oa^`OcIjh+)9<>U!J(MaaU_Ma#3v8C;-nwiFLu+uK7z!2iDUeT> z{}meEl(AwI>+_17c^z9Zl-a*8Q(N0Jx9sN4%;E0D9}Msxxt4nIyV7^3%@N>LemL!q z{7VrzG(22j&_Uv%li<2f2<(}hkr5j(s+^qMFQ76Su6y_(WTQ`3U7Rt&W0;0Y05VcO z^g6hJT#ePclgYt++HmB`_xe>q+U?2L5e1cSC|~Calo;?_I>812z3L0QISI(nUt(fb zW?o83>2HizxB#y@)&XUL)yMYy^yqZAN7{6#KsW*A1~B#pA+sllD$5;1#U}TFb}k^HC_zENzb!2@P&E)$8+ve8p4JP#vt9Xypa(5Jx5z=(402TL%Ac6Z9csK9|D;R)5@-AJDvElYkb|%pL9y(nCwr zFK+k!)fCHleJ=X~f9dYLC$D?*elVbo3pc%$XW}N#jz8~U5_0@}DUaT$P^+@hV};j9 zex|0kK!GtXCZ+}O1OkEK%n5Wo%5&adol1#%TRS{V1VvI!U45w3I*m_E^5x6U4PuL^ ziV9wk_#RVIqRSK;zlIwZS5hJX*aA<$3&_N{^{u|#%40w98VGl>&c{SaU4`DfiF8Z* z2#^o!`gQ;M>E&f{P~Kb9kOeQ4yK@N0{OojCuNYw5S~O0 z@ZmP}66^Mp&iomk-1!{kNtOc|>F$swtQjH?RTksgP0IqE&DcKz zw8#eSG1!ogM; z6>HZvOB(A_#W{uPh35V_6dB@cvZl(vV@`#Ih4Vp5)IN#UH@FvQ&C1Mtx;FOr@71`R z#VgNcWN?A<%)l)jlhi=r)6v%#fwr@^AQ{VX3pSSB>frMzM)j*VZ_at$d;8?qJAkVb zt_NehRHj+#WszV46@p&_mGEZs|C4dMpRSXelF_0I%Oa%#Tmu6rB8a#o-xKK0JoYTYOnt1!c19fe}#MP0X((Zwv zRMEh_`gwSGXq%a37a4X5f;nDR;|jiY1V~}vFROv=E+Z05lC5iCKnH#=9Qu!N_J07x z0;4#yj}}o}K`YX+h=YC&?u{FTTV#pZ$oNj-X&=aH-@v)1gHq#HRaIpL5`}xek6+U4 z>@4*{M}nf2mDTxH@j4Yhzs69JQCMg~g2s;n$9|rhYjz9m4^rUr{GcWk0HU|@eXc5# zF4T}po3IfmcV0m+m~qx_SAVjWjhS0Qy81fx^zc)`-0mw13i6+7(^-r0-W#gI@?XvD zsuCt5ypYxomB1j6YH?yLcQvQFy85>L=ph|6x{`Ne#uk!kJt0w1p9%}D^DP|#dek@E zL_bi^(YrW5!wJCGoc$XD#c2E6GM4P!>FMdk?v(57=Ht2`85O~HkX+BC#*!6_KDDFp zn#dWQ|64p#Um{n?CTqB{^1NlZokyd^_lMmbKkp2OfLG0Bl z#2{WBuZRJcX=Zj-2;71%ECxa6`QU7YM?v%GEH+UDfdh#RK*M4xcBT>UIxoF7@a~S~ z-m@wV2?~lHi^v=hJwFEkf4gPiB@RKsHCn+`Fb1>vE< zaR9%oP_qUdv`*i^K)pJjTksbjJy25}94f@)5_lt9Sjml9? zcV+mXXQqRR?iz*qufPUq%*?G6fBqb;p`pVDEF{`=FdsfX)oIx5mSDO?LK5UvAEsO@ z7M3};88JfaCq6HufMuEC&3!xiN1)-do~X<#(T~O)-E}S4EIQFCbajQR(d{-+$Av)w ze86qT`Vu7pwnb22VBlkSpt@;2OAg?)9v#jEgL%UPDgl}C-VBxIWiPJtU(xN~T4;y^ z#OHhWxG`C50|OPHKar<s@k6EtfXIO5TdfY}U+J%KJ$Yt5-#P`_ zLzQ_&c{$Ja_Bs1Ak>oCvaIjF3${$y{>sWr6uju@eG)|;=2FI>Zb_kX&*>HjKnAvxp zOgfKabx?M2u#xzy%CGw!NPmcW0di-d#gw2(5V5aRn)B?2ySP5dm1H>%3r;VM+)CaU((Rqm85(RQceV^ zAVQx?OX)*4^FZk$C&w@`SxIx78icx~t*yPeyX!J(zmm{tTnzN@6A1TBS*@ZnBx}&M zo+3Y4T+DWl>Fq4wBp{QYAmK2zv2jto!(vrT?DxBET%rj!1{uRh-n~B^f4oagHFpo;xrlU}gTA&YY#oFQ27 z$B!=o$b?`a<}gJjn5 zKG%3Qy}ox~mlVAl`wDuCwuy-Zl%B24O-+yqpuA4(l>ZtTI&~on(O{&snpL5_>`vHX zpkDvwS#g|^;y`qXZJcF3W7(O_+{r(lv$|1H^yz(`n2m@n3d%r7M{q#E zjgKB4O+b5ic!=SmNjnx+SJi&Z6no-E^mM049Ca&ksPcLnYAzT4r{6%YV#EHBw$ zrM(-~>NpYt@9ghfoutP$h-fxHHpEYy7%w@D=p?!Qc9*>^7#?iEjOOwgYTBZGc>3$i z$mhy7HmsmMFnvn*#O?S2N9j$L(udq!CXkd6CJ)&Yp$nmJGHDj9ToG{w=J_jXNj1M+ zjLGpkUjIzfaaH8%wXbh%Dk$gk#+@dVGjg*Xt#Yq&L;pLL-7fAVwtWR$@PPa5*btY1 z3hH=%wBX;CT4^zb4p^qKzyGd@iHS;);d3>$*h(HIreOAQ2Mowx!Fnu#n8p5X)Zse^ zG>%W=Da;NP+s$+o{cSBR$SH!Y3foCf#%{iqaJa7$m?dJnf3h6H$u{+W>nZwnf(~i zJF1wp$w|V|W^Jz)XIGm2-QE>t%{^Y(zD6b<@%VkMfRq$AL_{XR-b{kyCD66aj)=Eg z)AdOB4G{-H_S~$wLb}+IDVywlzOl5lgz&A19~+}k5y;f`Q9A>zM*{fjpne&pH~*hb3LMMF)U2pZpDmLUWL5U(DR41f0Kq6M@8M+k@k(WJUK9;iaXRsfGE$jH#Z2VPxUyP3204{1b485e)u+_Zqm z1tNkUtrm7Dm6#HakB=V{4uPH8*w}aldJhB(T42>+XR-o|Li4-5{M-W*Y^ST>LjfwJ z*>{9)L)y&9F7%B6QYd%V9U0SNJCNfkKwU(Pq6lp-qvfwxEGfBn1&@G9rtY5djraN~pQYlv;ZS&27Q}+m!XZjQ5A?<`&~{qhOYS?+wbd#YBC=+1VKq#-XLfa(8z}2oIPQ(osl*J2bOwFIFyJ;8u>LKrfdzqy|F-w|>j(y36rGJbO=OU_D z+$*>+1*uwCz>t~0%Vm%p%bE&n8X5V5qXE1D$%x|c9im_>#5=EZ!m-ztIe-dtJaz*e z9sSAEr&@>=yVP^#J*3?%_g9rYe8~Y1FEo<6>%t-<$wr76JOR|@++e*v$_Zd|eSIB; z1VW*dC~A;E02eks{79PPPLk*vm?((D39k5Ad~2EgM#G;!FCtHG1<%gM5kC_dpZs)( z-KR7zn}6NZOvLbo{cpDY>WLQY2yx*A0qM6It{>^dq7OM#3s~tBiOKH?`b;Cc2JYdy zuxz^OPe#QYwW&7tiSOW5mP4OMA$A0EiEtJ>A*PFjctEQgta9f^m6VhqVipj6$XNP# zcwhjROhP16>f#4Z!luM|R50)^~fu*ag1BLdtaDJkg-zdIkeF`q@Nr$1P2UmtSi z`>+u_G5Ir-W>lT8yH?>9+_lrQZ1EkUpn9hRV=mRL9nTWbg_n-*2r9e`1;zw)66!q` zTxE4l%?HTSJcXRY*|;yS^S&;4Jmm-D&PcurI$eR*A56H4l&Xe{vxy5WQ&Y(VKG(lZ zO`zamBZaqEB|D+KD@&OLh~!NHXfXs}dthMR(1a)Z7pgrHjgIBq z3F*I8R9vhF+aiF7%?~ywln^8aiq!3bK+1SRd(y3Q0!NSRH1m)U#Y83(**_n~(c;S& z7rax3JT4Asni3$6B}Y?0QL6))JmB8{AqCHsMMN;8m^4yB-J)QWek2ZoYH-~L2M0&( zc_w=vva?4mE*iSHxcD(-?mRX^@)r|QAadw>?1*k2*!hb!O*e+I4>@dO4#;2pRFh|O zwstyFOtu$b<9RS!rFT@xOY$!g|1bH3Bxp80gNySn7k^x(u1s*`jYNL~jz@@U4kKrLulh=+!|3$*#$|FyvK^ z_ehIX*3K9y;DT9^3zjY}3wEqJX8WqXaJ%!YG%w<3@?{KAJP#+C$DXVpUYRWoGzq&8FI_ zVjQP5jZ`Y1W5K7*E{&T=g!@5id>gW_$YF&H;j6E#Q*iDm`d!cV24pi75cV|bprS&g zrKJTs3c=zaoB|4=*&;gVhM+4Le(7>4SWufFJ#&kc)LceX7Vj>-k^o4PPgq#~fJb<= zvv9wc(_#_@LNnwey^xjy2TIiMMR-C{mP;hbFY&U=Cyr|?I;U3o)JG=jBfcqGXv9vL zyvf4@p|jEu!w=a-VPQa31$QMByoEwYz^JLI{c_r)wX?IUUw$`S75L!p8&lOGHTAZkYUcQM2b{qqTDWhLMNcO(F0L?M9I z_7W6x8XB78Z8#N(l?I*^5<+gjOflW2k@kU~+l$SwgS ziMQCh-`%a5j5$;ztUcj@*o0k@AwY8Ekn@{e$*t-q&4kL5?}S_eD6&YK0O84_0DOTw z>jf#di!=ML?B>|j&?+E&G7S_2qL@+8(yUJpEde5WLplybGl*Zw{CuYl|G88R-pt;> zF2vO!2#1Kg*>C)PeXrePRMQ098O!S&iTG`Yj^IasjEah?`~<1UV}{xHV3rF49tEGY^!WqG9>eA^uRNjz1C-hh@_Z0?GTUn+6#y$GueiAHVB8h@m#&`P zQ?Lh6snQ8j;^J5u;O_WZra;miG~RgkbH}rso2n(I-$391DvVD`O8P@Fx4rY*3s8>O zd?+|L1}9Yj9K#)`^?%3|fZP)E(n(K|MqUD&==5Kv-Yqp-=SsrS05ctxt^-acv2#Fr z*Fs64Nzcw&H1Q|4Zm6SW4B8bOk^fFAW2=P=3)p3j)4J@ zSeS`iS@XXZfOM(mewd3m3=W*U)%Ilz(3;QGG4zK7Ug-D6>40g0*^8$0_p zEF2!>e~qYe&_2S4Y-134C5YbIUg-a1E^Fyztk1WF7N_tw-*bbBNW4}sHuofxbm z{p$BYRZ{v3rAPUf1sT9KaNR<_eEB;;K}B_cw*j;yx05w?z^;(Qp=bcr5V2cf zfae?RWk{cI!^+;l!}El*4RYr3?8dt+6p|u=6f+=e*W4L(2(7?`&CDV-ucY1@NR}>@ z8(5!`{3w^x6*}uX;zHI;x~u<`#3tVXIq=2*AUa2*p#lo%h0GjwN85Vk6%}Y#uU}6F zZ4q-0_5qYUWUvTLnAaJI95Js`IQX<^7PEhE0hU0>_uhi*;nXL!UlxF9v2kzTu0>`K zV1{9XaAYjL3>Mcl>+&5(FNyG%l6w zemYN$Q0ajRyM}TR7e$$34ELhD=sa=tUfmYZ(mXxf;<-580ht=0$g#!lqAUQi1}JtI~DFR*0Pk<`-4RG2n;{kq}1P34yRC<@~ zCMu*X!rq0|uIow`<#apc0Mu4HJ{}Kz2#&=Kfa+pkDxg4vtecofK+F#i^neb+KvWW> z@Q&Zs+#(m*JX%OVB4&_55D^l(q~Uf%0YA?Wwx89CC#1XaeZ{m66(v3KnKn| zBJ$z>x}I;^7tD`glQTzOroX{+FlvpE#A1_y>4k;HSWast*d)N5+`q3NqZRPUz%V5t zx&_{?Hs}VZ+4=eIDtC9paRv_`MZm0k6)sX2^mx3xcMt@04{V$El4yXK)m3RB+bd$u$dE+ujbz$ zGqI)7K@e3X%XZ;7eB(U7XG#r-OfZ~f381vi+HeF6rSRFDefjc5yAd*y&{CN4-9YK% zuwPfppW#10Bds>1Qk<8vuy-F>98h4tmBLK) z^SyFcA_b)Gre#5nw-RF+M394kXKb69Hbek=?~h*Bb#9*>1(>*79*nQ%%h z{E_nBdlkkvl2|z}UShBhNdXLqmqX$l;x?03%sVA<3s%~csMl+)gai4{cw^qwD=|p; z0Iung1gzxw0V05{BX%D!+;ip(476&$+%xW}^17$ruxu41b(O3x>>>O|vcgCNWRt+=MA$jtp!}cg zYLo1sEP`6;0R>hzS>z^!xx67d0wZ!Lq`CswE6}dT`uOqVRY>d}ZOA}v!wL% z-2+I@6`2fRQ;Xx8nVH>m8UzUOAv>Gl6AmfnWREmI1s$C~%oAWB6K@b(fm93AI-xvZ zRv3Xepx1!uRirP%wCu4CXk?eJT)E$S{o1vzM%n0Cf9VAce;k&xG;7$&o#|g0Vnlyx zi?C~Cwa#k2hYO|Lc0XfQ_?)XVG>D<+HZhe@RA09`6}5p}6)ZJ`5whyG!IJCwX|4f^ zBury9)Yg842P_BsHqkI3(cfPpe+Pil%a<=veoh@KN>>Lz$1|S z9s+@{-J#qB;07uSp=(^IosSG&{iFl5G{pag?F*)V2uK{^4poPT4#=JX;@%2KR3d9< zCYV1I;>z#QF#ICUcq6MCx9Csi1RvF0*ngh;ltj}@BhY2E-W z+d88@SuW$rQ~9W)68nxqqI@I%o077sc%xF=eH&5 z84ikD10^L|*rL?LFW<9x{(NX)$jv<<;F15w>KhhSIVL&L*71L$ok5{I>!sO)pe{)(ecM3WU z(k~$6U)R?ck*)d@^s`#PCTQs}cn=fJ5S1R+>PixRZ%On8vaI+Zxud|kssAn(HV9Ka z3@SyMu(Oe@@noVFF^tL280POAZ`g0UdY51 zWNKeR8HU~nWej;AK#zesgztgsvwE$A#&xEJ+Pt-Dm(*tf`$uPbcs&@vqSN_kR9YyWU%~+7(0-GAv^YU(!kR@n* zQqXUqI;UQcQ5z0~Y1UKV|CA3%J6ehA>FHCICX5kuIiYWjqx_bbb)*q^MlJErtm>HRHcLrr+^=QacN1+#>OU+ zNd_JYA}B)O7$(Q@H>v1LZ(w4;+yp>p{F9L1@IZjm;7dz)5Q{1-M7=eCc-YBSd9Bn_ z#Gra;xVk83&w(>h`$MI)*uT?H-++NYm@=j;R{O;(G03|U? zGa2yV297g4RxnZTlDh8R0%IJ00>9;S?D_Q|q4~>R8YcnK&1(PR=ADl-3~#a1soj;f zza_f!r+DqEp2LGw*V>If#TGY2xr_>6B11dO`mF@eqi7U!h=B;Ez#-g-BHb3Afba6Vs1Il;uRFkCb+(r@4w(W{nZ$uRZb zdsG$LQ+#TEQkBI?g#F%~!eG999F>3-Wc9QV@G%b^8=3_a%IHYI1kvy|4C{q@M*G>Pd#(&^hnRz3C4?$__?8m6Px4|QZR~G}<1)2%O<<|7=@)Jg6Z*rG}v?JsAEBFU@2o#_fJ;*hoaqx4NiTFR-DKUR{d39} zxM`8kX0Hr4Db9&}1A4YpN3X`Y-jIeO+G2Mib5B`+eZLB&-CfvUZnsfh)S6ovMB}Qi zq5O;@XiwJl_`QGTkja-rr^_Qi{jlVMyd*5N ztN^w~WlQI4_d79J-;(?p_{)dByl-ZlXOw`C_=~L4Wz9cZS1C<7Jwyx=-SF?ZITSv{ zkJZcj1RbX3F)Trv<`aU$cjnZE?iE+7Gxunj3q@TCSCP$=B!4DKS!bM!epb*`e1YS5kxD+N8(20Kxs@W;Qu-QkhA^}UBemUkjP zH>_OPe{sE+lvn(wAYEey{iZvN!c>op-1ic!L6VEbYY=Dy$+9c)IR+SfN+5`4tUaED4NH1=f94uB&GCf*Pk?HG;Z~GIU@Wr~@YTK-GKzYX#^T-V4&EV{#7p4MPyuWMvzg zazRF3glR*VM?^#{@cuCxl0Co;Y$;iT2_{zKULR{e*E; zYq7aXtY93B|h+gN)9x@CBc0J&^yuw0^Nr+Cza@k@5j;cIfrt`*r z&R~9qxRZY|MllHzI#|sPfy94%k%s1-dFryyjU_URsm(a9Xxo$ zuB6LIFr|3l?F>9Dl(Gov{TrA)E2G0Cdz&StW>@}^aUnttQtErTqe)Lefbv^AI~bZU zE$Fg8WRU7Q*Sn9r90e>=kh6f~-vl?cb+5w9fff_PFqG7@sL0`^^zVz8zj#CRN8=L;r_wnYt)F&Bjnfh)g5k9?V&DLvW->;Bgl&N`J_#Wz0f3uJ zFDF3MgK^>7zCO0b5>cSOmcR^qtl&u)!mk016;c*gaBzatj$QtvDz1)|MFDR^UPA$I zF-kB_NV>#)`KRYe@ZptQ`5DqIi9qMYy%#Gzw*zx~?pWP0xh%ML)%eOxVIaAa$(3pO zSZaBP4QioyB_R}#)$COmjC=U(*U3Aaa$!*_bN^XD9J*SN!y!AdWE+fHMN3`WNj>8d~C z9lZD&_B3yZgYT+&|2~dj>@ZsIexlv)i_=Y~D|zjQLnpZr(HE-UdJH+rbgpn-Y=q+q zw>(0hL<$E)dX8qpq_1LQ>#vWNLLO#oDJ_9nv--VcTBIj6mb+TObR2%8*ub*m&_rBm zg+JqI#mRaZGNKHo12VA#0>QPLH<333z(8adScT!@6wp$^4U{jn(vwQyYlHi8I9dA% zj(~9Xn?e|c&dLh7sH=iuItX$`nT^1x9`cGEa1xV1bwR`z@VfxsBMSw3dTa4G%rQ?- zPFBPCRUPcA8`ZFG9Z&?3v>1#TWhv#?7P(590ant36bX#NgF**$E3dR{a;d3@6<+#! zhdY0E!I9Gr$d%U)2=4tQimo>5fvbosI*uoKKE^aumgv;H>lw= zmAj4*&G#of>yHv1K?JJ-Xa`KzKcu0#2Dt@z<=2bcw-v4GDmjpX0<2BO$k=X!#pofD ztOZ9ImJ#NP%~G-aNhKD4Vniho6rFMysqn5X|Jrfg@J`RBKfv*%*c}Q}0ctYx)7${` z#CgUPr9F5?sV}z2Oi2z3vREEZ2uBfkMev<1jmv*y_GVFyI{DT%_uQe70mDwOz`dk` zbUAD78^M^ZXr;NM;uqJ*k%^7&+DAdC@iO~hEPPtjBoInSW`J7GZ2dZkmCJ$Vw~`k> zSwW}K25CeKWnD_0F6nmnbb`YH7Rm9SZ!#a$m1q7`5)Vc==b5fp@fVfy;7zKL@c1de zzmxPm-v^a0Lyd(0IR6i8Zyt};+s2DNC`u{~BnnBADN3cHBpOT^GmAtrPaz~SBx8e_ zA~I!GrVtSc84{ulnM;I_5a)aMzI&gu_c@>a*ZJf3)*H|BthJuC?)$pF({*&a*J}@{ zC!9b!A$gbo+==9uG%01)9i>~}R}8&9*ZTcNZh?1sB{AdTrZ<>4;Lq`h`N4!_rQkU~ z(Gz>moci$h>~!~Hc4E-35VzP*%Dk_8+U9fk={96-4Cin$ZEO5-+%CF(=gZ6EN{Whc z^vd#D(iBk^2DNN`iB>x}*zzn=TA2oY%B93@2B-CCGi%q}Vv6iNTN%fD#ieX2y6DaE z8k>l%*H3=zzV*mv@l>CjKbQ3rMMLraIUDEa{8ze1`^~xLtGD8YEcRtqw9v$U;Sm2^;(7YUK+CZ^(;LIb>pfM}o#P9mgIK1-W0}vjj#zuVLf=Q?<|TiNX=umql!hw`lsadN(?4EK>}2 zpZ?n!F5-LmE&WCb4dD~V)mQD^IPknIeTVH9sjVylYdoYPtJ+4n7le*VS>*XjHXT@M zE*B|i-*a&3?_||R5@ZL)YHPl><%FF;#A}06U!ghHH@<8%dSBN21r%{iT1oa>J|1<* z2-_*9ao|KO-)(ytn=H4L>wAoH>{BThrx(%GNZ@0@fNEK(&!z@??vmQ-9h19nM$64R z)mzl;hFI>8Odhc=%{M3SP$zYa5j6{;f1v+)<$Y4P@%%P>rt0qck40s4LlY-CmtTrZ zR{OH;-RHg|Y<-d1VcKIOb)Dq}{QQZ!K8(!uT+98LfH>Y3B%Tc+jsJEE7rng0W!9V9 zIKG~-pl|!KL-Xk5_PS3Ir3RtV(Xwckh$fQQ)bN7CPUmG#XCGwGEph6c_ba9s=?FYK zdpV1BFIUYxM>)kKrt?Qt3r(G~ryP?}y#16L?-z9{x+80yr2Hi%AJ?74yGT# zekRytky02y@S7(Gls~-LV?J6q$QzOPFjcoESl#RKlhTJl#r1qPEiEk$6JMf$;pM=x zMZ)YR9?*yDTZ`{;J+gJe=XG~|%}8KHifaRR^@gFAh_t%*-28m)*LC6!Y8QQxXwqxPM{!SGRC;^5Bv^-=WY< z+M#EswSF#!JgXTwpdarjnbI=(8%tIhqqpot1E)8yu>cQ+hnrg%%pVLKUPziiCRRVO zLE#|0&$W%RlunE7tunIBeQwegpR8tYj~}d_K>b861J)fImK6OsG9F8&Xe~-u2*r6A4LqY04RroV`A74w$wBin#1@f zsqNeL;yLbHzCG2aGB5x6tdKUOJU#Ij4rXn@p>H5VtcU4h9~x_%uIxklmTi4=NBzXt z6*g5qx)t=0W8_T>%@?j6%#(oq~bU^vbb_QDXAypIhh05unOOqx>)DrcPB^lJl!Yss8PDngJ`9|2YhdIX0 zg;}y&N^Ul?Ok2erJdv>9bJrazJt2Lio}bH!?SIcI4D19MC$*Iqj}Xun_aCCdQiE>; zAFbpr&tDIvkNjLh)wEC)Rq~WNsl}89qGC%S8+ofmXB5oxN2J zOiUR?MPe>9qpz`rkt8M*CVWeiDd!LQmay&Z8(v#<>F2~ zNbx`5gQiur|h>UQz|NA?yT1~wRKS*IP*YkRvX%<uDQHoT zf;S^gAJW>E35mt)9UQu zzCVFe#g}j9@ZBhU2f4+EMl7&)c6r}Ic2vL$UJXh`z8;V-Qnrp?ScLz`D0ztZr65;5c3L6Dv-M*mBAXGjBIf4C`3lP+Sg-RX*2~#1)bmvUsyBC%<(}`m;6Vt8M=dWqg z3LdZNe(GT6Dkf5>ArVR|!5Z``r%u2D=kS#%gb@TqQf{#lL|K>r4gXqoC$VF!WsJk^ zuxecO7fIV+!gc9%O1g_%WE#Tc7e#qPS-y;2p0x0Noxa_dPa(+b2z&B-6OXfN_pF@x zb@qQEuJZ01s)~O!=-QM@oxZo|T zrK~-YwuQ#tRqnrHX&|q+(^#&eq7`&v9&8-wa*AySXwhPe?96P8Z#K78cS0+X2S!4IJKt{lOl;fvA;< zG7Ot8i2{H$%B1P>d*HwjMq3pu)=#X7$D@nQUh9=464mbAVFm>*bu?lP^(b*e_8$~&iiko4Pz}MJ7gPa+mG&*~+$r-(KlI5iUZE9VE{T>I zd)lpi>>fRfM;X+0E>D_mIIL*0#2v1%95f7e;Xl~)oEF{(e09<>iuRj*21qvs&J%Uw zrc1#%@Yco-_cpWqYmDd6M;vwh!9q-Ya6Mk>DZK&mkQsV-3M>vyIGOq7#h;K~85S%) z&&_>Z!!!1E54>eN%43W3@5V};FSk5;N;T=SWLuAvVCAqw}N zJ-G{S)nCw0K6QU{AY;VsOtL2%YMKt``vX&1qNB$)n~%_ozi#vF-!*M5O7MN$bU*16 zn+BZ>c2O}J;3* zi+5rjy%4@QXP#OcxmzWhCW)Rc%>P+f_x4G*>&HSvp5C_?y;yfg@#jSb@96z(987HS zt>23Bjp)|@sEf{a1n^hwkKGGShe<@dDP?LOf+#}^95>_iC!Ie@)jln$x_Ul2Z*NA5f zEv%uvEnFqIs2n4ts;rlOH zv2t?>sM@^@cM9j&drTy#i|J8RH$S7X?W2i365E9)yyMPD*5tcgTU&GW4t>q}@V~d? zUCeXBU$YTpI~bP+cO)vQE86VB1{4sUyAK@L3=GC?+#cX%alzukLLSy$w{e}wJNniJ zGcQ$R%Sq`OT@C!rz-ZH@uX|HkB|n`IE7`QXICN^#^&{QLsXZO3z;k(bFi&+H+ye)1 zW*?eyJODoSUara(?|V7w{!>*6eDCn9GxmR{EaFAQ6fj{~-Q)8HW%kCYE3~WTXggSk(+TZd>$ytOa8-*y z&i~K?#04@2vf zV*o%}=ELYrpr)(;Q-_@jW%VLUt2yi=Ruw9;h0Hzbq`X}G?OovHbA~2<>T{n;b;b~0 z#dtxMIzH!|{jGHa4$=aR1s5CKB^bksd0Y>(^nIrin_REkvtn?wdSS9bY4Rt1d%k# z()Lm*X@-8fvY!8R7z@Ls%a#kbzh87)+Q`KHh`G_ym*zR=_e%rE!q$trZFzi?qwb<$ zmkS{vqzr7SCP{8ceSHAJn_}A6BOU>|Wt4nIHFeU42U!^0^2T@AexrX@+;uR`=uv{J zz2&i3?bUSUKA+5MuAA*OJ-`^fo^J2&gmjWD%zK4-8>PFu`(XE9n!txrDJdy2XQ-4T3oaQKP_E~ zF3Q{R>*$x0w)1V08ILj>T9Y~`zkZBmiT~)fiJ0vSHimRdhnXf~k7S`8Z@Os^TPI_A zsfg6CQjzy%R#zaDk*sa@5xW|my4*P1Ue+2aQR2ij!KU|zX3l*>N67XFOTGB&a|3-F z@=t$#g??d;A(a3hWnq3E)EYk`R?ngI&dm?|c1V^jKkR{2%+%2E`T6Vgx;%Rr+4zkTpQq4f)$21&C(?U)900+9irgFoPO zxAH^xiH)yMdsKI;|3G1I%DoB6bswY>)E#3I9FXe&_h9)xRv?_x$R^mi=B9R;u{ zEI}zpS{?8?F(uN5X&*KlHl^g`?D1YPE^Yi-y-#?CQ_Z#UOB=c2nO`T+dLqy^06A+y zxsihfUDM5)<$DoFWi0+)2kgcg zLqmKILP$v_O7g-UmvvZuEefZmr!QjxmeGyMF-@Dw88aGqBu=SQJET9L@yqyEJA5Zn zn!Ew@9ze}OszwFRqb+41$v#6vKH8bdh$k?4rlc90eyg(m2_xGE-oW2uHw~#ZKSz~7 zt<6|@S?l8=56Iy-j~zQ!zW}`d-KrL@gO<@#+U3UCELE4b?`N4@S2}Z2b)F5)BKQ-q zEU1Ca7g~@ys_!~}i0FY03^<7h1*Kr}9KdYi4ufY804d>7E_|Fz#jcBRPB~zYEaJ|> z+L?zxk3&{{ur0z6L7NUgdnB?YcH>0E@s&z?y}mcTfC$~Ft=i&@H)_OEdN0{i(TI+& zJH4{%Cv(){F*stOiF%4)VJ$}yNd0iZD^RmJ&$el77kAD{vxF>~qy|F@xCshjNTcPy z3KkaJfr`Wjk}Eq{PQi=Z4GavG+eWS)Lx9-oCD?N*@k+eNo<`K$#)+zDt>47J6o8x> z=DVOtU$$X+VT1c3^$cr{lb2%esZ5Q$?oxP@JF^O*>*VFFeeng(FQV&cod#||5^^c% z$iRO+LW=bVm@#xevlkFV$l=@Vk`mjj*Jx0uk!>n+rS_ig$$n_?-{N(+8taILoz(f* zjv=Zexs&KX2u%p3;-;%tuj1H|Vh)Xv64*o-Ya2ko1t_Knt$(K_a{BL%(DVWQVY2@^$oD&L}WGg!O|=dJ}nL<~X)`A_ zVua~ZL*%&!N+IHFgmzxHSBG3ZBjpYXEghZQ{FaSagJ$S7yLwobb#EFp>zmG-czT!P zlIB5D3m4?Imp>}NhjO-B(q-$F_(nu%WLbtxPfyp0=5VKsA0V0^7*_*`^0)0OHHlya z$e(h#+Fu@#vnX3m&YnQ887g3Mk{acm1|_}y^#)|oBn(C>Pz=diO1RAQ!4q*64Hs0K4GtH+zs;8bo@GTGR1h^O zKapC^ETmhD7d$qSfHjdc)3}zt=-#6hIaZ+A*Tsk!xDxW5r!J%QF*=qP*#hqO5D|ar zJAYlhm3}oWMit0Vz_X570`E{V(?4y(Qxv+xWTOY@m*kVC_+OK(Gn&+IKk8@-x^7Qc zrtzWgy1C(f@B3fM<-}|P0n8@E7NP9-Lp&L%XiOq_gxy04JQw*C3KGT1!$%>wF#%ON zI=1C&zlRtkH!sh{;{PX#=*(y2A~t_>AWet|{+fsUAgBPP4N!5zR-4}Ua6~g{EHnJy8luu_$Ich;+uee zD;mtgaOh);*xsL&8zem^hPKC+sNR1j`~21++-`oG>wnxbJ_$9heFSI2o^L`K-&S{o zc!mi&=mhqpNPLMK)kI~(=u?$2Otm$swv0dL=_8S)&T!8og{Q`YhqJ!tf+{a^`uVuH zp0P4xhPDi!CMCY-IR}+nq+*H@N+}W)1so7y(ZT$Bj-Ji^C?j&|xMe&8w#{^}8T#9x zr=A7d2-H+VX#R$fo^YW1n4(|8W%Pg@DG!t7cI!nbzHFR7HgNx%R?L}~CnQ=OLxjv{ zqUo})v$noEsBiATI&k>+rIfMOmhbtd4y-R|1+!W5%IOLj6P$BWDPj<86Q36*pHzTI zC%h){4&rezh7E97=)zX-=1Yd%G4bcAf4^+LN+YHr>ZD#QD=V9TSZJaZCS(aHpu4XP zBojOCB`EBYiIIACxNSZ|?vP-Lnd-s)V=5dLDGGZOB20AKjrA4Vl1$cGgiD(@C!F~^ zzaH$xy9DY!cus0Jo?NQn&n~S5BIG;}bOcfWS}ckK5IG>Nx$?^%{}kJA8k)b3jUG}O zzcHHstYYGavL@t{Wct&}^5T9nQX#vP>#T%-VqkoD#(AFeG17TFQS%?2r4Bt=VxD6A z=Iij_&^vDPhvz?V-_KAuEO^!XcrGL~4KiHlY_FcZdozMKE z?Dgbv_MS7DYB2@M=^r)uGjlI$vBib8^Uli7trT$Z`^fzL!zeX)U*_++;J`p)0jEH? zjHo)g3L>||^?a`t?X2CgeEa&%ps(4D=FPKoIe9-4>3|pQMdTO80w8@_(u;O|gPuy7 z5x&xv8w=DsO*)5LY_4X$IGp)hMO$KO zMr$>e_P%%r;m?cfcG3HyIt1`(gGhDQRS6ah`JE_Y-f_bjLdG&NE$!YdzV_tYDogtL zR}9_EUUhj_D_4;9If{TwY`MhT2bWoN$RQ`e^6B)yH&zv`kGvUiun z>zP{3_U#U!Nm|e|Xe`Nk>;WnS-9r5()qH^VTZCwbwdYu3GkBp!vLId}6PwcKW zE-kpIJ?Eqif2)ZyRSs$tB5k<(_=YdU$kR1!)^N#ghKd@Qxi5yuqjtRCVBEk(O#Ap{Ys-+teA}C6 zBJFb=D{#rv7wzSvtq#sU_!2F*MtMO~jS?$jBH{|01-$p{b z0~M)q?uH$ZBz$_IX|=a^SaEvi1b6SQV|$`ULro`=zwF`LQ#W%=Jt8{gCclzWO)`VA zWj!m$VdH&9I<(vJ*BM*X^<5sg9iTAWfdl!-SO02Q6`Hv%I1m*4wfTP$m>`fK`$qNr zD?0uXmJmw#tnZP++c%GrLqT=jk|ehc0g07B3%|9cM-Wh5oHJ2~vN?*T!6Z1v(Wp&USwl^tnul{{vuB8$7T<}gw+3}(A zdexpRadJ4F#me}bm7`U1^HpohOYyN0runErq`Yg-toGti^!u^Xh=WBq(yXJ&5dQN$E#d52JV;V z^^hYcdQB2p6Z9JWSvH53`8u{_#ZX_m&GR*P(I(gS z$%E5-_>QS@&noi0|1O%Cpen=nNu#3s^`mLWp`jP)*bObT)F=(XNTh%pG9E#ZOQ4c2 zEZJkIn<1i#=jZ1sHTNI=-^7VMJ{d-eCcyFy&?-Tf4H*z?l8RMGthLKdUUh>3hMPxp zX2!cwQ%XaHa_wX4CTu*&%*|BlIcwc)zxyj~#O3|AMsxA9FqAr| zbXPWbBYW6Re$|#ITwy!-zZ#5F#6_d?3j$4;;FG!{aDqaOU?#|`qEg~+%sA@7N)47o zpby&#C>=s^U^dKa_`?-GZynkczxiFwCKqduk|%we3?|I-&>co1D->TzBEmP&c4IPt z4(DQ!;@x4DAA5wVb-6?jhc-tlRrXh6$-c69P}b*x?1r$^VU_jP+pJ4tOH+SnM%4v} zP1#J|Yt19bRuC3i!cey6y*41ga6`xmXj?S1e$4gStajy=%3?-)&vhc(idgLL;}5^{^)72^Jhao2U&>v7 zR;-p7ZGWirQQ4H^Tutk{Eo|zITVhXM(@XkYGUxEW)Mx)CMJ^mUZBFGfGm|d$B;M`M zG%1x)8~VZ~F?{XT{}&C?PlSusJ+XE}bt3k|@^JHN!J37f))8d09c5n&BIy8O5%b(p z0DSsf-@+>Q!u_uckx>#^9YR7eI_U~nWmp0x?z-z+H*d)d;gMoHE6TkGCxmQSX_L76q#duzoOE@_M~j4_{=`^ar~ctK!AdZp@Q z%Zd%pf_y>8_fD-&<>&GWX5pE4FKL}zs#{R5N}?^ec;VKuquGNc;U_;>|JnVQ{6S<( z%p4pcF)=!68$6h=(-+03wVAbY-UC2n4;dl^2u3d_vO#d-V4(`ZtgNFGO*J(HLlMd6;X*n5G*v;xWz7;OrcPD?8@&}%H*z4)Dx&i?|eRm5V z9WuZ8__y^xzM=|>C&0$G7@;LUj;y@~QH4O>p5@==D^xY+lIna+u+bb~`ofuo=0a;K(LRL!$#tais3?Q5R(OVvertygqFL9fZq-%g;dxeGX;oN`W zTgtT^sF7N9OPKsNb4*Q^2`B^Z#?6#%8rw=)UB0$;1eAnL9JsvVCx24)eG;pJMD&MG zi6TLKq6wupZ`g+lkfyBTiC3ZSLm%?&F9XT zSNV${eg!<%-S70sxx_i)F0JI<&HMN6k9HP)l6F=Vf84JCc9-xmHb4p;~6k&T$2aduy~5~Qc$ zXe+pDEXc<7&usr6zNXeUiFdWmj6h(|7+wct6X^lYFs{rG$5@-9J~1f0G=>?As4UZ? z(gD1jfB5E=-_0Md@?uU}FW)^Acm2SHJ+jY^X$-ZR#6>@mBEZ}& zmzfzEESSasx;!6mGK@ZHesQkX;^m9G_sn-a{l?xG3%|i z7d`kU@NUUvPOffs3z6-G8DBn#(*J7+pp zMka=egP_oQYO4Cl#*n7IQE)fk9(oV#>NlRx#fW(yoz#BVF{mFzy6Ii_DZKt_4|wk8 zuVflm|6%$fS4QV?$BT}JhW0~s;X2u}2RkF2B8z3ohhKS!-}P3^HnJZz8GaloC$G&w zQQwg+oI1gItZmdgzs~u^{*oJi@%d|%bGRw~Qcly2=aV_28q0q#ua19auTI;+Ca;vw znGrh8M5ZJAM=#AX`3yULur&EA5wKGuFva0f#v>MXu~_|l7sJfZu|@J$_BcX%-Dsg8 zrjd)2RZ$$+f8!-jY4m25_V(S>9oK!Y_n3U(ci0g?D#W9`)E~k;Md7mJ$zMC3*kq{qy$&|K7~_7HZwwb#r^Ssi;|4 zS3?*9%=^#d`@J#U@xFY1GGyaW=)ZYzk`OGGf!*o%a|{%9&yNMY58JI z4l{v*!_<;ak^VK&<8<`aZ*kn$4Q3Jn;bA(_B?9C`w=%Bv;Ckpczs7gzn75zzn!2t~ zj2X+bEp0K3Z*3f0Q0w!u{{%!sO-3L1(aRkN)iC zICA`meFTBR{iHall&)M(dU08PKxA0x^gl4>9FINuexb^Pg@(L5^Z;&uawf9d;o2d; zlN5+kN=MT}vI$TegHA~y%SQn^%_)Qs64Q3{Vaqxwvg1<6m9btX!N7ub*sJNpqQKj6 zAbiLj)M~$g5v_2l5C7UiJ}3#!r~B!kr%`tijXK7+3}6X4oWCBNILRrR>X$jOGd9o*a;5-bcWqE82L0rzZ?{_9cuqFHo)?Mn&$w0O!+B_lS*L0?Q4f@5MTIg$%ViMNm579kZr9)rcFW z5aFuKE)T(He37pKg z<8U`hpWj8LG+2v4A@H^Q8y$sGpeBr80~|pj%n*b$1cySEgF!2xgQn)@8uQ1MfnpmV z%mMO}`{Cgv%*J`5c*HK_8j(MJ|9BTtxu~om8lo#C!+a@n;R0XLN4QyhzP0j~OvENc z1F(MruU%--A%`)w>o+xXu{JhS!VM53DL~$p#qyPd>sMj0_9om?20**Tjvs#xbfvXv zsGS+Z_3-s5qD>;k7lZ*42+Tu-5vCjAj%XAyK|rn%TyF@sk;hamGG=H9=Yx>nrWyC6 z`8P4$15o;=e=WEKx-;HLMe^hd#FFO-MbUHcau%Nv)GCHKZI3@AtNH%58-OLz6|WW6 zcbYeK^}Un5(G9+CG6st>aIW_YzF*iS>IB9Lf##1jk>zPmSnNM_0TzV8HSxSH>`o4t zA?D2+g#SlY1?he8V)voxPQ~rt*w`p`T!sIJ@c+;P9B+kz9ldsxF&rh52*!aK7+`oC zL)gEyWozbUHcGl$S|#Wjazm2D5X9ROSugQcbWH%YeSf`C499_rHTex~1ym^*wJZ%V zCQa)(59v#TgM*`nU5+E&6g=-`xNJ-^UN{i&92y&Nl#wM{$#eLTpUF!SP%uJt;i$L^ zNhQs_*w_X(>(bYd7h)KXbHd-JNlCaJOtgbTLg)fjBZz(evx&gTm#;*-z?q@YC!#J$ zwT8O(qLJ8p^M7kAc*^ON0OkK`yZi&mvZO^rEef-W7M2(Q`4nh-Rw3;K3NWgH@z-Q} zr+Ic+_`ovHg~ttmZSyiN;TPds_4&2SzZk{zBa)|u<@A8rN8XGK+3FR0T=yIUtN|on z4hq{?%)QVH)(Rb^M8d4?>@0?E%L0jY&>Q?BxmB#Jb*PucZcKCI8dW@8UGXEB;^N|h z$pw#w);;|<9`e5i>1~a4FKke2G3-1R3J-a%X$zMcUz^dbGf-1KZ6czo|EaBd-bDN- z$Z=?+%8}*>1}s643z8IpucihOO7OPrM(=&Vxb8~VD*zH?xjWkPrxL>!+1O$P#xgTA z?U(F^+b=!<@e~3KBn~thNyH4TE7h~o)?OUHb}o*%+=&kDpV|Ek%v4%d{-#hs{UxDfUMU_te{nyNlqksEe zTeeD1*0ts<>=t-uBPjRM*SRdMNX@JxOq#~~quMH_Y9EDg!|;*cZ!2nRdBnsH{JK%D zKXQFIb|mUx?=P{+JNCn|=aEEeow?1OxHfSwTug00yk?j1tz|bX??#ACAR(hw9Gf<^ z=+&Cg^a0Z%(=8A-RS9z=7Ar|i2P3+v@edXc7^w+=*>h{fsXx#$fYewNwsjmpbbXy%w?%AID7d^Wx~ zqoOI@vezGJy?WfTI!?2Ej~ts79(9;(s`ujIT~o&{ks&` z>kn|6WUe~a{2c_9!QTzXA6WP@QcI=#G@EkP)f+ae!G$N$E@{daFSyTB&gGatRqYI| zk*myVX_?gwVfIhjq|{7zy(FVHV_THmb%$F+@0BfcTCB}+!0iHb>r^_kA#m!o)+@r zF3H^~vC92|!fAsM*9-|W9PSPp&O=d(DOCu?UeQgpdu_W=T8pc};ZXoBeVd{gr*nNswb<$!&DqIt9-rC0&cCZ4unHJ9h4}cl2#|xAwuq zEmzbZCK?SHojn&EeDA23)bJ?fov4%WTpEMZgD$EAbonAb($s2ZH*BcMQJ1UuV3vRG zbKdIiI~zwb*ZcX6h6__v62l@Q1|Scw0fGmev+r@cu^nD#XvY7nYt}EK=;^aNMC|X4 z)NmEx>041fl%06M0ut?j?}G0eSl<5hs9Omowp`Mjp+SqaImh_C+bMQ1zo<~)m? z{S%2>>`dDlPv81eCul7<{DYx5V%wnpVLNAq)z)Zw#^Xy{ zn=}1GKfhYV$wKx+yqNoi&A0;hs0i{LmLPv6L4>Eq|;XJvqMxd~Sz zk(`s+7RW=tjJk9bM88*4>+sO2hf#N@8{|W;vW#@a#=eY;SCjhoGsG>Qb=+mn`xqys z&Ji@nt>d*4;@Tf&Xt6Uo@zY8^(?GrxCgLh#|DF4MPZxwsNc zswn4XiPMnMhnOu?VpR_>oIq64M)5BtM|Lzlmbo@#e-GqZ-Z?{+uw`% zu#DJk40(B}`HYpYytK5#5!|FMnRw(W@hWM}KYLt77Vn>(tQg`iI$$A&RF_$GXVA!w&woWxv7I*tSL^$0%DMc_VMmTU{ykkk+3>!)s(9P3WihMvvz@Ob zVk;{HTWJiH78ZXUbisuXq|k6i?K}BruU)dd=S07ZBu`apU7{Pc$KdFih&$}J#|Adp zeDByO%fT_mEQjA$s6@*axXpGeH&6eEN*NDDjp7fK+AB6360BKX?04)}mhT-srd_Cn zJMg6?gNLJHL|n*{b@}P#!d@v%=6Mc8q+nf-7k5POpMD%`s+KUNE6x9hg|1E+>uR>m zyXxwx$hT$CY#Lwg_}+Qug3uj4;d@_0aEsO8SGSKR$GIKkiGVrukZa!ky_)jk?l}Gl863AJlk$PZd)dd9`_eI zVEXJPiloUnb*-H@W9?$!Y27~-YvVhruGca;bS>=`f6VTM2J`d-ZUoQ8EW`n4B#}D+ z#hMWccSVg|&?0r*gcRz>u7CFip8FN5qs-@V+~0gmeW$eS{`q3*p!?if&NHEZquy z3du3UQ-rV_Pjr%(L7cuXOvIhy@ENBl88mt1x1Mtl$3_a$v7Lv)G>FyixoQPW=_HjE zxF#LN8L^MHNDD*yUE62J=ReC=*s;Beqr?NcJcTH>%V=2Fw=V+h>%Y7AvE;RD`zV~p zkF&wLbQT9d67vQ%-1!lsBZ_%Q0g(Lc78Tt}42t=G0TRo=Dc+0-DTKrIBPqSfGLQ@3 z;a>xa#aD81CzB_SSjDE@gdv-BLX2CtUYRS6jokqk`l%T_`QFh z$n9M*6msI_rsbYTs=Jq{DgbR`)QAu#KmR&R8Jqn5`z<acY&b!`Dk}od9_rW4+dhgqFH%bn)V}nCOR1`vX z;(^otOlHD>PuxKvF*kjHZ)DWewk~7FL_eC#pzUX{Sd>&RQVN9eD14u0qVQE+8d}W} z1cBQDlAl4+Q6X8+L8O(A8QTdrF*0Zq8bUIX6Bx1#Fc>Nd4h373X<7Cn5^OQrcy)-x z91FZl6qXPMjN#axav_dr&mj3S{(h;A%zbhA@u@|v9<5#w%vKnRN=7oEl~G`&!|}LQ zQ9gYz&F7UnNgF@)_2+Q9Gbkc|Jn9`?g|7h>^vyoS63(-oNHA8Ya^-=YLA)JQJSdKFEHsFoAd9e3(fT4#-?Jh7dGSwc# zaq=9`|M$Jp6hWRuV%!W7tGNMgQQ~#(;KWDkRA6V*2E9fi$H|ySGFo)dt~g(g9N0Qk zJgk`5MEal7sT#I<^pBhmr!1wUrLV}%$2^kvW{gD0Q;++iB|4AY7;W-^TdPC>ugnv6 zPYs~3RP_RL3r5=B8Zvrh5@Hd}5?m}UGad$yRoJ*N!Ih-@YUW*8i`fVndbq1M9okJR zc|4_KjKo)BAP6h`v~M8_&HT>~58{W(ZDt|}B!pax9TC0Npeg?I%5JK|Y!nD(Heq0Q zMuq^T0@FzY061L+JxS&UqfNhqXKObm6k}bjK^1ZL*7C17#K6F<;^YSs7nVp2f(j(- z<%N@fU*yZZCwDB|fwHbV}M>kzJW$`W;1c4}J60!yTtD{n5-mD4D$_ z%>LJP+M7eA2WHlK<;-REa_z}_yg%1*leF|lsZSP^9f!<9JEwuckiq8&urPo+6G@rE z`3US>ONvpl#b6D4*y}v?$tc7SS>(zBJMP-Sw!Pa2!$N{A z57b_13xCf}^FZFj?XmoAZZw(^oDT!-&tLpglvmbn9JT8FffM1}Yo%E3{W;6ib;m{9 z)fv@~PfpoW)pce1A)pcypFUlM^*MhIvnIVk(UyI^kk}%b*b*5Y;O|diIVqRYX7+yJ zqF`@n4=Z*PGCsF-=7Ss&e3So+^-dgfQw!DOPVHCHR`dKIwO*E#!>;@lMTH_&I<(pZdr?MVwAEDpA^;uo93hj-yr}s`} zr7~F4ueI}GI3P3ocJttLU2!GLr)S|mYZkf8^0i}IpC+h#wCYW{o9~-QUP%;O`!@5M zm{m|mtWnJ^I?2saFGB5ggE0Ks7b3;bh=?kzh8XIpmzi=LSswm)<~cBgqXJe)jgh&8 z#KftPTT6*G3qM~iZa_pMb7uDgl93LS@--&;pjsg0J<>wmBQ;gh(eW@c;t}rT0n*h7>-d_rYei=F@$pqcUH|plx4UQ(Po=4~ z7ngMfQ)pMM>H~Css7DgSF=j(mKtTGv+r%E87J`e*%E|3IznMod)(_WgV=$pMlW z%K%_65Ez&d#IY2Q{{G8AwYEZ8drn^7Vsd@*#dK6u<{27%Vb@P!2RUvC02V!u+tAhO zRT$Sf<@{z~jRjla$vJ(go=z^^MxmPgb$e3BE>K-{V)|0{sPF5*Dktl+&M$0l2Sl(o zvYUy$e8%}uYxzZL{@ctw{r~F`vK(Ay`{Yb1jbf~HSe2ccx(0Fhp4yo;P)LAkFc|tS zP;bC^?;STr@ku^WG8paq>bHjDuA}0UB9}8>CkscX_9{k7ynUyz`sao$)7Bm)eRm<7 z-;Um{Y-dw<{P_NFRsUr%cVzMS&r&aR&bv4{Db>~0Bs>c!I09b1$=S1uvm_-Ykut*H z-(P;410*K2b~oz(#%nECZ?@12_g(s;QdFR_o6A9bB8dGi|IrRldr9^>3MDv8*4fGC zbj)1VThWS-Uk!_vv=bW^=(&Cu_oFHg z8v8%>tP>u!qR-#l_ifDk9^Ic=1G1gcpw3fwd(X0TV6JS0-0I_BEvdHWj@?k768PPVF7 z;7z5`om!moKGk;l(pggwQ>QBPvB!jJRCn98r?2HTf$l~RRJ%X*)(u4w9A@*JQO zqNcDf%Wa!+{@7cmj%`y}?hWM=_2>&1i-6<486WxYUD?dVbNzJ3t5@wp0i=B3rL8E@ zl_}n!b6M4P*_mokcDt|qS{bE~_W%A|7(dtSoTXnX8gv?bBnpa zNZc!a^HHWAGbwh~;QgCDS7K{s%~_r2#SCWxAKT2@<@~EruAF9}{FDk&SmUcAczRy- z_vE50E?{jx`B%QPtGRf5G)ovrS)qKue48Y>U{1_5oOsv!j|?fKpvXJO;6eV`(U*$U zO0|u`6jDq4KPxQ!^vYEh3MpNGF#Mli%$uPnDaYeYp=@PhYBPm&h*wZhMp=1NsO-=q zq0H+3qC@O&Gq5z1g@ZL%XW-CCr25~UtOW_H5EhE{XUudhTa-aC^+#CuUlF+p}2U1aSb`sR)h>{8=KBQ-}`> z13L`c3GF+I@L9|~XojPRj66K_{o3eX(==@>4GRkatV)J2znOy@A@hl+j#*x7uqpt% zaqa=JS!EWXI;ASto=j@J~xB{Cz5*W&)Ydz=)%H*ch$&qYSEeEt5N z4{^%U&>zm%Us~qE>;-GOzJQn(4j_FfeHXvUt@HZ*L-jky)}VZam>3Hh3putpLgvb2lKOJb2^5xMM* zIh18Z?tfpVHDT$O#_IeARE@N(tiLnn%T!~?_+E@_0hjkkaW6TR#E=tw$bTolxL6HdZA6zE^jHGRBayre+Ao@>$dM$a#w8&5Pv4i{WWa?+1=usby?EETArD&nqCXiUOLRiUKDfL*_;Ei|KK3 z9Jo{7o2_5J-Urt#ja;Sp7E}2Yo$ss32#Or8)ngA>4=YPfLfd$@sabrE6W`Qm*hfpER8fj=^V*?l)>}t`F15|`zhPF> zJEYOOys)r9=87G9>wq!dyESaQA=byLI93Ip&?=$;3g@?9|9l^Gk6 z$rv27zL=!--|qkjFlU&o4@{$gvUZw`Vg9Wr@~d>xo(*FH)G911&;MqEGDDKC!Xg{y z=~h-s?KtL8`*8#h9i9=* zXo>R+d+!1BpV+!WWs|frFwn4uoIa zs~Ai8ZvpLpdsteOkWj9UdZCnN?q#3a+FC{|uv;PB;Ns`6?@Bs!-qto3LV7>)ABB^| z<=bNLRBPr=;7S;TRRV0r3kfP)Am*~jjOhG5+HxAY;7jB`T;=W`2Ntn7;?dmw_~x7z z*}WiW;RI~hAoQoV#3}n;{SX%q&zUPGCjJ;%q`m)7dAGK}*X&z=(@{)p!|rsTEAR61@=wL~DZt4$0Y}u#EUdzbGa?w4K)aQ_z2)sX_GEG>S>F-Mnb4?X|M9o` z3u(b{l3^Qw&D)P`A5daUA(CZHlspfHP&SH~dwuKA53b(w;Ee)&HqGh=mgK8rq;;7~I0C z#uruuH=RP^is@bVKSSH7;%h|lJnr(IQdvZt(OTR@Xg;e!2692j&`>}!X+`kOk@^71 z@6~Wr$Y4Z|RvbBAZ1(H&wFM!tlW}l(@y%|94-DNl$gi1XlT_fU2*5~_=Z9uSz~)Y;4TM$0v)`dhOY>XGH*t!%|{d@Cdi2KSpM)!9RW-8rp;Vu)!*T)0>f; zH`dn8d||Q}yyuV6ovgE>Gwv_c1PjNYcoJ>v#K0<)<%aaP!ZTXKYcdWWp_2^OQBeQw zrOFpp+0=9ad-<6iZ~}j9j5EK!i1Eez*cp%tzzjJ_KV({F;Vzc8KPC=8U_8>TO{h6P z3)Yh~=l;*tDSrC@*%-VWfcno5m0SB~NYfaB^MBjI!5+Y!LC6J-`!Ik z?q7YjktvkNmtRVMoW=*{?ZDf( z%>4jir;&i=2S$2EDU$rUQDWxawa>UC-+;}K|GWVySQKPn;S3F-2TLx1wCxFa3L6_6 zA_hb~qy5vJn2qUusDYy$>VokqfGm}eUxO&#kDE^35<@0^@7$q-A;b?e8ZeTU3ThfD zMaAH>Ccvn{&^wabAFT~pQl|jRM|I)S1Yrd2S8QbK0}pSye-toyo(Xkn$JA8SUptC>jc7Uqv!;ZUJjs{@@T72%JTS{Oz@ZJD&= z(mXMh3d-=0N>8uix`vhXdiQ?~_UD(4@Avcle4h9FdEU>H8;Fs>c5*2;J z)HHl#ex%pg@?_i;ciu#m%736prJEq2PL5(${*PhVW z*x2{-Woo(r;uuT=l>5Fxm@>)-qVlFY-v03(rCPPmb5$htL!{hN( z+b23({&|(g;={c`p9eGKEH!0M0NpL+xUkmWqiE!D*n5(6o5NW()=ntR^tx~y3L0pE zQb$M!8n2D9=J>6~Qg7Eicn>LD_Woip_$hV=a!mkf_n9;K58kI}ZZ|DX-T6aAgo~F% z7?l9J+fZ_zrO;|dCwVc{QCu_&eqNWd-&VVfY*_HL`ucWLdVW>%q4i1QrSD1t7y`ZV zY-@PKZ*w`SlP@T~eK}A>dwLrpFpzfT3}5%}^DaATtRPc$u2y^gZeCkQhaYDTeT^-s z-x<>#Js^5TdZuk7k}S_wIn0yR)}i_ZW+c+URb$x8PlDW>LzO z{BR+TC4$cE|7+ChR@jRI08LL$Ydc6(HVQ&^oRmCad}Fz8RCPKtk+egmr=iMg%BC>QVYOn?>N60_KHzT$tZAF zREUhpQ?er10qH755)JOjY=MMS>M&MNRz{WfqlpzpP3%!5APpQ7@F$F>Wsd>`!B2rT z&!gQ%Yo~T+-de5Gxvo;lfBO7yei90q-HqEm-sCTIm;G$BoVpV=jI0F%^ZTgpQZiT0 zi}Z$Mj#&i+x_F(g*uvu^bUT|=Vz=ogo0W7Gfu literal 0 HcmV?d00001 diff --git a/my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png b/my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png new file mode 100644 index 0000000000000000000000000000000000000000..491aa0597c22df762e800d885616fa345b24e881 GIT binary patch literal 26253 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

TRF5ZC!?#S93IP80c9YkS@zt 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%0D2d2aPGj#*>H$K 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 literal 0 HcmV?d00001 From 4329c1eb0650aa8c398aadb5b1a81b31f0468fe6 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:43:09 +0200 Subject: [PATCH 16/25] update README --- README.md | 77 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 799e7c0..6464e45 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,22 @@ # Per-Title Analysis -*This a python package providing tools for optimizing your over-the-top bitrate ladder per each video you need to encode.* +*This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.* ## How does it work? -You can configure a template encoding ladder with constraints (min/max bitrate) that will be respected for the output optimal ladder. +You can configure a template encoding ladder with constraints (min/max bitrate) that will be respected for the output optimal ladder and comparing it with the default bitrate. You also have the control over analysis parameters (based on CRF encoding or multiple bitrate encodings with video quality metric assessments). The CRF Analyzer -This analyzer calculates an optimal bitrate for the higher profile. -Other profiles are declined top to bottom from the initial gap between each profiles of the template ladder. +This analyzer calculates an optimal bitrate for the higher profile for a given CRF value. +Other profiles are declined top to bottom from the initial gap between each profiles of the template ladder (only if you use linear model). +Otherwise every optimal bitrates are calculated for each profil in "for_each" model. The Metric Analyzer This analyzer encodes multiple bitrates for each profile in the template ladder (from min to max, respecting a bitrate step defined by the user) -It then calculates video quality metrics for each of these encodings (only ssim or psnr for now). +It then calculates video quality metrics for each of these encodings (only SSIM or PSNR for now). The final optimized ladder will be constructed choosing for the best quality/bitrate ratio (similar to Netflix). +You can then use a super graph to analyze your results ! + ### The template encoding ladder It is composed of multiple encoding profile object. @@ -24,21 +27,27 @@ Each encoding profile is defined by those attributes: - __bitrate_min__ (int): This is the minimal bitrate you set for this profile in the output optimized encoding ladder - __bitrate_max__ (int): This is the maximal bitrate you set for this profile in the output optimized encoding ladder - __required__ (bool): Indicates if you authorize the script to remove this profile if considered not useful after optimization (conditions for this to happen are explained after) -- __bitrate_factor__ (float): this is a private attribute calculated after initialization of the template encoding ladder +- __bitrate_steps_individual__ (int): This is the bitrate step used for metric_analyzer only if you want to configure one step for each profile +- __bitrate_factor__ (float): this is a private attribute calculated after initialization of the template encoding ladder ##### See this template example | width | height | bitrate_default | bitrate_min | bitrate_max | required | -| --- | --- | --- | --- | --- | --- | -| *in pixels* | *in pixels* | *in bits per second* | *in bits per second* | *in bits per second* | *bool* | -| 1920 | 1080 | 4500000 | 2000000 | 6000000 | True | -| 1280 | 720 | 3400000 | 1300000 | 4500000 | True | -| 960 | 540 | 2100000 | 700000 | 3000000 | True | -| 640 | 360 | 1100000 | 300000 | 2000000 | True | -| 480 | 270 | 750000 | 300000 | 900000 | False | -| 480 | 270 | 300000 | 150000 | 500000 | True | -##### What does it imply? *(soon)* +| --- | --- | --- | --- | --- | --- | --- | +| *in pixels* | *in pixels* | *in bits per second* | *in bits per second* | *in bits per second* | *bool* | *int bits per second* | +| 1920 | 1080 | 4500000 | 1000000 | 6000000 | True | 100000 | +| 1280 | 720 | 3400000 | 800000 | 5000000 | True | 100000 | +| 960 | 540 | 2100000 | 600000 | 4000000 | True | 100000 | +| 640 | 360 | 1100000 | 300000 | 3000000 | True | 100000 | +| 480 | 270 | 750000 | 200000 | 2000000 | False | 100000 | +| 480 | 270 | 300000 | 150000 | 2000000 | True | 100000 | +##### How configure it ? +You can now play with this values. +For example set the bitrate_default with your actual bitrate. +Then set the Min and Max considering your network constraints or storage capacity +If you need to keep one profile whatever happens set required to True. + -#### In depth: *(soon)* +#### In depth: - How to choose the analysis parameters - What is the multiple part analysis - How is the weighted average bitrate calculated @@ -47,15 +56,34 @@ Each encoding profile is defined by those attributes: ___ ## Installation: -This is package requires at least Python 3.4. +This is package requires at least Python 3.4 You need to have ffmpeg and ffprobe installed on the host running the script. +You need to have matplotlib and pylab to create the graphs ## Example: -This is an example using the CRF Analyzer method. +Two examples using the CRF Analyzer method and the Metric Analyzer one are included in the per_title_analysis folder. + +You can now use these scripts with the following command: +```bash +#command for CrfAnalyzer: + +python3 crf_analyzer.py [path/my_movie.mxf] [CRF_value] [number_of_parts] [model] +#model: 1 (linear mode, only one profile is encoded, the higher) or 0 (for_each mode, each profile is encoded) +example : python3 crf_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sources/my_movie.mxf 23 1 1 + +#command for MetricAnalyzer: + +python3 metric_analyzer.py [path/my_movie.mxf] [metric] [limit_metric_value] +#metric: psnr or ssim +#limit_metric_value: To find the optimal bitrate we need to fix a limit of quality/bitrate_step ratio. +#we advise you to use for PSNR a limit of 0.09 (with bitrate_step = 100 kbps) and for SSIM a limit of 0.005 (with bitrate_step = 50 kbps) +example : python3 metric_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sources/my_movie.mxf psnr 0.095 -##### Code: + + +##### Code: A simple example for CRF Analysis ```python # -*- coding: utf8 -*- @@ -71,13 +99,18 @@ PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 300000, 900000, False) PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True)) LADDER = pta.EncodingLadder(PROFILE_LIST) -# Create a new CRF analysis provider +# Create a new CRF analysis provider ANALYSIS = pta.CrfAnalyzer("{{ your_input_file_path }}", LADDER) # Launch various analysis ANALYSIS.process(1, 1920, 1080, 23, 2) ANALYSIS.process(10, 1920, 1080, 23, 2) -# Print results +# Print results Graph + +![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title.png) +![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png) + +# Print results JSON print(ANALYSIS.get_json()) ``` @@ -273,4 +306,4 @@ print(ANALYSIS.get_json()) }, "input_file_path": "{{ your_input_file_path }}" } -``` \ No newline at end of file +``` From ae59e452dae873f4ccfe8e618deacd65e986ac04 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:46:03 +0200 Subject: [PATCH 17/25] a little error on README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6464e45..4fd342d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Each encoding profile is defined by those attributes: - __bitrate_factor__ (float): this is a private attribute calculated after initialization of the template encoding ladder ##### See this template example -| width | height | bitrate_default | bitrate_min | bitrate_max | required | +| width | height | bitrate_default | bitrate_min | bitrate_max | required | bitrate_steps_individual | | --- | --- | --- | --- | --- | --- | --- | | *in pixels* | *in pixels* | *in bits per second* | *in bits per second* | *in bits per second* | *bool* | *int bits per second* | | 1920 | 1080 | 4500000 | 1000000 | 6000000 | True | 100000 | From 53114de074fa12329eb3c330482ce19db7008c95 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:49:40 +0200 Subject: [PATCH 18/25] add screenshots on readme side by side --- README.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4fd342d..c866a42 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Per-Title Analysis *This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.* +![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title.png) ![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png) + ## How does it work? You can configure a template encoding ladder with constraints (min/max bitrate) that will be respected for the output optimal ladder and comparing it with the default bitrate. @@ -41,10 +43,10 @@ Each encoding profile is defined by those attributes: | 480 | 270 | 750000 | 200000 | 2000000 | False | 100000 | | 480 | 270 | 300000 | 150000 | 2000000 | True | 100000 | ##### How configure it ? -You can now play with this values. -For example set the bitrate_default with your actual bitrate. -Then set the Min and Max considering your network constraints or storage capacity -If you need to keep one profile whatever happens set required to True. +- You can now play with this values. +- For example set the bitrate_default with your actual bitrate. +- Then set the Min and Max considering your network constraints or storage capacity +- If you need to keep one profile whatever happens set required to True. #### In depth: @@ -58,8 +60,8 @@ ___ ## Installation: This is package requires at least Python 3.4 -You need to have ffmpeg and ffprobe installed on the host running the script. -You need to have matplotlib and pylab to create the graphs +- You need to have ffmpeg and ffprobe installed on the host running the script. +- You need to have matplotlib and pylab to create the graphs ## Example: @@ -105,11 +107,6 @@ ANALYSIS = pta.CrfAnalyzer("{{ your_input_file_path }}", LADDER) ANALYSIS.process(1, 1920, 1080, 23, 2) ANALYSIS.process(10, 1920, 1080, 23, 2) -# Print results Graph - -![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title.png) -![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png) - # Print results JSON print(ANALYSIS.get_json()) ``` From 48630ef46b229112e948292c6457eb3e2c1b3748 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:52:01 +0200 Subject: [PATCH 19/25] Add files via upload --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c866a42..d2ad884 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Per-Title Analysis *This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.* -![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title.png) ![Screenshot](my_movie.mxf-PSNR-0.09-Per_Title_Histogram.png) - +

+ + +

## How does it work? You can configure a template encoding ladder with constraints (min/max bitrate) that will be respected for the output optimal ladder and comparing it with the default bitrate. From 86780cf9bc2fa068f97ac54adb2209486e5eb40c Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:52:21 +0200 Subject: [PATCH 20/25] Add files via upload --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d2ad884..08035d9 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ *This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.*

- - + +

## How does it work? From da39d6aab01d35388aba61faaf42f69a95f4965f Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:52:52 +0200 Subject: [PATCH 21/25] Add files via upload --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 08035d9..89d1f66 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ *This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.*

- - + +

## How does it work? From b38c2485ff95485749699f9be9102ae0a21328e3 Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:53:14 +0200 Subject: [PATCH 22/25] Add files via upload --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 89d1f66..eaae8ff 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ *This a python package providing tools for optimizing your over-the-top (OTT) bitrate ladder per each video you need to encode.*

- - + +

## How does it work? From 6f0f38e41a8b3f9c65322c4eade09102e281d6cb Mon Sep 17 00:00:00 2001 From: thomMar Date: Mon, 20 Aug 2018 16:54:17 +0200 Subject: [PATCH 23/25] Add files via upload --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eaae8ff..8ad64ec 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Each encoding profile is defined by those attributes: - If you need to keep one profile whatever happens set required to True. -#### In depth: +#### In depth: (SOON!) - How to choose the analysis parameters - What is the multiple part analysis - How is the weighted average bitrate calculated From fa900b487b194affea54dc74ae0f16f8cfb49cf3 Mon Sep 17 00:00:00 2001 From: thomMar Date: Tue, 21 Aug 2018 10:42:45 +0200 Subject: [PATCH 24/25] Update README.md --- README.md | 40 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 8ad64ec..6bc1f6e 100644 --- a/README.md +++ b/README.md @@ -70,47 +70,23 @@ This is package requires at least Python 3.4 Two examples using the CRF Analyzer method and the Metric Analyzer one are included in the per_title_analysis folder. You can now use these scripts with the following command: -```bash -#command for CrfAnalyzer: +### command for CrfAnalyzer: +```bash python3 crf_analyzer.py [path/my_movie.mxf] [CRF_value] [number_of_parts] [model] -#model: 1 (linear mode, only one profile is encoded, the higher) or 0 (for_each mode, each profile is encoded) example : python3 crf_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sources/my_movie.mxf 23 1 1 -#command for MetricAnalyzer: +#model: 1 (linear mode, only one profile is encoded, the higher) or 0 (for_each mode, each profile is encoded) +``` +### command for MetricAnalyzer: +```bash python3 metric_analyzer.py [path/my_movie.mxf] [metric] [limit_metric_value] +example : python3 metric_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sources/my_movie.mxf psnr 0.095 + #metric: psnr or ssim #limit_metric_value: To find the optimal bitrate we need to fix a limit of quality/bitrate_step ratio. #we advise you to use for PSNR a limit of 0.09 (with bitrate_step = 100 kbps) and for SSIM a limit of 0.005 (with bitrate_step = 50 kbps) -example : python3 metric_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sources/my_movie.mxf psnr 0.095 - - - -##### Code: A simple example for CRF Analysis -```python -# -*- coding: utf8 -*- - -from pertitleanalysis import per_title_analysis as pta - -# create your template encoding ladder -PROFILE_LIST = [] -PROFILE_LIST.append(pta.EncodingProfile(1920, 1080, 4500000, 2000000, 6000000, True)) -PROFILE_LIST.append(pta.EncodingProfile(1280, 720, 3400000, 1300000, 4500000, True)) -PROFILE_LIST.append(pta.EncodingProfile(960, 540, 2100000, 700000, 300000, True)) -PROFILE_LIST.append(pta.EncodingProfile(640, 360, 1100000, 300000, 2000000, True)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 750000, 300000, 900000, False)) -PROFILE_LIST.append(pta.EncodingProfile(480, 270, 300000, 150000, 500000, True)) -LADDER = pta.EncodingLadder(PROFILE_LIST) - -# Create a new CRF analysis provider -ANALYSIS = pta.CrfAnalyzer("{{ your_input_file_path }}", LADDER) -# Launch various analysis -ANALYSIS.process(1, 1920, 1080, 23, 2) -ANALYSIS.process(10, 1920, 1080, 23, 2) - -# Print results JSON -print(ANALYSIS.get_json()) ``` ##### JSON ouput: From f87ab1af65f429723df6c74ef22095a1882348e0 Mon Sep 17 00:00:00 2001 From: thomMar Date: Tue, 21 Aug 2018 10:48:01 +0200 Subject: [PATCH 25/25] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6bc1f6e..08cdbdd 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ example : python3 metric_analyzer.py /home/xxxx/Documents/pertitleanalysis/Sourc #we advise you to use for PSNR a limit of 0.09 (with bitrate_step = 100 kbps) and for SSIM a limit of 0.005 (with bitrate_step = 50 kbps) ``` +The JSON file and the Graphics are saved in your current working directory as /results/[my_movie.mxf]/ .json .png .png + ##### JSON ouput: ```json {