ESP32-CAM: Time-Lapse Camera for Plant Growth Monitoring
Intermediate1/8/2026- Author: IoTSpark Maker

ESP32-CAM: Time-Lapse Camera for Plant Growth Monitoring

ESP32-CAM periodically snaps photos (every 10 minutes by default) and saves them to an SD card as a sequence — frame_00001.jpg, frame_00002.jpg, and so on — ready to be stitched into a time-lapse video of plant growth, with a frame counter that survives resets.

ESP32-CAMtime-lapseSD-cardplant-monitoringesp32
0 steps4 components

The ESP32-CAM makes a cheap time-lapse camera for tracking plant growth over days or weeks.

This project takes JPEG photos on a schedule and saves them to an SD card in sequence — frame_00001.jpg, frame_00002.jpg, and so on. Unlike a basic scheduled-capture project, the frame counter is persisted to a counter.txt file on the SD card so it survives a mid-sequence reset (say, a power loss), keeping the time-lapse sequence unbroken.

Stitching the image sequence into a video (e.g. with ffmpeg) is done on a computer after pulling the SD card — not on the ESP32 itself.

You can use either an ESP32-CAM (AI-Thinker) or an ESP32-S3 WROOM N16R8 CAM with an OV2640 2MP / OV5640 5MP sensor for this project.

Detailed guide

ESP32-CAM periodically snaps photos and saves them to an SD card as a sequence — full 8-section guide, wiring diagram, and compile-verified code with a reset-proof frame counter.

1. Introduction

The ESP32-CAM is a cheap way to build a time-lapse camera for tracking plant growth over days or weeks. This project takes JPEG photos on a schedule (every 10 minutes by default) and saves them to an SD card in sequence: frame_00001.jpg, frame_00002.jpg, and so on.

What sets it apart from a basic "scheduled capture" project: the frame counter is persisted to a counter.txt file on the SD card, so it doesn't get lost or reset when the device restarts mid-sequence (say, after a power outage) — keeping the time-lapse sequence unbroken and never overwritten.

2. Components Needed

Component

Qty

Notes

ESP32-CAM (AI-Thinker), OV2640

1

Built-in camera + microSD slot, 4MB PSRAM

FTDI USB-to-Serial programmer

1

Required for flashing firmware over UART

microSD card (FAT32, ≤32GB recommended)

1

Needs enough capacity for a long-running time-lapse (UXGA ~200-400KB per photo)

A stable 5V/1A power supply + a fixed camera mount

1

A time-lapse needs the camera position/angle to stay fixed throughout the monitoring period

3. Wiring Diagram

FTDI

ESP32-CAM

TX

GPIO3 (RX)

RX

GPIO1 (TX)

GND

GND

5V

5V

Insert the microSD card into the board's built-in slot. Use SD_MMC in 1-bit mode to avoid conflicting with GPIO4 (the flash LED) — see the pin configuration details in the sample code below.

4. Environment Setup

Arduino IDE, board set to AI Thinker ESP32-CAM (FQBN esp32:esp32:esp32cam). No extra libraries needed — esp_camera.h, FS.h, and SD_MMC.h already ship with esp32 core 3.3.10.

5. Sample Code — Time-Lapse with a Persistent Counter

/*
  ESP32-CAM: Time-lapse Camera giam sat cay trong
  Board: ESP32-CAM (AI-Thinker), OV2640 + khe microSD tich hop

  Chup anh dinh ky (TIMELAPSE_INTERVAL_MS) va luu vao /timelapse/frame_NNNNN.jpg
  tren SD_MMC (1-bit mode). So thu tu frame duoc luu ben trong file counter.txt
  tren the SD de khong bi mat/trung so khi thiet bi khoi dong lai giua chung
  (vd sau mat dien) - dam bao chuoi anh time-lapse lien tuc. Ghep chuoi anh
  thanh video (vd ffmpeg -framerate 24 -i frame_%05d.jpg out.mp4) duoc thuc
  hien O MAY TINH sau khi rut the SD, khong xu ly tren ESP32.
*/

