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.
- Wake from timer, restore the frame counter from RTC memory.
- Power up and initialise the camera, then discard a few frames while exposure settles.
- Capture one frame and write it to the SD card as
/tl/img00042.jpg. - Power down the sensor, hold the flash pin LOW and set the next wake-up time.
- 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
| Item | Choice | Reason |
|---|---|---|
| Board | AI-Thinker ESP32-CAM | Camera, PSRAM and SD slot on one board |
| microSD card | FAT32 formatted, 32 GB or less | SD_MMC library mounts FAT file systems |
| SD mode | 1-bit | Frees GPIO 4 so the flash LED does not flicker during writes |
| Power | 5 V USB supply, or battery through a 5 V converter | Stable 5V pin prevents brownouts at wake-up |
| Frame size | SVGA to UXGA | Video players handle any size; UXGA gives most detail |
| Interval | 10 s to 10 min | Depends on how fast the scene changes |
Complete Code
#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 withset_exposure_ctrl(s, 0)andset_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.