<reponame>RPG3D/acl-ue4-plugin<filename>Tools/stat_parser.py
import multiprocessing
import numpy
import os
import platform
import queue
import time
import sys

# This script depends on a SJSON parsing package:
# https://pypi.python.org/pypi/SJSON/1.1.0
# https://shelter13.net/projects/SJSON/
# https://bitbucket.org/Anteru/sjson/src
import sjson


def parse_argv():
	options = {}
	options['stats'] = ""
	options['acl_stats'] = ""
	options['ue4_stats'] = ""
	options['dual_stat_inputs'] = False
	options['csv_summary'] = False
	options['csv_error'] = False
	options['csv_kr'] = False
	options['num_threads'] = 1

	for i in range(1, len(sys.argv)):
		value = sys.argv[i]

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-stats='):
			options['stats'] = value[7:].replace('"', '')

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-acl='):
			options['acl_stats'] = value[5:].replace('"', '')

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-ue4='):
			options['ue4_stats'] = value[5:].replace('"', '')

		if value == '-csv_summary':
			options['csv_summary'] = True

		if value == '-csv_error':
			options['csv_error'] = True

		if value == '-csv_kr':
			options['csv_kr'] = True

		if value.startswith('-parallel='):
			options['num_threads'] = int(value[len('-parallel='):].replace('"', ''))

	has_stats_dir = (not options['stats'] == None) and (not len(options['stats']) == 0)
	has_acl_stats_dir = (not options['acl_stats'] == None) and (not len(options['acl_stats']) == 0)
	has_ue4_stats_dir = (not options['ue4_stats'] == None) and (not len(options['ue4_stats']) == 0)
	if not has_stats_dir and not has_acl_stats_dir and not has_ue4_stats_dir:
		print('A stats input directory must be provided either with `-stats=` or with both `-acl=` and `-ue4=`')
		print_usage()
		sys.exit(1)

	if has_stats_dir and (has_acl_stats_dir or has_ue4_stats_dir):
		print('`-stats=` cannot be used with `-acl=` or `-ue4=`')
		print_usage()
		sys.exit(1)

	if not has_stats_dir and not (has_acl_stats_dir and has_ue4_stats_dir):
		print('Both `-acl=` and `-ue4=` must be provided together')
		print_usage()
		sys.exit(1)

	options['dual_stat_inputs'] = has_acl_stats_dir and has_ue4_stats_dir

	if has_acl_stats_dir and has_ue4_stats_dir:
		if not os.path.exists(options['acl_stats']) or not os.path.isdir(options['acl_stats']):
			print('ACL stats input directory not found: {}'.format(options['acl_stats']))
			print_usage()
			sys.exit(1)

		if not os.path.exists(options['ue4_stats']) or not os.path.isdir(options['ue4_stats']):
			print('UE4 stats input directory not found: {}'.format(options['ue4_stats']))
			print_usage()
			sys.exit(1)
	else:
		if not os.path.exists(options['stats']) or not os.path.isdir(options['stats']):
			print('Stats input directory not found: {}'.format(options['stats']))
			print_usage()
			sys.exit(1)

	if options['num_threads'] <= 0:
		print('-parallel switch argument must be greater than 0')
		print_usage()
		sys.exit(1)

	return options

def print_usage():
	print('Usage: python stat_parser.py [-stats=<path to input directory for stats>] [-acl=<path to acl stats>] [-ue4=<path to ue4 stats>] [-csv_summary] [-csv_error] [-csv_kr] [-parallel=<num threads>]')

def bytes_to_mb(size_in_bytes):
	return size_in_bytes / (1024.0 * 1024.0)

def bytes_to_kb(size_in_bytes):
	return size_in_bytes / 1024.0

def format_elapsed_time(elapsed_time):
	hours, rem = divmod(elapsed_time, 3600)
	minutes, seconds = divmod(rem, 60)
	return '{:0>2}h {:0>2}m {:05.2f}s'.format(int(hours), int(minutes), seconds)

def sanitize_csv_entry(entry):
	return entry.replace(', ', ' ').replace(',', '_')

def output_csv_summary(stat_dir, merged_stats):
	csv_filename = os.path.join(stat_dir, 'stats_summary.csv')
	print('Generating CSV file {} ...'.format(csv_filename))
	file = open(csv_filename, 'w')

	stat_acl, stat_auto = merged_stats[0]
	header = 'Clip Name, Raw Size'
	if 'ue4_auto' in stat_auto:
		header += ', Auto Size, Auto Ratio, Auto UE4 Error, Auto ACL Error'
	if 'ue4_acl' in stat_acl:
		header += ', ACL Size, ACL Ratio, ACL UE4 Error, ACL ACL Error'
	print(header, file = file)

	for (stat_acl, stat_auto) in merged_stats:
		clip_name = stat_acl['clip_name']
		raw_size = stat_acl['acl_raw_size']
		csv_line = '{}, {}'.format(clip_name, raw_size)

		if 'ue4_auto' in stat_auto:
			auto_size = stat_auto['ue4_auto']['compressed_size']
			auto_ratio = stat_auto['ue4_auto']['acl_compression_ratio']
			auto_ue4_error = stat_auto['ue4_auto']['ue4_max_error']
			auto_acl_error = stat_auto['ue4_auto']['acl_max_error']
			csv_line += ', {}, {}, {}, {}'.format(auto_size, auto_ratio, auto_ue4_error, auto_acl_error)

		if 'ue4_acl' in stat_acl:
			acl_size = stat_acl['ue4_acl']['compressed_size']
			acl_ratio = stat_acl['ue4_acl']['acl_compression_ratio']
			acl_ue4_error = stat_acl['ue4_acl']['ue4_max_error']
			acl_acl_error = stat_acl['ue4_acl']['acl_max_error']
			csv_line += ', {}, {}, {}, {}'.format(acl_size, acl_ratio, acl_ue4_error, acl_acl_error)

		print(csv_line, file = file)

	file.close()

def output_csv_error(stat_dir, merged_stats):
	stat_acl, stat_auto = merged_stats[0]
	if 'ue4_auto' in stat_auto and 'error_per_frame_and_bone' in stat_auto['ue4_auto']:
		csv_filename = os.path.join(stat_dir, 'stats_ue4_auto_error.csv')
		print('Generating CSV file {} ...'.format(csv_filename))
		file = open(csv_filename, 'w')

		print('Clip Name, Key Frame, Bone Index, Error', file = file)

		for (_, stat_auto) in merged_stats:
			name = stat_auto['clip_name']
			key_frame = 0
			for frame_errors in stat_auto['ue4_auto']['error_per_frame_and_bone']:
				bone_index = 0
				for bone_error in frame_errors:
					print('{}, {}, {}, {}'.format(name, key_frame, bone_index, bone_error), file = file)
					bone_index += 1

				key_frame += 1

		file.close()

	if 'ue4_acl' in stat_acl and 'error_per_frame_and_bone' in stat_acl['ue4_acl']:
		csv_filename = os.path.join(stat_dir, 'stats_ue4_acl_error.csv')
		print('Generating CSV file {} ...'.format(csv_filename))
		file = open(csv_filename, 'w')

		print('Clip Name, Key Frame, Bone Index, Error', file = file)

		for (stat_acl, _) in merged_stats:
			name = stat_acl['clip_name']
			key_frame = 0
			for frame_errors in stat_acl['ue4_acl']['error_per_frame_and_bone']:
				bone_index = 0
				for bone_error in frame_errors:
					print('{}, {}, {}, {}'.format(name, key_frame, bone_index, bone_error), file = file)
					bone_index += 1

				key_frame += 1

		file.close()

