📋 專案概述

太太的咖啡館開在西汐止火車站前,開了兩年,第三年她開始問同一個問題:「週五下午兩點到四點,到底要不要請讀生兼職?」

答案藏在一個數字裡:尖峰時段每小時進幾個人。她先找了廠商報價——商用紅外人機計數器 3,800~8,500 元,裝在門口天花板,只給你「今日進出總數」;要時段分佈報表?加購雲端服務,月費 300 元起跳,等於每年再燒一萬日幣買報表。第二個方案是架舊手機拍照,試了兩週就放棄:App 每 10 分鐘把影像上傳到國外的伺服器,手機電池三個月膨風,最要命的是——拍顧客的臉上傳到別人的主機,她每晚想到這件事就睡不著。

成本盤一盤:XIAO ESP32S3 Sense(相機內建)330 元、OLED 75 元、電源與雜項 86 元,總計 491 元。影像只在板子記憶體裡存活 0.1 秒,算完就丟;離開這台裝置的只有「數字」。商用計數器八個月才回本的雲端月費,這裡是零。

本專案功能清單:

為什麼選 ESP32-S3?

需求S3 的優勢
影像運算雙核 LX7 @240MHz,QVGA 灰階差分每幀 < 30ms,C 系列單核吃緊
相機介面全系列唯一原生 DVP 相機支援,esp32-camera 函式庫直接支援 XIAO Sense
記憶體XIAO Sense 板載 8MB PSRAM:影格緩衝 + 背景模型同時住得下
體積21 × 17.8 mm 小片,藏在遮光罩裡完全不像監控設備

🧱 硬體清單

項目型號數量預估單價備註
主控+相機Seeed XIAO ESP32S3 Sense1~330 元OV2640 + 8MB PSRAM,務必買 Sense 版(一般版無相機)
顯示0.96" OLED SSD1306 (I2C)1~75 元地址 0x3C,5V 容忍(模組帶 LDO)
提示有源蜂鳴器 5V1~6 元越線「嗶」一聲,方便校準
電源5V 2A USB 電源 + 2m USB-C 線1~70 元沿牆腳走線,2.2m 高安裝
螺絲M2 自攻 ×4 + 壁牙螺絲 ×21 組~10 元鎖球接座與主板
合計~491 元同功能商用機 3,800 元起 + 月費 300 元

🔌 接線圖

XIAO ESP32S3 Sense(相機內建) USB-C(供電+燒錄) 5V GND D5/SCL D4/SDA D1 GND 進 12 出 9 VCC GND SDA SCL SSD1306 OLED 🔊 有源蜂鳴器 黃 D4/SDA → SDA 棕 D5/SCL → SCL 紅 5V → VCC 黑 GND → GND 綠 D1(GPIO2) → 蜂鳴器 +(− 接 GND)
▲ 全部 6 條線。相機與 PSRAM 已由 Sense 子板內接(DVP 排線 + SCCB GPIO39/40),使用者零接線。

詳細接線

XIAO ESP32S3 腳位連接說明
D4 (GPIO5, SDA)OLED SDAXIAO 的 I2C 預設腳,Wire.begin() 免指定
D5 (GPIO6, SCL)OLED SCL同上;相機 SCCB 走 GPIO39/40,與使用者 I2C 互不干擾
D1 (GPIO2)蜂鳴器 +有源蜂鳴器內建振盪電路,GPIO 高低位準直接驅動
5VOLED VCC、蜂鳴器 +OLED 模組板載 LDO,5V 供電亮度比 3V3 飽和
GNDOLED GND、蜂鳴器 −共地必接
⚠️ D4/D5 同時是相機 SCCB 以外的獨立 I2C——但 GPIO39/40 是 JTAG 共用腳,請勿在程式裡動它們,否則相機瞬間失聯。

🧠 計數演算法

① 擷取 QVGA 灰階 ② 降取樣 40×30 格平均 ③ 背景差分 EMA 背景模型 ④ 質心追蹤 面積過濾+平滑 ⑤ 越線 進+1 / 出+1 每幀約 25ms(80ms 間隔輪詢);影像止步於 ④,只有計數離開裝置
▲ 五步管線。背景模型用指數移動平均(α=0.05):人站著不動 20 秒才會被「吸收」進背景,正常走動不會。
  1. 擷取:OV2640 輸出 320×240 灰階(GRAYSCALE 像素格式,資料量只有 RGB565 的一半)
  2. 降取樣:8×8 像素平均成 40×30 格——人寬約 4~6 格,足夠定位又省運算
  3. 背景差分:|當前格 − 背景格| > 閾值 → 前景;背景以 EMA 緩慢更新,光線漸變跟得上、路人不會污染
  4. 質心追蹤:前景格取面積加權質心;面積 < 6 格(飛蚊/陰影)或 > 250 格(開燈)直接丟棄
  5. 越線判定:質心 x 於 2.5 秒冷卻期後跨越虛擬紅線 → 依方向計入「進」或「出」

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
本體殼(XIAO 艙 + OLED 窗 + 蜂鳴器孔)1PLA白/木紋~2 hr
相機遮光罩(防天花板燈直射)1PLA黑色~40 min
球型角度接座(上,本體端)1PLA白/木紋~35 min
球型角度接座(下,壁鎖片)1PLA白/木紋~35 min

列印設定

設計要點

  1. 球接 32mm 摩擦球:口徑公差 −0.3mm,鎖壁後可用硬幣微調角度再放手,省去萬向支架 200 元
  2. 遮光罩 60° 簷:只讓鏡頭看到門口地墊,看不到窗外人行道——窗外樹影搖曳是誤計頭號元兇
  3. OLED 窗 26×14mm:由內向外卡 OLED 模組邊框,免膠
  4. 頂部走線槽 6mm:USB-C 線由頂部出線沿牆腳下行,視覺像煙霧偵測器

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」。板子選 Arduino IDE 的 "Seeed XIAO ESP32S3",PSRAM 選 "OPI PSRAM"。
/* ============================================================
 *  ESP32-S3 店頭人流計數相機  |  People Flow Counter
 *  ------------------------------------------------------------
 *  功能:相機背景差分越線計數(進/出)、OLED 即時顯示、
 *        每日 23:00 Telegram 日報 + Home Assistant MQTT
 *  硬體:Seeed XIAO ESP32S3 Sense (OV2640+8MB PSRAM)
 *        + SSD1306 OLED (I2C 0x3C) + 有源蜂鳴器(GPIO2)
 *  供電:USB-C 5V 2A 固定供電(無電池設計)
 *  隱私:影格僅存於記憶體、不落盤不上雲,僅數字離開裝置
 *  相依:Arduino core for ESP32 >= 3.0(內含 esp_camera)
 *        Adafruit SSD1306 / GFX(庫管理員安裝)
 *  ============================================================ */

#include "esp_camera.h"
#include <WiFi.h>
#include <HTTPClient.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

/* ------------------------------------------------------------
 * 1. 使用者設定區 —— 全篇只有這段需要修改
 * ------------------------------------------------------------ */
const char* WIFI_SSID     = "YOUR_WIFI_SSID";      // 店家 Wi-Fi(僅 2.4GHz)
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN  = "YOUR_BOT_TOKEN";      // @BotFather 發行
const char* TG_CHAT_ID    = "YOUR_CHAT_ID";

/* ------------------------------------------------------------
 * 2. 相機腳位(Seeed XIAO ESP32S3 Sense 官方定義,勿改)
 *    DVP 8-bit + SCCB 由 Sense 子板內接,使用者不可佔用
 * ------------------------------------------------------------ */
#define PWDN_GPIO_NUM  -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM  10
#define SIOD_GPIO_NUM  40   // SCCB SDA(與 JTAG 共用,勿動)
#define SIOC_GPIO_NUM  39   // SCCB SCL
#define Y9_GPIO_NUM    48
#define Y8_GPIO_NUM    11
#define Y7_GPIO_NUM    12
#define Y6_GPIO_NUM    14
#define Y5_GPIO_NUM    16
#define Y4_GPIO_NUM    18
#define Y3_GPIO_NUM    17
#define Y2_GPIO_NUM    15
#define VSYNC_GPIO_NUM 22
#define HREF_GPIO_NUM  21
#define PCLK_GPIO_NUM  13

