數位學習平台 ↗ EN 聯絡我們

登月小車 AMB82 MINI AI 自駕車(五)自動駕駛:循線、辨識標誌與參數調校

登月小車 AMB82 MINI AI 自駕車(五)自動駕駛:循線、辨識標誌與參數調校

本系列使用 登月小車(AMB82 MINI),從硬體安裝、蒐集與標註資料、訓練 YOLOv7-tiny 模型,一路做到能辨識交通標誌的自駕小車。這是第五篇。

這個單元我們會將轉換後的模型放入SD Card,讓程式讀取模型並控制登月小車。

5-1. 將模型放入SD Card

官方有制定放入規則,我們必須遵守才能讓程式讀取到模型,首先將SD Card 透過讀卡機接上電腦。

將之前的圖片全部刪除,並新增一個名為「NN_MDL」的資料夾,這個名稱不可更改。

將之前的圖片全部刪除,並新增一個名為「NN_MDL」的資料夾,這個名稱不可更改。

接著將轉換好的模型複製到這個資料夾內,並改名為「yolov7_tiny」,這個名稱也是規定,不要隨意更改。

接著將轉換好的模型複製到這個資料夾內,並改名為「yolov7_tiny」,這個名稱也是規定,不要隨意更改。

然後就可以將SD Card 退出電腦,並插在AMB82 MINI 上。

5-2. 自動駕駛程式撰寫

因為程式碼很長,所以挑重點說明:

宣告兩組 VideoSetting 物件,一組作為 RTSP 720p 影像串流(提供手機或電腦即時監看並疊加 OSD),另一組專門作為神經網路(NN)輸入。configNN 設定為 576×320、20 FPS 與 VIDEO_RGB 格式,精準符合客製化 YOLOv7-tiny 模型的輸入長寬比與推論頻率。

VideoSetting config(VIDEO_HD, 30, VIDEO_H264, 0);          // RTSP 串流 1280x720 (16:9)
VideoSetting configNN(NNWIDTH, NNHEIGHT, 20, VIDEO_RGB, 0); // NN 輸入 576x320,20 FPS

config.setBitrate(2 * 1024 * 1024);
Camera.configVideoChannel(CHANNEL, config);
Camera.configVideoChannel(CHANNELNN, configNN);
Camera.videoInit();

因為 AMB82 在小車機構上是上下顛倒安裝,所以也需要將取得的畫面旋轉 180 度才可以。CAMERA_ORIENTATION 設定為 3,代表進行 180 度(水平鏡像 + 垂直翻轉)。必須在 Camera.channelBegin() 啟動頻道後呼叫 isp_set_orientation(3),使 ISP 在底層完成硬體翻轉,確保 RTSP 畫面、OSD 標籤與神經網路辨識座標皆為正立。

#define CAMERA_ORIENTATION 3

extern "C" {
    int isp_set_orientation(int val);
}

// 在 setup() 內、啟動影像頻道後執行:
if (CAMERA_ORIENTATION > 0) {
    isp_set_orientation(CAMERA_ORIENTATION);
}

神經網路部分,使用 CUSTOMIZED_YOLOV7TINY 從 SD 卡載入自訂訓練的模型檔(NN_MDL/yolov7_tiny.nb),涵蓋 line、park、speed、stop 四個類別。透過 ObjDet.configThreshold() 在 NPU 底層設定一次性信心門檻(0.60f),低於此門檻的候選框直接在底層過濾,完全免除微控制器二次判定的效能浪費。

ObjDet.configVideo(configNN);
ObjDet.setResultCallback(ODPostProcess);
ObjDet.modelSelect(OBJECT_DETECTION, CUSTOMIZED_YOLOV7TINY, NA_MODEL, NA_MODEL);
ObjDet.configThreshold(NN_CONF_THRESHOLD, NN_NMS_THRESHOLD); // 信心度 60%、NMS 30%
ObjDet.begin();

為避免在數公尺外遠遠看到標誌就提早煞車,程式加入了 BBox 寬度測距過濾機制。藉由計算標誌在畫面中的相對寬度比例,只有當寬度達到門檻(代表小車已接近標誌)時才觸發動作;若距離太遠,則在 OSD 畫面上標示 [FAR] 提示,車輛保持正常行駛。

float boxWidth = item.xMax() - item.xMin();
bool isFar = false;
if ((obj_type == LABEL_PARK  && boxWidth < MIN_SIGN_WIDTH_PARK) ||
    (obj_type == LABEL_SPEED && boxWidth < MIN_SIGN_WIDTH_SPEED) ||
    (obj_type == LABEL_STOP  && boxWidth < MIN_SIGN_WIDTH_STOP)) {
    isFar = true;
}

// 僅在非遠距標誌時觸發對應控制旗標
if (!isFar) {
    if (obj_type == LABEL_PARK)  frameParkDetected  = true;
    if (obj_type == LABEL_SPEED) frameSpeedDetected = true;
    if (obj_type == LABEL_STOP)  frameStopDetected  = true;
}

採用事件驅動(Event-driven)控制架構。捨棄傳統 loop() 內部 delay() 輪詢模式,直接在神經網路完成物件辨識的回呼函式 ODPostProcess() 結尾呼叫 processCarControl()。只要 NPU 一產出辨識結果,微秒之內立即計算轉向差速並寫入馬達 PWM,達成零等待響應。

void ODPostProcess(std::vector<ObjectDetectionResult> results) {
    // ... 解析物件、更新旗標、繪製 OSD ...

    // 事件驅動:NN 辨識完成瞬間立即執行控制,零輪詢延遲!
    processCarControl();
}

