Учебное пособие по ARKit для iOS: рисование в воздухе голыми пальцами

Опубликовано: 2022-03-11

Недавно Apple анонсировала свою новую библиотеку дополненной реальности (AR) под названием ARKit. Для многих это выглядело как еще одна хорошая библиотека дополненной реальности, а не технологический прорыв, о котором нужно заботиться. Тем не менее, если вы посмотрите на прогресс AR за последние пару лет, не следует торопиться с такими выводами.

Учебное пособие по ARKit: взаимодействие с виртуальными объектами в приложении ARKit для iOS

В этом посте мы создадим забавный пример проекта ARKit, используя iOS ARKit. Пользователь кладет пальцы на стол, как будто держит ручку, нажимает на миниатюру и начинает рисовать. После завершения пользователь сможет преобразовать свой рисунок в 3D-объект, как показано на анимации ниже. Полный исходный код нашего примера iOS ARKit доступен на GitHub.

Демонстрация использования нашего приложения дополненной реальности iOS ARKit

Почему мы должны заботиться об iOS ARKit сейчас?

Каждый опытный разработчик, вероятно, знает, что AR — это старая концепция. Мы можем отнести первое серьезное развитие AR к тому времени, когда разработчики получили доступ к отдельным кадрам с веб-камер. Приложения в то время обычно использовались для преобразования вашего лица. Однако человечеству не потребовалось много времени, чтобы понять, что превращение лиц в кроликов не было одной из их самых насущных потребностей, и вскоре шумиха улеглась!

Я считаю, что дополненной реальности всегда не хватало двух ключевых технологических скачков, чтобы сделать ее полезной: удобство использования и погружение. Если вы проследили другие ажиотажи AR, вы заметите это. Например, ажиотаж вокруг дополненной реальности снова поднялся, когда разработчики получили доступ к отдельным кадрам с мобильных камер. Помимо сильного возвращения великих кроликов-трансформеров, мы увидели волну приложений, которые бросают 3D-объекты на напечатанные QR-коды. Но они так и не стали концепцией. Это была не дополненная реальность, а дополненные QR-коды.

Затем Google удивил нас произведением научной фантастики — Google Glass. Прошло два года, и когда этот удивительный продукт должен был появиться на свет, он уже был мертв! Многие критики анализировали причины провала Google Glass, возлагая вину на все, начиная от социальных аспектов и заканчивая скучным подходом Google к запуску продукта. Однако в этой статье нас волнует одна конкретная причина — погружение в окружающую среду. В то время как Google Glass решили проблему удобства использования, они по-прежнему представляли собой не более чем 2D-изображение, нанесенное в воздухе.

Технологические титаны, такие как Microsoft, Facebook и Apple, выучили этот суровый урок наизусть. В июне 2017 года Apple анонсировала свою прекрасную библиотеку iOS ARKit, сделав погружение своим главным приоритетом. Держание телефона по-прежнему является серьезным препятствием для взаимодействия с пользователем, но урок Google Glass научил нас тому, что аппаратное обеспечение не является проблемой.

Я считаю, что очень скоро мы приближаемся к новому пику ажиотажа вокруг AR, и благодаря этому новому важному повороту он может в конечном итоге найти свой домашний рынок, что позволит большему количеству приложений AR стать массовыми. Это также означает, что каждая компания, занимающаяся разработкой приложений дополненной реальности, сможет подключиться к экосистеме и пользовательской базе Apple.

Но хватит истории, давайте запачкаем руки кодом и увидим дополненную реальность Apple в действии!

Особенности погружения в ARKit

ARKit предоставляет две основные функции; первое — это положение камеры в трехмерном пространстве, а второе — обнаружение в горизонтальной плоскости. Для достижения первого ARKit предполагает, что ваш телефон представляет собой камеру, движущуюся в реальном 3D-пространстве, так что падение некоторого виртуального 3D-объекта в любой точке будет привязано к этой точке в реальном 3D-пространстве. Что касается последнего, ARKit обнаруживает горизонтальные плоскости, такие как столы, поэтому вы можете размещать на них объекты.

