WebアプリケーションによるROS2データの可視化1 基本部分の作成

WebアプリケーションによるROS2データの可視化1 基本部分の作成

※ ROSの表記について :
ROS1/ROS2について、文脈的にどちらでも当てはまる場合は単に「ROS」と表記しています。

はじめに

私はこれまでROSを使った開発に多数関わってきました。
その中のいくつかは「データ可視化」の課題で、ROSのデータをユーザが分かりやすいように加工して表示する仕組みや、ユーザのコマンドをROSを経由して操作対象に送る仕組みを開発しました。

ROSのデータを可視化するためのツールとして、ROS内の環境で済む場合はRvizを用いますが、Rviz の表現能力を超えた表現を行いたいこともありました。特に3Dによる表現を行う需要が高く、主にゲーム開発向けの統合開発環境 (IDE) であるUnityやUnreal Engineを用いた可視化も行ってきました。
UnityやUnreal Engineは元から3Dの表現に強いため、しっかりとしたアプリケーションを構築することはできますが、元からROSデータの送受信に特化しているわけでは無く、IDEの習熟やライブラリの追加が必要となります。

そこで、最近のHTML5を中心としたWebアプリケーションの技術に注目してみました。画像の表示はもちろん、ボタンやテキストボックスといったユーザインタフェースは始めから多数揃っているので、ユーザとのやり取りの作りこみやすさは以前からありました。また、3D表示についてはWebGLの仕組みがあり、最近ではGPUを使った演算性能向上も見られています。

本シリーズでは、これらWebアプリケーションの技術を用いて、ROS2のデータ可視化の仕組みの作成にチャレンジしてみます。


本記事は全4回のシリーズ記事の第1回です。

第1回 基本部分の作成
第2回 画像データの受信と表示
第3回 点群データの受信と表示
第4回 Webアプリケーション側からのROS2情報送信


類似プロジェクトを探る

「ROSデータをHTMLで受信してみよう」と思いついてから検索を行ってみると——実は以前からありました。

rosboard (★1) はパネル状に様々なROSデータを敷き詰めて俯瞰で見ることができるパッケージです。
単にデータを閲覧したい場合など、特に理由が無ければこちらを使えば良さそうです。

rosboard の中身について調べてみると、通信はJavaScriptを使って行われていて、「roslibjs」 (★2) というライブラリが使われていました。
ROSとWebSocket等利用して通信することができるパッケージ「ROSBridge」を経由してJavaScriptでROS通信することができます。
こちらを活用すれば、シンプルなJavaScriptでROS送受信ができますね。

また、roslibjsを起点に検索を行うと、Reactでの実装例 (★3) や Angular.jsでの実装例 (★4) も見つかりました。

今回は、表示の仕方をカスタムしたいため、rosboardを使用せず、roslibjsと、私が良く使っているNext.js (React) とを組み合わせてROS2データの送受信を行ってみることにします。

シリーズを通しての課題の設定

本記事では、まず本シリーズを通しての課題 (制作物) の設定をします。

ROS2データの受信 & 可視化処理では、ロボットのセンサとしてもよく使われている以下の情報を受信する処理を構築します。

  • カメラ映像 (sensor_msgs/Image)
  • 点群 (sensor_msgs/PointCloud2)

また、Webアプリケーション側からのROSデータ送付の実験として、文字列 (std_msgs/String) を送付し、ROS2が稼働しているサーバ側の動作を変更させてみます。

なお、本当に各デバイスを接続して本当のセンサ情報を使用するのは大変なので、今回はそれぞれダミーのデータを使用することにします。

  • カメラ映像 → 事前に撮影したコマ送りの画像を順次送信する。
  • 点群 → (一定の法則で) ランダムに生成した点群を順次送信する。

簡単なシステム構成は以下の通りです。

簡単なシステム構成

アプリケーションの完成イメージは以下の通りです。
ROS2データとして受信した画像、点群をそれぞれ並べて表示します。

ROS2 data visualization sample

基本部分の作成

シリーズ第1回の今回は「ROS2を起動するサーバ側システム」および「Next.jsによるクライアント側Webアプリケーション」それぞれのシリーズ全体を通した中核の部分を作成します。( 以降、それぞれを「サーバ」「Webアプリケーション」と表記します。 )
簡単な送受信の確認として、サーバシステム側では文字列 (std_msgs/String) を送信し、Webアプリケーション側ではこれを受信して表示させてみます。

