【保姆级教程】数字孪生终极浪漫:手写Python神器!在RViz中像玩《我的世界》一样规划无人机3D航线

📝 前言:三维空间的“降维打击”

大家好,我是你们的赛博导师,萨卡班机甲鱼!欢迎回到我的 3D 视觉与数字孪生系列教程的第四期!

上一期教程中,我们完成了一个极其硬核的操作:把 SolidWorks 的 CAD 白模和真实的彩色点云严丝合缝地嵌套在了一起,打造了一个毫无死角的“高保真数字孪生厂房”。

很多同学跟着做完后,兴奋地跑来问我:“鱼哥,图太帅了!但我准备给无人机画巡检路线时傻眼了。RViz 默认的 2D Nav Goal 工具只能在地上点,我的无人机是要在半空中上下飞的啊!难道我要手动在终端里敲成百上千个 (X, Y, Z) 坐标吗?

当然不!作为极客,我们拒绝枯燥的体力活。
今天,我将开源我独家编写的 “终极版 3D 交互规划器” Python 脚本!
运行它,你就能在 RViz 里召唤出一根**“直插云霄的光柱”和一个悬浮的“3D 定位球”**。你只需要用鼠标拖动它,就能像玩《我的世界 (Minecraft)》一样,在真实的 3D 厂房里丝滑地绘制飞行轨迹,并一键导出给飞控系统!准备好迎接真正的“上帝视角”了吗?👇


🚨 痛点直击:为什么你需要这个神器?

在庞大的几千万级点云厂房里,用传统方法画航线有三大致命痛点:

  1. “大海捞针”:点云太厚,用来定位的小球经常被埋在墙里、设备里,根本找不到。

  2. “高度盲区”:鼠标在 2D 屏幕上点击,你根本不知道这个点是在半空中,还是已经贴到地上了(容易撞机)。

  3. “精度堪忧”:手抖点歪了 5 厘米,无人机可能就撞上了柱子。

我的这个脚本,自带三大黑科技完美解决上述痛点:

  • 🚀 冲天信标光柱:无论小球藏得多深,一根 20 米长的亮黄色光柱直插云霄,让你一眼定位。

  • 🖥️ HUD 抬头显示:小球头顶实时悬浮绝对 3D 坐标,精确到小数点后两位。

  • 🧲 CAD级磁吸网格:松开鼠标瞬间,小球会自动吸附到 0.2m 的整数倍网格上,专治手抖!

废话不多说,直接上代码!


🛠️ 第一步:注入灵魂(创建 Python 脚本)

打开你的 Ubuntu 终端(快捷键 Ctrl+Alt+T),进入我们上一期建好的功能包:

# 1. 进入脚本存放文件夹
cd ~/catkin_ws/src/my_factory/scripts

# 2. 创建并编辑新脚本
gedit drone_3d_planner.py

在弹出的文本框中,直接复制粘贴以下全部代码(我已经加了详细的中文注释,参数全在最上面,可以随心所欲定制):

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import rospy
import csv
import os
import math
import tkinter as tk
from tkinter import messagebox, ttk
from interactive_markers.interactive_marker_server import *
from interactive_markers.menu_handler import *
from visualization_msgs.msg import *
from geometry_msgs.msg import Point, Pose

# =================================================================
# ★★★ 全局参数控制台 (大小、颜色随心调) ★★★
# =================================================================
CONTROL_SCALE = 0.05      # 周围控制箭头的大小
BALL_SIZE = 0.015         # 中间红色定位球的大小
BALL_COLOR =[1.0, 0.0, 0.0, 0.9] 

TEXT_SIZE = 0.04          # 头顶坐标文字大小
TEXT_HEIGHT = 0.02        # 文字悬浮高度

# --- ★核心黑科技:冲天光柱参数 ---
BEACON_RADIUS = 0.02      # 光柱的半径
BEACON_HEIGHT = 20.0      # 光柱的总高度 (米)
BEACON_GAP = 0.5          # 光柱底部距离小球的"悬空距离" (防遮挡)
BEACON_COLOR =[1.0, 1.0, 0.0, 0.4] # 亮黄色半透明