Так как же ARKit достигает этого? Это делается с помощью метода, называемого визуальной инерциальной одометрией (VIO). Не волнуйтесь, так же, как предприниматели находят свое удовольствие в количестве хихиканья, которое вы хихикаете, когда выясняете источник названия их стартапа, исследователи находят свое удовольствие в количестве чесаний головы, которые вы делаете, пытаясь расшифровать любой термин, который они придумали, когда называя свои изобретения - так что давайте позволим им повеселиться и двигаться дальше.

VIO — это метод, с помощью которого кадры камеры объединяются с датчиками движения для отслеживания местоположения устройства в трехмерном пространстве. Отслеживание движения по кадрам камеры осуществляется путем обнаружения признаков или, другими словами, краевых точек на изображении с высокой контрастностью — как граница между синей вазой и белым столом. Определив, насколько эти точки сместились относительно друг друга от одного кадра к другому, можно оценить, где находится устройство в трехмерном пространстве. Вот почему ARKit не будет работать должным образом, если его поместить лицом к невыразительной белой стене или когда устройство движется очень быстро, что приводит к размытию изображения.

Начало работы с ARKit в iOS

На момент написания этой статьи ARKit является частью iOS 11, которая все еще находится в стадии бета-тестирования. Итак, для начала вам необходимо загрузить бета-версию iOS 11 на iPhone 6s или более поздней версии и новую бета-версию Xcode. Мы можем начать новый проект ARKit из New > Project > Augmented Reality App . Тем не менее, я счел более удобным начать это руководство по дополненной реальности с официального образца Apple ARKit, который предоставляет несколько основных блоков кода и особенно полезен для обнаружения самолетов. Итак, давайте начнем с этого примера кода, сначала объясним в нем основные моменты, а затем модифицируем его для нашего проекта.

Во-первых, мы должны определить, какой двигатель мы собираемся использовать. ARKit можно использовать со Sprite SceneKit или Metal. В примере с Apple ARKit мы используем iOS SceneKit, 3D-движок, предоставленный Apple. Далее нам нужно настроить представление, которое будет отображать наши 3D-объекты. Это делается путем добавления представления типа ARSCNView .

ARSCNView — это подкласс основного представления SceneKit с именем SCNView , но он расширяет представление несколькими полезными функциями. Он отображает живое видео с камеры устройства в качестве фона сцены, в то время как пространство SceneKit автоматически сопоставляется с реальным миром, предполагая, что устройство является движущейся камерой в этом мире.

ARSCNView не выполняет обработку дополненной реальности самостоятельно, но для этого требуется объект сеанса дополненной реальности, который управляет камерой устройства и обработкой движения. Итак, для начала нам нужно назначить новую сессию:

 self.session = ARSession() sceneView.session = session sceneView.delegate = self setupFocusSquare()

Последняя строка выше добавляет визуальный индикатор, который помогает пользователю визуально описать статус обнаружения самолета. Focus Square предоставляется в виде примера кода, а не библиотеки ARKit, и это одна из основных причин, по которой мы начали с этого примера кода. Вы можете найти больше об этом в файле readme, включенном в пример кода. На следующем изображении показан фокусный квадрат, спроецированный на стол:

Квадрат фокуса, спроецированный на стол с помощью Apple ARKit

Следующим шагом является запуск сеанса ARKit. Имеет смысл перезапускать сеанс каждый раз, когда появляется представление, потому что мы не можем использовать информацию о предыдущем сеансе, если мы больше не отслеживаем пользователя. Итак, мы собираемся начать сеанс в viewDidAppear:

 override func viewDidAppear(_ animated: Bool) { let configuration = ARWorldTrackingSessionConfiguration() configuration.planeDetection = .horizontal session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) }