事前準備

それぞれの構成は以下の通りです。

  • サーバシステム
    • Ubuntu 22.04
    • ROS2 humble インストール済み
      • ROS node はPythonにて作成
  • Webアプリケーション
    • サーバシステムと同じPC内でWebサーバを起動
    • npm等はインストール済み
      • npm バージョン 10.2.3
      • React バージョン 18
      • Next.js バージョン 14.2.3

サーバシステム作成

サーバシステム側では、ROS2によるデータ送受信のパッケージを作成します。
今回が新規作成となるので、まず ros2 pkg createコマンドによりパッケージを作成します。
パッケージ名はvisualize_sample_server とします。

$ ros2 pkg create visualize_sample_server --node-name sample_sender_node --build-type ament_python

( 作成したプロジェクトディレクトリに入る )
$ cd visualize_sample_server

スクリプトの配置はノード名ディレクトリ配下ではなく、scriptsディレクトリ配下としたいので、setup.pyの中身を変更して、ビルド対象のディレクトリを変更します。
また、launchファイルも認識できるようにしておきます。

( エディタを開いて作成 )
$ vim setup.py
ソース詳細
import os
from glob import glob
from setuptools import find_packages, setup

package_name = 'visualize_sample_server'

setup(
    name=package_name,
    version='0.0.0',
    packages=find_packages(exclude=['test']),
    data_files=[
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        (os.path.join('share', package_name, 'launch'), glob(os.path.join('launch', '*launch.[pxy][yma]*'))),  # <-- 追加
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='k-nagasawa',
    maintainer_email='k-nagasawa@isp.co.jp',
    description='TODO: Package description',
    license='TODO: License declaration',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'sample_sender_node = scripts.sample_sender_node:main',  # <-- ディレクトリ名変更
        ],
    },
)

パッケージの基本的な準備ができたら、サンプルの文字列をpublishするsample_sender_nodeをscriptsディレクトリ内に作成します。

( エディタを開いて作成 )
$ mkdir scripts
$ vim scripts/sample_sender_node.py
ソース詳細
import rclpy
from rclpy.node import Node
from std_msgs.msg import String

### Node name
NODE_NAME = 'sample_sender'

### Topic name
TOPIC_NAME = '/test/message'

### Timer period
TIMER_PERIOD = 1.0  # 1 秒おきに呼び出し

### Sample sender node
class SampleSenderNode(Node) :
    def __init__(self) :
        super().__init__(NODE_NAME)

        ### Initialize
        self.count = 0
        self.publisher = self.create_publisher(String, TOPIC_NAME, 10)

        ### Set timer
        self.timer = self.create_timer(TIMER_PERIOD, self.on_timer)

    def on_timer(self) :
        
        ### Send message
        self.count += 1
        msg = String()
        msg.data = f'Hello ({self.count})'
        self.publisher.publish(msg)

def main(args = None):
    # Initialize rclpy
    print("Start sample sender node.")
    rclpy.init(args = args)

    # Create node
    node = SampleSenderNode()

    # Spin
    try :
        rclpy.spin(node)
    except KeyboardInterrupt :  # Ctrl + C
        pass
    print("Shutdown sample sender node.")
    rclpy.shutdown()

if __name__ == '__main__':
    main()

ノードを実行するlaunchファイルも作成します。

今回の実行ノードは`sample_sender_node`一つだけですが、ros_bridgeも併せて実行する必要があるため、launchファイルですべてまとめて実行できるようにしておきます。

ファイル名はexecute.launch.pyとします。

( エディタを開いて作成 )
$ vim launch/execute.launch.py 
ソース詳細
import os
import launch
from launch import LaunchDescription
from launch_ros.actions import Node

def generate_launch_description() :
    ld = LaunchDescription()

    # ROS bridge
    node_rosbridge = Node(
        package = 'rosbridge_server',
        executable = 'rosbridge_websocket',
        arguments = [
            '--address', '0.0.0.0', '--port', '8888',
        ],
    )
    ld.add_action(node_rosbridge)

    # Sample text sender node
    node_sample_text_sender = Node(
        package = 'visualize_sample_server',
        executable = 'sample_sender_node',
        name = 'sample_text_sender',
    )
    ld.add_action(node_sample_text_sender)

    return ld

