🖌️

rosbridge_suiteとReactでROS 2 Webアプリを作る

2023/05/15に公開

海洋ロボコンをやってた人です。

今回は以前から作りたかったROS 2 Webアプリ: ros2_robot_react_app をReactで作成したので、その備忘録として記載していきます。

まとめる理由は以下です。

  • rosbridge_suite等、自分でWebアプリ作るときにすぐ確認したいため
  • ROS 2のWebアプリ作成に関する情報が少ないため
  • UIがないと、様々な業界のユーザーが使いにくいため

また、本記事に対するコメントも積極的に募集しますので、よろしくお願いいたします。

1. rosbridge_suiteとReactでROS 2 Webアプリを作る

ROS 2でWebアプリを作る際、JSON API を使用して rosbridge と通信できるrosbridge_suiteを使用します。

また、フロントエンドはクロスプラットホーム対応、Android, iOSなどのネイティブアプリ対応が可能で、英語圏においてはシェア率の高い「React」を使用していきます。

他に理由をあげると、単にReactの勉強をしたかったからというのもあります。

また、Reactは構成要素をそれぞれ独立したオブジェクトとして扱うコンポーネント指向で各オブジェクトが独立しているので保守性が高く、拡張性が高い利点もあります。
あとはPropsで要素をバケツリレーで渡せる点とか。

Reactは大規模アプリ開発、Vue.jsは小中規模アプリ開発と一般的に言われています。
Reactの場合、コンポーネント指向のため再レンダリング時に一方向の更新になりますが、Vue.jsは双方向の更新となるという点でもそれぞれのメリデリがあります。

この辺りは、Reactの説明になるので省略しますが、いきなりReactとROS 2のアプリを作るのは初学者の自分にはハードルが高かったので、初めにJSで記述することにします。
その後、この記事を書く力が尽きない限りReactについても備忘録も兼ねて記述していきたいと思います。

1.1 必要なパッケージのインストール

  1. rosbridge_server

まずはrosbridge_severを立てるために以下をインストールします。
aptでHumbleでもインストールできます。

また起動は以下のlaunchで実行してください。

terminal
sudo apt update
sudo apt install -y ros-$ROS_DISTRO-rosbridge-suite

ros2 launch rosbridge_server rosbridge_websocket_launch.xml
  1. node.js

フロントエンド用に先にnode.jsとReactもインストールしておきます。

terminal
sudo apt install nodejs
nodejs -v
npm -v # もしなければ sudo apt install npm

node.jsでよく出てくるnpmとnpxの違いもメモ書き。

  • npm とは?

Node.jsをインストールすると一緒にnpmもインストールされます。
node package managerの略です。
「npm install」とかよく見ますがnpmはNode.jsのパッケージ管理ツールで「npm init」で作成できるpackage.jsonに書いてあるパッケージの管理を「npm 〜」でします。

  • npx とは?

node package executerの略でパッケージの実行ツールです。
npmとの違いはインストールされてないパッケージでも自動的に探してインストールして実行してインストールしたパッケージの削除をします。

  1. React

後述で使用するReactのパッケージ作成、起動、ライブラリのインストールは下記で行えます。

React appの作成

sudo npm i -g create-react-app
create-react-app ros2_robot_react_app
npm start

ライブラリのインストール

npm install roslib
npm install react-bootstrap bootstrap
npm install --save chart.js react-chartjs-2
npm install three @types/three @react-three/fiber

上記のインストールは、使用するライブラリに応じてで構いません。
こちらは自分が気になって使いたいものを抜粋しております。

1.2 HTML & JSでrosbridge_suiteとROS 2を繋げる

必要なパッケージ等インストールできたら、まずはHTMLとJSで記述したプログラムをrosbridge_suite経由でROS 2と通信して動作確認します。

適当なディレクトリにHTMLファイルもしくはJSファイルを準備し、WebアプリでROS 2のデータとやりとりするプログラムを作成していきます。

  • /image_raw via ROS 2 web
camera.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- <script src="https://static.robotwebtools.org/EventEmitter2/current/eventemitter2.min.js"></script> -->
<!-- <script type="text/javascript" src="roslib.min.js"></script>-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/roslibjs/0.20.0/roslib.min.js"></script>
</head>