В приведенном выше коде мы начинаем с настройки конфигурации сеанса ARKit для обнаружения горизонтальных плоскостей. На момент написания этой статьи Apple не предоставляет других вариантов, кроме этого. Но, видимо, это намекает на обнаружение в будущем более сложных объектов. Затем мы запускаем сеанс и обязательно сбрасываем отслеживание.

Наконец, нам нужно обновлять квадрат фокуса всякий раз, когда изменяется положение камеры, т. е. фактическая ориентация или положение устройства. Это можно сделать в функции делегата рендерера SCNView, которая вызывается каждый раз, когда будет рендериться новый кадр 3D-движка:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() }

К этому моменту, если вы запустите приложение, вы должны увидеть квадрат фокуса над потоком камеры, ищущим горизонтальную плоскость. В следующем разделе мы объясним, как обнаруживаются плоскости и как мы можем соответствующим образом расположить квадрат фокусировки.

Обнаружение самолетов в ARKit

ARKit может обнаруживать новые плоскости, обновлять существующие или удалять их. Чтобы удобно обрабатывать плоскости, мы создадим некоторый фиктивный узел SceneKit, который будет содержать информацию о положении плоскости и ссылку на квадрат фокуса. Плоскости определяются в направлениях X и Z, где Y — нормаль поверхности, т. е. мы всегда должны сохранять положение узлов чертежа в пределах одного и того же значения Y плоскости, если мы хотим, чтобы она выглядела так, как будто она напечатана на плоскости. .

Обнаружение самолетов осуществляется с помощью функций обратного вызова, предоставляемых ARKit. Например, следующая функция обратного вызова вызывается всякий раз, когда обнаруживается новый самолет:

 var planes = [ARPlaneAnchor: Plane]() func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { if let planeAnchor = anchor as? ARPlaneAnchor { serialQueue.async { self.addPlane(node: node, anchor: planeAnchor) self.virtualObjectManager.checkIfObjectShouldMoveOntoPlane(anchor: planeAnchor, planeAnchorNode: node) } } } func addPlane(node: SCNNode, anchor: ARPlaneAnchor) { let plane = Plane(anchor) planes[anchor] = plane node.addChildNode(plane) } ... class Plane: SCNNode { var anchor: ARPlaneAnchor var focusSquare: FocusSquare? init(_ anchor: ARPlaneAnchor) { self.anchor = anchor super.init() } ... }

Функция обратного вызова предоставляет нам два параметра: anchor и node . node является обычным узлом SceneKit, размещенным в точном положении и ориентации плоскости. У него нет геометрии, поэтому он невидим. Мы используем его, чтобы добавить наш собственный узел плоскости, который также невидим, но содержит информацию об ориентации и положении плоскости в anchor .

Так как же положение и ориентация сохраняются в ARPlaneAnchor ? Положение, ориентация и масштаб закодированы в матрице 4x4. Если у меня будет возможность выбрать для вас одно математическое понятие, то это, несомненно, будут матрицы. В любом случае, мы можем обойти это, описав эту матрицу 4x4 следующим образом: Великолепный двумерный массив, содержащий числа 4x4 с плавающей запятой. Умножая эти числа определенным образом на трехмерную вершину v1 в ее локальном пространстве, мы получаем новую трехмерную вершину v2, которая представляет v1 в мировом пространстве. Итак, если v1 = (1, 0, 0) в своем локальном пространстве, и мы хотим поместить его в точку x = 100 в мировом пространстве, v2 будет равно (101, 0, 0) относительно мирового пространства. Конечно, математика, стоящая за этим, становится более сложной, когда мы добавляем вращения вокруг осей, но хорошая новость заключается в том, что мы можем обойтись без ее понимания (я настоятельно рекомендую просмотреть соответствующий раздел этой превосходной статьи для подробного объяснения этой концепции). ).

checkIfObjectShouldMoveOntoPlane проверяет, есть ли у нас уже нарисованные объекты, и проверяет, совпадает ли ось Y всех этих объектов с осью вновь обнаруженных плоскостей.