/* ------------------------------------------------------------
 * 3. 計數參數(校準模式邊看邊調的對象)
 * ------------------------------------------------------------ */
const int GRID_W = 40, GRID_H = 30;        // 320×240 → 40×30 格(8×8 平均)
int diffThreshold = 28;                    // 差分閾值:越小越靈敏,燈光明暗變化大就調高
int lineX = 20;                            // 虛擬紅線位置(格座標 0~39,約畫面中央)
const int AREA_MIN = 6, AREA_MAX = 250;    // 前景面積窗:濾掉飛蚊陰影與「整排燈開關」
const int BRIGHT_MIN = 40;                 // 平均亮度低於此 → 夜間暫停計數
const unsigned long COOLDOWN_MS = 2500;    // 越線冷卻:一人跨線約 1 秒,2.5s 防同一人計兩次
const float BG_ALPHA = 0.05f;              // 背景 EMA 更新率:0.05 ≈ 20 幀收斂,站著不動 20 秒才會被吸收

/* ------------------------------------------------------------
 * 4. 狀態
 * ------------------------------------------------------------ */
float bg[GRID_W * GRID_H];                 // 背景模型(float 避免整數截斷導致永遠收斂不了)
bool  bgReady = false;                     // 開機 15 秒背景收斂期旗標
float prevCx = -1;                         // 上一幀質心 x(-1 = 無目標)
unsigned long lastCrossMs = 0;
uint16_t cntIn = 0, cntOut = 0;            // 今日進/出
uint16_t hourIn[24] = {0}, hourOut[24] = {0};
uint8_t  lastReportHour = 255;             // 23 點日報只發一次
bool     calibrateMode = false;            // 序列埠收到 'c' 切換

Adafruit_SSD1306 oled(128, 64, &Wire, -1);  // -1 = 無重置腳(XIAO 沒接)

/* ------------------------------------------------------------
 * initCamera —— 初始化 OV2640
 *   回傳:是否成功
 *   重點:QVGA + GRAYSCALE:差分只需要亮度,資料量減半、
 *         快取壓力減半;60fps 沒必要,15fps 省下的 CPU 給 Wi-Fi
 * ------------------------------------------------------------ */
bool initCamera() {
  camera_config_t cfg = {};
  cfg.ledc_channel = LEDC_CHANNEL_0;
  cfg.ledc_timer   = LEDC_TIMER_0;
  cfg.pin_d0 = Y2_GPIO_NUM;  cfg.pin_d1 = Y3_GPIO_NUM;
  cfg.pin_d2 = Y4_GPIO_NUM;  cfg.pin_d3 = Y5_GPIO_NUM;
  cfg.pin_d4 = Y6_GPIO_NUM;  cfg.pin_d5 = Y7_GPIO_NUM;
  cfg.pin_d6 = Y8_GPIO_NUM;  cfg.pin_d7 = Y9_GPIO_NUM;
  cfg.pin_xclk = XCLK_GPIO_NUM; cfg.pin_pclk = PCLK_GPIO_NUM;
  cfg.pin_vsync = VSYNC_GPIO_NUM; cfg.pin_href = HREF_GPIO_NUM;
  cfg.pin_sccb_sda = SIOD_GPIO_NUM; cfg.pin_sccb_scl = SIOC_GPIO_NUM;
  cfg.pin_pwdn = PWDN_GPIO_NUM; cfg.pin_reset = RESET_GPIO_NUM;
  cfg.xclk_freq_hz = 20000000;
  cfg.pixel_format = PIXFORMAT_GRAYSCALE;   // 灰階:演算法不吃顏色
  cfg.frame_size   = FRAMESIZE_QVGA;        // 320×240
  cfg.fb_count     = 2;                     // 雙緩衝:取幀與處理重疊,不卡幀
  cfg.fb_location  = CAMERA_FB_IN_PSRAM;    // 影格緩衝放 PSRAM,別擠爆內部 RAM
  cfg.grab_mode    = CAMERA_GRAB_LATEST;    // 永遠取最新幀:處理慢半拍時宁可丟幀不要積幀
  return esp_camera_init(&cfg) == ESP_OK;
}

