Go 博客

GIF 解码器:Go 接口练习

Rob Pike
2011 年 5 月 25 日

简介

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 连接。slicetmp 字段将用于管理解块。以下是 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 图像流转换为一个简单的字节流,可以通过调用 blockReaderRead 方法来访问。

连接这些部分

有了 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.Readerlzw.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:视频
博客索引