循線演算法以小車左側的黃色中線(line)為基準,選取畫面中最靠近車身底部的一段黃色中線,以其右側邊界加上預設車道偏移量 LINE_OFFSET 計算目標前進中心。透過 P 控制器計算偏差量 error,並換算為左右輪差速 steer。根據實車物理特性,左輪快右輪慢會向右轉,因此左輪為 baseSpeed + steer、右輪為 baseSpeed - steer。

float targetX = lineRightEdgeX + LINE_OFFSET;
float error = targetX - 0.5f;

int steer = (int)(KP * error);
int leftSpeed  = baseSpeed + steer;
int rightSpeed = baseSpeed - steer;

leftSpeed  = constrain(leftSpeed,  MIN_SPEED, MAX_SPEED);
rightSpeed = constrain(rightSpeed, MIN_SPEED, MAX_SPEED);
motorDrive(leftSpeed, rightSpeed);

當遇到直角彎或急彎導致鏡頭短暫看不到左側黃色中線時,小車不會在原地亂晃,而是切換至丟線無條件左轉尋線模式。藉由左輪停止(0)、右輪前進(70)的差速,主動把視野外左側的跑道線段拉回鏡頭視野內;若連續遺失超過 LINE_LOST_MAX 幀(預設 80 幀,約 4 秒)仍未見黃色中線,則觸發安全停車機制。

if (lineDetected) {
    // 正常 P 控制器循線 ...
} else if (LINE_LOST_MAX == 0 || lineLostCount < LINE_LOST_MAX) {
    // 丟線無條件向左轉尋線
    int leftSpeed  = SEARCH_LEFT_SPEED_L; // 0
    int rightSpeed = SEARCH_LEFT_SPEED_R; // 70
    motorDrive(leftSpeed, rightSpeed);
} else {
    // 超時安全煞停
    motorStop();
}

Stop 標誌具備停車 2 秒與防重複觸發冷卻機制。當偵測到 stop 標誌且距離達標時,小車煞停並啟動計時器;2 秒後自動恢復行駛,並隨即進入 5 秒的冷卻防護期(STOP_COOLDOWN_MS),確保小車起步後尚未完全駛離標誌區時,不會被同一個標誌連續二次觸發。

// 停車 2 秒倒數
if (stopHandling) {
    motorStop();
    if (millis() - stopStartTime >= STOP_DURATION_MS) {
        stopHandling = false;
        inStopCooldown = true;
        stopCooldownEnd = millis() + STOP_COOLDOWN_MS; // 啟動 5 秒防重複觸發冷卻
    }
    return;
}

// 新觸發(冷卻期內不重複觸發)
if (stopDetected && !inStopCooldown) {
    motorStop();
    stopHandling = true;
    stopStartTime = millis();
    return;
}

在背景 loop() 任務中,以按鈕 A(Pin 18)搭配 50ms 軟體防彈跳作為小車的動力總開關。開機時預設停止,按下按鈕切換為運轉模式;同時 I2C 128×64 OLED 即時顯示小車目前 IP、RTSP 埠號、模型狀態與馬達運轉狀態(RUNNING 或 STOPPED)。

int reading = digitalRead(BUTTON_PIN);
if (reading != lastButtonState) {
    lastDebounceTime = now;
}
if ((now - lastDebounceTime) > debounceDelay) {
    if (reading != currentStableState) {
        currentStableState = reading;
        if (currentStableState == LOW) {
            motorEnabled = !motorEnabled; // 切換馬達運轉狀態
            if (!motorEnabled) motorStop();
            updateOLED();
        }
    }
}
lastButtonState = reading;

完整程式碼:

記得輸入自己的Wi-Fi 名稱與密碼

/*
 * AMB82 MINI 自駕小車 — YOLOv7-tiny 循線 + 交通標誌辨識控制
 *
 * 功能:
 * 1. 從 SD 卡載入自訂 YOLOv7-tiny 模型 (NN_MDL/yolov7_tiny.nb)
 * 2. 偵測 line / park / speed / stop 四種標籤
 * 3. 根據 line 的位置進行差速循線(P 控制器)
 * 4. park  → 完全靜止(直到標誌消失)
 *    speed → 減速行駛(直到標誌消失後恢復)
 *    stop  → 停車 2 秒再啟動(含冷卻防重複觸發)
 * 5. WiFi + RTSP 串流(OSD 疊加辨識框)
 *
 * 硬體接線:
 *   右輪馬達 M1A = Pin 4 (正轉), M1B = Pin 1 (反轉)
 *   左輪馬達 M2A = Pin 5 (正轉), M2B = Pin 2 (反轉)
 *   攝影機旋轉 180° (isp_set_orientation(3))
 *   SD 卡內建 MicroSD 槽
 *
 * Arduino IDE 設定:
 *   Tools > Load NN Model > Load SD
 */

#include "WiFi.h"
#include "StreamIO.h"
#include "VideoStream.h"
#include "RTSP.h"
#include "NNObjectDetection.h"
#include "VideoStreamOverlay.h"
#include <Wire.h>
#include <Adafruit_OLED_libraries/Adafruit_GFX.h>
#include <Adafruit_OLED_libraries/Adafruit_SSD1306.h>

// =====================================================================
//  YOLOv7-tiny 模型類別定義(原 ObjectClassList.h 整合)
// =====================================================================
#define LABEL_LINE  0
#define LABEL_PARK  1
#define LABEL_SPEED 2
#define LABEL_STOP  3

struct ObjectDetectionItem {
    uint8_t index;
    const char* objectName;
    uint8_t filter;
};

// 自訂 YOLOv7-tiny 模型的 4 個類別(Index 必須與模型訓練時的 label 順序一致)
// filter = 1 表示啟用偵測,0 表示忽略
ObjectDetectionItem itemList[4] = {
    {LABEL_LINE,  "line",  1},    // 小車左側的黃色中線(用於循線)
    {LABEL_PARK,  "park",  1},    // 停車標誌 → 完全靜止
    {LABEL_SPEED, "speed", 1},    // 限速標誌 → 減速行駛
    {LABEL_STOP,  "stop",  1},    // 停車再開標誌 → 停 2 秒再啟動
};

// =====================================================================
//  OLED 顯示器 (SSD1306 I2C 128x64)
// =====================================================================
#define SCREEN_WIDTH   128
#define SCREEN_HEIGHT   64
#define OLED_RESET      -1
#define SCREEN_ADDRESS 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
bool oledFound = false;

// =====================================================================
//  硬體腳位定義
// =====================================================================
#define M1A 4    // 右輪正轉 (PWM)
#define M1B 1    // 右輪反轉 (PWM)
#define M2A 5    // 左輪正轉 (PWM)
#define M2B 2    // 左輪反轉 (PWM)
#define BUTTON_PIN 18  // 按鈕腳位(INPUT_PULLUP,按下為 LOW)

// =====================================================================
//  速度參數(可依實際測試調整)
// =====================================================================
#define NORMAL_SPEED      100    // 正常行駛速度 (PWM 0~255)
#define SLOW_SPEED         80    // 減速行駛速度 (偵測到 speed 標誌)
#define MIN_SPEED           0    // 最小速度
#define MAX_SPEED         255    // 最大速度

// =====================================================================
//  循線參數(可依實際測試調整)
// =====================================================================
#define LINE_OFFSET      0.25f   // line 右邊緣到道路中央的偏移量(歸一化座標 0~1)
#define KP                90    // P 控制器比例增益(調大轉向更猛,見可調參數指南)
#define LINE_LOST_MAX      80    // 連續丟失 line 的最大 frame 數(超過則安全停車;設為 0 表示持續左轉不超時)

// =====================================================================
//  丟線尋線參數(未偵測到 line 時無條件左轉)
// =====================================================================
#define SEARCH_LEFT_SPEED_L    0   // 丟線無條件左轉時左輪速度 (差速轉向)
#define SEARCH_LEFT_SPEED_R   70   // 丟線無條件左轉時右輪速度 (右快左慢帶動向左前進轉向)

// =====================================================================
//  NN 信心指數門檻(0.0~1.0)
//  由 ObjDet.configThreshold() 設定一次性全域門檻,NPU 底層直接過濾,無二次判定
// =====================================================================
#define NN_CONF_THRESHOLD     0.60f   // 一次信心門檻(調高至 60%,低於此值的結果在底層直接捨棄)
#define NN_NMS_THRESHOLD      0.30f   // NMS 重疊抑制門檻

// =====================================================================
//  交通標誌觸發尺寸門檻(歸一化寬度 0.0~1.0,未達門檻表示距離太遠,不觸發動作)
// =====================================================================
#define MIN_SIGN_WIDTH_PARK   0.08f   // park 標誌寬度需 >= 畫面 8% 才完全停車
#define MIN_SIGN_WIDTH_SPEED  0.05f   // speed 標誌寬度需 >= 畫面 5% 才進入減速
#define MIN_SIGN_WIDTH_STOP   0.10f   // stop 標誌寬度需 >= 畫面 10% 才停車 2 秒

// =====================================================================
//  效能優化與除錯開關(加速控制迴圈、降低延遲)
// =====================================================================
#define ENABLE_DEBUG_PRINT    0   // 設為 0 關閉每幀高頻 Serial/printf 輸出,大幅降低 UART 阻塞延遲(預設關閉以加速)
#define ENABLE_OSD_DRAW       1   // 1: 在 RTSP 串流繪製辨識框; 0: 完全關閉 OSD 繪圖以釋放 CPU 與記憶體頻寬

// =====================================================================
//  Stop 標誌參數
// =====================================================================
#define STOP_DURATION_MS  2000   // stop 停車持續時間(毫秒)
#define STOP_COOLDOWN_MS  5000   // stop 冷卻期(毫秒,防止重複觸發)

// =====================================================================
//  Speed 標誌參數
// =====================================================================
#define SPEED_LOST_FRAMES   100   // speed 消失後延遲恢復的 frame 數

// =====================================================================
//  攝影機 / NN / RTSP 設定
// =====================================================================
#define CHANNEL    0              // RTSP 串流頻道 (H264)
#define CHANNELNN  3              // NN 推論頻道 (RGB)
#define NNWIDTH    576            // NN 輸入寬度
#define NNHEIGHT   320            // NN 輸入高度
#define CAMERA_ORIENTATION 3      // 旋轉 180°(Mirror + Flip)

// ISP 硬體翻轉函式宣告
#ifdef __cplusplus
extern "C" {
#endif
int isp_set_orientation(int val);
#ifdef __cplusplus
}
#endif

// =====================================================================
//  全域物件
// =====================================================================
VideoSetting config(VIDEO_HD, 30, VIDEO_H264, 0);              // RTSP 串流 1280x720 (16:9,與 NN 長寬比匹配)
VideoSetting configNN(NNWIDTH, NNHEIGHT, 20, VIDEO_RGB, 0);     // NN 輸入 576x320,提升至 20fps 加快取像頻率
NNObjectDetection ObjDet;
RTSP rtsp;
StreamIO videoStreamer(1, 1);       // Camera → RTSP
StreamIO videoStreamerNN(1, 1);     // Camera → NN

// Wi-Fi 帳密(請依實際環境修改)
char ssid[] = "";  // 記得輸入自己的Wi-Fi 名稱
char pass[] = "";  // 記得輸入自己的Wi-Fi 密碼
int wifiStatus = WL_IDLE_STATUS;

IPAddress ip;
int rtsp_portnum;

// =====================================================================
//  控制狀態變數(由回呼函式更新,loop 中讀取)
// =====================================================================

// --- 循線相關 ---
volatile bool   lineDetected    = false;   // 本幀是否偵測到 line
volatile float  lineRightEdgeX  = 0.5f;    // line 右邊緣的歸一化 X 座標
volatile int    lineLostCount   = 0;       // 連續丟失 line 的 frame 計數

// --- 標誌旗標 ---
volatile bool   parkDetected    = false;   // 本幀是否偵測到 park
volatile bool   speedDetected   = false;   // 本幀是否偵測到 speed
volatile bool   stopDetected    = false;   // 本幀是否偵測到 stop

// --- Speed 減速控制 ---
volatile int    speedLostCount  = 0;       // speed 消失後的 frame 計數
bool            isSlowMode      = false;   // 目前是否處於減速模式

// --- Stop 停車再開控制 ---
bool            stopHandling    = false;   // 是否正在執行 stop 停車流程
unsigned long   stopStartTime   = 0;       // stop 開始停車的時間戳
unsigned long   stopCooldownEnd = 0;       // stop 冷卻期結束時間戳
bool            inStopCooldown  = false;   // 是否處於 stop 冷卻期

// --- 上一次的轉向紀錄(用於 line 丟失時維持方向) ---
int             lastSteerError  = 0;       // 上一次的轉向誤差

// --- 按鈕控制馬達開關 ---
bool            motorEnabled     = false;   // 馬達輸出開關(初始為關閉)
int             lastButtonState  = HIGH;    // 上一次按鈕狀態
int             currentStableState = HIGH;  // 穩定後的按鈕狀態
unsigned long   lastDebounceTime = 0;       // 消跳計時器
const unsigned long debounceDelay = 50;     // 消跳延遲 50ms

// --- 即時控制宣告 ---
void processCarControl();                   // 事件驅動車輛控制函式宣告

// =====================================================================
//  馬達控制函式
// =====================================================================

// 前進:左右輪分別設定速度(差速控制)
void motorDrive(int leftSpeed, int rightSpeed) {
    // 左輪
    if (leftSpeed >= 0) {
        analogWrite(M2A, constrain(leftSpeed, 0, MAX_SPEED));
        analogWrite(M2B, 0);
    } else {
        analogWrite(M2A, 0);
        analogWrite(M2B, constrain(-leftSpeed, 0, MAX_SPEED));
    }
    // 右輪
    if (rightSpeed >= 0) {
        analogWrite(M1A, constrain(rightSpeed, 0, MAX_SPEED));
        analogWrite(M1B, 0);
    } else {
        analogWrite(M1A, 0);
        analogWrite(M1B, constrain(-rightSpeed, 0, MAX_SPEED));
    }
}

// 完全停止
void motorStop() {
    analogWrite(M1A, 0);
    analogWrite(M1B, 0);
    analogWrite(M2A, 0);
    analogWrite(M2B, 0);
}

// =====================================================================
//  OLED 畫面更新函式
// =====================================================================
void updateOLED() {
    if (!oledFound) return;

    display.clearDisplay();
    display.setTextSize(1);
    display.setTextColor(SSD1306_WHITE);

    display.setCursor(0, 0);
    display.println(F("AMB82 Smart Car"));
    display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);

    display.setCursor(0, 14);
    display.print(F("IP: "));
    display.println(ip);

    display.setCursor(0, 26);
    display.print(F("RTSP Port: "));
    display.println(rtsp_portnum);

    display.setCursor(0, 38);
    display.println(F("Model: YOLOv7-tiny"));

    display.setCursor(0, 50);
    display.print(F("Motor: "));
    display.println(motorEnabled ? F("RUNNING [ON]") : F("STOPPED [OFF]"));

    display.display();
}

