Осваиваем Core Motion в iOS

This post is also available in: Английский

Gyroscope2

Появление в серии телефонов iPhone акселерометра, гироскопа и магнетометра открыло новые возможности перед разработчиками приложений для iOS. Однако, несмотря на то, что с момента появления доступа к их API прошло уже несколько лет, до сих пор в интернете существует довольно мало информации по их использованию. С одной стороны — эта тема в действительности не так объёмна, чтобы посвящать ей книги или даже серии статей. Но с другой — существуют определённые детали и подводные камни, о причинах и сути которых нужно знать. Эта статья предназначена для тех разработчиков, которые хотят освоить использование акселерометра и гироскопа в iPhone.

Немного истории из жизни датчиков

gyro

Научив телефон возможность отслеживать своё положение и перемещение в пространстве, инженеры Apple добавили новое измерение в создание игр и приложений дополненной реальности, стерев одну из многих граней между окружающим миром и программной действительностью.

Первая модель устройства имела только акселерометр и могла определить своё ускорение по всем направлениям декартовой системы координат. Компания Apple дала разработчикам доступ к API электронного акселерометра модели STMicroelectronics LIS302DL, который был в составе первой модели iPhone. Методы для работы с акселерометром сосредотачивались в классе UIAccelerometer.

Однако, получение данных с акселерометра — это лишь три степени свободы (по трём осям Декартовой системы координат). Целью Apple было обеспечение контроля устройством всех девяти. Почему девять? В данном случае всё очень просто: к 6ти степеням свободы классической механики (3 поступательных осевых движения и вращения вокруг 3х осей) добавляются 3 составляющих вектора воздействия магнитного поля Земли, окружающего устройство. Таким образом, предстояло ещё научить устройство определять вектора своего вращения, ориентацию в пространстве, и направление вектора магнитного воздействия извне.

Магнетометр впервые появился в модели 3GS, для демонстрации возможностей которого в iOS даже появилось стандартное приложение «Компас». Наконец, гироскоп для измерения вращательных скоростей, явил себя миру в составе телефона iPhone 4. Также акселерометр и магнетометр в этой модели были заменены более новыми и совершенными аналогами. Начиная с четвёртой модели телефона можно говорить о его способности отслеживать все 9 степеней свободы. Этот набор датчиков актуален и по сей день, в телефонах пятой модели их состав не изменился.

Как использовать Core motion

Чтобы вы не скучали, читая эту статью, мы начнём сразу с практики, отвлекаясь по ходу дела на некоторые теоретические и исторические подробности.

Все возможности по работе с датчиками движений устройства сосредоточены во фреймворке Core Motion. Для того, чтобы задействовать показания с датчиков, вам необходимо подключить его к своему проекту.

Для наглядности мы создадим проект в XCode, в главное окно которого будем выводить показания с датчиков движения. Важно знать, что тестировать функциональность Core Motion вы можете только на реальном устройстве. Это неудивительно, ведь в симуляторе нет никаких датчиков.

Итак, отредактируйте view вашего главного view controller’а образом, показанным на картинке ниже.

CoreMotionStoryboard

Первый раздел — Acceleration — будет отображать ускорение устройства по трём осям. Раздел Gravity отображает проекции вектора действующей на устройство силы земного притяжения. Раздел Rotation отображает полученные с гироскопа данные по скорости вращения устройства вокруг трёх осей. И, наконец, раздел Attitude — положение устройства в пространстве, выраженное с помощью трёх углов — крен (roll), тангаж (pitch) и рыскание (yaw) — как при угловых движениях летательных аппаратов. Эти углы соответствуют углам Эйлера, используемым для описания положения твёрдого тела в пространстве.

Оси системы координат XYZ привязаны к устройству и расположены следующим образом.

CoreMotionAxes

Данные, получаемые с акселерометра, приходят в виде трёх величин типа double. Они представляют собой три компоненты разложения вектора ускорения на указанные оси.

Данные о скоростях вращения, получаемые с гироскопа, также приходят в виде осевых компонент сложного вращения. При определении компонент вращения знак каждой компоненты определяется по правилу правой руки.

CoreMotionRotationAxes

Данные с акселерометра имеют размерность g, то есть измеряются относительно ускорения свободного падения. Данные с гироскопа имеют размерность радиан в секунду.

Привяжите все объекты UILabel, в которые будет выводиться информация (они все находятся справа), к контроллеру, создав для них отдельные свойства с IBOutlet. Затем создайте ещё два свойства — CMMotionManager и CADisplayLink.

CMMotionManager — это основной класс фреймворка Core Motion, служащий для доступа к API всех датчиков. CADisplayLink — класс, который мы используем как таймер, работающий с частотой обновления экрана.

