Я занимаюсь созданием приложений с функцией “свободные руки” (hands-free) — для этого использую возможности FaceID у таких устройств, как iPhone 14 Pro и iPad Pro.
Мне удалось добиться некоторых успехов в решении этих задач. Но все же я отдавал себе отчет в том, что мои разработки слишком сложны в использовании и ненадежны. Я проанализировал свой опыт, обсудил его с коллегой и решил копнуть глубже.
Вот куда меня привели эти поиски.
Графики SwiftUI
Я чувствовал, что корень моих неудач скрывался где-то в данных. Осознав проблему, я понял, что справиться с ней поможет новый фреймворк 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 секунд.
Это лучший результат, потому что я получил сигнал без шума. Однако теперь что-то происходило с правым столбцом 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
снова зарегистрирует несколько нестандартных значений.
Постепенно до меня дошло, почему все это не работало: код предполагал, что оба глаза будут функционировать с одинаковыми параметрами, но, судя по графам, данные не подтверждали это предположение.
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. Кроме того, мне приходилось работать с положительными и отрицательными значениями. Смотрите внимательно, и вы заметите переход оси справа.
Я приобрел полезный опыт, поскольку понял, почему было так трудно получить надежные результаты.
Выборка
Размышления над этой задачей привели меня к пониманию того, что все мы используем своего рода курсор на экране в качестве механизма обратной связи. Я вернулся к одной из своих первых версий и скопировал код для создания узла тени, который будет выделяться перед моим лицом. Это сработало, если бы не тот факт, что движение узла было слишком быстрым и слишком беспорядочным.
Затем я создал второй узел, положение которого обновлял всего раз в секунду, используя координаты узла тени. Чтобы все сгладить, добавил анимацию. У меня кое-что получилось (вы уже видели результат — зеленую летающую рыбу на самом первом изображении).
Теперь у меня было быстрое движение одним взглядом, медленное — поворотом головы, и постепенное, но существенное, накопление количества значений, зарегистрированных за X секунд, которые я отметил с помощью графов. Процесс пошел, пора было создавать третью версию.
Перезагрузка
В качестве отправной точки я взял код из первой статьи, а также работу, которую только что проделал с faceAnchors
.
Изображение, которое вы видите, содержит 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”), чтобы переместить слово из одного из двух меню в строку высказывания, на которой можно прочитать то, что было сказано.
Здесь все это показано в действии. А тут — полная версия исходного кода.
Читайте также:
- Распознавание лиц с помощью CoreML и ARKit
- Создание приложения для распознавания лиц с помощью Tensorflow.js
- Как создать компонент Toast в SwiftUI
Читайте нас в Telegram, VK и Дзен
Перевод статьи Mark Lucking: Build a Hands-free SwiftUI App Using ARKit and SceneKit