-
-
Save dreness/916025a5f049c94f2177 to your computer and use it in GitHub Desktop.
#!/usr/bin/env python | |
# dre at mac dot com | |
# Graphical and statistical analysis of frame rate in a video | |
# Requires ffmpeg and gnuplot | |
from sys import platform | |
from subprocess import check_output, DEVNULL | |
from os import path | |
import json | |
import argparse | |
import numpy as np | |
import pickle | |
from textwrap import dedent | |
# output files for frame data and gnuplot script | |
gpDataFile = "data.dat" | |
gpScriptFile = "frametimes.gnuplot" | |
# The attribute in the frame info dictionary from ffprobe that contains | |
# a time stamp, preferred in list order | |
frameTimeAttrs = ["pkt_pts_time", "pkt_dts_time"] | |
# Command line arguments | |
parser = argparse.ArgumentParser( | |
formatter_class=argparse.RawTextHelpFormatter, | |
description=""" | |
Visualize frame rate consistency in a video by calculating and graphing the | |
delay between each frame along with some derived stats. This helps find | |
stutters and frame drops in variable frame rate clips, and can also be used to | |
accurately compare quality of clips from different sources. | |
When specifying an optional target frame rate, the target inter-frame delay is | |
calculated and graphed, and a 'slow frames' stat is added to the top label. | |
""", | |
epilog=""" | |
Examples: | |
# Analyze the file ~/foo.mov and output an image to /tmp called foo.mov.png | |
plotframetimes.py --workdir /tmp ~/foo.mov | |
# Hint the analysis with the expected frame rate | |
plotframetimes.py --fps 60 ~/foo.mov | |
# Configure the y axis to use a log scale; values not far over 1 are best | |
plotframetimes.py --ylog 1.2 ~/foo.mov | |
# Customize y-axis range; useful to see detail when the default range is big | |
plotframetimes.py --yrange :18 ~/foo.mov # auto lower bound, 18ms upper | |
plotframetimes.py --yrange 12:18 ~/foo.mov # from 12 to 18ms | |
""", | |
) | |
parser.add_argument( | |
"--fps", | |
type=float, | |
help=dedent( | |
""" | |
Frame rate target, e.g. 60 | |
If present, used to derive target inter-frame delay. | |
""" | |
), | |
) | |
parser.add_argument( | |
"--ylog", | |
type=float, | |
help=dedent( | |
""" | |
Logarithmic base to use for y axis, e.g. 1.2 | |
If absent, use a linear scale. | |
""" | |
), | |
) | |
parser.add_argument( | |
"--xrange", | |
type=str, | |
help=dedent( | |
""" | |
X axis range in gnuplot form: {<min>}:{<max>} | |
e.g. 42, 42:84, :42 | |
If absent, both x axis bounds are auto-sized | |
""" | |
), | |
) | |
parser.add_argument( | |
"--yrange", | |
type=str, | |
help=dedent( | |
""" | |
Y range in gnuplot form: {<min>}:{<max>} | |
e.g. 15:17, 16:, :42 | |
If absent, both y axis bounds are auto-sized | |
""" | |
), | |
) | |
parser.add_argument( | |
"--workdir", | |
type=str, | |
default=check_output(["getconf", "DARWIN_USER_CACHE_DIR"]).strip().decode("utf-8") or None, | |
help=dedent( | |
""" | |
Directory for image output and pickle input / output. | |
If absent, defaults to current working directory. | |
""" | |
), | |
) | |
parser.add_argument( | |
"--debug", | |
action="store_true", | |
help=dedent( | |
""" | |
Show debug output. | |
""" | |
), | |
) | |
parser.add_argument( | |
"file", | |
help="Input video file to analyze, e.g. ~/foo.mov", | |
) | |
args = parser.parse_args() | |
debug = args.debug | |
# Build an array of options to print | |
label3 = "" | |
for a, b in args._get_kwargs(): | |
if (a != "file") and (b is not None): | |
label3 += "{}={} ".format(a, b) | |
if label3 != "": | |
label3 = "Options: " + label3 | |
# If we're on Windows using cygwin, we might need to recombobulate the path | |
# e.g. '/cygdrive/d/shadowplay/Overwatch/Overwatch 03.15.2017 - 00.51.52.05.mp4' | |
inputFile = args.file | |
if platform.startswith("win32"): | |
if inputFile.startswith("/cygdrive/"): | |
print("Converting cygwin path to windows...") | |
inputFile = check_output(["cygpath", "-w", inputFile]).strip() | |
if not path.exists(inputFile): | |
print("Nothing usable at that path...") | |
exit(1) | |
fileName = path.basename(inputFile) | |
# Try to use a cached pickle of the frame data | |
frame_data = f"{fileName}.pickle" | |
workdir = args.workdir | |
if workdir is not None: | |
debug and print("workdir is not none") | |
if not path.isdir(workdir): | |
print("Specified directory does not exist: {}".format(workdir)) | |
exit(1) | |
else: | |
imagePath = path.join(workdir, fileName) | |
picklePath = path.join(workdir, frame_data) | |
debug and print(f"derived picklePath {picklePath}") | |
else: | |
debug and print("workdir is none") | |
imagePath = fileName | |
picklePath = frame_data | |
def loadCacheMaybe(picklePath, frame_data): | |
debug and print(f"pp: {picklePath}") | |
j = None | |
if path.exists(picklePath): | |
debug and print("picklePath exists") | |
if path.getmtime(inputFile) > path.getmtime(picklePath): | |
print("Input file is newer than cached frame data; not using cache") | |
return | |
try: | |
pickleFile = open(picklePath, "rb") | |
j = pickle.load(pickleFile) | |
pickleFile.close() | |
print(f"Loading data from pickle file: {picklePath}") | |
return j | |
except Exception as e: | |
print("Unable to load pickle data from {}".format(frame_data)) | |
debug and print(e) | |
return None | |
else: | |
debug and print(f"no file at {picklePath}") | |
return None | |
j = loadCacheMaybe(picklePath, frame_data) | |
# get a json structure of frame and stream info using ffprobe | |
if j is None: | |
cmd = [ | |
"ffprobe", | |
"-show_entries", | |
"frame=pkt_pts_time,pkt_dts_time,coded_picture_number : stream", | |
"-select_streams", | |
"v", | |
"-of", | |
"json", | |
inputFile, | |
] | |
print("running ffprobe - this may take some time for large files") | |
ffprobe_output = check_output(cmd, stderr=DEVNULL if not debug else None) | |
j = json.loads(ffprobe_output) | |
frames = j["frames"] | |
print("Loaded {} frames".format(len(frames))) | |
# Sniff the available frame time attributes | |
frameTimeAttr = None | |
for fta in frameTimeAttrs: | |
if frames[42].get(fta) is not None: | |
frameTimeAttr = fta | |
debug and print(f"Found frame attritue {fta}") | |
if frameTimeAttr is None: | |
print("No available frame time attribute!") | |
exit(1) | |
# pickle the frame data for later, maybe | |
try: | |
pickleFile = open(picklePath, "wb") | |
pickle.dump(j, pickleFile) | |
pickleFile.close() | |
print(f"Saved frame data to {pickleFile}") | |
except Exception as e: | |
print("Unable to save pickle data to {}".format(pickleFile)) | |
debug and print(e) | |
# Grab the average frame rate of the entire stream | |
num, denom = j["streams"][0]["avg_frame_rate"].split("/") | |
avg_fps = round(float(num) / float(denom), 3) | |
# Use the supplied fps target, if present | |
if args.fps is not None: | |
fps = args.fps | |
else: | |
# otherwise use average fps as determined by ffprobe | |
fps = avg_fps | |
# open output files | |
dat = open(gpDataFile, "w") | |
gp = open(gpScriptFile, "w") | |
# Iterate frames, calculate deltas, and write data lines | |
deltas = [] | |
lastT = None | |
for f in frames: | |
try: | |
t = float(f[frameTimeAttr]) * 1000 | |
if lastT is not None: | |
d = round(t - lastT, 5) | |
else: # First frame | |
lastT = t | |
continue | |
deltas.append(d) | |
dat.write(str(d) + "\n") | |
lastT = t | |
except Exception as e: | |
debug and print(e) | |
next | |
dat.close() | |
debug and print(f"wrote gnuplot data file {dat}") | |
# More stats | |
delayTarget = round(float(1000) / float(fps), 5) | |
slowFrames = sum(round(i, 5) > delayTarget for i in deltas) | |
totalFrames = len(deltas) | |
slowPercent = round(float(slowFrames) / float(totalFrames) * 100, 5) | |
a = np.array(deltas) | |
p99 = np.percentile(a, 99) | |
mean = round(np.mean(a), 5) | |
ninetyNine = round(p99, 5) | |
fpsTarget = delayTarget | |
# Compose gnuplot script | |
label1 = "Frame rate analysis for {}".format(fileName) | |
label2 = "Avg {} fps ({} ms) ".format(round(avg_fps, 5), mean) | |
label2 += "Max {} ms ".format(round(max(deltas), 5)) | |
# Only display target fps if specified on the command line | |
if args.fps is not None: | |
label2 += "Target: {} fps ({} ms) {}/{} slow ({}%)".format( | |
fps, | |
delayTarget, | |
slowFrames, | |
totalFrames, | |
slowPercent, | |
) | |
plots = [ | |
"using 0:1 title 'delay'", | |
"{} title '99%: {}' ls 2".format(ninetyNine, ninetyNine), | |
"{} title 'mean: {}' ls 5".format(mean, mean), | |
] | |
if args.fps is not None: | |
plots.extend(["{} title 'target: {}' ls 1".format(fpsTarget, fpsTarget)]) | |
ylabel = "delay (ms)" | |
if args.ylog is not None: | |
ylog = "set logscale yy2 {}".format(args.ylog) | |
ylabel = ylabel + " (log {})".format(args.ylog) | |
else: | |
ylog = "" | |
ranges = "" | |
if args.yrange is not None: | |
ranges = "set yrange [{}] ; ".format(args.yrange) | |
ranges += "set y2range [{}] ; ".format(args.yrange) | |
if args.xrange is not None: | |
ranges += "set xrange [{}]".format(args.xrange) | |
gpScript = """ | |
set terminal postscript eps size 5,3 enhanced color \ | |
font 'Helvetica,10' linewidth 2 background "#000000" | |
set style line 80 lt 0 lw 3 lc rgb "#DDDDDD" | |
set border 3 back ls 80 | |
set tmargin 7 | |
set bmargin 7 | |
set output "{image}.eps" | |
set termoption dash | |
set title "\\n\\n\\n" noenhanced | |
set label 1 "{l1}\\n" at graph 0.02,1.1 left textcolor rgb "#EEEEEE" noenhanced | |
set label 3 "{l3}" at graph 0.8,1.1 right textcolor rgb "#EEEEEE" noenhanced | |
set label 2 "{l2}" at graph 0.02,1.06 left textcolor rgb "#BBBBBB" noenhanced | |
set style line 1 lt 0 lw 2 lc "green" | |
set style line 2 lt 2 lw 1 lc "purple" | |
set style line 3 lt 3 lw 1 lc "blue" | |
set style line 5 lt 5 lw 1 lc "orange" | |
set style line 6 lt 3 lw 2 lc rgbcolor "#AAAAAA" | |
set key below Right height 1.1 \ | |
spacing 1.1 box ls 6 textcolor rgb "#EEEEEE" enhanced | |
set style data points | |
set format y "%.5f" | |
set ytics nomirror | |
{ylog} | |
{ranges} | |
set y2tics ("10" 100, "15" 66.667, "24" 41.667, "30" 33.333, "60" 16.667) \ | |
textcolor rgb "#EEEEEE" | |
set xlabel 'frame number' textcolor rgb "#BBBBBB" noenhanced | |
set ylabel '{ylabel}' textcolor rgb "#BBBBBB" noenhanced | |
set y2label 'frames per second' textcolor rgb "#BBBBBB" noenhanced | |
plot 'data.dat' {plots} | |
""".format( | |
image=imagePath, | |
l1=label1, | |
l2=label2, | |
l3=label3, | |
ylog=ylog, | |
ranges=ranges, | |
ylabel=ylabel, | |
plots=",".join(plots), | |
) | |
gp.write(gpScript) | |
gp.close() | |
debug and print(f"wrote gnuplot script file {gpScriptFile}") | |
# Run gnuplot. | |
cmdout = check_output(["gnuplot", gpScriptFile]) | |
# debug and print(cmdout) | |
# Open the output image using launch services (OS X) or cygstart (windows) | |
imageFile = f"{imagePath}.eps" | |
imageFilePath = path.abspath(imageFile) | |
print(f"Trying to open {imageFilePath}") | |
if platform.startswith("win32"): | |
check_output(["cygstart", imageFilePath]) | |
else: | |
check_output(["open", imageFilePath]) |
dreness
commented
Jan 11, 2022
Hi,
First let me thank you for this python script.
Until I played around a bit I was confused, but I guess I should have payed more attention to the filename and not the description. What I'm trying to say is this script makes frametime graph and that information helps detecting framerate issues, but the only framerate info this script gives is the avg value. Does the second y axis "frames per second" have a use or am I missing something? it has no values and no range.
Also I think it would help labelling the values in the bottom rectangle as frametimes in milliseconds.
Sorry if I sound impolite but as new user it took me a bit of time to figure out those things.
Errors I had (Linux):
Line 95, DARWIN_USER_CACHE_DIR
doesn't exist and will error the script, I just changed it to default=None
and used "--workdir".
Line 357,
check_output(["open", imageFilePath])
This errors too, I changed it to:
check_output(["xdg-open", imageFilePath])
Hi, First let me thank you for this python script. Until I played around a bit I was confused, but I guess I should have payed more attention to the filename and not the description. What I'm trying to say is this script makes frametime graph and that information helps detecting framerate issues, but the only framerate info this script gives is the avg value. Does the second y axis "frames per second" have a use or am I missing something? it has no values and no range. Also I think it would help labelling the values in the bottom rectangle as frametimes in milliseconds. Sorry if I sound impolite but as new user it took me a bit of time to figure out those things. Errors I had (Linux): Line 95,
DARWIN_USER_CACHE_DIR
doesn't exist and will error the script, I just changed it todefault=None
and used "--workdir". Line 357,check_output(["open", imageFilePath])
This errors too, I changed it to:check_output(["xdg-open", imageFilePath])
Hi @jouven - sorry I didn't see your comment until just now.
- You're right that the second y axis is missing labels - I'll see if I can figure out what happened. I'm setting
y2tics
but I guess that's not sufficient for some reason. - labels in the bottom box are a good idea
- I'll add some checks around
DARWIN_USER_CACHE_DIR
because that obviously will break on anything other than macOS - sorry about that. - The current code treats everything that's not windows as macOS, which works for me personally but is certainly not universal. I'll add a check for linux, and thanks for telling me what the command name should be.