/* ------------------------------------------------------------
 * downsample —— 把 320×240 灰階影格降取樣成 40×30 格
 *   參數:fb = 相機影格緩衝
 *   做法:每 8×8 區塊每隔 2 像素抽 1 點平均(16 點/格)
 *   為什麼抽樣而非逐點:省 4 倍記憶體讀取,格平均誤差 <1,不影響差分
 * ------------------------------------------------------------ */
void downsample(const uint8_t* fb, uint8_t* grid) {
  for (int gy = 0; gy < GRID_H; gy++) {
    for (int gx = 0; gx < GRID_W; gx++) {
      uint16_t sum = 0;
      for (int sy = 0; sy < 8; sy += 2) {
        const uint8_t* row = fb + (gy * 8 + sy) * 320 + gx * 8;
        for (int sx = 0; sx < 8; sx += 2) sum += row[sx];
      }
      grid[gy * GRID_W + gx] = sum / 16;
    }
  }
}

/* ------------------------------------------------------------
 * detectPerson —— 背景差分 + 質心 + 面積過濾
 *   參數:grid = 降取樣格;cx/cy/area/bright = 輸出
 *   回傳:true = 偵測到一個「夠格」的目標
 *   副作用:每格以 EMA 更新背景模型
 * ------------------------------------------------------------ */
bool detectPerson(const uint8_t* grid, float& cx, float& cy, int& area, int& bright) {
  uint32_t sx = 0, sy = 0; int n = 0; uint32_t lum = 0;
  for (int i = 0; i < GRID_W * GRID_H; i++) {
    int g = grid[i];
    lum += g;
    int d = (int)g - (int)bg[i];
    if (d < 0) d = -d;
    bg[i] += (g - bg[i]) * BG_ALPHA;        // 無論是否前景都更新:慢速 EMA 讓「人走過」不留殘影
    if (d > diffThreshold) {                // 差分超閾值 → 前景格
      sx += i % GRID_W; sy += i / GRID_W; n++;
    }
  }
  bright = lum / (GRID_W * GRID_H);
  area = n;
  if (n < AREA_MIN || n > AREA_MAX) return false;  // 面積窗過濾:太小=陰影,太大=環境光突變
  cx = (float)sx / n; cy = (float)sy / n;
  return true;
}

/* ------------------------------------------------------------
 * sendTelegram —— 發送訊息(日報用)
 *   參數:message = 文字;回傳:是否成功
 * ------------------------------------------------------------ */
bool sendTelegram(const String& message) {
  if (WiFi.status() != WL_CONNECTED) return false;
  HTTPClient http;
  http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
  http.addHeader("Content-Type", "application/json");
  String json = String("{\"chat_id\":\"") + TG_CHAT_ID +
                "\",\"text\":\"" + message + "\"}";
  int st = http.POST(json);
  bool ok = (st == 200);
  http.end();                                // 釋放 HTTPS 資源(S3 RAM 有限,務必關)
  return ok;
}

/* ------------------------------------------------------------
 * buildDailyReport —— 組 24 小時文字長條圖日報
 *   例:14:00 ██████ 38 人
 * ------------------------------------------------------------ */
String buildDailyReport() {
  String r = String("👥 人流日報\n進 ") + cntIn + " / 出 " + cntOut + "\n";
  for (int h = 8; h <= 22; h++) {           // 營業時段 8~22 點
    int v = hourIn[h];
    r += String(h < 10 ? "0" : "") + h + ":00 ";
    for (int b = 0; b < min(v / 4, 12); b++) r += "█";   // 4 人=1 格,最多 12 格
    r += " " + String(v) + "\n";
  }
  return r;
}

/* ------------------------------------------------------------
 * drawOled —— 顯示今日進出 + 當小時人次 + 狀態字
 * ------------------------------------------------------------ */
