Refactor all video related processing to its own class

BUG=

Review URL: https://codereview.chromium.org/303043002

git-svn-id: svn://svn.chromium.org/chrome/trunk/src@276839 0039d316-1c4b-4281-b951-d872f2087c98
parent f7deb90a
...@@ -138,9 +138,10 @@ class VideoSpeedIndexImpl(SpeedIndexImpl): ...@@ -138,9 +138,10 @@ class VideoSpeedIndexImpl(SpeedIndexImpl):
# previous page to white. The tolerance of 8 experimentally does well with # previous page to white. The tolerance of 8 experimentally does well with
# video capture at 4mbps. We should keep this as low as possible with # video capture at 4mbps. We should keep this as low as possible with
# supported video compression settings. # supported video compression settings.
video_capture = tab.StopVideoCapture()
histograms = [(time, bmp.ColorHistogram(ignore_color=bitmap.WHITE, histograms = [(time, bmp.ColorHistogram(ignore_color=bitmap.WHITE,
tolerance=8)) tolerance=8))
for time, bmp in tab.StopVideoCapture()] for time, bmp in video_capture.GetVideoFrameIter()]
start_histogram = histograms[0][1] start_histogram = histograms[0][1]
final_histogram = histograms[-1][1] final_histogram = histograms[-1][1]
......
...@@ -36,6 +36,15 @@ class FakeTimelineModel(object): ...@@ -36,6 +36,15 @@ class FakeTimelineModel(object):
return self._events return self._events
class FakeVideo(object):
def __init__(self, frames):
self._frames = frames
def GetVideoFrameIter(self):
for frame in self._frames:
yield frame
class FakeBitmap(object): class FakeBitmap(object):
def __init__(self, r, g, b): def __init__(self, r, g, b):
...@@ -51,7 +60,7 @@ class FakeTab(object): ...@@ -51,7 +60,7 @@ class FakeTab(object):
def __init__(self, video_capture_result=None): def __init__(self, video_capture_result=None):
self._timeline_model = FakeTimelineModel() self._timeline_model = FakeTimelineModel()
self._javascript_result = None self._javascript_result = None
self._video_capture_result = video_capture_result self._video_capture_result = FakeVideo(video_capture_result)
@property @property
def timeline_model(self): def timeline_model(self):
......
...@@ -165,14 +165,10 @@ class Platform(object): ...@@ -165,14 +165,10 @@ class Platform(object):
def StopVideoCapture(self): def StopVideoCapture(self):
"""Stops capturing video. """Stops capturing video.
Yields: Returns:
(time_ms, bitmap) tuples representing each video keyframe. Only the first A telemetry.core.video.Video object.
frame in a run of sequential duplicate bitmaps is included.
time_ms is milliseconds relative to the first frame.
bitmap is a telemetry.core.Bitmap.
""" """
for t in self._platform_backend.StopVideoCapture(): return self._platform_backend.StopVideoCapture()
yield t
def CanMonitorPower(self): def CanMonitorPower(self):
"""Returns True iff power can be monitored asynchronously via """Returns True iff power can be monitored asynchronously via
......
...@@ -3,14 +3,13 @@ ...@@ -3,14 +3,13 @@
# found in the LICENSE file. # found in the LICENSE file.
import logging import logging
import subprocess
import tempfile import tempfile
from telemetry import decorators from telemetry import decorators
from telemetry.core import bitmap
from telemetry.core import exceptions from telemetry.core import exceptions
from telemetry.core import platform from telemetry.core import platform
from telemetry.core import util from telemetry.core import util
from telemetry.core import video
from telemetry.core.platform import proc_supporting_platform_backend from telemetry.core.platform import proc_supporting_platform_backend
from telemetry.core.platform.power_monitor import android_ds2784_power_monitor from telemetry.core.platform.power_monitor import android_ds2784_power_monitor
from telemetry.core.platform.power_monitor import android_dumpsys_power_monitor from telemetry.core.platform.power_monitor import android_dumpsys_power_monitor
...@@ -219,6 +218,7 @@ class AndroidPlatformBackend( ...@@ -219,6 +218,7 @@ class AndroidPlatformBackend(
return self.GetOSVersionName() >= 'K' return self.GetOSVersionName() >= 'K'
def StartVideoCapture(self, min_bitrate_mbps): def StartVideoCapture(self, min_bitrate_mbps):
"""Starts the video capture at specified bitrate."""
min_bitrate_mbps = max(min_bitrate_mbps, 0.1) min_bitrate_mbps = max(min_bitrate_mbps, 0.1)
if min_bitrate_mbps > 100: if min_bitrate_mbps > 100:
raise ValueError('Android video capture cannot capture at %dmbps. ' raise ValueError('Android video capture cannot capture at %dmbps. '
...@@ -238,10 +238,10 @@ class AndroidPlatformBackend( ...@@ -238,10 +238,10 @@ class AndroidPlatformBackend(
def StopVideoCapture(self): def StopVideoCapture(self):
assert self.is_video_capture_running, 'Must start video capture first' assert self.is_video_capture_running, 'Must start video capture first'
self._video_recorder.Stop() self._video_recorder.Stop()
self._video_output = self._video_recorder.Pull() self._video_recorder.Pull()
self._video_recorder = None self._video_recorder = None
for frame in self._FramesFromMp4(self._video_output):
yield frame return video.Video(self, self._video_output)
def CanMonitorPower(self): def CanMonitorPower(self):
return self._powermonitor.CanMonitorPower() return self._powermonitor.CanMonitorPower()
...@@ -252,60 +252,6 @@ class AndroidPlatformBackend( ...@@ -252,60 +252,6 @@ class AndroidPlatformBackend(
def StopMonitoringPower(self): def StopMonitoringPower(self):
return self._powermonitor.StopMonitoringPower() return self._powermonitor.StopMonitoringPower()
def _FramesFromMp4(self, mp4_file):
if not self.CanLaunchApplication('avconv'):
self.InstallApplication('avconv')
def GetDimensions(video):
proc = subprocess.Popen(['avconv', '-i', video], stderr=subprocess.PIPE)
dimensions = None
output = ''
for line in proc.stderr.readlines():
output += line
if 'Video:' in line:
dimensions = line.split(',')[2]
dimensions = map(int, dimensions.split()[0].split('x'))
break
proc.communicate()
assert dimensions, ('Failed to determine video dimensions. output=%s' %
output)
return dimensions
def GetFrameTimestampMs(stderr):
"""Returns the frame timestamp in integer milliseconds from the dump log.
The expected line format is:
' dts=1.715 pts=1.715\n'
We have to be careful to only read a single timestamp per call to avoid
deadlock because avconv interleaves its writes to stdout and stderr.
"""
while True:
line = ''
next_char = ''
while next_char != '\n':
next_char = stderr.read(1)
line += next_char
if 'pts=' in line:
return int(1000 * float(line.split('=')[-1]))
dimensions = GetDimensions(mp4_file)
frame_length = dimensions[0] * dimensions[1] * 3
frame_data = bytearray(frame_length)
# Use rawvideo so that we don't need any external library to parse frames.
proc = subprocess.Popen(['avconv', '-i', mp4_file, '-vcodec',
'rawvideo', '-pix_fmt', 'rgb24', '-dump',
'-loglevel', 'debug', '-f', 'rawvideo', '-'],
stderr=subprocess.PIPE, stdout=subprocess.PIPE)
while True:
num_read = proc.stdout.readinto(frame_data)
if not num_read:
raise StopIteration
assert num_read == len(frame_data), 'Unexpected frame size: %d' % num_read
yield (GetFrameTimestampMs(proc.stderr),
bitmap.Bitmap(3, dimensions[0], dimensions[1], frame_data))
def _GetFileContents(self, fname): def _GetFileContents(self, fname):
if not self._can_access_protected_file_contents: if not self._can_access_protected_file_contents:
logging.warning('%s cannot be retrieved on non-rooted device.' % fname) logging.warning('%s cannot be retrieved on non-rooted device.' % fname)
......
...@@ -2,13 +2,9 @@ ...@@ -2,13 +2,9 @@
# Use of this source code is governed by a BSD-style license that can be # Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file. # found in the LICENSE file.
import logging
import os
import unittest import unittest
from telemetry import test from telemetry import test
from telemetry.core import bitmap
from telemetry.core import util
from telemetry.core.platform import android_platform_backend from telemetry.core.platform import android_platform_backend
from telemetry.unittest import system_stub from telemetry.unittest import system_stub
...@@ -70,34 +66,3 @@ class AndroidPlatformBackendTest(unittest.TestCase): ...@@ -70,34 +66,3 @@ class AndroidPlatformBackendTest(unittest.TestCase):
cpu_stats = backend.GetCpuStats('7702') cpu_stats = backend.GetCpuStats('7702')
self.assertEquals(cpu_stats, {}) self.assertEquals(cpu_stats, {})
@test.Disabled
def testFramesFromMp4(self):
mock_adb = MockDevice(MockAdbCommands([]))
backend = android_platform_backend.AndroidPlatformBackend(mock_adb, False)
try:
backend.InstallApplication('avconv')
finally:
if not backend.CanLaunchApplication('avconv'):
logging.warning('Test not supported on this platform')
return # pylint: disable=W0150
vid = os.path.join(util.GetUnittestDataDir(), 'vid.mp4')
expected_timestamps = [
0,
763,
783,
940,
1715,
1732,
1842,
1926,
]
# pylint: disable=W0212
for i, timestamp_bitmap in enumerate(backend._FramesFromMp4(vid)):
timestamp, bmp = timestamp_bitmap
self.assertEquals(timestamp, expected_timestamps[i])
expected_bitmap = bitmap.Bitmap.FromPngFile(os.path.join(
util.GetUnittestDataDir(), 'frame%d.png' % i))
self.assertTrue(expected_bitmap.IsEqual(bmp))
...@@ -2,16 +2,12 @@ ...@@ -2,16 +2,12 @@
# Use of this source code is governed by a BSD-style license that can be # Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file. # found in the LICENSE file.
from telemetry.core import bitmap from telemetry.core import video
from telemetry.core import web_contents from telemetry.core import web_contents
DEFAULT_TAB_TIMEOUT = 60 DEFAULT_TAB_TIMEOUT = 60
class BoundingBoxNotFoundException(Exception):
pass
class Tab(web_contents.WebContents): class Tab(web_contents.WebContents):
"""Represents a tab in the browser """Represents a tab in the browser
...@@ -25,7 +21,6 @@ class Tab(web_contents.WebContents): ...@@ -25,7 +21,6 @@ class Tab(web_contents.WebContents):
""" """
def __init__(self, inspector_backend, backend_list): def __init__(self, inspector_backend, backend_list):
super(Tab, self).__init__(inspector_backend, backend_list) super(Tab, self).__init__(inspector_backend, backend_list)
self._tab_contents_bounding_box = None
@property @property
def browser(self): def browser(self):
...@@ -135,7 +130,8 @@ class Tab(web_contents.WebContents): ...@@ -135,7 +130,8 @@ class Tab(web_contents.WebContents):
self.WaitForJavaScriptExpression( self.WaitForJavaScriptExpression(
'!window.__telemetry_screen_%d' % int(color), 5) '!window.__telemetry_screen_%d' % int(color), 5)
def StartVideoCapture(self, min_bitrate_mbps): def StartVideoCapture(self, min_bitrate_mbps,
highlight_bitmap=video.HIGHLIGHT_ORANGE_FRAME):
"""Starts capturing video of the tab's contents. """Starts capturing video of the tab's contents.
This works by flashing the entire tab contents to a arbitrary color and then This works by flashing the entire tab contents to a arbitrary color and then
...@@ -147,54 +143,9 @@ class Tab(web_contents.WebContents): ...@@ -147,54 +143,9 @@ class Tab(web_contents.WebContents):
The platform is free to deliver a higher bitrate if it can do so The platform is free to deliver a higher bitrate if it can do so
without increasing overhead. without increasing overhead.
""" """
self.Highlight(bitmap.WEB_PAGE_TEST_ORANGE) self.Highlight(highlight_bitmap)
self.browser.platform.StartVideoCapture(min_bitrate_mbps) self.browser.platform.StartVideoCapture(min_bitrate_mbps)
self.ClearHighlight(bitmap.WEB_PAGE_TEST_ORANGE) self.ClearHighlight(highlight_bitmap)
def _FindHighlightBoundingBox(self, bmp, color, bounds_tolerance=8,
color_tolerance=8):
"""Returns the bounding box of the content highlight of the given color.
Raises:
BoundingBoxNotFoundException if the hightlight could not be found.
"""
content_box, pixel_count = bmp.GetBoundingBox(color,
tolerance=color_tolerance)
if not content_box:
return None
# We assume arbitrarily that tabs are all larger than 200x200. If this
# fails it either means that assumption has changed or something is
# awry with our bounding box calculation.
if content_box[2] < 200 or content_box[3] < 200:
raise BoundingBoxNotFoundException('Unexpectedly small tab contents.')
# TODO(tonyg): Can this threshold be increased?
if pixel_count < 0.9 * content_box[2] * content_box[3]:
raise BoundingBoxNotFoundException(
'Low count of pixels in tab contents matching expected color.')
# Since we allow some fuzziness in bounding box finding, we want to make
# sure that the bounds are always stable across a run. So we cache the
# first box, whatever it may be.
#
# This relies on the assumption that since Telemetry doesn't know how to
# resize the window, we should always get the same content box for a tab.
# If this assumption changes, this caching needs to be reworked.
if not self._tab_contents_bounding_box:
self._tab_contents_bounding_box = content_box
# Verify that there is only minor variation in the bounding box. If it's
# just a few pixels, we can assume it's due to compression artifacts.
for x, y in zip(self._tab_contents_bounding_box, content_box):
if abs(x - y) > bounds_tolerance:
# If this fails, it means either that either the above assumption has
# changed or something is awry with our bounding box calculation.
raise BoundingBoxNotFoundException(
'Unexpected change in tab contents box.')
return self._tab_contents_bounding_box
@property @property
def is_video_capture_running(self): def is_video_capture_running(self):
...@@ -206,36 +157,10 @@ class Tab(web_contents.WebContents): ...@@ -206,36 +157,10 @@ class Tab(web_contents.WebContents):
This looks for the initial color flash in the first frame to establish the This looks for the initial color flash in the first frame to establish the
tab content boundaries and then omits all frames displaying the flash. tab content boundaries and then omits all frames displaying the flash.
Yields: Returns:
(time_ms, bitmap) tuples representing each video keyframe. Only the first video: A video object which is a telemetry.core.Video
frame in a run of sequential duplicate bitmaps is typically included.
time_ms is milliseconds since navigationStart.
bitmap is a telemetry.core.Bitmap.
""" """
frame_generator = self.browser.platform.StopVideoCapture() return self.browser.platform.StopVideoCapture()
# Flip through frames until we find the initial tab contents flash.
content_box = None
for _, bmp in frame_generator:
content_box = self._FindHighlightBoundingBox(
bmp, bitmap.WEB_PAGE_TEST_ORANGE)
if content_box:
break
if not content_box:
raise BoundingBoxNotFoundException(
'Failed to identify tab contents in video capture.')
# Flip through frames until the flash goes away and emit that as frame 0.
timestamp = 0
for timestamp, bmp in frame_generator:
if not self._FindHighlightBoundingBox(bmp, bitmap.WEB_PAGE_TEST_ORANGE):
yield 0, bmp.Crop(*content_box)
break
start_time = timestamp
for timestamp, bmp in frame_generator:
yield timestamp - start_time, bmp.Crop(*content_box)
def WaitForNavigate(self, timeout=DEFAULT_TAB_TIMEOUT): def WaitForNavigate(self, timeout=DEFAULT_TAB_TIMEOUT):
"""Waits for the navigation to complete. """Waits for the navigation to complete.
......
...@@ -6,6 +6,7 @@ import logging ...@@ -6,6 +6,7 @@ import logging
from telemetry import test from telemetry import test
from telemetry.core import bitmap from telemetry.core import bitmap
from telemetry.core import video
from telemetry.core import util from telemetry.core import util
from telemetry.core import exceptions from telemetry.core import exceptions
from telemetry.core.backends.chrome import tracing_backend from telemetry.core.backends.chrome import tracing_backend
...@@ -27,7 +28,7 @@ class FakePlatform(object): ...@@ -27,7 +28,7 @@ class FakePlatform(object):
def StopVideoCapture(self): def StopVideoCapture(self):
self._is_video_capture_running = False self._is_video_capture_running = False
return [] return video.Video(self, None)
def SetFullPerformanceModeEnabled(self, enabled): def SetFullPerformanceModeEnabled(self, enabled):
pass pass
...@@ -89,10 +90,7 @@ class TabTest(tab_test_case.TabTestCase): ...@@ -89,10 +90,7 @@ class TabTest(tab_test_case.TabTestCase):
self.assertFalse(self._tab.is_video_capture_running) self.assertFalse(self._tab.is_video_capture_running)
self._tab.StartVideoCapture(min_bitrate_mbps=2) self._tab.StartVideoCapture(min_bitrate_mbps=2)
self.assertTrue(self._tab.is_video_capture_running) self.assertTrue(self._tab.is_video_capture_running)
try: self.assertIsNotNone(self._tab.StopVideoCapture())
self._tab.StopVideoCapture().next()
except Exception:
pass
self.assertFalse(self._tab.is_video_capture_running) self.assertFalse(self._tab.is_video_capture_running)
self._tab.browser._platform = original_platform self._tab.browser._platform = original_platform
......
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import subprocess
from telemetry.core import bitmap
from telemetry.page import cloud_storage
HIGHLIGHT_ORANGE_FRAME = bitmap.WEB_PAGE_TEST_ORANGE
class BoundingBoxNotFoundException(Exception):
pass
class Video(object):
"""Utilities for storing and interacting with the video capture."""
def __init__(self, platform_backend, video_file_path):
self._platform_backend = platform_backend
# TODO(satyanarayana): Figure out when to delete this file.
self._video_file_path = video_file_path
self._tab_contents_bounding_box = None
def UploadToCloudStorage(self, bucket, target_path):
"""Uploads video file to cloud storage.
Args:
target_path: Path indicating where to store the file in cloud storage.
"""
cloud_storage.Insert(bucket, target_path, self._video_file_path)
def GetVideoFrameIter(self):
"""Returns the iteration for processing the video capture.
This looks for the initial color flash in the first frame to establish the
tab content boundaries and then omits all frames displaying the flash.
Yields:
(time_ms, bitmap) tuples representing each video keyframe. Only the first
frame in a run of sequential duplicate bitmaps is typically included.
time_ms is milliseconds since navigationStart.
bitmap is a telemetry.core.Bitmap.
"""
frame_generator = self._FramesFromMp4(self._video_file_path)
# Flip through frames until we find the initial tab contents flash.
content_box = None
for _, bmp in frame_generator:
content_box = self._FindHighlightBoundingBox(
bmp, HIGHLIGHT_ORANGE_FRAME)
if content_box:
break
if not content_box:
raise BoundingBoxNotFoundException(
'Failed to identify tab contents in video capture.')
# Flip through frames until the flash goes away and emit that as frame 0.
timestamp = 0
for timestamp, bmp in frame_generator:
if not self._FindHighlightBoundingBox(bmp, HIGHLIGHT_ORANGE_FRAME):
yield 0, bmp.Crop(*content_box)
break
start_time = timestamp
for timestamp, bmp in frame_generator:
yield timestamp - start_time, bmp.Crop(*content_box)
def _FindHighlightBoundingBox(self, bmp, color, bounds_tolerance=8,
color_tolerance=8):
"""Returns the bounding box of the content highlight of the given color.
Raises:
BoundingBoxNotFoundException if the hightlight could not be found.
"""
content_box, pixel_count = bmp.GetBoundingBox(color,
tolerance=color_tolerance)
if not content_box:
return None
# We assume arbitrarily that tabs are all larger than 200x200. If this
# fails it either means that assumption has changed or something is
# awry with our bounding box calculation.
if content_box[2] < 200 or content_box[3] < 200:
raise BoundingBoxNotFoundException('Unexpectedly small tab contents.')
# TODO(tonyg): Can this threshold be increased?
if pixel_count < 0.9 * content_box[2] * content_box[3]:
raise BoundingBoxNotFoundException(
'Low count of pixels in tab contents matching expected color.')
# Since we allow some fuzziness in bounding box finding, we want to make
# sure that the bounds are always stable across a run. So we cache the
# first box, whatever it may be.
#
# This relies on the assumption that since Telemetry doesn't know how to
# resize the window, we should always get the same content box for a tab.
# If this assumption changes, this caching needs to be reworked.
if not self._tab_contents_bounding_box:
self._tab_contents_bounding_box = content_box
# Verify that there is only minor variation in the bounding box. If it's
# just a few pixels, we can assume it's due to compression artifacts.
for x, y in zip(self._tab_contents_bounding_box, content_box):
if abs(x - y) > bounds_tolerance:
# If this fails, it means either that either the above assumption has
# changed or something is awry with our bounding box calculation.
raise BoundingBoxNotFoundException(
'Unexpected change in tab contents box.')
return self._tab_contents_bounding_box
def _FramesFromMp4(self, mp4_file):
if not self._platform_backend.CanLaunchApplication('avconv'):
self._platform_backend.InstallApplication('avconv')
def GetDimensions(video):
proc = subprocess.Popen(['avconv', '-i', video], stderr=subprocess.PIPE)
dimensions = None
output = ''
for line in proc.stderr.readlines():
output += line
if 'Video:' in line:
dimensions = line.split(',')[2]
dimensions = map(int, dimensions.split()[0].split('x'))
break
proc.communicate()
assert dimensions, ('Failed to determine video dimensions. output=%s' %
output)
return dimensions
def GetFrameTimestampMs(stderr):
"""Returns the frame timestamp in integer milliseconds from the dump log.
The expected line format is:
' dts=1.715 pts=1.715\n'
We have to be careful to only read a single timestamp per call to avoid
deadlock because avconv interleaves its writes to stdout and stderr.
"""
while True:
line = ''
next_char = ''
while next_char != '\n':
next_char = stderr.read(1)
line += next_char
if 'pts=' in line:
return int(1000 * float(line.split('=')[-1]))
dimensions = GetDimensions(mp4_file)
frame_length = dimensions[0] * dimensions[1] * 3
frame_data = bytearray(frame_length)
# Use rawvideo so that we don't need any external library to parse frames.
proc = subprocess.Popen(['avconv', '-i', mp4_file, '-vcodec',
'rawvideo', '-pix_fmt', 'rgb24', '-dump',
'-loglevel', 'debug', '-f', 'rawvideo', '-'],
stderr=subprocess.PIPE, stdout=subprocess.PIPE)
while True:
num_read = proc.stdout.readinto(frame_data)
if not num_read:
raise StopIteration
assert num_read == len(frame_data), 'Unexpected frame size: %d' % num_read
yield (GetFrameTimestampMs(proc.stderr),
bitmap.Bitmap(3, dimensions[0], dimensions[1], frame_data))
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import logging
import os
import unittest
from telemetry import test
from telemetry.core import bitmap
from telemetry.core import util
from telemetry.core import video
from telemetry.core.platform import android_platform_backend
from telemetry.unittest import system_stub
class MockAdbCommands(object):
def CanAccessProtectedFileContents(self):
return True
class MockDevice(object):
def __init__(self, mock_adb_commands):
self.old_interface = mock_adb_commands
class VideoTest(unittest.TestCase) :
def setUp(self):
self._stubs = system_stub.Override(android_platform_backend,
['perf_control', 'thermal_throttle'])
def tearDown(self):
self._stubs.Restore()
@test.Disabled
def testFramesFromMp4(self):
mock_adb = MockDevice(MockAdbCommands())
backend = android_platform_backend.AndroidPlatformBackend(mock_adb, False)
try:
backend.InstallApplication('avconv')
finally:
if not backend.CanLaunchApplication('avconv'):
logging.warning('Test not supported on this platform')
return # pylint: disable=W0150
vid = os.path.join(util.GetUnittestDataDir(), 'vid.mp4')
expected_timestamps = [
0,
763,
783,
940,
1715,
1732,
1842,
1926,
]
video_obj = video.Video(backend, vid)
# Calling _FramesFromMp4 should return all frames.
# pylint: disable=W0212
for i, timestamp_bitmap in enumerate(video_obj._FramesFromMp4(vid)):
timestamp, bmp = timestamp_bitmap
self.assertEquals(timestamp, expected_timestamps[i])
expected_bitmap = bitmap.Bitmap.FromPngFile(os.path.join(
util.GetUnittestDataDir(), 'frame%d.png' % i))
self.assertTrue(expected_bitmap.IsEqual(bmp))
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment