ESP32-CAM 监控:小试牛刀写一个自助闪光

起因

之前用 ESP32-S3 做了个监控摄像头,效果还行。后来翻抽屉发现还有一块 AI-Thinker 的 ESP32-CAM 开发板——就是那种十几块钱、自带 OV2640 摄像头和 TF 卡槽的经典板子。手上有就不用浪费,再做一个吧:ai-thinker-esp32-cam

这次还是 ESP-IDF 从零写固件,功能跟上个项目差不多,但针对 AI-Thinker 这块板子做了不少适配。最终实现了这些东西:

  • 浏览器打开 IP 看 MJPEG 实时画面
  • 动态检测,检测到动静自动拍照
  • 暗光环境自动开闪光灯拍照
  • 拍照存 TF 卡,支持 WebDAV 上传 NAS
  • Web 管理界面,手机上就能配参数
  • Prometheus /metrics 端点接监控系统
  • WiFi AP/STA 双模式,首次上电手机配网

功能不复杂,但闪光灯那块逻辑花了我好几天。原因很简单:我懒得接光敏传感器。省了几根线,多写了几百行代码。

闪光灯逻辑:不接传感器的代价

需求就一句话,实现折腾好几天

天黑了自动开闪光灯拍照——需求说起来就这么多。接个光敏电阻到 ADC 口,读个电压值就完事了,半小时搞定的活。但 AI-Thinker ESP32-CAM 的 GPIO 本来就紧张,闪光灯占了 GPIO4,TF 卡占了 GPIO2/14/15,摄像头一堆引脚,剩下的没几个能用了。多接一个传感器还要面包板、杜邦线、分压电阻……光是想想就不想动。

那就用摄像头本身来感知亮度呗。摄像头不就是个感光元件吗,画面亮就是亮,暗就是暗。问题是固件运行时摄像头一直在以 JPEG 模式输出画面(MJPEG 流和动态检测都要用),得在不影响正常工作的前提下判断环境亮度。

读 OV2640 曝光寄存器——不行

第一反应是读 OV2640 的 AEC(Auto Exposure Control)寄存器。理论上曝光值能反映环境亮度:越暗曝光越高,直接读寄存器零延迟零开销,完美。

实测下来这个寄存器在连续 JPEG 输出模式下根本不靠谱——aec_value 一直稳定在最大值 671 不动弹,不管实际光线怎么变。查了 OV2640 的 datasheet,也没找到明确的说法。可能是连续输出模式下 AEC 反馈回路有 bug,也可能就是这颗 sensor 的"特色"了。总之这条路走不通。

灰度像素采样:把摄像头当光敏传感器

读寄存器不行,那就直接读像素。做法不复杂:

  1. 每 30 秒做一次亮度探测
  2. 把摄像头从 JPEG 模式临时切换到灰度模式(GRAYSCALE + QQVGA 160×120)
  3. 抓一帧,遍历所有像素算平均亮度
  4. 切回 JPEG 模式继续干活
c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 切到灰度模式
esp_err_t ret = camera_init_grayscale();
if (ret != ESP_OK) {
    ESP_LOGE(TAG, "Grayscale probe: init failed");
    return;
}

// 扔掉前两帧,刚切模式白平衡没稳定
for (int i = 0; i < 2; i++) {
    camera_fb_t *fb = esp_camera_fb_get();
    if (fb) esp_camera_fb_return(fb);
}

// 抓一帧算平均亮度
camera_fb_t *fb = NULL;
if (camera_capture(&fb) != ESP_OK || fb == NULL) {
    camera_restore_jpeg();
    return;
}

// 灰度模式一个像素一个字节,直接求和取平均
uint32_t sum = 0;
for (size_t i = 0; i < fb->len; i++) {
    sum += fb->buf[i];
}
uint8_t avg = (uint8_t)(sum / fb->len);
uint8_t pct = (uint8_t)((uint32_t)avg * 100 / 255);  // 换算成百分比

bool is_dark = (pct < cfg->flash_threshold);

// 切回 JPEG 模式
camera_restore_jpeg();

QQVGA 160×120 总共 19200 个像素,遍历一遍几百微秒,对性能没什么影响。灰度模式每个像素就是一个字节(0~255),直接求平均就行,简单粗暴。

不过有个前提条件:如果有人在浏览器看 MJPEG 画面,就不能做灰度探测——切模式会导致 MJPEG 流断掉。这种时候只能靠下面的备用方案。

JPEG 大小反推亮度:零开销的野路子

有人在线看画面的时候没法切模式,但还得判断亮度。怎么办?答案就藏在每一帧的 JPEG 数据里:JPEG 文件大小本身就是亮度指标

原理不复杂。暗场景下画面大部分区域是黑的,大面积相同颜色 JPEG 压缩率非常高,文件就小。场景越亮细节越多,文件越大。实际拿 SVGA 分辨率、quality=10 测了一组数据:

  • 暗光(关灯):12~14 KB
  • 正常室内:14~17 KB
  • 明亮(对着窗户):17~25 KB

根据这个做了个线性映射:

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
if (s_brightness.method != 2) {
    // 没有灰度数据,用 JPEG 大小估算
    uint32_t jpeg_kb = (uint32_t)frame_len / 1024;
    uint8_t pct;

    if (jpeg_kb >= 22) {
        pct = 100;
    } else if (jpeg_kb <= 12) {
        pct = 0;
    } else {
        pct = (uint8_t)((jpeg_kb - 12) * 100 / 10);
    }

    s_brightness.method = 1;
    s_brightness.brightness_pct = pct;
    s_brightness.is_dark = (pct < cfg->flash_threshold);
}