def output_csv_kr(stat_dir, clip_drop_rates, pose_drop_rates, track_drop_rates):
	csv_filename = os.path.join(stat_dir, 'stats_kr.csv')
	print('Generating CSV file {} ...'.format(csv_filename))
	file = open(csv_filename, 'w')

	print('Dropped Per Clip, Dropped Per Pose, Dropped Per Track', file = file)

	num_rows = max([len(clip_drop_rates), len(pose_drop_rates), len(track_drop_rates)])
	values = [('', '', '')] * num_rows

	for i in range(len(clip_drop_rates)):
		clip_rate = clip_drop_rates[i]
		_, pose_rate, track_rate = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for i in range(len(pose_drop_rates)):
		pose_rate = pose_drop_rates[i]
		clip_rate, _, track_rate = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for i in range(len(track_drop_rates)):
		track_rate = track_drop_rates[i]
		clip_rate, pose_rate, _ = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for (clip_rate, pose_rate, track_rate) in values:
		print('{}, {}, {}'.format(clip_rate, pose_rate, track_rate), file = file)

	file.close()

def print_progress(iteration, total, prefix='', suffix='', decimals = 1, bar_length = 40):
	# Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
	# With minor tweaks
	"""
	Call in a loop to create terminal progress bar
	@params:
		iteration   - Required  : current iteration (Int)
		total       - Required  : total iterations (Int)
		prefix      - Optional  : prefix string (Str)
		suffix      - Optional  : suffix string (Str)
		decimals    - Optional  : positive number of decimals in percent complete (Int)
		bar_length  - Optional  : character length of bar (Int)
	"""
	str_format = "{0:." + str(decimals) + "f}"
	percents = str_format.format(100 * (iteration / float(total)))
	filled_length = int(round(bar_length * iteration / float(total)))
	bar = '█' * filled_length + '-' * (bar_length - filled_length)

	# We need to clear any previous line we might have to ensure we have no visual artifacts
	# Note that if this function is called too quickly, the text might flicker
	terminal_width = 80
	sys.stdout.write('{}\r'.format(' ' * terminal_width))
	sys.stdout.flush()

	sys.stdout.write('%s |%s| %s%s %s\r' % (prefix, bar, percents, '%', suffix)),
	sys.stdout.flush()

	if iteration == total:
		sys.stdout.write('\n')

def append_stats(permutation, clip_stats, run_stats, aggregate_results):
	key = run_stats['desc']
	if not key in aggregate_results:
		run_total_stats = {}
		run_total_stats['desc'] = key
		run_total_stats['total_raw_size'] = 0
		run_total_stats['total_compressed_size'] = 0
		run_total_stats['total_compression_time'] = 0.0
		run_total_stats['acl_max_error'] = 0.0
		run_total_stats['ue4_max_error'] = 0.0
		run_total_stats['num_runs'] = 0
		aggregate_results[key] = run_total_stats

	run_total_stats = aggregate_results[key]
	run_total_stats['total_raw_size'] += clip_stats['acl_raw_size']
	run_total_stats['total_compressed_size'] += run_stats['compressed_size']
	run_total_stats['total_compression_time'] += run_stats['compression_time']
	run_total_stats['acl_max_error'] = max(run_stats['acl_max_error'], run_total_stats['acl_max_error'])
	run_total_stats['ue4_max_error'] = max(run_stats['ue4_max_error'], run_total_stats['ue4_max_error'])
	run_total_stats['num_runs'] += 1

	if not permutation in aggregate_results:
		permutation_stats = {}
		permutation_stats['total_raw_size'] = 0
		permutation_stats['total_compressed_size'] = 0
		permutation_stats['total_compression_time'] = 0.0
		permutation_stats['acl_max_error'] = 0.0
		permutation_stats['ue4_max_error'] = 0.0
		permutation_stats['num_runs'] = 0
		permutation_stats['worst_error'] = -1.0
		permutation_stats['worst_entry'] = None
		aggregate_results[permutation] = permutation_stats

	permutation_stats = aggregate_results[permutation]
	permutation_stats['total_raw_size'] += clip_stats['acl_raw_size']
	permutation_stats['total_compressed_size'] += run_stats['compressed_size']
	permutation_stats['total_compression_time'] += run_stats['compression_time']
	permutation_stats['acl_max_error'] = max(run_stats['acl_max_error'], permutation_stats['acl_max_error'])
	permutation_stats['ue4_max_error'] = max(run_stats['ue4_max_error'], permutation_stats['ue4_max_error'])
	permutation_stats['num_runs'] += 1
	if run_stats['acl_max_error'] > permutation_stats['worst_error']:
		permutation_stats['worst_error'] = run_stats['acl_max_error']
		permutation_stats['worst_entry'] = clip_stats

def do_parse_stats(options, stat_queue, result_queue):
	try:
		stats = []
		acl_error_values = []
		ue4_error_values = []
		ue4_keyreduction_data = {}
		ue4_keyreduction_data['drop_rates'] = []
		ue4_keyreduction_data['pose_drop_rates'] = []
		ue4_keyreduction_data['track_drop_rates'] = []
		acl_compression_times = []
		ue4_compression_times = []

		while True:
			stat_filename = stat_queue.get()
			if stat_filename is None:
				break

			if platform.system() == 'Windows':
				filename = '\\\\?\\{}'.format(stat_filename) # Long path prefix
			else:
				filename = stat_filename

			with open(filename, 'r') as file:
				try:
					file_data = sjson.loads(file.read())
					if 'error' in file_data:
						print('{} [{}]'.format(file_data['error'], stat_filename))
						continue

					file_data['filename'] = stat_filename
					file_data['clip_name'] = os.path.splitext(os.path.basename(stat_filename))[0].replace('_stats', '')

					if not options['csv_error']:
						# The sjson lib doesn't always return numbers as floats, sometimes as int but numpy doesn't like that
						if 'ue4_acl' in file_data and 'error_per_frame_and_bone' in file_data['ue4_acl']:
							for frame_error_values in file_data['ue4_acl']['error_per_frame_and_bone']:
								acl_error_values.extend([float(v) for v in frame_error_values])
							file_data['ue4_acl']['error_per_frame_and_bone'] = []

						if 'ue4_auto' in file_data and 'error_per_frame_and_bone' in file_data['ue4_auto']:
							for frame_error_values in file_data['ue4_auto']['error_per_frame_and_bone']:
								ue4_error_values.extend([float(v) for v in frame_error_values])
							file_data['ue4_auto']['error_per_frame_and_bone'] = []

					if 'ue4_acl' in file_data:
						acl_compression_times.append(file_data['ue4_acl']['compression_time'])

					if 'ue4_auto' in file_data:
						ue4_compression_times.append(file_data['ue4_auto']['compression_time'])

					if 'ue4_keyreduction' in file_data:
						num_animated_keys = float(file_data['ue4_keyreduction']['total_num_animated_keys'])
						if num_animated_keys > 2.001:
							drop_rate = float(file_data['ue4_keyreduction']['total_num_dropped_animated_keys']) / num_animated_keys
							ue4_keyreduction_data['drop_rates'].append(drop_rate)
							ue4_keyreduction_data['pose_drop_rates'].extend([float(v) for v in file_data['ue4_keyreduction']['dropped_pose_keys']])
							ue4_keyreduction_data['track_drop_rates'].extend([float(v) for v in file_data['ue4_keyreduction']['dropped_track_keys']])
						else:
							ue4_keyreduction_data['drop_rates'].append(0.0)
							ue4_keyreduction_data['pose_drop_rates'].extend([0.0 for v in file_data['ue4_keyreduction']['dropped_pose_keys']])
							ue4_keyreduction_data['track_drop_rates'].extend([0.0 for v in file_data['ue4_keyreduction']['dropped_track_keys']])

					stats.append(file_data)
				except sjson.ParseException:
					print('Failed to parse SJSON file: {}'.format(stat_filename))

			result_queue.put(('progress', stat_filename))

		results = {}
		results['stats'] = stats
		results['acl_error_values'] = acl_error_values
		results['ue4_error_values'] = ue4_error_values
		results['ue4_keyreduction_data'] = ue4_keyreduction_data
		results['acl_compression_times'] = acl_compression_times
		results['ue4_compression_times'] = ue4_compression_times

		result_queue.put(('done', results))
	except KeyboardInterrupt:
		print('Interrupted')

