ESP32-CAM Time-Lapse Camera with Deep Sleep and microSD Storage

A time-lapse turns hours of slow change, such as a seed sprouting, concrete curing, clouds moving or a construction site growing, into a short video. The ESP32-CAM is well suited to this: it can wake up at fixed intervals, take one photo, write it to a microSD card and spend the rest of the time in deep sleep.

In this tutorial you will build that camera, learn how to keep exposure consistent between frames so the video does not flicker, store a frame counter that survives deep sleep, estimate battery life with a simple calculation, and assemble the photos into an MP4 file on your computer.

How a Deep-Sleep Time-Lapse Works

In deep sleep the ESP32 switches off its CPUs and most RAM. Only the RTC domain keeps running: a timer, a few RTC GPIOs and 8 KB of RTC slow memory. When the timer expires, the chip resets and runs setup() again from the beginning. Normal global variables are lost, but variables marked RTC_DATA_ATTR are kept in RTC memory between sleeps.

  1. Wake from timer, restore the frame counter from RTC memory.
  2. Power up and initialise the camera, then discard a few frames while exposure settles.
  3. Capture one frame and write it to the SD card as /tl/img00042.jpg.
  4. Power down the sensor, hold the flash pin LOW and set the next wake-up time.
  5. Enter deep sleep.

Because start-up and capture take a few seconds, the real interval is slightly longer than the sleep time. The code measures how long it was awake and subtracts that from the sleep time so frames stay evenly spaced.

Hardware and Settings

ItemChoiceReason
BoardAI-Thinker ESP32-CAMCamera, PSRAM and SD slot on one board
microSD cardFAT32 formatted, 32 GB or lessSD_MMC library mounts FAT file systems
SD mode1-bitFrees GPIO 4 so the flash LED does not flicker during writes
Power5 V USB supply, or battery through a 5 V converterStable 5V pin prevents brownouts at wake-up
Frame sizeSVGA to UXGAVideo players handle any size; UXGA gives most detail
Interval10 s to 10 minDepends on how fast the scene changes

Complete Code

Deep-sleep time-lapse: wake, capture, save numbered JPEG to microSD, sleep
#include "esp_camera.h"
#include "FS.h"
#include "SD_MMC.h"
#include "driver/rtc_io.h"

// AI-Thinker ESP32-CAM pin map
#define PWDN_GPIO_NUM  32
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM   0
#define SIOD_GPIO_NUM  26
#define SIOC_GPIO_NUM  27
#define Y9_GPIO_NUM    35
#define Y8_GPIO_NUM    34
#define Y7_GPIO_NUM    39
#define Y6_GPIO_NUM    36
#define Y5_GPIO_NUM    21
#define Y4_GPIO_NUM    19
#define Y3_GPIO_NUM    18
#define Y2_GPIO_NUM     5
#define VSYNC_GPIO_NUM 25
#define HREF_GPIO_NUM  23
#define PCLK_GPIO_NUM  22

#define INTERVAL_S   60          // one frame per minute
#define SETTLE_FRAMES 5          // frames discarded for exposure

RTC_DATA_ATTR uint32_t frameNo = 0;   // survives deep sleep

bool initCamera() {
  camera_config_t c = {};
  c.ledc_channel = LEDC_CHANNEL_0;
  c.ledc_timer   = LEDC_TIMER_0;
  c.pin_d0 = Y2_GPIO_NUM;  c.pin_d1 = Y3_GPIO_NUM;
  c.pin_d2 = Y4_GPIO_NUM;  c.pin_d3 = Y5_GPIO_NUM;
  c.pin_d4 = Y6_GPIO_NUM;  c.pin_d5 = Y7_GPIO_NUM;
  c.pin_d6 = Y8_GPIO_NUM;  c.pin_d7 = Y9_GPIO_NUM;
  c.pin_xclk = XCLK_GPIO_NUM;   c.pin_pclk = PCLK_GPIO_NUM;
  c.pin_vsync = VSYNC_GPIO_NUM; c.pin_href = HREF_GPIO_NUM;
  c.pin_sccb_sda = SIOD_GPIO_NUM; c.pin_sccb_scl = SIOC_GPIO_NUM;
  c.pin_pwdn = PWDN_GPIO_NUM;   c.pin_reset = RESET_GPIO_NUM;
  c.xclk_freq_hz = 20000000;
  c.pixel_format = PIXFORMAT_JPEG;
  c.frame_size   = FRAMESIZE_UXGA;  // 1600x1200
  c.jpeg_quality = 10;
  c.fb_count     = 1;
  c.fb_location  = CAMERA_FB_IN_PSRAM;
  c.grab_mode    = CAMERA_GRAB_LATEST;
  return esp_camera_init(&c) == ESP_OK;
}

