我承认这是一个很好的回答我的问题的临界案例,但是由于我对这个问题进行了一些研究,这个答案既描述了我选择的方法,又提供了有关问题性质的更多信息应该有人撞到它。
“正确答案”又名最终算法
我最终得到的是我在问题中描述的变体。首先,每个字形被分成三元组 0、1 和中间字符。然后使用 256 槽静态字典压缩此三元信息。字典(或查找表)中的每一项都是二进制编码的字符串(0=0, 10=1, 11=中间),在最重要的一端添加一个 1。
灰度数据(用于中间三元组)散布在对查找表的引用之间。所以,数据基本上是这样的:
<LUT reference><gray value><gray value><LUT reference>...
灰度值的个数自然取决于从静态字典中查找到的三元数据中的中间三元数。
解压代码很短,可以很容易地写成一个状态机,只有一个指针和一个给出状态的 32 位变量。像这样的:
static uint32_t trits_to_decode;
static uint8_t *next_octet;
/* This should be called when starting to decode a glyph
data : pointer to the compressed glyph data */
void start_glyph(uint8_t *data)
{
next_octet = data; // set the pointer to the beginning of the glyph
trits_to_decode = 1; // this triggers reloading a new dictionary item
}
/* This function returns the next 8-bit pixel value */
uint8_t next_pixel(void)
{
uint8_t return_value;
// end sentinel only? if so, we are out of ternary data
if (trits_to_decode == 1)
// get the next ternary dictionary item
trits_to_decode = dictionary[*next_octet++];
// get the next pixel from the ternary word
// check the LSB bit(s)
if (trits_to_decode & 1)
{
trits_to_decode >>= 1;
// either full value or gray value, check the next bit
if (trits_to_decode & 1)
{
trits_to_decode >>= 1;
// grayscale value; get next from the buffer
return *next_octet++;
}
// if we are here, it is a full value
trits_to_decode >>= 1;
return 255;
}
// we have a zero, return it
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 值系统。
3 位或 5 位数据更容易打包成 16 位半字并将 MSB 始终设置为 1。然后可以使用与三元数据相同的技巧(移位直到得到 1)。
应该注意的是,压缩比在某个时候开始恶化。三值数据的压缩量不依赖于灰度级数。灰度级数据是未压缩的,八位字节的数量(几乎)与位数成线性关系。对于典型的字体,8 位灰度数据是总数的 1/2 .. 2/3,但这很大程度上取决于字体和大小。
因此,从 8 位减少到 4 位(在大多数情况下,这在视觉上是难以察觉的)通常会减少 1/4..1/3 的压缩大小,而减少到 3 位所提供的进一步减少要少得多.使用这种压缩算法,两位数据没有意义。
如何构建字典?
如果解压算法非常简单快速,真正的挑战在于字典的构建。很容易证明存在诸如最优字典之类的东西(字典为给定字体提供最少数量的压缩八位位组),但比我更聪明的人似乎已经证明找到这样的字典的问题是 NP 完全的。
由于我可能相当缺乏该领域的理论知识,我认为会有很好的工具提供相当好的近似值。可能有这样的工具,但我找不到,所以我推出了自己的米老鼠版本。 编辑:早期的算法相当愚蠢;发现了一个更简单更有效的方法
- 从 '0'、g'、'1' 的静态字典开始(其中 'g' 表示中间值)
- 将每个字形的三元数据拆分为一个三元组列表
- 找到最常见的连续项目组合(在第一次迭代时很可能是“0”、“0”)
- 用组合替换所有出现的组合并将组合添加到字典中(例如,数据'0','1','0','0','g'将变为'0','1 ', '00', 'g' if '0', '0' 替换为 '00')
- 删除字典中所有未使用的项目(至少在理论上它们可能会出现)
- 重复步骤 3-5,直到字典已满(即至少 253 轮)
这仍然是一个非常简单的方法,它可能会给出一个非常次优的结果。它唯一的优点是它有效。
效果如何?
一个答案就足够了,但要详细说明一下,这里有一些数字。这是一种具有 864 个字形的字体,典型字形大小为 14x11 像素,每像素 8 位。
- 原始未压缩大小:127101
- 中间值个数:46697
- 香农熵(逐个八位字节):
- 总计: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 个八位字节。当然,这种格式不满足其他要求(解压速度快,可以逐个字形解压缩,RAM 要求低),但它仍然表明数据中的冗余比我的廉价算法所能达到的要多利用。
字典编码通常在字典步骤之后与霍夫曼或算术编码结合使用。我们不能在这里这样做,但如果可以的话,它会再节省 4000 个八位字节。