В этой статье я разберу, как реализовать передачу JPEG-видео по RTP поверх UDP напрямую с ESP32 - так, чтобы поток открывался в VLC и ffplay, без RTSP, FFmpeg и промежуточных серверов.


Почему не HTTP MJPEG

В интернете легко найти десятки примеров стриминга с ESP32 через HTTP MJPEG. Возникает логичный вопрос: зачем вообще усложнять и использовать RTP?

Причин здесь несколько.

Во-первых, HTTP MJPEG это не очень интересно. Это по сути бесконечный HTTP-ответ с multipart-boundary, который работает ровно до тех пор, пока браузер его терпит. Протокол не предназначен для real-time мультимедиа и используется скорее как удобный хак.

Во-вторых, мне хотелось самому разобраться в протоколе, а не просто получить "картинку в браузере". RTP это стандартный транспорт для аудио и видео, с таймстампами, последовательностью пакетов и понятной моделью доставки. Разобраться в нём - полезный опыт, особенно если вы работаете с сетевыми протоколами.

В-третьих, UDP банально быстрее. HTTP текстовый протокол поверх TCP он требует установления соединения, гарантирует доставку, может задерживать пакеты ради порядка. Для видео это часто лишнее. Потерянный кадр лучше пропустить, чем задерживать весь поток. RTP поверх UDP не ждёт подтверждений, не блокируется из-за одного потерянного пакета и в целом лучше подходит для real-time передачи.

Наконец, RTP изначально рассчитан на мультимедиа.
HTTP MJPEG фактически привязывает вас к браузеру. RTP, напротив, даёт свободу: можно добавить аудио, синх��онизировать потоки и использовать любые совместимые клиенты, например OpenCV.

Введение

Для экспериментов я использовал готовый модуль Seeed Studio XIAO ESP32S3 Sense. Это компактная плата, которая хорошо подходит для экспериментов с камерой и звуком.

Модуль объединяет сразу несколько компонентов:

  • ESP32S3 (Xtensa LX7, dual-core, до 240 МГц)

  • 8 МБ PSRAM

  • 8 МБ Flash

  • Камеру

  • Цифровой PDM-микрофон

  • Слот microSD (до 32 ГБ, FAT)

С одной стороны, это удобная платформа "всё в одном". С другой - типичный embedded-мир с жёсткими ограничениями по ресурсам.

Ограничения, которые влияют на архитектуру

ESP32S3 остаётся микроконтроллером, а не SoC для видео. Это сразу накладывает ряд ограничений.

Во-первых, современные видеокодеки здесь недоступны. H.264 / H.265 требуют либо аппаратного кодера, либо существенно большей вычислительной мощности. Реализовывать их программно на ESP32S3 бессмысленно.

Во-вторых, память хоть и есть, но она не безгранична. Даже с 8 МБ PSRAM приходится внимательно следить за размерами буферов, копированием данных, количеством одновременных задач.

Зато камера аппаратно выдаёт JPEG. Это означает, что не требуется перекодирования, CPU почти не загружается, JPEG-кадр сразу попадает в PSRAM (SPIRAM), остаётся только правильно упаковать его в RTP.

"взял JPEG > нарезал на пакеты > отправил по сети"

Именно это и делает возможным передачу видео по RTP даже на таком ограниченном устройстве.

На первый взгляд ограничения выглядят как минус. Но именно они делают задачу интересной.

RTP: зачем он вообще нужен и что он на самом деле делает

Протокол RTP (Real-time Transport Protocol) определяет стандарт пакетов для передачи мультимедиа-данных (аудио и видео) через Интернет. Он разработан рабочей группой Audio Video Transport Working Group и впервые опубликован в 1996 г. в виде документа RFC 1889, а позже обновлён и расширен в RFC 3550.

RTP широко используется в коммуникационных и развлекательных системах, использующих потоковые медиа - телефонии, видеоконференциях, онлайн-телевидении, веб-сервисах push-to-talk и т.д. Как следует из названия протокола, целью его разработки была сквозная (от узла к узлу) потоковая передача медиа-данных в режиме реального времени.

Для передачи JPEG по RTP используется отдельный стандарт RFC 2435.

И здесь начинается самое интересное, потому что RTP JPEG это не передача JPEG-файла по сети. Это ключевой момент, который часто упускают.

