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