2026年2月13日 星期五

MicroPython 學習筆記 : ESP32-S3 CAM 開發板測試 (四)

今天繼續來測試 ESP32-S3 CAM 開發板, 本篇旨在測試能否透過 ov2640 鏡頭取得圖像之串流 (Streaming). 本系列之前的測試文章參考 :


程式碼使用 Vibe coding 方式叫 Gemini 3 幫我生成, 提示詞如下 : 

我有一塊 ESP32-S3 CAM 開發板, 已經燒錄好 shariltumin 2022 年的 MicroPython 韌體, 網址如下 :

# https://github.com/shariltumin/esp32-cam-micropython-2022/tree/main/firmwares-20230717/ESP32-S3/Freenove-OV2640/WiFi%2BBLE

經測試可正常拍照, 請幫我寫一個 MicroPython 程式讓開發板連上 WiFi 分享器後將鏡頭影像串流放在網頁伺服器上, 讓我可用手機連線網址觀看. 

生成的程式碼如下 :

# main.py 
import network
import socket
import camera
import time
import machine
import gc
import os
import errno

# ==========================================
# 1. 設定 Wi-Fi
# ==========================================
SSID = "我家的 WiFi 分享器"       
PASSWORD = "連線密碼"   

# ==========================================
# 2. 連線 Wi-Fi
# ==========================================
def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    if not wlan.isconnected():
        print(f"🔗 正在連接 Wi-Fi: {SSID}...", end="")
        wlan.connect(SSID, PASSWORD)
        retry = 0
        while not wlan.isconnected():
            time.sleep(1)
            print(".", end="")
            retry += 1
            if retry > 15:
                print("\n❌ Wi-Fi 連線逾時!")
                return None
    ip = wlan.ifconfig()[0]
    print(f"\n✅ Wi-Fi 已連線! IP: http://{ip}")
    return ip

# ==========================================
# 3. 相機初始化
# ==========================================
def init_camera():
    gc.collect()
    try:
        camera.init()
        camera.framesize(6) # VGA (640x480)
        camera.quality(12)
        print("📷 相機初始化成功")
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")

