WebアプリケーションによるROS2データの可視化2 画像データの受信と表示

WebアプリケーションによるROS2データの可視化2 画像データの受信と表示

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

はじめに


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

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


前回は「基本部分の作成」として、ROS2を起動するサーバ側システム (以降は単に「サーバ」と表記)、Next.js によるクライアント側Webアプリケーション (以降は単に「Webアプリケーション」と表記) それぞれの基本部分を作成し、サーバからWebアプリケーションへの文字列データ送信を行いました。

今回は、これら基本部分に「サーバからの画像データ送信機能」「Webアプリケーションで逐次受信して表示する機能」を追加し、受信した画像を表示させてみます。

ROS2 Data visualization sample - ROS接続状態 ... ON

画像データ送信の方針

前回の文字列同様、画像データの sensor_msg/Image をそのまま受けてバイナリで表示としたいのですが、実はそのままでは表示できません。

この話題でWeb検索をしてみると、StackOverflowをはじめ、多数の人が挑戦している様子。

結局のところ「sensor_msg/Image をそのまま受信した後に (ブラウザ側でbase64変換する等して) 表示」というのは難しいようです。
参照 (★1) を始めとしていくつかのサイトで紹介されている「一旦画像を文字列に変換してから送信する」方法を試してみます。

実装

サーバ側

サーバ側では、まず画像データ sensor_msg/Image 送信のノードを作ります。
ROS2 のトピックに乗せるには幅・高さ情報を登録してバイナリにする必要があります。
カメラを用意するのが大変なので、今回は事前に撮影した連続画像を指定ディレクトリから読み込んで順次送付する方式を取ります。

( サーバ側ディレクトリ )
$ cd visualize_sample_server

( エディタを開いて編集 )
$ vim scripts/image_sender_node.py
ソース詳細
import glob
import numpy as np
from PIL import Image

import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from std_msgs.msg import Header
from sensor_msgs.msg import Image as  SensorImage

### Node name
NODE_NAME = 'image_sender'

### Topic name
TOPIC_NAME = '/image'

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

### Image size
# NOTE: 指定サイズにリサイズ
IMAGE_WIDTH  = 640
IMAGE_HEIGHT = 480

### 指定ディレクトリを読み込み → 中の画像を順次表示
IMAGE_DIR = './images/'


### Image sender node
class ImageSenderNode(Node) :

    def __init__(self) :
        super().__init__(NODE_NAME)

        # Initialize publisher
        self.publisher = self.create_publisher(SensorImage, TOPIC_NAME, 10)

        # Load images path
        self.images = glob.glob(f'{IMAGE_DIR}/*.jpg')
        self.images.extend(glob.glob(f'{IMAGE_DIR}/*.png'))
        self.images = sorted(self.images)
        self.idx = 0
        self.images_max = len(self.images)

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

    def  on_timer(self) :

        # Open image
        img = Image.open(self.images[self.idx])
        img = img.resize((IMAGE_WIDTH, IMAGE_HEIGHT))

        # Publish
        header = Header(frame_id = 'map')
        header.stamp = self.get_clock().now().to_msg()
        msg = SensorImage()
        msg.header = header
        msg.height = img.height
        msg.width = img.width
        msg.encoding = 'rgb8'
        msg.is_bigendian = False
        msg.step = 3 * img.width
        msg.data = np.array(img).tobytes()
        self.publisher.publish(msg)        

        # Increment
        self.idx += 1
        if self.idx >= self.images_max : self.idx = 0

def main(args = None):

    # Initialize rclpy
    print("Start image sender node.")
    rclpy.init(args = args)

    # Create node
    node = ImageSenderNode()

    # Spin
    try :
        rclpy.spin(node)
    except KeyboardInterrupt :  # Ctrl + C
        pass

    print("Shutdown image sender node.")
    rclpy.shutdown()

if __name__ == '__main__':
    main()

次に、画像の変換ノードを作成します。
先ほど送ることにした sensor_msgs/Image のデータを読み込み、std_msgs/String のデータに変換して送り直します。
なお、参考にした実装では様々なデータタイプに対応できるようになっていましたが、ここでは事前に形式が分かっているため、タイプ別の分岐を入れないシンプルな方式をとっています。

