在 MicroPython 中使用自定义格式的点阵字体文件绘制文字(OLED 屏)

Pader2022年3月14日 发表于 系统与硬件 MicroPython

一直以来对硬件也是比较感兴趣的,前段时间正好有一个契机买了块开发板来玩,板子是乐蕊的 ESP32-C3,然后通过 I2C 接口接了温湿度传感器和 128x64 分辨率的 OLED 屏幕,刷的是 MicroPython 固件,先做一个带天气功能的小时钟来练练手。

一般我们在开发传统的桌面、网页、移动端应用时,最基础的显示层实现往往都是现成的,只需简单的把内容放到对应层就可以了,但在做这类比较底层的硬件开发的时候,因为板子的性能非常基础,所以基础的系统并没有非常复杂的封装,为了实现自己想要的效果,我花了不少时间在做 OLED 屏的文字显示方面的研究和优化实现。

首先看一下最基础的需求,就是能够显示自定义的字体,能够显示中文,再深入一点就是能够中英文不同宽度的字体混排,自动换行等等,这些看似无比简单的功能在这种开发板上却是相当的复杂。

在网上搜对应的关键字会发现有大量的实例,但大多是零散的代码,有不少照搬也不能用。所以在经过深入研究之后有了此文描述我在 MicroPython、OLED 屏的文字显示实现和优化过程。

传统的做法,在代码中保存点阵数据。

如果你搜索并看过一些文章就会发现,MicroPython 上基本上都是使用字模软件取到所谓的“字库”,将这些字库代码放到我们的代码中,往往都像下面一样:

[0xE3,0x41,0x41,0x22,0x22,0x22,0x22,0x14,0x14,0x14,0x08,0x08,0x08,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],#V0
[0x0E,0x11,0x20,0x40,0x40,0x40,0x40,0x41,0x40,0x40,0x20,0x30,0x0F,0x80,0x80,0x80,0x80,0x00,0x00,0x00,0xC0,0x80,0x80,0x80,0x80,0x00],#G1
[0x1C,0x63,0x41,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x41,0x63,0x1C,0x00,0x00,0x00,0x80,0x80,0x80,0x80,0x80,0x80,0x80,0x00,0x00,0x00],#O2
[0x7F,0x88,0x88,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x1C,0x00,0x80,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],#T3
[0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],#.4
[0xE3,0x61,0x51,0x51,0x51,0x49,0x49,0x45,0x45,0x43,0x43,0x43,0xE1,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],#N5
[0xFF,0x41,0x40,0x42,0x42,0x7E,0x42,0x42,0x40,0x40,0x40,0x41,0xFF,0x00,0x00,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x00,0x00],#E6
[0x7F,0x88,0x88,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x08,0x1C,0x00,0x80,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00],#T7

以上是 VGOT.NET 这几个字在 10x13 大小时的字体点阵数据。

那么这里的字模数据到底是什么呢?绘制的原理是什么?

我们首先看一下字模软件的设置,这里使用的是 PCtoLCD2002 取模软件,它的配置如下图:

这里使用的是行列式,顺向,十六进制的方式,在右下角展示了取模的过程,其取模是这样描述的:

从第一行开始向右取8个点作为一个字节,然后从第二行开始向右取8个点作为第二个字节...依此类推。如果最后不足8个点就补满8位。  取模顺序是从高到低,即第一个点作为最高位。如*-------取为10000000

也就是说从左上角开始,从左到右每8个点为一份数据(一个字节),然后往下到底为第一列,再从第二行右侧读第二列,如果后面的不够8个点,就往后补零补满8个点。

我们以字符 V 来做示例:

把其中的显示的像素作为 1,不显示的像素作为 0,按图中从上到下有两列的二进制数据,然后全部连到一块,如下:

[11100011, 01000001, 01000001, 00100010, 00100010, 00100010, 00100010, 00010100, 00010100, 00010100, 00001000, 00001000, 00001000, 10000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000, 00000000]

把它们全部转换为十进制为:

[227,65,65,34,34,34,34,20,20,20,8,8,8,128,0,0,0,0,0,0,0,0,0,0,0,0]

如果转为十六进制,前缀 0x 就是:

[0xE3,0x41,0x41,0x22,0x22,0x22,0x22,0x14,0x14,0x14,0x08,0x08,0x08,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00]

