Go 博客
GIF 解码器:Go 接口练习
简介
2011 年 5 月 10 日,在旧金山举行的 Google I/O 大会上,我们宣布 Go 语言现已在 Google App Engine 上推出。Go 是首个在 App Engine 上推出的直接编译成机器代码的语言,使其成为 CPU 密集型任务(例如图像处理)的理想选择。
为此,我们演示了一个名为 Moustachio 的程序,它可以轻松地改善像这样的图片
通过添加胡须并分享结果
所有图形处理,包括渲染抗锯齿胡须,都是由运行在 App Engine 上的 Go 程序完成的。(源代码可在 appengine-go 项目 中找到。)
虽然网络上大多数图像(至少是那些可能被添加胡须的图像)都是 JPEG,但还有无数其他格式在流传,因此 Moustachio 接受上传的几种格式的图像似乎是合理的。Go 图像库中已经存在 JPEG 和 PNG 解码器,但久负盛名的 GIF 格式却没有被表示,因此我们决定在宣布之前编写一个 GIF 解码器。该解码器包含一些片段,演示了 Go 的接口如何使某些问题更容易解决。这篇博文的其余部分描述了几个实例。
GIF 格式
首先,快速浏览一下 GIF 格式。GIF 图像文件是调色板的,也就是说,每个像素值都是文件包含的固定颜色映射表中的索引。GIF 格式起源于显示器通常只有 8 位/像素的时代,颜色映射表用于将有限的值集转换为点亮屏幕所需的 RGB(红、绿、蓝)三元组。(这与 JPEG 形成对比,JPEG 没有颜色映射表,因为编码分别表示不同的颜色信号。)
GIF 图像可以包含 1 到 8 位/像素(含),但 8 位/像素是最常见的。
简单来说,GIF 文件包含一个定义像素深度和图像尺寸的标题、颜色映射表(对于 8 位图像,为 256 个 RGB 三元组),然后是像素数据。像素数据存储为一维位流,使用 LZW 算法压缩,该算法对于计算机生成的图形非常有效,但对于摄影图像则不太好。压缩后的数据随后被分解成长度分隔的块,每个块由一个字节计数(0-255)以及相应的字节数组成
解块像素数据
为了在 Go 中解码 GIF 像素数据,我们可以使用 compress/lzw
包中的 LZW 解压缩器。它有一个 NewReader 函数,该函数返回一个对象,如 文档 所述,“通过解压缩从 r 读取的数据来满足读取操作”
func NewReader(r io.Reader, order Order, litWidth int) io.ReadCloser
这里,order
定义了位打包顺序,litWidth
是以位为单位的字大小,对于 GIF 文件,它对应于像素深度,通常为 8。
但我们不能只是将输入文件作为第一个参数传递给 NewReader
,因为解压缩器需要字节流,而 GIF 数据是必须解包的块流。为了解决这个问题,我们可以使用一些代码将输入 io.Reader
包裹起来以将其解块,并将该代码再次实现为 Reader
。换句话说,我们将解块代码放入一个新类型的 Read
方法中,我们将其称为 blockReader
。
以下是 blockReader
的数据结构。
type blockReader struct {
r reader // Input source; implements io.Reader and io.ByteReader.
slice []byte // Buffer of unread data.
tmp [256]byte // Storage for slice.
}
读取器 r
将是图像数据的来源,可能是文件或 HTTP 连接。slice
和 tmp
字段将用于管理解块。以下是 Read
方法的完整内容。这是一个很好的 Go 中切片和数组用法的例子。
1 func (b *blockReader) Read(p []byte) (int, os.Error) {
2 if len(p) == 0 {
3 return 0, nil
4 }
5 if len(b.slice) == 0 {
6 blockLen, err := b.r.ReadByte()
7 if err != nil {
8 return 0, err
9 }
10 if blockLen == 0 {
11 return 0, os.EOF
12 }
13 b.slice = b.tmp[0:blockLen]
14 if _, err = io.ReadFull(b.r, b.slice); err != nil {
15 return 0, err
16 }
17 }
18 n := copy(p, b.slice)
19 b.slice = b.slice[n:]
20 return n, nil
21 }
第 2-4 行只是进行健全性检查:如果没有存放数据的地方,则返回零。这种情况永远不会发生,但安全起见,最好这样做。
第 5 行询问是否有来自先前调用的剩余数据,方法是检查 b.slice
的长度。如果没有,则切片的长度为零,我们需要从 r
中读取下一个块。
GIF 块以字节计数开头,在第 6 行读取。如果计数为零,则 GIF 将其定义为终止块,因此我们在第 11 行返回 EOF
。
现在我们知道应该读取 blockLen
个字节,因此我们将 b.slice
指向 b.tmp
的前 blockLen
个字节,然后使用辅助函数 io.ReadFull
读取那么多字节。如果该函数无法读取恰好那么多字节,则将返回错误,这种情况永远不会发生。否则,我们有 blockLen
个字节可以读取。
第 18-19 行将数据从 b.slice
复制到调用者的缓冲区。我们正在实现 Read
,而不是 ReadFull
,因此我们允许返回少于请求的字节数。这样一来就很简单了:我们只需将数据从 b.slice
复制到调用者的缓冲区 (p
),而 copy
的返回值是传输的字节数。然后我们重新切片 b.slice
以删除前 n
个字节,为下一次调用做好准备。
将切片 (b.slice
) 与数组 (b.tmp
) 耦合是 Go 编程中的一种很好的技巧。在本例中,这意味着 blockReader
类型的 Read
方法永远不会执行任何分配。这也意味着我们不需要保留计数(它隐含在切片长度中),并且内置的 copy
函数保证我们永远不会复制比应该复制的更多内容。(有关切片的更多信息,请参阅 Go 博客中的这篇博文。)
有了 blockReader
类型,我们可以通过将输入读取器(例如文件)包装起来来解块图像数据流,如下所示
deblockingReader := &blockReader{r: imageFile}
这种包装将一个块分隔的 GIF 图像流转换为一个简单的字节流,可以通过调用 blockReader
的 Read
方法来访问。
连接这些部分
有了 blockReader
的实现和库中提供的 LZW 压缩器,我们就拥有了解码图像数据流所需的所有部分。我们通过这段来自代码的“雷霆之声”将它们缝合在一起
lzwr := lzw.NewReader(&blockReader{r: d.r}, lzw.LSB, int(litWidth))
if _, err = io.ReadFull(lzwr, m.Pix); err != nil {
break
}
就这样。
第一行创建一个 blockReader
并将其传递给 lzw.NewReader
以创建一个解压缩器。这里,d.r
是包含图像数据的 io.Reader
,lzw.LSB
定义了 LZW 解压缩器中的字节顺序,而 litWidth
是像素深度。
有了解压缩器,第二行调用 io.ReadFull
以解压缩数据并将其存储在图像 m.Pix
中。当 ReadFull
返回时,图像数据将被解压缩并存储在图像 m
中,准备显示。
这段代码第一次就运行成功了。真的。
我们可以通过将 NewReader
调用放在 ReadFull
的参数列表中来避免临时变量 lzwr
,就像我们在 NewReader
调用内部构建了 blockReader
一样,但这可能将太多内容塞进一行代码中。
结论
Go 的接口使我们能够通过像这样组装零件来构建软件,从而重构数据。在本例中,我们通过使用 io.Reader
接口将解块器和解压缩器链接在一起,实现了 GIF 解码,类似于类型安全的 Unix 管道。此外,我们编写了解块器作为 (隐式) Reader
接口的实现,因此不需要额外的声明或样板代码即可将其放入处理管道中。在大多数语言中,很难像这样紧凑地、干净地、安全地实现这种解码器,但接口机制加上一些约定使其在 Go 中几乎变得自然。
这值得另一张图片,这次是 GIF
GIF 格式定义在 http://www.w3.org/Graphics/GIF/spec-gif89a.txt 上。
下一篇文章:聚焦外部 Go 库
上一篇文章:Google I/O 2011 上的 Go:视频
博客索引