Учебное пособие по ARKit для iOS: рисование в воздухе голыми пальцами
Опубликовано: 2022-03-11Недавно Apple анонсировала свою новую библиотеку дополненной реальности (AR) под названием ARKit. Для многих это выглядело как еще одна хорошая библиотека дополненной реальности, а не технологический прорыв, о котором нужно заботиться. Тем не менее, если вы посмотрите на прогресс AR за последние пару лет, не следует торопиться с такими выводами.
В этом посте мы создадим забавный пример проекта ARKit, используя iOS ARKit. Пользователь кладет пальцы на стол, как будто держит ручку, нажимает на миниатюру и начинает рисовать. После завершения пользователь сможет преобразовать свой рисунок в 3D-объект, как показано на анимации ниже. Полный исходный код нашего примера iOS ARKit доступен на GitHub.
Почему мы должны заботиться об 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, включенном в пример кода. На следующем изображении показан фокусный квадрат, спроецированный на стол:
Следующим шагом является запуск сеанса 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:
Мы инициализируем отслеживание миниатюр в жесте касания следующим образом:
// 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, с передовыми методами распознавания трехмерной среды. Вы можете подождать, чтобы узнать, кто выиграет эту гонку, или вы можете стать ее частью, разработав настоящие приложения дополненной реальности с эффектом присутствия прямо сейчас! Но, пожалуйста, сделайте одолжение человечеству и не превращайте живые предметы в кроликов.