我承认这个回答可能有点勉强算是对我的问题的好答案,但是由于我已经对这个问题进行了一些研究,这个回答描述了我选择的方法,并提供了更多关于该问题性质的信息,这对于遇到类似问题的人来说非常有用。
“正确的答案”即最终算法
我最终采用了与我在问题中描述的方法略有不同的变体。首先,每个字形被分成三个trits:0、1和中间值。然后,这个三进制信息被压缩到一个256个槽位的静态字典中。字典(或查找表)中的每个项目都是一个二进制编码的字符串(0=0,10=1,11=中间值),其最高有效位添加了一个1。
灰度数据(用于中间值的trits)被插入到对查找表的引用之间。因此,数据实际上看起来像这样:
<LUT reference><gray value><gray value><LUT reference>...
灰度值的数量自然取决于从静态字典中查找到的三进制数据中间trit的数量。
解压缩代码非常短,可以轻松地编写成一个状态机,只需要一个指针和一个32位变量即可确定状态。类似这样:
static uint32_t trits_to_decode;
static uint8_t *next_octet;
void start_glyph(uint8_t *data)
{
next_octet = data;
trits_to_decode = 1;
}
uint8_t next_pixel(void)
{
uint8_t return_value;
if (trits_to_decode == 1)
trits_to_decode = dictionary[*next_octet++];
if (trits_to_decode & 1)
{
trits_to_decode >>= 1;
if (trits_to_decode & 1)
{
trits_to_decode >>= 1;
return *next_octet++;
}
trits_to_decode >>= 1;
return 255;
}
trits_to_decode >>= 1;
return 0;
}
这段代码并没有以完全相同的形式测试过,所以可能会有错别字或其他小错误。
在移位操作中有很多重复。我不太担心,因为编译器应该能够清理它们。(实际上,左移可能更好,因为然后可以在移位后使用进位位。但由于在C中没有直接的方法来做到这一点,所以我不去烦恼。)
还有一种优化与字典(查找表)的大小有关。可能存在短和长的项,因此可以构建支持32位、16位或8位项的字典。在这种情况下,字典必须按照小数值引用32位项、中间值引用16位项和大数值引用8位项的顺序进行排序,以避免对齐问题。然后查找代码看起来像这样:
static uint8_t dictionary_lookup(uint8_t octet)
{
if (octet < NUMBER_OF_32_BIT_ITEMS)
return dictionary32[octet];
if (octet < NUMBER_OF_32_BIT_ITEMS + NUMBER_OF_16_BIT_ITEMS)
return dictionary16[octet - NUMBER_OF_32_BIT_ITEMS];
return dictionary8[octet - NUMBER_OF_16_BIT_ITEMS - NUMBER_OF_32_BIT_ITEMS];
}
当然,如果每种字体都有其自己的词典,常量将变成从字体信息中查找的变量。由于该函数仅被调用一次,任何半靠谱的编译器都会内联该函数。
如果减少量化级别的数量,也可以处理。最简单的情况是使用4位灰度级(1..14)。这需要一个8位状态变量来保存灰度级别。那么,灰度级分支将变为:
// new state value
static uint8_t gray_value;
...
// new variable within the next_pixel() function
uint8_t return_value;
...
// there is no old gray value available?
if (gray_value == 0)
gray_value = *next_octet++;
// extract the low nibble
return_value = gray_value & 0x0f;
// shift the high nibble into low nibble
gray_value >>= 4;
return return_value;
实际上,这允许使用15个中间灰度级别(总共17个级别),这非常好地映射到线性的255值系统中。
三位或五位数据更容易打包成16位半字,并设置MSB始终为1。然后可以使用与三进制数据相同的技巧(移位直到获取1)。
应注意,在某些点处压缩比开始恶化。三进制数据的压缩量不取决于灰度级数。灰度级别数据未经压缩,八位情况下的八位数据数量(几乎)呈线性变化。对于典型字体,8位灰度级别数据占总数据的1/2..2/3,但这高度依赖于字体和大小。
因此,从8位降至4位(在大多数情况下在视觉上几乎不可感知)通常将压缩大小缩小1/4..1/3,而进一步降低至三位所提供的减小则明显较少。这种压缩算法不适用于两位数据。
如何构建字典?
如果解压缩算法非常简单快捷,则真正的难点在于字典构建。很容易证明存在最佳字典(对于给定字体提供最少的压缩八位数),但比我更聪明的人似乎已经证明了找到这样的字典是 NP-完全问题。
在我所欠缺的领域理论知识下,我认为会有很好的工具提供合理的好近似值。可能有这样的工具,但我找不到,所以我自己编写了一个 Mickey Mouse 版本。 编辑:早期算法相当幼稚;找到了一种更简单、更有效的方法
- 从 '0'、'g' 和 '1' 的静态字典开始
- 将每个字形的三进制数据拆分成 trits 列表
- 查找最常见的连续项目组合(在第一次迭代中,它可能是 '0'、'0')
- 用该组合替换所有出现的组合,并将该组合添加到字典中(例如,如果将 '0','0' 替换为 '00',则数据 '0','1','0','0','g' 将变为 '0','1','00','g')
- 删除字典中未使用的任何项(理论上它们可能出现)
- 重复步骤3-5,直到字典满为止(即至少253轮)
这仍然是一种非常简单的方法,可能会产生非常次优的结果。它的唯一优点是它可行。
它的效果如何?
一个回答是足够好,但为了更详细地说明,下面是一些数字。这是一个包含864个字形、典型字形大小为14x11像素和每像素8位的字体。
- 原始未压缩大小:127101字节
- 中间值数量:46697
- Shannon熵(逐字节):
- 总共:528914位=66115字节
- 三进制数据:176405位=22051字节
- 中间值:352509位=44064字节
- 简单压缩的三进制数据(0=0,10=1,11=中间值)(127101个三进制数):207505位=25939字节
- 字典压缩的三进制数据:18492字节
- 字典大小:647字节
- 完整压缩数据:647+18492+46697=65836字节
- 压缩率:48.2%
与逐字节熵的比较非常有启示性。中间值数据的熵很高,而三进制数据可以被压缩。这也可以解释为原始数据中0和255的值很高(相对于任何中间值)。
我们没有做任何事情来压缩中间值,因为似乎没有任何有意义的模式。然而,我们通过三进制数据明显击败了熵,即使总数据量低于熵限制。所以,我们可以做得更好。
将量化级别降低到17会将数据大小减小到约42920字节(压缩率超过66%)。此时熵为41717字节,因此算法会略微变差,这是可以预料的。
在实践中,较小的字体大小很难压缩。这并不奇怪,因为灰度信息占据了更大的信息比例。非常大的字体大小使用这种算法可以高效地压缩,但是跑长压缩是一个更好的选择。
有什么更好的方法吗?
如果我知道,我会使用它!但我仍然可以推测。
Jubatian
认为字体中会有很多重复。对于变音符号来说,这是正确的,因为aàäáâå在几乎所有字体中都有很多共同之处。然而,在大多数字体中,像p和b这样的字母似乎并不是如此。虽然基本形状接近,但还不够。(谨慎的逐像素字体设计则是另一回事。)
不幸的是,这种必然的重复在较小的字体大小中并不容易利用。我尝试创建一个包含所有可能扫描行的字典,然后仅引用这些扫描行。不幸的是,不同扫描行的数量很高,因此引用所添加的开销超过了好处。如果扫描行本身可以压缩,则情况会有所改变,但是每个扫描行的少量字节使得有效压缩变得困难。当然,这个问题取决于字体大小。
我的直觉告诉我,如果使用比完整扫描线更长或更短的运行方式,这仍然是正确的方法。使用4位像素结合这种方法可能会产生非常好的结果——只要有一种方法可以创建那个最佳字典。
朝着这个方向的一个提示是,完整字体数据(127101个八位字节)的LZMA2压缩文件(用xz进行最高压缩)仅占36720个八位字节。当然,这种格式不满足其他要求(快速解压缩,可以逐字解压缩,低内存需求),但它仍然显示出数据中存在比我的简单算法能够利用的更多冗余。
字典编码通常在字典步骤之后与Huffman或算术编码相结合。我们无法在这里这样做,但如果我们可以,将再节省4000个八位字节。