-
Notifications
You must be signed in to change notification settings - Fork 0
/
kMeansColorExtractor.ts
196 lines (167 loc) · 5.7 KB
/
kMeansColorExtractor.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
import rgbaCounter from '../counter/rgbaCounter'
import { RgbaColor } from '../types'
/**
* Additional options for K-Means color extractor
*/
export interface KMeansColorExtractorOptions {
/**
* Maximum number of loops the algorithm can perform (default 10).
* This number can be increased to have more accurate colors,
* but depending on the range of colors being it might take longer to finish.
*
* If the algorithm finishes its calculation before reaching the end,
* it will immediately return the colors found.
*/
maxRuns: number
/**
* Whether consider the alpha channel while finding the colors (default false).
*/
withAlpha: boolean
/**
* Use random parts of the array as the initial values or offset based (default false).
*/
randomSeed: boolean
/**
* After extracting the colors,
* should it return the nearest colors of each group or the median color of each group? (default true)
*
* With this option enabled it will return only colors that are part of color set.
* This option is useful if colors from a logo are being extracted,
* so only colors that are part of the logo will be returned.
*/
nearestColors: boolean
}
const defaultOptions: KMeansColorExtractorOptions = {
maxRuns: 10,
withAlpha: false,
randomSeed: false,
nearestColors: true
}
/**
* Extracts colors using the K-Means algorithm.
* With that approach the colors can be found by finding the mean color on each group.
* Where the colors are grouped based on their RGB distance.
*
* @param colors the list of all colors to be considered
* @param count the number of colors to be extracted
* @param options additional options {@see KMeansColorExtractorOptions} for more details
*/
const kMeansColorExtractor = (
colors: RgbaColor[],
count: number,
options?: Partial<KMeansColorExtractorOptions>
): RgbaColor[] => {
const mergedOptions = { ...defaultOptions, ...options }
const { withAlpha, nearestColors } = mergedOptions
const uniqueColors = rgbaCounter(colors, withAlpha)
.sort((a, b) => b.count - a.count)
.map((it) => it.color)
if (uniqueColors.length <= count) return uniqueColors
const foundColors = runKMeans(uniqueColors, count, mergedOptions)
if (!nearestColors) return foundColors
return foundColors.map((color) => getNearestColor(uniqueColors, color, withAlpha))
}
export default kMeansColorExtractor
const runKMeans = (colors: RgbaColor[], count: number, options: KMeansColorExtractorOptions) => {
const { maxRuns, withAlpha, randomSeed } = options
let centroids = getInitialCentroids(colors, count, randomSeed)
let clustering = getIndexedColors(colors, centroids, withAlpha)
for (let i = 0; i < maxRuns; i++) {
const newCentroids = recomputeCentroids(colors, clustering, count)
const newClustering = getIndexedColors(colors, newCentroids, withAlpha)
if (isEqualArray(clustering, newClustering)) {
return newCentroids
} else {
clustering = newClustering
centroids = newCentroids
}
}
return centroids
}
/**
* Creates an initial centroid list
*
* @param colors
* @param count
* @param randomSeed
*/
const getInitialCentroids = (colors: RgbaColor[], count: number, randomSeed: boolean) => {
const emptyArray = new Array(count).fill(null)
if (randomSeed) {
const randomIndex = () => Math.floor(Math.random() * colors.length)
return emptyArray.map(() => colors[randomIndex()])
}
const chunkSize = Math.floor(colors.length / count)
return emptyArray.map((_, index) => colors[index * chunkSize])
}
/**
* Index the colors based on the centroids.
* Each color will be assigned to the centroid index that is closest to that color.
*
* @param colors
* @param centroids
* @param withAlpha
*/
const getIndexedColors = (colors: RgbaColor[], centroids: RgbaColor[], withAlpha: boolean) => {
return colors.map((color) => getNearestColorIndex(centroids, color, withAlpha))
}
/**
* Recalculate the median position/color for each centroid based on the assigned colors for each centroid.
*
* @param colors
* @param clustering
* @param count
*/
const recomputeCentroids = (colors: RgbaColor[], clustering: number[], count: number) => {
const centroids = new Array(count)
.fill(null)
.map(() => ({ sum: { r: 0, g: 0, b: 0, a: 0 }, count: 0 }))
colors.forEach((color, index) => {
const cluster = clustering[index]
centroids[cluster].count++
centroids[cluster].sum.r += color.r
centroids[cluster].sum.g += color.g
centroids[cluster].sum.b += color.b
centroids[cluster].sum.a += color.a
})
return centroids.map(({ sum, count }) => ({
r: sum.r / count,
g: sum.g / count,
b: sum.b / count,
a: sum.a / count
} as RgbaColor))
}
const getNearestColorIndex = (colors: RgbaColor[], color: RgbaColor, withAlpha: boolean): number => {
let distance = 0
let index = -1
for (let i = 0; i < colors.length; i++) {
const item = colors[i]
const itemDistance = getColorDistance(color, item, withAlpha)
if (index === -1 || itemDistance < distance) {
index = i
distance = itemDistance
}
}
return index
}
const getNearestColor = (colors: RgbaColor[], color: RgbaColor, withAlpha: boolean): RgbaColor => {
const index = getNearestColorIndex(colors, color, withAlpha)
return colors[index]
}
/**
* Calculates the Euclidean distance between two colors.
*
* @param a
* @param b
* @param withAlpha
*/
const getColorDistance = (a: RgbaColor, b: RgbaColor, withAlpha?: boolean): number => {
const distance =
Math.pow(a.r - b.r, 2) +
Math.pow(a.g - b.g, 2) +
Math.pow(a.b - b.b, 2)
if (withAlpha) return distance + Math.pow(a.a - b.a, 2)
return distance
}
const isEqualArray = (a: number[], b: number[]) =>
!!a.find((it, index) => it === b[index])