动图魔方技术拆解 08:Palette Quantizer 如何把 PixelMap 压到 256 色
SEO 信息
- SEO 标题:动图魔方技术拆解 08:Palette Quantizer 如何把 PixelMap 压到 256 色
- SEO 摘要:基于 HarmonyOS NEXT / ArkTS 项目“动图魔方”,继续拆解 GIF 导出链路里最关键的颜色量化环节。本文聚焦
PaletteQuantizer.ets与FrameProcessor.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 编码器时,都必须变成同一种东西:
- 尺寸已经统一过的 RGB 帧。
- 不超过 256 色的全局调色板。
- 每个像素都能在调色板里找到一个索引值。
这就是 PaletteQuantizer 存在的原因。它解决的不是“做一个好看的调色板”这么抽象,而是下面几个非常具体的工程问题:
- 多帧素材颜色总量远超 256 色,必须先压缩到 GIF 容器能接受的范围。
- 颜色压缩不能只看单帧,否则多帧之间会闪色、跳色。
- 量化算法必须足够轻,能放进端侧导出链路,不把 UI 卡死。
- 调色板一旦产出,就要支持海量像素重复查询,不能每个像素都全量暴力算一遍。
二、本文目标与边界
本文只回答三件事:
FrameProcessor如何从多帧 RGB 数据里抽样,构建量化输入。PaletteQuantizer如何用 median-cut 生成不超过 256 色的调色板。nearestIndex()为什么要做缓存,以及它怎样把 RGB 帧落成 GIF 索引帧。
本文不展开的内容也先说明:
- 不重复第 06 篇里的 GIF89a 容器结构。
- 不重复第 07 篇里的 LZW 编码与数据子块写入。
- 不讨论抖动算法、透明色策略和感知色彩空间优化,这些属于后续可选增强,不是当前项目的首版基线。
三、量化发生在导出链路的什么位置
量化并不是一个独立实验函数,而是 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
};
}
这里有三个工程判断很关键:
- 先做“全局调色板”,再做“逐像素索引映射”,避免每帧各自产生一套色表。
- 采样是在所有帧上一起做,不是只拿第一帧代表全部,避免动图播放时色彩不连续。
- 映射结果直接写成
indices[],下一步就能交给GifEncoderService做 LZW 压缩和文件落盘。
四、为什么不是直接拿全部像素做调色板
如果把每一帧每一个像素都完整丢给量化器,颜色质量当然可能更高,但端侧导出会很快碰到两个现实问题:
- 大尺寸素材 + 多帧时,样本量会爆炸,排序和分箱成本直接上去。
- 很多颜色本来就高度重复,全量采集只会增加计算,不一定提升结果。
所以 FrameProcessor.buildPalette() 先算出总像素数,再用:
const stride = Math.max(1, Math.floor(totalPixels / MAX_SAMPLE_COLORS));
做步进采样。这个做法的价值不在“绝对最优”,而在“端侧可控”:
- 当素材较小,
stride会回落到1,等价于尽量全采样。 - 当素材很大,采样步长自动增大,把计算量压回可接受范围。
- 抽样逻辑不依赖具体素材来源,图片、视频、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;
}
这段实现的核心思路可以压缩成四步:
- 先把所有采样颜色放进一个大盒子。
- 找到 RGB 三个通道里跨度最大的那个通道。
- 按这个通道排序,从中间切成左右两个盒子。
- 重复切分,直到盒子数量达到
maxColors或已经切不动。
项目里没有做花哨的权重优化,但这套实现有两个非常实用的优点:
- 代码短,便于维护和调试,适合 ArkTS 本地项目长期保留。
- 结果稳定,足够支撑 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% 的问题:
- 大面积渐变会被拆成更接近的颜色簇。
- 高饱和色不会过早被灰色背景“平均掉”。
- 全局调色板仍能保持稳定结构,方便后续所有帧共用。
七、平均色为什么是最终输出
盒子切完以后,项目没有保留“盒子边界”,而是直接把每个盒子平均成一个颜色:
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;
}
这意味着调色板本质上是一组“代表色”。它不保证每个样本都能完全还原,但能保证:
- 每个分箱都有一个稳定落点。
- 输出色表长度可控,天然满足 GIF 的 256 色边界。
- 后续最近色匹配时,所有像素都能映射到一套统一色域。
对端侧应用来说,这种策略特别务实。因为项目真正要的是“把多帧内容稳定导出来”,不是做离线美术软件级别的颜色保真。
八、最近色匹配为什么一定要加缓存
有了调色板以后,还要把每个像素的 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,带来的收益很直接:
- 相同颜色第二次出现时可以 O(1) 复用。
- 静态背景越多,缓存收益越明显。
- 代码不复杂,不需要引入额外数据结构。
这类缓存不是“锦上添花”的优化,而是端侧多帧处理里非常典型的降耗手段。
九、量化验证日志
为了避免只停留在算法描述,我按 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
这组日志至少说明了四件事:
- median-cut 最终确实把 16 个样本收敛成了 6 个代表色。
- 同一批查询颜色第一次映射后,缓存里只留下 8 个唯一颜色键值。
- 重复颜色不会每次都重新扫完整个调色板。
- 输出索引分布是稳定的,说明量化结果已经能直接供
IndexedGifFrame.indices[]使用。
十、工程截图与证据
10.1 编辑页说明量化不是孤立函数

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

导出后的作品页能看到真实 GIF 文件已经落地,这意味着“采样 -> 调色板 -> 索引帧 -> LZW -> 文件写盘”这条链路是贯通的。对颜色量化来说,这比单独展示一段算法伪代码更有说服力。
10.3 构建记录说明代码来自真实工程
当前项目构建输出仍然是:
BUILD SUCCESSFUL
Will skip sign 'hos_hap'. No signingConfigs profile is configured in current project.
这条记录的意义是:本文分析的不是脱离项目的伪代码,而是来自可构建的 HarmonyOS 工程;当前风险点在签名配置,而不在 GIF 导出实现本身。
十一、工程复盘
把 PaletteQuantizer 单独拆开后,我对这套实现有三个更明确的判断:
- 当前版本选择 median-cut,不是为了做最先进的色彩科学,而是为了在 ArkTS 本地工程里拿到“稳定、够快、可维护”的 256 色调色板。
buildPalette()的采样策略和nearestIndex()的缓存策略是一对组合拳,前者控制样本规模,后者控制映射成本,二者缺一不可。- 把量化单独放在
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 再编辑输入,并保证导出参数对齐。
更多推荐


所有评论(0)