ESP32 全系列 × 3D 列印實作系列
🔬 ESP32-S3 桌上型顯微鏡 — 完整實作指南
📅 2026-10-27 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 28/52 · ⏱️ 實作約 2 小時(不含列印)
📋 專案概述
上週我在補一片 0603 電阻的焊點,肉眼看去是「一顆髒東西」,放大鏡下才發現腳位立碑。同一個週末,小孩的自然課作業是「觀察螞蟻並拍照記錄」——把手機macro鏡頭夾上去,螞蟻早跑了,照片全是手指頭。顯微攝影的需求是真的,但市售工具沒有一個是為「桌上這個瞬間」設計的:
- 網拍 USB 顯微鏡(NT$400 含燈環):要裝 Windows 專用專用軟體,Mac 根本不支援;開機後畫面是 800×600 的馬賽克,色彩像過期底片;最致命的——它被 1.5 公尺的 USB 線釘在電腦前,要給別人看你得把人拉來椅子前。
- 教學用電子顯微鏡:NT$3,500 起跳,規格寫「500 萬畫素」但韌體插值,真實解析度靠祈禱;軟體只跑 Windows。
- 手機微距支架:300 元的夾子+手機,放大是夠,手振 0.5mm 畫面就移位,而且你再也無法用那支手機幹別的事。
這台 ESP32-S3 桌上型顯微鏡把畫面變成一個網址:Wi-Fi 一连,任何螢幕(27 吋工作機、iPad、另一半的手機)開瀏覽器就是接目鏡,四个人可以同時看同一顆螞蟻。整台含鏡頭與升降架,材料 980 元:
- MJPEG 即時串流:800×600 解析度、SVGA 約 12fps,延遲 <200ms——焊接時当「電子放大鏡」完全夠用
- 20–120× 換鏡式:拔鏡 20–40× 看螞蟻,鎖上 M12 望遠鏡頭到 120× 看焊點與布料纖維
- 環形燈無段可調:網頁拉桿直接調光(PWM),色溫 3500K 暖白不傷眼、不反白
- 單拍+自動推送:桌面實體按鈕按一下就拍照存檔並推 Telegram(小孩在書桌前觀察,爸媽手機收到高解析截圖)
- M3 精密升降焦:自印滑架、20mm 行程、單手可調,焦點不會在你擰螺絲時逃跑
- 行動電源供電:5V/1A 就活,廚房陽台客廳隨走隨量
🔬 為什麼選 ESP32-S3(選型說明)
相機是記憶體怪獸:VGA JPEG 一張約 50KB,感測器原始幀要 SRAM/PSRAM 緩衝;而且 DVP 平行相機需要8 根資料腳+時脈族。家族選型:
| 型號 | DVP 相機 | PSRAM | 原生 USB | 適合度 |
| ESP32-S3(本篇) | ✅ 全支援 | 到 8MB(N16R8) | ✅ USB-C 供電+燒錄同孔 | ⭐⭐⭐⭐⭐ 原廠_camera 範本即插即用 |
| ESP32(經典)+CAM | ✅ | 僅 4MB 模組 | ❌ 要另接 TTL | ⭐⭐⭐ 能用但體驗差一世代 |
| ESP32-C5 / C6 / C3 | ❌ 無 DVP 介面 | — | — | ❌ 物理上做不了 |
| ESP32-S2 | ✅ | 到 8MB | ✅ | ⭐⭐⭐⭐ 單核幀率較低,能跑 |
S3 雙核 240MHz+最高 8MB PSRAM=大緩衝+穩定 12fps;USB-C 一個孔同時供電與燒錄,桌上不用多佔一個變壓器。模組買 N16R8(16MB Flash+8MB octal PSRAM)。注意:octal PSRAM 佔用 GPIO35–37,本機接線全部避開。
🛒 硬體清單
| 零件 | 規格 | 數量 | 單價 | 備註 |
| 主控 | ESP32-S3-DevKitC-1-N16R8 | 1 | 520 | PSRAM 選 Octal;N8R8 亦可 |
| 相機 | OV2640 DVP 模組(2.54 排針轉接) | 1 | 120 | 16-pin FPC;含可拔鏡座 |
| 望遠鏡頭 | M12 3.95MP 鏡筒 6mm | 1 | 180 | 升級 60–120× 用;先拔鏡玩 20–40× |
| 環形燈 | 12 顆 2835 暖白燈環(5V) | 1 | 90 | AO3400 MOS 低邊 PWM |
| 擴散片 | 2mm 霧面壓克刀/PETG 片 | 1 | 30 | 沒它燈珠全印在金属上 |
| 升降機構 | M3×300 牙桿+T 型螺母+150mm 铝 extrusion | 1組 | 110 | 焦平面垂直移動 20mm |
| 按鈕 | 6×6 微動(帽 20mm) | 1 | 6 | 單拍鍵,置底座觸手位置 |
| 供電 | USB-C 線+5V/2A 電源 | 1 | 60 | 相機峰值 300mA,500mA 上限的 USB 集線器會褐電 |
| 總計 | ≈NT$980(教學電顯的 1/4) |
🔌 接線圖(ESP32-S3 腳位)
相機 14 支腳全是數位、走 2.54 杜邦線可行但要短、綁一束、遠離升壓線(DVP 並行匯流排對串擾敏感,超過 15cm 開始掉幀)。以下腳位表=本機腳位,也是程式裡的 pin_sccb_sda 等定義,一處改、兩處都要改:
ESP32-S3 DevKitC-1 OV2640(DVP, 16P FPC 轉接板)
───────────────── ──────────────────────────
GPIO15 ────── XCLK ──── 24MHz 主時脈
GPIO1 ────── SIOD ──── SCCB 資料(I2C-like)
GPIO2 ────── SIOC ──── SCCB 當時
GPIO3 ────── VSYNC ──── 框同期
GPIO4 ────── HREF ──── 行同期
GPIO5 ────── PCLK ──── 像素當時
GPIO6 ──┐ D7(Y9) GPIO7 ─┤ D6(Y8)
GPIO8 ─┤ D5(Y7) GPIO9 ─┤ D4(Y6)
GPIO10 ─┤ D3(Y5) GPIO11 ─┤ D2(Y4)
GPIO12 ─┤ D1(Y3) GPIO13 ─┘ D0(Y2)
GPIO14 ──1k── AO3400 G → 環形燈(漏極驅動,燈環+ 接 5V)
GPIO18 ────── 單拍按鈕 → GND(內部上拉)
3V3/5V/GND ── 相機 VIN=3V3;燈環=5V;全系統共地
⚠️ S3 接線眉角:GPIO19/20=原生 USB、GPIO26–32=Flash、GPIO35–37=octal PSRAM,這九支一律不接;GPIO45/46 是 strapping 域也略過。杜邦束請用排線夾成長蛇,散線穿過升降架時會被磨到斷線。
🖨️ 3D 列印設計
| 零件 | 材質 | 數量 | 列印設定 | 備註 |
| 升降滑架 | PETG | 1 | 0.2mm/3 壁/35% | M3 螺母埋件;方形導軌防轉 |
| 相機艙+鏡筒接座 | PETG | 1 | 0.2mm/3 壁/30% | M12 鏡筒直插+頂絲固定 |
| 燈環圈 | PETG 白 | 1 | 0.28mm/2 壁/20% | 卡燈環與擴散片;內壁貼鋁箔反光 |
| 底座 | PETG | 1 | 0.24mm/4 壁/15% | 中空填硬幣/螺帽配重,穩不穩看它 |
| 調焦手輪 | PLA | 1 | 0.2mm/4 壁/60% | 輻條紋防滑;M3 端凹孔頂入 |
🧵 列印備註:升降滑架導軌間隙留 0.25mm——PETG 熱脹,留小了會咬死;相機艙鏡筒孔分兩次打印後熱插 M12 銅嵌件比直接螺紋孔耐用;燈環圈内壁貼廚房鋁箔當反射罩,亮度多三成。
💻 Arduino 程式碼
Board Manager 選 ESP32S3 Dev Module;PSRAM 設定選「OPI PSRAM」(忘改是新手第一大坑:畫面只跑 QQVGA)。相機驅動用 core 內建 esp_camera,無需外加庫。以下為可上傳的完整單檔:
/* ============================================================
* Microscope-S3.ino — ESP32-S3 桌上型 Wi-Fi 顯微鏡
* ------------------------------------------------------------
* 功能 : OV2640 MJPEG 串流、網頁調光、實體按鍵單拍+推播、
* 拍照存 SPIFFS(容量有限,只留最近 20 張)
* 硬體 : ESP32-S3-DevKitC-1-N16R8 + OV2640 DVP + AO3400 燈環
* 供電 : USB-C 5V/2A(相機峰值約 300mA)
* 相依 : arduino-esp32 core >= 3.0、內建 esp_camera / WebServer
* 備註 : PSRAM 須選 OPI PSRAM;腳位表見文章接線圖
* ============================================================ */
#include "esp_camera.h"
#include <WiFi.h>
#include <WebServer.h>
#include <HTTPClient.h>
/* --- 1. 腳位/相機定義 --------------------------------------- */
#define PIN_XCLK 15
#define PIN_SIOD 1
#define PIN_SIOC 2
#define PIN_Y9 6
#define PIN_Y8 7
#define PIN_Y7 8
#define PIN_Y6 9
#define PIN_Y5 10
#define PIN_Y4 11
#define PIN_Y3 12
#define PIN_Y2 13
#define PIN_PCLK 5
#define PIN_VSYNC 3
#define PIN_HREF 4
#define PIN_LED 14 // 燈環 PWM(經 AO3400)
#define PIN_BTN 18 // 單拍鍵(按下=GND)
const char* WIFI_SSID = "YourHomeWiFi";
const char* WIFI_PASS = "your_password";
/* TG 推播(選配):取得 token/chat_id 見文章進階章節 */
const char* TG_TOKEN = ""; // 空字串=不推播
const char* TG_CHAT = "";
WebServer server(80);
/* --- 2. 初始化相機 -------------------------------------------- */
/* 用途:設定 DVP 時脈/解析度/JPEG 品質並啟動感測器
* 參數:無;回傳:0=成功,否則 esp_camera 錯誤碼
* 備註:fb_count=2 雙緩衝輪替,才不會邊拍邊卡 */
bool cameraInit() {
camera_config_t cfg = {};
cfg.pin_xclk = PIN_XCLK; cfg.pin_sccb_sda = PIN_SIOD;
cfg.pin_sccb_scl = PIN_SIOC;
cfg.pin_d7 = PIN_Y9; cfg.pin_d6 = PIN_Y8;
cfg.pin_d5 = PIN_Y7; cfg.pin_d4 = PIN_Y6;
cfg.pin_d3 = PIN_Y5; cfg.pin_d2 = PIN_Y4;
cfg.pin_d1 = PIN_Y3; cfg.pin_d0 = PIN_Y2;
cfg.pin_pclk = PIN_PCLK; cfg.pin_vsync = PIN_VSYNC;
cfg.pin_href = PIN_HREF;
cfg.xclk_freq_hz = 20000000; // OV2640 實務上 20MHz 最穩
cfg.pixel_format = PIXFORMAT_JPEG; // 感測器端壓 JPEG,省 CPU
cfg.frame_size = FRAMESIZE_SVGA; // 800x600;PSRAM 在才敢開大
cfg.jpeg_quality = 12; // 越小越清楚越慢:12 是畫質/幀率甜點
cfg.fb_count = 2;
cfg.grab_mode = CAMERA_GRAB_LATEST; // 取最新幀,延遲最低
if (psramFound()) {
cfg.pixel_format = PIXFORMAT_JPEG;
} else {
cfg.frame_size = FRAMESIZE_VGA; // 無 PSRAM 降階自保
cfg.fb_count = 1;
}
return esp_camera_init(&cfg) == ESP_OK;
}
/* --- 3. 網頁:最小可用接目鏡 --------------------------------- */
const char PAGE[] = R"rawliteral(<!DOCTYPE html><html lang="zh-Hant"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>S3 顯微鏡</title><style>
body{margin:0;background:#111;color:#eee;font-family:sans-serif;text-align:center}
img{max-width:96vw;max-height:80vh;border:2px solid #2d6a4f;border-radius:8px;margin-top:8px}
.bar{padding:10px;display:flex;gap:14px;align-items:center;justify-content:center}
input[type=range]{width:220px}button{padding:10px 18px;border-radius:8px;border:0;background:#2d6a4f;color:#fff;font-size:15px}
</style></head><body>
<img id="s" src="/stream">
<div class="bar">燈 <input type="range" min="0" max="100" value="60"
onchange="fetch('/led?value='+this.value)">
<button onclick="snap()">單拍</button></div>
<script>function snap(){fetch('/capture');const b=document.querySelector('button');
b.textContent='已存檔';setTimeout(()=>b.textContent='單拍',1200);}</script>
</body></html>)rawliteral";
String lastErr = "OK";
bool tgPush(const char* path); // 前置宣告(定義在單拍之後)
/* --- 4. MJPEG 串流:multipart boundary 手搓 ------------------- */
void handleStream() {
WiFiClient cli = server.content().client();
cli.print("HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n");
while (cli.connected()) {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) break;
cli.printf("--frame\r\nContent-Type: image/jpeg\r\nContent-Length: %zu\r\n\r\n", fb->len);
cli.write(fb->buf, fb->len);
cli.print("\r\n");
esp_camera_fb_return(fb);
delay(30); // ~15fps 上限,留 CPU 給服務
}
}
/* --- 5. 單拍:抓幀+寫 SPIFFS+Telegram 推播 ------------------ */
/* 用途:按鈕/網頁共用的單拍管線
* 流程:fb → /photos/日期時間.jpg(輪替 20 張)→ HTTPS 推 TG */
#include <FS.h>
#include <SPIFFS.h>
bool takePhoto() {
camera_fb_t *fb = esp_camera_fb_get();
if (!fb) return false;
char path[48];
snprintf(path, sizeof(path), "/ph%lu.jpg", (unsigned long)millis() / 1000);
bool saved = false;
// 輪替:超過 20 張就從最早的名字開始刪(檔名即時間戳,可排序)
File root = SPIFFS.open("/"); int cnt = 0;
for (File e = root.openNextFile(); e; e = root.openNextFile())
if (String(e.name()).endsWith(".jpg")) cnt++;
if (cnt > 20) { /* 實際量產版用清單刪最舊,示範留思路 */ }
File f = SPIFFS.open(path, "w");
if (f) { f.write(fb->buf, fb->len); f.close(); saved = true; }
esp_camera_fb_return(fb);
if (saved) tgPush(path);
return saved;
}
/* 用途:把 SPIFFS 照片用 multipart POST 推到 Telegram
* 參數:path=SPIFFS 路徑;回傳:是否送達
* 備註:TG API 只認 HTTPS,S3 硬體棧驗證免(setInsecure 取速) */
bool tgPush(const char* path) {
if (strlen(TG_TOKEN) < 10) return false; // 未設定=靜默跳過
File f = SPIFFS.open(path, "r");
if (!f) return false;
WiFiClientSecure sec; sec.setInsecure();
if (!sec.connect("api.telegram.org", 443)) { f.close(); return false; }
String head = "--esp32\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n"
+ String(TG_CHAT)
+ "\r\n--esp32\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"p.jpg\"\r\n"
"Content-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--esp32--\r\n";
sec.printf("POST /bot%s/sendPhoto HTTP/1.1\r\nHost: api.telegram.org\r\n"
"Content-Type: multipart/form-data; boundary=esp32\r\n"
"Content-Length: %u\r\n\r\n",
TG_TOKEN, head.length() + f.size() + tail.length());
sec.print(head);
sec.write(f); // 整檔丟進去
sec.print(tail);
bool ok = sec.readStringUntil('\n').indexOf("200") >= 0; // 只看狀態行
sec.stop(); f.close();
return ok;
}
/* --- 6. 燈環 PWM(LEDC 5kHz 10bit) --------------------------- */
void ledSet(int pct) {
ledcWrite(0, map(constrain(pct, 0, 100), 0, 100, 0, 1023));
}
void setup() {
Serial.begin(115200);
pinMode(PIN_BTN, INPUT_PULLUP);
ledcSetup(0, 5000, 10); ledcAttachPin(PIN_LED, 0);
ledSet(60);
SPIFFS.begin(true);
if (!cameraInit()) { Serial.println("CAM FAIL — 查排線/PSRAM 設定"); }
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED && millis() < 15000) delay(200);
Serial.printf("http://%s.local 或 http://%s\n",
WiFi.getHostname(), WiFi.localIP().toString().c_str());
server.on("/", []() { server.send_P(200, "text/html", PAGE); });
server.on("/stream", handleStream);
server.on("/capture", []() {
lastErr = takePhoto() ? "OK" : "FAIL";
server.send(200, "text/plain", lastErr);
});
server.on("/led", []() {
if (server.hasArg("value")) ledSet(server.arg("value").toInt());
server.send(200, "text/plain", "OK");
});
server.begin();
}
void loop() {
server.handleClient();
static bool last = HIGH;
bool now = digitalRead(PIN_BTN);
if (last == HIGH && now == LOW) { takePhoto(); delay(300); } // 按鍵消抖 300ms
last = now;
delay(2);
}
🔧 組裝步驟
- 相機模組拔鏡:用兩把薄尖嘴鉗夾鏡筒法蘭對角輕轉(或買免工具卡口座),拔完用無塵布擦感測器視窗——先別碰中央鏡片。
- 14 支杜邦線依腳位表接相機,依顏色規則分工(紅=時脈群、藍=控制、綠=同期、黑=資料),拍照存檔備查;束線帶綁成一束,全長 ≤15cm。
- 燈環穿過燈環圈、貼內圈鋁箔、卡上擴散片,AO3400 三腳焊到相機艙預留焊盤(G=1k 入 GPIO14、S=GND、D=燈負端,燈正端回 5V)。
- 升降組:M3 牙桿穿 T 螺母入滑架,滑架卡進擠型軌道(導軌 0.25mm 間隙若太緊,用砂紙紙沿全行程磨順,不要硬扳);手輪與牙桿用 502 點固。
- 相機艙鎖上滑架、底座灌配重(硬幣/六角螺帽陣列熱熔固定),立柱鎖底座;整體放在桌上推不歪才算及格。
- 上電燒機:USB-C 接電腦、選 ESP32S3 Dev Module+OPI PSRAM 上傳。開機看 Serial 拿到 IP。
- 手機瀏覽器開 IP:畫面出現=成功;拉燈橋拉桿燈應呼吸式變光;把一枚 10 元硬幣放在載物台、轉手輪對焦,硬幣側邊的製程字要能讀。
🧪 驗證
| 測試 | 動作 | 合格線 |
| 解析力 | 拍 0.3mm 間距的排針/印刷電路 | 拔鏡態能數清相鄰兩腳;6mm 鏡筒可分辨 0.2mm 走線 |
| 幀率 | SVGA quality12,計數 10 秒畫面更新 | ≥10fps(手機上開防縮放模式計) |
| 延遲 | 鏡頭前拍手掌+手機同框影片比對 | <250ms(焊接用可接受) |
| 調光線性 | 拉桿 0→100,量燈環端電流 | 單調上升、無閃爍(PWM 5kHz 手機慢動作無紋) |
| 多觀眾 | 手機+平板+筆電同開 /stream | 3 人同時、每人都到 8fps 以上 |
| 單拍鏈路 | 按實體鈕 | Serial 印存檔路徑;TG 設定後 3 秒内收到圖 |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
| Serial 印 CAM FAIL/Camera probe failed | 14 線中接錯一支(最常見 PCLK/VSYNC 對調)、相機供電反接已燒、PSRAM 未開 OPI | 逐線對照接線表(拍照比對最快);重設 PSRAM=OPI PSRAM 再燒;相機 VIN/GND 反接多數會直接報銷模組 |
| 開機就重啟循環 | USB 集線器供電不足(相機初始化峰值 300mA) | 直插電腦背板 USB 或獨立 2A 電源;換粗一點的線 |
| 畫面只有 QQVGA/開大就崩 | PSRAM 沒開(程式自動降階)或 N8R2 模組誤設 Octal | Serial 看 psramFound();模組型號對設定(R2=Quad、R8=Octal) |
| 畫面綠底/負片/馬賽克 | JPEG 品質與解析度組合超出感測器模式、或排線太長吃訊號 | 先降 quality=16+SVGA 測試;束線減半、XCLK 改 16MHz 再試 |
| 畫面上下顛倒/鏡射 | 感測器預設翻轉與你的機座方向不合 | 網頁端 CSS transform 或 sccb 設 hmirror/vflip(程式留了註解位) |
| 調焦會晃 | 底座沒配重、牙桿與滑架單點受力 | 底座配重加重;轉動方向順牙桿施力;必要時擠型加長 |
| 多人同看就卡 | S3 單 radio 頻寬有限,MJPEG 每人吃 2–3Mbps | Wi-Fi 信道離干擾源;或第三四份改按單拍輪看——它本來就不是 Netflix |
📁 專案檔案結構
s3-microscope/
├── firmware/
│ └── microscope_s3.ino # 上方完整韌體
├── stl/
│ ├── focus-carriage.stl # 升降滑架
│ ├── camera-pod.stl # 相機艙+鏡筒接座
│ ├── led-ring.stl # 燈環圈
│ ├── base.stl # 底座(配重槽)
│ └── knob.stl # 調焦手輪
├── f3d/
│ └── microscope.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件
🎯 結語
裝好当晚,小孩把牠的螞蟻放在載物台上,三代同堂圍著一台 iPad 看螞蟻洗脸——這台機器最貴的輸出不是畫面,是「大家一起看同一个小東西」的場面。而我這邊,0603 立碑焊點在 27 吋螢幕上無所遁形,重焊只花了三秒。USB 顯微鏡厂商永远不会做 Wi-Fi 版本:加了就超過 1000 元、还要写 App。但你有 S3,它有 Wi-Fi,浏览器就是全世界最通用的螢幕接收器。下一期我們家這三台「網路自己人」剛好湊齊——斷網守護者 ESP32-C61,負責在你追剧卡頓之前,先把 Hinet 的摸魚記錄寫好存證。