( エディタを開いて編集 )
$ vim scripts/image_converter_node.py 
ソース詳細
import base64
import numpy as np
import simplejpeg

import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from std_msgs.msg import String
from sensor_msgs.msg import Image as SensorImage

### Node name
NODE_NAME = 'image_converter'

### Subscription image topic name
SUB_TOPIC_NAME = '/image'

### Publish image string topic name
PUB_TOPIC_NAME = '/image/converted'

### Quality of JPEG conversion
QUALITY = 50


### Image converter node
class ImageConverterNode(Node) :

    def __init__(self) :
        super().__init__(NODE_NAME)

        # Initialize publisher
        self.publisher = self.create_publisher(String, PUB_TOPIC_NAME, 10)

        # Initialize subscription
        self.subscription = self.create_subscription(
            SensorImage,
            SUB_TOPIC_NAME,
            self.image_callback,
            qos_profile_sensor_data,
        )

    def image_callback(self, msg) :
        """
        受信したImage msgをStringに変換して再送付。
        NOTE: 受信Image型は1種類 (rgb8) のみ対応している。
        """

        ### Parse message
        height = msg.height
        width  = msg.width
        encoding = msg.encoding

        ### Support encoding check
        if msg.encoding != 'rgb8' :
            print(f"Encoding of received images ¥"{encoding}¥" is not supported. Only ¥"rgb8¥" is supported")
            return

                ### 画像変換
        img = np.frombuffer(msg.data, np.uint8).reshape((height, width, 3))

        ### Jpeg 変換 (use simplejpeg)
        jpeg_img = simplejpeg.encode_jpeg(img, colorspace = 'RGB', quality = QUALITY)

        ### Publish
        pub_msg = String()
        pub_msg.data = base64.b64encode(jpeg_img).decode()
        self.publisher.publish(pub_msg)


def main(args = None):

    # Initialize rclpy
    print("Start image converter node.")
    rclpy.init(args = args)

    # Create node
    node = ImageConverterNode()

    # Spin
    try :
        rclpy.spin(node)
    except KeyboardInterrupt :  # Ctrl + C
        pass

    print("Shutdown image converter node.")
    rclpy.shutdown()


if __name__ == '__main__':
    main()

必要なノードの作成が完了したので、後は setup.py と launch ファイルの呼び出しの設定を行います。

( setup.py の編集 )
$ vim setup.py
ソース詳細
    entry_points={
        'console_scripts': [
            'sample_sender_node = scripts.sample_sender_node:main',
            'image_sender_node = scripts.image_sender_node:main',  // <-- 追加
            'image_converter_node = scripts.image_converter_node:main',  // <-- 追加
        ],
    },
( launch ファイルの編集 )
$ 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)

    # Image sender node  # <-- 追加
    node_image_sender = Node(
        package = 'visualize_sample_server',
        executable = 'image_sender_node',
        name = 'image_sender',
    )
    ld.add_action(node_image_sender)

    # Image converter node  # <-- 追加
    node_image_converter = Node(
        package = 'visualize_sample_server',
        executable = 'image_converter_node',
        name = 'image_converter',
    )
    ld.add_action(node_image_converter)

    # 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

Webアプリケーション側

Webアプリケーション側では、文字列化 = base64化された画像を読み込んで表示する ImageStringViewer を作成します。
上位モジュールからbase64の画像データが来たら、今表示されているものと置き換えるだけの単純なものです。

( Webアプリケーション側ディレクトリ )
$ cd visualize_sample_client

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

//////////////////////////////////////////////////
/// Image string viewer                        ///
//////////////////////////////////////////////////

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

    // 上位モジュールから呼び出される処理の定義
    useImperativeHandle(ref, () => ({

        // 画像データの更新を行う
        update: (data:string) => {
            setBlob('data:image/jpeg;base64,' + data);
        },
    }));

    // 受信画像 (blob)
    const [imageSrc, setReceivedImage] = useState<string>('');

    // 以前に読み込まれた画像データ
    // NOTE: revokeでメモリ解放を行うために利用
    let prevSrc = '';

    // (上位モジュールから渡ってきた) blobデータを設定する
    const setBlob = async (dataUri: string) => {

        // Data URL から新たなblobを設定
        const blob = await (await fetch(dataUri)).blob();
        let newSrc = window.URL.createObjectURL(blob);
        setReceivedImage(newSrc);

        // 以前のデータを解放する
        if (prevSrc !== '') window.URL.revokeObjectURL(prevSrc);
        prevSrc = newSrc;
    };


    // 表示 ... Base64形式で受信した画像の表示を行う
    // NOTE: 幅設定は上位モジュールより行う。
    return (
        <>
            <img src={imageSrc} width={props.width} />
        </>
    );
});