<body>
  <p><img id="image_sub"/></p>
  <hr/>

  <p>Connection: <span id="status" style="font-weight: bold;">N/A</span></p>

  <script>
    // Create ros object to communicate over your Rosbridge connection
    const ros = new ROSLIB.Ros({
      url: 'ws://localhost:9090',
      options: {
        ros_domain_id: '89' // ROS_DOMAIN_IDを設定する
      }
    });

    // When the Rosbridge server connects, fill the span with id "status" with "successful"
    ros.on("connection", () => {
      document.getElementById("status").innerHTML = "successful";
      console.log('Connected to ROSBridge WebSocket server.');
    });

    // When the Rosbridge server experiences an error, fill the "status" span with the returned error
    ros.on('error', function(error) {
      console.log('Error connecting to ROSBridge WebSocket server: ', error);
    });

    // When the Rosbridge server shuts down, fill the "status" span with "closed"
    ros.on('close', function() {
      console.log('Connection to ROSBridge WebSocket server closed.');
    });

    var image = new ROSLIB.Topic({
      ros : ros,
      name : '/color/image_raw/compressed',
      messageType : 'sensor_msgs/CompressedImage'
    });

    image.subscribe(function(message) {
      console.log('Received image');
      var data = "data:image/png;base64," + message.data;
      document.getElementById('image_sub').setAttribute('src', data); 
    });
</script>

</body>
</html>

簡単な説明を書いておくと、HTML内のscriptタグ内で主にROS 2の処理が実行されます。

初めのrosの記述がある部分でブラウザのURL、ROS_DOMAIN_ID、ROSの接続状況を判断し接続します。

IPアドレスを指定する場合はlocalhost:9090の部分を192.168.11.3:9090のように変更してください。

camera.html(ROS接続の部分)
    const ros = new ROSLIB.Ros({
      url: 'ws://localhost:9090',
      options: {
        ros_domain_id: '89' // ROS_DOMAIN_IDを設定する
      }
    });

******

    ros.on('close', function() {
      console.log('Connection to ROSBridge WebSocket server closed.');
    });

上記の部分はrosconnect.jsという形でJSに分割し、HTML側で

camera.html
<script type="text/javascript" src="rosconnect.js"></script>

などと呼び出すとコンポーネント化でき、わかりやすいと思います。

そして、ROSLIB.Topic以降でROSで送受信するデータの型、publish/subscribeを記述します。
JSでROSの画像イメージを受け取るとき、Base64形式の画像ファイルとして処理することに注意してください。

camera.html
    var image = new ROSLIB.Topic({
      ros : ros,
      name : '/color/image_raw/compressed',
      messageType : 'sensor_msgs/CompressedImage'
    });

    image.subscribe(function(message) {
      console.log('Received image');
      var data = "data:image/png;base64," + message.data;
      document.getElementById('image_sub').setAttribute('src', data); 
    });

他の部分は、ただのHTMLです。

roslibjsは、ディレクトリに直置きするか(Wi-Fiがつながらない環境だとこちらを推奨)

<script src="https://cdnjs.cloudflare.com/ajax/libs/roslibjs/0.20.0/roslib.min.js"></script>

という形でcdnjsよりjsのプログラウを読み込むかも選べます。

アプリケーションがWi-Fiと常に接続できる環境にあるかどうかで判断するとよいかと思います。

これのHTMLをそのままGoogle Chromeで表示し

terminal
ros2 launch rosbridge_server rosbridge_websocket_launch.xml

とrosbridgeを起動すると接続が完了し/color/image_raw/compressedのカメラ映像を表示することができます。

カメラの場合、subscribeするのはcompressedイメージであることに注意してください。
接続できると、以下のような形でROS/ROS 2のデータを可視化できます。

私はTB3 Gazeboのカメラ画像を入力させています。

terminal
. install/setup.bash 
ros2 launch turtlebot3_gazebo turtlebot3_world.launch.py


  • /cmd_vel via ROS 2 web

続いて、cmd_velのpublishも記述してみましょう。

rosの接続箇所(上記のcamera.html ROS接続の部分)は先程と変わらないので、それ以外の箇所を後述します。

