ESP32-CAM 实战教程:做一个带运动检测的低成本看护摄像头
本文由 AI 协助整理,代码和验证步骤适用于学习记录。摄像头画面涉及隐私,实际使用时请只用于合法、合规、知情的场景,不要把它做成“悄悄观察别人”的设备。

这篇准备做什么
上一篇已经把 ESP32-CAM 的网页视频流和声音采集跑起来了,这一篇继续往前拱一步:做一个带运动检测的低成本看护摄像头。手机可以通过网页查看实时画面,设备定时分析前后两帧图像,如果画面变化超过阈值,就认为“有动静”,然后抓拍一张 JPEG,记录事件时间,后续还可以扩展成 SD 卡保存、MQTT 通知、企业微信或 TG 推送。
先给这个项目划清边界:本文说的运动检测不是 AI 识别。它不会判断“这是人”“这是猫”“这是快递小哥”,它只判断画面是不是发生了明显变化。比如有人经过、门被打开、光线突然变化、摄像头被碰了一下,都可能触发。它像一个认真但有点朴素的门卫:能发现动静,但不一定知道动静是谁弄出来的。
这种方案的好处是轻量,适合 ESP32-CAM 这种资源有限的小板子。缺点也明显:误报会存在,尤其是光照变化、窗帘晃动、画面噪声、摄像头抖动。我们要做的不是假装它万能,而是把它做成一个能演示、能解释、能逐步调参的小项目。技术文章最怕一脸自信地吹“智能识别”,最后代码里其实只是 if(diff > 100)。那就像给电动车贴了跑车标,短距离看挺精神,拐弯就露馅。
项目功能设计
这个看护摄像头先实现四个功能:
- 手机访问网页,查看实时 MJPEG 画面。
- ESP32-CAM 周期抓取画面并做简单运动检测。
- 检测到动静后,保存最近一次抓拍到内存状态,网页可查看事件信息。
- 设置冷却时间,避免一秒钟内连续触发几十次。
扩展功能可以放到第二阶段:
| 扩展方向 | 说明 |
|---|---|
| SD 卡保存 | 把触发时的 JPEG 保存到 SD 卡 |
| MQTT 推送 | 发布 motion detected 事件 |
| HTTP 推送 | 上传图片到自己的服务端 |
| 补光灯 | 夜间触发时短时间打开 LED |
| 配置页面 | 在网页上调整阈值、冷却时间和分辨率 |
我建议先不要同时上所有功能。ESP32-CAM 的内存、供电和引脚资源都不算宽裕,一口气把 SD 卡、补光灯、MQTT、网页配置全堆上去,最后排错会像在一团数据线里找哪根是耳机线。先做“网页查看 + 运动检测 + 最近事件”,跑稳以后再扩展。
硬件准备
硬件仍然以 ESP32-CAM AI Thinker 为例:
| 模块 | 用途 |
|---|---|
| ESP32-CAM AI Thinker | 摄像头采集、网页服务、运动检测 |
| OV2640 摄像头 | 常见摄像头模组 |
| USB 转 TTL | 烧录程序 |
| 稳定 5V 电源 | 避免摄像头启动时棕断 |
| 手机 | 连接网页查看画面 |
| 可选 SD 卡 | 保存触发抓拍 |
如果要用 SD 卡,需要注意引脚资源和文件写入速度。SD 卡保存图片听起来很自然,但它会带来新的变量:卡是否识别、文件系统是否挂载、写入是否阻塞、图片命名是否冲突。学习阶段可以先不插 SD 卡,只把“最近一次触发抓拍”放在内存里展示。等运动检测逻辑稳定,再把保存图片加进去。
供电还是老话题。ESP32-CAM 很多问题都不是玄学,而是电源不够稳。摄像头启动、Wi-Fi 发射、补光灯点亮,这些动作都会拉电流。用电脑 USB 转 TTL 模块给 5V 供电时,要确认模块供电能力够用;不够的时候,串口会出现重启、乱码、初始化失败,像一个没睡醒的人努力开机。
运动检测的基本原理
最简单的运动检测叫帧差法。步骤如下:
- 取一帧画面作为上一帧。
- 再取一帧画面作为当前帧。
- 把两帧缩小,转成灰度摘要。
- 比较对应位置的像素差异。
- 差异超过阈值的像素数量达到一定比例,就认为有运动。
为什么要缩小?因为 ESP32-CAM 资源有限,直接对大图逐像素比较成本太高。把画面缩成很小的灰度摘要,例如 32x24 或 40x30,已经足够判断大致变化。我们不追求识别桌上硬币移动了 2 毫米,只希望发现“有人走过”“门开了”“画面明显变了”。
为什么要转灰度?因为颜色信息在这个入门方案里不是重点,亮度变化已经能提供足够信号。转灰度后数据更小,计算更快。缺点是光照变化也会带来明显差异,所以要配合阈值、最小变化面积和冷却时间。
事件触发流程