精度不如灰度采样,但好处是零额外开销——动态检测本来就要抓帧做帧差,JPEG 大小顺手拿到的,不多干一件事。

两套方案的关系:

  • 灰度探测(method=2):精确,但得切模式,有人看画面时不能用
  • JPEG 大小推算(method=1):精度一般,但随时能用,没有任何额外消耗
mermaid
flowchart TD
    A[每帧动态检测] --> B{灰度探测可用?}
    B -->|30秒内探测过 & 无 MJPEG 客户端| C[使用灰度探测结果]
    B -->|MJPEG 客户端在线或超时| D[用 JPEG 大小估算]
    C --> E{亮度 < 阈值?}
    D --> E
    E -->|是| F[标记为暗场景]
    E -->|否| G[标记为亮场景]
    F --> H[检测到动态时开闪光灯拍照]
    G --> I[正常拍照]

闪光灯控制:80% 占空比保命

AI-Thinker ESP32-CAM 的闪光灯接在 GPIO4 上。这里有个硬件坑:板子上闪光灯没串限流电阻。LED 直接挂 GPIO 上,全功率跑的话电流可能超标,时间长搞不好烧板子。

所以用 LEDC PWM 控制,占空比卡在 80%(205/255),给自己留条后路:

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#define FLASH_GPIO            4
#define FLASH_LEDC_TIMER      LEDC_TIMER_1   // Timer 0 被 camera XCLK 占了
#define FLASH_LEDC_CHANNEL    LEDC_CHANNEL_1
#define FLASH_PWM_FREQ        2000           // 2 kHz
#define FLASH_PWM_RES         LEDC_TIMER_8_BIT
#define FLASH_PWM_DUTY        205            // 约 80%

// 开灯
ledc_set_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL, FLASH_PWM_DUTY);
ledc_update_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL);

// 关灯
ledc_set_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL, 0);
ledc_update_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL);

注意 Timer 和 Channel 不能随便选。摄像头 XCLK 占了 Timer 0 / Channel 0,闪光灯必须用 Timer 1 / Channel 1,不然两个外设打架。这种坑 ESP-IDF 文档里可不会写,烧进去发现不对劲才知道。

闪光灯只在拍照时用,检测的时候绝不开

这个设计很关键。闪光灯只在做完帧差检测、确认有动态、需要保存照片的那一刻才开。做帧差比较的时候绝不开灯

原因一想就明白:开灯关灯之间画面亮度剧烈变化,帧差算法一看——好家伙整个画面都在动,直接报个 80% 以上的差异,全成假报警了。

c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
static void handle_motion_event(bool dark_scene)
{
    camera_fb_t *fb = NULL;
    if (dark_scene) {
        ESP_LOGI(TAG, "Dark scene, flash ON for photo");
        // 开灯 → 等 200ms → 拍照 → 关灯
        ledc_set_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL, FLASH_PWM_DUTY);
        ledc_update_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL);
        vTaskDelay(pdMS_TO_TICKS(200));  // 等白平衡追上来
        camera_capture(&fb);
        ledc_set_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL, 0);
        ledc_update_duty(FLASH_LEDC_SPEED, FLASH_LEDC_CHANNEL);
    }
    if (fb == NULL) {
        camera_capture(&fb);  // 不需要闪光灯就直接拍
    }
    // ... 存 TF 卡 / 上传 NAS ...
}

开灯之后等了 200ms 才拍照。刚开灯那会儿白平衡还没反应过来,拍出来颜色是偏的。等一下让 OV2640 的自动白平衡追上来,照片才看得清。

暗场景自动降动态检测阈值

还有个细节:暗场景下 JPEG 帧差值天然偏低(画面暗细节少,压缩后帧间差异小),用正常阈值可能检测不到动态。所以暗场景自动把阈值降到原来的四分之一:

c
1
2
3
uint8_t effective_thresh = dark_scene
    ? (cfg->motion_threshold > 20 ? cfg->motion_threshold / 4 : 5)
    : cfg->motion_threshold;

不多解释,想一下就能明白为什么。

其他功能

闪光灯是花时间最多的部分,其他就没什么好展开的了,列一下:

  • MJPEG 流:双缓冲 + PSRAM,浏览器打开就能看
  • WiFi 管理:AP/STA 双模式,首次上电 AP 配网,之后自动 STA
  • TF 卡:GPIO14 跟摄像头共用,必须等摄像头初始化完再挂载,顺序搞错直接炸
  • NAS 上传:WebDAV/HTTP POST,拍完自动传
  • Web 界面:配参数、看状态、下照片,手机操作
  • Prometheus 指标/metrics 端点,丢进 Grafana 画面板

写在后面

回头看,接个光敏电阻可能半小时就完事了。但用摄像头本身做亮度检测这个思路,倒逼着把 OV2640 的模式切换、JPEG 压缩特性、LEDC PWM 资源分配这些细节都摸了一遍。值不值不好说,反正折腾的过程挺有意思的。

代码全在 GitHub:Mi-Bee-Studio/ai-thinker-esp32-cam,README 里有构建和烧录步骤。