cmd.html (scriptタグ内)
      const cmdVel = new ROSLIB.Topic({
        ros: ros,
        name: '/cmd_vel',
        messageType: 'geometry_msgs/Twist'
      });

      const pad = document.getElementById('pad');
      const handle = document.getElementById('handle');

      let twist = new ROSLIB.Message({
        linear: { x: 0, y: 0, z: 0 },
        angular: { x: 0, y: 0, z: 0 }
      });

      let padRect = pad.getBoundingClientRect();

      handle.addEventListener('mousedown', (e) => {
        e.preventDefault();
        document.addEventListener('mousemove', moveHandle);
        document.addEventListener('mouseup', stopHandle);
      });

      function moveHandle(e) {
        let x = e.clientX - padRect.left - handle.offsetWidth / 2;
        let y = e.clientY - padRect.top - handle.offsetHeight / 2;
        let r = (pad.offsetWidth - handle.offsetWidth) / 2;
        let cx = r, cy = r;

        let dist = Math.sqrt(Math.pow(x - cx, 2) + Math.pow(y - cy, 2));
        if (dist > r) {
          x = (x - cx) * r / dist + cx;
          y = (y - cy) * r / dist + cy;
        }

        handle.style.left = x + 'px';
        handle.style.top = y + 'px';

        let nx = ((x - cx) / r);
        let ny = ((cy - y) / r);
        twist.linear.x = parseFloat(ny.toFixed(3));
        twist.angular.z = -parseFloat(nx.toFixed(3));
        cmdVel.publish(twist);
      }

      function stopHandle(e) {
        document.removeEventListener('mousemove', moveHandle);
        document.removeEventListener('mouseup', stopHandle);
        handle.style.left = '125px';
        handle.style.top = '125px';
        twist.linear.x = 0;
        twist.angular.z = 0;
        cmdVel.publish(twist);
      }

上記は、pad, handleの操作量に応じてhandleやtwistが変化するようになっており、

cmd.html
<body>
  <h1>ROS Websocket Example</h1>
  <p>Subscribing to topic: <code>/my_topic</code></p>
  <p><code>/my_topic</code> messages received: <span id="messages" style="font-weight: bold;"></span></p>

  <div id="pad" style="width: 300px; height: 300px; background-color: #eee;">
    <div id="handle" style="width: 50px; height: 50px; background-color: #303030; position: relative; left: 125px; top: 125px; border-radius: 50%;"></div>
  </div>
  <script>

このbodyタグ内で指定したidと紐付いています。

実行すると以下のようになり、pad, handleを動かすとTB3など/cmd_velを受信して動作するロボットが動きます。

このようにROSと接続する記述を用いるだけで簡単にWebアプリとやり取りができるので、HTML、JSファイルに上記の処理をまとめると

https://twitter.com/tasada038/status/1656281745543819264

のようなことができるようになります。

1.3 Reactでrosbridge_suiteとROS 2を繋げる

続いて、Reactでrosbridge_suiteROS 2をつなげる方法も書いていきます。

まずcreate-react-appでReact appを作成する場合、以下のファイルは不要なので削除等を行います。

  • App.jsのheaderタグ削除など削除

  • index.htmlの"favicon.ico"を変更し、アプリアイコンを変える

  • reportWebVitals.js, setupTests.js, App.test.js

  • index.htmlのインポート部分で削除した上記がインポートされないよう調整

また、コンポーネントを作成するときはrafce + Tabキーでコンポーネントの雛形を整形できます。


  • RosConnection.js

では最初にsrc下にcomponentsフォルダを作成し、RosConnection.jsというコンポーネントを作成します。

RosConnection.js
import React, { useEffect } from 'react';
import ROSLIB from 'roslib';

const Rosconnection = ({ rosUrl, rosDomainId, setRos}) => {

  useEffect(() => {
    const ros = new ROSLIB.Ros({
      url: rosUrl,
      options: {
        ros_domain_id: rosDomainId // ROS_DOMAIN_IDを設定する
      }
    });

    ros.on("connection", () => {
      setRos(ros);
      document.getElementById("status").innerHTML = "successful";
      console.log('Connected to ROSBridge WebSocket server.');
    });
  
    ros.on('error', function(error) {
      console.log('Error connecting to ROSBridge WebSocket server: ', error);
    });
  
    ros.on('close', function() {
      console.log('Connection to ROSBridge WebSocket server closed.');
    });

    return () => {
      ros.close();
    };
  }, [rosUrl, rosDomainId, setRos]);

  return (
    <>
    </>
  );
}
export default Rosconnection;

上記のプログラムですが、ROSとの接続でReactの副作用フックuseEffectを使用しています。

RosConnection.js
  useEffect(() => {
    const ros = new ROSLIB.Ros({
      url: rosUrl,
      options: {
        ros_domain_id: rosDomainId // ROS_DOMAIN_IDを設定する
      }
    });
    
    // 略 //
    
    return () => {
      ros.close();
    };
  }, [rosUrl, rosDomainId, setRos]);

useEffectは、Reactがコンポーネントの状態の変更に合わせて自動的に処理を行ってくれることです。
そのため、ROSブリッジサーバーへの接続が成功したとき、エラーが発生したとき、およびサーバーが閉じたときに、適切な処理が行われるようになります。

