基于地理位置的AR增强现实

随着支付宝,QQ AR技术的应用,越来越多的团队开始认识到AR的价值,可以说2016年AR技术离我们又进了一步。再往前倒一年,你会发现,其实双11会场也是做过AR红包的,但是当时的红包效果确实不怎么地,界面抖动的太厉害(没有对感应器做深层次的了解)。随之还有QQ火炬接力,当时没有记错的话,QQ的AR是好像2d的,设备要求在水平放置时,才能展示更好的效果。随着研究的深入,产品的效果也是越来越理想,这里介绍两个国内的AR产品。“视+”,基于标记的AR技术,逼格很高,也拥有了很庞大的用户群。”随便走“基于地理位置的AR技术,下面来几张截图:

这次的主题也是基于地理位置的AR实现。

前期分析

在做这个产品之前,要明确我们要做什么,之前也说了,就是类似于”随便走“的效果的AR技术,但是不需要他的导航的这样的产品。那么就是这样的产品中包含哪些技术实现

  • 后置摄像头取景
  • 方向的获取
  • 手机抬起的角度

后置摄像头的获取

这个技术倒是不难,直接使用UIImagePickerController就可以拿到后置摄像头录制的界面。同样使用AVFoundation也是可以拿到后置摄像头的信息的,这次的实现就是使用了AVFoudation来获取后置摄像头的信息。

.h文件

#import <UIKit/UIKit.h>

typedef NS_ENUM(NSInteger, QARViewState) {
    QARViewStateStartRunning,
    QARViewStateStopRunning,
    QARViewStateError
};

typedef void(^QARCompletionHandle) (NSError *error);

@interface QARView : UIView

@property (nonatomic, copy) QARCompletionHandle completionHandle;
@property (nonatomic, assign, readonly) QARViewState state;

- (instancetype)initWithFrame:(CGRect)frame completionHandle:(QARCompletionHandle)completionHandle;

@end

.m 文件的实现

#import "QARView.h"
#import <AVFoundation/AVFoundation.h>


@interface QARView ()

@property (nonatomic, strong) AVCaptureSession *captureSession;
@property (nonatomic, strong) AVCaptureDeviceInput *captureDeviceInput;
@property (nonatomic, strong) AVCaptureVideoPreviewLayer *captureVideoPreviewLayer;
@property (nonatomic, readwrite) QARViewState state;

@end

@implementation QARView

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [self.captureSession stopRunning];
    self.completionHandle = nil;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        [self initSelf];
        [self.captureSession startRunning];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super initWithFrame:frame];
    if (self) {
        [self initSelf];
        [self.captureSession startRunning];
    }
    return self;
}

- (instancetype)initWithFrame:(CGRect)frame completionHandle:(QARCompletionHandle)completionHandle {
    self = [super initWithFrame:frame];
    if (self) {
        _completionHandle = completionHandle;
        [self initSelf];
        [self.captureSession startRunning];
    }
    return self;
}

- (void)layoutSubviews {
    [super layoutSubviews];
    self.backgroundColor = [UIColor whiteColor];
    self.captureVideoPreviewLayer.frame = self.layer.bounds;
}

- (void)initSelf {
    [self addDeviceInput];
    [self.layer addSublayer:self.captureVideoPreviewLayer];
    [self addNotification];
}

- (void)addNotification {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionRuntimeError) name:AVCaptureSessionRuntimeErrorNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionStartRunning) name:AVCaptureSessionDidStartRunningNotification object:nil];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(sessionStopRunning) name:AVCaptureSessionDidStopRunningNotification object:nil];
}

- (void)addDeviceInput {
    if ([self.captureSession canAddInput:self.captureDeviceInput])
        [self.captureSession addInput:self.captureDeviceInput];
    else {
        NSError *error = [[NSError alloc] initWithDomain:@"com.qunar.ar.error" code:-1002 userInfo:@{@"message":@"会话没有输入源"}];
        if (self.completionHandle) {
            self.completionHandle(error);
        }
        self.state = QARViewStateError;
    }
}

- (AVCaptureSession *)captureSession {
    if (!_captureSession) {
        _captureSession = [[AVCaptureSession alloc] init];
    }
    return _captureSession;
}

- (AVCaptureDevice *)captureDevicePositionBack {
    return [self getCameraDeviceWithPosition:AVCaptureDevicePositionBack]?:nil;
}

