SEO 信息SEO 标题动图魔方技术拆解 08Palette 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 SDK6.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: Mapnumber, 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: Mapnumber, 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()使用Mapnumber, number复用查询结果量化结果已接入真实导出链路通过编辑页与作品页截图可对应导出闭环项目当前可正常构建通过构建日志显示BUILD SUCCESSFUL十三、小结第 08 篇真正想说明的不是“median-cut 是什么教科书算法”而是一个本地优先的 HarmonyOS GIF 工具为什么必须认真处理颜色量化这一步。PaletteQuantizer.ets当前实现不追求炫技但它把最重要的事做对了先控制样本规模再稳定生成全局调色板再把高频颜色映射成本压下来最后把结果交给 GIF 编码器。对于“动图魔方”这种强调端侧导出和真实作品落盘的工具来说这条路线是成立的而且是值得继续保留的工程基线。十四、下一篇衔接下一篇进入第 09 篇动图魔方技术拆解 09FrameProcessor 如何统一裁剪比例、滤镜、字幕和输出参数。到那一篇我会把量化之前的上游处理链路拆开重点讲FrameProcessor.ets怎样在同一条流水线上处理图片、视频帧和 GIF 再编辑输入并保证导出参数对齐。