# --- 辅助网格与吸附精度 ---
SNAP_RES = 0.2            # 鼠标松开时的自动吸附精度 (米)
GRID_SPACING = 0.2        # 网格间距
GRID_RANGE = 3            # 网格显示范围 (±3米)
GRID_LINE_WIDTH = 0.0005
GRID_ALPHA = 0.3

TRAJECTORY_WIDTH = 0.005  # 规划好的轨迹线粗细
TRAJECTORY_COLOR =[1.0, 0.0, 1.0, 1.0] # 紫色连线

# =================================================================

class WaypointApp:
    def __init__(self):
        rospy.init_node("drone_planner_master")
        self.waypoints =[] 
        self.file_path = os.path.expanduser("~/drone_path_3d.csv")
        self.current_pose =[0, 0, 1.0]
        self.need_update_gui = False 

        self.server = InteractiveMarkerServer("drone_controls")
        
        # --- 注册所有视觉组件 (latch=True保证一进RViz就能看到) ---
        self.line_pub = rospy.Publisher("visualization_marker", Marker, queue_size=10, latch=True)
        self.grid_lines_pub = rospy.Publisher("grid_3d_lines", Marker, queue_size=10, latch=True)
        self.grid_nodes_pub = rospy.Publisher("grid_3d_nodes", Marker, queue_size=10, latch=True)
        self.text_pub = rospy.Publisher("coordinate_text", Marker, queue_size=10, latch=True)
        self.beacon_pub = rospy.Publisher("drone_beacon", Marker, queue_size=10, latch=True)
        
        rospy.sleep(0.5)
        
        # 初始化
        self.make_drone_marker(Point(*self.current_pose))
        self.server.applyChanges()
        self.update_local_grid(Point(*self.current_pose))

        # --- 图形化管理界面 ---
        self.root = tk.Tk()
        self.root.title("无人机航线规划系统 (上帝视角版)")
        self.root.geometry("400x600")
        self.root.attributes("-topmost", True)
        self.style_gui()

    def style_gui(self):
        self.tree = ttk.Treeview(self.root, columns=("ID", "X", "Y", "Z"), show="headings", height=15)
        for col in["ID", "X", "Y", "Z"]:
            self.tree.heading(col, text=col); self.tree.column(col, width=60, anchor="center")
        self.tree.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)

        f = tk.Frame(self.root); f.pack(fill=tk.X, padx=5)
        tk.Button(f, text="🗑️ 删除选中航点", bg="#ffcccc", command=self.delete_point).pack(fill=tk.X, pady=2)
        tk.Button(f, text="➕ 插入到当前位置", command=self.insert_point).pack(fill=tk.X, pady=2)
        
        tk.Label(self.root, text=f"当前吸附精度: {SNAP_RES}m | 冲天光柱已就绪", fg="blue").pack(pady=5)
        tk.Button(self.root, text="💾 一键导出 CSV 航线", bg="#ccffcc", font=("Arial", 12, "bold"), command=self.save_csv).pack(fill=tk.X, padx=5, pady=10)

    # ================= 核心:网格跟随与磁力吸附 =================
    def update_local_grid(self, center_point):
        lines = Marker(); lines.header.frame_id = "base_link"; lines.type = Marker.LINE_LIST; lines.action = Marker.ADD
        lines.scale.x = GRID_LINE_WIDTH 
        lines.color.r = 0.0; lines.color.g = 1.0; lines.color.b = 1.0; lines.color.a = GRID_ALPHA
        
        nodes = Marker(); nodes.header.frame_id = "base_link"; nodes.type = Marker.POINTS; nodes.action = Marker.ADD
        nodes.scale.x = NODE_SIZE; nodes.scale.y = NODE_SIZE
        nodes.color.r = NODE_COLOR[0]; nodes.color.g = NODE_COLOR[1]; nodes.color.b = NODE_COLOR[2]; nodes.color.a = NODE_COLOR[3]

        min_x = center_point.x - GRID_RANGE; max_x = center_point.x + GRID_RANGE
        min_y = center_point.y - GRID_RANGE; max_y = center_point.y + GRID_RANGE
        min_z = center_point.z - GRID_RANGE; max_z = center_point.z + GRID_RANGE
        
        start_x = round(min_x / GRID_SPACING) * GRID_SPACING
        start_y = round(min_y / GRID_SPACING) * GRID_SPACING
        start_z = round(min_z / GRID_SPACING) * GRID_SPACING
        if start_z < 0: start_z = 0

        epsilon = 0.001
        y = start_y
        while y <= max_y + epsilon:
            z = start_z
            while z <= max_z + epsilon:
                lines.points.append(Point(min_x, y, z)); lines.points.append(Point(max_x, y, z)); z += GRID_SPACING
            y += GRID_SPACING
        x = start_x
        while x <= max_x + epsilon:
            z = start_z
            while z <= max_z + epsilon:
                lines.points.append(Point(x, min_y, z)); lines.points.append(Point(x, max_y, z)); z += GRID_SPACING
            x += GRID_SPACING
        x = start_x
        while x <= max_x + epsilon:
            y = start_y
            while y <= max_y + epsilon:
                lines.points.append(Point(x, y, min_z)); lines.points.append(Point(x, y, max_z)); y += GRID_SPACING
            x += GRID_SPACING

        cx = start_x
        while cx <= max_x + epsilon:
            cy = start_y
            while cy <= max_y + epsilon:
                cz = start_z
                while cz <= max_z + epsilon:
                    if (cx >= min_x and cx <= max_x and cy >= min_y and cy <= max_y and cz >= min_z and cz <= max_z):
                        nodes.points.append(Point(cx, cy, cz))
                    cz += GRID_SPACING
                cy += GRID_SPACING
            cx += GRID_SPACING

        self.grid_lines_pub.publish(lines)
        self.grid_nodes_pub.publish(nodes)

    def snap_to_grid(self, value):
        return round(value / SNAP_RES) * SNAP_RES

    def process_feedback(self, feedback):
        if feedback.event_type == InteractiveMarkerFeedback.POSE_UPDATE:
            raw_p = feedback.pose.position
            snapped_x = self.snap_to_grid(raw_p.x)
            snapped_y = self.snap_to_grid(raw_p.y)
            snapped_z = self.snap_to_grid(raw_p.z)
            self.current_pose =[snapped_x, snapped_y, snapped_z]
            
            # 实时更新文字、网格、和光柱
            self.publish_text(Point(snapped_x, snapped_y, snapped_z))
            self.publish_beacon(Point(snapped_x, snapped_y, snapped_z))
            self.update_local_grid(raw_p)

        elif feedback.event_type == InteractiveMarkerFeedback.MOUSE_UP:
            p = Pose()
            p.position.x = self.current_pose[0]
            p.position.y = self.current_pose[1]
            p.position.z = self.current_pose[2]
            self.server.setPose("drone_marker", p)
            self.server.applyChanges()
            self.update_local_grid(p.position)

    # ================= 交互与显示构建 =================
    def make_drone_marker(self, position):
        int_marker = InteractiveMarker()
        int_marker.header.frame_id = "base_link"
        int_marker.pose.position = position
        int_marker.scale = CONTROL_SCALE
        int_marker.name = "drone_marker"
        
        control = InteractiveMarkerControl()
        control.always_visible = True
        marker = Marker()
        marker.type = Marker.SPHERE
        marker.scale.x = BALL_SIZE; marker.scale.y = BALL_SIZE; marker.scale.z = BALL_SIZE
        marker.color.r = BALL_COLOR[0]; marker.color.g = BALL_COLOR[1]; marker.color.b = BALL_COLOR[2]; marker.color.a = BALL_COLOR[3]
        control.markers.append(marker)
        control.interaction_mode = InteractiveMarkerControl.MENU
        int_marker.controls.append(control)
        
        # 修正了四元数归一化,消灭底层报错
        self.add_axis(int_marker, 1, 0, 0, "move_x")
        self.add_axis(int_marker, 0, 1, 0, "move_z")
        self.add_axis(int_marker, 0, 0, 1, "move_y")
        
        self.server.insert(int_marker, self.process_feedback)
        self.menu_handler = MenuHandler()
        self.menu_handler.insert("🚀 记录为航点 (Record)", callback=self.record_point_ros)
        self.menu_handler.apply(self.server, int_marker.name)
        
        self.publish_text(position)
        self.publish_beacon(position)

    def add_axis(self, int_marker, x, y, z, name):
        control = InteractiveMarkerControl()
        control.orientation.w = 0.7071 
        control.orientation.x = x * 0.7071
        control.orientation.y = y * 0.7071
        control.orientation.z = z * 0.7071
        control.name = name
        control.interaction_mode = InteractiveMarkerControl.MOVE_AXIS
        int_marker.controls.append(control)

    def record_point_ros(self, feedback):
        self.waypoints.append(list(self.current_pose))
        self.need_update_gui = True

    def publish_beacon(self, position):
        beacon = Marker()
        beacon.header.frame_id = "base_link"
        beacon.type = Marker.CYLINDER
        beacon.action = Marker.ADD
        beacon.scale.x = BEACON_RADIUS * 2; beacon.scale.y = BEACON_RADIUS * 2; beacon.scale.z = BEACON_HEIGHT
        beacon.color.r = BEACON_COLOR[0]; beacon.color.g = BEACON_COLOR[1]; beacon.color.b = BEACON_COLOR[2]; beacon.color.a = BEACON_COLOR[3]
        
        # 让光柱悬浮在小球上方,不遮挡视线
        beacon.pose.position.x = position.x
        beacon.pose.position.y = position.y
        beacon.pose.position.z = position.z + BEACON_GAP + (BEACON_HEIGHT / 2.0)
        self.beacon_pub.publish(beacon)

    def publish_text(self, position):
        text = Marker()
        text.header.frame_id = "base_link"
        text.type = Marker.TEXT_VIEW_FACING
        text.action = Marker.ADD
        text.pose.position = position; text.pose.position.z += TEXT_HEIGHT
        text.scale.z = TEXT_SIZE
        text.color.r = 1.0; text.color.g = 1.0; text.color.b = 1.0; text.color.a = 1.0
        text.text = f"[{position.x:.1f}, {position.y:.1f}, {position.z:.1f}]"
        self.text_pub.publish(text)

    def publish_path(self):
        marker = Marker(); marker.header.frame_id = "base_link"
        marker.ns = "trajectory"; marker.id = 0
        marker.type = Marker.LINE_STRIP; marker.action = Marker.ADD
        marker.pose.orientation.w = 1.0 
        marker.scale.x = TRAJECTORY_WIDTH 
        marker.color.r = TRAJECTORY_COLOR[0]; marker.color.g = TRAJECTORY_COLOR[1]; marker.color.b = TRAJECTORY_COLOR[2]; marker.color.a = TRAJECTORY_COLOR[3]
        for wp in self.waypoints: marker.points.append(Point(*wp))
        self.line_pub.publish(marker)

    def update_gui_list(self):
        for row in self.tree.get_children(): self.tree.delete(row)
        for i, wp in enumerate(self.waypoints):
            self.tree.insert("", "end", values=(i+1, f"{wp[0]:.2f}", f"{wp[1]:.2f}", f"{wp[2]:.2f}"))
        self.publish_path() 

    def delete_point(self):
        selected = self.tree.selection()
        if selected:
            for item in reversed(selected): del self.waypoints[int(self.tree.item(item, "values")[0]) - 1]
            self.need_update_gui = True

    def insert_point(self):
        selected = self.tree.selection()
        if selected:
            self.waypoints.insert(int(self.tree.item(selected[0], "values")[0]), self.current_pose)
            self.need_update_gui = True

    def save_csv(self):
        if not self.waypoints: return
        try:
            with open(self.file_path, mode='w', newline='') as file:
                writer = csv.writer(file)
                writer.writerow(["Index", "X", "Y", "Z", "Yaw"])
                for i, wp in enumerate(self.waypoints):
                    yaw = 0.0
                    # 自动计算朝向,让无人机机头永远对准下一个点
                    if i < len(self.waypoints) - 1:
                        dx = self.waypoints[i+1][0] - wp[0]; dy = self.waypoints[i+1][1] - wp[1]
                        yaw = math.degrees(math.atan2(dy, dx))
                    writer.writerow([i+1, wp[0], wp[1], wp[2], yaw])
            messagebox.showinfo("成功", f"文件已保存至:\n{self.file_path}")
        except Exception as e: messagebox.showerror("错误", f"{e}")

    def run(self):
        while not rospy.is_shutdown():
            try:
                if self.need_update_gui:
                    self.update_gui_list(); self.need_update_gui = False
                self.root.update(); rospy.sleep(0.05)
            except tk.TclError: break

