
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-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.txttrên SD — khi thiết bị khởi động lại (mất điện, reset),frameIndextiế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.