Skip to content

Commit 6345eb9

Browse files
committed
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.
1 parent fb3c4a0 commit 6345eb9

2 files changed

Lines changed: 179 additions & 10 deletions

File tree

internal/render/gif.go

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66
"image"
7+
"image/color"
78
"image/color/palette"
89
"image/draw"
910
"image/gif"
@@ -32,32 +33,46 @@ func RenderGIF(glbBytes []byte, atlas *texture.Atlas, background string, frames,
3233
// Calculate rotation per frame
3334
rotationPerFrame := 360.0 / float64(frames)
3435

36+
// Render all frames first (in parallel)
37+
renderedFrames := make([]image.Image, frames)
38+
var wg sync.WaitGroup
39+
for i := 0; i < frames; i++ {
40+
wg.Add(1)
41+
go func(frameIdx int) {
42+
defer wg.Done()
43+
rotation := float64(frameIdx) * rotationPerFrame
44+
renderedFrames[frameIdx] = RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
45+
}(i)
46+
}
47+
wg.Wait()
48+
49+
// Determine palette
50+
var pal color.Palette
51+
if dithering {
52+
pal = palette.Plan9
53+
} else {
54+
pal = MedianCutQuantize(renderedFrames, 256)
55+
}
56+
3557
// Create GIF structure
3658
g := &gif.GIF{
3759
Image: make([]*image.Paletted, frames),
3860
Delay: make([]int, frames),
3961
LoopCount: 0, // 0 = infinite loop
4062
}
4163

42-
// Render frames in parallel
43-
var wg sync.WaitGroup
64+
// Quantize frames to palette (in parallel)
4465
for i := 0; i < frames; i++ {
4566
wg.Add(1)
4667
go func(frameIdx int) {
4768
defer wg.Done()
48-
rotation := float64(frameIdx) * rotationPerFrame
49-
50-
// Render frame
51-
img := RenderScene(mesh, atlasImage, rotation, width, height, bgColor)
52-
53-
// Quantize to palette
54-
paletted := image.NewPaletted(img.Bounds(), palette.Plan9)
69+
img := renderedFrames[frameIdx]
70+
paletted := image.NewPaletted(img.Bounds(), pal)
5571
if dithering {
5672
draw.FloydSteinberg.Draw(paletted, img.Bounds(), img, image.Point{})
5773
} else {
5874
draw.Draw(paletted, img.Bounds(), img, image.Point{}, draw.Src)
5975
}
60-
6176
g.Image[frameIdx] = paletted
6277
g.Delay[frameIdx] = delay
6378
}(i)

internal/render/quantize.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package render
2+
3+
import (
4+
"image"
5+
"image/color"
6+
"sort"
7+
)
8+
9+
// MedianCutQuantize generates an optimal palette for the given images using median-cut algorithm
10+
func MedianCutQuantize(images []image.Image, maxColors int) color.Palette {
11+
// Collect all unique colors from all images
12+
colorMap := make(map[uint32]struct{})
13+
for _, img := range images {
14+
bounds := img.Bounds()
15+
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
16+
for x := bounds.Min.X; x < bounds.Max.X; x++ {
17+
r, g, b, a := img.At(x, y).RGBA()
18+
if a < 128<<8 {
19+
continue // skip transparent pixels
20+
}
21+
// Pack RGB into uint32 (ignore alpha for palette)
22+
key := (r>>8)<<16 | (g>>8)<<8 | (b >> 8)
23+
colorMap[key] = struct{}{}
24+
}
25+
}
26+
}
27+
28+
// Convert to slice of colors
29+
colors := make([]rgbColor, 0, len(colorMap))
30+
for key := range colorMap {
31+
colors = append(colors, rgbColor{
32+
r: uint8(key >> 16),
33+
g: uint8(key >> 8),
34+
b: uint8(key),
35+
})
36+
}
37+
38+
// If fewer colors than max, just return them all
39+
if len(colors) <= maxColors {
40+
palette := make(color.Palette, len(colors))
41+
for i, c := range colors {
42+
palette[i] = color.RGBA{c.r, c.g, c.b, 255}
43+
}
44+
return palette
45+
}
46+
47+
// Perform median-cut
48+
buckets := medianCut(colors, maxColors)
49+
50+
// Convert buckets to palette (average color of each bucket)
51+
palette := make(color.Palette, len(buckets))
52+
for i, bucket := range buckets {
53+
palette[i] = bucket.average()
54+
}
55+
56+
return palette
57+
}
58+
59+
type rgbColor struct {
60+
r, g, b uint8
61+
}
62+
63+
type colorBucket []rgbColor
64+
65+
func (b colorBucket) average() color.RGBA {
66+
if len(b) == 0 {
67+
return color.RGBA{0, 0, 0, 255}
68+
}
69+
var rSum, gSum, bSum int
70+
for _, c := range b {
71+
rSum += int(c.r)
72+
gSum += int(c.g)
73+
bSum += int(c.b)
74+
}
75+
n := len(b)
76+
return color.RGBA{uint8(rSum / n), uint8(gSum / n), uint8(bSum / n), 255}
77+
}
78+
79+
func (b colorBucket) rangeOfChannel(ch int) int {
80+
if len(b) == 0 {
81+
return 0
82+
}
83+
min, max := 255, 0
84+
for _, c := range b {
85+
var v int
86+
switch ch {
87+
case 0:
88+
v = int(c.r)
89+
case 1:
90+
v = int(c.g)
91+
case 2:
92+
v = int(c.b)
93+
}
94+
if v < min {
95+
min = v
96+
}
97+
if v > max {
98+
max = v
99+
}
100+
}
101+
return max - min
102+
}
103+
104+
func medianCut(colors []rgbColor, maxBuckets int) []colorBucket {
105+
if len(colors) == 0 {
106+
return nil
107+
}
108+
109+
buckets := []colorBucket{colors}
110+
111+
for len(buckets) < maxBuckets {
112+
// Find bucket with largest range
113+
maxRange := 0
114+
maxIdx := 0
115+
maxCh := 0
116+
117+
for i, bucket := range buckets {
118+
if len(bucket) < 2 {
119+
continue
120+
}
121+
for ch := 0; ch < 3; ch++ {
122+
r := bucket.rangeOfChannel(ch)
123+
if r > maxRange {
124+
maxRange = r
125+
maxIdx = i
126+
maxCh = ch
127+
}
128+
}
129+
}
130+
131+
if maxRange == 0 {
132+
break // can't split further
133+
}
134+
135+
// Split the bucket with largest range
136+
bucket := buckets[maxIdx]
137+
sort.Slice(bucket, func(i, j int) bool {
138+
switch maxCh {
139+
case 0:
140+
return bucket[i].r < bucket[j].r
141+
case 1:
142+
return bucket[i].g < bucket[j].g
143+
default:
144+
return bucket[i].b < bucket[j].b
145+
}
146+
})
147+
148+
mid := len(bucket) / 2
149+
buckets[maxIdx] = bucket[:mid]
150+
buckets = append(buckets, bucket[mid:])
151+
}
152+
153+
return buckets
154+
}

0 commit comments

Comments
 (0)