summaryrefslogtreecommitdiffstats
path: root/lib/gs-key-colors.c
blob: fb0850cea02b58dbd15991ae294f69bf91428f32 (plain)
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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
 * vi:set noexpandtab tabstop=8 shiftwidth=8:
 *
 * Copyright (C) 2013-2016 Richard Hughes <richard@hughsie.com>
 * Copyright (C) 2013 Matthias Clasen <mclasen@redhat.com>
 * Copyright (C) 2014-2018 Kalev Lember <klember@redhat.com>
 * Copyright (C) 2021 Endless OS Foundation LLC
 *
 * Authors:
 *  - Richard Hughes <richard@hughsie.com>
 *  - Kalev Lember <klember@redhat.com>
 *  - Philip Withnall <pwithnall@endlessos.org>
 *
 * SPDX-License-Identifier: GPL-2.0+
 */

/**
 * SECTION:gs-key-colors
 * @short_description: Helper functions for calculating key colors
 *
 * Key colors are RGB colors which represent an app, and they are derived from
 * the app’s icon, or manually specified as an override.
 *
 * Use gs_calculate_key_colors() to calculate the key colors from an app’s icon.
 *
 * Since: 40
 */

#include "config.h"

#include <glib.h>
#include <gdk/gdk.h>
#include <gdk-pixbuf/gdk-pixbuf.h>

#include "gs-key-colors.h"

/* Hard-code the number of clusters to split the icon color space into. This
 * gives the maximum number of key colors returned for an icon. This number has
 * been chosen by examining 1000 icons to subjectively see how many key colors
 * each has. The number of key colors ranged from 1 to 6, but the mode was
 * definitely 3. */
const guint n_clusters = 3;

/* Discard pixels with less than this level of alpha. Almost all icons have a
 * transparent background/border at 100% transparency, and a blending fringe
 * with some intermediate level of transparency which should be ignored for
 * choosing key colors. A number of icons have partially-transparent colored
 * sections in the main body of the icon, which should be used if they’re above
 * this threshold. About 1% of icons have no completely opaque pixels, so we
 * can’t discard non-opaque pixels entirely. */
const guint minimum_alpha = 0.5 * 255;

typedef struct {
	guint8 red;
	guint8 green;
	guint8 blue;
} Pixel8;

typedef struct {
	Pixel8 color;
	union {
		guint8 alpha;
		guint8 cluster;
	};
} ClusterPixel8;

typedef struct {
	guint red;
	guint green;
	guint blue;
	guint n_members;
} CentroidAccumulator;

static inline ClusterPixel8 *
get_pixel (guint8 *pixels,
           guint   x,
           guint   y,
           guint   rowstride,
           guint   n_channels)
{
	return (ClusterPixel8 *) (pixels + y * rowstride + x * n_channels);
}

static inline guint
color_distance (const Pixel8 *a,
                const Pixel8 *b)
{
	/* Take the absolute value rather than the square root to save some
	 * time, as the caller is comparing distances.
	 *
	 * The arithmetic here can’t overflow, as the R/G/B components have a
	 * maximum value of 255 but the arithmetic is done in (at least) 32-bit
	 * variables.*/
	gint dr = b->red - a->red;
	gint dg = b->green - a->green;
	gint db = b->blue - a->blue;

	return abs (dr * dr + dg * dg + db * db);
}

/* NOTE: This has to return stable results when more than one cluster is
 * equidistant from the @pixel, or the k_means() function may not terminate. */
static inline gsize
nearest_cluster (const Pixel8 *pixel,
                 const Pixel8 *cluster_centres,
                 gsize         n_cluster_centres)
{
	gsize nearest_cluster = 0;
	guint nearest_cluster_distance = color_distance (&cluster_centres[0], pixel);

	for (gsize i = 1; i < n_cluster_centres; i++) {
		guint distance = color_distance (&cluster_centres[i], pixel);
		if (distance < nearest_cluster_distance) {
			nearest_cluster = i;
			nearest_cluster_distance = distance;
		}
	}

	return nearest_cluster;
}

/* A variant of g_random_int_range() which chooses without replacement,
 * tracking the used integers in @used_ints and @n_used_ints.
 * Once all integers in 0..max_ints have been used once, it will choose
 * with replacement. */
