HomeJournalThis post

Median Cut vs K-Means for Color Palettes

Compare median cut and seeded k-means on matched generated images, then preserve rare colors, stability evidence, and human art direction.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Median cut vs k-means color palette extraction produces candidates, not finished art direction. This guide compares deterministic partitions and seeded centroids across identical generated pixels, rare accents, stability, and error.

Median cut vs k-means color palette starts with the job

A palette for file-size reduction is not automatically a good palette for design tokens, illustration, or mood. Median cut partitions occupied color space into boxes; k-means moves centroids to reduce assignment error. These color quantization algorithms begin with different search structures. Before comparing them, define what the palette must preserve and how a human will use it.

For compression, population-weighted reconstruction error may dominate. For a brand mood board, one rare accent might matter more than a large neutral background. For interface tokens, contrast roles and naming matter after extraction. Median cut vs k-means color palette is therefore a candidate-generation decision, not an automatic art director.

Freeze the pixel contract: decoded dimensions, alpha handling, crop, sampling, background compositing, color space, and palette size. The studio uses generated fixtures so licenses and provenance are unambiguous. It reports numerical error as evidence about the fixture, never as proof of beauty, accessibility, or brand fit. The first figure puts the two algorithms over the same color cloud to keep the comparison matched.

Splitting boxes and moving centroidsMedian cut recursively partitions one RGB cloud while seeded k-means migrates centroids through the identical generated pixels.MEDIAN CUTSEEDED K-MEANSrecursive population splitsassignment ↔ centroid updates
Splitting boxes and moving centroids
Median cut recursively partitions one RGB cloud while seeded k-means migrates centroids through the identical generated pixels.
Algorithm anatomy
MethodStateIterationDeterminism policy
Median cutOccupied RGB boxesChoose box, axis, median splitRGB tie order and source index
K-meansCentroids and assignmentsAssign, average, repeatSeeded initialization and explicit empty repair
Figure 1: Both candidates see the same pixels; only the partition mechanism changes.

Prepare pixels and declare the color space

Decode to a known RGB representation, decide how transparent pixels contribute, and sample deterministically. Compositing transparent edges over black can manufacture dark colors; ignoring alpha can discard meaningful translucent paint. Record the policy with the export. Downsampling should preserve rare colors if they are important to the job.

Median cut vs k-means color palette needs one declared distance space. The teaching studio optimizes simple RGB squared error because it keeps the implementation inspectable. RGB distance is not perceptually uniform. The CSS Color 4 color-difference section provides a standards context for perceptual difference, but changing metrics also changes the optimization contract.

Do not compare median cut in RGB with k-means in another space and attribute every difference to the algorithm. Convert both inputs and centroids consistently if testing OKLab or Lab. Keep gamut mapping explicit. A wider or perceptual space can improve some judgments while creating conversion and clipping decisions that deserve their own receipt.

Build median cut as deterministic partitioning

Median cut begins with a box containing sampled pixels. Select a box to split, choose the channel with the largest declared range, sort by that channel, and divide near the population median. Repeat until the palette reaches its target size, then represent each leaf with a mean or another declared statistic. The classic median-cut paper is the primary reference for the adaptive quantization lineage.

Every tie needs a rule. The studio orders channels RGB when ranges tie, keeps source index as a stable secondary sort, selects the heaviest splittable box, and represents leaves with population-weighted means. Those decisions make median cut quantization repeatable and make its palette extraction receipt reviewable.

Median cut is fast to explain because its history is a tree. It may preserve broad occupancy well, yet rectangular boxes do not follow curved perceptual clusters. A rare accent can receive no leaf if population policy dominates. Export the split tree and populations so a missing color can be traced to a specific branch rather than blamed on vague “quantization.”

Build k-means with visible initialization

K-means alternates assignment to the nearest centroid and replacement of each centroid with its assigned mean. It converges to a local solution, so initialization and tie behavior matter. The original MacQueen paper establishes the sequential clustering lineage; modern implementations often use other initialization strategies.

The studio uses a seeded farthest-first initializer and deterministic tie order. It reports iterations, centroid movement, population, and empty-cluster repair. If a cluster becomes empty, the implementation moves the point with greatest current assignment error into it, then continues. Silent random replacement would make receipts hard to compare.

For median cut vs k-means color palette, k-means often attains lower error for its stated metric because centroid movement directly optimizes assignments. That does not guarantee a more useful palette. Large neutral areas can absorb centroids while a semantically important accent disappears. A seed sweep of k-means image colors reveals stability; it does not convert preference into fact.

Protect rare but important colors

