En este artículo te enseñaré a transmitir la cámara OV2640 de la ESP32-CAM en tiempo real usando UDP.

Más adelante vamos a transmitir audio y video pero por ahora nos vamos a centrar única y exclusivamente en la transmisión de video de la ESP32-CAM en tiempo real usando UDP para que sea realmente rápido.

Captura de pantalla mostrando recepción de video en tiempo real de la ESP32-CAM usando Python y UDP

He usado C++ con PlatformIO para programar la ESP32-CAM, y para el receptor he usado Python aunque obviamente se puede usar cualquier otra tecnología de tu agrado.

Nota: antes de empezar esta transmisión de video recomiendo que veas cómo enviar datos por UDP usando la ESP32-CAM

El problema de UDP y MTU

UDP tiene un límite de paquete de, en mi caso, 1460 bytes. Cuando superamos ese límite entonces el paquete se fragmenta.

Cuando enviamos una muestra que está completa por sí misma y que no supera el MTU, no pasa nada. Por ejemplo, podemos enviar la grabación del micrófono en varios paquetes de 1024 bytes y dejamos espacio suficiente para la cabecera IP y otros datos necesarios.

Esto cambia porque una foto no cabe en 1024 bytes, no es posible representar una sola foto en ese espacio, así que tenemos que dividirla en fragmentos y además de eso indicar, en cada fragmento, varios datos como el tamaño total, índice del fragmento y total de fragmentos.

Luego en el receptor debemos hacer el proceso inverso para rearmar la foto. El código que hace la división de la foto es:

 if (fb)
{
    // En cuántos lo vamos a dividir
    uint16_t fragmentosNecesarios = (fb->len + CHUNK_SIZE - 1) / CHUNK_SIZE;
    encabezado.idImagen = htons(indiceImagen);
    encabezado.tamanoTotal = htonl(fb->len); // htons porque se va a dividir en 2 bytes seguramente
    encabezado.totalFragmentos = htons(fragmentosNecesarios);
    for (uint16_t indiceFragmento = 0; indiceFragmento < fragmentosNecesarios; indiceFragmento++)
    {
        encabezado.indiceFragmento = htons(indiceFragmento);
        udp.beginPacket(IP_RECEPTOR_CAMARA, PUERTO_RECEPTOR_CAMARA);
        udp.write((uint8_t *)&encabezado, sizeof(encabezado));
        int longitud = CHUNK_SIZE;
        int offset = indiceFragmento * CHUNK_SIZE;
        if (longitud + offset > fb->len)
        {
            longitud = fb->len - offset;
        }
        udp.write(fb->buf + offset, longitud);
        // Serial.printf("Escrito fragmento %d con longitud %d y offset %d\n", indiceFragmento, longitud, offset);
        udp.endPacket();
    }
    esp_camera_fb_return(fb);
    indiceImagen++;
}

Python hace el proceso inverso y muestra la foto:


bytes_data, address = sock.recvfrom(
    TAMAÑO_BUFER_VIDEO)
id_imagen_host = struct.unpack('>H', bytes_data[:2])[0]
indice_fragmento = struct.unpack('>H', bytes_data[2:4])[0]
bytes_totales = struct.unpack('>L', bytes_data[4:8])[0]
total_fragmentos = struct.unpack('>H', bytes_data[8:10])[0]
fragmento_imagen = bytes_data[10:]

