Простая дополненная реальность с OpenCV, Three.js и WebSockets
Опубликовано: 2022-03-10Обычно считается, что дополненную реальность очень сложно создать. Однако можно создавать визуально впечатляющие проекты, используя только библиотеки с открытым исходным кодом. В этом руководстве мы будем использовать OpenCV в Python для обнаружения объектов в форме круга в потоке веб-камеры и замены их на 3D Earth в Three.js в окне браузера, используя WebSockets , чтобы соединить все это вместе.
Мы хотим строго разделить интерфейс и серверную часть, чтобы сделать их многоразовыми. В реальном приложении мы могли бы написать внешний интерфейс, например, на Unity, Unreal Engine или Blender, чтобы он выглядел действительно красиво. Интерфейс браузера проще всего реализовать, и он должен работать почти во всех возможных конфигурациях.
Для простоты мы разделим приложение на три части:
- Серверная часть Python с OpenCV OpenCV будет считывать поток с веб-камеры и открывать несколько окон с изображением с камеры после прохождения его через несколько фильтров, чтобы упростить отладку и дать нам небольшое представление о том, что на самом деле видит алгоритм обнаружения круга. Результатом этой части будут только 2D-координаты и радиус обнаруженной окружности.
- Интерфейс JavaScript с Three.js в браузере . Пошаговая реализация библиотеки Three.js для рендеринга текстурированной Земли с вращающейся вокруг нее луной. Самым интересным здесь будет сопоставление координат 2D-экрана с 3D-миром. Мы также аппроксимируем координаты и радиус, чтобы повысить точность OpenCV.
- WebSockets как во внешнем, так и во внутреннем интерфейсе Back-end с сервером WebSockets будет периодически отправлять сообщения с обнаруженными координатами и радиусами круга клиенту браузера.
1. Серверная часть Python с OpenCV
Нашим первым шагом будет просто импорт библиотеки OpenCV в Python и открытие окна с прямой трансляцией с веб-камеры.
Мы собираемся использовать новейшую версию OpenCV 3.0 (см. примечания по установке) с Python 2.7. Обратите внимание, что установка на некоторых системах может быть проблематичной, а официальная документация не очень полезна. Я попробовал себя на Mac OS X версии 3.0 от MacPorts, и у бинарного файла была проблема с зависимостями, поэтому вместо этого мне пришлось переключиться на Homebrew. Также обратите внимание, что некоторые пакеты OpenCV могут не поставляться с привязкой Python по умолчанию (необходимо использовать некоторые параметры командной строки).
С Homebrew я побежал:
brew install opencv
Это устанавливает OpenCV с привязками Python по умолчанию.
Просто для проверки я рекомендую вам запустить Python в интерактивном режиме (запустить python
в CLI без каких-либо аргументов) и написать import cv2
. Если OpenCV установлен правильно и пути к привязкам Python верны, он не должен вызывать никаких ошибок.
Позже мы также будем использовать Python numpy
для некоторых простых операций с матрицами, чтобы мы могли установить его и сейчас.
pip install numpy
Чтение изображения с камеры
Теперь мы можем протестировать камеру:
import cv2 capture = cv2.VideoCapture(0) while True: ret, image = capture.read() cv2.imshow('Camera stream', image) if cv2.waitKey(1) & 0xFF == ord('q'): break
С помощью cv2.VideoCapture(0)
мы получаем доступ к камере с индексом 0
, который используется по умолчанию (обычно это встроенная камера). Если вы хотите использовать другой, попробуйте числа больше нуля; однако нет простого способа перечислить все доступные камеры с текущей версией OpenCV.
Когда мы вызываем cv2.imshow('Camera stream', image)
в первый раз, он проверяет, что окна с таким именем не существует, и создает для нас новое окно с изображением с камеры. Одно и то же окно будет повторно использоваться для каждой итерации основного цикла.
Затем мы использовали capture.read()
для ожидания и захвата текущего изображения с камеры. Этот метод также возвращает логическое свойство ret
в случае, если камера отключена или следующий кадр по какой-либо причине недоступен.
В конце у нас есть cv2.waitKey(1)
, который в течение 1 миллисекунды проверяет, была ли нажата какая-либо клавиша, и возвращает ее код. Итак, когда мы нажимаем q
, мы выходим из цикла, закрываем окно, и приложение завершается.
Если все это работает, мы прошли самую сложную часть внутреннего приложения, которая заставляет камеру работать.
Фильтрация изображений камеры
Для фактического обнаружения круга мы собираемся использовать круговое преобразование Хафа , которое реализовано в cv2.HoughCircles()
и на данный момент является единственным алгоритмом, доступным в OpenCV. Для нас важно то, что в качестве входных данных требуется изображение в градациях серого, а для поиска краев на изображении используется алгоритм обнаружения краев Кэнни . Мы хотим иметь возможность вручную проверять, что видит алгоритм, поэтому составим одно большое изображение из четырех меньших изображений, к каждому из которых будет применен свой фильтр.
Детектор краев Кэнни — это алгоритм, который обрабатывает изображение, как правило, в четырех направлениях (по вертикали, горизонтали и двум диагоналям) и находит края. Фактические шаги, которые делает этот алгоритм, более подробно объясняются в Википедии или кратко в документах OpenCV.
В отличие от сопоставления с образцом, этот алгоритм обнаруживает круглые формы, поэтому мы можем использовать любые круглые объекты, которые у нас есть. Я собираюсь использовать крышку от банки с растворимым кофе, а затем оранжевую кофейную кружку.
Нам не нужно работать с полноразмерными изображениями (конечно, зависит от разрешения вашей камеры), поэтому мы изменим их размер прямо между capture.read()
и cv2.imshow
до ширины и высоты 640 пикселей соответственно, чтобы сохранить соотношение сторон:
width, height = image.shape scale = 640.0 / width image = cv2.resize(image, (0,0), fx=scale, fy=scale)
Затем мы хотим преобразовать его в изображение в оттенках серого и применить сначала срединное размытие , которое удаляет шум и сохраняет края, а затем детектор краев Канни, чтобы увидеть, с чем будет работать алгоритм обнаружения круга. По этой причине мы создадим сетку 2x2 со всеми четырьмя превью.
t = 100 # threshold for Canny Edge Detection algorithm grey = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) blured = cv2.medianBlur(grey, 15) # Create 2x2 grid for all previews grid = np.zeros([2*h, 2*w, 3], np.uint8) grid[0:h, 0:w] = image # We need to convert each of them to RGB from greyscaled 8 bit format grid[h:2*h, 0:w] = np.dstack([cv2.Canny(grey, t / 2, t)] * 3) grid[0:h, w:2*w] = np.dstack([blured] * 3) grid[h:2*h, w:2*w] = np.dstack([cv2.Canny(blured, t / 2, t)] * 3)
Несмотря на то, что детектор краев Canny использует размытие по Гауссу для уменьшения шума, по моему опыту, все же стоит использовать и срединное размытие. Вы можете сравнить два нижних изображения. Тот, что слева, — это просто обнаружение края Канни без какого-либо другого фильтра. Второе изображение также представляет собой обнаружение границ Кэнни, но на этот раз после применения срединного размытия. Это уменьшило объекты на заднем плане, что поможет обнаружению круга.
Обнаружение кругов с помощью градиента Хафа
Внутри OpenCV использует более эффективную реализацию преобразования круга Хафа, называемого методом градиента Хафа, который использует информацию о краях из детектора края Канни. Градиентный метод подробно описан в книге Learning OpenCV and the Circle Hough Transform в Википедии .
Теперь пришло время для фактического обнаружения круга:
sc = 1 # Scale for the algorithm md = 30 # Minimum required distance between two circles # Accumulator threshold for circle detection. Smaller numbers are more # sensitive to false detections but make the detection more tolerant. at = 40 circles = cv2.HoughCircles(blured, cv2.HOUGH_GRADIENT, sc, md, t, at)
Это возвращает массив всех обнаруженных кругов. Для простоты нас будет интересовать только первый. Hough Gradient довольно чувствителен к действительно круглым формам, поэтому маловероятно, что это приведет к ложным обнаружениям. Если это так, увеличьте параметр at
. Вот почему мы использовали срединное размытие выше; он удалил больше шума, поэтому мы можем использовать более низкий порог, делая обнаружение более устойчивым к неточностям и с меньшей вероятностью обнаружения ложных кругов.
В консоль выведем центр круга и его радиус, а также нарисуем найденный круг с центром на изображении с камеры в отдельном окне. Позже мы отправим его через WebSocket в браузер. Обратите внимание, что x
, y
и radius
указаны в пикселях.
if circles is not None: # We care only about the first circle found. circle = circles[0][0] x, y, radius = int(circle[0]), int(circle[1]), int(circle[2]) print(x, y, radius) # Highlight the circle cv2.circle(image, [x, y], radius, (0, 0, 255), 1) # Draw a dot in the center cv2.circle(image, [x, y], 1, (0, 0, 255), 1)
Это будет печатать в консольные кортежи, например:
(251, 202, 74) (252, 203, 73) (250, 202, 74) (246, 202, 76) (246, 204, 74) (246, 205, 72)
Как вы можете видеть на этой анимации, она вообще не смогла найти ни одного круга. Моя встроенная камера имеет только 15 кадров в секунду, и когда я быстро двигаю рукой, изображение размывается, поэтому оно не находит края круга, даже после применения фильтров.
В конце этой статьи мы еще вернемся к этой проблеме и будем много говорить о настройках камеры и выборе алгоритма обнаружения, но уже сейчас можно сказать, что несмотря на то, что моя настройка очень плохая (всего 15 кадров в секунду, плохое освещение, много шума на заднем плане, объект имеет низкую контрастность), результат достаточно хороший.
Это все на данный момент. У нас есть координаты x
и y
и radius
в пикселях круга, найденного на изображении с веб-камеры.
Вы можете увидеть полный исходный код этой части на gist.github.com.
2. Интерфейс JavaScript с Three.js в браузерах
Фронтальная часть основана на библиотеке Three.js (версия r72). Мы начнем с создания вращающейся текстурированной сферы, представляющей Землю в центре экрана, а затем добавим луну, вращающуюся вокруг нее. В конце мы отобразим координаты 2D-экранной мыши в 3D-пространство.
Наша HTML-страница будет состоять всего из одного элемента <canvas>
. см. index.html на gist.github.com.
Создание Земли
JavaScript будет немного длиннее, но он разделен на несколько функций инициализации, каждая из которых имеет одну цель. Текстуры Земли и Луны взяты с сайта planetpixelemporium.com. Обратите внимание, что при загрузке текстур применяются правила CORS.
var scene, camera, renderer, light, earthMesh, earthRotY = 0; function initScene(width, height) { scene = new THREE.Scene(); // Setup cameta with 45 deg field of view and same aspect ratio camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000); // Set the camera to 400 units along `z` axis camera.position.set(0, 0, 400); renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setSize(width, height); renderer.shadowMap.enabled = true; document.body.appendChild(renderer.domElement); } function initLight() { light = new THREE.SpotLight(0xffffff); // Position the light slightly to a side to make shadows look better. light.position.set(400, 100, 1000); light.castShadow = true; scene.add(light); } function initEarth() { // Load Earth texture and create material from it var earthMaterial = new THREE.MeshLambertMaterial({ map: THREE.ImageUtils.loadTexture("/images/earthmap1k.jpg"), }); // Create a sphere 25 units in radius and 16 segments // both horizontally and vertically. var earthGeometry = new THREE.SphereGeometry(25, 16, 16); earthMesh = new THREE.Mesh(earthGeometry, earthMaterial); earthMesh.receiveShadow = true; earthMesh.castShadow = true; // Add Earth to the scene scene.add(earthMesh); } // Update position of objects in the scene function update() { earthRotY += 0.007; earthMesh.rotation.y = earthRotY; } // Redraw entire scene function render() { update(); renderer.setClearColor(0x000000, 0); renderer.render(scene, camera); // Schedule another frame requestAnimationFrame(render); } document.addEventListener('DOMContentLoaded', function(e) { // Initialize everything and start rendering initScene(window.innerWidth, window.innerHeight); initEarth(); initLight(); // Start rendering the scene requestAnimationFrame(render); });
Смотрите живую демонстрацию здесь.
![](https://s.stat888.com/img/bg.png)
![threejs-вращающаяся земля](/uploads/article/1302/ioWC2KNQCzxs8QO9.gif)
В основном это были базовые вещи Three.js. Имена объектов и методов говорят сами за себя (например receiveShadow
или castShadow
), но если вы никогда не использовали их раньше, я настоятельно рекомендую вам ознакомиться с учебными пособиями Ли Стемкоски.
При желании мы могли бы также нарисовать ось в центре экрана, чтобы помочь нам с системой координат.
var axes = new THREE.AxisHelper(60); axes.position.set(0, 0, 0); scene.add(axes);
Добавление Луны
Создание луны будет очень похожим. Основное отличие состоит в том, что нам нужно установить положение Луны относительно Земли.
function initMoon() { // The same as initEarth() with just different texture } // Update position of objects in the scene function update() { // Update Earth position // ... // Update Moon position moonRotY += 0.005; radY += 0.03; radZ += 0.0005; // Calculate position on a sphere x = moonDist * Math.cos(radZ) * Math.sin(radY); y = moonDist * Math.sin(radZ) * Math.sin(radY); z = moonDist * Math.cos(radY); var pos = earthMesh.position; // We can keep `z` as is because we're not moving the Earth // along z axis. moonMesh.position.set(x + earthMesh.pos.x, y + earthMesh.pos.y, z); moonMesh.rotation.y = moonRotY; }
Смотрите живую демонстрацию здесь.
Сопоставление 2D-координат с 3D-миром
Пока все достаточно очевидно. Самая интересная часть будет заключаться в том, как скрыть координаты 2D-экрана, поступающие из OpenCV (см. вывод кругового обнаружения выше), в 3D-мир? Когда мы определяли радиусы и позиции в Three.js, мы использовали некоторые единицы измерения, но они не имеют ничего общего с реальными пикселями экрана. На самом деле размеры всего, что мы видим в сцене, сильно зависят от настроек нашей камеры (таких как соотношение сторон или поле зрения).
По этой причине мы создадим плоский плоский объект, который будет достаточно большим, чтобы покрыть всю сцену с центром в точке [0,0,0]
. В демонстрационных целях мы сопоставим 2D-координаты мыши с положением Земли в 3D с фиксированной осью z
. Другими словами, мы будем преобразовывать только x
и y
и не будем беспокоиться о z
, то есть о расстоянии от объекта до нашей камеры.
Мы преобразуем положения экрана мыши в диапазон от -1.0
до +1.0
с его центром в [0,0]
, потому что нам нужно работать с нормализованными векторами.
Позже мы воспользуемся именно этой техникой для отображения положения обнаруженного круга в 3D, а также для сопоставления размера круга из 2D в 3D.
var mouse = {}; function initPlane() { // The plane needs to be large to always cover entire scene var tmpGeometry = new THREE.PlaneGeometry(1000, 1000, 1, 1); tmpGeometry.position = new THREE.Vector3(0, 0, 0); var tmpMesh = new THREE.Mesh(tmpGeometry); } function onDocumentMouseMove(event) { // Current mouse position with [0,0] in the center of the window // and ranging from -1.0 to +1.0 with `y` axis inverted. mouse.x = (event.clientX / window.innerWidth) * 2 - 1; mouse.y = - (event.clientY / window.innerHeight) * 2 + 1; } function update() { // ... the rest of the function // We need mouse x and y coordinates to set vector's direction var vector = new THREE.Vector3(mouse.x, mouse.y, 0.0); // Unproject camera distortion (fov, aspect ratio) vector.unproject(camera); var norm = vector.sub(camera.position).normalize(); // Cast a line from our camera to the tmpMesh and see where these // two intersect. That's our 2D position in 3D coordinates. var ray = new THREE.Raycaster(camera.position, norm); var intersects = ray.intersectObject(tmpMesh); earthMesh.position.x = intersects[0].point.x; earthMesh.position.y = intersects[0].point.y; }
Смотрите живую демонстрацию здесь.
![threejs-вращающаяся земля](/uploads/article/1302/DKMEJPHA2w8PXfIy.gif)
Поскольку мы проверяем перекресток с помощью самолета, мы знаем, что всегда будет только один.
Это все для этой части. В конце следующей части мы также добавим WebSockets и элемент <video>
с нашим потоком камеры, который будет перекрываться 3D-сценой в Three.js.
3. Веб-сокеты как во внешнем, так и во внутреннем интерфейсе
Мы можем начать с реализации WebSockets в бэкенде Python, установив библиотеки simple-websocket-server
. Есть много разных библиотек, таких как Tornado или Autobahn. Мы будем использовать simple-websocket-server
потому что он очень прост в использовании и не имеет зависимостей.
pip install git+https://github.com/dpallot/simple-websocket-server.git
Мы запустим сервер WebSocket в отдельном потоке и будем отслеживать всех подключенных клиентов.
from SimpleWebSocketServer import SimpleWebSocketServer, WebSocket clients = [], server = None class SimpleWSServer(WebSocket): def handleConnected(self): clients.append(self) def handleClose(self): clients.remove(self) def run_server(): global server server = SimpleWebSocketServer(', 9000, SimpleWSServer, selectInterval=(1000.0 / 15) / 1000) server.serveforever() t = threading.Thread(target=run_server) t.start() # The rest of the OpenCV code ...
Мы использовали параметр selectInterval
в конструкторе сервера, чтобы он периодически проверял наличие ожидающих сообщений. Сервер отправляет сообщения только при получении данных от клиентов, либо ему нужно сидеть в основном потоке в цикле. Мы не можем позволить ему заблокировать основной поток, потому что он также нужен OpenCV. Поскольку мы знаем, что камера работает только со скоростью 15 кадров в секунду, мы можем использовать тот же интервал на сервере WebSocket.
Затем, после того, как мы обнаружим круги, мы можем перебрать всех подключенных клиентов и отправить текущую позицию и радиус относительно размера изображения.
for client in clients: msg = json.dumps({'x': x / w, 'y': y / h, 'radius': radius / w}) client.sendMessage(unicode(msg))
Вы можете увидеть полный исходный код сервера на gist.github.com.
Часть JavaScript будет имитировать то же поведение, что и с положением мыши. Мы также будем отслеживать несколько сообщений и вычислять среднее значение для каждой оси и радиуса, чтобы повысить точность.
var history = []; var ws = new WebSocket('ws://localhost:9000'); ws.onopen = function() { console.log('onopen'); }; ws.onmessage = function (event) { var m = JSON.parse(event.data); history.push({ x: mx * 2 - 1, y: -my * 2 + 1, radius: m.radius}); // ... rest of the function. };
Вместо того, чтобы устанавливать положение Земли в соответствии с моим текущим положением мыши, мы будем использовать переменную msgHistory
.
Вероятно, нет необходимости вставлять сюда весь код, поэтому не стесняйтесь просматривать детали реализации на gist.gihtub.com.
Затем добавьте один элемент <video>
с потоком веб-камеры, заполняющим все окно, которое будет перекрываться нашей 3D-сценой с прозрачным фоном.
var videoElm = document.querySelector('video'); // Make sure the video fits the window. var constrains = { video: { mandatory: { minWidth: window.innerWidth }}}; if (navigator.getUserMedia) { navigator.getUserMedia(constrains, function(stream) { videoElm.src = window.URL.createObjectURL(stream); // When the webcam stream is ready get it's dimensions. videoElm.oncanplay = function() { init(videoElm.clientWidth, videoElm.clientHeight); // Init everything ... requestAnimationFrame(render); } }, function() {}); }
Окончательный результат:
Чтобы быстро резюмировать, что мы сделали и что показано в приведенном выше видео:
- Серверная часть Python запускает сервер WebSocket.
- Сервер обнаруживает круг с помощью OpenCV из потока веб-камеры.
- Клиент JavaScript отображает тот же поток с веб-камеры, используя элемент
<video>
. - Клиент визуализирует 3D-сцену с помощью Three.js.
- Клиент подключается к серверу по протоколу WebSocket и получает положение и радиус окружности.
Фактический код, используемый для этой демонстрации, доступен на GitHub. Это немного сложнее, а также интерполирует координаты между двумя сообщениями от серверной части, потому что поток веб-камеры работает только со скоростью 15 кадров в секунду, а 3D-сцена визуализируется со скоростью 60 кадров в секунду. Оригинальное видео можно посмотреть на YouTube.
Предостережения
Стоит отметить некоторые выводы:
Обнаружение круга не идеально
Замечательно, что он работает с любым круглым объектом, но он очень чувствителен к шуму и деформации изображения, хотя, как вы можете видеть выше, наш результат довольно хорош. Кроме того, вероятно, нет практических примеров обнаружения кругов, кроме самого простого использования. Возможно, было бы лучше использовать обнаружение эллипса, но сейчас оно не реализовано в OpenCV.
Все зависит от вашей настройки
Встроенные веб-камеры, как правило, довольно плохи. 15 кадров в секунду недостаточно, и простое увеличение до 30 кадров в секунду значительно уменьшает размытость движения и делает обнаружение более надежным. Мы можем разбить этот пункт еще на четыре пункта:
- Искажения камеры
Многие камеры вносят некоторое искажение изображения, чаще всего эффект «рыбий глаз», который оказывает значительное влияние на определение формы. В документации OpenCV есть очень простое руководство о том, как уменьшить искажения путем калибровки камеры. - Официального списка устройств, поддерживаемых OpenCV, нет.
Даже если у вас уже есть хорошая камера, она может не работать с OpenCV без дополнительных объяснений. Я также читал о людях, использующих какую-то другую библиотеку для захвата изображения с камеры (например, libdc1394 для камер на базе IEEE 1394), а затем использующих OpenCV только для обработки изображений. Диспетчер пакетов Brew позволяет напрямую компилировать OpenCV с поддержкой libdc1394. - Некоторые камеры лучше работают с OpenCV, чем другие
Если вам повезет, вы можете установить некоторые параметры камеры, такие как количество кадров в секунду, прямо на вашей камере, но это также может не иметь никакого эффекта, если OpenCV не дружит с вашим устройством. Опять же, без каких-либо объяснений. - Все параметры зависят от реального использования
При использовании в реальной установке настоятельно рекомендуется тестировать алгоритмы и фильтры в реальной среде, поскольку такие факторы, как освещение, цвет фона или выбор объекта, существенно влияют на результат. Сюда же относятся тени от дневного света, люди, стоящие вокруг, и так далее.
Сопоставление с образцом обычно является лучшим выбором
Если вы увидите какую-либо дополненную реальность, используемую на практике, она, вероятно, будет основана на сопоставлении с образцом. Как правило, он более надежен и не так подвержен описанным выше проблемам.
Фильтры имеют решающее значение
Я думаю, что правильное использование фильтров требует некоторого опыта и всегда немного магии. Время обработки большинства фильтров зависит от их параметров, хотя в OpenCV 3.0 некоторые из них уже переписаны на CUDA C (C-подобный язык для высокопараллельного программирования с графическими картами NVIDIA), что значительно повышает производительность.
Фильтровать данные из OpenCV
Мы видели, что обнаружение окружности имеет некоторые неточности: иногда не удается найти какую-либо окружность или обнаруживает неправильный радиус. Чтобы свести к минимуму ошибки такого типа, было бы целесообразно реализовать более сложный метод повышения точности. В нашем примере мы использовали медиану для x
, y
и radius
, что очень просто. Часто используемый фильтр с хорошими результатами — это фильтр Калмана, используемый автопилотами для дронов, чтобы уменьшить неточность, поступающую от датчиков. Однако его реализация не так проста, как использование только math.mean()
с https://mathjs.org.
Заключение
Впервые я увидел подобное приложение в Национальном музее естественной истории в Мадриде два года назад и задумался, насколько сложно будет сделать что-то подобное.
Моя основная идея этой демонстрации состояла в том, чтобы использовать инструменты, распространенные в Интернете (такие как WebSockets и Three.js) и не требующие каких-либо предварительных условий, чтобы любой мог начать использовать их прямо сейчас. Вот почему я хотел использовать просто обнаружение круга, а не сопоставление с образцом, которое потребовало бы печати или наличия какого-то конкретного объекта реального мира.
Должен сказать, что я сильно недооценил фактические требования к камере. Высокая частота кадров в секунду и хорошее освещение важнее разрешения. Я также не ожидал, что несовместимость камеры с OpenCV станет проблемой.