После создания CMMotionManager вам следует задать частоту обновления данных. Выбирать значение частоты необходимо, исходя из цели использования датчиков. Чем выше частота обновления — тем больше ресурсов процессора потребуется для работы Core Motion. Чем она ниже — тем медленнее будут происходить обновления данных. Большая частота может потребоваться в том случае, если критична высокая степень отзывчивости устройства на движения. В общем случае для выбора частоты мы рекомендуем вам использовать таблицу, приведённую в официальной документации.

Мы в тестовом проекте выбрали частоту 30 гц, то есть 30 обновлений в секунду.

Затем добавьте в контроллер отдельный метод для вывода обновлённых значений на экран:

Как уже было указано, в качестве таймера мы будем использовать объект класса CADisplayLink. Он будет вызывать метод updateMotionData с частотой обновления экрана устройства. В самом методе получение данных происходит путём обращения к свойству deviceMotion и его полям. Такой подход к получению данных с датчиков, когда вы сами опрашиваете CMMotionManager на предмет новых данных, называется pull.

Существует альтернативный подход к получению показаний, он называется push. Принципиальное отличие push от pull состоит в том, что при pull вы можете потерять какую-то часть данных, так как сами решаете, когда их снять. Возможно данные успеют обновиться несколько раз в промежутке между парой ваших запросов. При получении данных методом push вы не пропустите ни одного показания, потому что в этом случае CMMotionManager сам доставляет вам данные с частотой их обновления. Для этого ему передаётся блок, который вызывается каждый раз при поступлении новых данных.

На вопрос «Какой метод выбрать?» разработчики фреймворка указывают, что в большинстве случаев вам достаточно pull. Push может быть критичен тогда, когда для вас очень важно не пропускать данные, а это довольно большая редкость в случае обычных пользовательских приложений и игр. Это скорее прерогатива приложений исследовательских. В случае push вы также получаете бОльшие вычислительные затраты и невозможность замедлить получение данных, если вдруг ваше приложение начало притормаживать. Кстати говоря, каждое показание содержит временную метку, так что вы всегда будете знать, сколько показаний вы пропустили, если используете pull.

Итак, для тестового проекта мы выбрали подход pull. Как видно из метода updateMotionData, мы получаем данные с гироскопа (rotation и attitude) и акселерометра (acceleration и gravity). Полный код контроллера выглядит следующим образом.

#import "DVViewController.h"
#import <CoreMotion/CoreMotion.h>

#define kCMDeviceMotionUpdateFrequency (1.f/30.f)

@interface DVViewController ()
@property (weak, nonatomic) IBOutlet UILabel *accelerationXVal;
@property (weak, nonatomic) IBOutlet UILabel *accelerationYVal;
@property (weak, nonatomic) IBOutlet UILabel *accelerationZVal;

@property (weak, nonatomic) IBOutlet UILabel *gravityXVal;
@property (weak, nonatomic) IBOutlet UILabel *gravityYVal;
@property (weak, nonatomic) IBOutlet UILabel *gravityZVal;

@property (weak, nonatomic) IBOutlet UILabel *rotationXVal;
@property (weak, nonatomic) IBOutlet UILabel *rotationYVal;
@property (weak, nonatomic) IBOutlet UILabel *rotationZVal;

@property (weak, nonatomic) IBOutlet UILabel *pitch;
@property (weak, nonatomic) IBOutlet UILabel *roll;
@property (weak, nonatomic) IBOutlet UILabel *yaw;

@property (nonatomic, strong) CMMotionManager *motionManager;
@property (nonatomic, strong) CADisplayLink *displayLink;
@end

@implementation DVViewController

-(CMMotionManager *)motionManager {
    if (!_motionManager) {
        _motionManager = [[CMMotionManager alloc] init];
        _motionManager.deviceMotionUpdateInterval = kCMDeviceMotionUpdateFrequency;
    }
    
    return _motionManager;
}

-(CADisplayLink *)displayLink {
    if (!_displayLink) {
        _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(updateMotionData)];
    }
    
    return _displayLink;
}

