Кулак вместо хоткея: делаю управление macOS жестами и разбираюсь, куда уходит память

Как-то раз воскресным вечером листал телегу и наткнулся на демку MediaPipe и захотелось поразвлекаться с камерой и жестами. Чтобы от этого была хоть какая-то польза, решил пусть MacBook вызывает нужные хоткеи, когда я показываю ему кулак или пару пальцев.
Сначала рассматривал MediaPipe на Python, но в итоге выбрал Swift и VNDetectHumanHandPoseRequest из Apple Vision. Для постоянно работающего приложения на Mac хотелось нативного компилируемого и быстрого, да и с макосью взаимодействовать проще.
Vision возвращает координаты 21 сустава, в трекере раскладываю их в том же порядке, что и MediaPipe: запястье, затем по четыре точки каждого пальца.
Итоговая программа работает так:
Камера, 640×480, 15 кадров/с
→ Vision: 21 точка руки
→ нормализация положения, размера и поворота
→ kNN по записанным образцам
→ удержание, срабатывание, флаг
→ CGEvent с горячей клавишей
Для записи жеста собирается 30 образцов с интервалом не меньше 0,2 секунды. Соседние кадры почти одинаковые, поэтому записывать каждый смысла мало. Во время записи стоит немного двигать и наклонять руку.
В признаках из координат вычитается положение запястья и делится на длину от запястья до основания среднего пальца. Так одна и та же поза остаётся похожей на себя при перемещении по кадру и изменении расстояния до камеры. Координата x предварительно умножается на соотношение сторон кадра: Vision нормирует обе оси в диапазон от 0 до 1, а кадр у нас не квадратный. Про эти координаты есть разбор Apple на WWDC.
После исключения запястья получается вектор из 40 чисел. Расстояние между двумя позами считается в Features.distance как RMS смещения 20 суставов:
d = sqrt(Σ ((xᵢ − xᵢ′)² + (yᵢ − yᵢ′)²) / 20)
Что за RMS и зачем здесь квадратный корень?
Берем каждый сустав и смотрим, насколько он сдвинулся относительно записанного образца. Если один ушёл на 0,1 длины ладони, а другой на 0,3, среднее арифметическое даст 0,2. RMS сначала возводит оба расстояния в квадрат, потом усредняет и берёт корень: √((0,1² + 0,3²) / 2) ≈ 0,224. Большая ошибка получает больший вес. Для руки делаем то же самое с 20 точками. Чем меньше итоговое число, тем больше текущая поза похожа на записанную.
Порог 0,25 - смещение на четверть длины ладони.
Дальше kNN-классификатор с k=5. Голосуют только соседи внутри допустимого радиуса, если подходящих образцов нет - жест отклоняется. Записанный класс без действия тоже участвует в голосовании: можно набрать случайных поз под именем none, чтобы они не вызывали хоткеи.
Что делает kNN на пальцах?
При записи я сохраняю несколько примеров кулака и несколько примеров "виктории". Для нового кадра считаю расстояние до каждого сохранённого примера, выбираю пять ближайших и смотрю, каких жестов среди них больше. Буква k означает число соседей, здесь это 5. Но слишком далёкие примеры в голосование не пускаю: если ни один жест не похож достаточно сильно, лучше ничего не делать.
В итоге суть всего мероприятия проста - Vision извлекает точки, а небольшой классификатор сравнивает их с моими образцами.
Жест не должен нажимать клавишу пятнадцать раз в секунду
В кадре рука держится дольше одного кадра, потому после распознавания нужно фиксировать состояние - подержал жест 300 мс, получил одно действие, убрал руку, можно повторить. После срабатывания влаг остается взведеным, пока его отсутствие или другая поза не продержатся 300 мс.
Клавиатура у меня обычно на коленях под столом, рука попадает в кадр, когда я сам её поднимаю, поэтому отдельный жест для включения детекции пока не делал.
Первая версия почти удалась, но на нашлись три ошибки. Для каждой удалось построить короткий пример и добавить регрессионный тест.
Ошибка | Что происходило | Как поправил |
|---|---|---|
Удержание через паузу | Два кадра кулака в моменты 0 и 10 секунд считались удержанием | Разрыв больше 250 мс сбрасывает таймеры |
Ошибка трекинга считалась отпусканием | Vision терял сустав, флаг снимался, тот же кулак срабатывал снова | Разделил руку, отсутствие руки и ненадёжное наблюдение |
Далёкие соседи участвовали в голосовании | Три далёких образца перевешивали два почти точных | Голосование ограничили радиусом |
С голосованием вышла особенно глупая ошибка. Представим, что я показал идеальный жест A. Среди пяти ближайших записей две относятся к A: одна совпала точно, другая почти точно. Ещё три относятся к B, но они намного дальше. Старый код считал голоса всех пяти и выбирал B со счётом 3:2. Затем проверял расстояние только до ближайшего образца B. Тот ещё укладывался в порог, и вместо действия A срабатывало B. Пример искусственный, но это именно тот сценарий, который теперь закрыт тестом.
С потерей руки однозначного решения нет. Пустой результат Vision может означать и “я опустил руку под стол”, и “детектор не увидел кулак”. Я оставил отсутствие руки сигналом отпускания. Иначе привычный способ закончить жест перестал бы работать.
Ненадёжные наблюдения обрабатываются отдельно - при коротких провалах до 250 мс считаем, что прежнее состояние продолжалось. Это относится и к удержанию, и к отпусканию. Более длинный провал сбрасывает оба таймера, сохраняя флаг.
Сколько стоит постоянно читать камеру
Вроде бы все заработало, но осознание того, что такая штука будет постоянно висеть в фоне, стимулировало поковырять быстродействие и прожорливость. Все приведённые замеры сделаны на одном MacBook M3 Pro с macOS 26.5 и встроенной FaceTime HD. Проценты CPU относятся к одному ядру.
Изменение | Наблюдение |
|---|---|
Vision постоянно обрабатывает кадры с целевой частотой 15 fps | CPU приложения около 15,5%, демона камеры около 3–4% |
Через 3 секунды без руки обработка снижается до 5 fps | CPU приложения около 6,6% |
Debug заменили на Release | Заметной разницы CPU нет, память уменьшилась примерно на 30 МБ |
Реальную частоту камеры ограничил 15 fps | CPU демона снизился с 3,1–3,5% до 2,1–2,2% |
Очереди назначил QoS utility | Измеримого выигрыша не получили |
В отдельном сравнении снизил холостую обработку до 2 fps | CPU приложения изменился с 3,5–4,7% до 3,4–3,5% |
Это последовательные рабочие замеры, а не одна аккуратная серия A/B. Последнюю строку нельзя вычитать из первой и получать универсальный коэффициент ускорения. Даже одна сборка в разных прогонах показывала от 5,9 до 11,1% CPU.
Часть разброса появилась благодаря моей тупости - макбук на батарее засыпал посреди бенчмарка. На macbook есть механизм под названием DarkWake, когда ноут просыпается частично и может делать фоновые дела - часть замера прошла в этом режиме. Это нашлось в pmset -g log, замеряющий агент с этим бился через caffeinate, но все равно получилось коряво, потому что бился он уже внутри DarkWake.
Отдельно выяснилось, обработка на 15 fps фактически работала на 11,5, потому что камера отдавала 23 кадра в секунду, а рублилось все простым фильтром по времени, который принимал каждый второй кадр, соседние кадры приходили чаще разрешённого интервала. Сначала заподозрил медленный Vision, но медиана одного вызова была около 6 мс. Дальше эту тему не копал.
Попытка ограничить саму камеру тоже сработала не сразу. activeVideoMinFrameDuration, заданный до startRunning(), на этой конфигурации сбрасывался. Когда выставил минимальную и максимальную длительность кадра после старта сессии, получили измеренные 15 fps.
У всех проверенных форматов камеры нижняя граница была 15 fps. Поэтому холостые 2 fps означают только реже вызывать Vision. Камера продолжает доставлять кадры, и ниже примерно 3,4% CPU в этих пробах потребление не опускается.
В процессе этих оптимизаций вылез еще косяк - интервал между кадрами при 2 fps равен 500 мс, а автомат жестов считает разрыв больше 250 мс потерей непрерывности. Если перейти в холостой режим слишком рано, таймер отпускания будет постоянно сбрасываться.
В итоге добавил холостой режим включается после max(3 с, releaseMs + 1 с) без руки, что бы флаг успевал обнулиться на полной частоте, при появлении руки возвращаем 15 fps. Обнаружение самой руки в холостом режиме при этом может ждать следующей проверки примерно до полусекунды, ещё до начала удержания. Чуть меньше отзывчивости, меньше потребления ресурсов.
245 МБ памяти. Наверное, нейросеть?
Далее мне стало интересно, с чего это вдруг маленькая примитивная программа жрет столько памяти.
Консольная версия показывала около 92 МБ RSS и 245 МБ по footprint. RSS, не который в XML контент складывает, а который считает сколько памяти процесса и нужных ему библиотек лежит в физической ОЗУ. А footprint отражает в том числе и видеопамять и swap и в общем сколько процесс стоит системе. Само собой сразу подумалось, что нейросеть Vision отьедает значительный кусок памяти.
Проверили отдельными процессами.
Что запущено | Footprint |
|---|---|
Vision на пустом кадре, с ANE, GPU или выбором по умолчанию | Около 16 МБ |
Vision с рукой в кадре | Около 24 МБ |
Vision с обработкой на CPU | 57–64 МБ |
AVCaptureSession без единого вызова Vision | Около 224 МБ |
То есть основная память была занята ещё до классификатора и даже до Vision. В разборе процесса нашлись примерно 102 МБ графической памяти, 73 МБ malloc и 13 МБ SceneKit. В консольной программе, которая ничего не рисует.
След привёл к видеоэффектам macOS - в документации Apple указано, что Reaction Effects на macOS по умолчанию включены для всех приложений. В процессе обнаружилась инфраструктура камеры и эффектов, а попытки отключить её ключами Info.plist заметно память не уменьшили.
Из этих цифр не следует, что любая камера на любом Mac обязательно съест 224 МБ, но в моём случае большая часть памяти появлялась уже при запуске AVCaptureSession без единого вызова Vision. Классификатор тут ни при чём: переписывать его ради экономии памяти было бы пустой работой. Убрать этот расход проверенными публичными настройками AVFoundation пока не получилось.
Принудительный выбор вычислителя Vision тоже не спас. На проверенных кадрах координаты ANE и GPU совпали с точностью вывода 0,00 px. Вариант на CPU оказался в 3–5 раз медленнее и местами расходился с результатом до 12 px. Сам Vision занимал примерно 4–8 мс.
Узнав про видеоэффекты и системные реакции решил раз уж они есть, то чего бы их не использовать - macOS сама реагирует на “викторию” и палец вверх, почему бы не привязать к ним мои хоткеи? Но в публичном API нашлись включение распознавания, список эффектов и возможность запускать эффект программно, а как считывать события или добавлять свои - ни я ни агенты не нашли.
В итоге в Info.plist приложения стоит NSCameraReactionEffectGesturesEnabledDefault=false. По документации Apple, с macOS 14.4 это задаёт начальное состояние распознавания жестов, пока пользователь не поменяет его в Control Center. Два маленьких тестовых приложения с ключами true и false при прямом запуске бинарников оба показали reactionEffectGesturesEnabled=false. Поэтому утверждать, что жесты выключил именно мой ключ, пока нельзя: настройка Control Center или прямой запуск бинарника могли повлиять на результат. Проверки жестом в видеопотоке не было. На чужие приложения этот ключ в любом случае не распространяется.
Кто ещё читает камеру
Раз камера постоянно включена, захотелось видеть в меню, кто ещё её использует. Казалось, где-нибудь должен быть для этого удобный API.
Источник | Что удалось получить |
|---|---|
AVFoundation, isInUseByAnotherApplication | На моем макбуке оставался false даже со вторым клиентом |
CoreMediaIO, DeviceIsRunningSomewhere | Факт использования без имён, включая нас самих |
ConnectClient / DisconnectClient в журнале демона | PID клиента, но подключение бывает уже при перечислении камер |
TCCAccessRequest в журнале | PID при первом старте потока; повторный старт не даёт нового события |
start/stop streaming | События потока, но PID скрыт |
Пришлось читать журнал appleh13camerad через CameraMonitor. Первая версия считала старты и остановки потоков и однажды показала семь чужих потоков вместо одного - это были следы собственных бенчмарков, процессы убивались без штатной остановки камеры, строка stop не появлялась.
Помогли записи атрибуции для Control Center - в проверках при обычной остановке и при kill -9 появлялось Removed attribution. Счётчик на парах added attribution и Removed attribution пережил последовательность: ни одного клиента, один, два, второй убит, первый остановлен. Получилось 0, 1, 2, 1, 0.
При старте совмещаем живой log stream с историей за час из log show. Они пересекаются, и одну запись нужно учесть один раз. В проверенных записях пара (machTimestamp, текст сообщения) совпадала в обоих источниках, её и используем для дедупликации. Просто отбросить всё до временной границы нельзя: Info-события встречаются в живом потоке, но отсутствуют в сохранённой истории.
Это вспомогательная диагностика, а не система защиты. Потоки старше часового окна могут остаться незамеченными до перезапуска. Строки журнала недокументированы, USB-камеры, Continuity Camera и другие демоны не проверялись. Если нужна именно отдельная утилита наблюдения за камерой и микрофоном, у Objective-See есть OverSight. Сравнение его точности с нашим счётчиком я не проводил.
От cli к SwiftUI
Когда программа стабильно заработала и я уже радостно показывал MacBook разные пальцы, решил превратить поделку в приложение на SwiftUI. Это оказалось несложно. Теперь MenuBarExtra показывает состояние, последние срабатывания и привязки. Пауза останавливает сессию захвата.
Для хоткеев создаются CGEvent с источником hidSystemState и отправкой на cghidEventTap. Стрелкам добавляются NumericPad и SecondaryFn. Для сна используется системный pmset, причём разрешённые команды перечислены в коде, произвольной shell-строки в конфиге нет.
По пути проявились ошибки, которые тесты классификатора увидеть не могли.
Место | Ошибка |
|---|---|
Первый запуск CLI | Вызов dispatchMain() из нашего async main завершался SIGTRAP |
Старт меню | Главный поток синхронно ждал log show и зависал на 2,5–3,2 секунды |
Повторный запуск движка | Во время await системного диалога actor принимал второй start и мог создать вторую сессию камеры |
Время жизни блокировки | extendLifetime(lock) перед бесконечным ожиданием не гарантировал удержание lock во время самого ожидания |
Особенно показателен actor движка, который сериализует доступ к своему состоянию, но на await другой вызов может войти внутрь. Нужна явная отметка “запуск уже идёт”, иначе кнопка Reload во время запроса прав превращается во второй запуск.
Что в итоге
Проект требует macOS 14 или новее и Swift 6. Проверялся на M3 Pro со встроенной камерой.
git clone https://github.com/andreygaag/hand-control.git
cd hand-control
swift build
# записать жест 1:
.build/debug/hand-control record fist
# записать жест 2:
.build/debug/hand-control record peace
# записать жест 3:
.build/debug/hand-control record fuck
Затем в ~/.config/hand-control/config.json можно задать привязки:
{
"holdMs": 300,
"releaseMs": 300,
"maxDistance": 0.25,
"bindings": {
"fist": { "hotkey": "ctrl+right" },
"peace": { "hotkey": "ctrl+left" },
"system": "sleep"
}
}
Для первого запуска полезен .build/debug/hand-control run --debug: видно кандидата, расстояние и причины ненадёжных кадров. Более редкие события пишутся в журнал, включая случаи, когда рука была показана, но ни один образец не прошёл порог. Это как раз та запись, которой не хватало при истории с наклоном.
Приложение в строке меню собирается скриптом scripts/build-app.sh; подробности установки и разрешений есть в README. Перед записью новых образцов работающий экземпляр нужно закрыть, после записи запустить его снова или перезагрузить конфигурацию.
Запись только из CLI, без превью, готовой нотаризованной сборки нет. Порог 0,25 выбран по рабочим пробам, независимой оценки доли пропусков и ложных срабатываний нет.
Насколько оно полезно - вопрос открытый, не то, что бы оно как-то улучшило продуктивность, но иногда прикольно вызвать telegram показав компьютеру кулак или вместо нажатия enter показать ok или послать компьютер в сон, показав ему средний палец. Ну и как минимум я немного понял как работают подобные балалайки.
Весь код писался в двухагентном режиме claude + codex, благодаря чему на все про все ушло полтора часа. Если у кого-то появятся идеи или предложения о том, что полезного можно из этого сделать, а может даже желание сделать pr - welcome.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.