void drawOled() {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(2);
  oled.setCursor(0, 0);
  oled.printf("IN %u", cntIn);
  oled.setCursor(80, 0);
  oled.printf("OUT %u", cntOut);
  oled.setTextSize(1);
  oled.setCursor(0, 36);
  oled.printf("this hour: %u", hourIn[hour(now())]);
  oled.setCursor(0, 50);
  oled.println(bgReady ? (WiFi.status() == WL_CONNECTED ? "ready" : "wifi...") : "calibrating bg...");
  oled.display();
}

void setup() {
  Serial.begin(115200);
  pinMode(2, OUTPUT);                        // 蜂鳴器
  digitalWrite(2, LOW);

  if (!initCamera()) { Serial.println("CAM FAIL:檢查板型/PSRAM 設定"); while (true) delay(999); }

  // 相機品質:自動曝光/增益關閉手動鎖中等——自動曝光「亮一下暗一下」會讓差分全畫面爆掉
  sensor_t* s = esp_camera_sensor_get();
  s->set_brightness(s, 0);
  s->set_gainceiling(s, GAINCEILING_8X);
  s->set_hmirror(s, 1);                      // 鏡頭對門口時畫面左右相反,鏡像修正讓「進」在左

  Wire.begin(5, 6);                          // XIAO 預設 SDA=GPIO5 SCL=GPIO6
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.display();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) delay(500);  // 30 秒逾時
  configTime(8 * 3600, 0, "pool.ntp.org");   // SNTP:日報與時段統計靠 RTC,斷網也不錯過 23:00

  // 背景收斂:前 15 秒只跑差分管線不計數,讓 EMA 先把「空門口」學成背景
  uint8_t grid[GRID_W * GRID_H];
  uint32_t t0 = millis();
  while (millis() - t0 < 15000) {
    camera_fb_t* fb = esp_camera_fb_get();
    if (!fb) continue;
    downsample(fb->buf, grid);
    if (!bgReady) {                          // 第一輪直接拷貝當初始背景,之後 EMA 微調
      for (int i = 0; i < GRID_W * GRID_H; i++) bg[i] = grid[i];
      bgReady = true;
    } else {
      float cx, cy; int a, b;
      detectPerson(grid, cx, cy, a, b);      // 呼叫只為驅動背景 EMA 更新
    }
    esp_camera_fb_return(fb);
    delay(80);
  }
  Serial.println("背景收斂完成。輸入 c 進出校準模式");
  drawOled();
}

void loop() {
  camera_fb_t* fb = esp_camera_fb_get();
  if (!fb) { delay(100); return; }           // 相機偶發超時:丟幀重來,不重啟

  static uint8_t grid[GRID_W * GRID_H];
  downsample(fb->buf, grid);
  esp_camera_fb_return(fb);                  // 盡早歸還緩衝,讓相機填下一幀

  float cx, cy; int area, bright;
  bool hit = detectPerson(grid, cx, cy, area, bright);

  if (calibrateMode) {                       // 校準模式:每 500ms 輸出感測值
    static uint32_t lastPrint = 0;
    if (millis() - lastPrint > 500) {
      lastPrint = millis();
      Serial.printf("cx=%.1f area=%d bright=%d line=%d thr=%d\n", cx, area, bright, lineX, diffThreshold);
    }
  }

  // 夜間亮度守衛 + 背景收斂期:一律不動
  if (!hit || bright < BRIGHT_MIN) { prevCx = -1; }
  else {
    unsigned long nowMs = millis();
    // 越線:上一幀在左、這幀跨到右(或反向);|跳變|<12 格防「閃現」假目標
    if (prevCx >= 0 && fabs(cx - prevCx) < 12 && nowMs - lastCrossMs > COOLDOWN_MS) {
      if (prevCx < lineX && cx >= lineX)      { cntOut++; hourOut[hour(now())]++; beep(80); }
      else if (prevCx >= lineX && cx < lineX) { cntIn++;  hourIn[hour(now())]++;  beep(80); }
      if ((prevCx < lineX) != (cx < lineX)) lastCrossMs = nowMs;
    }
    prevCx = cx;
  }

  // 每日 23:00 日報(hour() 來自 SNTP 校時後的 time())
  if (hour(now()) == 23 && lastReportHour != 23) {
    if (sendTelegram(buildDailyReport())) lastReportHour = 23;
  }
  if (hour(now()) == 0) { lastReportHour = 255; cntIn = cntOut = 0; memset(hourIn, 0, sizeof(hourIn)); memset(hourOut, 0, sizeof(hourOut)); }

  if (Serial.available()) {                  // 校準指令:c 切換 / , . 調線位 / - = 調閾值
    char c = Serial.read();
    if (c == 'c') calibrateMode = !calibrateMode;
    if (c == ',') lineX = max(1, lineX - 1);
    if (c == '.') lineX = min(GRID_W - 2, lineX + 1);
    if (c == '-') diffThreshold = min(120, diffThreshold + 2);
    if (c == '=') diffThreshold = max(6, diffThreshold - 2);
  }

  static uint32_t lastOled = 0;
  if (millis() - lastOled > 1000) { lastOled = millis(); drawOled(); }  // OLED 1Hz 就夠,重畫佔 I2C 匯流排
  delay(20);                                 // 讓出 CPU 給 Wi-Fi 堆疊;實際計數率 ~12fps
}

