mirror of
https://github.com/master-of-zen/Av1an.git
synced 2024-11-25 02:29:40 +00:00
ce03d7a6e2
Supports: * Pyscenedetect chunking * aom_keyframes chunking * Automatic detection of input as Vapoursynth script. * Cleaned up fast frame counting into unified method "frame_probe_fast" Areas for improvement: * Windows support * vspipe arg passing Fixes #173
231 lines
9.6 KiB
Python
Executable file
231 lines
9.6 KiB
Python
Executable file
#!/bin/env python
|
|
import os
|
|
import re
|
|
import struct
|
|
import subprocess
|
|
from collections import deque
|
|
from pathlib import Path
|
|
from subprocess import PIPE, STDOUT
|
|
|
|
import cv2
|
|
from tqdm import tqdm
|
|
|
|
from Av1an.commandtypes import CommandPair
|
|
from Av1an.logger import log
|
|
from Av1an.utils import terminate, frame_probe, frame_probe_fast
|
|
from Av1an.vapoursynth import compose_vapoursynth_pipe
|
|
|
|
# This is a script that returns a list of keyframes that aom would likely place. Port of aom's C code.
|
|
# It requires an aom first-pass stats file as input. FFMPEG first-pass file is not OK. Default filename is stats.bin.
|
|
# Script has been tested to have ~99% accuracy vs final aom encode.
|
|
|
|
# Elements related to parsing the stats file were written by MrSmilingWolf
|
|
|
|
# All of my contributions to this script are hereby public domain.
|
|
# I retain no rights or control over distribution.
|
|
|
|
|
|
# default params for 1st pass when aom isn't the final encoder and -v won't match aom's options
|
|
AOM_KEYFRAMES_DEFAULT_PARAMS = ['--threads=12', '--cpu-used=0' ,'--end-usage=q', '--cq-level=40']
|
|
|
|
|
|
# Fields meanings: <source root>/av1/encoder/firstpass.h
|
|
fields = ['frame', 'weight', 'intra_error', 'frame_avg_wavelet_energy', 'coded_error', 'sr_coded_error', 'tr_coded_error',
|
|
'pcnt_inter', 'pcnt_motion', 'pcnt_second_ref', 'pcnt_third_ref', 'pcnt_neutral', 'intra_skip_pct', 'inactive_zone_rows',
|
|
'inactive_zone_cols', 'MVr', 'mvr_abs', 'MVc', 'mvc_abs', 'MVrv', 'MVcv', 'mv_in_out_count', 'new_mv_count', 'duration', 'count', 'raw_error_stdev']
|
|
|
|
|
|
def get_second_ref_usage_thresh(frame_count_so_far):
|
|
adapt_upto = 32
|
|
min_second_ref_usage_thresh = 0.085
|
|
second_ref_usage_thresh_max_delta = 0.035
|
|
if frame_count_so_far >= adapt_upto:
|
|
return min_second_ref_usage_thresh + second_ref_usage_thresh_max_delta
|
|
return min_second_ref_usage_thresh + (frame_count_so_far / (adapt_upto - 1)) * second_ref_usage_thresh_max_delta
|
|
|
|
|
|
# I have no idea if the following function is necessary in the python implementation or what its purpose even is.
|
|
# noinspection PyPep8Naming
|
|
def DOUBLE_DIVIDE_CHECK(x):
|
|
if x < 0:
|
|
return x - 0.000001
|
|
else:
|
|
return x + 0.000001
|
|
|
|
|
|
# noinspection PyPep8Naming
|
|
def test_candidate_kf(dict_list, current_frame_index, frame_count_so_far):
|
|
previous_frame_dict = dict_list[current_frame_index - 1]
|
|
current_frame_dict = dict_list[current_frame_index]
|
|
future_frame_dict = dict_list[current_frame_index + 1]
|
|
|
|
p = previous_frame_dict
|
|
c = current_frame_dict
|
|
f = future_frame_dict
|
|
|
|
BOOST_FACTOR = 12.5
|
|
|
|
# For more documentation on the below, see
|
|
# https://aomedia.googlesource.com/aom/+/8ac928be918de0d502b7b492708d57ad4d817676/av1/encoder/pass2_strategy.c#1897
|
|
MIN_INTRA_LEVEL = 0.25
|
|
INTRA_VS_INTER_THRESH = 2.0
|
|
VERY_LOW_INTER_THRESH = 0.05
|
|
KF_II_ERR_THRESHOLD = 2.5
|
|
ERR_CHANGE_THRESHOLD = 0.4
|
|
II_IMPROVEMENT_THRESHOLD = 3.5
|
|
KF_II_MAX = 128.0
|
|
|
|
qmode = True
|
|
# TODO: allow user to set whether we're testing for constant-q mode keyframe placement or not. it's not a big difference.
|
|
|
|
is_keyframe = False
|
|
|
|
pcnt_intra = 1.0 - c['pcnt_inter']
|
|
modified_pcnt_inter = c['pcnt_inter'] - c['pcnt_neutral']
|
|
|
|
second_ref_usage_thresh = get_second_ref_usage_thresh(frame_count_so_far)
|
|
|
|
if ((qmode == False) or (frame_count_so_far > 2)) and (c['pcnt_second_ref'] < second_ref_usage_thresh) and (f['pcnt_second_ref'] < second_ref_usage_thresh) and ((c['pcnt_inter'] < VERY_LOW_INTER_THRESH) or ((pcnt_intra > MIN_INTRA_LEVEL) and (pcnt_intra > (INTRA_VS_INTER_THRESH * modified_pcnt_inter)) and ((c['intra_error'] / DOUBLE_DIVIDE_CHECK(c['coded_error'])) < KF_II_ERR_THRESHOLD) and ((abs(p['coded_error'] - c['coded_error']) / DOUBLE_DIVIDE_CHECK(c['coded_error']) > ERR_CHANGE_THRESHOLD) or (abs(p['intra_error'] - c['intra_error']) / DOUBLE_DIVIDE_CHECK(c['intra_error']) > ERR_CHANGE_THRESHOLD) or ((f['intra_error'] / DOUBLE_DIVIDE_CHECK(f['coded_error'])) > II_IMPROVEMENT_THRESHOLD)))):
|
|
boost_score = 0.0
|
|
old_boost_score = 0.0
|
|
decay_accumulator = 1.0
|
|
for i in range(0, 16):
|
|
lnf = dict_list[current_frame_index + 1 + i]
|
|
next_iiratio = (BOOST_FACTOR * lnf['intra_error'] / DOUBLE_DIVIDE_CHECK(lnf['coded_error']))
|
|
if next_iiratio > KF_II_MAX:
|
|
next_iiratio = KF_II_MAX
|
|
|
|
# Cumulative effect of decay in prediction quality.
|
|
if lnf['pcnt_inter'] > 0.85:
|
|
decay_accumulator = decay_accumulator * lnf['pcnt_inter']
|
|
else:
|
|
decay_accumulator = decay_accumulator * ((0.85 + lnf['pcnt_inter']) / 2.0)
|
|
|
|
# Keep a running total.
|
|
boost_score += (decay_accumulator * next_iiratio)
|
|
|
|
# Test various breakout clauses.
|
|
if (lnf['pcnt_inter'] < 0.05) or (next_iiratio < 1.5) or (((lnf['pcnt_inter'] - lnf['pcnt_neutral']) < 0.20)
|
|
and (next_iiratio < 3.0)) or ((boost_score - old_boost_score) < 3.0) or (lnf['intra_error'] < 200):
|
|
break
|
|
old_boost_score = boost_score
|
|
|
|
# If there is tolerable prediction for at least the next 3 frames then break out else discard this potential key frame and move on
|
|
if boost_score > 30.0 and (i > 3):
|
|
is_keyframe = True
|
|
return is_keyframe
|
|
|
|
|
|
def find_aom_keyframes(stat_file, key_freq_min):
|
|
keyframes_list = []
|
|
|
|
number_of_frames = round(os.stat(stat_file).st_size / 208) - 1
|
|
dict_list = []
|
|
|
|
with open(stat_file, 'rb') as file:
|
|
frame_buf = file.read(208)
|
|
while len(frame_buf) > 0:
|
|
stats = struct.unpack('d' * 26, frame_buf)
|
|
p = dict(zip(fields, stats))
|
|
dict_list.append(p)
|
|
frame_buf = file.read(208)
|
|
|
|
# intentionally skipping 0th frame and last 16 frames
|
|
frame_count_so_far = 1
|
|
for i in range(1, number_of_frames - 16):
|
|
is_keyframe = False
|
|
|
|
# https://aomedia.googlesource.com/aom/+/ce97de2724d7ffdfdbe986a14d49366936187298/av1/encoder/pass2_strategy.c#2065
|
|
if frame_count_so_far >= key_freq_min:
|
|
is_keyframe = test_candidate_kf(dict_list, i, frame_count_so_far)
|
|
if is_keyframe:
|
|
keyframes_list.append(i)
|
|
frame_count_so_far = 0
|
|
frame_count_so_far += 1
|
|
|
|
return keyframes_list
|
|
|
|
|
|
def compose_aomsplit_first_pass_command(video_path: Path, stat_file: Path, ffmpeg_pipe, video_params, is_vs) -> CommandPair:
|
|
"""
|
|
Generates the command for the first pass of the entire video used for aom keyframe split
|
|
|
|
:param video_path: the video path
|
|
:param stat_file: the stat_file output
|
|
:param ffmpeg_pipe: the av1an.ffmpeg_pipe with pix_fmt and -ff option
|
|
:param video_params: the video params for aomenc first pass
|
|
:param is_vs: is video_path a vapoursynth script
|
|
:return: ffmpeg, encode
|
|
"""
|
|
|
|
if is_vs:
|
|
f = compose_vapoursynth_pipe(video_path)
|
|
else:
|
|
f = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'error', '-i', video_path.as_posix(), *ffmpeg_pipe]
|
|
# removed -w -h from aomenc since ffmpeg filters can change it and it can be added into video_params
|
|
# TODO(n9Mtq4): if an encoder other than aom is being used, video_params becomes the default so -w -h may be needed again
|
|
|
|
|
|
# Adjust number of threads
|
|
video_params = ' '.join(video_params)
|
|
|
|
video_params = re.sub(r'(--threads=[0-9]+)', f'--threads={min(32 ,os.cpu_count() * 3)}', video_params)
|
|
|
|
e = ['aomenc', '--passes=2', '--pass=1', *video_params.split(), f'--fpf={stat_file.as_posix()}', '-o', os.devnull, '-']
|
|
return CommandPair(f, e)
|
|
|
|
|
|
def aom_keyframes(video_path: Path, stat_file, min_scene_len, ffmpeg_pipe, video_params, is_vs):
|
|
"""[Get frame numbers for splits from aomenc 1 pass stat file]
|
|
"""
|
|
|
|
log(f'Started aom_keyframes scenedetection\nParams: {video_params}\n')
|
|
|
|
total = frame_probe_fast(video_path, is_vs)
|
|
|
|
f, e = compose_aomsplit_first_pass_command(video_path, stat_file, ffmpeg_pipe, video_params, is_vs)
|
|
|
|
tqdm_bar = tqdm(total=total, initial=0, dynamic_ncols=True, unit="fr", leave=True, smoothing=0.2)
|
|
|
|
ffmpeg_pipe = subprocess.Popen(f, stdout=PIPE, stderr=STDOUT)
|
|
pipe = subprocess.Popen(e, stdin=ffmpeg_pipe.stdout, stdout=PIPE,
|
|
stderr=STDOUT, universal_newlines=True)
|
|
|
|
encoder_history = deque(maxlen=20)
|
|
frame = 0
|
|
|
|
while True:
|
|
line = pipe.stdout.readline()
|
|
if len(line) == 0 and pipe.poll() is not None:
|
|
break
|
|
line = line.strip()
|
|
|
|
if line:
|
|
encoder_history.append(line)
|
|
|
|
match = re.search(r"frame.*?/([^ ]+?) ", line)
|
|
if match:
|
|
new = int(match.group(1))
|
|
if new > frame:
|
|
tqdm_bar.update(new - frame)
|
|
frame = new
|
|
|
|
if pipe.returncode != 0 and pipe.returncode != -2: # -2 is Ctrl+C for aom
|
|
enc_hist = '\n'.join(encoder_history)
|
|
er = f"\nAom first pass encountered an error: {pipe.returncode}\n{enc_hist}"
|
|
log(er)
|
|
print(er)
|
|
if not stat_file.exists():
|
|
terminate()
|
|
else:
|
|
# aom crashed, but created keyframes.log, so we will try to continue
|
|
print("WARNING: Aom first pass crashed, but created a first pass file. Keyframe splitting may not be accurate.")
|
|
|
|
# aom kf-min-dist defaults to 0, but hardcoded to 3 in pass2_strategy.c test_candidate_kf. 0 matches default aom behavior
|
|
# https://aomedia.googlesource.com/aom/+/8ac928be918de0d502b7b492708d57ad4d817676/av1/av1_cx_iface.c#2816
|
|
# https://aomedia.googlesource.com/aom/+/ce97de2724d7ffdfdbe986a14d49366936187298/av1/encoder/pass2_strategy.c#1907
|
|
min_scene_len = 0 if min_scene_len is None else min_scene_len
|
|
|
|
keyframes = find_aom_keyframes(stat_file, min_scene_len)
|
|
|
|
return keyframes
|