def parallel_parse_stats(options, stat_files, label):
	stat_queue = multiprocessing.Queue()
	for stat_filename in stat_files:
		stat_queue.put(stat_filename)

	# Add a marker to terminate the jobs
	for i in range(options['num_threads']):
		stat_queue.put(None)

	result_queue = multiprocessing.Queue()

	jobs = [ multiprocessing.Process(target = do_parse_stats, args = (options, stat_queue, result_queue)) for _i in range(options['num_threads']) ]
	for job in jobs:
		job.start()

	if options['dual_stat_inputs']:
		label = ' {}'.format(label)
	else:
		label = ''	# No need for a label if we parse both together

	num_stat_files = len(stat_files)
	num_stat_file_processed = 0
	stats = []
	print_progress(num_stat_file_processed, len(stat_files), 'Aggregating{} results:'.format(label), '{} / {}'.format(num_stat_file_processed, num_stat_files))
	try:
		while True:
			try:
				(msg, data) = result_queue.get(True, 1.0)
				if msg == 'progress':
					num_stat_file_processed += 1
					print_progress(num_stat_file_processed, len(stat_files), 'Aggregating{} results:'.format(label), '{} / {}'.format(num_stat_file_processed, num_stat_files))
				elif msg == 'done':
					stats.append(data)
			except queue.Empty:
				all_jobs_done = True
				for job in jobs:
					if job.is_alive():
						all_jobs_done = False

				if all_jobs_done:
					break
	except KeyboardInterrupt:
		sys.exit(1)

	return stats

