OpenCV、Three.js、およびWebSocketを使用したシンプルな拡張現実

公開: 2022-03-10
簡単なまとめ↬このチュートリアルでは、PythonのOpenCVを使用して、Webカメラストリーム内の円形のオブジェクトを検出し、WebSocketを使用してこれをすべて結合しながら、ブラウザーウィンドウでThree.jsの3DEarthに置き換えます。

拡張現実は、一般的に作成するのが非常に難しいと考えられています。 ただし、オープンソースライブラリだけを使用して視覚的に印象的なプロジェクトを作成することは可能です。 このチュートリアルでは、PythonのOpenCVを使用して、Webカメラストリーム内の円形のオブジェクトを検出し、 WebSocketを使用してこれをすべて結合しながら、ブラウザーウィンドウでThree.jsの3DEarthに置き換えます。

再利用できるように、フロントエンドとバックエンドを厳密に分離したいと考えています。 実際のアプリケーションでは、たとえば、Unity、Unreal Engine、Blenderでフロントエンドを記述して、見栄えを良くすることができます。 ブラウザのフロントエンドは実装が最も簡単で、ほぼすべての可能な構成で動作するはずです。

簡単にするために、アプリを3つの小さな部分に分割します。

  1. OpenCVを使用したPythonバックエンドOpenCVは、Webカメラストリームを読み取り、複数のフィルターを通過した後、カメラ画像で複数のウィンドウを開きます。これにより、デバッグが容易になり、円検出アルゴリズムが実際に何を認識しているかについて少し洞察が得られます。 この部分の出力は、検出された円の2D座標と半径になります。
  2. ブラウザでのThree.jsを使用したJavaScriptフロントエンドThree.jsライブラリの段階的な実装により、月が回転するテクスチャ地球をレンダリングします。 ここで最も興味深いのは、2D画面の座標を3Dの世界にマッピングすることです。 また、OpenCVの精度を高めるために、座標と半径を概算します。
  3. フロントエンドとバックエンドの両方のWebSocketWebSocketサーバーを備えたバックエンドは、検出された円の座標と半径を含むメッセージをブラウザークライアントに定期的に送信します。
最終結果
ジャンプした後もっと! 以下を読み続けてください↓

1.OpenCVを使用したPythonバックエンド

最初のステップは、PythonでOpenCVライブラリをインポートし、ライブWebカメラストリームでウィンドウを開くことです。

Python2.7では最新のOpenCV3.0(インストールノートを参照)を使用します。 一部のシステムへのインストールには問題がある可能性があり、公式ドキュメントはあまり役に立ちません。 MacPortsのMacOS 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も返します。

最後に、キーが押されたかどうかを1ミリ秒チェックし、そのコードを返すcv2.waitKey(1)があります。 したがって、 qを押すとループから抜け出し、ウィンドウを閉じるとアプリが終了します。

これがすべて機能する場合、カメラを機能させるというバックエンドアプリの最も難しい部分に合格しました。

カメラ画像のフィルタリング

実際の円の検出には、 cv2.HoughCircles()メソッドで実装されている円ハフ変換を使用します。現在、OpenCVで使用できるアルゴリズムはこれだけです。 私たちにとって重要なことは、入力としてグレースケール画像が必要であり、内部のCannyエッジ検出アルゴリズムを使用して画像内のエッジを見つけることです。 アルゴリズムが何を認識しているかを手動で確認できるようにしたいので、それぞれ異なるフィルターが適用された4つの小さな画像から1つの大きな画像を作成します。

キャニーエッジ検出器は、通常4つの方向(垂直、水平、2つの対角線)で画像を処理し、エッジを検出するアルゴリズムです。 このアルゴリズムが行う実際の手順は、ウィキペディアまたはOpenCVドキュメントで簡単に説明されています。

パターンマッチングとは対照的に、このアルゴリズムは円形を検出するため、手渡す必要のある円形のオブジェクトを使用できます。 インスタントコーヒーの瓶の蓋を使い、次にオレンジのコーヒーマグを使います。

フルサイズの画像を操作する必要はないので(もちろん、カメラの解像度によって異なります)、アスペクト比を維持するために、 capture.read()cv2.imshowの間で幅と高さを640pxに変更します。

 width, height = image.shape scale = 640.0 / width image = cv2.resize(image, (0,0), fx=scale, fy=scale)

次に、それをグレースケール画像に変換し、最初にノイズを除去してエッジを保持する中央値ブラーを適用し、次にキャニーエッジ検出器を適用して、円検出アルゴリズムがどのように機能するかを確認します。 このため、4つのプレビューすべてで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) 
プレビュー付きのグリッド。 左上:生のウェブカメラデータ。 右上:中央値のぼかし後にグレースケール。 左下:グレースケールとキャニーエッジ。 右下:中央値のぼかしとキャニーエッジの後にグレースケール。