- (void)viewDidLoad
{
    [super viewDidLoad];
  // Do any additional setup after loading the view, typically from a nib.
    
    [self.motionManager startDeviceMotionUpdates];
    [self.displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
}

- (void)didReceiveMemoryWarning
{
    [super didReceiveMemoryWarning];
    // Dispose of any resources that can be recreated.
}

- (void)updateMotionData {
    //1
    CMAcceleration acceleration = self.motionManager.deviceMotion.userAcceleration;
    self.accelerationXVal.text = [NSString stringWithFormat:@"%.3f", acceleration.x];
    self.accelerationYVal.text = [NSString stringWithFormat:@"%.3f", acceleration.y];
    self.accelerationZVal.text = [NSString stringWithFormat:@"%.3f", acceleration.z];
    //

    //2
    CMAcceleration gravity = self.motionManager.deviceMotion.gravity;
    self.gravityXVal.text = [NSString stringWithFormat:@"%.3f", gravity.x];
    self.gravityYVal.text = [NSString stringWithFormat:@"%.3f", gravity.y];
    self.gravityZVal.text = [NSString stringWithFormat:@"%.3f", gravity.z];
    //

    //3
    CMRotationRate rotation = self.motionManager.deviceMotion.rotationRate;
    self.rotationXVal.text = [NSString stringWithFormat:@"%.3f", rotation.x];
    self.rotationYVal.text = [NSString stringWithFormat:@"%.3f", rotation.y];
    self.rotationZVal.text = [NSString stringWithFormat:@"%.3f", rotation.z];
    //

    //4
    CMAttitude *attitude = self.motionManager.deviceMotion.attitude;
    self.pitch.text = [NSString stringWithFormat:@"%.3f", attitude.pitch];
    self.roll.text = [NSString stringWithFormat:@"%.3f", attitude.roll];
    self.yaw.text = [NSString stringWithFormat:@"%.3f", attitude.yaw];
    //
}

@end

Запустите приложение на устройстве, покрутите его вокруг осей, понаблюдайте за изменениями показаний датчиков. Самая простая базовая часть позади. Дальше начинаются эксперименты.

Один в поле не воин

Если вы уже успели присмотреться к описанию класса CMMotionManager в документации, то скорее всего заметили, что в нём существуют методы для получения данных непосредственно с гироскопа, акселерометра и магнетометра. Так почему же мы использовали вместо них таинственный deviceMotion, и что это вообще такое? Для начала мы проведём маленький эксперимент, а затем расскажем вам его значение в исторической справке.

При создании объекта CMMotionManager, в том же месте, где вы задавали частоту обновления данных, задайте такую же частоту для обновления данных с акселерометра и гироскопа.

_motionManager.accelerometerUpdateInterval = kCMDeviceMotionUpdateFrequency;
_motionManager.gyroUpdateInterval = kCMDeviceMotionUpdateFrequency; 

Затем в методе viewDidLoad замените строку

[self.motionManager startDeviceMotionUpdates]; 

строками

[self.motionManager startAccelerometerUpdates];
[self.motionManager startGyroUpdates]; 

Наконец, в методе updateMotionData замените получение показаний ускорений (блок //1 ) и вращений (блок //3) c deviceMotion на соответствующие устройства.

//1
CMAcceleration acceleration = self.motionManager.accelerometerData.acceleration; 
//3
CMRotationRate rotation = self.motionManager.gyroData.rotationRate;

Запускайте тест. Положите телефон на ровную горизонтальную поверхность. Давайте проанализируем, что мы видим. Лёжа на столе в неподвижном состоянии телефон, судя по показаниям с акселерометра, имеет вертикальное ускорение, приблизительно равное единице, то есть ускорению свободного падения. Это не единственное чудо. Посмотрите на чистые показания с гироскопа — в неподвижном состоянии телефон имеет ещё и вращательную скорость.

jackie-chan-illuminati

Как такое может быть?

Обещанная историческая справка. После тестов работы акселерометра на первой модели устройства стало ясно — всё удовольствие от его использования портит довольно высокий уровень шума, вносимый датчиком в показания. Если говорить более точно — ему мешала гравитация.

Так как акселерометр является датчиком для считывания ускорений, то помимо собственного ускорения он также испытывает влияние силы гравитации и, как следствие, ускорения свободного падения. Используя методы цифровой обработки сигналов (высоко- и низкопроходные фильтры), у разработчиков SDK iOS получилось выделить из общего потока данные, относящиеся к вектору гравитации. И это было хорошей новостью — ведь с возможностью определить вектор гравитации приходит возможность частично определить вращение устройства в пространстве. Мы написали «частично» потому, что вращение невозможно определить в том случае, если оно происходит вокруг вектора гравитации — при таком движении направление вектора никак не изменяется.

Разработчикам приложений, в свою очередь, нужно было получать «чистое» ускорение устройства, без влияния вектора гравитации. И здесь начинаются плохие новости, потому как разделение общего потока данных на составляющие ускорения и гравитации проходит с использованием двух фильтров — высокопроходного для отделения гравитации и низкопроходного для отделения ускорения устройства. И чем выше степень фильтрации, тем больше нужных данных мы теряем, тем менее отзывчивым становится датчик. Таким образом, терялась точность, а вектор гравитации мог быть использован для определения положения лишь с натяжкой.

Появление гироскопа не облегчило ситуацию. За возможность считывать вращательные движения устройства и полное положение в углах Эйлера мы расплачивались новой трудностью — «смещением» показаний датчика. Можно было заметить, что при полностью неподвижном телефоне показания гироскопа не менялись, однако при этом были ненулевыми. То есть существовала определённая постоянная ошибка. Ещё хуже — при интегрировании скорости для получения углов положения ненулевая ошибка давала растущую линейную ошибку! Через полминуты использования гироскопа телефон по показаниям был отклонён на 45 градусов от своего реального положения.

Итак, быстро стало ясно, что датчики и алгоритмы получения данных не так совершенны, как бы нам того хотелось. По отдельности датчики явно не так хороши. Но истина, как это обычно бывает, где-то посередине. Решением всех проблем стала совместная работа акселерометра и гироскопа.

Оказалось, что совместно акселерометр и гироскоп помогают друг другу избавиться от всех описанных выше проблем. Специально разработанные алгоритмы, внедрённые разработчиками iOS SDK, учитывающие и использующие показания датчиков для улучшения работы друг друга, стали частью iOS 4, которая стала первой версией iOS, в которой фреймворк Core Motion был представлен в своём полном великолепии — с методами точного определения ускорений, вращений и положений в пространстве. Показания, полученные совместными усилиями двух сенсоров, очищенные от шума и ошибок, сосредоточены в свойстве deviceMotion. Теперь не требуется применять фильтрацию для отделения вектора ускорения от гравитации, deviceMotion содержит отдельные свойства для этих данных (соответственно user acceleration и gravity). Гироскоп больше не имеет постоянной ошибки, стало возможным точно определить ориентацию телефона в пространстве.

Теперь, проделав этот нехитрый эксперимент, вы узнали, почему всегда следует предпочитать считывать deviceMotion вместо показаний только с гироскопа или только с акселерометра. И пусть даже deviceMotion будет проделывать лишнюю работу, если вам нужны данные только об ускорении или только о вращении, это всё равно будут точные данные.

Gimbal lock

Нами остался незатронутым только последний раздел нашего теста — Attitude. На первый взгляд, здесь нет ничего сложного. Все углы честно изменяют своё значение при вращении устройства вокруг различных осей. Однако, если приглядеться получше, то можно заметить, что рыскание (yaw) иногда скачком изменяется практически на 180 градусов при изменении других углов. Таким образом, изменение крена (roll) и тангажа (pitch) влияет на значение рыскания. В очередной раз хотите спросить, что происходит?

gimbal

В статье подробно рассказывается о проблеме, которая называется gimbal lock, и её значении при работе с углами Эйлера. В частности там упомянут тот самый резкий переход на 180 градусов, от которого придётся избавляться, если анимация каких-либо объектов на экране зависит от значения угла yaw.

В той же статье упоминается, что распространённым способом избежать gimbal lock является уход от использования углов Эйлера к применению иных способов описания положения устройства в пространстве. В частности, речь идёт о кватернионах.

Описание кватернионов можно найти в любом достаточно компетентном учебнике по линейной алгебре, либо прочитать о них на википедии. Но откуда мы могли бы получить значение кватернионов для каждого текущего положения? Тут о нас позаботились разработчики фреймворка Core Motion — свойство quaternion включено в deviceMotion. Оно имеет тип CMQuaternion и является структурой, состоящей из четырёх значений типа double. Всё, что вам нужно сделать для получения понятных человеческому восприятию эйлеровых углов из неочевидных ему кватернионов — это применение известных формул преобразования.

Для тех же целей нам предоставлено свойство rotationMatrix, которое также избавлено от проблемы gimbal lock. Однако для матриц преобразование в углы чуть сложнее, чем в случае кватернионов. Предполагается, что матрицы могут быть использованы разработчиками в целях тесной интеграции с OpenGL ES.

Настройка Reference Frame

Последнее, что мы затронем в этой статье, — настройка reference frame. Reference frame — это опорная ориентация координатной системы, связанной с устройством, принимаемая за начальную. От неё отсчитываются все последующие значения углов Эйлера. Это своеобразная «нулевая точка отсчёта» для положения. Таким образом, задав новый reference frame, вы задаёте новое начальное положение осей координат. Это может быть полезно для приложений дополненной реальности, в которых часто необходимо постоянно отслеживать направление на север. Вы можете просто задать соответствующий reference frame — и ориентация вашего устройства будет одновременно и его отклонением от направления на магнитный север Земли (или на северный полюс, если пожелаете).

Давайте зададимся вопросом, от какого положения отсчитываются углы ориентации устройства, что является «нулевым» положением по умолчанию? За начальное положение при определении ориентации устройства принимается положение, которое занимало устройство в момент первого получения данных от датчиков. Причём это положение модифицируется таким образом, чтобы его ось Z была направлена вертикально вверх, противоположно вектору гравитации. А осями X и Y, таким образом, будут проекции этих осей на плоскость, перпендикулярную оси Z. Это положение является reference frame по умолчанию.

Есть ли в iOS возможность задавать свой reference frame? В iOS 4 — частично. В iOS 5 — да. Рассмотрим сначала iOS 4. Объекты класса CMAttitude, представляющие ориентацию устройства в пространстве, содержат в себе метод multiplyByInverseOfAttitude:. Этот метод принимает в качестве аргумента объект класса CMAttitude. Передав в этот метод некоторую ориентацию устройства можно посчитать разницу в углах между ориентацией, вызвавшей метод, и ориентацией, переданной методу. Таким образом вы получите отклонение от положения, которое не является настоящим reference frame. Такой подход плох тем, что приходится получать и хранить нужное положение, а также затрачивать вычислительные ресурсы на выполнение указанного метода.

В iOS 5 разработчики получили возможность задать reference frame при старте получений показаний с датчика. Его нужно передать в один из методов

  • (void)startDeviceMotionUpdatesUsingReferenceFrame:.
  • (void)startDeviceMotionUpdatesUsingReferenceFrame:toQueue:withHandler:.

Тем не менее, у вас нет возможности задать произвольный reference frame. Вы можете только выбрать только из 4х предоставленных.

  • CMAttitudeReferenceFrameXArbitraryZVertical — это тот самый вариант по-умолчанию, о котором было сказано выше. Ось Z направлена вертикально вверх, противоположно вектору гравитации, ось X направлена по проекции настоящей оси X на плоскость, перпендикулярную оси Z.
  • CMAttitudeReferenceFrameXArbitraryCorrectedZVertical — это модифицированный предыдущий вариант. Отличие состоит в том, что здесь в работу вступает магнетометр. С его помощью модифицированный алгоритм расчёта ориентации исключает растущую ошибку в значении угла yaw. Недостаток его заключается в затратах ресурсов процессора на дополнительные вычисления. Также, если вы находитесь рядом с достаточно неоднородной магнитной средой, этот reference frame скорее всего будет вносить ошибку.
  • CMAttitudeReferenceFrameXMagneticNorthZVertical — возможно, самый полезный вариант. Ось X этого reference frame всегда направлена в сторону магнитного севера. Это значит, что в работе опять же задействуется магнетометр. Следует отметить, что магнетометр нужно периодически калибровать. Для вызова стандартного экрана калибровки вам нужно просто установить свойство showsDeviceMovementDisplay в YES.
  • CMAttitudeReferenceFrameXTrueNorthZVertical — тоже самое, что и предыдущий вариант, но вместо магнитного севера ось X указывает на истинный север. Затрачивает наибольшее количество процессорных ресурсов, так как помимо магнетометра проводит дополнительные вычисления по преобразованию к истинному северу.

Таков набор опорных положений. Перед использованием того или иного reference frame’а проверьте его наличие в системе вызовом метода класса

+ (void)availableAttitudeReferenceFrames

Заключение

Поздравляем, вы осилили прочесть эту статью до самого конца!)

ThumbsUp

Датчики движения в iOS — удивительный механизм. Как и все приложения дополненной реальности они захватывают в первую очередь тем, что дают вам в руки инструмент для преобразования информации об окружающем вас мире в виртуальный мир вашего телефона. И надо отдать должное инженерам Apple — подключение датчиков к приложению является предельно простым, а их дальнейшее использование не ограничивает вас ни в чём, давая простор для творчества.

Надеемся, прочтение этой статьи приоткрыло перед вами если не новое измерение в создании приложений, то хотя бы несколько действительно любопытных фактов о фреймворке Core Motion. Нам было бы очень приятно вдохновить вас на создание новых сногсшибательных приложений с дополненной реальностью!

В следующей статье мы расскажем вам о нашем новом компоненте DVParallaxView и покажем на наглядном примере, как реализовать параллакс с использованием фреймворка Core Motion.

One thought on “Осваиваем Core Motion в iOS

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Пожалуйста напечатайте буквы/цифры изображенные на картинке

Please type the characters of this captcha image in the input box