Skip to content

Instantly share code, notes, and snippets.

@dreness
Last active June 28, 2023 01:29
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save dreness/916025a5f049c94f2177 to your computer and use it in GitHub Desktop.
Save dreness/916025a5f049c94f2177 to your computer and use it in GitHub Desktop.
Graphical and statistical analysis of frame rate in a video
#!/usr/bin/env python
# dre at mac dot com
# Graphical and statistical analysis of frame rate in a video
# Requires ffmpeg and gnuplot
# Example output:
# https://youbeill.in/scrap/Screen-Recording-2018-09-07-at-3.17.33-PM.mov.png
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
Copy link
Author

dreness commented Jan 11, 2022

plot-sample

@jouven
Copy link

jouven commented Aug 6, 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])

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment