ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

ESP32-CAM 跑 YOLO 目标检测:边缘计算与视频流架构实战

ESP32-CAM 跑 YOLO 目标检测:边缘计算与视频流架构实战 简介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模块初始化时有几个关键参数formatcamera.JPEG指定编码格式fb_locationcamera.FRAME_BUFFER_IN_PSRAM把帧缓冲放到 PSRAM 而不是内部 SRAM。这很关键因为 ESP32-CAM 内部 SRAM 只有 320KB 左右一张 800x600 的 RGB565 原始帧就要占将近 1MB只有 PSRAM 才放得下。import camera import network import time camera.init(0, formatcamera.JPEG, fb_locationcamera.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 的默认输入是 320x320QVGA 的 320x240 在宽高比上最接近缩放损失最小而且 JPEG 单帧体积控制在 20KB 左右在局域网 WiFi 下传输延迟明显更低。quality(12)是 MicroPython camera 驱动里比较折中的值低于 8 会出现明显块状噪声高于 40 对 YOLO 检测结果没有实质提升只会增加带宽占用。2.3 用 urequests 把 JPEG 帧推到 FlaskPOST 二进制与帧间隔控制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, databuf, headersheaders) 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; boundaryframe。服务端持续向同一个响应连接写入多帧 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 bContent-Type: image/jpeg\r\n\r\n latest_frame b\r\n) return Response(generate(), mimetypemultipart/x-mixed-replace; boundaryframe)这里有两个细节。第一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 titleESP32-CAM YOLO Monitor/title /head body h1ESP32-CAM YOLO Monitor/h1 img src/video_feed width640 height480 /body /htmlwidth640 height480会让浏览器把 320x240 的 QVGA 帧拉伸显示。这个拉伸在视觉上能看清画面但检测框的坐标是相对于原始分辨率的拉伸后框位置依然准确因为坐标比例没有变。另一个做法是让服务端把画面缩放到 640 再推流但那样会额外占用一次 cv2.resize 的开销在 PC 端不敏感在嵌入式设备上不值得。3.4 多线程下的隐性阻塞高帧率上传会卡住视频流Flask 开发服务器默认 threadedTrue但/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不需要 CUDAuse_yolo.py只需要读 weights 和 cfg 两个文件就能完成加载。这对一个以 Flask 为主体的项目来说依赖面干净很多。4.2 OpenCV DNN 加载 weights 和 cfgreadNet 与多输出层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_classes5 代表中心坐标 x、y、宽高 w、h 和置信度。yolo-fastest 的最终输出 shape 通常是(1, 3, 5, 5, 25)之类的小张量因为输入只有 320x320网格数量少anchors 数量是 3。解析时要做三件事过滤低置信度框、按置信度排序、执行 NMS 去重。def postprocess(outputs, conf_threshold0.5, nms_threshold0.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_threshold0.4表示两个框的 IoU 超过 40% 就认为它们是同一个目标值调大会保留更多重叠框调小会误删邻近目标。conf_threshold0.5则是硬过滤低于这个置信度的框直接丢弃。这两个参数是调优时最先该动的数值下表给出常见取值区间和对结果的影响参数推荐范围调低效果调高效果conf_threshold0.3 0.6召回率提升误检增多误检减少漏检增多nms_threshold0.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 把图像缩放放到板端而不是在服务端降采样最初我的摄像头用 XGA1024x768抓帧服务端推理前再 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(targetyolo_loop, daemonTrue).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 对行人和车辆的检测依然保持较高的召回率整套视频监控和目标检测方案才算真正跑通。本文还有配套的精品资源点击获取
返回列表