收尾專案:打造一台自主導航機器人

2026-07-18
  • ros2
  • capstone
  • project
  • integration

問題定義

前面十章分別介紹了 ROS2 的核心通訊機制、開發工具、機器人建模、模擬環境,以及導航、機械手臂操作、感知這三條可依需求選讀的支線,還有系統設計與部署的最佳實踐。這篇收尾專案的目的,不是教新的技術,而是練習一件前面每一章都沒有機會完整示範的事——把多個獨立學會的技術,整合成一個真正協同運作的完整系統。單獨掌握每一項技術,跟讓它們在同一個系統裡正確配合,中間存在明顯的落差,這正是這個專案要處理的問題。

專案目標:物體觸發式自主巡邏機器人

設計一個具備以下行為的機器人系統:機器人在地圖上巡邏移動,透過相機持續偵測環境,一旦偵測到特定類別的物體(例如一個紅色箱子),就中斷目前的巡邏路徑,自主導航到該物體附近停下,發布一則包含物體資訊的通知。這個場景刻意涵蓋了課程裡多個章節的技術,且彼此之間有明確的資料依賴關係,適合作為整合練習的骨架。

系統架構:串連課程涵蓋的技術模組

text
第 4 章 URDF/xacro/TF2 ──→ 機器人模型與座標系統(所有模組的共同地基)
      │
第 5 章 Gazebo ──→ 模擬環境、感測器資料來源
      │
      ├──→ 第 8 章 YOLO 物體偵測 ──→ 發布 /detections
      │         │
      │         ▼
      │    偵測到目標物體?
      │         │ 是
      │         ▼
第 6 章 Nav2 ──→ 中斷巡邏、規劃前往物體位置的路徑 ──→ 執行導航
      │
第 9 章 Lifecycle Node ──→ 統一管理巡邏控制器的啟用/停用狀態

實作範例:巡邏控制器節點

這個節點是整個系統的協調核心,訂閱物體偵測結果,管理「巡邏中」與「前往目標」兩種行為模式之間的切換,並透過第 6.4 節介紹的 Nav2 Action 介面實際下達導航指令。

檔案位置:~/ros2_ws/src/my_package/my_package/patrol_controller.py

python
import rclpy
from rclpy.lifecycle import Node as LifecycleNode
from rclpy.lifecycle import State, TransitionCallbackReturn
from rclpy.action import ActionClient
from vision_msgs.msg import Detection2DArray
from nav2_msgs.action import NavigateToPose
from geometry_msgs.msg import PoseStamped


PATROL_WAYPOINTS = [
    (1.0, 0.0),
    (1.0, 2.0),
    (-1.0, 2.0),
    (-1.0, 0.0),
]

TARGET_CLASS = "red_box"


