Table of Contents
gif 图片每帧最多只有 256 色,当把真彩色图片转成 gif 图片的时候,需要压缩颜色数量。为了尽可能还原图片色彩,压缩后的颜色最好是根据原图颜色生成。这就用到了八叉树颜色量化算法(1)。
八叉树颜色量化
比如,某个像素值是 (123, 234, 111),其二进制表示如下:
| R | 0 | 1 | 1 | 1 | 1 | 0 | 1 | 1 | 
| G | 1 | 1 | 1 | 0 | 1 | 0 | 1 | 0 | 
| B | 0 | 1 | 1 | 0 | 1 | 1 | 1 | 1 | 
| index | 2 | 7 | 7 | 4 | 7 | 1 | 7 | 5 | 
从左往右,可以用 8 个数字表示(index 行),分别表示所在八叉树对应层级中的位置。比如,第一个数 2 表示第一层下标为 2 的位置,第二个数 7 表示上一层位置 2 的子节点中下标为 7 的位置……
依此形成一个树,最后一级为叶子结点。当叶子结点超过最大颜色数(这里是 256,也可以设成其他数值,比如实践中我设成了 254,留两个给背景色和透明色),就需要合并叶子节点。合并原则是:
- 从倒数第二层开始。因为越下面的层级代表的颜色范围越小,合并后失真越小。
 - 子节点数需要大于等于两个。深度相同,选择子节点数少的那个。
 - 合并后,此节点的颜色值为累积颜色值对所代表像素数量的平均值——此值可以在遍历完所有像素后,取颜色的时候计算。同时,将该节点标记为叶子结点,以后此路径添加颜色时截止到此节点。
 
所有像素遍历结束后,八叉树就构建完成了,然后再次遍历所有像素,从八叉树上取色。
import { getBits } from './helpers'
/**
 * cotree color quantization
 * https://zhuanlan.zhihu.com/p/220177037
 */
export default class Quantizer {
  constructor () {
    this.tree = new TreeNode()
    this.tree.level = -1
    this.levelNodes = new Array(8).fill(0).map(() => {
      return []
    })
  }
  // max color nums-- a slot for transparant color, a slot for background color
  maxColor = 254
  // color count
  colorCount = 0
  // tree
  tree: TreeNode
  // level nodes
  levelNodes: Array<Array<TreeNode | null>>
  // add color
  addColor (r: number, g: number, b: number) {
    let parentNode = this.tree
    for (let i = 7; i >= 0; i--) {
      const idx = (getBits(r, i, 1) << 2) + (getBits(g, i, 1) << 1) + getBits(b, i, 1)
      let node = parentNode.nodes[idx]
      // create node
      if (!node) {
        parentNode.nodes[idx] = new TreeNode()
        node = parentNode.nodes[idx]
        node.level = 7 - i
        node.parent = parentNode
        parentNode.nodesCount++
        node.idxInLevelNodes = this.levelNodes[node.level].length
        this.levelNodes[node.level].push(node)
        // last level, color count + 1
        if (node.level === 7) {
          this.colorCount++
        }
      }
      node.count++
      node.redSum += r
      node.greenSum += g
      node.blueSum += b
      if (node.isLeaf) {
        break
      }
      if (node.level === 7) {
        node.isLeaf = true
      }
      parentNode = node
    }
    // check max color count
    if (this.colorCount > this.maxColor) {
      this.reduceColor()
    }
  }
  // get color--get transformed color
  getColor (r: number, g: number, b: number): Array<number> {
    let parentNode = this.tree
    const color = [0, 0, 0, 0]
    for (let i = 7; i >= 0; i--) {
      const idx = (getBits(r, i, 1) << 2) + (getBits(g, i, 1) << 1) + getBits(b, i, 1)
      const node = parentNode.nodes[idx]
      if (!node) {
        break
      }
      if (node.isLeaf) {
        color[0] = (node.redSum / node.count) >> 0
        color[1] = (node.greenSum / node.count) >> 0
        color[2] = (node.blueSum / node.count) >> 0
        color[3] = 1
        break
      }
      parentNode = node
    }
    return color
  }
  // reduce color
  private reduceColor () {
    let targetNode = null
    let minCount = Number.MAX_SAFE_INTEGER
    // from bottom to top, there are all leaf nodes in level 7, skip
    for (let i = 6; i >= 0; i--) {
      const nodes = this.levelNodes[i]
      for (let idx = 0, len = nodes.length; idx < len; idx++) {
        if (nodes[idx]) {
          const node = nodes[idx] as TreeNode
          if (!node.isLeaf && node.nodesCount > 1 && node.count < minCount) {
            targetNode = node
            minCount = node.count
          }
        }
      }
      if (targetNode) {
        break
      }
    }
    // delete sub nodes
    if (targetNode) {
      this.deleteSubNodes(targetNode)
    }
  }
  // delete sub nodes
  private deleteSubNodes (targetNode: TreeNode) {
    for (let i = 0, len = targetNode.nodes.length; i < len; i++) {
      const node = targetNode.nodes[i]
      if (node) {
        if (!node.isLeaf) {
          this.deleteSubNodes(node)
        }
        this.levelNodes[node.level][node.idxInLevelNodes] = null
      }
    }
    this.colorCount -= targetNode.nodesCount - 1
    targetNode.nodes = []
    targetNode.nodesCount = 0
    targetNode.isLeaf = true
  }
}
export class TreeNode {
  constructor () {
    this.isLeaf = false
    this.level = 0
    this.count = 0
    this.redSum = 0
    this.greenSum = 0
    this.blueSum = 0
    this.parent = null
    this.nodes = []
    this.nodesCount = 0
    this.idxInLevelNodes = 0
  }
  // is leaf node ?
  isLeaf: boolean
  // level
  level: number
  // color count
  count: number
  // red count
  redSum: number
  // green count
  greenSum: number
  // blue count
  blueSum: number
  // parent node
  parent: TreeNode | null
  // nodes
  nodes: Array<TreeNode>
  // nodes count
  nodesCount: number
  // idx in level nodes
  idxInLevelNodes: number
}
颜色抖动
通过量化的颜色生成 gif 图像的时候会出现颜色分块明显的情况。为了让颜色过渡合理一些,避免过多的细节丢失,就需要颜色抖动(dithering)(2)。
颜色抖动有多种方式:
- 随机颜色抖动(Random Dither)
 - 有序颜色抖动(Ordered Dither)
 - 误差扩散(Error Diffusion)
 
我采用的是误差扩散方法。误差扩散原理就是将量化后的颜色与原来的颜色比对,计算(RGB 各个通道)差值,然后将差值按照一定的比例加到周围像素上。
| * | 7/16 | |
| 3/16 | 5/16 | 1/16 | 
比例可以自行调整,表格中只是一种分配方式。
经过抖动,颜色的数量很可能超过预设的限制,所以扩散之后的颜色并不是最终的结果。为此,我尝试了两种方式:
- 二次量化。将扩散之后的图像再次量化处理。二次量化之后不再需要颜色抖动。
 - 取量化后颜色表中的近似色。近似色的选择需要计算色差,色差公式(3)如下:
 

这两种方式处理的结果……其实差不多。事实上,我选择的图片是否颜色抖动基本没差别。我不确定是处理有问题还是图片选择有问题。
Q&A
- Q:为什么八位二进制从左往右代表 1~8 层,而不是反过来?
A:因为左边的数字代表的范围更大。比如 123 这个数字,1 发生变化造成的影响比 2 发生变化造成的影响更大,影响更大的位,我们要放到树的上层。