书接很久以前的 这里. 最近机缘巧合又收了一个 10.2 寸的黑白红墨水屏价签. 很新, 屏幕保护膜都没撕, 又可见倒闭的公司库存.

ESL 10in2

可惜后盖上的贴纸不见了, 不知道是哪家公司的. 先拆拆看.

拆解

目前市面上大大小小价签很多种, 拆解价签的方法大同小异. 拆价签翻车的案例太多, 尤其大屏幕, 一旦翻车就是一顿外卖的损失. 所以拆解前一定要做好功课, 了解屏幕的结构, 以及拆解的方法. 卡扣一般会特别紧, 需要较薄的刀片, 但又不能太薄, 否则可能会受伤. 比如这个:

ESL Teardown Toolkit

另外加上软质塑料片, 这玩意文具店很多, 用来拆解屏幕背面和 PCB 的双面胶.

ESL Back Remoted

撬开后电池盖, 注意卡扣位置. 然后取掉电池. 方便之后取出 PCB.

然后就是拆卡扣, 从屏幕背面的一个开口处开始, 用刀片, 慢慢撬一圈卡扣, 注意不要伤到自己和屏幕边缘.

ESL Back Teardown Start

ESL Back Teardown Knot Removed

ESL Back Teardown Removing Tape

卡扣分离后, 这时候可以用软质塑料片, 伸进去, 慢慢拆开 PCB 和后盖的双面胶.

PCB 也是用双面胶贴在屏幕背后的, 这一步是最容易翻车的, 一定要慢慢来. 塑料片伸进去横向推, 一点一点的推开 PCB 和背板的双面胶.

ESL Back Teardown PCB Removed 1

ESL Back Teardown PCB Removed 2

PCB 丝印: Endor Telink1020 2021-07-22 change U5, 可见这是 BLOZI(保资) 的价签. 但是在今天, 他家官网都是挂的. 难道价签厂家也倒闭了…

24pin 屏幕排线丝印: HINK-E102A01-A1, 搜索可以找到全网唯一参考资料1, 虽然是个 CSDN, 但提供了不少线索, 里面说明了驱动 IC 是 SSD1677, 微雪 3in7 也使用了相同驱动 IC, 但那是一个黑白屏幕.

如果没有找到对应的驱动 IC, 那可能就需要参考 wenting 的方法逆向了. 请自行学习.

驱动

拆解完成后就可以尝试驱动了. 一般来说, 24pin 就是 AIO(All-In-One) 串口屏了, 驱动版都是通用的. 这里随便找了一个驱动板(咸鱼), 使用 RPi Pico(RP2040 MCU) 来驱动. 开发框架使用 Rust embassy.

Rust embedded-graphics 提供了非常方便的 Framebuffer API(注意, 没有屏幕旋转支持).

传统情况下, 我们只需要实现一个 Display trait, 就可以使用 embedded-graphics 的各种绘图 API 了. 但这里, 我们再抽象一级, 直接预留一个 update_frame(raw: &[u8]) 接口, 直接接收 Framebuffer::data() 作为参数. 这是在经历了 epd 项目之后, 尝试七八种屏幕之后发现最合适最通用的方法.

先写个骨架, 一切 SPI 串口 EPD 都可以这样搞, 唯一需要注意的是部分驱动 IC 的 BUSY 使用反逻辑:

struct EPD10in2<'a> {
    spi: Spi<'a, SPI0, Blocking>,
    dc: Output<'a, AnyPin>,
    busy: Input<'a, AnyPin>,
}

impl EPD10in2<'_> {
    fn send_command(&mut self, cmd: u8) {
        self.dc.set_low();
        self.spi.blocking_write(&[cmd]);
    }

    fn send_data(&mut self, data: &[u8]) {
        self.dc.set_high();
        self.spi.blocking_write(data);
    }

    fn send_command_data(&mut self, cmd: u8, data: &[u8]) {
        self.send_command(cmd);
        self.send_data(data);
    }

    pub fn busy_wait(&mut self) {
        loop {
            if self.busy.is_low() {
                info!("busy out");
                break;
            }
        }
    }

    // ...
    pub fn init(&mut self) {}
    pub fn update_frame(&mut self, raw: &[u8]) {}
    pub fn refresh(&mut self) {}
}

BWR 驱动 - 三色

微雪驱动使用了自定义黑白 LUT 和 4 阶灰度的 LUT 来驱动, 但个人经验是, 自定义 LUT 方法不适合三色 BWR 屏幕. 除非确定 LUT 来自厂家调教. 三色屏幕建议使用出厂的 OTP LUT(One-time-programming LUT). 这样可以保证屏幕的寿命, 也直接使用厂商调教过的颜色效果. 否则电子墨水屏在 LUT 表错误的情况下, 极容易永久性损坏, 例如我手头有若干永久性残影的屏幕.

