Реализуем функцию управления взглядом с помощью SwiftUI, ARKit и SceneKit

Я занимаюсь созданием приложений с функцией “свободные руки” (hands-free)  —  для этого использую возможности FaceID у таких устройств, как iPhone 14 Pro и iPad Pro.

Мне удалось добиться некоторых успехов в решении этих задач. Но все же я отдавал себе отчет в том, что мои разработки слишком сложны в использовании и ненадежны. Я проанализировал свой опыт, обсудил его с коллегой и решил копнуть глубже.

Вот куда меня привели эти поиски.

Графики SwiftUI

Я чувствовал, что корень моих неудач скрывался где-то в данных. Осознав проблему, я понял, что справиться с ней поможет новый фреймворк SwiftUI Charts. Поэтому создал код для построения графика.

Значения смешанных форм, созданные с помощью SwiftUI Charts

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

Однако с левым столбцом leftOut что-то не так. Когда я пытаюсь смотреть в центр экрана, он по-прежнему регистрирует значения. Он “падает”, только когда я смотрю в правую сторону.

if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookInLeft = faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue
}
if (faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue) > 0.1 {
looker.eyeLookInRight = faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue
}
if (faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutLeft = faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValu
}
if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutRight = faceAnchor.blendShapes[.eyeLookOutRight]!.doubleValue
}

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

Суммы смешанных форм, повторно созданные с помощью SwiftUI Charts

Это лучший результат, потому что я получил сигнал без шума. Однако теперь что-то происходило с правым столбцом rightIn.

if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookInLeft = faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue
looker.eyeLookInLeftPot += looker.eyeLookInLeft
}
if (faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue) > 0.1 {
looker.eyeLookInRight = faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue
looker.eyeLookInRightPot += looker.eyeLookInRight
}
if (faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutLeft = faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValue
looker.eyeLookOutLeftPot += looker.eyeLookOutLeft
}
if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutRight = faceAnchor.blendShapes[.eyeLookOutRight]!.doubleValue
looker.eyeLookOutRightPot += looker.eyeLookOutRight

Я создал третий график, на котором регистрируется ряд значений, наблюдаемых в течение X секунд. В этот раз я должен был получить четкий сигнал, даже если в самом конце столбец rightIn снова зарегистрирует несколько нестандартных значений.

Значения смешанных форм, зарегистрированные за X секунд

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

if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookInLeft = faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue
looker.eyeLookInLeftPot += 1
}
if (faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue) > 0.1 {
looker.eyeLookInRight = faceAnchor.blendShapes[.eyeLookInRight]!.doubleValue
looker.eyeLookInRightPot += 1
}
if (faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutLeft = faceAnchor.blendShapes[.eyeLookOutLeft]!.doubleValue
looker.eyeLookOutLeftPot += 1
}
if (faceAnchor.blendShapes[.eyeLookInLeft]!.doubleValue) > 0.1 {
looker.eyeLookOutRight = faceAnchor.blendShapes[.eyeLookOutRight]!.doubleValue
looker.eyeLookOutRightPot += 1
}

Я решил построить еще одну вариацию, в которой использовался угол, сообщаемый трансформациями глаз faceAnchor. Казалось, что она способна обнаружить более мелкие изменения.

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

Между тем, мне следовало быть внимательнее, поскольку в вертикальном режиме левая/правая стороны были осью X, а в горизонтальном  —  осью Y. Кроме того, мне приходилось работать с положительными и отрицательными значениями. Смотрите внимательно, и вы заметите переход оси справа.

Углы faceAnchor, построенные с помощью SwiftUi Charts

Я приобрел полезный опыт, поскольку понял, почему было так трудно получить надежные результаты.

Выборка

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

Затем я создал второй узел, положение которого обновлял всего раз в секунду, используя координаты узла тени. Чтобы все сгладить, добавил анимацию. У меня кое-что получилось (вы уже видели результат  —  зеленую летающую рыбу на самом первом изображении).

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

Перезагрузка

В качестве отправной точки я взял код из первой статьи, а также работу, которую только что проделал с faceAnchors.

Изображение, снятое на iPhone 13

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

Вершины, внутри которых есть некоторые фиксированные магические числа, такие как перечисленные здесь. Я называю их “фиксированными”, поскольку нашел их в этой публикации и дважды убедился в том, что они верны. Так что пока они фиксированы, но завтра могут и не быть  —  будьте бдительны.

