屏幕不够,算法来凑(二):ESP32 单色屏上的 Ditherpunk 实战
0x00 序
在上一篇文章中,我们在浏览器中通过 JavaScript 模拟了各种抖动算法的视觉效果。虽然原理通透了,但真正的挑战在于硬件端:如何在资源受限的嵌入式设备上复现这些效果?
本文将记录我基于 ESP32-S3 和一块 1.54 寸 ST7305 单色屏的实战过程,探讨如何在单片机上实现从基础的阈值法到复杂的误差扩散等多种图像处理算法。
0x01 硬件环境
- MCU: ESP32-S3 (当然,普通的 ESP32 甚至 ESP8266 也完全足以胜任)
- 屏幕: 1.54 inch Monochrome Display 鱼鹰光电单色屏幕
- 驱动: ST7305 (这种控制器也常见于一些黑白双色或黑白红三色的小尺寸墨水屏)
- 分辨率: 200 x 200 (1-bit Monza, 纯黑白)
选择 ESP32-S3 主要是因为其内置的大容量 SRAM(512KB+)和对 PSRAM 的支持,让我们在处理 200x200(只要 5KB)甚至更高分辨率的图像缓冲区时游刃有余。而 ST7305 是一款主要用于点阵电子纸显示器的驱动芯片,通过 SPI 接口通信。
0x02 核心挑战
在 PC 网页端,我们有 Float32Array 和近乎无限的内存。但在 ESP32-S3 上,我们需要关注:
- 内存管理:尽量复用 Buffer,避免频繁
malloc/free产生内存碎片。 - Gamma 校正:必须在 C 语言中手动实现 Gamma Table,否则画面会严重偏暗。
- 驱动适配:屏幕通常需要较为复杂的 SPI 初始化序列,特别是针对不同的屏幕玻璃面板,需要配置正确的电压和驱屏波形。
0x03 代码实现详解
项目工程结构如下:
1 | main/ |
建立 Gamma 查找表 (LUT)
正如第一篇所述,这是最关键的一步。如果不做 Gamma 校正,线性空间的抖动算法处理 sRGB 图片时,中间调会偏暗。由于 powf 函数在嵌入式上计算非常昂贵,我们绝对不能对每个像素实时计算。为了性能,我们在启动时预计算一个 256 长度的查找表。
1 | // Gamma correction LUT (0-255 -> 0.0-1.0 linear) |
任何后续的像素读取,都通过 s_gamma_lut[pixel] 来获取其线性亮度值。这是一种经典的空间换时间策略。
基础算法:Threshold & Random
最简单的算法往往是很好的基准线。
Threshold (阈值法) :
也就是二值化。这是最快的方法,但也是效果最差的方法。
1 | void dither_threshold(const uint8_t *src, uint8_t *dst) { |
实拍效果:细节大量丢失,俗称“两色图”。它只能保留最硬的边缘,几乎完全丢失了所有的灰度信息。
Random (随机抖动)
为了找回丢失的灰度,我们引入 rand() 来打破量化阶梯。通过给每个像素值增加一个随机噪声,使得原本在阈值附近的像素有一半概率翻转,从而宏观上表现出灰度。
1 | float noise = ((float)rand() / RAND_MAX) - 0.5f; // -0.5 to 0.5 |
实拍效果:虽然有灰度感了,但画面非常脏,充满了白噪声。这种高频噪声在人眼看来就是“雪花点”,并不讨喜。
有序抖动:Bayer Matrix
有序抖动非常适合没有 Framebuffer 的超低端单片机(如 Arduino Uno, ATtiny85),因为它是 Point-Operation(点操作),不需要知道邻居像素的信息,也不需要存储上一行的误差。我们可以对每个像素独立计算。
1 | static const float s_bayer_matrix[4][4] = { |
实拍效果:经典的十字交叉网纹。这种风格在复古 GameBoy 游戏和早期的 Macintosh 系统中非常常见。它通过规则的纹理来模拟灰度,虽然看起来有点“人工”,但比随机抖动干净得多。
误差扩散:Floyd-Steinberg & Atkinson
对于支持 Framebuffer 的设备(ESP32 有足够 RAM),误差扩散是最佳选择。你需要一个浮点数 Buffer 来存储扩散过程中的误差。
Floyd-Steinberg 是教科书般的标准,扩散系数为 7, 3, 5, 1 (/16)。它试图将量化产生的每一个误差都完美地分配给邻居,从数学上讲是最精确的。
实拍效果:
Atkinson 则更适合这种高解析度但低色深的屏幕。它由 Bill Atkinson 设计,不像 Floyd-Steinberg 那样保留 100% 的误差,而是只保留 75% 的误差用于扩散,这就人为制造了一些“死黑”和“死白”区域。这听起来像是缺点,但在低对比度的单色屏上,但这反而增加了局部对比度,让图像看起来更清晰锐利,减少了“蠕虫”伪影。
1 | // Distribute 1/8 to neighbors (Atkinson) |
实拍效果:这是我在这块屏上最喜欢的算法。线条硬朗,质感极佳,特别适合显示文字和 icon 混合的 UI 界面。
别有风味:Blue Noise (蓝噪声)
如果在嵌入式设备上想要模拟胶片感,蓝噪声是唯一的选择。它的计算成本和 Bayer 一样低(只需要查表),但效果却能通过“排列无序但分布均匀”的噪点来欺骗人眼。
我们需要将一张预计算好的蓝噪声纹理转换成 C 数组 (blue_noise.h) 存储在 Flash 中。这意味着你需要牺牲几十 KB 的 Flash 空间来换取这种效果。
1 | // Map 0-255 texture to 0.0-1.0 |
实拍效果:非常自然的颗粒感,没有任何规律性条纹,就像一张老照片。这种算法特别适合显示人像和风景摄影。
0x04 总结
在 ESP32 这种级别的 MCU 上,我们完全可以实现高质量的即时图像抖动。不同的算法适用于不同的场景:
- Bayer 有序抖动:计算开销最小,适合资源极端受限(如不带 RAM 的低端单片机)或复古风格游戏。
- Atkinson:对比度最高,边缘清晰,适合 UI 界面、文字混排场景。
- Blue Noise:颗粒感自然,适合显示摄影图片,但需要额外的 Flash 空间存储纹理。
希望本文能为你在单色屏幕开发中提供一些思路。
0x05 参考资料
- Kevincoooool/esp_lcd_st7305 - 本文 ST7305 驱动移植参考
- DuRuofu/esp-idf-st7305-Ink-screen - 另一个优秀的 ST7305 驱动实现
屏幕不够,算法来凑(二):ESP32 单色屏上的 Ditherpunk 实战
https://chaosgoo.com/2025/12/30/Ditherpunk-The-Art-of-Dithering-2-ESP32/