#include "esp_camera.h"
#include "Arduino.h"
#include "FS.h"
#include "SD_MMC.h"

// ---------- Chan camera co dinh cua AI-Thinker ESP32-CAM ----------
#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 TIMELAPSE_DIR          "/timelapse"
#define COUNTER_FILE            "/timelapse/counter.txt"
#define TIMELAPSE_INTERVAL_MS  (10UL * 60UL * 1000UL) // 10 phut/frame (chinh theo toc do sinh truong cay)

bool cameraReady = false;
bool sdReady = false;
unsigned long lastCaptureMs = 0;
unsigned int frameIndex = 0;

static bool initCamera() {
  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  config.xclk_freq_hz = 20000000;
  config.pixel_format = PIXFORMAT_JPEG;

  if (psramFound()) {
    config.frame_size = FRAMESIZE_UXGA; // 1600x1200 - chat luong cao cho time-lapse
    config.jpeg_quality = 10;
    config.fb_count = 2;
    config.fb_location = CAMERA_FB_IN_PSRAM;
    Serial.println("[CAM] PSRAM OK -> UXGA JPEG, fb_count=2");
  } else {
    config.frame_size = FRAMESIZE_SVGA; // 800x600 khi khong co PSRAM
    config.jpeg_quality = 14;
    config.fb_count = 1;
    config.fb_location = CAMERA_FB_IN_DRAM;
    Serial.println("[CAM] Khong thay PSRAM -> giam xuong SVGA JPEG, fb_count=1");
  }

  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) {
    Serial.printf("[CAM] Khoi tao that bai, ma loi 0x%x\n", err);
    return false;
  }

  sensor_t *s = esp_camera_sensor_get();
  if (s) {
    // Anh sang ngoai troi/trong nha thay doi nhieu trong ngay -> bat AEC/AGC tu dong
    s->set_exposure_ctrl(s, 1);
    s->set_gain_ctrl(s, 1);
    s->set_whitebal(s, 1);
  }
  return true;
}

static unsigned int loadCounter() {
  if (!SD_MMC.exists(COUNTER_FILE)) return 0;
  File f = SD_MMC.open(COUNTER_FILE, FILE_READ);
  if (!f) return 0;
  unsigned int value = f.parseInt();
  f.close();
  return value;
}

static void saveCounter(unsigned int value) {
  File f = SD_MMC.open(COUNTER_FILE, FILE_WRITE);
  if (!f) {
    Serial.println("[SD] Khong ghi duoc counter.txt");
    return;
  }
  f.print(value);
  f.close();
}

static bool initSdCard() {
  if (!SD_MMC.begin("/sdcard", true)) { // 1-bit mode, tranh xung GPIO4 (flash LED / SD_D1)
    Serial.println("[SD] Mount that bai. Kiem tra the SD da cam chua / dinh dang FAT32.");
    return false;
  }
  uint8_t cardType = SD_MMC.cardType();
  if (cardType == CARD_NONE) {
    Serial.println("[SD] Khong phat hien the SD.");
    return false;
  }

  if (!SD_MMC.exists(TIMELAPSE_DIR)) {
    SD_MMC.mkdir(TIMELAPSE_DIR);
  }

  frameIndex = loadCounter();
  uint64_t cardSizeMB = SD_MMC.cardSize() / (1024 * 1024);
  Serial.printf("[SD] San sang, dung luong %llu MB, tiep tuc tu frame #%u\n", cardSizeMB, frameIndex);
  return true;
}

