xref: /aosp_15_r20/external/webrtc/video/full_stack_tests_plot.py (revision d9f758449e529ab9291ac668be2861e7a55c2422)
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