推荐流程如下:
- 正常提供
/stream视频流,手机网页随时可看。 - 后台每隔一段时间抓取一帧低分辨率图像。
- 生成灰度摘要,与上一帧摘要比较。
- 如果变化超过阈值,记录事件时间和变化值。
- 抓拍一张 JPEG 作为最近事件图片。
- 进入冷却时间,例如 10 到 30 秒,避免连续触发。
这个流程有一个小技巧:视频流和运动检测不要抢得太凶。如果网页正在看视频,同时后台高频抓帧做检测,ESP32-CAM 会很辛苦。入门阶段可以降低检测频率,例如每 1 秒或 2 秒检测一次。看护摄像头不需要每 20 毫秒都发现变化,除非你打算监控闪电侠。
代码结构建议
可以把程序拆成几个职责:
camera_init() 初始化摄像头
handle_root() 返回网页
handle_stream() 输出 MJPEG 视频流
capture_motion_frame() 抓取检测帧
build_gray_summary() 生成灰度摘要
detect_motion() 判断是否有动静
record_event() 记录最近事件
学习阶段不一定要真的拆成多个文件,但函数边界要清楚。尤其是 handle_stream() 和运动检测逻辑,最好不要揉在一起。一个负责给浏览器喂图,一个负责后台分析。它们都和摄像头有关,但目标不同。
灰度摘要与差异判断
下面是一个简化版思路。真实项目里,如果直接拿 JPEG 数据做像素比较,需要先解码 JPEG,这一步会比较麻烦。学习阶段可以先降低目标:使用较低分辨率、抓取帧缓冲,然后基于可访问的图像数据生成摘要;也可以先在概念上实现事件流程,再逐步替换成更完整的解码逻辑。
核心判断可以写成这样:
const int SUMMARY_W = 32;
const int SUMMARY_H = 24;
uint8_t prevSummary[SUMMARY_W * SUMMARY_H];
uint8_t currSummary[SUMMARY_W * SUMMARY_H];
bool hasPrev = false;
struct MotionResult {
bool detected;
int changedPixels;
int score;
};
MotionResult compareSummary(const uint8_t* prev, const uint8_t* curr) {
const int pixelThreshold = 28;
const int minChangedPixels = 60;
int changed = 0;
int score = 0;
for (int i = 0; i < SUMMARY_W * SUMMARY_H; i++) {
int diff = abs((int)curr[i] - (int)prev[i]);
if (diff > pixelThreshold) {
changed++;
score += diff;
}
}
MotionResult r;
r.changedPixels = changed;
r.score = score;
r.detected = changed >= minChangedPixels;
return r;
}
这里有两个参数:
pixelThreshold 表示单个像素变化多大才算变化。数值太低会对噪声敏感;数值太高又会漏掉细微运动。
minChangedPixels 表示变化像素数量达到多少才触发。它能过滤一点点噪声,也能避免画面里某个小亮点闪一下就触发。
这两个参数没有绝对正确答案。摄像头角度、光照、分辨率、环境都会影响它。文章里可以写清楚:先从保守值开始,观察误报和漏报,再调参数。调参这件事很像给收音机找台,拧太小没声音,拧太大全是杂音。
冷却时间
检测到运动后,不应该马上继续触发下一次事件。比如有人从镜头前走过,前后可能持续 3 秒,如果每 200 毫秒触发一次,网页上会出现一排事件记录,看起来像摄像头很勤奋,实际只是在重复报告同一件事。
可以加一个冷却时间:
unsigned long lastMotionAt = 0;
const unsigned long cooldownMs = 15000;
bool canTriggerMotion() {
return millis() - lastMotionAt > cooldownMs;
}
void onMotionDetected(int score, int changedPixels) {
if (!canTriggerMotion()) {
return;
}
lastMotionAt = millis();
Serial.printf("motion detected, score=%d, changed=%d\n", score, changedPixels);
// 这里可以抓拍、记录事件、保存到 SD 卡或发布 MQTT。
}
冷却时间不是偷懒,而是让事件更像“事情”,而不是“帧变化”。对看护摄像头来说,10 到 30 秒通常比较舒服。太短容易刷屏,太长可能漏掉连续事件。真实场景里可以放到网页配置里,让用户自己调。
网页展示
网页可以先做三个区域:
- 实时画面:
<img src="/stream"> - 最近事件:显示触发时间、变化分数、变化像素数。
- 最近抓拍:显示
/last.jpg。
HTML 骨架可以这样写:
void handleRoot() {
server.send(200, "text/html",
"<!doctype html><html><head>"
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
"<style>"
"body{font-family:sans-serif;background:#111;color:#eee;text-align:center}"
"img{max-width:96%;border:1px solid #444;margin:8px}"
".card{background:#1f2937;margin:12px auto;padding:12px;max-width:680px;border-radius:8px}"
"</style></head><body>"
"<h2>ESP32-CAM Motion Guard</h2>"
"<div class='card'><h3>实时画面</h3><img src='/stream'></div>"
"<div class='card'><h3>最近事件</h3><div id='event'>loading...</div></div>"
"<div class='card'><h3>最近抓拍</h3><img id='last' src='/last.jpg'></div>"
"<script>"
"setInterval(async()=>{"
"let r=await fetch('/event');"
"let t=await r.text();"
"document.getElementById('event').innerText=t;"
"document.getElementById('last').src='/last.jpg?t='+Date.now();"
"},3000);"
"</script></body></html>");
}
网页不必一开始就做得很花。一个能看实时画面、能看最近事件、能刷新抓拍的页面,已经很适合写教程了。后面再增加阈值滑块、冷却时间输入框、夜间模式开关,就能继续拓展成更完整的小项目。
抓拍与保存
抓拍可以复用摄像头当前帧,保存最近一次 JPEG。最简单的内存方式是保留最近事件的指针或复制 JPEG 数据。不过 ESP32-CAM 内存紧张,复制大图要谨慎。更稳妥的学习路线是:触发时立即把当前 JPEG 发送给网页或保存到 SD 卡。
如果使用 SD 卡,图片命名可以按事件序号:
uint32_t eventId = 0;
String nextImageName() {
eventId++;
char name[32];
snprintf(name, sizeof(name), "/motion_%06lu.jpg", (unsigned long)eventId);
return String(name);
}
如果没有实时时钟,文件名里就不要假装有准确日期。可以用事件序号,或者在连上网络后再使用 NTP 时间。文章里诚实一点,比硬凑一个时间戳更可靠。
保存图片时注意两点:第一,写 SD 卡可能阻塞,不要在高频视频流里随便写;第二,SD 卡和摄像头、补光灯、Wi-Fi 一起工作时,对供电和引脚的要求更高。功能越多,越要一步一步验证。
误报处理
运动检测最难的不是“检测到”,而是“别乱报”。
常见误报来源有:
| 误报来源 | 处理建议 |
|---|---|
| 光照突然变化 | 提高阈值,加入连续两次触发确认 |
| 摄像头轻微抖动 | 固定支架,增加最小变化面积 |
| 画面噪声 | 降低增益,缩小后做简单平均 |
| 窗帘、树影晃动 | 调整摄像头角度,设置检测区域 |
| 夜间补光灯切换 | 补光切换后延迟一段时间再检测 |
其中“检测区域”很有用。比如你只关心门口,不关心窗帘,就可以只比较画面中间或左侧区域。这样能明显降低误报。ESP32-CAM 算力有限,少算一点区域反而是好事,既省资源,又更贴近需求。
MQTT 或推送扩展
如果想把事件发出去,可以用 MQTT:
void publishMotionEvent(int score, int changedPixels) {
char payload[128];
snprintf(payload, sizeof(payload),
"{\"event\":\"motion\",\"score\":%d,\"changed\":%d}",
score, changedPixels);
mqtt.publish("home/camera/frontdoor/motion", payload);
}
如果想推送图片到企业微信、TG 或自己的服务器,也可以做,但要小心 Token。不要把机器人 Token 写进博客截图,也不要提交到公开仓库。可以把 Token 放到本地配置文件,或者在文章里用占位符。
我更建议先推送事件文本,再考虑推送图片。图片上传更占带宽,也更容易失败。先把“发生动静”这件事可靠地发出去,再让它带图。项目不要一开始就穿西装打领带,先学会走路。
验证步骤
第一步,先跑通摄像头网页流。手机访问页面能看到实时画面以后,再谈运动检测。
第二步,固定摄像头。把 ESP32-CAM 放在稳定位置,不要用手拿着测试。手抖会让画面变化很大,检测算法会以为世界正在地震。
第三步,打开串口日志,先只打印差异分数和变化像素数,不触发事件。观察静止画面下的基线值,再让人从镜头前走过,记录运动时的值。阈值应该夹在两者之间。
第四步,开启事件触发和冷却时间。走过镜头一次,应该只记录一条事件,而不是刷出一页。
第五步,测试光照变化。开灯、关灯、拉窗帘,观察是否误报。如果误报严重,先提高阈值或加入连续确认。
第六步,长时间运行。至少让它运行 30 分钟,看是否重启、卡死、内存下降。短时间成功不代表稳定,看护类设备尤其要多给一点耐心。
隐私提醒
摄像头项目一定要讲隐私。哪怕只是学习,也要明确设备放在哪里、谁能访问、有没有密码、画面是否保存。不要把摄像头对准不该拍的地方,不要把网页服务暴露到公网。低成本不是低责任,能拍到画面的设备就应该认真对待。
如果只是家里学习,建议使用 ESP32 自己开的热点,或者局域网内访问。不要做端口映射,不要把摄像头裸露到互联网。真要远程看画面,也应该走有鉴权、有加密、有日志的方案。
本篇小结
这篇把 ESP32-CAM 从“能看画面”推进到“能发现动静”。它不依赖复杂 AI,而是用前后帧差异做一个轻量运动检测:缩小画面、转灰度摘要、比较差异、超过阈值触发事件,再配合冷却时间降低刷屏。
这个项目很适合继续扩展:可以保存 SD 卡,可以 MQTT 推送,可以增加网页配置,也可以加补光灯和夜间模式。但别急着一次全塞进去。先让摄像头稳定看画面,再让它稳定判断动静,最后再让它通知你。一步一步来,ESP32-CAM 就会从“会拍照的小板子”慢慢变成一个像样的看护节点。
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号