使用 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 的不兼容會成為一個問題。