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.

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.