这就是点阵字库的代码了,绘制时只要要按原样读取出二进制数据,就能得知每个点的开关状态,将这些开关状态绘制到屏幕相应位置的像素点上,就能完整的绘制出整个文字了。

我使用以下代码进行单个字符的绘制,注意这里首先要知道字符的高度,因为是从上到下读,每个单位代表 8 个点的数据,而每相同于字体高度的数据相当于一列,上面的例子则是13个元素为一列,再13个元素为下一列:

# 逐行绘制
for y in range(self.font_height):
    top = y_axis + y
    # 列行式,顺向读取方式。每一列后方跟的是下一列,所以 (行 + 列 * 字体高) 就代表所以行的下一列。
    for col in range(self.cols):
        b = bits[y + col * self.font_height]
        for x in range(8):
            # 从左读取位判断是否为1
            if b << x & 0x80:
                framebuffer.pixel(x_axis + x + col * 8, top, color)

这里的代码是我从网上Copy并修改而来,做了些优化,主要是直接使用位运算的方式从左到右判断每个点的状态,避免了将字模数据先转换为二进制字符串格式再逐个字符对比的的低效方式。

这就是传统的做法了,将代码保存到我们的程序文件中,将字符排重,以字符本身作为 Map 的索引,后面跟上点阵数据即可。

将字体保存在 MicroPython 自带的 Btree 数据文件中

在使用传统的做法后,后面又发现了一些问题,当我要放入的字符比较多的时候,代码就显的特别多,维护麻烦,而且因为使用的是 MicroPython,Python 代码在运行前要先将代码转换为字节码供虚拟机执行,这个初次加载转换的过程(加载字库的时候)就会有明显的卡顿,而且这么大一坨数组放在代码中也是相当的占用内存,对于开发板这种小资源的东西,内存可谓是寸土寸金,稍微一不注意就会内存不足。

大量的字库代码

我这里经过一些研究,用了两个方法降低了字节码的转换过程和内存的占用。

一是将所有 .py 文件使用 mpy-cross 转换为了 mpy 字节码文件,直接将这些 mpy 文件放进开发板,省去了启动时的转换过程,明显的缩短了启动和加载时间。

虽然免去了 .py 文件转换字节码的时候,但是在运行过程中的字库内存占用却依然不能省去,所以第二点我取了一个比较讨巧的办法,直接使用 MicroPython 自带的 btree 库,这是一个基于 BerkelyDB 实现的二叉树文件键值数据库,我直接将字符作为 K,字符的点阵数据转换为二进制作为 V 存进 btree 数据文件中,而且 btree 库本身实现了类似词典的访问机制,只要直接 db['V'] 就可以取出 V 字符的点阵数据了,btree 的查找也是非常高效,这样字库就可以存到单独的文件中,不用占用内存了,感觉是相当的好。

将字模数据写入 btree 的文件的代码:

import struct
import btree

# 将 python 字体代码保存为 btree 二进制字体文件
# 将 fonts 目录内的 python 字体文件覆盖到下方并运行即会在开发板 fonts 目录中生成对应的 font 文件

font_name = 'LucidaSans'
font_width = 6
font_height = 12
font_map = {
    ' ': [0,0,0,0,0,0,0,0,0,0,0,0],# 0
    '!': [0,0,32,32,32,32,32,32,0,32,0,0],#!1
    '"': [0,80,80,80,0,0,0,0,0,0,0,0],#"2
    # ... 更多的字符
}

f = open('fonts/%s.font' % font_name, 'wb')
db = btree.open(f, pagesize=512)

for i in font_map:
    bits = struct.pack('B' * len(fonts_map[i]), *fonts_map[i])
    db[i] = bits

# 将宽高保存到 ~~ 键中
db[b'~~'] = struct.pack('BB', font_width, font_height)

db.flush()

db.close()
f.close()

读取 btree 的代码:

class BtreeFont(MonoFont):

    def __init__(self, name):
        import btree
        self.fp = open('fonts/%s.font' % name, 'rb')
        self.db = btree.open(self.fp, pagesize=512)
        w, h = struct.unpack('BB', self.db['~~'])
        super().__init__(w, h)

自定义字库格式