if id_imagen_host != ultimo_id_imagen:
    # Ya es al menos la segunda vez que estamos recibiendo
      ultima_longitud_imagen = bytes_totales
      diccionario_imagen = {}
      ultimo_id_imagen = id_imagen_host
  diccionario_imagen[indice_fragmento] = fragmento_imagen

    if len(diccionario_imagen) == total_fragmentos:
        fragmentos_ordenados = [diccionario_imagen[i]
                                for i in sorted(diccionario_imagen.keys())]
              imagen_final = b"".join(fragmentos_ordenados)
              print(f"Imagen final armada {len(imagen_final)}")
              if len(imagen_final) == ultima_longitud_imagen:

                  imagen_array = np.frombuffer(imagen_final, dtype=np.uint8)
                  imagen_cv = cv2.imdecode(imagen_array, cv2.IMREAD_COLOR)

                  if imagen_cv is not None:
                      tiempo_actual = time.time()
                      diferencia = tiempo_actual - tiempo_anterior
                      if diferencia > 0:
                          fps = 1.0 / diferencia
                      else:
                          fps = 0
                      tiempo_anterior = tiempo_actual

                      fps_texto = f"FPS: {fps:.2f}"
                      cv2.putText(
                          imagen_cv,
                          fps_texto,
                          (10, 30),
                          cv2.FONT_HERSHEY_SIMPLEX,
                          1,
                          (0, 255, 0),
                          2
                      )
                      cv2.imshow("Video en Vivo", imagen_cv)

Esto se vuelve un poco complejo porque UDP no garantiza orden de entrega. También tiene la desventaja de que si un fragmento de la foto se pierde, toda la foto se habrá perdido.

Incluso así yo he logrado transmitir de la ESP32-CAM a hasta 27 FPS con el código que muestro a continuación.

Código fuente ESP32-CAM

Todos estos archivos se encuentran en la carpeta src del proyecto de PlatformIO divididos para su fácil lectura y revisión.

El código de credenciales.h queda así:

#define NOMBRE_RED_WIFI ""
#define PASSWORD_RED_WIFI ""

Luego tenemos el código que stremea la cámara OV2640 de la ESP32-CAM a través de UDP:

enviar_camara.h

#include "esp_camera.h"
#include "Arduino.h"
#define PUERTO_RECEPTOR_CAMARA 12345
#define IP_RECEPTOR_CAMARA "192.168.0.5"
#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

bool inicializar_camara()
{
    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_sccb_sda = SIOD_GPIO_NUM;
    config.pin_sccb_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;
    config.frame_size = FRAMESIZE_SVGA; 
    config.jpeg_quality = 20;            // calidad. Mejor calidad entre menor número. 0-63
    config.fb_count = 1;

    return esp_camera_init(&config) == ESP_OK;
}

Y finalmente el código main que incluye los otros archivos, se conecta al WiFi y empieza a transmitir la cámara en tiempo real.

Este archivo se llama main.cpp:


#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include "credenciales.h"
#include "enviar_camara.h"
#define PUERTO_UDP 12345
WiFiUDP udp;

void conectar_wifi()
{
  Serial.println("Conectando wifi...");
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(true, true);
  delay(100);
  wl_status_t resultado = WiFi.begin(NOMBRE_RED_WIFI, PASSWORD_RED_WIFI);
  Serial.printf("El resultado al conectar wifi es %d", resultado);
  WiFi.setTxPower(WIFI_POWER_8_5dBm);

  while (WiFi.status() != WL_CONNECTED)
  {
    delay(300);
    Serial.print(".");
  }
  Serial.println(WiFi.localIP());
}

void setup()
{
  Serial.begin(115200);
  ledcSetup(0, 5000, 8);
  ledcAttachPin(4, 0);
  conectar_wifi();
  udp.begin(PUERTO_UDP);
  ledcWrite(0, 10);
  delay(200);
  ledcWrite(0, 1);
  inicializar_camara();
}