В RTP JPEG не передаются стандартные JPEG-маркеры, такие как:

  • SOI (FFD8)

  • APP0 / JFIF

  • DQT (таблицы квантования)

  • SOF

  • SOS

  • EOI (FFD9)

формат простого JPEG файла от Ange Albertini
формат простого JPEG файла от Ange Albertini

Если просто взять JPEG-бинарник и отправить его кусками корректная работа не гарантируется.

Структура полезной нагрузки выглядит так:

[RTP header]
[RTP JPEG header]
[Restart Marker header]       <- если используется
[Quantization Table header]   <- передаётся ОДИН раз
[Quantization Table Data]     <- все таблицы подряд ОДИН раз
[Entropy-coded JPEG data]

JPEG payload header специфичен для RFC 2435, таблицы квантования не дублируются в каждом пакете, а основные данные это entropy-coded JPEG data (scan data). Это обеспечивает "сжатие" данных для потоковой передачи.

Размеры пакетов, MTU и зачем вообще нужно фрагментирование

RTP почти всегда работает поверх UDP, а UDP, в отличие от TCP, оперирует датаграммами ограниченного размера.

На практике в большинстве сетей MTU (Maximum Transmission Unit)  ≈ 1500 байт из них: IP header (20 байт), UDP header (8 байт), RTP header (12 байт) в итоге на полезную нагрузку остаётся порядка 1400 байт, а чаще меньше для запаса.

При этом JPEG-кадр с камеры это десятки килобайт. Очевидно, что такой объём невозможно передать одним UDP-пакетом. Поэтому разберем, как устроено фрагментирование.

Минимальный RTP header состоит из 12 байт:

struct rtp_header {
    uint8_t vpxcc;
    uint8_t payloadtype;
    uint16_t seqnum;
    uint32_t timestamp;
    uint32_t ssrc;
} __attribute__((packed));

Payload Type говорит приёмнику, какой формат данных находится в пакете.

Для RPC JPEG это 26, но там есть одна особенность с marker bit, о которой я расскажу позже.

SSRC (Synchronization Source Identifier) - это уникальный идентификатор источника потока.

  • Генерируется случайно при старте

  • Одинаков для всех пакетов одного потока

  • Позволяет клиенту понять, какие пакеты принадлежат одному источнику

Проще говоря "этот поток - от этой камеры"

Sequence Number

  • увеличивается на 1 для каждого RTP-пакета

  • используется для:

    • обнаружения потерь,

    • восстановления порядка пакетов

Важно понимать, что seqnum не связан с кадрами. Он считает RTP-пакеты, а не изображения

Timestamp - это временная метка RTP, и она часто вызывает путаницу.

Важно запомнить правило:

Все RTP-пакеты одного JPEG-кадра имеют одинаковый timestamp

  • увеличивается один раз на кадр

  • используется для:

    • синхронизации воспроизведения,

    • объединения пакетов в кадры

Если менять timestamp внутри одного кадра VLC не сможет корректно собрать изображение.

RTP JPEG header состоит из 8 байт

struct rtp_jpeg_header {
    uint8_t type_specific;
    uint8_t fragment_offset[3];
    uint8_t type;
    uint8_t q;
    uint8_t width;
    uint8_t height;
} __attribute__((packed));
  • fragment_offset — смещение в JPEG scan data

  • type / q — параметры JPEG

  • width / height — размеры изображения в блоках 8×8

Fragment offset: самая частая ошибка

Fragment offset считается не от начала JPEG-файла, а от начала scan data. Это одна из самых неочевидных частей RFC 2435, но именно она ломает совместимость. Если offset считается неправильно - VLC просто не покажет видео.

Marker bit: ещё один критичный момент

Marker bit в RTP заголовке:

  • не должен ставиться в каждом пакете

  • ставится только в последнем пакете JPEG-кадра

Это сигнал клиенту, что кадр завершён.

Ошибки с marker bit приводят к отсутствию изображения, дёрганому воспроизведению и неправильной синхронизации.

Q и Quantization Tables: что это и зачем они нужны

В RTP JPEG параметр Q напрямую связан с таблицами квантования JPEG. Без понимания этого места невозможно корректно передать JPEG-поток.

В JPEG-алгоритме таблицы квантования определяют степень сжатия, качество изображения и уровень потерь.

В RTP JPEG header есть поле q

Значение q интерпретируется так:

  • Q < 128
    Используются стандартные таблицы квантования, определённые в RFC 2435

  • Q >= 128
    Таблицы квантования передаются явно в потоке RTP

