ROS2 Pythonパッケージ開発のベストプラクティス【colcon・launch・パラメーター】


ROS2のPythonパッケージを初めて作るとき、setup.py の書き方・package.xml の依存関係・launch ファイルの書き方など、覚えることが多くて混乱します。

実際の開発でよく使うパターンをまとめました。


パッケージ作成

cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python my_robot \
  --dependencies rclpy std_msgs geometry_msgs nav_msgs sensor_msgs

ディレクトリ構成

my_robot/
├── my_robot/
│   ├── __init__.py
│   ├── robot_node.py        # メインノード
│   ├── llm_controller.py    # LLM制御クラス
│   └── utils/
│       ├── __init__.py
│       └── geometry.py      # 座標計算などのユーティリティ
├── launch/
│   ├── robot.launch.py      # 本番起動用
│   └── sim.launch.py        # シミュレーター起動用
├── config/
│   ├── robot_params.yaml    # ロボットパラメーター
│   └── nav2_params.yaml     # ナビゲーションパラメーター
├── test/
│   └── test_geometry.py
├── package.xml
├── setup.py
└── setup.cfg

setup.py

data_files の書き方を間違えると launch ファイルや config が見つからないエラーになります。

from setuptools import setup
import os
from glob import glob

package_name = 'my_robot'

setup(
    name=package_name,
    version='0.1.0',
    packages=[package_name, f'{package_name}.utils'],  # サブパッケージも忘れずに
    data_files=[
        # ament インデックスへの登録(必須)
        ('share/ament_index/resource_index/packages',
            ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        # launch ファイル
        (os.path.join('share', package_name, 'launch'),
            glob('launch/*.py')),
        # config ファイル
        (os.path.join('share', package_name, 'config'),
            glob('config/*.yaml')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    entry_points={
        'console_scripts': [
            # 'コマンド名 = パッケージ.モジュール:関数名'
            'robot_node = my_robot.robot_node:main',
            'llm_controller = my_robot.llm_controller:main',
        ],
    },
)

package.xml

<?xml version="1.0"?>
<package format="3">
  <name>my_robot</name>
  <version>0.1.0</version>
  <description>My robot package</description>
  <maintainer email="your@email.com">your name</maintainer>
  <license>MIT</license>

  <depend>rclpy</depend>
  <depend>std_msgs</depend>
  <depend>geometry_msgs</depend>
  <depend>nav_msgs</depend>
  <depend>sensor_msgs</depend>
  <depend>tf2_ros</depend>
  <depend>tf2_geometry_msgs</depend>

  <test_depend>ament_copyright</test_depend>
  <test_depend>ament_flake8</test_depend>
  <test_depend>ament_pep257</test_depend>
  <test_depend>pytest</test_depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

ノードの書き方(推奨パターン)

import rclpy
from rclpy.node import Node
from rclpy.parameter import Parameter
from geometry_msgs.msg import Twist
from sensor_msgs.msg import LaserScan

class RobotNode(Node):
    def __init__(self):
        super().__init__('robot_node')

        # パラメーターを宣言(デフォルト値付き)
        self.declare_parameter('max_speed', 0.3)
        self.declare_parameter('safety_radius', 0.5)
        self.declare_parameter('use_sim_time', False)

        # パラメーターを取得
        self.max_speed = self.get_parameter('max_speed').value
        self.safety_radius = self.get_parameter('safety_radius').value

        self.get_logger().info(
            f'起動: max_speed={self.max_speed}, safety_radius={self.safety_radius}'
        )

        # パブリッシャー・サブスクライバー
        self.cmd_pub = self.create_publisher(Twist, 'cmd_vel', 10)
        self.scan_sub = self.create_subscription(
            LaserScan, 'scan', self.scan_callback, 10
        )

        # タイマー(10Hz)
        self.timer = self.create_timer(0.1, self.control_loop)

    def scan_callback(self, msg: LaserScan):
        # LaserScanを処理
        ranges = [r for r in msg.ranges if not (r == float('inf') or r != r)]
        if ranges:
            self.min_distance = min(ranges)

    def control_loop(self):
        cmd = Twist()
        if hasattr(self, 'min_distance') and self.min_distance < self.safety_radius:
            # 障害物が近い → 停止
            self.get_logger().warn(f'障害物検出: {self.min_distance:.2f}m')
        else:
            cmd.linear.x = self.max_speed
        self.cmd_pub.publish(cmd)


def main(args=None):
    rclpy.init(args=args)
    node = RobotNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

launch ファイル

from launch import LaunchDescription
from launch.actions import (
    DeclareLaunchArgument,
    IncludeLaunchDescription,
    LogInfo
)
from launch.conditions import IfCondition
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare

def generate_launch_description():
    # 引数の宣言
    use_sim_time_arg = DeclareLaunchArgument(
        'use_sim_time',
        default_value='false',
        description='シミュレーター時刻を使うか'
    )
    use_rviz_arg = DeclareLaunchArgument(
        'use_rviz',
        default_value='true',
        description='RViz2を起動するか'
    )

    use_sim_time = LaunchConfiguration('use_sim_time')
    use_rviz = LaunchConfiguration('use_rviz')

    # configファイルのパスを解決
    config_file = PathJoinSubstitution([
        FindPackageShare('my_robot'),
        'config',
        'robot_params.yaml'
    ])

    # メインノード
    robot_node = Node(
        package='my_robot',
        executable='robot_node',
        name='robot_node',
        output='screen',
        parameters=[
            config_file,
            {'use_sim_time': use_sim_time}
        ],
        remappings=[
            ('cmd_vel', '/cmd_vel'),
            ('scan', '/scan'),
        ]
    )

    # RViz2(条件付き起動)
    rviz_node = Node(
        package='rviz2',
        executable='rviz2',
        condition=IfCondition(use_rviz),
        output='screen'
    )

    return LaunchDescription([
        use_sim_time_arg,
        use_rviz_arg,
        LogInfo(msg='ロボット起動'),
        robot_node,
        rviz_node,
    ])
# 起動
ros2 launch my_robot robot.launch.py

# 引数を渡す
ros2 launch my_robot robot.launch.py use_sim_time:=true use_rviz:=false

yaml パラメーターファイル

# config/robot_params.yaml
robot_node:
  ros__parameters:
    max_speed: 0.3          # m/s
    safety_radius: 0.5      # m
    use_sim_time: false
    llm_model: "claude-haiku-4-5-20251001"
    llm_timeout: 5.0        # 秒

注意: ros__parameters__ はアンダースコア2つです。1つだと読み込まれません(よく間違える)。


ビルドTips

cd ~/ros2_ws

# 全パッケージをビルド
colcon build --symlink-install

# 特定パッケージだけ
colcon build --symlink-install --packages-select my_robot

# ビルド後は必ずsource
source install/setup.bash

# パッケージが見つからないとき
colcon build --symlink-install && source install/setup.bash
ros2 pkg list | grep my_robot

--symlink-install をつけるとPythonファイルへのシンボリックリンクが作られ、ファイルを編集するたびに再ビルドしなくて済みます(C++ノードはビルドが必要)。