虽然使用 btree 存储字库的方式已经看似很好了,但是后面仍然碰到一些麻烦:

  1. btree 库只在 MicroPython 固件中,每次生成 btree 字库要先用字模生成代码,再将代码放进 MicroPython 代码中上传到开发板来执行生成字库,太麻烦。

  2. 开发板性能很弱,btree 虽然读取高效,但是因为实现相对简单,写入并不高效,因为内部要对索引进行分页,排序等处理,所以字符一旦多起来,生成字库的速度就会呈指数级下降,在我后面做另一个项目时,因为要写入两千个字符,等的时间非常长,而且因为内存原因总是失败。

  3. btree 虽然读取的非常快,但因为其是可改动的,并且内部使用页的方式去维护数据,所以难免会存在空闲的区间,MicroPython 并没有提供太多的配置和方法去调整这一块的参数(只有 pagesize),所以 btree 仍然会使用不少的额外空间,要知道开发板的存储空间也很宝贵。

所以定义一套即高效又精简的字库就在我心头萌生了。

在这里我要达到以下几点:

  1. 生成字库要简单,尤其是生成大量字符的字库时不要那么麻烦。

  2. 字库要能直接在电脑上生成,不要依赖于固件内部的功能,要能把电脑上生成好的字库文件直接传到开发板中就能使用。

  3. 字库的读取性能要高效,得像 btree 一样那么高效,不能动不动就来一个全文件的扫描。

  4. 字库文件要精简,不要有任何额外的冗余的空间占用,最好每一个字节都拿来用。

在经过一定的构思后,想法大至如下:

首先是文件的格式,文件内部分为三个区域,分别是信息区(存储字体名,字体宽高等描述数据),索引区(用于查找字符位置的索引区,使用二分法查找,按顺序排列的定长字符),字符点阵数据区(与索引一样顺序的点阵数据区)。

这里要求每个字库下的字符都是定长的,比如都是 utf-8 占三个字节长度,普通英文 ASCII 占一个字节长度,只有单个字符定长了才能方便进行二分法查找。

字库包含的字符长度不一定,所以索引、数据区的长度也不一定。

因为上面的原因我需要在字体区信息区里存储至少字体宽、高、单个字符在索引中的长度、以及整个索引或数据区的长度或开始位置,然后再加上一个字体的名称,最终格式如下:

| 20个字节字符串为字体的名称 | 1字节 unsigned char 为字体宽 | 1字节 unsigned char 为字体高 | 2 字节 unsigned short 为包含的字符数量 | 4 字节 unsigned int 为点阵数据开始位置 |
| 紧跟着所有相同长度(1字节每个英文或3字节每个UTF-8中文)的从低到高排序字符作为二分查找索引 | 从点阵数据开始位置紧跟着所有点阵数据,顺序与索引相同 |

索引长度 = 点阵开始位置减去索引开始位置28
每个字符在索引中占用长度 = 索引长度 / 字符数量
每个字符占用的点阵数据长度 = 向上取整(字符宽 / 8) * 字符高 (这是由取模软件决定的,取模方式:阴码,行列式,顺向)

这样头部28字节就是信息区,往后到点阵数据开始位置为索引区,索引区后面紧跟的就是点阵数据区,没有一点浪费。

读取是先从信息区取到索引结束位置,然后根据索引长度/字符数得到每个字符的字节数,然后在索引区使用二分法快速查找到指定字符所在的位置,再位移到对应的点阵数据区位置取出点阵数据交给绘制的代码即可。

生成的过程也很简单,我写了一段脚本将已经排重的字符进行排序保存成文本文件,使用 PCtoLCD2002 软件的导入大量文本文件直接生成索引和二进制字库文件(点击打开文本文件导入文件,勾选生成索引文件生成二进制字库文件,再点击开始生成即可),将这两个文件拷贝到脚本所在目录中执行我写的另一个脚本即可直接生成目标字体文件。

对文本进行排序的脚本,会生成 text-index.txt 文件:

'''
将字符串排序后保存为 GBK 文件
用于给 PCtoLCD2000 软件生成字模,排序主要用于生成字体文件后用二分法查找
'''

# 这里存放的是要进行排序的文字,注意文字要先排重
text = '''
一乙二十丁厂七卜人入八九几儿了力乃刀又三于干亏士工土才寸下大丈与万上小口巾山千乞
川亿个勺久凡及夕丸么广亡门义之尸弓己已子卫也女飞刃习叉马乡丰王井开夫天无元专云扎
艺木五支厅不太犬区历尤友匹车巨牙屯比互切
'''

