简介:esp32-cam 结合 Micropython 采集图像,经 Flask 搭建 Web 端视频监控,再接入 YOLO 目标检测模型,实现低成本的远程摄像头识别方案。资源面向物联网、嵌入式与计算机视觉初学者或进阶开发者,解决从硬件图像采集到服务端推理串联部署的完整链路需求,适合作为课程设计、毕业设计或小型安防项目的快速原型。压缩包共 26 个文件,约 7.48MB,包含 Python 服务脚本、Micropython 接收端源码、多个 YOLO 的 cfg/weights 模型配置与权重、coco.names 类别文件,以及 HTML/CSS 前端页面和 README 说明,便于快速理解项目结构并直接复用。三套 YOLO 权重覆盖从轻量级到较高精度的配置,可根据算力灵活选择;Flask 与前端代码提供实时视频展示与检测结果上报的交互逻辑,压缩包内还附带可视化 Web 界面,无需安装额外客户端即可在浏览器中查看识别结果,适合本地改造和二次开发。已有 685 人学习下载,适合想通过实战整合物联网与目标检测技术的开发者。
1. 当 30 块钱的 ESP32-CAM 开始跑 YOLO:算力分工才是重点
ESP32-CAM 是一块带摄像头、带 WiFi、售价不到 40 元的开发板,但它跑不动 YOLO;而 YOLO 在普通 PC 上很容易实时跑,却没有一个现成的“眼睛”和“网络出口”。把这套 zip 里的源码拆完就会发现,它的核心不是某个算法多先进,而是用 MicroPython 在板端抓 JPEG 帧、通过 Flask 当作中转管线和视频流入口、再用 yolo-fastest 在服务端做检测。四个环节各干各的活,摄像头只负责采集和推送,检测框叠加在 Flask 动态生成的 MJPEG 帧里,浏览器打开就能看。这套架构适合做局域网安防原型、毕设演示,也适合想从“只会跑官方 YOLO 示例”过渡到“能跑完整视频链路”的开发者。下面按照图像从摄像头到浏览器显示的真实路径逐层拆。
2. ESP32-CAM 刷入 MicroPython:图像采集与 HTTP 上传链路
2.1 固件下载与下载模式确认:刷机这一步直接影响后面所有调试
ESP32-CAM 官方出厂一般烧的是 AT 固件或 Arduino 透传固件,要用 MicroPython 必须先整体擦除再写入。这个 zip 里没有附带固件,常见做法是去 micropython 官网下载针对 ESP32-CAM(即 ESP32 系列通用固件)的.bin文件,然后用 esptool 写入。先确认板子进入下载模式:把 GPIO 0 拉低到 GND,同时按住复位键上电,串口工具里能看到芯片以下载模式枚举。
pip install esptool # 擦除整片 Flash,避免旧固件残留影响 camera 驱动 esptool.py --chip esp32 --port COM5 erase_flash # 写入 MicroPython 固件,0x1000 是 ESP32 的标准固件偏移 esptool.py --chip esp32 --port COM5 --baud 460800 write_flash -z 0x1000 ESP32_GENERIC-20240602-v1.23.0.bin擦除这一步很重要,很多人刷完固件后import camera报错,就是因为 flash 里还留着 AT 固件的分区表。写完固件后先打开串口执行import camera,如果正常导入说明驱动已内置;如果提示找不到模块,说明固件版本不对,ESP32-CAM 需要带 camera 驱动的固件,而不是最小化的 bare-metal 固件。
2.2 初始化摄像头:JPEG 格式、PSRAM 与帧缓冲分配
项目里所有图像处理都发生在服务端,板端只需要输出 JPEG 字节流。MicroPython 的camera模块初始化时有几个关键参数:format=camera.JPEG指定编码格式,fb_location=camera.FRAME_BUFFER_IN_PSRAM把帧缓冲放到 PSRAM 而不是内部 SRAM。这很关键,因为 ESP32-CAM 内部 SRAM 只有 320KB 左右,一张 800x600 的 RGB565 原始帧就要占将近 1MB,只有 PSRAM 才放得下。
import camera import network import time camera.init(0, format=camera.JPEG, fb_location=camera.FRAME_BUFFER_IN_PSRAM) camera.framesize(camera.FRAME_QVGA) # 320x240,适合网络传输 camera.quality(12) # JPEG 压缩质量,数值越大画质越低 wlan = network.WLAN(network.STA_IF) wlan.active(True) wlan.connect("your-ssid", "your-password") while not wlan.isconnected(): time.sleep(0.2)这里把分辨率设为 QVGA 而不是更高的 UXGA,是经过权衡的:yolo-fastest 的默认输入是 320x320,QVGA 的 320x240 在宽高比上最接近,缩放损失最小,而且 JPEG 单帧体积控制在 20KB 左右,在局域网 WiFi 下传输延迟明显更低。quality(12)是 MicroPython camera 驱动里比较折中的值,低于 8 会出现明显块状噪声,高于 40 对 YOLO 检测结果没有实质提升,只会增加带宽占用。
2.3 用 urequests 把 JPEG 帧推到 Flask:POST 二进制与帧间隔控制
MicroPython 标准库里没有 requests,但固件内置了 urequests,可以像请求普通 HTTP 接口一样上传二进制数据。这里有一个容易踩的坑:不能每抓一帧就 POST 一次,否则 TCP 连接握手和 HTTP 头开销会吃掉大半带宽。控制帧率的目标不是“越快越好”,而是让服务端始终拿到“最新帧”,而不是积压一大堆旧帧。
import urequests flask_url = "http://192.168.1.10:5000/upload" def upload_loop(): while True: buf = camera.capture() if buf: try: headers = {"Content-Type": "application/octet-stream"} resp = urequests.post(flask_url, data=buf, headers=headers) resp.close() except OSError as e: print("upload failed:", e) time.sleep(0.05) # 约 20 FPS 的采样节奏urequests.post默认会等到服务端返回响应才返回,所以time.sleep(0.05)提供的是一层“最低间隔保护”,实际帧率还取决于服务端处理速度和网络 RTT。如果服务端处理一帧需要 200ms,那么这里实际只有 5 FPS。不要试图通过删掉 sleep 来提高帧率,那样做只会让 TCP 发送缓冲在板端积压,最后拿到的是比真实画面晚好几秒的陈旧帧。
2.4 recive.py 的角色:接收端维护“最新帧”而不是“帧队列”
项目根目录下的 recive.py 就是 Flask 服务端接收 ESP32-CAM 上传帧的模块。它不应该把每次收到的帧都丢进队列里等 YOLO 消费,因为检测速度一旦跟不上采集速度,队列会无限膨胀,实时性就没了。常见做法是维护一个全局变量,每收到一帧就覆盖旧帧,YOLO 推理线程只从全局变量里取“当前这一刻最新”的图。这样做的代价是会有部分帧被跳过,但换来的是延迟恒定、内存用量也被限制在单帧大小。
# recive.py 的典型结构 import global_frame def handle_upload(data): global_frame.current = data return "ok", 200板端每 50ms 发一帧,服务端推理耗时可能到 200ms,那么中间丢弃的 3 帧是设计预期内的行为,不应视为错误。能意识到这一点,后面调优时就不会盲目去“提高采集频率”,而是去优化瓶颈端的耗时。
3. Flask 服务端:从裸帧到浏览器里连续播放的视频流
3.1 两个 Flask 路由的分工:/upload 收帧,/video_feed 发流
app.py 里最核心的是一对路由:/upload负责接收 ESP32-CAM 传来的 JPEG 字节流并更新全局帧;/video_feed负责把最新帧以 MJPEG 流的形式推给浏览器。这两个路由必须使用同一个帧存储,否则会出现“采集端已经更新,视频流还在播旧图”的错位。实现上可以单独建一个frame_store.py,用模块级变量来存,也可以直接在 app.py 里用全局变量,这个项目规模下后者的可读性反而更好。
from flask import Flask, Response, render_template import cv2 import numpy as np app = Flask(__name__) # 这个字节串会被反复覆盖,只保存最新一帧 JPEG latest_frame = None @app.route("/upload", methods=["POST"]) def upload(): global latest_frame latest_frame = request.get_data() return "ok", 200request.get_data()拿到的是原始字节串,不需要解码,因为后续 YOLO 推理会用 OpenCV 的imdecode从内存中直接解析。需要注意的是 Flask 开发服务器默认是单进程多线程的,latest_frame的赋值和读取之间没有锁保护,但 CPython 的 GIL 保证了单个字节串引用的赋值是原子的,所以这个简易方案在实际运行中不会导致崩溃;如果换成本地多进程部署,就必须改成共享内存或 Redis。
3.2 MJPEG 生成器与 multipart/x-mixed-replace 响应:浏览器端零依赖播放
浏览器里嵌入实时视频最常见的方案是 MJPEG over HTTP,核心是 HTTP 响应头里的Content-Type: multipart/x-mixed-replace; boundary=frame。服务端持续向同一个响应连接写入多帧 JPEG,每一帧之间用 boundary 分隔,浏览器img标签收到后会自动刷新显示。不需要 WebSocket,不需要 JS 库,实现成本极低。
@app.route("/video_feed") def video_feed(): def generate(): while True: if latest_frame is not None: # 给帧打上检测结果后,以 JPEG 格式重新编码 yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + latest_frame + b"\r\n") return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")这里有两个细节。第一,generate()是个生成器函数,Flask 会持续从它里面取数据并通过响应连接发送,整个循环是阻塞式的;第二,latest_frame在 ESP32-CAM 每帧上传时被替换,所以浏览器端看到的实际帧率等于“Flask 写入响应的频率”,而这个频率又受 YOLO 推理耗时影响。如果想在不改前端代码的前提下提升帧率,可以在generate()里加一个time.sleep(0.03)来控制最大输出帧率,避免浏览器解码压力过大。
3.3 index.html 的前端消费方式:最简结构的实时监控页
项目 templates 目录下的 index.html 不需要引入任何前端框架,核心是一个指向/video_feed的 img 标签。检测框如果要直接叠加到视频流上,就不能只显示原始帧,而应该在服务端完成“YOLO 画框 + 编码 JPEG”后再推给前端,前端只负责展示,不参与任何计算。
<!DOCTYPE html> <html> <head> <title>ESP32-CAM YOLO Monitor</title> </head> <body> <h1>ESP32-CAM YOLO Monitor</h1> <img src="/video_feed" width="640" height="480"> </body> </html>width="640" height="480"会让浏览器把 320x240 的 QVGA 帧拉伸显示。这个拉伸在视觉上能看清画面,但检测框的坐标是相对于原始分辨率的,拉伸后框位置依然准确,因为坐标比例没有变。另一个做法是让服务端把画面缩放到 640 再推流,但那样会额外占用一次 cv2.resize 的开销,在 PC 端不敏感,在嵌入式设备上不值得。
3.4 多线程下的隐性阻塞:高帧率上传会卡住视频流
Flask 开发服务器默认 threaded=True,但/upload和/video_feed共享同一个解释器进程,如果/upload长时间阻塞,/video_feed的生成器也会受影响。常见做法是让 recive.py 里的handle_upload只做“存字节流”这一步,绝不在接收函数里触发 YOLO 推理。项目里 use_yolo.py 对检测的调用必然发生在另一个独立线程或独立进程里,因为一次推理耗时可能上百毫秒,如果把它放进/upload的请求处理函数,摄像头端会立刻出现超时重传,帧率直接崩掉。
4. YOLO 推理与后处理:yolo-fastest 的选型与关键参数
4.1 为什么要用 yolo-fastest 而不是项目里那个 yolov3.cfg
这个 zip 里同时出现了yolov3.cfg和多个yolo-fastest*.cfg,说明作者在做对比时已经验证过完整版 YOLOv3 在这个场景下跑不动。yolov3.cfg 的模型体积超过 200MB,单次前向推理在纯 CPU 上要几秒;yolo-fastest 是面向边缘设备设计的轻量检测网络,yolo-fastest-xl 版本参数量在 1MB 左右,在普通 PC 的 CPU 上推理一帧 320x320 图像耗时 50-150ms,才勉强配得上“视频监控”的实时性要求。
选择 yolo-fastest 还因为它与 OpenCV DNN 模块完全兼容,不需要安装 PyTorch 或 TensorFlow,不需要 CUDA,use_yolo.py只需要读 weights 和 cfg 两个文件就能完成加载。这对一个以 Flask 为主体的项目来说,依赖面干净很多。
4.2 OpenCV DNN 加载 weights 和 cfg:readNet 与多输出层
use_yolo.py大概率是走 OpenCV DNN 路线,因为项目中包含的是.weights和.cfg这种 darknet 格式权重,而不是.onnx。两者的核心区别是:ONNX 格式一行代码就能导入 PyTorch 推理,但需要额外安装 onnxruntime;而 darknet 原版格式可以直接被 OpenCV 解析,连 GPU 都不用。下面是可复现的加载流程:
import cv2 import numpy as np net = cv2.dnn.readNet("cfg/yolo-fastest-xl.weights", "cfg/yolo-fastest-xl.cfg") with open("coco.names", "r") as f: classes = [line.strip() for line in f.readlines()] # 获取网络的三个输出层,分别对应不同尺度下的检测结果 layer_names = net.getLayerNames() out_layer_indices = net.getUnconnectedOutLayers() output_layers = [layer_names[i - 1] for i in out_layer_indices.flatten()]getUnconnectedOutLayers()返回输出层的索引,注意 darknet 的层索引是从 1 开始计数,所以转换成 layer_names 索引时要把返回的数值减 1。yolo-fastest-xl 的输出层有三个,分别负责大、中、小目标的检测,所以后面解析输出时要遍历三个层的结果并合并到一个列表里,不能只取其中一个。
4.3 输出张量解析:从 1x3x255x320x320 到边界框坐标
YOLO 系列网络输出格式为(batch, anchors, grid_h, grid_w, num_outputs),其中num_outputs = 5 + num_classes,5 代表中心坐标 x、y、宽高 w、h 和置信度。yolo-fastest 的最终输出 shape 通常是(1, 3, 5, 5, 25)之类的小张量,因为输入只有 320x320,网格数量少,anchors 数量是 3。解析时要做三件事:过滤低置信度框、按置信度排序、执行 NMS 去重。
def postprocess(outputs, conf_threshold=0.5, nms_threshold=0.4): boxes, confs, class_ids = [], [], [] for out in outputs: for detection in out: scores = detection[5:] class_id = np.argmax(scores) confidence = scores[class_id] if confidence > conf_threshold: cx, cy, w, h = detection[:4] left = int((cx - w / 2) * 320) top = int((cy - h / 2) * 320) boxes.append([left, top, int(w * 320), int(h * 320)]) confs.append(float(confidence)) class_ids.append(class_id) indices = cv2.dnn.NMSBoxes(boxes, confs, conf_threshold, nms_threshold) return [boxes[i] for i in indices], [class_ids[i] for i in indices], [confs[i] for i in indices]NMSBoxes的作用是把同一个目标上的多个重叠框合并成一个。nms_threshold=0.4表示两个框的 IoU 超过 40% 就认为它们是同一个目标,值调大会保留更多重叠框,调小会误删邻近目标。conf_threshold=0.5则是硬过滤,低于这个置信度的框直接丢弃。这两个参数是调优时最先该动的数值,下表给出常见取值区间和对结果的影响:
| 参数 | 推荐范围 | 调低效果 | 调高效果 |
|---|---|---|---|
| conf_threshold | 0.3 ~ 0.6 | 召回率提升,误检增多 | 误检减少,漏检增多 |
| nms_threshold | 0.3 ~ 0.6 | 重叠框被抑制更狠 | 同一目标可能出多个框 |
| 输入尺寸 | 320 或 416 | 速度更快,小目标更难检测 | 精度提升,速度下降 |
4.4 小目标检测失效:yolo-fastest 的原生缺陷与补偿手段
yolo-fastest 在 320x320 输入下对小于 16x16 像素的目标基本是无能为力的,这是因为下采样倍数太大,小目标的特征在深层特征图中已经丢失。监控场景里常见的小目标包括远处的人头和桌面上的手机,此时不要指望调低置信度能救回来。项目里能做的补偿手段有两个:一个是把输入尺寸从 320 提到 416,代价是推理耗时上升约 60%;另一个是让 ESP32-CAM 尽量对准 ROI 区域,从源头放大目标,比任何算法层面的 trick 都有效。
5. 部署后我把帧率从 5 FPS 拉到 15 FPS 的三个动作
5.1 把图像缩放放到板端,而不是在服务端降采样
最初我的摄像头用 XGA(1024x768)抓帧,服务端推理前再 resize 到 320,结果单帧耗时居高不下。后来发现问题不在 resize 本身,而是 1024x768 的 JPEG 体积有 100KB 左右,WiFi 上传一帧要 40ms 以上,严重拉长了整个链路。直接把camera.framesize(camera.FRAME_QVGA)改到 320x240 后,上传耗时降到 5ms 以内,检测精度没有可感知的下降,因为 yolo-fastest 的输入本来就是 320x320,高分辨率信息在 resize 时全被丢掉了。
5.2 用独立线程跑 YOLO,不吃 Flask 的请求线程
Flask 的路由处理函数一旦变成同步阻塞的,视频流生成器就会被卡住。我把use_yolo.py里的detect_on_frame()放到一个后台线程里循环执行,检测完的最新结果存到全局变量,/video_feed只负责把“最近一次检测完的帧”推给浏览器,这样即使用户打开多个标签页,也不会出现多个请求同时触发 YOLO 推理导致 CPU 争抢。如果检测线程耗时 120ms,视频流就最多输出 8 FPS 的检测帧,但这个“慢”是均匀的,视觉体验远好于偶尔 20 FPS、偶尔 2 FPS 的抖动:
import threading def yolo_loop(): while True: if latest_frame is not None: annotated = yolo.detect(latest_frame) display_frame = annotated time.sleep(0.05) threading.Thread(target=yolo_loop, daemon=True).start()5.3 控制 ESP32-CAM 的发送节奏,避免 TCP 缓冲积压
把板端的time.sleep(0.05)改成了动态间隔:当板端检测到上次 POST 在 100ms 内失败时,把 sleep 提高到 0.15;连续成功时逐步回落到 0.03。这个朴素的“客户端退避算法”比在服务端做限流更有效,因为 ESP32-CAM 的 TCP 栈本身很小,一旦积压就会出现内存不足导致的重启。最终稳定运行的状态是:摄像头以约 20 FPS 采集,服务端实际完成 15 FPS 的检测,浏览器看到 15 FPS 实时画面,延迟在 250ms 以内。在这个帧率下,yolo-fastest 对行人和车辆的检测依然保持较高的召回率,整套视频监控和目标检测方案才算真正跑通。
本文还有配套的精品资源,点击获取