feat: add NAV2 perception3D module

This commit is contained in:
2026-07-29 15:10:12 +09:00
commit 92f28f37f9
21 changed files with 701 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
build/
install/
log/
.colcon/
*.log
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
MODULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNTIME_ROOT="${AUTONOMY_STUDIO_RUNTIME_ROOT:-$HOME/autonomy-studio/runtime/nav2}"
exec bash "$RUNTIME_ROOT/docker/docker_build_nav2.sh" "$(dirname "$MODULE_PATH")" "$(basename "$MODULE_PATH")" "${NAV2_DOCKER_IMAGE:-future-nav2-gazebo:humble}" "${ROS_DOMAIN_ID:-0}"
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
MODULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
RUNTIME_ROOT="${AUTONOMY_STUDIO_RUNTIME_ROOT:-$HOME/autonomy-studio/runtime/nav2}"
exec bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" \
"lms-nav2-perception3d" \
"$(dirname "$MODULE_PATH")" \
"$(basename "$MODULE_PATH")" \
"${NAV2_DOCKER_IMAGE:-future-nav2:humble}" \
"${ROS_DOMAIN_ID:-0}" \
sim_mode:=false robot:=mkmini
+20
View File
@@ -0,0 +1,20 @@
odometry:
ros__parameters:
odom_topic: "/vehicle_odom"
odom_frame: "odom"
base_frame: "base_link"
pcd_crop:
ros__parameters:
input_topic: "/raw/lidar"
output_topic: "/lidar_filtered"
min_z: -5.0
max_z: 0.5
# min_z: -10.0
# max_z: 10.0
min_radius: 1.0
max_radius: 50.0
# min_angle: -1.570796
# max_angle: 2.356194
+16
View File
@@ -0,0 +1,16 @@
odometry:
ros__parameters:
odom_topic: "/vehicle_odom"
odom_frame: "odom"
base_frame: "base_link"
pcd_crop:
ros__parameters:
input_topic: "/raw/lidar"
output_topic: "/lidar_filtered"
min_z: -2.5
max_z: 1.5
min_radius: 2.0
max_radius: 30.0
+31
View File
@@ -0,0 +1,31 @@
lidar:
x: 0.456
y: 0.0
z: 1.135
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_lidar
imu:
x: 0.0
y: 0.0
z: 0.5
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_imu
rgb_front:
x: 0.56331
y: 0.56343
z: 0.80703
roll: -0.025
pitch: 0.315
yaw: 1.035
frame_id: base_link
child_frame_id: hero/rgb_front
+21
View File
@@ -0,0 +1,21 @@
lidar:
x: 0.0
y: 0.0
z: 0.03618
roll: 0.0
pitch: 0.0
yaw: 2.356194
frame_id: base_link
child_frame_id: os_lidar
imu:
x: 0.006253
y: -0.011775
z: 0.007645
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_imu
+31
View File
@@ -0,0 +1,31 @@
lidar:
x: 0.456
y: 0.0
z: 1.135
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_lidar
imu:
x: 0.0
y: 0.0
z: 0.5
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_imu
rgb_front:
x: 0.56331
y: 0.56343
z: 0.80703
roll: -0.025
pitch: 0.315
yaw: 1.035
frame_id: base_link
child_frame_id: hero/rgb_front
+31
View File
@@ -0,0 +1,31 @@
lidar:
x: 0.1
y: 0.0
z: 0.6
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_lidar
imu:
x: 0.0
y: 0.0
z: 0.5
roll: 0.0
pitch: 0.0
yaw: 0.0
frame_id: base_link
child_frame_id: os_imu
rgb_front:
x: 0.10731
y: 0.56343
z: -0.27697
roll: -0.025
pitch: 0.315
yaw: 1.035
frame_id: base_link
child_frame_id: hero/rgb_front
+67
View File
@@ -0,0 +1,67 @@
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.actions import GroupAction
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.descriptions import ParameterFile
from nav2_common.launch import RewrittenYaml
from launch.actions import OpaqueFunction
from launch.substitutions import PythonExpression
from launch.conditions import IfCondition
import os
import yaml
ARGUMENTS = [
DeclareLaunchArgument('use_sim_time', default_value='true'),
DeclareLaunchArgument('sim_mode', default_value='true'),
DeclareLaunchArgument('robot', default_value='fr09', choices=['mkmini', 'fr09']),
]
def generate_launch_description():
use_sim_time = LaunchConfiguration('use_sim_time')
robot = LaunchConfiguration('robot')
remappings = [('/tf', 'tf'),
('/tf_static', 'tf_static')]
# Directories
pkg_perception = get_package_share_directory(
'perception3d_pkg')
params_file = PathJoinSubstitution([
pkg_perception,
'config',
PythonExpression(["'3d_' + '", robot, "' + '.yaml'"])])
param_substitutions = {
'use_sim_time': use_sim_time,
}
configured_params = ParameterFile(
RewrittenYaml(
source_file=params_file,
param_rewrites=param_substitutions,
convert_types=True),
allow_substs=True)
load_nodes = GroupAction(
actions=[
Node(
package='perception3d_pkg',
executable='pcd_crop',
name='pcd_crop',
output='screen',
parameters=[configured_params],
remappings=remappings
)
],
)
# Create launch description and add actions
ld = LaunchDescription(ARGUMENTS)
ld.add_action(load_nodes)
return ld
+18
View File
@@ -0,0 +1,18 @@
<?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>perception3d_pkg</name>
<version>0.0.0</version>
<description>TODO: Package description</description>
<maintainer email="jang1future@gmail.com">dgist</maintainer>
<license>TODO: License declaration</license>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
View File
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
import math
from typing import Optional
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32
from sensor_msgs.msg import Imu
from rosgraph_msgs.msg import Clock
from nav_msgs.msg import Odometry
from geometry_msgs.msg import Quaternion, TransformStamped
from tf2_ros import TransformBroadcaster
def quat_to_yaw(x: float, y: float, z: float, w: float) -> float:
siny_cosp = 2.0 * (w * z + x * y)
cosy_cosp = 1.0 - 2.0 * (y * y + z * z)
return math.atan2(siny_cosp, cosy_cosp)
def yaw_to_quaternion(yaw: float) -> Quaternion:
q = Quaternion()
q.x = 0.0
q.y = 0.0
q.z = math.sin(yaw * 0.5)
q.w = math.cos(yaw * 0.5)
return q
def normalize_angle(angle: float) -> float:
while angle > math.pi:
angle -= 2.0 * math.pi
while angle < -math.pi:
angle += 2.0 * math.pi
return angle
def stamp_to_sec(stamp) -> float:
return float(stamp.sec) + float(stamp.nanosec) * 1e-9
class SpeedImuOdomNode(Node):
def __init__(self):
super().__init__('speed_imu_odom_node')
# Parameters
self.declare_parameter('odom_topic', '/vehicle_odom')
self.declare_parameter('odom_frame', 'odom')
self.declare_parameter('base_frame', 'base_link')
self.odom_topic = self.get_parameter('odom_topic').value
self.odom_frame = self.get_parameter('odom_frame').value
self.base_frame = self.get_parameter('base_frame').value
self.declare_parameter('pose_cov_x', 0.5)
self.declare_parameter('pose_cov_y', 0.5)
self.declare_parameter('pose_cov_yaw', 0.2)
self.declare_parameter('twist_cov_vx', 0.2)
self.declare_parameter('twist_cov_vy', 0.2)
self.declare_parameter('twist_cov_wz', 0.1)
self.pose_cov_x = self.get_parameter('pose_cov_x').get_parameter_value().double_value
self.pose_cov_y = self.get_parameter('pose_cov_y').get_parameter_value().double_value
self.pose_cov_yaw = self.get_parameter('pose_cov_yaw').get_parameter_value().double_value
self.twist_cov_vx = self.get_parameter('twist_cov_vx').get_parameter_value().double_value
self.twist_cov_vy = self.get_parameter('twist_cov_vy').get_parameter_value().double_value
self.twist_cov_wz = self.get_parameter('twist_cov_wz').get_parameter_value().double_value
self.x = 0.0
self.y = 0.0
self.yaw = 0.0
self.max_dt = 0.1
# State
self.speed_mps: float = 0.0
self.current_yaw: Optional[float] = None
self.last_yaw: Optional[float] = None
self.last_pub_time: Optional[float] = None
self.x = 0.0
self.y = 0.0
self.has_speed = False
self.has_imu = False
self.ground_truth_odom: Optional[Odometry] = None
# Pub/Sub
self.odom_pub = self.create_publisher(Odometry, self.odom_topic, 50)
self.tf_broadcaster = TransformBroadcaster(self)
self.create_subscription(Float32, '/raw/speed', self.speed_cb, 50)
self.create_subscription(Imu, '/raw/imu', self.imu_cb, 50)
self.get_logger().info('SpeedImuOdomNode started')
self.get_logger().info(f'odom_topic: {self.odom_topic}')
def speed_cb(self, msg: Float32):
self.speed_mps = float(msg.data)
self.has_speed = True
def imu_cb(self, msg: Imu):
self.has_imu = True
# speed가 아직 없으면 선택적으로 skip
if not self.has_speed:
return
# IMU timestamp 우선 사용
stamp = msg.header.stamp
now_sec = stamp_to_sec(stamp)
omega = float(msg.angular_velocity.z)
if self.last_pub_time is None:
self.last_pub_time = now_sec
self.current_yaw = self.yaw
self.last_yaw = self.yaw
# 첫 샘플은 dt가 없으므로 현재 yaw로 publish
self.publish_odom(stamp, self.yaw, self.speed_mps, omega)
return
dt = now_sec - self.last_pub_time
if dt <= 0.0:
# 시간 역전/동일시간이면 적분하지 않고 현재 yaw만 publish
self.publish_odom(stamp, self.yaw, self.speed_mps, omega)
return
# 긴 공백은 과적분 방지
if dt > self.max_dt:
dt = self.max_dt
# angular velocity integration
self.yaw += omega * dt
self.yaw = normalize_angle(self.yaw)
# position integration
self.x += self.speed_mps * math.cos(self.yaw) * dt
self.y += self.speed_mps * math.sin(self.yaw) * dt
self.current_yaw = self.yaw
self.last_yaw = self.yaw
self.last_pub_time = now_sec
self.publish_odom(stamp, self.yaw, self.speed_mps, omega)
def publish_odom(self, stamp, yaw: float, v: float, omega: float):
odom = Odometry()
odom.header.stamp = stamp
odom.header.frame_id = self.odom_frame
odom.child_frame_id = self.base_frame
odom.pose.pose.position.x = self.x
odom.pose.pose.position.y = self.y
odom.pose.pose.position.z = 0.0
odom.pose.pose.orientation = yaw_to_quaternion(yaw)
odom.twist.twist.linear.x = v
odom.twist.twist.linear.y = 0.0
odom.twist.twist.linear.z = 0.0
odom.twist.twist.angular.x = 0.0
odom.twist.twist.angular.y = 0.0
odom.twist.twist.angular.z = omega
odom.pose.covariance = [
self.pose_cov_x, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, self.pose_cov_y, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 999.0,0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 999.0,0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 999.0,0.0,
0.0, 0.0, 0.0, 0.0, 0.0, self.pose_cov_yaw
]
odom.twist.covariance = [
self.twist_cov_vx, 0.0, 0.0, 0.0, 0.0, 0.0,
0.0, self.twist_cov_vy, 0.0, 0.0, 0.0, 0.0,
0.0, 0.0, 999.0,0.0, 0.0, 0.0,
0.0, 0.0, 0.0, 999.0,0.0, 0.0,
0.0, 0.0, 0.0, 0.0, 999.0,0.0,
0.0, 0.0, 0.0, 0.0, 0.0, self.twist_cov_wz
]
self.odom_pub.publish(odom)
tf_msg = TransformStamped()
tf_msg.header.stamp = stamp
tf_msg.header.frame_id = self.odom_frame
tf_msg.child_frame_id = self.base_frame
tf_msg.transform.translation.x = self.x
tf_msg.transform.translation.y = self.y
tf_msg.transform.translation.z = 0.0
tf_msg.transform.rotation = yaw_to_quaternion(yaw)
self.tf_broadcaster.sendTransform(tf_msg)
def main(args=None):
rclpy.init(args=args)
node = SpeedImuOdomNode()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import PointCloud2
import sensor_msgs_py.point_cloud2 as pc2
import numpy as np
class PointCloudCrop(Node):
def __init__(self):
super().__init__('pcd_crop')
self.declare_parameter('min_z', -2.5)
self.declare_parameter('max_z', -0.5)
self.declare_parameter('min_radius', 5.0) # 너무 가까운 점 제거
self.declare_parameter('max_radius', 30.0) # 너무 먼 점 제거
self.declare_parameter('input_topic', '/raw/lidar')
self.declare_parameter('output_topic', '/lidar_filtered')
self.min_z = self.get_parameter('min_z').value
self.max_z = self.get_parameter('max_z').value
self.min_radius = self.get_parameter('min_radius').value
self.max_radius = self.get_parameter('max_radius').value
input_topic = self.get_parameter('input_topic').value
output_topic = self.get_parameter('output_topic').value
self.declare_parameter('min_angle', (-np.pi)) # rad
self.declare_parameter('max_angle', (np.pi)) # rad
self.min_angle = self.get_parameter('min_angle').value
self.max_angle = self.get_parameter('max_angle').value
self.sub = self.create_subscription(
PointCloud2,
input_topic,
self.callback,
10
)
self.pub = self.create_publisher(
PointCloud2,
output_topic,
10
)
self.get_logger().info(
f"Crop started: Z[{self.min_z}, {self.max_z}] "
f"Radius[{self.min_radius}, {self.max_radius}]"
f"Angle[{self.min_angle}, {self.max_angle}]"
)
def callback(self, msg: PointCloud2):
pts = pc2.read_points(
msg,
field_names=("x", "y", "z"),
skip_nans=True
)
if pts.shape[0] == 0:
return
# structured array -> 일반 (N,3) float32 배열
points = np.stack(
(pts["x"], pts["y"], pts["z"]), axis=-1).astype(np.float32, copy=False)
x = points[:, 0]
y = points[:, 1]
z = points[:, 2]
min_r2 = self.min_radius * self.min_radius
max_r2 = self.max_radius * self.max_radius
r2 = x * x + y * y
angles = np.arctan2(-y, -x)
if self.min_angle < self.max_angle:
angle_mask = (angles >= self.min_angle) & (angles <= self.max_angle)
else:
angle_mask = (angles >= self.min_angle) | (angles <= self.max_angle)
mask = (
(z >= self.min_z) &
(z <= self.max_z) &
(r2 >= min_r2) &
(r2 <= max_r2) &
angle_mask
)
filtered = points[mask]
if filtered.shape[0] == 0:
return
filtered_msg = pc2.create_cloud_xyz32(msg.header, filtered.tolist())
self.pub.publish(filtered_msg)
def main(args=None):
rclpy.init(args=args)
node = PointCloudCrop()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
+6
View File
@@ -0,0 +1,6 @@
def main():
print('Hi from perception3d_pkg.')
if __name__ == '__main__':
main()
View File
+4
View File
@@ -0,0 +1,4 @@
[develop]
script_dir=$base/lib/perception3d_pkg
[install]
install_scripts=$base/lib/perception3d_pkg
+36
View File
@@ -0,0 +1,36 @@
import os
from glob import glob
from setuptools import find_packages, setup
package_name = 'perception3d_pkg'
setup(
name=package_name,
version='0.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
(os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
(os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='dgist',
maintainer_email='jang1future@gmail.com',
description='TODO: Package description',
license='TODO: License declaration',
extras_require={
'test': [
'pytest',
],
},
entry_points={
'console_scripts': [
'test = perception3d_pkg.test:main',
'pcd_crop = perception3d_pkg.pcd_crop:main'
],
},
)
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_copyright.main import main
import pytest
# Remove the `skip` decorator once the source file(s) have a copyright header
@pytest.mark.skip(reason='No copyright header has been placed in the generated source file.')
@pytest.mark.copyright
@pytest.mark.linter
def test_copyright():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found errors'
+25
View File
@@ -0,0 +1,25 @@
# Copyright 2017 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_flake8.main import main_with_errors
import pytest
@pytest.mark.flake8
@pytest.mark.linter
def test_flake8():
rc, errors = main_with_errors(argv=[])
assert rc == 0, \
'Found %d code style errors / warnings:\n' % len(errors) + \
'\n'.join(errors)
+23
View File
@@ -0,0 +1,23 @@
# Copyright 2015 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from ament_pep257.main import main
import pytest
@pytest.mark.linter
@pytest.mark.pep257
def test_pep257():
rc = main(argv=['.', 'test'])
assert rc == 0, 'Found code style errors / warnings'