def get_stat_files(options):
	if options['dual_stat_inputs']:
		acl_stat_files = []
		ue4_stat_files = []

		for (dirpath, dirnames, filenames) in os.walk(options['acl_stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				acl_stat_files.append(stat_filename)

		for (dirpath, dirnames, filenames) in os.walk(options['ue4_stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				ue4_stat_files.append(stat_filename)

		acl_file_set = set([os.path.basename(file) for file in acl_stat_files])
		ue4_file_set = set([os.path.basename(file) for file in ue4_stat_files])
		if len(acl_file_set.intersection(ue4_file_set)) != len(acl_stat_files):
			print('The input files for ACL and UE4 do not match, some are missing in one or the other')
			sys.exit(1)

		return (acl_stat_files, ue4_stat_files)
	else:
		stat_files = []

		for (dirpath, dirnames, filenames) in os.walk(options['stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				stat_files.append(stat_filename)

		return (stat_files, stat_files)

def percentile_rank(values, value):
	return (values < value).mean() * 100.0

if __name__ == "__main__":
	options = parse_argv()

	acl_stat_files, ue4_stat_files = get_stat_files(options)

	if len(acl_stat_files) == 0:
		print('No input clips found')
		sys.exit(0)

	aggregating_start_time = time.clock()

	acl_stats = parallel_parse_stats(options, acl_stat_files, 'ACL')
	if options['dual_stat_inputs']:
		ue4_stats = parallel_parse_stats(options, ue4_stat_files, 'UE4 Auto')
	else:
		ue4_stats = acl_stats

	acl_error_values = numpy.array([])
	acl_compression_times = numpy.array([])
	for result in acl_stats:
		acl_error_values = numpy.append(acl_error_values, result['acl_error_values'])
		acl_compression_times = numpy.append(acl_compression_times, result['acl_compression_times'])

	ue4_error_values = numpy.array([])
	ue4_compression_times = numpy.array([])
	for result in ue4_stats:
		ue4_error_values = numpy.append(ue4_error_values, result['ue4_error_values'])
		ue4_compression_times = numpy.append(ue4_compression_times, result['ue4_compression_times'])

	clip_drop_rates = numpy.array([])
	pose_drop_rates = numpy.array([])
	track_drop_rates = numpy.array([])
	for result in acl_stats:
		clip_drop_rates = numpy.append(clip_drop_rates, result['ue4_keyreduction_data']['drop_rates'])
		pose_drop_rates = numpy.append(pose_drop_rates, result['ue4_keyreduction_data']['pose_drop_rates'])
		track_drop_rates = numpy.append(track_drop_rates, result['ue4_keyreduction_data']['track_drop_rates'])
	clip_drop_rates = numpy.sort(clip_drop_rates)
	pose_drop_rates = numpy.sort(pose_drop_rates)
	track_drop_rates = numpy.sort(track_drop_rates)

	# Flatten our stats into a list and strip the error values
	acl_stats = [ stat for result in acl_stats for stat in result['stats'] ]
	ue4_stats = [ stat for result in ue4_stats for stat in result['stats'] ]

	# Sort out stats by clip name so we can zip them in pairs
	acl_stats.sort(key=lambda stat: stat['clip_name'])
	ue4_stats.sort(key=lambda stat: stat['clip_name'])

	merged_stats = list(zip(acl_stats, ue4_stats))

	aggregating_end_time = time.clock()
	print('Parsed stats in {}'.format(format_elapsed_time(aggregating_end_time - aggregating_start_time)))

	if options['csv_summary']:
		output_csv_summary(os.getcwd(), merged_stats)

	if options['csv_error']:
		output_csv_error(os.getcwd(), merged_stats)

	if options['csv_kr'] and len(clip_drop_rates) > 0:
		output_csv_kr(os.getcwd(), clip_drop_rates, pose_drop_rates, track_drop_rates)

	print()
	print('Stats per run type:')
	aggregate_results = {}
	num_acl_size_wins = 0
	num_acl_accuracy_wins = 0
	num_acl_speed_wins = 0
	num_acl_wins = 0
	num_acl_auto_wins = 0
	for (stat_acl, stat_auto) in merged_stats:
		if 'ue4_auto' in stat_auto:
			ue4_auto = stat_auto['ue4_auto']
			ue4_auto['desc'] = '{} {} {}'.format(ue4_auto['algorithm_name'], ue4_auto['rotation_format'], ue4_auto['translation_format'])
			append_stats('ue4_auto', stat_auto, ue4_auto, aggregate_results)

		if 'ue4_acl' in stat_acl:
			ue4_acl = stat_acl['ue4_acl']
			ue4_acl['desc'] = ue4_acl['algorithm_name']
			append_stats('ue4_acl', stat_acl, ue4_acl, aggregate_results)

		if 'ue4_keyreduction' in stat_acl:
			ue4_keyreduction = stat_acl['ue4_keyreduction']
			ue4_keyreduction['desc'] = ue4_keyreduction['algorithm_name']
			append_stats('ue4_keyreduction', stat_acl, ue4_keyreduction, aggregate_results)

		if 'ue4_auto' in stat_auto and 'ue4_acl' in stat_acl:
			ue4_auto = stat_auto['ue4_auto']
			ue4_acl = stat_acl['ue4_acl']
			if ue4_acl['compressed_size'] < ue4_auto['compressed_size']:
				num_acl_size_wins += 1
			if ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error']:
				num_acl_accuracy_wins += 1
			if ue4_acl['compression_time'] < ue4_auto['compression_time']:
				num_acl_speed_wins += 1
			if ue4_acl['compressed_size'] < ue4_auto['compressed_size'] and ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error'] and ue4_acl['compression_time'] < ue4_auto['compression_time']:
				num_acl_wins += 1

			lowers_error = ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error'];
			saved_size = int(ue4_auto['compressed_size']) - int(ue4_acl['compressed_size'])
			lowers_size = ue4_acl['compressed_size'] < ue4_auto['compressed_size'];
			error_under_threshold = float(ue4_acl['ue4_max_error']) <= 0.1;

			# keep it if it we want to force the error below the threshold and it reduces error
			# or if has an acceptable error and saves space
			# or if saves the same amount and an acceptable error that is lower than the previous best
			reduces_error_below_threshold = lowers_error and error_under_threshold;
			has_acceptable_error_and_saves_space = error_under_threshold and saved_size > 0;
			lowers_error_and_saves_same_or_better = error_under_threshold and lowers_error and saved_size >= 0;
			if reduces_error_below_threshold or has_acceptable_error_and_saves_space or lowers_error_and_saves_same_or_better:
				num_acl_auto_wins += 1

	print()
	raw_size = 0.0
	if 'ue4_auto' in aggregate_results:
		ue4_auto = aggregate_results['ue4_auto']
		raw_size = ue4_auto['total_raw_size']
		ratio = float(ue4_auto['total_raw_size']) / float(ue4_auto['total_compressed_size'])
		print('Total Automatic Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_auto['total_compressed_size']), format_elapsed_time(ue4_auto['total_compression_time']), ratio, ue4_auto['ue4_max_error'], ue4_auto['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_auto['worst_entry']['clip_name'], ue4_auto['worst_entry']['ue4_auto']['acl_compression_ratio'], ue4_auto['worst_entry']['ue4_auto']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_auto['total_compression_time']))
		print('Compression time 50, 85, 99th percentile: {:.3f}, {:.3f}, {:.3f} seconds'.format(numpy.percentile(ue4_compression_times, 50.0), numpy.percentile(ue4_compression_times, 85.0), numpy.percentile(ue4_compression_times, 99.0)))
		if len(ue4_error_values) > 0:
			print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(ue4_error_values, 99.0)))
			print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(ue4_error_values, 0.01)))
		print()

	if 'ue4_acl' in aggregate_results:
		ue4_acl = aggregate_results['ue4_acl']
		raw_size = ue4_acl['total_raw_size']
		ratio = float(ue4_acl['total_raw_size']) / float(ue4_acl['total_compressed_size'])
		print('Total ACL Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_acl['total_compressed_size']), format_elapsed_time(ue4_acl['total_compression_time']), ratio, ue4_acl['ue4_max_error'], ue4_acl['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_acl['worst_entry']['clip_name'], ue4_acl['worst_entry']['ue4_acl']['acl_compression_ratio'], ue4_acl['worst_entry']['ue4_acl']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_acl['total_compression_time']))
		print('Compression time 50, 85, 99th percentile: {:.3f}, {:.3f}, {:.3f} seconds'.format(numpy.percentile(acl_compression_times, 50.0), numpy.percentile(acl_compression_times, 85.0), numpy.percentile(acl_compression_times, 99.0)))
		if len(acl_error_values) > 0:
			print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(acl_error_values, 99.0)))
			print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(acl_error_values, 0.01)))
		print()

	if 'ue4_keyreduction' in aggregate_results:
		ue4_keyreduction = aggregate_results['ue4_keyreduction']
		raw_size = ue4_keyreduction['total_raw_size']
		ratio = float(ue4_keyreduction['total_raw_size']) / float(ue4_keyreduction['total_compressed_size'])
		print('Total Key Reduction Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_keyreduction['total_compressed_size']), format_elapsed_time(ue4_keyreduction['total_compression_time']), ratio, ue4_keyreduction['ue4_max_error'], ue4_keyreduction['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_keyreduction['worst_entry']['clip_name'], ue4_keyreduction['worst_entry']['ue4_keyreduction']['acl_compression_ratio'], ue4_keyreduction['worst_entry']['ue4_keyreduction']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_keyreduction['total_compression_time']))
		#print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(acl_error_values, 99.0)))
		#print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(acl_error_values, 0.01)))
		print()

	num_clips = float(len(acl_stat_files))
	print('Raw size: {:.2f} MB'.format(bytes_to_mb(raw_size)))
	print('ACL was smaller for {} clips ({:.2f} %)'.format(num_acl_size_wins, float(num_acl_size_wins) / num_clips * 100.0))
	print('ACL was more accurate for {} clips ({:.2f} %)'.format(num_acl_accuracy_wins, float(num_acl_accuracy_wins) / num_clips * 100.0))
	print('ACL has faster compression for {} clips ({:.2f} %)'.format(num_acl_speed_wins, float(num_acl_speed_wins) / num_clips * 100.0))
	print('ACL was smaller, better, faster for {} clips ({:.2f} %)'.format(num_acl_wins, float(num_acl_wins) / num_clips * 100.0))
	print('ACL won with simulated auto {} clips ({:.2f} %)'.format(num_acl_auto_wins, float(num_acl_auto_wins) / num_clips * 100.0))

	if len(clip_drop_rates) > 0:
		print()
		print('Key reduction clip avg drop rate: {:.2f} %'.format(numpy.average(clip_drop_rates) * 100.0))
		print('Key reduction clip 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(clip_drop_rates, 50.0) * 100.0))
		print('Key reduction clip 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(clip_drop_rates, 90.0) * 100.0))
		print('Key reduction pose avg drop rate: {:.2f} %'.format(numpy.average(pose_drop_rates) * 100.0))
		print('Key reduction pose 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(pose_drop_rates, 50.0) * 100.0))
		print('Key reduction pose 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(pose_drop_rates, 90.0) * 100.0))
		print('Key reduction track avg drop rate: {:.2f} %'.format(numpy.average(track_drop_rates) * 100.0))
		print('Key reduction track 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(track_drop_rates, 50.0) * 100.0))
		print('Key reduction track 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(track_drop_rates, 90.0) * 100.0))

import multiprocessing
import numpy
import os
import platform
import queue
import time
import sys

# This script depends on a SJSON parsing package:
# https://pypi.python.org/pypi/SJSON/1.1.0
# https://shelter13.net/projects/SJSON/
# https://bitbucket.org/Anteru/sjson/src
import sjson


def parse_argv():
	options = {}
	options['stats'] = ""
	options['acl_stats'] = ""
	options['ue4_stats'] = ""
	options['dual_stat_inputs'] = False
	options['csv_summary'] = False
	options['csv_error'] = False
	options['csv_kr'] = False
	options['num_threads'] = 1

	for i in range(1, len(sys.argv)):
		value = sys.argv[i]

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-stats='):
			options['stats'] = value[7:].replace('"', '')

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-acl='):
			options['acl_stats'] = value[5:].replace('"', '')

		# TODO: Strip trailing '/' or '\'
		if value.startswith('-ue4='):
			options['ue4_stats'] = value[5:].replace('"', '')

		if value == '-csv_summary':
			options['csv_summary'] = True

		if value == '-csv_error':
			options['csv_error'] = True

		if value == '-csv_kr':
			options['csv_kr'] = True

		if value.startswith('-parallel='):
			options['num_threads'] = int(value[len('-parallel='):].replace('"', ''))

	has_stats_dir = (not options['stats'] == None) and (not len(options['stats']) == 0)
	has_acl_stats_dir = (not options['acl_stats'] == None) and (not len(options['acl_stats']) == 0)
	has_ue4_stats_dir = (not options['ue4_stats'] == None) and (not len(options['ue4_stats']) == 0)
	if not has_stats_dir and not has_acl_stats_dir and not has_ue4_stats_dir:
		print('A stats input directory must be provided either with `-stats=` or with both `-acl=` and `-ue4=`')
		print_usage()
		sys.exit(1)

	if has_stats_dir and (has_acl_stats_dir or has_ue4_stats_dir):
		print('`-stats=` cannot be used with `-acl=` or `-ue4=`')
		print_usage()
		sys.exit(1)

	if not has_stats_dir and not (has_acl_stats_dir and has_ue4_stats_dir):
		print('Both `-acl=` and `-ue4=` must be provided together')
		print_usage()
		sys.exit(1)

	options['dual_stat_inputs'] = has_acl_stats_dir and has_ue4_stats_dir

	if has_acl_stats_dir and has_ue4_stats_dir:
		if not os.path.exists(options['acl_stats']) or not os.path.isdir(options['acl_stats']):
			print('ACL stats input directory not found: {}'.format(options['acl_stats']))
			print_usage()
			sys.exit(1)

		if not os.path.exists(options['ue4_stats']) or not os.path.isdir(options['ue4_stats']):
			print('UE4 stats input directory not found: {}'.format(options['ue4_stats']))
			print_usage()
			sys.exit(1)
	else:
		if not os.path.exists(options['stats']) or not os.path.isdir(options['stats']):
			print('Stats input directory not found: {}'.format(options['stats']))
			print_usage()
			sys.exit(1)

	if options['num_threads'] <= 0:
		print('-parallel switch argument must be greater than 0')
		print_usage()
		sys.exit(1)

	return options

def print_usage():
	print('Usage: python stat_parser.py [-stats=<path to input directory for stats>] [-acl=<path to acl stats>] [-ue4=<path to ue4 stats>] [-csv_summary] [-csv_error] [-csv_kr] [-parallel=<num threads>]')

def bytes_to_mb(size_in_bytes):
	return size_in_bytes / (1024.0 * 1024.0)

def bytes_to_kb(size_in_bytes):
	return size_in_bytes / 1024.0

def format_elapsed_time(elapsed_time):
	hours, rem = divmod(elapsed_time, 3600)
	minutes, seconds = divmod(rem, 60)
	return '{:0>2}h {:0>2}m {:05.2f}s'.format(int(hours), int(minutes), seconds)

def sanitize_csv_entry(entry):
	return entry.replace(', ', ' ').replace(',', '_')

def output_csv_summary(stat_dir, merged_stats):
	csv_filename = os.path.join(stat_dir, 'stats_summary.csv')
	print('Generating CSV file {} ...'.format(csv_filename))
	file = open(csv_filename, 'w')

	stat_acl, stat_auto = merged_stats[0]
	header = 'Clip Name, Raw Size'
	if 'ue4_auto' in stat_auto:
		header += ', Auto Size, Auto Ratio, Auto UE4 Error, Auto ACL Error'
	if 'ue4_acl' in stat_acl:
		header += ', ACL Size, ACL Ratio, ACL UE4 Error, ACL ACL Error'
	print(header, file = file)

	for (stat_acl, stat_auto) in merged_stats:
		clip_name = stat_acl['clip_name']
		raw_size = stat_acl['acl_raw_size']
		csv_line = '{}, {}'.format(clip_name, raw_size)

		if 'ue4_auto' in stat_auto:
			auto_size = stat_auto['ue4_auto']['compressed_size']
			auto_ratio = stat_auto['ue4_auto']['acl_compression_ratio']
			auto_ue4_error = stat_auto['ue4_auto']['ue4_max_error']
			auto_acl_error = stat_auto['ue4_auto']['acl_max_error']
			csv_line += ', {}, {}, {}, {}'.format(auto_size, auto_ratio, auto_ue4_error, auto_acl_error)

		if 'ue4_acl' in stat_acl:
			acl_size = stat_acl['ue4_acl']['compressed_size']
			acl_ratio = stat_acl['ue4_acl']['acl_compression_ratio']
			acl_ue4_error = stat_acl['ue4_acl']['ue4_max_error']
			acl_acl_error = stat_acl['ue4_acl']['acl_max_error']
			csv_line += ', {}, {}, {}, {}'.format(acl_size, acl_ratio, acl_ue4_error, acl_acl_error)

		print(csv_line, file = file)

	file.close()

def output_csv_error(stat_dir, merged_stats):
	stat_acl, stat_auto = merged_stats[0]
	if 'ue4_auto' in stat_auto and 'error_per_frame_and_bone' in stat_auto['ue4_auto']:
		csv_filename = os.path.join(stat_dir, 'stats_ue4_auto_error.csv')
		print('Generating CSV file {} ...'.format(csv_filename))
		file = open(csv_filename, 'w')

		print('Clip Name, Key Frame, Bone Index, Error', file = file)

		for (_, stat_auto) in merged_stats:
			name = stat_auto['clip_name']
			key_frame = 0
			for frame_errors in stat_auto['ue4_auto']['error_per_frame_and_bone']:
				bone_index = 0
				for bone_error in frame_errors:
					print('{}, {}, {}, {}'.format(name, key_frame, bone_index, bone_error), file = file)
					bone_index += 1

				key_frame += 1

		file.close()

	if 'ue4_acl' in stat_acl and 'error_per_frame_and_bone' in stat_acl['ue4_acl']:
		csv_filename = os.path.join(stat_dir, 'stats_ue4_acl_error.csv')
		print('Generating CSV file {} ...'.format(csv_filename))
		file = open(csv_filename, 'w')

		print('Clip Name, Key Frame, Bone Index, Error', file = file)

		for (stat_acl, _) in merged_stats:
			name = stat_acl['clip_name']
			key_frame = 0
			for frame_errors in stat_acl['ue4_acl']['error_per_frame_and_bone']:
				bone_index = 0
				for bone_error in frame_errors:
					print('{}, {}, {}, {}'.format(name, key_frame, bone_index, bone_error), file = file)
					bone_index += 1

				key_frame += 1

		file.close()

def output_csv_kr(stat_dir, clip_drop_rates, pose_drop_rates, track_drop_rates):
	csv_filename = os.path.join(stat_dir, 'stats_kr.csv')
	print('Generating CSV file {} ...'.format(csv_filename))
	file = open(csv_filename, 'w')

	print('Dropped Per Clip, Dropped Per Pose, Dropped Per Track', file = file)

	num_rows = max([len(clip_drop_rates), len(pose_drop_rates), len(track_drop_rates)])
	values = [('', '', '')] * num_rows

	for i in range(len(clip_drop_rates)):
		clip_rate = clip_drop_rates[i]
		_, pose_rate, track_rate = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for i in range(len(pose_drop_rates)):
		pose_rate = pose_drop_rates[i]
		clip_rate, _, track_rate = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for i in range(len(track_drop_rates)):
		track_rate = track_drop_rates[i]
		clip_rate, pose_rate, _ = values[i]
		values[i] = (clip_rate, pose_rate, track_rate)

	for (clip_rate, pose_rate, track_rate) in values:
		print('{}, {}, {}'.format(clip_rate, pose_rate, track_rate), file = file)

	file.close()

def print_progress(iteration, total, prefix='', suffix='', decimals = 1, bar_length = 40):
	# Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
	# With minor tweaks
	"""
	Call in a loop to create terminal progress bar
	@params:
		iteration   - Required  : current iteration (Int)
		total       - Required  : total iterations (Int)
		prefix      - Optional  : prefix string (Str)
		suffix      - Optional  : suffix string (Str)
		decimals    - Optional  : positive number of decimals in percent complete (Int)
		bar_length  - Optional  : character length of bar (Int)
	"""
	str_format = "{0:." + str(decimals) + "f}"
	percents = str_format.format(100 * (iteration / float(total)))
	filled_length = int(round(bar_length * iteration / float(total)))
	bar = '█' * filled_length + '-' * (bar_length - filled_length)

	# We need to clear any previous line we might have to ensure we have no visual artifacts
	# Note that if this function is called too quickly, the text might flicker
	terminal_width = 80
	sys.stdout.write('{}\r'.format(' ' * terminal_width))
	sys.stdout.flush()

	sys.stdout.write('%s |%s| %s%s %s\r' % (prefix, bar, percents, '%', suffix)),
	sys.stdout.flush()

	if iteration == total:
		sys.stdout.write('\n')

def append_stats(permutation, clip_stats, run_stats, aggregate_results):
	key = run_stats['desc']
	if not key in aggregate_results:
		run_total_stats = {}
		run_total_stats['desc'] = key
		run_total_stats['total_raw_size'] = 0
		run_total_stats['total_compressed_size'] = 0
		run_total_stats['total_compression_time'] = 0.0
		run_total_stats['acl_max_error'] = 0.0
		run_total_stats['ue4_max_error'] = 0.0
		run_total_stats['num_runs'] = 0
		aggregate_results[key] = run_total_stats

	run_total_stats = aggregate_results[key]
	run_total_stats['total_raw_size'] += clip_stats['acl_raw_size']
	run_total_stats['total_compressed_size'] += run_stats['compressed_size']
	run_total_stats['total_compression_time'] += run_stats['compression_time']
	run_total_stats['acl_max_error'] = max(run_stats['acl_max_error'], run_total_stats['acl_max_error'])
	run_total_stats['ue4_max_error'] = max(run_stats['ue4_max_error'], run_total_stats['ue4_max_error'])
	run_total_stats['num_runs'] += 1

	if not permutation in aggregate_results:
		permutation_stats = {}
		permutation_stats['total_raw_size'] = 0
		permutation_stats['total_compressed_size'] = 0
		permutation_stats['total_compression_time'] = 0.0
		permutation_stats['acl_max_error'] = 0.0
		permutation_stats['ue4_max_error'] = 0.0
		permutation_stats['num_runs'] = 0
		permutation_stats['worst_error'] = -1.0
		permutation_stats['worst_entry'] = None
		aggregate_results[permutation] = permutation_stats

	permutation_stats = aggregate_results[permutation]
	permutation_stats['total_raw_size'] += clip_stats['acl_raw_size']
	permutation_stats['total_compressed_size'] += run_stats['compressed_size']
	permutation_stats['total_compression_time'] += run_stats['compression_time']
	permutation_stats['acl_max_error'] = max(run_stats['acl_max_error'], permutation_stats['acl_max_error'])
	permutation_stats['ue4_max_error'] = max(run_stats['ue4_max_error'], permutation_stats['ue4_max_error'])
	permutation_stats['num_runs'] += 1
	if run_stats['acl_max_error'] > permutation_stats['worst_error']:
		permutation_stats['worst_error'] = run_stats['acl_max_error']
		permutation_stats['worst_entry'] = clip_stats

def do_parse_stats(options, stat_queue, result_queue):
	try:
		stats = []
		acl_error_values = []
		ue4_error_values = []
		ue4_keyreduction_data = {}
		ue4_keyreduction_data['drop_rates'] = []
		ue4_keyreduction_data['pose_drop_rates'] = []
		ue4_keyreduction_data['track_drop_rates'] = []
		acl_compression_times = []
		ue4_compression_times = []

		while True:
			stat_filename = stat_queue.get()
			if stat_filename is None:
				break

			if platform.system() == 'Windows':
				filename = '\\\\?\\{}'.format(stat_filename) # Long path prefix
			else:
				filename = stat_filename

			with open(filename, 'r') as file:
				try:
					file_data = sjson.loads(file.read())
					if 'error' in file_data:
						print('{} [{}]'.format(file_data['error'], stat_filename))
						continue

					file_data['filename'] = stat_filename
					file_data['clip_name'] = os.path.splitext(os.path.basename(stat_filename))[0].replace('_stats', '')

					if not options['csv_error']:
						# The sjson lib doesn't always return numbers as floats, sometimes as int but numpy doesn't like that
						if 'ue4_acl' in file_data and 'error_per_frame_and_bone' in file_data['ue4_acl']:
							for frame_error_values in file_data['ue4_acl']['error_per_frame_and_bone']:
								acl_error_values.extend([float(v) for v in frame_error_values])
							file_data['ue4_acl']['error_per_frame_and_bone'] = []

						if 'ue4_auto' in file_data and 'error_per_frame_and_bone' in file_data['ue4_auto']:
							for frame_error_values in file_data['ue4_auto']['error_per_frame_and_bone']:
								ue4_error_values.extend([float(v) for v in frame_error_values])
							file_data['ue4_auto']['error_per_frame_and_bone'] = []

					if 'ue4_acl' in file_data:
						acl_compression_times.append(file_data['ue4_acl']['compression_time'])

					if 'ue4_auto' in file_data:
						ue4_compression_times.append(file_data['ue4_auto']['compression_time'])

					if 'ue4_keyreduction' in file_data:
						num_animated_keys = float(file_data['ue4_keyreduction']['total_num_animated_keys'])
						if num_animated_keys > 2.001:
							drop_rate = float(file_data['ue4_keyreduction']['total_num_dropped_animated_keys']) / num_animated_keys
							ue4_keyreduction_data['drop_rates'].append(drop_rate)
							ue4_keyreduction_data['pose_drop_rates'].extend([float(v) for v in file_data['ue4_keyreduction']['dropped_pose_keys']])
							ue4_keyreduction_data['track_drop_rates'].extend([float(v) for v in file_data['ue4_keyreduction']['dropped_track_keys']])
						else:
							ue4_keyreduction_data['drop_rates'].append(0.0)
							ue4_keyreduction_data['pose_drop_rates'].extend([0.0 for v in file_data['ue4_keyreduction']['dropped_pose_keys']])
							ue4_keyreduction_data['track_drop_rates'].extend([0.0 for v in file_data['ue4_keyreduction']['dropped_track_keys']])

					stats.append(file_data)
				except sjson.ParseException:
					print('Failed to parse SJSON file: {}'.format(stat_filename))

			result_queue.put(('progress', stat_filename))

		results = {}
		results['stats'] = stats
		results['acl_error_values'] = acl_error_values
		results['ue4_error_values'] = ue4_error_values
		results['ue4_keyreduction_data'] = ue4_keyreduction_data
		results['acl_compression_times'] = acl_compression_times
		results['ue4_compression_times'] = ue4_compression_times

		result_queue.put(('done', results))
	except KeyboardInterrupt:
		print('Interrupted')

def parallel_parse_stats(options, stat_files, label):
	stat_queue = multiprocessing.Queue()
	for stat_filename in stat_files:
		stat_queue.put(stat_filename)

	# Add a marker to terminate the jobs
	for i in range(options['num_threads']):
		stat_queue.put(None)

	result_queue = multiprocessing.Queue()

	jobs = [ multiprocessing.Process(target = do_parse_stats, args = (options, stat_queue, result_queue)) for _i in range(options['num_threads']) ]
	for job in jobs:
		job.start()

	if options['dual_stat_inputs']:
		label = ' {}'.format(label)
	else:
		label = ''	# No need for a label if we parse both together

	num_stat_files = len(stat_files)
	num_stat_file_processed = 0
	stats = []
	print_progress(num_stat_file_processed, len(stat_files), 'Aggregating{} results:'.format(label), '{} / {}'.format(num_stat_file_processed, num_stat_files))
	try:
		while True:
			try:
				(msg, data) = result_queue.get(True, 1.0)
				if msg == 'progress':
					num_stat_file_processed += 1
					print_progress(num_stat_file_processed, len(stat_files), 'Aggregating{} results:'.format(label), '{} / {}'.format(num_stat_file_processed, num_stat_files))
				elif msg == 'done':
					stats.append(data)
			except queue.Empty:
				all_jobs_done = True
				for job in jobs:
					if job.is_alive():
						all_jobs_done = False

				if all_jobs_done:
					break
	except KeyboardInterrupt:
		sys.exit(1)

	return stats

def get_stat_files(options):
	if options['dual_stat_inputs']:
		acl_stat_files = []
		ue4_stat_files = []

		for (dirpath, dirnames, filenames) in os.walk(options['acl_stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				acl_stat_files.append(stat_filename)

		for (dirpath, dirnames, filenames) in os.walk(options['ue4_stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				ue4_stat_files.append(stat_filename)

		acl_file_set = set([os.path.basename(file) for file in acl_stat_files])
		ue4_file_set = set([os.path.basename(file) for file in ue4_stat_files])
		if len(acl_file_set.intersection(ue4_file_set)) != len(acl_stat_files):
			print('The input files for ACL and UE4 do not match, some are missing in one or the other')
			sys.exit(1)

		return (acl_stat_files, ue4_stat_files)
	else:
		stat_files = []

		for (dirpath, dirnames, filenames) in os.walk(options['stats']):
			for filename in filenames:
				if not filename.endswith('.sjson'):
					continue

				stat_filename = os.path.join(dirpath, filename)
				stat_files.append(stat_filename)

		return (stat_files, stat_files)

def percentile_rank(values, value):
	return (values < value).mean() * 100.0

if __name__ == "__main__":
	options = parse_argv()

	acl_stat_files, ue4_stat_files = get_stat_files(options)

	if len(acl_stat_files) == 0:
		print('No input clips found')
		sys.exit(0)

	aggregating_start_time = time.clock()

	acl_stats = parallel_parse_stats(options, acl_stat_files, 'ACL')
	if options['dual_stat_inputs']:
		ue4_stats = parallel_parse_stats(options, ue4_stat_files, 'UE4 Auto')
	else:
		ue4_stats = acl_stats

	acl_error_values = numpy.array([])
	acl_compression_times = numpy.array([])
	for result in acl_stats:
		acl_error_values = numpy.append(acl_error_values, result['acl_error_values'])
		acl_compression_times = numpy.append(acl_compression_times, result['acl_compression_times'])

	ue4_error_values = numpy.array([])
	ue4_compression_times = numpy.array([])
	for result in ue4_stats:
		ue4_error_values = numpy.append(ue4_error_values, result['ue4_error_values'])
		ue4_compression_times = numpy.append(ue4_compression_times, result['ue4_compression_times'])

	clip_drop_rates = numpy.array([])
	pose_drop_rates = numpy.array([])
	track_drop_rates = numpy.array([])
	for result in acl_stats:
		clip_drop_rates = numpy.append(clip_drop_rates, result['ue4_keyreduction_data']['drop_rates'])
		pose_drop_rates = numpy.append(pose_drop_rates, result['ue4_keyreduction_data']['pose_drop_rates'])
		track_drop_rates = numpy.append(track_drop_rates, result['ue4_keyreduction_data']['track_drop_rates'])
	clip_drop_rates = numpy.sort(clip_drop_rates)
	pose_drop_rates = numpy.sort(pose_drop_rates)
	track_drop_rates = numpy.sort(track_drop_rates)

	# Flatten our stats into a list and strip the error values
	acl_stats = [ stat for result in acl_stats for stat in result['stats'] ]
	ue4_stats = [ stat for result in ue4_stats for stat in result['stats'] ]

	# Sort out stats by clip name so we can zip them in pairs
	acl_stats.sort(key=lambda stat: stat['clip_name'])
	ue4_stats.sort(key=lambda stat: stat['clip_name'])

	merged_stats = list(zip(acl_stats, ue4_stats))

	aggregating_end_time = time.clock()
	print('Parsed stats in {}'.format(format_elapsed_time(aggregating_end_time - aggregating_start_time)))

	if options['csv_summary']:
		output_csv_summary(os.getcwd(), merged_stats)

	if options['csv_error']:
		output_csv_error(os.getcwd(), merged_stats)

	if options['csv_kr'] and len(clip_drop_rates) > 0:
		output_csv_kr(os.getcwd(), clip_drop_rates, pose_drop_rates, track_drop_rates)

	print()
	print('Stats per run type:')
	aggregate_results = {}
	num_acl_size_wins = 0
	num_acl_accuracy_wins = 0
	num_acl_speed_wins = 0
	num_acl_wins = 0
	num_acl_auto_wins = 0
	for (stat_acl, stat_auto) in merged_stats:
		if 'ue4_auto' in stat_auto:
			ue4_auto = stat_auto['ue4_auto']
			ue4_auto['desc'] = '{} {} {}'.format(ue4_auto['algorithm_name'], ue4_auto['rotation_format'], ue4_auto['translation_format'])
			append_stats('ue4_auto', stat_auto, ue4_auto, aggregate_results)

		if 'ue4_acl' in stat_acl:
			ue4_acl = stat_acl['ue4_acl']
			ue4_acl['desc'] = ue4_acl['algorithm_name']
			append_stats('ue4_acl', stat_acl, ue4_acl, aggregate_results)

		if 'ue4_keyreduction' in stat_acl:
			ue4_keyreduction = stat_acl['ue4_keyreduction']
			ue4_keyreduction['desc'] = ue4_keyreduction['algorithm_name']
			append_stats('ue4_keyreduction', stat_acl, ue4_keyreduction, aggregate_results)

		if 'ue4_auto' in stat_auto and 'ue4_acl' in stat_acl:
			ue4_auto = stat_auto['ue4_auto']
			ue4_acl = stat_acl['ue4_acl']
			if ue4_acl['compressed_size'] < ue4_auto['compressed_size']:
				num_acl_size_wins += 1
			if ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error']:
				num_acl_accuracy_wins += 1
			if ue4_acl['compression_time'] < ue4_auto['compression_time']:
				num_acl_speed_wins += 1
			if ue4_acl['compressed_size'] < ue4_auto['compressed_size'] and ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error'] and ue4_acl['compression_time'] < ue4_auto['compression_time']:
				num_acl_wins += 1

			lowers_error = ue4_acl['ue4_max_error'] < ue4_auto['ue4_max_error'];
			saved_size = int(ue4_auto['compressed_size']) - int(ue4_acl['compressed_size'])
			lowers_size = ue4_acl['compressed_size'] < ue4_auto['compressed_size'];
			error_under_threshold = float(ue4_acl['ue4_max_error']) <= 0.1;

			# keep it if it we want to force the error below the threshold and it reduces error
			# or if has an acceptable error and saves space
			# or if saves the same amount and an acceptable error that is lower than the previous best
			reduces_error_below_threshold = lowers_error and error_under_threshold;
			has_acceptable_error_and_saves_space = error_under_threshold and saved_size > 0;
			lowers_error_and_saves_same_or_better = error_under_threshold and lowers_error and saved_size >= 0;
			if reduces_error_below_threshold or has_acceptable_error_and_saves_space or lowers_error_and_saves_same_or_better:
				num_acl_auto_wins += 1

	print()
	raw_size = 0.0
	if 'ue4_auto' in aggregate_results:
		ue4_auto = aggregate_results['ue4_auto']
		raw_size = ue4_auto['total_raw_size']
		ratio = float(ue4_auto['total_raw_size']) / float(ue4_auto['total_compressed_size'])
		print('Total Automatic Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_auto['total_compressed_size']), format_elapsed_time(ue4_auto['total_compression_time']), ratio, ue4_auto['ue4_max_error'], ue4_auto['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_auto['worst_entry']['clip_name'], ue4_auto['worst_entry']['ue4_auto']['acl_compression_ratio'], ue4_auto['worst_entry']['ue4_auto']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_auto['total_compression_time']))
		print('Compression time 50, 85, 99th percentile: {:.3f}, {:.3f}, {:.3f} seconds'.format(numpy.percentile(ue4_compression_times, 50.0), numpy.percentile(ue4_compression_times, 85.0), numpy.percentile(ue4_compression_times, 99.0)))
		if len(ue4_error_values) > 0:
			print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(ue4_error_values, 99.0)))
			print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(ue4_error_values, 0.01)))
		print()

	if 'ue4_acl' in aggregate_results:
		ue4_acl = aggregate_results['ue4_acl']
		raw_size = ue4_acl['total_raw_size']
		ratio = float(ue4_acl['total_raw_size']) / float(ue4_acl['total_compressed_size'])
		print('Total ACL Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_acl['total_compressed_size']), format_elapsed_time(ue4_acl['total_compression_time']), ratio, ue4_acl['ue4_max_error'], ue4_acl['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_acl['worst_entry']['clip_name'], ue4_acl['worst_entry']['ue4_acl']['acl_compression_ratio'], ue4_acl['worst_entry']['ue4_acl']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_acl['total_compression_time']))
		print('Compression time 50, 85, 99th percentile: {:.3f}, {:.3f}, {:.3f} seconds'.format(numpy.percentile(acl_compression_times, 50.0), numpy.percentile(acl_compression_times, 85.0), numpy.percentile(acl_compression_times, 99.0)))
		if len(acl_error_values) > 0:
			print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(acl_error_values, 99.0)))
			print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(acl_error_values, 0.01)))
		print()

	if 'ue4_keyreduction' in aggregate_results:
		ue4_keyreduction = aggregate_results['ue4_keyreduction']
		raw_size = ue4_keyreduction['total_raw_size']
		ratio = float(ue4_keyreduction['total_raw_size']) / float(ue4_keyreduction['total_compressed_size'])
		print('Total Key Reduction Compression:')
		print('Compressed {:.2f} MB, Elapsed {}, Ratio [{:.2f} : 1], Max error [UE4: {:.4f}, ACL: {:.4f}]'.format(bytes_to_mb(ue4_keyreduction['total_compressed_size']), format_elapsed_time(ue4_keyreduction['total_compression_time']), ratio, ue4_keyreduction['ue4_max_error'], ue4_keyreduction['acl_max_error']))
		print('Least accurate: {} Ratio: {:.2f}, Error: {:.4f}'.format(ue4_keyreduction['worst_entry']['clip_name'], ue4_keyreduction['worst_entry']['ue4_keyreduction']['acl_compression_ratio'], ue4_keyreduction['worst_entry']['ue4_keyreduction']['acl_max_error']))
		print('Compression speed: {:.2f} KB/sec'.format(bytes_to_kb(raw_size) / ue4_keyreduction['total_compression_time']))
		#print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(acl_error_values, 99.0)))
		#print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(acl_error_values, 0.01)))
		print()

	num_clips = float(len(acl_stat_files))
	print('Raw size: {:.2f} MB'.format(bytes_to_mb(raw_size)))
	print('ACL was smaller for {} clips ({:.2f} %)'.format(num_acl_size_wins, float(num_acl_size_wins) / num_clips * 100.0))
	print('ACL was more accurate for {} clips ({:.2f} %)'.format(num_acl_accuracy_wins, float(num_acl_accuracy_wins) / num_clips * 100.0))
	print('ACL has faster compression for {} clips ({:.2f} %)'.format(num_acl_speed_wins, float(num_acl_speed_wins) / num_clips * 100.0))
	print('ACL was smaller, better, faster for {} clips ({:.2f} %)'.format(num_acl_wins, float(num_acl_wins) / num_clips * 100.0))
	print('ACL won with simulated auto {} clips ({:.2f} %)'.format(num_acl_auto_wins, float(num_acl_auto_wins) / num_clips * 100.0))

	if len(clip_drop_rates) > 0:
		print()
		print('Key reduction clip avg drop rate: {:.2f} %'.format(numpy.average(clip_drop_rates) * 100.0))
		print('Key reduction clip 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(clip_drop_rates, 50.0) * 100.0))
		print('Key reduction clip 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(clip_drop_rates, 90.0) * 100.0))
		print('Key reduction pose avg drop rate: {:.2f} %'.format(numpy.average(pose_drop_rates) * 100.0))
		print('Key reduction pose 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(pose_drop_rates, 50.0) * 100.0))
		print('Key reduction pose 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(pose_drop_rates, 90.0) * 100.0))
		print('Key reduction track avg drop rate: {:.2f} %'.format(numpy.average(track_drop_rates) * 100.0))
		print('Key reduction track 50th percentile drop rate: {:.2f} %'.format(numpy.percentile(track_drop_rates, 50.0) * 100.0))
		print('Key reduction track 90th percentile drop rate: {:.2f} %'.format(numpy.percentile(track_drop_rates, 90.0) * 100.0))
# This script depends on a SJSON parsing package: # https://pypi.python.org/pypi/SJSON/1.1.0 # https://shelter13.net/projects/SJSON/ # https://bitbucket.org/Anteru/sjson/src # TODO: Strip trailing '/' or '\' # TODO: Strip trailing '/' or '\' # TODO: Strip trailing '/' or '\' # Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console # With minor tweaks Call in a loop to create terminal progress bar 	@params: 		iteration   - Required  : current iteration (Int) 		total       - Required  : total iterations (Int) 		prefix      - Optional  : prefix string (Str) 		suffix      - Optional  : suffix string (Str) 		decimals    - Optional  : positive number of decimals in percent complete (Int) 		bar_length  - Optional  : character length of bar (Int) # We need to clear any previous line we might have to ensure we have no visual artifacts # Note that if this function is called too quickly, the text might flicker # Long path prefix # The sjson lib doesn't always return numbers as floats, sometimes as int but numpy doesn't like that # Add a marker to terminate the jobs # No need for a label if we parse both together # Flatten our stats into a list and strip the error values # Sort out stats by clip name so we can zip them in pairs # keep it if it we want to force the error below the threshold and it reduces error # or if has an acceptable error and saves space # or if saves the same amount and an acceptable error that is lower than the previous best #print('Bone error 99th percentile: {:.4f}'.format(numpy.percentile(acl_error_values, 99.0))) #print('Error threshold percentile rank: {:.2f} (0.01)'.format(percentile_rank(acl_error_values, 0.01)))