class PatrolController(LifecycleNode):
    def __init__(self):
        super().__init__("patrol_controller")
        self.nav_client = None
        self.detection_sub = None
        self.mode = "idle"
        self.waypoint_index = 0

    def on_configure(self, state: State) -> TransitionCallbackReturn:
        self.nav_client = ActionClient(self, NavigateToPose, "navigate_to_pose")
        self.detection_sub = self.create_subscription(
            Detection2DArray, "/detections", self.on_detections, 10
        )
        self.get_logger().info("配置完成,等待啟用")
        return TransitionCallbackReturn.SUCCESS

    def on_activate(self, state: State) -> TransitionCallbackReturn:
        self.mode = "patrolling"
        self.send_next_waypoint()
        return super().on_activate(state)

    def on_deactivate(self, state: State) -> TransitionCallbackReturn:
        self.mode = "idle"
        return super().on_deactivate(state)

    def on_detections(self, msg: Detection2DArray):
        if self.mode != "patrolling":
            return

        for detection in msg.detections:
            for result in detection.results:
                if result.hypothesis.class_id == TARGET_CLASS and \
                   result.hypothesis.score > 0.7:
                    self.get_logger().info(
                        f"偵測到目標物體,信心分數 {result.hypothesis.score:.2f},中斷巡邏"
                    )
                    self.mode = "approaching_target"
                    self.send_goal_to_detected_object(detection)
                    return

    def send_next_waypoint(self):
        if self.mode != "patrolling":
            return
        x, y = PATROL_WAYPOINTS[self.waypoint_index]
        self.waypoint_index = (self.waypoint_index + 1) % len(PATROL_WAYPOINTS)
        self.send_nav_goal(x, y, on_result=self.send_next_waypoint)

    def send_goal_to_detected_object(self, detection):
        estimated_x, estimated_y = self.estimate_object_position(detection)
        self.send_nav_goal(estimated_x, estimated_y, on_result=self.on_reached_target)

    def send_nav_goal(self, x, y, on_result):
        self.nav_client.wait_for_server()
        goal_msg = NavigateToPose.Goal()
        goal_msg.pose = PoseStamped()
        goal_msg.pose.header.frame_id = "map"
        goal_msg.pose.pose.position.x = x
        goal_msg.pose.pose.position.y = y
        goal_msg.pose.pose.orientation.w = 1.0

        future = self.nav_client.send_goal_async(goal_msg)
        future.add_done_callback(
            lambda f: self._on_goal_response(f, on_result)
        )

    def _on_goal_response(self, future, on_result):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().warn("導航目標被拒絕")
            return
        result_future = goal_handle.get_result_async()
        result_future.add_done_callback(lambda f: on_result())

    def on_reached_target(self):
        self.get_logger().info("已抵達目標物體位置,發布通知")
        self.mode = "patrolling"
        self.send_next_waypoint()

    def estimate_object_position(self, detection):
        return (0.5, 1.0)


def main():
    rclpy.init()
    node = PatrolController()
    try:
        rclpy.spin(node)
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == "__main__":
    main()

幾個地方直接體現了整合多個章節技術時需要考量的設計:

  • 繼承 LifecycleNode(第 9.1 節)而不是一般 Node,讓整個巡邏行為可以被明確地啟用/停用,而不是節點一啟動就立刻自主行動——這在整合測試階段特別重要,你會希望能先確認每個子系統各自正常,再統一啟用整個自主行為。
  • estimate_object_position 這個方法目前是簡化的固定值——完整實作需要結合第 8.2 節的點雲資料,把 2D 偵測框結合深度資訊反推三維位置,這裡刻意留白,作為讀者可以自己延伸練習的部分。
  • 巡邏與追蹤目標共用同一個 send_nav_goal 方法,兩種行為模式最終都收斂到同一套跟 Nav2 互動的邏輯,避免重複程式碼。

系統整合順序:分層驗證,而不是一次全部啟動

實務上組裝這類系統時,一次把所有節點全部啟動、直接測試完整行為,出問題時很難判斷是哪一層出錯。建議的驗證順序:

  1. 模型與座標系層:先確認第 4 章的 URDF/TF2 正確,用 RViz2(第 4.4 節)目視確認模型顯示正常、TF 樹完整。
  2. 模擬與感測器層:啟動第 5 章的 Gazebo 模擬,確認感測器資料(相機、雷射)正確橋接到 ROS2,用 ros2 topic hz 確認發布頻率符合預期。
  3. 感知層:單獨啟動第 8 章的 YOLO 偵測節點,確認 /detections 主題能正確辨識出測試場景裡放置的目標物體,這一步先不啟動導航或巡邏邏輯。
  4. 導航層:單獨測試第 6 章的 Nav2 系統,用手動發送的固定目標點(不透過巡邏邏輯)確認導航本身能正常運作。
  5. 整合層:以上都確認正常後,才啟動 patrol_controller,串連感知結果與導航行為,觀察完整流程。

每一層都先獨立驗證通過,才進到下一層,是控制整合複雜度、讓問題容易定位的關鍵原則——這跟第 10.4 節介紹 Sim-to-Real 循序漸進的精神是一致的,只是應用在系統整合,而不是模擬到實機的轉移。

預期行為