// =====================================================================
//  setup()
// =====================================================================
void setup() {
    Serial.begin(115200);

    // ----- OLED 初始化 -----
    if (display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
        oledFound = true;
        display.clearDisplay();
        display.setTextSize(1);
        display.setTextColor(SSD1306_WHITE);
        display.setCursor(0, 0);
        display.println(F("Smart Car Booting..."));
        display.display();
        Serial.println("[INFO] OLED initialized.");
    } else {
        oledFound = false;
        Serial.println("[WARN] OLED not found, skipping.");
    }

    // 馬達腳位初始化
    pinMode(M1A, OUTPUT);
    pinMode(M1B, OUTPUT);
    pinMode(M2A, OUTPUT);
    pinMode(M2B, OUTPUT);
    motorStop();  // 開機先停車

    // 按鈕初始化(內部上拉,按下為 LOW)
    pinMode(BUTTON_PIN, INPUT_PULLUP);

    // ----- WiFi 連線 -----
    Serial.println("\n[INFO] Connecting to WiFi...");
    while (wifiStatus != WL_CONNECTED) {
        Serial.print("Attempting to connect to WPA SSID: ");
        Serial.println(ssid);
        wifiStatus = WiFi.begin(ssid, pass);
        delay(2000);
    }
    ip = WiFi.localIP();
    Serial.print("[INFO] WiFi connected. IP: ");
    Serial.println(ip);

    // ----- 攝影機設定 -----
    config.setBitrate(2 * 1024 * 1024);  // RTSP 2Mbps
    Camera.configVideoChannel(CHANNEL, config);
    Camera.configVideoChannel(CHANNELNN, configNN);
    Camera.videoInit();

    // ----- RTSP 串流 -----
    rtsp.configVideo(config);
    rtsp.begin();
    rtsp_portnum = rtsp.getPort();

    // ----- NN 物件偵測設定 -----
    // 使用 CUSTOMIZED_YOLOV7TINY 從 SD 卡載入自訂模型
    // SD 卡路徑: NN_MDL/yolov7_tiny.nb
    ObjDet.configVideo(configNN);
    ObjDet.setResultCallback(ODPostProcess);
    ObjDet.modelSelect(OBJECT_DETECTION, CUSTOMIZED_YOLOV7TINY, NA_MODEL, NA_MODEL);
    // 設定全域一次性信心門檻與 NMS 門檻(單層過濾,NPU 底層直接捨棄 < NN_CONF_THRESHOLD 的候選框)
    ObjDet.configThreshold(NN_CONF_THRESHOLD, NN_NMS_THRESHOLD);
    ObjDet.begin();

    // ----- StreamIO: Camera → RTSP -----
    videoStreamer.registerInput(Camera.getStream(CHANNEL));
    videoStreamer.registerOutput(rtsp);
    if (videoStreamer.begin() != 0) {
        Serial.println("[ERROR] StreamIO Camera->RTSP link failed");
    }
    Camera.channelBegin(CHANNEL);

    // ----- StreamIO: Camera → NN -----
    videoStreamerNN.registerInput(Camera.getStream(CHANNELNN));
    videoStreamerNN.setStackSize();
    videoStreamerNN.setTaskPriority();
    videoStreamerNN.registerOutput(ObjDet);
    if (videoStreamerNN.begin() != 0) {
        Serial.println("[ERROR] StreamIO Camera->NN link failed");
    }
    Camera.channelBegin(CHANNELNN);

    // 攝影機旋轉 180°(因機構倒裝)
    // 必須在 Camera.channelBegin() 之後呼叫,ISP 才能正確套用硬體翻轉
    delay(500);
    if (CAMERA_ORIENTATION > 0) {
        isp_set_orientation(CAMERA_ORIENTATION);
    }

    // ----- OSD 疊加層 -----
    OSD.configVideo(CHANNEL, config);
    OSD.begin();

    // ----- 列印啟動資訊 -----
    Serial.println("\n==============================================");
    Serial.println("   AMB82 MINI Smart Car — Ready!");
    Serial.println("==============================================");
    Serial.print("RTSP URL: rtsp://");
    Serial.print(ip);
    Serial.print(":");
    Serial.println(rtsp_portnum);
    Serial.println("Model: CUSTOMIZED_YOLOV7TINY (SD Card)");
    Serial.println("Labels: line, park, speed, stop");
    Serial.println("==============================================\n");

    // ----- OLED 顯示連線資訊與初始狀態 -----
    updateOLED();
}