uint16_t indiceImagen = 0;
#define CHUNK_SIZE 1024
#pragma pack(push, 1) // Le dice al compilador: alinea a 1 byte (sin relleno)
typedef struct
{
  uint16_t idImagen;
  uint16_t indiceFragmento;
  uint32_t tamanoTotal;
  uint16_t totalFragmentos;
} EncabezadoFragmentoImagen;
#pragma pack(pop) // Restaura la configuración de alineación predeterminada
void loop()
{
  camera_fb_t *fb = esp_camera_fb_get();
  if (fb)
  {
    // En cuántos lo vamos a dividir
    uint16_t fragmentosNecesarios = (fb->len + CHUNK_SIZE - 1) / CHUNK_SIZE;
    EncabezadoFragmentoImagen encabezado;
    encabezado.idImagen = htons(indiceImagen);
    encabezado.tamanoTotal = htonl(fb->len); // htons porque se va a dividir en 2 bytes seguramente
    encabezado.totalFragmentos = htons(fragmentosNecesarios);
    for (uint16_t indiceFragmento = 0; indiceFragmento < fragmentosNecesarios; indiceFragmento++)
    {
      encabezado.indiceFragmento = htons(indiceFragmento);
      udp.beginPacket(IP_RECEPTOR_CAMARA, PUERTO_RECEPTOR_CAMARA);
      udp.write((uint8_t *)&encabezado, sizeof(encabezado));
      int longitud = CHUNK_SIZE;
      int offset = indiceFragmento * CHUNK_SIZE;
      if (longitud + offset > fb->len)
      {
        longitud = fb->len - offset;
      }
      udp.write(fb->buf + offset, longitud);
      // Serial.printf("Escrito fragmento %d con longitud %d y offset %d\n", indiceFragmento, longitud, offset);
      udp.endPacket();
    }
    Serial.printf("Tomamos  y enviamos con longitud %d\n", fb->len);
    esp_camera_fb_return(fb);
    indiceImagen++;
  }
  else
  {
    Serial.printf("Error tomando foto. El frame buffer es null\n");
  }
}

Código fuente receptor Python

Ahora veamos el código completo de Python que recibe los fragmentos UDP, arma la foto y la muestra en vivo usando OpenCV.

Necesitamos instalar

pip install opencv-python
pip install numpy

Receptor recibir_foto.py queda como se ve a continuación.

import socket
import time
import numpy as np
import struct
import cv2
# Debe coincidir con:
# udp.beginPacket(IP_RECEPTOR_CAMARA, PUERTO_RECEPTOR_CAMARA);
LOCAL_IP = "192.168.0.5"
# Debe coincidir con
# #define PUERTO_RECEPTOR_CAMARA 12345
LOCAL_PORT = 12345
BUFFER_SIZE = 1500


sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

try:
    sock.bind((LOCAL_IP, LOCAL_PORT))
    print(f"Servidor UDP iniciado y escuchando en {LOCAL_IP}:{LOCAL_PORT}")
except Exception as e:
    print(f"Error al asignar el socket: {e}")
    exit()


ultimo_id_imagen = -1
ultima_longitud_imagen = 0
diccionario_imagen = {}


tiempo_anterior = time.time()
contador_frames = 0
while True:
    try:
        bytes_data, address = sock.recvfrom(
            BUFFER_SIZE)
        id_imagen_host = struct.unpack('>H', bytes_data[:2])[0]
        indice_fragmento = struct.unpack('>H', bytes_data[2:4])[0]
        bytes_totales = struct.unpack('>L', bytes_data[4:8])[0]
        total_fragmentos = struct.unpack('>H', bytes_data[8:10])[0]
        fragmento_imagen = bytes_data[10:]

        # print(f"id {id_imagen_host} indice_Fragmento {indice_fragmento} bytes totales {bytes_totales} total_fragmentos {total_fragmentos}")
        if id_imagen_host != ultimo_id_imagen:
            # Ya es al menos la segunda vez que estamos recibiendo
            # imagen.append(fragmento_imagen)
            ultima_longitud_imagen = bytes_totales
            diccionario_imagen = {}
            ultimo_id_imagen = id_imagen_host
            # print("Comenzada la recepción")
        diccionario_imagen[indice_fragmento] = fragmento_imagen

        if len(diccionario_imagen) == total_fragmentos:
            fragmentos_ordenados = [diccionario_imagen[i]
                                    for i in sorted(diccionario_imagen.keys())]
            imagen_final = b"".join(fragmentos_ordenados)
            if len(imagen_final) == ultima_longitud_imagen:
                # print(f"{ultimo_id_imagen} Armado bien mide {len(imagen_final)}")

                imagen_array = np.frombuffer(imagen_final, dtype=np.uint8)
                imagen_cv = cv2.imdecode(imagen_array, cv2.IMREAD_COLOR)

                if imagen_cv is not None:
                    tiempo_actual = time.time()
                    fps = 1.0 / (tiempo_actual - tiempo_anterior)
                    tiempo_anterior = tiempo_actual

                    fps_texto = f"FPS: {fps:.2f}"
                    cv2.putText(
                        imagen_cv,
                        fps_texto,
                        (10, 30),
                        cv2.FONT_HERSHEY_SIMPLEX,
                        1,
                        (0, 255, 0),
                        2
                    )
                    cv2.imshow("Video en Vivo", imagen_cv)

                    if cv2.waitKey(1) & 0xFF == ord('q'):
                        break
                    """
                nombre_archivo = f"imagen_id_{ultimo_id_imagen}.jpeg"
                try:
                    with open(nombre_archivo, "wb") as f:
                        f.write(imagen_final)
                    print(
                        f"Imagen guardada exitosamente como: {nombre_archivo}")
                except IOError as e:
                    print(f"Error al intentar guardar el archivo: {e}")
                """
            else:
                print(
                    f"Mide {len(imagen_final)} pero debería medir {ultima_longitud_imagen}")
            diccionario_imagen = {}

    except KeyboardInterrupt:
        print("\nCerrando servidor UDP...")
        break
    except Exception as e:
        print(f"Error durante la recepción: {e}")
        break