text = text.replace('\n', '')
text_arr = []

for word in text:
    text_arr.append(word)

text_arr.sort()
text = ''.join(text_arr)

# 保存成 GBK 后用字模软件打开
f = open('text-index.txt', 'wt', encoding='gbk')
f.write(text)
f.close()

print(text)

生成字体的文件,会在 fonts 目录下生成 .font 文件:

import struct

'''
将字体取模软件生成的字体与索引生成专用的点阵字体文件
'''

font_name = 'SimSun2000'
font_width = 12
font_height = 12
file_name = '2000' # 字体与索引文件名的前缀,后面跟的分别是 .fon 和 _index.txt

f = open('fonts/%s.font' % font_name, 'wb')

# 读出索引,并从 GBK 转为 UTF-8
fi = open('%s_index.TXT' % file_name, 'r', encoding='gbk')
text = fi.read()
fi.close()
text_bin = text.strip().encode('utf-8')
count = len(text_bin.decode())

# 打开字体文件
fon = open('%s.fon' % file_name, 'rb')
# blen = font_width + font_height

# 前 20 为字体名
f.write(font_name.encode())
f.seek(20)

# 20:21 字体宽,21:22 字体高,22:24 字体数量,24:28 字体点阵数据开始位置
font_pos = len(text_bin) + 28
f.write(struct.pack('BBHI', font_width, font_height, count, font_pos))
f.write(text_bin)

# 写入字体点阵数据
fi = open('%s.FON' % file_name, 'rb')
f.write(fi.read())
fi.close()

f.close()

读取字库查找字符代码:

class FileFont(MonoFont):
    '''
    基于文件的字体文件格式

    此字体格式为最紧凑的格式,文件定义如下:

    | 20个字节字符串为字体的名称 | 1字节 unsigned char 为字体宽 | 1字节 unsigned char 为字体高 | 2 字节 unsigned short 为包含的字符数量 | 4 字节 unsigned int 为点阵数据开始位置 |
    | 紧跟着所有相同长度(1字节每个英文或3字节每个UTF-8中文)的从低到高排序字符作为二分查找索引 | 从点阵数据开始位置紧跟着所有点阵数据,顺序与索引相同 |

    索引长度 = 点阵开始位置减去索引开始位置28
    每个字符在索引中占用长度 = 索引长度 / 字符数量
    每个字符占用的点阵数据长度 = 向上取整(字符宽 / 8) * 字符高 (这是由取模软件决定的,取模方式:阴码,行列式,顺向)
    '''

    def __init__(self, name):
        self.fp = open('fonts/%s.font' % name, 'rb')
        self.name = self.fp.read(20).decode()

        # 字体宽,高,数量,点阵数据开始位置
        w, h, self.count, self.data_pos = struct.unpack('BBHI', self.fp.read(8))

        # 一个字符在索引中占用的长度
        self.char_len = int((self.data_pos - FF_INDEX_START) / self.count)

        # 一个字符的点阵数据占用的长度
        self.bits_len = ceil(w / 8) * h

        super().__init__(w, h)


    def get_bits(self, char):
        '''
        读取指定字符的点阵数据
        '''

        # 二分法查找字符的索引位置
        first = 0
        last = self.count
        pos = None

        while first <= last:
            middle = (first + last) // 2
            self.fp.seek(FF_INDEX_START + middle * self.char_len)
            item = self.fp.read(self.char_len).decode()

            if char == item:
                pos = middle
                break
            elif char < item:
                last = middle - 1
            else:
                first = middle + 1

        if pos is None:
            return None

        # 读取点阵数据
        self.fp.seek(self.data_pos + pos * self.bits_len)
        return self.fp.read(self.bits_len)

使用这种方法在本地生成的字体文件相比 btree 占用的空间还要小,没有任何浪费,且二分法查找索引速度极快,整个字体都是放在磁盘中不需要读进内存(实测2500个汉字宋体12x12像素大小生成的字库只有 67K 大小),也不会产生额外的内存占用,可谓是 MicroPython 下点阵字库的终极之道了。

评论 共有 1 条评论