let mouthTopLeft = Array(250...256)
let mouthTopCenter = [24]
let mouthTopRight = Array(685...691).reversed()
let mouthRight = [684]
let mouthBottomRight = [682, 683,700,709,710,725]
let mouthBottomCenter = [25]
let mouthBottomLeft = [265,274,290,275,247,248]
let mouthLeft = [249]
let mouthClockwise : [Int] = mouthLeft +
mouthTopLeft + mouthTopCenter +
mouthTopRight + mouthRight +
mouthBottomRight + mouthBottomCenter +
mouthBottomLeft
let eyeTopLeft = Array(1090...1101)
let eyeBottomLeft = Array(1102...1108) + Array(1085...1089)
let eyeTopRight = Array(1069...1080)
let eyeBottomRight = Array(1081...1084) + Array(1061...1068)
let nose = [9]
let leftEye = [1064]
let rightEye = [42]
let mouth = [24,25]
let forehead = [20]

Я буду использовать вершины для привязки маркера к центру экрана  —  ранее я применял zero, но faceAnchor работает лучше благодаря динамичности.

Затем я создал виртуальный шар, плавающий перед моим носом. Я также ввел в уравнение ориентацию faceAnchor.

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

func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
let device = trackingView.device

faceGeometry = ARSCNFaceGeometry(device: device!)
faceNode = SCNNode(geometry: faceGeometry)
faceNode.geometry?.firstMaterial?.fillMode = .lines
faceNode.geometry?.firstMaterial?.diffuse.contents = UIColor.white.withAlphaComponent(0.75)

faceNode.addChildNode(sphereNode)
return faceNode
}

func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {

faceGeometry.update(from: faceAnchor.geometry)
}

Упомянутый здесь sphereNode был просто сферой. Но это только начало: мне нужно было добиться стабилизации и сделать выбор менее трудоемким.

После нескольких неудачных попыток я реализовал нечто совершенно иное  —  отслеживание того, как долго пользователь смотрит в ту или иную сторону (как долго он удерживает взгляд, а не то, сколько раз он смотрит в ту или иную сторону).

if (looked.gazeX > 0.05 && !looked.paused && !looked.outOfBounds && looked.spell) {
DispatchQueue.main.async { [self] in
Task { await looks.addShape(faceSeen: looked.gazeX) }
}
}

Взгляды

Чтобы обеспечить максимально быстрый отклик, я отслеживаю и сообщаю о взгляде в обратном вызове рендеринга. Однако это означает, что я получаю 10 и более откликов, когда направляю взгляд влево, вправо, вверх и вниз. Чтобы поддерживать чистоту, я сохранил эти ответы в “акторе”.

actor Looks: NSObject {
static var shared = Looks()

func addShape(faceSeen:Float) {
gazesX.append(faceSeen)
}

func rightShapes() -> Int {
let gazesSeen = gazesX.filter( { $0 > 0 } )
return gazesSeen.count
}

func leftShapes() -> Int {
let gazesSeen = gazesX.filter( { $0 < 0 } )
return gazesSeen.count
}

func resetShapes() {
gazesX.removeAll()
}
}

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

if looksRight > 16 {
self.looked.vindex += 1
self.looked.vindex = self.looked.vindex % vowels.count
}
Белая точка на носу помогает держать лицо по центру экрана для отслеживания взгляда

Однако если пользоваться приложениями, произносить слова по буквам быстро надоест. Я хотел реализовать нечто подобное тому, что делает приложение WhatsApp при наборе текста.

Для этого я использовал список самых популярных слов на английском языке, а также простое регулярное выражение для возврата списка найденных слов на основе введенных букв. Если вы введете “t”, то получите все слова с буквой “t. Если введете “th”, то список будет перестроен только с теми словами, которые начинаются с “th”.

Вы можете переключаться между двумя режимами, чтобы выбрать больше букв и просмотреть предложенные слова. Если нужного слова там нет, вы переключаетесь обратно [с помощью взгляда вверх для переключения между двумя режимами] и произносите все слово по буквам.

Кроме того, я использовал жест “высунуть язык” (“tongue out”), чтобы переместить слово из одного из двух меню в строку высказывания, на которой можно прочитать то, что было сказано.

Здесь все это показано в действии. А тут  —  полная версия исходного кода.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Mark Lucking: Build a Hands-free SwiftUI App Using ARKit and SceneKit

Предыдущая статьяДата и время в JavaScript
Следующая статьяКонвейер данных в реальном времени с Kafka и ClickHouse