Повторное изобретение велосипеда, на первый взгляд, не имеет практического смысла. Но это не значит, что данный процесс не может принести пользы: можно лишний раз вспомнить курс физики, или, даже, открыть для себя что-то новое. Не говоря о том, что это позволяет увлекательно провести время. Сегодня мы с вами попробуем изготовить «невидимое» пианино, используя подручные материалы. Узнаем, как оно работает, и как оно работать не должно. Все алгоритмы реализуем с нуля – без использования OpenCV.

Заменят гитару электроорганы..
Заменят гитару электроорганы..

Первое, что нужно сделать для реализации модели клавиатуры пианино – определить перемещение Δx, либо абсолютное значение координат того места, где находится палец музыканта. Затем необходимо определить, что произошло нажатие на клавишу (и силу данного нажатия). Поэтапно рассмотрим, как этого можно добиться. За основу частично взяты конструктивные элементы контроллера системы виртуальной реальности Oculus Quest. Разберём их по пунктам.

1. Акселерометр – только часть дела

Акселерометр – устройство достаточно нехитрое. Оно позволяет получить значения «перегрузок» предмета по трём координатным осям. Теоретически, с его помощью, мы можем узнать, на какое расстояние сдвинулся наш предмет. Возьмём акселерометр ADXL345, припаяем к нему провода и подключим, запустив стандартный пример из библиотеки «Adafruit ADXL345» - sensortest. Посмотрим, что он выведет в монитор порта:

Вывод X, Y, Z показаний в м/с^2
Вывод X, Y, Z показаний в м/с^2

Мы можем видеть значения приложенных ускорений по 3 осям. При этом по оси Z ускорение всегда равно 10. Если наклонить акселерометр, сила притяжения Земли начинает воздействовать на остальные грузы, вызывая отклонение значений. Исходя из этого, выясняется, что мы не можем определить значения Δx через перегрузки, так как простой наклон прибора вызывает перегрузку по оси, как будто бы систему сдвигают в сторону.

Но внимательный читатель скажет: при возрастании перегрузки на одну ось, перегрузка на другую ось должна уменьшаться. Давайте просто посчитаем синусы и косинусы по осям, и отделим тем самым переменное значение перегрузки от постоянной составляющей.
Здесь нам помешает ещё одна проблема: перегрузки, возникающие при смещении прибора на небольшое расстояние очень незначительны (не более 0,5g), и немногим превышают значения фонового шума от тряски и погрешность датчика. Поэтому мы не сможем ориентироваться только на показания акселерометра.

Тем не менее, с его помощью мы определим, когда произошло нажатие клавиши: так как при этом возникает резкая перегрузка, то просто запишем, что при a(z) < 7 м/с^2 нажатие зарегистрировано, и далее на небольшой промежуток остановим прослушивание. Так как при игре на фортепиано мы всё равно не можем нажимать клавишу быстрее определенного порога (примерно 6 раз/сек), то установим задержку в 100 мс.

2. Инфракрасный датчик. Или «1000 способов, как это не работает»

Изначально, идея заключалась в следующем: в комплекте с Arduino идёт инфракрасный фотодиод (так называемый «датчик пламени»), который выдаёт сигнал тем сильнее, чем ближе к нему источник света. Поскольку нам требуется значение лишь для одной оси, я закрепил один фотодиод на столе в качестве «трекера» и один светодиод поверх акселерометра. После чего попытался измерить расстояние. Но меня ожидало полное фиаско.

Иллюстрация распределения света. В трёх различных точках интенсивность одинакова
Иллюстрация распределения света. В трёх различных точках интенсивность одинакова

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

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

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

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

Данный способ себя не оправдал: теоретически мы получали два значения, которые разнились на какую-то величину. Но по этим значениям определить точное расстояние было по-прежнему невозможно. К тому же, выяснилось, что фотодиоду нужно достаточно большое время на «отдых» (восстановление), чтобы правильно снять показания с двух разных точек. Если добавить дополнительные светодиоды, то этот промежуток становился ещё больше.

Как можно было выйти из данной ситуации? Если не добавить больше светодиодов, то добавить дополнительные фотодатчики? Но где же взять их в таком количестве? И как обработать их показания?

3. Машинное зрение из глины и палок. Палки экономим.

Для данного проекта не хотелось что-либо докупать, поэтому я полез в загашник, и нашел веб-камеру от старого ноутбука. Данная камера представляет собой КМОП-массив 640x480, при этом, он чувствителен к ИК излучению. Но требуются небольшие доработки. Это уже не раз описывалось на Хабре, поэтому расскажу вкратце