// =====================================================================
//  NN 辨識結果回呼函式
//  - 由 NN 推論執行緒呼叫(約 10fps)
//  - 更新全域旗標,繪製 OSD
// =====================================================================
void ODPostProcess(std::vector<ObjectDetectionResult> results) {
    uint16_t im_h = config.height();
    uint16_t im_w = config.width();

    // 本幀的暫存旗標
    bool   frameLineDetected  = false;
    float  frameLineRightX    = 0.0f;
    float  frameLineBottomY   = 0.0f;   // 追蹤最靠近車身的 line(yMax 最大)
    bool   frameParkDetected  = false;
    bool   frameSpeedDetected = false;
    bool   frameStopDetected  = false;

#if ENABLE_OSD_DRAW
    OSD.createBitmap(CHANNEL);
#endif

    if (ObjDet.getResultCount() > 0) {
        for (int i = 0; i < ObjDet.getResultCount(); i++) {
            int obj_type = results[i].type();

            // 檢查 index 是否在有效範圍內,且 filter 為啟用
            if (obj_type >= 0 && obj_type < 4 && itemList[obj_type].filter) {
                ObjectDetectionResult item = results[i];

                // 計算此目標的歸一化寬度 (0.0~1.0) 與是否為遠距離標誌
                float boxWidth = item.xMax() - item.xMin();
                bool isFar = false;
                if ((obj_type == LABEL_PARK  && boxWidth < MIN_SIGN_WIDTH_PARK) ||
                    (obj_type == LABEL_SPEED && boxWidth < MIN_SIGN_WIDTH_SPEED) ||
                    (obj_type == LABEL_STOP  && boxWidth < MIN_SIGN_WIDTH_STOP)) {
                    isFar = true;
                }

#if ENABLE_OSD_DRAW || ENABLE_DEBUG_PRINT
                // 座標轉換(歸一化 0~1 → 像素座標)
                int xmin = (int)(item.xMin() * im_w);
                int xmax = (int)(item.xMax() * im_w);
                int ymin = (int)(item.yMin() * im_h);
                int ymax = (int)(item.yMax() * im_h);
#endif

#if ENABLE_OSD_DRAW
                // 依類別設定不同的 OSD 顏色
                uint32_t boxColor;
                switch (obj_type) {
                    case LABEL_LINE:  boxColor = OSD_COLOR_GREEN;  break;
                    case LABEL_PARK:  boxColor = OSD_COLOR_RED;    break;
                    case LABEL_SPEED: boxColor = OSD_COLOR_YELLOW; break;
                    case LABEL_STOP:  boxColor = OSD_COLOR_ORANGE; break;
                    default:          boxColor = OSD_COLOR_WHITE;   break;
                }

                // 繪製辨識框
                OSD.drawRect(CHANNEL, xmin, ymin, xmax, ymax, 3, boxColor);

                // 繪製標籤文字 + 信心分數(遠處標誌標記 [FAR])
                char text_str[32];
                if (obj_type != LABEL_LINE && isFar) {
                    snprintf(text_str, sizeof(text_str), "%s %d [FAR]", itemList[obj_type].objectName, item.score());
                } else {
                    snprintf(text_str, sizeof(text_str), "%s %d", itemList[obj_type].objectName, item.score());
                }
                OSD.drawText(CHANNEL, xmin, ymin - OSD.getTextHeight(CHANNEL), text_str, OSD_COLOR_CYAN);
#endif

                // --- 更新各類別偵測旗標(交通標誌需達最小寬度才觸發動作) ---
                switch (obj_type) {
                    case LABEL_LINE:
                        frameLineDetected = true;
                        // 選取畫面最下方的 line(yMax 最大 = 最靠近車身)
                        // 用該 line 的右邊緣 xMax 作為循線參考
                        if (item.yMax() > frameLineBottomY) {
                            frameLineBottomY = item.yMax();
                            frameLineRightX  = item.xMax();
                        }
                        break;
                    case LABEL_PARK:
                        if (!isFar) {
                            frameParkDetected = true;
                        }
                        break;
                    case LABEL_SPEED:
                        if (!isFar) {
                            frameSpeedDetected = true;
                        }
                        break;
                    case LABEL_STOP:
                        if (!isFar) {
                            frameStopDetected = true;
                        }
                        break;
                }

#if ENABLE_DEBUG_PRINT
                // Serial 除錯輸出
                printf("[OD] %s (score:%d, w:%.2f%s) x:[%d-%d] y:[%d-%d]\r\n",
                    itemList[obj_type].objectName, item.score(), boxWidth,
                    isFar ? " [FAR]" : "", xmin, xmax, ymin, ymax);
#endif
            }
        }
    }

#if ENABLE_OSD_DRAW
    // 繪製畫面中心參考線(垂直白色虛線,方便觀察循線偏差)
    int centerX = im_w / 2;
    OSD.drawLine(CHANNEL, centerX, 0, centerX, im_h, 1, OSD_COLOR_WHITE);

    OSD.update(CHANNEL);
#endif

    // --- 更新全域控制變數 ---
    lineDetected   = frameLineDetected;
    parkDetected   = frameParkDetected;
    speedDetected  = frameSpeedDetected;
    stopDetected   = frameStopDetected;

    if (frameLineDetected) {
        lineRightEdgeX = frameLineRightX;
        lineLostCount  = 0;
    } else {
        lineLostCount++;
    }

    // Speed 消失計數
    if (frameSpeedDetected) {
        speedLostCount = 0;
    } else {
        if (isSlowMode) {
            speedLostCount++;
        }
    }

    // =========================================================
    //  事件驅動:NN 辨識完成瞬間立即執行控制,零輪詢延遲!
    // =========================================================
    processCarControl();
}