動作確認で実行してみます。
launchファイルを実行し、別のターミナルでトピックをsubscriptionすると、文字列が送信されている様子を確認できます。

( ターミナル1 … ビルドして実行 )

( ビルド )
$ colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release --symlink-install

( 実行 )
$ source ./install/setup.sh
$ ros2 launch visualize_sample_server execute.launch.py

( ターミナル2 … 受信 )

$ ros2 topic echo /test/message
data: Hello (32)
---
data: Hello (33)
---
( 以降続く )

Webアプリケーション作成

サーバシステム側完成後、Webアプリケーション側を作成します。
こちらも新規作成なので、初めにnpxコマンドでパッケージを作ります。
パッケージ名はvisualize_sample_clientとします。

$ npx create-next-app@latest
-> 対話式で進む。
  -> プロジェクト名を設定 (visualize_sample_client とする)
  -> TypeScript で記述するか → Yes
  -> Tailwind CSS を導入するか -> No
  -> src/ ディレクトリを作るか -> No
  -> App Route -> Yes
  -> (その他は適当で良い)

( 作成したプロジェクトディレクトリに入る )
$ cd visualize_sample_client

依存パッケージとしてroslibjsの他に、使い慣れているBootstrapを導入しておきます。
点群の表示で使用するThree.jsも併せて入れておきます。

( bootstrap install )
$ npm install --save bootstrap @types/bootstrap
$ npm install react-bootstrap @types/react-bootstrap

( roslibjs install )
$ npm install @types/roslib --save-dev

( Three.js install )
$ npm install three @types/three @react-three/fiber @react-three/drei

libsディレクトリ配下に「roslibjsを使って送受信する」という機能を持ったRosOperatorモジュールを作成します。これによりメインの処理とROS2の送受信処理とを分離します。
ファイル名をros_operation.tsxとします。

$ mkdir libs

( エディタを開いて作成 )
$ vim libs/ros_operations.tsx
ソース詳細
import React, { forwardRef, useImperativeHandle, useEffect, useRef, useState } from 'react';
import * as ROSLIB from 'roslib';

///////////////////////////////////////////////////
/// Settings                                                                        ///
///////////////////////////////////////////////////

// ROS接続URL
const ROS_CONNECTION_URL = 'ws://127.0.0.1:8888'

// Test string topic name
const TOPIC_NAME_TEST_STR = '/test/message';

///////////////////////////////////////////////////
/// Interfaces                                                                     ///
///////////////////////////////////////////////////

// String 型
export interface String {
    data: string;
}

///////////////////////////////////////////////////
/// ROS Operator                                                               ///
///////////////////////////////////////////////////

const RosOperator = forwardRef((props, ref) => {

    // 上位モジュールから呼び出される処理
    useImperativeHandle(ref, () => ({
        // ROSとの接続
        connect: () => {
            connectToROS();
        },
    }));

    // ROSLIB object
    const [ros, setRosObject] = useState<ROSLIB.Ros | null>(null);

    // Test string topic listener
    const [testStrListener, setTestStrListener] = useState<ROSLIB.Topic | null>(null);

    // Connect to ROS ... ROSとの接続を開始する
    const connectToROS = () => {
    
        // 接続開始
        console.log('ROS Operator: Try connection...');
        props.setRosConnected?.(false);  // NOTE: 上位モジュールのROS接続確認フラグをOFF

        // Initialize ROS connection object
        setRosObject(new ROSLIB.Ros({url:ROS_CONNECTION_URL}));
    };

    // ROS オブジェクト更新時に Listener & Sender を更新
    useEffect(() => {

        // 初回表示時は ROS オブジェクトが null なのでスキップ
        if (ros === null) { return; }

        // 接続完了検知 or エラー検知
        // NOTE: 検知時に上位モジュールにフラグ通知
        ros.on('connection', () => {
            console.log('ROS Operator: ROS connected.');
            props.setRosConnected?.(true);
        });
        ros.on('error', (err) => {
            console.log('ROS Operator: ROS connection error, ', err);
            props.setRosConnected?.(false);
        });
        ros.on('close', () => {
            console.log('ROS Operator: ROS connection closed.');
            props.setRosConnected?.(false);
        });

        // Test string topic listener
        setTestStrListener(
            new ROSLIB.Topic({
                ros: ros,
                name: TOPIC_NAME_TEST_STR,
                messageType: 'std_msgs/String',
            })
        );
    }, [ros]);

    // testStrListener更新時 に subscribe 設定
    useEffect(() => {

        // 初回表示時はオブジェクトが空なのでスキップ
        if (testStrListener === null) { return; }

        // Test string topic msg subscription event
        testStrListener.subscribe((msg: ROSLIB.Message) => {
            // 上位モジュールへ通知
            props.updateTestStr?.((msg as String).data);
        });
    }, [testStrListener]);

    return (
        <>< />    // 表示は無し
    );
});
RosOperator.displayName = 'RosOperator';
export default RosOperator;

