再探OLED:显示效果的极限

0. 前言

这是一个小插曲,最近看一部棒球漫画《失忆投捕》有点上头,正好为了射箭动作训练器打的板子回来了。虽然板子有点小问题,但是不妨碍拿它当开发板用,我可以拿它来做个《失忆投捕》的赛博周边。

用的显示屏是I2C接口的SSD1306驱动的0.96寸OLED屏,分辨率是128*64。这么小的屏幕要用来放hrk和kei的美照属实有点勉强,所以这篇文章正是在研究这块小屏的极限能做到什么效果。

这是原图,傻子状态下的要圭。

KeiOriginal

然后再截取并压缩到128*64,这是要用来显示的图。

KeiCrop


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,二值化后效果如图:

KeiBinary

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显示的效果如图: OLEDBW


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, &notification, 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的泪痣原本是丢失的,在灰度模式下又能显示出来了,头发和衣服也出现了一些明暗对比。

单色

OLEDBWSCALE

灰度

OLEDGS


5. 最终效果Plus

后来偶尔发现,把黑白反色反一下,显示效果瞬间炸裂。

Kei1

KEIINV

Kei2

KEI2INV


参考资料

  1. 聊聊SSD1306 OLED显示屏的灰度显示