为 AI 配色提案保留像素账本
把图片交给 AI,请它总结“这是什么配色”,会得到很有气氛的词。可如果后续要生成主题变量,仍需要一个稳定的数值底座:到底哪些颜色出现了多少次?我会先从像素提取少量代表色,再让模型帮忙命名与讨论用途。
八叉树要合并颜色,不能合并掉像素
八叉树把 RGB 每一位组合成 0 到 7 的分支。每插入一个像素,沿途节点都累加数量与通道总和。减少颜色时,折叠一个节点的子树,用该节点累计的均值作为代表色。无论树怎样折叠,输出色桶的数量总和都应该等于输入像素数。
这个不变量很重要。若把根节点或只剩一个孩子的节点排除在归并之外,某些实现会把未统计的内部节点当成叶子,返回一张看似正常、实际上丢了像素的调色板。五个测试像素——黑、白、红、白、黑——足够暴露这类问题。
明确“最多”,不承诺“恰好”
下面每次折叠最深层的一个节点,优先处理样本少的节点。它是教学用的八叉树量化,不是最小感知误差算法;一次折叠可能跨过目标数量,所以maxColors=2可能只剩一个颜色。若产品必须恰好给出两色,需要另一种拆分或聚类策略,不能偷偷复制色桶凑数。
Nano Banana 2.1 的图像编辑说明适合了解后续参考图工作流,但提取的 RGB 也不能保证生成图严格遵守。应把色值、占比、命名提案分开,让模型解释色彩关系,不替代原始统计。
运行node palette.js,验证像素总数守恒、色数上限、单色输入和重复像素加权。示例直接使用 RGB 数组,未测试图片解码、透明合成、浏览器或真实模型;颜色距离也没有做感知空间校准。保留这份账本,创意建议才有可以回头核对的依据。
可独立运行的核心例子
function palette(pixels,maxColors) { if(!Number.isInteger(maxColors)||maxColors<1||!pixels.length)throw Error('数量无效'); const node=depth=>({depth,count:0,sum:[0,0,0],children:new Map()}); const root=node(0); for(const rgb of pixels) { if(rgb.length!==3||rgb.some(v=>!Number.isInteger(v)||v<0||v>255))throw Error('RGB无效'); let n=root; for(let depth=0;depth<=8;depth++) { n.count++;rgb.forEach((v,i)=>n.sum[i]+=v); if(depth===8)break; const bit=7-depth,k=((rgb[0]>>bit&1)<<2)|((rgb[1]>>bit&1)<<1)|(rgb[2]>>bit&1); if(!n.children.has(k))n.children.set(k,node(depth+1)); n=n.children.get(k); } } const leaves=n=>n.children.size?[...n.children.values()].flatMap(leaves):[n]; while(leaves(root).length>maxColors) { const candidates=[]; function visit(n) { if(!n.children.size)return; if([...n.children.values()].every(c=>!c.children.size))candidates.push(n); else n.children.forEach(visit); } visit(root); candidates.sort((a,b)=>b.depth-a.depth||a.count-b.count); if(!candidates.length)throw Error('归并无进展'); candidates[0].children.clear(); } return leaves(root).map(n=>({rgb:n.sum.map(v=>Math.round(v/n.count)),count:n.count})); } const px=[[0,0,0],[255,255,255],[255,0,0],[255,255,255],[0,0,0]]; const out=palette(px,2); if(out.length>2||out.reduce((s,c)=>s+c.count,0)!==5)throw Error('像素丢失'); const one=palette([[12,34,56],[12,34,56]],8); if(one.length!==1||one[0].count!==2||one[0].rgb.join(',')!=='12,34,56')throw Error('单色失败'); const weighted=palette([[0,0,0],[0,0,0],[255,255,255]],1)[0]; if(weighted.rgb[0]!==85||weighted.count!==3)throw Error('加权错误'); console.log(JSON.stringify(out));