static gint32
random_int_range_no_replacement (guint max_ints,
				 gboolean *used_ints,
				 guint *n_used_ints)
{
	gint32 random_value = g_random_int_range (0, (gint32) max_ints);

	if (*n_used_ints < max_ints) {
		while (used_ints[random_value])
			random_value = (random_value + 1) % max_ints;

		used_ints[random_value] = TRUE;
		*n_used_ints = *n_used_ints + 1;
	}

	return random_value;
}

/* Extract the key colors from @pb by clustering the pixels in RGB space.
 * Clustering is done using k-means, with initialisation using a
 * Random Partition.
 *
 * This approach can be thought of as plotting every pixel in @pb in a
 * three-dimensional color space, with red, green and blue axes (alpha is
 * clipped to 0 (pixel is ignored) or 1 (pixel is used)). The key colors for
 * the image are the ones where a large number of pixels are plotted in a group
 * in the color space — either a lot of pixels with an identical color
 * (repeated use of exactly the same color in the image) or a lot of pixels in
 * a rough group (use of a lot of similar shades of the same color in the
 * image).
 *
 * By transforming to a color space, information about the X and Y positions of
 * each color is ignored, so a thin outline in the image of a single color
 * will appear in the color space as a cluster, just as a contiguous block of
 * one color would.
 *
 * The k-means clustering algorithm is then used to find these clusters. k-means
 * is used, rather than (say) principal component analysis, because it
 * inherently calculates the centroid for each cluster. In a color space, the
 * centroid is itself a color, which can then be used as the key color to
 * return.
 *
 * The number of clusters is limited to @n_clusters, as a subjective survey of
 * 1000 icons found that they commonly used this number of key colors.
 *
 * Various other shortcuts have been taken which make this approach quite
 * specific to key color extraction from icons, with the aim of making it
 * faster. That’s fine — it doesn’t matter if the results this function produces
 * are optimal, only that they’re good enough. */
