从零实现 GIF 制作工具:LZW 压缩与 Median Cut 色彩量化

GIF 制作工具,原本以为直接用现成的库就完事了,结果发现纯前端实现更有意思。这篇文章聊聊 GIF 格式的核心技术:LZW 压缩和 Median Cut 色彩量化。

为什么 GIF 这么难搞?

GIF 格式诞生于 1987 年,那时候的设计理念跟现在完全不同。最大的坑在于:GIF 只支持 256 色。现在的图片动辄几百万色,要塞进 256 色的框框里,还得保持画质,这就是色彩量化的难题。

另一个坑是 LZW 压缩。GIF 用的 LZW 算法是专利保护的(2003 年过期),但更重要的是,LZW 的实现细节很容易踩坑。比如码表溢出怎么办?Clear Code 什么时候发?这些细节文档里写得含糊,得靠实验摸索。

Median Cut:把几百万色砍到 256 色

Median Cut 是经典的色彩量化算法,核心思想很简单:把颜色空间切成 256 个方块,每个方块取中心点作为代表色。

算法步骤

bash 复制代码
function medianCut(pixels, maxColors) {
  // 1. 统计所有颜色及其出现频率
  const colorMap = new Map()
  for (let i = 0; i < pixels.length; i += 4) {
    const key = `${pixels[i]},${pixels[i+1]},${pixels[i+2]}`
    colorMap.set(key, (colorMap.get(key) || 0) + 1)
  }
  
  // 2. 初始化:所有颜色放进一个桶
  const buckets = [{
    entries: Array.from(colorMap.entries()),
    rMin: 0, rMax: 255,
    gMin: 0, gMax: 255,
    bMin: 0, bMax: 255
  }]
  
  // 3. 反复切分,直到桶数达到 maxColors
  while (buckets.length < maxColors) {
    // 找范围最大的桶
    const bucket = findWidestBucket(buckets)
    if (!bucket) break
    
    // 沿最长边切分
    const [left, right] = splitBucket(bucket)
    buckets.splice(buckets.indexOf(bucket), 1, left, right)
  }
  
  // 4. 每个桶取加权平均色作为调色板
  return buckets.map(b => computeAverage(b))
}

关键细节

为什么要按出现频率加权? 因为颜色出现的次数越多,对视觉影响越大。如果一个像素只出现一次,它被量化错了也无所谓;但背景色要是偏了,整张图都难看。

切分策略的选择:标准 Median Cut 按颜色数量均分,但更好的做法是按像素数量均分。这样可以避免一个桶里塞了几百万像素,另一个桶只有几个像素的情况。

LZW 压缩:GIF 的灵魂

LZW 是一种字典编码算法,核心思想是用短编码代替重复出现的字符串。GIF 的 LZW 有几个特殊点:

1. 变长编码

LZW 的编码长度是动态增长的。初始码长是 minCodeSize + 1,随着字典增大,码长逐步增加到 12 位。一旦字典满了(4096 项),就发 Clear Code 重置字典。

bash 复制代码
function packBits(codes, minCodeSize) {
  const clearCode = 1 << minCodeSize
  const endCode = clearCode + 1
  
  let codeSize = minCodeSize + 1
  let nextCode = endCode + 1
  
  for (const code of codes) {
    // 把编码塞进位缓冲区
    bitBuffer |= (code << bitCount)
    bitCount += codeSize
    
    // 每凑够 8 位就输出一个字节
    while (bitCount >= 8) {
      bytes.push(bitBuffer & 0xFF)
      bitBuffer >>>= 8
      bitCount -= 8
    }
    
    // 字典满了,发 Clear Code
    if (nextCode >= 4096) {
      codes.push(clearCode)
      nextCode = endCode + 1
      codeSize = minCodeSize + 1
    }
    // 码长增长
    else if (nextCode >= (1 << codeSize) && codeSize < 12) {
      codeSize++
    }
  }
}

2. 字典构建

LZW 的字典是边压缩边构建的。每次输出一个编码,就把"当前编码 + 下一个像素"加入字典。

bash 复制代码
function lzwEncode(indexedPixels, minCodeSize) {
  const dict = new Map()
  let w = indexedPixels[0]
  
  for (let i = 1; i < indexedPixels.length; i++) {
    const k = indexedPixels[i]
    
    // w+k 在字典里,继续扩展
    if (dict.get(w)?.has(k)) {
      w = dict.get(w).get(k)
    }
    // w+k 不在字典里,输出 w,把 w+k 加入字典
    else {
      codes.push(w)
      if (!dict.has(w)) dict.set(w, new Map())
      dict.get(w).set(k, nextCode++)
      w = k
    }
  }
  
  codes.push(w)
  return codes
}

3. 性能优化

原始 LZW 实现用 w+k 作为字典键,字符串拼接很慢。优化方案是用嵌套 Map:第一层 Map 的键是前缀编码,第二层 Map 的键是后缀像素值。这样查找从 O(n) 降到 O(1)。

GIF 文件结构

GIF 文件是按块组织的,主要包含:

bash 复制代码
GIF89a Header
Logical Screen Descriptor
Netscape Application Extension (循环播放)
┌─ Graphics Control Extension (延迟、透明)
│  Image Descriptor
│  Local Color Table
│  LZW Image Data
└─ (重复每一帧)
GIF Trailer

延迟时间的坑

GIF 的延迟单位是 10 毫秒,不是 1 毫秒。而且很多播放器会强制最小延迟为 20ms(2 个单位),所以你设 10ms 实际播放可能是 20ms。

循环播放

GIF89a 标准本身不支持循环播放,是 Netscape 加的扩展。循环次数 0 表示无限循环,1 表示播放 1 次(总共播放 2 次)。这个坑我踩了好久。

实战经验

做 JsonKit 的 GIF 制作工具时,遇到几个性能问题:

色彩量化太慢:原始实现遍历所有像素找最近色,O(width × height × paletteSize)。优化方案是用 KD-Tree 或预先建立颜色查找表。

LZW 压缩太慢:字典查找是瓶颈。用嵌套 Map 替代字符串拼接后,速度提升了 10 倍。

内存爆炸 :处理大图时,Canvas 的 getImageData 会返回巨大的数组。解决方案是分块处理,或者用 Web Worker 避免阻塞主线程。

最终效果

相关工具

总结

GIF 格式虽然古老,但背后的技术很有意思。LZW 压缩是早期无损压缩的经典算法,Median Cut 是色彩量化的基石。理解这些原理,不仅能写出更好的 GIF 工具,对理解现代图片格式(WebP、AVIF)也有帮助。

完整代码在 JsonKit 的 GIF Maker 工具里,欢迎试用。

相关推荐
AlienZHOU4 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc7 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师9 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen9 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒9 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow10 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81810 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007810 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明10 小时前
第 6 章 组件进阶
前端·vue.js
江华森10 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端