当然, 的确是可以自己调教三色 LUT 逻辑, 相关论文有不少, 例如 Zeng, W.; Yi, Z.; Zhou, X.; Zhao, Y.; Feng, H.; Yang, J.; Liu, L.; Chi, F.; Zhang, C.; Zhou, G. Design of Driving Waveform for Shortening Red Particles Response Time in Three-Color Electrophoretic Displays. Micromachines 2021, 12, 578. https://doi.org/10.3390/mi12050578. 请沿着引文链自行探索.

一般来说, 三色墨水屏在墨囊黑白粒子之外额外加入了第三种颜色的粒子, 例如红色, 黄色. 彩色粒子的带电量和粘度(粒子物理运动特性)和黑色粒子可以通过较弱电压区分. 驱动过程大概是: 清屏, 激活(让黑色和彩色粒子尽可能分层而不是黏在一起), 然后利用较弱电压, 使得彩色粒子在屏幕上浮动, 形成彩色图像.

通读 SSD1677 的数据手册, 对照微雪的驱动代码, 找到核心修改点. 几乎所有 EPD 驱动 IC 的手册都是极其含糊, 这个也不例外. 其中一些关键词, 可能是需要你通读过其他同类型驱动 IC 才能理解.

SSD1677 LUT Mapping

SSD1677 有如上两种模式, 一种是 BWR 三色, 一种是黑白两色. 按照不同方式使用 LUT.

SSD1677 Display Update Control 2

SSD1677 Write Display Option

这两个含糊的 Command 描述文档, 隐藏了 BWR 驱动的细节.

  • Display Mode 1 即 BWR 三色模式, Display Mode 2 为黑白模式
  • Display Update Control 2 命令的 0x99 和 0x91 分别可以加载不同模式的 OTP LUT
    • 0xC7, 0xCF 决定了最终刷新使用的 Display Mode
  • Write Display Option 命令中设置了 WS(LUT) 不同时间片对应的 Display Mode

那么这里, 我们直接加载 Display Mode 1 的 OTP LUT, 然后使用 Display Update Control 2(with Display Mode 1) 命令刷新.

由此修改 init() 函数:

    pub fn init(&mut self) {
        self.send_command(0x12); // Soft reset
        Delay.delay_ms(20_u32);

        self.send_command_data(0x46, &[0xF7]);
        self.busy_wait();

        self.send_command_data(0x47, &[0xF7]);
        self.busy_wait();

        // Driver output control
        // 0x27F = 639
        self.send_command_data(0x01, &[0x7F, 0x02, 0x00]);

        // set gate voltage
        self.send_command_data(0x03, &[0x00]);
        // set source voltage
        self.send_command_data(0x04, &[0x41, 0xA8, 0x32]); // POR

        // set data entry sequence
        self.send_command_data(0x11, &[0x03]);

        // set border
        self.send_command_data(0x3C, &[0x03]);

        // set booster strength
        self.send_command_data(0x0C, &[0xAE, 0xC7, 0xC3, 0xC0, 0xC0]);

        // set internal sensor on
        self.send_command_data(0x18, &[0x80]);

        // set vcom value
        self.send_command_data(0x2C, &[0x44]);

        // setting X direction start/end position of RAM
        // 640 -> 639 => 0x27F
        // 960 -> 959 => 0x3BF
        self.send_command_data(0x44, &[0x00, 0x00, 0xBF, 0x03]);
        self.send_command_data(0x45, &[0x00, 0x00, 0x7F, 0x02]);
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command_data(0x37, &[0x00; 10]); // Use Mode 1 !!!

        // Load Waveform !!!
        // 0x91, Load LUT with Mode 1
        self.send_command_data(0x22, &[0x91]);
        self.send_command(0x20);
        self.busy_wait();

        // Display Update Control 2
        self.send_command_data(0x22, &[0xCF]);
    }

除了屏幕大小设定的修改, 最核心的用 !!! 标注.

补齐写 RAM 函数和屏幕刷新函数:

    pub fn update_bw_frame(&mut self, buf: &[u8]) {
        // self.send_command_data(0x4E, &[0x00, 0x00]);
        // self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command(0x24);
        self.send_data(buf);
    }

    pub fn update_red_frame(&mut self, buf: &[u8]) {
        // self.send_command_data(0x4E, &[0x00, 0x00]);
        // self.send_command_data(0x4F, &[0x00, 0x00]);

        self.send_command(0x26);
        self.send_data(buf);
    }

    pub fn refresh(&mut self) {
        let mut delay = Delay;
        self.send_command(0x20); // Master activation
        delay.delay_ms(100_u32); //must
        self.busy_wait();
    }