キャニーエッジ検出器はガウスぼかしを使用してノイズを低減しますが、私の経験では、中央値ぼかしも使用する価値があります。 下の2つの画像を比較できます。 左側は、他のフィルターを使用しないキャニーエッジ検出です。 2番目の画像もキャニーエッジ検出ですが、今回はメジアンブラーを適用した後です。 円の検出に役立つ背景のオブジェクトを減らしました。

ハフ勾配で円を検出する

内部的には、OpenCVは、Cannyエッジ検出器からのエッジ情報を使用するHough GradientMethodと呼ばれるHoughCircleTransformのより効率的な実装を使用します。 勾配法については、ウィキペディアの 『 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経由でブラウザに送信します。 xy 、および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) 
HoughGradientを使用して検出された円を含むWebカメラストリーム。

このアニメーションでわかるように、円はまったく見つかりませんでした。 私の内蔵カメラは15fpsしかなく、手をすばやく動かすと画像がぼやけて、フィルターを適用した後でも円のエッジが見つかりません。

この記事の終わりに、この問題に戻って、カメラ固有の設定と検出アルゴリズムの選択について多くのことを話しますが、私の設定が非常に悪い(15 fpsのみ、不十分な照明、背景にノイズが多く、オブジェクトのコントラストが低い)、結果はかなり良好です。

それは今のところすべてです。 ウェブカメラの画像にある円の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); });

こちらのライブデモをご覧ください。

threejs-spinning-earth
Three.jsを使用したテクスチャ球。 (地球テクスチャクレジット)

これはほとんど基本的なThree.jsのものでした。 オブジェクト名とメソッド名は自明です( receiveShadowcastShadowなど)が、これまで使用したことがない場合は、LeeStemkoskiのチュートリアルを参照することを強くお勧めします。

オプションで、画面の中央に軸を描画して、座標系を支援することもできます。

 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; }

こちらのライブデモをご覧ください。

Three.jsで地球と月。 (地球と月のテクスチャクレジット)

2D座標を3D世界にマッピングする

これまでのところ、すべてがかなり明白です。 最も興味深い部分は、OpenCV(上記の円形検出の出力を参照)からの2D画面座標を3D世界に変換する方法です。 Three.jsで半径と位置を定義するときに、いくつかの単位を使用しましたが、これらは実際の画面のピクセルとは関係ありません。 実際、シーンに表示されるすべてのサイズは、カメラの設定(アスペクト比や視野など)に大きく依存します。

このため、 [0,0,0]を中心として、シーン全体をカバーするのに十分な大きさの平面オブジェクトを作成します。 デモンストレーションの目的で、2Dマウス座標を固定z軸を使用して3Dで地球の位置にマッピングします。 つまり、 xyのみを変換し、オブジェクトからカメラまでの距離である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-spinning-earth
2Dマウス位置にマッピングされた3Dの地球位置。 (地球と月のテクスチャクレジット)

平面との交差をチェックしているので、常に1つだけになることがわかります。

この部分は以上です。 次のパートの最後に、Three.jsの3DシーンでオーバーレイされるカメラストリームにWebSocketと<video>要素も追加します。

3.フロントエンドとバックエンドの両方のWebSocket

simple-websocket-serverライブラリをインストールすることで、PythonバックエンドにWebSocketを実装することから始めることができます。 トルネードやアウトバーンのような多くの異なるライブラリがあります。 非常に使いやすく、依存関係がないため、 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. };

Earthの位置を現在のマウスの位置に設定する代わりに、 msgHistory変数を使用します。

コード全体をここに貼り付ける必要はおそらくないので、gist.gihtub.comで実装の詳細を確認してください。

次に、透明な背景を持つ3Dシーンでオーバーレイされるウィンドウ全体を埋めるWebカメラストリームを含む1つの<video>要素を追加します。

 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() {}); }

最終結果:

OpenCVによって検出された座標にマッピングされたEarthを使用した最終的なアプリケーション。

私たちが行ったことと上記のビデオが示していることをすばやく要約するには、次のようにします。

  1. PythonバックエンドはWebSocketサーバーを実行します。
  2. サーバーは、WebカメラストリームからOpenCVを使用してサークルを検出します。
  3. JavaScriptクライアントは、 <video>要素を使用して同じWebカメラストリームを表示します。
  4. クライアントはThree.jsを使用して3Dシーンをレンダリングします。
  5. クライアントはWebSocketプロトコルを介してサーバーに接続し、円の位置と半径を受け取ります。