Population-weighted error treats every pixel equally, which means a tiny accent has tiny influence. Product images, portraits, and posters often contain small colors that carry identity: an alert, lip color, signature mark, or specular highlight. Declare masks or semantic regions before selecting a palette, and report their retention separately.

Median cut vs k-means color palette can be extended with reserved colors, stratified sampling, weighted points, or post-selection curation. Each intervention changes the objective. Do not say “the algorithm found the brand accent” if a hand-authored mask protected it. Say the accent policy reserved or weighted that region.

The generated accent fixture contains a small coral mark against a large blue-green field. The studio reports nearest-palette distance and represented population for that mask. A portrait-like fixture is geometric and synthetic; it is not a skin-tone fairness study. These labels keep useful diagnostics from borrowing authority they have not earned.

Measure stability across crops, seeds, and sizes

A palette that changes radically after a one-pixel crop is difficult to use as a stable design input. Rerun extraction across crop variants, k-means seeds, and palette sizes. Because palette order is arbitrary, match colors before comparing them; otherwise the same set in a different order looks unstable.

The studio performs minimum-cost matching by exhaustive permutation for its small palette size. It reports matched color displacement, not just equal index positions. Median cut has no random seed in this implementation, but crop and tie boundaries can still alter leaves. K-means adds initialization sensitivity.

Median cut vs k-means color palette should include naming stability too. Sort final swatches by a declared display rule only after evaluation, and keep the algorithm’s internal identity in the receipt. A design-token export may name roles manually; do not derive “primary” from population alone. Stability is evidence that candidates are robust to small input changes, not evidence that the visual direction is right.

Artifact-derived accent fixture outputsThe pinned accent fixture shows its source, both quantized outputs, both error maps, exact swatches, populations, and rare-accent distances from the deterministic studio.sourcemedian outputk-means outputmedian errork-means errormedian populations: 1554 · 1516 · 1780 · 1294k-means populations: 2048 · 88 · 2112 · 1896rare accent: 88 pxnearest median distance: 177.11nearest k-means distance: 0.00median MSE: 211.417k-means MSE: 65.926median output: 2a9621e9k-means output: 52b86e65encoded RGB · generated 96×64
Artifact-derived accent fixture outputs
The pinned accent fixture shows its source, both quantized outputs, both error maps, exact swatches, populations, and rare-accent distances from the deterministic studio.
Pinned accent-fixture outputs from the palette studio
MethodSwatches with populationsOutput / assignment / error hashesMSERare-accent distance
Median cutrgb(29 124 131) · 1554; rgb(48 124 143) · 1516; rgb(68 124 155) · 1780; rgb(97 122 163) · 12942a9621e9 / b72a4f37 / 77c62690211.416938177.108144
K-meansrgb(59 124 150) · 2048; rgb(255 86 92) · 88; rgb(33 124 133) · 2112; rgb(84 124 165) · 189652b86e65 / fd3a5105 / e8dd220665.9259440.000000
Figure 2: Every raster, population, hash, and error value comes from the studio’s pinned 96×64 generated accent fixture.

Compare error without confusing it with taste

Report mean squared error in the optimization space, a spatial error map, cluster populations, and any masked-region error. These explain what the algorithms compressed. They do not measure harmony, contrast accessibility, cultural meaning, or emotional fit. A lower scalar can coexist with a duller or less distinctive palette.

For median cut vs k-means color palette, compare identical pixels, palette size, space, alpha policy, and sampling. If k-means is allowed more colors or median cut receives a different crop, the table is decorative. Display iterations and empty-cluster repairs beside error so operating differences stay visible.

If the palette will drive accessible UI, test contrast after assigning semantic roles; extracted swatches alone have no foreground/background contract. If it will drive texture work, add spatial dithering after palette selection rather than crediting the palette extractor for spatial structure. Numerical evidence narrows candidates. Human art direction chooses what the visual system should communicate.

Run the generated palette studio

The browser artifact supplies four generated fixtures: gradient, rare accent, portrait-like geometry, and deterministic noise. Both algorithms consume the same pixel buffer and palette size. Controls change seed, crop, and number of colors. The receipt includes input hash, color space, algorithm policy, swatches, populations, assignment error, convergence, empty clusters, and stability matching.

Start with four colors. Inspect the original, both quantized canvases, and error maps before reading the scalar table. Then change only the seed; median-cut output should remain fixed while k-means may move. Change the crop and watch both methods. Export the swatch sheet and JSON receipt together.

The median cut vs k-means color palette outputs are measurements of generated fixtures in this executing browser. They are not a benchmark across devices, a user study, or a universal ranking. The studio’s deterministic supported path hashes rendered pixels so regression tests can catch implementation drift. Canvas export is checked by decoding the produced PNG before the download link is enabled.

