fix: median cut blends distinct colors under imbalanced pixel frequency#23
Draft
Fuitad wants to merge 1 commit into
Draft
fix: median cut blends distinct colors under imbalanced pixel frequency#23Fuitad wants to merge 1 commit into
Fuitad wants to merge 1 commit into
Conversation
MedianCutQuantization split each bucket at the pixel-array midpoint (mid := len(pixels) / 2), not at a boundary between distinct colors. Pixel art typically has wildly imbalanced color frequency (a large solid fill vastly outnumbering a 1-2px accent detail), so splitting at the raw pixel-count median lands the cut inside the dominant color's own run instead of between colors. The dominant color got needlessly re-subdivided into near-duplicate buckets while rare accent colors never got isolated into their own bucket, and were instead averaged into a blended, off-palette hex value - even when target_colors comfortably exceeded the number of unique colors actually present, which should guarantee lossless recovery. On a 32x32 sprite with a ~200px tunic fill, ~90px skin fill, a thin outline, and 2px highlight/buckle accents, quantizing to target_colors=32 (13 unique colors present) returned colors like Fix: reduce to a frequency-weighted histogram of unique colors before bucketing, so a split always falls between distinct colors. The split point still favors common colors over rare ones by cumulative pixel count, preserving normal median-cut reduction behavior when target_colors is genuinely smaller than the number of unique colors. I initially suspected a second bug in ApplyQuantizedPalette/ ChangePixelFormat (pixels resolving to the wrong palette index, transparency lost on conversion to indexed mode). Verified via aseprite --batch scripts and full end-to-end integration tests (Go clustering -> Lua apply -> get_pixels) that ChangePixelFormat correctly honors a palette set immediately before it runs, in both the lossless (target_colors >= unique colors) and genuine lossy reduction cases. The apparent scrambling in my initial hand-written repro script was caused by omitting the existing ReassertIndexedTransparentColor() call, not a new bug - so no change was needed there. Testing: - go test ./...: 633 passed - go test -tags=integration ./...: unchanged pre-existing suite passed, plus the new regression test - Added TestMedianCutQuantization_ImbalancedFrequencyPreservesExactColors (pkg/aseprite/quantization_test.go): pure-Go unit test, no Aseprite needed, guards the fix in CI - Added TestIntegration_QuantizePalette_ImbalancedFrequencyPreservesExactColors (pkg/tools/quantize_palette_imbalanced_frequency_bug_test.go): reproduces the exact hero-sprite shape end to end through the real quantize_palette pipeline (export -> Go quantize -> Lua apply -> get_pixels), checking both exact colors and transparency survive conversion to indexed mode. Verified RED without the fix (blended colors and wrong pixels, matching the originally observed corruption) and GREEN with it.
Fuitad
added a commit
to Fuitad/pixel-plugin
that referenced
this pull request
Jul 2, 2026
Refreshes all 5 platform binaries from Fuitad/pixel-mcp @ e8887b3, which fixes quantize_palette's median-cut algorithm splitting each color bucket at the raw pixel-array midpoint instead of a boundary between distinct colors. Pixel art typically has wildly imbalanced color frequency (a large solid fill vastly outnumbering a 1-2px accent detail), so splitting at the pixel-count median landed the cut inside the dominant color's own run instead of between colors: the dominant color got needlessly re-subdivided into near-duplicate buckets while rare accent colors were averaged into a blended, off-palette hex value instead of being isolated into their own bucket, even when target_colors comfortably exceeded the number of unique colors present (which should guarantee lossless recovery) (upstream willibrandon/pixel-mcp#23). Bumps plugin version to 0.5.3 and documents the change in CHANGELOG.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bug
MedianCutQuantizationsplit each bucket at the pixel-array midpoint (mid := len(pixels) / 2), not at a boundary between distinct colors. Pixel art typically has wildly imbalanced color frequency (a large solid fill vastly outnumbering a 1-2px accent detail), so splitting at the raw pixel-count median lands the cut inside the dominant color's own run instead of between colors.The dominant color got needlessly re-subdivided into near-duplicate buckets while rare accent colors never got isolated into their own bucket, and were instead averaged into a blended, off-palette hex value - even when
target_colorscomfortably exceeded the number of unique colors actually present, which should guarantee lossless recovery.On a 32x32 sprite with a ~200px tunic fill, ~90px skin fill, a thin outline, and 2px highlight/buckle accents, quantizing to
target_colors=32(13 unique colors present) returned colors that did not exist anywhere in the source image.Fix
Reduce to a frequency-weighted histogram of unique colors before bucketing, so a split always falls between distinct colors. The split point still favors common colors over rare ones by cumulative pixel count, preserving normal median-cut reduction behavior when
target_colorsis genuinely smaller than the number of unique colors.Investigation note
I initially suspected a second bug in
ApplyQuantizedPalette/ChangePixelFormat(pixels resolving to the wrong palette index, transparency lost on conversion to indexed mode). I verified viaaseprite --batchscripts and full end-to-end integration tests (Go clustering -> Lua apply ->get_pixels) thatChangePixelFormatcorrectly honors a palette set immediately before it runs, in both the lossless (target_colors >= unique colors) and genuine lossy-reduction cases. The apparent scrambling in my initial hand-written repro script was caused by omitting the existingReassertIndexedTransparentColor()call, not a new bug, so no change was needed in that file.Testing
go test ./...: all passinggo test -tags=integration ./...: pre-existing suite passing, plus the new regression testTestMedianCutQuantization_ImbalancedFrequencyPreservesExactColors(pkg/aseprite/quantization_test.go): pure-Go unit test, no Aseprite needed, guards the fix in CITestIntegration_QuantizePalette_ImbalancedFrequencyPreservesExactColors(pkg/tools/quantize_palette_imbalanced_frequency_bug_test.go): reproduces the exact hero-sprite shape end to end through the realquantize_palettepipeline (export -> Go quantize -> Lua apply ->get_pixels), checking both exact colors and transparency survive conversion to indexed mode. Verified RED without the fix (blended colors and wrong pixels, matching the originally observed corruption) and GREEN with it.