
简介这是一份针对物联网嵌入式开发场景的ESP32实战例程基于MicroPython实现AI人脸识别功能可驱动OV2640与OV5640摄像头完成图像采集与实时检测。代码针对ESP32-S3进行适配模块接线方式已在程序中定义关键部分附有详细注释方便开发者依据手头硬件做适当调整。资源面向正在学习ESP32摄像头应用、MicroPython编程或物联网AI项目的工程师与高校学生。压缩包内共两个文件分别是Python源码和一条技术答疑快捷链接整个包仅2KB非常轻量下载后解压即可调用。目前该例程已有271人学习浏览对于入门级人脸识别项目具备一定参考价值。通过研读这份代码读者可以掌握摄像头在MicroPython环境中的初始化与图像读取流程了解边缘设备上运行人脸识别模型的基本路径同时学习如何组织硬件配置、图像处理与识别逻辑。配套的答疑链接也为解决开发中遇到的适配问题提供了便利适合作为课程设计或项目起步的参考资料。1. 为什么在 ESP32 上跑 MicroPython却把 AI 人脸识别放远端拿到带 OV2640 的 ESP32-CAM很多人第一反应是把 FaceNet 这类模型直接塞进芯片。实际上一张 SVGA 的 JPEG 帧已经占掉 100 多 KB 内存再跑卷积MicroPython 的垃圾回收会被拖得几乎停不下来。更常见的落地结构是ESP32 用 MicroPython 驱动 OV2640/OV5640 抓图把 JPEG 走 WiFi 发给 PC 或边缘盒子由服务端的人脸识别算法返回“是谁”ESP32 拿结果去开继电器、亮 OLED 或者发门禁记录。这也是物联网嵌入式人脸识别的标准做法。需要的人不只是想做玩具更多是门禁、考勤、访客提醒这类要快速验证又要让摄像头端保持低成本的项目。2. ESP32 与 OV2640/OV5640 摄像头驱动的选型和固件准备2.1 根据分辨率、接口和内存选摄像头OV2640 和 OV5640 都走 SCCB 控制加 DVP 数据接口引脚定义大体一致所以同一个 MicroPython 例程通常只改初始化参数就能切换。真正影响选型的是像素、帧率和数据量。参数OV2640OV5640说明最大分辨率1600x1200 (UXGA)2592x1944 (5MP)人脸识别一般用不到最大分辨率常用 JPEG 帧大小SVGA 约 80-130 KBXGA 约 150-200 KB带 PSRAM 才能稳定跑 XGA功耗约 0.15W约 0.25W电池供电优先选 OV2640适用场景ESP32-CAM 通用头、室内门禁需要拍清人脸细节或远距离识别OV5640 镜头座更挑信号质量如果手上是普通 ESP32-CAM我一般先跑 OV2640代码里把分辨率控制在 SVGA帧率够用网络压力也小。OV5640 的优势是暗光细节更好但 MicroPython 抓图后字节数组要一次性放进内存没有 PSRAM 的模块跑到 XGA 以上很容易直接复位。2.2 固件准备确认 MicroPython 带 camera 驱动MicroPython 官方固件默认不带摄像头驱动所以第一步不是写代码而是确认固件里有没有 camera 模块。常见的做法是先用 esptool 烧录一个社区编译好的带 camera 驱动固件烧录地址固定是 0x1000。python -m esptool --chip esp32s3 --port /dev/ttyACM0 erase_flash python -m esptool --chip esp32s3 --port /dev/ttyACM0 write_flash -z 0x1000 firmware-esp32s3-camera.binerase_flash不是每次都必须但旧固件里的蓝牙配置和 NVS 数据可能干扰摄像头初始化换新固件时建议先擦一次。--chip要根据主控换ESP32-CAM 老版本是esp32ESP32-S3 是esp32s3烧错会直接报连接失败。烧录后先在 REPL 里验证模块名因为不同社区固件入口不同try: from esp32_camera import Camera print(esp32_camera ok) except ImportError: print(no camera driver, flash another firmware)这里不直接import camera是因为部分固件把接口封装成esp32_camera另一部分则叫camera。例程里统一用esp32_camera的写法如果你的固件报导入失败只需要把模块名换掉初始化逻辑不变。2.3 初始化 OV2640/OV5640 的最小代码和参数语义下面这段是 ESP32-CAM 默认引脚的初始化代码OV2640 和 OV5640 通用。from esp32_camera import Camera cam Camera( pin_pwdn32, pin_reset-1, pin_xclk0, pin_sccb_sda26, pin_sccb_scl27, pin_d735, pin_d634, pin_d539, pin_d436, pin_d321, pin_d219, pin_d118, pin_d05, pin_vsync25, pin_href23, pin_pclk22, xclk_freq20_000_000, pixel_formatCamera.JPEG, frame_sizeCamera.FRAME_SVGA, jpeg_quality10, ) cam.init()引脚顺序照抄时要对着板子原理图确认ESP32-CAM 的 D0 到 D7 不是顺序排列的写错一根线就会黑屏。xclk_freq是摄像头主时钟20MHz 是 OV2640 的常用值OV5640 切换到高分辨率时如果出现花屏优先把它降到 10MHz。pixel_format一定要选 JPEG不要选 RGB565JPEG 是摄像头硬件压缩好的传输和储存都更省。jpeg_quality范围是 0 到 63数值越大画质越差人脸识别推荐 10 到 12太大会让 JPEG 体积翻倍太小则人脸边缘噪声多影响服务端特征提取。初始化后不能立刻拍照最好先延时 100ms 让摄像头完成内部同步。如果capture()一直返回 None最常见的原因是pin_pwdn方向不对ESP32-CAM 这个引脚是低电平使能初始化代码里不要改成输出高。3. MicroPython 例程里的图像采集与 WiFi 上传实现3.1 拍摄 JPEG 并写出到文件便于离线排查先验证摄像头能不能稳定出图再谈网络。用 2.3 的cam对象直接抓一帧buf cam.capture() if buf is None: raise RuntimeError(capture failed) print(capture ok, payload:, len(buf), bytes) with open(/img.jpg, wb) as f: f.write(buf)capture()返回的是完整 JPEG 字节串长度经常超过 100KBMicroPython 在分配这段内存时如果失败会返回 None。把图片写到本地文件是为了在电脑上打开看如果画面是全绿或全紫多半是 DVP 数据线引脚定义错如果画面偏色但轮廓清楚通常是白平衡还没收敛多抓几帧再看。3.2 最常见做法HTTP POST 原始 JPEG 到识别服务例程里我一般不用 Multipart 表单直接把buf作为 body 上传服务端按image/jpeg解析省掉一层编码开销。import network import urequests import time wlan network.WLAN(network.STA_IF) wlan.active(True) if not wlan.isconnected(): wlan.connect(esp32-iot, 12345678) max_wait 20 while not wlan.isconnected() and max_wait 0: time.sleep_ms(500) max_wait - 1 if not wlan.isconnected(): raise RuntimeError(wifi connect failed) url http://192.168.1.100:8000/ai/face resp urequests.post( url, databuf, headers{Content-Type: image/jpeg} ) if resp.status_code 200: data resp.json() print(data.get(name), data.get(score)) resp.close()连接 WiFi 的循环判断不能省ESP32 从复位到关联 AP 经常需要 3 到 5 秒只延时一次很容易出错。urequests.post发送大块字节时不会做二次分包所以服务端收到的就是完整 JPEG。响应结束后一定要resp.close()否则连接句柄泄漏几十帧之后网络栈会报RuntimeError: core dumped。3.3 网络参数连接、超时和缓冲区参数建议值说明STA 模式主动激活wlan.active(True)先于 connect不激活会一直超时连接等待15-20 秒热点慢时 5 秒不够JPEG 质量8-12每提高 2 档约减少 15% 体积上传间隔200ms-2s取决于服务端单帧推理时间如果边缘节点要处理大量人脸识别请求ESP32 端不要每抓一帧就同步等待结果更好的节奏是本地保留一个最新帧上传频率降到 500ms。服务端来不及处理时旧帧直接丢弃也比阻塞摄像头强。3.4 用线程解耦抓图与识别避免传输阶段丢帧MicroPython 支持_thread可以开一个抓图线程和一个上传线程中间用全局变量加锁交换最新帧。import _thread import time latest None lock _thread.allocate_lock() def capture_loop(): global latest while True: frame cam.capture() if frame: lock.acquire() latest frame lock.release() def upload_loop(): global latest while True: lock.acquire() frame latest lock.release() if frame: try: resp urequests.post(url, dataframe, headers{Content-Type: image/jpeg}) resp.close() except OSError as e: print(upload error, e) time.sleep_ms(100) _thread.start_new_thread(capture_loop, ()) upload_loop()这里不要用列表做队列MicroPython 堆内存碎片化后append到几千字节就会失败。覆盖式存储丢帧是故意的人脸识别不需要每一帧都处理丢中间帧可以避免内存被旧帧占满。锁只保护指针交换不保护frame内容因为latest一旦换掉上一个上传线程已经持有了自己的引用。4. 服务端 AI 人脸识别从 OpenCV DNN 检测到特征比对4.1 接收图像FastAPI 的临时接口ESP32 端把 body 原样传上来服务端用 FastAPI 的UploadFile接收最快。from fastapi import FastAPI, UploadFile import cv2 import numpy as np app FastAPI() app.post(/ai/face) async def ai_face(file: UploadFile): data await file.read() img cv2.imdecode(np.frombuffer(data, np.uint8), cv2.IMREAD_COLOR) return {framesize: [img.shape[1], img.shape[0]]}这里没有用await file.read()之外的分块读取是因为单张 JPEG 最多 300KB一次读完更简单。返回值只回传了尺寸用来确认整条链路通没通。先跑通这条再挂模型避免一上来就分不清是摄像头问题还是算法问题。4.2 用 YuNet 做人脸检测替换 Haar 级联很多教程还在用 Haar 级联但 ESP32 传到服务端的图经常有侧脸、暗光、遮挡Haar 漏检率太高。开源免费商用的人脸识别模型里OpenCV 官方维护的 YuNet 是更稳的选择。from cv2 import FaceDetectorYN detector FaceDetectorYN.create( face_detection_yunet_2023mar.onnx, , (320, 320), 0.6, 0.3, 5000 ) def find_faces(img): h, w img.shape[:2] detector.setInputSize((w, h)) retval, faces detector.detect(img) return [] if faces is None else facesFaceDetectorYN.create的第三个参数是输入尺寸这里先给 320x320 再按实际图像setInputSize模型会自动缩放。0.6 是检测置信度阈值实测中侧脸低于 0.6 很常见调到 0.4 会多出很多误检框。0.3 是 NMS 阈值多个人脸靠得近时这个值要小。faces每行包含 bbox 坐标、5 个关键点坐标和置信度后续特征对齐就用这些关键点。4.3 特征比对不是直接比人脸图片检测到人脸只是第一步还要判断“是谁”。常见做法是注册阶段把每张人脸图片编码成 128 维或 512 维特征向量识别时计算上传人脸特征和已知特征之间的余弦相似度。import numpy as np known_embeddings { alice: np.zeros(128), # 实际值由注册流程写入 bob: np.zeros(128), } def recognize(emb): best_name, best_score unknown, -1.0 for name, ref in known_embeddings.items(): cos float(np.dot(emb, ref) / (np.linalg.norm(emb) * np.linalg.norm(ref) 1e-6)) if cos best_score: best_name, best_score name, cos return best_name, best_score代码里的np.zeros(128)只是占位实际应保存注册者照片经过特征提取模型后的输出向量。特征提取模型是 FaceNet、ArcFace 还是 MobileFaceNet接口都差不多区别只在向量维度和阈值范围。初始阈值结果倾向调整方法0.70 以上误拒多熟人经常识别不出降到 0.550.40 以下误识多不同人会被当成同一人抬高到 0.600.50-0.60常用起点用 50 个真值样本跑分布再定不同模型输出的相似度分布差别很大不能拿别人项目的 0.6 硬套。我在实际项目里会先让一个人在镜头前录 50 帧再找 10 个不同人的照片跑一遍取两类分数中间值作为阈值。4.4 识别结果回传 ESP32 控制外设服务端把名字和分数一起返回ESP32 端按业务规则控制 GPIO。from machine import Pin import time relay Pin(4, Pin.OUT) data resp.json() allow data.get(name) alice and float(data.get(score, 0)) 0.58 if allow: relay.on() time.sleep_ms(800) relay.off()Pin(4) 是 ESP32-CAM 板载 LED 或继电器控制脚实际硬件要看原理图。这里把阈值判断放在 ESP32 端而不是服务端是为了让不同设备可以有不同的权限策略。比如同一张人脸在门口机放行在财务室不放行只需要改设备端配置。5. 从 OV2640 切到 OV5640 的兼容设计与性能验证技巧5.1 用一个配置字典兼容 OV2640 和 OV5640切换摄像头最怕改初始化代码时漏参数。把摄像头的差异集中在一个字典里例程结构会清楚很多。CAM_PROFILE { OV2640: { xclk_freq: 20_000_000, frame_size: Camera.FRAME_SVGA, jpeg_quality: 10, }, OV5640: { xclk_freq: 10_000_000, frame_size: Camera.FRAME_XGA, jpeg_quality: 12, }, } profile CAM_PROFILE[OV5640] cam Camera( pin_pwdn32, pin_reset-1, pin_xclk0, pin_sccb_sda26, pin_sccb_scl27, pin_d735, pin_d634, pin_d539, pin_d436, pin_d321, pin_d219, pin_d118, pin_d05, pin_vsync25, pin_href23, pin_pclk22, xclk_freqprofile[xclk_freq], pixel_formatCamera.JPEG, frame_sizeprofile[frame_size], jpeg_qualityprofile[jpeg_quality], ) cam.init()OV5640 的 10MHz 主时钟看起来比 OV2640 低但实际测试中高分辨率下反而更稳定。紫色竖条纹通常是 MCLK 频率过高导致信号质量差降频比换排线见效更快。JPEG 质量 12 在 XGA 下约 150KB 到 180KB网络上传速度比 OV2640 的 SVGA 慢 30% 左右如果服务端识别速度跟不上优先降分辨率而不是降画质。5.2 同步时间戳确认端到端时延人脸识别门禁对时延敏感调试时用 NTP 同步后再量一次完整往返。import ntptime import utime ntptime.host ntp.aliyun.com ntptime.settime() t0 utime.ticks_ms() # 这里发起一次 urequests.post代码同 3.2 rtt utime.ticks_diff(utime.ticks_ms(), t0) print(total rtt ms:, rtt)这里测到的时间包含抓图、WiFi 上传、服务端推理和响应返回。本地 WiFi 下 SVGA 帧大约在 80ms 到 150ms 之间如果超过 600ms基本能断定是服务端模型推理太慢而不是摄像头或网络问题。可以用 curl 单独压服务端接口排除 ESP32 的干扰。5.3 连续抓帧测试避免 OV5640 内存碎片高分辨率摄像头最容易暴露内存问题跑一个连续抓帧脚本快速验证稳定性。import gc for i in range(30): frame cam.capture() if frame is None: print(capture failed at frame, i) break print(i, len(frame)) frame None gc.collect()frame None主动释放引用gc.collect()强制回收。没有 PSRAM 的模块在 OV5640 XGA 下通常跑不到 30 帧就失败这时不要继续调上传逻辑先把分辨率降回 SVGA。如果在 OV5640 上看到紫色竖条纹先降xclk_freq到 10MHz再查供电和排线不要一开始就怀疑代码逻辑。本文还有配套的精品资源点击获取