最近在开源社区看到并实际搭了一个很有意思的小项目——基于乐鑫ESP32-S3的ESP-Mosaico。它的核心玩法很简单:把摄像头的实时画面处理成马赛克拼贴风格,再输出到一块RGB LCD上,配合LVGL做触摸交互,整体像一个迷你的“像素风取景器”。我前后折腾了大概两周,从硬件接线、SDK配置到算法调优都过了一遍,踩了不少坑也总结了一些经验,这篇就把完整方案和实操细节写出来,给想玩嵌入式图像处理的朋友一个可以直接参考的路线。
这个项目适合谁?两类人比较对味:一类是已经在用ESP-IDF的嵌入式开发者,想看看摄像头、显示、GUI这三样东西怎么在一个芯片上低成本地跑起来;另一类是玩过ESP32但没接触过图像处理的人,想了解“马赛克”这种视觉效果从取帧到上屏到底经历了什么。它不像官方Demo那么完整,但正因为“半成品”的属性,留了很多可以自己发挥的空间。
1. 项目整体设计与思路拆解
1.1 ESP-Mosaico到底在做什么
ESP-Mosaico不是一个单纯播放图片的小相框,它要解决的是一整套“摄像头→图像处理→显示→人机交互”的链路。Mosaico在意大利语里是“马赛克”的意思,放在这个项目里很贴切:摄像头采集到的视频帧,通过降采样、分块求平均、颜色量化这几步处理,变成一个一个色块组成的低分辨率画面,再把这种“像素风”效果实时显示在LCD上。
听起来不复杂,但真正做起来有几个麻烦点:摄像头的数据进来是持续的,每秒钟几十帧,你要决定哪些帧拿来处理;处理完的帧要发给LCD,而LCD刷新又有自己的节奏;如果中间还要叠加LVGL的UI控件,内存和带宽的分配就更紧张。所以整个项目本质上是一个“怎么在有限资源里把实时图像管线撑起来”的工程问题,马赛克算法反而是其中最简单的部分。
我做的时候把项目拆成了三层来设计:
- 底层是硬件驱动层,负责摄像头DVP接口的数据采集、LCD的RGB接口驱动、触摸的I2C读取。
- 中间是图像处理层,把YUV或RGB的数据转成RGB565,做像素化处理,再塞进framebuffer。
- 上层是应用层,用LVGL管理界面,把处理后的图像以控件的形式展示,同时处理触摸事件。
这个分层的价值在于:图像处理算法可以单独调,UI和驱动互不干扰,后面想换屏幕、换摄像头,不需要把整套逻辑推翻。
1.2 硬件选型:为什么ESP32-S3是甜点
乐鑫的芯片其实可选的不多时也已经不少,ESP32、ESP32-S3、ESP32-P4各自定位不同。ESP32-P4性能更强,甚至有专门的图像处理能力,但开发板价格偏高,资料也相对新,对普通玩家不太友好。ESP32-S3则是目前的“甜点”:双核240MHz、带向量指令集、最大8MB PSRAM、内置DVP摄像头接口和RGB LCD控制器,几乎是为这种小型视觉项目量身定制的。
具体到ESP-Mosaico这个场景,S3有几个点很重要:
- DVP摄像头接口是硬件控制器,能自动把摄像头sensor的数据搬到内存里,不占用CPU去逐像素读取。
- 内置的RGB LCD控制器可以直接驱动16位并口的屏幕,加上DMA传输,CPU只需要把图像数据准备好,剩下的交给硬件。
- PSRAM容量可以选到8MB,足够放1-2帧VGA分辨率的RGB565图像缓冲区。如果只用芯片自带SRAM,一帧640x480的图像就得占用600多KB,根本放不下。
我用的方案是ESP32-S3-WROOM-1模组(8MB PSRAM版本),搭配OV2640摄像头模组,屏幕是一块4.3寸480x272的RGB屏。这套组合跑原图预览大约能到20-25fps,打开马赛克处理之后降到12fps左右,作为实验项目完全够用。
1.3 软件框架:LVGL加摄像头双线程架构
软件方面我的做法是跑两个FreeRTOS任务,一个任务只干摄像头抓帧和图像处理,另一个任务跑LVGL刷新界面。两个任务通过事件标志和共享framebuffer通信。
为什么不用单线程?因为LVGL的刷新如果和摄像头处理放在同一个任务里,帧率会非常难看。摄像头采集虽然走DMA,但处理一帧的数据需要几十毫秒的CPU时间,如果这时候GUI还在渲染动画,两边都会卡。把LVGL跑在独立的task里,给它固定时间片,UI至少是流畅的。
两个任务之间我设计了一个简单的“当前帧指针”机制:摄像头任务处理完一帧后,把数据写入A或B两个buffer之一,然后置位一个标志位,通知LVGL任务去读。LVGL flush回调拿到buffer地址之后直接通过DMA发送给LCD,不拷贝数据,只传指针。这样全程只有一次真正的像素处理,没有多余的内存搬移。
2. 核心细节解析与算法落地
2.1 马赛克效果的本质:像素化
马赛克效果看着神秘,本质就是“降低空间分辨率”,只是把降低的过程做得有辨识度。最简单的实现是分块平均:把整幅图像按N×N像素分成一个个小方块,取每个方块内所有像素的平均颜色,然后用这个平均色填满整个方块。N取8、16或24,视觉效果分别是轻度朦胧、经典马赛克、近似抽象色块。
我有一个循环处理的做法,代码核心很简短:
void pixelate_frame(uint16_t *fb, int width, int height, int block_size) { for (int y = 0; y < height; y += block_size) { for (int x = 0; x < width; x += block_size) { int r_sum = 0, g_sum = 0, b_sum = 0; int count = 0; for (int dy = 0; dy < block_size; dy++) { for (int dx = 0; dx < block_size; dx++) { int px = x + dx; int py = y + dy; if (px >= width || py >= height) continue; uint16_t color = fb[py * width + px]; r_sum += (color >> 11) & 0x1F; g_sum += (color >> 5) & 0x3F; b_sum += color & 0x1F; count++; } } uint16_t avg_r = r_sum / count; uint16_t avg_g = g_sum / count; uint16_t avg_b = b_sum / count; uint16_t avg_color = (avg_r << 11) | (avg_g << 5) | avg_b; for (int dy = 0; dy < block_size; dy++) { for (int dx = 0; dx < block_size; dx++) { int px = x + dx; int py = y + dy; if (px >= width || py >= height) continue; fb[py * width + px] = avg_color; } } } } }这版代码是最直观的写法,但不代表最优。分块平均有个隐藏问题:如果图像中有较亮的物体,平均色容易发灰。后来我在实际使用中改成了“区域中值”或“带权重平均”,效果会更接近真实马赛克拼接,不过计算量会高一些。对于ESP32-S3这种带单精度FPU但更适合定点运算的芯片,我建议第一次跑就用RGB565位的移位方式,别用浮点。
2.2 颜色量化:从真彩色到小调色板
如果只是像素化,画面颜色还是很丰富,看着像低分辨率照片,还不是“拼接马赛克”的感觉。真正的马赛克拼贴应该有明显的色彩断层,这就涉及颜色量化:把图像从几十万种颜色压缩到几百种甚至几十种,让色块边缘出现明显的色阶。
常见做法有两种:固定调色板和自适应调色板。固定调色板就是直接用预先定义好的64色或256色调色板,比如把RGB空间均匀切分,5-6-5的RGB565取高位,正好是32×64×32的色域,理论上就是65536色,想要64色就继续把低两位抹掉。这样做速度极快,因为只需要查一个映射表。
自适应调色板更复杂一点,要先统计整帧图像的颜色直方图,然后用中位切分或K-Means聚类选出代表性颜色。这个算法在小图像上可行,但在实时视频流里每一帧都做聚类,计算压力太大。我的折中方案是:每隔30帧做一次自适应调色板更新,中途的帧用上一次的调色板映射。这样既能适应场景变化,又不会拖累实时性。
用固定调色板时要注意一个细节:不要一个像素一个像素地去算映射,那会慢得怀疑人生。正确做法是建立一个65536项的映射表,把RGB565的所有可能值提前映射到目标调色板索引上,运行时查表就够了。65536项的数组用uint8_t存,只有64KB,PSRAM随便放。查表一次的耗时只相当于一次内存访问。
static uint8_t *color_lut; // 初始化时预计算: // color_lut[idx] = map_idx_to_palette[idx]; // 处理时: uint16_t color = fb[idx]; uint8_t pal_index = color_lut[color]; uint16_t mapped = palette[pal_index];2.3 性能优化:三重手段让帧率维持可用
在不换芯片的前提下,马赛克处理能不能跑起来、跑多快,基本由三件事决定:内存访问效率、计算复杂度、显示DMA带宽。我实际优化后,480x272画面、16像素块大小时,处理一帧大约25ms,加上采集和显示传输,整体帧率稳定在12fps左右。优化手段主要是三个:
第一个是避免重复读取。原始的双循环里,每个像素会被读取两次(一次求平均,一次填充),如果先单独算平均再填充,必然多一次遍历。我改成一边遍历一边累积,求出平均色之后,再按行填充时直接从缓存里读取平均值,不再二次访问原像素。这样省掉了一半的像素读取开销。
第二个是使用ESP32-S3的SIMD指令。S3的向量指令集里有一些对字节和半字批量处理的指令,理论上可以直接加速求平均和颜色转换。不过这个需要写内联汇编或使用特定的ESP-IDF API,开发成本偏高。实际项目中,我选择了一个更简单的优化:把图像缩小到(M/16) × (N/16)大小,处理完后再插值放大显示。因为马赛克块本身就是低分辨率表现,所以先缩小再放大并不会损失视觉效果,但处理量直接降到原来的1/256,性能提升非常可观。
第三个是显示层面用DMA+双缓冲。LCD的RGB接口支持帧同步信号,把两个framebuffer都注册好,显示控制器在VSYNC中断里切换要显示的buffer。这样CPU在写另一个buffer的时候,LCD正在刷新当前buffer,不会互相等待,画面没有撕裂。
3. 实操过程与关键实现
3.1 环境准备与工程结构
我的开发环境是ESP-IDF v5.2版本,这个版本对新的ESP32-S3芯片型号支持比较完善,且自带组件管理工具。如果你用的是老版本,建议先升级。
新建工程之后,需要把几个组件添加进来:
- esp32-camera:负责OV2640/OV5640等sensor的初始化、DVP时序、画面缓存管理。
- lvgl:图形库,我用的是LVGL v8.3,相对稳定且资料多。
- esp_lcd:官方LCD驱动框架,RGB屏的部分会用到。
- esp_touch:触摸屏驱动,I2C连接的GT911或FT6x36这一类。
用菜单配置做基础设置时,重点不是每项都看一遍,关键是这三个:
- Serial flasher config → Flash size选择你板子实际的容量。
- Component config → ESP32-S3-specific → 开启PSRAM,且选择Octal或Quad模式要与硬件匹配。
- Component config → LVGL → 色彩深度改为16位,缓冲区大小分给两个buffer,每个buffer按屏幕高度的一部分来分配,比如两行或1/10屏。
我踩过最坑的地方是Flash和PSRAM的模式没配对,导致系统能启动但随机崩溃。后来查了ESP-IDF的日志,发现是SPI mode设置比模组支持的快了一档。建议先按模组官网的标准配置走,跑通基本Demo后再追求性能参数。
3.2 摄像头采集到LCD显示的完整链路
完整链路的代码流程是这样的:
// 初始化摄像头 camera_config_t config = { .pin_pwdn = -1, .pin_reset = -1, .pin_xclk = CAM_XCLK, .pin_sccb_sda = CAM_SDA, .pin_sccb_scl = CAM_SCL, .pin_d7 = CAM_D7, .pin_d6 = CAM_D6, .pin_d5 = CAM_D5, .pin_d4 = CAM_D4, .pin_d3 = CAM_D3, .pin_d2 = CAM_D2, .pin_d1 = CAM_D1, .pin_d0 = CAM_D0, .pin_vsync = CAM_VSYNC, .pin_href = CAM_HREF, .pin_pclk = CAM_PCLK, .xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0, .pixel_format = PIXFORMAT_RGB565, .frame_size = FRAMESIZE_VGA, .jpeg_quality = 12, .fb_count = 2, };这里有个容易忽略的点:pixel_format要设为RGB565,而不是JPEG。JPEG格式虽然能直接把压缩图像传给LVGL解码显示,但解码会占用大量CPU时间,对实时处理很不友好。不设成RGB565的话,你图像处理逻辑里就得多一步JPEG解码。
摄像头初始化完成后,主循环里每一帧就是典型的“采集-处理-显示”三步:
while (1) { camera_fb_t *fb = esp_camera_fb_get(); if (!fb) continue; process_mosaico(fb->buf, fb->width, fb->height, block_size); lvgl_update_image(fb->buf); esp_camera_fb_return(fb); vTaskDelay(10 / portTICK_PERIOD_MS); }注意esp_camera_fb_get()拿到缓冲区之后,处理完必须调用esp_camera_fb_return()释放,否则摄像头驱动后续帧没有缓冲区可用,直接死等。我就是因为漏了这一步,程序运行几秒钟后画面就停住了,日志里全是buffer相关的报错。
3.3 LVGL界面与交互
LVGL集成时,我用了image控件来显示摄像头画面。Image控件的内存缓冲区直接指向framebuffer,并设置为LV_IMG_CF_RAW格式。这样LVGL不会自己做任何解码,只是把这个区域的内容随界面一起刷新,效率很高。
界面设计得很简单:
- 顶部有一个label显示当前帧率和处理时间。
- 中间区域是实时图像。
- 底部两个按钮,一个切换原始画面和马赛克画面,一个调整马赛克块大小。
触摸交互用LVGL的输入设备注册机制,初始化触摸芯片之后把read_cb回调注册进去,LVGL就能感知点击位置。我在按钮的event回调里直接修改全局变量block_size,下一次画面处理循环就会生效。
有一个细节值得提:LVGL的刷新和图像更新是两套索路。图像数据是摄像头任务写入framebuffer,然后通知LVGL的image控件“这个区域脏了”,LVGL下次刷新时会去读这个区域的数据。由于核心共享只靠一个指针,我用了一个lv_img_set_src()调用来触发刷新,而不是直接操作LVGL内部缓冲。这样做的好处是不会出现LVGL读一半、摄像头写一半的情况,画面至少是完整一帧。
3.4 关键代码参考
下面这段是我最终用来做“马赛克拼贴”效果的核心处理,它比上一篇的“分块平均”多了一步颜色量化和网格描边:
#define BLOCK_COLOR_COUNT 64 static uint16_t palette[BLOCK_COLOR_COUNT]; uint16_t quantize_color(uint16_t rgb565) { // 通过查表返回调色板里的颜色 return color_lut[rgb565]; } void mosaico_effect(uint16_t *fb, int w, int h, int bs) { for (int y = 0; y < h; y += bs) { for (int x = 0; x < w; x += bs) { int r = 0, g = 0, b = 0, n = 0; for (int j = 0; j < bs; j++) { if (y + j >= h) break; for (int i = 0; i < bs; i++) { if (x + i >= w) continue; uint16_t c = fb[(y + j) * w + (x + i)]; r += (c >> 11) & 0x1F; g += (c >> 5) & 0x3F; b += c & 0x1F; n++; } } uint16_t avg = ((r / n) << 11) | ((g / n) << 5) | (b / n); uint16_t q = quantize_color(avg); for (int j = 0; j < bs; j++) { if (y + j >= h) break; for (int i = 0; i < bs; i++) { if (x + i >= w) continue; fb[(y + j) * w + (x + i)] = q; } } // 在色块边缘画一条灰度格,增强马赛克拼接感 for (int i = 0; i < bs && x + i < w; i++) fb[y * w + x + i] = 0x18E3; for (int j = 0; j < bs && y + j < h; j++) fb[(y + j) * w + x] = 0x18E3; } } }效果上,58行到63行的网格线非常关键。没有它,画面只是低分辨率模糊图;加上它,眼睛会自动识别成“拼出来的马赛克”。这里的0x18E3是一个偏暗的灰绿色,换成纯黑也可以,但纯黑线条太刺眼,暗灰绿会更像手工拼贴。
4. 常见问题排查与优化建议
4.1 画面撕裂与闪烁问题排查
我一开始直接把摄像头帧写到LVGL的显示缓冲里,结果LCD刷新的时候经常出现画面上下撕裂,尤其是画面中有大面积亮色物体时,一撕裂就能看到两条明显的“分界线”。
问题根源在于摄像头帧率和LCD刷新率不同步。摄像头是25fps左右,LCD是60Hz,两边的节奏对不上,就可能出现刷新中间换帧。排查顺序是:
- 先确认LVGL的flush回调是否用了DMA,如果没有,用DMA传输。
- 再检查是否做了双缓冲,并把双缓冲切换的时机放在VSYNC中断里。
- 最后确认摄像头帧是否在
esp_camera_fb_return之前就完成了处理,不要一边处理一边让驱动覆盖这块内存。
处理完之后画面的撕裂基本消失。偶尔极短时间的撕裂发生在LVGL刷新尾部,属于可接受范围。
4.2 内存不足与PSRAM带宽瓶颈
这种项目的内存模型其实很清晰:VGA帧是600KB,双缓冲就1.2MB,哪怕加了PSRAM也会很快吃紧。我实际设置的是分配三个VGA大小的RGB565缓冲区,一个给摄像头采集,一个给处理中间帧,一个给LVGL刷新缓冲,总共1.8MB。这个配置跑480x272的屏幕绰绰有余,但如果你要用800x480的屏幕,一张帧就变768KB,三个缓冲就2.3MB,8MB PSRAM也开始紧张。
内存问题不只是“够不够放”的问题,还有带宽。ESP32-S3的PSRAM访问带宽比内部SRAM低得多,如果频繁从PSRAM读像素再写回PSRAM,帧率会直线下降。我的优化思路是:矩阵运算和颜色查表尽量放在内部SRAM的数据块里完成,只有大块framebuffer放PSRAM。处理一帧的过程中,尽量一次遍历完成全部操作,别来来回回多次读取同一块数据。
如果你观察到画面卡顿但CPU占用不高,大概率是PSRAM带宽打满了。试着把分辨率从VGA降到QVGA,或者把马赛克块调大一些,一般情况下帧率能恢复。
4.3 触摸失灵与UI调试实录
触摸屏的问题出现在初始化阶段,屏幕一直没反应。排查时发现I2C总线没上拉电阻,但线上测量SDA是有波形的,后来仔细看才发现是触摸芯片的中断引脚(INT)没接对。GT911这类芯片如果没有正确触发中断,LVGL的资源库就不会主动去读触摸寄存器。
接线和驱动都正确之后,触摸还是不准,尤其是边缘位置偏移很大。LVGL自带坐标校准逻辑,但校准步骤要做对:先用串口打印原始坐标和屏幕分辨率的关系,然后写一个简单的两点校准函数,把原始坐标线性映射到屏幕上。
调试UI时我习惯开LVGL的日志输出等级,它会打印控件绘制和事件分发的时间。有一次发现按钮点了没反应,日志显示event指针全是无符号类型,排查之后发现是lv_event_cb函数签名写错了,回调参数类型不匹配。这种事新手特别容易踩,记得仔细对照头文件里的回调定义。
下面是一份常见问题速查表,是我项目过程中总结出来的:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 摄像头黑屏 | XCLK频率设置过高,sensor跑不起来 | 降到20MHz以下 |
| 图像颜色偏绿/偏紫 | RGB565和RGB888通道顺序不对 | 确认sensor输出的bayer格式与buffer匹配 |
| 画面卡顿 | 没有用PSRAM存framebuffer | 在PSRAM分配缓冲区,开启malloc使用PSRAM |
| 跑一段时间内存崩溃 | 漏调esp_camera_fb_return或buffer holder分配失败 | 检查所有分支,确保每次fb_get都有对应return |
| LCD显示方向不对 | LCD驱动面板参数与屏幕模组不一致 | 检查初始化序列里的行/列交换命令 |
| LVGL控件不显示 | 没有创建style或未执行lv_refr_now | 检查是否调用了lv_timer_handler |
4.4 针对帧率瓶颈的几个实测建议
如果你跑完基础流程,觉得帧率还能压榨,我提供三个方向按性价比排序:
第一个方向是把摄像头分辨率降到QVGA,但显示时放大到屏幕分辨率。因为马赛克图像本身像素块很大,低分辨率带来的模糊和马赛克效果天然融合,肉眼分辨不出差异,帧率却能翻倍。我实测QVGA下相同处理的帧率是20fps,VGA下只有11fps。
第二个方向是使用esp32-camera的fb_count = 3,三缓冲能更好地把拍摄和处理重叠起来。摄像头DMA写满一个buffer后自动进入下一个,CPU处理当前buffer时,DMA还能继续写,等待时间几乎消失。
第三个方向是我前面提过的“先缩小,再放大”策略。在Framebuffer层面先做一次1/4缩放到一个小buffer,马赛克处理就在小buffer上进行,最后把结果直接输出给LVGL。代价是马赛克块边缘会稍微柔和一点,但对于普通玩家来说,完全值得。
最后分享一点个人体会
我在调这个项目时最有感触的一点是:马赛克算法本身三十分钟就能写完,但整个项目百分之七十的时间花在了内存管理和显示时序上。嵌入式图像处理的难点从来不是算法数学有多难,而是你所有的资源都在一个纸面上写好的数字里:几点几兆的内存、多少MHz的总线、多少个DMA通道。学会在限制里做取舍,比学会某个炫酷算法更有价值。
如果接下来想继续玩这个方向,我推荐几个扩展思路:把调色板改成固定艺术风格(比如荧光色、低饱和),或者用ESP-NOW无线传两台设备做“马赛克画布互传”,再或者把处理结果编码成PNG存入SD卡。ESP32-S3的余量比想象中要大,一个“很简单的马赛克项目”往往就是通往更复杂视觉应用大门的钥匙。