diff options
Diffstat (limited to 'ios/WALT/DragLatencyController.mm')
-rw-r--r-- | ios/WALT/DragLatencyController.mm | 393 |
1 files changed, 393 insertions, 0 deletions
diff --git a/ios/WALT/DragLatencyController.mm b/ios/WALT/DragLatencyController.mm new file mode 100644 index 0000000..5b6b9b4 --- /dev/null +++ b/ios/WALT/DragLatencyController.mm @@ -0,0 +1,393 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#import "DragLatencyController.h" + +#import <dispatch/dispatch.h> +#import <math.h> +#import <numeric> +#import <vector> + +#import "UIAlertView+Extensions.h" +#import "WALTAppDelegate.h" +#import "WALTClient.h" +#import "WALTLogger.h" +#import "WALTTouch.h" + +static const NSTimeInterval kGoalpostFrequency = 0.55; // TODO(pquinn): User-configurable settings. +static const NSUInteger kMinTouchEvents = 100; +static const NSUInteger kMinLaserEvents = 8; +static const char kWALTLaserTag = 'L'; + +@interface WALTLaserEvent : NSObject +@property (assign) NSTimeInterval t; +@property (assign) int value; +@end + +@implementation WALTLaserEvent +@end + +/** Linear interpolation between x0 and x1 at alpha. */ +template <typename T> +static T Lerp(const T& x0, const T& x1, double alpha) { + NSCAssert(alpha >= 0 && alpha <= 1, @"alpha must be between 0 and 1 (%f)", alpha); + return ((1 - alpha) * x0) + (alpha * x1); +} + +/** Linear interpolation of (xp, yp) at x. */ +template <typename S, typename T> +static std::vector<T> Interpolate(const std::vector<S>& x, + const std::vector<S>& xp, + const std::vector<T>& yp) { + NSCAssert(xp.size(), @"xp must contain at least one value."); + NSCAssert(xp.size() == yp.size(), @"xp and yp must have matching lengths."); + + std::vector<T> y; + y.reserve(x.size()); + + size_t i = 0; // Index into x. + + for (; i < x.size() && x[i] < xp.front(); ++i) { + y.push_back(yp.front()); // Pad out y with yp.front() for x values before xp.front(). + } + + size_t ip = 0; // Index into xp/yp. + + for (; ip < xp.size() && i < x.size(); ++i) { + while (ip < xp.size() && xp[ip] <= x[i]) { // Find an xp[ip] greater than x[i]. + ++ip; + } + if (ip >= xp.size()) { + break; // Ran out of values. + } + + const double alpha = (x[i] - xp[ip - 1]) / static_cast<double>(xp[ip] - xp[ip - 1]); + y.push_back(Lerp(yp[ip - 1], yp[ip], alpha)); + } + + for (; i < x.size(); ++i) { + y.push_back(yp.back()); // Pad out y with yp.back() for values after xp.back(). + } + + return y; +} + +/** Extracts the values of y where the corresponding value in x is equal to value. */ +template <typename S, typename T> +static std::vector<S> Extract(const std::vector<T>& x, const std::vector<S>& y, const T& value) { + NSCAssert(x.size() == y.size(), @"x and y must have matching lengths."); + std::vector<S> extracted; + + for (size_t i = 0; i < x.size(); ++i) { + if (x[i] == value) { + extracted.push_back(y[i]); + } + } + + return extracted; +} + +/** Returns the standard deviation of the values in x. */ +template <typename T> +static T StandardDeviation(const std::vector<T>& x) { + NSCAssert(x.size() > 0, @"x must have at least one value."); + const T sum = std::accumulate(x.begin(), x.end(), T{}); + const T mean = sum / x.size(); + const T ss = std::accumulate(x.begin(), x.end(), T{}, ^(T accum, T value){ + return accum + ((value - mean) * (value - mean)); + }); + return sqrt(ss / (x.size() - 1)); +} + +/** Returns the index of the smallest value in x. */ +template <typename T> +static size_t ArgMin(const std::vector<T>& x) { + NSCAssert(x.size() > 0, @"x must have at least one value."); + size_t imin = 0; + for (size_t i = 1; i < x.size(); ++i) { + if (x[i] < x[imin]) { + imin = i; + } + } + return imin; +} + +/** + * Finds a positive time value that shifting laserTs by will minimise the standard deviation of + * interpolated touchYs. + */ +static NSTimeInterval FindBestShift(const std::vector<NSTimeInterval>& laserTs, + const std::vector<NSTimeInterval>& touchTs, + const std::vector<CGFloat>& touchYs) { + NSCAssert(laserTs.size() > 0, @"laserTs must have at least one value."); + NSCAssert(touchTs.size() == touchYs.size(), @"touchTs and touchYs must have matching lengths."); + + const NSTimeInterval kSearchCoverage = 0.15; + const int kSteps = 1500; + const NSTimeInterval kShiftStep = kSearchCoverage / kSteps; + + std::vector<NSTimeInterval> deviations; + deviations.reserve(kSteps); + + std::vector<NSTimeInterval> ts(laserTs.size()); + for (int i = 0; i < kSteps; ++i) { + for (size_t j = 0; j < laserTs.size(); ++j) { + ts[j] = laserTs[j] + (kShiftStep * i); + } + + std::vector<CGFloat> laserYs = Interpolate(ts, touchTs, touchYs); + deviations.push_back(StandardDeviation(laserYs)); + } + + return ArgMin(deviations) * kShiftStep; +} + +@interface DragLatencyController () +- (void)updateCountDisplay; +- (void)processEvent:(UIEvent *)event; +- (void)receiveTriggers:(id)context; +- (void)stopReceiver; +@end + +@implementation DragLatencyController { + WALTClient *_client; + WALTLogger *_logger; + + NSMutableArray<WALTTouch *> *_touchEvents; + NSMutableArray<WALTLaserEvent *> *_laserEvents; + + NSThread *_triggerReceiver; + dispatch_semaphore_t _receiverComplete; +} + +- (void)dealloc { + [self stopReceiver]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + _client = ((WALTAppDelegate *)[UIApplication sharedApplication].delegate).client; + _logger = [WALTLogger sessionLogger]; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + + [self updateCountDisplay]; + + [_logger appendString:@"DRAGLATENCY\n"]; +} + +- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { + [self processEvent:event]; +} + +- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { + [self processEvent:event]; +} + +- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event { + [self processEvent:event]; +} + +- (void)processEvent:(UIEvent *)event { + // TODO(pquinn): Pull out coalesced touches. + + WALTTouch *touch = [[WALTTouch alloc] initWithEvent:event]; + [_touchEvents addObject:touch]; + [_logger appendFormat:@"TOUCH\t%.3f\t%.2f\t%.2f\n", + touch.kernelTime, touch.location.x, touch.location.y]; + [self updateCountDisplay]; +} + +- (void)updateCountDisplay { + NSString *counts = [NSString stringWithFormat:@"N ✛ %lu ⇄ %lu", + (unsigned long)_laserEvents.count, (unsigned long)_touchEvents.count]; + self.countLabel.text = counts; +} + +- (IBAction)start:(id)sender { + [self reset:sender]; + + self.goalpostView.hidden = NO; + self.statusLabel.text = @""; + + [UIView beginAnimations:@"Goalpost" context:NULL]; + [UIView setAnimationDuration:kGoalpostFrequency]; + [UIView setAnimationBeginsFromCurrentState:NO]; + [UIView setAnimationRepeatCount:FLT_MAX]; + [UIView setAnimationRepeatAutoreverses:YES]; + + self.goalpostView.transform = + CGAffineTransformMakeTranslation(0.0, -CGRectGetHeight(self.view.frame) + 300); + + [UIView commitAnimations]; + + _receiverComplete = dispatch_semaphore_create(0); + _triggerReceiver = [[NSThread alloc] initWithTarget:self + selector:@selector(receiveTriggers:) + object:nil]; + [_triggerReceiver start]; +} + +- (IBAction)reset:(id)sender { + [self stopReceiver]; + + self.goalpostView.transform = CGAffineTransformMakeTranslation(0.0, 0.0); + self.goalpostView.hidden = YES; + + _touchEvents = [[NSMutableArray<WALTTouch *> alloc] init]; + _laserEvents = [[NSMutableArray<WALTLaserEvent *> alloc] init]; + + [self updateCountDisplay]; + + NSError *error = nil; + if (![_client syncClocksWithError:&error]) { + UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"WALT Connection Error" error:error]; + [alert show]; + } + + [_logger appendString:@"RESET\n"]; +} + +- (void)receiveTriggers:(id)context { + // Turn on laser change notifications. + NSError *error = nil; + if (![_client sendCommand:WALTLaserOnCommand error:&error]) { + UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"WALT Connection Error" error:error]; + [alert show]; + dispatch_semaphore_signal(_receiverComplete); + return; + } + + NSData *response = [_client readResponseWithTimeout:kWALTReadTimeout]; + if (![_client checkResponse:response forCommand:WALTLaserOnCommand]) { + UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"WALT Response Error" + message:@"Failed to start laser probe." + delegate:nil + cancelButtonTitle:@"Dismiss" + otherButtonTitles:nil]; + [alert show]; + dispatch_semaphore_signal(_receiverComplete); + return; + } + + while (!NSThread.currentThread.isCancelled) { + WALTTrigger response = [_client readTriggerWithTimeout:kWALTReadTimeout]; + if (response.tag == kWALTLaserTag) { + WALTLaserEvent *event = [[WALTLaserEvent alloc] init]; + event.t = response.t; + event.value = response.value; + [_laserEvents addObject:event]; + [_logger appendFormat:@"LASER\t%.3f\t%d\n", event.t, event.value]; + } else if (response.tag != '\0') { // Don't fail for timeout errors. + UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"WALT Response Error" + message:@"Failed to read laser probe." + delegate:nil + cancelButtonTitle:@"Dismiss" + otherButtonTitles:nil]; + [alert show]; + } + } + + // Turn off laser change notifications. + [_client sendCommand:WALTLaserOffCommand error:nil]; + [_client readResponseWithTimeout:kWALTReadTimeout]; + + dispatch_semaphore_signal(_receiverComplete); +} + +- (void)stopReceiver { + // TODO(pquinn): This will deadlock if called in rapid succession -- there is a small delay + // between dispatch_semaphore_signal() and -[NSThread isExecuting] changing. + // Unfortunately, NSThread is not joinable... + if (_triggerReceiver.isExecuting) { + [_triggerReceiver cancel]; + dispatch_semaphore_wait(_receiverComplete, DISPATCH_TIME_FOREVER); + } +} + +- (IBAction)computeStatistics:(id)sender { + if (_touchEvents.count < kMinTouchEvents) { + self.statusLabel.text = + [NSString stringWithFormat:@"Too few touch events (%lu/%lu).", + (unsigned long)_touchEvents.count, (unsigned long)kMinTouchEvents]; + [self reset:sender]; + return; + } + + // Timestamps are reset to be relative to t0 to make the output easier to read. + const NSTimeInterval t0 = _touchEvents.firstObject.kernelTime; + const NSTimeInterval tF = _touchEvents.lastObject.kernelTime; + + std::vector<NSTimeInterval> ft(_touchEvents.count); + std::vector<CGFloat> fy(_touchEvents.count); + for (NSUInteger i = 0; i < _touchEvents.count; ++i) { + ft[i] = _touchEvents[i].kernelTime - t0; + fy[i] = _touchEvents[i].location.y; + } + + // Remove laser events that have a timestamp outside [t0, tF]. + [_laserEvents filterUsingPredicate:[NSPredicate predicateWithBlock: + ^BOOL(WALTLaserEvent *evaluatedObject, NSDictionary<NSString *, id> *bindings) { + return evaluatedObject.t >= t0 && evaluatedObject.t <= tF; + }]]; + + if (_laserEvents.count < kMinLaserEvents) { + self.statusLabel.text = + [NSString stringWithFormat:@"Too few laser events (%lu/%lu).", + (unsigned long)_laserEvents.count, (unsigned long)kMinLaserEvents]; + [self reset:sender]; + return; + } + + if (_laserEvents.firstObject.value != 0) { + self.statusLabel.text = @"First laser crossing was not into the beam."; + [self reset:sender]; + return; + } + + std::vector<NSTimeInterval> lt(_laserEvents.count); + std::vector<int> lv(_laserEvents.count); + for (NSUInteger i = 0; i < _laserEvents.count; ++i) { + lt[i] = _laserEvents[i].t - t0; + lv[i] = _laserEvents[i].value; + } + + // Calculate interpolated touch y positions at each laser event. + std::vector<CGFloat> ly = Interpolate(lt, ft, fy); + + // Labels for each laser event to denote those above/below the beam. + // The actual side is irrelevant, but events on the same side should have the same label. The + // vector will look like [0, 1, 1, 0, 0, 1, 1, 0, 0, ...]. + std::vector<int> sideLabels(lt.size()); + for (size_t i = 0; i < lt.size(); ++i) { + sideLabels[i] = ((i + 1) / 2) % 2; + } + + NSTimeInterval averageBestShift = 0; + for (int side = 0; side < 2; ++side) { + std::vector<NSTimeInterval> lts = Extract(sideLabels, lt, side); + NSTimeInterval bestShift = FindBestShift(lts, ft, fy); + averageBestShift += bestShift / 2; + } + + self.statusLabel.text = [NSString stringWithFormat:@"%.3f s", averageBestShift]; + + [self reset:sender]; +} +@end |