Skip to content

Commit

Permalink
Update files used for launch
Browse files Browse the repository at this point in the history
  • Loading branch information
elvout committed Feb 19, 2024
1 parent 62f2e6d commit cdf58f3
Show file tree
Hide file tree
Showing 3 changed files with 130 additions and 42 deletions.
47 changes: 45 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,55 @@ project(yolov8_ros)
# Find CMake dependencies
find_package(ament_cmake REQUIRED)

# Install python dependencies
# Build Python virtual environment
add_custom_target(
poetry_virtualenv ALL
COMMAND poetry install --no-root --no-ansi --no-interaction
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Building ${CMAKE_PROJECT_NAME} virtualenv... may take some time"
COMMENT "Building ${CMAKE_PROJECT_NAME} virtualenv... this may take some time"
)

# Install launch files.
# https://docs.ros.org/en/humble/Tutorials/Intermediate/Launch/Launch-system.html
install(
DIRECTORY
launch
DESTINATION share/${PROJECT_NAME}/
)

# ROS 1 provided a mechanism in XML launch files to find the corresponding
# source directory of a package. ROS 2 removed this mechanism but instead allows
# users to reference the "install" path of a package within a workspace. Thus,
# the virtual environment in this source directory must be discoverable inside
# the workspace's install directory. We manually symlink the virtual environment
# directory for two reasons:
# 1) "colcon build" would otherwise copy gigabytes worth of libraries
# 2) "colcon build --symlink-install" creates a symlink for each file in the
# directory, rather than a single symlink for the directory itself.
#
# ROS 2 looks for executables in ${CMAKE_INSTALL_PREFIX}/lib.
file(
# "file" commands are executed at build time. The install directory won't
# exist yet during a fresh build, which will cause the next CREATE_LINK to
# fail.
MAKE_DIRECTORY
${CMAKE_INSTALL_PREFIX}
)
file(
CREATE_LINK
${CMAKE_SOURCE_DIR}/.venv
${CMAKE_INSTALL_PREFIX}/.venv
SYMBOLIC
)
install(
DIRECTORY
yolov8_ros
DESTINATION lib/${PROJECT_NAME}
)
install(
PROGRAMS
scripts/python_entrypoint.py
DESTINATION lib/${PROJECT_NAME}
)

ament_package()
15 changes: 7 additions & 8 deletions launch/right_wrist_node.launch
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,15 @@
<arg name="stretch_robot_rotate_image_90deg" default="true" />

<node
pkg="yolov8-ros"
pkg="yolov8_ros"
name="right_wrist_node"
launch-prefix="$(find yolov8-ros)/.venv/bin/python"
type="python_entrypoint.py"
launch-prefix="$(find-pkg-prefix yolov8_ros)/.venv/bin/python"
exec="python_entrypoint.py"
output="screen"
>
<param name="depth_topic" value="$(arg depth_topic)" />
<param name="rgb_topic" value="$(arg rgb_topic)" />
<param name="camera_info_topic" value="$(arg camera_info_topic)" />
<param name="stretch_robot_rotate_image_90deg" value="$(arg stretch_robot_rotate_image_90deg)" />
<param name="depth_topic" value="$(var depth_topic)" />
<param name="rgb_topic" value="$(var rgb_topic)" />
<param name="camera_info_topic" value="$(var camera_info_topic)" />
<param name="stretch_robot_rotate_image_90deg" value="$(var stretch_robot_rotate_image_90deg)" />
</node>

</launch>
110 changes: 78 additions & 32 deletions scripts/python_entrypoint.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,72 @@
#! /usr/bin/env python3