На практики таблицы почти всегда нестандартные, поэтому q = 128

Когда q >= 128, в RTP-потоке появляется дополнительная структура:

[Quantization Table header]
[Quantization Table data]
struct rtp_jpeg_quant_header {
    uint8_t mbz; // must be zero
    uint8_t precision; // 0 (8 бит) или 1 (16 бит)
    uint16_t length; // длина данных таблиц
} __attribute__((packed));

После заголовка идут все таблицы квантования подряд. Обычно две таблицы (Y и Cb/Cr) по 64 значения каждая (при precision = 0), без каких-либо JPEG-маркеров (DQT отсутствует).

Quantization Tables передаются один раз на кадр, а не в каждом RTP-пакете.

Типичная схема:

RTP packet #1:
  [RTP header]
  [JPEG header]
  [Quantization Table header]
  [Quantization Table data]
  [Scan data fragment]

RTP packet #2..N:
  [RTP header]
  [JPEG header]
  [Scan data fragment]

Практика: алгоритм передачи JPEG по RTP

Передача одного JPEG-кадра по RTP состоит из следующих шагов:

  1. Прочитать JPEG-кадр из буфера камеры

  2. Извлечь таблицы квантования (DQT)

  3. Найти начало JPEG scan data

  4. Подготовить заголовки RTP и RTP JPEG

  5. Разбить scan data на чанки

  6. Отправить чанки по UDP с корректными offset и marker bit

Разберём подробнее.

Извлечение таблиц квантования

#define MAX_QUANT_TABLES 4
#define QUANT_TABLE_SIZE 64

static сonst uint8_t* tables[MAX_QUANT_TABLES];

static int extract_quant_tables_refs(const uint8_t* buf, size_t size, const uint8_t** tables) {
    size_t pos = 0;
    int count = 0;
    while (pos < size - 4) {
        if (buf[pos] == 0xFF && buf[pos + 1] == 0xDB) {
            pos += 2;

            if (pos + 2 > size)
                break;
            pos += 2;

            if (pos >= size)
                break;
            uint8_t table_info = buf[pos++];
            uint8_t table_id = table_info & 0x0F;
            uint8_t precision = (table_info >> 4) & 0x0F;

            if (precision != 0 || table_id >= MAX_QUANT_TABLES)
                continue;

            if (pos + QUANT_TABLE_SIZE > size)
                continue;
            tables[count++] = buf + pos;
            pos += QUANT_TABLE_SIZE;
        } else {
            pos++;
        }
    }

    return count;
}
  1. Идём по всему JPEG-файлу.

  2. Ищем специальный маркер, который говорит: «Здесь таблица квантования».

  3. Когда находим:

    1. читаем номер таблицы и точность;

    2. проверяем, что она подходит (8-битная, номер нормальный);

    3. сохраняем указатель на начало таблицы в массив tables.

  4. Возвращаем количество найденных таблиц.

JPEG может содержать несколько таблиц квантования в одном файле. Обычно есть таблица для яркости (luminance), таблица для цветности (chrominance), а иногда и больше, если используют разные каналы или разные кадры.

Если бы мы копировали данные таблиц куда-то один раз, нам пришлось бы заранее выделять память для всех таблиц, а их может быть от 1 до 4 (или даже больше).

Что даёт массив указателей: каждый элемент хранит адрес начала конкретной таблицы в JPEG-буфере, мы не копируем саму таблицу, а просто говорим: "эта таблица начинается вот здесь". Это экономит память и ускоряет работу.

Найти начало и конец scan data

static const uint8_t* get_jpeg_data(const uint8_t* buf, size_t size, size_t* out_size) {
    if (!buf || size < 4) {
        *out_size = 0;
        return NULL;
    }

    // Найти FF DA (SOS)
    size_t pos = 0;
    while (pos < size - 1) {
        if (buf[pos] == 0xFF && buf[pos + 1] == 0xDA) {
            break;
        }
        pos++;
    }
    if (pos >= size - 1) {
        *out_size = 0;
        return NULL; // SOS не найден
    }

    // Пропустить FF DA
    pos += 2;

    // Прочитать длину SOS-сегмента (2 байта, big-endian)
    if (pos + 2 > size) {
        *out_size = 0;
        return NULL;
    }
    uint16_t sos_length = (buf[pos] << 8) | buf[pos + 1];
    pos += 2;

    // Пропустить параметры SOS (sos_length - 2, поскольку длина включает себя)
    if (sos_length < 2 || pos + (sos_length - 2) > size) {
        *out_size = 0;
        return NULL;
    }
    pos += (sos_length - 2);

    // Теперь pos указывает на начало сжатых данных
    size_t data_start = pos;

    // Найти FF D9 (EOI)
    while (pos < size - 1) {
        if (buf[pos] == 0xFF && buf[pos + 1] == 0xD9) {
            break;
        }
        pos++;
    }
    if (pos >= size - 1) {
        *out_size = 0;
        return NULL; // EOI не найден
    }

    *out_size = pos - data_start;
    return buf + data_start;
}

