From 6345eb982159c6acfab11476c7c234ab06f40ab5 Mon Sep 17 00:00:00 2001 From: devilreef <86633411+devilr33f@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:54:53 +0600 Subject: [PATCH] feat: add median-cut quantization for custom GIF palette Generate optimal 256-color palette from rendered frames when dithering disabled, replacing generic Plan9 palette for better color accuracy without dithering artifacts. --- internal/render/gif.go | 35 +++++--- internal/render/quantize.go | 154 ++++++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+), 10 deletions(-) create mode 100644 internal/render/quantize.go diff --git a/internal/render/gif.go b/internal/render/gif.go index 8c5ecac..4e2507b 100644 --- a/internal/render/gif.go +++ b/internal/render/gif.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "image" + "image/color" "image/color/palette" "image/draw" "image/gif" @@ -32,6 +33,27 @@ func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, // Calculate rotation per frame rotationPerFrame := 360.0 / float64(frames) + // Render all frames first (in parallel) + renderedFrames := make([]image.Image, frames) + var wg sync.WaitGroup + for i := 0; i < frames; i++ { + wg.Add(1) + go func(frameIdx int) { + defer wg.Done() + rotation := float64(frameIdx) * rotationPerFrame + renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor) + }(i) + } + wg.Wait() + + // Determine palette + var pal color.Palette + if dithering { + pal = palette.Plan9 + } else { + pal = MedianCutQuantize(renderedFrames, 256) + } + // Create GIF structure g := &gif.GIF{ Image: make([]*image.Paletted, frames), @@ -39,25 +61,18 @@ func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames, LoopCount: 0, // 0 = infinite loop } - // Render frames in parallel - var wg sync.WaitGroup + // Quantize frames to palette (in parallel) for i := 0; i < frames; i++ { wg.Add(1) go func(frameIdx int) { defer wg.Done() - rotation := float64(frameIdx) * rotationPerFrame - - // Render frame - img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor) - - // Quantize to palette - paletted := image.NewPaletted(img.Bounds(), palette.Plan9) + img := renderedFrames[frameIdx] + paletted := image.NewPaletted(img.Bounds(), pal) if dithering { draw.FloydSteinberg.Draw(paletted, img.Bounds(), img, image.Point{}) } else { draw.Draw(paletted, img.Bounds(), img, image.Point{}, draw.Src) } - g.Image[frameIdx] = paletted g.Delay[frameIdx] = delay }(i) diff --git a/internal/render/quantize.go b/internal/render/quantize.go new file mode 100644 index 0000000..e1c8aa4 --- /dev/null +++ b/internal/render/quantize.go @@ -0,0 +1,154 @@ +package render + +import ( + "image" + "image/color" + "sort" +) + +// MedianCutQuantize generates an optimal palette for the given images using median-cut algorithm +func MedianCutQuantize(images []image.Image, maxColors int) color.Palette { + // Collect all unique colors from all images + colorMap := make(map[uint32]struct{}) + for _, img := range images { + bounds := img.Bounds() + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + if a < 128<<8 { + continue // skip transparent pixels + } + // Pack RGB into uint32 (ignore alpha for palette) + key := (r>>8)<<16 | (g>>8)<<8 | (b >> 8) + colorMap[key] = struct{}{} + } + } + } + + // Convert to slice of colors + colors := make([]rgbColor, 0, len(colorMap)) + for key := range colorMap { + colors = append(colors, rgbColor{ + r: uint8(key >> 16), + g: uint8(key >> 8), + b: uint8(key), + }) + } + + // If fewer colors than max, just return them all + if len(colors) <= maxColors { + palette := make(color.Palette, len(colors)) + for i, c := range colors { + palette[i] = color.RGBA{c.r, c.g, c.b, 255} + } + return palette + } + + // Perform median-cut + buckets := medianCut(colors, maxColors) + + // Convert buckets to palette (average color of each bucket) + palette := make(color.Palette, len(buckets)) + for i, bucket := range buckets { + palette[i] = bucket.average() + } + + return palette +} + +type rgbColor struct { + r, g, b uint8 +} + +type colorBucket []rgbColor + +func (b colorBucket) average() color.RGBA { + if len(b) == 0 { + return color.RGBA{0, 0, 0, 255} + } + var rSum, gSum, bSum int + for _, c := range b { + rSum += int(c.r) + gSum += int(c.g) + bSum += int(c.b) + } + n := len(b) + return color.RGBA{uint8(rSum / n), uint8(gSum / n), uint8(bSum / n), 255} +} + +func (b colorBucket) rangeOfChannel(ch int) int { + if len(b) == 0 { + return 0 + } + min, max := 255, 0 + for _, c := range b { + var v int + switch ch { + case 0: + v = int(c.r) + case 1: + v = int(c.g) + case 2: + v = int(c.b) + } + if v < min { + min = v + } + if v > max { + max = v + } + } + return max - min +} + +func medianCut(colors []rgbColor, maxBuckets int) []colorBucket { + if len(colors) == 0 { + return nil + } + + buckets := []colorBucket{colors} + + for len(buckets) < maxBuckets { + // Find bucket with largest range + maxRange := 0 + maxIdx := 0 + maxCh := 0 + + for i, bucket := range buckets { + if len(bucket) < 2 { + continue + } + for ch := 0; ch < 3; ch++ { + r := bucket.rangeOfChannel(ch) + if r > maxRange { + maxRange = r + maxIdx = i + maxCh = ch + } + } + } + + if maxRange == 0 { + break // can't split further + } + + // Split the bucket with largest range + bucket := buckets[maxIdx] + sort.Slice(bucket, func(i, j int) bool { + switch maxCh { + case 0: + return bucket[i].r < bucket[j].r + case 1: + return bucket[i].g < bucket[j].g + default: + return bucket[i].b < bucket[j].b + } + }) + + mid := len(bucket) / 2 + buckets[maxIdx] = bucket[:mid] + buckets = append(buckets, bucket[mid:]) + } + + return buckets +}