Pārlūkot izejas kodu

Merge pull request #1083 from zauberzeug/ros2_example

Provide ROS2 example with joystick input and 3d visualization
Rodja Trappe 1 gadu atpakaļ
vecāks
revīzija
9e547bf4e7

+ 27 - 0
examples/ros2/Dockerfile

@@ -0,0 +1,27 @@
+FROM ros:humble-ros-base
+
+RUN apt-get update && apt-get install -y \
+    python3-pip \
+    # python3-dev \
+    # build-essential \
+   && rm -rf /var/lib/apt/lists/*
+
+SHELL ["/bin/bash", "-c"]
+
+RUN pip3 install nicegui
+
+ADD ros2_ws /ros2_ws
+
+WORKDIR /ros2_ws
+
+RUN source /opt/ros/humble/setup.bash && \
+    colcon build --symlink-install
+
+COPY ros_entrypoint.sh /
+
+EXPOSE 8080
+
+ENTRYPOINT ["/ros_entrypoint.sh"]
+
+CMD ros2 launch gui main_launch.py
+

+ 20 - 0
examples/ros2/README.md

@@ -0,0 +1,20 @@
+# ROS2 Example
+
+This example is a basic ROS2 implementation with NiceGUI.
+It starts up a user interface with virtual joystick and pose visualization reachable through http://127.0.0.1:8080.
+The joystick publishes twist messages which are consumed by a simple simulator node which itself publishes the new pose of a virtual mobile robot.
+ROS2 and NiceGUI are fully functional in this example.
+
+Over all it is a bit more complex than a super minimal example to allow auto-reloading of the NiceGUI ROS2 node.
+
+## Usage
+
+Change into the `examples/ros2` folder and run:
+
+```bash
+docker compose up --build
+```
+
+Afterwards you can access the user interface at http://127.0.0.1:8080.
+
+If you want to run the NiceGUI node locally or in your own own ROS2 project, you can simply copy the code from the `ros2_ws/src/gui` folder.

+ 9 - 0
examples/ros2/docker-compose.yml

@@ -0,0 +1,9 @@
+version: "3"
+services:
+  nicegui:
+    build:
+      context: .
+    ports:
+      - 8080:8080
+    volumes:
+      - ./ros2_ws/src:/ros2_ws/src

+ 0 - 0
examples/ros2/ros2_ws/src/gui/gui/__init__.py


+ 74 - 0
examples/ros2/ros2_ws/src/gui/gui/node.py

@@ -0,0 +1,74 @@
+import math
+import threading
+from pathlib import Path
+
+import rclpy
+from geometry_msgs.msg import Pose, Twist
+from rclpy.executors import ExternalShutdownException
+from rclpy.node import Node
+
+from nicegui import app, globals, run, ui
+
+
+class NiceGuiNode(Node):
+
+    def __init__(self) -> None:
+        super().__init__('nicegui')
+        self.cmd_vel_publisher = self.create_publisher(Twist, 'cmd_vel', 1)
+        self.subscription = self.create_subscription(Pose, 'pose', self.handle_pose, 1)
+
+        with globals.index_client:
+            with ui.row().classes('items-stretch'):
+                with ui.card().classes('w-44 text-center items-center'):
+                    ui.label('Control').classes('text-2xl')
+                    ui.joystick(color='blue', size=50,
+                                on_move=lambda e: self.send_speed(float(e.y), float(e.x)),
+                                on_end=lambda _: self.send_speed(0.0, 0.0))
+                    ui.label('Publish steering commands by dragging your mouse around in the blue field').classes('mt-6')
+                with ui.card().classes('w-44 text-center items-center'):
+                    ui.label('Data').classes('text-2xl')
+                    ui.label('linear velocity').classes('text-xs mb-[-1.8em]')
+                    slider_props = 'readonly selection-color=transparent'
+                    self.linear = ui.slider(min=-1, max=1, step=0.05, value=0).props(slider_props)
+                    ui.label('angular velocity').classes('text-xs mb-[-1.8em]')
+                    self.angular = ui.slider(min=-1, max=1, step=0.05, value=0).props(slider_props)
+                    ui.label('position').classes('text-xs mb-[-1.4em]')
+                    self.position = ui.label('---')
+                with ui.card().classes('w-96 h-96 items-center'):
+                    ui.label('Visualization').classes('text-2xl')
+                    with ui.scene(350, 300) as scene:
+                        with scene.group() as self.robot_3d:
+                            prism = [[-0.5, -0.5], [0.5, -0.5], [0.75, 0], [0.5, 0.5], [-0.5, 0.5]]
+                            self.robot_object = scene.extrusion(prism, 0.4).material('#4488ff', 0.5)
+
+    def send_speed(self, x: float, y: float) -> None:
+        msg = Twist()
+        msg.linear.x = x
+        msg.angular.z = -y
+        self.linear.value = x
+        self.angular.value = y
+        self.cmd_vel_publisher.publish(msg)
+
+    def handle_pose(self, msg: Pose) -> None:
+        self.position.text = f'x: {msg.position.x:.2f}, y: {msg.position.y:.2f}'
+        self.robot_3d.move(msg.position.x, msg.position.y)
+        self.robot_3d.rotate(0, 0, 2 * math.atan2(msg.orientation.z, msg.orientation.w))
+
+
+def main() -> None:
+    # NOTE: This function is defined as the ROS entry point in setup.py, but it's empty to enable NiceGUI auto-reloading
+    pass
+
+
+def ros_main() -> None:
+    rclpy.init()
+    node = NiceGuiNode()
+    try:
+        rclpy.spin(node)
+    except ExternalShutdownException:
+        pass
+    
+
+app.on_startup(lambda: threading.Thread(target=ros_main).start())
+run.APP_IMPORT_STRING = f'{__name__}:app'  # ROS2 uses a non-standard module name, so we need to specify it here
+ui.run(uvicorn_reload_dirs=str(Path(__file__).parent.resolve()), favicon='🤖')

+ 19 - 0
examples/ros2/ros2_ws/src/gui/launch/main_launch.py

@@ -0,0 +1,19 @@
+from launch import LaunchDescription
+from launch_ros.actions import Node
+
+
+def generate_launch_description():
+    return LaunchDescription([
+        Node(
+            package='gui',
+            executable='nicegui_node',
+            name='example_gui',
+            output='screen',
+        ),
+        Node(
+            package='simulator',
+            executable='simulator_node',
+            name='example_simulator',
+            output='screen',
+        ),
+    ])

+ 16 - 0
examples/ros2/ros2_ws/src/gui/package.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="3">
+  <name>gui</name>
+  <version>1.0.0</version>
+  <description>This is an example of NiceGUI in a ROS2 node that uses a joystick to send geometry_msgs/Twist messages.</description>
+  <maintainer email="nicegui@zauberzeug.com">Zauberzeug GmbH</maintainer>
+  <license>MIT License</license>
+  
+  <depend>rclpy</depend>
+  <depend>geometry_msgs</depend>
+
+  <export>
+    <build_type>ament_python</build_type>
+  </export>
+</package>

+ 4 - 0
examples/ros2/ros2_ws/src/gui/setup.cfg

@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/gui
+[install]
+install_scripts=$base/lib/gui

+ 27 - 0
examples/ros2/ros2_ws/src/gui/setup.py

@@ -0,0 +1,27 @@
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from setuptools import setup
+
+package_xml = ET.parse('package.xml').getroot()
+package_name = package_xml.find('name').text
+data = Path('share') / package_name
+setup(
+    name=package_name,
+    version=package_xml.find('version').text,
+    packages=[package_name],
+    maintainer=package_xml.find('license').text,
+    maintainer_email=package_xml.find('maintainer').attrib['email'],
+    license=package_xml.find('license').text,
+    data_files=[
+        (str(data), ['package.xml']),
+        (str(data / 'launch'), ['launch/main_launch.py']),
+    ],
+    install_requires=['setuptools'],
+    zip_safe=True,
+    entry_points={
+        'console_scripts': [
+            'nicegui_node = gui.node:main',
+        ],
+    },
+)

+ 16 - 0
examples/ros2/ros2_ws/src/simulator/package.xml

@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="3">
+  <name>simulator</name>
+  <version>1.0.0</version>
+  <description>A very simple simulator which receives geometry_msgs/Twist messages and publishes geometry_msgs/Pose messages on the "pose" topic.</description>
+  <maintainer email="nicegui@zauberzeug.com">Zauberzeug GmbH</maintainer>
+  <license>MIT License</license>
+  
+  <depend>rclpy</depend>
+  <depend>geometry_msgs</depend>
+
+  <export>
+    <build_type>ament_python</build_type>
+  </export>
+</package>

+ 4 - 0
examples/ros2/ros2_ws/src/simulator/setup.cfg

@@ -0,0 +1,4 @@
+[develop]
+script_dir=$base/lib/simulator
+[install]
+install_scripts=$base/lib/simulator

+ 25 - 0
examples/ros2/ros2_ws/src/simulator/setup.py

@@ -0,0 +1,25 @@
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from setuptools import setup
+
+package_xml = ET.parse('package.xml').getroot()
+package_name = package_xml.find('name').text
+setup(
+    name=package_name,
+    version=package_xml.find('version').text,
+    packages=[package_name],
+    maintainer=package_xml.find('license').text,
+    maintainer_email=package_xml.find('maintainer').attrib['email'],
+    license=package_xml.find('license').text,
+    data_files=[
+        (str(Path('share') / package_name), ['package.xml']),
+    ],
+    install_requires=['setuptools'],
+    zip_safe=True,
+    entry_points={
+        'console_scripts': [
+            'simulator_node = simulator.node:main',
+        ],
+    },
+)

+ 0 - 0
examples/ros2/ros2_ws/src/simulator/simulator/__init__.py


+ 43 - 0
examples/ros2/ros2_ws/src/simulator/simulator/node.py

@@ -0,0 +1,43 @@
+import math
+
+import rclpy
+from geometry_msgs.msg import Pose, Twist
+from rclpy.node import Node
+
+
+class Simulator(Node):
+    INTERVAL = 0.1
+
+    def __init__(self) -> None:
+        super().__init__('simulator')
+        self.pose_publisher_ = self.create_publisher(Pose, 'pose', 1)
+        self.subscription = self.create_subscription(Twist, 'cmd_vel', self.handle_velocity_command, 1)
+        self.pose = Pose()
+        self.linear_velocity = 0.0
+        self.angular_velocity = 0.0
+        self.timer = self.create_timer(self.INTERVAL, self.update_pose)
+
+    def handle_velocity_command(self, msg: Twist) -> None:
+        self.linear_velocity = msg.linear.x
+        self.angular_velocity = msg.angular.z
+
+    def update_pose(self) -> None:
+        yaw = 2 * math.atan2(self.pose.orientation.z, self.pose.orientation.w)
+        self.pose.position.x += self.linear_velocity * math.cos(yaw) * self.INTERVAL
+        self.pose.position.y += self.linear_velocity * math.sin(yaw) * self.INTERVAL
+        yaw += self.angular_velocity * self.INTERVAL
+        self.pose.orientation.z = math.sin(yaw / 2)
+        self.pose.orientation.w = math.cos(yaw / 2)
+        self.pose_publisher_.publish(self.pose)
+
+
+def main(args=None) -> None:
+    rclpy.init(args=args)
+    simulator = Simulator()
+    rclpy.spin(simulator)
+    simulator.destroy_node()
+    rclpy.shutdown()
+
+
+if __name__ == '__main__':
+    main()

+ 7 - 0
examples/ros2/ros_entrypoint.sh

@@ -0,0 +1,7 @@
+#!/bin/bash
+set -e
+
+source /opt/ros/humble/setup.bash
+source install/setup.bash
+
+exec "$@"

+ 1 - 0
main.py

@@ -284,6 +284,7 @@ async def index_page(client: Client) -> None:
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('SQLite Database', 'CRUD operations on a SQLite database')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Pandas DataFrame', 'displays an editable [pandas](https://pandas.pydata.org) DataFrame')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
             example_link('Lightbox', 'A thumbnail gallery where each image can be clicked to enlarge')
+            example_link('ROS2', 'Using NiceGUI as web interface for a ROS2 robot')
 
 
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
     with ui.row().classes('bg-primary w-full min-h-screen mt-16'):
         link_target('why')
         link_target('why')