あとはメインの処理を作成します。
pagesディレクトリ配下に表示用のviewer.tsxを作成します。
サーバシステム側に接続するボタンを作り、ボタンが押されたらテキストを受信し、適宜表示更新を行うようにします。

$ mkdir pages

( エディタを開いて作成 )
$ vim pages/viewer.tsx
ソース詳細
// React
import React, { useRef, useState } from 'react';

// Bootstrap
import { Container, Row, Col } from 'react-bootstrap';
import { Button, Form } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';

// ROS Operator
import  RosOperator from '../libs/ros_operation';


//////////////////////////////////////////////////
/// Viewer                                                                        ///
//////////////////////////////////////////////////

const Viewer = () => {

    // ROS operator ref
    const rosOpRef = useRef<any>(null!);

    // ROS 接続状態 (ROS Operatorにて更新)
    const [rosConnected, setRosConnected] = useState<boolean>(false);

    // Test string
    const [testStr, setTestStr] = useState<string>('');

    // ROS接続開始
    const startConnect = () => {
        rosOpRef.current.connect();
    };

    // 表示
    return (
        <>
            {/* ROS Operator */}
            <RosOperator ref={rosOpRef}
                setRosConnected={setRosConnected}
                updateImage={updateImage}
                updatePointCloud={updatePointCloud}
                updateTestStr={setTestStr}
            />

            <Container>
                {/* Title */}
                <Row>
                    <Col>
                        <h1>ROS2 data visualization sample</h1>
                    </Col>
                </Row>

                {/* Header ... ROS接続ボタンおよび接続状態確認 */}
                <Row>
                    <Col>
                        <Button 
                            variant={!rosConnected ? "primary" : "secondary"}
                            disabled={rosConnected}
                            onClick={!rosConnected ? startConnect : null}
                        >
                            ROS接続開始
                        </Button>
                        <h2>ROS接続状態 ... {rosConnected ? 'ON' : 'OFF'}</h2>
                    </Col>
                </Row>

                {/* Logger ... ログ表示等 */}
                <Row>
                    <Col>
                        <h2>ログ表示</h2>
                        <div>{testStr}</div>
                    </Col>
                </Row>
            </Container>
        </>
    );
};
export default Viewer;

ここまででWebアプリケーション側も作成できたので、実際に動かしてみます。

実行

それぞれの機能ができたので、実行してみます。

サーバシステム側は作成したlaunchファイルを実行し、テキスト送信ノードとros_bridgeを起動します。

$ source ./install/setup.sh
$ ros2 launch visualize_sample_server execute.launch.py

Webアプリケーション側はnpmのデバッグ機能を利用してローカルサーバを立ち上げます。
ros_bridgeが8888ポートで起動しているので、衝突しないように適当なポート番号を指定して実行します。

$ PORT=8000 npm run dev

指定したポート番号にて、ブラウザでローカルWebサーバにアクセスして動作を確認します。 ( ここでは http://127.0.0.1:8000/viewer )

アクセスすると、タイトルとROSの接続状態 (OFF) が表示されます。

ROS2 data visualization sample 1

サーバシステム側が起動済みの状態で「ROS接続開始」ボタンを押すと実際にros_bridge経由で接続し、サーバシステム側で送信しているテキストメッセージを逐次ログ表示部に表示します。

ROS2 data visualization sample 2

ここまでで、今回の基本部分が作成できました。

まとめと次回

今回は全4回のシリーズ全体の課題の設定を行い、全体の基本となる部分の作成を行いました。
この基本部分を元にして、次回以降、画像の受信処理、点群の受信処理を順に作成していきます。


参照

(★1) rosboard
(★2) roslibjs
(★3) rosbridge_suiteとReactでROS 2 Webアプリを作る
(★4) Angularでroslibjsを動かす