在屏幕大小正确设定的前提下, 0x4E/0x4F(RAM 当前 X/Y) 可以只写一次, 之后就不需要了, 自动增长.

为了测试效果, 我们找一张 LLM 生成的图. 由于屏幕是 960x640, 而一般 LLM 生成的图是正方形, 需要进行缩放. 这里可以使用 Context Aware Image Resizing(CAIR) 算法, 或者传统直接缩放.

考虑到我们的屏幕只有三种颜色, 无法体现图片的丰富彩色和灰度细节, 需要使用抖动 (Diffusion Dithering) 来模拟灰度. 然后提取 BW frame 和 Red frame, 分别写 RAM. 相关任务可以通过 ImageMagick 完成:

convert ~/Downloads/_34f9c9ae-d1c2-47a9-ab55-089ffc7cb626.jpeg -size 960x640 -dither FloydSteinberg -remap 3color.gif out.gif

这里 3color.gif 是只包含红黑白三色的索引色图, 用来提供调色板. out.gif 是经过抖动处理的图片.

之前突然想到, Rust 的过程宏不就是在编译期执行的吗? 于是就写了个过程宏, 用来自动加载图片, 提取 BW/Red frame. 项目在 text-image.

全部刷新大概需要 20 秒左右, 最终效果如下:

BWR Image with Dithering

BW 驱动 - 黑白双色

通过前面的描述, 其实大家都会发现, 首先 SSD1677 本身就支持三色和双色驱动两种模式, 且三色屏幕的彩色粒子和黑色粒子如果一同处理, 那完全是可以把三色屏幕当做双色屏幕来驱动的.

方法1: 依然使用三色模式, 只不过 RED RAM 永远置空. 这是最简单的, 不需要修改任何代码, 但需要忍受长大 20 秒的刷新时间.

方法2: 启用驱动 IC 的双色模式?

这里主要介绍双色模式, 它的好处是, 刷新速度可以调教到更快, 并且有可能支持灰度显示, 以及快速局部刷新. 黑白双色模式最主要的是驱动像素到新的状态, 需要拿到前一状态和目标状态, 然后执行对应的波形. 这需要驱动 IC 有对应的支持.

这里是驱动 IC 手册中含糊没有介绍清楚的部分:

SSD1677 LUT

可见 LUT 表和 上一篇文章 中的类似, 但似乎缺乏核心的 “AB” 概念? 其实不然. 我们大致按照 Display Mode 1 整理得到格式是:

  • VS - L0(LUT0) - for BLACK
  • VS - L1(LUT1) - for WHITE
  • VS - L2(LUT2) - for RED (R=1, B/W=0)
  • VS - L3(LUT3) - for RED (R=1, B/W=1), LUT3=LUT2
  • VS - L4(LUT4) - reserved
  • TP / RP - time period / repeat
  • FR - Frame Rate

而按照 Display Mode 2, 经过测试, 发现 LUT 是:

  • VS - L0(LUT0) - Black to Black
  • VS - L1(LUT1) - Black to White
  • VS - L2(LUT2) - White to Black
  • VS - L3(LUT3) - White to White
  • (其他部分一致)

可见, 这里其实是有新旧 AB 转换的概念的, 实测发现在 Display Mode 2 下, 0x24 B/W RAM 表示当前(目标/NEW)显示状态, 0x26 Red RAM 则是上一(OLD)状态. 那么看起来就可以实现快速刷新了. 只需要我们在写入新内容到 NEW RAM 同时, 把旧内容写入 OLD RAM. 驱动 IC 将自动使用 OLD/NEW 信息执行对应的波形, 完成像素位的状态转换.

那么是不是有种方法可以让驱动 IC 自动完成这个过程呢? 答案是肯定的. 这个功能在不同驱动 IC 的叫法不同, 比如在 UCxxxx 系列手册中, 叫做 N2OCP (New to Old Copy), 在 SSD1677 中, 叫做 “RAM ping-pong”.

翻看上面 0x37 Write Display Option 命令的 F[6] 位, 可以看到 “RAM Ping-Pong for Display Mode 2” 的描述. 且同时告知, 只有 Display Mode 2(黑白双色) 支持这个功能.

