2026年2月14日 星期六

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

今天開 QRV 去裕昌做 150K 保養時帶了筆電與 ESP32-S3 CAM 去, 但測試 TF 卡的掛載一直無法成功, Gemini 認為這可能是因為 shariltumin 的 2020 年版韌體雖然拍照沒問題, 但它可能不支援自定義腳位, 鎖死了 SDMMC 的預設腳位 (通常是 GPIO 14/15/2), 而我板子上的印的卻是 GPIO 38/39/40, 建議我改燒新版韌體. 

由於前天找出庫藏 ov2640 更換之前拍照圖像會潰敗的鏡頭模組, 證實問題出在 ov2640 而非韌體, 所以 cnadler86 較新的 2026 版韌體應該是可用的, 或許此版韌體可順利掛載 TF 卡, 本篇旨在測試 TF 卡之前, 先驗證 2026 版韌體可正常拍照. 


首先到  cnadler86 的 GitHub 下載 2026 版韌體 : 




解壓縮後得到映像檔 firmware.bin, 將 ESP32-S3 CAM 的 UART 端口接到 PC USB, 開啟命令提示字元視窗, 用 esptools 先洗掉舊韌體 : 

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 erase_flash   
esptool.py v4.6.2
Serial port COM4
Connecting.............. (須按 BOOT 鈕)
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Erasing flash (this may take a while)...
Chip erase completed successfully in 4.1s
Hard resetting via RTS pin...

然後燒錄新韌體 : 

D:\ESP32-S3-CAM>esptool --chip esp32s3 --port COM4 write_flash -z 0 firmware.bin    
esptool.py v4.6.2
Serial port COM4
Connecting........................
Chip is ESP32-S3 (revision v0.2)
Features: WiFi, BLE
Crystal is 40MHz
MAC: e0:72:a1:d7:dd:d4
Uploading stub...
Running stub...
Stub running...
Configuring flash size...
Flash will be erased from 0x00000000 to 0x001dbfff...
Compressed 1948208 bytes to 1238683...
Wrote 1948208 bytes (1238683 compressed) at 0x00000000 in 108.2 seconds (effective 144.0 kbit/s)...
Hash of data verified.

Leaving...
Hard resetting via RTS pin...

燒錄完畢拔掉 UART 端口的 Type C 改插 OTG 端口, 按 Thonny 的 "執行/停止-重新啟動" 進入 MicroPython 互動執行環境 : 

MicroPython v1.27.0-dirty on 2026-01-12; Generic ESP32S3 module with Octal-SPIRAM with ESP32S3
Type "help()" for more information.
>>> 

可見確實是 2026 年的新版 MicroPython v1.27 版韌體. 使用之前測試中 Gemini 提供的最終勝利版程式拍照 :

import camera
import time
import os
import gc

gc.collect()

print("========================================")
print("🚀 Vibe Cam - 最終勝利存檔版")
print("========================================")

cam = None
try:
    # 1. 啟動
    cam = camera.Camera()
    cam.init()
    time.sleep(0.5)

    # 2. 設定 (使用剛剛驗證成功的 Keyword 方式)
    try:
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, frame_size=camera.FrameSize.VGA)
        print("✅ 格式設定成功 (JPEG/VGA)")
    except:
        pass # 已經設過了或失敗都沒關係,反正數據看起來是對的

    time.sleep(1.5) # 等待緩衝區穩定

    # 3. 拍照
    print("📸 拍攝中...")
    # 為了清空舊緩衝區,建議空拍一張
    try:
        cam.capture()
        time.sleep(0.1)
    except:
        pass
        
    buf = cam.capture()
    print(f"📦 取得數據: {len(buf)} bytes")

    # 4. 【關鍵】不做任何檢查,直接暴力存檔!
    filename = "vibe_victory.jpg"
    with open(filename, "wb") as f:
        f.write(buf)
        
    print(f"💾 已強制儲存至: {filename}")
    print("🎉 請立刻用 Thonny 下載這張照片,它絕對是好的!")
    
    # 5. 釋放
    cam.deinit()

except Exception as e:
    print(f"❌ 錯誤: {e}")

print("========================================")

拍照結果正常 :




純淨拍照版測試程式也改為新版韌體的物件導向寫法 (2026 進化版) :

import camera
import time
import os
import gc

# 1. 記憶體大掃除
gc.collect()
print("========================================")
print("🚀 Vibe Cam - 2026 進化版 (內部存檔)")
print(f"🧠 可用記憶體: {gc.mem_free() / 1024:.2f} KB")
print("========================================")

