Привет, Хабр. Меня зовут Степанян Альберт и я всё ещё бегун по граблям и разработчик отдела R&D в компании «Биорг».

В Древнем Риме говорили: «Mens sana in corpore sano» - в здоровом теле здоровый дух. Два тысячелетия спустя мы, айтишники, превратили эту мудрость в её полную противоположность: здоровый дух, сидящий в нездоровом теле перед монитором. Наш позвоночник принимает форму вопросительного знака, а шея начинает напоминать ржавый механизм - повернуть голову можно только всем корпусом, издавая при этом характерный хруст.
Те, кто много кодит, знают об этой проблеме не понаслышке. Но, в отличие от большинства офисных работников, у нас под рукой оказалась система видеоидентификации, способная распознавать микродвижения человеческого лица. И как-то раз, тестируя систему, я поймал себя на мысли:
- Да это же готовый трекер движений. Дай ему интерфейс и он превратится в игру. Осталось только придумать, во что играть…
Попробовать разминку с Джонни и Джулией
Бесплатно, без смс, без регистрации и без подвоха. Под музыку или без! Мне не жалко. Задача просто шею размять!
Обычный антифрод и его необычная судьба
Я работаю в команде, где одна из задач - борьба с фродом. Способность определять живость человека по движениям головы, согласно командам - собственно то, из чего родилась разминка - это изначально просто одна из фич, которые я сделал для системы видеоидентификации. Корректность выполнения движений (хотя не только этот критерий) помогает подтвердить, что перед нами живой пользователь.
О том, как удалось прийти к этому решению и почему оно эффективно, уже рассказано в статье Развенчан маскарад подделок: прорыв в проверке документов
Что принципиально для этой задачи: детектировать ключевые точки лица с высокой точностью. Алгоритм в реальном времени вычисляет углы поворота головы, наклоны, моргания. Но ведь эти движения похожи на те, которые человек выполняет во время зарядки!
Тогда и родилась идея: а что, если сделать не просто идентификацию, а полезную разминку для шеи, встроенную прямо в браузер?
Рождение Джонни и Джулии
Испытать искреннее удивление от собственных возможностей и окружающего мира - это прекрасные и глубокие ощущения. Получить их может тот, кто вырвался из привычной рутины, кто столкнулся с тем, что выходит за рамки повседневного опыта. Так на свет появились два персонажа: Джонни и Джулия.
Их история похожа на красивый роман. За плечами дипломы одного из лучших университетов мира и блестящие перспективы за рубежом. Они приехали в Россию как гости, желая увидеть страну. Россия встретила их искренним гостеприимством, новой культурой, непередаваемой атмосферой. Наша страна покорила их сердца навсегда. Очарованные увиденным, они приняли главное решение в своей жизни - остаться здесь, с нами.

Почему двое? В виджет я добавил определение пола пользователя на старте - система понимает, кто перед камерой, и предлагает тренера того же пола.
В общем, это мелочь, но она делает взаимодействие чуть более персонализированным. Пол система определяет с помощью открытой библиотеки face-api.js с лицензией MIT. По желанию, пол инструктора можно сменить. Для этого есть специальная кнопка - справа вверху.
Персонажи нарисованы в мультяшном стиле, чтобы получились яркие, добрые, с улыбкой. Ведь разминка - это радость, а не напряжение, так? :) К тому же, я подумал: если пользователь будет делать серьёзное лицо, разминая шею, это выглядит глупо. А если рядом весёлый инструктор - процесс идёт легче.
Есть один нехитрый приём, который делает картинку цельной:
Убираем фон у окна с пользователем и заменяем его на белый.
То же самое делаем с фоном инструктора.
В итоге фоны сливаются с общим белым фоном страницы, и визуально окна перестают быть «окнами», всё превращается в единую композицию. Профит: пользователь видит себя и тренера в одном пространстве, а не в двух отдельных квадратах.

