Unreal Engine 5を使ってみる その16(測距センサーを用いた簡易的なスキャン、Node-RED)
はじめに
今回は測距センサーを利用してLiDARのようにスキャンできないかな?と思ったので、まずはUnreal Engine 5 (UE5) のシミュレーション環境で試してみました。
なぜ試そうと思ったのかというと、LiDARが高額だったからです。実物は見たことがあって、どうやらモーターでぐるぐる回転させていました。これを、よく使っている電子部品で代用できないかなと考えました。
▼1万円は超えるという印象で、学生の私にとっては高額です。
空間をスキャンしてマップを生成する場合などは、もちろん自己位置推定などの技術が必要ですが、今回は非常に簡易的で理想的な状況でシミュレーションを行っています。シミュレーションであれば実際に購入するコストはかからないので、今後自己位置推定などもシミュレーション内で行えるようにしていきたいところです。
▼CSVファイルに座標データを保存したときとよく似た方法です。
▼以前の記事はこちら
Fusion 360でモデリング
測距センサーはVL53L0X、回転には180度回転サーボモータのSG90を利用する想定で簡単にモデリングしました。
▼VL53L0XはAmazonだと値段が様々です。
▼以下の記事で利用したことがあります。
▼SG90はTower Proのものを利用しています。秋月電子で購入した方が安いです。
https://akizukidenshi.com/catalog/g/g108761
▼以下の記事で利用したことがあります。
連続回転サーボでぐるぐる回転させる方がLiDARっぽくなるような気もしたのですが、測距センサーの配線が絡まってしまいます。
▼スリップリングという部品を使うと、導通したまま回転させることができるようです。今回はコストになるので使いません。
測距センサー2つが収まるように簡単な筐体を設計し、サーボモータの回転部に配置しました。
▼こんな見た目です。

このモデルをFBXファイルとしてエクスポートしておきました。
UE5で動かす
今回はStack O Botのプロジェクトを利用しました。
▼Stack O Botはラーニングパスが用意されています。
https://dev.epicgames.com/community/learning/paths/yG/stack-o-bot
Unreal Engine 5.3でプロジェクトを作成しました。
▼どこかの惑星の基地みたいな感じですね。

▼ゲームを開始すると、ロボットがスポーンしました。

▼Fキーを押すと、二人目が出てきました。

このロボットの頭に、先程のFusion 360で作成したモデルを配置します。
▼インポートオプションのほとんどはデフォルトのままですが、Combine MeshesだけTrueにしています。こうしないと、部品がバラバラに取り込まれます。

▼インポートできました。

これをStack O Botの頭に配置しました。
▼部品ごとに分かれていないのでサーボモータごと回ってしまうのですが、ほとんど隠れるので今回は気にしていません。

▼レーザーの始点と終点になる位置に、実体のないスタティックメッシュを用意しました。ブループリントで座標を取得して利用します。


プログラムはStack O BotのEvent Tickの処理に続けて実行するようにしています。
▼ブループリントでLine Traceを設定しています。これをHTTP Blueprintで送信しています。


▼回転はRelative Rotationを変数で管理して、決められた値の範囲を出ると逆転します。

後で気づいたのですが、-180~180なので360度回転してしまっています。SG90は180度だけ回転するサーボなのでミスです。0~180にすればOKです。
▼ゲームを開始すると、Line Traceが表示されました。

▼物体と接触すると、その空間座標が出力されます。

このレーザーを回転させて、ワールド内を歩き、衝突した座標をHTTP BlueprintでNode-REDと通信して送信します。
▼HTTP Blueprintについては、以下の記事で使ったことがあります。
▼本当はObjectDelivererというプラグインがあったのですが、Node-REDと通信できませんでした。また使い方を詳しく調べてみようと思います。
https://www.fab.com/ja/listings/b6ffd7d7-80da-483f-a7fa-09cb46b72651
Pythonで座標をプロット
UE5の座標をプロットするのに、Pythonのmatplotlibを利用します。今回は2次元座標にプロットしていきます。
Pythonの実行はNode-REDで、私が開発したpython-venvノードを利用します。Pythonの仮想環境を作成し、その環境にパッケージをインストールして、コードを実行できるノードになっています。
▼年末に開発の変遷を書きました。
https://qiita.com/background/items/d2e05e8d85427761a609
PythonのコードについてはChatGPTに聞きながら、以下のフローを作成しました。データ形式はx座標、y座標をコンマ区切りにしたものです。
▼全体のフローはこちら