1. Нужно сковырнуть объектив камеры, после чего сколоть на нём со внутренней стороны тоненькое стёклышко, отливающее красно-зелёным светом. Это позволит усилить чувствительность матрицы к инфракрасному свету. Также это может сбить фокусировку, проблема решается симптоматически.

2. Теперь, наоборот, нужно отфильтровать видимый спектр, чтобы он не мешал работе датчиков. Для этого возьму ИК-проницаемое оргстекло от старого телефона (встречается в районе ИК-порта, можно также взять от других приборов – пультов д/у и т.д.). Установим его перед объективом.

3. Для работы камеры необходимо подать на неё питание 3.3 В. Стабилизатора AMS1117 под рукой не было, но был большой модуль питания, поэтому конструкция получилась чуть громоздкой. Кабель должен быть экранированным, так как линии данных у этой веб-камеры слабенькие, даже небольших оголённых участков длиной в 5-7 см достаточно, чтобы сбивать связь.

Итоговый "трекер" можете видеть на картинке выше. Теперь давайте проверим изображение. Быстрее всего это сделать через программу “Amcap”, просто подключив камеру. Там же можно поиграть с настройками видеоусилителя (это будет нужно для упрощения «бинаризации»). Слева – изображение до «доработок», справа – после.

К слову, изображение с камеры в ИК-спектре создаёт немного «устрашающую» картинку – недаром этот приём используется во многих фильмах ужасов. Мы видим также несколько оптических засветок по диагонали. Для их гашения на светодиод нужно установить рассеиватель. В идеале – шарообразный, чтобы при проекции имел одинаковую форму вне зависимости от угла поворота. Я сделал некое подобие шарика из оргстекла, чего достаточно для тестов. В конечном итоге, после различных настроек и доработок, изображение с камеры в Amcap стало выглядеть так:

Итоговая проекция света на матрицу
Итоговая проекция света на матрицу

На этом разработка аппаратной части завершена – мы имеем светлое пятно на дисплее, положение которого в достаточной степени коррелирует с нашими перемещениями. Матрица из 307 200 «датчиков пламени» четко позволяет определить углы свечения. Акселерометр передаёт углы наклона.

Как можно улучшить технологию? Есть несколько вариантов

  • Установить ИК-диод рядом с трекером, а на руке использовать светоотражатель. Таким образом, можно обойтись без проводов. Похожим образом работают модули эхолокации.

  • Установить камеру на руку, а светодиод – на стол. С точки зрения физики одинаково, источник света ли движется относительно камеры, или камера относительно источника света.

  • Установить камеру на руку, а светодиод не устанавливать. Камеру ориентировать по уже имеющемуся в комнате «фоновому» ИК-излучению. Данный способ применён в контроллере Oculus Quest, но имеет недостаток – при отсутствии какого-либо излучения ориентация контроллера затрудняется. Поэтому китайцы продают на Aliexpress специальную «ИК-подветку» для комнаты.

Но вернёмся к нашей теме.

4. Пишем алгоритм

Не скажу, что я силён в написании алгоритмов, но, в любом случае, для начала нужно поставить цель: что мы имеем, и что хотим получить в итоге. Ход работы следующий:

Входные данные: массив 640x480 (глубина цвета – 24 бита, по 8 бит на канал).
Выходные данные: координаты центра светового пятна (x, y) – при разрешении 16x12 (выбрано для упрощения программы).

Первое, что нужно сделать – бинаризация (превращение цвета 24 бита в 1 бит).
Воспользуемся встроенным в камеру видеоусилителем, установим его параметры
(цветность, яркость). Далее отфильтруем выход графиком в виде «ступеньки» по
пороговому значению в массив нулей и единиц.

После этого нужно найти центр фигуры.

Существует множество способов решить эту задачу, навскидку:

  • Отсортировать белые пиксели в отдельный массив, вычислить средние координаты.

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

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

Я решил для начала выбрать третий способ. Кстати говоря, мне не пришлось прикручивать доп. библиотеки для интерполяции – уменьшение разрешения, как оказалось, есть в библиотеке веб-камеры (правда, неизвестен его алгоритм).

Так как я пишу под Windows, данные с камеры будут находиться на «устройстве», получить доступ к которому можно через WinAPI. Я не горел желанием этим заниматься, поэтому в сети нашёл библиотеку “Escapi 3.0”, которая реализовывала требуемые функции (открытие камеры с заданным разрешением, настройку видеоусилителя, выдачу кадра в виде указателя на массив).

По итогу получили алгоритм «проще некуда»:

Ужатая бинарная картинка с камеры. Точка - наш светодиод. Развертка массива в консоль в виде чисел.
Ужатая бинарная картинка с камеры. Точка - наш светодиод. Развертка массива в консоль в виде чисел.
Нахождение центра
Нахождение центра

