Skip to content

fix: median cut blends distinct colors under imbalanced pixel frequency#23

Draft
Fuitad wants to merge 1 commit into
willibrandon:mainfrom
Fuitad:fix/median-cut-imbalanced-frequency
Draft

fix: median cut blends distinct colors under imbalanced pixel frequency#23
Fuitad wants to merge 1 commit into
willibrandon:mainfrom
Fuitad:fix/median-cut-imbalanced-frequency

Conversation

@Fuitad

@Fuitad Fuitad commented Jul 2, 2026

Copy link
Copy Markdown

Bug

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 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_colors is 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 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 in that file.

Testing

  • go test ./...: all passing
  • go test -tags=integration ./...: pre-existing suite passing, 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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant