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 发生变化造成的影响更大,影响更大的位,我们要放到树的上层。