ESP32-CAM: Time-lapse Camera giám sát cây trồng
Trung cấp1/8/2026- Tác giả: IoTlabs Maker

ESP32-CAM: Time-lapse Camera giám sát cây trồng

ESP32-CAM chụp ảnh định kỳ (mặc định 10 phút/lần) lưu vào thẻ SD thành chuỗi frame_00001.jpg, frame_00002.jpg... để ghép thành video time-lapse giám sát quá trình sinh trưởng cây trồng, có counter bền vững qua reset.

ESP32-CAMtime-lapseSD-cardplant-monitoringesp32

ESP32-CAM phù hợp làm camera time-lapse giá rẻ để theo dõi quá trình sinh trưởng cây trồng qua nhiều ngày/tuần.

Bài này chụp ảnh JPEG định kỳ và lưu vào thẻ SD theo thứ tự frame_00001.jpg, frame_00002.jpg... Khác với project chụp-theo-lịch cơ bản, số thứ tự frame được lưu bền vững vào file counter.txt trên thẻ SD để không bị mất/trùng khi thiết bị khởi động lại giữa chừng (ví dụ mất điện) đảm bảo chuỗi ảnh time-lapse liên tục.

Việc ghép chuỗi ảnh thành video (vd bằng ffmpeg) được thực hiện ở máy tính sau khi rút thẻ SD, không xử lý trên ESP32.

Module có thể dùng cho bài này là ESP32-CAM (AI-Thinker) hoặc ESP32-S3 WROOM N16R8 CAM + OV2640 2MP / OV5640 – 5MP.

Hướng dẫn chi tiết

Hướng dẫn ESP32-CAM chụp ảnh time-lapse định kỳ lưu vào thẻ SD với counter bền vững qua reset, dùng giám sát cây trồng.

1. Giới thiệu

ESP32-CAM là lựa chọn giá rẻ để dựng camera time-lapse theo dõi quá trình sinh trưởng cây trồng qua nhiều ngày/tuần. Bài này chụp ảnh JPEG định kỳ (mặc định 10 phút/frame) và lưu vào thẻ SD theo thứ tự frame_00001.jpg, frame_00002.jpg...

Điểm khác biệt so với project "chụp theo lịch" cơ bản: số thứ tự frame được lưu bền vững vào counter.txt trên thẻ SD, nên không bị mất/trùng số khi thiết bị khởi động lại giữa chừng (ví dụ mất điện) — đảm bảo chuỗi ảnh time-lapse không bị đứt đoạn hay ghi đè.

2. Linh kiện cần có

Linh kiện

Số lượng

Ghi chú

ESP32-CAM (AI-Thinker), OV2640

1

Camera + khe microSD tích hợp, 4MB PSRAM

Mạch nạp FTDI USB-to-Serial

1

Bắt buộc để nạp firmware qua UART

Thẻ microSD (FAT32, ≤32GB khuyến nghị)

1

Cần đủ dung lượng cho time-lapse dài ngày (UXGA ~200-400KB/ảnh)

Nguồn 5V/1A ổn định + giá đỡ cố định camera

1

Time-lapse cần vị trí/góc camera không đổi suốt quá trình theo dõi

3. Sơ đồ kết nối

FTDI

ESP32-CAM

TX

GPIO3 (RX)

RX

GPIO1 (TX)

GND

GND

5V

5V

Thẻ microSD cắm vào khe tích hợp trên board. Dùng SD_MMC 1-bit mode để tránh xung đột GPIO4 (đèn flash) — xem chi tiết cấu hình chân trong code mẫu bên dưới.

4. Cài đặt môi trường

Arduino IDE, board AI Thinker ESP32-CAM (FQBN esp32:esp32:esp32cam). Không cần cài thư viện ngoài — esp_camera.h, FS.h, SD_MMC.h có sẵn trong core esp32 3.3.10.

5. Code ví dụ — Time-lapse với counter bền vững

/*
  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. Giải thích code

  • loadCounter() / saveCounter(): đọc/ghi số thứ tự frame hiện tại vào counter.txt trên SD — khi thiết bị khởi động lại (mất điện, reset), frameIndex tiếp tục từ giá trị đã lưu thay vì reset về 0 (tránh ghi đè các frame cũ).

  • AEC/AGC/AWB tự động (set_exposure_ctrl, set_gain_ctrl, set_whitebal): bật vì ánh sáng ngoài trời/trong nhà thay đổi đáng kể trong ngày — cần thiết cho time-lapse chạy nhiều giờ liên tục để ảnh không bị quá sáng/tối theo thời điểm chụp.

  • Độ phân giải UXGA khi có PSRAM: ưu tiên chất lượng ảnh cao cho time-lapse (không bị giới hạn băng thông mạng như project Telegram), giảm xuống SVGA khi thiếu PSRAM.

  • TIMELAPSE_DIR riêng biệt: tách thư mục /timelapse để dễ quản lý, không lẫn với các file khác trên thẻ SD.

7. Lỗi thường gặp

Lỗi

Nguyên nhân

Cách sửa

Chuỗi frame bị nhảy số/không liên tục

Thẻ SD đầy giữa chừng, ghi file thất bại nhưng counter vẫn không tăng (đúng behavior — file không lưu thì không tăng số)

Kiểm tra dung lượng SD định kỳ; với UXGA ~300KB/ảnh, 10 phút/ảnh, 1 tháng cần ~1.3GB

Ảnh time-lapse bị lệch góc/rung

Camera không được cố định chắc chắn

Dùng giá đỡ/kẹp cố định, tránh rung động từ gió hoặc va chạm

Video ghép ra bị giật/không đều khoảng cách thời gian

Thiết bị reset nhiều lần làm khoảng cách giữa các frame không đều

Đây là giới hạn của time-lapse dựa trên uptime; nếu cần mốc thời gian tuyệt đối, thêm module RTC (DS3231) và ghi timestamp vào tên file

Ảnh bị quá sáng/tối vào ban đêm

OV2640 không có đèn hồng ngoại, không phù hợp chụp thiếu sáng hoàn toàn

Thêm đèn LED phụ trợ hoặc chỉ chạy time-lapse trong khung giờ có ánh sáng đủ

8. Tổng kết

Dự án xây dựng một camera time-lapse độc lập, bền vững qua reset nhờ counter lưu trên SD — phù hợp cho các ứng dụng giám sát dài hạn không cần WiFi liên tục: theo dõi cây trồng, công trình xây dựng, hoặc bất kỳ quá trình nào diễn ra chậm theo thời gian. Ghép video từ chuỗi ảnh thực hiện offline bằng ffmpeg sau khi lấy thẻ SD ra.