// Export settings
ImageStringViewer.displayName = 'ImageStringViewer';
export default ImageStringViewer;

後はRosOperatorの方で /image/converted トピックを購読できるようにし、Viewer側のつなぎ込みを行います。

( RosOperator 編集 )
$ vim libs/ros_operation.tsx
ソース詳細
import React, { forwardRef, useImperativeHandle, useEffect, useRef, useState } from 'react';
import * as ROSLIB from 'roslib';

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

// ROS接続URL
// TODO: 実際使用するURLへの更新を行う。
const ROS_CONNECTION_URL = 'ws://127.0.0.1:8888'

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

// Image (string) topic name
const TOPIC_NAME_IMAGE = '/image/converted';


///////////////////////////////////////////////////
/// 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);

    // Image topic listener
    // NOTE: 画像データはStringとして受信する
    const [imageListener, setImageListener] = 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',
            })
        );

        // Image (string) topic listener
        setImageListener(
            new ROSLIB.Topic({
                ros: ros,
                name: TOPIC_NAME_IMAGE,
                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]);

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

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

        // Image topic msg subscription event
        imageListener.subscribe((msg: ROSLIB.Message) => {
            // 上位モジュールへ通知
            props.updateImage?.((msg as String).data);
        });

    }, [imageListener]);

    // 空要素
    return (
        <></>
    );
});
RosOperator.displayName = 'RosOperator';
export default RosOperator;
( 主となるViewer部の編集 )
$ 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';

// Components
import  ImageStringViewer from './components/image_string_viewer';

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

const Viewer = () => {

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

    // Image string viewer ref
    const imageStringViewerRef = useRef<any>(null!);

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

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

    // Command string
    const [commandStr, setCommandStr] = useState<string>('');

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

    // ROS側の画像データ更新
    // NOTE: ROS OperatorよりImage string viewerに伝達
    const updateImage = (data:string) => {
        imageStringViewerRef.current.update(data);
    };

    // ROS側へコマンド送付を行う
    const sendToServer = () => {
        if (commandStr !== '') {
            rosOpRef.current.sendTextCommand(commandStr);
        }
    };


    // 表示
    return (
        <>
            {/* ROS Operator */}
            <RosOperator ref={rosOpRef}
                setRosConnected={setRosConnected}
                updateImage={updateImage}
                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>

                {/* Viewer ... 表示部 */}
                <Row>
                    {/* Image viewer */}
                    <Col>
                        <ImageStringViewer ref={imageStringViewerRef} width={600} />
                    </Col>
                </Row>

                {/* Logger ... ログ表示等 */}
                <Row>
                    <Col>
                        <h2>ログ表示</h2>
                        <div>{testStr}</div>
                    </Col>
                </Row>

            </Container>
        </>
    );
};

export default Viewer;

実行

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

サーバ側:

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

Webアプリケーション側 (npmデバッグ実行) :

$ PORT=8000 npm run dev

ブラウザでWebアプリケーションにアクセス ( ここでは http://127.0.0.1:8000/viewer ) し、
「ROS接続開始」ボタンでサーバ側と繋ぐと、送られてきた画像データが順次表示されることが確認できます。

ROS2 Data visualization sample - ROS接続状態 ... ON

ここまでで、画像の受信の処理を作成することができました。

まとめと次回

今回は第2回として、サーバ側での画像データの変換および送信、Webアプリケーション側での表示を行いました。

画像そのままを送付できないのが少し大変ですが、変換できることさえ分かれば送受信は難しくないですね。
あまり大きい画像の送受信は遅延が発生してしまいますが、今回の 640 × 480、4fps 程度なら特に遅延無くスムーズに表示できました。

次回は、ここまでの内容に点群の受信処理を組み合わせてみます。


参照

(★1) Webでカメラ表示::Raspberry Pi4 + Ubuntu22.04 + ROS2 Humble でロボット作り(その6)