📋 專案概述

上週我在補一片 0603 電阻的焊點,肉眼看去是「一顆髒東西」,放大鏡下才發現腳位立碑。同一個週末,小孩的自然課作業是「觀察螞蟻並拍照記錄」——把手機macro鏡頭夾上去,螞蟻早跑了,照片全是手指頭。顯微攝影的需求是真的,但市售工具沒有一個是為「桌上這個瞬間」設計的:

這台 ESP32-S3 桌上型顯微鏡把畫面變成一個網址:Wi-Fi 一连,任何螢幕(27 吋工作機、iPad、另一半的手機)開瀏覽器就是接目鏡,四个人可以同時看同一顆螞蟻。整台含鏡頭與升降架,材料 980 元:

🔬 為什麼選 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-N16R81520PSRAM 選 Octal;N8R8 亦可
相機OV2640 DVP 模組(2.54 排針轉接)112016-pin FPC;含可拔鏡座
望遠鏡頭M12 3.95MP 鏡筒 6mm1180升級 60–120× 用;先拔鏡玩 20–40×
環形燈12 顆 2835 暖白燈環(5V)190AO3400 MOS 低邊 PWM
擴散片2mm 霧面壓克刀/PETG 片130沒它燈珠全印在金属上
升降機構M3×300 牙桿+T 型螺母+150mm 铝 extrusion1組110焦平面垂直移動 20mm
按鈕6×6 微動(帽 20mm)16單拍鍵,置底座觸手位置
供電USB-C 線+5V/2A 電源160相機峰值 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;全系統共地
ESP32-S3 顯微鏡 · 線路示意 ESP32-S3 N16R8 GPIO15 XCLKGPIO1 SIODGPIO2 SIOCGPIO3 VSYNCGPIO4 HREFGPIO5 PCLKGPIO6–13 D0–D7GPIO14 LED·PWMGPIO18 單拍5V 燈環・3V3 相機 OV2640 DVP 2MP・SCCB・可拔 M12 鏡 XCLK 24MHz SCCB SDA/SCL VSYNC·HREF·PCLK D0–D7 資料束(≤15cm) LED 燈環 ×12 +霧面擴散片 AO3400 PWM+1k GND 共地 BTN
⚠️ S3 接線眉角:GPIO19/20=原生 USB、GPIO26–32=Flash、GPIO35–37=octal PSRAM,這九支一律不接;GPIO45/46 是 strapping 域也略過。杜邦束請用排線夾成長蛇,散線穿過升降架時會被磨到斷線。

🖨️ 3D 列印設計

零件材質數量列印設定備註
升降滑架PETG10.2mm/3 壁/35%M3 螺母埋件;方形導軌防轉
相機艙+鏡筒接座PETG10.2mm/3 壁/30%M12 鏡筒直插+頂絲固定
燈環圈PETG 白10.28mm/2 壁/20%卡燈環與擴散片;內壁貼鋁箔反光
底座PETG10.24mm/4 壁/15%中空填硬幣/螺帽配重,穩不穩看它
調焦手輪PLA10.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); }

🔧 組裝步驟

  1. 相機模組拔鏡:用兩把薄尖嘴鉗夾鏡筒法蘭對角輕轉(或買免工具卡口座),拔完用無塵布擦感測器視窗——先別碰中央鏡片。
  2. 14 支杜邦線依腳位表接相機,依顏色規則分工(紅=時脈群、藍=控制、綠=同期、黑=資料),拍照存檔備查;束線帶綁成一束,全長 ≤15cm。
  3. 燈環穿過燈環圈、貼內圈鋁箔、卡上擴散片,AO3400 三腳焊到相機艙預留焊盤(G=1k 入 GPIO14、S=GND、D=燈負端,燈正端回 5V)。
  4. 升降組:M3 牙桿穿 T 螺母入滑架,滑架卡進擠型軌道(導軌 0.25mm 間隙若太緊,用砂紙紙沿全行程磨順,不要硬扳);手輪與牙桿用 502 點固。
  5. 相機艙鎖上滑架、底座灌配重(硬幣/六角螺帽陣列熱熔固定),立柱鎖底座;整體放在桌上推不歪才算及格。
  6. 上電燒機:USB-C 接電腦、選 ESP32S3 Dev Module+OPI PSRAM 上傳。開機看 Serial 拿到 IP。
  7. 手機瀏覽器開 IP:畫面出現=成功;拉燈橋拉桿燈應呼吸式變光;把一枚 10 元硬幣放在載物台、轉手輪對焦,硬幣側邊的製程字要能讀。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到書桌 → 開啟 HTML 組裝說明書

🧪 驗證

測試動作合格線
解析力拍 0.3mm 間距的排針/印刷電路拔鏡態能數清相鄰兩腳;6mm 鏡筒可分辨 0.2mm 走線
幀率SVGA quality12,計數 10 秒畫面更新≥10fps(手機上開防縮放模式計)
延遲鏡頭前拍手掌+手機同框影片比對<250ms(焊接用可接受)
調光線性拉桿 0→100,量燈環端電流單調上升、無閃爍(PWM 5kHz 手機慢動作無紋)
多觀眾手機+平板+筆電同開 /stream3 人同時、每人都到 8fps 以上
單拍鏈路按實體鈕Serial 印存檔路徑;TG 設定後 3 秒内收到圖

🆘 問題排除

症狀最可能的原因處方
Serial 印 CAM FAIL/Camera probe failed14 線中接錯一支(最常見 PCLK/VSYNC 對調)、相機供電反接已燒、PSRAM 未開 OPI逐線對照接線表(拍照比對最快);重設 PSRAM=OPI PSRAM 再燒;相機 VIN/GND 反接多數會直接報銷模組
開機就重啟循環USB 集線器供電不足(相機初始化峰值 300mA)直插電腦背板 USB 或獨立 2A 電源;換粗一點的線
畫面只有 QQVGA/開大就崩PSRAM 沒開(程式自動降階)或 N8R2 模組誤設 OctalSerial 看 psramFound();模組型號對設定(R2=Quad、R8=Octal)
畫面綠底/負片/馬賽克JPEG 品質與解析度組合超出感測器模式、或排線太長吃訊號先降 quality=16+SVGA 測試;束線減半、XCLK 改 16MHz 再試
畫面上下顛倒/鏡射感測器預設翻轉與你的機座方向不合網頁端 CSS transform 或 sccb 設 hmirror/vflip(程式留了註解位)
調焦會晃底座沒配重、牙桿與滑架單點受力底座配重加重;轉動方向順牙桿施力;必要時擠型加長
多人同看就卡S3 單 radio 頻寬有限,MJPEG 每人吃 2–3MbpsWi-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 的摸魚記錄寫好存證。

← 上一篇:ESP32-C5 雙頻 Wi-Fi 訊號偵測筆 📚 系列索引 下一篇:ESP32-C61 斷網守護者 →