简单对 init() 函数做修改, 这里只贴出修改的部分:

    pub fn init(&mut self) {
        // ....

        // Display Option
        #[rustfmt::skip]
        self.send_command_data(0x37, &[
            0x00,
            0xFF, //B
            0xFF, //C
            0xFF, //D
            0xFF, //E
            // 0x0F, RAM ping-pong disable
            // 0x4F, RAM ping-pong enable
            0x4F, //F, RAM ping-pong enable. only in Display Mode 2
            0xFF, //G
            0xFF, //H
            0xFF, //I
            0xFF, //J
        ]); // MODE 2

        self.send_command_data(0x22, &[0x99]); // Load LUT with Mode 2
        self.send_command(0x20);
        self.busy_wait();

        // Display Update Control 2
        self.send_command_data(0x22, &[0xCF]);
    }

废话不多说, 我们编写一张快速刷新的波形, 前置条件是需要屏幕是纯白的, 也就是刚刚清屏后的状态. 这可以通过 OTP LUT 实现. 这里只关注我们需要的黑色和白色状态.

  • 对于 Black to Black 和 White to White, 什么都不做
  • Black to White, 加 VSL 电压, 若干周期
  • White to Black, 加 VSH 电压, 若干周期

经过测试, 我们得到了如下 LUT, 简洁到惊人. 0x0F 是个人测试的值, 即 15 个周期. RP=0 表示只重复一次. 相关内容和上一篇文章介绍的大同小异.

    pub fn configure_partial_update(&mut self) {
        #[rustfmt::skip]
        const LUT: &[u8] = &[
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
            0b10_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
            0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT5, reserved
            // TP[xA, xB, xC, xD], RP
            0x0F,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//7
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//9
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            // FR
            0x22,0x22,0x22,0x22,0x22
        ];
        self.send_command_data(0x32, LUT);
    }

而此时只需要写入 0x24 B/W RAM, 就可实现快速刷新, 实测大概 1s 左右即可完成刷屏. 且无闪动.

BW 驱动 - 灰度大法

看着 0x0F 这个时间周期值, 是不是手痒. 没错, 我们可以尝试调整这个值, 从而实现灰度显示. 灰度显示对于阅读器类应用意义重大, 矢量字体的抗锯齿渲染, 以及图片的灰度显示, 都可以大大提升用户体验.

这里假设我们需要实现 16 级别灰度, 正好对应 0x00 ~ 0x0F.

  • 可以刷屏 16 次, 每次刷入 1/16 的灰度层次, 朴素但是可行
  • !! 刷屏 4 次, 分别是 8, 4, 2, 1 个周期, 从而实现 16 级灰度, 相当于只用了 4 倍刷新时间