// =====================================================================
//  車輛控制函式(由 ODPostProcess 辨識完成後立即呼叫,零等待延遲)
// =====================================================================
void processCarControl() {
    if (!motorEnabled) {
        motorStop();
        return;
    }

    unsigned long now = millis();

    // =========================================================
    //  優先級 1:Park — 完全靜止
    // =========================================================
    if (parkDetected) {
        motorStop();
        Serial.println("[CTRL] PARK detected -> FULL STOP");
        return;
    }

    // =========================================================
    //  優先級 2:Stop 正在執行中(等待 2 秒計時)
    // =========================================================
    if (stopHandling) {
        motorStop();
        unsigned long elapsed = now - stopStartTime;
        if (elapsed >= STOP_DURATION_MS) {
            // 停車 2 秒結束,恢復行駛
            stopHandling = false;
            inStopCooldown = true;
            stopCooldownEnd = now + STOP_COOLDOWN_MS;
            Serial.println("[CTRL] STOP 2s done -> Resume driving (cooldown started)");
        }
        return;
    }

    // =========================================================
    //  優先級 3:Stop 新觸發(非冷卻期內)
    // =========================================================
    if (stopDetected && !inStopCooldown) {
        motorStop();
        stopHandling = true;
        stopStartTime = now;
        Serial.println("[CTRL] STOP detected -> Begin 2s stop");
        return;
    }

    // =========================================================
    //  決定當前基礎速度(Speed 減速 or 正常)
    // =========================================================
    int baseSpeed = NORMAL_SPEED;
    if (speedDetected) {
        isSlowMode = true;
        speedLostCount = 0;
        baseSpeed = SLOW_SPEED;
        Serial.println("[CTRL] SPEED detected -> Slow mode");
    } else if (isSlowMode) {
        if (speedLostCount >= SPEED_LOST_FRAMES) {
            isSlowMode = false;
            baseSpeed = NORMAL_SPEED;
            Serial.println("[CTRL] SPEED gone -> Normal speed restored");
        } else {
            baseSpeed = SLOW_SPEED;
        }
    }

    // =========================================================
    //  循線與丟線搜尋控制(未偵測到 line 時無條件左轉尋線)
    // =========================================================
    if (lineDetected) {
        // --- 正常循線:P 控制器差速導正 ---
        float targetX = lineRightEdgeX + LINE_OFFSET;
        float error = targetX - 0.5f;

        // P 控制器差速轉向(配合實體小車動力回授)
        // 當目標偏右 (error > 0) 時,左輪減速、右輪加速導正路線
        int steer = (int)(KP * error);
        int leftSpeed  = baseSpeed + steer;
        int rightSpeed = baseSpeed - steer;

        // 限制速度範圍
        leftSpeed  = constrain(leftSpeed,  MIN_SPEED, MAX_SPEED);
        rightSpeed = constrain(rightSpeed, MIN_SPEED, MAX_SPEED);

        // 左右差速驅動(M2 為左輪,M1 為右輪)
        motorDrive(leftSpeed, rightSpeed);

#if ENABLE_DEBUG_PRINT
        printf("[CTRL] Line follow: target=%.2f err=%.2f steer=%d L=%d R=%d base=%d\r\n",
            targetX, error, steer, leftSpeed, rightSpeed, baseSpeed);
#endif
    } else if (LINE_LOST_MAX == 0 || lineLostCount < LINE_LOST_MAX) {
        // --- 未偵測到 line:無條件左轉尋線(線段在左側鏡頭未拍到) ---
        int leftSpeed  = SEARCH_LEFT_SPEED_L;
        int rightSpeed = SEARCH_LEFT_SPEED_R;

        // 若處於減速模式,等比例降速以策安全
        if (isSlowMode) {
            leftSpeed  = (leftSpeed * SLOW_SPEED) / NORMAL_SPEED;
            rightSpeed = (rightSpeed * SLOW_SPEED) / NORMAL_SPEED;
        }

        leftSpeed  = constrain(leftSpeed,  MIN_SPEED, MAX_SPEED);
        rightSpeed = constrain(rightSpeed, MIN_SPEED, MAX_SPEED);

        motorDrive(leftSpeed, rightSpeed);

#if ENABLE_DEBUG_PRINT
        printf("[CTRL] Line lost (%d/%d): Unconditional LEFT turn L=%d R=%d\r\n",
            lineLostCount, LINE_LOST_MAX, leftSpeed, rightSpeed);
#endif
    } else {
        // 連續超過 LINE_LOST_MAX 幀都找不到 line → 安全停車
        motorStop();
        Serial.println("[CTRL] Line LOST too long -> STOP for safety");
    }
}

// =====================================================================
//  loop() — 背景按鈕輪詢與狀態更新
// =====================================================================
void loop() {
    unsigned long now = millis();

    // === 檢查 Stop 冷卻期是否結束 ===
    if (inStopCooldown && now >= stopCooldownEnd) {
        inStopCooldown = false;
        Serial.println("[CTRL] Stop cooldown ended.");
    }

    // =========================================================
    //  按鈕讀取 + 防彈跳 + 切換馬達開關 (Button A - Pin 18)
    // =========================================================
    int reading = digitalRead(BUTTON_PIN);
    if (reading != lastButtonState) {
        lastDebounceTime = millis();
    }
    if ((millis() - lastDebounceTime) > debounceDelay) {
        if (reading != currentStableState) {
            currentStableState = reading;
            if (currentStableState == LOW) {  // 按下的瞬間(下降緣觸發切換)
                motorEnabled = !motorEnabled;
                if (!motorEnabled) {
                    motorStop();  // 關閉時立即停車
                }
                updateOLED();     // 同步更新 OLED 狀態
                Serial.print("[BTN] Motor ");
                Serial.println(motorEnabled ? "ENABLED (RUNNING)" : "DISABLED (STOPPED)");
            }
        }
    }
    lastButtonState = reading;

    // 若馬達開關關閉,確保馬達停止
    if (!motorEnabled) {
        motorStop();
    }

    delay(10);  // 快速輪詢按鈕並釋放 CPU 給 FreeRTOS / NN
}

5-3. 實際執行

透過Arduino IDE 將程式下載至AMB82 MINI。

透過Arduino IDE 將程式下載至AMB82 MINI。

打開電源後除了連上網路外,為了保護車子突然暴衝,一開始會關閉馬達輸出。

打開電源後除了連上網路外,為了保護車子突然暴衝,一開始會關閉馬達輸出。

只要按下「按鈕 A」即可讓車子開始自動駕駛。

只要按下「按鈕 A」即可讓車子開始自動駕駛。

將登月小車放在跑道上,並在跑道上佈置標誌。

將登月小車放在跑道上,並在跑道上佈置標誌。

記得打開VLC 實時觀看辨識狀況。

記得打開VLC 實時觀看辨識狀況。

5-4. 完整可調參數指南 (Tunable Parameters)

1. 速度控制參數

參數名稱預設值建議範圍功能詳細說明與實車影響
NORMAL_SPEED10080 ~ 140小車直線正常巡航速度 (PWM 0~255)。
• 調大:直線加速快、過彎慣性大,對轉向靈敏度要求高。
• 調小:行駛穩定,適合小半徑賽道。
SLOW_SPEED8060 ~ 90限速模式行駛速度 (偵測到 speed 標誌時)。
• 當偵測到限速標誌時的基準巡航速度。
• 需低於 NORMAL_SPEED,但不得低於馬達死區門檻(低於 50 可能卡死)。
MIN_SPEED00馬達輸出下限保護值,防止差速計算出負值造成非預期的劇烈倒退。
MAX_SPEED255200 ~ 255馬達輸出上限保護值,防止 PWM 溢位。

2. 循線幾何與 P 控制器參數