啟動整合後的系統,機器人會依照 PATROL_WAYPOINTS 定義的路徑點依序巡邏。在場景裡放置一個標記為目標類別的物體後:

text
[INFO] [patrol_controller]: 偵測到目標物體,信心分數 0.84,中斷巡邏
[INFO] [patrol_controller]: 已抵達目標物體位置,發布通知

機器人會中斷原本前往下一個巡邏點的路徑,改為導航到偵測到物體的估計位置,抵達後印出通知訊息,接著自動恢復巡邏(回到下一個巡邏點)。

常見的整合階段問題

問題一:各層獨立測試都正常,但整合後巡邏邏輯完全沒有反應

排查方向:這類問題往往不是任何單一模組本身壞了,而是模組之間的介面假設不一致——例如 patrol_controller 訂閱的 /detections 主題名稱,跟 YOLO 節點實際發布的主題名稱因為命名空間設定(呼應第 9.4 節)不一致,兩者各自獨立測試時因為沒有命名空間衝突而正常,整合到同一個 launch 檔案後才暴露問題。用 ros2 topic list 加上 ros2 node info 確認實際的主題名稱與訂閱關係,是排查這類「各自正常、合體失敗」問題最直接的方式。

問題二:系統運作一段時間後,偵測到物體卻沒有觸發導航中斷

排查方向:檢查 self.mode 狀態機的邏輯是否有考慮到所有可能的時序——例如物體偵測結果剛好在機器人正在執行 send_nav_goal 但還沒收到 _on_goal_response 的空檔期間到達,如果狀態切換邏輯沒有妥善處理這種中間狀態,可能導致偵測結果被忽略、或觸發了衝突的重複導航請求。這類問題是系統整合階段特別容易浮現、單一模組測試時不會遇到的時序問題,值得放慢速度、加上足夠的 log 輸出,逐步重現問題發生的確切時序。

小結

這個收尾專案把課程涵蓋的建模、模擬、導航、感知、系統設計技術,整合成一個具備感知驅動決策能力的自主機器人系統。系統整合階段的問題往往跟單一技術本身無關,而是模組之間的介面假設、命名空間、時序邏輯沒有對齊——分層驗證、逐步整合,是控制這種複雜度最有效的策略。到這裡,恭喜你完成了整套 ROS2 課程——從第一個節點的 talkerlistener,到一個能感知環境、自主決策、安全部署的完整機器人系統,這條路徑涵蓋的內容,已經足以支撐你開始獨立規劃跟推進屬於自己的機器人專案。

延伸閱讀

常見問題

faq_01.log
這個收尾專案一定要照本文的架構做嗎?
不需要,本文提供的是一個涵蓋課程核心技術、複雜度適中的參考架構,實際專案可以依照你更感興趣的方向調整——例如把物體偵測換成第 7 章的抓取任務、或加入第 9 章的多機器人設計。收尾專案真正的價值在於『系統整合』這個過程本身:單獨學會每一項技術,跟把它們組合成一個真正協同運作的完整系統,中間會遇到很多單項技術學習時不會碰到的問題,這才是這個專案要練習的核心能力。
faq_02.log
整合過程中如果卡關,應該從哪裡開始排查?
回到本文『系統整合順序』小節提到的分層驗證原則——先確認底層(模型顯示、TF 樹)沒問題,再確認中層(感測器資料、建圖),最後才整合最上層的決策邏輯。多數整合階段的問題,追根究柢都是某個底層環節其實沒有真正就緒,只是症狀在更上層才被觀察到,這也是為什麼前面每一章的『常見錯誤』小節,都值得在整合階段卡關時回頭重新檢視。
faq_03.log
這個專案適合直接搬到真實機器人上嗎?
本文的實作範例是基於第 5 章的 Gazebo 模擬環境,如果要搬到真實硬體,必須先完整走過第 10.4 節介紹的 Sim-to-Real 步驟——不能假設在模擬裡運作正常的系統,可以直接不做任何調整地部署到真實機器人上,這是整個課程反覆強調的重要原則。