# 再探OLED:显示效果的极限 ## 0. 前言 这是一个小插曲,最近看一部棒球漫画《失忆投捕》有点上头,正好为了[射箭动作训练器](../cat3_archery/gyro_griphand.html)打的板子回来了。虽然板子有点小问题,但是不妨碍拿它当开发板用,我可以拿它来做个《失忆投捕》的赛博周边。 用的显示屏是I2C接口的SSD1306驱动的0.96寸OLED屏,分辨率是128*64。这么小的屏幕要用来放hrk和kei的美照属实有点勉强,所以这篇文章正是在研究这块小屏的极限能做到什么效果。 这是原图,傻子状态下的要圭。 ![KeiOriginal](kei_original.jpg) 然后再截取并压缩到128*64,这是要用来显示的图。 ![KeiCrop](kei2.jpg) --- ## 1. 二值化 截取后的图是一张灰度图片,但是OLED屏是单色的,也就是只有“亮”和“不亮”两个状态,没法显示中间的灰色,所以需要先把图片转成纯黑白。 ### LVGL转换工具 最简单的方法还是利用[这篇文章](esp-idf-oled.html)里提到的LVGL转换工具,转换处理后仍然是灰度图也没事,LVGL在ESP32上的实际显示效果确实是黑白的。然而,这样显示出来的黑白图片效果不够好,因为二值化的阈值不一定合适。有些地方,例如鼻子,相比周围区域显然是要保留的,但是简单二值化就会被判为白色,另一些地方,比如头发,又保留太多线条显得凌乱。 ### 使用OpenCV自定义阈值 DeepSeek给出了利用OpenCV二值化的代码,其中阈值是可以自定义的。 ``` python 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](output_binary.jpg) OpenCV也支持一些更高级的二值化算法,比如自适应阈值,但这个例子里实际效果改善不明显,因此仅供参考。 ``` python # 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](oled_bw.jpg) --- ## 2. PWM实现灰度 二值化毕竟会丢失灰度信息,有什么办法让单色屏也能显示出灰度呢?有,利用PWM。只要屏幕以足够快的速度不停地一开一关,人眼就会以为显示的是灰色。灰色的深浅由开和关所占的比例控制。 ### 跳过LVGL 为了能控制灰度,我需要能直接控制每一帧下,每个像素的开关状态。因此要跳过LVGL这层封装,直接与SSD1306通信。Deepseek总结的关键点如下: * 显示缓冲区:SSD1306的显示内存是按页组织的,每页8行像素。128x64的屏幕需要128x8字节的缓冲区。 * 像素映射:每个字节代表垂直的8个像素(1位=1像素),LSB对应顶部像素,MSB对应底部像素。 * 内存地址模式:示例中使用的是页地址模式,你也可以使用水平或垂直地址模式。 * 性能优化:对于频繁更新,可以只更新屏幕的部分区域,而不是整个屏幕。 * SPI支持:如果你的SSD1306使用SPI接口,需要修改通信部分的代码。 因为仅仅是跳过了LVGL层,SSD1306的底层驱动仍然是用的ESP-IDF写好的。参考例程,用这几个函数初始化屏幕: ``` c 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个像素。 ```c uint8_t frame_buffer[EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 8] = {0}; ``` 然后是和阈值做比较的步骤: ``` c #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。 ``` c 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)`循环里加入定时器来实测帧率。 ``` c 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,但是软件修改又不会爆炸,理论上只要波形还没有失真就可以超频。 ``` c 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。 ### 预渲染 虽然瓶颈在于传输速度,但是图形的计算仍然是消耗了时间的。比起双缓冲,因为我的图片和阈值都是固定的,可以事先把比大小这步做完保存下来。用空间换时间,实现起来也很简单。 ``` c 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表示后面一个字节跟着的是命令。 ```c 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的作用是表示后面跟着的是数据。把命令和数据一口气全发出去可以减少握手的开销。 ``` c 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](oled_bw_scale.jpg) **灰度** ![OLEDGS](oled_gs.jpg) --- ## 5. 最终效果Plus 后来偶尔发现,把黑白反色反一下,显示效果瞬间炸裂。 **Kei1** ![KEIINV](kei_inv.png) **Kei2** ![KEI2INV](kei2_inv.png) --- ## 参考资料 1. [聊聊SSD1306 OLED显示屏的灰度显示](https://bbs.eeworld.com.cn/thread-1284380-1-1.html)