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.

ItemConnectionNotes
Push button, leg 1GPIO 13Internal pull-up enabled in code
Push button, leg 2GNDPressed = LOW
100 nF capacitor (optional)GPIO 13 to GNDHardware debounce on long wires
5 V supply5V and GND pinsAt 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.

Button-triggered capture into PSRAM with a self-refreshing web viewer
#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->len bytes 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/jpeg type. 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_MS to 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.