def encode_file(self, project: Project): """ Encodes a single video file on the local machine. :param project: The project for this encode :return: None """ project.setup() set_log(project.logging, project.temp) # find split locations split_locations = split_routine(project, project.resume) # create a chunk queue chunk_queue = load_or_gen_chunk_queue(project, project.resume, split_locations) self.done_file(project, chunk_queue) if not project.resume: extract_audio( str(project.input.resolve()), str(project.temp.resolve()), project.audio_params, ) # do encoding loop project.determine_workers() self.startup(project, chunk_queue) queue = Queue(project, chunk_queue) queue.encoding_loop() if queue.status.lower() == "fatal": msg = "FATAL Encoding process encountered fatal error, shutting down" print("\n::", msg) log(msg) sys.exit(1) # concat project.concat_routine() if project.vmaf or project.vmaf_plots: self.vmaf = VMAF( n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter, ) self.vmaf.plot_vmaf(project.input, project.output_file, project) # Delete temp folders if not project.keep: shutil.rmtree(project.temp)
def per_frame_probe(q_list, q, chunk, project): qfile = chunk.make_q_file(q_list) cmd = per_frame_probe_cmd(chunk, q, project.ffmpeg_pipe, project.encoder, 1, qfile) pipe = make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk) vm = VMAF(n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter) fl = vm.call_vmaf(chunk, gen_probes_names(chunk, q)) jsn = VMAF.read_json(fl) vmafs = [x['metrics']['vmaf'] for x in jsn['frames']] return vmafs
def __init__(self, project): self.vmaf_runner = VMAF(n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter) self.n_threads = project.n_threads self.probing_rate = project.probing_rate self.probes = project.probes self.target = project.target_quality self.min_q = project.min_q self.max_q = project.max_q self.make_plots = project.vmaf_plots self.encoder = project.encoder self.ffmpeg_pipe = project.ffmpeg_pipe self.temp = project.temp
def fast_search(self, chunk): """ Experimental search Use Euler's method with known relation between cq and vmaf Formula -ln(1-score/100) = vmaf_cq_deriv*last_q + constant constant = -ln(1-score/100) - vmaf_cq_deriv*last_q Formula -ln(1-project.vmaf_target/100) = vmaf_cq_deriv*cq + constant cq = (-ln(1-project.vmaf_target/100) - constant)/vmaf_cq_deriv """ vmaf_cq = [] q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) vmaf_cq_deriv = -0.18 next_q = int( round(last_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score)) / vmaf_cq_deriv)) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q # Single probe cq guess or exit to avoid divide by zero if self.probes == 1 or next_q == last_q: self.log_probes(vmaf_cq, chunk.frames, chunk.name, next_q, self.target) return next_q # Second probe at guessed value score_2 = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) # Calculate slope vmaf_cq_deriv = (VMAF.transform_vmaf(score_2) - VMAF.transform_vmaf(score)) / (next_q - last_q) # Same deal different slope next_q = int( round(next_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score_2)) / vmaf_cq_deriv)) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q self.log_probes(vmaf_cq, chunk.frames, chunk.name, next_q, self.target) return next_q
def per_frame_probe(self, q_list, q, chunk): qfile = chunk.make_q_file(q_list) cmd = self.per_frame_probe_cmd(chunk, q, self.encoder, 1, qfile) pipe = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk) fl = self.vmaf_runner.call_vmaf(chunk, self.gen_probes_names(chunk, q)) jsn = VMAF.read_json(fl) vmafs = [x['metrics']['vmaf'] for x in jsn['frames']] return vmafs
def weighted_search(num1, vmaf1, num2, vmaf2, target): """ Returns weighted value closest to searched :param num1: Q of first probe :param vmaf1: VMAF of first probe :param num2: Q of second probe :param vmaf2: VMAF of first probe :param target: VMAF target :return: Q for new probe """ dif1 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf2)) dif2 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf1)) tot = dif1 + dif2 new_point = int(round(num1 * (dif1 / tot) + (num2 * (dif2 / tot)))) return new_point
def vmaf_probe(chunk: Chunk, q, project: Project, probing_rate): """ Calculates vmaf and returns path to json file :param chunk: the Chunk :param q: Value to make probe :param project: the Project :param probing_rate: 1 out of every N frames should be encoded for analysis :return : path to json file with vmaf scores """ n_threads = project.n_threads if project.n_threads else 12 cmd = probe_cmd(chunk, q, project.ffmpeg_pipe, project.encoder, probing_rate, n_threads) pipe = make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk) vm = VMAF(n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter) file = vm.call_vmaf(chunk, gen_probes_names(chunk, q), vmaf_rate=probing_rate) return file
def per_shot_target_quality(chunk: Chunk, project: Project): vmaf_cq = [] frames = chunk.frames # get_scene_scores(chunk, project.ffmpeg_pipe) # Adapt probing rate if project.probing_rate in (1, 2): probing_rate = project.probing_rate else: probing_rate = adapt_probing_rate(project.probing_rate, frames) q_list = [] score = 0 # Make middle probe middle_point = (project.min_q + project.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf( vmaf_probe(chunk, last_q, project, probing_rate)) vmaf_cq.append((score, last_q)) if project.probes < 3: #Use Euler's method with known relation between cq and vmaf vmaf_cq_deriv = -0.18 ## Formula -ln(1-score/100) = vmaf_cq_deriv*last_q + constant #constant = -ln(1-score/100) - vmaf_cq_deriv*last_q ## Formula -ln(1-project.vmaf_target/100) = vmaf_cq_deriv*cq + constant #cq = (-ln(1-project.vmaf_target/100) - constant)/vmaf_cq_deriv next_q = int( round(last_q + (VMAF.transform_vmaf(project.target_quality) - VMAF.transform_vmaf(score)) / vmaf_cq_deriv)) #Clamp if next_q < project.min_q: next_q = project.min_q if project.max_q < next_q: next_q = project.max_q #Single probe cq guess or exit to avoid divide by zero if project.probes == 1 or next_q == last_q: return next_q #Second probe at guessed value score_2 = VMAF.read_weighted_vmaf( vmaf_probe(chunk, next_q, project, probing_rate)) #Calculate slope vmaf_cq_deriv = (VMAF.transform_vmaf(score_2) - VMAF.transform_vmaf(score)) / (next_q - last_q) #Same deal different slope next_q = int( round(next_q + (VMAF.transform_vmaf(project.target_quality) - VMAF.transform_vmaf(score_2)) / vmaf_cq_deriv)) #Clamp if next_q < project.min_q: next_q = project.min_q if project.max_q < next_q: next_q = project.max_q return next_q # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < project.target_quality: next_q = project.min_q q_list.append(project.min_q) else: next_q = project.max_q q_list.append(project.max_q) # Edge case check score = VMAF.read_weighted_vmaf( vmaf_probe(chunk, next_q, project, probing_rate)) vmaf_cq.append((score, next_q)) if next_q == project.min_q and score < project.target_quality: log(f"Chunk: {chunk.name}, Rate: {probing_rate}, Fr: {frames}\n" f"Q: {sorted([x[1] for x in vmaf_cq])}, Early Skip Low CQ\n" f"Vmaf: {sorted([x[0] for x in vmaf_cq], reverse=True)}\n" f"Target Q: {vmaf_cq[-1][1]} VMAF: {round(vmaf_cq[-1][0], 2)}\n\n") return next_q elif next_q == project.max_q and score > project.target_quality: log(f"Chunk: {chunk.name}, Rate: {probing_rate}, Fr: {frames}\n" f"Q: {sorted([x[1] for x in vmaf_cq])}, Early Skip High CQ\n" f"Vmaf: {sorted([x[0] for x in vmaf_cq], reverse=True)}\n" f"Target Q: {vmaf_cq[-1][1]} VMAF: {round(vmaf_cq[-1][0], 2)}\n\n") return next_q # Set boundary if score < project.target_quality: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(project.probes - 2): new_point = weighted_search(vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, project.target_quality) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf( vmaf_probe(chunk, new_point, project, probing_rate)) vmaf_cq.append((score, new_point)) # Update boundary if score < project.target_quality: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = get_target_q(vmaf_cq, project.target_quality) log(f'Chunk: {chunk.name}, Rate: {probing_rate}, Fr: {frames}\n' f'Q: {sorted([x[1] for x in vmaf_cq])}\n' f'Vmaf: {sorted([x[0] for x in vmaf_cq], reverse=True)}\n' f'Target Q: {q} VMAF: {round(q_vmaf, 2)}\n\n') # Plot Probes if project.vmaf_plots and len(vmaf_cq) > 3: plot_probes(project, vmaf_cq, chunk, frames) return q
class EncodingManager: def __init__(self): self.workers = None self.vmaf = None self.initial_frames = 0 def encode_file(self, project: Project): """ Encodes a single video file on the local machine. :param project: The project for this encode :return: None """ project.setup() set_log(project.logging, project.temp) # find split locations split_locations = split_routine(project, project.resume) # create a chunk queue chunk_queue = load_or_gen_chunk_queue(project, project.resume, split_locations) self.done_file(project, chunk_queue) if not project.resume: extract_audio(project.input, project.temp, project.audio_params) if project.reuse_first_pass: segment_first_pass(project.temp, split_locations) # do encoding loop project.determine_workers() self.startup(project, chunk_queue) queue = Queue(project, chunk_queue) queue.encoding_loop() if queue.status.lower() == "fatal": msg = "FATAL Encoding process encountered fatal error, shutting down" print("\n::", msg) log(msg) sys.exit(1) # concat project.concat_routine() if project.vmaf or project.vmaf_plots: self.vmaf = VMAF( n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter, ) self.vmaf.plot_vmaf(project.input, project.output_file, project) # Delete temp folders if not project.keep: shutil.rmtree(project.temp) def done_file(self, project: Project, chunk_queue: List[Chunk]): done_path = project.temp / "done.json" if project.resume and done_path.exists(): log("Resuming...") with open(done_path) as done_file: data = json.load(done_file) project.set_frames(data["frames"]) done = len(data["done"]) self.initial_frames = sum(data["done"].values()) log(f"Resumed with {done} encoded clips done") else: self.initial_frames = 0 total = project.get_frames() d = {"frames": total, "done": {}} with open(done_path, "w") as done_file: json.dump(d, done_file) def startup(self, project: Project, chunk_queue: List[Chunk]): clips = len(chunk_queue) project.workers = min(project.workers, clips) print( f"\rQueue: {clips} Workers: {project.workers} Passes: {project.passes}\n" f'Params: {" ".join(project.video_params)}') BaseManager.register("Counter", Counter) counter = Manager().Counter(project.get_frames(), self.initial_frames, not project.quiet) project.counter = counter
def per_shot_target_quality(self, chunk: Chunk): """ :type: Chunk chunk to probe :rtype: int q to use """ vmaf_cq = [] frames = chunk.frames self.probing_rate = adapt_probing_rate(self.probing_rate, frames) q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < self.target: next_q = self.min_q q_list.append(self.min_q) else: next_q = self.max_q q_list.append(self.max_q) # Edge case check score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) vmaf_cq.append((score, next_q)) if (next_q == self.min_q and score < self.target) or (next_q == self.max_q and score > self.target): self.log_probes( vmaf_cq, frames, chunk.name, next_q, score, skip="low" if score < self.target else "high", ) return next_q # Set boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(self.probes - 2): new_point = self.weighted_search(vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, self.target) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, new_point)) vmaf_cq.append((score, new_point)) # Update boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = self.get_target_q(vmaf_cq, self.target) self.log_probes(vmaf_cq, frames, chunk.name, q, q_vmaf) # Plot Probes if self.make_plots and len(vmaf_cq) > 3: self.plot_probes(vmaf_cq, chunk, frames) return q
class TargetQuality: def __init__(self, project): self.vmaf_runner = VMAF( n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter, ) self.n_threads = project.n_threads self.probing_rate = project.probing_rate self.probes = project.probes self.probe_slow = project.probe_slow self.target = project.target_quality self.min_q = project.min_q self.max_q = project.max_q self.make_plots = project.vmaf_plots self.encoder = project.encoder self.ffmpeg_pipe = project.ffmpeg_pipe self.temp = project.temp self.workers = project.workers self.video_params = project.video_params def log_probes(self, vmaf_cq, frames, name, target_q, target_vmaf, skip=None): """ Logs probes result :type vmaf_cq: list probe measurements (q_vmaf, q) :type frames: int frame count of chunk :type name: str chunk name :type skip: str None if normal results, else "high" or "low" :type target_q: int Calculated q to be used :type target_vmaf: float Calculated VMAF that would be achieved by using the q :return: None """ if skip == "high": sk = " Early Skip High CQ" elif skip == "low": sk = " Early Skip Low CQ" else: sk = "" log(f"Chunk: {name}, Rate: {self.probing_rate}, Fr: {frames}") log(f"Probes: {str(sorted(vmaf_cq))[1:-1]}{sk}") log(f"Target Q: {target_q} VMAF: {round(target_vmaf, 2)}") def per_shot_target_quality(self, chunk: Chunk): """ :type: Chunk chunk to probe :rtype: int q to use """ vmaf_cq = [] frames = chunk.frames self.probing_rate = adapt_probing_rate(self.probing_rate, frames) q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < self.target: next_q = self.min_q q_list.append(self.min_q) else: next_q = self.max_q q_list.append(self.max_q) # Edge case check score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) vmaf_cq.append((score, next_q)) if (next_q == self.min_q and score < self.target) or (next_q == self.max_q and score > self.target): self.log_probes( vmaf_cq, frames, chunk.name, next_q, score, skip="low" if score < self.target else "high", ) return next_q # Set boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(self.probes - 2): new_point = self.weighted_search(vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, self.target) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, new_point)) vmaf_cq.append((score, new_point)) # Update boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = self.get_target_q(vmaf_cq, self.target) self.log_probes(vmaf_cq, frames, chunk.name, q, q_vmaf) # Plot Probes if self.make_plots and len(vmaf_cq) > 3: self.plot_probes(vmaf_cq, chunk, frames) return q def get_target_q(self, scores, target_quality): """ Interpolating scores to get Q closest to target Interpolation type for 2 probes changes to linear """ x = [x[1] for x in sorted(scores)] y = [float(x[0]) for x in sorted(scores)] if len(x) > 2: interpolation = "quadratic" else: interpolation = "linear" f = interpolate.interp1d(x, y, kind=interpolation) xnew = np.linspace(min(x), max(x), max(x) - min(x)) tl = list(zip(xnew, f(xnew))) q = min(tl, key=lambda l: abs(l[1] - target_quality)) return int(q[0]), round(q[1], 3) def weighted_search(self, num1, vmaf1, num2, vmaf2, target): """ Returns weighted value closest to searched :param num1: Q of first probe :param vmaf1: VMAF of first probe :param num2: Q of second probe :param vmaf2: VMAF of first probe :param target: VMAF target :return: Q for new probe """ dif1 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf2)) dif2 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf1)) tot = dif1 + dif2 new_point = int(round(num1 * (dif1 / tot) + (num2 * (dif2 / tot)))) return new_point def vmaf_probe(self, chunk: Chunk, q): """ Calculates vmaf and returns path to json file :param chunk: the Chunk :param q: Value to make probe :param project: the Project :return : path to json file with vmaf scores """ n_threads = (self.n_threads if self.n_threads else vmaf_auto_threads(self.workers)) cmd = self.probe_cmd(chunk, q, self.ffmpeg_pipe, self.encoder, self.probing_rate, n_threads) pipe, utility = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk, utility) fl = self.vmaf_runner.call_vmaf(chunk, self.gen_probes_names(chunk, q), vmaf_rate=self.probing_rate) return fl def probe_cmd_slow(self, encoder, q): args = self.video_params.copy() drop_indexs = [] drop_pattern = [ "--cq-level=*", "--passes=*", "--pass=*", "--crf", "--quantizer", ] for pattern in drop_pattern: if fnmatch.filter(args, pattern): index = args.index(fnmatch.filter(args, pattern)[0]) drop_indexs.append(index) if pattern == "--crf" or pattern == "--quantizer": drop_indexs.append(index + 1) for i in sorted(drop_indexs, reverse=True): args.pop(i) params = construct_target_quality_slow_command(encoder, str(q)) params.extend(args) return params def probe_cmd(self, chunk: Chunk, q, ffmpeg_pipe, encoder, probing_rate, n_threads) -> CommandPair: """ Generate and return commands for probes at set Q values These are specifically not the commands that are generated by the user or encoder defaults, since these should be faster than the actual encoding commands. These should not be moved into encoder classes at this point. """ pipe = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", "-", "-vf", f"select=not(mod(n\\,{probing_rate}))", *ffmpeg_pipe, ] probe_name = self.gen_probes_names(chunk, q).with_suffix(".ivf").as_posix() if encoder == "aom": params = construct_target_quality_command("aom", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("aom", str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "x265": params = construct_target_quality_command("x265", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("x265", str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "rav1e": params = construct_target_quality_command("rav1e", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("rav1e", str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "vpx": params = construct_target_quality_command("vpx", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("vpx", str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "svt_av1": params = construct_target_quality_command("svt_av1", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("svt_av1", str(q)) cmd = CommandPair(pipe, [*params, "-b", probe_name]) elif encoder == "x264": params = construct_target_quality_command("x264", str(n_threads), str(q)) if self.probe_slow: params = self.probe_cmd_slow("x264", str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) return cmd def search(self, q1, v1, q2, v2, target): if abs(target - v2) < 0.5: return q2 if v1 > target and v2 > target: return min(q1, q2) if v1 < target and v2 < target: return max(q1, q2) dif1 = abs(target - v2) dif2 = abs(target - v1) tot = dif1 + dif2 new_point = int(round(q1 * (dif1 / tot) + (q2 * (dif2 / tot)))) return new_point def interpolate_data(self, vmaf_cq: list, target_quality): x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] # Interpolate data f = interpolate.interp1d(x, y, kind="quadratic") xnew = np.linspace(min(x), max(x), max(x) - min(x)) # Getting value closest to target tl = list(zip(xnew, f(xnew))) target_quality_cq = min(tl, key=lambda l: abs(l[1] - target_quality)) return target_quality_cq, tl, f, xnew def per_shot_target_quality_routine(self, chunk: Chunk): """ Applies per_shot_target_quality to this chunk. Determines what the cq value should be and sets the per_shot_target_quality_cq for this chunk :param project: the Project :param chunk: the Chunk :return: None """ chunk.per_shot_target_quality_cq = self.per_shot_target_quality(chunk) def gen_probes_names(self, chunk: Chunk, q): """ Make name of vmaf probe """ return chunk.fake_input_path.with_name( f"v_{q}{chunk.name}").with_suffix(".ivf") def make_pipes(self, ffmpeg_gen_cmd: Command, command: CommandPair): ffmpeg_gen_pipe = subprocess.Popen(ffmpeg_gen_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) ffmpeg_pipe = subprocess.Popen( command[0], stdin=ffmpeg_gen_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) pipe = subprocess.Popen( command[1], stdin=ffmpeg_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, ) utility = (ffmpeg_gen_pipe, ffmpeg_pipe) return pipe, utility def plot_probes(self, vmaf_cq, chunk: Chunk, frames): """ Makes graph with probe decisions """ if plt is None: log("Matplotlib is not installed or could not be loaded\ . Unable to plot probes.") return # Saving plot of vmaf calculation x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] cq, tl, f, xnew = self.interpolate_data(vmaf_cq, self.target) matplotlib.use("agg") plt.ioff() plt.plot(xnew, f(xnew), color="tab:blue", alpha=1) plt.plot(x, y, "p", color="tab:green", alpha=1) plt.plot(cq[0], cq[1], "o", color="red", alpha=1) plt.grid(True) plt.xlim(self.min_q, self.max_q) vmafs = [ int(x[1]) for x in tl if isinstance(x[1], float) and not isnan(x[1]) ] plt.ylim(min(vmafs), max(vmafs) + 1) plt.ylabel("VMAF") plt.title(f"Chunk: {chunk.name}, Frames: {frames}") plt.xticks(np.arange(self.min_q, self.max_q + 1, 1.0)) temp = self.temp / chunk.name plt.savefig(f"{temp}.png", dpi=200, format="png") plt.close()
def per_shot_target_quality(self, chunk: Chunk): # Refactor this mess vmaf_cq = [] frames = chunk.frames if self.probing_rate not in (1, 2): self.probing_rate = self.adapt_probing_rate( self.probing_rate, frames) if self.probes < 3: return self.fast_search(chunk) q_list = [] score = 0 # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < self.target: next_q = self.min_q q_list.append(self.min_q) else: next_q = self.max_q q_list.append(self.max_q) # Edge case check score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) vmaf_cq.append((score, next_q)) if next_q == self.min_q and score < self.target: self.log_probes(vmaf_cq, frames, chunk.name, skip='low') return next_q elif next_q == self.max_q and score > self.target: self.log_probes(vmaf_cq, frames, chunk.name, skip='high') return next_q # Set boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(self.probes - 2): new_point = self.weighted_search(vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, self.target) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, new_point)) vmaf_cq.append((score, new_point)) # Update boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = self.get_target_q(vmaf_cq, self.target) self.log_probes(vmaf_cq, frames, chunk.name, q=q, q_vmaf=q_vmaf) # log(f'Scene_score {self.get_scene_scores(chunk, self.ffmpeg_pipe)}') # Plot Probes if self.make_plots and len(vmaf_cq) > 3: self.plot_probes(vmaf_cq, chunk, frames) return q
class TargetQuality: def __init__(self, project): self.vmaf_runner = VMAF(n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter) self.n_threads = project.n_threads self.probing_rate = project.probing_rate self.probes = project.probes self.target = project.target_quality self.min_q = project.min_q self.max_q = project.max_q self.make_plots = project.vmaf_plots self.encoder = project.encoder self.ffmpeg_pipe = project.ffmpeg_pipe self.temp = project.temp def per_frame_target_quality_routine(self, chunk: Chunk): """ Applies per_shot_target_quality to this chunk. Determines what the cq value should be and sets the per_shot_target_quality_cq for this chunk :param project: the Project :param chunk: the Chunk :return: None """ chunk.per_frame_target_quality_q_list = self.per_frame_target_quality( chunk) def log_probes(self, vmaf_cq, frames, name, skip=None, q=None, q_vmaf=None): """Logs probes""" if skip == 'high': sk = ' Early Skip High CQ' elif skip == 'low': sk = ' Early Skip Low CQ' else: sk = '' target_q = q if q else vmaf_cq[-1][1] target_vmaf = q_vmaf if q_vmaf else round(vmaf_cq[-1][0], 2) log(f"Chunk: {name}, Rate: {self.probing_rate}, Fr: {frames}") log(f"Probes: {str(sorted(vmaf_cq))[1:-1]}{sk}") log(f"Target Q: {target_q} VMAF: {target_vmaf}") def per_shot_target_quality(self, chunk: Chunk): # Refactor this mess vmaf_cq = [] frames = chunk.frames if self.probing_rate not in (1, 2): self.probing_rate = self.adapt_probing_rate( self.probing_rate, frames) if self.probes < 3: return self.fast_search(chunk) q_list = [] score = 0 # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < self.target: next_q = self.min_q q_list.append(self.min_q) else: next_q = self.max_q q_list.append(self.max_q) # Edge case check score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) vmaf_cq.append((score, next_q)) if next_q == self.min_q and score < self.target: self.log_probes(vmaf_cq, frames, chunk.name, skip='low') return next_q elif next_q == self.max_q and score > self.target: self.log_probes(vmaf_cq, frames, chunk.name, skip='high') return next_q # Set boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(self.probes - 2): new_point = self.weighted_search(vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, self.target) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, new_point)) vmaf_cq.append((score, new_point)) # Update boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = self.get_target_q(vmaf_cq, self.target) self.log_probes(vmaf_cq, frames, chunk.name, q=q, q_vmaf=q_vmaf) # log(f'Scene_score {self.get_scene_scores(chunk, self.ffmpeg_pipe)}') # Plot Probes if self.make_plots and len(vmaf_cq) > 3: self.plot_probes(vmaf_cq, chunk, frames) return q def fast_search(self, chunk): """ Experimental search Use Euler's method with known relation between cq and vmaf Formula -ln(1-score/100) = vmaf_cq_deriv*last_q + constant constant = -ln(1-score/100) - vmaf_cq_deriv*last_q Formula -ln(1-project.vmaf_target/100) = vmaf_cq_deriv*cq + constant cq = (-ln(1-project.vmaf_target/100) - constant)/vmaf_cq_deriv """ vmaf_cq = [] q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) vmaf_cq_deriv = -0.18 next_q = int( round(last_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score)) / vmaf_cq_deriv)) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q # Single probe cq guess or exit to avoid divide by zero if self.probes == 1 or next_q == last_q: return next_q # Second probe at guessed value score_2 = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) # Calculate slope vmaf_cq_deriv = (VMAF.transform_vmaf(score_2) - VMAF.transform_vmaf(score)) / (next_q - last_q) # Same deal different slope next_q = int( round(next_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score_2)) / vmaf_cq_deriv)) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q self.log_probes(vmaf_cq, chunk.frames, chunk.name) return next_q def adapt_probing_rate(self, rate, frames): """ Change probing rate depending on amount of frames in scene. Ensure that low frame count scenes get decent amount of probes :param rate: given rate of probing :param frames: amount of frames in scene :return: new probing rate """ #Todo: Make it depend on amount of motion in scene #For current moment it's 4 for everything if frames > 0: return 4 if frames < 40: return 4 elif frames < 120: return 8 elif frames <= 240: return 10 elif frames > 240: return 16 def get_target_q(self, scores, target_quality): """ Interpolating scores to get Q closest to target Interpolation type for 2 probes changes to linear """ x = [x[1] for x in sorted(scores)] y = [float(x[0]) for x in sorted(scores)] if len(x) > 2: interpolation = 'quadratic' else: interpolation = 'linear' f = interpolate.interp1d(x, y, kind=interpolation) xnew = np.linspace(min(x), max(x), max(x) - min(x)) tl = list(zip(xnew, f(xnew))) q = min(tl, key=lambda l: abs(l[1] - target_quality)) return int(q[0]), round(q[1], 3) def weighted_search(self, num1, vmaf1, num2, vmaf2, target): """ Returns weighted value closest to searched :param num1: Q of first probe :param vmaf1: VMAF of first probe :param num2: Q of second probe :param vmaf2: VMAF of first probe :param target: VMAF target :return: Q for new probe """ dif1 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf2)) dif2 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf1)) tot = dif1 + dif2 new_point = int(round(num1 * (dif1 / tot) + (num2 * (dif2 / tot)))) return new_point def vmaf_probe(self, chunk: Chunk, q): """ Calculates vmaf and returns path to json file :param chunk: the Chunk :param q: Value to make probe :param project: the Project :return : path to json file with vmaf scores """ n_threads = self.n_threads if self.n_threads else 12 cmd = self.probe_cmd(chunk, q, self.ffmpeg_pipe, self.encoder, self.probing_rate, n_threads) pipe = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk) fl = self.vmaf_runner.call_vmaf(chunk, self.gen_probes_names(chunk, q), vmaf_rate=self.probing_rate) return fl def get_closest(self, q_list, q, positive=True): """ Returns closest value from the list, ascending or descending :param q_list: list of q values that been already used :param q: :param positive: search direction, positive - only values bigger than q :return: q value from list """ if positive: q_list = [x for x in q_list if x > q] else: q_list = [x for x in q_list if x < q] return min(q_list, key=lambda x: abs(x - q)) def probe_cmd(self, chunk: Chunk, q, ffmpeg_pipe, encoder, probing_rate, n_threads) -> CommandPair: """ Generate and return commands for probes at set Q values These are specifically not the commands that are generated by the user or encoder defaults, since these should be faster than the actual encoding commands. These should not be moved into encoder classes at this point. """ pipe = [ 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', '-', '-vf', f'select=not(mod(n\\,{probing_rate}))', *ffmpeg_pipe ] probe_name = self.gen_probes_names(chunk, q).with_suffix('.ivf').as_posix() if encoder == 'aom': params = [ 'aomenc', '--passes=1', f'--threads={n_threads}', '--tile-columns=2', '--tile-rows=1', '--end-usage=q', '-b', '8', '--cpu-used=6', f'--cq-level={q}', '--enable-filter-intra=0', '--enable-smooth-intra=0', '--enable-paeth-intra=0', '--enable-cfl-intra=0', '--enable-obmc=0', '--enable-palette=0', '--enable-overlay=0', '--enable-intrabc=0', '--enable-angle-delta=0', '--reduced-tx-type-set=1', '--enable-dual-filter=0', '--enable-intra-edge-filter=0', '--enable-order-hint=0', '--enable-flip-idtx=0', '--enable-dist-wtd-comp=0', '--enable-rect-tx=0', '--enable-interintra-wedge=0', '--enable-onesided-comp=0', '--enable-interintra-comp=0', '--enable-global-motion=0', '--min-partition-size=32', '--max-partition-size=32' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) elif encoder == 'x265': params = [ 'x265', '--log-level', '0', '--no-progress', '--y4m', '--frame-threads', f'{n_threads}', '--preset', 'fast', '--crf', f'{q}' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) elif encoder == 'rav1e': params = [ 'rav1e', '-y', '-s', '10', '--threads', f'{n_threads}', '--tiles', '16', '--quantizer', f'{q}', '--low-latency', '--rdo-lookahead-frames', '5' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) elif encoder == 'vpx': params = [ 'vpxenc', '-b', '10', '--profile=2', '--passes=1', '--pass=1', '--codec=vp9', f'--threads={n_threads}', '--cpu-used=9', '--end-usage=q', f'--cq-level={q}', '--row-mt=1' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) elif encoder == 'svt_av1': params = [ 'SvtAv1EncApp', '-i', 'stdin', '--lp', f'{n_threads}', '--preset', '8', '-q', f'{q}', '--tile-rows', '1', '--tile-columns', '2', '--hme', '0', '--pred-struct', '0', '--sg-filter-mode', '0', '--enable-restoration-filtering', '0', '--cdef-level', '0', '--disable-dlf', '0', '--mrp-level', '0', '--enable-tpl-la', '0', '--enable-mfmv', '0', '--enable-local-warp', '0', '--enable-global-motion', '0', '--enable-interintra-comp', '0', '--obmc-level', '0', '--rdoq-level', '0', '--filter-intra-level', '0', '--enable-intra-edge-filter', '0', '--enable-pic-based-rate-est', '0', '--pred-me', '0', '--bipred-3x3', '0', '--compound', '0', '--use-default-me-hme', '0', '--ext-block', '0', '--hbd-md', '0', '--palette-level', '0', '--umv', '0', '--tf-level', '3' ] cmd = CommandPair(pipe, [*params, '-b', probe_name, '-']) elif encoder == 'svt_vp9': params = [ 'SvtVp9EncApp', '-i', 'stdin', '--lp', f'{n_threads}', '-enc-mode', '8', '-q', f'{q}' ] # TODO: pipe needs to output rawvideo cmd = CommandPair(pipe, [*params, '-b', probe_name, '-']) elif encoder == 'x264': params = [ 'x264', '--log-level', 'error', '--demuxer', 'y4m', '-', '--no-progress', '--threads', f'{n_threads}', '--preset', 'medium', '--crf', f'{q}' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) return cmd def search(self, q1, v1, q2, v2, target): if abs(target - v2) < 0.5: return q2 if v1 > target and v2 > target: return min(q1, q2) if v1 < target and v2 < target: return max(q1, q2) dif1 = abs(target - v2) dif2 = abs(target - v1) tot = dif1 + dif2 new_point = int(round(q1 * (dif1 / tot) + (q2 * (dif2 / tot)))) return new_point def interpolate_data(self, vmaf_cq: list, target_quality): x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] # Interpolate data f = interpolate.interp1d(x, y, kind='quadratic') xnew = np.linspace(min(x), max(x), max(x) - min(x)) # Getting value closest to target tl = list(zip(xnew, f(xnew))) target_quality_cq = min(tl, key=lambda l: abs(l[1] - target_quality)) return target_quality_cq, tl, f, xnew def per_shot_target_quality_routine(self, chunk: Chunk): """ Applies per_shot_target_quality to this chunk. Determines what the cq value should be and sets the per_shot_target_quality_cq for this chunk :param project: the Project :param chunk: the Chunk :return: None """ chunk.per_shot_target_quality_cq = self.per_shot_target_quality(chunk) def get_scene_scores(self, chunk, ffmpeg_pipe): """ Run ffmpeg scenedetection filter Gets average amount of motion in scene """ pipecmd = [ 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', '-', *ffmpeg_pipe ] params = [ 'ffmpeg', '-hide_banner', '-i', '-', '-vf', 'select=\'gte(scene,0)\',metadata=print', '-f', 'null', '-' ] cmd = CommandPair(pipecmd, [*params]) pipe = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) history = [] while True: line = pipe.stdout.readline().strip() if len(line) == 0 and pipe.poll() is not None: break if len(line) == 0: continue if line: history.append(line) if pipe.returncode != 0 and pipe.returncode != -2: print(f"\n:: Error in getting scene score {pipe.returncode}") print(f"\n:: Chunk: {chunk.index}") print('\n'.join(history)) scores = [x for x in history if 'score' in x] results = [] for x in scores: matches = re.findall(r"=\s*([\S\s]+)", x) var = float(matches[-1]) if var < 0.3: results.append(var) result = (round(np.average(results), 4) * 1000) return result def gen_probes_names(self, chunk: Chunk, q): """ Make name of vmaf probe """ return chunk.fake_input_path.with_name( f'v_{q}{chunk.name}').with_suffix('.ivf') def make_pipes(self, ffmpeg_gen_cmd: Command, command: CommandPair): ffmpeg_gen_pipe = subprocess.Popen(ffmpeg_gen_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) ffmpeg_pipe = subprocess.Popen(command[0], stdin=ffmpeg_gen_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) pipe = subprocess.Popen(command[1], stdin=ffmpeg_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) return pipe def plot_probes(self, vmaf_cq, chunk: Chunk, frames): """ Makes graph with probe decisions """ if plt is None: log('Matplotlib is not installed or could not be loaded\ . Unable to plot probes.') return # Saving plot of vmaf calculation x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] cq, tl, f, xnew = self.interpolate_data(vmaf_cq, self.target) matplotlib.use('agg') plt.ioff() plt.plot(xnew, f(xnew), color='tab:blue', alpha=1) plt.plot(x, y, 'p', color='tab:green', alpha=1) plt.plot(cq[0], cq[1], 'o', color='red', alpha=1) plt.grid(True) plt.xlim(self.min_q, self.max_q) vmafs = [ int(x[1]) for x in tl if isinstance(x[1], float) and not isnan(x[1]) ] plt.ylim(min(vmafs), max(vmafs) + 1) plt.ylabel('VMAF') plt.title(f'Chunk: {chunk.name}, Frames: {frames}') plt.xticks(np.arange(self.min_q, self.max_q + 1, 1.0)) temp = self.temp / chunk.name plt.savefig(f'{temp}.png', dpi=200, format='png') plt.close() def make_q_file(self, q_list, chunk): qfile = chunk.fake_input_path.with_name( f'probe_{chunk.name}').with_suffix('.txt') with open(qfile, 'w') as fl: text = '' for x in q_list: text += str(x) + '\n' fl.write(text) return qfile def per_frame_probe_cmd(self, chunk: Chunk, q, encoder, probing_rate, qp_file) -> CommandPair: """ Generate and return commands for probes at set Q values These are specifically not the commands that are generated by the user or encoder defaults, since these should be faster than the actual encoding commands. These should not be moved into encoder classes at this point. """ pipe = [ 'ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-hwaccel', 'auto', '-i', '-', '-vf', f'select=not(mod(n\\,{probing_rate}))', *self.ffmpeg_pipe ] probe_name = self.gen_probes_names(chunk, q).with_suffix('.ivf').as_posix() if encoder == 'svt_av1': params = [ 'SvtAv1EncApp', '-i', 'stdin', '--preset', '8', '--rc', '0', '--passes', '1', '--use-q-file', '1', '--qpfile', f'{qp_file.as_posix()}' ] cmd = CommandPair(pipe, [*params, '-b', probe_name, '-']) else: print('supported only by SVT-AV1') exit() """ elif encoder == 'x265': params = [ 'x265', '--log-level', '0', '--no-progress', '--y4m', '--preset', 'fast', '--crf', f'{q}' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) """ return cmd def per_frame_probe(self, q_list, q, chunk): qfile = chunk.make_q_file(q_list) cmd = self.per_frame_probe_cmd(chunk, q, self.encoder, 1, qfile) pipe = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk) fl = self.vmaf_runner.call_vmaf(chunk, self.gen_probes_names(chunk, q)) jsn = VMAF.read_json(fl) vmafs = [x['metrics']['vmaf'] for x in jsn['frames']] return vmafs def add_probes_to_frame_list(self, frame_list, q_list, vmafs): frame_list = list(frame_list) for index, q_vmaf in enumerate(zip(q_list, vmafs)): frame_list[index]['probes'].append((q_vmaf[0], q_vmaf[1])) return frame_list def per_frame_target_quality(self, chunk): frames = chunk.frames frame_list = [{'frame_number': x, 'probes': []} for x in range(frames)] for _ in range(self.probes): q_list = self.gen_next_q(frame_list, chunk) vmafs = self.per_frame_probe(q_list, 1, chunk) frame_list = self.add_probes_to_frame_list(frame_list, q_list, vmafs) mse = round( self.get_square_error([x['probes'][-1][1] for x in frame_list], self.target), 2) # print(':: MSE:', mse) if mse < 1.0: return q_list return q_list def get_square_error(self, ls, target): total = 0 for i in ls: dif = i - target total += dif**2 mse = total / len(ls) return mse def gen_next_q(self, frame_list, chunk): q_list = [] probes = len(frame_list[0]['probes']) if probes == 0: return [self.min_q] * len(frame_list) elif probes == 1: return [self.max_q] * len(frame_list) else: for probe in frame_list: x = [x[0] for x in probe['probes']] y = [x[1] for x in probe['probes']] if probes > 2: if len(x) != len(set(x)): q_list.append(probe['probes'][-1][0]) continue interpolation = 'quadratic' if probes > 2 else 'linear' f = interpolate.interp1d(x, y, kind=interpolation) xnew = np.linspace(min(x), max(x), max(x) - min(x)) tl = list(zip(xnew, f(xnew))) q = min(tl, key=lambda l: abs(l[1] - self.target)) q_list.append(int(round(q[0]))) return q_list
class TargetQuality: def __init__(self, project): self.vmaf_runner = VMAF( n_threads=project.n_threads, model=project.vmaf_path, res=project.vmaf_res, vmaf_filter=project.vmaf_filter, ) self.n_threads = project.n_threads self.probing_rate = project.probing_rate self.probes = project.probes self.target = project.target_quality self.min_q = project.min_q self.max_q = project.max_q self.make_plots = project.vmaf_plots self.encoder = project.encoder self.ffmpeg_pipe = project.ffmpeg_pipe self.temp = project.temp self.workers = project.workers def per_frame_target_quality_routine(self, chunk: Chunk): """ Applies per_shot_target_quality to this chunk. Determines what the cq value should be and sets the per_shot_target_quality_cq for this chunk :param project: the Project :param chunk: the Chunk :return: None """ chunk.per_frame_target_quality_q_list = self.per_frame_target_quality(chunk) def log_probes(self, vmaf_cq, frames, name, target_q, target_vmaf, skip=None): """ Logs probes result :type vmaf_cq: list probe measurements (q_vmaf, q) :type frames: int frame count of chunk :type name: str chunk name :type skip: str None if normal results, else "high" or "low" :type target_q: int Calculated q to be used :type target_vmaf: float Calculated VMAF that would be achieved by using the q :return: None """ if skip == "high": sk = " Early Skip High CQ" elif skip == "low": sk = " Early Skip Low CQ" else: sk = "" log(f"Chunk: {name}, Rate: {self.probing_rate}, Fr: {frames}") log(f"Probes: {str(sorted(vmaf_cq))[1:-1]}{sk}") log(f"Target Q: {target_q} VMAF: {round(target_vmaf, 2)}") def per_shot_target_quality(self, chunk: Chunk): """ :type: Chunk chunk to probe :rtype: int q to use """ # TODO: Refactor this mess vmaf_cq = [] frames = chunk.frames self.probing_rate = adapt_probing_rate(self.probing_rate, frames) if self.probes < 3: return self.fast_search(chunk) q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) # Initialize search boundary vmaf_lower = score vmaf_upper = score vmaf_cq_lower = last_q vmaf_cq_upper = last_q # Branch if score < self.target: next_q = self.min_q q_list.append(self.min_q) else: next_q = self.max_q q_list.append(self.max_q) # Edge case check score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) vmaf_cq.append((score, next_q)) if (next_q == self.min_q and score < self.target) or ( next_q == self.max_q and score > self.target ): self.log_probes( vmaf_cq, frames, chunk.name, next_q, score, skip="low" if score < self.target else "high", ) return next_q # Set boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = next_q else: vmaf_upper = score vmaf_cq_upper = next_q # VMAF search for _ in range(self.probes - 2): new_point = self.weighted_search( vmaf_cq_lower, vmaf_lower, vmaf_cq_upper, vmaf_upper, self.target ) if new_point in [x[1] for x in vmaf_cq]: break q_list.append(new_point) score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, new_point)) vmaf_cq.append((score, new_point)) # Update boundary if score < self.target: vmaf_lower = score vmaf_cq_lower = new_point else: vmaf_upper = score vmaf_cq_upper = new_point q, q_vmaf = self.get_target_q(vmaf_cq, self.target) self.log_probes(vmaf_cq, frames, chunk.name, q, q_vmaf) # Plot Probes if self.make_plots and len(vmaf_cq) > 3: self.plot_probes(vmaf_cq, chunk, frames) return q def fast_search(self, chunk): """ Experimental search Use Euler's method with known relation between cq and vmaf Formula -ln(1-score/100) = vmaf_cq_deriv*last_q + constant constant = -ln(1-score/100) - vmaf_cq_deriv*last_q Formula -ln(1-project.vmaf_target/100) = vmaf_cq_deriv*cq + constant cq = (-ln(1-project.vmaf_target/100) - constant)/vmaf_cq_deriv """ vmaf_cq = [] q_list = [] # Make middle probe middle_point = (self.min_q + self.max_q) // 2 q_list.append(middle_point) last_q = middle_point score = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, last_q)) vmaf_cq.append((score, last_q)) vmaf_cq_deriv = -0.18 next_q = int( round( last_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score)) / vmaf_cq_deriv ) ) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q # Single probe cq guess or exit to avoid divide by zero if self.probes == 1 or next_q == last_q: self.log_probes(vmaf_cq, chunk.frames, chunk.name, next_q, self.target) return next_q # Second probe at guessed value score_2 = VMAF.read_weighted_vmaf(self.vmaf_probe(chunk, next_q)) # Calculate slope vmaf_cq_deriv = (VMAF.transform_vmaf(score_2) - VMAF.transform_vmaf(score)) / ( next_q - last_q ) # Same deal different slope next_q = int( round( next_q + (VMAF.transform_vmaf(self.target) - VMAF.transform_vmaf(score_2)) / vmaf_cq_deriv ) ) # Clamp if next_q < self.min_q: next_q = self.min_q if self.max_q < next_q: next_q = self.max_q self.log_probes(vmaf_cq, chunk.frames, chunk.name, next_q, self.target) return next_q def get_target_q(self, scores, target_quality): """ Interpolating scores to get Q closest to target Interpolation type for 2 probes changes to linear """ x = [x[1] for x in sorted(scores)] y = [float(x[0]) for x in sorted(scores)] if len(x) > 2: interpolation = "quadratic" else: interpolation = "linear" f = interpolate.interp1d(x, y, kind=interpolation) xnew = np.linspace(min(x), max(x), max(x) - min(x)) tl = list(zip(xnew, f(xnew))) q = min(tl, key=lambda l: abs(l[1] - target_quality)) return int(q[0]), round(q[1], 3) def weighted_search(self, num1, vmaf1, num2, vmaf2, target): """ Returns weighted value closest to searched :param num1: Q of first probe :param vmaf1: VMAF of first probe :param num2: Q of second probe :param vmaf2: VMAF of first probe :param target: VMAF target :return: Q for new probe """ dif1 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf2)) dif2 = abs(VMAF.transform_vmaf(target) - VMAF.transform_vmaf(vmaf1)) tot = dif1 + dif2 new_point = int(round(num1 * (dif1 / tot) + (num2 * (dif2 / tot)))) return new_point def vmaf_probe(self, chunk: Chunk, q): """ Calculates vmaf and returns path to json file :param chunk: the Chunk :param q: Value to make probe :param project: the Project :return : path to json file with vmaf scores """ n_threads = self.n_threads if self.n_threads else self.auto_vmaf_threads() cmd = self.probe_cmd( chunk, q, self.ffmpeg_pipe, self.encoder, self.probing_rate, n_threads ) pipe, utility = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk, utility) fl = self.vmaf_runner.call_vmaf( chunk, self.gen_probes_names(chunk, q), vmaf_rate=self.probing_rate ) return fl def auto_vmaf_threads(self): """ Calculates number of vmaf threads based on CPU cores in system :return: Integer value for number of threads """ cores = os.cpu_count() # One thread may not be enough to keep the CPU saturated, so over-provision a bit. over_provision_factor = 1.25 minimum_threads = 1 return int(max((cores / self.workers) * over_provision_factor, minimum_threads)) def get_closest(self, q_list, q, positive=True): """ Returns closest value from the list, ascending or descending :param q_list: list of q values that been already used :param q: :param positive: search direction, positive - only values bigger than q :return: q value from list """ if positive: q_list = [x for x in q_list if x > q] else: q_list = [x for x in q_list if x < q] return min(q_list, key=lambda x: abs(x - q)) def probe_cmd( self, chunk: Chunk, q, ffmpeg_pipe, encoder, probing_rate, n_threads ) -> CommandPair: """ Generate and return commands for probes at set Q values These are specifically not the commands that are generated by the user or encoder defaults, since these should be faster than the actual encoding commands. These should not be moved into encoder classes at this point. """ pipe = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", "-", "-vf", f"select=not(mod(n\\,{probing_rate}))", *ffmpeg_pipe, ] probe_name = self.gen_probes_names(chunk, q).with_suffix(".ivf").as_posix() if encoder == "aom": params = construct_target_quality_command("aom", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "x265": params = construct_target_quality_command("x265", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "rav1e": params = construct_target_quality_command("rav1e", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "vpx": params = construct_target_quality_command("vpx", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) elif encoder == "svt_av1": params = construct_target_quality_command("svt_av1", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-b", probe_name]) elif encoder == "svt_vp9": params = construct_target_quality_command("svt_vp9", str(n_threads), str(q)) # TODO: pipe needs to output rawvideo cmd = CommandPair(pipe, [*params, "-b", probe_name, "-"]) elif encoder == "x264": params = construct_target_quality_command("x264", str(n_threads), str(q)) cmd = CommandPair(pipe, [*params, "-o", probe_name, "-"]) return cmd def search(self, q1, v1, q2, v2, target): if abs(target - v2) < 0.5: return q2 if v1 > target and v2 > target: return min(q1, q2) if v1 < target and v2 < target: return max(q1, q2) dif1 = abs(target - v2) dif2 = abs(target - v1) tot = dif1 + dif2 new_point = int(round(q1 * (dif1 / tot) + (q2 * (dif2 / tot)))) return new_point def interpolate_data(self, vmaf_cq: list, target_quality): x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] # Interpolate data f = interpolate.interp1d(x, y, kind="quadratic") xnew = np.linspace(min(x), max(x), max(x) - min(x)) # Getting value closest to target tl = list(zip(xnew, f(xnew))) target_quality_cq = min(tl, key=lambda l: abs(l[1] - target_quality)) return target_quality_cq, tl, f, xnew def per_shot_target_quality_routine(self, chunk: Chunk): """ Applies per_shot_target_quality to this chunk. Determines what the cq value should be and sets the per_shot_target_quality_cq for this chunk :param project: the Project :param chunk: the Chunk :return: None """ chunk.per_shot_target_quality_cq = self.per_shot_target_quality(chunk) def gen_probes_names(self, chunk: Chunk, q): """ Make name of vmaf probe """ return chunk.fake_input_path.with_name(f"v_{q}{chunk.name}").with_suffix(".ivf") def make_pipes(self, ffmpeg_gen_cmd: Command, command: CommandPair): ffmpeg_gen_pipe = subprocess.Popen( ffmpeg_gen_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT ) ffmpeg_pipe = subprocess.Popen( command[0], stdin=ffmpeg_gen_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) pipe = subprocess.Popen( command[1], stdin=ffmpeg_pipe.stdout, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, ) utility = (ffmpeg_gen_pipe, ffmpeg_pipe) return pipe, utility def plot_probes(self, vmaf_cq, chunk: Chunk, frames): """ Makes graph with probe decisions """ if plt is None: log( "Matplotlib is not installed or could not be loaded\ . Unable to plot probes." ) return # Saving plot of vmaf calculation x = [x[1] for x in sorted(vmaf_cq)] y = [float(x[0]) for x in sorted(vmaf_cq)] cq, tl, f, xnew = self.interpolate_data(vmaf_cq, self.target) matplotlib.use("agg") plt.ioff() plt.plot(xnew, f(xnew), color="tab:blue", alpha=1) plt.plot(x, y, "p", color="tab:green", alpha=1) plt.plot(cq[0], cq[1], "o", color="red", alpha=1) plt.grid(True) plt.xlim(self.min_q, self.max_q) vmafs = [int(x[1]) for x in tl if isinstance(x[1], float) and not isnan(x[1])] plt.ylim(min(vmafs), max(vmafs) + 1) plt.ylabel("VMAF") plt.title(f"Chunk: {chunk.name}, Frames: {frames}") plt.xticks(np.arange(self.min_q, self.max_q + 1, 1.0)) temp = self.temp / chunk.name plt.savefig(f"{temp}.png", dpi=200, format="png") plt.close() def make_q_file(self, q_list, chunk): qfile = chunk.fake_input_path.with_name(f"probe_{chunk.name}").with_suffix( ".txt" ) with open(qfile, "w") as fl: text = "" for x in q_list: text += str(x) + "\n" fl.write(text) return qfile def per_frame_probe_cmd( self, chunk: Chunk, q, encoder, probing_rate, qp_file ) -> CommandPair: """ Generate and return commands for probes at set Q values These are specifically not the commands that are generated by the user or encoder defaults, since these should be faster than the actual encoding commands. These should not be moved into encoder classes at this point. """ pipe = [ "ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", "-", "-vf", f"select=not(mod(n\\,{probing_rate}))", *self.ffmpeg_pipe, ] probe_name = self.gen_probes_names(chunk, q).with_suffix(".ivf").as_posix() if encoder == "svt_av1": params = [ "SvtAv1EncApp", "-i", "stdin", "--preset", "8", "--rc", "0", "--passes", "1", "--use-q-file", "1", "--qpfile", f"{qp_file.as_posix()}", ] cmd = CommandPair(pipe, [*params, "-b", probe_name, "-"]) else: print("supported only by SVT-AV1") exit() """ elif encoder == 'x265': params = [ 'x265', '--log-level', '0', '--no-progress', '--y4m', '--preset', 'fast', '--crf', f'{q}' ] cmd = CommandPair(pipe, [*params, '-o', probe_name, '-']) """ return cmd def per_frame_probe(self, q_list, q, chunk): qfile = chunk.make_q_file(q_list) cmd = self.per_frame_probe_cmd(chunk, q, self.encoder, 1, qfile) pipe, utility = self.make_pipes(chunk.ffmpeg_gen_cmd, cmd) process_pipe(pipe, chunk, utility) fl = self.vmaf_runner.call_vmaf(chunk, self.gen_probes_names(chunk, q)) jsn = VMAF.read_json(fl) vmafs = [x["metrics"]["vmaf"] for x in jsn["frames"]] return vmafs def add_probes_to_frame_list(self, frame_list, q_list, vmafs): frame_list = list(frame_list) for index, q_vmaf in enumerate(zip(q_list, vmafs)): frame_list[index]["probes"].append((q_vmaf[0], q_vmaf[1])) return frame_list def per_frame_target_quality(self, chunk): frames = chunk.frames frame_list = [{"frame_number": x, "probes": []} for x in range(frames)] for _ in range(self.probes): q_list = self.gen_next_q(frame_list, chunk) vmafs = self.per_frame_probe(q_list, 1, chunk) frame_list = self.add_probes_to_frame_list(frame_list, q_list, vmafs) mse = round( self.get_square_error( [x["probes"][-1][1] for x in frame_list], self.target ), 2, ) # print(':: MSE:', mse) if mse < 1.0: return q_list return q_list def get_square_error(self, ls, target): total = 0 for i in ls: dif = i - target total += dif ** 2 mse = total / len(ls) return mse def gen_next_q(self, frame_list, chunk): q_list = [] probes = len(frame_list[0]["probes"]) if probes == 0: return [self.min_q] * len(frame_list) elif probes == 1: return [self.max_q] * len(frame_list) else: for probe in frame_list: x = [x[0] for x in probe["probes"]] y = [x[1] for x in probe["probes"]] if probes > 2: if len(x) != len(set(x)): q_list.append(probe["probes"][-1][0]) continue interpolation = "quadratic" if probes > 2 else "linear" f = interpolate.interp1d(x, y, kind=interpolation) xnew = np.linspace(min(x), max(x), max(x) - min(x)) tl = list(zip(xnew, f(xnew))) q = min(tl, key=lambda l: abs(l[1] - self.target)) q_list.append(int(round(q[0]))) return q_list