Chromium Code Reviews| OLD | NEW |
|---|---|
| (Empty) | |
| 1 # Copyright 2017 The Chromium Authors. All rights reserved. | |
| 2 # Use of this source code is governed by a BSD-style license that can be | |
| 3 # found in the LICENSE file. | |
| 4 | |
| 5 """Script for automatically measuring motion-to-photon latency for VR. | |
| 6 | |
| 7 Doing so requires two specialized pieces of hardware. The first is a Motopho, | |
| 8 which when used with a VR flicker app, finds the delay between movement and | |
| 9 the test device's screen updating in response to the movement. The second is | |
| 10 a set of servos, which physically moves the test device and Motopho during the | |
| 11 latency test. | |
| 12 """ | |
| 13 | |
| 14 import argparse | |
| 15 import glob | |
| 16 import httplib | |
| 17 import logging | |
| 18 import os | |
| 19 import re | |
| 20 import serial | |
| 21 import subprocess | |
| 22 import sys | |
| 23 import threading | |
| 24 import time | |
| 25 | |
| 26 # RobotArm connection constants | |
| 27 BAUD_RATE = 115200 | |
| 28 CONNECTION_TIMEOUT = 3.0 | |
| 29 NUM_TRIES = 5 | |
| 30 # Motopho constants | |
| 31 DEFAULT_ADB_PATH = ('/home/gtvchrome/tools/android/android-sdk-linux/' | |
| 32 'platform-tools/adb') | |
| 33 DEFAULT_MOTOPHO_PATH = '/home/gtvchrome/motopho/Motopho' | |
|
Nico
2017/04/10 18:41:28
This will be correct approximately nowhere. Either
bsheedy
2017/04/10 22:36:15
Done via first approach. Also added a TODO about m
| |
| 34 MOTOPHO_THREAD_TIMEOUT = 30 | |
| 35 | |
| 36 class MotophoThread(threading.Thread): | |
| 37 """Handles the running of the Motopho script and extracting results.""" | |
| 38 def __init__(self): | |
| 39 threading.Thread.__init__(self) | |
| 40 self._latency = None | |
| 41 self._max_correlation = None | |
| 42 | |
| 43 def run(self): | |
| 44 motopho_output = "" | |
| 45 try: | |
| 46 motopho_output = subprocess.check_output(["./motophopro_nograph"], | |
| 47 stderr=subprocess.STDOUT) | |
| 48 except subprocess.CalledProcessError as e: | |
| 49 logging.error('Failed to run Motopho script: %s', e.output) | |
| 50 raise e | |
| 51 | |
| 52 if "FAIL" in motopho_output: | |
| 53 logging.error('Failed to get latency, logging raw output: %s', | |
| 54 motopho_output) | |
| 55 return 1 | |
| 56 | |
| 57 self._latency = None | |
| 58 self._max_correlation = None | |
| 59 for line in motopho_output.split("\n"): | |
| 60 if 'Motion-to-photon latency:' in line: | |
| 61 self._latency = float(line.split(" ")[-2]) | |
| 62 if 'Max correlation is' in line: | |
| 63 self._max_correlation = float(line.split(' ')[-1]) | |
| 64 if self._latency and self._max_correlation: | |
| 65 break; | |
| 66 | |
| 67 @property | |
| 68 def latency(self): | |
| 69 return self._latency | |
| 70 | |
| 71 @property | |
| 72 def max_correlation(self): | |
| 73 return self._max_correlation | |
| 74 | |
| 75 | |
| 76 class RobotArm(): | |
| 77 """Handles the serial communication with the servos/arm used for movement.""" | |
| 78 def __init__(self, device_name, num_tries, baud, timeout): | |
| 79 self._connection = None | |
| 80 connected = False | |
| 81 for _ in xrange(num_tries): | |
| 82 try: | |
| 83 self._connection = serial.Serial('/dev/' + device_name, | |
| 84 baud, | |
| 85 timeout=timeout) | |
| 86 except serial.SerialException as e: | |
| 87 pass | |
| 88 if self._connection and 'Enter parameters' in self._connection.read(1024): | |
| 89 connected = True | |
| 90 break | |
| 91 if not connected: | |
| 92 raise serial.SerialException('Failed to connect to the robot arm.') | |
| 93 | |
| 94 def StartMotophoMovement(self): | |
| 95 if not self._connection: | |
| 96 return | |
| 97 self._connection.write('9\n') | |
| 98 | |
| 99 def StopAllMovement(self): | |
| 100 if not self._connection: | |
| 101 return | |
| 102 self._connection.write('0\n') | |
| 103 | |
| 104 | |
| 105 def GetParsedArgs(): | |
| 106 """Parses the command line arguments passed to the script. | |
| 107 | |
| 108 Fails if any unknown arguments are present. | |
| 109 """ | |
| 110 parser = argparse.ArgumentParser() | |
| 111 parser.add_argument('--adb-path', | |
| 112 type=os.path.realpath, | |
| 113 help='The absolute path to adb', | |
| 114 default=DEFAULT_ADB_PATH) | |
| 115 parser.add_argument('--motopho-path', | |
| 116 type=os.path.realpath, | |
| 117 help='The absolute path to the directory with Motopho ' | |
| 118 'scripts', | |
| 119 default=DEFAULT_MOTOPHO_PATH) | |
| 120 parser.add_argument('--output-dir', | |
| 121 type=os.path.realpath, | |
| 122 help='The directory where the script\'s output files ' | |
| 123 'will be saved') | |
| 124 parser.add_argument('-v', '--verbose', | |
| 125 dest='verbose_count', default=0, action='count', | |
| 126 help='Verbose level (multiple times for more)') | |
| 127 (args, unknown_args) = parser.parse_known_args() | |
| 128 SetLogLevel(args.verbose_count) | |
| 129 if unknown_args: | |
| 130 parser.error('Received unknown arguments: %s' % ' '.join(unknown_args)) | |
| 131 return args | |
| 132 | |
| 133 | |
| 134 def SetLogLevel(verbose_count): | |
| 135 """Sets the log level based on the command line arguments.""" | |
| 136 log_level = logging.WARNING | |
| 137 if verbose_count == 1: | |
| 138 log_level = logging.INFO | |
| 139 elif verbose_count >= 2: | |
| 140 log_level = logging.DEBUG | |
| 141 logger = logging.getLogger() | |
| 142 logger.setLevel(log_level) | |
| 143 | |
| 144 | |
| 145 # Taken from Daydream's perf test suite | |
|
Nico
2017/04/10 18:41:28
Do you have permissions to open-source that code?
bsheedy
2017/04/10 22:36:15
I checked with the Daydream team, and it turns out
| |
| 146 def GetTtyDevices(tty_pattern, vendor_ids): | |
| 147 """Find all devices connected to tty that match a pattern and device id. | |
| 148 | |
| 149 If a serial device is connected to the computer via USB, this function | |
| 150 will check all tty devices that match tty_pattern, and return the ones | |
| 151 that have vendor identification number in the list vendor_ids. | |
| 152 | |
| 153 Args: | |
| 154 tty_pattern: The search pattern, such as r'ttyACM\d+'. | |
| 155 vendor_ids: The list of 16-bit USB vendor ids, such as [0x2a03]. | |
| 156 | |
| 157 Returns: | |
| 158 A list of strings of tty devices, for example ['ttyACM0']. | |
| 159 """ | |
| 160 product_string = 'PRODUCT=' | |
| 161 sys_class_dir = '/sys/class/tty/' | |
| 162 | |
| 163 tty_devices = glob.glob(sys_class_dir + '*') | |
| 164 | |
| 165 matcher = re.compile('.*' + tty_pattern) | |
| 166 tty_matches = [x for x in tty_devices if matcher.search(x)] | |
| 167 tty_matches = [x[len(sys_class_dir):] for x in tty_matches] | |
| 168 | |
| 169 found_devices = [] | |
| 170 for match in tty_matches: | |
| 171 class_filename = sys_class_dir + match + '/device/uevent' | |
| 172 with open(class_filename, 'r') as uevent_file: | |
| 173 # Look for the desired product id in the uevent text. | |
| 174 for line in uevent_file: | |
| 175 if product_string in line: | |
| 176 ids = line[len(product_string):].split('/') | |
| 177 ids = [int(x, 16) for x in ids] | |
| 178 | |
| 179 for desired_id in vendor_ids: | |
| 180 if desired_id in ids: | |
| 181 found_devices.append(match) | |
| 182 | |
| 183 return found_devices | |
| 184 | |
| 185 | |
| 186 def RunCommand(cmd): | |
| 187 """Runs the given cmd list. | |
| 188 | |
| 189 Prints the command's output and exits if any error occurs. | |
| 190 """ | |
| 191 try: | |
| 192 subprocess.check_output(cmd, stderr=subprocess.STDOUT) | |
| 193 except subprocess.CalledProcessError as e: | |
| 194 logging.error('Failed command output: %s', e.output) | |
| 195 raise e | |
| 196 | |
| 197 | |
| 198 def SetChromeCommandLineFlags(adb_path, flags): | |
| 199 """Sets the given Chrome command line flags.""" | |
| 200 RunCommand([adb_path, | |
| 201 'shell', "echo 'chrome " + ' '.join(flags) + "' > " | |
| 202 + '/data/local/tmp/chrome-command-line']) | |
| 203 | |
| 204 | |
| 205 def main(): | |
| 206 args = GetParsedArgs() | |
| 207 | |
| 208 RunCommand([args.adb_path, 'root']) | |
| 209 RunCommand([args.adb_path, 'install', '-r', 'apks/ChromePublic.apk']) | |
| 210 # Force WebVR support and don't have first run experience | |
| 211 SetChromeCommandLineFlags(args.adb_path, ['--enable-webvr', '--disable-fre']) | |
| 212 | |
| 213 # Motopho scripts use relative paths, so switch to the Motopho directory | |
| 214 os.chdir(args.motopho_path) | |
| 215 | |
| 216 # Connect to the Arduino that drives the servos | |
| 217 devices = GetTtyDevices(r'ttyACM\d+', [0x2a03, 0x2341]) | |
| 218 if len(devices) != 1: | |
| 219 logging.error('Found %d devices, expected 1', len(devices)) | |
| 220 return 1 | |
| 221 robot_arm = RobotArm(devices[0], NUM_TRIES, BAUD_RATE, CONNECTION_TIMEOUT) | |
| 222 | |
| 223 # Wake the device | |
| 224 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_WAKEUP']) | |
| 225 # Sleep a bit, otherwise WebGL can crash when Canary starts | |
| 226 time.sleep(1) | |
| 227 | |
| 228 # Start Chrome and go to the flicker app | |
| 229 # TODO(bsheedy): See about having versioned copies of the flicker app instead | |
| 230 # of using personal github. | |
| 231 RunCommand([args.adb_path, 'shell', 'am', 'start', | |
| 232 '-a', 'android.intent.action.MAIN', | |
| 233 '-n', 'org.chromium.chrome/com.google.android.apps.chrome.Main', | |
| 234 'https://weableandbob.github.io/Motopho/flicker_apps/webvr/webvr- flicker-app-klaus.html?polyfill=0\&canvasClickPresents=1']) | |
| 235 time.sleep(10) | |
| 236 | |
| 237 # Tap the screen to start presenting | |
| 238 RunCommand([args.adb_path, 'shell', 'input', 'touchscreen', | |
| 239 'tap', '800', '800']) | |
| 240 # Wait for VR to fully start up | |
| 241 time.sleep(5) | |
| 242 | |
| 243 # Start the Motopho script | |
| 244 motopho_thread = MotophoThread() | |
| 245 motopho_thread.start() | |
| 246 # Let the Motopho be stationary so the script can calculate its bias | |
| 247 time.sleep(3) | |
| 248 | |
| 249 # Move so we can measure latency | |
| 250 robot_arm.StartMotophoMovement() | |
| 251 motopho_thread.join(MOTOPHO_THREAD_TIMEOUT) | |
| 252 if motopho_thread.isAlive(): | |
| 253 # TODO(bsheedy): Look into ways to prevent Motopho from not sending any | |
| 254 # data until unplugged and replugged into the machine after a reboot. | |
| 255 logging.error('Motopho thread timeout, Motopho may need to be replugged.') | |
| 256 robot_arm.StopAllMovement() | |
| 257 | |
| 258 logging.info('Latency: %s', motopho_thread.latency) | |
| 259 logging.info('Max correlation: %s', motopho_thread.max_correlation) | |
| 260 | |
| 261 # TODO(bsheedy): Change this to output JSON compatible with the performance | |
| 262 # dashboard. | |
| 263 if args.output_dir and os.path.isdir(args.output_dir): | |
| 264 with file(os.path.join(args.output_dir, 'output.txt'), 'w') as outfile: | |
| 265 outfile.write('Latency: %s\nMax correlation: %s\n' % | |
| 266 (motopho_thread.latency, motopho_thread.max_correlation)) | |
| 267 | |
| 268 # Exit VR and Close Chrome | |
| 269 # TODO(bsheedy): See about closing current tab before exiting so they don't | |
| 270 # pile up over time. | |
| 271 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_BACK']) | |
| 272 RunCommand([args.adb_path, 'shell', 'am', 'force-stop', | |
| 273 'org.chromium.chrome']) | |
| 274 | |
| 275 # Turn off the screen | |
| 276 RunCommand([args.adb_path, 'shell', 'input', 'keyevent', 'KEYCODE_POWER']) | |
| 277 | |
| 278 return 0 | |
| 279 | |
| 280 if __name__ == '__main__': | |
| 281 sys.exit(main()) | |
| OLD | NEW |