再探OLED:显示效果的极限
0. 前言
这是一个小插曲,最近看一部棒球漫画《失忆投捕》有点上头,正好为了射箭动作训练器打的板子回来了。虽然板子有点小问题,但是不妨碍拿它当开发板用,我可以拿它来做个《失忆投捕》的赛博周边。
用的显示屏是I2C接口的SSD1306驱动的0.96寸OLED屏,分辨率是128*64。这么小的屏幕要用来放hrk和kei的美照属实有点勉强,所以这篇文章正是在研究这块小屏的极限能做到什么效果。
这是原图,傻子状态下的要圭。
然后再截取并压缩到128*64,这是要用来显示的图。
1. 二值化
截取后的图是一张灰度图片,但是OLED屏是单色的,也就是只有“亮”和“不亮”两个状态,没法显示中间的灰色,所以需要先把图片转成纯黑白。
LVGL转换工具
最简单的方法还是利用这篇文章里提到的LVGL转换工具,转换处理后仍然是灰度图也没事,LVGL在ESP32上的实际显示效果确实是黑白的。然而,这样显示出来的黑白图片效果不够好,因为二值化的阈值不一定合适。有些地方,例如鼻子,相比周围区域显然是要保留的,但是简单二值化就会被判为白色,另一些地方,比如头发,又保留太多线条显得凌乱。
使用OpenCV自定义阈值
DeepSeek给出了利用OpenCV二值化的代码,其中阈值是可以自定义的。
import cv2
import numpy as np
def grayscale_to_binary(image_path, threshold=127, save_path='binary_image.jpg'):
"""
将灰度图像转换为二值图像
参数:
image_path (str): 输入图像路径
threshold (int): 阈值 (0-255),默认127
save_path (str): 输出图像保存路径
返回:
binary_image: 二值化后的图像
"""
# 读取图像为灰度图
gray_image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)
if gray_image is None:
raise ValueError("无法加载图像,请检查路径是否正确")
# 应用阈值进行二值化
_, binary_image = cv2.threshold(gray_image, threshold, 255, cv2.THRESH_BINARY)
# 保存图像
cv2.imwrite(save_path, binary_image)
# 显示图像(可选)
cv2.imshow('Original Gray Image', gray_image)
cv2.imshow('Binary Image', binary_image)
cv2.waitKey(0)
cv2.destroyAllWindows()
return binary_image
# 使用示例
# 调整threshold参数可以改变二值化阈值(0-255之间的整数)
binary_img = grayscale_to_binary('input.jpg', threshold=150, save_path='output_binary.jpg')
经过肉眼对比,最终选择threshold=175,二值化后效果如图:
OpenCV也支持一些更高级的二值化算法,比如自适应阈值,但这个例子里实际效果改善不明显,因此仅供参考。
# Otsu自动阈值
_, binary_image = cv2.threshold(gray_image, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
# 自适应阈值
binary_image = cv2.adaptiveThreshold(gray_image, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY, 11, 2)
效果
用OLED显示的效果如图:
2. PWM实现灰度
二值化毕竟会丢失灰度信息,有什么办法让单色屏也能显示出灰度呢?有,利用PWM。只要屏幕以足够快的速度不停地一开一关,人眼就会以为显示的是灰色。灰色的深浅由开和关所占的比例控制。
跳过LVGL
为了能控制灰度,我需要能直接控制每一帧下,每个像素的开关状态。因此要跳过LVGL这层封装,直接与SSD1306通信。Deepseek总结的关键点如下:
显示缓冲区:SSD1306的显示内存是按页组织的,每页8行像素。128x64的屏幕需要128x8字节的缓冲区。
像素映射:每个字节代表垂直的8个像素(1位=1像素),LSB对应顶部像素,MSB对应底部像素。
内存地址模式:示例中使用的是页地址模式,你也可以使用水平或垂直地址模式。
性能优化:对于频繁更新,可以只更新屏幕的部分区域,而不是整个屏幕。
SPI支持:如果你的SSD1306使用SPI接口,需要修改通信部分的代码。
因为仅仅是跳过了LVGL层,SSD1306的底层驱动仍然是用的ESP-IDF写好的。参考例程,用这几个函数初始化屏幕:
ESP_ERROR_CHECK(esp_lcd_new_panel_ssd1306(io_handle, &panel_config, &panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
再用esp_lcd_panel_draw_bitmap()
这个函数来更新像素的内容。
图片数据结构
在Python里用OpenCV能读到0~255之间的灰度值,先把它写死到一个二维数组里。假设有一个灰度阈值,我就一个像素一个像素地拿灰度值和阈值比大小,如果低于阈值,那么认为这个像素是亮的。现在我要把这些信息转换成SSD1306支持的格式,我用一个uint8的数组来表示图片,每一个字节对应垂直的8个像素。
uint8_t frame_buffer[EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 8] = {0};
然后是和阈值做比较的步骤:
#define EXAMPLE_LCD_H_RES 128
#define EXAMPLE_LCD_V_RES 64
uint8_t thresh
for (int x = 0; x < EXAMPLE_LCD_H_RES; x++) {
for (int y = 0; y < EXAMPLE_LCD_V_RES; y++){
if (gray_image[x + y * EXAMPLE_LCD_H_RES] < thresh){
frame_buffer[x + (y / 8) * EXAMPLE_LCD_H_RES] |= (1 << (y % 8));
}
else {
frame_buffer[x + (y / 8) * EXAMPLE_LCD_H_RES] &= ~(1 << (y % 8));
}
}
}
通过不同阈值的组合,把每次计算出来的frame_buffer
不停地更新给SSD1306,就能实现PWM调制。
3. 帧率的极限
如果每一帧刷新的速度不够快的话,人会肉眼可见地看到屏幕闪烁,所以要尽可能提高屏幕的刷新率。人眼通常看到24Hz以上的画面就会自动脑补成连续的,30Hz的屏幕人会觉得不卡,60Hz以上会觉得流畅,而近年来手机和电脑的屏幕都在朝着120Hz以上的高刷发展。那么我手上的这块屏幕最快能多快呢?
I2C理论帧率
屏幕的刷新速度受限于ESP32和SSD1306的通信速度,DeepSeek的计算如下:
接口类型 | 典型时钟频率 | 理论最大帧率 (128×64) |
---|---|---|
I2C | 400 kHz (Fast Mode) | ~30-50 FPS (实际通常 10-30 FPS) |
SPI | 8-10 MHz (硬件SPI) | ~70-100 FPS (实际通常 30-60 FPS) |
计算示例 (I2C 400kHz):
每帧数据量 = 128 × 8 (页) × 1字节 = 1024 字节
每字节传输开销 ≈ 9 bits (8数据 + ACK)
总bit数 ≈ 1024 × 9 = 9216 bits
传输时间 ≈ 9216 / 400,000 ≈ 23 ms
理论最大帧率 ≈ 1000 / 23 ≈ 43 FPS
实际帧率会更低,因为还需计算命令传输、函数调用开销等。
而我手上这款OLED屏幕的厂商就没有把SPI引脚引出来,所以没法改用SPI驱动,只能去找I2C的极限。
双缓冲优化
双缓冲的原理是有两个独立的缓冲区,在传输第一个缓冲区时计算第二个缓冲区,计算完了再传输第二个计算第一个,使计算和传输完全并行,消除等待时间。基于ESP32-IDF的RTOS的实现方法如下,它通过任务通知的机制告诉传输任务缓冲区的数据何时会ready for transmit。
TaskHandle_t transmit_task_handle;
uint8_t frame_buffer[2][128*8];
volatile int ready_buffer = -1; // -1表示无数据可传输
// 传输任务
void transmit_task(void *pv) {
while (1) {
// 等待通知(阻塞)
uint32_t notification;
xTaskNotifyWait(0, 0, ¬ification, portMAX_DELAY);
// 传输数据
if (ready_buffer != -1) {
ssd1306_update_screen(frame_buffer[ready_buffer]);
ready_buffer = -1;
}
}
}
// 渲染任务
void render_task(void *pv) {
int buffer_idx = 0;
while (1) {
render_content(frame_buffer[buffer_idx]);
// 标记缓冲区就绪
ready_buffer = buffer_idx;
// 通知传输任务
xTaskNotify(transmit_task_handle, 0, eNoAction);
// 切换缓冲区
buffer_idx ^= 1;
}
}
// 初始化
void app_main() {
xTaskCreate(transmit_task, "transmit", 4096, NULL, 1, &transmit_task_handle);
xTaskCreate(render_task, "render", 4096, NULL, 2, NULL);
}
在while(1)
循环里加入定时器来实测帧率。
int64_t last_time = esp_timer_get_time();
int64_t current_time;
while (1){
...... //other tasks
ssd1306_update_screen(frame_buffer[ready_buffer]);
current_time=esp_timer_get_time();
float fps = 1e6 / (current_time - last_time);
ESP_LOGI("FPS", "Transmit: %.1f", fps);
last_time=current_time;
}
然而,实测发现,1)传输和渲染都放在app_main()里,经过测试fps是37.9,2)分别创建了传输任务和渲染任务,经过测试fps是38.8。两种方法差异不大,说明瓶颈在于i2c的传输速度,用双缓冲的方式并不能明显优化fps。反而双缓冲因为任务调度的需要,会额外消耗调度资源,还可能导致触发看门狗。
I2C超频
超频是一个简单粗暴的解决方法。虽然ESP官方文档说I2C的时钟频率不应该超过400kHz,但是软件修改又不会爆炸,理论上只要波形还没有失真就可以超频。
i2c_device_config_t i2c_lcd_config = {
.dev_addr_length=I2C_ADDR_BIT_LEN_7,
.device_address = 0x3C,
.scl_speed_hz = 400*1000,
.scl_wait_us=100
};
i2c_master_dev_handle_t i2c_dev_handle = NULL;
i2c_master_bus_add_device(i2c_bus, &i2c_lcd_config, &i2c_dev_handle);
把scl_speed_hz
改成600kHz,在没有用双缓冲的情况下,帧率就来到了53.8Hz。
预渲染
虽然瓶颈在于传输速度,但是图形的计算仍然是消耗了时间的。比起双缓冲,因为我的图片和阈值都是固定的,可以事先把比大小这步做完保存下来。用空间换时间,实现起来也很简单。
uint8_t frame_buffer[3][EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 8] = {0};
uint8_t thresh_list[3]={100,150,200};
for (int i = 0; i<3;i++){
uint8_t thresh=thresh_list[i];
frame_buffer[i] = generate_image(gray_image,thresh);
}
然后要传输图像时,只需要把buffer指针指给frame_buffer[i]
即可,避免了在内存里反复拷贝数据。使用预渲染后,在600kHz时钟下,帧率提高到了59Hz。
跳过ESP-IDF的SSD1306驱动
再进一步提高帧率,需要去优化I2C传输时的overhead。ESP-IDF对SSD1306的驱动包装了好几层,在I2C接口之上还封装了一个LCD Panel层,这一层的用处是统一不同厂商的底层接口,但这时候额外的这层封装没有必要。只利用I2C接口,跳过LCD Panel层,也是足够控制SSD1306的。
首先是初始化,命令序列可以从数据手册得到,DeepSeek给出了示例代码。把ESP-IDF的官方驱动剥开剥到最底层,可以用来参考哪些设定是需要设的。发送时先发一个0x00表示后面一个字节跟着的是命令。
void ssd1306_send_command(uint8_t cmd, i2c_master_dev_handle_t dev_handle) {
uint8_t buf[2] = {0x00, cmd}; // 0x00是命令控制字节
ESP_ERROR_CHECK(i2c_master_transmit(
dev_handle,
buf,
sizeof(buf),
pdMS_TO_TICKS(100)
));
}
void ssd1306_init(i2c_master_dev_handle_t dev_handle) {
// 初始化命令序列(参考SSD1306数据手册)
ssd1306_send_command(0xAE,dev_handle); // 关闭显示
// 基础配置
//ssd1306_send_command(0xD5); // 设置显示时钟分频
//ssd1306_send_command(0x80); // 建议值
ssd1306_send_command(0xA8, dev_handle); // 设置多路复用率
ssd1306_send_command(0x3F,dev_handle); // 对于128x64屏幕
ssd1306_send_command(0xD3,dev_handle); // 设置显示偏移
ssd1306_send_command(0x00, dev_handle); // 无偏移
ssd1306_send_command(0x40, dev_handle); // 设置显示起始行
// 电荷泵配置
ssd1306_send_command(0x8D, dev_handle);
ssd1306_send_command(0x14, dev_handle); // 启用电荷泵
// 内存地址模式
ssd1306_send_command(0x20, dev_handle);
ssd1306_send_command(0x00, dev_handle); // 水平地址模式
// 扫描方向
//ssd1306_send_command(0xA1); // 段重映射(列127->SEG0)
//ssd1306_send_command(0xC8); // COM输出扫描方向(从COM63开始)
// 对比度配置
//ssd1306_send_command(0x81);
//ssd1306_send_command(0xCF); // 对比度值
// 预充电周期
//ssd1306_send_command(0xD9);
//ssd1306_send_command(0xF1);
// COM引脚配置
ssd1306_send_command(0xDA, dev_handle);
ssd1306_send_command(0x12, dev_handle); // 对于128x64屏幕
// 全亮/全灭
//ssd1306_send_command(0xA4); // 恢复RAM内容显示
// 显示模式
//ssd1306_send_command(0xA6, dev_handle); // 正常显示(非反转)
ssd1306_send_command(0xA0, dev_handle);
ssd1306_send_command(0xC0, dev_handle);
// 开启显示
ssd1306_send_command(0xAF, dev_handle);
}
每次刷新时,同样I2C发送数据。这里准备两个buffer,第一个是命令buffer,内容是0x00后面跟着需要刷新的X,Y范围,这里是全屏刷新。第二个buffer是数据buffer,内容是0x40后面跟着原本的1024字节的像素数据,0x40的作用是表示后面跟着的是数据。把命令和数据一口气全发出去可以减少握手的开销。
const uint8_t command_buffer[7]={0x00,0x21,0x00,0x7F,0x22,0x00,0x07};
void ssd1306_update_screen(uint8_t* frame_buffer, i2c_master_dev_handle_t dev_handle) {
// 设置地址范围(全屏)
i2c_master_transmit(dev_handle,command_buffer,7,100);
// 发送全部数据(0x40+1024字节)
i2c_master_transmit(dev_handle, frame_buffer,1025,100);
}
在600kHz时钟下,帧率来到了60.7Hz。再把I2C超频到800kHz,帧率最终来到73Hz。
其他优化方式
DeepSeek还提到了别的优化方式,比如分块传输,每次只传输需要更新的数据,而不是每次都传一整个屏幕。这种方法适合于大块连续的像素点的更新。实际比对发现,我的图片帧与帧之间有接近50%的像素点都需要更新。用分块传输还需要分开传每块的位置信息,得不偿失,所以没有采用这个方法。
4. 最终效果
这是实际显示的效果前后对比,可以看到Kei的泪痣原本是丢失的,在灰度模式下又能显示出来了,头发和衣服也出现了一些明暗对比。
单色
灰度
5. 最终效果Plus
后来偶尔发现,把黑白反色反一下,显示效果瞬间炸裂。
Kei1
Kei2