Теперь вернемся к updateFocusSquare() , описанному в предыдущем разделе. Мы хотим, чтобы фокус был квадратным в центре экрана, но проецировался на ближайшую обнаруженную плоскость. Код ниже демонстрирует это:

 func updateFocusSquare() { let worldPos = worldPositionFromScreenPosition(screenCenter, self.sceneView) self.focusSquare?.simdPosition = worldPos } func worldPositionFromScreenPosition(_ position: CGPoint, in sceneView: ARSCNView) -> float3? { let planeHitTestResults = sceneView.hitTest(position, types: .existingPlaneUsingExtent) if let result = planeHitTestResults.first { return result.worldTransform.translation } return nil }

sceneView.hitTest ищет плоскости реального мира, соответствующие 2D-точке на экране, проецируя эту 2D-точку на ближайшую нижнюю плоскость. result.worldTransform — это матрица 4x4, которая содержит всю информацию о преобразовании обнаруженной плоскости, а result.worldTransform.translation — это удобная функция, которая возвращает только позицию.

Теперь у нас есть вся информация, необходимая для размещения 3D-объекта на обнаруженных поверхностях с учетом 2D-точки на экране. Итак, приступим к рисованию.

Рисунок

Давайте сначала объясним подход к рисованию фигур, которые следуют человеческому пальцу в компьютерном зрении. Рисование фигур выполняется путем обнаружения каждого нового местоположения движущегося пальца, удаления вершины в этом месте и соединения каждой вершины с предыдущей. Вершины можно соединить простой линией или кривой Безье, если нам нужен плавный вывод.

Для простоты мы будем следовать немного наивному подходу к рисованию. Для каждого нового положения пальца мы будем сбрасывать на обнаруженный план очень маленькую коробочку со скругленными углами и почти нулевой высотой. Это будет выглядеть как точка. Как только пользователь закончит рисовать и нажмет кнопку 3D, мы изменим высоту всех перетаскиваемых объектов в зависимости от движения пальца пользователя.

