ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ESP32-P4 硬件解码 MJPEG 播放实战:从 AVI 转换到 720p 30fps 流畅显示

ESP32-P4 硬件解码 MJPEG 播放实战:从 AVI 转换到 720p 30fps 流畅显示 1. 项目缘起与整体设计思路1.1 为什么要在 ESP32-P4 上折腾 MJPEG 播放先说说我为什么盯上了这个方向。手头有一块 ESP32-P4 的开发板这块芯片跟之前玩过的 ESP32-S3 完全不是一个量级——双核 RISC-V 跑到 400MHz自带 JPEG 硬件编解码器还有 2D 图形加速单元DMA2D片上内存也宽裕了不少。我第一反应就是这玩意儿拿来播视频应该很爽。但真到选方案的时候问题就来了。常见的嵌入式视频播放路线无非这么几条一是 H.264 软解二是 H.264 硬解三是 MJPEG 逐帧解码。前两条路在 ESP32-P4 上要么没有硬解单元要么软解性能根本扛不住而 MJPEG 这条路恰好踩中了 P4 的强项——它内置的 JPEG 解码器就是为逐帧解码设计的每一帧 MJPEG 本质上就是一张独立的 JPEG 图片硬件解码器直接吃进去吐出来CPU 几乎不参与。所以整个项目的核心思路就一句话用 ESP32-P4 的 JPEG 硬件解码器逐帧解码 MJPEG 数据流配合 DMA2D 做色彩空间转换和缩放最后刷到屏幕上。这条路走通了播放 720p 甚至 1080p 的 MJPEG 视频都不是问题。1.2 从 AVI 到纯 MJPEG为什么要做这个转换一开始我图省事直接找了个 AVI 文件往板子上塞。结果发现两个大问题第一AVI 是个容器格式里面除了 MJPEG 视频流还有音频流、索引表、头部信息一大堆东西ESP32-P4 要解析这些容器结构白白浪费 CPU 和内存第二AVI 的帧数据在文件里是交错存放的读取的时候需要不断 seek对 SD 卡或者 Flash 的随机读取性能要求很高。后来我想明白了嵌入式播放场景下容器格式就是个累赘。我需要的只是纯粹的 MJPEG 帧序列——每一帧就是一张完整的 JPEG 图片按顺序排列读一帧解一帧。这样文件结构最简单读取最顺序解码最直接。于是整个流程就变成了用 FFmpeg 把 AVI 里的 MJPEG 流提取出来转成纯 MJPEG 文件其实就是一堆 JPEG 图片首尾相连然后 ESP32-P4 直接按帧读取、解码、显示。这个转换过程用一条 FFmpeg 命令就能搞定后面我会详细说。1.3 整体架构设计整个播放系统的架构可以分成三层第一层是数据源层。MJPEG 文件存在 SD 卡或者 Flash 里通过 FATFS 或者 SPIFFS 文件系统读取。我实测下来SD 卡用 4 线 SDMMC 模式读取速度能到 20MB/s 以上完全够用。如果用 Flash建议用内存映射方式直接读速度更快。第二层是解码层。这是核心。ESP32-P4 的 JPEG 解码器通过esp_jpeg组件调用输入一帧 JPEG 数据输出 YUV422 或者 RGB565 格式的像素数据。这里有个关键点解码输出格式的选择直接影响后续处理的效率。如果屏幕是 RGB565 接口那解码器直接输出 RGB565 最省事但如果需要缩放就得先输出 YUV422用 DMA2D 做缩放和色彩转换。第三层是显示层。解码后的像素数据通过 DMA2D 或者直接通过 LCD 接口的 DMA 通道刷到屏幕上。这里要用双缓冲或者三缓冲机制一帧在显示的时候另一帧已经在解码了这样才能做到流畅播放。提示ESP32-P4 的 JPEG 解码器和 DMA2D 都可以通过 ESP-IDF 的驱动接口调用不需要自己写寄存器操作。但要注意这两个外设共享内存带宽如果同时高负载运行需要合理分配 DMA 通道优先级。2. 核心细节解析与实操要点2.1 MJPEG 文件格式的本质很多人对 MJPEG 有误解以为它是一种像 H.264 那样的压缩编码格式。其实不是。MJPEG 根本就不是一种编码格式它只是一种组织方式——把一串独立的 JPEG 图片按顺序排列每一帧都是一张完整的、可以独立解码的 JPEG 图片。这意味着什么呢意味着你不需要任何特殊的解码器任何能解 JPEG 的库都能解 MJPEG。也意味着每一帧的数据量都很大——一张 720p 的 JPEG 图片质量因子 80 的话大概 50KB 到 100KB。一秒 30 帧就是 1.5MB 到 3MB 的数据量。这对存储和读取带宽提出了要求。纯 MJPEG 文件的结构极其简单就是一堆 JPEG 图片首尾相连没有任何额外的头部、索引或者分隔符。你可能会问那怎么知道一帧到哪里结束、下一帧从哪里开始答案是通过 JPEG 的 SOI 和 EOI 标记。每张 JPEG 图片以0xFFD8SOIStart of Image开头以0xFFD9EOIEnd of Image结尾。读取的时候从 SOI 开始读一直读到 EOI就是一帧完整的数据。这个特性非常关键因为它决定了读取策略你可以顺序读取文件每读到 EOI 就认为一帧结束然后把这一帧丢给解码器。不需要预先知道每帧的大小也不需要索引表。2.2 FFmpeg 转换命令详解把 AVI 转成纯 MJPEGFFmpeg 一条命令就够了。但这条命令里有很多细节我踩过坑之后才搞明白。最基本的命令是这样的ffmpeg -i input.avi -c:v copy -f mjpeg output.mjpeg这条命令的意思是输入input.avi视频流直接复制-c:v copy不重新编码输出格式为mjpeg文件名为output.mjpeg。但这里有个大坑-c:v copy要求输入文件里的视频流本身就是 MJPEG 编码的。如果你的 AVI 里是 H.264 或者其他编码这条命令会报错。这时候你需要重新编码ffmpeg -i input.avi -c:v mjpeg -q:v 5 -f mjpeg output.mjpeg-q:v 5控制 JPEG 质量范围是 2 到 31数字越小质量越高、文件越大。我实测下来-q:v 5在 720p 下大概每帧 60KB 左右画质已经相当不错了。还有一个关键参数是-vf fps30用来控制帧率。如果你的源视频是 60fps但 ESP32-P4 解不过来可以降到 30fpsffmpeg -i input.avi -c:v mjpeg -q:v 5 -vf fps30 -f mjpeg output.mjpeg注意转换的时候一定要加-f mjpeg强制指定输出格式。如果不加FFmpeg 会根据文件扩展名猜测格式有时候会猜错导致输出的文件里混入了容器头信息ESP32-P4 读取的时候就会出错。另外如果你只想提取视频流、不要音频可以加-an参数ffmpeg -i input.avi -c:v mjpeg -q:v 5 -an -f mjpeg output.mjpeg2.3 ESP32-P4 JPEG 解码器的使用要点ESP32-P4 的 JPEG 解码器通过 ESP-IDF 的esp_jpeg组件调用。这个组件的 API 设计得比较简洁核心就是几个函数#include esp_jpeg_dec.h jpeg_dec_handle_t jpeg_dec; jpeg_dec_config_t config DEFAULT_JPEG_DEC_CONFIG(); config.output_type JPEG_PIXEL_FORMAT_RGB565_BE; jpeg_dec_open(config, jpeg_dec); jpeg_dec_io_t io { .inbuf jpeg_data, .inbuf_len jpeg_len, .outbuf out_buffer, }; jpeg_dec_parse_header(jpeg_dec, io); jpeg_dec_process(jpeg_dec, io); jpeg_dec_close(jpeg_dec);这段代码看起来简单但有几个关键点需要注意第一输出格式的选择。JPEG_PIXEL_FORMAT_RGB565_BE表示输出 RGB565 大端格式。如果你的屏幕是 RGB565 接口直接用这个格式最省事。但如果需要缩放建议输出JPEG_PIXEL_FORMAT_YUV422然后用 DMA2D 做缩放和色彩转换因为 DMA2D 对 YUV 格式的缩放支持更好。第二输出缓冲区的大小。JPEG 解码后的数据量比压缩数据大得多。一张 720p 的 JPEG 图片压缩后可能只有 60KB但解码成 RGB565 后是 1280×720×2 1.8MB。这个缓冲区必须提前分配好而且要考虑双缓冲的需求。第三解码器的初始化开销。jpeg_dec_open和jpeg_dec_close有一定的开销如果每帧都开关一次性能会受影响。正确的做法是初始化一次然后每帧调用jpeg_dec_process最后再关闭。但要注意jpeg_dec_process之后解码器的状态会改变下一帧需要重新调用jpeg_dec_parse_header。2.4 DMA2D 加速色彩转换与缩放DMA2D 是 ESP32-P4 的 2D 图形加速单元能做色彩空间转换、缩放、混合等操作。在 MJPEG 播放场景下它主要用来做两件事一是色彩空间转换。JPEG 解码器输出的是 YUV422 格式但屏幕需要 RGB565。用 CPU 做这个转换很慢用 DMA2D 就快得多。DMA2D 支持 YUV422 到 RGB565 的硬件转换一次操作就能完成。二是缩放。如果你的视频是 1080p但屏幕只有 720p就需要缩放。DMA2D 支持硬件缩放比 CPU 缩放快得多。使用 DMA2D 的代码大概长这样esp_lcd_dpi_panel_config_t dpi_config { .dma2d_cfg { .color_mode LCD_COLOR_MODE_RGB565, .scale_en true, }, };这里的关键是scale_en true开启硬件缩放。然后配置输入和输出的分辨率esp_lcd_dpi_panel_set_dma2d_scale(dpi_panel, src_width, src_height, dst_width, dst_height);提示DMA2D 的缩放算法是双线性插值画质比最近邻插值好很多。但缩放比例不要太大否则会有明显的模糊。我实测下来缩放比例在 0.5 到 2 倍之间效果最好。2.5 双缓冲与帧同步机制要做到流畅播放双缓冲是必须的。基本思路是准备两个缓冲区一个在解码的时候另一个在显示解码完成后交换。但这里有个问题ESP32-P4 的 JPEG 解码器和 LCD 控制器是异步工作的。解码器写完缓冲区后需要通知 LCD 控制器来取数据。这个通知机制如果用不好就会出现撕裂或者卡顿。我的做法是用 FreeRTOS 的信号量来做同步// 解码任务 while (1) { xSemaphoreTake(decode_done_sem, portMAX_DELAY); // 解码下一帧到 back_buffer jpeg_dec_process(jpeg_dec, io); // 交换缓冲区 swap_buffers(); // 通知显示任务 xSemaphoreGive(display_ready_sem); } // 显示任务 while (1) { xSemaphoreTake(display_ready_sem, portMAX_DELAY); // 把 front_buffer 刷到屏幕 esp_lcd_panel_draw_bitmap(panel, 0, 0, width, height, front_buffer); // 等待刷新完成 xSemaphoreTake(vsync_sem, portMAX_DELAY); xSemaphoreGive(decode_done_sem); }这个模型的关键是vsync 信号量。LCD 控制器每刷新完一帧会产生一个 VSYNC 中断在这个中断里释放信号量告诉解码任务可以开始解码下一帧了。这样就能保证解码和显示严格同步不会出现撕裂。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装先说环境。我用的是 ESP-IDF v5.3这是目前对 ESP32-P4 支持最好的版本。安装过程就不赘述了官方文档写得很清楚。重点说一下需要额外安装的组件第一个是esp_jpeg。这是乐鑫官方提供的 JPEG 解码组件在 ESP-IDF 的组件管理器里可以直接找到。安装命令idf.py add-dependency espressif/esp_jpeg^1.0.0第二个是esp_lcd_dpi_panel。这是 LCD 驱动组件支持 DMA2D 加速。同样通过组件管理器安装idf.py add-dependency espressif/esp_lcd_dpi_panel^1.0.0第三个是 FATFS 或者 SPIFFS。如果 MJPEG 文件存在 SD 卡上用 FATFS如果存在 Flash 上用 SPIFFS。这两个都是 ESP-IDF 自带的不需要额外安装。安装完组件后在menuconfig里配置一下Component config → JPEG Decoder → Enable JPEG decoder打开Component config → LCD → Enable DMA2D打开Component config → FATFS → Enable FATFS打开3.2 MJPEG 文件读取与帧解析读取 MJPEG 文件的核心逻辑是顺序读取遇到 SOI 开始记录遇到 EOI 结束一帧。代码大概长这样#define SOI_MARKER 0xFFD8 #define EOI_MARKER 0xFFD9 uint8_t *read_next_frame(FILE *fp, size_t *frame_size) { uint8_t *buffer malloc(MAX_FRAME_SIZE); size_t pos 0; uint16_t marker; // 寻找 SOI while (fread(marker, 1, 2, fp) 2) { if (marker SOI_MARKER) { buffer[pos] 0xFF; buffer[pos] 0xD8; break; } } // 读取直到 EOI while (fread(marker, 1, 2, fp) 2) { buffer[pos] (marker 8) 0xFF; buffer[pos] marker 0xFF; if (marker EOI_MARKER) { break; } } *frame_size pos; return buffer; }这段代码看起来简单但有几个性能陷阱第一个陷阱是逐字节读取。fread每次读 2 个字节系统调用开销很大。正确的做法是用一个大的缓冲区一次读几 KB然后在内存里扫描 SOI 和 EOI 标记。第二个陷阱是内存分配。每帧都malloc和free会产生内存碎片。正确的做法是预分配两个帧缓冲区循环使用。第三个陷阱是 EOI 标记的误判。JPEG 数据内部也可能出现0xFFD9这样的字节序列但它不是真正的 EOI。正确的做法是解析 JPEG 的段结构跳过熵编码数据段。不过对于 MJPEG 来说由于每帧都是独立的 JPEG而且 EOI 标记在文件里是唯一的所以直接扫描0xFFD9在实践中是可行的。3.3 解码与显示流水线搭建整个流水线可以分成三个任务读取任务从文件里读取下一帧数据放到raw_buffer里然后通知解码任务。解码任务从raw_buffer里取数据调用 JPEG 解码器解码到yuv_buffer然后通知显示任务。显示任务从yuv_buffer里取数据通过 DMA2D 转换并缩放到rgb_buffer然后刷到屏幕。这三个任务通过 FreeRTOS 的队列和信号量来同步。我实测下来这种流水线设计能把 CPU 占用率降到 20% 以下大部分工作都由硬件完成。关键代码如下// 读取任务 void read_task(void *arg) { while (1) { xSemaphoreTake(read_sem, portMAX_DELAY); size_t frame_size; uint8_t *frame read_next_frame(fp, frame_size); xQueueSend(raw_queue, frame, portMAX_DELAY); xSemaphoreGive(decode_sem); } } // 解码任务 void decode_task(void *arg) { while (1) { xSemaphoreTake(decode_sem, portMAX_DELAY); uint8_t *frame; xQueueReceive(raw_queue, frame, portMAX_DELAY); jpeg_dec_io_t io { .inbuf frame, .inbuf_len frame_size, .outbuf yuv_buffer, }; jpeg_dec_parse_header(jpeg_dec, io); jpeg_dec_process(jpeg_dec, io); xSemaphoreGive(display_sem); } } // 显示任务 void display_task(void *arg) { while (1) { xSemaphoreTake(display_sem, portMAX_DELAY); esp_lcd_dpi_panel_set_dma2d_scale(dpi_panel, src_w, src_h, dst_w, dst_h); esp_lcd_panel_draw_bitmap(panel, 0, 0, dst_w, dst_h, yuv_buffer); xSemaphoreTake(vsync_sem, portMAX_DELAY); xSemaphoreGive(read_sem); } }3.4 性能实测与优化前后对比我做了几组对比测试结果如下配置分辨率帧率CPU 占用备注纯 CPU 解码 CPU 色彩转换720p12fps95%卡顿明显硬件解码 CPU 色彩转换720p22fps60%基本流畅硬件解码 DMA2D 色彩转换720p30fps25%非常流畅硬件解码 DMA2D 色彩转换 缩放1080p→720p30fps30%非常流畅从表格可以看出硬件解码和 DMA2D 是性能翻倍的关键。纯 CPU 方案只能跑到 12fps而硬件加速方案能跑到 30fpsCPU 占用还从 95% 降到了 25%。还有一个优化点是文件读取。一开始我用的是单线 SPI 模式读 SD 卡速度只有 2MB/s成了瓶颈。后来改成 4 线 SDMMC 模式速度直接飙到 20MB/s帧率从 20fps 提升到了 30fps。提示如果你的 MJPEG 文件存在 Flash 里建议用内存映射方式直接读速度比文件系统快得多。但要注意 Flash 的读取速度受限于 SPI 时钟一般也就 40MHz 到 80MHz换算下来 5MB/s 到 10MB/s比 SD 卡慢不少。4. 常见问题与排查技巧实录4.1 解码失败与花屏问题问题现象播放的时候偶尔出现花屏或者某几帧直接解码失败。排查思路首先检查 MJPEG 文件本身有没有问题。用 FFmpeg 重新转一遍确保每帧都是完整的 JPEG。然后检查读取逻辑看看是不是把两帧的数据混在一起了。常见原因最常见的原因是EOI 标记误判。JPEG 数据内部可能出现0xFFD9字节序列如果直接扫描这个标记就会把一帧截断。正确的做法是解析 JPEG 的段结构跳过熵编码数据段。解决方法在读取的时候不要简单地扫描0xFFD9而是解析 JPEG 的段结构。每个段以0xFF开头后面跟一个字节的段类型。对于0xFFDASOSStart of Scan段后面的数据是熵编码数据需要特殊处理——熵编码数据里可能出现0xFF00这样的填充字节需要跳过。4.2 帧率不稳定与卡顿问题现象播放的时候帧率忽高忽低有时候会卡顿一下。排查思路首先用示波器或者逻辑分析仪看一下 VSYNC 信号的周期确认屏幕刷新是否稳定。然后检查解码任务的执行时间看看是不是某几帧解码特别慢。常见原因最常见的原因是缓冲区不足。如果只有两个缓冲区解码任务和显示任务会互相等待导致帧率不稳定。正确的做法是用三个缓冲区这样解码任务可以在显示任务刷屏的时候继续解码下一帧。解决方法把缓冲区数量从 2 增加到 3。内存够的话甚至可以增加到 4。但要注意每个缓冲区的大小是width × height × bytes_per_pixel720p 的 RGB565 缓冲区是 1.8MB三个就是 5.4MB。ESP32-P4 的 PSRAM 有 32MB完全够用。4.3 内存不足与分配失败问题现象程序运行一段时间后崩溃报内存分配失败。排查思路用heap_caps_print_heap_info打印内存使用情况看看是哪块内存不够。常见原因最常见的原因是内存碎片。如果每帧都malloc和free会产生大量碎片最终导致分配失败。正确的做法是预分配缓冲区循环使用。解决方法在程序初始化的时候一次性分配好所有需要的缓冲区。包括两个原始数据缓冲区存 JPEG 压缩数据、两个 YUV 缓冲区存解码后的数据、两个 RGB 缓冲区存转换后的数据。这样运行过程中就不需要再分配内存了。4.4 常见问题速查表问题现象可能原因排查方法解决方法花屏EOI 误判检查读取逻辑解析 JPEG 段结构卡顿缓冲区不足检查缓冲区数量增加到 3 个缓冲区内存不足内存碎片打印堆信息预分配缓冲区帧率低文件读取慢测 SD 卡速度改用 4 线 SDMMC解码失败JPEG 数据损坏用 FFmpeg 重新转确保每帧完整色彩不对输出格式错误检查解码器配置改用 RGB565 或 YUV422撕裂同步机制问题检查 VSYNC 信号用信号量同步4.5 独家避坑技巧技巧一用 FFmpeg 的-q:v参数控制文件大小。质量因子从 5 降到 10文件大小能减少一半但画质下降不明显。对于嵌入式播放来说这个 trade-off 很划算。技巧二把 MJPEG 文件放在 SD 卡上用 4 线 SDMMC 模式。我实测下来4 线模式比 1 线模式快 5 倍以上帧率提升非常明显。技巧三用 DMA2D 做缩放的时候尽量用整数倍缩放。比如 2 倍、0.5 倍这样画质最好速度也最快。非整数倍缩放会有插值误差画质会下降。技巧四解码器的输出缓冲区要 4 字节对齐。ESP32-P4 的 JPEG 解码器对输出缓冲区的对齐有要求不对齐的话会报错或者性能下降。用heap_caps_aligned_alloc分配对齐内存。技巧五用esp_timer测量每帧的解码时间。这样能快速定位性能瓶颈。我实测下来720p 的 JPEG 解码大概需要 8msDMA2D 转换和缩放大概需要 4ms总共 12ms理论上能跑到 80fps。实际受限于屏幕刷新率稳定在 30fps。技巧六如果屏幕支持 RGB888优先用 RGB888。虽然数据量比 RGB565 大 50%但色彩过渡更自然画质明显更好。ESP32-P4 的内存带宽足够支撑 720p 的 RGB888 播放。技巧七用-vf scale在 FFmpeg 转换的时候就做好缩放。比如你的屏幕是 720p但源视频是 1080p可以在转换的时候直接缩放到 720p这样 ESP32-P4 就不需要做缩放了能省不少 CPU 和 DMA2D 资源。ffmpeg -i input.avi -c:v mjpeg -q:v 5 -vf scale1280:720 -f mjpeg output.mjpeg这个命令在转换的时候就把视频缩放到 1280×720ESP32-P4 直接解码显示就行不需要再做缩放。我实测下来这样能再省 5% 到 10% 的 CPU。技巧八注意 SD 卡的碎片问题。如果 SD 卡用了很久文件碎片化严重读取速度会下降。建议定期格式化 SD 卡或者用专门的工具做碎片整理。我遇到过因为碎片导致帧率从 30fps 掉到 15fps 的情况格式化之后恢复正常。技巧九用双缓冲加垂直同步避免撕裂。这个前面说过了但值得再强调一遍。没有垂直同步的话屏幕刷新和缓冲区交换不同步会出现明显的撕裂。加上垂直同步之后画面就非常干净了。技巧十如果播放的是循环视频可以把整个 MJPEG 文件加载到 PSRAM 里。ESP32-P4 有 32MB PSRAM一个 720p 30fps 10 秒的 MJPEG 文件大概 18MB完全放得下。加载到内存之后读取速度就是内存带宽了比 SD 卡快得多帧率能稳定在 30fps 以上。这个方案特别适合开机动画或者循环播放的场景。我实测下来把 18MB 的 MJPEG 文件加载到 PSRAM 只需要 1 秒左右之后播放就非常流畅了。技巧十一注意 JPEG 解码器的并发限制。ESP32-P4 只有一个 JPEG 解码器不能同时解码多帧。如果你的应用需要同时播放多个视频需要分时复用解码器或者用软件解码作为补充。技巧十二用esp_lcd_panel_draw_bitmap的时候注意坐标参数。这个函数的参数是(panel, x_start, y_start, x_end, y_end, buffer)其中x_end和y_end是排他的也就是说实际绘制的区域是[x_start, x_end)和[y_start, y_end)。如果搞错了画面会偏移或者只显示一部分。这些技巧都是我在实际项目中踩过坑之后总结出来的希望能帮你少走弯路。ESP32-P4 的 MJPEG 播放性能其实非常强只要用对了方法720p 30fps 甚至 1080p 30fps 都不是问题。关键是要把硬件加速用起来把流水线搭好把内存管理做好。
返回列表