static void captureFrame() {
  camera_fb_t *fb = esp_camera_fb_get();
  if (!fb) {
    Serial.println("[CAM] Lay khung hinh that bai, bo qua frame nay.");
    return;
  }

  Serial.printf("[CAM] Chup thanh cong: %dx%d, %u bytes\n", fb->width, fb->height, fb->len);

  if (sdReady) {
    char path[48];
    snprintf(path, sizeof(path), "%s/frame_%05u.jpg", TIMELAPSE_DIR, frameIndex);
    File file = SD_MMC.open(path, FILE_WRITE);
    if (!file) {
      Serial.printf("[SD] Khong mo duoc file %s de ghi.\n", path);
    } else {
      file.write(fb->buf, fb->len);
      file.close();
      frameIndex++;
      saveCounter(frameIndex);
      Serial.printf("[SD] Da luu: %s (tong %u frame)\n", path, frameIndex);
    }
  } else {
    Serial.println("[SD] SD khong san sang, bo qua luu frame nay.");
  }

  esp_camera_fb_return(fb);
}

void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println();
  Serial.println("=== ESP32-CAM Plant Time-lapse boot ===");

  cameraReady = initCamera();
  if (!cameraReady) {
    Serial.println("[CAM] Camera khong san sang, se thu lai trong loop().");
  }

  sdReady = initSdCard();

  lastCaptureMs = millis() - TIMELAPSE_INTERVAL_MS; // chup ngay frame dau khi boot
  Serial.printf("[SCHEDULE] Time-lapse: 1 frame moi %lu ms\n", TIMELAPSE_INTERVAL_MS);
}

void loop() {
  unsigned long now = millis();

  if (cameraReady && (now - lastCaptureMs >= TIMELAPSE_INTERVAL_MS)) {
    lastCaptureMs = now;
    captureFrame();
  }

  if (!cameraReady) {
    static unsigned long lastCamRetry = 0;
    if (now - lastCamRetry > 10000) {
      Serial.println("[CAM] Retry khoi tao camera...");
      cameraReady = initCamera();
      lastCamRetry = now;
    }
  }

  if (!sdReady) {
    static unsigned long lastSdRetry = 0;
    if (now - lastSdRetry > 30000) {
      Serial.println("[SD] Retry mount the SD...");
      sdReady = initSdCard();
      lastSdRetry = now;
    }
  }

  delay(50);
}

6. Code Walkthrough

  • loadCounter() / saveCounter(): reads/writes the current frame number to counter.txt on the SD card — when the device restarts (power loss, reset), frameIndex resumes from the saved value instead of resetting to 0 (avoiding overwriting older frames).

  • Automatic AEC/AGC/AWB (set_exposure_ctrl, set_gain_ctrl, set_whitebal): enabled because outdoor/indoor lighting changes significantly throughout the day — necessary for a time-lapse running for hours on end, so photos don't come out over- or under-exposed depending on when they were taken.

  • UXGA resolution when PSRAM is available: prioritizes high image quality for the time-lapse (unlike the Telegram project, it isn't constrained by network bandwidth), falling back to SVGA when PSRAM isn't present.

  • A dedicated TIMELAPSE_DIR: keeps the /timelapse folder separate for easier management, so it doesn't mix with other files on the SD card.

7. Common Issues

Issue

Cause

Fix

Frame numbers skip or aren't sequential

The SD card filled up partway through — the file write failed, but the counter correctly doesn't increment (expected behavior: no saved file means no counter bump)

Check SD card space periodically; at UXGA (~300KB/photo) and one photo every 10 minutes, a month needs about 1.3GB

Time-lapse photos are misaligned or shaky

The camera isn't mounted securely

Use a stable mount/clamp, and avoid vibration from wind or bumps

The assembled video looks jerky or has uneven time spacing

Repeated device resets make the interval between frames uneven

This is an inherent limitation of an uptime-based time-lapse; if you need absolute timestamps, add an RTC module (DS3231) and encode the timestamp into the filename

Photos come out over- or under-exposed at night

The OV2640 has no IR illumination, so it isn't suited to shooting in total darkness

Add a supplementary LED light, or only run the time-lapse during hours with adequate light

8. Summary

This project builds a standalone time-lapse camera that survives resets thanks to the counter saved on the SD card — well suited for long-term monitoring that doesn't need constant WiFi: tracking plant growth, construction progress, or any slow-moving process. Assembling the video from the image sequence is done offline with ffmpeg after pulling the SD card.