sock.close()

Ajustar parámetros de transmisión de video

En el código verás la IP 192.168.0.6, esa es la IP de mi computadora donde ejecuto el script de Python, obviamente tú debes cambiarla en el receptor y en el emisor de video (o sea, la ESP32-CAM)

Para cambiar la calidad de transmisión de cámara en vivo puedes ajustar inicializar_camara cambiando frame_size y jpeg_quality:

config.frame_size = FRAMESIZE_SVGA; 
config.jpeg_quality = 20;

El frame_size indica la resolución, siendo la resolución máxima 1600 x 1200 o sea 2MP para la OV2640. Puedes elegir entre uno de los siguientes mientras no superes los 2 MP:


typedef enum {
    FRAMESIZE_96X96,    // 96x96
    FRAMESIZE_QQVGA,    // 160x120
    FRAMESIZE_QCIF,     // 176x144
    FRAMESIZE_HQVGA,    // 240x176
    FRAMESIZE_240X240,  // 240x240
    FRAMESIZE_QVGA,     // 320x240
    FRAMESIZE_CIF,      // 400x296
    FRAMESIZE_HVGA,     // 480x320
    FRAMESIZE_VGA,      // 640x480
    FRAMESIZE_SVGA,     // 800x600
    FRAMESIZE_XGA,      // 1024x768
    FRAMESIZE_HD,       // 1280x720
    FRAMESIZE_SXGA,     // 1280x1024
    FRAMESIZE_UXGA,     // 1600x1200
    // 3MP Sensors
    FRAMESIZE_FHD,      // 1920x1080
    FRAMESIZE_P_HD,     //  720x1280
    FRAMESIZE_P_3MP,    //  864x1536
    FRAMESIZE_QXGA,     // 2048x1536
    // 5MP Sensors
    FRAMESIZE_QHD,      // 2560x1440
    FRAMESIZE_WQXGA,    // 2560x1600
    FRAMESIZE_P_FHD,    // 1080x1920
    FRAMESIZE_QSXGA,    // 2560x1920
    FRAMESIZE_INVALID
} framesize_t;

Y la calidad JPEG indica la calidad de imagen donde un 0 es la calidad máxima y un 63 es la calidad mínima.

Recuerda: entre mejor calidad y resolución la imagen se verá mejor, pero la transmisión será más lenta.

Conclusión

Elegí UDP solo por probar, ya que previamente envié y recibí audio por UDP y quise saber si era posible hacerlo con la cámara.

No he explorado otros protocolos para transmitir video en tiempo real desde la ESP32-CAM y me quedo satisfecho con el código que he escrito usando UDP, sin embargo desconozco si hay un protocolo de transmisión que mejore la velocidad.

Si el post ha sido de tu agrado te invito a que me sigas para saber cuando haya escrito un nuevo post, haya actualizado algún sistema o publicado un nuevo software. Facebook | X | Instagram | Telegram | También estoy a tus órdenes para cualquier contratación en mi página de contacto