ESP32-CAM 实战教程:做一个带运动检测的低成本看护摄像头

本文由 AI 协助整理,代码和验证步骤适用于学习记录。摄像头画面涉及隐私,实际使用时请只用于合法、合规、知情的场景,不要把它做成“悄悄观察别人”的设备。

ESP32-CAM 运动检测原理

这篇准备做什么

上一篇已经把 ESP32-CAM 的网页视频流和声音采集跑起来了,这一篇继续往前拱一步:做一个带运动检测的低成本看护摄像头。手机可以通过网页查看实时画面,设备定时分析前后两帧图像,如果画面变化超过阈值,就认为“有动静”,然后抓拍一张 JPEG,记录事件时间,后续还可以扩展成 SD 卡保存、MQTT 通知、企业微信或 TG 推送。

先给这个项目划清边界:本文说的运动检测不是 AI 识别。它不会判断“这是人”“这是猫”“这是快递小哥”,它只判断画面是不是发生了明显变化。比如有人经过、门被打开、光线突然变化、摄像头被碰了一下,都可能触发。它像一个认真但有点朴素的门卫:能发现动静,但不一定知道动静是谁弄出来的。

这种方案的好处是轻量,适合 ESP32-CAM 这种资源有限的小板子。缺点也明显:误报会存在,尤其是光照变化、窗帘晃动、画面噪声、摄像头抖动。我们要做的不是假装它万能,而是把它做成一个能演示、能解释、能逐步调参的小项目。技术文章最怕一脸自信地吹“智能识别”,最后代码里其实只是 if(diff > 100)。那就像给电动车贴了跑车标,短距离看挺精神,拐弯就露馅。

项目功能设计

这个看护摄像头先实现四个功能:

  1. 手机访问网页,查看实时 MJPEG 画面。
  2. ESP32-CAM 周期抓取画面并做简单运动检测。
  3. 检测到动静后,保存最近一次抓拍到内存状态,网页可查看事件信息。
  4. 设置冷却时间,避免一秒钟内连续触发几十次。

扩展功能可以放到第二阶段:

扩展方向 说明
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 供电时,要确认模块供电能力够用;不够的时候,串口会出现重启、乱码、初始化失败,像一个没睡醒的人努力开机。

运动检测的基本原理

最简单的运动检测叫帧差法。步骤如下:

  1. 取一帧画面作为上一帧。
  2. 再取一帧画面作为当前帧。
  3. 把两帧缩小,转成灰度摘要。
  4. 比较对应位置的像素差异。
  5. 差异超过阈值的像素数量达到一定比例,就认为有运动。

为什么要缩小?因为 ESP32-CAM 资源有限,直接对大图逐像素比较成本太高。把画面缩成很小的灰度摘要,例如 32x24 或 40x30,已经足够判断大致变化。我们不追求识别桌上硬币移动了 2 毫米,只希望发现“有人走过”“门开了”“画面明显变了”。

为什么要转灰度?因为颜色信息在这个入门方案里不是重点,亮度变化已经能提供足够信号。转灰度后数据更小,计算更快。缺点是光照变化也会带来明显差异,所以要配合阈值、最小变化面积和冷却时间。

事件触发流程

ESP32-CAM 事件触发流程

推荐流程如下:

  1. 正常提供 /stream 视频流,手机网页随时可看。
  2. 后台每隔一段时间抓取一帧低分辨率图像。
  3. 生成灰度摘要,与上一帧摘要比较。
  4. 如果变化超过阈值,记录事件时间和变化值。
  5. 抓拍一张 JPEG 作为最近事件图片。
  6. 进入冷却时间,例如 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 秒通常比较舒服。太短容易刷屏,太长可能漏掉连续事件。真实场景里可以放到网页配置里,让用户自己调。

网页展示

网页可以先做三个区域:

  1. 实时画面:<img src="/stream">
  2. 最近事件:显示触发时间、变化分数、变化像素数。
  3. 最近抓拍:显示 /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 就会从“会拍照的小板子”慢慢变成一个像样的看护节点。

打赏

如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏码

打赏支持:王庆岩

posted on 2026-09-16 23:51  潇洒石头  阅读(38)  评论(0)    收藏  举报

导航