📋 專案概述
太太的咖啡館開在西汐止火車站前,開了兩年,第三年她開始問同一個問題:「週五下午兩點到四點,到底要不要請讀生兼職?」
答案藏在一個數字裡:尖峰時段每小時進幾個人。她先找了廠商報價——商用紅外人機計數器 3,800~8,500 元,裝在門口天花板,只給你「今日進出總數」;要時段分佈報表?加購雲端服務,月費 300 元起跳,等於每年再燒一萬日幣買報表。第二個方案是架舊手機拍照,試了兩週就放棄:App 每 10 分鐘把影像上傳到國外的伺服器,手機電池三個月膨風,最要命的是——拍顧客的臉上傳到別人的主機,她每晚想到這件事就睡不著。
成本盤一盤:XIAO ESP32S3 Sense(相機內建)330 元、OLED 75 元、電源與雜項 86 元,總計 491 元。影像只在板子記憶體裡存活 0.1 秒,算完就丟;離開這台裝置的只有「數字」。商用計數器八個月才回本的雲端月費,這裡是零。
本專案功能清單:
- 虛擬紅線越線計數:背景差分 + 質心追蹤,「左進右出」方向可校準
- 影像零外流:無 SD 卡、無雲端、無影像串流——只有計數離開裝置,顧客隱私與店家都安心
- OLED 即時顯示今日進/出與當小時人次,收銀台瞄一眼就好
- 每天 23:00 Telegram 日報(24 小時文字長條圖)+ Home Assistant MQTT 即時值
- 夜間亮度守衛:畫面太暗自動暫停,開關燈不誤計
- 序列埠校準模式:即時輸出質心位置,手把手調靈敏度與線位
為什麼選 ESP32-S3?
| 需求 | S3 的優勢 |
|---|---|
| 影像運算 | 雙核 LX7 @240MHz,QVGA 灰階差分每幀 < 30ms,C 系列單核吃緊 |
| 相機介面 | 全系列唯一原生 DVP 相機支援,esp32-camera 函式庫直接支援 XIAO Sense |
| 記憶體 | XIAO Sense 板載 8MB PSRAM:影格緩衝 + 背景模型同時住得下 |
| 體積 | 21 × 17.8 mm 小片,藏在遮光罩裡完全不像監控設備 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控+相機 | Seeed XIAO ESP32S3 Sense | 1 | ~330 元 | OV2640 + 8MB PSRAM,務必買 Sense 版(一般版無相機) |
| 顯示 | 0.96" OLED SSD1306 (I2C) | 1 | ~75 元 | 地址 0x3C,5V 容忍(模組帶 LDO) |
| 提示 | 有源蜂鳴器 5V | 1 | ~6 元 | 越線「嗶」一聲,方便校準 |
| 電源 | 5V 2A USB 電源 + 2m USB-C 線 | 1 | ~70 元 | 沿牆腳走線,2.2m 高安裝 |
| 螺絲 | M2 自攻 ×4 + 壁牙螺絲 ×2 | 1 組 | ~10 元 | 鎖球接座與主板 |
| 合計 | ~491 元 | 同功能商用機 3,800 元起 + 月費 300 元 |
🔌 接線圖
詳細接線
| XIAO ESP32S3 腳位 | 連接 | 說明 |
|---|---|---|
| D4 (GPIO5, SDA) | OLED SDA | XIAO 的 I2C 預設腳,Wire.begin() 免指定 |
| D5 (GPIO6, SCL) | OLED SCL | 同上;相機 SCCB 走 GPIO39/40,與使用者 I2C 互不干擾 |
| D1 (GPIO2) | 蜂鳴器 + | 有源蜂鳴器內建振盪電路,GPIO 高低位準直接驅動 |
| 5V | OLED VCC、蜂鳴器 + | OLED 模組板載 LDO,5V 供電亮度比 3V3 飽和 |
| GND | OLED GND、蜂鳴器 − | 共地必接 |
⚠️ D4/D5 同時是相機 SCCB 以外的獨立 I2C——但 GPIO39/40 是 JTAG 共用腳,請勿在程式裡動它們,否則相機瞬間失聯。
🧠 計數演算法
- 擷取:OV2640 輸出 320×240 灰階(GRAYSCALE 像素格式,資料量只有 RGB565 的一半)
- 降取樣:8×8 像素平均成 40×30 格——人寬約 4~6 格,足夠定位又省運算
- 背景差分:|當前格 − 背景格| > 閾值 → 前景;背景以 EMA 緩慢更新,光線漸變跟得上、路人不會污染
- 質心追蹤:前景格取面積加權質心;面積 < 6 格(飛蚊/陰影)或 > 250 格(開燈)直接丟棄
- 越線判定:質心 x 於 2.5 秒冷卻期後跨越虛擬紅線 → 依方向計入「進」或「出」
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 本體殼(XIAO 艙 + OLED 窗 + 蜂鳴器孔) | 1 | PLA | 白/木紋 | ~2 hr |
| 相機遮光罩(防天花板燈直射) | 1 | PLA | 黑色 | ~40 min |
| 球型角度接座(上,本體端) | 1 | PLA | 白/木紋 | ~35 min |
| 球型角度接座(下,壁鎖片) | 1 | PLA | 白/木紋 | ~35 min |
列印設定
- 層厚:0.2mm;遮光罩內壁要光滑,改 0.16mm 減少階紋反光
- 填充:一般件 20%;球接兩件 40%(夾持力來源)
- 壁厚:遮光罩 3 層(0.96mm)防透光;其餘 2 層
- 支撐:僅球接上座需要,支撐設在與子板接觸面內側
設計要點
- 球接 32mm 摩擦球:口徑公差 −0.3mm,鎖壁後可用硬幣微調角度再放手,省去萬向支架 200 元
- 遮光罩 60° 簷:只讓鏡頭看到門口地墊,看不到窗外人行道——窗外樹影搖曳是誤計頭號元兇
- OLED 窗 26×14mm:由內向外卡 OLED 模組邊框,免膠
- 頂部走線槽 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 組裝說明書
- 列印零件:4 個零件;遮光罩用黑色耗材,球接兩件填充調 40%
- 鎖主板入殼:XIAO 以 M2 螺絲鎖本體殼柱位,鏡頭對準殼正面方窗
- 接 OLED 與蜂鳴器:共 6 條線(見接線圖),沿殼內走線槽整理
- 裝遮光罩與球接:遮光罩卡鏡頭外圈;球接上座壓入本體底部
- 壁裝:下座鎖門框內側 2.2m 高,球接壓緊後可硬幣微調俯角
- 校準:燒錄 → 序列埠 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 不發報;恢復網路後自動正常 |
🎯 進階延伸
- 雙鏡頭雙向驗證:兩台對裝,進出配對差 >3 時標記「存疑時段」,準確率從 ~85% 拉到 ~95%
- ESPHome 版:改用 esp32-camera + esp-idf component,直接併入 HA 生態系
- 停留熱區:把 40×30 格的前景計數累加成熱圖,週報看「哪面牆的菜單最吸睛」
- PoE 化:加 W5500 SPI 乙太模組,免拉 USB 長線、免 Wi-Fi 塞車
📦 檔案清單
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