※ ROSの表記について :
ROS1/ROS2について、文脈的にどちらでも当てはまる場合は単に「ROS」と表記しています。
はじめに
本記事は全4回のシリーズ記事の第2回です。
第1回 基本部分の作成
第2回 画像データの受信と表示
第3回 点群データの受信と表示
Webアプリケーション側からのROS2情報送信
前回は「基本部分の作成」として、ROS2を起動するサーバ側システム (以降は単に「サーバ」と表記)、Next.js によるクライアント側Webアプリケーション (以降は単に「Webアプリケーション」と表記) それぞれの基本部分を作成し、サーバからWebアプリケーションへの文字列データ送信を行いました。
今回は、これら基本部分に「サーバからの画像データ送信機能」「Webアプリケーションで逐次受信して表示する機能」を追加し、受信した画像を表示させてみます。
画像データ送信の方針
前回の文字列同様、画像データの 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接続開始」ボタンでサーバ側と繋ぐと、送られてきた画像データが順次表示されることが確認できます。
ここまでで、画像の受信の処理を作成することができました。
まとめと次回
今回は第2回として、サーバ側での画像データの変換および送信、Webアプリケーション側での表示を行いました。
画像そのままを送付できないのが少し大変ですが、変換できることさえ分かれば送受信は難しくないですね。
あまり大きい画像の送受信は遅延が発生してしまいますが、今回の 640 × 480、4fps 程度なら特に遅延無くスムーズに表示できました。
次回は、ここまでの内容に点群の受信処理を組み合わせてみます。
参照
(★1) Webでカメラ表示::Raspberry Pi4 + Ubuntu22.04 + ROS2 Humble でロボット作り(その6)