Как считать синхронизацию
Разминка состоит из 30 движений, и они каждый раз генерируются случайным образом. Никаких заучиваний - каждый новый подход будет отличаться от предыдущего. Это не даёт скучать и не позволяет мозгу «засыпать» на заученных автоматических движениях.
Для каждого движения есть несколько вариантов упражнений. У каждого есть ожидаемая длительность - t.time. Пока пользователь выполняет движение, система замеряет его фактическую скорость и фиксирует задержку (тот самый delay) - насколько пользователь отстаёт от тренера.
Когда все упражнения завершены суммируется две величины:
totalExp- общее ожидаемое время всех движений;totalDelay- суммарное отставание пользователя.
Дальше простая формула:
raw = 100 - (totalDelay / totalExp * 100); score = Math.max(0, Math.min(100, raw));
То есть, если вы выполнили разминку идеально синхронно - твой скор стремится к 100. Но лучше оставить небольшой люфт. Ведь в реальном мире есть погрешности: камера может чуть дрожать, браузер - подвисать, а твоя шея - иметь свою точку зрения на то, как быстро надо поворачиваться.
И ещё один важный нюанс по геймплею. Заметил в тестах, что некоторые пользователи не могли с первого раза повторить то или иное движение. Может шея затекла, может не поняли траекторию. Зависать на одном движении бесконечно - плохая идея. Это раздражает и убивает смысл разминки.
Поэтому сделано так: после двух неудачных повторов инструктор переходит к следующему движению. Пользователь не чувствует себя неудачником, а просто продолжает разминку. А если он вообще не понял, что делать, - система не наказывает, а мягко ведёт дальше. В конце его ждёт честный результат, а не строгий экзамен.
Итоговый результат мы очеловечиваем:
if (score >= 98) text = "Синхронизация 100% - Вы копия тренера!"; else if (score >= 90) text = "90% синхронизации - Осталось совсем чуть чуть!"; else if (score >= 70) text = "70% синхронизации - Подтягиваемся к идеалу!"; else if (score >= 50) text = "50% синхронизации - Половина пути пройдена!"; else text = "30% синхронизации - Ловим темп тренера!";
98%, а не 100% специально обозначено как идеал. Потому что перфекционизм - вредно для здоровья. Если ты получил 98% - считай, что ты чемпион! А вот 30% - это повод не расстраиваться, а попробовать ещё раз, просто поймав ритм.
Как подружили разминку с телефонами
Изначально проектировал разминку для больших мониторов лэптов и рабочих станций. А что? Удобно! Сидишь перед камерой ноутбука, поворачиваешь голову, и Джонни с Джулией тебя понимают. Но когда я решил затестить у коллег, первым делом они спросили:
- А на телефоне это работать будет?
Первая мысль была - определять устройство по размеру экрана. Но это оказалось ловушкой. Современные телефоны имеют диагональ почти как у небольших ноутбуков, а планшеты и вовсе размывают границы.
Я лично столкнулся с этой проблемой на своём Redmi Note 9. Экран у него, конечно, не как у ноутбука, но по ширине в пикселях он вполне сопоставим с некоторыми дешёвыми ноутбуками. Система считала, что раз экран достаточно широкий, значит, это компьютер, и отказывалась переключаться в мобильный режим. Кнопки оставались мелкими, интерфейс не адаптировался, а инструктор с пользователем пытались уместиться в той же раскладке, что и на большом экране. Работать было невозможно.
Стало понятно, размер экрана - ненадёжный признак, и нужно искать что-то другое.
Тогда я пошёл другим путём: проверять наличие датчика ориентации, но не одним, а двумя признаками:
function isMobileDevice() { return 'DeviceOrientationEvent' in window && 'ontouchstart' in window; }
Первое условие - поддержка события ориентации (есть у телефонов и планшетов). Второе - поддержка касаний (отсекает компьютеры, даже если у них случайно затесался акселерометр). Вместе 2 условия дают почти 100% точность.
Если проверка проходит - добавляем на страницу класс mobile:
if (isMobileDevice()) { document.body.classList.add('mobile'); }
И дальше уже через CSS управляем всем, что должно отличаться. Но самое главное - это размер и положение окна с пользователем и инструктором.
На компьютере, как и на телефоне, в горизонтальном режиме, мы используем классическую раскладку: пользователь слева, инструктор справа. Это естественно для широкого экрана - взгляд скользит слева направо, и ты видишь одновременно и себя, и тренера.
На телефоне в вертикальном режиме всё иначе. Экран узкий, раскладка «слева-направо» не работает. Поэтому пользователь занимает верхнюю половину экрана, а окно с инструктором - нижнюю. Так пользователь видит и тренера, и себя одновременно.
И это не просто визуальная прихоть. Если окно с пользователем слишком маленькое - он не видит, правильно ли выполняет движения. Если слишком большое - перекрывает инструктора: теряется смысл «повторяй за мной». Поэтому пришлось потратить несколько итераций, чтобы найти идеальные пропорции для каждого типа устройства и каждой ориентации.
Сам полноэкранный режим реализован и для компа, и для телефона:
const hint = document.querySelector('.exit-fullscreen-hint'); if (hint) { hint.style.display = 'none'; }
Справедливости ради: подсказка про выход из полноэкранного режима появляется и на компьютере, и на телефоне. Просто она разная. На компьютере - «Нажмите Esc для выхода из полноэкранного режима». На телефоне - «Проведите снизу вверх для выхода из полноэкранного режима». В обоих случаях она исчезает через 3 секунды, чтобы не мешать пользователю разминаться.
Трекинг лица: локальная сборка без сюрпризов
Для трекинга лица в разминке использовал MediaPipe Face Mesh. Это открытая библиотека от Google, распространяемая под лицензией Apache 2.0. Взял её в локальную сборку, поэтому всё работает стабильно и без внешних запросов к CDN. Никаких сюрпризов с недоступными серверами или обновлёнными API. Только предсказуемая логика в твоём браузере.
Инициализируется трекер так:
this.faceLandmarker = new FaceMesh({ locateFile: (file) => `/static/mediapipe-face-mesh/${file}` }); this.faceLandmarker.setOptions({ maxNumFaces: 1, refineLandmarks: false, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 });
Обратите внимание на refineLandmarks: false - он выключен специально. Нам не нужна микро-детализация губ и глаз, только общий контур головы. Это и экономия ресурсов браузера и именно благодаря локальной сборке можно настраивать параметры под свои задачи, не оглядываясь на внешние зависимости.
И ещё один бонус локального подхода: библиотека лежит в нашем проекте, а браузер кеширует её при первом запросе, так что при повторных запусках она не скачивается заново.
И главное, что MediaPipe работает независимо от того, как держать телефон: вертикально или горизонтально. Главное, чтобы лицо было в кадре!
Какой режим выбрать?
В итоге оказалось, что в вертикальном режиме пользователь интуитивно держит телефон на уровне лица и это идеально для детекции. Плюс, интерфейс с Джонни и Джулией выглядит как видеозвонок с личным тренером.
А в горизонтальном режиме телефон можно поставить на подставку или положить на стол и выполнять упражнения, глядя в экран, как в маленькое зеркало. Такой формат больше похож на классический фитнес-гаджет.
Так что теперь рекомендую вертикальный режим для атмосферы, а горизонтальный для техничного прохождения с максимальным комфортом.
Музыка - двигатель прогресса (и шеи)
Когда прототип с Джонни и Джулией заработал, я дал его потестить коллегам. Офис разделился на два лагеря.
Первые, хмуро глядя в монитор, синхронно поворачивали головы и напоминали роботов на конвейере. Вторые незаметно покачивались в такт и слегка пританцовывали, даже когда никто не смотрел. Разница была в одном: у вторых на фоне тихо играла музыка.
Я решил, что пусть пользователь сам решает, хочет ли он разминаться в тишине или под ритмичный трек. Так появились две кнопки: «Под музыку» и «Без музыки».
И если с «тихим» режимом всё просто, то с музыкой пришлось повозиться. Перебрал десяток треков разного темпа, пока не нашел тот самый, который совпадает с комфортной скоростью поворота головы при разминке. Слишком быстро - пользователь не успевает, слишком медленно - скучно. Идеальный темп нашёлся где-то около 140 BPM, как у хорошего транс-хауса.
И вот теперь представь, читатель: ты сидишь после пяти часов дебаггинга, запускаешь разминку, выбираешь «Под музыку», и на экране появляется симпатичная Джулия, которая под ритмичный бит начинает поворачивать голову. Ты повторяешь и незаметно для себя перестаёшь думать о багах и просто двигаешься. Это работает как медитация.
Тайна, которую мы не раскроем (но вы всё равно узнаете)
Когда разминка заканчивается и на экране появляется твой результат что-то происходит.
Не буду раскрывать, что именно. Пусть это останется сюрпризом для тех, кто дойдёт до финала. Скажу одно: я вложил туда частичку души, и, судя по первым тестам, разминка вызывает чистую, детскую радость даже у самых суровых бэкенд-разработчиков.
Так что, когда дойдёте до конца, поделитесь в комментариях, какой бонус увидели и понравилось ли вам.
Заключение
История этой разминки напомнила мне , что лучшие идеи всё же рождаются не из строгих ТЗ. Я не планировал делать разминку. Я работал над своей частью антифрод-блока. А побочно получился продукт, который одинаково полезен и для безопасности системы, и для шеи разработчика.
А вообще, Джонни и Джулии у нас нравится. Они уже члены нашей команды - ходят на парады и поздравляют нас с праздниками.