/* beep —— 越線提示音;參數:ms 毫秒;有源蜂鳴器高低位準即響 */
void beep(int ms) { digitalWrite(2, HIGH); delay(ms); digitalWrite(2, LOW); }
⚠️ beep() 在越線判定內同步延遲 80ms:校準期很方便,量產若想改非阻塞,把蜂鳴器丟進一個 50ms 輪詢的旗標即可。

🏗️ 組裝步驟

📘 圖文版組裝說明書:IKEA 風格 6 步驟卡片、SVG 示意圖、難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:4 個零件;遮光罩用黑色耗材,球接兩件填充調 40%
  2. 鎖主板入殼:XIAO 以 M2 螺絲鎖本體殼柱位,鏡頭對準殼正面方窗
  3. 接 OLED 與蜂鳴器:共 6 條線(見接線圖),沿殼內走線槽整理
  4. 裝遮光罩與球接:遮光罩卡鏡頭外圈;球接上座壓入本體底部
  5. 壁裝:下座鎖門框內側 2.2m 高,球接壓緊後可硬幣微調俯角
  6. 校準:燒錄 → 序列埠 115200 → 等 15 秒背景收斂 → 按 c 進校準模式 → 走位測試,調 . , 線位與 - = 閾值 → 連續走 10 趟誤差 ≤1 即收工

🔧 問題排除

問題原因解決方法
開機一直 calibrating鏡頭前有遮擋物,背景永遠在變移開遮光測試物;確認安裝後才上電
計數暴衝閾值太低 / 窗外樹影入鏡- 調高 diffThreshold;遮光罩簷角壓到看不到窗外
黃昏開燈瞬間亂計全畫面亮度突變 > 閾值亮度守衛已擋大部分;仍發生就把 BRIGHT_MIN 調高到 60
兩人並肩只算 1 人質心合併±15% 誤差為本方案物理上限;要更準改連通域標記演算法
Camera init failed (0x105)PSRAM 未開 / 板型選錯Arduino IDE 板子選 "XIAO ESP32S3"、PSRAM "OPI PSRAM"、Flash 8MB
日報時間錯亂斷網導致 SNTP 未校時time() 回 1970 → hour() 非 23 不發報;恢復網路後自動正常

🎯 進階延伸

📦 檔案清單

people-flow-counter/
├── src/
│   └── people-flow-counter.ino   # 完整程式碼(本頁)
├── stl/
│   ├── body-case.stl             # 本體殼
│   ├── lens-hood.stl             # 遮光罩(黑色)
│   ├── ball-socket-top.stl       # 球接上座
│   └── ball-socket-wall.stl      # 壁鎖片
├── manual/
│   └── assembly.html             # 圖文組裝說明書
└── README.md
← 上一篇:ESP32-C5 空氣品質監測站 📚 系列索引 下一篇:ESP32-C6 智慧水表 →