Home | History | Annotate | Download | only in sensor_fusion
      1 # Copyright 2014 The Android Open Source Project
      2 #
      3 # Licensed under the Apache License, Version 2.0 (the "License");
      4 # you may not use this file except in compliance with the License.
      5 # You may obtain a copy of the License at
      6 #
      7 #      http://www.apache.org/licenses/LICENSE-2.0
      8 #
      9 # Unless required by applicable law or agreed to in writing, software
     10 # distributed under the License is distributed on an "AS IS" BASIS,
     11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     12 # See the License for the specific language governing permissions and
     13 # limitations under the License.
     14 
     15 import its.image
     16 import its.device
     17 import its.objects
     18 import time
     19 import math
     20 import pylab
     21 import os.path
     22 import matplotlib
     23 import matplotlib.pyplot
     24 import json
     25 import Image
     26 import numpy
     27 import cv2
     28 import bisect
     29 import scipy.spatial
     30 import sys
     31 
     32 NAME = os.path.basename(__file__).split(".")[0]
     33 
     34 # Capture 210 QVGA frames (which is 7s at 30fps)
     35 N = 210
     36 W,H = 320,240
     37 
     38 FEATURE_PARAMS = dict( maxCorners = 50,
     39                        qualityLevel = 0.3,
     40                        minDistance = 7,
     41                        blockSize = 7 )
     42 
     43 LK_PARAMS = dict( winSize  = (15, 15),
     44                   maxLevel = 2,
     45                   criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
     46                         10, 0.03))
     47 
     48 # Constants to convert between different time units (for clarity).
     49 SEC_TO_NSEC = 1000*1000*1000.0
     50 SEC_TO_MSEC = 1000.0
     51 MSEC_TO_NSEC = 1000*1000.0
     52 MSEC_TO_SEC = 1/1000.0
     53 NSEC_TO_SEC = 1/(1000*1000*1000.0)
     54 NSEC_TO_MSEC = 1/(1000*1000.0)
     55 
     56 # Pass/fail thresholds.
     57 THRESH_MAX_CORR_DIST = 0.005
     58 THRESH_MAX_SHIFT_MS = 2
     59 THRESH_MIN_ROT = 0.001
     60 
     61 def main():
     62     """Test if image and motion sensor events are well synchronized.
     63 
     64     The instructions for running this test are in the SensorFusion.pdf file in
     65     the same directory as this test.
     66 
     67     The command-line argument "replay" may be optionally provided. Without this
     68     argument, the test will collect a new set of camera+gyro data from the
     69     device and then analyze it (and it will also dump this data to files in the
     70     current directory). If the "replay" argument is provided, then the script
     71     will instead load the dumped data from a previous run and analyze that
     72     instead. This can be helpful for developers who are digging for additional
     73     information on their measurements.
     74     """
     75 
     76     # Collect or load the camera+gyro data. All gyro events as well as camera
     77     # timestamps are in the "events" dictionary, and "frames" is a list of
     78     # RGB images as numpy arrays.
     79     if "replay" not in sys.argv:
     80         events, frames = collect_data()
     81     else:
     82         events, frames = load_data()
     83 
     84     # Sanity check camera timestamps are enclosed by sensor timestamps
     85     # This will catch bugs where camera and gyro timestamps go completely out
     86     # of sync
     87     cam_times = get_cam_times(events["cam"])
     88     min_cam_time = min(cam_times) * NSEC_TO_SEC
     89     max_cam_time = max(cam_times) * NSEC_TO_SEC
     90     gyro_times = [e["time"] for e in events["gyro"]]
     91     min_gyro_time = min(gyro_times) * NSEC_TO_SEC
     92     max_gyro_time = max(gyro_times) * NSEC_TO_SEC
     93     if not (min_cam_time > min_gyro_time and max_cam_time < max_gyro_time):
     94         print "Test failed: camera timestamps [%f,%f] " \
     95               "are not enclosed by gyro timestamps [%f, %f]" % (
     96             min_cam_time, max_cam_time, min_gyro_time, max_gyro_time)
     97         assert(0)
     98 
     99     # Compute the camera rotation displacements (rad) between each pair of
    100     # adjacent frames.
    101     cam_rots = get_cam_rotations(frames)
    102     if max(abs(cam_rots)) < THRESH_MIN_ROT:
    103         print "Device wasn't moved enough"
    104         assert(0)
    105 
    106     # Find the best offset (time-shift) to align the gyro and camera motion
    107     # traces; this function integrates the shifted gyro data between camera
    108     # samples for a range of candidate shift values, and returns the shift that
    109     # result in the best correlation.
    110     offset = get_best_alignment_offset(cam_times, cam_rots, events["gyro"])
    111 
    112     # Plot the camera and gyro traces after applying the best shift.
    113     cam_times = cam_times + offset*SEC_TO_NSEC
    114     gyro_rots = get_gyro_rotations(events["gyro"], cam_times)
    115     plot_rotations(cam_rots, gyro_rots)
    116 
    117     # Pass/fail based on the offset and also the correlation distance.
    118     dist = scipy.spatial.distance.correlation(cam_rots,gyro_rots)
    119     print "Best correlation of %f at shift of %.2fms"%(dist, offset*SEC_TO_MSEC)
    120     assert(dist < THRESH_MAX_CORR_DIST)
    121     assert(abs(offset) < THRESH_MAX_SHIFT_MS*MSEC_TO_SEC)
    122 
    123 def get_best_alignment_offset(cam_times, cam_rots, gyro_events):
    124     """Find the best offset to align the camera and gyro traces.
    125 
    126     Uses a correlation distance metric between the curves, where a smaller
    127     value means that the curves are better-correlated.
    128 
    129     Args:
    130         cam_times: Array of N camera times, one for each frame.
    131         cam_rots: Array of N-1 camera rotation displacements (rad).
    132         gyro_events: List of gyro event objects.
    133 
    134     Returns:
    135         Offset (seconds) of the best alignment.
    136     """
    137     # Measure the corr. dist. over a shift of up to +/- 100ms (1ms step size).
    138     # Get the shift corresponding to the best (lowest) score.
    139     candidates = range(-100,101)
    140     dists = []
    141     for shift in candidates:
    142         times = cam_times + shift*MSEC_TO_NSEC
    143         gyro_rots = get_gyro_rotations(gyro_events, times)
    144         dists.append(scipy.spatial.distance.correlation(cam_rots,gyro_rots))
    145     best_corr_dist = min(dists)
    146     best_shift = candidates[dists.index(best_corr_dist)]
    147 
    148     # Fit a curve to the corr. dist. data to measure the minima more
    149     # accurately, by looking at the correlation distances within a range of
    150     # +/- 20ms from the measured best score; note that this will use fewer
    151     # than the full +/- 20 range for the curve fit if the measured score
    152     # (which is used as the center of the fit) is within 20ms of the edge of
    153     # the +/- 100ms candidate range.
    154     i = len(dists)/2 + best_shift
    155     candidates = candidates[i-20:i+21]
    156     dists = dists[i-20:i+21]
    157     a,b,c = numpy.polyfit(candidates, dists, 2)
    158     exact_best_shift = -b/(2*a)
    159     if abs(best_shift - exact_best_shift) > 2.0 or a <= 0 or c <= 0:
    160         print "Test failed; bad fit to time-shift curve"
    161         assert(0)
    162 
    163     xfit = [x/10.0 for x in xrange(candidates[0]*10,candidates[-1]*10)]
    164     yfit = [a*x*x+b*x+c for x in xfit]
    165     fig = matplotlib.pyplot.figure()
    166     pylab.plot(candidates, dists, 'r', label="data")
    167     pylab.plot(xfit, yfit, 'b', label="fit")
    168     pylab.plot([exact_best_shift+x for x in [-0.1,0,0.1]], [0,0.01,0], 'b')
    169     pylab.xlabel("Relative horizontal shift between curves (ms)")
    170     pylab.ylabel("Correlation distance")
    171     pylab.legend()
    172     matplotlib.pyplot.savefig("%s_plot_shifts.png" % (NAME))
    173 
    174     return exact_best_shift * MSEC_TO_SEC
    175 
    176 def plot_rotations(cam_rots, gyro_rots):
    177     """Save a plot of the camera vs. gyro rotational measurements.
    178 
    179     Args:
    180         cam_rots: Array of N-1 camera rotation measurements (rad).
    181         gyro_rots: Array of N-1 gyro rotation measurements (rad).
    182     """
    183     # For the plot, scale the rotations to be in degrees.
    184     fig = matplotlib.pyplot.figure()
    185     cam_rots = cam_rots * (360/(2*math.pi))
    186     gyro_rots = gyro_rots * (360/(2*math.pi))
    187     pylab.plot(range(len(cam_rots)), cam_rots, 'r', label="camera")
    188     pylab.plot(range(len(gyro_rots)), gyro_rots, 'b', label="gyro")
    189     pylab.legend()
    190     pylab.xlabel("Camera frame number")
    191     pylab.ylabel("Angular displacement between adjacent camera frames (deg)")
    192     pylab.xlim([0, len(cam_rots)])
    193     matplotlib.pyplot.savefig("%s_plot.png" % (NAME))
    194 
    195 def get_gyro_rotations(gyro_events, cam_times):
    196     """Get the rotation values of the gyro.
    197 
    198     Integrates the gyro data between each camera frame to compute an angular
    199     displacement. Uses simple Euler approximation to implement the
    200     integration.
    201 
    202     Args:
    203         gyro_events: List of gyro event objects.
    204         cam_times: Array of N camera times, one for each frame.
    205 
    206     Returns:
    207         Array of N-1 gyro rotation measurements (rad).
    208     """
    209     all_times = numpy.array([e["time"] for e in gyro_events])
    210     all_rots = numpy.array([e["z"] for e in gyro_events])
    211     gyro_rots = []
    212     # Integrate the gyro data between each pair of camera frame times.
    213     for icam in range(len(cam_times)-1):
    214         # Get the window of gyro samples within the current pair of frames.
    215         tcam0 = cam_times[icam]
    216         tcam1 = cam_times[icam+1]
    217         igyrowindow0 = bisect.bisect(all_times, tcam0)
    218         igyrowindow1 = bisect.bisect(all_times, tcam1)
    219         sgyro = 0
    220         # Integrate samples within the window.
    221         for igyro in range(igyrowindow0, igyrowindow1):
    222             vgyro0 = all_rots[igyro]
    223             vgyro1 = all_rots[igyro+1]
    224             tgyro0 = all_times[igyro]
    225             tgyro1 = all_times[igyro+1]
    226             vgyro = 0.5 * (vgyro0 + vgyro1)
    227             deltatgyro = (tgyro1 - tgyro0) * NSEC_TO_SEC
    228             sgyro += vgyro * deltatgyro
    229         # Handle the fractional intervals at the sides of the window.
    230         for side,igyro in enumerate([igyrowindow0-1, igyrowindow1]):
    231             vgyro0 = all_rots[igyro]
    232             vgyro1 = all_rots[igyro+1]
    233             tgyro0 = all_times[igyro]
    234             tgyro1 = all_times[igyro+1]
    235             vgyro = 0.5 * (vgyro0 + vgyro1)
    236             deltatgyro = (tgyro1 - tgyro0) * NSEC_TO_SEC
    237             if side == 0:
    238                 f = (tcam0 - tgyro0) / (tgyro1 - tgyro0)
    239                 sgyro += vgyro * deltatgyro * (1.0 - f)
    240             else:
    241                 f = (tcam1 - tgyro0) / (tgyro1 - tgyro0)
    242                 sgyro += vgyro * deltatgyro * f
    243         gyro_rots.append(sgyro)
    244     gyro_rots = numpy.array(gyro_rots)
    245     return gyro_rots
    246 
    247 def get_cam_rotations(frames):
    248     """Get the rotations of the camera between each pair of frames.
    249 
    250     Takes N frames and returns N-1 angular displacements corresponding to the
    251     rotations between adjacent pairs of frames, in radians.
    252 
    253     Args:
    254         frames: List of N images (as RGB numpy arrays).
    255 
    256     Returns:
    257         Array of N-1 camera rotation measurements (rad).
    258     """
    259     gframes = []
    260     for frame in frames:
    261         frame = (frame * 255.0).astype(numpy.uint8)
    262         gframes.append(cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY))
    263     rots = []
    264     for i in range(1,len(gframes)):
    265         gframe0 = gframes[i-1]
    266         gframe1 = gframes[i]
    267         p0 = cv2.goodFeaturesToTrack(gframe0, mask=None, **FEATURE_PARAMS)
    268         p1,st,_ = cv2.calcOpticalFlowPyrLK(gframe0, gframe1, p0, None,
    269                 **LK_PARAMS)
    270         tform = procrustes_rotation(p0[st==1], p1[st==1])
    271         # TODO: Choose the sign for the rotation so the cam matches the gyro
    272         rot = -math.atan2(tform[0, 1], tform[0, 0])
    273         rots.append(rot)
    274         if i == 1:
    275             # Save a debug visualization of the features that are being
    276             # tracked in the first frame.
    277             frame = frames[i]
    278             for x,y in p0[st==1]:
    279                 cv2.circle(frame, (x,y), 3, (100,100,255), -1)
    280             its.image.write_image(frame, "%s_features.jpg"%(NAME))
    281     return numpy.array(rots)
    282 
    283 def get_cam_times(cam_events):
    284     """Get the camera frame times.
    285 
    286     Args:
    287         cam_events: List of (start_exposure, exposure_time, readout_duration)
    288             tuples, one per captured frame, with times in nanoseconds.
    289 
    290     Returns:
    291         frame_times: Array of N times, one corresponding to the "middle" of
    292             the exposure of each frame.
    293     """
    294     # Assign a time to each frame that assumes that the image is instantly
    295     # captured in the middle of its exposure.
    296     starts = numpy.array([start for start,exptime,readout in cam_events])
    297     exptimes = numpy.array([exptime for start,exptime,readout in cam_events])
    298     readouts = numpy.array([readout for start,exptime,readout in cam_events])
    299     frame_times = starts + (exptimes + readouts) / 2.0
    300     return frame_times
    301 
    302 def load_data():
    303     """Load a set of previously captured data.
    304 
    305     Returns:
    306         events: Dictionary containing all gyro events and cam timestamps.
    307         frames: List of RGB images as numpy arrays.
    308     """
    309     with open("%s_events.txt"%(NAME), "r") as f:
    310         events = json.loads(f.read())
    311     n = len(events["cam"])
    312     frames = []
    313     for i in range(n):
    314         img = Image.open("%s_frame%03d.jpg"%(NAME,i))
    315         w,h = img.size[0:2]
    316         frames.append(numpy.array(img).reshape(h,w,3) / 255.0)
    317     return events, frames
    318 
    319 def collect_data():
    320     """Capture a new set of data from the device.
    321 
    322     Captures both motion data and camera frames, while the user is moving
    323     the device in a proscribed manner.
    324 
    325     Returns:
    326         events: Dictionary containing all gyro events and cam timestamps.
    327         frames: List of RGB images as numpy arrays.
    328     """
    329     with its.device.ItsSession() as cam:
    330         print "Starting sensor event collection"
    331         cam.start_sensor_events()
    332 
    333         # Sleep a few seconds for gyro events to stabilize.
    334         time.sleep(5)
    335 
    336         # TODO: Ensure that OIS is disabled; set to DISABLE and wait some time.
    337 
    338         # Capture the frames.
    339         props = cam.get_camera_properties()
    340         fmt = {"format":"yuv", "width":W, "height":H}
    341         s,e,_,_,_ = cam.do_3a(get_results=True)
    342         req = its.objects.manual_capture_request(s, e)
    343         print "Capturing %dx%d with sens. %d, exp. time %.1fms" % (
    344                 W, H, s, e*NSEC_TO_MSEC)
    345         caps = cam.do_capture([req]*N, fmt)
    346 
    347         # Get the gyro events.
    348         print "Reading out sensor events"
    349         gyro = cam.get_sensor_events()["gyro"]
    350 
    351         # Combine the events into a single structure.
    352         print "Dumping event data"
    353         starts = [c["metadata"]["android.sensor.timestamp"] for c in caps]
    354         exptimes = [c["metadata"]["android.sensor.exposureTime"] for c in caps]
    355         readouts = [c["metadata"]["android.sensor.rollingShutterSkew"]
    356                     for c in caps]
    357         events = {"gyro": gyro, "cam": zip(starts,exptimes,readouts)}
    358         with open("%s_events.txt"%(NAME), "w") as f:
    359             f.write(json.dumps(events))
    360 
    361         # Convert the frames to RGB.
    362         print "Dumping frames"
    363         frames = []
    364         for i,c in enumerate(caps):
    365             img = its.image.convert_capture_to_rgb_image(c)
    366             frames.append(img)
    367             its.image.write_image(img, "%s_frame%03d.jpg"%(NAME,i))
    368 
    369         return events, frames
    370 
    371 def procrustes_rotation(X, Y):
    372     """
    373     Procrustes analysis determines a linear transformation (translation,
    374     reflection, orthogonal rotation and scaling) of the points in Y to best
    375     conform them to the points in matrix X, using the sum of squared errors
    376     as the goodness of fit criterion.
    377 
    378     Args:
    379         X, Y: Matrices of target and input coordinates.
    380 
    381     Returns:
    382         The rotation component of the transformation that maps X to Y.
    383     """
    384     X0 = (X-X.mean(0)) / numpy.sqrt(((X-X.mean(0))**2.0).sum())
    385     Y0 = (Y-Y.mean(0)) / numpy.sqrt(((Y-Y.mean(0))**2.0).sum())
    386     U,s,Vt = numpy.linalg.svd(numpy.dot(X0.T, Y0),full_matrices=False)
    387     return numpy.dot(Vt.T, U.T)
    388 
    389 if __name__ == '__main__':
    390     main()
    391 
    392