1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
|
#!/usr/bin/env python
# Copyright (c) 2015 The WebRTC project authors. All Rights Reserved.
#
# Use of this source code is governed by a BSD-style license
# that can be found in the LICENSE file in the root of the source
# tree. An additional intellectual property rights grant can be found
# in the file PATENTS. All contributing project authors may
# be found in the AUTHORS file in the root of the source tree.
"""Generate graphs for data generated by loopback tests.
Usage examples:
Show end to end time for a single full stack test.
./full_stack_tests_plot.py -df end_to_end -o 600 --frames 1000 vp9_data.txt
Show simultaneously PSNR and encoded frame size for two different runs of
full stack test. Averaged over a cycle of 200 frames. Used e.g. for
screenshare slide test.
./full_stack_tests_plot.py -c 200 -df psnr -drf encoded_frame_size \\
before.txt after.txt
Similar to the previous test, but multiple graphs.
./full_stack_tests_plot.py -c 200 -df psnr vp8.txt vp9.txt --next \\
-c 200 -df sender_time vp8.txt vp9.txt --next \\
-c 200 -df end_to_end vp8.txt vp9.txt
"""
import argparse
from collections import defaultdict
import itertools
import sys
import matplotlib.pyplot as plt
import numpy
# Fields
DROPPED = 0
INPUT_TIME = 1 # ms (timestamp)
SEND_TIME = 2 # ms (timestamp)
RECV_TIME = 3 # ms (timestamp)
RENDER_TIME = 4 # ms (timestamp)
ENCODED_FRAME_SIZE = 5 # bytes
PSNR = 6
SSIM = 7
ENCODE_TIME = 8 # ms (time interval)
TOTAL_RAW_FIELDS = 9
SENDER_TIME = TOTAL_RAW_FIELDS + 0
RECEIVER_TIME = TOTAL_RAW_FIELDS + 1
END_TO_END = TOTAL_RAW_FIELDS + 2
RENDERED_DELTA = TOTAL_RAW_FIELDS + 3
FIELD_MASK = 255
# Options
HIDE_DROPPED = 256
RIGHT_Y_AXIS = 512
# internal field id, field name, title
_FIELDS = [
# Raw
(DROPPED, "dropped", "dropped"),
(INPUT_TIME, "input_time_ms", "input time"),
(SEND_TIME, "send_time_ms", "send time"),
(RECV_TIME, "recv_time_ms", "recv time"),
(ENCODED_FRAME_SIZE, "encoded_frame_size", "encoded frame size"),
(PSNR, "psnr", "PSNR"),
(SSIM, "ssim", "SSIM"),
(RENDER_TIME, "render_time_ms", "render time"),
(ENCODE_TIME, "encode_time_ms", "encode time"),
# Auto-generated
(SENDER_TIME, "sender_time", "sender time"),
(RECEIVER_TIME, "receiver_time", "receiver time"),
(END_TO_END, "end_to_end", "end to end"),
(RENDERED_DELTA, "rendered_delta", "rendered delta"),
]
NAME_TO_ID = {field[1]: field[0] for field in _FIELDS}
ID_TO_TITLE = {field[0]: field[2] for field in _FIELDS}
def FieldArgToId(arg):
if arg == "none":
return None
if arg in NAME_TO_ID:
return NAME_TO_ID[arg]
if arg + "_ms" in NAME_TO_ID:
return NAME_TO_ID[arg + "_ms"]
raise Exception("Unrecognized field name \"{}\"".format(arg))
class PlotLine(object):
"""Data for a single graph line."""
def __init__(self, label, values, flags):
self.label = label
self.values = values
self.flags = flags
class Data(object):
"""Object representing one full stack test."""
def __init__(self, filename):
self.title = ""
self.length = 0
self.samples = defaultdict(list)
self._ReadSamples(filename)
def _ReadSamples(self, filename):
"""Reads graph data from the given file."""
f = open(filename)
it = iter(f)
self.title = it.next().strip()
self.length = int(it.next())
field_names = [name.strip() for name in it.next().split()]
field_ids = [NAME_TO_ID[name] for name in field_names]
for field_id in field_ids:
self.samples[field_id] = [0.0] * self.length
for sample_id in xrange(self.length):
for col, value in enumerate(it.next().split()):
self.samples[field_ids[col]][sample_id] = float(value)
self._SubtractFirstInputTime()
self._GenerateAdditionalData()
f.close()
def _SubtractFirstInputTime(self):
offset = self.samples[INPUT_TIME][0]
for field in [INPUT_TIME, SEND_TIME, RECV_TIME, RENDER_TIME]:
if field in self.samples:
self.samples[field] = [x - offset for x in self.samples[field]]
def _GenerateAdditionalData(self):
"""Calculates sender time, receiver time etc. from the raw data."""
s = self.samples
last_render_time = 0
for field_id in [
SENDER_TIME, RECEIVER_TIME, END_TO_END, RENDERED_DELTA
]:
s[field_id] = [0] * self.length
for k in range(self.length):
s[SENDER_TIME][k] = s[SEND_TIME][k] - s[INPUT_TIME][k]
decoded_time = s[RENDER_TIME][k]
s[RECEIVER_TIME][k] = decoded_time - s[RECV_TIME][k]
s[END_TO_END][k] = decoded_time - s[INPUT_TIME][k]
if not s[DROPPED][k]:
if k > 0:
s[RENDERED_DELTA][k] = decoded_time - last_render_time
last_render_time = decoded_time
def _Hide(self, values):
"""
Replaces values for dropped frames with None.
These values are then skipped by the Plot() method.
"""
return [
None if self.samples[DROPPED][k] else values[k]
for k in range(len(values))
]
def AddSamples(self, config, target_lines_list):
"""Creates graph lines from the current data set with given config."""
for field in config.fields:
# field is None means the user wants just to skip the color.
if field is None:
target_lines_list.append(None)
continue
field_id = field & FIELD_MASK
values = self.samples[field_id]
if field & HIDE_DROPPED:
values = self._Hide(values)
target_lines_list.append(
PlotLine(self.title + " " + ID_TO_TITLE[field_id], values,
field & ~FIELD_MASK))
def AverageOverCycle(values, length):
"""
Returns the list:
[
avg(values[0], values[length], ...),
avg(values[1], values[length + 1], ...),
...
avg(values[length - 1], values[2 * length - 1], ...),
]
Skips None values when calculating the average value.
"""
total = [0.0] * length
count = [0] * length
for k, val in enumerate(values):
if val is not None:
total[k % length] += val
count[k % length] += 1
result = [0.0] * length
for k in range(length):
result[k] = total[k] / count[k] if count[k] else None
return result
class PlotConfig(object):
"""Object representing a single graph."""
def __init__(self,
fields,
data_list,
cycle_length=None,
frames=None,
offset=0,
output_filename=None,
title="Graph"):
self.fields = fields
self.data_list = data_list
self.cycle_length = cycle_length
self.frames = frames
self.offset = offset
self.output_filename = output_filename
self.title = title
def Plot(self, ax1):
lines = []
for data in self.data_list:
if not data:
# Add None lines to skip the colors.
lines.extend([None] * len(self.fields))
else:
data.AddSamples(self, lines)
def _SliceValues(values):
if self.offset:
values = values[self.offset:]
if self.frames:
values = values[:self.frames]
return values
length = None
for line in lines:
if line is None:
continue
line.values = _SliceValues(line.values)
if self.cycle_length:
line.values = AverageOverCycle(line.values, self.cycle_length)
if length is None:
length = len(line.values)
elif length != len(line.values):
raise Exception("All arrays should have the same length!")
ax1.set_xlabel("Frame", fontsize="large")
if any(line.flags & RIGHT_Y_AXIS for line in lines if line):
ax2 = ax1.twinx()
ax2.set_xlabel("Frame", fontsize="large")
else:
ax2 = None
# Have to implement color_cycle manually, due to two scales in a graph.
color_cycle = ["b", "r", "g", "c", "m", "y", "k"]
color_iter = itertools.cycle(color_cycle)
for line in lines:
if not line:
color_iter.next()
continue
if self.cycle_length:
x = numpy.array(range(self.cycle_length))
else:
x = numpy.array(
range(self.offset, self.offset + len(line.values)))
y = numpy.array(line.values)
ax = ax2 if line.flags & RIGHT_Y_AXIS else ax1
ax.Plot(x,
y,
"o-",
label=line.label,
markersize=3.0,
linewidth=1.0,
color=color_iter.next())
ax1.grid(True)
if ax2:
ax1.legend(loc="upper left", shadow=True, fontsize="large")
ax2.legend(loc="upper right", shadow=True, fontsize="large")
else:
ax1.legend(loc="best", shadow=True, fontsize="large")
def LoadFiles(filenames):
result = []
for filename in filenames:
if filename in LoadFiles.cache:
result.append(LoadFiles.cache[filename])
else:
data = Data(filename)
LoadFiles.cache[filename] = data
result.append(data)
return result
LoadFiles.cache = {}
def GetParser():
class CustomAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
if "ordered_args" not in namespace:
namespace.ordered_args = []
namespace.ordered_args.append((self.dest, values))
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("-c",
"--cycle_length",
nargs=1,
action=CustomAction,
type=int,
help="Cycle length over which to average the values.")
parser.add_argument(
"-f",
"--field",
nargs=1,
action=CustomAction,
help="Name of the field to show. Use 'none' to skip a color.")
parser.add_argument("-r",
"--right",
nargs=0,
action=CustomAction,
help="Use right Y axis for given field.")
parser.add_argument("-d",
"--drop",
nargs=0,
action=CustomAction,
help="Hide values for dropped frames.")
parser.add_argument("-o",
"--offset",
nargs=1,
action=CustomAction,
type=int,
help="Frame offset.")
parser.add_argument("-n",
"--next",
nargs=0,
action=CustomAction,
help="Separator for multiple graphs.")
parser.add_argument(
"--frames",
nargs=1,
action=CustomAction,
type=int,
help="Frame count to show or take into account while averaging.")
parser.add_argument("-t",
"--title",
nargs=1,
action=CustomAction,
help="Title of the graph.")
parser.add_argument("-O",
"--output_filename",
nargs=1,
action=CustomAction,
help="Use to save the graph into a file. "
"Otherwise, a window will be shown.")
parser.add_argument(
"files",
nargs="+",
action=CustomAction,
help="List of text-based files generated by loopback tests.")
return parser
def _PlotConfigFromArgs(args, graph_num):
# Pylint complains about using kwargs, so have to do it this way.
cycle_length = None
frames = None
offset = 0
output_filename = None
title = "Graph"
fields = []
files = []
mask = 0
for key, values in args:
if key == "cycle_length":
cycle_length = values[0]
elif key == "frames":
frames = values[0]
elif key == "offset":
offset = values[0]
elif key == "output_filename":
output_filename = values[0]
elif key == "title":
title = values[0]
elif key == "drop":
mask |= HIDE_DROPPED
elif key == "right":
mask |= RIGHT_Y_AXIS
elif key == "field":
field_id = FieldArgToId(values[0])
fields.append(field_id | mask if field_id is not None else None)
mask = 0 # Reset mask after the field argument.
elif key == "files":
files.extend(values)
if not files:
raise Exception(
"Missing file argument(s) for graph #{}".format(graph_num))
if not fields:
raise Exception(
"Missing field argument(s) for graph #{}".format(graph_num))
return PlotConfig(fields,
LoadFiles(files),
cycle_length=cycle_length,
frames=frames,
offset=offset,
output_filename=output_filename,
title=title)
def PlotConfigsFromArgs(args):
"""Generates plot configs for given command line arguments."""
# The way it works:
# First we detect separators -n/--next and split arguments into groups, one
# for each plot. For each group, we partially parse it with
# argparse.ArgumentParser, modified to remember the order of arguments.
# Then we traverse the argument list and fill the PlotConfig.
args = itertools.groupby(args, lambda x: x in ["-n", "--next"])
prep_args = list(list(group) for match, group in args if not match)
parser = GetParser()
plot_configs = []
for index, raw_args in enumerate(prep_args):
graph_args = parser.parse_args(raw_args).ordered_args
plot_configs.append(_PlotConfigFromArgs(graph_args, index))
return plot_configs
def ShowOrSavePlots(plot_configs):
for config in plot_configs:
fig = plt.figure(figsize=(14.0, 10.0))
ax = fig.add_subPlot(1, 1, 1)
plt.title(config.title)
config.Plot(ax)
if config.output_filename:
print "Saving to", config.output_filename
fig.savefig(config.output_filename)
plt.close(fig)
plt.show()
if __name__ == "__main__":
ShowOrSavePlots(PlotConfigsFromArgs(sys.argv[1:]))
|