cam = None  # 先宣告變數,方便後面釋放

try:
    # 2. 初始化相機 (新版寫法:物件導向)
    print("📷 初始化相機物件...")
    cam = camera.Camera()
    cam.init()

    # 【新版關鍵】設定格式與大小
    # 舊版韌體是寫死的,新版建議明確指定,確保是 JPEG 格式
    try:
        # VGA = 640x480, 適合一般測試
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, frame_size=camera.FrameSize.VGA)
        print("✅ 格式設定成功 (JPEG/VGA)")
    except Exception as e:
        print(f"⚠️ 設定格式略過 (可能已設定): {e}")

    # 【關鍵技巧】等待光線穩定
    print("🔥 暖身中 (等待 2 秒讓鏡頭適應光線)...")
    time.sleep(2.0)

    # 3. 拍照
    print("📸 拍攝中...")
    
    # 【新版建議】先空拍一張清空緩衝區 (避免拍到上一張殘影)
    try:
        cam.capture()
        time.sleep(0.1)
    except:
        pass

    # 正式拍照
    buf = cam.capture()

    if buf and len(buf) > 0:
        print(f"📦 取得影像: {len(buf)} bytes")
        
        # 4. 智慧存檔 (存入內部 Flash) - 保留您原本優秀的邏輯
        i = 0
        while True:
            # 存到根目錄
            filename = f"photo_{i}.jpg"
            try:
                # 檢查檔案是否存在
                os.stat(filename)
                # 如果沒報錯,代表檔案存在,號碼 +1
                i += 1
            except OSError:
                # 報錯代表找不到檔案,這個名字可以用!
                break
        
        # 寫入檔案
        with open(filename, "wb") as f:
            f.write(buf)
            
        print(f"💾 已儲存至內部空間: {filename}")
        print("🎉 成功!請在 Thonny 左側檔案欄按右鍵 -> Refresh (重新整理) 查看。")

    else:
        print("❌ 拍照失敗 (數據為空)")

    # 5. 釋放資源
    if cam:
        cam.deinit()
        print("✅ 相機已釋放")

except Exception as e:
    print(f"❌ 發生錯誤: {e}")
    # 強制釋放保險機制
    if cam:
        try:
            cam.deinit()
        except:
            pass

print("========================================")

此程式拍照結果會以 photo_x.jpg 依序存檔 : 




另外串流伺服器程式在新版韌體環境也要修改 : 

import network
import socket
import camera
import time
import machine
import gc
import os
import errno

# ==========================================
# 0. 全域變數
# ==========================================
cam = None  # 用來存放相機物件

# ==========================================
# 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():
    global cam
    gc.collect()
    try:
        # 1. 建立相機物件
        cam = camera.Camera()
        cam.init()
        
        # 2. 設定參數 (JPEG, VGA)
        # 注意:新版韌體必須明確指定 PixelFormat.JPEG
        time.sleep(0.5)
        cam.reconfigure(pixel_format=camera.PixelFormat.JPEG, 
                        frame_size=camera.FrameSize.VGA)
        
        print("📷 相機初始化成功 (Mode: cnadler86 2026)")
        return True
    except Exception as e:
        print(f"⚠️ 相機初始化失敗: {e}")
        return False

# ==========================================
# 4. 網頁 HTML (保持不變,因為這段很穩定)
# ==========================================
html = """<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <title>Vibe Cam 2026</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 (2026 New Firmware)</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():
    global cam
    
    # 先連網
    ip = connect_wifi()
    if not ip: return

    # 再開相機
    if not init_camera():
        print("❌ 無法啟動伺服器,因為相機失敗")
        return

    # 建立 Socket
    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:
                    # --- 拍照區 (新版 API) ---
                    # 直接呼叫 cam 物件的方法
                    buf = cam.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()
        if cam:
            cam.deinit() # 記得釋放物件
            print("相機已釋放")

# 啟動!
start_server()

執行結果如下 :

True

✅ Wi-Fi 已連線! IP: http://192.168.50.111
📷 相機初始化成功 (Mode: cnadler86 2026)
✅ 伺服器就緒。請打開瀏覽器,並觀察 Shell 的輸出。
43
25
26721
43
25
26729
43
25
28113
....

手機連線同一 WiFi 分享器後用瀏覽器連線 http://192.168.50.111 就可以看到連續圖片播放形成的串流視訊效果 :





以上測試證明 cnadler86 的 2026 版韌體拍照沒有問題. 

沒有留言 :