├── .gitignore ├── LICENSE ├── README.md ├── go.mod └── quantize ├── bench ├── bench_test.go ├── go.mod └── go.sum ├── bucket.go ├── mediancut.go ├── mediancut_test.go ├── test_image.jpg └── test_image2.gif /.gitignore: -------------------------------------------------------------------------------- 1 | test_output.gif 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Eric Pauley 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # go-quantize 2 | go-quantize is a highly-optimized and memory-efficient palette generator. It currently implements the Median Cut algorithm, including weighted color priority. 3 | 4 | ## Performance 5 | go-quantize makes exactly two slice allocations per palette generated, the larger of which is efficiently pooled. It also uses performant direct pixel accesses for certain image types, reducing memory footprint and increasing throughput. 6 | 7 | ## Benchmarks 8 | go-quantize performs significantly faster than existing quantization libraries: 9 | 10 | ``` 11 | # bench/bench_test.go 12 | BenchmarkQuantize-8 50 20070550 ns/op 122690 B/op 258 allocs/op 13 | BenchmarkSoniakeysMedian-8 3 465833354 ns/op 3479624 B/op 782 allocs/op 14 | BenchmarkSoniakeysMean-8 3 342759921 ns/op 2755712 B/op 262 allocs/op 15 | BenchmarkEsimov-8 2 645129392 ns/op 35849608 B/op 8872273 allocs/op 16 | ``` 17 | 18 | ## Example Usage 19 | ```go 20 | file, err := os.Open("test_image.jpg") 21 | if err != nil { 22 | fmt.Println("Couldn't open test file") 23 | return 24 | } 25 | i, _, err := image.Decode(file) 26 | if err != nil { 27 | fmt.Println("Couldn't decode test file") 28 | return 29 | } 30 | q := MedianCutQuantizer{} 31 | p := q.Quantize(make([]color.Color, 0, 256), i) 32 | fmt.Println(p) 33 | ``` 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ericpauley/go-quantize 2 | 3 | go 1.12 4 | -------------------------------------------------------------------------------- /quantize/bench/bench_test.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | "os" 7 | "testing" 8 | 9 | _ "image/jpeg" 10 | 11 | "github.com/ericpauley/go-quantize/quantize" 12 | "github.com/esimov/colorquant" 13 | "github.com/soniakeys/quant/mean" 14 | "github.com/soniakeys/quant/median" 15 | ) 16 | 17 | func getImage(b *testing.B) (m image.Image) { 18 | file, err := os.Open("../test_image.jpg") 19 | if err != nil { 20 | b.Fatal("Couldn't open test file") 21 | } 22 | m, _, err = image.Decode(file) 23 | if err != nil { 24 | b.Fatal("Couldn't decode test file") 25 | } 26 | b.ReportAllocs() 27 | b.ResetTimer() 28 | return 29 | } 30 | 31 | func BenchmarkQuantize(b *testing.B) { 32 | m := getImage(b) 33 | q := quantize.MedianCutQuantizer{quantize.Mean, nil, false} 34 | for i := 0; i < b.N; i++ { 35 | q.Quantize(make([]color.Color, 0, 256), m) 36 | } 37 | } 38 | 39 | func BenchmarkSoniakeysMedian(b *testing.B) { 40 | m := getImage(b) 41 | q := median.Quantizer(256) 42 | for i := 0; i < b.N; i++ { 43 | q.Palette(m) 44 | } 45 | } 46 | 47 | func BenchmarkSoniakeysMean(b *testing.B) { 48 | m := getImage(b) 49 | q := mean.Quantizer(256) 50 | for i := 0; i < b.N; i++ { 51 | q.Palette(m) 52 | } 53 | } 54 | 55 | func BenchmarkEsimov(b *testing.B) { 56 | m := getImage(b) 57 | q := colorquant.Quant{} 58 | for i := 0; i < b.N; i++ { 59 | q.Quantize(m, 256) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /quantize/bench/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ericpauley/go-quantize/quantize/bench 2 | // Note: We use a separate go.mod file here because comparison libraries should not be in top-level dependencies 3 | go 1.12 4 | 5 | require ( 6 | github.com/ericpauley/go-quantize v0.0.0-20180803033130-bfdbba883ede 7 | github.com/esimov/colorquant v1.0.0 8 | github.com/soniakeys/quant v1.0.0 9 | ) 10 | 11 | replace github.com/ericpauley/go-quantize => ../.. 12 | -------------------------------------------------------------------------------- /quantize/bench/go.sum: -------------------------------------------------------------------------------- 1 | github.com/esimov/colorquant v1.0.0 h1:Au0vgJi9uTftrZxoqKJXGO1im5pny79mJpVYPij3vp0= 2 | github.com/esimov/colorquant v1.0.0/go.mod h1:av7lYasj6eTILlP0s+rmU8POP1rsktNIBEIjjDd+wJk= 3 | github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= 4 | github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= 5 | -------------------------------------------------------------------------------- /quantize/bucket.go: -------------------------------------------------------------------------------- 1 | package quantize 2 | 3 | import "image/color" 4 | 5 | type colorAxis uint8 6 | 7 | // Color axis constants 8 | const ( 9 | red colorAxis = iota 10 | green 11 | blue 12 | ) 13 | 14 | type colorPriority struct { 15 | p uint32 16 | color.RGBA 17 | } 18 | 19 | func (c colorPriority) axis(span colorAxis) uint8 { 20 | switch span { 21 | case red: 22 | return c.R 23 | case green: 24 | return c.G 25 | default: 26 | return c.B 27 | } 28 | } 29 | 30 | type colorBucket []colorPriority 31 | 32 | func (cb colorBucket) partition() (colorBucket, colorBucket) { 33 | mean, span := cb.span() 34 | left, right := 0, len(cb)-1 35 | for left < right { 36 | cb[left], cb[right] = cb[right], cb[left] 37 | for cb[left].axis(span) < mean && left < right { 38 | left++ 39 | } 40 | for cb[right].axis(span) >= mean && left < right { 41 | right-- 42 | } 43 | } 44 | if left == 0 { 45 | return cb[:1], cb[1:] 46 | } 47 | if left == len(cb)-1 { 48 | return cb[:len(cb)-1], cb[len(cb)-1:] 49 | } 50 | return cb[:left], cb[left:] 51 | } 52 | 53 | func (cb colorBucket) mean() color.RGBA { 54 | var r, g, b uint64 55 | var p uint64 56 | for _, c := range cb { 57 | p += uint64(c.p) 58 | r += uint64(c.R) * uint64(c.p) 59 | g += uint64(c.G) * uint64(c.p) 60 | b += uint64(c.B) * uint64(c.p) 61 | } 62 | return color.RGBA{uint8(r / p), uint8(g / p), uint8(b / p), 255} 63 | } 64 | 65 | type constraint struct { 66 | min uint8 67 | max uint8 68 | vals [256]uint64 69 | } 70 | 71 | func (c *constraint) update(index uint8, p uint32) { 72 | if index < c.min { 73 | c.min = index 74 | } 75 | if index > c.max { 76 | c.max = index 77 | } 78 | c.vals[index] += uint64(p) 79 | } 80 | 81 | func (c *constraint) span() uint8 { 82 | return c.max - c.min 83 | } 84 | 85 | func (cb colorBucket) span() (uint8, colorAxis) { 86 | var R, G, B constraint 87 | R.min = 255 88 | G.min = 255 89 | B.min = 255 90 | var p uint64 91 | for _, c := range cb { 92 | R.update(c.R, c.p) 93 | G.update(c.G, c.p) 94 | B.update(c.B, c.p) 95 | p += uint64(c.p) 96 | } 97 | var toCount *constraint 98 | var span colorAxis 99 | if R.span() > G.span() && R.span() > B.span() { 100 | span = red 101 | toCount = &R 102 | } else if G.span() > B.span() { 103 | span = green 104 | toCount = &G 105 | } else { 106 | span = blue 107 | toCount = &B 108 | } 109 | var counted uint64 110 | var i int 111 | var c uint64 112 | for i, c = range toCount.vals { 113 | if counted > p/2 || counted+c == p { 114 | break 115 | } 116 | counted += c 117 | } 118 | return uint8(i), span 119 | } 120 | -------------------------------------------------------------------------------- /quantize/mediancut.go: -------------------------------------------------------------------------------- 1 | // Package quantize offers an implementation of the draw.Quantize interface using an optimized Median Cut method, 2 | // including advanced functionality for fine-grained control of color priority 3 | package quantize 4 | 5 | import ( 6 | "image" 7 | "image/color" 8 | "sync" 9 | ) 10 | 11 | type bucketPool struct { 12 | sync.Pool 13 | maxCap int 14 | m sync.Mutex 15 | } 16 | 17 | func (p *bucketPool) getBucket(c int) colorBucket { 18 | p.m.Lock() 19 | if p.maxCap > c { 20 | p.maxCap = p.maxCap * 99 / 100 21 | } 22 | if p.maxCap < c { 23 | p.maxCap = c 24 | } 25 | maxCap := p.maxCap 26 | p.m.Unlock() 27 | val := p.Pool.Get() 28 | if val == nil || cap(val.(colorBucket)) < c { 29 | return make(colorBucket, maxCap)[0:c] 30 | } 31 | slice := val.(colorBucket) 32 | slice = slice[0:c] 33 | for i := range slice { 34 | slice[i] = colorPriority{} 35 | } 36 | return slice 37 | } 38 | 39 | var bpool bucketPool 40 | 41 | // AggregationType specifies the type of aggregation to be done 42 | type AggregationType uint8 43 | 44 | const ( 45 | // Mode - pick the highest priority value 46 | Mode AggregationType = iota 47 | // Mean - weighted average all values 48 | Mean 49 | ) 50 | 51 | // MedianCutQuantizer implements the go draw.Quantizer interface using the Median Cut method 52 | type MedianCutQuantizer struct { 53 | // The type of aggregation to be used to find final colors 54 | Aggregation AggregationType 55 | // The weighting function to use on each pixel 56 | Weighting func(image.Image, int, int) uint32 57 | // Whether to create a transparent entry 58 | AddTransparent bool 59 | } 60 | 61 | //bucketize takes a bucket and performs median cut on it to obtain the target number of grouped buckets 62 | func bucketize(colors colorBucket, num int) (buckets []colorBucket) { 63 | if len(colors) == 0 || num == 0 { 64 | return nil 65 | } 66 | bucket := colors 67 | buckets = make([]colorBucket, 1, num*2) 68 | buckets[0] = bucket 69 | 70 | for len(buckets) < num && len(buckets) < len(colors) { // Limit to palette capacity or number of colors 71 | bucket, buckets = buckets[0], buckets[1:] 72 | if len(bucket) < 2 { 73 | buckets = append(buckets, bucket) 74 | continue 75 | } else if len(bucket) == 2 { 76 | buckets = append(buckets, bucket[:1], bucket[1:]) 77 | continue 78 | } 79 | 80 | left, right := bucket.partition() 81 | buckets = append(buckets, left, right) 82 | } 83 | return 84 | } 85 | 86 | // palettize finds a single color to represent a set of color buckets 87 | func (q MedianCutQuantizer) palettize(p color.Palette, buckets []colorBucket) color.Palette { 88 | for _, bucket := range buckets { 89 | switch q.Aggregation { 90 | case Mean: 91 | mean := bucket.mean() 92 | p = append(p, mean) 93 | case Mode: 94 | var best colorPriority 95 | for _, c := range bucket { 96 | if c.p > best.p { 97 | best = c 98 | } 99 | } 100 | p = append(p, best.RGBA) 101 | } 102 | } 103 | return p 104 | } 105 | 106 | // quantizeSlice expands the provided bucket and then palettizes the result 107 | func (q MedianCutQuantizer) quantizeSlice(p color.Palette, colors []colorPriority) color.Palette { 108 | numColors := cap(p) - len(p) 109 | addTransparent := q.AddTransparent 110 | if addTransparent { 111 | for _, c := range p { 112 | if _, _, _, a := c.RGBA(); a == 0 { 113 | addTransparent = false 114 | } 115 | } 116 | if addTransparent { 117 | numColors-- 118 | } 119 | } 120 | buckets := bucketize(colors, numColors) 121 | p = q.palettize(p, buckets) 122 | if addTransparent { 123 | p = append(p, color.RGBA{0, 0, 0, 0}) 124 | } 125 | return p 126 | } 127 | 128 | func colorAt(m image.Image, x int, y int) color.RGBA { 129 | switch i := m.(type) { 130 | case *image.YCbCr: 131 | yi := i.YOffset(x, y) 132 | ci := i.COffset(x, y) 133 | c := color.YCbCr{ 134 | i.Y[yi], 135 | i.Cb[ci], 136 | i.Cr[ci], 137 | } 138 | return color.RGBA{c.Y, c.Cb, c.Cr, 255} 139 | case *image.RGBA: 140 | ci := i.PixOffset(x, y) 141 | return color.RGBA{i.Pix[ci+0], i.Pix[ci+1], i.Pix[ci+2], i.Pix[ci+3]} 142 | default: 143 | return color.RGBAModel.Convert(i.At(x, y)).(color.RGBA) 144 | } 145 | } 146 | 147 | // buildBucket creates a prioritized color slice with all the colors in the image 148 | func (q MedianCutQuantizer) buildBucket(m image.Image) (bucket colorBucket) { 149 | bounds := m.Bounds() 150 | size := (bounds.Max.X - bounds.Min.X) * (bounds.Max.Y - bounds.Min.Y) * 2 151 | sparseBucket := bpool.getBucket(size) 152 | 153 | for y := bounds.Min.Y; y < bounds.Max.Y; y++ { 154 | for x := bounds.Min.X; x < bounds.Max.X; x++ { 155 | priority := uint32(1) 156 | if q.Weighting != nil { 157 | priority = q.Weighting(m, x, y) 158 | } 159 | if priority != 0 { 160 | c := colorAt(m, x, y) 161 | index := int(c.R)<<16 | int(c.G)<<8 | int(c.B) 162 | for i := 1; ; i++ { 163 | p := &sparseBucket[index%size] 164 | if p.p == 0 || p.RGBA == c { 165 | *p = colorPriority{p.p + priority, c} 166 | break 167 | } 168 | index += 1 + i 169 | } 170 | } 171 | } 172 | } 173 | bucket = sparseBucket[:0] 174 | switch m.(type) { 175 | case *image.YCbCr: 176 | for _, p := range sparseBucket { 177 | if p.p != 0 { 178 | r, g, b := color.YCbCrToRGB(p.R, p.G, p.B) 179 | bucket = append(bucket, colorPriority{p.p, color.RGBA{r, g, b, p.A}}) 180 | } 181 | } 182 | default: 183 | for _, p := range sparseBucket { 184 | if p.p != 0 { 185 | bucket = append(bucket, p) 186 | } 187 | } 188 | } 189 | return 190 | } 191 | 192 | // Quantize quantizes an image to a palette and returns the palette 193 | func (q MedianCutQuantizer) Quantize(p color.Palette, m image.Image) color.Palette { 194 | bucket := q.buildBucket(m) 195 | defer bpool.Put(bucket) 196 | return q.quantizeSlice(p, bucket) 197 | } 198 | -------------------------------------------------------------------------------- /quantize/mediancut_test.go: -------------------------------------------------------------------------------- 1 | package quantize 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/gif" 9 | "os" 10 | "testing" 11 | 12 | _ "image/jpeg" 13 | ) 14 | 15 | func TestBuildBucket(t *testing.T) { 16 | file, err := os.Open("test_image.jpg") 17 | if err != nil { 18 | t.Fatal("Couldn't open test file") 19 | } 20 | i, _, err := image.Decode(file) 21 | if err != nil { 22 | t.Fatal("Couldn't decode test file") 23 | } 24 | 25 | q := MedianCutQuantizer{Mode, nil, false} 26 | 27 | colors := q.buildBucket(i) 28 | t.Logf("Naive color map contains %d elements", len(colors)) 29 | 30 | for _, p := range colors { 31 | if p.p == 0 { 32 | t.Fatal("Bucket had a 0 priority element") 33 | } 34 | } 35 | 36 | q = MedianCutQuantizer{Mode, func(i image.Image, x int, y int) uint32 { 37 | if x < 2 || y < 2 || x > i.Bounds().Max.X-2 || y > i.Bounds().Max.X-2 { 38 | return 1 39 | } 40 | return 0 41 | }, false} 42 | 43 | colors = q.buildBucket(i) 44 | t.Logf("Color map contains %d elements", len(colors)) 45 | } 46 | 47 | func ExampleMedianCutQuantizer() { 48 | file, err := os.Open("test_image.jpg") 49 | if err != nil { 50 | fmt.Println("Couldn't open test file") 51 | return 52 | } 53 | i, _, err := image.Decode(file) 54 | if err != nil { 55 | fmt.Println("Couldn't decode test file") 56 | return 57 | } 58 | q := MedianCutQuantizer{} 59 | p := q.Quantize(make([]color.Color, 0, 256), i) 60 | fmt.Println(p) 61 | } 62 | 63 | func TestQuantize(t *testing.T) { 64 | file, err := os.Open("test_image.jpg") 65 | if err != nil { 66 | t.Fatal("Couldn't open test file") 67 | } 68 | i, _, err := image.Decode(file) 69 | if err != nil { 70 | t.Fatal("Couldn't decode test file") 71 | } 72 | q := MedianCutQuantizer{Mean, nil, false} 73 | p := q.Quantize(make([]color.Color, 0, 256), i) 74 | t.Logf("Created palette with %d colors", len(p)) 75 | 76 | q = MedianCutQuantizer{Mean, nil, false} 77 | p2 := q.Quantize(make([]color.Color, 0, 256), i) 78 | 79 | if len(p) != len(p2) { 80 | t.Fatal("Quantize is not deterministic") 81 | } 82 | 83 | for i := range p { 84 | if p[i] != p2[i] { 85 | t.Fatal("Quantize is not deterministic") 86 | } 87 | } 88 | 89 | q = MedianCutQuantizer{Mode, nil, false} 90 | p = q.Quantize(make([]color.Color, 0, 256), i) 91 | t.Logf("Created palette with %d colors", len(p)) 92 | 93 | q = MedianCutQuantizer{Mean, nil, true} 94 | p = q.Quantize(color.Palette{color.RGBA{0, 0, 0, 0}}, i) 95 | t.Logf("Created palette with %d colors", len(p)) 96 | 97 | q = MedianCutQuantizer{Mean, nil, true} 98 | p = q.Quantize(make([]color.Color, 0, 256), i) 99 | t.Logf("Created palette with %d colors", len(p)) 100 | } 101 | 102 | func BenchmarkQuantize(b *testing.B) { 103 | file, err := os.Open("test_image.jpg") 104 | if err != nil { 105 | b.Fatal("Couldn't open test file") 106 | } 107 | m, _, err := image.Decode(file) 108 | if err != nil { 109 | b.Fatal("Couldn't decode test file") 110 | } 111 | q := MedianCutQuantizer{Mean, nil, false} 112 | b.ReportAllocs() 113 | b.ResetTimer() 114 | for i := 0; i < b.N; i++ { 115 | q.Quantize(make([]color.Color, 0, 256), m) 116 | } 117 | } 118 | 119 | func TestRGBAQuantize(t *testing.T) { 120 | i := image.NewRGBA(image.Rect(0, 0, 1, 1)) 121 | q := MedianCutQuantizer{Mean, nil, false} 122 | p := q.Quantize(make([]color.Color, 0, 256), i) 123 | t.Logf("Created palette with %d colors", len(p)) 124 | } 125 | 126 | // TestOverQuantize ensures that the quantizer can properly handle an image with more space than needed in the palette 127 | func TestOverQuantize(t *testing.T) { 128 | file, err := os.Open("test_image2.gif") 129 | if err != nil { 130 | t.Fatal("Couldn't open test file") 131 | } 132 | i, _, err := image.Decode(file) 133 | if err != nil { 134 | t.Fatal("Couldn't decode test file") 135 | } 136 | q := MedianCutQuantizer{Mean, nil, false} 137 | p := q.Quantize(make([]color.Color, 0, 256), i) 138 | t.Logf("Created palette with %d colors", len(p)) 139 | } 140 | 141 | func TestEmptyQuantize(t *testing.T) { 142 | i := image.NewNRGBA(image.Rect(0, 0, 0, 0)) 143 | 144 | q := MedianCutQuantizer{Mean, nil, false} 145 | p := q.Quantize(make([]color.Color, 0, 256), i) 146 | if len(p) != 0 { 147 | t.Fatal("Quantizer returned colors for empty image") 148 | } 149 | t.Logf("Created palette with %d colors", len(p)) 150 | } 151 | 152 | func TestGif(t *testing.T) { 153 | file, err := os.Open("test_image.jpg") 154 | if err != nil { 155 | t.Fatal("Couldn't open test file") 156 | } 157 | i, _, err := image.Decode(file) 158 | if err != nil { 159 | t.Fatal("Couldn't decode test file") 160 | } 161 | 162 | q := MedianCutQuantizer{Mode, nil, false} 163 | f, err := os.Create("test_output.gif") 164 | if err != nil { 165 | t.Fatal("Couldn't open output file") 166 | } 167 | 168 | options := gif.Options{NumColors: 128, Quantizer: q, Drawer: nil} 169 | 170 | w := bufio.NewWriter(f) 171 | 172 | gif.Encode(w, i, &options) 173 | } 174 | -------------------------------------------------------------------------------- /quantize/test_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericpauley/go-quantize/ae555eb2afa4d069c3a75cff344528ed1e9acf85/quantize/test_image.jpg -------------------------------------------------------------------------------- /quantize/test_image2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ericpauley/go-quantize/ae555eb2afa4d069c3a75cff344528ed1e9acf85/quantize/test_image2.gif --------------------------------------------------------------------------------