Runnable artifact — A synthetic palette-extraction study; error metrics do not establish perceptual preference, brand fit, accessibility, or universal algorithm superiority.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Palette quantization studio</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{max-width:1080px;margin:auto;padding:22px;background:#131015;color:#fff;font:16px/1.5 system-ui}button,select,input,a{font:inherit;min-height:44px;padding:8px}.controls{display:flex;gap:12px;flex-wrap:wrap}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:14px}figure{margin:0;padding:12px;border:1px solid #88798e;border-radius:14px}canvas{width:100%;image-rendering:pixelated;background:#08070a}.swatches{display:flex;min-height:52px}.swatches span{flex:1;border:2px solid #fff}.scroll{overflow:auto}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #88798e;padding:7px;text-align:left}textarea{width:100%;min-height:280px;background:#09070a;color:#fff}.downloads{display:flex;gap:8px;flex-wrap:wrap}.downloads a[aria-disabled="true"]{opacity:.45;pointer-events:none}@media(max-width:520px){body{padding:12px}}</style><main><h1>Median cut vs k-means palette studio</h1><p>Generated fixtures, deterministic encoded-RGB objective, and explicit curator boundary. Error is not aesthetic preference.</p><div class="controls"><label>Fixture <select id="fixture"><option>accent</option><option>gradient</option><option>portrait-like</option><option>noise</option></select></label><label>Colors <input id="count" type="number" value="4" min="2" max="6" required></label><label>Seed <input id="seed" type="number" value="904" required></label><label>Crop <select id="crop"><option value="none">none</option><option value="trim-left">trim-left</option><option value="trim-top">trim-top</option></select></label><button id="run">Extract both palettes</button></div><p id="status" aria-live="polite"></p><div class="grid"><figure><canvas id="source" width="96" height="64" aria-label="Generated source pixels"></canvas><figcaption>Generated source</figcaption></figure><figure><canvas id="median" width="96" height="64" aria-label="Median-cut quantized output"></canvas><figcaption>Median cut output</figcaption><div id="medianSwatches" class="swatches" aria-label="Median cut swatches"></div></figure><figure><canvas id="medianAssign" width="96" height="64" aria-label="Median-cut assignment map"></canvas><figcaption>Median cut assignments</figcaption></figure><figure><canvas id="medianError" width="96" height="64" aria-label="Median-cut error map"></canvas><figcaption>Median cut error map</figcaption></figure><figure><canvas id="means" width="96" height="64" aria-label="K-means quantized output"></canvas><figcaption>K-means output</figcaption><div id="meansSwatches" class="swatches" aria-label="K-means swatches"></div></figure><figure><canvas id="meansAssign" width="96" height="64" aria-label="K-means assignment map"></canvas><figcaption>K-means assignments</figcaption></figure><figure><canvas id="meansError" width="96" height="64" aria-label="K-means error map"></canvas><figcaption>K-means error map</figcaption></figure><figure><canvas id="swatchSheet" width="96" height="16" aria-label="Downloadable median-cut and k-means swatch sheet"></canvas><figcaption>Paired swatch sheet: median cut above, k-means below</figcaption></figure></div><div id="semantic" class="scroll" aria-label="Accessible palette, assignment, and error evidence"></div><textarea id="receipt" readonly aria-label="Execution receipt"></textarea><p class="downloads"><a id="download" download="palette-receipt.json">Receipt JSON</a> <a id="paletteJson" download="palette-values.json">Palette JSON</a> <a id="swatchPng" download="palette-swatch-sheet.png">Swatch-sheet PNG</a> <a id="medianPng" download="median-cut-output.png">Median output PNG</a> <a id="meansPng" download="k-means-output.png">K-means output PNG</a> <a id="medianAssignmentPng" download="median-cut-assignment-map.png">Median assignment PNG</a> <a id="meansAssignmentPng" download="k-means-assignment-map.png">K-means assignment PNG</a> <a id="medianErrorPng" download="median-cut-error-map.png">Median error PNG</a> <a id="meansErrorPng" download="k-means-error-map.png">K-means error PNG</a> <a id="evidence" download="palette-lossless-evidence.json">Lossless map evidence JSON</a></p></main><script>
const ids=id=>document.getElementById(id),W=96,H=64,clamp=v=>Math.max(0,Math.min(255,Math.round(v)));function rand(seed){let s=seed>>>0;return()=>{s^=s<<13;s^=s>>>17;s^=s<<5;return(s>>>0)/4294967296}}
function pixels(name,seed){const r=rand(seed),out=[];for(let y=0;y<H;y++)for(let x=0;x<W;x++){let p;if(name==='gradient')p=[35+x*1.8,45+y*2.2,180-x*.7];else if(name==='accent')p=(x>70&&x<79&&y>12&&y<24)?[255,86,92]:[20+x*.8,105+y*.6,125+x*.5];else if(name==='portrait-like'){const face=((x-48)/22)**2+((y-31)/28)**2<1;p=face?[190+x*.25,118+y*.35,92+y*.2]:[28+x*.3,48+y*.2,85+x*.4]}else p=[r()*255,r()*255,r()*255];out.push(p.map(clamp))}return out}
const dist=(a,b)=>a.reduce((s,v,i)=>s+(v-b[i])**2,0),mean=rows=>[0,1,2].map(i=>rows.reduce((s,p)=>s+p[i],0)/rows.length);function medianCut(data,k){let boxes=[data.map((p,i)=>({p,i}))];while(boxes.length<k){boxes.sort((a,b)=>b.length-a.length);const box=boxes.shift(),ranges=[0,1,2].map(c=>Math.max(...box.map(x=>x.p[c]))-Math.min(...box.map(x=>x.p[c]))),axis=ranges.indexOf(Math.max(...ranges));box.sort((a,b)=>a.p[axis]-b.p[axis]||a.i-b.i);const mid=Math.floor(box.length/2);boxes.push(box.slice(0,mid),box.slice(mid))}return boxes.map(box=>mean(box.map(x=>x.p)))}
function kmeans(data,k,seed){const random=rand(seed),centers=[data[Math.floor(random()*data.length)]];while(centers.length<k){const ranked=data.map((p,index)=>({p,index,d:Math.min(...centers.map(c=>dist(p,c)))})).sort((a,b)=>b.d-a.d||a.index-b.index);centers.push(ranked[0].p)}let emptyRepairs=0,iterations=0,assign=[];for(;iterations<20;iterations++){assign=data.map(p=>{let best=0;for(let i=1;i<centers.length;i++)if(dist(p,centers[i])<dist(p,centers[best]))best=i;return best});let changed=0;for(let i=0;i<k;i++){let rows=data.filter((_,j)=>assign[j]===i);if(!rows.length){rows=[data.map((p,index)=>({p,index,d:dist(p,centers[assign[index]])})).sort((a,b)=>b.d-a.d||a.index-b.index)[0].p];emptyRepairs++}const next=mean(rows);changed=Math.max(changed,Math.sqrt(dist(next,centers[i])));centers[i]=next}if(changed<.01)break}return{centers,iterations:iterations+1,emptyRepairs,tieRule:'lowest centroid index for equal squared distance; empty repair uses farthest assigned pixel then lowest source index'}}
const nearest=(p,pal)=>pal.reduce((best,c,i)=>dist(p,c)<dist(p,pal[best])?i:best,0);function permutations(values){if(values.length<=1)return[values];return values.flatMap((value,index)=>permutations(values.filter((_,i)=>i!==index)).map(rest=>[value,...rest]))}function minimumCostMatch(a,b){if(a.length!==b.length||a.length>6)throw new RangeError('minimum-cost matcher supports equal palettes with k<=6');let best=null;for(const order of permutations([...b.keys()])){const distances=a.map((p,i)=>Math.sqrt(dist(p,b[order[i]]))),total=distances.reduce((sum,value)=>sum+value,0);if(!best||total<best.total)best={order,distances,total,mean:total/a.length}}return best}function independentNearestTotal(a,b){return a.reduce((sum,p)=>sum+Math.sqrt(Math.min(...b.map(q=>dist(p,q)))),0)}
function paintCanvas(canvas,data,width=canvas.width,height=canvas.height){const ctx=canvas.getContext('2d'),im=ctx.createImageData(width,height);data.forEach((p,i)=>im.data.set([...p.map(clamp),255],i*4));ctx.putImageData(im,0,0)}function paint(canvasId,data){paintCanvas(ids(canvasId),data)}function hash(data){let h=2166136261;for(const p of data.flat(Infinity))h=Math.imul(h^(Number(p)>>>0),16777619);return(h>>>0).toString(16).padStart(8,'0')}function croppedPixels(name,seed,crop){const base=pixels(name,seed);if(crop==='none')return base;return base.map((_,i)=>{const x=i%W,y=Math.floor(i/W),sx=crop==='trim-left'?Math.min(W-1,x+1):x,sy=crop==='trim-top'?Math.min(H-1,y+1):y;return base[sy*W+sx]})}function outputFor(src,pal,assignments=src.map(p=>nearest(p,pal))){return src.map((_,i)=>pal[assignments[i]].map(clamp))}function paletteHash(pal){return hash(pal.map(p=>p.map(clamp)))}function errorPixels(src,out){return src.map((p,i)=>{const e=clamp(Math.sqrt(dist(p,out[i]))*3);return[e,e,e]})}function assignmentPixels(assignments,palette){return assignments.map(index=>palette[index].map(clamp))}function indexPixels(assignments){return assignments.map(index=>[index,index,index])}function swatchPixels(medianPalette,meansPalette,k){return Array.from({length:W*16},(_,index)=>{const x=index%W,y=Math.floor(index/W),palette=y<8?medianPalette:meansPalette;return palette[Math.min(k-1,Math.floor(x*k/W))].map(clamp)})}function canvasFor(data,width=W,height=H){const canvas=document.createElement('canvas');canvas.width=width;canvas.height=height;paintCanvas(canvas,data,width,height);return canvas}const canvasBlob=canvas=>new Promise(resolve=>canvas.toBlob(resolve,'image/png'));async function validatePng(canvas,expected,mode='rgb'){const blob=await canvasBlob(canvas),width=canvas.width,height=canvas.height;let decoded=false,decodedWidth=0,decodedHeight=0,pixelsMatch=false,decodedPixelHash=null;try{const bitmap=await createImageBitmap(blob),decodedCanvas=document.createElement('canvas');decodedWidth=bitmap.width;decodedHeight=bitmap.height;decodedCanvas.width=decodedWidth;decodedCanvas.height=decodedHeight;decodedCanvas.getContext('2d').drawImage(bitmap,0,0);const rgba=decodedCanvas.getContext('2d').getImageData(0,0,decodedWidth,decodedHeight).data,decodedData=[];pixelsMatch=decodedWidth===width&&decodedHeight===height&&expected.length===width*height;for(let index=0;index<expected.length&&pixelsMatch;index++){const offset=index*4;if(mode==='index'){const value=expected[index];decodedData.push(value);pixelsMatch=rgba[offset]===value&&rgba[offset+1]===value&&rgba[offset+2]===value&&rgba[offset+3]===255}else{const value=expected[index].map(clamp);decodedData.push(value);pixelsMatch=rgba[offset]===value[0]&&rgba[offset+1]===value[1]&&rgba[offset+2]===value[2]&&rgba[offset+3]===255}}decoded=decodedWidth===width&&decodedHeight===height;decodedPixelHash=pixelsMatch?hash(decodedData):null;bitmap.close()}catch{}return{blob,receipt:{mime:blob.type,bytes:blob.size,decoded,width:decodedWidth,height:decodedHeight,pixelsMatch,sourcePixelHash:hash(expected),decodedPixelHash,encoding:mode==='index'?'RGB channels each store the exact zero-based palette index; alpha 255':'exact 8-bit RGB channels; alpha 255'}}}function jsonExport(payload){const text=JSON.stringify(payload,null,2),reparsed=JSON.parse(text),blob=new Blob([text],{type:'application/json'});return{blob,receipt:{mime:blob.type,bytes:new TextEncoder().encode(text).length,reparsed:JSON.stringify(reparsed)===JSON.stringify(payload)}}}function setLink(id,url){const link=ids(id);link.href=url||'';link.setAttribute('aria-disabled',url?'false':'true')}const downloadIds=['download','paletteJson','swatchPng','medianPng','meansPng','medianAssignmentPng','meansAssignmentPng','medianErrorPng','meansErrorPng','evidence'];
async function run(){ids('run').disabled=true;const fixture=ids('fixture').value,rawSeed=ids('seed').value.trim(),rawK=ids('count').value.trim(),seed=Number(rawSeed),k=Number(rawK),crop=ids('crop').value,valid=rawSeed!==''&&rawK!==''&&Number.isFinite(seed)&&Number.isInteger(seed)&&Number.isInteger(k)&&k>=2&&k<=6;if(!valid){const data={fixture:'palette-quantization-v3',valid:false,input:{fixture,seed:rawSeed,k:rawK,crop},errors:['seed must be a finite integer','palette size must be an integer from 2 through 6']};ids('receipt').value='FAIL: '+JSON.stringify(data,null,2);ids('receipt').dataset.execution=JSON.stringify(data);ids('status').textContent='FAIL — enter a finite integer seed and 2–6 colors.';downloadIds.forEach(id=>setLink(id,''));ids('run').disabled=false;return}
const src=croppedPixels(fixture,seed,crop),med=medianCut(src,k),km=kmeans(src,k,seed),medianAssignments=src.map(p=>nearest(p,med)),kmeansAssignments=src.map(p=>nearest(p,km.centers)),medOut=outputFor(src,med,medianAssignments),kmOut=outputFor(src,km.centers,kmeansAssignments),medErrors=errorPixels(src,medOut),kmErrors=errorPixels(src,kmOut),swatches=swatchPixels(med,km.centers,k),mse=(a,b)=>a.reduce((s,p,i)=>s+dist(p,b[i]),0)/(a.length*3),pop=assignments=>Array.from({length:k},(_,i)=>assignments.filter(value=>value===i).length);paint('source',src);paint('median',medOut);paint('medianAssign',assignmentPixels(medianAssignments,med));paint('medianError',medErrors);paint('means',kmOut);paint('meansAssign',assignmentPixels(kmeansAssignments,km.centers));paint('meansError',kmErrors);paintCanvas(ids('swatchSheet'),swatches,W,16);for(const [id,pal,populations] of [['medianSwatches',med,pop(medianAssignments)],['meansSwatches',km.centers,pop(kmeansAssignments)]])ids(id).innerHTML=pal.map((p,index)=>'<span role="img" aria-label="rgb '+p.map(clamp).join(' ')+' population '+populations[index]+'" title="rgb('+p.map(clamp).join(',')+'), population '+populations[index]+'" style="background:rgb('+p.map(clamp).join(',')+')"></span>').join('');
const seedSweep=[seed,seed+1,seed+2].map(value=>{const candidate=kmeans(src,k,value).centers,match=minimumCostMatch(km.centers,candidate);return{seed:value,paletteHash:paletteHash(candidate),minimumCostDisplacementFromCurrent:match.mean,assignment:match.order}}),cropSweep=['none','trim-left','trim-top'].map(name=>{const candidate=croppedPixels(fixture,seed,name),candidateMedian=medianCut(candidate,k),candidateMeans=kmeans(candidate,k,seed).centers;return{crop:name,inputHash:hash(candidate),medianPaletteHash:paletteHash(candidateMedian),kmeansPaletteHash:paletteHash(candidateMeans),medianMinimumCostDisplacement:minimumCostMatch(med,candidateMedian).mean,kmeansMinimumCostDisplacement:minimumCostMatch(km.centers,candidateMeans).mean}}),paletteSizeSweep=[2,3,4,5,6].map(size=>({k:size,medianPaletteHash:paletteHash(medianCut(src,size)),kmeansPaletteHash:paletteHash(kmeans(src,size,seed).centers)})),matcherOracle={source:[[0,0,0],[2,0,0]],target:[[1,0,0],[100,0,0]]};matcherOracle.independentNearestTotal=independentNearestTotal(matcherOracle.source,matcherOracle.target);matcherOracle.minimumBijectiveTotal=minimumCostMatch(matcherOracle.source,matcherOracle.target).total;matcherOracle.pass=matcherOracle.independentNearestTotal===2&&matcherOracle.minimumBijectiveTotal===99;
const medianPopulations=pop(medianAssignments),kmeansPopulations=pop(kmeansAssignments),medianAssignmentCanvas=canvasFor(indexPixels(medianAssignments)),meansAssignmentCanvas=canvasFor(indexPixels(kmeansAssignments)),exportsList=await Promise.all([validatePng(ids('median'),medOut),validatePng(ids('means'),kmOut),validatePng(ids('swatchSheet'),swatches),validatePng(medianAssignmentCanvas,medianAssignments,'index'),validatePng(meansAssignmentCanvas,kmeansAssignments,'index'),validatePng(ids('medianError'),medErrors),validatePng(ids('meansError'),kmErrors)]),[medianPng,meansPng,swatchPng,medianAssignmentPng,meansAssignmentPng,medianErrorPng,meansErrorPng]=exportsList,method=(palette,assignments,out,errors,extra={})=>({palette:palette.map(p=>p.map(clamp)),populations:assignments===medianAssignments?medianPopulations:kmeansPopulations,mse:mse(src,out),assignmentMapHash:hash(assignments),errorMapHash:hash(errors),outputHash:hash(out),maxError:Math.max(...errors.map(row=>row[0]/3)),...extra}),data={fixture:'palette-quantization-v3',valid:true,generatedFixture:fixture,seed,k,crop,width:W,height:H,colorSpace:'encoded RGB teaching objective',alphaPolicy:'opaque generated pixels',inputHash:hash(src),matching:{name:'exhaustive bijective minimum-cost RGB displacement',limit:'k<=6',oracle:matcherOracle},medianCut:method(med,medianAssignments,medOut,medErrors,{tieRule:'widest RGB range; source-index tie order',emptyRepairs:0,png:medianPng.receipt}),kmeans:method(km.centers,kmeansAssignments,kmOut,kmErrors,{iterations:km.iterations,emptyRepairs:km.emptyRepairs,tieRule:km.tieRule,initialization:'seeded farthest-first',png:meansPng.receipt}),rareAccent:{target:[255,86,92],representedPixels:src.filter(p=>p[0]===255&&p[1]===86&&p[2]===92).length,nearestMedianDistance:Math.sqrt(Math.min(...med.map(p=>dist([255,86,92],p)))),nearestKmeansDistance:Math.sqrt(Math.min(...km.centers.map(p=>dist([255,86,92],p))))},stability:{matching:'exhaustive bijective minimum-cost RGB displacement',seedSweep,cropSweep,paletteSizeSweep},exports:{medianOutputPng:medianPng.receipt,kmeansOutputPng:meansPng.receipt,swatchSheetPng:swatchPng.receipt,medianAssignmentMapPng:medianAssignmentPng.receipt,kmeansAssignmentMapPng:meansAssignmentPng.receipt,medianErrorMapPng:medianErrorPng.receipt,kmeansErrorMapPng:meansErrorPng.receipt},curatorBoundary:'error does not establish preference, brand fit, or accessibility'};
const palettePayload={schema:'palette-values-v1',fixture,seed,k,crop,width:W,height:H,colorSpace:data.colorSpace,medianCut:{palette:data.medianCut.palette,populations:medianPopulations,tieRule:data.medianCut.tieRule},kmeans:{palette:data.kmeans.palette,populations:kmeansPopulations,iterations:data.kmeans.iterations,emptyRepairs:data.kmeans.emptyRepairs,tieRule:data.kmeans.tieRule},stability:data.stability},evidencePayload={schema:'palette-lossless-maps-v1',fixture,seed,k,crop,width:W,height:H,assignmentEncoding:'one zero-based palette index per source pixel; PNG stores the index identically in R, G, and B',errorEncoding:'one exact 8-bit RGB error sample per source pixel',methods:{medianCut:{palette:data.medianCut.palette,assignmentIndices:medianAssignments,errorMapRgb:medErrors,assignmentMapHash:data.medianCut.assignmentMapHash,errorMapHash:data.medianCut.errorMapHash,outputHash:data.medianCut.outputHash},kmeans:{palette:data.kmeans.palette,assignmentIndices:kmeansAssignments,errorMapRgb:kmErrors,assignmentMapHash:data.kmeans.assignmentMapHash,errorMapHash:data.kmeans.errorMapHash,outputHash:data.kmeans.outputHash}},reconstruction:'For each pixel, select palette[assignmentIndices[pixel]]; hash the resulting RGB rows with the receipt FNV-1a routine.'},paletteJson=jsonExport(palettePayload),evidenceJson=jsonExport(evidencePayload);data.exports.paletteJson=paletteJson.receipt;data.exports.losslessEvidenceJson=evidenceJson.receipt;const semantic={fixture,seed,k,colorSpace:data.colorSpace,methods:{medianCut:data.medianCut,kmeans:data.kmeans},rareAccent:data.rareAccent,stability:data.stability,exports:data.exports};ids('semantic').innerHTML='<table><caption>Accessible paired palette evidence</caption><thead><tr><th>Method</th><th>Swatches and populations</th><th>Assignment hash</th><th>Error hash / MSE</th><th>Diagnostics</th></tr></thead><tbody><tr><th>Median cut</th><td>'+data.medianCut.palette.map((p,i)=>'rgb('+p.join(' ')+') · '+data.medianCut.populations[i]).join('; ')+'</td><td>'+data.medianCut.assignmentMapHash+'</td><td>'+data.medianCut.errorMapHash+' / '+data.medianCut.mse.toFixed(3)+'</td><td>tie: '+data.medianCut.tieRule+'; repairs: 0</td></tr><tr><th>K-means</th><td>'+data.kmeans.palette.map((p,i)=>'rgb('+p.join(' ')+') · '+data.kmeans.populations[i]).join('; ')+'</td><td>'+data.kmeans.assignmentMapHash+'</td><td>'+data.kmeans.errorMapHash+' / '+data.kmeans.mse.toFixed(3)+'</td><td>tie: '+data.kmeans.tieRule+'; repairs: '+data.kmeans.emptyRepairs+'</td></tr></tbody></table>';const pass=matcherOracle.pass&&exportsList.every(item=>item.receipt.decoded&&item.receipt.pixelsMatch)&&paletteJson.receipt.reparsed&&evidenceJson.receipt.reparsed;const receipt=ids('receipt');receipt.value=(pass?'PASS: ':'FAIL: ')+JSON.stringify(data,null,2);receipt.dataset.execution=JSON.stringify(data);ids('status').textContent=pass?'PASS — ten palette, swatch, output, assignment, error, and receipt downloads are verified.':'FAIL — export, decode, or matching oracle failed.';const blobs={download:new Blob([JSON.stringify(data,null,2)],{type:'application/json'}),paletteJson:paletteJson.blob,swatchPng:swatchPng.blob,medianPng:medianPng.blob,meansPng:meansPng.blob,medianAssignmentPng:medianAssignmentPng.blob,meansAssignmentPng:meansAssignmentPng.blob,medianErrorPng:medianErrorPng.blob,meansErrorPng:meansErrorPng.blob,evidence:evidenceJson.blob};downloadIds.forEach(id=>setLink(id,pass?URL.createObjectURL(blobs[id]):''));ids('run').disabled=false}ids('run').onclick=run;run();
</script></html>

Curate candidates into a visual system

Once both palettes exist, label each swatch by potential role, not merely rank. Test combinations at realistic sizes, against surrounding imagery, and across light and dark contexts. Preserve one rejected palette with a short reason such as “lost the navigation accent” or “three neutrals collapse at small scale.” Rejection notes make art direction teachable.

Median cut vs k-means color palette is a productive comparison because the candidates fail differently. Median cut exposes its partitions and tends toward coverage by boxes. K-means follows the declared distance objective and can fit dense clouds tightly. The curator decides which failure is acceptable for this artifact.

If the palette will be transferred to another image, transfer only after extracting deliberately. If perceptual relationships matter, interpret palette distance in a perceptual color space. If texture exemplars drive the composition, keep exemplar texture separate from global palette reduction. Each downstream operation should retain the extraction receipt.

Export the algorithm and the art direction

Choose median cut when deterministic partitions, predictable runtime, and an explainable split tree matter most. Choose seeded k-means when minimizing a declared assignment objective matters and iteration is acceptable. Add accent protection when the brief names rare colors. In every case, preserve color space, sample policy, crop, palette size, seed, tie rules, error, and curator notes.

A median cut vs k-means color palette decision can change with new product imagery or a brand revision. Schedule a revisit when the input cohort, color pipeline, metric, or visual identity changes. Do not refresh merely to chase a smaller synthetic error number.

Take one action: extract both candidate palettes before choosing the one your visual system can actually use. Run the four fixtures, add one generated or licensed product-shaped input, inspect error maps and rare accents, then write a human selection note. Publish the chosen swatches with the receipt so another designer can reproduce the candidates without pretending the algorithm made the final aesthetic decision.

Artifact-derived stability and repair receiptExact seed and crop sweeps use exhaustive bijective minimum-cost matching; the matcher oracle, error hashes, tie rules, and observed empty-repair count remain explicit.seed 904 · 2bc9b874seed 905 · 2bc9b874seed 906 · 2bc9b874trim-left · 982f3f8atrim-top · faab3418bijective displacementmatcher oraclenearest total = 2bijective total = 99empty repairs observed0 · policy retainedtie ruleslowest centroid indexlowest source indexerror evidenceM 77c62690K e8dd2206
Artifact-derived stability and repair receipt
Exact seed and crop sweeps use exhaustive bijective minimum-cost matching; the matcher oracle, error hashes, tie rules, and observed empty-repair count remain explicit.
Stability and repair values from the pinned fixture
CaseInput/palette hashBijective displacementDiagnostic
Seed 9042bc9b8740.000000Exhaustive permutation, k=4
Seed 9052bc9b8740.000000Exhaustive permutation, k=4
Seed 9062bc9b8740.000000Exhaustive permutation, k=4
Crop none21d06db0median 0.000000; k-means 0.000000Same generated fixture
Crop trim-left982f3f8amedian 7.853355; k-means 0.595351Same generated fixture
Crop trim-topfaab3418median 0.593750; k-means 0.452202Same generated fixture
Matcher oracletwo-color adversarial setsindependent nearest 2; bijective minimum 99Proves assignments are one-to-one
Empty-cluster policy0 repairs observedn/aFarthest assigned pixel; lowest source index tie
  1. Compare the identical generated fixture.
  2. Pair centroids with the exhaustive bijective matcher.
  3. Review errors, repairs, and the curator boundary.
Figure 3: The plotted values are executable receipt fields, while the curator still owns role naming and acceptance.