if __name__ == "__main__":
    app = WaypointApp()
    app.run()

保存并关闭文件。


🛡️ 第二步:赋予“执行魔法”

在 Linux 中,刚写好的代码是没有执行权限的,我们需要激活它:

chmod +x ~/catkin_ws/src/my_factory/scripts/drone_3d_planner.py

🚀 第三步:全军出击!(RViz 配置指南)

现在,激动人心的时刻到了。我们将同时启动上一期的环境底座和我们刚刚写的规划大脑

  1. 启动数字孪生厂房

    roslaunch my_factory fusion.launch
  2. 启动 3D 规划神器
    打开新终端,运行脚本:

    ~/catkin_ws/src/my_factory/scripts/drone_3d_planner.py

【终极 RViz 唤醒指南】
此时 RViz 里可能还没看到控制球,你需要手动把它们加进来(别怕,只要加一次,按 Ctrl+S 保存后以后就全自动了!)。

  • 点击左下角 Add

    1. 找 InteractiveMarkers -> Topic 选 /drone_controls/update。(召唤红色定位球)

    2. 找 Marker -> Topic 选 /coordinate_text。(召唤头顶 HUD 坐标)

    3. 找 Marker -> Topic 选 /drone_beacon。(召唤明黄色冲天光柱!)

    4. 找 Marker -> Topic 选 /visualization_marker。(召唤紫色轨迹连线)