# ==========================================
# 4. 網頁 HTML
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam Stable</title>
    <style>
        body { font-family: sans-serif; background: #222; color: #fff; text-align: center; padding: 20px; }
        .btn { background: #007bff; color: white; padding: 15px 30px; border: none; border-radius: 5px; font-size: 1.2rem; cursor: pointer; margin: 20px; }
        img { width: 100%; max-width: 640px; border: 2px solid #555; background: #000; min-height: 240px; }
    </style>
</head>
<body>
    <h1>🏠 Vibe Cam (Stable)</h1>
    <img id="stream" src="" alt="等待影像...">
    <br>
    <button class="btn" onclick="toggle()">Start / Stop</button>
    <div id="status" style="color:#aaa;">Ready</div>

    <script>
        var active = false;
        var img = document.getElementById("stream");
        var stat = document.getElementById("status");
        
        function toggle() { 
            active = !active; 
            if(active) {
                stat.innerText = "Streaming...";
                load(); 
            } else {
                stat.innerText = "Stopped";
            }
        }
        
        function load() {
            if(!active) return;
            // 加上時間戳記防止快取
            img.src = "/capture?t=" + new Date().getTime();
            
            img.onload = () => { if(active) load(); };
            
            // 如果失敗 (破圖),等 0.5 秒再重試,不要死命重讀
            img.onerror = () => { 
                stat.innerText = "Error (Retrying...)";
                if(active) setTimeout(load, 500); 
            };
        }
    </script>
</body>
</html>
"""

# ==========================================
# 5. 主伺服器 (穩定版)
# ==========================================
def start_server():
    ip = connect_wifi()
    if not ip: return

    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    s.bind(('', 80))
    s.listen(5)
    s.settimeout(0.5) 
    
    print("✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。")

    try:
        while True:
            try:
                conn, addr = s.accept()
                conn.settimeout(None)
                request = conn.recv(1024).decode()
                
                if "GET /capture" in request:
                    # --- 拍照區 ---
                    buf = camera.capture()
                    
                    if buf and len(buf) > 0:
                        # 【關鍵修正】分兩次傳送:先送標頭,再送圖片
                        # 這樣可以減少記憶體壓力,避免破圖
                        conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n')
                        conn.send(f'Content-Length: {len(buf)}\r\n\r\n'.encode())
                        conn.send(buf)
                    else:
                        print("⚠️ 拍照失敗 (無數據)")
                        conn.send(b'HTTP/1.1 500 Error\r\n\r\n')
                
                elif "GET /nuke" in request:
                    print("☢️ DELETE main.py...")
                    try: os.remove('main.py')
                    except: pass
                    conn.send(b'HTTP/1.1 200 OK\r\n\r\nRebooting...')
                    time.sleep(1)
                    machine.reset()

                else:
                    conn.send(b'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n')
                    conn.send(html.encode())
                
                conn.close()
                gc.collect()

            except OSError as e:
                if e.args[0] == 110 or e.args[0] == 116: continue
                print(f"Socket Error: {e}")

    except KeyboardInterrupt:
        print("\n🛑 停止中...")
        s.close()
        camera.deinit()

# 執行
init_camera()
start_server()

此程式內建了一個嵌入式網頁伺服器透過網頁來呈現 VGA(640x480) 視訊. 解析度是以 camera.framesize(6) 來設定的, 關於圖像的解析度規格摘要如下表 : 


 解析度  用途  優點  缺點
 VGA
(640x480)
 
(視訊串流王者)
即時監控
網路攝影機 (Webcam)
AI 辨識 (如人臉偵測)
流暢度高
WiFi 良好可達 15~25 FPS
延遲低
畫面幾乎同步,不卡頓。
 放大看細節會糊掉,解析度較低。
SVGA
(800x600)
 
(拍照甜蜜點)
縮時攝影
定時拍照存檔
較高畫質的監控
細節提升
比 VGA 多 50% 像素,文字/人臉更清
傳輸尚可:約 0.5 秒可完成。
幀率下降
串流會掉到 10 FPS 以下。會有明顯殘影或延遲感。
 UXGA
(1600x1200)
 
(極限畫質)
需要看清遠處車牌
或微小細節的靜態照片
發揮鏡頭最大實力 (200 萬畫素全開)。 非常慢
處理需 1~2 秒
噪點多
暗部雜訊明顯
傳輸不穩
易掉封包導致畫面撕裂。


camera.framesize() 的傳入參數代碼說明如下表 : 


代碼 解析度名稱 像素 (WxH) 說明與建議用途
5 QVGA 320x240 速度最快 適合網路訊號極差,或需要超高刷新率 (FPS) 的場合。畫面較小且模糊。
6 VGA 640x480 🎯 預設值 (平衡點) 畫質與傳輸速度的最佳平衡。適合一般的即時監控串流。
7 SVGA 800x600 畫質甜蜜點 畫質明顯比 VGA 清楚,適合縮時攝影或靜態存檔。串流時 FPS 會略降。
8 XGA 1024x768 適合需要看清楚細節的靜態拍照。檔案較大,不建議用於即時串流。
10 UXGA 1600x1200 🔥 硬體極限 200 萬畫素全開。處理速度非常慢 (約 1~2 秒一張),且暗部雜訊較多。


上面的程式並非使用影片串流協定 (MJPEG Stream) 在瀏覽器上呈現視訊, 而是使用一種輕量化的快速連拍 (Rapid Polling) 法, 簡言之, 其原理類似  "翻頁書 (Flipbook) 動畫", ESP32 不會主動一直丟影片給瀏覽器, 而是由網頁裡的 JavaScript 程式中的 load() 函式扮演主動角色, 連續向 ESP32-S3 CAM 要求提供一張照片, ESP32 就拍一張傳過去, 只要速度夠快 (每秒約 5~10 次), 因為視覺暫留, 人眼看起來就像是流暢的影片了. 

為了避免記憶體瞬間爆掉, 此程式分三次與瀏覽器通訊, 先送 HTTP 檔頭, 再送 Content-Length, 最後才送出真正的 JPEG 圖片資料. 為了容錯, 程式中使用了 try...except 包裹住拍照動作, 萬一相機偶爾一次 No Data (沒拍到), 程式不會崩潰, 而是回傳 500 錯誤, 繼續等下一次拍照. 另外還使用 img.onerror 監聽器, 如果這張照片傳輸失敗 (破圖), 瀏覽器不會卡住, 而是休息 0.5 秒後自動重試, 這讓畫面看起來永遠是活著的. 

為了避免 while 迴圈鎖死, 此程式設有逃生門設計, s.settimeout(0.5) 會每 0.5 秒檢查一次是否有按下 Ctrl+C, 如果沒有它就繼續工作; 如果有它就優雅地關閉伺服器. 

將此程式直接貼到開發板的互動視窗, 或存成 main.py 上傳到開發板根目錄下, 先按 Thonny的 "執行/重新啟動", 再按 Ctrl+D 即執行 main.py, 輸出串流伺服器的網址 :

True
📷 相機初始化成功
True

✅ Wi-Fi 已連線! IP: http://192.168.1.112
✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。

這時用手機連線同一 WiFi 分享器, 開啟瀏覽器輸入此網址, 會顯示 "不支援 HTTPS 安全連線" 頁面, 按 "繼續造訪網站" 就會顯示 VibeCam Home 網頁 : 




按 "Start/Stop" 鈕就會出現串流視訊 (底下顯示 Streaming) : 




互動視窗輸出 : 

43
25
12259
43
25
12259
43
.....

其中第一個數字 43 是HTTP 檔頭的第一部分 "HTTP/1.1 200 OK\r\nContent-Type: image/jpeg\r\n" 的字元數 (剛好 43 個, 固定), 用來告訴瀏覽器 "我準備好要傳送一張 JPEG 圖片給你囉". 

第二個數字 25 是 HTTP 檔頭的第二部分 (包含圖片大小訊息, 不固定) "Content-Length: 12259\r\n\r\n", 用來告訴瀏覽器 "這張圖片的大小是 12259 bytes, 請準備接收". 

第三個數字 12259 (會變動) 是當下拍到的那張照片的大小 (byte 數). 




再次按 "Start/Stop" 鈕就會結束串流視訊 (底下顯示 Stopped), 注意此為交替 (toggle) 的按鈕 : 




如果串流只出現框框沒有影像, 需拔除 OTG USB 電源線, 等幾秒鐘電容放電完再上電, 先在 Thonny 按 "執行/重新啟動", 然後按 Ctrl+D 冷啟動就會執行 main.py 了. 

沒有留言 :