В следующем коде показан класс PointNode , представляющий точку:

 let POINT_SIZE = CGFloat(0.003) let POINT_HEIGHT = CGFloat(0.00001) class PointNode: SCNNode { static var boxGeo: SCNBox? override init() { super.init() if PointNode.boxGeo == nil { PointNode.boxGeo = SCNBox(width: POINT_SIZE, height: POINT_HEIGHT, length: POINT_SIZE, chamferRadius: 0.001) // Setup the material of the point let material = PointNode.boxGeo!.firstMaterial material?.lightingModel = SCNMaterial.LightingModel.blinn material?.diffuse.contents = UIImage(named: "wood-diffuse.jpg") material?.normal.contents = UIImage(named: "wood-normal.png") material?.specular.contents = UIImage(named: "wood-specular.jpg") } let object = SCNNode(geometry: PointNode.boxGeo!) object.transform = SCNMatrix4MakeTranslation(0.0, Float(POINT_HEIGHT) / 2.0, 0.0) self.addChildNode(object) } . . . }

Вы заметите в приведенном выше коде, что мы перемещаем геометрию по оси Y на половину высоты. Причина этого в том, чтобы убедиться, что нижняя часть объекта всегда находится в точке y = 0 , чтобы он отображался над плоскостью.

Далее, в функции обратного вызова рендерера SceneKit мы нарисуем некоторый индикатор, который действует как точка кончика пера, используя тот же класс PointNode . Мы опустим точку в этом месте, если включено рисование, или поднимем рисунок в 3D-структуру, если включен 3D-режим:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { updateFocusSquare() // Setup a dot that represents the virtual pen's tippoint if (self.virtualPenTip == nil) { self.virtualPenTip = PointNode(color: UIColor.red) self.sceneView.scene.rootNode.addChildNode(self.virtualPenTip!) } // Draw if let screenCenterInWorld = worldPositionFromScreenPosition(self.screenCenter, self.sceneView) { // Update virtual pen position self.virtualPenTip?.isHidden = false self.virtualPenTip?.simdPosition = screenCenterInWorld // Draw new point if (self.inDrawMode && !self.virtualObjectManager.pointNodeExistAt(pos: screenCenterInWorld)){ let newPoint = PointNode() self.sceneView.scene.rootNode.addChildNode(newPoint) self.virtualObjectManager.loadVirtualObject(newPoint, to: screenCenterInWorld) } // Convert drawing to 3D if (self.in3DMode ) { if self.trackImageInitialOrigin != nil { DispatchQueue.main.async { let newH = 0.4 * (self.trackImageInitialOrigin!.y - screenCenterInWorld.y) / self.sceneView.frame.height self.virtualObjectManager.setNewHeight(newHeight: newH) } } else { self.trackImageInitialOrigin = screenCenterInWorld } } }

virtualObjectManager — это класс, управляющий нарисованными точками. В 3D-режиме мы оцениваем разницу от последней позиции и увеличиваем/уменьшаем высоту всех точек с этим значением.

До сих пор мы рисовали на обнаруженной поверхности, предполагая, что виртуальное перо находится в центре экрана. Теперь самое интересное — обнаружение пальца пользователя и использование его вместо центра экрана.

Обнаружение кончика пальца пользователя

Одной из крутых библиотек, которую Apple представила в iOS 11, является Vision Framework. Он предоставляет некоторые методы компьютерного зрения довольно удобным и эффективным способом. В частности, мы собираемся использовать технику отслеживания объектов для нашего урока дополненной реальности. Отслеживание объектов работает следующим образом: сначала мы предоставляем ему изображение и координаты квадрата в границах изображения для объекта, который мы хотим отслеживать. После этого мы вызываем некоторую функцию для инициализации отслеживания. Наконец, мы загружаем новое изображение, на котором положение этого объекта изменилось, и результат анализа предыдущей операции. Учитывая это, он вернет нам новое местоположение объекта.

Мы воспользуемся небольшой хитростью. Мы попросим пользователя положить руку на стол, как если бы он держал ручку, и убедиться, что его миниатюра обращена к камере, после чего он должен нажать на миниатюру на экране. Здесь необходимо уточнить два момента. Во-первых, миниатюра должна иметь достаточно уникальных особенностей, чтобы их можно было проследить по контрасту между белой миниатюрой, кожей и столом. Это означает, что более темный пигмент кожи приведет к более надежному отслеживанию. Во-вторых, поскольку пользователь кладет руки на стол, и поскольку мы уже определяем стол как плоскость, проецирование положения миниатюры из 2D-вида в 3D-среду приведет к почти точному местоположению пальца на экране. стол.

На следующем изображении показаны характерные точки, которые могут быть обнаружены библиотекой Vision:

Особенности iOS ARKit, обнаруженные библиотекой Vision

Мы инициализируем отслеживание миниатюр в жесте касания следующим образом:

 // MARK: Object tracking fileprivate var lastObservation: VNDetectedObjectObservation? var trackImageBoundingBox: CGRect? let trackImageSize = CGFloat(20) @objc private func tapAction(recognizer: UITapGestureRecognizer) { lastObservation = nil let tapLocation = recognizer.location(in: view) // Set up the rect in the image in view coordinate space that we will track let trackImageBoundingBoxOrigin = CGPoint(x: tapLocation.x - trackImageSize / 2, y: tapLocation.y - trackImageSize / 2) trackImageBoundingBox = CGRect(origin: trackImageBoundingBoxOrigin, size: CGSize(width: trackImageSize, height: trackImageSize)) let t = CGAffineTransform(scaleX: 1.0 / self.view.frame.size.width, y: 1.0 / self.view.frame.size.height) let normalizedTrackImageBoundingBox = trackImageBoundingBox!.applying(t) // Transfrom the rect from view space to image space guard let fromViewToCameraImageTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait).inverted() else { return } var trackImageBoundingBoxInImage = normalizedTrackImageBoundingBox.applying(fromViewToCameraImageTransform) trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y // Image space uses bottom left as origin while view space uses top left lastObservation = VNDetectedObjectObservation(boundingBox: trackImageBoundingBoxInImage) }

Самая сложная часть выше — как преобразовать местоположение касания из координатного пространства UIView в координатное пространство изображения. ARKit предоставляет нам матрицу displayTransform , которая преобразует координатное пространство изображения в координатное пространство области просмотра, но не наоборот. Итак, как мы можем сделать обратное? Используя обратную матрицу. Я действительно пытался свести к минимуму использование математики в этом посте, но иногда это неизбежно в 3D-мире.

Затем в рендерере мы собираемся передать новое изображение для отслеживания нового местоположения пальца:

 func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { // Track the thumbnail guard let pixelBuffer = self.sceneView.session.currentFrame?.capturedImage, let observation = self.lastObservation else { return } let request = VNTrackObjectRequest(detectedObjectObservation: observation) { [unowned self] request, error in self.handle(request, error: error) } request.trackingLevel = .accurate do { try self.handler.perform([request], on: pixelBuffer) } catch { print(error) } . . . }

Как только отслеживание объекта будет завершено, он вызовет функцию обратного вызова, в которой мы обновим местоположение эскиза. Обычно это обратный код, написанный в распознавателе касаний:

 fileprivate func handle(_ request: VNRequest, error: Error?) { DispatchQueue.main.async { guard let newObservation = request.results?.first as? VNDetectedObjectObservation else { return } self.lastObservation = newObservation var trackImageBoundingBoxInImage = newObservation.boundingBox // Transfrom the rect from image space to view space trackImageBoundingBoxInImage.origin.y = 1 - trackImageBoundingBoxInImage.origin.y guard let fromCameraImageToViewTransform = self.sceneView.session.currentFrame?.displayTransform(withViewportSize: self.sceneView.frame.size, orientation: UIInterfaceOrientation.portrait) else { return } let normalizedTrackImageBoundingBox = trackImageBoundingBoxInImage.applying(fromCameraImageToViewTransform) let t = CGAffineTransform(scaleX: self.view.frame.size.width, y: self.view.frame.size.height) let unnormalizedTrackImageBoundingBox = normalizedTrackImageBoundingBox.applying(t) self.trackImageBoundingBox = unnormalizedTrackImageBoundingBox // Get the projection if the location of the tracked image from image space to the nearest detected plane if let trackImageOrigin = self.trackImageBoundingBox?.origin { self.lastFingerWorldPos = self.virtualObjectManager.worldPositionFromScreenPosition(CGPoint(x: trackImageOrigin.x - 20.0, y: trackImageOrigin.y + 40.0), in: self.sceneView) } } }

Наконец, мы будем использовать self.lastFingerWorldPos вместо центра экрана при рисовании, и все готово.

ARKit и будущее

В этом посте мы продемонстрировали, как дополненная реальность может быть иммерсивной за счет взаимодействия с пальцами пользователя и реальными столами. С дальнейшими достижениями в области компьютерного зрения и добавлением к гаджетам более подходящего для AR оборудования (например, камер глубины) мы можем получить доступ к 3D-структуре все большего и большего количества объектов вокруг нас.

Хотя это еще не выпущено в массы, стоит упомянуть, что Microsoft очень серьезно настроена выиграть гонку AR с помощью своего устройства Hololens, которое сочетает в себе оборудование, адаптированное к AR, с передовыми методами распознавания трехмерной среды. Вы можете подождать, чтобы узнать, кто выиграет эту гонку, или вы можете стать ее частью, разработав настоящие приложения дополненной реальности с эффектом присутствия прямо сейчас! Но, пожалуйста, сделайте одолжение человечеству и не превращайте живые предметы в кроликов.