├── .github
└── FUNDING.yml
├── LICENSE
├── README.md
├── doc
├── crop.png
└── outline.png
├── example
├── 25535163354.jpg
├── 26939476984.jpg
├── 27417460620.jpg
├── 28411051634.jpg
├── 28922730122.jpg
├── 28930160605.jpg
├── 6833735316.jpg
├── 8527042251.jpg
├── example.go
└── output.html
├── example2
└── example2.go
├── imgprep.go
└── kmeans.go
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | ko_fi: supportadev
2 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016 Carl Asman. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are
5 | met:
6 |
7 | * Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above
10 | copyright notice, this list of conditions and the following disclaimer
11 | in the documentation and/or other materials provided with the
12 | distribution.
13 | * Neither the name of the copyright holder nor the names of its
14 | contributors may be used to endorse or promote products derived from
15 | this software without specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://goreportcard.com/report/github.com/EdlinOrg/prominentcolor)
2 | [](https://godoc.org/github.com/EdlinOrg/prominentcolor)
3 |
4 | # prominentcolor
5 |
6 | ## Find the K most dominant colors in an image
7 |
8 | The `Kmeans` function returns the K most dominant colors in the image, ordered in the order of dominance.
9 |
10 | To execute it with the default values, call
11 |
12 | `func Kmeans(orgimg image.Image) (centroids []ColorItem)`
13 |
14 | which takes an image and returns the K dominant colors, sorted by the most frequent one first.
15 |
16 | It uses Kmeans++ to pick K initial centroid values, and goes through all pixels to re-calculate the centroids used.
17 |
18 | As default function `Kmeans` uses these settings:
19 | * K=3
20 | * crops the center of the image before resizing (removing 25% on all sides)
21 | * resizes to 80 pixels
22 | * uses Kmeans++
23 | * uses median value to find the color for the centroid
24 | * mask out white, black or green backgrounds
25 |
26 | To have more control, call `KmeansWithArgs` or `KmeansWithAll`.
27 | Below are the parameters that can be tweaked when calling those functions.
28 |
29 | ## K
30 | As default it has got K=3.
31 |
32 | If set to high, it will get too detailed and would separate nuances of the same color in different centroids.
33 |
34 | ## Resizing
35 | As default it resizes the image to 80 pixels wide (and whatever height to preserve aspect ratio).
36 |
37 | The higher value, the more time it will take to process since it goes through all pixels.
38 |
39 | ## Arguments
40 |
41 | ### `ArgumentSeedRandom` : Kmeans++ vs Random
42 | As default it uses Kmeans++.
43 |
44 | Kmeans++ will take K points that are as far away from each other as possible,
45 | to avoid that the points might be too close to each other and really could be in the same cluster.
46 | Hence the initial step takes slightly longer than just randomly picking the initial K starting points.
47 |
48 | ### `ArgumentAverageMean` : Median vs mean for picking color
49 | As default it uses median.
50 |
51 | When the colors are being lumped together in the K clusters, it can pick the _mean_ value, meaning
52 | adding all values together and dividing by the number of colors in that cluster.
53 | This will make the centroid color to be close to the color of the majority of the pixels in that cluster.
54 | Median will take the median value, i.e. just take the one in the middle of all colors in the cluster.
55 |
56 | ### `ArgumentNoCropping` : Crop to center of image vs not cropping
57 |
58 | As default, it crops the center of the image (removing 25% on all sides).
59 |
60 | The theory being that the most relevant area in the image is in the middle,
61 | so even if the most dominant color in that area is not the most dominant color in the whole image, it might be what the user percieve as most dominant.
62 |
63 | The image below contains mostly green color, but since the flower is in the center of the image, we might be interested in perceiving that as the dominant color. When cropping (default) it finds pink to be most dominant, without cropping (by setting `ArgumentNoCropping`), green is most dominant.
64 |
65 | 
66 |
67 | ### `ArgumentLAB` : RGB vs LAB
68 |
69 | As default it uses RGB.
70 |
71 | LAB is experimental atm, hence RGB is default.
72 |
73 | ### `ArgumentDebugImage` : Save temporary image
74 |
75 | Saves an image in `/tmp/` where the pixels that have been masked out are colored pink.
76 | Useful when modifying the values of the masks, so you can observe the result.
77 |
78 | ## Masking; removing background colours
79 |
80 | `GetDefaultMasks` is the function containing the masks used as default, they can be used as a starting point
81 | when passing other masks to the function. As default it filters white, black or green backgrounds.
82 |
83 | To handle items that are shot against white/black/green background ("isolated" objects / clipart / green screen images),
84 | the image is pre-processed to disregard the white/black/green background:
85 | If the four corners are in that same color (or close to it), the code will take those as starting points for the areas to be removed.
86 |
87 | In the image below, it removes much of the white (the pink pixels are the pixels that have been removed).
88 | By removing those areas, "white" will have less of a chance of becoming the dominant color.
89 |
90 | 
91 |
92 | ## Sample code
93 |
94 | See
95 | [example/example.go](example/example.go)
96 | and
97 | [example2/example2.go](example2/example2.go)
98 | for sample calls with different parameters.
99 |
100 | The sample images in
101 | [example/](example/)
102 | comes from flickr and are all Public Domain https://creativecommons.org/publicdomain/zero/1.0/
103 |
104 | The images used:
105 | * https://www.flickr.com/photos/65720474@N03/8527042251/
106 | * https://www.flickr.com/photos/isasza/26939476984/
107 | * https://www.flickr.com/photos/janosvirag/27417460620/
108 | * https://www.flickr.com/photos/janosvirag/28922730122/
109 | * https://www.flickr.com/photos/mathiasappel/25535163354/
110 | * https://www.flickr.com/photos/mathiasappel/28930160605/
111 | * https://www.flickr.com/photos/pasukaru76/6833735316/
112 | * https://www.flickr.com/photos/sloalan/28411051634/
113 |
114 | ## Author
115 |
116 | Carl Asman (www.edlin.org)
117 |
118 | ## BSD License
119 |
120 | See [LICENSE](LICENSE)
121 |
--------------------------------------------------------------------------------
/doc/crop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/doc/crop.png
--------------------------------------------------------------------------------
/doc/outline.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/doc/outline.png
--------------------------------------------------------------------------------
/example/25535163354.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/25535163354.jpg
--------------------------------------------------------------------------------
/example/26939476984.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/26939476984.jpg
--------------------------------------------------------------------------------
/example/27417460620.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/27417460620.jpg
--------------------------------------------------------------------------------
/example/28411051634.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/28411051634.jpg
--------------------------------------------------------------------------------
/example/28922730122.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/28922730122.jpg
--------------------------------------------------------------------------------
/example/28930160605.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/28930160605.jpg
--------------------------------------------------------------------------------
/example/6833735316.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/6833735316.jpg
--------------------------------------------------------------------------------
/example/8527042251.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EdlinOrg/prominentcolor/0cadac69bd03e873f418712f44574837d63d7755/example/8527042251.jpg
--------------------------------------------------------------------------------
/example/example.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | _ "image/jpeg"
7 | "io/ioutil"
8 | "log"
9 | "os"
10 | "strings"
11 |
12 | prominentcolor ".."
13 | )
14 |
15 | func loadImage(fileInput string) (image.Image, error) {
16 | f, err := os.Open(fileInput)
17 | defer f.Close()
18 | if err != nil {
19 | log.Println("File not found:", fileInput)
20 | return nil, err
21 | }
22 | img, _, err := image.Decode(f)
23 | if err != nil {
24 | return nil, err
25 | }
26 |
27 | return img, nil
28 | }
29 |
30 | func outputColorRange(colorRange []prominentcolor.ColorItem) string {
31 | var buff strings.Builder
32 | buff.WriteString("
")
33 | for _, color := range colorRange {
34 | buff.WriteString(fmt.Sprintf("#%s %d | ", color.AsString(), color.AsString(), color.Cnt))
35 | }
36 | buff.WriteString("
")
37 | return buff.String()
38 | }
39 |
40 | func outputTitle(str string) string {
41 | return "" + str + "
"
42 | }
43 |
44 | func processBatch(k int, bitarr []int, img image.Image) string {
45 | var buff strings.Builder
46 |
47 | prefix := fmt.Sprintf("K=%d, ", k)
48 | resizeSize := uint(prominentcolor.DefaultSize)
49 | bgmasks := prominentcolor.GetDefaultMasks()
50 |
51 | for i := 0; i < len(bitarr); i++ {
52 | res, err := prominentcolor.KmeansWithAll(k, img, bitarr[i], resizeSize, bgmasks)
53 | if err != nil {
54 | log.Println(err)
55 | continue
56 | }
57 | buff.WriteString(outputTitle(prefix + bitInfo(bitarr[i])))
58 | buff.WriteString(outputColorRange(res))
59 | }
60 |
61 | return buff.String()
62 | }
63 |
64 | func bitInfo(bits int) string {
65 | list := make([]string, 0, 4)
66 | // random seed or Kmeans++
67 | if prominentcolor.IsBitSet(bits, prominentcolor.ArgumentSeedRandom) {
68 | list = append(list, "Random seed")
69 | } else {
70 | list = append(list, "Kmeans++")
71 | }
72 | // Mean or median
73 | if prominentcolor.IsBitSet(bits, prominentcolor.ArgumentAverageMean) {
74 | list = append(list, "Mean")
75 | } else {
76 | list = append(list, "Median")
77 | }
78 | // LAB or RGB
79 | if prominentcolor.IsBitSet(bits, prominentcolor.ArgumentLAB) {
80 | list = append(list, "LAB")
81 | } else {
82 | list = append(list, "RGB")
83 | }
84 | // Cropping or not
85 | if prominentcolor.IsBitSet(bits, prominentcolor.ArgumentNoCropping) {
86 | list = append(list, "No cropping")
87 | } else {
88 | list = append(list, "Cropping center")
89 | }
90 | // Done
91 | return strings.Join(list, ", ")
92 | }
93 |
94 | func main() {
95 | // Prepare
96 | outputDirectory := "./"
97 | dataDirectory := "./"
98 |
99 | var buff strings.Builder
100 | buff.WriteString("Colors listed in order of dominance: hex color followed by number of entries
")
101 |
102 | // for each file within working directory
103 | files, err := ioutil.ReadDir(dataDirectory)
104 | if err != nil {
105 | log.Fatal(err)
106 | }
107 | for _, f := range files {
108 | filename := f.Name()
109 | // Only process jpg
110 | if !strings.HasSuffix(filename, ".jpg") {
111 | continue
112 | }
113 | // Define the differents sets of params
114 | kk := []int{
115 | prominentcolor.ArgumentAverageMean | prominentcolor.ArgumentNoCropping,
116 | prominentcolor.ArgumentNoCropping,
117 | prominentcolor.ArgumentDefault,
118 | }
119 | // Load the image
120 | img, err := loadImage(filename)
121 | if err != nil {
122 | log.Printf("Error loading image %s\n", filename)
123 | log.Println(err)
124 | continue
125 | }
126 | // Process & html output
127 | buff.WriteString(" | ")
128 | buff.WriteString(processBatch(3, kk, img))
129 | buff.WriteString(" |
")
130 | }
131 |
132 | // Finalize the html output
133 | buff.WriteString("
")
134 |
135 | // And write it to the disk
136 | if err = ioutil.WriteFile(outputDirectory+"output.html", []byte(buff.String()), 0644); err != nil {
137 | panic(err)
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/example/output.html:
--------------------------------------------------------------------------------
1 | Colors listed in order of dominance: hex color followed by number of entries
 | K=3, Kmeans++, Mean, RGB, No cropping#441F1C 1731 | #9E6B68 1512 | #CBB9B7 1077 |
K=3, Kmeans++, Median, RGB, No cropping#401B18 1694 | #9B6B6A 1528 | #D2B2AE 1098 |
K=3, Kmeans++, Median, RGB, Cropping center#6A1616 1701 | #E98584 1341 | #B64D50 1198 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#676E73 5203 | #3A444A 2013 | #B1A98D 864 |
K=3, Kmeans++, Median, RGB, No cropping#696F74 4839 | #3F4C50 2404 | #B4A78E 837 |
K=3, Kmeans++, Median, RGB, Cropping center#414E52 3252 | #8A856F 2810 | #C0B293 2018 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#364A2E 2213 | #7D7A64 1311 | #CEB6C0 796 |
K=3, Kmeans++, Median, RGB, No cropping#364C31 2211 | #7D7860 1336 | #D6B9C8 773 |
K=3, Kmeans++, Median, RGB, Cropping center#DAC8D1 1895 | #BF9695 752 | #6D5D4D 693 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#5C8132 2038 | #A3B081 1254 | #242614 948 |
K=3, Kmeans++, Median, RGB, No cropping#568531 1937 | #99B073 1373 | #1F2310 930 |
K=3, Kmeans++, Median, RGB, Cropping center#607D35 2079 | #BBA38C 1234 | #2A2010 927 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#39382D 1849 | #6B7280 1341 | #BABEAC 1050 |
K=3, Kmeans++, Median, RGB, No cropping#373829 1862 | #727484 1407 | #BFC0B3 971 |
K=3, Kmeans++, Median, RGB, Cropping center#6B7AB7 1674 | #1D222E 1328 | #364985 1238 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#6E3F17 676 | #B57B4B 638 | #BEB0A1 295 |
K=3, Kmeans++, Median, RGB, No cropping#C49565 595 | #673911 521 | #9B6738 493 |
K=3, Kmeans++, Median, RGB, Cropping center#643811 1831 | #A16B3A 1218 | #CE9663 1126 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#D0C018 300 | #DAD796 205 | #908F77 202 |
K=3, Kmeans++, Median, RGB, No cropping#CFBE0C 290 | #979788 224 | #E2DB96 193 |
K=3, Kmeans++, Median, RGB, Cropping center#EEEFE4 2611 | #D0C003 318 | #A0A18E 271 |
|
 | K=3, Kmeans++, Mean, RGB, No cropping#474F57 2313 | #8361B0 2162 | #C1B236 1925 |
K=3, Kmeans++, Median, RGB, No cropping#89649E 2492 | #43505A 2245 | #D1BD25 1663 |
K=3, Kmeans++, Median, RGB, Cropping center#D9CC06 4351 | #BC88B8 1450 | #443027 599 |
|
--------------------------------------------------------------------------------
/example2/example2.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "image"
5 | _ "image/jpeg"
6 | "log"
7 | "os"
8 |
9 | "path/filepath"
10 |
11 | "fmt"
12 |
13 | prominentcolor ".."
14 | )
15 |
16 | func loadImage(fileInput string) (image.Image, error) {
17 | f, err := os.Open(fileInput)
18 | defer f.Close()
19 | if err != nil {
20 | log.Println("File not found:", fileInput)
21 | return nil, err
22 | }
23 | img, _, err := image.Decode(f)
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | return img, nil
29 | }
30 |
31 | // Process images in a directory, for each image it picks out the dominant color and
32 | // prints out an imagemagick call to resize image and use the dominant color as padding for the background
33 | // it saves tmp files in /tmp/ with the masked bit marked as pink
34 | func main() {
35 |
36 | inputPattern := "../example/*.jpg"
37 | outputDirectory := "/tmp/"
38 |
39 | files, err := filepath.Glob(inputPattern)
40 |
41 | if nil != err {
42 | log.Println(err)
43 | log.Println("Error: failed glob")
44 | return
45 | }
46 |
47 | for _, file := range files {
48 | img, err := loadImage(file)
49 | if nil != err {
50 | log.Println(err)
51 | log.Printf("Error: failed loading %s\n", file)
52 | continue
53 | }
54 | cols, err := prominentcolor.KmeansWithArgs(prominentcolor.ArgumentNoCropping|prominentcolor.ArgumentDebugImage, img)
55 | if err != nil {
56 | log.Println(err)
57 | continue
58 | }
59 | col := cols[0].AsString()
60 | base := filepath.Base(file)
61 | fmt.Printf("convert %s -resize 800x356 -background '#%s' -gravity center -extent 800x356 %s%s\n", base, col, outputDirectory, base)
62 | }
63 |
64 | }
65 |
--------------------------------------------------------------------------------
/imgprep.go:
--------------------------------------------------------------------------------
1 | // Copyright 2016 Carl Asman. All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 |
5 | package prominentcolor
6 |
7 | import (
8 | "image"
9 | "image/color"
10 | "image/draw"
11 | "log"
12 |
13 | "image/jpeg"
14 | "os"
15 |
16 | "time"
17 |
18 | "fmt"
19 |
20 | "github.com/nfnt/resize"
21 | "github.com/oliamb/cutter"
22 | )
23 |
24 | // ColorBackgroundMask defines which color channels to look for color to ignore
25 | type ColorBackgroundMask struct {
26 | // Setting them all to true or all to false; Treshold is used, otherwise PercDiff
27 | R, G, B bool
28 |
29 | // Treshold is the lower limit to check against for each r,g,b value, when all R,G,B that has true set should be above to be ignored (upper if all set to false)
30 | Treshold uint32
31 |
32 | // PercDiff if any of R,G,B is true (but not all), any of the other colors divided by the color value that is true, must be below PercDiff
33 | PercDiff float32
34 | }
35 |
36 | // ProcessImg process the image and mark unwanted pixels transparent.
37 | // It checks the corners, if not all of them match the mask, we conclude it's not a clipart/solid background and do nothing
38 | func ProcessImg(arguments int, bgmasks []ColorBackgroundMask, img image.Image) draw.Image {
39 | imgDraw := createDrawImage(img)
40 | rect := imgDraw.Bounds()
41 |
42 | //loop through the masks, and the first one that matches on the four corners is the one that will be used
43 | foundMaskThatmatched := false
44 | var bgmaskToUse ColorBackgroundMask
45 | for _, bgmask := range bgmasks {
46 | // Check the corners, if not all of them are the color of the mask,
47 | // we conclude it's not a solid background and do nothing special
48 | if !ignorePixel(rect.Min.X, rect.Min.Y, bgmask, &imgDraw) || !ignorePixel(rect.Min.X, rect.Max.Y-1, bgmask, &imgDraw) || !ignorePixel(rect.Max.X-1, rect.Min.Y, bgmask, &imgDraw) || !ignorePixel(rect.Max.X-1, rect.Max.Y-1, bgmask, &imgDraw) {
49 | continue
50 | }
51 | foundMaskThatmatched = true
52 | bgmaskToUse = bgmask
53 | }
54 |
55 | // no mask that we can apply
56 | if !foundMaskThatmatched {
57 | return imgDraw
58 | }
59 |
60 | ProcessImgOutline(bgmaskToUse, &imgDraw)
61 |
62 | // if debug argument is set, save a tmp file to be able to view what was masked out
63 | if IsBitSet(arguments, ArgumentDebugImage) {
64 | tmpFilename := fmt.Sprintf("/tmp/tmp%d.jpg", time.Now().UnixNano()/1000000)
65 | toimg, _ := os.Create(tmpFilename)
66 | defer toimg.Close()
67 | jpeg.Encode(toimg, imgDraw, &jpeg.Options{Quality: 100})
68 | }
69 |
70 | return imgDraw
71 | }
72 |
73 | // ProcessImgOutline follow the outline of the image and mark all "white" pixels as transparent
74 | func ProcessImgOutline(bgmask ColorBackgroundMask, imgDraw *draw.Image) {
75 |
76 | rect := (*imgDraw).Bounds()
77 |
78 | var pointsToProcess []image.Point
79 |
80 | // points to add to start processing: corners only
81 | pointsToProcess = append(pointsToProcess, image.Point{X: rect.Min.X, Y: rect.Min.Y})
82 | pointsToProcess = append(pointsToProcess, image.Point{X: rect.Min.X, Y: rect.Max.Y - 1})
83 | pointsToProcess = append(pointsToProcess, image.Point{X: rect.Max.X - 1, Y: rect.Min.Y})
84 | pointsToProcess = append(pointsToProcess, image.Point{X: rect.Max.X - 1, Y: rect.Max.Y - 1})
85 |
86 | var p image.Point
87 | for len(pointsToProcess) > 0 {
88 | //pop from slice
89 | p, pointsToProcess = pointsToProcess[len(pointsToProcess)-1], pointsToProcess[:len(pointsToProcess)-1]
90 |
91 | if !isPixelTransparent(p.X, p.Y, imgDraw) && ignorePixel(p.X, p.Y, bgmask, (imgDraw)) {
92 |
93 | //Mark the pixel
94 | markPixel(p.X, p.Y, (imgDraw))
95 | if !isPixelTransparent(p.X, p.Y, imgDraw) {
96 | log.Println("ERROR: marking")
97 | }
98 |
99 | //add pixels above, below, left,right
100 | //unless its transparent
101 | if rect.Min.X < p.X {
102 | if !isPixelTransparent(p.X-1, p.Y, imgDraw) {
103 | pointsToProcess = append(pointsToProcess, image.Point{X: p.X - 1, Y: p.Y})
104 | }
105 | }
106 |
107 | if p.X < rect.Max.X-1 {
108 | if !isPixelTransparent(p.X+1, p.Y, imgDraw) {
109 | pointsToProcess = append(pointsToProcess, image.Point{X: p.X + 1, Y: p.Y})
110 | }
111 | }
112 |
113 | if rect.Min.Y < p.Y {
114 | if !isPixelTransparent(p.X, p.Y-1, imgDraw) {
115 | pointsToProcess = append(pointsToProcess, image.Point{X: p.X, Y: p.Y - 1})
116 | }
117 | }
118 |
119 | if p.Y < rect.Max.Y-1 {
120 | if !isPixelTransparent(p.X, p.Y+1, imgDraw) {
121 | pointsToProcess = append(pointsToProcess, image.Point{X: p.X, Y: p.Y + 1})
122 | }
123 | }
124 | }
125 | }
126 | }
127 |
128 | // createDrawImage creates a draw.Image so we can work with the single pixels
129 | func createDrawImage(img image.Image) draw.Image {
130 | b := img.Bounds()
131 | cimg := image.NewRGBA(b)
132 | draw.Draw(cimg, b, img, b.Min, draw.Src)
133 | return cimg
134 | }
135 |
136 | // prepareImg resizes to a smaller size and remove any "white" background pixels for isolated/clipart images
137 | func prepareImg(arguments int, bgmasks []ColorBackgroundMask, imageSize uint, orgimg image.Image) image.Image {
138 |
139 | if !IsBitSet(arguments, ArgumentNoCropping) {
140 | // crop to remove 25% on all sides
141 | croppedimg, err := cutter.Crop(orgimg, cutter.Config{
142 | Width: int(orgimg.Bounds().Dx() / 2),
143 | Height: int(orgimg.Bounds().Dy() / 2),
144 | Mode: cutter.Centered,
145 | })
146 |
147 | if err != nil {
148 | log.Println("Warning: failed cropping")
149 | log.Println(err)
150 | } else {
151 | orgimg = croppedimg
152 | }
153 | }
154 |
155 | // Don't resize if the image is smaller than imageSize
156 | rec := orgimg.Bounds()
157 |
158 | if uint(rec.Dx()) > imageSize || uint(rec.Dy()) > imageSize {
159 | img := resize.Resize(imageSize, 0, orgimg, resize.Lanczos3)
160 | return ProcessImg(arguments, bgmasks, img)
161 | }
162 |
163 | return ProcessImg(arguments, bgmasks, orgimg)
164 | }
165 |
166 | // markPixel sets a purple color (to make it stick out if we want to look at the image) and makes the pixel transparent
167 | func markPixel(x, y int, img *draw.Image) {
168 | (*img).Set(x, y, color.RGBA{255, 0, 255, 0})
169 | }
170 |
171 | // isPixelTransparent returns bool if the pixel is transparent (alpha==0)
172 | func isPixelTransparent(x, y int, img *draw.Image) bool {
173 | colorAt := (*img).At(x, y)
174 | _, _, _, a := colorAt.RGBA()
175 | return a == 0
176 | }
177 |
178 | // ignorePixel checks if the pixel should be ignored (i.e. being transparent or white)
179 | func ignorePixel(x, y int, bgmask ColorBackgroundMask, img *draw.Image) bool {
180 | colorAt := (*img).At(x, y)
181 |
182 | r, g, b, a := colorAt.RGBA()
183 |
184 | if a == 0 {
185 | return true
186 | }
187 |
188 | //if looking for black
189 | if !(bgmask.R || bgmask.G || bgmask.B) {
190 | if r > bgmask.Treshold {
191 | return false
192 | }
193 |
194 | if g > bgmask.Treshold {
195 | return false
196 | }
197 |
198 | if b > bgmask.Treshold {
199 | return false
200 | }
201 |
202 | return true
203 | }
204 |
205 | //if not looking for white
206 | if !(bgmask.R && bgmask.G && bgmask.B) {
207 |
208 | var aArr, baseArr []float32
209 |
210 | if bgmask.R {
211 | baseArr = append(baseArr, float32(r))
212 | } else {
213 | aArr = append(aArr, float32(r))
214 | }
215 | if bgmask.G {
216 | baseArr = append(baseArr, float32(g))
217 | } else {
218 | aArr = append(aArr, float32(g))
219 | }
220 | if bgmask.B {
221 | baseArr = append(baseArr, float32(b))
222 | } else {
223 | aArr = append(aArr, float32(b))
224 | }
225 |
226 | for _, val := range aArr {
227 | for _, base := range baseArr {
228 | if val/base > bgmask.PercDiff {
229 | return false
230 | }
231 | }
232 | }
233 |
234 | return true
235 | }
236 |
237 | // Checking for white
238 |
239 | if bgmask.R && r < bgmask.Treshold {
240 | return false
241 | }
242 |
243 | if bgmask.G && g < bgmask.Treshold {
244 | return false
245 | }
246 |
247 | if bgmask.B && b < bgmask.Treshold {
248 | return false
249 | }
250 |
251 | return true
252 | }
253 |
--------------------------------------------------------------------------------
/kmeans.go:
--------------------------------------------------------------------------------
1 | // Copyright 2016 Carl Asman. All rights reserved.
2 | // Use of this source code is governed by a BSD-style
3 | // license that can be found in the LICENSE file.
4 |
5 | // Package prominentcolor finds the K most dominant/prominent colors in an image
6 | package prominentcolor
7 |
8 | import (
9 | "fmt"
10 | "image"
11 | "image/color"
12 | "log"
13 | "math/rand"
14 |
15 | "sort"
16 |
17 | "time"
18 |
19 | "github.com/lucasb-eyer/go-colorful"
20 | )
21 |
22 | const (
23 | // ArgumentDefault default settings
24 | ArgumentDefault int = 0
25 | // ArgumentSeedRandom randomly pick initial values (instead of K-means++)
26 | ArgumentSeedRandom = 1 << iota
27 | // ArgumentAverageMean take the mean value when determining the centroid color (instead of median)
28 | ArgumentAverageMean
29 | // ArgumentNoCropping do not crop background that is considered "white"
30 | ArgumentNoCropping
31 | // ArgumentLAB (experimental, it seems to be buggy in some cases): uses LAB instead of RGB when measuring distance
32 | ArgumentLAB
33 | // ArgumentDebugImage saves a tmp file in /tmp/ where the area that has been cut away by the mask is marked pink
34 | // useful when figuring out what values to pick for the masks
35 | ArgumentDebugImage
36 | )
37 |
38 | const (
39 | // DefaultK is the k used as default
40 | DefaultK = 3
41 | // DefaultSize is the default size images are re-sized to
42 | DefaultSize = 80
43 | )
44 |
45 | var (
46 | // MaskWhite "constant" for white mask (for ease of re-use for other mask arrays)
47 | MaskWhite = ColorBackgroundMask{R: true, G: true, B: true, Treshold: uint32(0xc000)}
48 | // MaskBlack "constant" for black mask (for ease of re-use for other mask arrays)
49 | MaskBlack = ColorBackgroundMask{R: false, G: false, B: false, Treshold: uint32(0x5000)}
50 | // MaskGreen "constant" for green mask (for ease of re-use for other mask arrays)
51 | MaskGreen = ColorBackgroundMask{R: false, G: true, B: false, PercDiff: 0.9}
52 | )
53 |
54 | // ErrNoPixelsFound is returned when no non-alpha pixels are found in the provided image
55 | var ErrNoPixelsFound = fmt.Errorf("Failed, no non-alpha pixels found (either fully transparent image, or the ColorBackgroundMask removed all pixels)")
56 |
57 | // ColorRGB contains the color values
58 | type ColorRGB struct {
59 | R, G, B uint32
60 | }
61 |
62 | // ColorItem contains color and have many occurrences of this color found
63 | type ColorItem struct {
64 | Color ColorRGB
65 | Cnt int
66 | }
67 |
68 | // AsString gives back the color in hex as 6 character string
69 | func (c *ColorItem) AsString() string {
70 | return fmt.Sprintf("%.2X%.2X%.2X", c.Color.R, c.Color.G, c.Color.B)
71 | }
72 |
73 | // createColor returns ColorItem struct unless it was a transparent color
74 | func createColor(c color.Color) (ColorItem, bool) {
75 | r, g, b, a := c.RGBA()
76 |
77 | if a == 0 {
78 | // transparent pixels are ignored
79 | return ColorItem{}, true
80 | }
81 |
82 | divby := uint32(256.0)
83 | return ColorItem{Color: ColorRGB{R: r / divby, G: g / divby, B: b / divby}}, false
84 | }
85 |
86 | // IsBitSet check if "lookingfor" is set in "bitset"
87 | func IsBitSet(bitset int, lookingfor int) bool {
88 | return lookingfor == (bitset & lookingfor)
89 | }
90 |
91 | // GetDefaultMasks returns the masks that are used for the default settings
92 | func GetDefaultMasks() []ColorBackgroundMask {
93 | return []ColorBackgroundMask{MaskWhite, MaskBlack, MaskGreen}
94 | }
95 |
96 | // Kmeans uses the default: k=3, Kmeans++, Median, crop center, resize to 80 pixels, mask out white/black/green backgrounds
97 | // It returns an array of ColorItem which are three centroids, sorted according to dominance (most frequent first).
98 | func Kmeans(orgimg image.Image) (centroids []ColorItem, err error) {
99 | return KmeansWithAll(DefaultK, orgimg, ArgumentDefault, DefaultSize, GetDefaultMasks())
100 | }
101 |
102 | // KmeansWithArgs takes arguments which consists of the bits, see constants Argument*
103 | func KmeansWithArgs(arguments int, orgimg image.Image) (centroids []ColorItem, err error) {
104 | return KmeansWithAll(DefaultK, orgimg, arguments, DefaultSize, GetDefaultMasks())
105 | }
106 |
107 | // KmeansWithAll takes additional arguments to define k, arguments (see constants Argument*), size to resize and masks to use
108 | func KmeansWithAll(k int, orgimg image.Image, arguments int, imageReSize uint, bgmasks []ColorBackgroundMask) ([]ColorItem, error) {
109 |
110 | img := prepareImg(arguments, bgmasks, imageReSize, orgimg)
111 |
112 | allColors, _ := extractColorsAsArray(img)
113 |
114 | numColors := len(allColors)
115 |
116 | if numColors == 0 {
117 | return nil, ErrNoPixelsFound
118 | }
119 |
120 | if numColors == 1 {
121 | return allColors, nil
122 | }
123 |
124 | if numColors <= k {
125 | sortCentroids(allColors)
126 | return allColors, nil
127 | }
128 |
129 | centroids, err := kmeansSeed(k, allColors, arguments)
130 | if err != nil {
131 | return nil, err
132 | }
133 |
134 | cent := make([][]ColorItem, k)
135 |
136 | //initialize
137 | cent[0] = allColors
138 | for i := 1; i < k; i++ {
139 | cent[i] = []ColorItem{}
140 | }
141 |
142 | //rounds is a safety net to make sure we terminate if its a bug in our distance function (or elsewhere) that makes k-means not terminate
143 | rounds := 0
144 | maxRounds := 5000
145 | changes := 1
146 |
147 | for changes > 0 && rounds < maxRounds {
148 | changes = 0
149 | tmpCent := make([][]ColorItem, k)
150 | for i := 0; i < k; i++ {
151 | tmpCent[i] = []ColorItem{}
152 | }
153 |
154 | for i := 0; i < k; i++ {
155 | for _, aColor := range cent[i] {
156 | closestCentroid := findClosest(arguments, aColor, centroids)
157 |
158 | tmpCent[closestCentroid] = append(tmpCent[closestCentroid], aColor)
159 | if closestCentroid != i {
160 | changes++
161 | }
162 | }
163 | }
164 | cent = tmpCent
165 | centroids = calculateCentroids(cent, arguments)
166 | rounds++
167 | }
168 |
169 | if rounds >= maxRounds {
170 | log.Println("Warning: terminated k-means due to max number of iterations")
171 | }
172 |
173 | sortCentroids(centroids)
174 | return centroids, nil
175 | }
176 |
177 | // ByColorCnt makes the ColorItem sortable
178 | type byColorCnt []ColorItem
179 |
180 | func (a byColorCnt) Len() int { return len(a) }
181 | func (a byColorCnt) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
182 | func (a byColorCnt) Less(i, j int) bool {
183 | if a[i].Cnt == a[j].Cnt {
184 | return a[i].AsString() < a[j].AsString()
185 | }
186 | return a[i].Cnt < a[j].Cnt
187 | }
188 |
189 | // sortCentroids sorts them from most dominant color descending
190 | func sortCentroids(centroids []ColorItem) {
191 | sort.Sort(sort.Reverse(byColorCnt(centroids)))
192 | }
193 |
194 | func calculateCentroids(cent [][]ColorItem, arguments int) []ColorItem {
195 | var centroids []ColorItem
196 |
197 | for _, colors := range cent {
198 |
199 | var meanColor ColorItem
200 | if IsBitSet(arguments, ArgumentAverageMean) {
201 | meanColor = mean(colors)
202 | } else {
203 | meanColor = median(colors)
204 | }
205 |
206 | centroids = append(centroids, meanColor)
207 | }
208 |
209 | return centroids
210 | }
211 |
212 | // mean calculate the mean color values from an array of colors
213 | func mean(colors []ColorItem) ColorItem {
214 |
215 | var r, g, b float64
216 |
217 | r, g, b = 0.0, 0.0, 0.0
218 |
219 | cntInThisBucket := 0
220 | for _, aColor := range colors {
221 | cntInThisBucket += aColor.Cnt
222 | r += float64(aColor.Color.R)
223 | g += float64(aColor.Color.G)
224 | b += float64(aColor.Color.B)
225 | }
226 |
227 | theSize := float64(len(colors))
228 |
229 | return ColorItem{Cnt: cntInThisBucket, Color: ColorRGB{R: uint32(r / theSize), G: uint32(g / theSize), B: uint32(b / theSize)}}
230 | }
231 |
232 | // median calculate the median color from an array of colors
233 | func median(colors []ColorItem) ColorItem {
234 |
235 | var rValues, gValues, bValues []int
236 |
237 | cntInThisBucket := 0
238 |
239 | for _, aColor := range colors {
240 | cntInThisBucket += aColor.Cnt
241 | rValues = append(rValues, int(aColor.Color.R))
242 | gValues = append(gValues, int(aColor.Color.G))
243 | bValues = append(bValues, int(aColor.Color.B))
244 | }
245 |
246 | retR := 0
247 | if 0 != len(rValues) {
248 | sort.Ints(rValues)
249 | retR = rValues[int(len(rValues)/2)]
250 | }
251 |
252 | retG := 0
253 | if 0 != len(gValues) {
254 | sort.Ints(gValues)
255 | retG = gValues[int(len(gValues)/2)]
256 | }
257 |
258 | retB := 0
259 | if 0 != len(bValues) {
260 | sort.Ints(bValues)
261 | retB = bValues[int(len(bValues)/2)]
262 | }
263 |
264 | return ColorItem{Cnt: cntInThisBucket, Color: ColorRGB{R: uint32(retR), G: uint32(retG), B: uint32(retB)}}
265 | }
266 |
267 | // extractColorsAsArray counts the number of occurrences of each color in the image, returns array and numPixels
268 | func extractColorsAsArray(img image.Image) ([]ColorItem, int) {
269 | m, numPixels := extractColors(img)
270 | v := make([]ColorItem, len(m))
271 | idx := 0
272 | for _, value := range m {
273 | v[idx] = value
274 | idx++
275 | }
276 |
277 | return v, numPixels
278 | }
279 |
280 | // extractColors counts the number of occurrences of each color in the image, returns map
281 | func extractColors(img image.Image) (map[string]ColorItem, int) {
282 |
283 | m := make(map[string]ColorItem)
284 |
285 | numPixels := 0
286 | data := img.Bounds()
287 | for x := data.Min.X; x < data.Max.X; x++ {
288 | for y := data.Min.Y; y < data.Max.Y; y++ {
289 | colorAt := img.At(x, y)
290 | colorItem, ignore := createColor(colorAt)
291 | if ignore {
292 | continue
293 | }
294 | numPixels++
295 | asString := colorItem.AsString()
296 | value, ok := m[asString]
297 | if ok {
298 | value.Cnt++
299 | m[asString] = value
300 | } else {
301 | colorItem.Cnt = 1
302 | m[asString] = colorItem
303 | }
304 | }
305 | }
306 | return m, numPixels
307 | }
308 |
309 | // findClosest returns the index of the closest centroid to the color "c"
310 | func findClosest(arguments int, c ColorItem, centroids []ColorItem) int {
311 |
312 | centLen := len(centroids)
313 |
314 | closestIdx := 0
315 | closestDistance := distance(arguments, c, centroids[0])
316 |
317 | for i := 1; i < centLen; i++ {
318 | distance := distance(arguments, c, centroids[i])
319 | if distance < closestDistance {
320 | closestIdx = i
321 | closestDistance = distance
322 | }
323 | }
324 | return closestIdx
325 | }
326 |
327 | // distance returns the distance between two colors
328 | func distance(arguments int, c ColorItem, p ColorItem) float64 {
329 | if IsBitSet(arguments, ArgumentLAB) {
330 | return distanceLAB(c, p)
331 | }
332 | return distanceRGB(c, p)
333 | }
334 |
335 | func distanceLAB(c ColorItem, p ColorItem) float64 {
336 | errmsg := "Warning: LAB failed, fallback to RGB"
337 |
338 | a, err := colorful.Hex("#" + c.AsString())
339 | if err != nil {
340 | log.Fatal(err)
341 | log.Println(errmsg)
342 | return distanceRGB(c, p)
343 | }
344 |
345 | b, err2 := colorful.Hex("#" + p.AsString())
346 | if err2 != nil {
347 | log.Fatal(err2)
348 | log.Println(errmsg)
349 | return distanceRGB(c, p)
350 | }
351 |
352 | return a.DistanceLab(b)
353 | }
354 |
355 | func distanceRGB(c ColorItem, p ColorItem) float64 {
356 | r := c.Color.R
357 | g := c.Color.G
358 | b := c.Color.B
359 |
360 | r2 := p.Color.R
361 | g2 := p.Color.G
362 | b2 := p.Color.B
363 |
364 | //sqrt not needed since we just want to compare distances to each other
365 | return float64((r-r2)*(r-r2) + (g-g2)*(g-g2) + (b-b2)*(b-b2))
366 | }
367 |
368 | // kmeansSeed calculates the initial cluster centroids
369 | func kmeansSeed(k int, allColors []ColorItem, arguments int) ([]ColorItem, error) {
370 | if k > len(allColors) {
371 | return nil, fmt.Errorf("Failed, k larger than len(allColors): %d vs %d\n", k, len(allColors))
372 | }
373 |
374 | rand.Seed(time.Now().UnixNano())
375 |
376 | if IsBitSet(arguments, ArgumentSeedRandom) {
377 | return kmeansSeedRandom(k, allColors), nil
378 | }
379 | return kmeansPlusPlusSeed(k, arguments, allColors), nil
380 | }
381 |
382 | // kmeansSeedRandom picks k random points as initial centroids
383 | func kmeansSeedRandom(k int, allColors []ColorItem) []ColorItem {
384 | var centroids []ColorItem
385 |
386 | taken := make(map[int]bool)
387 |
388 | for i := 0; i < k; i++ {
389 | idx := rand.Intn(len(allColors))
390 |
391 | //check if we already taken this one
392 | _, ok := taken[idx]
393 | if ok {
394 | i--
395 | continue
396 | }
397 | taken[idx] = true
398 | centroids = append(centroids, allColors[idx])
399 | }
400 | return centroids
401 | }
402 |
403 | // kmeansPlusPlusSeed picks initial centroids using K-Means++
404 | func kmeansPlusPlusSeed(k int, arguments int, allColors []ColorItem) []ColorItem {
405 | var centroids []ColorItem
406 |
407 | taken := make(map[int]bool)
408 |
409 | initIdx := rand.Intn(len(allColors))
410 | centroids = append(centroids, allColors[initIdx])
411 | taken[initIdx] = true
412 |
413 | for kk := 1; kk < k; kk++ {
414 |
415 | totaldistances := 0.0
416 | var point2distance []float64
417 |
418 | for j := 0; j < len(allColors); j++ {
419 |
420 | _, ok := taken[j]
421 | if ok {
422 | point2distance = append(point2distance, 0.0)
423 | continue
424 | }
425 |
426 | minDistanceToCluster := -1.0
427 | for i := 0; i < len(centroids); i++ {
428 | d := distance(arguments, centroids[i], allColors[j])
429 | if minDistanceToCluster == -1.0 || d < minDistanceToCluster {
430 | minDistanceToCluster = d
431 | }
432 | }
433 |
434 | squareDistance := minDistanceToCluster * minDistanceToCluster
435 | totaldistances += squareDistance
436 | point2distance = append(point2distance, squareDistance)
437 | }
438 |
439 | rndpoint := rand.Float64() * totaldistances
440 |
441 | sofar := 0.0
442 | for j := 0; j < len(point2distance); j++ {
443 | if rndpoint <= sofar {
444 | centroids = append(centroids, allColors[j])
445 | taken[j] = true
446 | break
447 | }
448 | sofar += point2distance[j]
449 | }
450 | }
451 |
452 | return centroids
453 | }
454 |
--------------------------------------------------------------------------------