void sleepNow(unsigned long awakeMs) {
  esp_camera_deinit();
  // PWDN HIGH powers the OV2640 down; hold it through deep sleep
  pinMode(PWDN_GPIO_NUM, OUTPUT);
  digitalWrite(PWDN_GPIO_NUM, HIGH);
  rtc_gpio_hold_en(GPIO_NUM_32);
  // Keep the flash LED off
  pinMode(4, OUTPUT);
  digitalWrite(4, LOW);
  rtc_gpio_hold_en(GPIO_NUM_4);

  uint64_t sleepMs = INTERVAL_S * 1000ULL;
  if (awakeMs < sleepMs) sleepMs -= awakeMs;   // keep spacing even
  esp_sleep_enable_timer_wakeup(sleepMs * 1000ULL);  // microseconds
  Serial.printf("Awake %lu ms, sleeping %llu ms\n", awakeMs, sleepMs);
  Serial.flush();
  esp_deep_sleep_start();
}

void setup() {
  unsigned long tStart = millis();
  Serial.begin(115200);
  rtc_gpio_hold_dis(GPIO_NUM_32);        // release holds from last sleep
  rtc_gpio_hold_dis(GPIO_NUM_4);

  if (!initCamera()) {
    Serial.println("Camera init failed");
    sleepNow(millis() - tStart);
  }
  sensor_t *s = esp_camera_sensor_get();
  s->set_lenc(s, 1);                     // lens shading correction
  s->set_whitebal(s, 1);
  s->set_exposure_ctrl(s, 1);

  for (int i = 0; i < SETTLE_FRAMES; i++) {   // let AE and AWB settle
    camera_fb_t *f = esp_camera_fb_get();
    if (f) esp_camera_fb_return(f);
  }
  camera_fb_t *fb = esp_camera_fb_get();

  if (fb && SD_MMC.begin("/sdcard", true)) {     // 1-bit mode
    SD_MMC.mkdir("/tl");
    char path[32];
    snprintf(path, sizeof(path), "/tl/img%05lu.jpg", (unsigned long)frameNo);
    File f = SD_MMC.open(path, FILE_WRITE);
    if (f) {
      f.write(fb->buf, fb->len);
      f.close();
      Serial.printf("Saved %s, %u bytes\n", path, (unsigned)fb->len);
      frameNo++;
    }
    SD_MMC.end();
  } else {
    Serial.println("Capture or SD mount failed");
  }
  if (fb) esp_camera_fb_return(fb);
  sleepNow(millis() - tStart);
}

void loop() {
}

The counter is kept in RTC memory, which is lost if power is removed. After a power cut the numbering restarts at zero and would overwrite old frames, so start each new session with an empty card, or store the counter with Preferences as in the PIR camera project if your power is unreliable.

Estimating Battery Life

Average current is a weighted average of the awake and sleep currents. Measure both on your own board with a multimeter in series with the 5 V lead, because board-level sleep current is much higher than the bare ESP32 figure; the regulator and other parts stay powered.

I_avg = (I_awake x t_awake + I_sleep x t_sleep) / (t_awake + t_sleep)

Battery life (hours) = usable capacity (mAh) / I_avg (mA)

Example with measured values I_awake = 180 mA, t_awake = 4 s,
I_sleep = 6 mA, t_sleep = 56 s:
I_avg = (180 x 4 + 6 x 56) / 60 = (720 + 336) / 60 = 17.6 mA
A 2000 mAh pack (usable) -> about 2000 / 17.6 = 114 hours

The numbers in the example are placeholders to show the method; replace them with your own measurements. The calculation also shows where to save power: shortening the awake time often helps more than anything else.

Making the Video

Copy the tl folder to your computer and run ffmpeg. The pattern img%05d.jpg matches the five-digit numbering, and -framerate 24 plays 24 photos per second, so one hour of one-minute frames becomes 2.5 seconds of video.

ffmpeg -framerate 24 -i img%05d.jpg -c:v libx264 -pix_fmt yuv420p timelapse.mp4

Common Problems

  • Video flickers in brightness: auto exposure differs slightly per frame. Increase SETTLE_FRAMES, or set manual exposure with set_exposure_ctrl(s, 0) and set_aec_value() for indoor scenes with fixed lighting.
  • Green or purple frames: not enough settle frames after a cold start; add more.
  • Missing frame numbers: a write failed or a brownout reset happened during capture. Check the supply and the SD card.
  • Night frames are black: the OV2640 needs light. For night time-lapse, add lighting or skip frames when the image is very dark.
  • Card fills up: at UXGA each frame can be a few hundred kilobytes. Multiply your measured frame size by the number of frames per day to check that the card is large enough.

Summary

A deep-sleep time-lapse wakes on the RTC timer, settles exposure, saves one numbered JPEG to the SD card in 1-bit mode, powers down the sensor and sleeps for the interval minus its awake time. Measure your real awake and sleep currents to estimate battery life, mount the camera firmly, and join the frames with a single ffmpeg command.