SEO 信息

  • SEO 标题:动图魔方技术拆解 08:Palette Quantizer 如何把 PixelMap 压到 256 色
  • SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,继续拆解 GIF 导出链路里最关键的颜色量化环节。本文聚焦 PaletteQuantizer.etsFrameProcessor.ets,说明项目如何先对多帧 RGB 数据做采样,再用 median-cut 生成不超过 256 色的全局调色板,最后通过带缓存的最近色匹配把每个像素压成 GIF 可写入的索引帧。文中包含真实源码、量化验证日志、工程截图和验收清单,适合正在做 HarmonyOS GIF 导出、图片压缩或端侧媒体处理的开发者。
  • 关键词:HarmonyOS, ArkTS, Palette Quantizer, PixelMap, GIF 调色板, median-cut, 最近色匹配, GIF 编码
  • 文章封面doc/csdn-series/covers/cover-08-palette-quantizer.jpg
  • 投稿方向:普通技术拆解 / GIF 编码器
  • 项目环境:HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

第 06 篇把 GIF89a 容器结构拆开了,第 07 篇把 LZW 编码与 sub-block 写法讲清了,但 GIF 文件能不能真正“长得像原图”,关键还取决于颜色量化。GIF 天生只能吃调色板索引,不能直接吞整帧 RGBA;如果量化这一步做得不稳,常见结果不是“颜色差一点”,而是肤色发灰、渐变断层、整张图脏掉。本文专门回到 PaletteQuantizer.ets,看“动图魔方”是怎么把 PixelMap 压到 256 色以内的。

一、真实工程问题背景

“动图魔方”的导出链路同时要支持图片拼 GIF、视频抽帧转 GIF、原生 GIF 再编辑,以及 3D / 伪 3D 合成导出。上游的素材来源可以不同,但落到 GIF 编码器时,都必须变成同一种东西:

  1. 尺寸已经统一过的 RGB 帧。
  2. 不超过 256 色的全局调色板。
  3. 每个像素都能在调色板里找到一个索引值。

这就是 PaletteQuantizer 存在的原因。它解决的不是“做一个好看的调色板”这么抽象,而是下面几个非常具体的工程问题:

  1. 多帧素材颜色总量远超 256 色,必须先压缩到 GIF 容器能接受的范围。
  2. 颜色压缩不能只看单帧,否则多帧之间会闪色、跳色。
  3. 量化算法必须足够轻,能放进端侧导出链路,不把 UI 卡死。
  4. 调色板一旦产出,就要支持海量像素重复查询,不能每个像素都全量暴力算一遍。

二、本文目标与边界

本文只回答三件事:

  1. FrameProcessor 如何从多帧 RGB 数据里抽样,构建量化输入。
  2. PaletteQuantizer 如何用 median-cut 生成不超过 256 色的调色板。
  3. nearestIndex() 为什么要做缓存,以及它怎样把 RGB 帧落成 GIF 索引帧。

本文不展开的内容也先说明:

  1. 不重复第 06 篇里的 GIF89a 容器结构。
  2. 不重复第 07 篇里的 LZW 编码与数据子块写入。
  3. 不讨论抖动算法、透明色策略和感知色彩空间优化,这些属于后续可选增强,不是当前项目的首版基线。

三、量化发生在导出链路的什么位置

量化并不是一个独立实验函数,而是 FrameProcessor -> GifEncoderService 之间的中间桥梁。项目里对应的两段核心调用很明确:

private static buildPalette(frames: RgbFrame[]): number[] {
  let totalPixels = 0;
  for (let index = 0; index < frames.length; index++) {
    totalPixels += frames[index].width * frames[index].height;
  }
  const stride = Math.max(1, Math.floor(totalPixels / MAX_SAMPLE_COLORS));
  const samples: number[] = [];
  for (let index = 0; index < frames.length; index++) {
    const rgb = frames[index].rgb;
    const pixelCount = frames[index].width * frames[index].height;
    for (let pixel = 0; pixel < pixelCount; pixel += stride) {
      const offset = pixel * 3;
      samples.push((rgb[offset] << 16) | (rgb[offset + 1] << 8) | rgb[offset + 2]);
    }
  }
  return PaletteQuantizer.quantize(samples, MAX_PALETTE);
}
private static toIndexedFrame(frame: RgbFrame, palette: number[], cache: Map<number, number>): IndexedGifFrame {
  const pixelCount = frame.width * frame.height;
  const indices: number[] = [];
  for (let pixel = 0; pixel < pixelCount; pixel++) {
    const offset = pixel * 3;
    indices.push(PaletteQuantizer.nearestIndex(
      palette,
      frame.rgb[offset],
      frame.rgb[offset + 1],
      frame.rgb[offset + 2],
      cache
    ));
  }
  return {
    width: frame.width,
    height: frame.height,
    indices: indices,
    delayCs: frame.delayCs
  };
}

这里有三个工程判断很关键:

  1. 先做“全局调色板”,再做“逐像素索引映射”,避免每帧各自产生一套色表。
  2. 采样是在所有帧上一起做,不是只拿第一帧代表全部,避免动图播放时色彩不连续。
  3. 映射结果直接写成 indices[],下一步就能交给 GifEncoderService 做 LZW 压缩和文件落盘。

四、为什么不是直接拿全部像素做调色板

如果把每一帧每一个像素都完整丢给量化器,颜色质量当然可能更高,但端侧导出会很快碰到两个现实问题:

  1. 大尺寸素材 + 多帧时,样本量会爆炸,排序和分箱成本直接上去。
  2. 很多颜色本来就高度重复,全量采集只会增加计算,不一定提升结果。

所以 FrameProcessor.buildPalette() 先算出总像素数,再用:

const stride = Math.max(1, Math.floor(totalPixels / MAX_SAMPLE_COLORS));

做步进采样。这个做法的价值不在“绝对最优”,而在“端侧可控”:

  1. 当素材较小,stride 会回落到 1,等价于尽量全采样。
  2. 当素材很大,采样步长自动增大,把计算量压回可接受范围。
  3. 抽样逻辑不依赖具体素材来源,图片、视频、3D 合成帧都能共用。

这正符合“动图魔方”的定位: 优先把真实导出链路跑通、跑稳,而不是只在实验室输入上追求极限颜色保真。

五、median-cut 在这个项目里是怎么落地的

PaletteQuantizer.quantize() 没有引入复杂第三方库,而是直接用一套可读性很强的 median-cut 实现:

static quantize(samples: number[], maxColors: number): number[] {
  if (samples.length === 0) {
    return [0x000000, 0xFFFFFF];
  }
  const limit = Math.max(2, Math.min(256, maxColors));
  let boxes: number[][] = [samples];

  while (boxes.length < limit) {
    let targetIndex = -1;
    let targetRange = -1;
    let targetChannel = 0;
    for (let index = 0; index < boxes.length; index++) {
      const box = boxes[index];
      if (box.length < 2) {
        continue;
      }
      const widest = PaletteQuantizer.widestChannel(box);
      if (widest.range > targetRange) {
        targetRange = widest.range;
        targetIndex = index;
        targetChannel = widest.channel;
      }
    }
    if (targetIndex < 0) {
      break;
    }

    const box = boxes[targetIndex];
    box.sort((a, b) =>
      PaletteQuantizer.channelValue(a, targetChannel) - PaletteQuantizer.channelValue(b, targetChannel));
    const mid = box.length >> 1;
    const left = box.slice(0, mid);
    const right = box.slice(mid);
    // ...
  }

  const palette: number[] = [];
  for (let index = 0; index < boxes.length; index++) {
    palette.push(PaletteQuantizer.averageColor(boxes[index]));
  }
  return palette;
}

这段实现的核心思路可以压缩成四步:

  1. 先把所有采样颜色放进一个大盒子。
  2. 找到 RGB 三个通道里跨度最大的那个通道。
  3. 按这个通道排序,从中间切成左右两个盒子。
  4. 重复切分,直到盒子数量达到 maxColors 或已经切不动。

项目里没有做花哨的权重优化,但这套实现有两个非常实用的优点:

  1. 代码短,便于维护和调试,适合 ArkTS 本地项目长期保留。
  2. 结果稳定,足够支撑 GIF 导出这种“256 色上限明确”的业务场景。

六、最宽通道切分为什么有效

widestChannel() 的职责很单纯:找出当前盒子里变化最大的颜色轴。

private static widestChannel(box: number[]): ChannelRange {
  // 统计 R/G/B 的 min/max
  const rangeR = maxR - minR;
  const rangeG = maxG - minG;
  const rangeB = maxB - minB;
  if (rangeR >= rangeG && rangeR >= rangeB) {
    return { range: rangeR, channel: 0 };
  }
  if (rangeG >= rangeB) {
    return { range: rangeG, channel: 1 };
  }
  return { range: rangeB, channel: 2 };
}

这背后的工程直觉是:如果一个颜色盒子在蓝色维度上最散,就优先沿蓝色切;如果在绿色维度上最散,就沿绿色切。这样做虽然不是感知层面的“最聪明”划分,但能用最小复杂度持续缩小盒内方差。

对 GIF 来说,这已经足够解决 80% 的问题:

  1. 大面积渐变会被拆成更接近的颜色簇。
  2. 高饱和色不会过早被灰色背景“平均掉”。
  3. 全局调色板仍能保持稳定结构,方便后续所有帧共用。

七、平均色为什么是最终输出

盒子切完以后,项目没有保留“盒子边界”,而是直接把每个盒子平均成一个颜色:

private static averageColor(box: number[]): number {
  if (box.length === 0) {
    return 0x000000;
  }
  let sumR = 0;
  let sumG = 0;
  let sumB = 0;
  for (let index = 0; index < box.length; index++) {
    const color = box[index];
    sumR += (color >> 16) & 0xFF;
    sumG += (color >> 8) & 0xFF;
    sumB += color & 0xFF;
  }
  const red = Math.round(sumR / box.length) & 0xFF;
  const green = Math.round(sumG / box.length) & 0xFF;
  const blue = Math.round(sumB / box.length) & 0xFF;
  return (red << 16) | (green << 8) | blue;
}

这意味着调色板本质上是一组“代表色”。它不保证每个样本都能完全还原,但能保证:

  1. 每个分箱都有一个稳定落点。
  2. 输出色表长度可控,天然满足 GIF 的 256 色边界。
  3. 后续最近色匹配时,所有像素都能映射到一套统一色域。

对端侧应用来说,这种策略特别务实。因为项目真正要的是“把多帧内容稳定导出来”,不是做离线美术软件级别的颜色保真。

八、最近色匹配为什么一定要加缓存

有了调色板以后,还要把每个像素的 RGB 值变成调色板索引。项目使用的是平方距离最近色:

static nearestIndex(palette: number[], red: number, green: number, blue: number, cache: Map<number, number>): number {
  const key = (red << 16) | (green << 8) | blue;
  const cached = cache.get(key);
  if (cached !== undefined) {
    return cached;
  }
  let best = 0;
  let bestDist = Number.MAX_VALUE;
  for (let index = 0; index < palette.length; index++) {
    const color = palette[index];
    const dr = ((color >> 16) & 0xFF) - red;
    const dg = ((color >> 8) & 0xFF) - green;
    const db = (color & 0xFF) - blue;
    const dist = dr * dr + dg * dg + db * db;
    if (dist < bestDist) {
      bestDist = dist;
      best = index;
      if (dist === 0) {
        break;
      }
    }
  }
  cache.set(key, best);
  return best;
}

如果不加缓存,多帧 GIF 的重复背景、肤色、阴影、字幕边缘都会被反复计算。这里把 (r,g,b) 直接压成 0xRRGGBB 作为 key,带来的收益很直接:

  1. 相同颜色第二次出现时可以 O(1) 复用。
  2. 静态背景越多,缓存收益越明显。
  3. 代码不复杂,不需要引入额外数据结构。

这类缓存不是“锦上添花”的优化,而是端侧多帧处理里非常典型的降耗手段。

九、量化验证日志

为了避免只停留在算法描述,我按 PaletteQuantizer.ets 的同样逻辑构造了一组样本颜色做本地验证。输入是 16 个代表色,目标色表限制为 6 色,得到的日志如下:

sampleCount: 16
quantizedPalette:
  #202f5f
  #237d7d
  #5f9ce9
  #e5b37d
  #b2abff
  #c4f2ff
uniqueQueryColors: 8
cacheEntriesAfterFirstPass: 8
mappedIndices: [0,0,2,3,1,5,5,2,0,3,2,5]
histogram:
  0 -> 3
  1 -> 1
  2 -> 3
  3 -> 2
  5 -> 3

这组日志至少说明了四件事:

  1. median-cut 最终确实把 16 个样本收敛成了 6 个代表色。
  2. 同一批查询颜色第一次映射后,缓存里只留下 8 个唯一颜色键值。
  3. 重复颜色不会每次都重新扫完整个调色板。
  4. 输出索引分布是稳定的,说明量化结果已经能直接供 IndexedGifFrame.indices[] 使用。

十、工程截图与证据

10.1 编辑页说明量化不是孤立函数

GIF 编辑页

这张图对应的是项目真实编辑链路。用户在这里做裁剪、滤镜、字幕、时长和导出设置,量化发生在这些编辑操作之后,说明 PaletteQuantizer 服务的是完整导出流程,而不是单独跑在测试脚本里的算法样品。

10.2 作品页说明量化结果已经进入真实输出

GIF 作品页

导出后的作品页能看到真实 GIF 文件已经落地,这意味着“采样 -> 调色板 -> 索引帧 -> LZW -> 文件写盘”这条链路是贯通的。对颜色量化来说,这比单独展示一段算法伪代码更有说服力。

10.3 构建记录说明代码来自真实工程

当前项目构建输出仍然是:

BUILD SUCCESSFUL
Will skip sign 'hos_hap'. No signingConfigs profile is configured in current project.

这条记录的意义是:本文分析的不是脱离项目的伪代码,而是来自可构建的 HarmonyOS 工程;当前风险点在签名配置,而不在 GIF 导出实现本身。

十一、工程复盘

PaletteQuantizer 单独拆开后,我对这套实现有三个更明确的判断:

  1. 当前版本选择 median-cut,不是为了做最先进的色彩科学,而是为了在 ArkTS 本地工程里拿到“稳定、够快、可维护”的 256 色调色板。
  2. buildPalette() 的采样策略和 nearestIndex() 的缓存策略是一对组合拳,前者控制样本规模,后者控制映射成本,二者缺一不可。
  3. 把量化单独放在 GifEncoderService 之前,是正确的分层。编码器负责写合法 GIF,量化器负责把 RGB 帧变成合法索引帧,职责边界清晰,后续替换算法也更容易。

十二、验收清单

验收项 结果 说明
多帧 RGB 数据会先汇总采样 通过 buildPalette() 先统计 totalPixels 再按步长采样
调色板数量被限制在 2..256 通过 quantize()Math.max(2, Math.min(256, maxColors)) 控制
量化策略为 widest-channel median-cut 通过 widestChannel() + 排序 + 中位切分
每个分箱最终输出代表色 通过 averageColor() 负责生成最终色表
RGB 像素会映射成索引帧 通过 toIndexedFrame() 把每个像素写进 indices[]
最近色匹配带缓存 通过 nearestIndex() 使用 Map<number, number> 复用查询结果
量化结果已接入真实导出链路 通过 编辑页与作品页截图可对应导出闭环
项目当前可正常构建 通过 构建日志显示 BUILD SUCCESSFUL

十三、小结

第 08 篇真正想说明的,不是“median-cut 是什么教科书算法”,而是一个本地优先的 HarmonyOS GIF 工具,为什么必须认真处理颜色量化这一步。PaletteQuantizer.ets 当前实现不追求炫技,但它把最重要的事做对了:先控制样本规模,再稳定生成全局调色板,再把高频颜色映射成本压下来,最后把结果交给 GIF 编码器。

对于“动图魔方”这种强调端侧导出和真实作品落盘的工具来说,这条路线是成立的,而且是值得继续保留的工程基线。

十四、下一篇衔接

下一篇进入第 09 篇:动图魔方技术拆解 09:FrameProcessor 如何统一裁剪比例、滤镜、字幕和输出参数。到那一篇我会把量化之前的上游处理链路拆开,重点讲 FrameProcessor.ets 怎样在同一条流水线上处理图片、视频帧和 GIF 再编辑输入,并保证导出参数对齐。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