[此处插入一张包含:悬浮红球、黄色光柱、头顶数字坐标、以及弹出的 GUI 列表小窗口的 RViz 整体截图,一定要突出赛博朋克感!]


🎮 第四步:像打游戏一样规划航线!

现在,在错综复杂的设备点云中,那根冲天光柱绝对是你最耀眼的明灯。

  1. 寻找与拖拽:鼠标按住红球周围的彩色箭头,任意上下左右拖动。看看红球头顶的数字是不是在实时狂跳?

  2. 磁吸黑科技:当你松开鼠标的瞬间,“啪”的一下!小球会强行吸附到 0.2m 的网格点上。这保证了你导出的坐标绝对是工程级的规整数据。

  3. 右键定点:把球拖到设备上方安全的位置 -> 点击鼠标右键 -> 选择 “🚀 记录为航点”

  4. 实时列表管理:看看旁边弹出来的 Python 悬浮小窗口,你的坐标已经自动写进表格了!点错了?直接在表格里选中,点“删除选中航点”,RViz 里的紫色轨迹线会瞬间重新连接!

  5. 一键导出:全部规划完后,点击绿色的“保存 CSV”按钮。

去你的主目录(Home)看看吧,一个名为 drone_path_3d.csv 的 Excel 文件已经静静躺在那里了。
里面不仅有精确的 XYZ,连无人机机头该朝哪边转的 Yaw(偏航角) 都帮你算好了!


🔜 结语

从配置环境,到激光/视觉建图,再到 CAD 虚实融合,最后到手写 Python 实现 3D 轨迹规划并导出。
恭喜你,你已经彻底打通了工业数字孪生与无人机自动驾驶的全链路核心技术!

标签: #ROS #Python #路径规划 #RViz #无人机巡检 #数字孪生

如果这个系列教程对你有帮助,请狠狠地点赞、一键三连!如果你在复现过程中遇到了任何奇葩的 Bug,别忘了在评论区留言,赛博导师在线帮你排雷!我们下个硬核系列再见!🚀

————————————————
版权声明:本文为CSDN博主「萨卡班机甲鱼」的原创文章,遵循CC 4.0 BY-SA版权协议。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