1#!/usr/bin/python
2# Copyright (C) 2015 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16
17import argparse, math, re, sys
18import xml.etree.ElementTree as ET
19from collections import defaultdict, namedtuple
20import itertools
21
22
23def createLookup(values, key):
24  """Creates a lookup table for a collection of values based on keys.
25
26  Arguments:
27    values: a collection of arbitrary values. Must be iterable.
28    key: a function of one argument that returns the key for a value.
29
30  Returns:
31    A dict mapping keys (as generated by the key argument) to lists of
32    values. All values in the lists have the same key, and are in the order
33    they appeared in the collection.
34  """
35  lookup = defaultdict(list)
36  for v in values:
37    lookup[key(v)].append(v)
38  return lookup
39
40
41def _intify(value):
42  """Returns a value converted to int if possible, else the original value."""
43  try:
44    return int(value)
45  except ValueError:
46    return value
47
48
49class Size(namedtuple('Size', ['width', 'height'])):
50  """A namedtuple with width and height fields."""
51  def __str__(self):
52    return '%dx%d' % (self.width, self.height)
53
54
55class _VideoResultBase(object):
56  """Helper methods for results. Not for use by applications.
57
58  Attributes:
59    codec: The name of the codec (string) or None
60    size: Size representing the video size or None
61    mime: The mime-type of the codec (string) or None
62    rates: The measured achievable frame rates
63    is_decoder: True iff codec is a decoder.
64  """
65
66  def __init__(self, is_decoder):
67    self.codec = None
68    self.mime = None
69    self.size = None
70    self._rates_from_failure = []
71    self._rates_from_message = []
72    self.is_decoder = is_decoder
73
74  def _inited(self):
75    """Returns true iff codec, mime and size was set."""
76    return None not in (self.codec, self.mime, self.size)
77
78  def __len__(self):
79    # don't report any result if codec name, mime type and size is unclear
80    if not self._inited():
81      return 0
82    return len(self.rates)
83
84  @property
85  def rates(self):
86    return self._rates_from_failure or self._rates_from_message
87
88  def _parseDict(self, value):
89    """Parses a MediaFormat from its string representation sans brackets."""
90    return dict((k, _intify(v))
91                for k, v in re.findall(r'([^ =]+)=([^ [=]+(?:|\[[^\]]+\]))(?:, |$)', value))
92
93  def _cleanFormat(self, format):
94    """Removes internal fields from a parsed MediaFormat."""
95    format.pop('what', None)
96    format.pop('image-data', None)
97
98  MESSAGE_PATTERN = r'(?P<key>\w+)=(?P<value>\{[^}]*\}|[^ ,{}]+)'
99
100  def _parsePartialResult(self, message_match):
101    """Parses a partial test result conforming to the message pattern.
102
103    Returns:
104      A tuple of string key and int, string or dict value, where dict has
105      string keys mapping to int or string values.
106    """
107    key, value = message_match.group('key', 'value')
108    if value.startswith('{'):
109      value = self._parseDict(value[1:-1])
110      if key.endswith('Format'):
111        self._cleanFormat(value)
112    else:
113      value = _intify(value)
114    return key, value
115
116  def _parseValuesFromBracket(self, line):
117    """Returns the values enclosed in brackets without the brackets.
118
119    Parses a line matching the pattern "<tag>: [<values>]" and returns <values>.
120
121    Raises:
122      ValueError: if the line does not match the pattern.
123    """
124    try:
125      return re.match(r'^[^:]+: *\[(?P<values>.*)\]\.$', line).group('values')
126    except AttributeError:
127      raise ValueError('line does not match "tag: [value]": %s' % line)
128
129  def _parseRawData(self, line):
130    """Parses the raw data line for video performance tests.
131
132    Yields:
133      Dict objects corresponding to parsed results, mapping string keys to
134      int, string or dict values.
135    """
136    try:
137      values = self._parseValuesFromBracket(line)
138      result = {}
139      for m in re.finditer(self.MESSAGE_PATTERN + r'(?P<sep>,? +|$)', values):
140        key, value = self._parsePartialResult(m)
141        result[key] = value
142        if m.group('sep') != ' ':
143          yield result
144          result = {}
145    except ValueError:
146      print >> sys.stderr, 'could not parse line %s' % repr(line)
147
148  def _tryParseMeasuredFrameRate(self, line):
149    """Parses a line starting with 'Measured frame rate:'."""
150    if line.startswith('Measured frame rate: '):
151      try:
152        values = self._parseValuesFromBracket(line)
153        values = re.split(r' *, *', values)
154        self._rates_from_failure = list(map(float, values))
155      except ValueError:
156        print >> sys.stderr, 'could not parse line %s' % repr(line)
157
158  def parse(self, test):
159    """Parses the ValueArray and FailedScene lines of a test result.
160
161    Arguments:
162      test: An ElementTree <Test> element.
163    """
164    failure = test.find('FailedScene')
165    if failure is not None:
166      trace = failure.find('StackTrace')
167      if trace is not None:
168        for line in re.split(r'[\r\n]+', trace.text):
169          self._parseFailureLine(line)
170    details = test.find('Details')
171    if details is not None:
172      for array in details.iter('ValueArray'):
173        message = array.get('message')
174        self._parseMessage(message, array)
175
176  def _parseFailureLine(self, line):
177    raise NotImplementedError
178
179  def _parseMessage(self, message, array):
180    raise NotImplementedError
181
182  def getData(self):
183    """Gets the parsed test result data.
184
185    Yields:
186       Result objects containing at least codec, size, mime and rates attributes."""
187    yield self
188
189
190class VideoEncoderDecoderTestResult(_VideoResultBase):
191  """Represents a result from a VideoEncoderDecoderTest performance case."""
192
193  def __init__(self, unused_m):
194    super(VideoEncoderDecoderTestResult, self).__init__(is_decoder=False)
195
196  # If a VideoEncoderDecoderTest succeeds, it provides the results in the
197  # message of a ValueArray. If fails, it provides the results in the failure
198  # using raw data. (For now it also includes some data in the ValueArrays even
199  # if it fails, which we ignore.)
200
201  def _parseFailureLine(self, line):
202    """Handles parsing a line from the failure log."""
203    self._tryParseMeasuredFrameRate(line)
204    if line.startswith('Raw data: '):
205      for result in self._parseRawData(line):
206        fmt = result['EncOutputFormat']
207        self.size = Size(fmt['width'], fmt['height'])
208        self.codec = result['codec']
209        self.mime = fmt['mime']
210
211  def _parseMessage(self, message, array):
212    """Handles parsing a message from ValueArrays."""
213    if message.startswith('codec='):
214      result = dict(self._parsePartialResult(m)
215                  for m in re.finditer(self.MESSAGE_PATTERN + '(?: |$)', message))
216      if 'EncInputFormat' in result:
217        self.codec = result['codec']
218        fmt = result['EncInputFormat']
219        self.size = Size(fmt['width'], fmt['height'])
220        self.mime = result['EncOutputFormat']['mime']
221        self._rates_from_message.append(1000000./result['min'])
222
223
224class VideoDecoderPerfTestResult(_VideoResultBase):
225  """Represents a result from a VideoDecoderPerfTest performance case."""
226
227  # If a VideoDecoderPerfTest succeeds, it provides the results in the message
228  # of a ValueArray. If fails, it provides the results in the failure only
229  # using raw data.
230
231  def __init__(self, unused_m):
232    super(VideoDecoderPerfTestResult, self).__init__(is_decoder=True)
233
234  def _parseFailureLine(self, line):
235    """Handles parsing a line from the failure log."""
236    self._tryParseMeasuredFrameRate(line)
237    # if the test failed, we can only get the codec/size/mime from the raw data.
238    if line.startswith('Raw data: '):
239      for result in self._parseRawData(line):
240        fmt = result['DecOutputFormat']
241        self.size = Size(fmt['width'], fmt['height'])
242        self.codec = result['codec']
243        self.mime = result['mime']
244
245  def _parseMessage(self, message, array):
246    """Handles parsing a message from ValueArrays."""
247    if message.startswith('codec='):
248      result = dict(self._parsePartialResult(m)
249                  for m in re.finditer(self.MESSAGE_PATTERN + '(?: |$)', message))
250      if result.get('decodeto') == 'surface':
251        self.codec = result['codec']
252        fmt = result['DecOutputFormat']
253        self.size = Size(fmt['width'], fmt['height'])
254        self.mime = result['mime']
255        self._rates_from_message.append(1000000. / result['min'])
256
257
258class Results(object):
259  """Container that keeps all test results."""
260  def __init__(self):
261      self._results = [] # namedtuples
262      self._device = None
263
264  VIDEO_ENCODER_DECODER_TEST_REGEX = re.compile(
265      'test(.*)(\d{4})x(\d{4})(Goog|Other)$')
266
267  VIDEO_DECODER_PERF_TEST_REGEX = re.compile(
268      'test(VP[89]|H26[34]|MPEG4|HEVC)(\d+)x(\d+)(.*)$')
269
270  TestCaseSpec = namedtuple('TestCaseSpec', 'package path class_ regex result_class')
271
272  def _getTestCases(self):
273    return [
274      self.TestCaseSpec(package='CtsDeviceVideoPerf',
275                   path='TestSuite/TestSuite/TestSuite/TestSuite/TestCase',
276                   class_='VideoEncoderDecoderTest',
277                   regex=self.VIDEO_ENCODER_DECODER_TEST_REGEX,
278                   result_class=VideoEncoderDecoderTestResult),
279      self.TestCaseSpec(package='CtsMediaTestCases',
280                   path='TestSuite/TestSuite/TestSuite/TestCase',
281                   class_='VideoDecoderPerfTest',
282                   regex=self.VIDEO_DECODER_PERF_TEST_REGEX,
283                   result_class=VideoDecoderPerfTestResult)
284    ]
285
286  def _verifyDeviceInfo(self, device):
287    assert self._device in (None, device), "expected %s device" % self._device
288    self._device = device
289
290  def importXml(self, xml):
291    self._verifyDeviceInfo(xml.find('DeviceInfo/BuildInfo').get('buildName'))
292
293    packages = createLookup(self._getTestCases(), lambda tc: tc.package)
294
295    for pkg in xml.iter('TestPackage'):
296      tests_in_package = packages.get(pkg.get('name'))
297      if not tests_in_package:
298        continue
299      paths = createLookup(tests_in_package, lambda tc: tc.path)
300      for path, tests_in_path in paths.items():
301        classes = createLookup(tests_in_path, lambda tc: tc.class_)
302        for tc in pkg.iterfind(path):
303          tests_in_class = classes.get(tc.get('name'))
304          if not tests_in_class:
305            continue
306          for test in tc.iter('Test'):
307            for tc in tests_in_class:
308              m = tc.regex.match(test.get('name'))
309              if m:
310                result = tc.result_class(m)
311                result.parse(test)
312                self._results.append(result)
313
314  def importFile(self, path):
315    print >> sys.stderr, 'Importing "%s"...' % path
316    try:
317      return self.importXml(ET.parse(path))
318    except ET.ParseError:
319      raise ValueError('not a valid XML file')
320
321  def getData(self):
322    for result in self._results:
323      for data in result.getData():
324        yield data
325
326  def dumpXml(self, results):
327    yield '<?xml version="1.0" encoding="utf-8" ?>'
328    yield '<!-- Copyright 2015 The Android Open Source Project'
329    yield ''
330    yield '     Licensed under the Apache License, Version 2.0 (the "License");'
331    yield '     you may not use this file except in compliance with the License.'
332    yield '     You may obtain a copy of the License at'
333    yield ''
334    yield '          http://www.apache.org/licenses/LICENSE-2.0'
335    yield ''
336    yield '     Unless required by applicable law or agreed to in writing, software'
337    yield '     distributed under the License is distributed on an "AS IS" BASIS,'
338    yield '     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.'
339    yield '     See the License for the specific language governing permissions and'
340    yield '     limitations under the License.'
341    yield '-->'
342    yield ''
343    yield '<MediaCodecs>'
344    last_section = None
345    Comp = namedtuple('Comp', 'is_decoder google mime name')
346    by_comp = createLookup(results,
347                           lambda e: Comp(is_decoder=e.is_decoder, google='.google.' in e.codec, mime=e.mime, name=e.codec))
348    for comp in sorted(by_comp):
349      section = 'Decoders' if comp.is_decoder else 'Encoders'
350      if section != last_section:
351        if last_section:
352          yield '    </%s>' % last_section
353        yield '    <%s>' % section
354        last_section = section
355      yield '        <MediaCodec name="%s" type="%s" update="true">' % (comp.name, comp.mime)
356      by_size = createLookup(by_comp[comp], lambda e: e.size)
357      for size in sorted(by_size):
358        values = list(itertools.chain(*(e.rates for e in by_size[size])))
359        min_, max_ = min(values), max(values)
360        med_ = int(math.sqrt(min_ * max_))
361        yield '            <Limit name="measured-frame-rate-%s" range="%d-%d" />' % (size, med_, med_)
362      yield '        </MediaCodec>'
363    if last_section:
364      yield '    </%s>' % last_section
365    yield '</MediaCodecs>'
366
367
368class Main(object):
369  """Executor of this utility."""
370
371  def __init__(self):
372    self._result = Results()
373
374    self._parser = argparse.ArgumentParser('get_achievable_framerates')
375    self._parser.add_argument('result_xml', nargs='+')
376
377  def _parseArgs(self):
378    self._args = self._parser.parse_args()
379
380  def _importXml(self, xml):
381    self._result.importFile(xml)
382
383  def _report(self):
384    for line in self._result.dumpXml(r for r in self._result.getData() if r):
385      print line
386
387  def run(self):
388    self._parseArgs()
389    try:
390      for xml in self._args.result_xml:
391        try:
392          self._importXml(xml)
393        except (ValueError, IOError, AssertionError) as e:
394          print >> sys.stderr, e
395          raise KeyboardInterrupt
396      self._report()
397    except KeyboardInterrupt:
398      print >> sys.stderr, 'Interrupted.'
399
400if __name__ == '__main__':
401  Main().run()
402
403