使用 OpenCV、Three.js 和 WebSockets 的简单增强现实
已发表: 2022-03-10增强现实通常被认为很难创造。 但是,仅使用开源库就可以制作出视觉上令人印象深刻的项目。 在本教程中,我们将使用 Python 中的OpenCV来检测网络摄像头流中的圆形对象,并在浏览器窗口中用Three.js中的 3D Earth 替换它们,同时使用WebSockets将它们连接在一起。
我们希望严格分离前端和后端以使其可重用。 例如,在现实世界的应用程序中,我们可以用 Unity、Unreal Engine 或 Blender 编写前端,让它看起来非常漂亮。 浏览器前端是最容易实现的,并且应该适用于几乎所有可能的配置。
为了简单起见,我们将应用程序分成三个较小的部分:
- 带有 OpenCV 的 Python 后端OpenCV 将读取网络摄像头流,并在将摄像头图像通过多个过滤器后打开多个窗口以简化调试,并让我们对圆形检测算法实际看到的内容有一些了解。 这部分的输出将只是检测到的圆的 2D 坐标和半径。
- 浏览器中带有 Three.js 的 JavaScript 前端逐步实现 Three.js 库,以渲染带纹理的地球,月球围绕地球旋转。 这里最有趣的是将 2D 屏幕坐标映射到 3D 世界。 我们还将近似坐标和半径以提高 OpenCV 的准确性。
- 前端和后端中的 WebSockets 带有 WebSockets 服务器的后端将定期向浏览器客户端发送带有检测到的圆坐标和半径的消息。
1. 使用 OpenCV 的 Python 后端
我们的第一步将只是在 Python 中导入 OpenCV 库并打开一个带有实时网络摄像头流的窗口。
我们将在 Python 2.7 中使用最新的 OpenCV 3.0(参见安装说明)。 请注意,在某些系统上安装可能会出现问题,官方文档也不是很有帮助。 我在 MacPorts 的 Mac OS X 3.0 版上进行了尝试,但二进制文件存在依赖性问题,因此我不得不改用 Homebrew。 另请注意,默认情况下,某些 OpenCV 包可能不附带 Python 绑定(您需要使用一些命令行选项)。
使用 Homebrew 我跑了:
brew install opencv
默认情况下,这会使用 Python 绑定安装 OpenCV。
只是为了测试一下,我建议您以交互模式运行python
(在 CLI 中不带任何参数运行 python)并编写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()
方法中实现的圆形 Hough 变换,现在是 OpenCV 中唯一可用的算法。 对我们来说重要的是它需要一张灰度图像作为输入,并使用内部的Canny 边缘检测器算法来查找图像中的边缘。 我们希望能够手动检查算法看到的内容,因此我们将从四个较小的图像中合成一个大图像,每个图像都应用了不同的过滤器。
Canny 边缘检测器是一种算法,它通常在四个方向(垂直、水平和两个对角线)上处理图像并找到边缘。 该算法的实际步骤在 Wikipedia 上有更详细的说明,或者在 OpenCV 文档中简要说明。
与模式匹配相比,该算法检测圆形,因此我们可以使用我们必须处理的任何圆形对象。 我将使用速溶咖啡罐的盖子,然后使用橙色咖啡杯。
我们不需要使用全尺寸图像(当然取决于您的相机分辨率),因此我们将在capture.read()
和cv2.imshow
之间将它们调整为 640px 宽度和高度,以保持纵横比:
width, height = image.shape scale = 640.0 / width image = cv2.resize(image, (0,0), fx=scale, fy=scale)
然后我们想将其转换为灰度图像并应用第一个中值模糊来去除噪声并保留边缘,然后使用 Canny 边缘检测器来查看圆形检测算法将使用什么。 出于这个原因,我们将用所有四个预览组成 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 边缘检测器使用高斯模糊来减少噪声,但根据我的经验,它仍然值得使用中值模糊。 您可以比较两个底部图像。 左边的只是 Canny 边缘检测,没有任何其他过滤器。 第二张图像也是 Canny 边缘检测,但这次是在应用中值模糊之后。 它减少了背景中的物体,这将有助于圆形检测。
用霍夫梯度检测圆
在内部,OpenCV 使用更有效的霍夫圆变换实现,称为霍夫梯度方法,它使用来自 Canny 边缘检测器的边缘信息。 梯度法在《 Learning OpenCV and the Circle Hough Transform on Wikipedia 》一书中有深入描述。
现在是时候进行实际的圆检测了:
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)
正如您在此动画中看到的那样,它根本找不到任何圆圈。 我的内置摄像头只有 15fps,当我快速移动手时,图像会变得模糊,因此即使在应用滤镜后也找不到圆形边缘。
在本文的最后,我们会回到这个问题,并讨论很多关于相机特定设置和检测算法的选择,但我们已经可以说,即使我的设置非常糟糕(只有 15fps,光线不好,背景中有很多噪音,物体对比度低),结果相当不错。
目前为止就这样了。 我们有在网络摄像头图像中找到的圆的x
和y
坐标和radius
(以像素为单位)。
您可以在 gist.github.com 上查看这部分的完整源代码。
2. 浏览器中带有 Three.js 的 JavaScript 前端
前端部分基于 Three.js(版本 r72)库。 我们将首先在屏幕中心创建一个代表地球的旋转纹理球体,然后添加围绕它旋转的月亮。 最后,我们将 2D 屏幕鼠标坐标映射到 3D 空间。
我们的 HTML 页面将只包含一个<canvas>
元素。 请参阅 gist.github.com 上的index.html 。
创造地球
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
),但如果您之前从未使用过它,我强烈建议您查看 Lee Stemkoski 的教程。
可选地,我们还可以在屏幕中心绘制一个轴来帮助我们使用坐标系。
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 世界
到目前为止,一切都很明显。 最有趣的部分是如何将来自 OpenCV 的 2D 屏幕坐标(参见上面的圆形检测的输出)转换为 3D 世界? 当我们在 Three.js 中定义半径和位置时,我们使用了一些单位,但这些与实际屏幕像素无关。 事实上,我们在场景中看到的所有东西的尺寸都高度依赖于我们的相机设置(如纵横比或视野)。
出于这个原因,我们将制作一个平面对象,它的大小足以覆盖整个场景,其中心位于[0,0,0]
。 出于演示目的,我们将 2D 鼠标坐标映射到具有固定z
轴的 3D 地球位置。 换句话说,我们将只转换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>
元素以及我们的相机流,这些元素将被 Three.js 中的 3D 场景覆盖。
3. 前端和后端的WebSockets
我们可以通过安装simple-websocket-server
库在 Python 后端实现 WebSockets 开始。 有许多不同的库,例如 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 也需要它。 由于我们知道相机仅以 15fps 运行,我们可以在 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>
元素显示相同的网络摄像头流。 - 客户端使用 Three.js 渲染 3D 场景。
- 客户端通过 WebSocket 协议连接服务器,接收圆的位置和半径。
用于此演示的实际代码可在 GitHub 上找到。 它稍微复杂一些,并且还在来自后端的两条消息之间插入坐标,因为网络摄像头流仅以 15fps 运行,而 3D 场景以 60fps 渲染。 您可以在 YouTube 上看到原始视频。
注意事项
有一些发现值得注意:
圆圈检测并不理想
它适用于任何圆形物体都很棒,但它对噪声和图像变形非常敏感,尽管正如您在上面看到的那样,我们的结果非常好。 此外,除了最基本的用法之外,可能没有可用的圆形检测的实际示例。 使用椭圆检测可能会更好,但它现在还没有在 OpenCV 中实现。
一切都取决于您的设置
内置网络摄像头通常非常糟糕。 15fps 是不够的,只需将其增加到 30fps 即可显着减少运动模糊并使检测更加可靠。 我们可以将这一点分解为另外四点:
- 相机失真
许多相机会引入一些图像失真,最常见的是鱼眼效应,它对形状检测有很大影响。 OpenCV 的文档有一个非常简单的教程,介绍了如何通过校准相机来减少失真。 - OpenCV 支持的设备没有官方列表
即使您已经拥有一台好的相机,如果没有进一步的解释,它也可能无法与 OpenCV 一起使用。 我还读到过有人使用其他库来捕获相机图像(例如基于 IEEE 1394 的相机的 libdc1394),然后使用 OpenCV 来处理图像。 Brew 包管理器让您可以直接使用 libdc1394 支持编译 OpenCV。 - 一些相机比其他相机更适合 OpenCV
如果你幸运的话,你可以直接在你的相机上设置一些相机选项,比如每秒帧数,但如果 OpenCV 对你的设备不友好,它也可能根本没有效果。 再次,没有任何解释。 - 所有参数都取决于实际使用情况
在实际安装中使用时,强烈建议在实际环境中测试算法和过滤器,因为灯光、背景颜色或对象选择等因素会对结果产生重大影响。 这还包括来自日光的阴影、站在周围的人等等。
模式匹配通常是更好的选择
如果您在实践中看到任何增强现实,它可能是基于模式匹配的。 它通常更可靠,并且不受上述问题的影响。
过滤器至关重要
我认为正确使用过滤器需要一些经验,而且总是需要一点魔法。 大多数过滤器的处理时间取决于它们的参数,尽管在 OpenCV 3.0 中,其中一些已经被重写为 CUDA C(一种用于与 NVIDIA 显卡进行高度并行编程的类 C 语言),这带来了显着的性能改进。
从 OpenCV 过滤数据
我们已经看到圆检测有一些不准确之处:有时它无法找到任何圆或检测到错误的半径。 为了最大限度地减少这种类型的错误,实施一些更复杂的方法来提高准确性是值得的。 在我们的示例中,我们使用了x
、 y
和radius
的中值,这非常简单。 卡尔曼滤波器是一种常用的、效果良好的滤波器,无人机的自动驾驶仪使用它来减少来自传感器的不准确性。 然而,它的实现并不像使用 https://mathjs.org 中的math.mean()
那样简单。
结论
两年前,我第一次在马德里的国家自然历史博物馆看到了类似的应用程序,我想知道制作类似的东西会有多困难。
我在这个演示背后的核心想法是使用网络上常见的工具(如 WebSockets 和 Three.js),并且不需要任何先决条件,因此任何人都可以立即开始使用它们。 这就是为什么我只想使用圆形检测而不是模式匹配,这需要打印或拥有一些特定的真实世界对象。
我需要说我严重低估了实际的相机要求。 每秒高帧数和良好的照明比分辨率更重要。 我也没想到相机与 OpenCV 的不兼容会成为一个问题。