废话不多说, 直接上代码:

    /// Level 0 to 15
    fn configure_gray_update_level(&mut self, level: u8) {
        #[rustfmt::skip]
        let lut: &[u8] = &[
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT0, B2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT1, B2W
            0b01_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT2, W2B
            0b00_00_00_00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//LUT3, W2W
            0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,//5
            // TP[xA, xB, xC, xD], RP
            level,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//7
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,//9
            0x00,0x00,0x00,0x00,0x00,
            0x00,0x00,0x00,0x00,0x00,
            // FR
            0x22,0x22,0x22,0x22,0x22
        ];
        self.send_command_data(0x32, lut);
    }

    pub fn refresh_gray4_image(&mut self, buf: &[u8]) {
        for level in (0..4).rev() {
            // level: (8, 4, 2, 1)
            self.configure_gray_update_level(1 << level);
            self.send_command(0x24);

            for chunk in buf.chunks(4) {
                let mut n = 0;
                for b in chunk {
                    if b & (0x10 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (1 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                }
                // 0xFF is white, 0x00 is black
                self.send_data(&[n]);
            }

            self.refresh();
        }
    }

    pub fn refresh_gray2_image(&mut self, buf: &[u8]) {
        for level in [1, 0] {
            // level: 9, 5
            self.configure_gray_update_level(1 << (level + 2) + 1);
            self.send_command(0x24);

            for chunk in buf.chunks(2) {
                let mut n = 0;
                for b in chunk {
                    if b & (0b01_00_00_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_01_00_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_00_01_00 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                    if b & (0b00_00_00_01 << level) != 0 {
                        n = (n << 1) | 1;
                    } else {
                        n = n << 1;
                    }
                }
                // 0xFF is white, 0x00 is black
                self.send_data(&[n]);
            }

            self.refresh();
        }
    }

以上代码中, refresh_gray4_imagerefresh_gray2_image 分别是 4 级和 2 级灰度的刷新函数. 这里对灰度的每一位的权重映射做了微调. 格式兼容 embedded-graphics 中的 Framebuffer 和 Image 类型, 可以直接使用. 其中的 bit 操作, 其实基本上是 Github Copilot 写的, 我只是稍微修改了下边界情况和编译错误, 实现不是最优, 但好理解.

显示效果, 这里以互联网 UGC 时代的化石, 徐静蕾手写体为例:

Gray4 for Font Renderring

你猜我抗锯齿字体怎么渲染的? 没错, 还是 text-image 过程宏, 使用方法:

let (w, h, raw) = text_image::text_image!(
    text = "北京市发布持续低温黄色预警\n北京市发布道路结冰橙色预警\n\n-12.5℃ \n-16℃ -- -7℃\n相对湿度 36%\n东北风 1级",
    font = "./徐静蕾手写体.ttf",
    font_size = 48.0,
    line_spacing = 0,
    inverse,
    Gray4,
);

info!("w: {}, h: {}", w, h);

epd.set_partial_refresh(Rectangle::new(Point::new(128, 160), Size::new(w, h)));
epd.refresh_gray4_image(raw);

当然这里用到了 “局部刷新” 函数, 我们这就介绍.

局部刷新

局部刷新是指, 只刷新屏幕的一个矩形部分, 而不是整屏刷新. 这在阅读器类应用中, 是非常重要的功能. 包括弹出式菜单, 局部 UI 元素等都可以用到. 在日历天气中, 局部刷新可以只刷新时间或天气的一小部分, 而不是整屏刷新, 从而大大提升观感.

实际上局部刷新只需要找驱动 IC 手册中的 RAM X/Y Start/End 关键词即可, UCxxxx 系列的驱动 IC, 也会直接提供 Partial Update 的相关命令. 这里一笔带过直接上代码:

    fn clear_as_bw_mode(&mut self) {
        // set X/Y ram counter
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);

        const NBUF: usize = 960 * 640 / 8;
        self.send_command(0x24);
        for i in 0..NBUF {
            self.send_data(&[0xFF]); // W
        }

        // reset X/Y ram counter
        self.send_command_data(0x4E, &[0x00, 0x00]);
        self.send_command_data(0x4F, &[0x00, 0x00]);
        self.send_command(0x26);
        for i in 0..NBUF {
            self.send_data(&[0xFF]); // Red off
        }
    }
    pub fn set_partial_refresh(&mut self, rect: Rectangle) {
        // clear old buf
        self.clear_as_bw_mode();
        let x0 = (rect.top_left.x as u16);
        let x1 = rect.bottom_right().unwrap().x as u16;
        let y0 = rect.top_left.y as u16;
        let y1 = rect.bottom_right().unwrap().y as u16;

        self.send_command_data(0x44, &[(x0 & 0xff) as u8, (x0 >> 8) as u8, (x1 & 0xff) as u8, (x1 >> 8) as u8]);
        self.send_command_data(0x45, &[(y0 & 0xff) as u8, (y0 >> 8) as u8, (y1 & 0xff) as u8, (y1 >> 8) as u8]);
        // set X/Y ram counter
        self.send_command_data(0x4E, &[(x0 & 0xff) as u8, (x0 >> 8) as u8]);
        self.send_command_data(0x4F, &[(y0 & 0xff) as u8, (y0 >> 8) as u8]);
    }

需要注意的是:

  • 在开启局部刷新前需要处理好 OLD/NEW RAM 的状态, 这里选择直接清空
  • X/Y RAM counter 需要设置正确
  • 部分驱动 IC 要求 gate 或 source 边界以 8 对齐, 需要参考手册, 并提前对图片进行裁剪补齐

结语

这里只是简单介绍了如何驱动这块价签, 以及如何实现局部刷新和灰度显示. 其中若干技术可以混用, 例如局部刷新 + 灰度显示, 局部刷新 + 三色显示等等. 屏幕也可以在不同状态下重新初始化, 最终实现的效果, 取决于你的想象力.

原理有了, 剩下的就是点子了. 之前做了一个 Bing Image Creator 加随机每日诗词的小工具. 效果还不错, 但一堆粗糙脚本未整理.

一些可能用到的技术再提一遍:

  • Context Aware Image Resizing(CAIR) - PhotoShop 或一些图形学算法库
  • Diffusion Dithering - ImageMagick / PhotoShop
  • 各种 Text to Image 模型
  • 我的 text-image 过程宏, 粗糙, 但支持各种图片加载, 字体抗锯齿渲染
  • Rust Embedded 我推荐 embassy 框架, 至少它够统一, 而且是 async-first 的
    • Rust embedded-graphics 提供了 Framebuffer API, 以及各种图形绘制 API
  • 本项目粗糙代码, 未整理 commit