このデモで使用される実際のコードは、GitHubで入手できます。 3Dシーンが60fpsでレンダリングされているのに対し、Webカメラストリームは15 fpsでのみ実行されるため、少し洗練されており、バックエンドからの2つのメッセージ間の座標も補間されます。 あなたはYouTubeで元のビデオを見ることができます。

警告

注目に値するいくつかの発見があります:

円の検出は理想的ではありません

どんな円形のオブジェクトでも機能するのは素晴らしいことですが、ノイズや画像の変形に非常に敏感ですが、上記のように、結果はかなり良好です。 また、最も基本的な使用法を除いて、利用可能な円検出の実際的な例はおそらくありません。 楕円検出を使用する方が良いかもしれませんが、現在OpenCVには実装されていません。

すべてはセットアップに依存します

内蔵のウェブカメラは一般的にかなり悪いです。 15 fpsでは不十分であり、30 fpsに増やすだけでモーションブラーが大幅に減少し、検出の信頼性が高まります。 この点をさらに4つの点に分解できます。

  • カメラの歪み
    多くのカメラでは、画像の歪みが発生します。最も一般的なのは、形状検出に大きな影響を与える魚眼効果です。 OpenCVのドキュメントには、カメラのキャリブレーションによって歪みを減らす方法に関する非常に簡単なチュートリアルがあります。
  • OpenCVでサポートされているデバイスの公式リストはありません
    あなたがすでに良いカメラを持っているとしても、それはさらなる説明なしではOpenCVで動作しないかもしれません。 また、他のライブラリを使用してカメラ画像(IEEE 1394ベースのカメラのlibdc1394など)をキャプチャし、OpenCVを使用して画像を処理する人々についても読みました。 Brewパッケージマネージャーを使用すると、libdc1394をサポートしてOpenCVを直接コンパイルできます。
  • 一部のカメラは他のカメラよりもOpenCVでうまく機能します
    運が良ければ、1秒あたりのフレーム数などのカメラオプションをカメラに直接設定できますが、OpenCVがデバイスに対応していない場合は、まったく効果がない可能性があります。 繰り返しますが、説明はありません。
  • すべてのパラメータは実際の使用法に依存します
    実際のインストールで使用する場合は、ライト、背景色、オブジェクトの選択などが結果に大きな影響を与えるため、実際の環境でアルゴリズムとフィルターをテストすることを強くお勧めします。 これには、日光からの影、周りに立っている人なども含まれます。

通常、パターンマッチングの方が適しています

実際に使用されている拡張現実を見ると、おそらくパターンマッチングに基づいているでしょう。 一般的に信頼性が高く、上記の問題の影響を受けません。

フィルタは重要です

フィルタを正しく使用するには、ある程度の経験と常に少しの魔法が必要だと思います。 ほとんどのフィルターの処理時間はパラメーターによって異なりますが、OpenCV 3.0では、一部のフィルターはすでにCUDA C(NVIDIAグラフィックカードを使用した高度な並列プログラミング用のCに似た言語)に書き直されており、パフォーマンスが大幅に向上しています。

OpenCVからのデータのフィルタリング

円の検出にはいくつかの不正確さがあります。円を見つけられなかったり、間違った半径を検出したりすることがあります。 このタイプのエラーを最小限に抑えるには、精度を向上させるためのより高度な方法を実装する価値があります。 この例では、 xy 、およびradiusの中央値を使用しました。これは非常に単純です。 良好な結果が得られる一般的に使用されるフィルターは、センサーからの不正確さを減らすためにドローンの自動操縦で使用されるカルマンフィルターです。 ただし、その実装は、https://mathjs.orgのmath.mean()を使用するほど単純ではありません。

結論

私は2年前にマドリッドの国立自然史博物館で同様のアプリケーションを最初に見ましたが、同様のものを作成するのはどれほど難しいのだろうかと思いました。

このデモの背後にある私の中心的なアイデアは、Web上で一般的なツール(WebSocketsやThree.jsなど)を使用し、前提条件を必要としないため、誰でもすぐに使用できるようにすることでした。 そのため、パターンマッチングではなく、円の検出のみを使用したかったのです。パターンマッチングでは、印刷したり、特定の実世界のオブジェクトを使用したりする必要があります。

私は実際のカメラの要件を大幅に過小評価していたと言う必要があります。 1秒あたりのフレーム数が多く、適切な照明が解像度よりも重要です。 また、OpenCVとのカメラの非互換性が問題になるとは思っていませんでした。