參數名稱預設值建議範圍功能詳細說明與實車影響
LINE_OFFSET0.25f0.10f ~ 0.35f黃色中線右側到小車前進中軸的偏移量(歸一化 0~1)。
• 調大(如 0.28f):車身往右靠,與左側黃色中線拉開更大安全距離,防止左側輪胎壓線。
• 調小(如 0.15f):車身貼近左側黃色中線行駛。
• 若賽道車道較寬,建議設定在 0.22f ~ 0.28f 之間。
KP9060 ~ 130P 控制器比例增益 (Proportional Gain)。
• 控制轉向的修正力矩大小(steer = KP × error)。
• 調大(如 110):轉向更猛烈,能迅速切入大角度急彎,但容易出現 S 型蛇行晃動。
• 調小(如 75):走線平穩流暢,但若過大彎可能轉向不足衝出賽道。
LINE_LOST_MAX8040 ~ 120連續丟失黃色中線的最大畫格數。
• 以 20 FPS 計算,80 幀約為 4 秒搜尋時間。
• 超過此幀數依然未見黃色中線時觸發安全緊急煞車。
• 若設為 0:表示小車將持續旋轉搜尋,永不超時停車。

3. 丟線尋線(無條件左轉)參數

參數名稱預設值建議範圍功能詳細說明與實車影響
SEARCH_LEFT_SPEED_L00 ~ 40丟線尋線時左輪速度。
• 設為 0:左輪固定停止,以左輪為圓心進行單邊快速左轉。
• 設為負值(如 -30):左右輪反向原地旋轉。
• 設為小正值(如 30):邊向前切邊向左弧線轉彎。
SEARCH_LEFT_SPEED_R7060 ~ 100丟線尋線時右輪速度。
• 右輪動力提供向左轉向的外側推力。
• 數值越大左轉推力越強、找線半徑越緊湊。

4. NPU 推論與信心指數門檻

參數名稱預設值建議範圍功能詳細說明與實車影響
NN_CONF_THRESHOLD0.60f0.50f ~ 0.70fNPU 單層全域信心指數門檻。
• 0.60f 代表信心度需 ≥ 60%。
• 低於此門檻的候選框直接在 NPU 底層拋棄,不進入微控制器記憶體。
• 賽場光線充足時建議設為 0.60f ~ 0.65f,極佳消除背景雜訊。
NN_NMS_THRESHOLD0.30f0.20f ~ 0.40fNMS (非極大值抑制) 重疊度門檻。
• 用於消除同一個物體周圍重複出現的多個重疊候選框。

5. 交通標誌觸發尺寸門檻 (測距過濾)

💡 原理:影像中標誌框的寬度比例(0.0 ~ 1.0)直接反映車輛與標誌的物理距離。未達此寬度表示標誌太遠,畫面標示 [FAR] 且不動作。

參數名稱預設值建議範圍觸發動作與調校重點
MIN_SIGN_WIDTH_PARK0.08f0.06f ~ 0.12fpark 停車標誌最小寬度(預設 8% 畫面寬度)。
• 達標時觸發:完全煞停直到標誌離開。
• 調大可讓小車行駛到標誌正前方更近處才煞停。
MIN_SIGN_WIDTH_SPEED0.05f0.03f ~ 0.08fspeed 限速標誌最小寬度(預設 5% 畫面寬度)。
• 達標時觸發:切換至減速模式 (SLOW_SPEED)。
• 限速標誌通常立於路旁且需提早反應,設定較低以利提早降速。
MIN_SIGN_WIDTH_STOP0.10f0.08f ~ 0.14fstop 停車再開標誌最小寬度(預設 10% 畫面寬度)。
• 達標時觸發:煞停 2 秒後再自動啟動。

6. 交通標誌時序參數

參數名稱預設值建議範圍功能詳細說明
STOP_DURATION_MS20001000 ~ 3000stop 煞停維持時間(毫秒)。
• 倒數計時期間馬達強制完全靜止輸出。
STOP_COOLDOWN_MS50003000 ~ 7000stop 停車後冷卻防護時間(毫秒)。
• 停車 2 秒結束起步後,小車在冷卻期內不再重複觸發同一個 stop 標誌,直到駛離標誌區。
SPEED_LOST_FRAMES10040 ~ 150speed 標誌離開視野後的減速維持幀數。
• 標誌出鏡後維持約 3~5 秒緩行(100 幀),之後平穩恢復為 NORMAL_SPEED。

7. 效能優化與除錯開關

參數名稱預設值可選值功用與效益說明
ENABLE_DEBUG_PRINT00 / 1每畫格串口除錯列印開關。
• 0 (預設關閉):消除高頻 printf,每幀節省 5~15ms UART 阻塞等待,實車競賽推薦關閉。
• 1 (開啟除錯):每幀輸出座標、BBox 寬度、偏差量與左右馬達速度。
ENABLE_OSD_DRAW10 / 1RTSP 影像 OSD 框繪製開關。
• 1 (預設開啟):在視訊畫面上繪製色彩邊框、標籤與 [FAR] 狀態。
• 0 (脫機極速):完全關閉 OSD 繪圖與顯存拷貝,釋放 CPU 給即時差速。

結論

以上就是所有的教學內容了,從基礎操作到蒐集資料,還經歷了最重要的模型訓練與轉換,看到登月小車跑起來是不是很感動呢!如果不順利,可以依照「可調參數指南」調整,希望大家學習愉快,下個專案再見!

系列文章

  1. (一)基礎認識
  2. (二)資料蒐集
  3. (三)資料標註
  4. (四)訓練模型與轉換
  5. (五)自動駕駛(本篇)