ESP32-CAM Push Button Photo Capture with a Browser Photo Viewer
A camera that takes a picture when you press a button is the basis of many projects: a doorbell camera, a parcel-drop recorder, a document scanner or a simple photo booth. On the ESP32-CAM the challenge is not the button itself but the details around it: picking a GPIO that is actually free, debouncing the contact, getting a fresh frame instead of an old one, and keeping the photo after the frame buffer is returned to the driver.
In this project you will connect a push button, capture a photo into PSRAM on every press, and serve the latest photo on a web page that refreshes automatically. No microSD card is needed, which keeps the SD pins free.
Choosing a GPIO for the Button
When the microSD card is not used, GPIO 2, 4, 12, 13, 14 and 15 are available on the header. GPIO 4 drives the flash LED, and GPIO 12 and 15 are strapping pins, so a button that happens to be pressed at power-up could change how the chip boots. GPIO 13 has no strapping role and supports the internal pull-up, so it is the best choice.
| Item | Connection | Notes |
|---|---|---|
| Push button, leg 1 | GPIO 13 | Internal pull-up enabled in code |
| Push button, leg 2 | GND | Pressed = LOW |
| 100 nF capacitor (optional) | GPIO 13 to GND | Hardware debounce on long wires |
| 5 V supply | 5V and GND pins | At least 1 A to 2 A, short wires |
How the Capture Works
Each call to esp_camera_fb_get() hands you a frame buffer owned by the driver. You must give it back with esp_camera_fb_return(), otherwise the driver runs out of buffers. To keep a photo for later, copy the JPEG bytes into your own memory first. PSRAM is ideal for this: ps_malloc() allocates from it, and a photo of up to a few hundred kilobytes fits easily.
Because the camera keeps capturing in the background, the frame waiting in the buffer may be several hundred milliseconds old. The code discards one frame and then takes the next, so the photo shows the moment the button was pressed, not a moment earlier.
Complete Code
This sketch uses the Arduino WebServer library, which is part of the ESP32 core. Replace the Wi-Fi details, upload, and open the printed IP address in a browser.
#include <WiFi.h>
#include <WebServer.h>
#include "esp_camera.h"
const char *ssid = "YourWiFiName";
const char *password = "YourWiFiPassword";
// 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 BUTTON_PIN 13
#define RED_LED 33 // active LOW
#define DEBOUNCE_MS 50
WebServer server(80);
uint8_t *photoBuf = nullptr; // copy of the last photo in PSRAM
size_t photoLen = 0;
uint32_t photoCount = 0;
unsigned long photoTime = 0;
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_SVGA; // 800x600 stills
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;
}
bool takePhoto() {
camera_fb_t *fb = esp_camera_fb_get();
if (fb) esp_camera_fb_return(fb); // discard the older frame
fb = esp_camera_fb_get();
if (!fb) return false;
uint8_t *newBuf = (uint8_t *)ps_malloc(fb->len);
if (!newBuf) { // out of PSRAM
esp_camera_fb_return(fb);
return false;
}
memcpy(newBuf, fb->buf, fb->len); // keep our own copy
size_t newLen = fb->len;
esp_camera_fb_return(fb); // give the buffer back quickly
free(photoBuf); // drop the previous photo
photoBuf = newBuf;
photoLen = newLen;
photoCount++;
photoTime = millis();
return true;
}
void handleRoot() {
String html = "<!DOCTYPE html><html><head>"
"<meta name='viewport' content='width=device-width'>"
"<meta http-equiv='refresh' content='3'>"
"<title>Button Camera</title></head><body style='font-family:sans-serif'>";
html += "<h3>Photos taken: " + String(photoCount) + "</h3>";
if (photoLen) {
html += "<p>Last photo " + String((millis() - photoTime) / 1000) + " s ago, " +
String(photoLen) + " bytes</p>";
html += "<img src='/latest.jpg?n=" + String(photoCount) + "' style='max-width:100%'>";
} else {
html += "<p>Press the button to take a photo.</p>";
}
html += "</body></html>";
server.send(200, "text/html", html);
}
void handleLatest() {
if (!photoLen) {
server.send(404, "text/plain", "No photo yet");
return;
}
server.setContentLength(photoLen);
server.send(200, "image/jpeg", "");
server.sendContent((const char *)photoBuf, photoLen);
}
void setup() {
Serial.begin(115200);
pinMode(BUTTON_PIN, INPUT_PULLUP);
pinMode(RED_LED, OUTPUT);
digitalWrite(RED_LED, HIGH);
if (!psramFound() || !initCamera()) {
Serial.println("PSRAM missing or camera init failed");
while (true) delay(1000);
}
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.printf("\nViewer: http://%s/\n", WiFi.localIP().toString().c_str());
server.on("/", handleRoot);
server.on("/latest.jpg", handleLatest);
server.begin();
}
void loop() {
server.handleClient();
// Debounce: act once per press, after the level is stable
static int lastReading = HIGH;
static int stableState = HIGH;
static unsigned long lastChange = 0;
int reading = digitalRead(BUTTON_PIN);
if (reading != lastReading) {
lastChange = millis();
lastReading = reading;
}
if (millis() - lastChange > DEBOUNCE_MS && reading != stableState) {
stableState = reading;
if (stableState == LOW) { // new press detected
digitalWrite(RED_LED, LOW);
bool ok = takePhoto();
digitalWrite(RED_LED, HIGH);
Serial.printf("Photo %lu %s, %u bytes\n", (unsigned long)photoCount,
ok ? "OK" : "FAILED", (unsigned)photoLen);
}
}
}
Code Walkthrough
- Debounce: a mechanical button bounces for a few milliseconds. The loop only accepts a new state after the reading has been steady for 50 ms, and only the HIGH to LOW change triggers a photo, so holding the button down takes one picture.
- takePhoto() discards one frame, captures the next, allocates exactly
fb->lenbytes in PSRAM and copies the JPEG before returning the buffer. The old copy is freed only after the new one succeeds, so a failed capture never leaves you without a photo. - handleLatest() sends the stored bytes with the correct length and
image/jpegtype. The?n=number in the image URL changes after each photo, which stops the browser from showing a cached image. - The red LED on GPIO 33 lights during capture as feedback that the press was registered.
- Auto refresh of three seconds is done by a meta tag, so no JavaScript is needed.
Capture and the web server run in the same loop(), so they never touch the photo buffer at the same time and no locking is needed. If you later move capture into a separate FreeRTOS task, protect photoBuf and photoLen with a mutex, otherwise a browser could read a buffer that is being freed.
Troubleshooting
- Several photos per press: increase
DEBOUNCE_MSto 100, or add the 100 nF capacitor. - Photos taken without pressing: the wire is picking up noise; add an external pull-up and keep it away from the antenna.
- Photo looks dark or green right after boot: the sensor's auto exposure needs a few frames to settle. Take and discard two or three frames in
setup(). - Page loads slowly while a photo is taken: capture and web serving share one loop; this is normal and lasts only a fraction of a second.
- Board will not boot with the button wired: you used GPIO 12 or 15; move it to GPIO 13.
Summary
GPIO 13 with the internal pull-up is the safest button input on the ESP32-CAM. A debounced falling edge triggers a capture, one stale frame is discarded, and the JPEG is copied into PSRAM so the frame buffer can be returned at once. Serving that copy through WebServer gives an instant photo viewer, and the same takePhoto() function can later feed an SD card, an HTTP upload or a Telegram message.