-(AVCaptureDevice *)getCameraDeviceWithPosition:(AVCaptureDevicePosition )position{
    NSArray *cameras= [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
    for (AVCaptureDevice *camera in cameras) {
        if ([camera position]==position) {
            return camera;
        }
    }
    return nil;
}

#pragma mark - Property init

- (AVCaptureInput *)captureDeviceInput {
    if (!_captureDeviceInput) {
        NSError *error;
        _captureDeviceInput = [[AVCaptureDeviceInput alloc] initWithDevice:[self captureDevicePositionBack] error:&error];
        if (error) return nil;
    }
    return _captureDeviceInput;
}

- (AVCaptureVideoPreviewLayer *)captureVideoPreviewLayer {
    if (!_captureVideoPreviewLayer) {
        _captureVideoPreviewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:self.captureSession];
        _captureVideoPreviewLayer.backgroundColor = [[UIColor whiteColor] CGColor];
        _captureVideoPreviewLayer.videoGravity=AVLayerVideoGravityResizeAspectFill;
    }
    return _captureVideoPreviewLayer;
}

-(void)sessionRuntimeError{
    NSError *error = [[NSError alloc] initWithDomain:@"com.qunar.ar.error" code:-1001 userInfo:@{@"message":@"QAR录制会话运行出错"}];
    if (self.completionHandle) {
        self.completionHandle(error);
    }
    self.state = QARViewStateError;
}

- (void)sessionStartRunning {
    if (self.completionHandle) {
        self.completionHandle(nil);
    }
    [self setState:QARViewStateStartRunning];
}

- (void)sessionStopRunning {
    if (self.completionHandle) {
        self.completionHandle(nil);
    }
    self.state = QARViewStateStopRunning;
}

@end

使用AVFoudation来获取后置摄像头的好处就是,AVFoundation更加接近底层,不必要做过多的封装开销,同时能更好的做容错处理。比如接听电话中断的处理..

方向的获取

我觉得这是整个工程中最大的难点!!!!!下面就来介绍,为什么这是最大的难点。首先我们可以从下面的方案中获取当前的方向

  • CLLocation 中的 Heading
  • CoreMotion 中的 magneticField
  • CoreMotion 中的 Attitude(欧拉角) 中的 quaternion(四元素)

正如你能理解到的,可以从定位信息中(这里也是陀螺仪传递的信息,但是经过了矫正)拿到当前的指南针的角度,同时可以从陀螺仪感应器中拿到磁场在x,y,z三轴上的分量。这应该是正常人首先能想到的方法了。但是这两种方法都存在致命的缺陷,

首先说一下CLLocation中的trueHeading获取到的指南针的信息不受设备空间位置的影响
这个是很优秀的也是我们想要的结果
但是拿到的值不稳地(下面的红色的箭头),会出现很大幅度的抖动。
可能在简单的界面中看不是很直观,如果把这个值放在了AR目标物上.
这个目标物就会在横向不停的抖动。即便做了卡尔曼滤波,做了低通滤波

接下来说CoreMotion中magneticField磁场信息获取到的指南针的角度
你会发现绿色和蓝色箭头和红色箭头会存在很大的夹角,这个夹角是在设备抬起的时候导致的。
在现有的资料查询和实验后没有找到一个标准的公式,能将CoreMotion中的磁场信息转化成稳定的指南针信息
保留这个磁场的计算

上面的代码来自stackoverflow这里讲解了CoreMotion中的magneticField和CLLocation中的trueHeading的不同点。

同样尝试的还有CoreMotion中的欧拉角中的YAW,测试中发现这是一个不确定的值,没有参考系。所以导致设备找不着北(这个不清楚是个例还是真的就是这样)。使用欧拉角可能会存在万向节死锁的问题(这里如果只是使用欧拉角中的一个值的话,应该不会出现死锁)。

最后摸索的四元素,如果你去查资料,估计也很难明白它是什么作用。首先看一下他的结构

typedef struct
{
    double x, y, z, w;
} CMQuaternion;

给出的解释是在xyz向量中旋转的角度是w,他是一个四维的空间,很难讲的清楚。这里需要知道的就是,从这四个分量中可以拿到需要的pitch和compass角度,计算方法是:

// 用四元素来计算方向和旋转角度
CMQuaternion quat = motion.attitude.quaternion;
// 这个值在这里没有什么用处
// double myRoll = atan2(2*(quat.y*quat.w - quat.x*quat.z), 1 - 2*quat.y*quat.y - 2*quat.z*quat.z) ;
double myPitch = atan2(2*(quat.x*quat.w + quat.y*quat.z), 1 - 2*quat.x*quat.x - 2*quat.y*quat.y);
// yaw这个值来自正北
double myYaw = degreesConvert(atan2(2*(quat.z*quat.w + quat.x*quat.y), 1 - 2*quat.y*quat.y - 2*quat.z*quat.z));

拿到的Yaw的角度,经过测试是参考的指南针的方向,得到的值也很稳定。

实现

这里先来看一下成果

上面的gif就是完成之后的效果图了,为什么会是三份呢,在计算位置变化和绘制界面上采用了不同的方案。这也是一个渐变的过程,随着对AR技术了解的深入渐渐的趋向于用矩阵来计算目标物的位置移动,之后接触到了GLKit和sceneKit