Разбиваем на чанки

#define RTP_PACKET_SIZE 1500
#define RTP_PAYLOAD_SIZE 1024

  size_t jpeg_size;
  const uint8_t* jpeg_data = get_jpeg_data(fb->buf, fb->len, &jpeg_size);
  if (jpeg_data == NULL) {
      ESP_LOGE(TAG, "empty jpeg payload");
      return;
  }

  const uint8_t* quant_tables[MAX_QUANT_TABLES];
  memset(quant_tables, 0, sizeof(const uint8_t*) * MAX_QUANT_TABLES);

  int quant_tables_count = extract_quant_tables_refs(fb->buf, fb->len, quant_tables);

  uint8_t* payload;
  size_t data_index = 0;

  while (data_index < jpeg_size) {
        size_t tables_size =
            (data_index == 0) ? (quant_tables_count * QUANT_TABLE_SIZE) + sizeof(struct jpeg_quant_header) : 0;
        payload = buf + sizeof(struct rtp_header) + sizeof(struct rtp_jpeg_header);

        if (tables_size > 0) {
            struct jpeg_quant_header* qh = (struct jpeg_quant_header*)payload;
            qh->mbz = 0;
            qh->precision = 0; // 8-bit tables
            qh->length = htons(quant_tables_count * QUANT_TABLE_SIZE);
            payload += sizeof(*qh);

            for (int i = 0; i < quant_tables_count; i++) {
                memcpy(payload, quant_tables[i], QUANT_TABLE_SIZE);
                payload += QUANT_TABLE_SIZE;
            }
        }

        size_t chunk_size = min(RTP_PAYLOAD_SIZE - tables_size, jpeg_size - data_index);

        set_fragment_offset(jpeg_header->fragment_offset, data_index);
        memcpy(payload, jpeg_data + data_index, chunk_size);

        header->payloadtype = RTP_JPEG_PAYLOADTYPE | (((data_index + chunk_size) >= jpeg_size) ? RTP_MARKER_MASK : 0);

Это фрагменты кода из моего репозитория, где реализован RTP JPEG Stream, я публикую их здесь просто для примера, переменная buf - это указатель на буфер в памяти. Как видите, я стараюсь лишний раз не аллоцировать память.

DRAM_ATTR static uint8_t rtp_jpeg_packet[RTP_PACKET_SIZE];

Си не мой основной язык, я рассчитываю на оптимизацию от компилятора, но если у вас есть предложения и замечания, пишите в комментарии.

Про VLC порты и SDP

VLC оказался отличным валидатором корректности реализации он очень чувствителен к fragment offset, marker bit, корректным RTP timestamp, а ещё к UDP портам.

Оказывается чётные порты используются для RTP (аудио / видео), нечётные - для упра��ляющих протоколов (RTCP), если назначить другие ffplay работает, а VLC нет.

Чтобы VLC мог воспроизводить поток RTP с ESP32, нам нужен SDP-файл (Session Description Protocol)

v=0
o=- 0 0 IN IP4 192.168.1.42
s=RTP JPEG Stream
c=IN IP4 192.168.1.78
t=0 0
m=video 4000 RTP/AVP 26
a=rtpmap:26 JPEG/90000

где 192.168.1.42 адрес выданный твоему устройству, а 192.168.1.78 адрес на который VLC будет принимать поток.

А так можно запустить ffplay из пакета ffmpeg для отладки.

ffplay -protocol_whitelist file,udp,rtp -i jpeg.sdp

Ссылки

P.S.

Бонусом я реализовал передачу аудио со встроенного PDM-микрофона с использованием кодека PCMU (μ-law), который я позаимствовал из исходников ffmpeg.