From eda259ba73774501cd68993f702b1be9f0147f8c Mon Sep 17 00:00:00 2001 From: 392781 Date: Mon, 17 Aug 2020 14:37:04 -0700 Subject: [PATCH 01/10] Added getdominantcolors method - Finds the dominant colors in an image using k-means clustering algorithm --- src/PIL/Image.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 8ab67d55e..d2219b7c4 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1272,6 +1272,77 @@ class Image: return self.im.getband(band) return self.im # could be abused + def getdominantcolors(self, numcolors=3, maxiter=50, threshold=1): + """ + Returns a list of dominant colors in an image using k-means + clustering. + + :param numcolors: Number of dominant colors to search for. + The default number is 3. + :param maxiter: Maximum number of iterations to run the + algorithm. The default limit is 50. + :param threshold: Early stopping condition for the algorithm. + Higher values correspond with increased color differences. The + default is set to 1 (corresponding to 1 pixel difference). + :returns: An unsorted list of (pixel) values. + """ + + def euclidean(p1, p2): + return sum([(p1[i] - p2[i]) ** 2 for i in range(channels)]) + + if self.mode in ("1", "L", "P"): + channels = 1 + elif self.mode in ("RGB", "YCbCr", "LAB", "HSV"): + channels = 3 + elif self.mode in ("RGBA", "CMYK"): + channels = 4 + + w, h = self.size() + + pixels_and_counts = [] + for count, color in self.im.getcolors(w * h): + pixels_and_counts.append((color, count)) + + centroids = [] + for i in range(numcolors): + centroids.append(([pixels_and_counts[i]], pixels_and_counts[i][0])) + + for iter in range(maxiter): + cluster = {} + for i in range(numcolors): + cluster[i] = [] + + for pixel in pixels_and_counts: + smallest_distance = float("Inf") + + for i in range(numcolors): + distance = euclidean(pixel[0], centroids[i][1]) + if distance < smallest_distance: + smallest_distance = distance + idx = i + + cluster[idx].append(pixel) + + difference = 0 + for i in range(numcolors): + previous = centroids[i][1] + pixel_sum = [0.0 for i in range(channels)] + count_sum = 0 + + for pixel in cluster[i]: + count_sum += pixel[1] + for channel in range(channels): + pixel_sum[channel] += pixel[0][channel] * pixel[1] + + current = [(channel_sum / count_sum) for channel_sum in pixel_sum] + centroids[i] = (cluster[i], current) + difference = max(difference, euclidean(previous, current)) + + if difference < threshold: + break + + return [tuple(map(int, center[1])) for center in centroids] + def getextrema(self): """ Gets the the minimum and maximum pixel values for each band in From f7afddaf17206457580a125a2cbea6484dc7cf13 Mon Sep 17 00:00:00 2001 From: 392781 Date: Tue, 18 Aug 2020 14:43:56 -0700 Subject: [PATCH 02/10] Added support for various image modes --- src/PIL/Image.py | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d2219b7c4..03f13448a 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1287,24 +1287,31 @@ class Image: :returns: An unsorted list of (pixel) values. """ - def euclidean(p1, p2): - return sum([(p1[i] - p2[i]) ** 2 for i in range(channels)]) - - if self.mode in ("1", "L", "P"): + self.load() + if self.mode in ("F", "I", "L", "P"): channels = 1 elif self.mode in ("RGB", "YCbCr", "LAB", "HSV"): channels = 3 elif self.mode in ("RGBA", "CMYK"): channels = 4 + else: + tb = sys.exc_info()[2] + raise ValueError("Unsupported image mode").with_traceback(tb) - w, h = self.size() + def euclidean(p1, p2): + if channels == 1: + return (p1 - p2) ** 2 + return sum([(p1[i] - p2[i]) ** 2 for i in range(channels)]) + + w, h = self.size pixels_and_counts = [] - for count, color in self.im.getcolors(w * h): + for count, color in self.getcolors(w * h): pixels_and_counts.append((color, count)) centroids = [] for i in range(numcolors): + # Formatted as (pixel_cluster, center) centroids.append(([pixels_and_counts[i]], pixels_and_counts[i][0])) for iter in range(maxiter): @@ -1326,22 +1333,34 @@ class Image: difference = 0 for i in range(numcolors): previous = centroids[i][1] - pixel_sum = [0.0 for i in range(channels)] count_sum = 0 - for pixel in cluster[i]: - count_sum += pixel[1] - for channel in range(channels): - pixel_sum[channel] += pixel[0][channel] * pixel[1] + if channels == 1: + pixel_sum = 0.0 + for pixel in cluster[i]: + count_sum += pixel[1] + pixel_sum += pixel[0] * pixel[1] + current = pixel_sum / count_sum + else: + pixel_sum = [0.0 for i in range(channels)] + for pixel in cluster[i]: + count_sum += pixel[1] + for channel in range(channels): + pixel_sum[channel] += pixel[0][channel] * pixel[1] + current = [(channel_sum / count_sum) for channel_sum in pixel_sum] - current = [(channel_sum / count_sum) for channel_sum in pixel_sum] centroids[i] = (cluster[i], current) difference = max(difference, euclidean(previous, current)) if difference < threshold: break - return [tuple(map(int, center[1])) for center in centroids] + if self.mode == "F": + return [center[1] for center in centroids] + elif self.mode in ("I", "L", "P"): + return [int(center[1]) for center in centroids] + else: + return [tuple(map(int, center[1])) for center in centroids] def getextrema(self): """ From 6f3e77b617c13c099f3f0fbe8fa15a70d069d458 Mon Sep 17 00:00:00 2001 From: 392781 Date: Tue, 18 Aug 2020 14:46:31 -0700 Subject: [PATCH 03/10] Created some basic tests of getdominantcolors functionality --- Tests/test_image_getdominantcolors.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Tests/test_image_getdominantcolors.py diff --git a/Tests/test_image_getdominantcolors.py b/Tests/test_image_getdominantcolors.py new file mode 100644 index 000000000..c6c734bb7 --- /dev/null +++ b/Tests/test_image_getdominantcolors.py @@ -0,0 +1,22 @@ +from .helper import hopper + + +def test_getdominantcolors(): + def getdominantcolors(mode, numcolors=None): + im = hopper(mode) + + if numcolors: + colors = im.getdominantcolors(numcolors) + else: + colors = im.getdominantcolors() + return len(colors) + + assert getdominantcolors("F") == 3 + assert getdominantcolors("I") == 3 + assert getdominantcolors("L") == 3 + assert getdominantcolors("P") == 3 + assert getdominantcolors("RGB") == 3 + assert getdominantcolors("YCbCr") == 3 + assert getdominantcolors("CMYK") == 3 + assert getdominantcolors("RGBA") == 3 + assert getdominantcolors("HSV") == 3 From 9c0f98aa595722eb1f62fcfc821085f0ccdf36dd Mon Sep 17 00:00:00 2001 From: 392781 Date: Tue, 18 Aug 2020 14:59:31 -0700 Subject: [PATCH 04/10] Added some comments --- src/PIL/Image.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 03f13448a..dc5222015 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1287,7 +1287,6 @@ class Image: :returns: An unsorted list of (pixel) values. """ - self.load() if self.mode in ("F", "I", "L", "P"): channels = 1 elif self.mode in ("RGB", "YCbCr", "LAB", "HSV"): @@ -1314,11 +1313,13 @@ class Image: # Formatted as (pixel_cluster, center) centroids.append(([pixels_and_counts[i]], pixels_and_counts[i][0])) + # Begin k-means clustering for iter in range(maxiter): cluster = {} for i in range(numcolors): cluster[i] = [] + # Calculates all pixel distances from each center to add to the cluster for pixel in pixels_and_counts: smallest_distance = float("Inf") @@ -1330,6 +1331,7 @@ class Image: cluster[idx].append(pixel) + # Adjusting the center of each cluster difference = 0 for i in range(numcolors): previous = centroids[i][1] From 8cce08891c21496165451623db50bea7ab43c647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20Lencevi=C4=8Dius?= Date: Wed, 19 Aug 2020 08:28:04 -0700 Subject: [PATCH 05/10] Update src/PIL/Image.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index dc5222015..e8e9127de 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1302,10 +1302,8 @@ class Image: return (p1 - p2) ** 2 return sum([(p1[i] - p2[i]) ** 2 for i in range(channels)]) - w, h = self.size - pixels_and_counts = [] - for count, color in self.getcolors(w * h): + for count, color in self.getcolors(self.width * self.height): pixels_and_counts.append((color, count)) centroids = [] From 37ff4b9c7032543318d017e65a85220845fc6602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20Lencevi=C4=8Dius?= Date: Wed, 19 Aug 2020 08:28:34 -0700 Subject: [PATCH 06/10] Update src/PIL/Image.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index e8e9127de..a6c511106 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1284,7 +1284,7 @@ class Image: :param threshold: Early stopping condition for the algorithm. Higher values correspond with increased color differences. The default is set to 1 (corresponding to 1 pixel difference). - :returns: An unsorted list of (pixel) values. + :returns: An unsorted list of pixel values. """ if self.mode in ("F", "I", "L", "P"): From c48e87155f1bcf532a9acde28be186397e6d169c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20Lencevi=C4=8Dius?= Date: Wed, 19 Aug 2020 08:28:52 -0700 Subject: [PATCH 07/10] Update Tests/test_image_getdominantcolors.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- Tests/test_image_getdominantcolors.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Tests/test_image_getdominantcolors.py b/Tests/test_image_getdominantcolors.py index c6c734bb7..7dafed081 100644 --- a/Tests/test_image_getdominantcolors.py +++ b/Tests/test_image_getdominantcolors.py @@ -2,13 +2,9 @@ from .helper import hopper def test_getdominantcolors(): - def getdominantcolors(mode, numcolors=None): + def getdominantcolors(mode): im = hopper(mode) - - if numcolors: - colors = im.getdominantcolors(numcolors) - else: - colors = im.getdominantcolors() + colors = im.getdominantcolors() return len(colors) assert getdominantcolors("F") == 3 From 21315e5fb3f12887977c79a6972041f128c970e9 Mon Sep 17 00:00:00 2001 From: 392781 Date: Wed, 19 Aug 2020 17:50:25 -0700 Subject: [PATCH 08/10] Added a quality resizing option for larger images --- src/PIL/Image.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index a6c511106..b8b6ad6bf 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1272,7 +1272,7 @@ class Image: return self.im.getband(band) return self.im # could be abused - def getdominantcolors(self, numcolors=3, maxiter=50, threshold=1): + def getdominantcolors(self, numcolors=3, maxiter=50, threshold=1, quality=1.0): """ Returns a list of dominant colors in an image using k-means clustering. @@ -1284,10 +1284,20 @@ class Image: :param threshold: Early stopping condition for the algorithm. Higher values correspond with increased color differences. The default is set to 1 (corresponding to 1 pixel difference). + :param quality: Used for scaling an image to speed up calculations. + The default value is 1.0. :returns: An unsorted list of pixel values. """ - if self.mode in ("F", "I", "L", "P"): + # Checking if # of pixels is greater than a 1080p image. + if quality >= 1.0 and self.width * self.height >= 2073600: + recommended_quality = 300000 / self.width * self.height + message = "Lower quality recommended: {:.4}".format(recommended_quality) + warnings.warn(message) + elif quality != 1.0: + self.thumbnail((quality * self.width, quality * self.height)) + + if self.mode in ("F", "L", "I", "P"): channels = 1 elif self.mode in ("RGB", "YCbCr", "LAB", "HSV"): channels = 3 From 5593b629b98d6f290dc7f2dd9cd5eede6e6aeaa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ronald=20Lencevi=C4=8Dius?= Date: Sat, 28 Nov 2020 03:36:34 -0800 Subject: [PATCH 09/10] Update src/PIL/Image.py Co-authored-by: Andrew Murray <3112309+radarhere@users.noreply.github.com> --- src/PIL/Image.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b8b6ad6bf..71189539b 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1304,8 +1304,7 @@ class Image: elif self.mode in ("RGBA", "CMYK"): channels = 4 else: - tb = sys.exc_info()[2] - raise ValueError("Unsupported image mode").with_traceback(tb) + raise ValueError("Unsupported image mode") def euclidean(p1, p2): if channels == 1: From 7725aa5f4e923fb4f228ce5bcb553e9ae7dfb891 Mon Sep 17 00:00:00 2001 From: Andrew Murray <3112309+radarhere@users.noreply.github.com> Date: Sun, 25 Jul 2021 13:10:29 +1000 Subject: [PATCH 10/10] Simplified check for number of channels --- src/PIL/Image.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 71189539b..322b87bc1 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -1297,13 +1297,8 @@ class Image: elif quality != 1.0: self.thumbnail((quality * self.width, quality * self.height)) - if self.mode in ("F", "L", "I", "P"): - channels = 1 - elif self.mode in ("RGB", "YCbCr", "LAB", "HSV"): - channels = 3 - elif self.mode in ("RGBA", "CMYK"): - channels = 4 - else: + channels = self.im.bands + if channels not in (1, 3, 4): raise ValueError("Unsupported image mode") def euclidean(p1, p2):