[{"id":"0b3bcc8fc3384143","type":"venv","z":"790506c326ae6cc7","venvconfig":"c696dc15ecc2e01e","name":"","code":"import socket\nimport threading\nimport matplotlib.pyplot as plt\nimport matplotlib.animation as animation\n\n# TCPサーバー設定\nHOST = '0.0.0.0' # すべてのインターフェースでリッスン\nPORT = 5000 # 使用するポート番号\n\n# 座標データのリスト\nx_data = []\ny_data = []\n\ndef handle_client(client_socket):\n \"\"\" クライアントからのデータを受け取り、座標を格納 \"\"\"\n global x_data, y_data\n try:\n while True:\n # データ受信\n data = client_socket.recv(1024).decode('utf-8')\n print(data)\n if not data:\n break\n try:\n # \"x,y\"形式のデータをパース\n x, y = map(float, data.strip().split(','))\n x_data.append(x)\n y_data.append(y)\n print(f\"Received: X={x}, Y={y}\")\n except ValueError:\n print(\"Invalid data:\", data)\n except ConnectionResetError:\n print(\"Client disconnected\")\n finally:\n client_socket.close()\n\ndef start_server():\n \"\"\" TCPサーバーを起動し、クライアントを待機 \"\"\"\n server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n server.bind((HOST, PORT))\n server.listen(5)\n print(f\"Listening on {HOST}:{PORT}\")\n\n while True:\n client_sock, addr = server.accept()\n print(f\"Accepted connection from {addr}\")\n client_thread = threading.Thread(target=handle_client, args=(client_sock,))\n client_thread.start()\n\n# サーバーを別スレッドで起動\nserver_thread = threading.Thread(target=start_server, daemon=True)\nserver_thread.start()\n\ndef update_plot(frame):\n \"\"\" リアルタイムでプロットを更新 \"\"\"\n plt.cla() # 現在のプロットをクリア\n plt.scatter(x_data, y_data, c='red', label=\"Received Points\")\n plt.xlabel(\"X Coordinate\")\n plt.ylabel(\"Y Coordinate\")\n plt.title(\"Real-time TCP Data Plot\")\n plt.legend()\n plt.grid(True)\n\n# リアルタイムプロットの設定\nfig = plt.figure()\nani = animation.FuncAnimation(fig, update_plot, interval=500) # 0.5秒ごとに更新\n\nprint(\"Starting real-time plot...\")\nplt.show()\n","continuous":true,"x":610,"y":1940,"wires":[["78ff95ef2e84288c"]]},{"id":"96a9b88ff1308cbf","type":"inject","z":"790506c326ae6cc7","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":470,"y":1940,"wires":[["0b3bcc8fc3384143"]]},{"id":"78ff95ef2e84288c","type":"debug","z":"790506c326ae6cc7","name":"debug 517","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":770,"y":1940,"wires":[]},{"id":"ec05929238122b88","type":"pip","z":"790506c326ae6cc7","venvconfig":"c696dc15ecc2e01e","name":"","arg":"matplotlib","action":"install","tail":false,"x":610,"y":1880,"wires":[["28526a39b0f3af25"]]},{"id":"a5c46238c452adbb","type":"inject","z":"790506c326ae6cc7","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":470,"y":1880,"wires":[["ec05929238122b88"]]},{"id":"28526a39b0f3af25","type":"debug","z":"790506c326ae6cc7","name":"debug 518","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":770,"y":1880,"wires":[]},{"id":"2a9f2d77c35d1bb6","type":"tcp request","z":"790506c326ae6cc7","name":"","server":"127.0.0.1","port":"5000","out":"time","ret":"string","splitc":"0","newline":"","trim":false,"tls":"","x":650,"y":2000,"wires":[["d108778c11d56165"]]},{"id":"d108778c11d56165","type":"debug","z":"790506c326ae6cc7","name":"debug 519","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"false","statusVal":"","statusType":"auto","x":850,"y":2000,"wires":[]},{"id":"096de7c5df2b4412","type":"inject","z":"790506c326ae6cc7","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"0, 0","payloadType":"str","x":450,"y":2000,"wires":[["2a9f2d77c35d1bb6"]]},{"id":"c942b71317b80d74","type":"inject","z":"790506c326ae6cc7","name":"","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"1000, 0","payloadType":"str","x":450,"y":2040,"wires":[["2a9f2d77c35d1bb6"]]},{"id":"fb199475c480e5b9","type":"http in","z":"790506c326ae6cc7","name":"","url":"/position","method":"post","upload":true,"swaggerDoc":"","x":490,"y":2100,"wires":[["cc9c2bb9de3c4933","6b13fbf7602f622c"]]},{"id":"cc9c2bb9de3c4933","type":"http response","z":"790506c326ae6cc7","name":"","statusCode":"","headers":{},"x":670,"y":2100,"wires":[]},{"id":"6b13fbf7602f622c","type":"json","z":"790506c326ae6cc7","name":"","property":"payload","action":"obj","pretty":false,"x":590,"y":2060,"wires":[["8b27da947e0adb13"]]},{"id":"8b27da947e0adb13","type":"template","z":"790506c326ae6cc7","name":"","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"{{payload.x}}, {{payload.y}}","output":"str","x":740,"y":2060,"wires":[["2a9f2d77c35d1bb6"]]},{"id":"bead740fbce3ff16","type":"comment","z":"790506c326ae6cc7","name":"Plot TCP Server","info":"","x":460,"y":1840,"wires":[]},{"id":"c696dc15ecc2e01e","type":"venv-config","venvname":"UE","version":"3.10"}]
ObjectDelivererが使えたらHTTP通信は不要だったのですが、今回はhttp in/outノードでUE5からの通信を受け取っています。
座標がプロットされるかを確認するため、injectノードに座標を入れ、tcp requestノードで送信してみました。
▼ちゃんとプロットされています。軸のスケールも自動的に変更されています。

ワールド内を歩いてみる
最初にスポーンする四角形のフィールドを歩きながら、スキャンしてみました。
▼少しずつマップが作成されています。
頭にアタッチしているわけでは無いので、頭の揺れとは関係なく水平に回転し続けています。見た目的には不自然ですが、スキャンするという用途ではこの挙動でひとまず良いかと思います。
角の部分が斜めになっているのは、元々コリジョンがあるようでした。逆に窓の部分はコリジョンが無くて、センサーが反応していません。
▼角の隙間はそもそも入れません。

▼スキャンした座標をプロットできました。

最後に
非常に簡易的ではありますが、UE5の座標を送信して、リアルタイムでプロットすることができました。
自己位置推定もUE5で行えるようにして、LiDARを利用したマップ生成のようなシミュレーションができたら面白そうだなと思っています。ARマーカー関連の技術を試してみたいところです。