UIView绘制目标并做位置移动。这里存在的优点和缺点是

  • 学习和接入成本低
  • 同样的计算方法UIView表现上很生硬,动画不流畅

下面是代码实现:

@interface QARViewManager ()
@end
@implementation QARViewManager
- (void)startMotion {
    __weak typeof(self) weakSelf = self;
    self.motion.motionCallback = ^(double zDegrees,double compassDegrees,double decareCompassDegrees, double xyTheta,NSError *error) {
        if (error) {
            [weakSelf excuteFailCallBack:error];
        } else {
            dispatch_async(dispatch_get_main_queue(), ^{
                __strong typeof(weakSelf) strongSelf = weakSelf;
                if (strongSelf) {
                    [strongSelf.radioView setRotate:compassDegrees];

                    QARModel *model = (QARModel *)(strongSelf.data[0]);
                    UIView *view = (UIView *)(strongSelf.visibleViews[0]);
                    double pointX = screenWidthHalf()*3.2*cos(M_PI_2 - (decareCompassDegrees - model.decareDegrees));
                    double pointY = screenHeightHalf()*2*sin(zDegrees);

                    model.point2dx = screenWidthHalf() + pointX;
                    model.point2dy = screenHeightHalf() - pointY;

                    view.center = CGPointMake(model.point2dx, model.point2dy);
                }
            });
        }
    };
    [self.motion updateMotion];
}
@end

GLKit绘制目标并做位置移动。这里存在的优点和缺点是

  • 学习和接入的成本高
  • 同样的计算方法UIView表现上很生硬,动画流畅
  • 接口设计复杂
  • 目标位置需要额外计算

下面是代码实现:

@interface QARViewManager ()
@end
@implementation QARViewManager
- (void)startMotion {
    __weak typeof(self) weakSelf = self;
    self.motion.motionCallback = ^(double zDegrees,double compassDegrees,double decareCompassDegrees, double xyTheta,NSError *error) {
        if (error) {
            [weakSelf excuteFailCallBack:error];
        } else {
            dispatch_async(dispatch_get_main_queue(), ^{
                __strong typeof(weakSelf) strongSelf = weakSelf;
                if (strongSelf) {
                    [strongSelf.radioView setRotate:compassDegrees];
                    QARModel *model = (QARModel *)(strongSelf.data[0]);
                    GLKMatrix4 modelViewMatrix = GLKMatrix4Translate(GLKMatrix4Identity, 6.0 * cos(M_PI_2 - (decareCompassDegrees - model.decareDegrees)), 6.0*sin(zDegrees), -3.0f);
                    // 将modelView矩阵赋值给需要做旋转的实例
                    modelViewMatrix = GLKMatrix4Rotate(modelViewMatrix, M_PI-xyTheta, 0.0, 0.0, 1.0);

                    strongSelf.glkView.effect.transform.modelviewMatrix = modelViewMatrix;
                    [strongSelf.glkView setNeedsDisplay];
                }
            });
        }
    };
    [self.motion updateMotion];
}

@end

SceneKit绘制目标并做位置移动。这里存在的优点和缺点是

  • 学习和接入的成本一般
  • 同样的计算方法UIView表现上很生硬,动画流畅

下面是代码实现:

@interface QARViewManager ()
@end
@implementation QARViewManager
- (void)startMotion {
    __weak typeof(self) weakSelf = self;
    self.motion.motionCallback = ^(double zDegrees,double compassDegrees,double decareCompassDegrees, double xyTheta,NSError *error) {
        if (error) {
            [weakSelf excuteFailCallBack:error];
        } else {
            dispatch_async(dispatch_get_main_queue(), ^{
                __strong typeof(weakSelf) strongSelf = weakSelf;
                if (strongSelf) {
                    [strongSelf.radioView setRotate:compassDegrees];                        
                    QARModel *model = (QARModel *)(strongSelf.data[0]);
                    SCNMatrix4 mat = SCNMatrix4MakeTranslation(3.0 * cos(M_PI_2 - (decareCompassDegrees - model.decareDegrees)), 3.6*sin(zDegrees), -3.0);
                    mat = SCNMatrix4Rotate(mat, M_PI-xyTheta, 0.0, 0.0, 1.0);
                    strongSelf.boxNode.transform = mat;
                }
            });
        }
    };
    [self.motion updateMotion];
}

@end

性能分析

当前界面中只是对单个目标物做位置的更新,所以三个方案中Animation检测都达到了60fs,随着目标物的增加,
UIView做位置的转化就会使得当前的帧率下降。GLKit和SceneKit的优势也会凸现出来,GLKit中存在剪裁的逻辑,减少了不在当前视窗的目标物的计算。

准备知识

下面是我在AR调研时学习和准备的知识

  • CoreMotion陀螺仪
  • AVFoundation获取后置摄像头
  • 矩阵转换
  • OpenGLES绘制目标物
  • GLKit
  • GameKit