static void
k_means (GArray    *colors,
         GdkPixbuf *pb)
{
	gint rowstride, n_channels;
	gint width, height;
	guint8 *raw_pixels;
	ClusterPixel8 *pixels;
	const ClusterPixel8 *pixels_end;
	Pixel8 cluster_centres[n_clusters];
	CentroidAccumulator cluster_accumulators[n_clusters];
	gboolean used_clusters[n_clusters];
	guint n_used_clusters = 0;
	guint n_assignments_changed;
	guint n_iterations = 0;
	guint assignments_termination_limit;

	n_channels = gdk_pixbuf_get_n_channels (pb);
	rowstride = gdk_pixbuf_get_rowstride (pb);
	raw_pixels = gdk_pixbuf_get_pixels (pb);
	width = gdk_pixbuf_get_width (pb);
	height = gdk_pixbuf_get_height (pb);

	/* The pointer arithmetic over pixels can be simplified if we can assume
	 * there are no gaps in the @raw_pixels data. Since the caller is
	 * downsizing the #GdkPixbuf, this is a reasonable assumption. */
	g_assert (rowstride == width * n_channels);
	g_assert (n_channels == 4);

	pixels = (ClusterPixel8 *) raw_pixels;
	pixels_end = &pixels[height * width];

	memset (cluster_centres, 0, sizeof (cluster_centres));
	memset (used_clusters, 0, sizeof (used_clusters));

	/* Initialise the clusters using the Random Partition method: randomly
	 * assign a starting cluster to each pixel.
	 *
	 * The Forgy method (choosing random pixels as the starting cluster
	 * centroids) is not appropriate as the checks required to make sure
	 * they aren’t transparent or duplicated colors mean that the
	 * initialisation step may never complete. Consider the case of an icon
	 * which is a block of solid color. */
	for (ClusterPixel8 *p = pixels; p < pixels_end; p++) {
		if (p->alpha < minimum_alpha)
			p->cluster = G_N_ELEMENTS (cluster_centres);
		else
			p->cluster = random_int_range_no_replacement (G_N_ELEMENTS (cluster_centres), used_clusters, &n_used_clusters);
	}

	/* Iterate until every cluster is relatively settled. This is determined
	 * by the number of pixels whose assignment to a cluster changes in
	 * each iteration — if the number of pixels is less than 1% of the image
	 * then subsequent iterations are not going to significantly affect the
	 * results.
	 *
	 * As we’re choosing key colors, finding the optimal result is not
	 * needed. We just need to find one which is good enough, quickly.
	 *
	 * A second termination condition is set on the number of iterations, to
	 * avoid a potential infinite loop. This termination condition is never
	 * normally expected to be hit — typically an icon will require 5–10
	 * iterations to terminate based on @n_assignments_changed. */
	assignments_termination_limit = width * height * 0.01;
	n_iterations = 0;
	do {
		/* Update step. Re-calculate the centroid of each cluster from
		 * the colors which are in it. */
		memset (cluster_accumulators, 0, sizeof (cluster_accumulators));

		for (const ClusterPixel8 *p = pixels; p < pixels_end; p++) {
			if (p->cluster >= G_N_ELEMENTS (cluster_centres))
				continue;

			cluster_accumulators[p->cluster].red += p->color.red;
			cluster_accumulators[p->cluster].green += p->color.green;
			cluster_accumulators[p->cluster].blue += p->color.blue;
			cluster_accumulators[p->cluster].n_members++;
		}

		for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) {
			if (cluster_accumulators[i].n_members == 0)
				continue;

			cluster_centres[i].red = cluster_accumulators[i].red / cluster_accumulators[i].n_members;
			cluster_centres[i].green = cluster_accumulators[i].green / cluster_accumulators[i].n_members;
			cluster_centres[i].blue = cluster_accumulators[i].blue / cluster_accumulators[i].n_members;
		}

		/* Update assignments of colors to clusters. */
		n_assignments_changed = 0;
		for (ClusterPixel8 *p = pixels; p < pixels_end; p++) {
			gsize new_cluster;

			if (p->cluster >= G_N_ELEMENTS (cluster_centres))
				continue;

			new_cluster = nearest_cluster (&p->color, cluster_centres, G_N_ELEMENTS (cluster_centres));
			if (new_cluster != p->cluster)
				n_assignments_changed++;
			p->cluster = new_cluster;
		}

		n_iterations++;
	} while (n_assignments_changed > assignments_termination_limit && n_iterations < 50);

	/* Output the cluster centres: these are the icon’s key colors. */
	for (gsize i = 0; i < G_N_ELEMENTS (cluster_centres); i++) {
		GdkRGBA color;

		if (cluster_accumulators[i].n_members == 0)
			continue;

		color.red = (gdouble) cluster_centres[i].red / 255.0;
		color.green = (gdouble) cluster_centres[i].green / 255.0;
		color.blue = (gdouble) cluster_centres[i].blue / 255.0;
		color.alpha = 1.0;
		g_array_append_val (colors, color);
	}
}

/**
 * gs_calculate_key_colors:
 * @pixbuf: an app icon to calculate key colors from
 *
 * Calculate the set of key colors present in @pixbuf. These are the colors
 * which stand out the most, and they are subjective. This function does not
 * guarantee to return perfect results, but should return workable results for
 * most icons.
 *
 * @pixbuf will be scaled down to 32×32 pixels, so if it can be provided at
 * that resolution by the caller, this function will return faster.
 *
 * Returns: (transfer full) (element-type GdkRGBA): key colors for @pixbuf
 * Since: 40
 */
GArray *
gs_calculate_key_colors (GdkPixbuf *pixbuf)
{
	g_autoptr(GdkPixbuf) pb_small = NULL;
	g_autoptr(GArray) colors = g_array_new (FALSE, FALSE, sizeof (GdkRGBA));

	/* people almost always use BILINEAR scaling with pixbufs, but we can
	 * use NEAREST here since we only care about the rough colour data, not
	 * whether the edges in the image are smooth and visually appealing;
	 * NEAREST is twice as fast as BILINEAR */
	pb_small = gdk_pixbuf_scale_simple (pixbuf, 32, 32, GDK_INTERP_NEAREST);

	/* require an alpha channel for storing temporary values; most images
	 * have one already, about 2% don’t */
	if (gdk_pixbuf_get_n_channels (pixbuf) != 4) {
		g_autoptr(GdkPixbuf) temp = g_steal_pointer (&pb_small);
		pb_small = gdk_pixbuf_add_alpha (temp, FALSE, 0, 0, 0);
	}

	/* get a list of key colors */
	k_means (colors, pb_small);

	return g_steal_pointer (&colors);
}