今回は、依存配列に指定された変数(下記)が変更された場合に再度実行されるので、ROSブリッジのサーバーと再接続等ができます。

  }, [rosUrl, rosDomainId, setRos]);

また、Reactを使用する場合は、roslib.jsを読み込むのではなく、npm installでインストールしたroslibjsをインポートして使用します。


  • App.js

RosConnection.jsが作成できたら、ここで定義したrosの情報を各コンポーネントに渡せるようにメインとなるApp.jsを先に記述してきます。

App.js
import './App.css';
import React, { useState } from 'react';
import CameraData from './components/CameraData';
import Rosconnection from './components/RosConnection';
import 'bootstrap/dist/css/bootstrap.min.css';
import { Row, Col } from 'react-bootstrap';

function App() {
  const [ros, setRos] = useState(null);
  return (
    <>
      {/* <Rosconnection rosUrl="ws://localhost:9090" rosDomainId="89"> */}
      <Rosconnection rosUrl="ws://192.168.11.3:9090" rosDomainId="89" setRos={setRos} />
      {ros &&
        <>
        <Row>
          <Col>
            <div className="d-flex justify-content-center align-items-center">
              <CameraData ros={ros} />
            </div>
          </Col>
        </Row>
        </>
      }

      <hr/>
      <h3>Connection: <span id="status">N/A</span></h3>
    </>
  );
}

export default App;

重複しますが、RosConnection.jsでrosの接続等の情報を取得しているので、このrosという変数をReactのステートフックuseStateで親のコンポーネントから子のコンポーネントへ値を渡します。

これをpropsを渡すとも言います。

App.js
 const [ros, setRos] = useState(null);

上記の記述をメインのApp.jsに記述しておくことで、後述するROSのコンポーネントや追加するROSコンポーネントに共通してROSの情報を渡すことができます。

rosという変数は

App.js
            <div className="d-flex justify-content-center align-items-center">
              <CameraData ros={ros} />
            </div>

という形で<コンポーネント名 変数名={props名} />という形で渡しています。

Cardのタグはreact-bootstrapの装飾なので、任意です。


  • CameraData.js

ROS接続用のコンポーネントおよびrosのpropsの受け渡し部分を作成したので、カメラのコンポーネントも作成します。

CameraData.js
import React, { useEffect, useState } from 'react';
import ROSLIB from 'roslib';
import Card from 'react-bootstrap/Card';

const CameraData = ({ros}) => {
  const [imgData, setImgData] = useState('');

  useEffect(() => {
    if (!ros) {
        return;
      }

    var image = new ROSLIB.Topic({
      ros : ros,
      name : '/color/image_raw/compressed',
      messageType : 'sensor_msgs/CompressedImage'
    });
  
    image.subscribe(function(message) {
      console.log('Received image');
      const data = "data:image/png;base64," + message.data;
    //   const imgData = setAttribute('src', data);
      setImgData(data);
    });
  
  }, [ros]);
  
  return (
    <>
        <Card className="mb-4" style={{ width: '48rem' }}>
        <Card.Body>
            <Card.Title>Camera Image</Card.Title>
            <Card.Subtitle className="mb-2 text-muted">subscribe image_raw</Card.Subtitle>
            <Card.Text>
              <img src={imgData} alt="Camera Data" />
            </Card.Text>
        </Card.Body>
        </Card>
    </>
  );
}

export default CameraData

コンポーネントでrosの情報:propsを受け取るには

CameraData.js
const CameraData = ({ros}) => {

という形で受け取ります。

また、ROSの情報をReactで扱うときは、値の受け渡しがしやすいようにuseStateで管理しています。

CameraData.js
const [imgData, setImgData] = useState('');

あとは、普通のHTML、JSで記述するときとほとんど変わりません。


これらを駆使してReactでアプリケーションを作って動かすと、下記のようなアプリができます。

https://twitter.com/tasada038/status/1657295119501119488


以上、rosbridge_suiteとReactでROS 2 Webアプリを作る方法を備忘録でまとめました。

ロボットアプリを作る方の参考になれば幸いです。

もし役に立った!という場合は「いいね」ボタンをいただけると励みになります。

ここまでご覧いただき、ありがとうございました。

Reference

--- rosbridge

Foxglove: Using Rosbridge with ROS 2

--- React app

ros-ui-react

cepheus_launcher

--- Camera

Ros-image-node-form-mobile-phones-using-Roslibjs

image-from-roslibjs

--- React library

React-Bootstrap

react-chart-js-2

react-three-fiber

react-grid-layout

react Material UI

Discussion