5. Пишем программу

Мы получили всё необходимое для реализации программы: координаты световой точки, момент нажатия. Теперь необходимо скомпоновать всё вместе. Для этого в программе задействовать 3 составляющих:

  • Escapi (для работы с web-камерой) + прицепом идут SDL2 + STB Image

  • WinAPI COM-порт (для связи с Arduino)

  • VirtualMIDI by Tobias Erichsen (для выдачи значений в MIDI- порт Microsoft MM)

Итак, требовалось получить изображение, как в Amcap, но в виде массива, с которым можно работать. Для этого надо было найти исходники программы с аналогичным функционалом. В качестве заготовки я скомпилировал пример, который шел в комплекте с Escapi, под названием «multicap». Я запустил его сразу из коробки, убедился, что он будет работать с моей камерой, после чего собрал его из исходника, попутно собрав под винду и библиотеку SDL2.

Из соседнего примера «enumprops» взял параметры для настройки видеопроцессора. Запустив пример «propgui» можно настроить их ползунками.

propgui.exe Escapi
propgui.exe Escapi

Параметры сохраняются до переподключения устройства. Чтобы каждый раз их не настраивать, вывел в функцию при старте программы. По итогу мы имеем массив «capture.mTargetBuf[cam_width * cam_height]» с видеоданными. Так как мы уже установили разрешение 16x12, повесив задачу интерполяции на API, пройдёмся по этому массиву сначала упрощённым алгоритмом «бинаризации».

		for (int i = 0; i < cimg_height; i++) {
			for (int j = 0; j < cimg_width; j++) {
				unsigned char tgbuf = capture[0].mTargetBuf[(i*cimg_width) + j];
				if (tgbuf > 0x96) {
					tgmass[(i*cimg_width)+j] = 1;
				}
			}
		}

Далее в полученном новом массиве tgmass 16x12 найдём центр, используя алгоритм из п.4 (нижний и верхний циклы можно совместить, разделены для наглядности). По оси y также выполняется отзеркаливание картинки с камеры.

		for (int i = 0; i < cimg_height; i++) {
			for (int j = 0; j < cimg_width; j++) {
				if (tgmass[(i*cimg_width) + j] > 0) {
					center_x = i;
					center_y = cimg_width - j -1;
				}
			}
		}

Теперь необходимо отследить сигнал с Arduino. Я настроил контроллер таким образом, что при срабатывании посылается в COM-порт единица. Инициализируем COM-порт, и далее будем проверять, пришли туда данные или нет (было ли произведено нажатие). Данные можно не вычитывать, сам факт прихода данных уже достаточен для того, чтобы вызвать алгоритм определения центра (пример взят с ru.wikibooks.org).

На этом моменте рендеринг картинки нам уже не нужен, он начинает тормозить программу (так как она разрывается между прослушиванием в одном потоке устройства и прослушиванием COM-порта). Но пока оставим его для диагностических целей.

Когда получен сигнал с Arduino, и получены координаты центра, время передать данные на MIDI маппер. Для этого в библиотеке virtualMIDI посылаем команду MIDI. Подробнее об этой библиотеке я рассказывал в своём видео. Оно на английском языке, но выполнено достаточно просто, и суть работы данного API будет понятна.

В работе с MIDI создадим массив (линейный), переводящий хроматический звукоряд в диатонический. После чего посылаем команду на снятие ноты, а затем, на её установку в функции sendMIDI:

unsigned char chromaticNote[16] = {0, 2, 4, 5, 7, 9, 11, 12, 14, 16, 17, 19, 21, 23, 24, 26};
// 		sendMIDI(chromaticNote[center_y], 0); //sending note on

void sendMIDI(unsigned char rcvValue, unsigned char reset) {
	char length = 3; //MIDI CMD length
	byte myMidiData[3];

	if (reset > 0) {
		myMidiData[0] = 0x89; //Note OFF
	} 
	else {
		myMidiData[0] = 0x99; //NOTE ON 
	}
		myMidiData[1] = 0x30 + rcvValue; //noteKey
		myMidiData[2] = 0x7F; //velocity

	if (!virtualMIDISendData(midiPort, myMidiData, length)) {
		printf("error sending data: %d\n" + GetLastError());
		return;
	}
}

На этом основной костяк программы готов, можно переходить к испытаниям.

6. Испытания

Если все три "аппарата" ввода-вывода успешно подключены, программа запускается в штатном режиме (в противном случае выкинет ошибку на каком-то этапе).

Когда всё готово, можно попробовать нажать "невидимые" клавиши:

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

Kekovsky специально для habr.com, 05.2023 г.

Комментарии (4)