# Assuming the following project structure:
#
# project_dir
# ├── scripts
# │ ├── __file__
# │ └── ...
# ├── package1
# │ ├── __init__.py
# │ └── module_a.py
# ├── package2
# │ ├── __init__.py
# │ └── module_b.py
# └── ...
#
# The objectives of this script are as follows:
# 1. Create Python module objects for module_a and module_b dynamically,
# i.e., without knowing the names "package1", "package2", "module_a", or
# "module_b".
# 2. Read the name of a node spawned by an invocation of this script from a
# launch file and run the main() method of the respective module, again
# without knowing the name of the package that the (uniquely named) module
# belongs to.
"""
This script is intended to be executed from a ROS 2 XML launch file to run a
Python module as a node within a virtual environment. Virtual environments are
frequently used to avoid versioning conflicts of Python package dependencies,
particularly for learning-based projects.
Recall that Python modules are run using the -m flag, e.g.,
python3 -m pypackage.module
This is incompatible with ROS 2 XML launch files since the "exec" property of
the <node> tag expects a single executable file. This script functions as an
executable file for the "exec" property.
Importantly, this script must be executed inside the virtual environment.
Although this script uses /usr/bin/env in its shebang, PATH should not be relied
upon to provide the correct python executable since attempting to find and
source the proper ".venv/bin/activate" from an XML launch file would be
extremely messy. Instead, assuming that this script ("python_entrypoint.py") is
located in the following directory structure after building a ROS 2 workspace:
${CMAKE_INSTALL_PATH}/ (typically: ws/install/project_name)
├── .venv/
├── lib/
│ └── project_name/
│ ├── python_entrypoint.py
│ ├── pypackage1/
│ │ ├── __init__.py
│ │ └── module_a.py
│ └── pypackage2/
│ ├── __init__.py
│ └── module_b.py
└── share/
└── project_name/
└── launch/
└── launch_file.launch
this script can be executed from a launch file using the "launch-prefix"
property of the <node> tag. In the following example, a node is created by
running "module_a" of Python package "pypackage1" within the ROS 2 package
"project_name".
<node
pkg="project_name"
name="module_a"
launch-prefix="$(find-pkg-prefix project_name)/.venv/bin/python"
exec="python_entrypoint.py"
...
>
The directory structure is not arbitrary: ROS 2 expects launch files to be
located in "${CMAKE_INSTALL_PATH}/share/" and executables to be located in
"${CMAKE_INSTALL_PATH}/lib/". In the ROS 2 XML launch file specification,
$(find-pkg-prefix project_name) expands to ${CMAKE_INSTALL_PATH}.
The objectives of this script are as follows:
1. Create Python module objects for module_a and module_b dynamically,
i.e., without knowing the names "pypackage1", "pypackage2", "module_a", or
"module_b".
2. Read the name of a node from the command invoked by a launch file and run the
main() function of the corresponding Python module, again without knowing the
name of the package that the module belongs to.
Assumptions and Limitations:
1. All Python packages and modules must be uniquely named.
2. Python modules functioning as nodes need to have a main() function.
3. This script needs to run each module to determine whether the
module contains a main() function.
"""

import argparse
import importlib.util
Expand All @@ -32,27 +77,28 @@


def find_valid_modules() -> dict[str, ModuleType]:
# Valid modules are modules with a main() function.
# Valid modules are defined as modules with a main() function.
valid_modules: dict[str, ModuleType] = {}

project_dir = Path(__file__).parent.parent
project_dir = Path(__file__).parent

project_packages_info = [
project_packages_info: list[pkgutil.ModuleInfo] = [
module
for module in pkgutil.iter_modules(path=[str(project_dir)])
if module.ispkg
]

# Find valid modules within this project's packages.
for package_info in project_packages_info:
# Import the package since submodules may reference the package by
# themselves importing from it. This process is somewhat convoluted and
# Importing the package is necessary since submodules may reference the
# package by importing from it, which requires the package to be loaded
# and present in sys.modules. This process is somewhat convoluted and
# filled with landmines:
# https://docs.python.org/3.10/library/importlib.html#checking-if-a-module-can-be-imported
package_spec = package_info.module_finder.find_spec(package_info.name) # type: ignore
if package_spec is None or package_spec.loader is None:
continue
package = importlib.util.module_from_spec(package_spec)
package: ModuleType = importlib.util.module_from_spec(package_spec)
sys.modules[package_info.name] = package
package_spec.loader.exec_module(package)

Expand All @@ -79,18 +125,18 @@ def find_valid_modules() -> dict[str, ModuleType]:
if __name__ == "__main__":
valid_modules = find_valid_modules()

# Read the module name from command-line arguments. roslaunch appends the
# command-line argument "__name:=name_property_in_launch_file"
# Read the module name from command-line arguments. The ROS 2 launch system
# appends the command-line argument "__node:=name_of_node".
argparser = argparse.ArgumentParser(prefix_chars="_")
argparser.add_argument(
"__name:",
"__node:",
type=str,
choices=valid_modules,
required=True,
help="Module to run",
)

namespace, _ = argparser.parse_known_args()
module_name: str = namespace.__getattribute__("name:")
module_name: str = namespace.__getattribute__("node:")

valid_modules[module_name].main()

0 comments on commit cdf58f3

Please sign in to comment.