Найти в Дзене
Nuances of programming

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

Оглавление

Источник: Nuances of Programming

Курс SkillFactory iOS-разработчик. Научитесь создавать полезные мобильные приложения для iOS, которые смогут выйти в топ App Store.

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

-2

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

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

Графики SwiftUI

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

Значения смешанных форм, созданные с помощью 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
Суммы смешанных форм, повторно созданные с помощью 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 секунд
Значения смешанных форм, зарегистрированные за 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
Углы faceAnchor, построенные с помощью SwiftUi Charts

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

Выборка

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

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

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

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

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

Изображение, снятое на iPhone 13
Изображение, снятое на 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”), чтобы переместить слово из одного из двух меню в строку высказывания, на которой можно прочитать то, что было сказано.

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

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

Читайте нас в Telegram, VK

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